feat: Enhance minigame effects and UI interactions

- Implemented LeaveMinigameQueueEffect to handle player removal from queues and runtime.
- Added OpenMinigameQueueUIEffect to manage the opening of the minigame queue UI.
- Introduced StartPlayerTimerEffect and StopPlayerTimerEffect for managing player timers and scoring.
- Enhanced PlaceBlocksEffect with options for filling volumes and hollow placements.
- Updated SpawnItemEffect to fix codec handling for item IDs.
- Added MountPlayerEffect and DismountPlayerEffect for player interactions with NPCs.
- Improved StartQueuedMinigameEffect to check player counts before starting games.
- Added localization entries for new effects and UI elements.
- Updated manifest to include HyUI as a dependency.
This commit is contained in:
2026-06-11 17:17:54 -07:00
parent 67e70a71bd
commit 49bbd2b871
58 changed files with 3664 additions and 37 deletions
Binary file not shown.
+2
View File
@@ -61,6 +61,8 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
// Your dependencies here
compileOnly fileTree(dir: 'run/mods', include: ['HyUI-*.jar'])
testCompileOnly fileTree(dir: 'run/mods', include: ['HyUI-*.jar'])
}
test {
+24
View File
@@ -0,0 +1,24 @@
{
"Version": 4,
"ServerName": "Hytale Server",
"MOTD": "",
"Password": "",
"MaxPlayers": 100,
"MaxViewRadius": 32,
"Defaults": {
"World": "default",
"GameMode": "Adventure"
},
"ConnectionTimeouts": {},
"RateLimit": {},
"Modules": {},
"LogLevels": {},
"Mods": {},
"DisplayTmpTagsInStrings": false,
"PlayerStorage": {
"Type": "Hytale"
},
"Update": {},
"Backup": {},
"WorldMap": {}
}
@@ -0,0 +1,51 @@
# King of the Hill — Overview
In this tutorial you will build a **King of the Hill** minigame with three rounds. Each round has a dedicated capture point in a different part of the arena. Players earn one point per second while standing on the active hill. The player with the most points after all three rounds wins.
## What You Will Build
| Volume / File | Purpose |
|---|---|
| `King_Of_The_Hill.json` | Minigame definition — rounds, timing, HUD, PvP rules |
| Queue pad | Players step on it to join the waiting session |
| Start button | Game master trigger to launch the game and set phase to ACTIVE |
| Arena boundary | Map discovery tag — tells the runtime which map it is running on |
| Spawn zone | Where players enter to activate and start playing |
| Capture Zone A | Active during Round 1 — scores +1/second while standing inside |
| Capture Zone B | Active during Round 2 — scores +1/second while standing inside |
| Capture Zone C | Active during Round 3 — scores +1/second while standing inside |
| Round 2 / Round 3 signal volumes | Announce the new round when it starts |
| End-game timer volumes | Detect when Round 3 finishes and end the game |
## Game Flow
```
1. Players step on the queue pad.
2. Game master triggers the start button (or add auto-start logic).
3. Runtime starts → phase is set to ACTIVE.
4. Players enter the spawn zone → they become ACTIVE (HUD shown, items given).
5. Round 1 runs for 120 seconds. Capture Zone A scores.
6. Round 1 ends → Round 2 begins. Capture Zone B scores.
7. Round 2 ends → Round 3 begins. Capture Zone C scores.
8. Round 3 ends → game_end timer fires → EndMinigame.
9. Scores are tallied. Highest score wins. Arena resets.
```
## Prerequisites
- A Hytale server with core-minigames installed.
- A world with three distinct hilltop areas for your capture zones (A, B, C).
- A lobby or staging area where players queue before the game begins.
- Basic familiarity with placing trigger volumes and adding conditions/effects in the editor.
## Files in This Tutorial
| File | Contents |
|---|---|
| `02-minigame-definition.md` | JSON definition with all fields explained |
| `03-join-and-start.md` | Queue pad, start button, map boundary |
| `04-spawn-and-activation.md` | Spawn zone + player activation |
| `05-capture-zones.md` | The three scoring capture zones |
| `06-round-signals.md` | Round announcements and end-game timer |
| `07-end-and-reset.md` | Ending the game and resetting the arena |
| `08-full-example-files.md` | All complete configurations in one place |
@@ -0,0 +1,60 @@
# King of the Hill — Minigame Definition
Create the following file in your server's assets:
```
src/main/resources/Server/Minigames/King_Of_The_Hill.json
```
```json
{
"Id": "King_Of_The_Hill",
"Name": "King of the Hill",
"Enabled": true,
"MinPlayers": 2,
"MaxPlayers": 16,
"TeamMode": "SOLO",
"WinCondition": "HIGHEST_SCORE",
"RoundLengthSeconds": 120,
"TotalRounds": 3,
"CountdownSeconds": 10,
"AllowPvp": true,
"RequiredGameMode": "Adventure",
"ShowHud": true,
"ShowScore": true,
"ShowTime": true,
"ShowRound": true
}
```
## Field Breakdown
| Field | Value | Why |
|---|---|---|
| `Id` | `King_Of_The_Hill` | Must match the filename exactly and all `MinigameId` fields on every trigger volume in the world. |
| `MinPlayers` | `2` | Queue will not start a countdown until at least 2 players have joined. Set to `1` for solo testing. |
| `TeamMode` | `SOLO` | Each player competes individually with their own score. |
| `WinCondition` | `HIGHEST_SCORE` | The player with the most points when the game ends wins. |
| `RoundLengthSeconds` | `120` | Each hill is active for 2 minutes before the next round begins. |
| `TotalRounds` | `3` | Three rounds run in sequence. The runtime advances rounds automatically when each timer expires. |
| `CountdownSeconds` | `10` | After MinPlayers has joined the queue, a 10-second countdown begins before the game starts. |
| `AllowPvp` | `true` | Players can knock each other off the hill. Set to `false` for a passive score-race version. |
| `RequiredGameMode` | `Adventure` | Applied to each player when they activate. Prevents block breaking and placing. |
| `ShowHud` | `true` | Enables the in-game HUD. Without this, no HUD appears even if the other Show* fields are set. |
| `ShowScore` | `true` | Shows the player's current score on the HUD. |
| `ShowTime` | `true` | Shows time remaining in the current round on the HUD. Only renders when `RoundLengthSeconds > 0`. |
| `ShowRound` | `true` | Shows current round out of total rounds (e.g. `2 / 3`). Only renders when `TotalRounds > 1`. |
## How Rounds Work
You do not need to manually advance rounds. The runtime handles this automatically:
1. When the game starts, the `$round` timer begins counting down `RoundLengthSeconds` for round 1.
2. When the timer expires, round 2 starts and the timer resets.
3. When round 3's timer expires, `on_game_rounds_complete` is dispatched.
Your trigger volumes use the `CurrentRound` condition to check which round is active, so each capture zone automatically becomes active or inactive as rounds change.
## Testing With One Player
Change `MinPlayers` to `1` while setting up and testing your world. Change it back before going live.
@@ -0,0 +1,86 @@
# King of the Hill — Join Pad & Auto-Start
This step sets up the volumes that let players join the queue and auto-start the game once enough players are ready.
---
## 1. Arena Boundary Volume
This volume is not interactive — it exists purely so the runtime knows which map it is running on. Place it as a large box that encompasses the entire arena.
**Volume type:** Any (Enter trigger is fine, no effects needed)
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
| `MapId` | `Hillside` *(replace with your map name)* |
| `MapRole` | `MainArena` |
No conditions. No effects.
> The `MapId` value is arbitrary — pick a name that describes your arena (e.g. `Hillside`, `Volcano`, `Castle`). Use the same value on every other volume in this world that belongs to this map.
---
## 2. Queue Pad
Place this volume at a lobby pad where players gather before the game. When stepped on, it adds the player to the queue and attempts to start the game. The start attempt is a no-op until `MinPlayers` (2) have joined, at which point the runtime is created automatically.
**Volume type:** Enter trigger
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
**Effects:**
| Effect | Fields |
|---|---|
| `JoinMinigameQueue` | `MinigameId = King_Of_The_Hill` |
| `StartQueuedMinigame` | `MinigameId = King_Of_The_Hill` |
> **How the auto-start works:** `StartQueuedMinigame` checks that no runtime is already running and that the queue has at least `MinPlayers` players before doing anything. With `MinPlayers = 2`: Player 1 steps on the pad — `StartQueuedMinigame` sees only 1 queued player and skips. Player 2 steps on the pad — queue now has 2 players, `StartQueuedMinigame` creates the runtime (phase = `WAITING_FOR_PLAYERS`). The signal volume below immediately advances it to `ACTIVE`.
Players who step on this pad after the game has already started are queued for the next game — `StartQueuedMinigame` detects the active runtime and skips.
---
## 3. STARTING Phase Volume
The system runs the `CountdownSeconds` countdown automatically during `WAITING_FOR_PLAYERS`. Once the countdown finishes, the runtime advances to `STARTING` and fires `SignalPhaseStart=STARTING`. Your volume responds by setting the phase to `ACTIVE` to begin the game.
**Volume type:** Any (Tag Added trigger — fired by the runtime, not physically entered)
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
| `MapId` | `Hillside` |
| `SignalPhaseStart` | `STARTING` |
**Effects:**
| Effect | Fields |
|---|---|
| `SetMinigamePhase` | `MinigameId = King_Of_The_Hill`, `Phase = ACTIVE` |
| `SetRound` | `Operation = Set`, `Amount = 1` |
(anything else you want to happen? Effects, sounds, Starting cinematic? Do before setting to active and starting the round)
> **Countdown**: with `CountdownSeconds = 10` in the definition, a "Starting in N..." message is sent to all players each second. If a player steps off the queue pad (`LeaveMinigameQueue`) or disconnects during the countdown and the player count drops below `MinPlayers`, the countdown cancels and players are returned to the queue automatically.
>
> **Skip the countdown**: set `CountdownSeconds = 0` in the definition to go from `STARTING` to `ACTIVE` immediately.
---
## Leave Queue (Optional)
If players want to leave before the game starts, add a separate pad with:
| Effect | Fields |
|---|---|
| `LeaveMinigameQueue` | `MinigameId = King_Of_The_Hill` |
@@ -0,0 +1,67 @@
# King of the Hill — Spawn Zone & Player Activation
Players are not automatically active when the game starts. They must enter the spawn zone, which triggers the activation lifecycle: inventory save, item giving, game mode change, and HUD display.
---
## What Activation Does
When `SetPlayerStatus ACTIVE` fires for a player, the runtime:
1. Saves their current inventory (if `SavePlayerInventory = true`, the default).
2. Gives them their start items or assigned loadout (if any defined in the definition).
3. Applies `RequiredGameMode` (`Adventure` in this tutorial).
4. Shows the HUD (since `ShowHud = true`).
This only happens once per player per game. If the same player re-enters the spawn zone it is harmless — the runtime ignores duplicate ACTIVE requests.
---
## Spawn Zone Volume
Place this volume at the area where players begin the round — typically a platform or small room at the edge of the arena that all players pass through when the game starts.
**Volume type:** Enter trigger
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
| `MapId` | `Hillside` |
**Conditions:**
| Condition | Fields | Purpose |
|---|---|---|
| `PlayerInMinigame` | `MinigameId = King_Of_The_Hill` | Only fires for players who are actually in the runtime (not random passersby). |
| `MinigamePhase` | `MinigameId = King_Of_The_Hill`, `Phase = ACTIVE` | Prevents accidental activation while players are still in the lobby countdown. |
**Effects:**
| Effect | Fields |
|---|---|
| `SetPlayerStatus` | `MinigameId = King_Of_The_Hill`, `Status = ACTIVE` |
| `SetSpawnPoint` | `MinigameId = King_Of_The_Hill`, `DestinationId = 100,64,200` *(replace with your spawn coordinates)* |
> The `SetSpawnPoint` stores a respawn destination for each player. When a player dies during the game, the runtime automatically respawns them at their stored checkpoint. Replace `100,64,200` with the actual world coordinates of your spawn area center. You can also use the format `world_name,X,Y,Z` if the spawn is in a different world.
---
## How Players Get to the Spawn Zone
In a typical KOTH setup, players are somewhere in the arena or lobby when the game starts. You have two options:
**Option A — Players walk there.** The spawn zone is accessible from the lobby area. When the game starts and phase becomes `ACTIVE`, players walk through the spawn zone on their way into the arena. This is the simplest approach and works well when the lobby and arena are adjacent.
**Option B — Teleport on game start.** Add a `SignalPhaseStart=ACTIVE` signal volume (covered in `06-round-signals.md`) with a `RespawnPlayer` effect. This teleports all active players to their spawn on phase change.
For this tutorial, Option A is assumed. If using Option B, `RespawnPlayer` resolves the destination using the player's stored checkpoint from `SetSpawnPoint`, so the activation volume still needs to fire first. Consider placing the spawn zone between the lobby exit and the arena entrance so it is always traversed.
---
## Respawn on Death
Because `SetSpawnPoint` is called at activation, player deaths during the game automatically respawn at those coordinates. No additional setup is required for basic respawning.
If you want players to respawn with a delay or only when a specific condition is met, add a `RespawnPlayer` effect to a separate signal or death-event volume.
@@ -0,0 +1,147 @@
# King of the Hill : Capture Zones
Each round has one active capture zone. Players standing inside the correct zone for the current round earn +1 point per second. Zones outside the active round do nothing.
You will create three separate trigger volumes : one per hilltop.
---
## How Scoring Works
Each capture zone uses:
- A **TICK** volume type so it fires continuously while players are standing inside it.
- A `CurrentRound` condition that passes only when the correct round number is active.
- A `ModifyPlayerScore` effect that adds 1 point per trigger fire.
The tick rate on the volume controls how often points are awarded. Set it to **1 second** in the editor so players earn 1 point per second. If the tick fires every 0.5 seconds, players would earn 2 points per second : adjust the amount or tick rate to your preference.
leaving it at zero would be everytick its fired, Giving 30 points
---
## Capture Zone A : Round 1
Place this volume on Hilltop A, the first round's scoring area. Size it so that players must be clearly on top of the hill.
**Volume type:** TICK trigger (tick interval: 1 second)
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
| `MapId` | `Hillside` |
**Conditions:**
| Condition | Fields | Purpose |
|---|---|---|
| `PlayerInMinigame` | `MinigameId = King_Of_The_Hill` | Only scores players in the runtime. |
| `CurrentRound` | `MinigameId = King_Of_The_Hill`, `Compare = EQUAL`, `Round = 1` | Only active during Round 1. |
**Effects:**
| Effect | Fields |
|---|---|
| `ModifyPlayerScore` | `MinigameId = King_Of_The_Hill`, `Operation = ADD`, `Amount = 1` |
---
## Capture Zone B : Round 2
Place this volume on Hilltop B. Same setup as Zone A with only the round number changed.
**Volume type:** TICK trigger (tick interval: 1 second)
**Tags:** Same as Zone A (`MinigameId`, `MapId`)
**Conditions:**
| Condition | Fields |
|---|---|
| `PlayerInMinigame` | `MinigameId = King_Of_The_Hill` |
| `CurrentRound` | `MinigameId = King_Of_The_Hill`, `Compare = EQUAL`, `Round = 2` |
**Effects:**
| Effect | Fields |
|---|---|
| `ModifyPlayerScore` | `MinigameId = King_Of_The_Hill`, `Operation = ADD`, `Amount = 1` |
---
## Capture Zone C : Round 3
Place this volume on Hilltop C.
**Volume type:** TICK trigger (tick interval: 1 second)
**Tags:** Same as above
**Conditions:**
| Condition | Fields |
|---|---|
| `PlayerInMinigame` | `MinigameId = King_Of_The_Hill` |
| `CurrentRound` | `MinigameId = King_Of_The_Hill`, `Compare = EQUAL`, `Round = 3` |
**Effects:**
| Effect | Fields |
|---|---|
| `ModifyPlayerScore` | `MinigameId = King_Of_The_Hill`, `Operation = ADD`, `Amount = 1` |
---
## Notes
**You may want to add further effects the zones** :
On Capture Zone 1 using `SignalRoundStart=1` volume tag (covered in `06-round-signals.md` and `tags.md`)
you can add the following to play a sound at the start of the round and have a VFX with a duration of the round set
On Tag Added trigger
| Effect | Fields |
|---|---|
| `PlaySound` | `Your choice of sound`|
| `Play VFX` | `Your choice of VFX` `Duration = round time`|
**Multiple players on the hill at once** : each player in the volume gets the `ModifyPlayerScore` effect independently. If two players stand on Hilltop A during Round 1, both earn +1/second. This is intentional for KOTH : fighting over the hill is part of the game.
**Zone size** : make your capture zones intentionally tight so there is real competition for the spot. A zone that is too large removes the "king of the hill" tension.
**Dymanic Zone Size**
Lets say you have rounds last 60 secounds. You want the Capture Zone for round 2 to get smaller half way.
Create two Capture Zones one the full size and one the half size
Add on the large Capture Zone 2 using `SignalRoundStart=2` volume tag (covered in `06-round-signals.md` and `tags.md`)
And another tag to each to later identify them
`Round2Capture=Large`
`Round2Capture=Small`
The larger Capture Zone
On Tag Added trigger
| Effect | Fields |
|---|---|
| `DisableVolume` | `MatchTagKey=Round2Capture` `MatchTagValue=Small`|
| `PlaySound` | `Your choice of sound`|
| `Play VFX` | `Your choice of VFX` `Duration = 30`|
| `EnableVolume` | `EffectDelay=30` `MatchTagKey=Round2Capture` `MatchTagValue=Small`|
| `ModifyTag` | `Operation=Set` `TagKey=SmallRound2Capture` `TagValue=Enabled` `MatchTagKey=Round2Capture` `MatchTagValue=Small`|
| `DisableVolume` | `MatchTagKey=Round2Capture` `MatchTagValue=Large`|
The Smaller Capture Zone
On Tag Added trigger
| Effect | Fields |
|---|---|
| `PlaySound` | `Your choice of sound`|
| `Play VFX` | `Your choice of VFX` `Duration = 30`|
| `EnableVolume` | `EffectDelay=30` `MatchTagKey=Round2Capture` `MatchTagValue=Large`|
| `ModifyTag` | `Operation=Remove` `TagKey=SmallRound2Capture` `TagValue=Enabled` `MatchTagKey=Round2Capture` `MatchTagValue=Small`|
| `DisableVolume` | `MatchTagKey=Round2Capture` `MatchTagValue=Large`|
There are a couple of ways to do this, but this is just one I felt like typing.
**Score display** : the HUD shows the current score in real time. Players can see how their score compares while standing on the hill. Round and time remaining are also shown because `ShowRound = true` and `ShowTime = true` are set in the definition.
@@ -0,0 +1,112 @@
# King of the Hill — Round Signals & Game End
This step wires up announcements for each new round and explains how the game ends automatically when the final round completes.
---
## How Signal Volumes Work
Signal volumes have special tags (`SignalRoundStart`, `SignalPhaseTick`, `SignalPhaseStart`, etc.) that the runtime writes to at the right moment. When the tag value changes, Hytale fires the volume's `TAG_ADDED` trigger, which runs the attached effects.
Every signal volume must also have `MinigameId` set so the runtime knows which game's signals to respond to.
---
## Round 2 Announcement
When Round 2 begins, fire a message to all players announcing the new hill location.
**Volume type:** Any (Tag Added trigger is conventional for signal volumes — the volume is never physically entered, it is fired by the runtime)
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
| `MapId` | `Hillside` |
| `SignalRoundStart` | `2` |
**Effects:**
| Effect | Fields |
|---|---|
| `SendMinigameMessage` | `MinigameId = King_Of_The_Hill`, `MessageKey = round_2_start`, `Target = ALL` |
> `MessageKey` references a translation key in your server's language files. If you do not have a dedicated translations setup, you can skip `SendMinigameMessage` for now — the HUD already shows the updated round number automatically.
---
## Round 3 Announcement
Same structure, fires when Round 3 begins.
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
| `MapId` | `Hillside` |
| `SignalRoundStart` | `3` |
**Effects:**
| Effect | Fields |
|---|---|
| `SendMinigameMessage` | `MinigameId = King_Of_The_Hill`, `MessageKey = round_3_start`, `Target = ALL` |
---
## How the Game Ends Automatically
When the Round 3 timer expires, the runtime calls `end()` automatically (because `TotalRounds = 3` and neither `OvertimeEnabled` nor `SuddenDeathEnabled` are set). No extra timer volume is needed.
The end sequence fires in this order:
1. `SignalPhaseStart=ENDING` fires — use this for a scoreboard display, player freeze, or end announcement.
2. HUDs are removed, inventories restored, rewards distributed.
3. `on_game_end` event fires (with `reason = "rounds_complete"`).
4. Runtime is removed from the active list.
5. `SignalPhaseStart=RESETTING` fires — use this to restore blocks, re-enable the queue pad, etc.
6. `on_arena_reset` event fires.
You do not need a `StartTimer`/`SignalOnTimerExpire` pair to end the game.
---
## ENDING Phase Volume (Optional)
To show a final message or freeze players briefly at game end, respond to `SignalPhaseStart=ENDING`:
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
| `MapId` | `Hillside` |
| `SignalPhaseStart` | `ENDING` |
**Example effects:**
| Effect | Fields |
|---|---|
| `SendMinigameMessage` | `MinigameId = King_Of_The_Hill`, `MessageKey = game_over`, `Target = ALL` |
---
## Round 1 Announcement (Optional)
Round 1 begins immediately when the game goes ACTIVE (there is no `SignalRoundStart=1` in the current round implementation for the initial round). If you want a Round 1 announcement, fire it from the `SignalPhaseStart=ACTIVE` volume set up in step 03:
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
| `MapId` | `Hillside` |
| `SignalPhaseStart` | `ACTIVE` |
**Effects:**
| Effect | Fields |
|---|---|
| `SendMinigameMessage` | `MinigameId = King_Of_The_Hill`, `MessageKey = round_1_start`, `Target = ALL` |
@@ -0,0 +1,87 @@
# King of the Hill — Ending & Resetting
This step covers what happens when the game ends, what signals fire and when, and how to use arena reset to prepare for the next game.
---
## What Happens When the Game Ends
When the final round timer expires (and no overtime/sudden death is configured), the runtime calls `end("rounds_complete")` automatically. The full sequence:
1. **HUDs removed** for all players in the runtime.
2. **Phase set to `ENDING`**`SignalPhaseStart=ENDING` fires.
3. **Active signal timers cancelled.**
4. **Temporary entities cleaned up** (spawned NPCs/items tracked by the runtime).
5. **Player inventories restored** (since `RestorePlayerInventory = true` is the default).
6. **Rewards distributed** (if any `Rewards` are defined in the definition).
7. **`on_game_end` event dispatched** with `reason = "rounds_complete"` and final scores.
8. **Runtime state cleared** — scores, player status, timers, counters all zeroed.
9. **Runtime removed** from the active list.
10. **Phase set to `RESETTING`**`SignalPhaseStart=RESETTING` fires.
11. **`on_arena_reset` event dispatched** (since `ResetOnEnd = true` is the default).
You do not need to manually reset scores or clear HUDs.
---
## ENDING Phase Volume (Optional)
Use `SignalPhaseStart=ENDING` to show a scoreboard, announce the winner, or freeze players briefly before cleanup. This fires while the runtime still holds final scores.
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
| `MapId` | `Hillside` |
| `SignalPhaseStart` | `ENDING` |
**Example effects:**
| Effect | Fields |
|---|---|
| `SendMinigameMessage` | `MinigameId = King_Of_The_Hill`, `MessageKey = game_over`, `Target = ALL` |
---
## Arena Reset Volume
Use `SignalPhaseStart=RESETTING` to restore any world state changed during the game. This fires after the runtime has been cleaned up, so it is safe to re-enable the queue pad without risk of double-triggering.
> **Scope note:** `SignalPhaseStart=RESETTING` is scoped by `MinigameId` tag match, NOT by physical containment within the `MapRole=MainArena` boundary. Any volume anywhere in the world with a matching `MinigameId` tag will respond. The arena boundary volume is only used for map *discovery*, not for event routing.
For this King of the Hill setup there are no block placements or NPC spawns, so the arena reset volume is optional. Add it if you want to re-enable the queue pad, reset visual markers, or restore barriers changed mid-game.
**Tags:**
| Tag | Value |
|---|---|
| `MinigameId` | `King_Of_The_Hill` |
| `MapId` | `Hillside` |
| `SignalPhaseStart` | `RESETTING` |
**Example effects:**
| Effect | Fields | Purpose |
|---|---|---|
| `PlaceBlocks` | *(coordinates of any changed areas, original block id)* | Restore modified terrain. |
---
## Repeating the Game
After the runtime ends and `SignalPhaseStart=RESETTING` fires, the minigame is ready to start again. Players can step back onto the queue pad and a new runtime will be created when `StartQueuedMinigame` fires again.
No state from the previous game persists — scores, player status, timers, and counters are all cleared by the end flow.
---
## Manual Reset
If you need to reset the game mid-match (for testing or crash recovery), use the admin command:
```
/minigame reset King_Of_The_Hill
```
This calls `ResetMinigame` directly, which sets phase to `RESETTING`, fires `SignalPhaseStart=RESETTING`, clears runtime state, and dispatches `on_arena_reset` — without going through the normal end flow (no rewards, no inventory restore, no `ENDING` phase).
@@ -0,0 +1,135 @@
# King of the Hill — Complete Reference
All volumes and the definition file in one place. Use this as a checklist when setting up your world.
---
## Definition File
**Path:** `src/main/resources/Server/Minigames/King_Of_The_Hill.json`
```json
{
"Id": "King_Of_The_Hill",
"Name": "King of the Hill",
"Enabled": true,
"MinPlayers": 2,
"MaxPlayers": 16,
"TeamMode": "SOLO",
"WinCondition": "HIGHEST_SCORE",
"RoundLengthSeconds": 120,
"TotalRounds": 3,
"CountdownSeconds": 10,
"AllowPvp": true,
"RequiredGameMode": "Adventure",
"ShowHud": true,
"ShowScore": true,
"ShowTime": true,
"ShowRound": true
}
```
---
## Volume Checklist
| # | Volume Name | Type | Tags | Conditions | Effects |
|---|---|---|---|---|---|
| 1 | Arena Boundary | Enter | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `MapRole=MainArena` | — | — |
| 2 | Queue Pad | Enter | `MinigameId=King_Of_The_Hill` | — | `JoinMinigameQueue``StartQueuedMinigame` |
| 3 | Starting Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalPhaseStart=STARTING` | — | `SetMinigamePhase ACTIVE` |
| 4 | Spawn Zone | Enter | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `MinigamePhase=ACTIVE` | `SetPlayerStatus ACTIVE``SetSpawnPoint` |
| 5 | Capture Zone A | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 1` | `ModifyPlayerScore ADD 1` |
| 6 | Capture Zone B | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 2` | `ModifyPlayerScore ADD 1` |
| 7 | Capture Zone C | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 3` | `ModifyPlayerScore ADD 1` |
| 8 | Round 2 Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalRoundStart=2` | — | `SendMinigameMessage round_2_start ALL` |
| 9 | Round 3 Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalRoundStart=3` | — | `SendMinigameMessage round_3_start ALL` |
| 10 | End Announcement | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalPhaseStart=ENDING` | — | `SendMinigameMessage game_over ALL` *(optional)* |
| 11 | Arena Reset | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalPhaseStart=RESETTING` | — | *(block restore effects)* *(optional)* |
The game ends automatically when Round 3's timer expires — no end-game timer volume is needed.
---
## Volume Detail — Queue Pad (Auto-Start)
```
Tags:
MinigameId = King_Of_The_Hill
Effects:
JoinMinigameQueue MinigameId=King_Of_The_Hill
StartQueuedMinigame MinigameId=King_Of_The_Hill
```
`StartQueuedMinigame` is a no-op until `MinPlayers` (2) are in the queue AND no runtime is already running.
---
## Volume Detail — Starting Signal
```
Tags:
MinigameId = King_Of_The_Hill
MapId = Hillside
SignalPhaseStart = STARTING
Effects:
SetMinigamePhase MinigameId=King_Of_The_Hill Phase=ACTIVE
```
Fires after the countdown completes. The system runs the `CountdownSeconds` countdown
automatically during `WAITING_FOR_PLAYERS` — no timer volume required.
---
## Volume Detail — Spawn Zone
```
Tags:
MinigameId = King_Of_The_Hill
MapId = Hillside
Conditions:
PlayerInMinigame MinigameId=King_Of_The_Hill
MinigamePhase MinigameId=King_Of_The_Hill Phase=ACTIVE
Effects:
SetPlayerStatus MinigameId=King_Of_The_Hill Status=ACTIVE
SetSpawnPoint MinigameId=King_Of_The_Hill DestinationId=<X,Y,Z>
```
Replace `<X,Y,Z>` with the world coordinates of your spawn area.
---
## Volume Detail — Capture Zone A (copy pattern for B and C)
```
Tags:
MinigameId = King_Of_The_Hill
MapId = Hillside
Conditions:
PlayerInMinigame MinigameId=King_Of_The_Hill
CurrentRound MinigameId=King_Of_The_Hill Compare=EQUAL Round=1
Effects:
ModifyPlayerScore MinigameId=King_Of_The_Hill Operation=ADD Amount=1
```
Change `Round=1` to `Round=2` and `Round=3` for Zones B and C.
---
## Common Mistakes
| Symptom | Likely Cause |
|---|---|
| HUD never appears | `SetPlayerStatus ACTIVE` is not firing — check that the spawn zone has both `PlayerInMinigame` and `MinigamePhase=ACTIVE` conditions. |
| Score never changes | Capture zone tick interval is not set, or the `CurrentRound` condition does not match the active round. Verify the `Round` value matches the round number (1, 2, or 3). |
| Game never starts | Queue pad must have both `JoinMinigameQueue` AND `StartQueuedMinigame`. The starting signal volume must have `SignalPhaseStart=STARTING` with `SetMinigamePhase ACTIVE` effect. |
| Game never ends | Rounds do not auto-end if `OvertimeEnabled` or `SuddenDeathEnabled` are set in the definition. Verify neither is enabled. |
| Volume conditions blocked | Wrong `MinigameId` value — ensure every volume and every condition/effect field uses exactly `King_Of_The_Hill` (case-sensitive, underscores not spaces). |
| Players not activating | Phase was never set to `ACTIVE` — verify the auto-start signal volume (`SignalPhaseStart=WAITING_FOR_PLAYERS`) is present and fires `SetMinigamePhase ACTIVE`. |
| Game starts with 1 player | `MinPlayers` in the definition is set to `1`. Change to `2` if you need two players. |
@@ -0,0 +1,50 @@
These are just examples of how tutorial files might be depending on their game and format.
solo-brawl/
01-overview.md
02-create-minigame-definition.md
03-create-start-pad.md
04-create-arena-and-spawns.md
05-activate-players.md
06-enable-pvp-and-damage.md
07-add-scoring.md
08-add-timer-and-win-condition.md
09-end-reset-and-cleanup.md
10-full-example-files.md
team-objective-game/
01-overview.md
02-create-minigame-definition.md
03-create-queue-and-start-flow.md
04-create-map-tags-and-arena.md
05-create-team-spawns.md
06-assign-and-balance-teams.md
07-activate-players.md
08-create-objectives.md
09-add-team-scoring.md
10-add-win-condition.md
11-end-reset-and-cleanup.md
12-full-example-files.md
parkour-checkpoints/
01-overview.md
02-create-minigame-definition.md
03-create-start-and-finish-volumes.md
04-create-checkpoints.md
05-track-player-progress.md
06-add-timer.md
07-handle-falls-and-respawns.md
08-finish-reset-and-cleanup.md
09-full-example-files.md
hot-potato/
01-overview.md
02-create-minigame-definition.md
03-create-queue-and-start-flow.md
04-create-arena-and-spawns.md
05-activate-players.md
06-spawn-the-potato-item.md
07-pass-the-potato.md
08-countdown-and-explosion.md
09-eliminate-or-score-players.md
10-end-reset-and-cleanup.md
11-full-example-files.md
+3
View File
@@ -97,6 +97,9 @@ All minigame effects that operate on a runtime include a `MinigameId` field. In
| `ClearInventory` | `MinigameId`, optional `PlayerId` | Clears the player's inventory. |
| `SetObjectiveStatus` | `MinigameId`, `ObjectiveId`, `Status` | Creates or updates an objective status and dispatches `on_objective_complete` when complete. |
| `ModifyObjectiveProgress` | `MinigameId`, `ObjectiveId`, `Operation`, `Amount` | Sets, adds, or subtracts objective progress and completes an active objective when progress reaches the required threshold. |
| `OpenMinigameQueueUI` | optional `MinigameId`, optional `MapId` | Opens the queue UI page for the triggering player. Mode is inferred: no `MinigameId` → Global browser; `MinigameId` only → Single-game map list; both `MinigameId` and `MapId` → Local arena popup. |
| `StartPlayerTimer` | `MinigameId`, optional `PlayerId` | Starts a per-player stopwatch for the triggering player. Records the start timestamp in player state. Use on race start pads. If `PlayerId` is blank, the trigger context player is used. |
| `StopPlayerTimer` | `MinigameId`, optional `PlayerId`, optional `Operation` | Stops the player's stopwatch and applies the lap time to the player's score. `Operation` controls multi-round behaviour: `SET` (default) replaces the score, `ADD` accumulates lap times for a total time, `BEST` keeps the lowest recorded lap. Ignored if `StartPlayerTimer` was never called for this player. |
### Operation Values
+14 -1
View File
@@ -18,7 +18,8 @@ Definition file: `Server/Minigames/Quick_Brawl.json`
"WinCondition": "HIGHEST_SCORE",
"RoundLengthSeconds": 120,
"CountdownSeconds": 5,
"AllowPvp": true
"AllowPvp": true,
...
}
```
@@ -28,6 +29,18 @@ Editor setup:
2. Add the `StartMinigame` effect.
3. Set `MinigameId` to `Quick_Brawl` using the asset picker.
## Activating Players
Players must be explicitly set to `ACTIVE` status before the activation lifecycle runs. This is what triggers inventory saving, start item giving, game mode change, and HUD display. Without a `SetPlayerStatus ACTIVE` effect, none of these happen.
A common pattern is a spawn-room trigger volume that all players enter when the game starts:
1. Create a trigger volume covering the spawn area.
2. Add `PlayerInMinigame` as a condition (`MinigameId = Quick_Brawl`, leave `PlayerId` blank).
3. Add `SetPlayerStatus` as an effect (`MinigameId = Quick_Brawl`, `Status = ACTIVE`, leave `PlayerId` blank).
The entering player is resolved from the trigger context, so leaving `PlayerId` blank activates whoever enters the volume.
## Score Zone
Definition file: `Server/Minigames/Hill_Points.json`
+23
View File
@@ -65,6 +65,7 @@ Supported values:
| `FIRST_TO_SCORE` | First player or team to a target score wins. |
| `LAST_ALIVE` | Last active player or team wins. |
| `OBJECTIVE_COMPLETE` | Objective completion determines the winner. |
| `LOWEST_TIME` | Fastest completion time wins. Use `StartPlayerTimer` on the start pad and `StopPlayerTimer` on the finish line. Players who have not finished (score = 0) are ranked last. Times display as `M:SS.cc`. |
| `CUSTOM` | Trigger logic determines the winner. |
## World Rules
@@ -91,6 +92,28 @@ Supported values:
| `StartLoadouts` | `LoadoutConfig[]` | No | `[]` | Named item loadouts. A player's assigned loadout (set via `SetPlayerLoadout`) takes priority over `StartItems`. |
| `Rewards` | `RewardConfig[]` | No | `[]` | Rewards distributed at game end, before `on_game_end` fires. |
## HUD
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `ShowHud` | Boolean | No | `false` | Whether to show a HUD for each active player. Opt-in per game. |
| `ShowScore` | Boolean | No | `true` | Whether to show the player's current score on the HUD. |
| `ShowLives` | Boolean | No | `true` | Whether to show the player's remaining lives on the HUD. Only visible when `DefaultPlayerLives` is greater than `0`. |
| `ShowPosition` | Boolean | No | `false` | Whether to show the player's current race position (rank among active non-eliminated players). Useful for racing games using `LOWEST_SCORE`. |
| `ShowRound` | Boolean | No | `true` | Whether to show the current round out of total rounds (e.g. `2 / 3`). Only rendered when `TotalRounds` is greater than `1`. |
| `ShowTime` | Boolean | No | `true` | Whether to show the time remaining in the current round (e.g. `1:45`). Only rendered when `RoundLengthSeconds` is greater than `0`. Displays `0:00` once the round timer has expired. |
| `ShowScoreboard` | Boolean | No | `false` | Whether to show a ranked scoreboard panel on the HUD listing player names and scores in order. Capped at 10 rows or `MaxPlayers`, whichever is smaller. |
The HUD is shown when a player becomes `ACTIVE` and removed when the game ends. It refreshes approximately once per second. Position is ranked by current score respecting `WinCondition` (`LOWEST_SCORE` ranks lower scores ahead).
## Queue UI
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `UIQueueable` | Boolean | No | `false` | Whether this minigame appears in the global queue UI browser. Must be `true` for the game to show in `GLOBAL` mode. `SINGLE` and `LOCAL` modes work regardless of this flag — they are controlled by the `UIQueue=True` volume tag. |
When `UIQueueable=true`, the game will appear in the `OpenMinigameQueueUI` global browser alongside the queue count and a clickable join button, provided at least one `MainArena` volume has `UIQueue=True`.
## ItemStackConfig
```json
+23
View File
@@ -0,0 +1,23 @@
## Tutorial Feedback
## Tests
Need to test, expand, and work on load outs, back burner for now.
## Changes and questions
Make it in the set spawn point you can type the XYZ for where to spawn the player. (is that likely now, but should be their own boxes)
the double send message name. Need to change the custom one.
## Bugs
## Completed
- **HUD top-right positioning** — HUD anchor now uses `setRight(8).setTop(8)` and renders in the top-right corner.
- **Scoreboard HUD panel** (`ShowScoreboard` in definition) — ranked player list with name + score shown below the standard HUD tiles. Capped at 10 rows. IDs are stable for delta refresh.
- **Queue UI system** (`OpenMinigameQueueUI` effect) — three modes: Global (all UIQueueable games), Single (one game's maps), Local (single arena popup). Join auto-attempts game start. Leave queue button shown when already queued. Volume tags: `UIQueue=True` + `UIQueueType=Global|Single|Local` on `MainArena` volumes. Definition flag: `UIQueueable=true`.
+62
View File
@@ -104,6 +104,68 @@ MapId=Castle
SignalOnTimerExpire=overtime_warning
```
## Map Voting Tags
These tags control which maps and variants appear in the voting UI shown to queued players once `MinPlayers` is reached on a game with `MapSelectionMode: VOTE`.
| Tag | Required | Values | Used By |
|-----|----------|--------|---------|
| `MapVoteable` | Yes | `True` | Marks a `MainArena` volume's map as a selectable option in the vote UI. |
| `VariantVotable` | No | `True` | Marks a specific variant of a map as a selectable variant in the vote UI. Only meaningful when the volume also has `VariantId` set. |
Both tags must be on a volume that also has `MinigameId`, `MapId`, and `MapRole=MainArena`.
Map-only example (no variant choice):
```text
MinigameId=KOTH
MapId=Castle
MapRole=MainArena
MapVoteable=True
```
Map with voteable variants:
```text
MinigameId=KOTH
MapId=Castle
VariantId=Night
MapRole=MainArena
MapVoteable=True
VariantVotable=True
```
When variants are present the vote UI groups them under the map name. Voting for a variant records a `MapId:VariantId` preference (e.g. `Castle:Night`) that the queue service honours when selecting the starting map.
If only one total voteable option exists (one map with no variants, or one map with exactly one variant), the vote UI is skipped entirely and the game starts directly.
## Queue UI Tags
These tags opt a `MainArena` volume into the joinable queue UI. They are read by `OpenMinigameQueueUI` when a player opens the queue menu from an NPC or interaction pad.
| Tag | Required | Values | Used By |
|-----|----------|--------|---------|
| `UIQueue` | Yes | `True` | Marks the volume as a queue UI candidate. Must be on a `MapRole=MainArena` volume. |
| `UIQueueType` | No | `Global`, `Single`, `Local` | Controls which display modes include this arena. Defaults to `Global`. |
`UIQueueType` meanings:
| Value | Behaviour |
|-------|-----------|
| `Global` | Arena appears in the global game browser (all games) and in single-game menus. |
| `Single` | Arena appears only when browsing that specific minigame. |
| `Local` | Arena is intended for a nearby NPC opened with `OpenMinigameQueueUI` + explicit `MinigameId` + `MapId`. |
Minimum queue UI candidate (add to an existing `MainArena` volume):
```text
MinigameId=KOTH
MapId=Castle
MapRole=MainArena
UIQueue=True
UIQueueType=Global
```
The minigame's `UIQueueable=true` definition flag must also be set for the game to appear in the global browser.
## Validation Notes
- A volume with `MapId` but no `MinigameId` produces a warning and is ignored for map discovery.
@@ -26,6 +26,15 @@ import net.kewwbec.minigames.service.MinigameServices;
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
import net.kewwbec.minigames.system.MinigameDeathRespawnSystem;
import net.kewwbec.minigames.system.MinigameKillScoreSystem;
import net.kewwbec.minigames.system.MinigamePregameSystem;
import net.kewwbec.minigames.ui.MinigameHudService;
import net.kewwbec.minigames.ui.MinigameHudSystem;
import net.kewwbec.minigames.ui.MinigameMenuOpenSystem;
import net.kewwbec.minigames.ui.MinigameMenuService;
import net.kewwbec.minigames.ui.MinigameQueueUIService;
import net.kewwbec.minigames.system.MinigameQueueUISystem;
import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem;
import net.kewwbec.minigames.volume.effect.OpenMinigameQueueUIEffect;
import net.kewwbec.minigames.volume.condition.CounterCondition;
import net.kewwbec.minigames.volume.condition.CurrentRoundCondition;
import net.kewwbec.minigames.volume.condition.MinigamePhaseCondition;
@@ -72,16 +81,30 @@ import net.kewwbec.minigames.volume.effect.StartMinigameEffect;
import net.kewwbec.minigames.volume.effect.StartQueuedMinigameEffect;
import net.kewwbec.minigames.volume.effect.StartTimerEffect;
import net.kewwbec.minigames.volume.effect.StopTimerEffect;
import net.kewwbec.minigames.volume.effect.StartPlayerTimerEffect;
import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect;
import net.kewwbec.minigames.volume.effect.VoteForMapEffect;
import com.hypixel.hytale.server.core.command.system.AbstractCommand;
import com.hypixel.hytale.server.core.command.system.CommandManager;
import com.hypixel.hytale.server.core.io.adapter.PacketAdapters;
import com.hypixel.hytale.server.core.io.adapter.PacketFilter;
import net.kewwbec.minigames.command.TriggerVolumeCloneCommand;
import net.kewwbec.minigames.command.TriggerVolumeCloneGroupCommand;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.lang.reflect.Field;
import java.nio.file.Path;
import java.util.Map;
import java.util.logging.Level;
public final class MinigameCorePlugin extends JavaPlugin {
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
private static MinigameCorePlugin instance;
private MinigameServices services;
private MinigameQueueUIService queueUIService;
private PacketFilter duplicatePacketFilter;
private ComponentType<EntityStore, LockMountComponent> lockMountComponentType;
public MinigameCorePlugin(@Nonnull JavaPluginInit init) {
@@ -95,6 +118,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
ServerManager.get().registerSubPacketHandlers(LockMountPacketHandler::new);
getEntityStoreRegistry().registerSystem(new MinigameKillScoreSystem());
getEntityStoreRegistry().registerSystem(new MinigameDeathRespawnSystem());
TriggerVolumeDuplicateSystem duplicateSystem = new TriggerVolumeDuplicateSystem();
this.duplicatePacketFilter = duplicateSystem.register();
registerTriggerVolumeEffectTypes();
registerTriggerVolumeConditionTypes();
@@ -116,10 +141,25 @@ public final class MinigameCorePlugin extends JavaPlugin {
runtimeService.setPlayerAdapter(adapters);
runtimeService.setEntityAdapter(adapters);
runtimeService.setInventoryAdapter(adapters);
this.services = new MinigameServices(minigameService, runtimeService, adapters, new MinigameMapDiscoveryService(), new MinigameQueueService(), signals);
var menuService = new MinigameMenuService();
menuService.setPlayerAdapter(adapters);
runtimeService.setMenuService(menuService);
getEntityStoreRegistry().registerSystem(new MinigameMenuOpenSystem(menuService));
var hudService = new MinigameHudService();
hudService.setPlayerAdapter(adapters);
runtimeService.setHudService(hudService);
getEntityStoreRegistry().registerSystem(new MinigameHudSystem(hudService));
var queueService = new MinigameQueueService();
runtimeService.setQueueService(queueService);
getEntityStoreRegistry().registerSystem(new MinigamePregameSystem(runtimeService));
this.services = new MinigameServices(minigameService, runtimeService, adapters, new MinigameMapDiscoveryService(), queueService, signals);
this.queueUIService = new MinigameQueueUIService(services);
this.queueUIService.setPlayerAdapter(adapters);
getEntityStoreRegistry().registerSystem(new MinigameQueueUISystem(queueUIService));
registerTriggerVolumeAssetSources();
this.getCommandRegistry().registerCommand(new MinigameCommand(services));
injectTriggerVolumeCloneCommand();
LOGGER.atInfo().log("core-minigames registered " + registeredTriggerConditionCount()
+ " trigger conditions and " + registeredTriggerEffectCount() + " trigger effects.");
}
@@ -130,6 +170,10 @@ public final class MinigameCorePlugin extends JavaPlugin {
services.runtime().shutdownAll("minigames.runtime.reason.plugin_shutdown");
services.signals().shutdown();
}
if (duplicatePacketFilter != null) {
PacketAdapters.deregisterInbound(duplicatePacketFilter);
duplicatePacketFilter = null;
}
services = null;
instance = null;
}
@@ -139,10 +183,43 @@ public final class MinigameCorePlugin extends JavaPlugin {
return instance != null ? instance.services : null;
}
@Nullable
public static MinigameQueueUIService getQueueUIService() {
return instance != null ? instance.queueUIService : null;
}
public static ComponentType<EntityStore, LockMountComponent> getLockMountComponentType() {
return instance.lockMountComponentType;
}
private void injectTriggerVolumeCloneCommand() {
try {
Field commandRegistrationField = CommandManager.class.getDeclaredField("commandRegistration");
commandRegistrationField.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, AbstractCommand> commandRegistration =
(Map<String, AbstractCommand>) commandRegistrationField.get(CommandManager.get());
AbstractCommand tvCommand = commandRegistration.get("triggervolume");
if (tvCommand == null) {
LOGGER.atWarning().log("Cannot inject /triggervolume clone: triggervolume command not registered");
return;
}
Field hasBeenRegisteredField = AbstractCommand.class.getDeclaredField("hasBeenRegistered");
hasBeenRegisteredField.setAccessible(true);
hasBeenRegisteredField.set(tvCommand, false);
TriggerVolumeCloneCommand cloneCommand = new TriggerVolumeCloneCommand();
tvCommand.addSubCommand(cloneCommand);
cloneCommand.completeRegistration();
TriggerVolumeCloneGroupCommand cloneGroupCommand = new TriggerVolumeCloneGroupCommand();
tvCommand.addSubCommand(cloneGroupCommand);
cloneGroupCommand.completeRegistration();
hasBeenRegisteredField.set(tvCommand, true);
LOGGER.atInfo().log("Injected /triggervolume clone and clonegroup commands");
} catch (Exception e) {
LOGGER.at(Level.WARNING).withCause(e).log("Failed to inject /triggervolume clone command");
}
}
private void registerTriggerVolumeEffectTypes() {
var triggerVolumes = TriggerVolumesPlugin.get();
registerTriggerVolumeEffectType(triggerVolumes, StartMinigameEffect.TYPE_ID, StartMinigameEffect.class, StartMinigameEffect.CODEC);
@@ -178,6 +255,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
registerTriggerVolumeEffectType(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID, ModifyObjectiveProgressEffect.class, ModifyObjectiveProgressEffect.CODEC);
registerTriggerVolumeEffectType(triggerVolumes, AwardKillScoreEffect.TYPE_ID, AwardKillScoreEffect.class, AwardKillScoreEffect.CODEC);
registerTriggerVolumeEffectType(triggerVolumes, PlaceBlocksEffect.TYPE_ID, PlaceBlocksEffect.class, PlaceBlocksEffect.CODEC);
registerTriggerVolumeEffectType(triggerVolumes, OpenMinigameQueueUIEffect.TYPE_ID, OpenMinigameQueueUIEffect.class, OpenMinigameQueueUIEffect.CODEC);
registerTriggerVolumeEffectType(triggerVolumes, StartPlayerTimerEffect.TYPE_ID, StartPlayerTimerEffect.class, StartPlayerTimerEffect.CODEC);
registerTriggerVolumeEffectType(triggerVolumes, StopPlayerTimerEffect.TYPE_ID, StopPlayerTimerEffect.class, StopPlayerTimerEffect.CODEC);
}
private <T extends TriggerEffect> void registerTriggerVolumeEffectType(
@@ -272,6 +352,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
registerMinigameIdAssetField(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID);
registerMinigameIdAssetField(triggerVolumes, AwardKillScoreEffect.TYPE_ID);
registerMinigameIdAssetField(triggerVolumes, PlaceBlocksEffect.TYPE_ID);
registerMinigameIdAssetField(triggerVolumes, OpenMinigameQueueUIEffect.TYPE_ID);
registerMinigameIdAssetField(triggerVolumes, StartPlayerTimerEffect.TYPE_ID);
registerMinigameIdAssetField(triggerVolumes, StopPlayerTimerEffect.TYPE_ID);
registerMinigameIdAssetField(triggerVolumes, MinigamePhaseCondition.TYPE_ID);
registerMinigameIdAssetField(triggerVolumes, PlayerInMinigameCondition.TYPE_ID);
registerMinigameIdAssetField(triggerVolumes, PlayerStatusCondition.TYPE_ID);
@@ -309,7 +392,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
|| typeId.equals(SendMinigameMessageEffect.TYPE_ID) || typeId.equals(AssignTeamEffect.TYPE_ID)
|| typeId.equals(SaveInventoryEffect.TYPE_ID) || typeId.equals(RestoreInventoryEffect.TYPE_ID)
|| typeId.equals(ClearInventoryEffect.TYPE_ID) || typeId.equals(AwardKillScoreEffect.TYPE_ID)
|| typeId.equals(PlaceBlocksEffect.TYPE_ID)) {
|| typeId.equals(PlaceBlocksEffect.TYPE_ID)
|| typeId.equals(OpenMinigameQueueUIEffect.TYPE_ID)) {
count++;
}
}
@@ -14,6 +14,7 @@ import com.hypixel.hytale.server.core.asset.type.item.config.Item;
import com.hypixel.hytale.server.core.asset.type.soundevent.config.SoundEvent;
import com.hypixel.hytale.server.core.entity.ItemUtils;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.protocol.packets.inventory.UpdatePlayerInventory;
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
import com.hypixel.hytale.server.core.inventory.ItemStack;
@@ -151,6 +152,23 @@ public final class HytaleServerAdapters implements
restoreInventory(store, ref, "utility", inventory, InventoryComponent.Utility.getComponentType());
restoreInventory(store, ref, "storage", inventory, InventoryComponent.Storage.getComponentType());
restoreInventory(store, ref, "backpack", inventory, InventoryComponent.Backpack.getComponentType());
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
if (playerRef != null && ref.isValid()) {
var armor = store.getComponent(ref, InventoryComponent.Armor.getComponentType());
var hotbar = store.getComponent(ref, InventoryComponent.Hotbar.getComponentType());
var utility = store.getComponent(ref, InventoryComponent.Utility.getComponentType());
var storage = store.getComponent(ref, InventoryComponent.Storage.getComponentType());
var tool = store.getComponent(ref, InventoryComponent.Tool.getComponentType());
var backpack = store.getComponent(ref, InventoryComponent.Backpack.getComponentType());
playerRef.getPacketHandler().writeNoCache(new UpdatePlayerInventory(
storage != null ? storage.getInventory().toPacket() : null,
armor != null ? armor.getInventory().toPacket() : null,
hotbar != null ? hotbar.getInventory().toPacket() : null,
utility != null ? utility.getInventory().toPacket() : null,
tool != null ? tool.getInventory().toPacket() : null,
backpack != null ? backpack.getInventory().toPacket() : null
));
}
return null;
});
}
@@ -0,0 +1,115 @@
package net.kewwbec.minigames.command;
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.command.system.CommandContext;
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractWorldCommand;
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem;
import org.joml.Vector3d;
import javax.annotation.Nonnull;
import java.util.HashMap;
import java.util.Map;
public final class TriggerVolumeCloneCommand extends AbstractWorldCommand {
private final RequiredArg<String> nameArg = this.withRequiredArg("name", "server.commands.triggervolume.clone.name.desc", ArgTypes.STRING);
private final OptionalArg<String> newNameArg = this.withOptionalArg("newName", "server.commands.triggervolume.clone.newName.desc", ArgTypes.STRING);
private final OptionalArg<String> updateTagsArg = this.withOptionalArg("updateTags", "server.commands.triggervolume.clone.updateTags.desc", ArgTypes.STRING);
public TriggerVolumeCloneCommand() {
super("clone", "server.commands.triggervolume.clone.desc");
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull World world, @Nonnull Store<EntityStore> store) {
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
if (tvPlugin == null) {
return;
}
TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType());
if (manager == null) {
return;
}
String sourceName = nameArg.get(context);
VolumeEntry source = manager.getVolume(sourceName);
if (source == null) {
context.sendMessage(Message.translation("server.commands.triggervolume.clone.notFound").param("name", sourceName));
return;
}
Ref<EntityStore> playerRef = context.senderAsPlayerRef();
if (playerRef == null || !playerRef.isValid()) {
return;
}
String requestedNewName = newNameArg.get(context);
String newId;
if (requestedNewName != null && !requestedNewName.isBlank()) {
if (manager.hasVolume(requestedNewName)) {
context.sendMessage(Message.translation("server.commands.triggervolume.create.alreadyExists").param("name", requestedNewName));
return;
}
newId = requestedNewName;
} else {
newId = TriggerVolumeDuplicateSystem.generateCopyId(sourceName, manager);
}
VolumeEntry copy = TriggerVolumeDuplicateSystem.duplicateEntry(source, newId);
TransformComponent transform = store.getComponent(playerRef, TransformComponent.getComponentType());
if (transform != null) {
copy.setPosition(new Vector3d(transform.getPosition()));
} else {
copy.setPosition(new Vector3d(source.getPosition()));
}
String updateTagsRaw = updateTagsArg.get(context);
if (updateTagsRaw != null && !updateTagsRaw.isBlank()) {
applyTagUpdates(copy, updateTagsRaw);
}
manager.register(newId, copy);
manager.markSpatialDirty();
manager.notifyViewersAdd(copy);
PlayerRef playerRefComponent = store.getComponent(playerRef, PlayerRef.getComponentType());
if (playerRefComponent != null) {
manager.setPlayerSelection(playerRefComponent.getUuid(), newId);
}
context.sendMessage(Message.translation("server.commands.triggervolume.clone.success")
.param("source", sourceName)
.param("name", newId));
}
private static void applyTagUpdates(@Nonnull VolumeEntry entry, @Nonnull String raw) {
String stripped = raw.strip();
if (stripped.startsWith("[") && stripped.endsWith("]")) {
stripped = stripped.substring(1, stripped.length() - 1);
}
Map<String, String> tags = new HashMap<>(entry.getRawTags());
for (String pair : stripped.split(",")) {
int eq = pair.indexOf('=');
if (eq > 0) {
String key = pair.substring(0, eq).strip();
String value = pair.substring(eq + 1).strip();
if (!key.isEmpty()) {
tags.put(key, value);
}
}
}
entry.setTags(tags);
}
}
@@ -0,0 +1,211 @@
package net.kewwbec.minigames.command;
import com.hypixel.hytale.builtin.triggervolumes.EntityTargetType;
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerCondition;
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect;
import com.hypixel.hytale.builtin.triggervolumes.manager.GroupEntry;
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.command.system.CommandContext;
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractWorldCommand;
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem;
import org.joml.Vector3d;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class TriggerVolumeCloneGroupCommand extends AbstractWorldCommand {
private final RequiredArg<String> groupNameArg = this.withRequiredArg("groupName", "server.commands.triggervolume.clonegroup.groupName.desc", ArgTypes.STRING);
private final RequiredArg<String> newGroupNameArg = this.withRequiredArg("newGroupName", "server.commands.triggervolume.clonegroup.newGroupName.desc", ArgTypes.STRING);
private final RequiredArg<String> findArg = this.withRequiredArg("find", "server.commands.triggervolume.clonegroup.find.desc", ArgTypes.STRING);
private final RequiredArg<String> replaceArg = this.withRequiredArg("replace", "server.commands.triggervolume.clonegroup.replace.desc", ArgTypes.STRING);
private final OptionalArg<String> updateTagsArg = this.withOptionalArg("updateTags", "server.commands.triggervolume.clone.updateTags.desc", ArgTypes.STRING);
public TriggerVolumeCloneGroupCommand() {
super("clonegroup", "server.commands.triggervolume.clonegroup.desc");
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull World world, @Nonnull Store<EntityStore> store) {
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
if (tvPlugin == null) {
return;
}
TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType());
if (manager == null) {
return;
}
String sourceGroupName = groupNameArg.get(context);
String newGroupName = newGroupNameArg.get(context);
String find = findArg.get(context);
String replace = replaceArg.get(context);
GroupEntry sourceGroup = manager.getGroup(sourceGroupName);
if (sourceGroup == null) {
context.sendMessage(Message.translation("server.commands.triggervolume.clonegroup.notFound").param("name", sourceGroupName));
return;
}
if (manager.hasGroup(newGroupName)) {
context.sendMessage(Message.translation("server.commands.triggervolume.clonegroup.alreadyExists").param("name", newGroupName));
return;
}
List<VolumeEntry> members = manager.getGroupMembers(sourceGroupName);
if (members.isEmpty()) {
context.sendMessage(Message.translation("server.commands.triggervolume.clonegroup.empty").param("name", sourceGroupName));
return;
}
Ref<EntityStore> playerRef = context.senderAsPlayerRef();
if (playerRef == null || !playerRef.isValid()) {
return;
}
String updateTagsRaw = updateTagsArg.get(context);
Map<String, String> tagOverrides = parseTagOverrides(updateTagsRaw);
Vector3d newOrigin = new Vector3d(sourceGroup.getOrigin());
TransformComponent transform = store.getComponent(playerRef, TransformComponent.getComponentType());
if (transform != null) {
newOrigin.set(transform.getPosition());
}
Vector3d originDelta = new Vector3d(newOrigin).sub(sourceGroup.getOrigin());
GroupEntry newGroup = duplicateGroup(sourceGroup, newGroupName, world.getName(), newOrigin, tagOverrides);
manager.registerGroup(newGroupName, newGroup);
String worldName = world.getName().toLowerCase(java.util.Locale.ROOT);
List<String> clonedNames = new ArrayList<>();
for (VolumeEntry member : members) {
String newVolumeName = renameVolume(member.getId(), find, replace);
if (manager.hasVolume(newVolumeName)) {
newVolumeName = resolveConflict(newVolumeName, manager);
}
VolumeEntry copy = TriggerVolumeDuplicateSystem.duplicateEntry(member, newVolumeName);
Vector3d relativeOffset = new Vector3d(member.getPosition()).sub(sourceGroup.getOrigin());
copy.setPosition(new Vector3d(newOrigin).add(relativeOffset));
copy.setGroupId(newGroupName);
if (!tagOverrides.isEmpty()) {
Map<String, String> tags = new HashMap<>(copy.getRawTags());
tags.putAll(tagOverrides);
copy.setTags(tags);
}
manager.register(newVolumeName, copy);
newGroup.addMember(newVolumeName);
manager.notifyViewersAdd(copy);
clonedNames.add(newVolumeName);
}
manager.markSpatialDirty();
PlayerRef playerRefComponent = store.getComponent(playerRef, PlayerRef.getComponentType());
if (playerRefComponent != null && !clonedNames.isEmpty()) {
manager.setPlayerSelection(playerRefComponent.getUuid(), clonedNames.get(0));
}
context.sendMessage(Message.translation("server.commands.triggervolume.clonegroup.success")
.param("source", sourceGroupName)
.param("name", newGroupName)
.param("count", String.valueOf(clonedNames.size())));
}
@Nonnull
private static String renameVolume(@Nonnull String originalName, @Nonnull String find, @Nonnull String replace) {
if (find.isEmpty() || !originalName.contains(find)) {
return originalName;
}
return originalName.replace(find, replace);
}
@Nonnull
private static String resolveConflict(@Nonnull String base, @Nonnull TriggerVolumeManager manager) {
for (int n = 2; n < 1000; n++) {
String candidate = base + n;
if (!manager.hasVolume(candidate)) {
return candidate;
}
}
return TriggerVolumeDuplicateSystem.generateCopyId(base, manager);
}
@Nonnull
private static GroupEntry duplicateGroup(
@Nonnull GroupEntry source,
@Nonnull String newId,
@Nonnull String worldName,
@Nonnull Vector3d newOrigin,
@Nonnull Map<String, String> tagOverrides
) {
EnumSet<EntityTargetType> targetTypes = source.getTargetTypes().isEmpty()
? EnumSet.of(EntityTargetType.PLAYER)
: EnumSet.copyOf(source.getTargetTypes());
GroupEntry entry = new GroupEntry(
newId,
worldName.toLowerCase(java.util.Locale.ROOT),
new Vector3d(newOrigin),
TriggerEffect.deepCopyList(source.getEffects()),
targetTypes,
source.isEnabled(),
source.getColor()
);
entry.getConditions().addAll(TriggerCondition.deepCopyList(source.getConditions()));
entry.getRejectionEffects().addAll(TriggerEffect.deepCopyList(source.getRejectionEffects()));
entry.setConditionTiming(source.getConditionTiming());
entry.setRejectionDelayMode(source.getRejectionDelayMode());
Map<String, String> tags = new HashMap<>(source.getRawTags());
tags.putAll(tagOverrides);
if (!tags.isEmpty()) {
entry.setTags(tags);
}
return entry;
}
@Nonnull
private static Map<String, String> parseTagOverrides(@Nullable String raw) {
Map<String, String> result = new HashMap<>();
if (raw == null || raw.isBlank()) {
return result;
}
String stripped = raw.strip();
if (stripped.startsWith("[") && stripped.endsWith("]")) {
stripped = stripped.substring(1, stripped.length() - 1);
}
for (String pair : stripped.split(",")) {
int eq = pair.indexOf('=');
if (eq > 0) {
String key = pair.substring(0, eq).strip();
String value = pair.substring(eq + 1).strip();
if (!key.isEmpty()) {
result.put(key, value);
}
}
}
return result;
}
}
@@ -17,7 +17,7 @@ public final class Enums {
}
public enum WinCondition {
HIGHEST_SCORE, LOWEST_SCORE, FIRST_TO_SCORE, LAST_ALIVE, OBJECTIVE_COMPLETE, CUSTOM
HIGHEST_SCORE, LOWEST_SCORE, FIRST_TO_SCORE, LAST_ALIVE, OBJECTIVE_COMPLETE, LOWEST_TIME, CUSTOM
}
public enum MapSelectionMode {
@@ -264,6 +264,62 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
(def, parent) -> def.rewards = parent.rewards
)
.add()
.<Boolean>appendInherited(
new KeyedCodec<>("ShowHud", Codec.BOOLEAN),
(def, v) -> def.showHud = v,
def -> def.showHud,
(def, parent) -> def.showHud = parent.showHud
)
.add()
.<Boolean>appendInherited(
new KeyedCodec<>("ShowScore", Codec.BOOLEAN),
(def, v) -> def.showScore = v,
def -> def.showScore,
(def, parent) -> def.showScore = parent.showScore
)
.add()
.<Boolean>appendInherited(
new KeyedCodec<>("ShowLives", Codec.BOOLEAN),
(def, v) -> def.showLives = v,
def -> def.showLives,
(def, parent) -> def.showLives = parent.showLives
)
.add()
.<Boolean>appendInherited(
new KeyedCodec<>("ShowPosition", Codec.BOOLEAN),
(def, v) -> def.showPosition = v,
def -> def.showPosition,
(def, parent) -> def.showPosition = parent.showPosition
)
.add()
.<Boolean>appendInherited(
new KeyedCodec<>("ShowRound", Codec.BOOLEAN),
(def, v) -> def.showRound = v,
def -> def.showRound,
(def, parent) -> def.showRound = parent.showRound
)
.add()
.<Boolean>appendInherited(
new KeyedCodec<>("ShowTime", Codec.BOOLEAN),
(def, v) -> def.showTime = v,
def -> def.showTime,
(def, parent) -> def.showTime = parent.showTime
)
.add()
.<Boolean>appendInherited(
new KeyedCodec<>("ShowScoreboard", Codec.BOOLEAN),
(def, v) -> def.showScoreboard = v,
def -> def.showScoreboard,
(def, parent) -> def.showScoreboard = parent.showScoreboard
)
.add()
.<Boolean>appendInherited(
new KeyedCodec<>("UIQueueable", Codec.BOOLEAN),
(def, v) -> def.uiQueueable = v,
def -> def.uiQueueable,
(def, parent) -> def.uiQueueable = parent.uiQueueable
)
.add()
.build();
public static final ValidatorCache<String> VALIDATOR_CACHE = new ValidatorCache<>(new AssetKeyValidator<>(MinigameDefinition::getAssetStore));
@@ -313,6 +369,14 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
protected ItemStackConfig[] startItems = new ItemStackConfig[0];
protected LoadoutConfig[] startLoadouts = new LoadoutConfig[0];
protected RewardConfig[] rewards = new RewardConfig[0];
protected boolean showHud = false;
protected boolean showScore = true;
protected boolean showLives = true;
protected boolean showPosition = false;
protected boolean showRound = true;
protected boolean showTime = true;
protected boolean showScoreboard = false;
protected boolean uiQueueable = false;
protected MinigameDefinition() {}
@@ -374,6 +438,14 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
public ItemStackConfig[] getStartItems() { return startItems != null ? startItems : new ItemStackConfig[0]; }
public LoadoutConfig[] getStartLoadouts() { return startLoadouts != null ? startLoadouts : new LoadoutConfig[0]; }
public RewardConfig[] getRewards() { return rewards != null ? rewards : new RewardConfig[0]; }
public boolean isShowHud() { return showHud; }
public boolean isShowScore() { return showScore; }
public boolean isShowLives() { return showLives; }
public boolean isShowPosition() { return showPosition; }
public boolean isShowRound() { return showRound; }
public boolean isShowTime() { return showTime; }
public boolean isShowScoreboard() { return showScoreboard; }
public boolean isUIQueueable() { return uiQueueable; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public void setName(String name) { this.name = name; }
@@ -7,10 +7,14 @@ import net.kewwbec.minigames.adapter.HytaleAdapters;
import net.kewwbec.minigames.model.ItemStackConfig;
import net.kewwbec.minigames.model.LoadoutConfig;
import net.kewwbec.minigames.model.MinigameDefinition;
import net.kewwbec.minigames.service.MinigameQueueService;
import net.kewwbec.minigames.ui.MinigameHudService;
import net.kewwbec.minigames.ui.MinigameMenuService;
import net.kewwbec.minigames.model.MinigameMapCandidate;
import net.kewwbec.minigames.model.PlayerMinigameState;
import net.kewwbec.minigames.model.RewardConfig;
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect;
import javax.annotation.Nullable;
import java.util.Collection;
@@ -33,16 +37,34 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
@Nullable
private MinigameVolumeSignalService signalService;
@Nullable
private MinigameQueueService queueService;
@Nullable
private HytaleAdapters.PlayerAdapter playerAdapter;
@Nullable
private HytaleAdapters.EntityAdapter entityAdapter;
@Nullable
private HytaleAdapters.InventoryAdapter inventoryAdapter;
@Nullable
private MinigameMenuService menuService;
@Nullable
private MinigameHudService hudService;
public void setMenuService(@Nullable MinigameMenuService menuService) {
this.menuService = menuService;
}
public void setHudService(@Nullable MinigameHudService hudService) {
this.hudService = hudService;
}
public void setSignalService(@Nullable MinigameVolumeSignalService signalService) {
this.signalService = signalService;
}
public void setQueueService(@Nullable MinigameQueueService queueService) {
this.queueService = queueService;
}
public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) {
this.playerAdapter = playerAdapter;
}
@@ -66,13 +88,109 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
var runtime = new MinigameRuntime(UUID.randomUUID().toString(), definition, selected.mapId(), selected.variantId());
runtime.phase(MinigamePhase.WAITING_FOR_PLAYERS);
runtimes.put(runtime.runtimeId(), runtime);
int countdownSecs = Math.max(0, definition.getCountdownSeconds());
long countdownEndMs = System.currentTimeMillis() + (long) countdownSecs * 1000L;
runtime.variables().put("$countdownEnd", countdownEndMs);
runtime.variables().put("$countdownNext", (long) countdownSecs);
if (signalService != null) {
signalService.onPhaseChange(runtime, MinigamePhase.WAITING_FOR_PLAYERS);
}
dispatch(new MinigameEvent("on_game_start", definition.getId(), eventPayload(runtime)));
return runtime;
}
@Override
public void tickPregame(long nowMs) {
for (MinigameRuntime runtime : List.copyOf(runtimes.values())) {
if (runtime.phase() != MinigamePhase.WAITING_FOR_PLAYERS) continue;
// Cancel if player count has dropped below minimum (leave pad or disconnect)
int present = countPresentPlayers(runtime);
if (present < runtime.definition().getMinPlayers()) {
cancelPregame(runtime, "player_count_too_low");
continue;
}
Object endObj = runtime.variables().get("$countdownEnd");
Object nextObj = runtime.variables().get("$countdownNext");
if (!(endObj instanceof Long) || !(nextObj instanceof Long)) continue;
long endMs = (Long) endObj;
long remainingMs = endMs - nowMs;
long remainingSecs = (remainingMs + 999L) / 1000L;
// Send a chat message once per remaining-second crossover
long nextTick = (Long) nextObj;
if (remainingSecs < nextTick) {
runtime.variables().put("$countdownNext", remainingSecs);
var payload = new HashMap<>(eventPayload(runtime));
payload.put("seconds_remaining", remainingSecs);
dispatch(new MinigameEvent("on_countdown_tick", runtime.minigameId(), Map.copyOf(payload)));
if (playerAdapter != null && remainingSecs > 0) {
Message msg = Message.translation("minigames.countdown.tick").param("seconds", remainingSecs);
for (String playerId : runtime.players().keySet()) {
playerAdapter.sendMessage(playerId, msg);
}
}
}
if (remainingMs <= 0) {
runtime.variables().remove("$countdownEnd");
runtime.variables().remove("$countdownNext");
advanceToStarting(runtime);
}
}
}
private void advanceToStarting(MinigameRuntime runtime) {
runtime.phase(MinigamePhase.STARTING);
if (signalService != null) {
signalService.onPhaseChange(runtime, MinigamePhase.STARTING);
}
dispatch(new MinigameEvent("on_game_starting", runtime.minigameId(), eventPayload(runtime)));
}
private void cancelPregame(MinigameRuntime runtime, String reason) {
LOGGER.atInfo().log("Cancelling pregame for minigame=%s reason=%s players=%d minRequired=%d",
runtime.minigameId(), reason, runtime.players().size(), runtime.definition().getMinPlayers());
if (playerAdapter != null) {
Message msg = Message.translation("minigames.countdown.cancelled");
for (String playerId : runtime.players().keySet()) {
playerAdapter.sendMessage(playerId, msg);
}
}
// Re-queue remaining players so they can start again
if (queueService != null) {
for (String playerId : runtime.players().keySet()) {
if (playerAdapter == null || playerAdapter.isOnline(playerId)) {
queueService.join(runtime.minigameId(), playerId);
}
}
}
var payload = new HashMap<>(eventPayload(runtime));
payload.put("reason", reason);
dispatch(new MinigameEvent("on_game_cancelled", runtime.minigameId(), Map.copyOf(payload)));
clearRuntimeState(runtime);
runtimes.remove(runtime.runtimeId());
}
private int countPresentPlayers(MinigameRuntime runtime) {
if (playerAdapter == null) return runtime.players().size();
int count = 0;
for (String playerId : runtime.players().keySet()) {
if (playerAdapter.isOnline(playerId)) count++;
}
return count;
}
@Override
public void end(String minigameId, String reason) {
for (MinigameRuntime runtime : matching(minigameId)) {
if (hudService != null) {
hudService.removeAllForRuntime(runtime.runtimeId());
}
runtime.phase(MinigamePhase.ENDING);
if (signalService != null) {
signalService.onPhaseChange(runtime, MinigamePhase.ENDING);
@@ -89,6 +207,10 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
clearRuntimeState(runtime);
runtimes.remove(runtime.runtimeId());
if (runtime.definition().isResetOnEnd()) {
if (signalService != null) {
runtime.phase(MinigamePhase.RESETTING);
signalService.onPhaseChange(runtime, MinigamePhase.RESETTING);
}
dispatch(new MinigameEvent("on_arena_reset", runtime.minigameId(), eventPayload(runtime)));
}
}
@@ -124,8 +246,9 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
LoadoutConfig loadout = resolveLoadout(runtime, playerState);
if (loadout != null) {
if (loadout.isPlayerCustomizable()) {
// TODO: show loadout selection UI and apply player's chosen loadout
LOGGER.atInfo().log("Player-customizable loadout UI not yet implemented: player=%s loadout=%s", playerId, loadout.getId());
if (menuService != null) {
menuService.requestLoadoutSelection(playerId, runtime);
}
} else {
for (ItemStackConfig item : loadout.getItems()) {
if (item != null) {
@@ -142,6 +265,11 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
}
playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
}
LOGGER.atInfo().log("[MinigameHUD] onPlayerActivated: player=%s minigame=%s showHud=%s hudService=%s",
playerId, runtime.minigameId(), def.isShowHud(), hudService != null ? "set" : "NULL");
if (def.isShowHud() && hudService != null) {
hudService.requestHudShow(playerId, runtime);
}
}
@Override
@@ -240,6 +368,10 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
var donePayload = new HashMap<>(eventPayload(runtime));
donePayload.put("final_round", current);
dispatch(new MinigameEvent("on_game_rounds_complete", runtime.minigameId(), Map.copyOf(donePayload)));
if (!runtime.definition().isOvertimeEnabled() && !runtime.definition().isSuddenDeathEnabled()) {
end(runtime.minigameId(), "rounds_complete");
}
} else {
int next = current + 1;
runtime.roundNumber(next);
@@ -314,24 +446,25 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
private void sendEndScores(MinigameRuntime runtime) {
if (playerAdapter == null || runtime.players().isEmpty()) return;
boolean lowestWins = runtime.definition().getWinCondition() == WinCondition.LOWEST_SCORE;
var scoreOrder = Comparator.comparingInt((PlayerMinigameState player) -> player.score());
var ranked = runtime.players().entrySet().stream()
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
lowestWins ? scoreOrder : scoreOrder.reversed()))
.toList();
boolean lowestTime = runtime.definition().getWinCondition() == WinCondition.LOWEST_TIME;
List<String> ranked = buildRankedPlayers(runtime);
var recipients = runtime.players().keySet();
for (String recipient : recipients) {
playerAdapter.sendMessage(recipient, Message.translation("minigames.endscores.header"));
int rank = 1;
for (var entry : ranked) {
String name = playerAdapter.getDisplayName(entry.getKey());
for (String playerId : ranked) {
var state = runtime.players().get(playerId);
if (state == null) continue;
String name = playerAdapter.getDisplayName(playerId);
String scoreText = lowestTime
? StopPlayerTimerEffect.formatPlayerTime(state.score())
: String.valueOf(state.score());
playerAdapter.sendMessage(recipient,
Message.translation("minigames.endscores.entry")
.param("rank", rank)
.param("player", name)
.param("score", entry.getValue().score()));
.param("score", scoreText));
rank++;
}
}
@@ -378,7 +511,21 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
}
private List<String> buildRankedPlayers(MinigameRuntime runtime) {
boolean lowestWins = runtime.definition().getWinCondition() == WinCondition.LOWEST_SCORE;
WinCondition wc = runtime.definition().getWinCondition();
if (wc == WinCondition.LOWEST_TIME) {
return runtime.players().entrySet().stream()
.sorted((a, b) -> {
int sa = a.getValue().score();
int sb = b.getValue().score();
if (sa == 0 && sb == 0) return 0;
if (sa == 0) return 1;
if (sb == 0) return -1;
return Integer.compare(sa, sb);
})
.map(Map.Entry::getKey)
.toList();
}
boolean lowestWins = wc == WinCondition.LOWEST_SCORE;
var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score());
return runtime.players().entrySet().stream()
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
@@ -20,4 +20,5 @@ public interface MinigameRuntimeService {
Collection<MinigameRuntime> runtimes();
void shutdownAll(String reason);
void dispatch(MinigameRuntime runtime, String eventId, Map<String, Object> payload);
void tickPregame(long nowMs);
}
@@ -29,6 +29,10 @@ public final class MinigameMapDiscoveryService {
public static final String TAG_MAP_ROLE = "MapRole";
public static final String TAG_SPAWN_ROLE = "SpawnRole";
public static final String TAG_TEAM_ID = "TeamId";
public static final String TAG_UI_QUEUE = "UIQueue";
public static final String TAG_UI_QUEUE_TYPE = "UIQueueType";
public static final String TAG_MAP_VOTEABLE = "MapVoteable";
public static final String TAG_VARIANT_VOTABLE = "VariantVotable";
public static final String ROLE_MAIN_ARENA = "MainArena";
public static final String ROLE_TEAM_SPAWN = "TeamSpawn";
@@ -86,7 +86,20 @@ public final class MinigameQueueService {
.findFirst();
if (winningMapId.isPresent()) {
List<MinigameMapCandidate> voted = available.stream().filter(candidate -> candidate.mapId().equals(winningMapId.get())).toList();
String winning = winningMapId.get();
int colon = winning.indexOf(':');
String winMap = colon >= 0 ? winning.substring(0, colon) : winning;
String winVariant = colon >= 0 ? winning.substring(colon + 1) : null;
// Try exact map+variant match first
if (winVariant != null) {
final String fv = winVariant;
List<MinigameMapCandidate> exact = available.stream()
.filter(c -> c.mapId().equals(winMap) && fv.equals(c.variantId()))
.toList();
if (!exact.isEmpty()) return exact.get(random.nextInt(exact.size()));
}
// Fall back to map-only match
List<MinigameMapCandidate> voted = available.stream().filter(c -> c.mapId().equals(winMap)).toList();
if (!voted.isEmpty()) {
return voted.get(random.nextInt(voted.size()));
}
@@ -0,0 +1,27 @@
package net.kewwbec.minigames.system;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.system.tick.TickingSystem;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.logger.HytaleLogger;
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
public final class MinigamePregameSystem extends TickingSystem<EntityStore> {
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
private final MinigameRuntimeService runtimeService;
public MinigamePregameSystem(MinigameRuntimeService runtimeService) {
this.runtimeService = runtimeService;
}
@Override
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
try {
runtimeService.tickPregame(System.currentTimeMillis());
} catch (Throwable e) {
LOGGER.atWarning().log("Pregame tick failed: %s: %s",
e.getClass().getSimpleName(), e.getMessage());
}
}
}
@@ -0,0 +1,27 @@
package net.kewwbec.minigames.system;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.system.tick.TickingSystem;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.logger.HytaleLogger;
import net.kewwbec.minigames.ui.MinigameQueueUIService;
public final class MinigameQueueUISystem extends TickingSystem<EntityStore> {
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
private final MinigameQueueUIService service;
public MinigameQueueUISystem(MinigameQueueUIService service) {
this.service = service;
}
@Override
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
try {
service.openQueued(store);
} catch (Throwable e) {
LOGGER.atWarning().log("Queue UI tick failed: %s: %s",
e.getClass().getSimpleName(), e.getMessage());
}
}
}
@@ -0,0 +1,216 @@
package net.kewwbec.minigames.system;
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerCondition;
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect;
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.logger.HytaleLogger;
import com.hypixel.hytale.protocol.GameMode;
import com.hypixel.hytale.protocol.InteractionType;
import com.hypixel.hytale.protocol.Packet;
import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChain;
import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChains;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
import com.hypixel.hytale.server.core.inventory.ItemStack;
import com.hypixel.hytale.server.core.io.adapter.PacketAdapters;
import com.hypixel.hytale.server.core.io.adapter.PacketFilter;
import com.hypixel.hytale.server.core.io.adapter.PlayerPacketFilter;
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import org.joml.Vector3d;
import org.joml.Vector3f;
import javax.annotation.Nonnull;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.concurrent.ThreadLocalRandom;
public final class TriggerVolumeDuplicateSystem {
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
private static final String EDITOR_TOOL_ITEM_ID = "EditorTool_TriggerVolume";
private static final String ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
@Nonnull
public PacketFilter register() {
return PacketAdapters.registerInbound((PlayerPacketFilter) this::onPickPacket);
}
private boolean onPickPacket(@Nonnull PlayerRef playerRef, @Nonnull Packet packet) {
if (!(packet instanceof SyncInteractionChains chains)) {
return false;
}
boolean hasPick = false;
for (SyncInteractionChain chain : chains.updates) {
if (chain.interactionType == InteractionType.Pick) {
hasPick = true;
break;
}
}
if (!hasPick) {
return false;
}
LOGGER.atInfo().log("[TriggerVolumeDuplicate] Pick packet from player %s", playerRef.getUuid());
Ref<EntityStore> entityRef = playerRef.getReference();
if (entityRef == null || !entityRef.isValid()) {
LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: entity ref null/invalid");
return false;
}
World world = entityRef.getStore().getExternalData().getWorld();
if (world == null) {
LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: world null");
return false;
}
world.execute(() -> handlePickOnWorldThread(playerRef, entityRef));
return false;
}
private void handlePickOnWorldThread(@Nonnull PlayerRef playerRef, @Nonnull Ref<EntityStore> entityRef) {
if (!entityRef.isValid()) {
return;
}
Store<EntityStore> store = entityRef.getStore();
Player player = store.getComponent(entityRef, Player.getComponentType());
if (player == null) {
LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: no Player component");
return;
}
if (player.getGameMode() != GameMode.Creative) {
LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: gameMode=%s (need Creative)", player.getGameMode());
return;
}
ItemStack heldItem = InventoryComponent.getItemInHand(store, entityRef);
String heldItemId = heldItem != null ? heldItem.getItemId() : null;
if (!EDITOR_TOOL_ITEM_ID.equals(heldItemId)) {
LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: heldItem=%s (need %s)", heldItemId, EDITOR_TOOL_ITEM_ID);
return;
}
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
if (tvPlugin == null) {
LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: TriggerVolumesPlugin not available");
return;
}
TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType());
if (manager == null) {
LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: TriggerVolumeManager not found in store");
return;
}
String selectedId = manager.getPlayerSelection(playerRef.getUuid());
if (selectedId == null || selectedId.isBlank()) {
LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: no volume selected for player %s", playerRef.getUuid());
return;
}
VolumeEntry source = manager.getVolume(selectedId);
if (source == null) {
LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: selected volume '%s' not found in manager", selectedId);
return;
}
String newId = generateCopyId(selectedId, manager);
VolumeEntry copy = duplicateEntry(source, newId);
TransformComponent playerTransform = store.getComponent(entityRef, TransformComponent.getComponentType());
if (playerTransform != null) {
Vector3d volumePos = source.getPosition();
Vector3d playerPos = playerTransform.getPosition();
double dx = playerPos.x() - volumePos.x();
double dz = playerPos.z() - volumePos.z();
double len = Math.sqrt(dx * dx + dz * dz);
if (len > 0.001) {
dx /= len;
dz /= len;
} else {
dx = 1.0;
dz = 0.0;
}
copy.setPosition(new Vector3d(volumePos.x() + dx, volumePos.y(), volumePos.z() + dz));
}
manager.register(newId, copy);
manager.markSpatialDirty();
manager.notifyViewersAdd(copy);
manager.setPlayerSelection(playerRef.getUuid(), newId);
LOGGER.atInfo().log("[TriggerVolumeDuplicate] Duplicated '%s' -> '%s' at %s", selectedId, newId, copy.getPosition());
}
@Nonnull
public static String generateCopyId(@Nonnull String sourceId, @Nonnull TriggerVolumeManager manager) {
String base = truncate(sourceId + "_Copy", 64);
if (!manager.hasVolume(base)) {
return base;
}
for (int n = 2; n < 1000; n++) {
String candidate = truncate(base + n, 64);
if (!manager.hasVolume(candidate)) {
return candidate;
}
}
return generateRandomId(manager);
}
@Nonnull
private static String truncate(@Nonnull String s, int max) {
return s.length() <= max ? s : s.substring(0, max);
}
@Nonnull
private static String generateRandomId(@Nonnull TriggerVolumeManager manager) {
ThreadLocalRandom rng = ThreadLocalRandom.current();
String id;
do {
StringBuilder sb = new StringBuilder("tv_");
for (int i = 0; i < 6; i++) {
sb.append(ID_CHARS.charAt(rng.nextInt(ID_CHARS.length())));
}
id = sb.toString();
} while (manager.hasVolume(id));
return id;
}
@Nonnull
public static VolumeEntry duplicateEntry(@Nonnull VolumeEntry source, @Nonnull String newId) {
VolumeEntry entry = new VolumeEntry(
newId,
source.getWorldName(),
new Vector3d(source.getPosition()),
source.getShape().copy(),
TriggerEffect.deepCopyList(source.getEffects()),
EnumSet.copyOf(source.getTargetTypes()),
source.isEnabled()
);
entry.getConditions().addAll(TriggerCondition.deepCopyList(source.getConditions()));
entry.getRejectionEffects().addAll(TriggerEffect.deepCopyList(source.getRejectionEffects()));
entry.setConditionTiming(source.getConditionTiming());
entry.setRejectionDelayMode(source.getRejectionDelayMode());
entry.setProjectileSource(source.getProjectileSource());
entry.setEffectAssetRef(source.getEffectAssetRef());
entry.setKeepLoaded(source.isKeepLoaded());
entry.setActivationDelay(source.getActivationDelay());
entry.setCancelDelayedEffectsOnExit(source.isCancelDelayedEffectsOnExit());
entry.setCooldown(source.getCooldown());
entry.setCooldownMode(source.getCooldownMode());
Vector3f color = source.getColor();
if (color != null) {
entry.setColor(new Vector3f(color));
}
if (!source.getRawTags().isEmpty()) {
entry.setTags(new HashMap<>(source.getRawTags()));
}
return entry;
}
}
@@ -0,0 +1,463 @@
package net.kewwbec.minigames.ui;
import au.ellie.hyui.builders.Alignment;
import au.ellie.hyui.builders.GroupBuilder;
import au.ellie.hyui.builders.HudBuilder;
import au.ellie.hyui.builders.HyUIAnchor;
import au.ellie.hyui.builders.HyUIPadding;
import au.ellie.hyui.builders.HyUIPatchStyle;
import au.ellie.hyui.builders.HyUIStyle;
import au.ellie.hyui.builders.HyUIHud;
import au.ellie.hyui.builders.LabelBuilder;
import net.kewwbec.minigames.adapter.HytaleAdapters;
import net.kewwbec.minigames.model.MinigameDefinition;
import net.kewwbec.minigames.model.PlayerMinigameState;
import net.kewwbec.minigames.runtime.MinigameRuntime;
import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.logger.HytaleLogger;
import javax.annotation.Nullable;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static net.kewwbec.minigames.model.Enums.WinCondition;
public final class MinigameHudService {
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
private static final long OPEN_REQUEST_TTL_MILLIS = 10_000;
private static final int HUD_WIDTH = 192;
private static final int SB_MAX_ROWS = 10;
private static final int SB_ROW_HEIGHT = 16;
private static final int SB_SCORE_WIDTH = 44;
private final ConcurrentHashMap<String, HudEntry> activeHuds = new ConcurrentHashMap<>();
private final ConcurrentHashMap<UUID, PendingHudOpen> pendingOpens = new ConcurrentHashMap<>();
@Nullable
private HytaleAdapters.PlayerAdapter playerAdapter;
public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) {
this.playerAdapter = playerAdapter;
}
public void requestHudShow(String playerId, MinigameRuntime runtime) {
UUID uuid;
try {
uuid = UUID.fromString(playerId);
} catch (IllegalArgumentException e) {
LOGGER.atWarning().log("[MinigameHUD] requestHudShow: playerId is not a UUID: '%s'", playerId);
return;
}
LOGGER.atInfo().log("[MinigameHUD] requestHudShow: queued uuid=%s minigame=%s", uuid, runtime.minigameId());
pendingOpens.put(uuid, new PendingHudOpen(uuid, playerId, runtime, System.currentTimeMillis()));
}
public void openQueued(Store<EntityStore> store) {
long now = System.currentTimeMillis();
int pendingCount = pendingOpens.size();
if (pendingCount > 0) {
LOGGER.atInfo().log("[MinigameHUD] openQueued: processing %d pending HUD open(s)", pendingCount);
}
for (PendingHudOpen pending : pendingOpens.values()) {
if (now - pending.createdAtMillis() > OPEN_REQUEST_TTL_MILLIS) {
LOGGER.atWarning().log("[MinigameHUD] openQueued: TTL expired for uuid=%s, dropping",
pending.playerUuid());
pendingOpens.remove(pending.playerUuid(), pending);
continue;
}
Ref<EntityStore> ref = store.getExternalData().getRefFromUUID(pending.playerUuid());
if (ref == null) {
LOGGER.atInfo().log("[MinigameHUD] openQueued: ref not found yet for uuid=%s (will retry)",
pending.playerUuid());
continue;
}
if (!ref.isValid()) {
LOGGER.atWarning().log("[MinigameHUD] openQueued: ref invalid for uuid=%s", pending.playerUuid());
continue;
}
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
if (player == null) {
LOGGER.atWarning().log("[MinigameHUD] openQueued: PlayerRef component null for uuid=%s",
pending.playerUuid());
continue;
}
if (pendingOpens.remove(pending.playerUuid(), pending)) {
LOGGER.atInfo().log("[MinigameHUD] openQueued: opening HUD for uuid=%s", pending.playerUuid());
showHud(player, pending.playerId(), pending.runtime());
}
}
}
public void refreshAll() {
for (Map.Entry<String, HudEntry> entry : activeHuds.entrySet()) {
String playerId = entry.getKey();
HudEntry hudEntry = entry.getValue();
var runtime = hudEntry.runtime();
if (!runtime.players().containsKey(playerId)) {
activeHuds.remove(playerId, hudEntry);
try {
hudEntry.hud().remove();
} catch (Throwable ignored) {
}
continue;
}
try {
updateValues(hudEntry.content(), playerId, runtime);
hudEntry.hud().refreshOrRerender(false, false);
} catch (Throwable e) {
LOGGER.atWarning().log("HUD refresh failed for player=%s: %s", playerId, e.getMessage());
}
}
}
public void removeAllForRuntime(String runtimeId) {
for (Map.Entry<String, HudEntry> entry : activeHuds.entrySet()) {
if (runtimeId.equals(entry.getValue().runtimeId())) {
activeHuds.remove(entry.getKey(), entry.getValue());
try {
entry.getValue().hud().remove();
} catch (Throwable ignored) {
}
}
}
pendingOpens.values().removeIf(p -> runtimeId.equals(p.runtime().runtimeId()));
}
private void showHud(PlayerRef player, String playerId, MinigameRuntime runtime) {
HudEntry existing = activeHuds.get(playerId);
if (existing != null) {
try {
existing.hud().remove();
} catch (Throwable ignored) {
}
}
try {
HudContent content = buildHudContent(runtime.definition());
updateValues(content, playerId, runtime);
HyUIHud hud = HudBuilder.hudForPlayer(player)
.addElement(content.root)
.show(player);
activeHuds.put(playerId, new HudEntry(hud, runtime.runtimeId(), runtime, content));
} catch (Throwable e) {
LOGGER.atWarning().log("Failed to show HUD for player=%s minigame=%s: %s",
playerId, runtime.minigameId(), e.getMessage());
}
}
// Builds the static HUD structure once — label builders are reused across
// refreshes. IDs assigned by HyUI's counter are fixed at creation time, so
// delta updates can reference the same selectors on subsequent refreshOrRerender calls.
private static HudContent buildHudContent(MinigameDefinition def) {
boolean showScore = def.isShowScore();
boolean showLives = def.isShowLives() && def.getDefaultPlayerLives() > 0;
boolean showPosition = def.isShowPosition();
boolean showRound = def.isShowRound() && def.getTotalRounds() > 1;
boolean showTime = def.isShowTime() && def.getRoundLengthSeconds() > 0;
int statCount = (showScore ? 1 : 0) + (showLives ? 1 : 0) + (showPosition ? 1 : 0)
+ (showRound ? 1 : 0) + (showTime ? 1 : 0);
if (statCount == 0)
statCount = 1;
int statWidth = (HUD_WIDTH - 8) / statCount;
GroupBuilder statsRow = GroupBuilder.group()
.withLayoutMode("Left")
.withAnchor(new HyUIAnchor().setWidth(HUD_WIDTH).setHeight(44));
LabelBuilder scoreLabel = null;
LabelBuilder livesLabel = null;
LabelBuilder posLabel = null;
LabelBuilder roundLabel = null;
LabelBuilder timeLabel = null;
if (showScore) {
scoreLabel = valueLabel(statWidth);
statsRow.addChild(statTile("SCORE", scoreLabel, statWidth));
}
if (showLives) {
livesLabel = valueLabel(statWidth);
statsRow.addChild(statTile("LIVES", livesLabel, statWidth));
}
if (showPosition) {
posLabel = valueLabel(statWidth);
statsRow.addChild(statTile("POS", posLabel, statWidth));
}
if (showRound) {
roundLabel = valueLabel(statWidth);
statsRow.addChild(statTile("ROUND", roundLabel, statWidth));
}
if (showTime) {
timeLabel = valueLabel(statWidth);
statsRow.addChild(statTile("TIME", timeLabel, statWidth));
}
// Scoreboard rows — built once, text mutated each refresh (stable IDs).
int sbRowCount = def.isShowScoreboard() ? Math.min(def.getMaxPlayers(), SB_MAX_ROWS) : 0;
LabelBuilder[] sbNameLabels = new LabelBuilder[sbRowCount];
LabelBuilder[] sbScoreLabels = new LabelBuilder[sbRowCount];
int sbSectionHeight = sbRowCount > 0 ? (4 + 16 + sbRowCount * SB_ROW_HEIGHT) : 0;
int rootHeight = 76 + sbSectionHeight;
GroupBuilder root = GroupBuilder.group()
.withLayoutMode("Top")
.withAnchor(new HyUIAnchor().setRight(8).setTop(8).setWidth(HUD_WIDTH).setHeight(rootHeight))
.withBackground(patch("#0c1520"))
.withOutlineColor("#1e3050")
.withOutlineSize(1.0f)
.withPadding(HyUIPadding.symmetric(4, 4))
.addChild(kicker(def.getName(), HUD_WIDTH - 8))
.addChild(spacer(HUD_WIDTH, 4))
.addChild(statsRow);
if (sbRowCount > 0) {
root.addChild(spacer(HUD_WIDTH, 4));
root.addChild(kicker("SCOREBOARD", HUD_WIDTH - 8));
for (int i = 0; i < sbRowCount; i++) {
LabelBuilder nameLabel = sbNameLabel();
LabelBuilder scoreCol = sbScoreLabel();
sbNameLabels[i] = nameLabel;
sbScoreLabels[i] = scoreCol;
root.addChild(GroupBuilder.group()
.withLayoutMode("Left")
.withAnchor(new HyUIAnchor().setWidth(HUD_WIDTH).setHeight(SB_ROW_HEIGHT))
.addChild(nameLabel)
.addChild(scoreCol));
}
}
return new HudContent(root, scoreLabel, livesLabel, posLabel, roundLabel, timeLabel,
sbNameLabels, sbScoreLabels);
}
// Mutates the label builders in place — withText() returns `this`, ID unchanged.
private void updateValues(HudContent content, String playerId, MinigameRuntime runtime) {
var playerState = runtime.players().get(playerId);
if (content.scoreLabel != null) {
int score = playerState != null ? playerState.score() : 0;
content.scoreLabel.withText(String.valueOf(score));
}
if (content.livesLabel != null) {
int lives = playerState != null ? playerState.lives() : 0;
content.livesLabel.withText(String.valueOf(lives));
}
if (content.posLabel != null) {
content.posLabel.withText(positionText(playerId, runtime));
}
if (content.roundLabel != null) {
content.roundLabel.withText(roundText(runtime));
}
if (content.timeLabel != null) {
content.timeLabel.withText(timeText(runtime));
}
if (content.sbNameLabels.length > 0) {
WinCondition wc = runtime.definition().getWinCondition();
boolean lowestTime = wc == WinCondition.LOWEST_TIME;
List<Map.Entry<String, PlayerMinigameState>> ranked;
if (lowestTime) {
ranked = runtime.players().entrySet().stream()
.filter(e -> !runtime.eliminatedPlayers().contains(e.getKey()))
.sorted((a, b) -> {
int sa = a.getValue().score();
int sb = b.getValue().score();
if (sa == 0 && sb == 0) return 0;
if (sa == 0) return 1;
if (sb == 0) return -1;
return Integer.compare(sa, sb);
})
.toList();
} else {
boolean lowestWins = wc == WinCondition.LOWEST_SCORE;
var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score());
ranked = runtime.players().entrySet().stream()
.filter(e -> !runtime.eliminatedPlayers().contains(e.getKey()))
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
lowestWins ? scoreOrder : scoreOrder.reversed()))
.toList();
}
for (int i = 0; i < content.sbNameLabels.length; i++) {
if (i < ranked.size()) {
String pid = ranked.get(i).getKey();
String name = playerAdapter != null ? playerAdapter.getDisplayName(pid) : shortId(pid);
int score = ranked.get(i).getValue().score();
String scoreText = lowestTime
? StopPlayerTimerEffect.formatPlayerTime(score)
: String.valueOf(score);
content.sbNameLabels[i].withText((i + 1) + ". " + name);
content.sbScoreLabels[i].withText(scoreText);
} else {
content.sbNameLabels[i].withText("");
content.sbScoreLabels[i].withText("");
}
}
}
}
private static String shortId(String playerId) {
return playerId.length() > 8 ? playerId.substring(0, 8) : playerId;
}
private static LabelBuilder valueLabel(int width) {
return LabelBuilder.label()
.withText("")
.withAnchor(new HyUIAnchor().setWidth(width - 8).setHeight(22))
.withStyle(new HyUIStyle().setFontSize(18f).setTextColor("#ffffff")
.setRenderBold(true).setWrap(false)
.setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(10f));
}
private static GroupBuilder statTile(String label, LabelBuilder valueLabel, int width) {
return GroupBuilder.group()
.withLayoutMode("Top")
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(44))
.withPadding(HyUIPadding.symmetric(2, 4))
.addChild(LabelBuilder.label()
.withText(label)
.withAnchor(new HyUIAnchor().setWidth(width - 8).setHeight(16))
.withStyle(new HyUIStyle().setFontSize(9f).setTextColor("#7cc7ff")
.setRenderBold(true).setRenderUppercase(true).setWrap(false)
.setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(7f)))
.addChild(valueLabel);
}
private static LabelBuilder sbNameLabel() {
return LabelBuilder.label()
.withText("")
.withAnchor(new HyUIAnchor().setWidth(HUD_WIDTH - 8 - SB_SCORE_WIDTH).setHeight(14))
.withStyle(new HyUIStyle().setFontSize(9f).setTextColor("#c8ddf0")
.setWrap(false).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(7f)
.setHorizontalAlignment(Alignment.Start));
}
private static LabelBuilder sbScoreLabel() {
return LabelBuilder.label()
.withText("")
.withAnchor(new HyUIAnchor().setWidth(SB_SCORE_WIDTH).setHeight(14))
.withStyle(new HyUIStyle().setFontSize(9f).setTextColor("#ffffff")
.setRenderBold(true).setWrap(false).setShrinkTextToFit(true)
.setMinShrinkTextToFitFontSize(7f)
.setHorizontalAlignment(Alignment.End));
}
private static LabelBuilder kicker(String text, int width) {
return LabelBuilder.label()
.withText(text)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(16))
.withStyle(new HyUIStyle().setFontSize(9f).setTextColor("#4a7aa8")
.setRenderBold(true).setRenderUppercase(true).setWrap(false)
.setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(7f));
}
private static LabelBuilder spacer(int width, int height) {
return LabelBuilder.label()
.withText(" ")
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
.withStyle(new HyUIStyle().setFontSize(1f).setTextColor("#0c1520"));
}
private static HyUIPatchStyle patch(String color) {
return new HyUIPatchStyle().setColor(color);
}
private static String roundText(MinigameRuntime runtime) {
int total = runtime.definition().getTotalRounds();
return runtime.roundNumber() + " / " + total;
}
private static String timeText(MinigameRuntime runtime) {
Long expiry = runtime.timers().get("$round");
long remainingMs = expiry != null ? expiry - System.currentTimeMillis() : 0;
if (remainingMs < 0)
remainingMs = 0;
long totalSeconds = remainingMs / 1000;
long minutes = totalSeconds / 60;
long seconds = totalSeconds % 60;
return minutes + ":" + String.format("%02d", seconds);
}
private static String positionText(String playerId, MinigameRuntime runtime) {
WinCondition wc = runtime.definition().getWinCondition();
List<String> ranked;
if (wc == WinCondition.LOWEST_TIME) {
ranked = runtime.players().entrySet().stream()
.filter(e -> !runtime.eliminatedPlayers().contains(e.getKey()))
.sorted((a, b) -> {
int sa = a.getValue().score();
int sb = b.getValue().score();
if (sa == 0 && sb == 0) return 0;
if (sa == 0) return 1;
if (sb == 0) return -1;
return Integer.compare(sa, sb);
})
.map(Map.Entry::getKey)
.toList();
} else {
boolean lowestWins = wc == WinCondition.LOWEST_SCORE;
var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score());
ranked = runtime.players().entrySet().stream()
.filter(e -> !runtime.eliminatedPlayers().contains(e.getKey()))
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
lowestWins ? scoreOrder : scoreOrder.reversed()))
.map(Map.Entry::getKey)
.toList();
}
int activeCount = ranked.size();
if (activeCount == 0)
return "-";
int pos = ranked.indexOf(playerId);
return (pos >= 0 ? pos + 1 : activeCount + 1) + " / " + activeCount;
}
private static class HudContent {
final GroupBuilder root;
@Nullable
final LabelBuilder scoreLabel;
@Nullable
final LabelBuilder livesLabel;
@Nullable
final LabelBuilder posLabel;
@Nullable
final LabelBuilder roundLabel;
@Nullable
final LabelBuilder timeLabel;
final LabelBuilder[] sbNameLabels;
final LabelBuilder[] sbScoreLabels;
HudContent(GroupBuilder root,
@Nullable LabelBuilder scoreLabel,
@Nullable LabelBuilder livesLabel,
@Nullable LabelBuilder posLabel,
@Nullable LabelBuilder roundLabel,
@Nullable LabelBuilder timeLabel,
LabelBuilder[] sbNameLabels,
LabelBuilder[] sbScoreLabels) {
this.root = root;
this.scoreLabel = scoreLabel;
this.livesLabel = livesLabel;
this.posLabel = posLabel;
this.roundLabel = roundLabel;
this.timeLabel = timeLabel;
this.sbNameLabels = sbNameLabels;
this.sbScoreLabels = sbScoreLabels;
}
}
private record HudEntry(HyUIHud hud, String runtimeId, MinigameRuntime runtime, HudContent content) {
}
private record PendingHudOpen(UUID playerUuid, String playerId, MinigameRuntime runtime, long createdAtMillis) {
}
}
@@ -0,0 +1,31 @@
package net.kewwbec.minigames.ui;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.system.tick.TickingSystem;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.logger.HytaleLogger;
public final class MinigameHudSystem extends TickingSystem<EntityStore> {
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
private static final int REFRESH_EVERY_TICKS = 20;
private final MinigameHudService hudService;
private int tickCount = 0;
public MinigameHudSystem(MinigameHudService hudService) {
this.hudService = hudService;
}
@Override
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
try {
hudService.openQueued(store);
if (tickCount++ % REFRESH_EVERY_TICKS == 0) {
hudService.refreshAll();
}
} catch (Throwable e) {
LOGGER.atWarning().log("HUD system tick failed: %s: %s",
e.getClass().getSimpleName(), e.getMessage());
}
}
}
@@ -0,0 +1,26 @@
package net.kewwbec.minigames.ui;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.system.tick.TickingSystem;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.logger.HytaleLogger;
public final class MinigameMenuOpenSystem extends TickingSystem<EntityStore> {
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
private final MinigameMenuService menuService;
public MinigameMenuOpenSystem(MinigameMenuService menuService) {
this.menuService = menuService;
}
@Override
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
try {
menuService.openQueued(store);
} catch (Throwable e) {
LOGGER.atWarning().log("Minigame menu open tick failed: %s: %s",
e.getClass().getSimpleName(), e.getMessage());
}
}
}
@@ -0,0 +1,209 @@
package net.kewwbec.minigames.ui;
import au.ellie.hyui.builders.ButtonBuilder;
import au.ellie.hyui.builders.ContainerBuilder;
import au.ellie.hyui.builders.GroupBuilder;
import au.ellie.hyui.builders.HyUIAnchor;
import au.ellie.hyui.builders.HyUIPadding;
import au.ellie.hyui.builders.HyUIPatchStyle;
import au.ellie.hyui.builders.HyUIStyle;
import au.ellie.hyui.builders.LabelBuilder;
import au.ellie.hyui.builders.PageBuilder;
import au.ellie.hyui.builders.UIElementBuilder;
import net.kewwbec.minigames.adapter.HytaleAdapters;
import net.kewwbec.minigames.model.ItemStackConfig;
import net.kewwbec.minigames.model.LoadoutConfig;
import net.kewwbec.minigames.runtime.MinigameRuntime;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.logger.HytaleLogger;
import javax.annotation.Nullable;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public final class MinigameMenuService {
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
private static final long OPEN_REQUEST_TTL_MILLIS = 10_000;
private final ConcurrentHashMap<UUID, PendingLoadoutOpen> pendingOpens = new ConcurrentHashMap<>();
@Nullable
private HytaleAdapters.PlayerAdapter playerAdapter;
public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) {
this.playerAdapter = playerAdapter;
}
public void requestLoadoutSelection(String playerId, MinigameRuntime runtime) {
UUID uuid;
try {
uuid = UUID.fromString(playerId);
} catch (IllegalArgumentException ignored) {
return;
}
pendingOpens.put(uuid, new PendingLoadoutOpen(uuid, playerId, runtime, System.currentTimeMillis()));
}
public void openQueued(Store<EntityStore> store) {
long now = System.currentTimeMillis();
for (PendingLoadoutOpen pending : pendingOpens.values()) {
if (now - pending.createdAtMillis() > OPEN_REQUEST_TTL_MILLIS) {
pendingOpens.remove(pending.playerUuid(), pending);
continue;
}
Ref<EntityStore> ref = store.getExternalData().getRefFromUUID(pending.playerUuid());
if (ref == null || !ref.isValid()) {
continue;
}
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
if (player == null) {
continue;
}
if (pendingOpens.remove(pending.playerUuid(), pending)) {
openLoadoutSelection(player, pending.playerId(), pending.runtime(), store);
}
}
}
private void openLoadoutSelection(PlayerRef player, String playerId, MinigameRuntime runtime, Store<EntityStore> store) {
LoadoutConfig[] loadouts = runtime.definition().getStartLoadouts();
if (loadouts == null || loadouts.length == 0) {
return;
}
try {
ContainerBuilder container = compactDecoratedContainer("Choose Loadout", 560, Math.min(80 + loadouts.length * 52, 480))
.withPadding(HyUIPadding.symmetric(18, 14))
.withLayoutMode("Top")
.withBackground(patch("#101722"))
.addContentChild(loadoutHeader(runtime.definition().getName()))
.addContentChild(loadoutList(playerId, runtime, loadouts));
PageBuilder.pageForPlayer(player)
.addElement(container)
.open(player, store);
} catch (Throwable e) {
LOGGER.atWarning().log("Failed to open loadout selection for player=%s minigame=%s: %s",
playerId, runtime.minigameId(), e.getMessage());
}
}
private static GroupBuilder loadoutHeader(String gameName) {
return GroupBuilder.group()
.withLayoutMode("Top")
.withAnchor(new HyUIAnchor().setWidth(510).setHeight(68))
.withPadding(HyUIPadding.symmetric(8, 6))
.addChild(kicker(gameName, 488))
.addChild(titleLabel("Select Your Loadout", 488))
.addChild(caption("Pick a loadout. It takes effect when you become active.", 488));
}
private GroupBuilder loadoutList(String playerId, MinigameRuntime runtime, LoadoutConfig[] loadouts) {
int height = loadouts.length * 52;
GroupBuilder group = GroupBuilder.group()
.withLayoutMode("Top")
.withAnchor(new HyUIAnchor().setWidth(510).setHeight(height));
for (LoadoutConfig loadout : loadouts) {
if (loadout == null) continue;
String display = loadout.getDisplayName() != null && !loadout.getDisplayName().isBlank()
? loadout.getDisplayName()
: loadout.getId();
int itemCount = loadout.getItems() != null ? loadout.getItems().length : 0;
String subtitle = itemCount == 1 ? "1 item" : itemCount + " items";
group.addChild(inset(
actionButton(display, subtitle, 462, () -> applyLoadout(playerId, runtime, loadout)),
510, 462, 42
));
group.addChild(spacer(510, 8));
}
return group;
}
private void applyLoadout(String playerId, MinigameRuntime runtime, LoadoutConfig loadout) {
var playerState = runtime.players().get(playerId);
if (playerState == null) {
return;
}
playerState.loadoutId(loadout.getId());
if (playerAdapter != null && loadout.getItems() != null) {
for (ItemStackConfig item : loadout.getItems()) {
if (item != null) {
playerAdapter.giveItem(playerId, item.getItemId(), item.getAmount());
}
}
}
}
// --- UI helpers ---
private static ContainerBuilder compactDecoratedContainer(String title, int width, int height) {
return ContainerBuilder.decoratedContainer()
.withTitleText(title)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
.editElementAfter((commands, selector) -> commands.setObject(
selector + " #Content.Anchor",
new HyUIAnchor().setTop(0).toHytaleAnchor()
));
}
private static ButtonBuilder actionButton(String text, String tooltip, int width, Runnable action) {
return ButtonBuilder.secondaryTextButton()
.withText(text)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(38))
.withTooltipText(tooltip)
.onClick(action);
}
private static GroupBuilder inset(UIElementBuilder<?> child, int outerWidth, int childWidth, int height) {
return GroupBuilder.group()
.withLayoutMode("Left")
.withAnchor(new HyUIAnchor().setWidth(outerWidth).setHeight(height))
.addChild(spacer(22, height))
.addChild(child.withAnchor(new HyUIAnchor().setWidth(childWidth).setHeight(height)));
}
private static LabelBuilder spacer(int width, int height) {
return LabelBuilder.label()
.withText(" ")
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
.withStyle(new HyUIStyle().setFontSize(1f).setTextColor("#101722"));
}
private static LabelBuilder kicker(String text, int width) {
return LabelBuilder.label()
.withText(text)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(18))
.withStyle(new HyUIStyle().setFontSize(11f).setTextColor("#7cc7ff").setRenderBold(true)
.setRenderUppercase(true).setWrap(false).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8f));
}
private static LabelBuilder titleLabel(String text, int width) {
return LabelBuilder.label()
.withText(text)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(32))
.withStyle(new HyUIStyle().setFontSize(22f).setTextColor("#ffffff").setRenderBold(true)
.setWrap(false).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(12f));
}
private static LabelBuilder caption(String text, int width) {
return LabelBuilder.label()
.withText(text)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(24))
.withStyle(new HyUIStyle().setFontSize(11f).setTextColor("#9eb4cf").setWrap(true)
.setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8f));
}
private static HyUIPatchStyle patch(String color) {
return new HyUIPatchStyle().setColor(color);
}
private record PendingLoadoutOpen(UUID playerUuid, String playerId, MinigameRuntime runtime, long createdAtMillis) {}
}
@@ -0,0 +1,680 @@
package net.kewwbec.minigames.ui;
import au.ellie.hyui.builders.ButtonBuilder;
import au.ellie.hyui.builders.ContainerBuilder;
import au.ellie.hyui.builders.GroupBuilder;
import au.ellie.hyui.builders.HyUIAnchor;
import au.ellie.hyui.builders.HyUIPadding;
import au.ellie.hyui.builders.HyUIPatchStyle;
import au.ellie.hyui.builders.HyUIStyle;
import au.ellie.hyui.builders.LabelBuilder;
import au.ellie.hyui.builders.PageBuilder;
import au.ellie.hyui.builders.UIElementBuilder;
import net.kewwbec.minigames.adapter.HytaleAdapters;
import net.kewwbec.minigames.model.MinigameDefinition;
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
import net.kewwbec.minigames.service.MinigameServices;
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.logger.HytaleLogger;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import static net.kewwbec.minigames.model.Enums.MapSelectionMode;
public final class MinigameQueueUIService {
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
private static final long TTL_MS = 10_000;
private static final int SECTION_INSET = 22;
private final MinigameServices services;
@Nullable
private HytaleAdapters.PlayerAdapter playerAdapter;
private final ConcurrentHashMap<UUID, PendingOpen> pendingOpens = new ConcurrentHashMap<>();
public enum UIQueueMode { GLOBAL, SINGLE, LOCAL, VOTE }
public MinigameQueueUIService(MinigameServices services) {
this.services = services;
}
public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) {
this.playerAdapter = playerAdapter;
}
public void requestOpen(PlayerRef player, UIQueueMode mode, String minigameId, String mapId) {
pendingOpens.put(player.getUuid(),
new PendingOpen(player.getUuid(), mode,
minigameId != null ? minigameId : "",
mapId != null ? mapId : "",
System.currentTimeMillis()));
}
public void openQueued(Store<EntityStore> store) {
long now = System.currentTimeMillis();
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
if (tvPlugin == null) return;
TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType());
if (manager == null) return;
for (PendingOpen pending : pendingOpens.values()) {
if (now - pending.createdAtMillis() > TTL_MS) {
pendingOpens.remove(pending.uuid(), pending);
continue;
}
Ref<EntityStore> ref = store.getExternalData().getRefFromUUID(pending.uuid());
if (ref == null || !ref.isValid()) continue;
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
if (player == null) continue;
if (pendingOpens.remove(pending.uuid(), pending)) {
try {
switch (pending.mode()) {
case GLOBAL -> openGlobal(player, store, manager);
case SINGLE -> openSingle(player, pending.minigameId(), false, store, manager);
case LOCAL -> openLocal(player, pending.minigameId(), pending.mapId(), store, manager);
case VOTE -> openVote(player, pending.minigameId(), store, manager);
}
} catch (Throwable e) {
LOGGER.atWarning().log("Queue UI open failed player=%s mode=%s: %s: %s",
player.getUuid(), pending.mode(), e.getClass().getSimpleName(), e.getMessage());
}
}
}
}
// ── GLOBAL ───────────────────────────────────────────────────────────────
private void openGlobal(PlayerRef player, Store<EntityStore> store, TriggerVolumeManager manager) {
String playerId = player.getUuid().toString();
sendDebugInfo(player, manager);
List<String> gameIds = scanArenas(manager, null, null).stream()
.map(ArenaEntry::minigameId)
.distinct()
.filter(id -> services.minigames().definition(id)
.map(MinigameDefinition::isUIQueueable)
.orElse(false))
.sorted()
.toList();
// Left: game list
GroupBuilder gameList = surface("GAME QUEUE", "Select a game to join.", 414, 368);
if (gameIds.isEmpty()) {
gameList.addChild(inset(caption("No games are currently available.", 352), 414, 352, 24));
} else {
for (String gameId : gameIds) {
var defOpt = services.minigames().definition(gameId);
if (defOpt.isEmpty()) continue;
var def = defOpt.get();
int queued = services.queue().players(gameId).size();
gameList.addChild(spacer(414, 6));
String label = def.getName() + " | " + queued + " queued";
String tooltip = def.getDescription().isBlank() ? def.getName() : def.getDescription();
gameList.addChild(inset(
ButtonBuilder.secondaryTextButton()
.withText(label)
.withAnchor(new HyUIAnchor().setWidth(352).setHeight(46))
.withTooltipText(tooltip)
.onClick((ignored, ui) -> openSingle(player, gameId, true, store, manager)),
414, 352, 46));
}
}
// Right: queue status
String activeGameId = findActiveQueueFor(playerId);
String statusSubtitle = activeGameId == null ? "Not in a queue." : "Currently queued.";
GroupBuilder statusPanel = surface("YOUR QUEUE", statusSubtitle, 384, 368);
if (activeGameId != null) {
var defOpt = services.minigames().definition(activeGameId);
String gameName = defOpt.map(MinigameDefinition::getName).orElse(activeGameId);
int queued = services.queue().players(activeGameId).size();
int needed = defOpt.map(d -> Math.max(0, d.getMinPlayers() - queued)).orElse(0);
String status = needed > 0 ? "Need " + needed + " more to start" : "Enough players — starting soon!";
statusPanel.addChild(spacer(384, 4));
statusPanel.addChild(inset(kicker(gameName, 322), 384, 322, 18));
statusPanel.addChild(inset(bodyLabel(queued + " queued • " + status, 322), 384, 322, 24));
statusPanel.addChild(spacer(384, 10));
final String finalActiveGameId = activeGameId;
statusPanel.addChild(inset(
dangerButton("Leave Queue", "Leave the " + gameName + " queue", 322, () -> {
leaveQueue(player, finalActiveGameId);
openGlobal(player, store, manager);
}),
384, 322, 38));
}
ContainerBuilder container = compactDecoratedContainer("Game Queue", 860, 480)
.withPadding(HyUIPadding.symmetric(18, 14))
.withLayoutMode("Top")
.withBackground(patch("#101722"))
.addContentChild(GroupBuilder.group()
.withLayoutMode("Left")
.withAnchor(new HyUIAnchor().setWidth(832).setHeight(398))
.addChild(gameList)
.addChild(spacer(16, 398))
.addChild(statusPanel));
PageBuilder.pageForPlayer(player).addElement(container).open(player, store);
}
// ── SINGLE ───────────────────────────────────────────────────────────────
private void openSingle(PlayerRef player, String minigameId, boolean showBack,
Store<EntityStore> store, TriggerVolumeManager manager) {
var defOpt = services.minigames().definition(minigameId);
if (defOpt.isEmpty()) return;
var def = defOpt.get();
String playerId = player.getUuid().toString();
int totalQueued = services.queue().players(minigameId).size();
boolean inQueue = services.queue().players(minigameId).contains(playerId);
List<String> mapIds = scanArenas(manager, minigameId, null).stream()
.map(ArenaEntry::mapId)
.filter(m -> !m.isBlank())
.distinct()
.sorted()
.toList();
// Calculate content height: header rows + back button + map buttons + join-any + leave
int rows = mapIds.isEmpty() ? 1 : mapIds.size();
int contentHeight = 66 // surface header (kicker + caption + spacers)
+ (showBack ? 50 : 0) // back button
+ rows * 53 // map buttons + spacers
+ (mapIds.size() > 1 ? 50 : 0) // join-any button
+ (inQueue ? 54 : 0); // leave queue button
contentHeight = Math.max(160, Math.min(420, contentHeight));
GroupBuilder mapPanel = surface(def.getName(),
def.getMinPlayers() + " - " + def.getMaxPlayers() + " players • " + totalQueued + " queued",
654, contentHeight);
if (showBack) {
mapPanel.addChild(inset(
actionButton("← All Games", "Back to game browser", 592,
() -> openGlobal(player, store, manager)),
654, 592, 38));
mapPanel.addChild(spacer(654, 6));
}
if (mapIds.isEmpty()) {
mapPanel.addChild(inset(caption("No maps found for this game.", 592), 654, 592, 24));
} else {
for (String mapId : mapIds) {
String label = prettify(mapId) + " | " + totalQueued + " queued";
mapPanel.addChild(spacer(654, 6));
mapPanel.addChild(inset(
ButtonBuilder.secondaryTextButton()
.withText(label)
.withAnchor(new HyUIAnchor().setWidth(592).setHeight(46))
.withTooltipText("Join queue — vote for " + prettify(mapId))
.onClick((ignored, ui) -> {
joinQueue(player, minigameId, mapId, store);
openSingle(player, minigameId, showBack, store, manager);
}),
654, 592, 46));
}
}
if (mapIds.size() > 1) {
mapPanel.addChild(spacer(654, 6));
mapPanel.addChild(inset(
actionButton("Join Any Map | " + totalQueued + " queued", "Join without a map preference", 592,
() -> {
joinQueue(player, minigameId, "", store);
openSingle(player, minigameId, showBack, store, manager);
}),
654, 592, 38));
}
if (inQueue) {
mapPanel.addChild(spacer(654, 10));
mapPanel.addChild(inset(
dangerButton("Leave Queue", "Leave the " + def.getName() + " queue", 592, () -> {
leaveQueue(player, minigameId);
openSingle(player, minigameId, showBack, store, manager);
}),
654, 592, 38));
}
int containerHeight = Math.max(280, Math.min(500, contentHeight + 82));
ContainerBuilder container = compactDecoratedContainer(def.getName(), 700, containerHeight)
.withPadding(HyUIPadding.symmetric(18, 14))
.withLayoutMode("Top")
.withBackground(patch("#101722"))
.addContentChild(mapPanel);
PageBuilder.pageForPlayer(player).addElement(container).open(player, store);
}
// ── LOCAL ─────────────────────────────────────────────────────────────────
private void openLocal(PlayerRef player, String minigameId, String mapId,
Store<EntityStore> store, TriggerVolumeManager manager) {
var defOpt = services.minigames().definition(minigameId);
if (defOpt.isEmpty()) return;
var def = defOpt.get();
String playerId = player.getUuid().toString();
int queued = services.queue().players(minigameId).size();
boolean inQueue = services.queue().players(minigameId).contains(playerId);
int needed = Math.max(0, def.getMinPlayers() - queued);
String mapDisplay = mapId.isBlank() ? "Arena" : prettify(mapId);
String statusText = needed > 0 ? "Need " + needed + " more" : "Ready to start!";
GroupBuilder arenaPanel = surface(def.getName(), mapDisplay, 554, 148);
arenaPanel.addChild(inset(
bodyLabel(def.getMinPlayers() + " - " + def.getMaxPlayers() + " players • "
+ queued + " queued • " + statusText, 492),
554, 492, 24));
arenaPanel.addChild(spacer(554, 10));
GroupBuilder buttons = GroupBuilder.group()
.withLayoutMode("Left")
.withAnchor(new HyUIAnchor().setWidth(554).setHeight(38))
.addChild(spacer(SECTION_INSET, 38));
if (!inQueue) {
buttons.addChild(ButtonBuilder.secondaryTextButton()
.withText("Join Queue")
.withAnchor(new HyUIAnchor().setWidth(238).setHeight(38))
.onClick((ignored, ui) -> {
joinQueue(player, minigameId, mapId, store);
openLocal(player, minigameId, mapId, store, manager);
}));
} else {
buttons.addChild(ButtonBuilder.cancelTextButton()
.withText("Leave Queue")
.withAnchor(new HyUIAnchor().setWidth(238).setHeight(38))
.onClick((ignored, ui) -> {
leaveQueue(player, minigameId);
openLocal(player, minigameId, mapId, store, manager);
}));
}
arenaPanel.addChild(buttons);
ContainerBuilder container = compactDecoratedContainer(def.getName(), 600, 240)
.withPadding(HyUIPadding.symmetric(18, 14))
.withLayoutMode("Top")
.withBackground(patch("#101722"))
.addContentChild(arenaPanel);
PageBuilder.pageForPlayer(player).addElement(container).open(player, store);
}
// ── VOTE ──────────────────────────────────────────────────────────────────
private void openVote(PlayerRef player, String minigameId, Store<EntityStore> store, TriggerVolumeManager manager) {
var defOpt = services.minigames().definition(minigameId);
if (defOpt.isEmpty()) return;
var def = defOpt.get();
String playerId = player.getUuid().toString();
int totalQueued = services.queue().players(minigameId).size();
Map<String, String> allVotes = services.queue().votes(minigameId);
String currentVote = allVotes.get(playerId);
Map<String, Long> voteCounts = allVotes.values().stream()
.collect(Collectors.groupingBy(v -> v, Collectors.counting()));
List<VoteMapEntry> voteableMaps = scanVoteableMaps(manager, minigameId);
int contentHeight = 66
+ voteableMaps.stream().mapToInt(m -> m.variants().isEmpty() ? 52 : 22 + m.variants().size() * 44).sum()
+ 10;
contentHeight = Math.max(160, Math.min(520, contentHeight));
String subtitle = totalQueued + " / " + def.getMaxPlayers() + " players — vote for a map!";
GroupBuilder votePanel = surface(def.getName(), subtitle, 572, contentHeight);
if (voteableMaps.isEmpty()) {
votePanel.addChild(inset(caption("No voteable maps found. Add MapVoteable=True to MainArena volumes.", 510), 572, 510, 24));
} else {
for (VoteMapEntry map : voteableMaps) {
long mapVotes = voteCounts.getOrDefault(map.mapId(), 0L)
+ map.variants().stream().mapToLong(v -> voteCounts.getOrDefault(map.mapId() + ":" + v, 0L)).sum();
votePanel.addChild(spacer(572, 6));
if (map.variants().isEmpty()) {
boolean voted = map.mapId().equals(currentVote);
String label = (voted ? "" : "") + prettify(map.mapId()) + " | " + mapVotes + " votes";
votePanel.addChild(inset(
ButtonBuilder.secondaryTextButton()
.withText(label)
.withAnchor(new HyUIAnchor().setWidth(510).setHeight(46))
.withTooltipText("Vote for " + prettify(map.mapId()))
.onClick((ignored, ui) -> {
services.queue().vote(minigameId, playerId, map.mapId());
openVote(player, minigameId, store, manager);
}),
572, 510, 46));
} else {
votePanel.addChild(inset(kicker(prettify(map.mapId()) + " | " + mapVotes + " votes", 510), 572, 510, 18));
for (String variant : map.variants()) {
String voteKey = map.mapId() + ":" + variant;
long variantVotes = voteCounts.getOrDefault(voteKey, 0L);
boolean voted = voteKey.equals(currentVote);
String label = (voted ? "" : "") + prettify(variant) + " | " + variantVotes + " votes";
votePanel.addChild(inset(
ButtonBuilder.secondaryTextButton()
.withText(label)
.withAnchor(new HyUIAnchor().setWidth(510).setHeight(38))
.withTooltipText("Vote for " + prettify(map.mapId()) + "" + prettify(variant))
.onClick((ignored, ui) -> {
services.queue().vote(minigameId, playerId, voteKey);
openVote(player, minigameId, store, manager);
}),
572, 510, 38));
votePanel.addChild(spacer(572, 4));
}
}
}
}
int containerHeight = Math.max(240, Math.min(560, contentHeight + 82));
ContainerBuilder container = compactDecoratedContainer(def.getName() + " — Vote", 640, containerHeight)
.withPadding(HyUIPadding.symmetric(18, 14))
.withLayoutMode("Top")
.withBackground(patch("#101722"))
.addContentChild(votePanel);
PageBuilder.pageForPlayer(player).addElement(container).open(player, store);
}
private List<VoteMapEntry> scanVoteableMaps(TriggerVolumeManager manager, String minigameId) {
Map<String, List<String>> mapVariants = new LinkedHashMap<>();
manager.getVolumes().stream()
.filter(v -> minigameId.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID)))
.filter(v -> MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ROLE)))
.filter(v -> "True".equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_VOTEABLE)))
.filter(v -> !v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "").isBlank())
.forEach(v -> {
String mapId = v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ID);
String variantId = v.getRawTags().get(MinigameMapDiscoveryService.TAG_VARIANT_ID);
boolean variantVotable = "True".equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_VARIANT_VOTABLE));
List<String> variants = mapVariants.computeIfAbsent(mapId, k -> new ArrayList<>());
if (variantId != null && !variantId.isBlank() && variantVotable && !variants.contains(variantId)) {
variants.add(variantId);
}
});
return mapVariants.entrySet().stream()
.map(e -> new VoteMapEntry(e.getKey(), List.copyOf(e.getValue())))
.toList();
}
private record VoteMapEntry(String mapId, List<String> variants) {}
private boolean hasMultipleVoteChoices(Store<EntityStore> store, String minigameId) {
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
if (tvPlugin == null) return false;
TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType());
if (manager == null) return false;
List<VoteMapEntry> maps = scanVoteableMaps(manager, minigameId);
int total = maps.stream().mapToInt(m -> m.variants().isEmpty() ? 1 : m.variants().size()).sum();
return total > 1;
}
// ── ACTIONS ───────────────────────────────────────────────────────────────
private void joinQueue(PlayerRef player, String minigameId, String mapId, Store<EntityStore> store) {
String playerId = player.getUuid().toString();
services.queue().join(minigameId, playerId);
if (!mapId.isBlank()) {
services.queue().vote(minigameId, playerId, mapId);
}
services.minigames().definition(minigameId).ifPresent(def -> {
Set<String> queued = services.queue().players(minigameId);
if (services.runtime().runtimes(minigameId).isEmpty() && queued.size() >= def.getMinPlayers()) {
if (def.getMapSelectionMode() == MapSelectionMode.VOTE && hasMultipleVoteChoices(store, minigameId)) {
// Show vote UI — to all players when threshold is first hit, otherwise just the new joiner
Set<UUID> targets = queued.size() == def.getMinPlayers()
? queued.stream().map(id -> { try { return UUID.fromString(id); } catch (IllegalArgumentException e) { return null; } }).filter(u -> u != null).collect(Collectors.toSet())
: Set.of(player.getUuid());
long now = System.currentTimeMillis();
targets.forEach(uuid -> pendingOpens.put(uuid, new PendingOpen(uuid, UIQueueMode.VOTE, minigameId, "", now)));
} else {
var discovered = services.maps().discover(store, minigameId);
services.queue().startQueued(def, discovered.candidates(), services.runtime());
}
}
});
}
private void leaveQueue(PlayerRef player, String minigameId) {
services.queue().leave(minigameId, player.getUuid().toString());
}
// ── HELPERS ───────────────────────────────────────────────────────────────
@Nullable
private String findActiveQueueFor(String playerId) {
return services.minigames().definitions().stream()
.filter(def -> services.queue().players(def.getId()).contains(playerId))
.map(MinigameDefinition::getId)
.findFirst()
.orElse(null);
}
private List<ArenaEntry> scanArenas(TriggerVolumeManager manager,
@Nullable String filterMinigameId,
@Nullable String filterMapId) {
return manager.getVolumes().stream()
.filter(v -> "True".equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_UI_QUEUE))
&& MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(
v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ROLE))
&& v.getRawTags().containsKey(MinigameMapDiscoveryService.TAG_MINIGAME_ID))
.filter(v -> filterMinigameId == null
|| filterMinigameId.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID)))
.filter(v -> filterMapId == null
|| filterMapId.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ID)))
.map(v -> new ArenaEntry(
v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID),
v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, ""),
v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_UI_QUEUE_TYPE, "Global")))
.distinct()
.toList();
}
private static String prettify(String id) {
if (id == null || id.isBlank()) return "Unknown";
return Arrays.stream(id.replace('_', ' ').split("\\s+"))
.map(w -> w.isEmpty() ? w : Character.toUpperCase(w.charAt(0)) + w.substring(1).toLowerCase())
.collect(Collectors.joining(" "));
}
// ── DEBUG ─────────────────────────────────────────────────────────────────
private void sendDebugInfo(PlayerRef player, TriggerVolumeManager manager) {
if (playerAdapter == null) return;
String playerId = player.getUuid().toString();
for (MinigameDefinition def : services.minigames().definitions()) {
if (!def.isDebug()) continue;
String p = "[QueueUI:" + def.getId() + "] ";
debug(playerId, p + def.getName() + " — debug report");
debug(playerId, p + " UIQueueable: " + def.isUIQueueable()
+ (def.isUIQueueable() ? " (visible in global browser if volumes are tagged)" : " — add UIQueueable:true to show in global browser"));
List<VolumeEntry> ownedVolumes = manager.getVolumes().stream()
.filter(v -> def.getId().equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID)))
.toList();
if (ownedVolumes.isEmpty()) {
debug(playerId, p + " volumes: none found with MinigameId=" + def.getId());
continue;
}
List<VolumeEntry> queueVolumes = ownedVolumes.stream()
.filter(v -> "True".equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_UI_QUEUE)))
.toList();
if (queueVolumes.isEmpty()) {
List<VolumeEntry> mainArenaVolumes = ownedVolumes.stream()
.filter(v -> MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(
v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ROLE)))
.toList();
if (mainArenaVolumes.isEmpty()) {
debug(playerId, p + " volumes: found " + ownedVolumes.size() + " volume(s) for this game, none have MapRole=MainArena or UIQueue=True");
debug(playerId, p + " fix: add MapRole=MainArena and UIQueue=True to the arena volume(s)");
} else {
debug(playerId, p + " volumes: found " + mainArenaVolumes.size() + " MainArena volume(s), none have UIQueue=True");
debug(playerId, p + " fix: add UIQueue=True to the MainArena volume(s)");
for (VolumeEntry v : mainArenaVolumes) {
String mapId = v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "(no MapId)");
debug(playerId, p + " volume MapId=" + mapId + " MapRole=MainArena UIQueue=(missing)");
}
}
continue;
}
List<VolumeEntry> arenaVolumes = queueVolumes.stream()
.filter(v -> MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(
v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ROLE)))
.toList();
if (arenaVolumes.isEmpty()) {
debug(playerId, p + " volumes: found " + queueVolumes.size() + " volume(s) with UIQueue=True, none have MapRole=MainArena");
debug(playerId, p + " fix: add MapRole=MainArena to the arena volume(s)");
for (VolumeEntry v : queueVolumes) {
String mapId = v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "(no MapId)");
String role = v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ROLE, "(missing)");
debug(playerId, p + " volume MapId=" + mapId + " MapRole=" + role);
}
continue;
}
for (VolumeEntry v : arenaVolumes) {
String mapId = v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "(no MapId)");
String queueType = v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_UI_QUEUE_TYPE, "Global (default)");
debug(playerId, p + " volume OK — MapId=" + mapId + " UIQueueType=" + queueType);
}
if (def.isUIQueueable()) {
debug(playerId, p + " RESULT: visible in global browser — " + arenaVolumes.size() + " map(s)");
} else {
debug(playerId, p + " RESULT: hidden — volumes are tagged correctly but UIQueueable=false");
debug(playerId, p + " fix: add \"UIQueueable\": true to the definition");
}
}
}
private void debug(String playerId, String text) {
if (playerAdapter != null) {
playerAdapter.sendMessage(playerId, Message.raw(text));
}
}
// ── UI BUILDERS ───────────────────────────────────────────────────────────
// All styles mirror DuelMenuService for visual consistency.
private static ContainerBuilder compactDecoratedContainer(String title, int width, int height) {
return ContainerBuilder.decoratedContainer()
.withTitleText(title)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
.editElementAfter((commands, selector) -> commands.setObject(
selector + " #Content.Anchor",
new HyUIAnchor().setTop(0).toHytaleAnchor()
));
}
private static GroupBuilder surface(String title, String subtitle, int width, int height) {
return GroupBuilder.group()
.withLayoutMode("Top")
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
.withBackground(patch("#172131"))
.withOutlineColor("#2b405d")
.withOutlineSize(1.0f)
.addChild(spacer(width, 8))
.addChild(inset(kicker(title, Math.max(80, width - 44)), width, Math.max(80, width - 44), 18))
.addChild(inset(caption(subtitle, Math.max(80, width - 44)), width, Math.max(80, width - 44), 24))
.addChild(spacer(width, 8));
}
private static GroupBuilder inset(UIElementBuilder<?> child, int outerWidth, int childWidth, int height) {
return GroupBuilder.group()
.withLayoutMode("Left")
.withAnchor(new HyUIAnchor().setWidth(outerWidth).setHeight(height))
.addChild(spacer(SECTION_INSET, height))
.addChild(child.withAnchor(new HyUIAnchor().setWidth(childWidth).setHeight(height)));
}
private static ButtonBuilder actionButton(String text, String tooltip, int width, Runnable action) {
return ButtonBuilder.secondaryTextButton()
.withText(text)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(38))
.withTooltipText(tooltip)
.onClick(action);
}
private static ButtonBuilder dangerButton(String text, String tooltip, int width, Runnable action) {
return ButtonBuilder.cancelTextButton()
.withText(text)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(38))
.withTooltipText(tooltip)
.onClick(action);
}
private static LabelBuilder kicker(String text, int width) {
return LabelBuilder.label()
.withText(text)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(18))
.withStyle(textStyle(11, "#7cc7ff", true)
.setRenderUppercase(true).setWrap(false)
.setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8));
}
private static LabelBuilder bodyLabel(String text, int width) {
return LabelBuilder.label()
.withText(text)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(20))
.withStyle(textStyle(13, "#dbe7f6", false)
.setWrap(true).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(10));
}
private static LabelBuilder caption(String text, int width) {
return LabelBuilder.label()
.withText(text)
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(24))
.withStyle(textStyle(11, "#9eb4cf", false)
.setWrap(true).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8));
}
private static LabelBuilder spacer(int width, int height) {
return LabelBuilder.label()
.withText(" ")
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
.withStyle(textStyle(1, "#172131", false));
}
private static HyUIStyle textStyle(float size, String color, boolean bold) {
return new HyUIStyle()
.setFontSize(size)
.setTextColor(color)
.setRenderBold(bold)
.setLetterSpacing(0);
}
private static HyUIPatchStyle patch(String color) {
return new HyUIPatchStyle().setColor(color);
}
private record PendingOpen(UUID uuid, UIQueueMode mode, String minigameId, String mapId, long createdAtMillis) {}
private record ArenaEntry(String minigameId, String mapId, String queueType) {}
}
@@ -18,12 +18,27 @@ public final class LeaveMinigameQueueEffect extends MinigameRuntimeEffect {
var services = services();
String playerId = resolvePlayerId(context, null);
String id = resolvedMinigameId(context);
if (services != null && playerId != null && !id.isBlank()) {
services.queue().leave(id, playerId);
debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' left queue for minigame='" + id + "'");
} else {
if (services == null || playerId == null || id.isBlank()) {
debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "')");
return;
}
// Remove from pre-start queue session
services.queue().leave(id, playerId);
// Also remove from the runtime if the game is still in the pregame window.
// This triggers the min-player check on the next pregame tick.
services.runtime().runtimes(id).forEach(runtime -> {
var phase = runtime.phase();
if (phase == net.kewwbec.minigames.model.Enums.MinigamePhase.WAITING_FOR_PLAYERS
|| phase == net.kewwbec.minigames.model.Enums.MinigamePhase.COUNTDOWN
|| phase == net.kewwbec.minigames.model.Enums.MinigamePhase.STARTING) {
runtime.players().remove(playerId);
debugMessage(context, TYPE_ID, "removed player='" + playerId + "' from pregame runtime minigame='" + id + "'");
}
});
debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' left queue for minigame='" + id + "'");
}
}
@@ -11,8 +11,8 @@ import com.hypixel.hytale.server.core.entity.UUIDComponent;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementConfig;
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager;
import com.hypixel.hytale.server.core.entity.EntityUtils;
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
import com.hypixel.hytale.server.core.modules.physics.component.PhysicsValues;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.server.npc.NPCPlugin;
@@ -165,7 +165,9 @@ public final class MountPlayerEffect extends MinigameRuntimeEffect {
return;
}
movementManager.setDefaultSettings(config.toPacket(), EntityUtils.getPhysicsValues(playerRef, context.getStore()), playerComponent.getGameMode());
PhysicsValues physicsValues = context.getStore().getComponent(playerRef, PhysicsValues.getComponentType());
if (physicsValues == null) physicsValues = PhysicsValues.getDefault();
movementManager.setDefaultSettings(config.toPacket(), physicsValues, playerComponent.getGameMode());
movementManager.applyDefaultSettings();
movementManager.update(playerRefComponent.getPacketHandler());
}
@@ -0,0 +1,50 @@
package net.kewwbec.minigames.volume.effect;
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
import com.hypixel.hytale.codec.Codec;
import com.hypixel.hytale.codec.KeyedCodec;
import com.hypixel.hytale.codec.builder.BuilderCodec;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import net.kewwbec.minigames.MinigameCorePlugin;
import net.kewwbec.minigames.ui.MinigameQueueUIService;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
public final class OpenMinigameQueueUIEffect extends MinigameRuntimeEffect {
public static final String TYPE_ID = "OpenMinigameQueueUI";
@Nonnull
public static final BuilderCodec<OpenMinigameQueueUIEffect> CODEC =
BuilderCodec.builder(OpenMinigameQueueUIEffect.class, OpenMinigameQueueUIEffect::new, MINIGAME_BASE_CODEC)
.<String>append(
new KeyedCodec<>("MapId", Codec.STRING, false),
(effect, value) -> effect.mapId = value,
effect -> effect.mapId
)
.add()
.build();
@Nullable
private String mapId;
@Override
public void execute(@Nonnull TriggerContext context) {
MinigameQueueUIService queueUIService = MinigameCorePlugin.getQueueUIService();
PlayerRef player = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType());
if (queueUIService == null || player == null) {
return;
}
String resolvedMinigameId = resolvedMinigameId(context);
String resolvedMapId = mapId != null ? mapId.trim() : "";
MinigameQueueUIService.UIQueueMode mode = resolvedMinigameId.isBlank()
? MinigameQueueUIService.UIQueueMode.GLOBAL
: resolvedMapId.isBlank()
? MinigameQueueUIService.UIQueueMode.SINGLE
: MinigameQueueUIService.UIQueueMode.LOCAL;
queueUIService.requestOpen(player, mode, resolvedMinigameId, resolvedMapId);
}
}
@@ -1,6 +1,7 @@
package net.kewwbec.minigames.volume.effect;
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
import com.hypixel.hytale.codec.Codec;
import com.hypixel.hytale.codec.KeyedCodec;
import com.hypixel.hytale.codec.builder.BuilderCodec;
@@ -8,6 +9,7 @@ import com.hypixel.hytale.logger.HytaleLogger;
import com.hypixel.hytale.math.util.ChunkUtil;
import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType;
import com.hypixel.hytale.server.core.universe.world.World;
import org.joml.Vector3d;
import javax.annotation.Nonnull;
import java.util.ArrayList;
@@ -41,6 +43,10 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
.add()
.<Integer>append(new KeyedCodec<>("MaxBlocks", Codec.INTEGER, false), (effect, value) -> effect.maxBlocks = value, effect -> effect.maxBlocks)
.add()
.<Boolean>append(new KeyedCodec<>("FillVolume", Codec.BOOLEAN, false), (effect, value) -> effect.fillVolume = value, effect -> effect.fillVolume)
.add()
.<Boolean>append(new KeyedCodec<>("Hollow", Codec.BOOLEAN, false), (effect, value) -> effect.hollow = value, effect -> effect.hollow)
.add()
.build();
private String blockId = "";
@@ -51,6 +57,8 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
private int y2 = 0;
private int z2 = 0;
private int maxBlocks = DEFAULT_MAX_BLOCKS;
private boolean fillVolume = false;
private boolean hollow = false;
@Override
public void execute(@Nonnull TriggerContext context) {
@@ -66,12 +74,30 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
return;
}
int minX = Math.min(x1, x2);
int maxX = Math.max(x1, x2);
int minY = Math.max(0, Math.min(y1, y2));
int maxY = Math.min(319, Math.max(y1, y2));
int minZ = Math.min(z1, z2);
int maxZ = Math.max(z1, z2);
int minX, maxX, minY, maxY, minZ, maxZ;
if (fillVolume) {
VolumeEntry volume = context.getVolume();
if (volume == null) {
debugMessage(context, TYPE_ID, "SKIPPED FillVolume=true but no volume in context");
return;
}
Vector3d wMin = new Vector3d();
Vector3d wMax = new Vector3d();
volume.getShape().getWorldAABB(volume.getPosition(), wMin, wMax);
minX = (int) Math.floor(Math.min(wMin.x(), wMax.x()));
maxX = (int) Math.floor(Math.max(wMin.x(), wMax.x()));
minZ = (int) Math.floor(Math.min(wMin.z(), wMax.z()));
maxZ = (int) Math.floor(Math.max(wMin.z(), wMax.z()));
minY = Math.max(0, (int) Math.floor(Math.min(wMin.y(), wMax.y())));
maxY = Math.min(319, (int) Math.floor(Math.max(wMin.y(), wMax.y())));
} else {
minX = Math.min(x1, x2);
maxX = Math.max(x1, x2);
minY = Math.max(0, Math.min(y1, y2));
maxY = Math.min(319, Math.max(y1, y2));
minZ = Math.min(z1, z2);
maxZ = Math.max(z1, z2);
}
long totalBlocks = blockCount(minX, maxX, minY, maxY, minZ, maxZ);
int limit = maxBlocks > 0 ? maxBlocks : DEFAULT_MAX_BLOCKS;
@@ -84,6 +110,7 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
return;
}
final int fMinX = minX, fMaxX = maxX, fMinY = minY, fMaxY = maxY, fMinZ = minZ, fMaxZ = maxZ;
World world = context.getStore().getExternalData().getWorld();
Map<Long, List<BlockPosition>> positionsByChunk = new HashMap<>();
for (int x = minX; x <= maxX; x++) {
@@ -91,6 +118,9 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
long chunkIndex = ChunkUtil.indexChunkFromBlock(x, z);
List<BlockPosition> positions = positionsByChunk.computeIfAbsent(chunkIndex, ignored -> new ArrayList<>());
for (int y = minY; y <= maxY; y++) {
if (hollow && x != fMinX && x != fMaxX && y != fMinY && y != fMaxY && z != fMinZ && z != fMaxZ) {
continue;
}
positions.add(new BlockPosition(x, y, z));
}
}
@@ -110,7 +140,7 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
})
);
debugMessage(context, TYPE_ID, "FIRED block='" + blockId + "' count=" + totalBlocks);
debugMessage(context, TYPE_ID, "FIRED block='" + blockId + "' count=" + totalBlocks + (hollow ? " hollow" : "") + (fillVolume ? " fillVolume" : ""));
}
private static long blockCount(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) {
@@ -26,7 +26,8 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
@Nonnull
public static final BuilderCodec<SpawnItemEffect> CODEC =
BuilderCodec.builder(SpawnItemEffect.class, SpawnItemEffect::new, MINIGAME_BASE_CODEC)
.<String>append(new KeyedCodec<>("ItemId", Codec.STRING), (effect, value) -> effect.itemId = value, effect -> effect.itemId)
//.<String>append(new KeyedCodec<>("ItemId", Codec.STRING), (effect, value) -> effect.itemId = value, effect -> effect.itemId)
.append(new KeyedCodec<>("ItemId", Codec.STRING), (effect, value) -> effect.itemId = value, effect -> effect.itemId)
.add()
.<Integer>append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount)
.add()
@@ -0,0 +1,45 @@
package net.kewwbec.minigames.volume.effect;
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
import com.hypixel.hytale.codec.Codec;
import com.hypixel.hytale.codec.KeyedCodec;
import com.hypixel.hytale.codec.builder.BuilderCodec;
import javax.annotation.Nonnull;
public final class StartPlayerTimerEffect extends MinigameRuntimeEffect {
public static final String TYPE_ID = "StartPlayerTimer";
static final String VAR_TIMER_START = "$timer_start_ms";
@Nonnull
public static final BuilderCodec<StartPlayerTimerEffect> CODEC =
BuilderCodec.builder(StartPlayerTimerEffect.class, StartPlayerTimerEffect::new, MINIGAME_BASE_CODEC)
.append(new KeyedCodec<>("PlayerId", Codec.STRING, false),
(effect, value) -> effect.playerId = value,
effect -> effect.playerId)
.add()
.build();
private String playerId;
@Override
public void execute(@Nonnull TriggerContext context) {
String id = resolvePlayerId(context, playerId);
if (id == null) {
debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)");
return;
}
var rt = runtime(context);
if (rt.isEmpty()) {
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
return;
}
var player = rt.get().players().get(id);
if (player == null) {
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
return;
}
player.variables().put(VAR_TIMER_START, System.currentTimeMillis());
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' timer started");
}
}
@@ -25,12 +25,26 @@ public final class StartQueuedMinigameEffect extends MinigameRuntimeEffect {
}
var defOpt = services.minigames().definition(id);
if (defOpt.isPresent()) {
var discovered = services.maps().discover(context, id);
services.queue().startQueued(defOpt.get(), discovered.candidates(), services.runtime());
debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "' candidates=" + discovered.candidates().size());
} else {
if (defOpt.isEmpty()) {
debugMessage(context, TYPE_ID, "SKIPPED (definition not found for id='" + id + "')");
return;
}
var def = defOpt.get();
if (!services.runtime().runtimes(id).isEmpty()) {
debugMessage(context, TYPE_ID, "SKIPPED (runtime already active for id='" + id + "')");
return;
}
int queued = services.queue().players(id).size();
if (queued < def.getMinPlayers()) {
debugMessage(context, TYPE_ID, "SKIPPED (queue=" + queued + " < minPlayers=" + def.getMinPlayers() + ")");
return;
}
var discovered = services.maps().discover(context, id);
services.queue().startQueued(def, discovered.candidates(), services.runtime());
debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "' candidates=" + discovered.candidates().size());
}
}
@@ -0,0 +1,82 @@
package net.kewwbec.minigames.volume.effect;
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
import com.hypixel.hytale.codec.Codec;
import com.hypixel.hytale.codec.KeyedCodec;
import com.hypixel.hytale.codec.builder.BuilderCodec;
import com.hypixel.hytale.codec.codecs.EnumCodec;
import javax.annotation.Nonnull;
public final class StopPlayerTimerEffect extends MinigameRuntimeEffect {
public static final String TYPE_ID = "StopPlayerTimer";
@Nonnull
public static final BuilderCodec<StopPlayerTimerEffect> CODEC =
BuilderCodec.builder(StopPlayerTimerEffect.class, StopPlayerTimerEffect::new, MINIGAME_BASE_CODEC)
.append(new KeyedCodec<>("PlayerId", Codec.STRING, false),
(effect, value) -> effect.playerId = value,
effect -> effect.playerId)
.add()
.append(new KeyedCodec<>("Operation", new EnumCodec<>(Operation.class), false),
(effect, value) -> effect.operation = value,
effect -> effect.operation)
.add()
.build();
private String playerId;
private Operation operation = Operation.SET;
@Override
public void execute(@Nonnull TriggerContext context) {
String id = resolvePlayerId(context, playerId);
if (id == null) {
debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)");
return;
}
var rt = runtime(context);
if (rt.isEmpty()) {
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
return;
}
var player = rt.get().players().get(id);
if (player == null) {
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
return;
}
Object startObj = player.variables().get(StartPlayerTimerEffect.VAR_TIMER_START);
if (!(startObj instanceof Long startMs)) {
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' timer was never started)");
return;
}
long elapsedMs = System.currentTimeMillis() - startMs;
int lap = (int) Math.min(elapsedMs / 10L, Integer.MAX_VALUE);
player.variables().remove(StartPlayerTimerEffect.VAR_TIMER_START);
Operation op = operation != null ? operation : Operation.SET;
int prev = player.score();
int next = switch (op) {
case SET -> lap;
case ADD -> prev + lap;
case BEST -> (prev <= 0 || lap < prev) ? lap : prev;
};
player.score(next);
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' op=" + op
+ " lap=" + formatPlayerTime(lap) + " score=" + formatPlayerTime(next));
}
public enum Operation {
SET,
ADD,
BEST
}
// Centiseconds → M:SS.cc (0 = DNF / no time)
public static String formatPlayerTime(int centiseconds) {
if (centiseconds <= 0) return "--:--";
int minutes = centiseconds / 6000;
int seconds = (centiseconds % 6000) / 100;
int hundredths = centiseconds % 100;
return minutes + ":" + String.format("%02d", seconds) + "." + String.format("%02d", hundredths);
}
}
@@ -215,3 +215,102 @@ customUI.triggerVolumeEffectEditor.effectType.StartQueuedMinigame = Start Queued
customUI.triggerVolumeEffectEditor.effectType.SpawnItem = Spawn Item
customUI.triggerVolumeEffectEditor.effectType.SpawnNpc = Spawn NPC
customUI.triggerVolumeEffectEditor.effectType.MountPlayer = Mount Player
customUI.triggerVolumeEffectEditor.effectType.DismountPlayer = Dismount Player
customUI.triggerVolumeEffectEditor.effectType.OpenMinigameQueueUI = Open Queue UI
customUI.triggerVolumeEffectEditor.effectType.PlaceBlocks = Place Blocks
customUI.triggerVolumeEffectEditor.effectType.SetPlayerLoadout = Set Player Loadout
customUI.triggerVolumeEffectEditor.effectType.StartPlayerTimer = Start Player Timer
customUI.triggerVolumeEffectEditor.effectType.StopPlayerTimer = Stop Player Timer
customUI.triggerVolumeEffectEditor.effectType.StartMinigame = Start Minigame
customUI.triggerVolumeEffectEditor.effectType.EndMinigame = End Minigame
customUI.triggerVolumeEffectEditor.effectType.ResetMinigame = Reset Minigame
customUI.triggerVolumeEffectEditor.effectType.SetMinigamePhase = Set Phase
customUI.triggerVolumeEffectEditor.effectType.ResetScores = Reset Scores
customUI.triggerVolumeEffectEditor.effectType.ModifyPlayerScore = Modify Player Score
customUI.triggerVolumeEffectEditor.effectType.ModifyTeamScore = Modify Team Score
customUI.triggerVolumeEffectEditor.effectType.ModifyCounter = Modify Counter
customUI.triggerVolumeEffectEditor.effectType.ModifyPlayerLives = Modify Player Lives
customUI.triggerVolumeEffectEditor.effectType.SetPlayerStatus = Set Player Status
customUI.triggerVolumeEffectEditor.conditionType.MinigamePhase = Minigame Phase
customUI.triggerVolumeEffectEditor.conditionType.PlayerInMinigame = Player In Minigame
customUI.triggerVolumeEffectEditor.conditionType.PlayerStatus = Player Status
customUI.triggerVolumeEffectEditor.conditionType.PlayerScore = Player Score
customUI.triggerVolumeEffectEditor.conditionType.TeamScore = Team Score
customUI.triggerVolumeEffectEditor.conditionType.Counter = Counter
customUI.triggerVolumeEffectEditor.field.common.NpcId = NPC UUID
customUI.triggerVolumeEffectEditor.field.common.NpcId.tooltip = Optional exact NPC UUID to target. Leave empty to use the nearest matching NPC.
customUI.triggerVolumeEffectEditor.field.common.NpcRoleId = NPC Role
customUI.triggerVolumeEffectEditor.field.common.NpcRoleId.tooltip = Optional NPC role filter. Leave empty to match any NPC.
customUI.triggerVolumeEffectEditor.field.common.RemoveNpc = Remove NPC After Dismount
customUI.triggerVolumeEffectEditor.field.common.RemoveNpc.tooltip = If true, the NPC is removed from the world after the player dismounts.
customUI.triggerVolumeEffectEditor.field.common.BalanceTeams = Balance Teams
customUI.triggerVolumeEffectEditor.field.common.BalanceTeams.tooltip = If true, evenly distribute all runtime players across available teams instead of assigning a specific team.
customUI.triggerVolumeEffectEditor.field.common.PreventManualDismount = Lock Mount
customUI.triggerVolumeEffectEditor.field.common.PreventManualDismount.tooltip = If true, the player cannot manually dismount.
customUI.triggerVolumeEffectEditor.field.common.LoadoutId = Loadout
customUI.triggerVolumeEffectEditor.field.common.LoadoutId.tooltip = The loadout id from the minigame definition to apply to the player.
customUI.triggerVolumeEffectEditor.field.common.BlockId = Block
customUI.triggerVolumeEffectEditor.field.common.BlockId.tooltip = The block type asset id to place.
customUI.triggerVolumeEffectEditor.field.common.X1 = X1
customUI.triggerVolumeEffectEditor.field.common.X1.tooltip = First corner X coordinate.
customUI.triggerVolumeEffectEditor.field.common.Y1 = Y1
customUI.triggerVolumeEffectEditor.field.common.Y1.tooltip = First corner Y coordinate.
customUI.triggerVolumeEffectEditor.field.common.Z1 = Z1
customUI.triggerVolumeEffectEditor.field.common.Z1.tooltip = First corner Z coordinate.
customUI.triggerVolumeEffectEditor.field.common.X2 = X2
customUI.triggerVolumeEffectEditor.field.common.X2.tooltip = Second corner X coordinate.
customUI.triggerVolumeEffectEditor.field.common.Y2 = Y2
customUI.triggerVolumeEffectEditor.field.common.Y2.tooltip = Second corner Y coordinate.
customUI.triggerVolumeEffectEditor.field.common.Z2 = Z2
customUI.triggerVolumeEffectEditor.field.common.Z2.tooltip = Second corner Z coordinate.
customUI.triggerVolumeEffectEditor.field.common.MaxBlocks = Max Blocks
customUI.triggerVolumeEffectEditor.field.common.MaxBlocks.tooltip = Maximum block count allowed for this operation. Defaults to 32768. Increase with care.
customUI.triggerVolumeEffectEditor.field.common.FillVolume = Fill Volume Shape
customUI.triggerVolumeEffectEditor.field.common.FillVolume.tooltip = If true, use this trigger volume's bounding box as the fill region instead of the X1/Y1/Z1 - X2/Y2/Z2 coordinates.
customUI.triggerVolumeEffectEditor.field.common.Hollow = Hollow
customUI.triggerVolumeEffectEditor.field.common.Hollow.tooltip = If true, only place blocks on the outer shell of the region. Interior blocks are left unchanged.
customUI.triggerVolumeEffectEditor.field.common.Operation.option.BEST = Best
customUI.triggerVolumeEffectEditor.field.MountPlayer.PreventManualDismount.tooltip = Prevents the player from manually dismounting while this component is active.
customUI.triggerVolumeEffectEditor.field.AssignTeam.BalanceTeams.tooltip = Distribute all runtime players evenly across teams. TeamId is ignored when this is enabled.
customUI.triggerVolumeEffectEditor.field.DismountPlayer.PlayerId.tooltip = Optional player UUID. Leave empty to dismount the triggering player.
customUI.triggerVolumeEffectEditor.field.DismountPlayer.NpcRoleId.tooltip = Optional NPC role filter. Only dismounts if the player is mounted on an NPC with this role.
customUI.triggerVolumeEffectEditor.field.DismountPlayer.RemoveNpc.tooltip = Remove the NPC from the world after the player is dismounted.
customUI.triggerVolumeEffectEditor.field.OpenMinigameQueueUI.MinigameId.tooltip = Minigame to show. Leave empty to open the global game browser.
customUI.triggerVolumeEffectEditor.field.OpenMinigameQueueUI.MapId.tooltip = Specific map to show as a compact join popup. Requires MinigameId to also be set.
customUI.triggerVolumeEffectEditor.field.PlaceBlocks.BlockId.tooltip = The block type asset id to place throughout the region.
customUI.triggerVolumeEffectEditor.field.PlaceBlocks.X1.tooltip = X coordinate of the first corner of the fill region.
customUI.triggerVolumeEffectEditor.field.PlaceBlocks.Y1.tooltip = Y coordinate of the first corner of the fill region.
customUI.triggerVolumeEffectEditor.field.PlaceBlocks.Z1.tooltip = Z coordinate of the first corner of the fill region.
customUI.triggerVolumeEffectEditor.field.PlaceBlocks.X2.tooltip = X coordinate of the second corner of the fill region.
customUI.triggerVolumeEffectEditor.field.PlaceBlocks.Y2.tooltip = Y coordinate of the second corner of the fill region.
customUI.triggerVolumeEffectEditor.field.PlaceBlocks.Z2.tooltip = Z coordinate of the second corner of the fill region.
customUI.triggerVolumeEffectEditor.field.PlaceBlocks.MaxBlocks.tooltip = Maximum number of blocks this effect is allowed to place. Protects against oversized regions.
customUI.triggerVolumeEffectEditor.field.PlaceBlocks.FillVolume.tooltip = Use this trigger volume's own bounding box as the fill region. Overrides the X1/Y1/Z1 - X2/Y2/Z2 fields.
customUI.triggerVolumeEffectEditor.field.PlaceBlocks.Hollow.tooltip = Only place blocks on the outer faces of the region. Interior is left untouched.
customUI.triggerVolumeEffectEditor.field.SetPlayerLoadout.PlayerId.tooltip = Optional player UUID. Leave empty to apply the loadout to the triggering player.
customUI.triggerVolumeEffectEditor.field.SetPlayerLoadout.LoadoutId.tooltip = The loadout id defined in the minigame definition to give the player.
customUI.triggerVolumeEffectEditor.field.StartPlayerTimer.PlayerId.tooltip = Optional player UUID. Leave empty to start the timer for the triggering player.
customUI.triggerVolumeEffectEditor.field.StopPlayerTimer.PlayerId.tooltip = Optional player UUID. Leave empty to stop the timer for the triggering player.
customUI.triggerVolumeEffectEditor.field.StopPlayerTimer.Operation.tooltip = How to apply the elapsed time to the player's score. SET replaces, ADD accumulates, BEST keeps the lowest non-zero time.
# /triggervolume clone
server.commands.triggervolume.clone.desc = Clone a trigger volume and place it at your position
server.commands.triggervolume.clone.name.desc = Name of the trigger volume to clone
server.commands.triggervolume.clone.newName.desc = Optional name for the clone (defaults to <name>_Copy)
server.commands.triggervolume.clone.updateTags.desc = Comma-separated tag overrides applied to the clone, e.g. MinigameId=Fishing,MapId=Cold
server.commands.triggervolume.clone.notFound = No trigger volume found with name '{name}'
server.commands.triggervolume.clone.success = Cloned '{source}' to '{name}' at your position
# /triggervolume clonegroup
server.commands.triggervolume.clonegroup.desc = Clone a trigger volume group, renaming member volumes via find/replace
server.commands.triggervolume.clonegroup.groupName.desc = Name of the group to clone
server.commands.triggervolume.clonegroup.newGroupName.desc = Name for the new group
server.commands.triggervolume.clonegroup.find.desc = Phrase to replace in member volume names
server.commands.triggervolume.clonegroup.replace.desc = Replacement phrase for member volume names
server.commands.triggervolume.clonegroup.notFound = No trigger volume group found with name '{name}'
server.commands.triggervolume.clonegroup.alreadyExists = A trigger volume group already exists with name '{name}'
server.commands.triggervolume.clonegroup.empty = Group '{name}' has no member volumes
server.commands.triggervolume.clonegroup.success = Cloned group '{source}' to '{name}' with {count} volumes
+1 -1
View File
@@ -11,7 +11,7 @@
"Website": "https://kewwbec.net",
"ServerVersion": "^0.5.3",
"Dependencies": {
"Ellie:HyUI": "*"
},
"OptionalDependencies": {