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:
Binary file not shown.
@@ -61,6 +61,8 @@ dependencies {
|
|||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
|
||||||
// Your dependencies here
|
// Your dependencies here
|
||||||
|
compileOnly fileTree(dir: 'run/mods', include: ['HyUI-*.jar'])
|
||||||
|
testCompileOnly fileTree(dir: 'run/mods', include: ['HyUI-*.jar'])
|
||||||
}
|
}
|
||||||
|
|
||||||
test {
|
test {
|
||||||
|
|||||||
+24
@@ -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
|
||||||
@@ -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. |
|
| `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. |
|
| `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. |
|
| `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
|
### Operation Values
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -18,7 +18,8 @@ Definition file: `Server/Minigames/Quick_Brawl.json`
|
|||||||
"WinCondition": "HIGHEST_SCORE",
|
"WinCondition": "HIGHEST_SCORE",
|
||||||
"RoundLengthSeconds": 120,
|
"RoundLengthSeconds": 120,
|
||||||
"CountdownSeconds": 5,
|
"CountdownSeconds": 5,
|
||||||
"AllowPvp": true
|
"AllowPvp": true,
|
||||||
|
...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -28,6 +29,18 @@ Editor setup:
|
|||||||
2. Add the `StartMinigame` effect.
|
2. Add the `StartMinigame` effect.
|
||||||
3. Set `MinigameId` to `Quick_Brawl` using the asset picker.
|
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
|
## Score Zone
|
||||||
|
|
||||||
Definition file: `Server/Minigames/Hill_Points.json`
|
Definition file: `Server/Minigames/Hill_Points.json`
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ Supported values:
|
|||||||
| `FIRST_TO_SCORE` | First player or team to a target score wins. |
|
| `FIRST_TO_SCORE` | First player or team to a target score wins. |
|
||||||
| `LAST_ALIVE` | Last active player or team wins. |
|
| `LAST_ALIVE` | Last active player or team wins. |
|
||||||
| `OBJECTIVE_COMPLETE` | Objective completion determines the winner. |
|
| `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. |
|
| `CUSTOM` | Trigger logic determines the winner. |
|
||||||
|
|
||||||
## World Rules
|
## 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`. |
|
| `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. |
|
| `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
|
## ItemStackConfig
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -104,6 +104,68 @@ MapId=Castle
|
|||||||
SignalOnTimerExpire=overtime_warning
|
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
|
## Validation Notes
|
||||||
|
|
||||||
- A volume with `MapId` but no `MinigameId` produces a warning and is ignored for map discovery.
|
- 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.service.MinigameVolumeSignalService;
|
||||||
import net.kewwbec.minigames.system.MinigameDeathRespawnSystem;
|
import net.kewwbec.minigames.system.MinigameDeathRespawnSystem;
|
||||||
import net.kewwbec.minigames.system.MinigameKillScoreSystem;
|
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.CounterCondition;
|
||||||
import net.kewwbec.minigames.volume.condition.CurrentRoundCondition;
|
import net.kewwbec.minigames.volume.condition.CurrentRoundCondition;
|
||||||
import net.kewwbec.minigames.volume.condition.MinigamePhaseCondition;
|
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.StartQueuedMinigameEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.StartTimerEffect;
|
import net.kewwbec.minigames.volume.effect.StartTimerEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.StopTimerEffect;
|
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 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.Nonnull;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
public final class MinigameCorePlugin extends JavaPlugin {
|
public final class MinigameCorePlugin extends JavaPlugin {
|
||||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
private static MinigameCorePlugin instance;
|
private static MinigameCorePlugin instance;
|
||||||
private MinigameServices services;
|
private MinigameServices services;
|
||||||
|
private MinigameQueueUIService queueUIService;
|
||||||
|
private PacketFilter duplicatePacketFilter;
|
||||||
private ComponentType<EntityStore, LockMountComponent> lockMountComponentType;
|
private ComponentType<EntityStore, LockMountComponent> lockMountComponentType;
|
||||||
|
|
||||||
public MinigameCorePlugin(@Nonnull JavaPluginInit init) {
|
public MinigameCorePlugin(@Nonnull JavaPluginInit init) {
|
||||||
@@ -95,6 +118,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
ServerManager.get().registerSubPacketHandlers(LockMountPacketHandler::new);
|
ServerManager.get().registerSubPacketHandlers(LockMountPacketHandler::new);
|
||||||
getEntityStoreRegistry().registerSystem(new MinigameKillScoreSystem());
|
getEntityStoreRegistry().registerSystem(new MinigameKillScoreSystem());
|
||||||
getEntityStoreRegistry().registerSystem(new MinigameDeathRespawnSystem());
|
getEntityStoreRegistry().registerSystem(new MinigameDeathRespawnSystem());
|
||||||
|
TriggerVolumeDuplicateSystem duplicateSystem = new TriggerVolumeDuplicateSystem();
|
||||||
|
this.duplicatePacketFilter = duplicateSystem.register();
|
||||||
registerTriggerVolumeEffectTypes();
|
registerTriggerVolumeEffectTypes();
|
||||||
registerTriggerVolumeConditionTypes();
|
registerTriggerVolumeConditionTypes();
|
||||||
|
|
||||||
@@ -116,10 +141,25 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
runtimeService.setPlayerAdapter(adapters);
|
runtimeService.setPlayerAdapter(adapters);
|
||||||
runtimeService.setEntityAdapter(adapters);
|
runtimeService.setEntityAdapter(adapters);
|
||||||
runtimeService.setInventoryAdapter(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();
|
registerTriggerVolumeAssetSources();
|
||||||
|
|
||||||
this.getCommandRegistry().registerCommand(new MinigameCommand(services));
|
this.getCommandRegistry().registerCommand(new MinigameCommand(services));
|
||||||
|
injectTriggerVolumeCloneCommand();
|
||||||
LOGGER.atInfo().log("core-minigames registered " + registeredTriggerConditionCount()
|
LOGGER.atInfo().log("core-minigames registered " + registeredTriggerConditionCount()
|
||||||
+ " trigger conditions and " + registeredTriggerEffectCount() + " trigger effects.");
|
+ " 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.runtime().shutdownAll("minigames.runtime.reason.plugin_shutdown");
|
||||||
services.signals().shutdown();
|
services.signals().shutdown();
|
||||||
}
|
}
|
||||||
|
if (duplicatePacketFilter != null) {
|
||||||
|
PacketAdapters.deregisterInbound(duplicatePacketFilter);
|
||||||
|
duplicatePacketFilter = null;
|
||||||
|
}
|
||||||
services = null;
|
services = null;
|
||||||
instance = null;
|
instance = null;
|
||||||
}
|
}
|
||||||
@@ -139,10 +183,43 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
return instance != null ? instance.services : null;
|
return instance != null ? instance.services : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static MinigameQueueUIService getQueueUIService() {
|
||||||
|
return instance != null ? instance.queueUIService : null;
|
||||||
|
}
|
||||||
|
|
||||||
public static ComponentType<EntityStore, LockMountComponent> getLockMountComponentType() {
|
public static ComponentType<EntityStore, LockMountComponent> getLockMountComponentType() {
|
||||||
return instance.lockMountComponentType;
|
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() {
|
private void registerTriggerVolumeEffectTypes() {
|
||||||
var triggerVolumes = TriggerVolumesPlugin.get();
|
var triggerVolumes = TriggerVolumesPlugin.get();
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, StartMinigameEffect.TYPE_ID, StartMinigameEffect.class, StartMinigameEffect.CODEC);
|
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, ModifyObjectiveProgressEffect.TYPE_ID, ModifyObjectiveProgressEffect.class, ModifyObjectiveProgressEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, AwardKillScoreEffect.TYPE_ID, AwardKillScoreEffect.class, AwardKillScoreEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, AwardKillScoreEffect.TYPE_ID, AwardKillScoreEffect.class, AwardKillScoreEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, PlaceBlocksEffect.TYPE_ID, PlaceBlocksEffect.class, PlaceBlocksEffect.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(
|
private <T extends TriggerEffect> void registerTriggerVolumeEffectType(
|
||||||
@@ -272,6 +352,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
registerMinigameIdAssetField(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, AwardKillScoreEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, AwardKillScoreEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, PlaceBlocksEffect.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, MinigamePhaseCondition.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, PlayerInMinigameCondition.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, PlayerInMinigameCondition.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, PlayerStatusCondition.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(SendMinigameMessageEffect.TYPE_ID) || typeId.equals(AssignTeamEffect.TYPE_ID)
|
||||||
|| typeId.equals(SaveInventoryEffect.TYPE_ID) || typeId.equals(RestoreInventoryEffect.TYPE_ID)
|
|| typeId.equals(SaveInventoryEffect.TYPE_ID) || typeId.equals(RestoreInventoryEffect.TYPE_ID)
|
||||||
|| typeId.equals(ClearInventoryEffect.TYPE_ID) || typeId.equals(AwardKillScoreEffect.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++;
|
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.asset.type.soundevent.config.SoundEvent;
|
||||||
import com.hypixel.hytale.server.core.entity.ItemUtils;
|
import com.hypixel.hytale.server.core.entity.ItemUtils;
|
||||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
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.InventoryComponent;
|
||||||
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
|
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
|
||||||
import com.hypixel.hytale.server.core.inventory.ItemStack;
|
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, "utility", inventory, InventoryComponent.Utility.getComponentType());
|
||||||
restoreInventory(store, ref, "storage", inventory, InventoryComponent.Storage.getComponentType());
|
restoreInventory(store, ref, "storage", inventory, InventoryComponent.Storage.getComponentType());
|
||||||
restoreInventory(store, ref, "backpack", inventory, InventoryComponent.Backpack.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;
|
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 {
|
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 {
|
public enum MapSelectionMode {
|
||||||
|
|||||||
@@ -264,6 +264,62 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
|||||||
(def, parent) -> def.rewards = parent.rewards
|
(def, parent) -> def.rewards = parent.rewards
|
||||||
)
|
)
|
||||||
.add()
|
.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();
|
.build();
|
||||||
|
|
||||||
public static final ValidatorCache<String> VALIDATOR_CACHE = new ValidatorCache<>(new AssetKeyValidator<>(MinigameDefinition::getAssetStore));
|
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 ItemStackConfig[] startItems = new ItemStackConfig[0];
|
||||||
protected LoadoutConfig[] startLoadouts = new LoadoutConfig[0];
|
protected LoadoutConfig[] startLoadouts = new LoadoutConfig[0];
|
||||||
protected RewardConfig[] rewards = new RewardConfig[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() {}
|
protected MinigameDefinition() {}
|
||||||
|
|
||||||
@@ -374,6 +438,14 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
|||||||
public ItemStackConfig[] getStartItems() { return startItems != null ? startItems : new ItemStackConfig[0]; }
|
public ItemStackConfig[] getStartItems() { return startItems != null ? startItems : new ItemStackConfig[0]; }
|
||||||
public LoadoutConfig[] getStartLoadouts() { return startLoadouts != null ? startLoadouts : new LoadoutConfig[0]; }
|
public LoadoutConfig[] getStartLoadouts() { return startLoadouts != null ? startLoadouts : new LoadoutConfig[0]; }
|
||||||
public RewardConfig[] getRewards() { return rewards != null ? rewards : new RewardConfig[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 setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
public void setName(String name) { this.name = name; }
|
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.ItemStackConfig;
|
||||||
import net.kewwbec.minigames.model.LoadoutConfig;
|
import net.kewwbec.minigames.model.LoadoutConfig;
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
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.MinigameMapCandidate;
|
||||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||||
import net.kewwbec.minigames.model.RewardConfig;
|
import net.kewwbec.minigames.model.RewardConfig;
|
||||||
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
|
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
|
||||||
|
import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect;
|
||||||
|
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@@ -33,16 +37,34 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
@Nullable
|
@Nullable
|
||||||
private MinigameVolumeSignalService signalService;
|
private MinigameVolumeSignalService signalService;
|
||||||
@Nullable
|
@Nullable
|
||||||
|
private MinigameQueueService queueService;
|
||||||
|
@Nullable
|
||||||
private HytaleAdapters.PlayerAdapter playerAdapter;
|
private HytaleAdapters.PlayerAdapter playerAdapter;
|
||||||
@Nullable
|
@Nullable
|
||||||
private HytaleAdapters.EntityAdapter entityAdapter;
|
private HytaleAdapters.EntityAdapter entityAdapter;
|
||||||
@Nullable
|
@Nullable
|
||||||
private HytaleAdapters.InventoryAdapter inventoryAdapter;
|
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) {
|
public void setSignalService(@Nullable MinigameVolumeSignalService signalService) {
|
||||||
this.signalService = signalService;
|
this.signalService = signalService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setQueueService(@Nullable MinigameQueueService queueService) {
|
||||||
|
this.queueService = queueService;
|
||||||
|
}
|
||||||
|
|
||||||
public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) {
|
public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) {
|
||||||
this.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());
|
var runtime = new MinigameRuntime(UUID.randomUUID().toString(), definition, selected.mapId(), selected.variantId());
|
||||||
runtime.phase(MinigamePhase.WAITING_FOR_PLAYERS);
|
runtime.phase(MinigamePhase.WAITING_FOR_PLAYERS);
|
||||||
runtimes.put(runtime.runtimeId(), runtime);
|
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)));
|
dispatch(new MinigameEvent("on_game_start", definition.getId(), eventPayload(runtime)));
|
||||||
return 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
|
@Override
|
||||||
public void end(String minigameId, String reason) {
|
public void end(String minigameId, String reason) {
|
||||||
for (MinigameRuntime runtime : matching(minigameId)) {
|
for (MinigameRuntime runtime : matching(minigameId)) {
|
||||||
|
if (hudService != null) {
|
||||||
|
hudService.removeAllForRuntime(runtime.runtimeId());
|
||||||
|
}
|
||||||
runtime.phase(MinigamePhase.ENDING);
|
runtime.phase(MinigamePhase.ENDING);
|
||||||
if (signalService != null) {
|
if (signalService != null) {
|
||||||
signalService.onPhaseChange(runtime, MinigamePhase.ENDING);
|
signalService.onPhaseChange(runtime, MinigamePhase.ENDING);
|
||||||
@@ -89,6 +207,10 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
clearRuntimeState(runtime);
|
clearRuntimeState(runtime);
|
||||||
runtimes.remove(runtime.runtimeId());
|
runtimes.remove(runtime.runtimeId());
|
||||||
if (runtime.definition().isResetOnEnd()) {
|
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)));
|
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);
|
LoadoutConfig loadout = resolveLoadout(runtime, playerState);
|
||||||
if (loadout != null) {
|
if (loadout != null) {
|
||||||
if (loadout.isPlayerCustomizable()) {
|
if (loadout.isPlayerCustomizable()) {
|
||||||
// TODO: show loadout selection UI and apply player's chosen loadout
|
if (menuService != null) {
|
||||||
LOGGER.atInfo().log("Player-customizable loadout UI not yet implemented: player=%s loadout=%s", playerId, loadout.getId());
|
menuService.requestLoadoutSelection(playerId, runtime);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for (ItemStackConfig item : loadout.getItems()) {
|
for (ItemStackConfig item : loadout.getItems()) {
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
@@ -142,6 +265,11 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
}
|
}
|
||||||
playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
|
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
|
@Override
|
||||||
@@ -240,6 +368,10 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
var donePayload = new HashMap<>(eventPayload(runtime));
|
var donePayload = new HashMap<>(eventPayload(runtime));
|
||||||
donePayload.put("final_round", current);
|
donePayload.put("final_round", current);
|
||||||
dispatch(new MinigameEvent("on_game_rounds_complete", runtime.minigameId(), Map.copyOf(donePayload)));
|
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 {
|
} else {
|
||||||
int next = current + 1;
|
int next = current + 1;
|
||||||
runtime.roundNumber(next);
|
runtime.roundNumber(next);
|
||||||
@@ -314,24 +446,25 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
private void sendEndScores(MinigameRuntime runtime) {
|
private void sendEndScores(MinigameRuntime runtime) {
|
||||||
if (playerAdapter == null || runtime.players().isEmpty()) return;
|
if (playerAdapter == null || runtime.players().isEmpty()) return;
|
||||||
|
|
||||||
boolean lowestWins = runtime.definition().getWinCondition() == WinCondition.LOWEST_SCORE;
|
boolean lowestTime = runtime.definition().getWinCondition() == WinCondition.LOWEST_TIME;
|
||||||
var scoreOrder = Comparator.comparingInt((PlayerMinigameState player) -> player.score());
|
List<String> ranked = buildRankedPlayers(runtime);
|
||||||
var ranked = runtime.players().entrySet().stream()
|
|
||||||
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
|
|
||||||
lowestWins ? scoreOrder : scoreOrder.reversed()))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
var recipients = runtime.players().keySet();
|
var recipients = runtime.players().keySet();
|
||||||
for (String recipient : recipients) {
|
for (String recipient : recipients) {
|
||||||
playerAdapter.sendMessage(recipient, Message.translation("minigames.endscores.header"));
|
playerAdapter.sendMessage(recipient, Message.translation("minigames.endscores.header"));
|
||||||
int rank = 1;
|
int rank = 1;
|
||||||
for (var entry : ranked) {
|
for (String playerId : ranked) {
|
||||||
String name = playerAdapter.getDisplayName(entry.getKey());
|
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,
|
playerAdapter.sendMessage(recipient,
|
||||||
Message.translation("minigames.endscores.entry")
|
Message.translation("minigames.endscores.entry")
|
||||||
.param("rank", rank)
|
.param("rank", rank)
|
||||||
.param("player", name)
|
.param("player", name)
|
||||||
.param("score", entry.getValue().score()));
|
.param("score", scoreText));
|
||||||
rank++;
|
rank++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,7 +511,21 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<String> buildRankedPlayers(MinigameRuntime runtime) {
|
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());
|
var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score());
|
||||||
return runtime.players().entrySet().stream()
|
return runtime.players().entrySet().stream()
|
||||||
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
|
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ public interface MinigameRuntimeService {
|
|||||||
Collection<MinigameRuntime> runtimes();
|
Collection<MinigameRuntime> runtimes();
|
||||||
void shutdownAll(String reason);
|
void shutdownAll(String reason);
|
||||||
void dispatch(MinigameRuntime runtime, String eventId, Map<String, Object> payload);
|
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_MAP_ROLE = "MapRole";
|
||||||
public static final String TAG_SPAWN_ROLE = "SpawnRole";
|
public static final String TAG_SPAWN_ROLE = "SpawnRole";
|
||||||
public static final String TAG_TEAM_ID = "TeamId";
|
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_MAIN_ARENA = "MainArena";
|
||||||
public static final String ROLE_TEAM_SPAWN = "TeamSpawn";
|
public static final String ROLE_TEAM_SPAWN = "TeamSpawn";
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,20 @@ public final class MinigameQueueService {
|
|||||||
.findFirst();
|
.findFirst();
|
||||||
|
|
||||||
if (winningMapId.isPresent()) {
|
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()) {
|
if (!voted.isEmpty()) {
|
||||||
return voted.get(random.nextInt(voted.size()));
|
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();
|
var services = services();
|
||||||
String playerId = resolvePlayerId(context, null);
|
String playerId = resolvePlayerId(context, null);
|
||||||
String id = resolvedMinigameId(context);
|
String id = resolvedMinigameId(context);
|
||||||
if (services != null && playerId != null && !id.isBlank()) {
|
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 {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "')");
|
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;
|
||||||
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementConfig;
|
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.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.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.PlayerRef;
|
||||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
import com.hypixel.hytale.server.npc.NPCPlugin;
|
import com.hypixel.hytale.server.npc.NPCPlugin;
|
||||||
@@ -165,7 +165,9 @@ public final class MountPlayerEffect extends MinigameRuntimeEffect {
|
|||||||
return;
|
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.applyDefaultSettings();
|
||||||
movementManager.update(playerRefComponent.getPacketHandler());
|
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;
|
package net.kewwbec.minigames.volume.effect;
|
||||||
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
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.Codec;
|
||||||
import com.hypixel.hytale.codec.KeyedCodec;
|
import com.hypixel.hytale.codec.KeyedCodec;
|
||||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
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.math.util.ChunkUtil;
|
||||||
import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType;
|
import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType;
|
||||||
import com.hypixel.hytale.server.core.universe.world.World;
|
import com.hypixel.hytale.server.core.universe.world.World;
|
||||||
|
import org.joml.Vector3d;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -41,6 +43,10 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
|
|||||||
.add()
|
.add()
|
||||||
.<Integer>append(new KeyedCodec<>("MaxBlocks", Codec.INTEGER, false), (effect, value) -> effect.maxBlocks = value, effect -> effect.maxBlocks)
|
.<Integer>append(new KeyedCodec<>("MaxBlocks", Codec.INTEGER, false), (effect, value) -> effect.maxBlocks = value, effect -> effect.maxBlocks)
|
||||||
.add()
|
.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();
|
.build();
|
||||||
|
|
||||||
private String blockId = "";
|
private String blockId = "";
|
||||||
@@ -51,6 +57,8 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
|
|||||||
private int y2 = 0;
|
private int y2 = 0;
|
||||||
private int z2 = 0;
|
private int z2 = 0;
|
||||||
private int maxBlocks = DEFAULT_MAX_BLOCKS;
|
private int maxBlocks = DEFAULT_MAX_BLOCKS;
|
||||||
|
private boolean fillVolume = false;
|
||||||
|
private boolean hollow = false;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void execute(@Nonnull TriggerContext context) {
|
public void execute(@Nonnull TriggerContext context) {
|
||||||
@@ -66,12 +74,30 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int minX = Math.min(x1, x2);
|
int minX, maxX, minY, maxY, minZ, maxZ;
|
||||||
int maxX = Math.max(x1, x2);
|
if (fillVolume) {
|
||||||
int minY = Math.max(0, Math.min(y1, y2));
|
VolumeEntry volume = context.getVolume();
|
||||||
int maxY = Math.min(319, Math.max(y1, y2));
|
if (volume == null) {
|
||||||
int minZ = Math.min(z1, z2);
|
debugMessage(context, TYPE_ID, "SKIPPED FillVolume=true but no volume in context");
|
||||||
int maxZ = Math.max(z1, z2);
|
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);
|
long totalBlocks = blockCount(minX, maxX, minY, maxY, minZ, maxZ);
|
||||||
int limit = maxBlocks > 0 ? maxBlocks : DEFAULT_MAX_BLOCKS;
|
int limit = maxBlocks > 0 ? maxBlocks : DEFAULT_MAX_BLOCKS;
|
||||||
@@ -84,6 +110,7 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final int fMinX = minX, fMaxX = maxX, fMinY = minY, fMaxY = maxY, fMinZ = minZ, fMaxZ = maxZ;
|
||||||
World world = context.getStore().getExternalData().getWorld();
|
World world = context.getStore().getExternalData().getWorld();
|
||||||
Map<Long, List<BlockPosition>> positionsByChunk = new HashMap<>();
|
Map<Long, List<BlockPosition>> positionsByChunk = new HashMap<>();
|
||||||
for (int x = minX; x <= maxX; x++) {
|
for (int x = minX; x <= maxX; x++) {
|
||||||
@@ -91,6 +118,9 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
|
|||||||
long chunkIndex = ChunkUtil.indexChunkFromBlock(x, z);
|
long chunkIndex = ChunkUtil.indexChunkFromBlock(x, z);
|
||||||
List<BlockPosition> positions = positionsByChunk.computeIfAbsent(chunkIndex, ignored -> new ArrayList<>());
|
List<BlockPosition> positions = positionsByChunk.computeIfAbsent(chunkIndex, ignored -> new ArrayList<>());
|
||||||
for (int y = minY; y <= maxY; y++) {
|
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));
|
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) {
|
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
|
@Nonnull
|
||||||
public static final BuilderCodec<SpawnItemEffect> CODEC =
|
public static final BuilderCodec<SpawnItemEffect> CODEC =
|
||||||
BuilderCodec.builder(SpawnItemEffect.class, SpawnItemEffect::new, MINIGAME_BASE_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()
|
.add()
|
||||||
.<Integer>append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
.<Integer>append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
||||||
.add()
|
.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);
|
var defOpt = services.minigames().definition(id);
|
||||||
if (defOpt.isPresent()) {
|
if (defOpt.isEmpty()) {
|
||||||
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 {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (definition not found for id='" + id + "')");
|
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.SpawnItem = Spawn Item
|
||||||
customUI.triggerVolumeEffectEditor.effectType.SpawnNpc = Spawn NPC
|
customUI.triggerVolumeEffectEditor.effectType.SpawnNpc = Spawn NPC
|
||||||
customUI.triggerVolumeEffectEditor.effectType.MountPlayer = Mount Player
|
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
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"Website": "https://kewwbec.net",
|
"Website": "https://kewwbec.net",
|
||||||
"ServerVersion": "^0.5.3",
|
"ServerVersion": "^0.5.3",
|
||||||
"Dependencies": {
|
"Dependencies": {
|
||||||
|
"Ellie:HyUI": "*"
|
||||||
},
|
},
|
||||||
"OptionalDependencies": {
|
"OptionalDependencies": {
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user