Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 109 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -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:<id>}` 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:<id>}`

---

Expand Down Expand Up @@ -91,6 +93,10 @@ These are replaced for countdown-related texts:
- `{days}` `{hours}` `{minutes}` `{seconds}`
- `{total_seconds}`
- `{target}` target time string
- `{animation:<id>}` render an animation frame (see **Animations**)

> `{animation:<id>}` can be used anywhere a normal text string is rendered (showcases, `after.text`, text actions,
> etc.).

### 3) MiniMessage formatting

Expand All @@ -102,6 +108,77 @@ Player-visible texts support MiniMessage tags such as:

---

## Animations

Animations are defined in `animations.yml` and referenced using:

```yml
{ animation:<id> }
```

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.
Expand Down Expand Up @@ -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:<id>}`.

### 2) Transfer action

Expand Down Expand Up @@ -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:<id>}` 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: "<yellow>New Year in</yellow> <white>{remaining}</white>"
after:
text: "<gold>Happy New Year!</gold>"
duration: 10m
color: red
text: "{animation:new-year-bossbar}"
actions:
- type: text
shift: 0s
Expand All @@ -371,6 +449,16 @@ timers:
- "0"
```

```yml
# animations.yml
animations:
new-year-bossbar:
interval: 0.5
text:
- "<gold>Happy New Year!</gold>"
- "<yellow>New Year in</yellow> <white>{remaining}</white>"
```

Reload:

```txt
Expand All @@ -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.

---
Expand All @@ -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.
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)
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ plugins {
}

group = "top.ourisland"
version = "0.1.0"
version = "0.2.0-SNAPSHOT"

repositories {
mavenCentral()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
37 changes: 37 additions & 0 deletions src/main/java/top/ourisland/invertotimer/config/ConfigManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +22,8 @@ public class ConfigManager {
@Getter
private GlobalConfig globalConfig = GlobalConfig.defaults();
private Map<String, TimerConfig> timers = new LinkedHashMap<>();
@Getter
private Map<String, AnimationConfig> animations = new LinkedHashMap<>();

public ConfigManager(final Logger logger, final Path dataDir) {
this.logger = logger;
Expand All @@ -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) {
Expand Down Expand Up @@ -100,6 +105,38 @@ private Map<String, TimerConfig> loadTimers() {
}
}

private Map<String, AnimationConfig> 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<String, AnimationConfig> 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<String, TimerConfig> getTimers() {
return Collections.unmodifiableMap(timers);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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:<id>}} placeholder.
* <p>
* YAML formats supported:
* <ul>
* <li>Simple (uniform interval): {@code interval: 0.5} + {@code text: [ ... ]}</li>
* <li>Advanced (per-frame duration): {@code frames: [ {duration: 0.5, text: "..."}, ... ]}</li>
* </ul>
*
* @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<Frame> 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<Frame> 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<Frame> 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));
}
}
Loading
Loading