Refactor DimensionScheduler to support sound settings in message groups and effects

This commit is contained in:
besiece committed 2026-11-08 20:44:55 +01:00
1 parent 23d8fc85de
commit e558ae3fac
4 files changed
+128 -22

No files matched your search

@@ -3,6 +3,9 @@ package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import eu.mhsl.craftattack.spawn.core.Main; import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance; import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import net.kyori.adventure.key.InvalidKeyException;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.sound.Sound;
import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
@@ -26,16 +29,26 @@ import java.util.logging.Level;
/** Reads named times, message groups and effect definitions; execution belongs to their consumers. */ /** Reads named times, message groups and effect definitions; execution belongs to their consumers. */
public class DimensionScheduler extends Appliance { public class DimensionScheduler extends Appliance {
public record Effect(String name, int minutesBeforeOpening, String message) { public record SoundSettings(String name, float volume, float pitch) {
public Sound asSound() {
return Sound.sound(Key.key(this.name), Sound.Source.MASTER, this.volume, this.pitch);
}
}
public record MessageGroup(List<String> texts, Optional<SoundSettings> sound) {
}
public record Effect(String name, int minutesBeforeOpening, String message, Optional<SoundSettings> sound) {
public Duration leadTime() { public Duration leadTime() {
return Duration.ofMinutes(this.minutesBeforeOpening); return Duration.ofMinutes(this.minutesBeforeOpening);
} }
} }
private static final String FILE_NAME = "dimension-schedule.yml"; private static final String FILE_NAME = "dimension-schedule.yml";
private static final MessageGroup EMPTY_MESSAGE_GROUP = new MessageGroup(List.of(), Optional.empty());
private final File scheduleFile = new File(Main.instance().getDataFolder(), FILE_NAME); private final File scheduleFile = new File(Main.instance().getDataFolder(), FILE_NAME);
private Map<String, Instant> times = Map.of(); private Map<String, Instant> times = Map.of();
private Map<String, List<String>> messages = Map.of(); private Map<String, MessageGroup> messages = Map.of();
private Map<String, List<Effect>> effects = Map.of(); private Map<String, List<Effect>> effects = Map.of();
private Instant loadedAt = Instant.now(); private Instant loadedAt = Instant.now();
@@ -65,7 +78,7 @@ public class DimensionScheduler extends Appliance {
} }
ConfigurationSection messageSection = section(configuration, "messages"); ConfigurationSection messageSection = section(configuration, "messages");
ConfigurationSection effectSection = section(configuration, "effects"); ConfigurationSection effectSection = section(configuration, "effects");
Map<String, List<String>> loadedMessages = readMessages(messageSection == null ? section(defaults, "messages") : messageSection); Map<String, MessageGroup> loadedMessages = readMessages(messageSection == null ? section(defaults, "messages") : messageSection);
Map<String, List<Effect>> loadedEffects = readEffects(effectSection == null ? section(defaults, "effects") : effectSection); Map<String, List<Effect>> loadedEffects = readEffects(effectSection == null ? section(defaults, "effects") : effectSection);
this.times = Map.copyOf(loadedTimes); this.times = Map.copyOf(loadedTimes);
@@ -90,12 +103,18 @@ public class DimensionScheduler extends Appliance {
throw new IllegalArgumentException(parent.getCurrentPath() + "." + key + " muss ein YAML-Abschnitt sein."); throw new IllegalArgumentException(parent.getCurrentPath() + "." + key + " muss ein YAML-Abschnitt sein.");
} }
private static Map<String, List<String>> readMessages(ConfigurationSection section) { private static Map<String, MessageGroup> readMessages(ConfigurationSection section) {
if(section == null) return Map.of(); if(section == null) return Map.of();
Map<String, List<String>> messages = new HashMap<>(); Map<String, MessageGroup> messages = new HashMap<>();
for(Map.Entry<String, Object> entry : section.getValues(false).entrySet()) { for(Map.Entry<String, Object> entry : section.getValues(false).entrySet()) {
if(!(entry.getValue() instanceof List<?> values)) { Object configured = entry.getValue();
throw new IllegalArgumentException("messages." + entry.getKey() + " muss eine Liste von Texten sein."); Optional<SoundSettings> sound = Optional.empty();
if(configured instanceof ConfigurationSection group) {
configured = group.get("texts");
sound = readSound(group);
}
if(!(configured instanceof List<?> values)) {
throw new IllegalArgumentException("messages." + entry.getKey() + " muss eine Textliste oder ein Abschnitt mit texts sein.");
} }
List<String> texts = new ArrayList<>(); List<String> texts = new ArrayList<>();
for(Object value : values) { for(Object value : values) {
@@ -104,7 +123,7 @@ public class DimensionScheduler extends Appliance {
} }
texts.add(text); texts.add(text);
} }
messages.put(entry.getKey(), List.copyOf(texts)); messages.put(entry.getKey(), new MessageGroup(List.copyOf(texts), sound));
} }
return Map.copyOf(messages); return Map.copyOf(messages);
} }
@@ -127,13 +146,45 @@ public class DimensionScheduler extends Appliance {
if(!(message instanceof String text) || text.isBlank()) { if(!(message instanceof String text) || text.isBlank()) {
throw new IllegalArgumentException(effect.getCurrentPath() + ".message muss ein nicht leerer Text sein."); throw new IllegalArgumentException(effect.getCurrentPath() + ".message muss ein nicht leerer Text sein.");
} }
definitions.add(new Effect(name, count, text)); definitions.add(new Effect(name, count, text, readSound(effect)));
} }
effects.put(groupName, List.copyOf(definitions)); effects.put(groupName, List.copyOf(definitions));
} }
return Map.copyOf(effects); return Map.copyOf(effects);
} }
private static Optional<SoundSettings> readSound(ConfigurationSection section) {
Object configured = section.get("sound");
if(configured == null) return Optional.empty();
if(!(configured instanceof String name)) {
throw new IllegalArgumentException(section.getCurrentPath() + ".sound muss ein Sound-Name sein.");
}
if(name.isBlank()) return Optional.empty();
String key;
try {
key = Key.key(name.trim()).asString();
} catch(InvalidKeyException e) {
throw new IllegalArgumentException("Ungültiger Sound-Name bei " + section.getCurrentPath() + ".sound: " + name, e);
}
float volume = readSoundNumber(section, "volume", true);
float pitch = readSoundNumber(section, "pitch", false);
return Optional.of(new SoundSettings(key, volume, pitch));
}
private static float readSoundNumber(ConfigurationSection section, String key, boolean allowZero) {
Object configured = section.get(key);
if(configured == null) return 1.0f;
if(!(configured instanceof Number number)) {
throw new IllegalArgumentException(section.getCurrentPath() + "." + key + " muss eine Zahl sein.");
}
float value = number.floatValue();
if(!Float.isFinite(value) || (allowZero ? value < 0 : value <= 0)) {
throw new IllegalArgumentException(section.getCurrentPath() + "." + key
+ (allowZero ? " muss endlich und mindestens 0 sein." : " muss endlich und größer als 0 sein."));
}
return value;
}
private static Optional<Instant> readTime(String key, Object configured) { private static Optional<Instant> readTime(String key, Object configured) {
if(configured == null) return Optional.empty(); if(configured == null) return Optional.empty();
if(!(configured instanceof String value)) { if(!(configured instanceof String value)) {
@@ -157,7 +208,11 @@ public class DimensionScheduler extends Appliance {
} }
public List<String> getMessages(String name) { public List<String> getMessages(String name) {
return this.messages.getOrDefault(name, List.of()); return this.getMessageGroup(name).texts();
}
public MessageGroup getMessageGroup(String name) {
return this.messages.getOrDefault(name, EMPTY_MESSAGE_GROUP);
} }
public List<Effect> getEffects(String name) { public List<Effect> getEffects(String name) {
@@ -22,20 +22,40 @@ times:
messages: messages:
end-activation-denied: end-activation-denied:
- "Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ..." texts:
- "Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ..."
sound: "entity.warden.roar"
pitch: 0.2
volume: 1.0
effects: effects:
end-opening: end-opening:
five-minutes-before: five-minutes-before:
minutes-before-opening: 5 minutes-before-opening: 5
message: "Etwas regt sich in der Leere ..." message: "Etwas regt sich in der Leere ..."
sound: "ambient.cave"
pitch: 0.5
volume: 1.0
opened: opened:
minutes-before-opening: 0 minutes-before-opening: 0
message: "Der Weg ins End ist nun offen!" message: "Der Weg ins End ist nun offen!"
sound: "block.end_portal.spawn"
pitch: 1.0
volume: 1.0
``` ```
`times` kann beliebige benannte Zeitpunkte enthalten. `messages.end-activation-denied` `times` kann beliebige benannte Zeitpunkte enthalten. `messages.end-activation-denied.texts`
enthält beliebig viele Texte für die zufällige private Chat-Nachricht. enthält beliebig viele Texte für die zufällige private Chat-Nachricht.
Die bisherige direkte Textliste wird ebenfalls weiterhin gelesen.
`sound`, `pitch` und `volume` stehen direkt bei den zugehörigen Texten beziehungsweise Effekten.
Diese Sound-Angaben werden beim Reload mit eingelesen und direkt mit der jeweiligen Nachricht
einmal beim Empfänger abgespielt. Der Ablehnungs-Sound ist nur für den auslösenden Spieler hörbar;
Ankündigungs- und Öffnungssounds erhalten alle Empfänger der Chat-Nachricht jeweils an ihrer
eigenen Position. Es gibt keine zusätzlichen Tasks oder Schleifen für die Sound-Wiedergabe.
Ohne `sound` oder mit leerem Sound-Namen bleibt es bei der Textausgabe. `volume` und `pitch`
haben den Standardwert `1.0`; Lautstärke muss mindestens `0`, Pitch größer als `0` sein.
Ungültige Sound-Schlüssel oder nicht endliche Zahlen lassen den Reload wie andere ungültige
Konfigurationswerte scheitern und behalten die bisherige Konfiguration.
Unter `effects.end-opening` können frei benannte Effekte ergänzt oder entfernt werden. Unter `effects.end-opening` können frei benannte Effekte ergänzt oder entfernt werden.
`minutes-before-opening` ist eine nicht negative ganze Zahl: Ein positiver Wert gibt den `minutes-before-opening` ist eine nicht negative ganze Zahl: Ein positiver Wert gibt den
Abstand zur End-Öffnung in Minuten an, `0` kennzeichnet einen Effekt beim tatsächlichen Öffnen. Abstand zur End-Öffnung in Minuten an, `0` kennzeichnet einen Effekt beim tatsächlichen Öffnen.
@@ -124,7 +144,7 @@ Alle Flavourtexte und zeitgesteuerten Chat-Effekte verwenden einheitlich End-Gr
## Erweiterungen und Leistung ## Erweiterungen und Leistung
Zusätzliche Sounds oder andere Effektarten können später zentral in `EndOpeningEffects.play(effect)` Weitere Effektarten können später zentral in `EndOpeningEffects.play(effect)`
ergänzt werden und werden bei manueller sowie zeitgesteuerter Öffnung gleichermaßen ausgeführt. ergänzt werden und werden bei manueller sowie zeitgesteuerter Öffnung gleichermaßen ausgeführt.
Neue Ankündigungen benötigen nur einen weiteren YAML-Eintrag mit Minutenwert und Text. Neue Ankündigungen benötigen nur einen weiteren YAML-Eintrag mit Minutenwert und Text.
@@ -2,16 +2,20 @@ package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler; import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.Effect; import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.Effect;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.MessageGroup;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.SoundSettings;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText; import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import net.kyori.adventure.sound.Sound;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
/** Presents YAML-defined End effects. Add sounds or other effects in play(). */ /** Sends each YAML-defined message together with its optional sound. */
final class EndOpeningEffects { final class EndOpeningEffects {
private @Nullable DimensionScheduler scheduler; private @Nullable DimensionScheduler scheduler;
@@ -25,10 +29,11 @@ final class EndOpeningEffects {
void denyActivation(Player player) { void denyActivation(Player player) {
if(this.scheduler == null) return; if(this.scheduler == null) return;
List<String> messages = this.scheduler.getMessages("end-activation-denied"); MessageGroup group = this.scheduler.getMessageGroup("end-activation-denied");
List<String> messages = group.texts();
if(messages.isEmpty()) return; if(messages.isEmpty()) return;
String message = messages.get(ThreadLocalRandom.current().nextInt(messages.size())); String message = messages.get(ThreadLocalRandom.current().nextInt(messages.size()));
player.sendMessage(DimensionScheduleText.message(message)); send(player, DimensionScheduleText.message(message), group.sound().map(SoundSettings::asSound));
} }
void playOpening() { void playOpening() {
@@ -39,6 +44,12 @@ final class EndOpeningEffects {
void play(Effect effect) { void play(Effect effect) {
Component message = DimensionScheduleText.message(effect.message()); Component message = DimensionScheduleText.message(effect.message());
Bukkit.getOnlinePlayers().forEach(player -> player.sendMessage(message)); Optional<Sound> sound = effect.sound().map(SoundSettings::asSound);
Bukkit.getOnlinePlayers().forEach(player -> send(player, message, sound));
}
private static void send(Player player, Component message, Optional<Sound> sound) {
player.sendMessage(message);
sound.ifPresent(value -> player.playSound(value, Sound.Emitter.self()));
} }
} }
@@ -7,11 +7,16 @@ times:
messages: messages:
end-activation-denied: end-activation-denied:
- "Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ..." texts:
- "Der Rahmen fühlt sich ungewöhnlich kalt an. Du hast das Gefühl, als würde er dir die Wärme aus den Fingern ziehen …" - "Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ..."
- "Die Luft über dem leeren Rahmen scheint ungewöhnlich dunkel. Du hast das Gefühl, in einen bodenlosen Abgrund zu blicken ..." - "Der Rahmen fühlt sich ungewöhnlich kalt an. Du hast das Gefühl, als würde er dir die Wärme aus den Fingern ziehen …"
- "Für einen kurzen Moment scheint die Umgebung vollkommen still zu werden. Du fragst dich, ob du dir das nur eingebildet hast ..." - "Die Luft über dem leeren Rahmen scheint ungewöhnlich dunkel. Du hast das Gefühl, in einen bodenlosen Abgrund zu blicken ..."
- "Ein schwaches, violettes Licht flackert zwischen den Augen auf. Etwas scheint dich zu beobachten ..." - "Für einen kurzen Moment scheint die Umgebung vollkommen still zu werden. Du fragst dich, ob du dir das nur eingebildet hast ..."
- "Ein schwaches, violettes Licht flackert zwischen den Augen auf. Etwas scheint dich zu beobachten ..."
# Ein Sound pro Nachricht, gemeinsam mit dem Nachrichten-Cooldown.
sound: "entity.warden.roar"
pitch: 0.2
volume: 1.0
effects: effects:
end-opening: end-opening:
@@ -20,16 +25,31 @@ effects:
four-hours-before: four-hours-before:
minutes-before-opening: 240 minutes-before-opening: 240
message: "Eine unheimliche Stille scheint sich langsam über die Welt auszubreiten. Du hast das seltsame Gefühl, beobachtet zu werden ..." message: "Eine unheimliche Stille scheint sich langsam über die Welt auszubreiten. Du hast das seltsame Gefühl, beobachtet zu werden ..."
sound: "ambient.cave"
pitch: 0.5
volume: 1.0
one-hour-before: one-hour-before:
minutes-before-opening: 60 minutes-before-opening: 60
message: "Die Luft fühlt sich plötzlich ungewöhnlich schwer an. Du hast das Gefühl, als würde eine unsichtbare Kraft auf dir lasten ..." message: "Die Luft fühlt sich plötzlich ungewöhnlich schwer an. Du hast das Gefühl, als würde eine unsichtbare Kraft auf dir lasten ..."
sound: "ambient.cave"
pitch: 0.5
volume: 1.0
ten-minutes-before: ten-minutes-before:
minutes-before-opening: 10 minutes-before-opening: 10
message: "Ein seltsames Geräusch hallt durch die Welt. Könnte es das Schlagen gewaltiger Flügel sein ...?" message: "Ein seltsames Geräusch hallt durch die Welt. Könnte es das Schlagen gewaltiger Flügel sein ...?"
sound: "ambient.cave"
pitch: 0.5
volume: 1.0
one-minute-before: one-minute-before:
minutes-before-opening: 1 minutes-before-opening: 1
message: "Ein gewaltiger Schatten scheint über die Welt zu ziehen. Du blickst zum Himmel, doch dort ist nichts zu sehen ..." message: "Ein gewaltiger Schatten scheint über die Welt zu ziehen. Du blickst zum Himmel, doch dort ist nichts zu sehen ..."
sound: "entity.ender_dragon.growl"
pitch: 0.5
volume: 1.0
# 0: Nur beim tatsächlichen Öffnen, gleichermaßen durch Command oder Zeitplan. # 0: Nur beim tatsächlichen Öffnen, gleichermaßen durch Command oder Zeitplan.
opened: opened:
minutes-before-opening: 0 minutes-before-opening: 0
message: "Ein gewaltiger Riss öffnet sich in der Realität! Die Leere erwacht und die letzte Grenze fällt. Der Weg ins End ist nun offen!" message: "Ein gewaltiger Riss öffnet sich in der Realität! Die Leere erwacht und die letzte Grenze fällt. Der Weg ins End ist nun offen!"
sound: "block.end_portal.spawn"
pitch: 1.0
volume: 1.0