diff --git a/.gitignore b/.gitignore index eebedce..7eefc56 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ target/ *.iml .idea/workspace.xml forwarding.secret +progress.properties +progress.properties.tmp diff --git a/src/main/java/eu/mhsl/minecraft/endlessjumper/EndlessJumper.java b/src/main/java/eu/mhsl/minecraft/endlessjumper/EndlessJumper.java index 1060b34..fae6833 100644 --- a/src/main/java/eu/mhsl/minecraft/endlessjumper/EndlessJumper.java +++ b/src/main/java/eu/mhsl/minecraft/endlessjumper/EndlessJumper.java @@ -23,7 +23,6 @@ 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; @@ -37,6 +36,7 @@ public class EndlessJumper { public static void main(String[] args) throws IOException { MinecraftServer server = MinecraftServer.init(new Auth.Velocity(readForwardingSecret())); + Progress.load(); InstanceManager instanceManager = MinecraftServer.getInstanceManager(); GlobalEventHandler eventHandler = MinecraftServer.getGlobalEventHandler(); PlayerSkin skin = PlayerSkin.fromUsername("Notch"); @@ -52,18 +52,16 @@ public class EndlessJumper { p.setSkin(skin); event.setSpawningInstance(world); - - // 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()); + Player p = event.getPlayer(); + p.setCanPickupItem(true); + ScoreTab.join(p); + Jokers.giveStart(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 -> { diff --git a/src/main/java/eu/mhsl/minecraft/endlessjumper/Progress.java b/src/main/java/eu/mhsl/minecraft/endlessjumper/Progress.java new file mode 100644 index 0000000..3097310 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/endlessjumper/Progress.java @@ -0,0 +1,54 @@ +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); + } + + /** 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); + } + } +} 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 47b9fdc..9a61a78 100644 --- a/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/BlockPalette.java +++ b/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/BlockPalette.java @@ -61,6 +61,10 @@ public class BlockPalette { new Stage(1000, "End", NIGHT, Particle.END_ROD, SoundEvent.MUSIC_END, List.of( Block.END_STONE, Block.END_STONE, Block.END_STONE_BRICKS, Block.PURPUR_BLOCK, Block.PURPUR_PILLAR, Block.OBSIDIAN + )), + // nach dem End endlos weiter mit einem neutralen Block + new Stage(1100, "Unendlich", NIGHT, null, SoundEvent.MUSIC_MENU, List.of( + Block.SMOOTH_QUARTZ )) ); diff --git a/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/Fireworks.java b/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/Fireworks.java new file mode 100644 index 0000000..17cf3c6 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/Fireworks.java @@ -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 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()); + } +} 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 b430926..1e27e48 100644 --- a/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/JumpGenerator.java +++ b/src/main/java/eu/mhsl/minecraft/endlessjumper/generator/JumpGenerator.java @@ -1,6 +1,7 @@ package eu.mhsl.minecraft.endlessjumper.generator; import eu.mhsl.minecraft.endlessjumper.Jokers; +import eu.mhsl.minecraft.endlessjumper.Progress; import eu.mhsl.minecraft.endlessjumper.ScoreTab; import net.kyori.adventure.sound.Sound; import net.kyori.adventure.text.Component; @@ -43,6 +44,7 @@ public class JumpGenerator { private static final int PREVIEW = 3; private static final int FULL_DIFFICULTY_AT = 1000; + private static final int FINALE_AT = 1000; private static final Block BRIDGE_BLOCK = Block.WHITE_STAINED_GLASS; private static final int BRIDGE_STEP_TICKS = 4; @@ -51,6 +53,7 @@ public class JumpGenerator { 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("Du hast das End erreicht", 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); @@ -62,9 +65,11 @@ public class JumpGenerator { 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 course = new ArrayList<>(); private final Map courseIndex = new HashMap<>(); - private int standing = 0; + private int standing; private int combo = 0; private Pos checkpoint; // bis zu diesem Index wurde per Brücke gebaut: diese Blöcke zählen nicht zur Combo @@ -74,14 +79,33 @@ public class JumpGenerator { private final Map rescueBlocks = new HashMap<>(); private Pos rescueSafe; - public JumpGenerator(Pos start, Player player, Instance world) { + /** @param startIndex Fortschritt, ab dem gespielt wird (0 oder ein gespeicherter Checkpoint) */ + public JumpGenerator(Pos start, int startIndex, Player player, Instance world) { this.player = player; this.world = world; + this.base = startIndex; + this.standing = startIndex; this.checkpoint = start; - this.atmosphere = new StageAtmosphere(player, world, BlockPalette.stageAt(0)); - this.addStep(new Step(start, Block.DIAMOND_BLOCK)); + 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); + if(startIndex > 0) this.player.sendMessage(Component.text("Weiter ab Sprung " + startIndex, NamedTextColor.GOLD)); + // nacheinander animieren, damit jeder Block aus seinem bereits stehenden Vorgänger wächst - this.ensureGenerated(PREVIEW + 1, BlockTransition.TOTAL_TICKS); + this.ensureGenerated(startIndex + PREVIEW + 1, BlockTransition.TOTAL_TICKS); + } + + private Step step(int index) { + return this.course.get(index - this.base); + } + + /** Index des nächsten zu erzeugenden Schritts. */ + private int generated() { + return this.base + this.course.size(); } private static boolean isCheckpoint(int index) { @@ -97,7 +121,7 @@ public class JumpGenerator { } private Pos landing() { - return this.course.get(this.standing + 1).pos(); + return this.step(this.standing + 1).pos(); } /** Wird bei jeder Bewegung aufgerufen: erkennt Landungen und behandelt das Runterfallen. */ @@ -121,7 +145,8 @@ public class JumpGenerator { } if(isCheckpoint(index)) { - this.checkpoint = this.course.get(index).pos(); + this.checkpoint = this.step(index).pos(); + Progress.save(this.player.getUuid(), index); 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)); @@ -137,25 +162,38 @@ public class JumpGenerator { this.player.sendActionBar(Component.text(stage.name(), NamedTextColor.GRAY)); } + if(previous < FINALE_AT && index >= FINALE_AT) this.celebrate(); + this.ensureGenerated(index + PREVIEW + 1, 0); } + /** Ankunft im End: Feuerwerk rund um den Spieler. Danach geht es endlos weiter. */ + private void celebrate() { + this.player.showTitle(FINALE_TITLE); + for(int i = 0; i < 6; i++) { + Pos at = this.player.getPosition().add(this.random.nextInt(-6, 7), 0, this.random.nextInt(2, 10)); + this.world.scheduler().buildTask(() -> Fireworks.launch(this.world, at, this.random)) + .delay(TaskSchedule.tick(1 + i * 8)) + .schedule(); + } + } + private void ensureGenerated(int upTo, int stepTicks) { int delay = 0; - while(this.course.size() <= upTo) { + while(this.generated() <= upTo) { this.generateStep(delay, null); delay += stepTicks; } } private void addStep(Step step) { - this.courseIndex.put(BlockKey.of(step.pos()), this.course.size()); + 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.course.size(); + 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)); @@ -216,14 +254,14 @@ public class JumpGenerator { this.bridgeEnd = nextCheckpoint; // 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()); + for(int i = this.standing + 1; i < this.generated() && i <= nextCheckpoint; i++) { + this.buildPath(this.step(i - 1).pos(), this.step(i).pos()); } int delay = 0; - while(this.course.size() <= nextCheckpoint) { + while(this.generated() <= nextCheckpoint) { Pos from = this.course.getLast().pos(); - int index = this.course.size(); - this.generateStep(delay, () -> this.buildPath(from, this.course.get(index).pos())); + int index = this.generated(); + this.generateStep(delay, () -> this.buildPath(from, this.step(index).pos())); delay += BRIDGE_STEP_TICKS; } @@ -267,7 +305,7 @@ public class JumpGenerator { * mindestens 3 Blöcke nach vorne, die Rettung baut nur dahinter und darunter. */ private void rescue() { - Pos safe = this.course.get(this.standing).pos(); + Pos safe = this.step(this.standing).pos(); int x = safe.blockX(); int z = safe.blockZ(); int floorY = safe.blockY() - RESCUE_DEPTH;