diff --git a/README.md b/README.md index 3257559..23f0704 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ invertoTimer is a lightweight Velocity plugin for global countdowns and scheduled server-wide events. It’s designed for “whole network” moments such as New Year, grand openings, maintenance reminders, and other -timed announcements — with configurable timers, multiple display modes, and flexible actions. +timed announcements — with configurable timers, multiple display modes, flexible actions, and text animations. --- @@ -25,11 +25,13 @@ invertoTimer provides: - Server limitations (**global + per-timer**) to include/exclude specific backend servers - i18n language files and **MiniMessage** formatting for player-visible text - `{i18n:key}` tokens for translating player-visible texts +- `{animation:}` placeholder to render animated text defined in `animations.yml` -The plugin uses two config files: +The plugin uses three config files: - `config.yml`: global settings (language, timezone, global limitation, etc.) - `timer.yml`: timer definitions (timers, showcases, actions) +- `animations.yml`: animation definitions used by `{animation:}` --- @@ -91,6 +93,10 @@ These are replaced for countdown-related texts: - `{days}` `{hours}` `{minutes}` `{seconds}` - `{total_seconds}` - `{target}` target time string +- `{animation:}` render an animation frame (see **Animations**) + +> `{animation:}` can be used anywhere a normal text string is rendered (showcases, `after.text`, text actions, +> etc.). ### 3) MiniMessage formatting @@ -102,6 +108,77 @@ Player-visible texts support MiniMessage tags such as: --- +## Animations + +Animations are defined in `animations.yml` and referenced using: + +```yml +{ animation: } +``` + +An animation produces a piece of text that changes over time (frames). +The resulting frame text is then rendered like normal text, meaning it can also contain `{remaining}`, `{i18n:...}` and +MiniMessage tags. + +### `animations.yml` format + +Top-level key: + +- `animations:` map of animation ids → definitions + +Two supported definition styles: + +#### A) Simple fixed-interval frames + +```yml +# ============================================================ +# Configured animations +# ============================================================ +animations: + # ---------------------------------------------------------- + # Animation id (used in timer text) + # ---------------------------------------------------------- + new-year-bossbar: + # Frame interval in seconds + interval: 0.5 + # List of frames (cycled) + text: + - "Happy New Year!" + - "New Year in {remaining}" +``` + +#### B) Per-frame duration + +```yml +animations: + new-year-bossbar2: + # Explicit frames with individual durations (seconds) + frames: + - duration: 0.5 + text: "Happy New Year!" + - duration: 1 + text: "Happy!" +``` + +### Using animations in timers + +Example (bossbar text uses an animation): + +```yml +timers: + new-year: + description: "New year timer." + cron: "0 0 1 1 *" + showcases: + bossbar: + start-at: 1h + interval: 1s + color: red + text: "{animation:new-year-bossbar}" +``` + +--- + ## Showcases Showcases are **periodic displays** that run while a timer is active. @@ -289,7 +366,8 @@ Example: - "0" ``` -Title/subtitle/actionbar/message texts support `{i18n:key}` + placeholders + MiniMessage. +Title/subtitle/actionbar/message texts support `{i18n:key}` + placeholders + MiniMessage. +Animations can also be used here via `{animation:}`. ### 2) Transfer action @@ -342,22 +420,22 @@ Configure: - Edit `config.yml` (language, timezone, global limitation) - Edit `timer.yml` (timers, showcases, actions) +- (Optional) Edit `animations.yml` and use `{animation:}` in texts Minimal example: ```yml +# timer.yml timers: new-year: description: "New year timer." cron: "0 0 1 1 *" showcases: - actionbar: + bossbar: start-at: 1h interval: 1s - text: "New Year in {remaining}" - after: - text: "Happy New Year!" - duration: 10m + color: red + text: "{animation:new-year-bossbar}" actions: - type: text shift: 0s @@ -371,6 +449,16 @@ timers: - "0" ``` +```yml +# animations.yml +animations: + new-year-bossbar: + interval: 0.5 + text: + - "Happy New Year!" + - "New Year in {remaining}" +``` + Reload: ```txt @@ -382,7 +470,7 @@ Reload: ## Feedback Please use GitHub Issues for bug reports and feature requests. -When reporting a bug, include your Velocity version, the invertoTimer version, your `config.yml` and `timer.yml`, and +When reporting a bug, include your Velocity version, the invertoTimer version, your configuration files, and the relevant console logs so the issue can be reproduced. --- @@ -400,4 +488,15 @@ files when needed. ## License This project is licensed under the MIT License. See -the [LICENSE](https://github.com/Our-Island/invertoTimer/blob/master/LICENSE) file for details. \ No newline at end of file +the [LICENSE](https://github.com/Our-Island/invertoTimer/blob/master/LICENSE) file for details. + +This project uses the following third-party libraries. All dependencies are compatible with AGPL-3.0, and their licences +are respected: + +- **Lombok** + - Repository: [Mojang/brigadier](https://github.com/projectlombok/lombok) + - License: [LICENSE](https://github.com/projectlombok/lombok/blob/master/LICENSE) + +- **SnakeYaml** + - Repository: [snakeyaml/snakeyaml](https://bitbucket.org/snakeyaml/snakeyaml/) + - License: [Apache-2.0](https://bitbucket.org/snakeyaml/snakeyaml/src/master/LICENSE.txt) diff --git a/build.gradle.kts b/build.gradle.kts index 7c4ba51..64b7000 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } group = "top.ourisland" -version = "0.1.0" +version = "0.2.0-SNAPSHOT" repositories { mavenCentral() diff --git a/src/main/java/top/ourisland/invertotimer/BuildConstants.java b/src/main/java/top/ourisland/invertotimer/BuildConstants.java index 5f46684..5be8364 100644 --- a/src/main/java/top/ourisland/invertotimer/BuildConstants.java +++ b/src/main/java/top/ourisland/invertotimer/BuildConstants.java @@ -7,5 +7,5 @@ public class BuildConstants { /** * Version number of the plugin. */ - public static final String VERSION = "0.1.0"; + public static final String VERSION = "0.2.0-SNAPSHOT"; } diff --git a/src/main/java/top/ourisland/invertotimer/config/ConfigManager.java b/src/main/java/top/ourisland/invertotimer/config/ConfigManager.java index c7c1b62..1e0ee31 100644 --- a/src/main/java/top/ourisland/invertotimer/config/ConfigManager.java +++ b/src/main/java/top/ourisland/invertotimer/config/ConfigManager.java @@ -2,6 +2,7 @@ import lombok.Getter; import org.slf4j.Logger; +import top.ourisland.invertotimer.config.model.AnimationConfig; import top.ourisland.invertotimer.config.model.GlobalConfig; import top.ourisland.invertotimer.config.model.TimerConfig; import top.ourisland.invertotimer.util.YamlUtil; @@ -21,6 +22,8 @@ public class ConfigManager { @Getter private GlobalConfig globalConfig = GlobalConfig.defaults(); private Map timers = new LinkedHashMap<>(); + @Getter + private Map animations = new LinkedHashMap<>(); public ConfigManager(final Logger logger, final Path dataDir) { this.logger = logger; @@ -36,9 +39,11 @@ public void reloadAll() { copyDefaultIfAbsent("config.yml"); copyDefaultIfAbsent("timer.yml"); + copyDefaultIfAbsent("animations.yml"); this.globalConfig = loadGlobal(); this.timers = loadTimers(); + this.animations = loadAnimations(); } private void copyDefaultIfAbsent(final String filename) { @@ -100,6 +105,38 @@ private Map loadTimers() { } } + private Map loadAnimations() { + final Path p = dataDir.resolve("animations.yml"); + if (!Files.exists(p)) { + return new LinkedHashMap<>(); + } + + try { + final Object root = YamlUtil.parse(Files.readString(p)); + if (!(root instanceof Map m)) { + logger.warn("{} root is not a map.", p.getFileName()); + return new LinkedHashMap<>(); + } + Object animationsObj = m.get("animations"); + if (!(animationsObj instanceof Map animationsMap)) { + logger.warn("{} missing 'animations:' root map.", p.getFileName()); + return new LinkedHashMap<>(); + } + + final Map out = new LinkedHashMap<>(); + for (Map.Entry e : animationsMap.entrySet()) { + final String id = String.valueOf(e.getKey()); + if (e.getValue() instanceof Map am) { + out.put(id, AnimationConfig.fromYaml(id, am)); + } + } + return out; + } catch (Exception e) { + logger.error("Failed to load {}", p.getFileName(), e); + return new LinkedHashMap<>(); + } + } + public Map getTimers() { return Collections.unmodifiableMap(timers); } diff --git a/src/main/java/top/ourisland/invertotimer/config/model/AnimationConfig.java b/src/main/java/top/ourisland/invertotimer/config/model/AnimationConfig.java new file mode 100644 index 0000000..ce6c28b --- /dev/null +++ b/src/main/java/top/ourisland/invertotimer/config/model/AnimationConfig.java @@ -0,0 +1,87 @@ +package top.ourisland.invertotimer.config.model; + +import top.ourisland.invertotimer.util.YamlUtil; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Config record defining a text animation that can be used by {@code {animation:}} placeholder. + *

+ * YAML formats supported: + *

    + *
  • Simple (uniform interval): {@code interval: 0.5} + {@code text: [ ... ]}
  • + *
  • Advanced (per-frame duration): {@code frames: [ {duration: 0.5, text: "..."}, ... ]}
  • + *
+ * + * @param id the animation id + * @param frames ordered frames (looped) + * @param totalDurationMs total duration of one loop in milliseconds (>= 1) + */ +public record AnimationConfig( + String id, + List frames, + long totalDurationMs +) { + + /** + * A single animation frame. + * + * @param durationMs frame duration in milliseconds (>= 1) + * @param text frame text (may contain placeholders / MiniMessage) + */ + public record Frame( + long durationMs, + String text + ) { + } + + public static AnimationConfig fromYaml(final String id, final Map m) { + if (m == null) { + return new AnimationConfig(id, List.of(new Frame(1000, "")), 1000); + } + + Object framesObj = m.get("frames"); + if (framesObj instanceof List framesList && !framesList.isEmpty()) { + List frames = new ArrayList<>(); + long total = 0; + + for (Object o : framesList) { + if (!(o instanceof Map fm)) continue; + + double durSec = YamlUtil.getDouble(fm, "duration", 1.0); + long durMs = Math.max(1, (long) Math.round(durSec * 1000.0)); + String text = YamlUtil.getString(fm, "text", ""); + + frames.add(new Frame(durMs, text)); + total += durMs; + } + + if (frames.isEmpty()) { + return new AnimationConfig(id, List.of(new Frame(1000, "")), 1000); + } + return new AnimationConfig(id, List.copyOf(frames), Math.max(1, total)); + } + + double intervalSec = YamlUtil.getDouble(m, "interval", 1.0); + long intervalMs = Math.max(1, (long) Math.round(intervalSec * 1000.0)); + + Object textObj = m.get("text"); + List frames = new ArrayList<>(); + if (textObj instanceof List texts) { + for (Object o : texts) { + frames.add(new Frame(intervalMs, o == null ? "" : String.valueOf(o))); + } + } else if (textObj != null) { + frames.add(new Frame(intervalMs, String.valueOf(textObj))); + } + + if (frames.isEmpty()) { + frames.add(new Frame(intervalMs, "")); + } + + long total = intervalMs * (long) frames.size(); + return new AnimationConfig(id, List.copyOf(frames), Math.max(1, total)); + } +} diff --git a/src/main/java/top/ourisland/invertotimer/runtime/timer/TimerInstance.java b/src/main/java/top/ourisland/invertotimer/runtime/timer/TimerInstance.java index 2364858..60b8725 100644 --- a/src/main/java/top/ourisland/invertotimer/runtime/timer/TimerInstance.java +++ b/src/main/java/top/ourisland/invertotimer/runtime/timer/TimerInstance.java @@ -7,11 +7,10 @@ import org.slf4j.Logger; import top.ourisland.invertotimer.InvertoTimer; import top.ourisland.invertotimer.action.Action; -import top.ourisland.invertotimer.config.model.ActionConfig; -import top.ourisland.invertotimer.config.model.GlobalConfig; -import top.ourisland.invertotimer.config.model.ShowcaseConfig; -import top.ourisland.invertotimer.config.model.TimerConfig; +import top.ourisland.invertotimer.config.ConfigManager; +import top.ourisland.invertotimer.config.model.*; import top.ourisland.invertotimer.runtime.RuntimeContext; +import top.ourisland.invertotimer.runtime.TextRenderer; import top.ourisland.invertotimer.runtime.action.ActionFactory; import top.ourisland.invertotimer.runtime.showcase.ShowcaseFactory; import top.ourisland.invertotimer.runtime.showcase.ShowcaseSlot; @@ -25,6 +24,8 @@ import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; final class TimerInstance { private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @@ -34,6 +35,7 @@ final class TimerInstance { private final ProxyServer proxy; private final Logger logger; + private final ConfigManager configManager; private final TimerConfig cfg; private final ZoneId zoneId; @@ -59,12 +61,14 @@ final class TimerInstance { @NonNull final InvertoTimer plugin, final ProxyServer proxy, final Logger logger, + final ConfigManager configManager, final TimerConfig cfg, final ZoneId zoneId ) { this.plugin = plugin; this.proxy = proxy; this.logger = logger; + this.configManager = configManager; this.cfg = cfg; this.zoneId = zoneId; @@ -170,6 +174,10 @@ private String applyPlaceholders(final String text, final Instant now) { long seconds = rem % 60; String out = text == null ? "" : text; + + // {animation:} placeholder (defined in animations.yml) + out = replaceAnimations(out, now); + out = out.replace("{id}", cfg.id()); out = out.replace("{description}", cfg.description()); out = out.replace("{remaining}", TimeUtil.formatHMS(remainingSec)); @@ -179,7 +187,9 @@ private String applyPlaceholders(final String text, final Instant now) { out = out.replace("{seconds}", String.valueOf(seconds)); out = out.replace("{total_seconds}", String.valueOf(remainingSec)); if (nextTarget != null) out = out.replace("{target}", nextTarget.toString()); - return out; + + // animations may introduce {i18n:key} tokens, run i18n pass again to cover them + return TextRenderer.replaceI18n(out); } private void cancelActionTasks() { @@ -251,6 +261,44 @@ private boolean isPlayerAllowed(final Player p, final GlobalConfig global) { return cfg.limitation().isAllowed(serverName); } + private String replaceAnimations(final String in, final Instant now) { + if (in == null || in.isEmpty()) return ""; + final Matcher m = Pattern.compile("\\{animation:([a-zA-Z0-9_.-]+)}").matcher(in); + if (!m.find()) return in; + + final StringBuilder sb = new StringBuilder(); + do { + final String id = m.group(1); + final String frame = animationFrameText(id, now); + m.appendReplacement(sb, Matcher.quoteReplacement(frame)); + } while (m.find()); + m.appendTail(sb); + return sb.toString(); + } + + private String animationFrameText(final String id, final Instant now) { + if (id == null || id.isBlank()) return ""; + final AnimationConfig anim = configManager.animations().get(id); + if (anim == null) return ""; + + final List frames = anim.frames(); + if (frames == null || frames.isEmpty()) return ""; + + final long total = Math.max(1, anim.totalDurationMs()); + final long pos = Math.floorMod(now.toEpochMilli(), total); + + long acc = 0; + for (AnimationConfig.Frame f : frames) { + acc += Math.max(1, f.durationMs()); + if (pos < acc) { + return f.text() == null ? "" : f.text(); + } + } + + AnimationConfig.Frame last = frames.getLast(); + return last.text() == null ? "" : last.text(); + } + void tick(final Instant now, final GlobalConfig global) { this.lastNow = now; this.lastGlobal = global; diff --git a/src/main/java/top/ourisland/invertotimer/runtime/timer/TimerRunner.java b/src/main/java/top/ourisland/invertotimer/runtime/timer/TimerRunner.java index a5d5ab1..5a28d35 100644 --- a/src/main/java/top/ourisland/invertotimer/runtime/timer/TimerRunner.java +++ b/src/main/java/top/ourisland/invertotimer/runtime/timer/TimerRunner.java @@ -13,8 +13,9 @@ import top.ourisland.invertotimer.config.model.GlobalConfig; import top.ourisland.invertotimer.config.model.TimerConfig; -import java.time.*; -import java.util.*; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.TimeUnit; public class TimerRunner { @@ -29,7 +30,12 @@ public class TimerRunner { private volatile Map timerConfigs; private ScheduledTask tickTask; - public TimerRunner(final InvertoTimer plugin, final ProxyServer proxy, final Logger logger, final ConfigManager configs) { + public TimerRunner( + final InvertoTimer plugin, + final ProxyServer proxy, + final Logger logger, + final ConfigManager configs + ) { this.plugin = plugin; this.proxy = proxy; this.logger = logger; @@ -45,7 +51,14 @@ public synchronized void reloadFromConfig() { instances.clear(); for (String id : timerConfigs.keySet()) { - instances.put(id, new TimerInstance(plugin, proxy, logger, timerConfigs.get(id), global.zoneId())); + instances.put(id, new TimerInstance( + plugin, + proxy, + logger, + configs, + timerConfigs.get(id), + global.zoneId()) + ); } } diff --git a/src/main/resources/animations.yml b/src/main/resources/animations.yml new file mode 100644 index 0000000..9efb343 --- /dev/null +++ b/src/main/resources/animations.yml @@ -0,0 +1,43 @@ +# ============================================================ +# Configured animations +# ============================================================ +# Use in timer texts with placeholder: {animation:} +# +# Notes: +# - All frame texts support the same placeholders as timer.yml, e.g. {remaining}, {days}, etc. +# - Frame texts also support {i18n:key} and MiniMessage formatting. +# - Animations loop forever based on real time. +# +# Two formats are supported: +# +# 1) Simple (uniform interval for each frame): +# : +# interval: 0.5 +# text: +# - "frame 1" +# - "frame 2" +# +# 2) Advanced (per-frame duration): +# : +# frames: +# - duration: 0.5 +# text: "frame 1" +# - duration: 1 +# text: "frame 2" +# ============================================================ +animations: + # ---------------------------------------------------------- + # Animation id (used in timer text) + # ---------------------------------------------------------- + new-year-bossbar: + interval: 0.5 + text: + - "Happy New Year!" + - "New Year in {remaining}" + + new-year-bossbar2: + frames: + - duration: 0.5 + text: "Happy New Year!" + - duration: 1 + text: "Happy!" diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 9cbdd8f..df193e6 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -20,8 +20,9 @@ lang: "en_us" # Timers using cron/time are evaluated in this timezone. # Valid values are Java ZoneId strings, e.g.: # "Asia/Shanghai", "UTC", "Europe/Paris" +# See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones # ------------------------------------------------------------ -timezone: "Asia/Shanghai" +timezone: "Etc/UTC" # ------------------------------------------------------------ # Global server limitation (applied BEFORE each timer's limitation). diff --git a/src/main/resources/timer.yml b/src/main/resources/timer.yml index f1041a7..4f06a63 100644 --- a/src/main/resources/timer.yml +++ b/src/main/resources/timer.yml @@ -41,6 +41,7 @@ timers: # 2) Placeholders: # {remaining} {days} {hours} {minutes} {seconds} # {total_seconds} {id} {description} {target} + # {animation:} -> play an animation defined in animations.yml # 3) MiniMessage formatting: # text, , , etc. # @@ -241,7 +242,6 @@ timers: # # NOTE: # - command is treated as plain String; do NOT use MiniMessage here. - # - placeholders like {id} {remaining} etc depend on your renderString() policy. # ------------------------------------------------------ - type: command shift: 5s