feat: Add new effects for player interactions and NPC management
- Introduced DismountPlayerEffect to handle player dismounting from NPCs. - Added MountPlayerEffect for mounting players onto NPCs with configurable parameters. - Implemented PlaceBlocksEffect to allow block placement in specified regions. - Created RespawnPlayerEffect with improved destination resolution for player respawns. - Added SetPlayerLoadoutEffect to manage player loadouts dynamically. - Introduced SetPlayerStatusEffect to manage player statuses with additional activation logic. - Created SpawnNpcEffect for spawning and managing NPCs with respawn capabilities. - Updated language files to include tooltips and descriptions for new effects.
This commit is contained in:
@@ -104,6 +104,9 @@ Current effect type IDs:
|
||||
- `VoteForMap`
|
||||
- `StartQueuedMinigame`
|
||||
- `SpawnItem`
|
||||
- `SpawnNpc`
|
||||
- `MountPlayer`
|
||||
- `PlaceBlocks`
|
||||
- `SetRound`
|
||||
- `StartTimer`
|
||||
- `StopTimer`
|
||||
|
||||
@@ -12,11 +12,13 @@ Current runtime events emitted by the service layer:
|
||||
|----------|------------|
|
||||
| `on_game_start` | A minigame runtime starts. |
|
||||
| `on_game_end` | A minigame runtime ends. |
|
||||
| `on_arena_reset` | A minigame runtime is reset. |
|
||||
| `on_arena_reset` | A minigame runtime is reset via `ResetMinigame`, or a game ends with `ResetOnEnd=true`. |
|
||||
| `on_phase_change` | `SetMinigamePhase` changes the runtime phase. |
|
||||
| `on_round_start` | `SetRound` starts a round, or the round timer advances to the next round. |
|
||||
| `on_round_end` | The internal round timer expires. |
|
||||
| `on_game_rounds_complete` | The final configured round ends. |
|
||||
| `on_overtime_start` | The final round ends and `OvertimeEnabled=true`. Phase is set to `OVERTIME`. Fired before `on_game_rounds_complete`. |
|
||||
| `on_sudden_death_start` | The final round ends and `SuddenDeathEnabled=true`. Phase is set to `SUDDEN_DEATH`. Fired before `on_game_rounds_complete`. |
|
||||
| `on_game_rounds_complete` | The final configured round ends (after any overtime/sudden-death events). |
|
||||
| `on_timer_expired` | A named signal timer expires. The internal `$round` timer is consumed by runtime round progression and is not re-dispatched. |
|
||||
| `on_player_eliminated` | `SetPlayerStatus` sets `ELIMINATED`, or `ModifyPlayerLives` reduces lives to zero. |
|
||||
| `on_objective_complete` | An objective status becomes `COMPLETE` or objective progress reaches completion. |
|
||||
@@ -68,6 +70,10 @@ All minigame effects that operate on a runtime include a `MinigameId` field. In
|
||||
| `VoteForMap` | `MinigameId`, optional `MapId` | Votes for a map in the waiting session. If `MapId` is blank, the triggering volume's `MapId` tag is used. |
|
||||
| `StartQueuedMinigame` | `MinigameId` | Starts a queued match using `MapSelectionMode` and discovered map tags. |
|
||||
| `SpawnItem` | optional `MinigameId`, `ItemId`, optional `Amount`, `RespawnDelaySeconds`, `SpawnKey`, `OffsetX`, `OffsetY`, `OffsetZ` | Spawns an item entity on the ground at the trigger volume position. When attached to a `TICK` volume, it respawns after the previous item is picked up or despawns and the delay has elapsed. |
|
||||
| `SpawnNpc` | optional `MinigameId`, `RoleId`, optional `Count`, `RespawnDelaySeconds`, `SpawnKey`, `OffsetX`, `OffsetY`, `OffsetZ`, `Yaw`, `Pitch`, `Roll`, `SpreadRadius` | Spawns one or more NPCs at the trigger volume position. Spawned NPCs are tracked as temporary runtime entities and can respawn after all active NPCs for the spawn key are gone. |
|
||||
| `MountPlayer` | optional `MinigameId`, `PlayerId`, `NpcId`, `NpcRoleId`, `MaxDistance`, `AnchorX`, `AnchorY`, `AnchorZ`, `MovementConfig`, `EmptyRoleId`, optional `PreventManualDismount` | Mounts the triggering or configured player onto the nearest matching tracked NPC. When `PreventManualDismount` is `true`, the player cannot dismount manually until a `DismountPlayer` effect runs. |
|
||||
| `DismountPlayer` | optional `MinigameId`, optional `PlayerId`, optional `NpcRoleId`, optional `RemoveNpc` | Dismounts the player from their NPC mount. `RemoveNpc` defaults to `true` and removes the mount entity from the world. Clears any manual-dismount lock set by `MountPlayer`. |
|
||||
| `PlaceBlocks` | optional `MinigameId`, `BlockId`, `X1`, `Y1`, `Z1`, `X2`, `Y2`, `Z2`, optional `MaxBlocks` | Places `BlockId` in the inclusive cuboid between the two world-coordinate corners. `MaxBlocks` defaults to `32768` as a guard against accidental large fills. |
|
||||
| `AwardKillScore` | optional `MinigameId`, `TargetIds`, `Points`, `AwardTarget` | Configures kill scoring for deaths inside this volume. `TargetIds` accepts `Player`, NPC role ids such as `fox`, or `*`. `AwardTarget` is `PLAYER`, `TEAM`, or `BOTH`. |
|
||||
| `EndMinigame` | `MinigameId`, optional `Reason` | Ends the selected minigame runtime. |
|
||||
| `ResetMinigame` | `MinigameId` | Resets the selected minigame runtime. |
|
||||
@@ -77,14 +83,15 @@ All minigame effects that operate on a runtime include a `MinigameId` field. In
|
||||
| `ModifyTeamScore` | `MinigameId`, `TeamId`, `Operation`, `Amount` | Sets, adds, or subtracts a team's score. |
|
||||
| `ModifyCounter` | `MinigameId`, `CounterId`, `Operation`, `Amount` | Sets, adds, or subtracts a named runtime counter. |
|
||||
| `ModifyPlayerLives` | `MinigameId`, optional `PlayerId`, `Operation`, `Amount` | Sets, adds, or subtracts a player's remaining lives. |
|
||||
| `SetPlayerStatus` | `MinigameId`, optional `PlayerId`, `Status` | Sets a player's minigame status. |
|
||||
| `SetPlayerStatus` | `MinigameId`, optional `PlayerId`, `Status` | Sets a player's minigame status. Setting `ACTIVE` triggers the activation lifecycle: saves inventory (if `SavePlayerInventory`), gives loadout or start items, and applies `RequiredGameMode`. |
|
||||
| `SetPlayerLoadout` | `MinigameId`, optional `PlayerId`, `LoadoutId` | Assigns a named loadout to a player. The loadout must exist in `StartLoadouts`. Takes effect the next time the player becomes `ACTIVE`. Pass an empty `LoadoutId` to clear the assignment. |
|
||||
| `SetRound` | `MinigameId`, `Operation`, `Amount` | Sets, adds, or subtracts the runtime round number and dispatches `on_round_start`. |
|
||||
| `StartTimer` | `MinigameId`, `TimerName`, `DurationSeconds` | Starts a named runtime timer and signal timer. |
|
||||
| `StopTimer` | `MinigameId`, `TimerName` | Stops a named runtime timer and cancels the signal timer. |
|
||||
| `SetSpawnPoint` | `MinigameId`, optional `PlayerId`, `DestinationId` | Stores a player's respawn destination/checkpoint. |
|
||||
| `RespawnPlayer` | `MinigameId`, optional `PlayerId`, optional `DestinationId` | Respawns a player using explicit destination, checkpoint, then team spawn fallback. |
|
||||
| `SendMinigameMessage` | `MinigameId`, `MessageKey`, `Target` | Sends a translated message to `PLAYER`, `TEAM`, or `ALL`. |
|
||||
| `AssignTeam` | `MinigameId`, optional `PlayerId`, `TeamId` | Assigns a player to a team in the runtime. |
|
||||
| `AssignTeam` | `MinigameId`, optional `PlayerId`, `TeamId`, optional `BalanceTeams` | Assigns a player to a team in the runtime. If `BalanceTeams` is true, evenly distributes all runtime players across existing teams or auto-creates `team1..teamN` using `TeamCount`, or `PlayersPerTeam` when `TeamCount` is `0`. |
|
||||
| `SaveInventory` | `MinigameId`, optional `PlayerId` | Saves the player's current inventory into runtime state. |
|
||||
| `RestoreInventory` | `MinigameId`, optional `PlayerId` | Restores the player's saved runtime inventory. |
|
||||
| `ClearInventory` | `MinigameId`, optional `PlayerId` | Clears the player's inventory. |
|
||||
@@ -117,12 +124,32 @@ Use an explicit `PlayerId` only when the trigger is not naturally tied to the pl
|
||||
|
||||
Team spawn volumes are normal Hytale trigger volumes tagged with `MinigameId`, `MapId`, optional `VariantId`, `SpawnRole=TeamSpawn`, and `TeamId=<team id>`.
|
||||
|
||||
Player deaths during a minigame use the same resolution order automatically. `DestinationId` and `SetSpawnPoint` checkpoint values are coordinate strings, such as `100,64,200` or `world_name,100,64,200`.
|
||||
|
||||
## Item Spawners
|
||||
|
||||
`SpawnItem` creates a normal Hytale item drop, not a direct inventory grant. Use it for health packs, ammo, weapons, armor, and other pickups placed in the arena.
|
||||
|
||||
For automatic respawns, attach it to a trigger volume that fires on `TICK`. The effect keeps one active item per volume plus `SpawnKey`; once that item reference is invalid, it waits `RespawnDelaySeconds` before spawning the next copy. If one volume hosts several item spawners, give each effect a different `SpawnKey`.
|
||||
|
||||
## NPC Spawners
|
||||
|
||||
`SpawnNpc` uses Hytale NPC role ids. `RoleId` is registered as a spawnable NPC role asset picker in the editor. Use `Count` plus `SpreadRadius` for small packs, and use `RespawnDelaySeconds` on a repeatedly firing volume when the encounter should come back after all NPCs for the same `SpawnKey` are gone.
|
||||
|
||||
## Player Mounts
|
||||
|
||||
`MountPlayer` uses Hytale's built-in NPC mount component. It mounts the player onto the nearest tracked NPC in the trigger volume, optionally filtered by `NpcRoleId` or exact `NpcId`. The trigger volume must track the NPC as well as the player, so include NPC target types on the volume when using nearest-NPC mounting.
|
||||
|
||||
`AnchorX`, `AnchorY`, and `AnchorZ` define the rider anchor passed to the client. `EmptyRoleId` defaults to `Empty_Role`, matching Hytale's built-in mount action. `MovementConfig` is optional; set it when the mounted player should use horse-style movement settings.
|
||||
|
||||
Set `PreventManualDismount=true` to lock the player onto the mount. The client's dismount packet is silently dropped until `DismountPlayer` is called. `DismountPlayer` always clears the lock, dismounts the player, and removes the NPC entity from the world by default (`RemoveNpc=true`). Filter by `NpcRoleId` if multiple mount types are active at the same time.
|
||||
|
||||
## Block Placement
|
||||
|
||||
`PlaceBlocks` fills an inclusive world-coordinate cuboid with one block type. Configure `BlockId`, `X1`, `Y1`, `Z1`, `X2`, `Y2`, and `Z2`; coordinates can be provided in either corner order.
|
||||
|
||||
The effect clamps Y to the valid world range `0..319`. Use `MaxBlocks` when a fill intentionally exceeds the default `32768` block guard.
|
||||
|
||||
## Kill Scoring
|
||||
|
||||
`AwardKillScore` is configured on a normal trigger volume, but scoring is applied by the minigame death system rather than by volume enter/exit/tick. The volume must cover the area where deaths should count and should use the normal `MinigameId`, `MapId`, and optional `VariantId` tags for runtime routing.
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ Required fields:
|
||||
- `Operation`
|
||||
- `Amount`
|
||||
|
||||
The current implementation expects an explicit `TeamId`.
|
||||
Use `AssignTeam` with `BalanceTeams=true` to split all players in the started runtime evenly across teams. If no teams already exist, the effect creates `team1..teamN` from the minigame's `TeamCount`.
|
||||
|
||||
## Counter Gate
|
||||
|
||||
|
||||
+49
-16
@@ -18,7 +18,7 @@ Minigame definitions live in `src/main/resources/Server/Minigames/<Id>.json`. JS
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `WorldId` | String | No | `""` | Optional world identifier. |
|
||||
| `RequiredGameMode` | String | No | `"adventure"` | Game mode expected for players. |
|
||||
| `RequiredGameMode` | Enum | No | `Adventure` | Game mode applied to each player when they become active. |
|
||||
|
||||
## Players
|
||||
|
||||
@@ -26,6 +26,7 @@ Minigame definitions live in `src/main/resources/Server/Minigames/<Id>.json`. JS
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `MinPlayers` | Integer | No | `1` | Minimum player count. |
|
||||
| `MaxPlayers` | Integer | No | `16` | Maximum player count. |
|
||||
| `DefaultPlayerLives` | Integer | No | `0` | Initial lives assigned to each player when a queued runtime starts. |
|
||||
| `AllowJoinMidgame` | Boolean | No | `false` | Whether players can join after the game starts. |
|
||||
| `AllowSpectators` | Boolean | No | `true` | Whether spectators are allowed. |
|
||||
|
||||
@@ -34,8 +35,8 @@ Minigame definitions live in `src/main/resources/Server/Minigames/<Id>.json`. JS
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `TeamMode` | Enum | No | `SOLO` | `SOLO`, `TEAMS`, or `FREE_FOR_ALL`. |
|
||||
| `TeamCount` | Integer | No | `0` | Number of teams when team mode is enabled. `0` means automatic. |
|
||||
| `PlayersPerTeam` | Integer | No | `0` | Maximum players per team. `0` means no explicit limit. |
|
||||
| `TeamCount` | Integer | No | `0` | Explicit number of teams when using `BalanceTeams`. `0` defers to `PlayersPerTeam`. |
|
||||
| `PlayersPerTeam` | Integer | No | `0` | When `TeamCount` is `0`, `BalanceTeams` divides total players by this to determine team count. `0` means no automatic splitting. |
|
||||
|
||||
## Timing
|
||||
|
||||
@@ -44,14 +45,14 @@ Minigame definitions live in `src/main/resources/Server/Minigames/<Id>.json`. JS
|
||||
| `RoundLengthSeconds` | Integer | No | `300` | Active round duration. |
|
||||
| `TotalRounds` | Integer | No | `1` | Number of rounds to run. Values less than or equal to `0` allow open-ended round progression. |
|
||||
| `CountdownSeconds` | Integer | No | `10` | Pre-game countdown duration. |
|
||||
| `OvertimeEnabled` | Boolean | No | `false` | Enables overtime handling. |
|
||||
| `SuddenDeathEnabled` | Boolean | No | `false` | Enables sudden-death handling. |
|
||||
| `OvertimeEnabled` | Boolean | No | `false` | When the final round ends, sets the phase to `OVERTIME` and dispatches `on_overtime_start` before `on_game_rounds_complete`. |
|
||||
| `SuddenDeathEnabled` | Boolean | No | `false` | When the final round ends (after overtime if also enabled), sets the phase to `SUDDEN_DEATH` and dispatches `on_sudden_death_start`. |
|
||||
|
||||
## Win Condition
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `WinCondition` | Enum | No | `HIGHEST_SCORE` | How the winner is determined. |
|
||||
| `WinCondition` | Enum | No | `HIGHEST_SCORE` | How the winner is determined. Affects end-score display order and reward distribution. |
|
||||
| `MapSelectionMode` | Enum | No | `VOTE` | How a waiting session chooses a discovered map: `VOTE` or `RANDOM`. |
|
||||
| `FriendlyFireKillScoreMode` | Enum | No | `NO_POINTS` | How same-team player kills affect kill scoring: `OFF`, `LOSES_POINTS`, or `NO_POINTS`. |
|
||||
|
||||
@@ -59,7 +60,8 @@ Supported values:
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `HIGHEST_SCORE` | Highest score at the end wins. |
|
||||
| `HIGHEST_SCORE` | Highest score at the end wins. Players ranked descending. |
|
||||
| `LOWEST_SCORE` | Lowest score at the end wins. Players ranked ascending. |
|
||||
| `FIRST_TO_SCORE` | First player or team to a target score wins. |
|
||||
| `LAST_ALIVE` | Last active player or team wins. |
|
||||
| `OBJECTIVE_COMPLETE` | Objective completion determines the winner. |
|
||||
@@ -70,23 +72,24 @@ Supported values:
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `AllowPvp` | Boolean | No | `false` | Whether players can damage each other. |
|
||||
| `AllowBlockBreaking` | Boolean | No | `false` | Whether blocks can be broken. |
|
||||
| `AllowBlockPlacing` | Boolean | No | `false` | Whether blocks can be placed. |
|
||||
| `AllowBlockBreaking` | Boolean | No | `false` | Whether blocks can be broken. Readable via definition; enforcement relies on `RequiredGameMode` (`Adventure` blocks both break and place by default). |
|
||||
| `AllowBlockPlacing` | Boolean | No | `false` | Whether blocks can be placed. Same enforcement note as above. |
|
||||
|
||||
## Inventory
|
||||
## Lifecycle
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `SavePlayerInventory` | Boolean | No | `true` | Save player inventory on join. |
|
||||
| `RestorePlayerInventory` | Boolean | No | `true` | Restore player inventory when the game ends. |
|
||||
| `ResetOnEnd` | Boolean | No | `true` | Reset runtime state when the game ends. |
|
||||
| `ResetOnEnd` | Boolean | No | `true` | After clearing runtime state at game end, dispatches `on_arena_reset` so trigger volumes can reset world state. |
|
||||
| `SavePlayerInventory` | Boolean | No | `true` | Saves each player's inventory the first time they become `ACTIVE`. Restored on game end if `RestorePlayerInventory` is true. |
|
||||
| `RestorePlayerInventory` | Boolean | No | `true` | Restores each player's saved inventory when the game ends. |
|
||||
|
||||
## Items
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `StartItems` | `ItemStackConfig[]` | No | `[]` | Items given to players at game start. |
|
||||
| `Rewards` | `RewardConfig[]` | No | `[]` | Rewards distributed at game end. |
|
||||
| `StartItems` | `ItemStackConfig[]` | No | `[]` | Items given to each player when they become `ACTIVE`, if they have no assigned loadout. |
|
||||
| `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. |
|
||||
|
||||
## ItemStackConfig
|
||||
|
||||
@@ -102,6 +105,28 @@ Supported values:
|
||||
| `ItemId` | String | Yes | Hytale item ID. |
|
||||
| `Amount` | Integer | Yes | Stack amount. |
|
||||
|
||||
## LoadoutConfig
|
||||
|
||||
```json
|
||||
{
|
||||
"Id": "archer",
|
||||
"DisplayName": "Archer",
|
||||
"Items": [
|
||||
{ "ItemId": "hunting_bow", "Amount": 1 },
|
||||
{ "ItemId": "arrow", "Amount": 32 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `Id` | String | Yes | Loadout ID referenced by `SetPlayerLoadout`. |
|
||||
| `DisplayName` | String | No | Player-facing name for UI display. |
|
||||
| `Items` | `ItemStackConfig[]` | No | Items given when this loadout is applied. |
|
||||
| `PlayerCustomizable` | Boolean | No | Reserved for a future loadout-selection UI. When `true`, item giving is skipped at activation. Defaults to `false`. |
|
||||
|
||||
Loadouts are selected by a `SetPlayerLoadout` effect before the player becomes `ACTIVE`. If no loadout is assigned, `StartItems` is used as a fallback.
|
||||
|
||||
## RewardConfig
|
||||
|
||||
```json
|
||||
@@ -115,9 +140,17 @@ Supported values:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `Target` | String | Yes | `winner`, `participant`, `team_winner`, or a team ID. |
|
||||
| `Target` | String | Yes | Who receives the reward. See target values below. |
|
||||
| `Items` | `ItemStackConfig[]` | Yes | Items to grant. |
|
||||
|
||||
Reward target values:
|
||||
|
||||
| Value | Recipients |
|
||||
|-------|------------|
|
||||
| `all` | Every player in the runtime. |
|
||||
| `winner` | The player ranked first (rank determined by `WinCondition`). |
|
||||
| `top_N` | Top N players, e.g. `top_3`. |
|
||||
|
||||
## Minimal Example
|
||||
|
||||
```json
|
||||
|
||||
@@ -63,6 +63,7 @@ Signal tags let runtime state changes fire normal Hytale trigger-volume logic by
|
||||
|-----|----------|--------|----------|
|
||||
| `SignalRoundStart` | No | Round number, such as `1` | Fires once when that round starts. |
|
||||
| `SignalRoundTick` | No | Round number, such as `1` | Fires repeatedly while that round is active. |
|
||||
| `SignalPhaseStart` | No | `MinigamePhase` enum name, such as `ACTIVE` or `ENDING` | Fires once when the runtime enters that phase. |
|
||||
| `SignalPhaseTick` | No | `MinigamePhase` enum name, such as `ACTIVE` | Fires repeatedly while the runtime is in that phase. |
|
||||
| `SignalTickDelay` | No | Seconds, such as `1` or `0.5` | Delay between repeated signal fires. Minimum effective delay is 50ms. |
|
||||
| `SignalOnTimerExpire` | No | Timer name from `StartTimer` | Fires once when the named timer expires. |
|
||||
@@ -87,6 +88,14 @@ SignalPhaseTick=ACTIVE
|
||||
SignalTickDelay=1
|
||||
```
|
||||
|
||||
Game-end phase example:
|
||||
|
||||
```text
|
||||
MinigameId=KOTH
|
||||
MapId=Castle
|
||||
SignalPhaseStart=ENDING
|
||||
```
|
||||
|
||||
Timer-expire example:
|
||||
|
||||
```text
|
||||
|
||||
@@ -90,6 +90,8 @@ Tag each team spawn volume with:
|
||||
|
||||
This means checkpoint-based games can still override spawns per player, while team arena games can rely on tagged team spawn volumes.
|
||||
|
||||
Player deaths during a minigame automatically use the same destination order. `DestinationId` and `SetSpawnPoint` values are coordinate strings, such as `100,64,200` or `world_name,100,64,200`; team-spawn fallback is resolved from tagged spawn volumes.
|
||||
|
||||
### Counters
|
||||
|
||||
Use a trigger volume to increment a named runtime counter:
|
||||
@@ -117,6 +119,15 @@ Use a small trigger volume at the pickup location and attach:
|
||||
|
||||
The item spawns at the volume position plus optional `OffsetX`, `OffsetY`, and `OffsetZ`. Attach the effect to a `TICK` trigger when the pickup should automatically respawn after being collected or despawning. If the same volume has multiple item spawners, set a different `SpawnKey` for each one.
|
||||
|
||||
### Block Fill Effects
|
||||
|
||||
Attach `PlaceBlocks` when a trigger should place or replace a cuboid of blocks:
|
||||
|
||||
- Effect: `PlaceBlocks`
|
||||
- Fields: `BlockId = <block id>`, `X1/Y1/Z1 = <first world corner>`, `X2/Y2/Z2 = <second world corner>`
|
||||
|
||||
The region is inclusive and uses absolute world block coordinates. `MaxBlocks` defaults to `32768`.
|
||||
|
||||
## Reusable Effect Assets
|
||||
|
||||
For repeated logic, define a Hytale trigger effect asset and attach it to multiple trigger volumes. The minigame trigger condition/effect entries are normal codec types, so they can be reused the same way Hytale's built-in trigger-volume effects are reused.
|
||||
|
||||
@@ -6,11 +6,15 @@ import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.asset.TriggerEffectAsset;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerCondition;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect;
|
||||
import com.hypixel.hytale.component.ComponentType;
|
||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||
import com.hypixel.hytale.server.core.io.ServerManager;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.server.core.asset.HytaleAssetStore;
|
||||
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
|
||||
import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
|
||||
import com.hypixel.hytale.server.npc.NPCPlugin;
|
||||
import net.kewwbec.minigames.adapter.HytaleServerAdapters;
|
||||
import net.kewwbec.minigames.command.MinigameCommand;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
@@ -20,6 +24,7 @@ import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||
import net.kewwbec.minigames.service.MinigameQueueService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
|
||||
import net.kewwbec.minigames.system.MinigameDeathRespawnSystem;
|
||||
import net.kewwbec.minigames.system.MinigameKillScoreSystem;
|
||||
import net.kewwbec.minigames.volume.condition.CounterCondition;
|
||||
import net.kewwbec.minigames.volume.condition.CurrentRoundCondition;
|
||||
@@ -43,6 +48,11 @@ import net.kewwbec.minigames.volume.effect.ModifyObjectiveProgressEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ModifyPlayerLivesEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ModifyPlayerScoreEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ModifyTeamScoreEffect;
|
||||
import net.kewwbec.minigames.mount.LockMountComponent;
|
||||
import net.kewwbec.minigames.mount.LockMountPacketHandler;
|
||||
import net.kewwbec.minigames.volume.effect.DismountPlayerEffect;
|
||||
import net.kewwbec.minigames.volume.effect.MountPlayerEffect;
|
||||
import net.kewwbec.minigames.volume.effect.PlaceBlocksEffect;
|
||||
import net.kewwbec.minigames.volume.effect.RespawnPlayerEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ResetMinigameEffect;
|
||||
import net.kewwbec.minigames.volume.effect.ResetScoresEffect;
|
||||
@@ -51,11 +61,13 @@ import net.kewwbec.minigames.volume.effect.SaveInventoryEffect;
|
||||
import net.kewwbec.minigames.volume.effect.SendMinigameMessageEffect;
|
||||
import net.kewwbec.minigames.volume.effect.SetMinigamePhaseEffect;
|
||||
import net.kewwbec.minigames.volume.effect.SetObjectiveStatusEffect;
|
||||
import net.kewwbec.minigames.volume.effect.SetPlayerLoadoutEffect;
|
||||
import net.kewwbec.minigames.volume.effect.SetPlayerStatusEffect;
|
||||
import net.kewwbec.minigames.volume.effect.SetRoundEffect;
|
||||
import net.kewwbec.minigames.volume.effect.SetSpawnPointEffect;
|
||||
import net.kewwbec.minigames.volume.effect.JoinMinigameQueueEffect;
|
||||
import net.kewwbec.minigames.volume.effect.SpawnItemEffect;
|
||||
import net.kewwbec.minigames.volume.effect.SpawnNpcEffect;
|
||||
import net.kewwbec.minigames.volume.effect.StartMinigameEffect;
|
||||
import net.kewwbec.minigames.volume.effect.StartQueuedMinigameEffect;
|
||||
import net.kewwbec.minigames.volume.effect.StartTimerEffect;
|
||||
@@ -70,6 +82,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
private static MinigameCorePlugin instance;
|
||||
private MinigameServices services;
|
||||
private ComponentType<EntityStore, LockMountComponent> lockMountComponentType;
|
||||
|
||||
public MinigameCorePlugin(@Nonnull JavaPluginInit init) {
|
||||
super(init);
|
||||
@@ -78,7 +91,10 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
@Override
|
||||
protected void setup() {
|
||||
instance = this;
|
||||
this.lockMountComponentType = getEntityStoreRegistry().registerComponent(LockMountComponent.class, LockMountComponent::new);
|
||||
ServerManager.get().registerSubPacketHandlers(LockMountPacketHandler::new);
|
||||
getEntityStoreRegistry().registerSystem(new MinigameKillScoreSystem());
|
||||
getEntityStoreRegistry().registerSystem(new MinigameDeathRespawnSystem());
|
||||
registerTriggerVolumeEffectTypes();
|
||||
registerTriggerVolumeConditionTypes();
|
||||
|
||||
@@ -98,6 +114,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
var signals = new MinigameVolumeSignalService();
|
||||
runtimeService.setSignalService(signals);
|
||||
runtimeService.setPlayerAdapter(adapters);
|
||||
runtimeService.setEntityAdapter(adapters);
|
||||
runtimeService.setInventoryAdapter(adapters);
|
||||
this.services = new MinigameServices(minigameService, runtimeService, adapters, new MinigameMapDiscoveryService(), new MinigameQueueService(), signals);
|
||||
registerTriggerVolumeAssetSources();
|
||||
|
||||
@@ -121,6 +139,10 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
return instance != null ? instance.services : null;
|
||||
}
|
||||
|
||||
public static ComponentType<EntityStore, LockMountComponent> getLockMountComponentType() {
|
||||
return instance.lockMountComponentType;
|
||||
}
|
||||
|
||||
private void registerTriggerVolumeEffectTypes() {
|
||||
var triggerVolumes = TriggerVolumesPlugin.get();
|
||||
registerTriggerVolumeEffectType(triggerVolumes, StartMinigameEffect.TYPE_ID, StartMinigameEffect.class, StartMinigameEffect.CODEC);
|
||||
@@ -133,11 +155,15 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
registerTriggerVolumeEffectType(triggerVolumes, ModifyCounterEffect.TYPE_ID, ModifyCounterEffect.class, ModifyCounterEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, ModifyPlayerLivesEffect.TYPE_ID, ModifyPlayerLivesEffect.class, ModifyPlayerLivesEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, SetPlayerStatusEffect.TYPE_ID, SetPlayerStatusEffect.class, SetPlayerStatusEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, SetPlayerLoadoutEffect.TYPE_ID, SetPlayerLoadoutEffect.class, SetPlayerLoadoutEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, JoinMinigameQueueEffect.TYPE_ID, JoinMinigameQueueEffect.class, JoinMinigameQueueEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, LeaveMinigameQueueEffect.TYPE_ID, LeaveMinigameQueueEffect.class, LeaveMinigameQueueEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, VoteForMapEffect.TYPE_ID, VoteForMapEffect.class, VoteForMapEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, StartQueuedMinigameEffect.TYPE_ID, StartQueuedMinigameEffect.class, StartQueuedMinigameEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, SpawnItemEffect.TYPE_ID, SpawnItemEffect.class, SpawnItemEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, SpawnNpcEffect.TYPE_ID, SpawnNpcEffect.class, SpawnNpcEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, MountPlayerEffect.TYPE_ID, MountPlayerEffect.class, MountPlayerEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, DismountPlayerEffect.TYPE_ID, DismountPlayerEffect.class, DismountPlayerEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, SetRoundEffect.TYPE_ID, SetRoundEffect.class, SetRoundEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, StartTimerEffect.TYPE_ID, StartTimerEffect.class, StartTimerEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, StopTimerEffect.TYPE_ID, StopTimerEffect.class, StopTimerEffect.CODEC);
|
||||
@@ -151,6 +177,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
registerTriggerVolumeEffectType(triggerVolumes, SetObjectiveStatusEffect.TYPE_ID, SetObjectiveStatusEffect.class, SetObjectiveStatusEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID, ModifyObjectiveProgressEffect.class, ModifyObjectiveProgressEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, AwardKillScoreEffect.TYPE_ID, AwardKillScoreEffect.class, AwardKillScoreEffect.CODEC);
|
||||
registerTriggerVolumeEffectType(triggerVolumes, PlaceBlocksEffect.TYPE_ID, PlaceBlocksEffect.class, PlaceBlocksEffect.CODEC);
|
||||
}
|
||||
|
||||
private <T extends TriggerEffect> void registerTriggerVolumeEffectType(
|
||||
@@ -202,6 +229,11 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
}
|
||||
|
||||
triggerVolumes.registerAssetSource("MinigameDefinition", () -> MinigameDefinition.getAssetMap().getAssetMap().keySet());
|
||||
triggerVolumes.registerAssetSource("NpcRole", () -> {
|
||||
var npcPlugin = NPCPlugin.get();
|
||||
return npcPlugin != null ? npcPlugin.getRoleTemplateNames(true) : java.util.List.<String>of();
|
||||
});
|
||||
triggerVolumes.registerAssetSource("MovementConfig", () -> com.hypixel.hytale.server.core.entity.entities.player.movement.MovementConfig.getAssetMap().getAssetMap().keySet());
|
||||
registerMinigameIdAssetField(triggerVolumes, StartMinigameEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, EndMinigameEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, ResetMinigameEffect.TYPE_ID);
|
||||
@@ -212,11 +244,20 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
registerMinigameIdAssetField(triggerVolumes, ModifyCounterEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, ModifyPlayerLivesEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SetPlayerStatusEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SetPlayerLoadoutEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, JoinMinigameQueueEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, LeaveMinigameQueueEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, VoteForMapEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, StartQueuedMinigameEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SpawnItemEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, SpawnNpcEffect.TYPE_ID);
|
||||
triggerVolumes.registerAssetField(SpawnNpcEffect.TYPE_ID, "RoleId", "NpcRole");
|
||||
registerMinigameIdAssetField(triggerVolumes, MountPlayerEffect.TYPE_ID);
|
||||
triggerVolumes.registerAssetField(MountPlayerEffect.TYPE_ID, "NpcRoleId", "NpcRole");
|
||||
triggerVolumes.registerAssetField(MountPlayerEffect.TYPE_ID, "EmptyRoleId", "NpcRole");
|
||||
triggerVolumes.registerAssetField(MountPlayerEffect.TYPE_ID, "MovementConfig", "MovementConfig");
|
||||
registerMinigameIdAssetField(triggerVolumes, DismountPlayerEffect.TYPE_ID);
|
||||
triggerVolumes.registerAssetField(DismountPlayerEffect.TYPE_ID, "NpcRoleId", "NpcRole");
|
||||
registerMinigameIdAssetField(triggerVolumes, SetRoundEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, StartTimerEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, StopTimerEffect.TYPE_ID);
|
||||
@@ -230,6 +271,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
registerMinigameIdAssetField(triggerVolumes, SetObjectiveStatusEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, AwardKillScoreEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, PlaceBlocksEffect.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, MinigamePhaseCondition.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, PlayerInMinigameCondition.TYPE_ID);
|
||||
registerMinigameIdAssetField(triggerVolumes, PlayerStatusCondition.TYPE_ID);
|
||||
@@ -260,10 +302,14 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
|| typeId.startsWith("SetRound") || typeId.startsWith("Start") || typeId.startsWith("Stop")
|
||||
|| typeId.equals(JoinMinigameQueueEffect.TYPE_ID) || typeId.equals(LeaveMinigameQueueEffect.TYPE_ID)
|
||||
|| typeId.equals(VoteForMapEffect.TYPE_ID) || typeId.equals(StartQueuedMinigameEffect.TYPE_ID)
|
||||
|| typeId.equals(SpawnItemEffect.TYPE_ID) || typeId.equals(RespawnPlayerEffect.TYPE_ID)
|
||||
|| typeId.equals(SpawnItemEffect.TYPE_ID) || typeId.equals(SpawnNpcEffect.TYPE_ID)
|
||||
|| typeId.equals(MountPlayerEffect.TYPE_ID) || typeId.equals(DismountPlayerEffect.TYPE_ID)
|
||||
|| typeId.equals(SetPlayerLoadoutEffect.TYPE_ID)
|
||||
|| typeId.equals(RespawnPlayerEffect.TYPE_ID)
|
||||
|| typeId.equals(SendMinigameMessageEffect.TYPE_ID) || typeId.equals(AssignTeamEffect.TYPE_ID)
|
||||
|| typeId.equals(SaveInventoryEffect.TYPE_ID) || typeId.equals(RestoreInventoryEffect.TYPE_ID)
|
||||
|| typeId.equals(ClearInventoryEffect.TYPE_ID) || typeId.equals(AwardKillScoreEffect.TYPE_ID)) {
|
||||
|| typeId.equals(ClearInventoryEffect.TYPE_ID) || typeId.equals(AwardKillScoreEffect.TYPE_ID)
|
||||
|| typeId.equals(PlaceBlocksEffect.TYPE_ID)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.kewwbec.minigames.adapter;
|
||||
|
||||
import com.hypixel.hytale.protocol.GameMode;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -20,6 +21,8 @@ public final class HytaleAdapters {
|
||||
boolean isOnline(String playerId);
|
||||
void sendMessage(String playerId, Message message);
|
||||
String getDisplayName(String playerId);
|
||||
void setGameMode(String playerId, GameMode gameMode);
|
||||
void giveItem(String playerId, String itemId, int amount);
|
||||
}
|
||||
|
||||
public interface WorldAdapter {
|
||||
|
||||
@@ -7,11 +7,16 @@ import com.hypixel.hytale.component.RemoveReason;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.math.vector.Rotation3f;
|
||||
import com.hypixel.hytale.math.vector.Transform;
|
||||
import com.hypixel.hytale.protocol.GameMode;
|
||||
import com.hypixel.hytale.protocol.SoundCategory;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.asset.type.item.config.Item;
|
||||
import com.hypixel.hytale.server.core.asset.type.soundevent.config.SoundEvent;
|
||||
import com.hypixel.hytale.server.core.entity.ItemUtils;
|
||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
||||
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
|
||||
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
|
||||
import com.hypixel.hytale.server.core.inventory.ItemStack;
|
||||
import com.hypixel.hytale.server.core.modules.entity.teleport.Teleport;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.Universe;
|
||||
@@ -70,6 +75,32 @@ public final class HytaleServerAdapters implements
|
||||
return findPlayer(playerId).map(PlayerRef::getUsername).orElse(playerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setGameMode(String playerId, GameMode gameMode) {
|
||||
if (gameMode == null) return;
|
||||
withPlayerRef(playerId, ref -> {
|
||||
Player.setGameMode(ref, gameMode, ref.getStore());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void giveItem(String playerId, String itemId, int amount) {
|
||||
if (itemId == null || itemId.isBlank() || amount <= 0) return;
|
||||
withPlayerRef(playerId, ref -> {
|
||||
Item item = Item.getAssetMap().getAsset(itemId);
|
||||
if (item != null) {
|
||||
ItemStack stack = new ItemStack(item.getId(), amount);
|
||||
var transaction = Player.giveItem(stack, ref, ref.getStore());
|
||||
var remainder = transaction.getRemainder();
|
||||
if (!ItemStack.isEmpty(remainder)) {
|
||||
ItemUtils.dropItem(ref, remainder, ref.getStore());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean worldExists(String worldId) {
|
||||
var universe = Universe.get();
|
||||
|
||||
@@ -17,7 +17,7 @@ public final class Enums {
|
||||
}
|
||||
|
||||
public enum WinCondition {
|
||||
HIGHEST_SCORE, FIRST_TO_SCORE, LAST_ALIVE, OBJECTIVE_COMPLETE, CUSTOM
|
||||
HIGHEST_SCORE, LOWEST_SCORE, FIRST_TO_SCORE, LAST_ALIVE, OBJECTIVE_COMPLETE, CUSTOM
|
||||
}
|
||||
|
||||
public enum MapSelectionMode {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package net.kewwbec.minigames.model;
|
||||
|
||||
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.array.ArrayCodec;
|
||||
import com.hypixel.hytale.codec.validation.Validators;
|
||||
|
||||
public class LoadoutConfig {
|
||||
public static final BuilderCodec<LoadoutConfig> CODEC = BuilderCodec.builder(LoadoutConfig.class, LoadoutConfig::new)
|
||||
.<String>append(
|
||||
new KeyedCodec<>("Id", Codec.STRING),
|
||||
(cfg, v) -> cfg.id = v,
|
||||
cfg -> cfg.id
|
||||
)
|
||||
.addValidator(Validators.nonNull())
|
||||
.add()
|
||||
.<String>append(
|
||||
new KeyedCodec<>("DisplayName", Codec.STRING),
|
||||
(cfg, v) -> cfg.displayName = v,
|
||||
cfg -> cfg.displayName
|
||||
)
|
||||
.add()
|
||||
.<ItemStackConfig[]>append(
|
||||
new KeyedCodec<>("Items", new ArrayCodec<>(ItemStackConfig.CODEC, ItemStackConfig[]::new)),
|
||||
(cfg, v) -> cfg.items = v,
|
||||
cfg -> cfg.items
|
||||
)
|
||||
.add()
|
||||
.<Boolean>append(
|
||||
new KeyedCodec<>("PlayerCustomizable", Codec.BOOLEAN, false),
|
||||
(cfg, v) -> cfg.playerCustomizable = v,
|
||||
cfg -> cfg.playerCustomizable
|
||||
)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
protected String id;
|
||||
protected String displayName = "";
|
||||
protected ItemStackConfig[] items = new ItemStackConfig[0];
|
||||
protected boolean playerCustomizable = false;
|
||||
|
||||
public LoadoutConfig() {}
|
||||
|
||||
public String getId() { return id; }
|
||||
public String getDisplayName() { return displayName != null ? displayName : ""; }
|
||||
public ItemStackConfig[] getItems() { return items != null ? items : new ItemStackConfig[0]; }
|
||||
public boolean isPlayerCustomizable() { return playerCustomizable; }
|
||||
}
|
||||
@@ -144,6 +144,13 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
||||
(def, parent) -> def.countdownSeconds = parent.countdownSeconds
|
||||
)
|
||||
.add()
|
||||
.<Integer>appendInherited(
|
||||
new KeyedCodec<>("DefaultPlayerLives", Codec.INTEGER, false),
|
||||
(def, v) -> def.defaultPlayerLives = v,
|
||||
def -> def.defaultPlayerLives,
|
||||
(def, parent) -> def.defaultPlayerLives = parent.defaultPlayerLives
|
||||
)
|
||||
.add()
|
||||
.<Boolean>appendInherited(
|
||||
new KeyedCodec<>("OvertimeEnabled", Codec.BOOLEAN),
|
||||
(def, v) -> def.overtimeEnabled = v,
|
||||
@@ -243,6 +250,13 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
||||
(def, parent) -> def.startItems = parent.startItems
|
||||
)
|
||||
.add()
|
||||
.<LoadoutConfig[]>appendInherited(
|
||||
new KeyedCodec<>("StartLoadouts", new ArrayCodec<>(LoadoutConfig.CODEC, LoadoutConfig[]::new)),
|
||||
(def, v) -> def.startLoadouts = v,
|
||||
def -> def.startLoadouts,
|
||||
(def, parent) -> def.startLoadouts = parent.startLoadouts
|
||||
)
|
||||
.add()
|
||||
.<RewardConfig[]>appendInherited(
|
||||
new KeyedCodec<>("Rewards", new ArrayCodec<>(RewardConfig.CODEC, RewardConfig[]::new)),
|
||||
(def, v) -> def.rewards = v,
|
||||
@@ -282,6 +296,7 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
||||
protected int roundLengthSeconds = 300;
|
||||
protected int totalRounds = 1;
|
||||
protected int countdownSeconds = 10;
|
||||
protected int defaultPlayerLives = 0;
|
||||
protected boolean overtimeEnabled = false;
|
||||
protected boolean suddenDeathEnabled = false;
|
||||
protected WinCondition winCondition = WinCondition.HIGHEST_SCORE;
|
||||
@@ -296,6 +311,7 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
||||
protected boolean allowBlockBreaking = false;
|
||||
protected boolean allowBlockPlacing = false;
|
||||
protected ItemStackConfig[] startItems = new ItemStackConfig[0];
|
||||
protected LoadoutConfig[] startLoadouts = new LoadoutConfig[0];
|
||||
protected RewardConfig[] rewards = new RewardConfig[0];
|
||||
|
||||
protected MinigameDefinition() {}
|
||||
@@ -341,6 +357,7 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
||||
public int getRoundLengthSeconds() { return roundLengthSeconds; }
|
||||
public int getTotalRounds() { return totalRounds; }
|
||||
public int getCountdownSeconds() { return countdownSeconds; }
|
||||
public int getDefaultPlayerLives() { return defaultPlayerLives; }
|
||||
public boolean isOvertimeEnabled() { return overtimeEnabled; }
|
||||
public boolean isSuddenDeathEnabled() { return suddenDeathEnabled; }
|
||||
public WinCondition getWinCondition() { return winCondition != null ? winCondition : WinCondition.HIGHEST_SCORE; }
|
||||
@@ -355,6 +372,7 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
||||
public boolean isAllowBlockBreaking() { return allowBlockBreaking; }
|
||||
public boolean isAllowBlockPlacing() { return allowBlockPlacing; }
|
||||
public ItemStackConfig[] getStartItems() { return startItems != null ? startItems : new ItemStackConfig[0]; }
|
||||
public LoadoutConfig[] getStartLoadouts() { return startLoadouts != null ? startLoadouts : new LoadoutConfig[0]; }
|
||||
public RewardConfig[] getRewards() { return rewards != null ? rewards : new RewardConfig[0]; }
|
||||
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
|
||||
@@ -16,6 +16,7 @@ public final class PlayerMinigameState {
|
||||
private int score;
|
||||
private int lives;
|
||||
private String checkpointId;
|
||||
private String loadoutId;
|
||||
private final Set<String> tags = new HashSet<>();
|
||||
private final Map<String, Object> variables = new HashMap<>();
|
||||
private final Map<String, Object> savedInventory = new HashMap<>();
|
||||
@@ -39,6 +40,8 @@ public final class PlayerMinigameState {
|
||||
public void lives(int lives) { this.lives = lives; touch(); }
|
||||
public String checkpointId() { return checkpointId; }
|
||||
public void checkpointId(String checkpointId) { this.checkpointId = checkpointId; touch(); }
|
||||
public String loadoutId() { return loadoutId; }
|
||||
public void loadoutId(String loadoutId) { this.loadoutId = loadoutId; touch(); }
|
||||
public Set<String> tags() { return tags; }
|
||||
public Map<String, Object> variables() { return variables; }
|
||||
public Map<String, Object> savedInventory() { return savedInventory; }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package net.kewwbec.minigames.mount;
|
||||
|
||||
import com.hypixel.hytale.component.Component;
|
||||
import com.hypixel.hytale.component.ComponentType;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.MinigameCorePlugin;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public final class LockMountComponent implements Component<EntityStore> {
|
||||
|
||||
public LockMountComponent() {
|
||||
}
|
||||
|
||||
public static ComponentType<EntityStore, LockMountComponent> getComponentType() {
|
||||
return MinigameCorePlugin.getLockMountComponentType();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Component<EntityStore> clone() {
|
||||
return new LockMountComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package net.kewwbec.minigames.mount;
|
||||
|
||||
import com.hypixel.hytale.builtin.mounts.MountPlugin;
|
||||
import com.hypixel.hytale.builtin.mounts.MountedComponent;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.protocol.MountController;
|
||||
import com.hypixel.hytale.protocol.packets.interaction.DismountNPC;
|
||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
||||
import com.hypixel.hytale.server.core.io.handlers.IPacketHandler;
|
||||
import com.hypixel.hytale.server.core.io.handlers.SubPacketHandler;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
|
||||
public final class LockMountPacketHandler implements SubPacketHandler {
|
||||
private static final int PACKET_DISMOUNT_NPC = 294;
|
||||
|
||||
private final IPacketHandler packetHandler;
|
||||
|
||||
public LockMountPacketHandler(IPacketHandler packetHandler) {
|
||||
this.packetHandler = packetHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerHandlers() {
|
||||
this.packetHandler.registerHandler(PACKET_DISMOUNT_NPC, p -> this.handle((DismountNPC) p));
|
||||
}
|
||||
|
||||
private void handle(DismountNPC packet) {
|
||||
PlayerRef playerRef = this.packetHandler.getPlayerRef();
|
||||
Ref<EntityStore> ref = playerRef.getReference();
|
||||
if (ref == null || !ref.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Store<EntityStore> store = ref.getStore();
|
||||
store.getExternalData().getWorld().execute(() -> {
|
||||
if (store.getComponent(ref, LockMountComponent.getComponentType()) != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player playerComponent = store.getComponent(ref, Player.getComponentType());
|
||||
if (playerComponent == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
MountedComponent mounted = store.getComponent(ref, MountedComponent.getComponentType());
|
||||
if (mounted == null) {
|
||||
MountPlugin.checkDismountNpc(store, ref, playerComponent);
|
||||
} else if (mounted.getControllerType() == MountController.BlockMount) {
|
||||
store.tryRemoveComponent(ref, MountedComponent.getComponentType());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,42 @@ package net.kewwbec.minigames.runtime;
|
||||
|
||||
import com.hypixel.hytale.server.core.HytaleServer;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import net.kewwbec.minigames.adapter.HytaleAdapters;
|
||||
import net.kewwbec.minigames.model.ItemStackConfig;
|
||||
import net.kewwbec.minigames.model.LoadoutConfig;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||
import net.kewwbec.minigames.model.RewardConfig;
|
||||
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||
import static net.kewwbec.minigames.model.Enums.WinCondition;
|
||||
|
||||
public final class DefaultMinigameRuntimeService implements MinigameRuntimeService {
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
|
||||
private final Map<String, MinigameRuntime> runtimes = new HashMap<>();
|
||||
@Nullable
|
||||
private MinigameVolumeSignalService signalService;
|
||||
@Nullable
|
||||
private HytaleAdapters.PlayerAdapter playerAdapter;
|
||||
@Nullable
|
||||
private HytaleAdapters.EntityAdapter entityAdapter;
|
||||
@Nullable
|
||||
private HytaleAdapters.InventoryAdapter inventoryAdapter;
|
||||
|
||||
public void setSignalService(@Nullable MinigameVolumeSignalService signalService) {
|
||||
this.signalService = signalService;
|
||||
@@ -33,6 +47,14 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
this.playerAdapter = playerAdapter;
|
||||
}
|
||||
|
||||
public void setEntityAdapter(@Nullable HytaleAdapters.EntityAdapter entityAdapter) {
|
||||
this.entityAdapter = entityAdapter;
|
||||
}
|
||||
|
||||
public void setInventoryAdapter(@Nullable HytaleAdapters.InventoryAdapter inventoryAdapter) {
|
||||
this.inventoryAdapter = inventoryAdapter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MinigameRuntime start(MinigameDefinition definition) {
|
||||
return start(definition, MinigameMapCandidate.none(definition.getId()));
|
||||
@@ -51,34 +73,77 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
@Override
|
||||
public void end(String minigameId, String reason) {
|
||||
for (MinigameRuntime runtime : matching(minigameId)) {
|
||||
if (signalService != null) signalService.cancelAll(runtime.runtimeId());
|
||||
runtime.phase(MinigamePhase.ENDING);
|
||||
if (signalService != null) {
|
||||
signalService.onPhaseChange(runtime, MinigamePhase.ENDING);
|
||||
signalService.cancelAll(runtime.runtimeId());
|
||||
}
|
||||
cleanupTemporaryEntities(runtime);
|
||||
if (runtime.definition().isRestorePlayerInventory()) {
|
||||
restoreAllInventories(runtime);
|
||||
}
|
||||
giveRewards(runtime);
|
||||
var payload = new HashMap<String, Object>(eventPayload(runtime));
|
||||
payload.put("reason", reason);
|
||||
dispatch(new MinigameEvent("on_game_end", runtime.minigameId(), payload));
|
||||
clearRuntimeState(runtime);
|
||||
runtimes.remove(runtime.runtimeId());
|
||||
if (runtime.definition().isResetOnEnd()) {
|
||||
dispatch(new MinigameEvent("on_arena_reset", runtime.minigameId(), eventPayload(runtime)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset(String minigameId) {
|
||||
for (MinigameRuntime runtime : matching(minigameId)) {
|
||||
if (signalService != null) signalService.cancelAll(runtime.runtimeId());
|
||||
runtime.phase(MinigamePhase.RESETTING);
|
||||
runtime.players().clear();
|
||||
runtime.teams().clear();
|
||||
runtime.objectives().clear();
|
||||
runtime.variables().clear();
|
||||
runtime.timers().clear();
|
||||
runtime.activeVolumes().clear();
|
||||
runtime.spectators().clear();
|
||||
runtime.eliminatedPlayers().clear();
|
||||
runtime.temporaryBlocks().clear();
|
||||
runtime.temporaryEntities().clear();
|
||||
if (signalService != null) {
|
||||
signalService.onPhaseChange(runtime, MinigamePhase.RESETTING);
|
||||
signalService.cancelAll(runtime.runtimeId());
|
||||
}
|
||||
clearRuntimeState(runtime);
|
||||
dispatch(new MinigameEvent("on_arena_reset", runtime.minigameId(), eventPayload(runtime)));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerActivated(MinigameRuntime runtime, String playerId) {
|
||||
var def = runtime.definition();
|
||||
|
||||
if (inventoryAdapter != null && def.isSavePlayerInventory()) {
|
||||
var playerState = runtime.players().get(playerId);
|
||||
if (playerState != null && playerState.savedInventory().isEmpty()) {
|
||||
var snapshot = inventoryAdapter.saveInventory(playerId);
|
||||
playerState.savedInventory().putAll(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
if (playerAdapter != null) {
|
||||
var playerState = runtime.players().get(playerId);
|
||||
LoadoutConfig loadout = resolveLoadout(runtime, playerState);
|
||||
if (loadout != null) {
|
||||
if (loadout.isPlayerCustomizable()) {
|
||||
// TODO: show loadout selection UI and apply player's chosen loadout
|
||||
LOGGER.atInfo().log("Player-customizable loadout UI not yet implemented: player=%s loadout=%s", playerId, loadout.getId());
|
||||
} else {
|
||||
for (ItemStackConfig item : loadout.getItems()) {
|
||||
if (item != null) {
|
||||
playerAdapter.giveItem(playerId, item.getItemId(), item.getAmount());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (ItemStackConfig item : def.getStartItems()) {
|
||||
if (item != null) {
|
||||
playerAdapter.giveItem(playerId, item.getItemId(), item.getAmount());
|
||||
}
|
||||
}
|
||||
}
|
||||
playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<MinigameRuntime> runtime(String id) {
|
||||
MinigameRuntime byRuntimeId = runtimes.get(id);
|
||||
@@ -156,6 +221,22 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
if (total > 0 && current >= total) {
|
||||
sendEndScores(runtime);
|
||||
|
||||
if (runtime.definition().isOvertimeEnabled()) {
|
||||
runtime.phase(MinigamePhase.OVERTIME);
|
||||
if (signalService != null) {
|
||||
signalService.onPhaseChange(runtime, MinigamePhase.OVERTIME);
|
||||
}
|
||||
dispatch(new MinigameEvent("on_overtime_start", runtime.minigameId(), Map.copyOf(eventPayload(runtime))));
|
||||
}
|
||||
|
||||
if (runtime.definition().isSuddenDeathEnabled()) {
|
||||
runtime.phase(MinigamePhase.SUDDEN_DEATH);
|
||||
if (signalService != null) {
|
||||
signalService.onPhaseChange(runtime, MinigamePhase.SUDDEN_DEATH);
|
||||
}
|
||||
dispatch(new MinigameEvent("on_sudden_death_start", runtime.minigameId(), Map.copyOf(eventPayload(runtime))));
|
||||
}
|
||||
|
||||
var donePayload = new HashMap<>(eventPayload(runtime));
|
||||
donePayload.put("final_round", current);
|
||||
dispatch(new MinigameEvent("on_game_rounds_complete", runtime.minigameId(), Map.copyOf(donePayload)));
|
||||
@@ -186,6 +267,41 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
return runtimes(id);
|
||||
}
|
||||
|
||||
private void cleanupTemporaryEntities(MinigameRuntime runtime) {
|
||||
if (runtime.temporaryEntities().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (entityAdapter == null) {
|
||||
LOGGER.atWarning().log("Cannot clean up %s temporary entities for runtime=%s minigame=%s because no entity adapter is configured",
|
||||
runtime.temporaryEntities().size(), runtime.runtimeId(), runtime.minigameId());
|
||||
runtime.temporaryEntities().clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var entityIds = java.util.List.copyOf(runtime.temporaryEntities());
|
||||
LOGGER.atInfo().log("Cleaning up %s temporary entities for runtime=%s minigame=%s", entityIds.size(), runtime.runtimeId(), runtime.minigameId());
|
||||
for (String entityId : entityIds) {
|
||||
entityAdapter.despawn(entityId);
|
||||
}
|
||||
runtime.temporaryEntities().clear();
|
||||
}
|
||||
|
||||
private void clearRuntimeState(MinigameRuntime runtime) {
|
||||
// Explicitly zero scores before clearing maps so any retained state object is not left with stale points.
|
||||
runtime.players().values().forEach(player -> player.score(0));
|
||||
runtime.teams().values().forEach(team -> team.score(0));
|
||||
runtime.players().clear();
|
||||
runtime.teams().clear();
|
||||
runtime.objectives().clear();
|
||||
runtime.variables().clear();
|
||||
runtime.timers().clear();
|
||||
runtime.activeVolumes().clear();
|
||||
runtime.spectators().clear();
|
||||
runtime.eliminatedPlayers().clear();
|
||||
cleanupTemporaryEntities(runtime);
|
||||
runtime.temporaryBlocks().clear();
|
||||
}
|
||||
|
||||
private static Map<String, Object> eventPayload(MinigameRuntime runtime) {
|
||||
return Map.of(
|
||||
"runtime_id", runtime.runtimeId(),
|
||||
@@ -198,9 +314,11 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
private void sendEndScores(MinigameRuntime runtime) {
|
||||
if (playerAdapter == null || runtime.players().isEmpty()) return;
|
||||
|
||||
boolean lowestWins = runtime.definition().getWinCondition() == WinCondition.LOWEST_SCORE;
|
||||
var scoreOrder = Comparator.comparingInt((PlayerMinigameState player) -> player.score());
|
||||
var ranked = runtime.players().entrySet().stream()
|
||||
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
|
||||
Comparator.comparingInt((PlayerMinigameState player) -> player.score()).reversed()))
|
||||
lowestWins ? scoreOrder : scoreOrder.reversed()))
|
||||
.toList();
|
||||
|
||||
var recipients = runtime.players().keySet();
|
||||
@@ -219,6 +337,74 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
}
|
||||
}
|
||||
|
||||
private void restoreAllInventories(MinigameRuntime runtime) {
|
||||
if (inventoryAdapter == null) return;
|
||||
for (var entry : runtime.players().entrySet()) {
|
||||
var saved = entry.getValue().savedInventory();
|
||||
if (!saved.isEmpty()) {
|
||||
inventoryAdapter.restoreInventory(entry.getKey(), saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void giveRewards(MinigameRuntime runtime) {
|
||||
if (playerAdapter == null || runtime.players().isEmpty()) return;
|
||||
RewardConfig[] rewards = runtime.definition().getRewards();
|
||||
if (rewards == null || rewards.length == 0) return;
|
||||
|
||||
List<String> ranked = buildRankedPlayers(runtime);
|
||||
for (RewardConfig reward : rewards) {
|
||||
if (reward == null || reward.getTarget() == null) continue;
|
||||
Set<String> eligible = resolveRewardTargets(reward.getTarget(), ranked);
|
||||
for (String playerId : eligible) {
|
||||
for (ItemStackConfig item : reward.getItems()) {
|
||||
if (item != null) {
|
||||
playerAdapter.giveItem(playerId, item.getItemId(), item.getAmount());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static LoadoutConfig resolveLoadout(MinigameRuntime runtime, @Nullable PlayerMinigameState playerState) {
|
||||
if (playerState == null || playerState.loadoutId() == null) return null;
|
||||
for (LoadoutConfig loadout : runtime.definition().getStartLoadouts()) {
|
||||
if (loadout != null && loadout.getId().equals(playerState.loadoutId())) {
|
||||
return loadout;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> buildRankedPlayers(MinigameRuntime runtime) {
|
||||
boolean lowestWins = runtime.definition().getWinCondition() == WinCondition.LOWEST_SCORE;
|
||||
var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score());
|
||||
return runtime.players().entrySet().stream()
|
||||
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
|
||||
lowestWins ? scoreOrder : scoreOrder.reversed()))
|
||||
.map(Map.Entry::getKey)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static Set<String> resolveRewardTargets(String target, List<String> ranked) {
|
||||
String lower = target.trim().toLowerCase();
|
||||
if ("all".equals(lower)) {
|
||||
return new HashSet<>(ranked);
|
||||
}
|
||||
if ("winner".equals(lower) && !ranked.isEmpty()) {
|
||||
return Set.of(ranked.get(0));
|
||||
}
|
||||
if (lower.startsWith("top_")) {
|
||||
try {
|
||||
int n = Integer.parseInt(lower.substring(4));
|
||||
return new HashSet<>(ranked.subList(0, Math.min(n, ranked.size())));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
private static void dispatch(MinigameEvent event) {
|
||||
HytaleServer server = HytaleServer.get();
|
||||
if (server != null) {
|
||||
|
||||
@@ -12,6 +12,7 @@ public interface MinigameRuntimeService {
|
||||
MinigameRuntime start(MinigameDefinition definition, MinigameMapCandidate map);
|
||||
void end(String id, String reason);
|
||||
void reset(String id);
|
||||
void onPlayerActivated(MinigameRuntime runtime, String playerId);
|
||||
Optional<MinigameRuntime> runtime(String id);
|
||||
Collection<MinigameRuntime> runtimes(String minigameId);
|
||||
Optional<MinigameRuntime> runtimeForArena(String minigameId, String mapId, String variantId);
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||
import net.kewwbec.minigames.model.MinigameMapDiscoveryResult;
|
||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||
import net.kewwbec.minigames.model.ValidationIssue;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
|
||||
@@ -105,6 +106,31 @@ public final class MinigameMapDiscoveryService {
|
||||
return new MinigameMapCandidate(minigameId, clean(tags.get(TAG_MAP_ID)), clean(tags.get(TAG_VARIANT_ID)), 1, ROLE_MAIN_ARENA.equals(clean(tags.get(TAG_MAP_ROLE))));
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public String respawnDestination(
|
||||
@Nullable Store<EntityStore> store,
|
||||
@Nonnull MinigameRuntime runtime,
|
||||
@Nonnull PlayerMinigameState player,
|
||||
@Nullable String destinationOverride
|
||||
) {
|
||||
String destination = clean(destinationOverride);
|
||||
if (!destination.isBlank()) {
|
||||
return destination;
|
||||
}
|
||||
|
||||
destination = clean(player.checkpointId());
|
||||
if (!destination.isBlank()) {
|
||||
return destination;
|
||||
}
|
||||
|
||||
String teamId = clean(player.teamId());
|
||||
if (!teamId.isBlank()) {
|
||||
return teamSpawnDestination(store, runtime, teamId);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public String teamSpawnDestination(@Nullable Store<EntityStore> store, @Nonnull MinigameRuntime runtime, @Nonnull String teamId) {
|
||||
TriggerVolumeManager manager = manager(store);
|
||||
|
||||
@@ -54,7 +54,11 @@ public final class MinigameQueueService {
|
||||
MinigameRuntime runtime = runtimeService.start(definition, selected);
|
||||
Session session = session(definition.getId());
|
||||
for (String playerId : session.players) {
|
||||
runtime.players().putIfAbsent(playerId, new net.kewwbec.minigames.model.PlayerMinigameState(playerId, definition.getId()));
|
||||
runtime.players().computeIfAbsent(playerId, ignored -> {
|
||||
var player = new net.kewwbec.minigames.model.PlayerMinigameState(playerId, definition.getId());
|
||||
player.lives(Math.max(0, definition.getDefaultPlayerLives()));
|
||||
return player;
|
||||
});
|
||||
}
|
||||
sessions.remove(definition.getId());
|
||||
return runtime;
|
||||
|
||||
@@ -31,6 +31,7 @@ import java.util.logging.Level;
|
||||
*
|
||||
* Volume designers set config tags to describe WHEN a volume should fire:
|
||||
* SignalRoundStart = <n> fire once when round n starts
|
||||
* SignalPhaseStart = <phase> fire once when the given phase starts
|
||||
* SignalRoundTick = <n> fire repeatedly while round n is active
|
||||
* SignalPhaseTick = <phase> fire repeatedly while the given phase is active
|
||||
* SignalTickDelay = <seconds> repeat interval (0 = 50ms minimum)
|
||||
@@ -40,6 +41,7 @@ public final class MinigameVolumeSignalService {
|
||||
|
||||
// Config tags read from volumes — set by map designers, never written by this service
|
||||
public static final String TAG_SIGNAL_ROUND_START = "SignalRoundStart";
|
||||
public static final String TAG_SIGNAL_PHASE_START = "SignalPhaseStart";
|
||||
public static final String TAG_SIGNAL_ROUND_TICK = "SignalRoundTick";
|
||||
public static final String TAG_SIGNAL_PHASE_TICK = "SignalPhaseTick";
|
||||
public static final String TAG_SIGNAL_TICK_DELAY = "SignalTickDelay";
|
||||
@@ -69,31 +71,69 @@ public final class MinigameVolumeSignalService {
|
||||
cancelLoops(roundLoops, runtime.runtimeId());
|
||||
|
||||
String roundKey = String.valueOf(newRound);
|
||||
LOGGER.atInfo().log("Minigame signal: round start runtime=%s minigame=%s map=%s variant=%s round=%s",
|
||||
runtime.runtimeId(), runtime.minigameId(), runtime.mapId(), runtime.variantId(), roundKey);
|
||||
|
||||
int worldsChecked = 0;
|
||||
int volumesChecked = 0;
|
||||
int startMatches = 0;
|
||||
int tickMatches = 0;
|
||||
|
||||
for (World world : worlds()) {
|
||||
worldsChecked++;
|
||||
TriggerVolumeManager manager = manager(world);
|
||||
if (manager == null) continue;
|
||||
if (manager == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: round start skipped world=%s reason=no TriggerVolumeManager", world.getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
ActorPair actor = findActor(runtime, world);
|
||||
if (actor == null) continue;
|
||||
if (actor == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: round start skipped world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
||||
world.getName(), runtime.players().keySet());
|
||||
continue;
|
||||
}
|
||||
|
||||
for (VolumeEntry volume : manager.getVolumes()) {
|
||||
if (!runtime.minigameId().equals(volume.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) continue;
|
||||
volumesChecked++;
|
||||
Map<String, String> tags = volume.getRawTags();
|
||||
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||
if (hasRoundSignal(tags)) {
|
||||
LOGGER.atInfo().log("Minigame signal: round volume skipped volume=%s world=%s reason=minigame mismatch expected=%s actual=%s tags=%s",
|
||||
volume.getId(), world.getName(), runtime.minigameId(), tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID), tags);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// One-shot: fire immediately
|
||||
if (roundKey.equals(volume.getRawTags().get(TAG_SIGNAL_ROUND_START))) {
|
||||
if (roundKey.equals(tags.get(TAG_SIGNAL_ROUND_START))) {
|
||||
startMatches++;
|
||||
LOGGER.atInfo().log("Minigame signal: SignalRoundStart match volume=%s world=%s round=%s tags=%s",
|
||||
volume.getId(), world.getName(), roundKey, tags);
|
||||
toggle(manager, volume.getId(), actor);
|
||||
} else if (tags.containsKey(TAG_SIGNAL_ROUND_START)) {
|
||||
LOGGER.atInfo().log("Minigame signal: SignalRoundStart no match volume=%s world=%s expected=%s actual=%s tags=%s",
|
||||
volume.getId(), world.getName(), roundKey, tags.get(TAG_SIGNAL_ROUND_START), tags);
|
||||
}
|
||||
|
||||
// Repeating: schedule loop
|
||||
if (roundKey.equals(volume.getRawTags().get(TAG_SIGNAL_ROUND_TICK))) {
|
||||
long delay = parseDelayMs(volume.getRawTags().get(TAG_SIGNAL_TICK_DELAY));
|
||||
if (roundKey.equals(tags.get(TAG_SIGNAL_ROUND_TICK))) {
|
||||
tickMatches++;
|
||||
long delay = parseDelayMs(tags.get(TAG_SIGNAL_TICK_DELAY));
|
||||
LOGGER.atInfo().log("Minigame signal: SignalRoundTick schedule volume=%s world=%s round=%s delayMs=%s tags=%s",
|
||||
volume.getId(), world.getName(), roundKey, delay, tags);
|
||||
Future<?> future = scheduleLoop(runtime.runtimeId(), runtime.minigameId(),
|
||||
TAG_SIGNAL_ROUND_TICK, roundKey, volume.getId(), world.getName(), delay);
|
||||
roundLoops.computeIfAbsent(runtime.runtimeId(), k -> new ArrayList<>()).add(future);
|
||||
} else if (tags.containsKey(TAG_SIGNAL_ROUND_TICK)) {
|
||||
LOGGER.atInfo().log("Minigame signal: SignalRoundTick no match volume=%s world=%s expected=%s actual=%s tags=%s",
|
||||
volume.getId(), world.getName(), roundKey, tags.get(TAG_SIGNAL_ROUND_TICK), tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.atInfo().log("Minigame signal: round start scan complete runtime=%s worlds=%s volumes=%s startMatches=%s tickMatches=%s",
|
||||
runtime.runtimeId(), worldsChecked, volumesChecked, startMatches, tickMatches);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,25 +144,67 @@ public final class MinigameVolumeSignalService {
|
||||
cancelLoops(phaseLoops, runtime.runtimeId());
|
||||
|
||||
String phaseKey = newPhase.name();
|
||||
LOGGER.atInfo().log("Minigame signal: phase change runtime=%s minigame=%s map=%s variant=%s phase=%s",
|
||||
runtime.runtimeId(), runtime.minigameId(), runtime.mapId(), runtime.variantId(), phaseKey);
|
||||
|
||||
int worldsChecked = 0;
|
||||
int volumesChecked = 0;
|
||||
int startMatches = 0;
|
||||
int tickMatches = 0;
|
||||
|
||||
for (World world : worlds()) {
|
||||
worldsChecked++;
|
||||
TriggerVolumeManager manager = manager(world);
|
||||
if (manager == null) continue;
|
||||
if (manager == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: phase change skipped world=%s reason=no TriggerVolumeManager", world.getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
ActorPair actor = findActor(runtime, world);
|
||||
if (actor == null) continue;
|
||||
if (actor == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: phase change skipped world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
||||
world.getName(), runtime.players().keySet());
|
||||
continue;
|
||||
}
|
||||
|
||||
for (VolumeEntry volume : manager.getVolumes()) {
|
||||
if (!runtime.minigameId().equals(volume.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) continue;
|
||||
volumesChecked++;
|
||||
Map<String, String> tags = volume.getRawTags();
|
||||
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||
if (hasPhaseSignal(tags)) {
|
||||
LOGGER.atInfo().log("Minigame signal: phase volume skipped volume=%s world=%s reason=minigame mismatch expected=%s actual=%s tags=%s",
|
||||
volume.getId(), world.getName(), runtime.minigameId(), tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID), tags);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phaseKey.equals(volume.getRawTags().get(TAG_SIGNAL_PHASE_TICK))) {
|
||||
long delay = parseDelayMs(volume.getRawTags().get(TAG_SIGNAL_TICK_DELAY));
|
||||
if (phaseKey.equals(tags.get(TAG_SIGNAL_PHASE_START))) {
|
||||
startMatches++;
|
||||
LOGGER.atInfo().log("Minigame signal: SignalPhaseStart match volume=%s world=%s phase=%s tags=%s",
|
||||
volume.getId(), world.getName(), phaseKey, tags);
|
||||
toggle(manager, volume.getId(), actor);
|
||||
} else if (tags.containsKey(TAG_SIGNAL_PHASE_START)) {
|
||||
LOGGER.atInfo().log("Minigame signal: SignalPhaseStart no match volume=%s world=%s expected=%s actual=%s tags=%s",
|
||||
volume.getId(), world.getName(), phaseKey, tags.get(TAG_SIGNAL_PHASE_START), tags);
|
||||
}
|
||||
|
||||
if (phaseKey.equals(tags.get(TAG_SIGNAL_PHASE_TICK))) {
|
||||
tickMatches++;
|
||||
long delay = parseDelayMs(tags.get(TAG_SIGNAL_TICK_DELAY));
|
||||
LOGGER.atInfo().log("Minigame signal: SignalPhaseTick schedule volume=%s world=%s phase=%s delayMs=%s tags=%s",
|
||||
volume.getId(), world.getName(), phaseKey, delay, tags);
|
||||
Future<?> future = scheduleLoop(runtime.runtimeId(), runtime.minigameId(),
|
||||
TAG_SIGNAL_PHASE_TICK, phaseKey, volume.getId(), world.getName(), delay);
|
||||
phaseLoops.computeIfAbsent(runtime.runtimeId(), k -> new ArrayList<>()).add(future);
|
||||
} else if (tags.containsKey(TAG_SIGNAL_PHASE_TICK)) {
|
||||
LOGGER.atInfo().log("Minigame signal: SignalPhaseTick no match volume=%s world=%s expected=%s actual=%s tags=%s",
|
||||
volume.getId(), world.getName(), phaseKey, tags.get(TAG_SIGNAL_PHASE_TICK), tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.atInfo().log("Minigame signal: phase change scan complete runtime=%s worlds=%s volumes=%s startMatches=%s tickMatches=%s",
|
||||
runtime.runtimeId(), worldsChecked, volumesChecked, startMatches, tickMatches);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,6 +215,8 @@ public final class MinigameVolumeSignalService {
|
||||
public void startTimer(MinigameRuntime runtime, String timerName, long durationMs) {
|
||||
cancelTimer(runtime.runtimeId(), timerName);
|
||||
runtime.timers().put(timerName, System.currentTimeMillis() + durationMs);
|
||||
LOGGER.atInfo().log("Minigame signal: start timer runtime=%s minigame=%s timer=%s durationMs=%s",
|
||||
runtime.runtimeId(), runtime.minigameId(), timerName, durationMs);
|
||||
|
||||
Future<?> future = scheduler.schedule(
|
||||
() -> fireTimerExpiry(runtime.runtimeId(), runtime.minigameId(), timerName),
|
||||
@@ -148,7 +232,10 @@ public final class MinigameVolumeSignalService {
|
||||
var futures = timerFutures.get(runtimeId);
|
||||
if (futures != null) {
|
||||
Future<?> f = futures.remove(timerName);
|
||||
if (f != null) f.cancel(false);
|
||||
if (f != null) {
|
||||
f.cancel(false);
|
||||
LOGGER.atInfo().log("Minigame signal: cancelled timer runtime=%s timer=%s", runtimeId, timerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +245,7 @@ public final class MinigameVolumeSignalService {
|
||||
cancelLoops(phaseLoops, runtimeId);
|
||||
var futures = timerFutures.remove(runtimeId);
|
||||
if (futures != null) futures.values().forEach(f -> f.cancel(false));
|
||||
LOGGER.atInfo().log("Minigame signal: cancelled all loops and timers runtime=%s", runtimeId);
|
||||
}
|
||||
|
||||
/** Shuts down the background scheduler. Call on plugin shutdown. */
|
||||
@@ -167,6 +255,8 @@ public final class MinigameVolumeSignalService {
|
||||
|
||||
private Future<?> scheduleLoop(String runtimeId, String minigameId, String tagKey, String tagValue,
|
||||
String volumeId, String worldName, long delayMs) {
|
||||
LOGGER.atInfo().log("Minigame signal: schedule loop runtime=%s minigame=%s world=%s volume=%s tag=%s value=%s delayMs=%s",
|
||||
runtimeId, minigameId, worldName, volumeId, tagKey, tagValue, delayMs);
|
||||
return scheduler.scheduleAtFixedRate(
|
||||
() -> tickSignal(runtimeId, minigameId, tagKey, tagValue, volumeId, worldName),
|
||||
delayMs, delayMs, TimeUnit.MILLISECONDS
|
||||
@@ -178,34 +268,72 @@ public final class MinigameVolumeSignalService {
|
||||
try {
|
||||
Universe universe = Universe.get();
|
||||
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
||||
if (universe == null || plugin == null) return;
|
||||
if (universe == null || plugin == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s reason=universe or TriggerVolumesPlugin unavailable",
|
||||
runtimeId, volumeId);
|
||||
return;
|
||||
}
|
||||
|
||||
World world = universe.getWorld(worldName);
|
||||
if (world == null) return;
|
||||
if (world == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=world not found",
|
||||
runtimeId, volumeId, worldName);
|
||||
return;
|
||||
}
|
||||
|
||||
world.execute(() -> {
|
||||
var services = MinigameCorePlugin.getServices();
|
||||
if (services == null) return;
|
||||
if (services == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=services unavailable",
|
||||
runtimeId, volumeId, worldName);
|
||||
return;
|
||||
}
|
||||
|
||||
var rtOpt = services.runtime().runtime(runtimeId);
|
||||
if (rtOpt.isEmpty()) return;
|
||||
if (rtOpt.isEmpty()) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=runtime not found",
|
||||
runtimeId, volumeId, worldName);
|
||||
return;
|
||||
}
|
||||
MinigameRuntime rt = rtOpt.get();
|
||||
|
||||
if (TAG_SIGNAL_ROUND_TICK.equals(expectedTagKey)) {
|
||||
if (!expectedTagValue.equals(String.valueOf(rt.roundNumber()))) return;
|
||||
if (!expectedTagValue.equals(String.valueOf(rt.roundNumber()))) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick stopped runtime=%s volume=%s reason=round changed expected=%s actual=%s",
|
||||
runtimeId, volumeId, expectedTagValue, rt.roundNumber());
|
||||
return;
|
||||
}
|
||||
} else if (TAG_SIGNAL_PHASE_TICK.equals(expectedTagKey)) {
|
||||
if (!expectedTagValue.equals(rt.phase().name())) return;
|
||||
if (!expectedTagValue.equals(rt.phase().name())) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick stopped runtime=%s volume=%s reason=phase changed expected=%s actual=%s",
|
||||
runtimeId, volumeId, expectedTagValue, rt.phase().name());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
||||
if (manager == null) return;
|
||||
if (manager == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=no TriggerVolumeManager",
|
||||
runtimeId, volumeId, worldName);
|
||||
return;
|
||||
}
|
||||
|
||||
VolumeEntry volume = manager.getVolume(volumeId);
|
||||
if (volume == null) return;
|
||||
if (volume == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=volume not found",
|
||||
runtimeId, volumeId, worldName);
|
||||
return;
|
||||
}
|
||||
|
||||
ActorPair actor = findActor(rt, world);
|
||||
if (actor == null) return;
|
||||
if (actor == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
||||
runtimeId, volumeId, worldName, rt.players().keySet());
|
||||
return;
|
||||
}
|
||||
|
||||
LOGGER.atInfo().log("Minigame signal: tick firing runtime=%s minigame=%s world=%s volume=%s tag=%s value=%s tags=%s",
|
||||
runtimeId, minigameId, worldName, volumeId, expectedTagKey, expectedTagValue, volume.getRawTags());
|
||||
toggle(manager, volumeId, actor);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
@@ -217,39 +345,83 @@ public final class MinigameVolumeSignalService {
|
||||
try {
|
||||
Universe universe = Universe.get();
|
||||
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
||||
if (universe == null || plugin == null) return;
|
||||
if (universe == null || plugin == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s reason=universe or TriggerVolumesPlugin unavailable",
|
||||
runtimeId, timerName);
|
||||
return;
|
||||
}
|
||||
|
||||
var dispatched = new AtomicBoolean(false);
|
||||
LOGGER.atInfo().log("Minigame signal: timer expired runtime=%s minigame=%s timer=%s",
|
||||
runtimeId, minigameId, timerName);
|
||||
|
||||
for (World world : worlds()) {
|
||||
world.execute(() -> {
|
||||
var services = MinigameCorePlugin.getServices();
|
||||
if (services == null) return;
|
||||
if (services == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=services unavailable",
|
||||
runtimeId, timerName, world.getName());
|
||||
return;
|
||||
}
|
||||
|
||||
var rtOpt = services.runtime().runtime(runtimeId);
|
||||
if (rtOpt.isEmpty()) return;
|
||||
if (rtOpt.isEmpty()) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=runtime not found",
|
||||
runtimeId, timerName, world.getName());
|
||||
return;
|
||||
}
|
||||
MinigameRuntime rt = rtOpt.get();
|
||||
|
||||
if (dispatched.compareAndSet(false, true)) {
|
||||
LOGGER.atInfo().log("Minigame signal: dispatch on_timer_expired runtime=%s timer=%s", runtimeId, timerName);
|
||||
services.runtime().dispatch(rt, "on_timer_expired", Map.of("timer_name", timerName));
|
||||
}
|
||||
|
||||
// Internal timers (name starts with '$') are handled by the runtime service,
|
||||
// not by volume signals.
|
||||
if (timerName.startsWith("$")) return;
|
||||
if (timerName.startsWith("$")) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry not toggling volumes runtime=%s timer=%s reason=internal timer",
|
||||
runtimeId, timerName);
|
||||
return;
|
||||
}
|
||||
|
||||
TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
||||
if (manager == null) return;
|
||||
if (manager == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=no TriggerVolumeManager",
|
||||
runtimeId, timerName, world.getName());
|
||||
return;
|
||||
}
|
||||
|
||||
ActorPair actor = findActor(rt, world);
|
||||
if (actor == null) return;
|
||||
if (actor == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
||||
runtimeId, timerName, world.getName(), rt.players().keySet());
|
||||
return;
|
||||
}
|
||||
|
||||
int matches = 0;
|
||||
|
||||
for (VolumeEntry volume : manager.getVolumes()) {
|
||||
if (!minigameId.equals(volume.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) continue;
|
||||
if (timerName.equals(volume.getRawTags().get(TAG_SIGNAL_TIMER_EXPIRE))) {
|
||||
Map<String, String> tags = volume.getRawTags();
|
||||
if (!minigameId.equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||
if (tags.containsKey(TAG_SIGNAL_TIMER_EXPIRE)) {
|
||||
LOGGER.atInfo().log("Minigame signal: timer volume skipped volume=%s world=%s reason=minigame mismatch expected=%s actual=%s tags=%s",
|
||||
volume.getId(), world.getName(), minigameId, tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID), tags);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (timerName.equals(tags.get(TAG_SIGNAL_TIMER_EXPIRE))) {
|
||||
matches++;
|
||||
LOGGER.atInfo().log("Minigame signal: SignalOnTimerExpire match volume=%s world=%s timer=%s tags=%s",
|
||||
volume.getId(), world.getName(), timerName, tags);
|
||||
toggle(manager, volume.getId(), actor);
|
||||
} else if (tags.containsKey(TAG_SIGNAL_TIMER_EXPIRE)) {
|
||||
LOGGER.atInfo().log("Minigame signal: SignalOnTimerExpire no match volume=%s world=%s expected=%s actual=%s tags=%s",
|
||||
volume.getId(), world.getName(), timerName, tags.get(TAG_SIGNAL_TIMER_EXPIRE), tags);
|
||||
}
|
||||
}
|
||||
LOGGER.atInfo().log("Minigame signal: timer expiry scan complete runtime=%s timer=%s world=%s matches=%s",
|
||||
runtimeId, timerName, world.getName(), matches);
|
||||
});
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -260,9 +432,14 @@ public final class MinigameVolumeSignalService {
|
||||
/** Toggles MinigameSignal between "0" and "1" to fire TAG_ADDED. */
|
||||
private static void toggle(TriggerVolumeManager manager, String volumeId, ActorPair actor) {
|
||||
VolumeEntry volume = manager.getVolume(volumeId);
|
||||
if (volume == null) return;
|
||||
if (volume == null) {
|
||||
LOGGER.atInfo().log("Minigame signal: toggle skipped volume=%s reason=volume not found", volumeId);
|
||||
return;
|
||||
}
|
||||
String current = volume.getRawTags().get(TAG_SIGNAL_KEY);
|
||||
String next = "1".equals(current) ? "0" : "1";
|
||||
LOGGER.atInfo().log("Minigame signal: toggle volume=%s %s=%s->%s actor=%s tags=%s",
|
||||
volumeId, TAG_SIGNAL_KEY, current, next, actor.uuid(), volume.getRawTags());
|
||||
manager.setTag(volumeId, TAG_SIGNAL_KEY, next, actor.ref(), actor.uuid());
|
||||
}
|
||||
|
||||
@@ -298,7 +475,10 @@ public final class MinigameVolumeSignalService {
|
||||
|
||||
private static void cancelLoops(Map<String, List<Future<?>>> loopMap, String runtimeId) {
|
||||
List<Future<?>> futures = loopMap.remove(runtimeId);
|
||||
if (futures != null) futures.forEach(f -> f.cancel(false));
|
||||
if (futures != null) {
|
||||
futures.forEach(f -> f.cancel(false));
|
||||
LOGGER.atInfo().log("Minigame signal: cancelled loops runtime=%s count=%s", runtimeId, futures.size());
|
||||
}
|
||||
}
|
||||
|
||||
private static long parseDelayMs(@Nullable String value) {
|
||||
@@ -307,9 +487,18 @@ public final class MinigameVolumeSignalService {
|
||||
double seconds = Double.parseDouble(value);
|
||||
return Math.max(MIN_DELAY_MS, (long) (seconds * 1000));
|
||||
} catch (NumberFormatException ignored) {
|
||||
LOGGER.atInfo().log("Minigame signal: invalid SignalTickDelay value=%s usingDefaultMs=%s", value, MIN_DELAY_MS);
|
||||
return MIN_DELAY_MS;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasRoundSignal(Map<String, String> tags) {
|
||||
return tags.containsKey(TAG_SIGNAL_ROUND_START) || tags.containsKey(TAG_SIGNAL_ROUND_TICK);
|
||||
}
|
||||
|
||||
private static boolean hasPhaseSignal(Map<String, String> tags) {
|
||||
return tags.containsKey(TAG_SIGNAL_PHASE_START) || tags.containsKey(TAG_SIGNAL_PHASE_TICK);
|
||||
}
|
||||
|
||||
private record ActorPair(Ref<EntityStore> ref, UUID uuid) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package net.kewwbec.minigames.system;
|
||||
|
||||
import com.hypixel.hytale.component.CommandBuffer;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.component.dependency.Dependency;
|
||||
import com.hypixel.hytale.component.dependency.Order;
|
||||
import com.hypixel.hytale.component.dependency.SystemDependency;
|
||||
import com.hypixel.hytale.component.query.Query;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DeathComponent;
|
||||
import com.hypixel.hytale.server.core.modules.entity.damage.DeathSystems;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.MinigameCorePlugin;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public final class MinigameDeathRespawnSystem extends DeathSystems.OnDeathSystem {
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
private static final Set<Dependency<EntityStore>> DEPENDENCIES =
|
||||
Set.of(new SystemDependency<>(Order.BEFORE, DeathSystems.PlayerDeathScreen.class));
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Query<EntityStore> getQuery() {
|
||||
return PlayerRef.getComponentType();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Set<Dependency<EntityStore>> getDependencies() {
|
||||
return DEPENDENCIES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComponentAdded(
|
||||
@Nonnull Ref<EntityStore> ref,
|
||||
@Nonnull DeathComponent component,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer
|
||||
) {
|
||||
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
|
||||
if (playerRef == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String playerId = playerRef.getUuid().toString();
|
||||
var services = MinigameCorePlugin.getServices();
|
||||
if (services == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var runtime = services.runtime().runtimeForPlayer(playerId).orElse(null);
|
||||
if (runtime == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var player = runtime.players().get(playerId);
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String destination = services.maps().respawnDestination(store, runtime, player, null);
|
||||
if (destination.isBlank()) {
|
||||
LOGGER.atInfo().log("Minigame respawn skipped player=%s runtime=%s reason=no checkpoint or team spawn",
|
||||
playerId, runtime.runtimeId());
|
||||
return;
|
||||
}
|
||||
|
||||
component.setShowDeathMenu(false);
|
||||
DeathComponent.respawn(store, ref).thenRun(() -> services.adapters().teleport(playerId, destination))
|
||||
.exceptionally(throwable -> {
|
||||
LOGGER.at(Level.WARNING).withCause(throwable).log("Minigame respawn failed player=%s runtime=%s destination=%s",
|
||||
playerId, runtime.runtimeId(), destination);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import com.hypixel.hytale.codec.Codec;
|
||||
import com.hypixel.hytale.codec.KeyedCodec;
|
||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||
import net.kewwbec.minigames.model.TeamState;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
public final class AssignTeamEffect extends MinigameRuntimeEffect {
|
||||
public static final String TYPE_ID = "AssignTeam";
|
||||
@@ -16,15 +20,28 @@ public final class AssignTeamEffect extends MinigameRuntimeEffect {
|
||||
BuilderCodec.builder(AssignTeamEffect.class, AssignTeamEffect::new, MINIGAME_BASE_CODEC)
|
||||
.append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId)
|
||||
.add()
|
||||
.append(new KeyedCodec<>("TeamId", Codec.STRING), (effect, value) -> effect.teamId = value, effect -> effect.teamId)
|
||||
.append(new KeyedCodec<>("TeamId", Codec.STRING, false), (effect, value) -> effect.teamId = value, effect -> effect.teamId)
|
||||
.add()
|
||||
.append(new KeyedCodec<>("BalanceTeams", Codec.BOOLEAN, false), (effect, value) -> effect.balanceTeams = value, effect -> effect.balanceTeams)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private String playerId;
|
||||
private String teamId;
|
||||
private boolean balanceTeams = false;
|
||||
|
||||
@Override
|
||||
public void execute(@Nonnull TriggerContext context) {
|
||||
var rt = runtime(context);
|
||||
if (rt.isEmpty()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
||||
return;
|
||||
}
|
||||
if (balanceTeams) {
|
||||
balanceTeams(context, rt.get());
|
||||
return;
|
||||
}
|
||||
|
||||
String id = resolvePlayerId(context, playerId);
|
||||
if (id == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)");
|
||||
@@ -34,11 +51,6 @@ public final class AssignTeamEffect extends MinigameRuntimeEffect {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (no team id configured)");
|
||||
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)");
|
||||
@@ -58,4 +70,51 @@ public final class AssignTeamEffect extends MinigameRuntimeEffect {
|
||||
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' team=" + prevTeam + "->" + teamId);
|
||||
}
|
||||
|
||||
private void balanceTeams(@Nonnull TriggerContext context, @Nonnull MinigameRuntime runtime) {
|
||||
if (runtime.players().isEmpty()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED balance (no players in runtime)");
|
||||
return;
|
||||
}
|
||||
|
||||
List<TeamState> teams = balanceTeams(runtime);
|
||||
if (teams.size() < 2) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED balance (needs at least 2 teams)");
|
||||
return;
|
||||
}
|
||||
|
||||
for (TeamState team : teams) {
|
||||
team.players().clear();
|
||||
}
|
||||
|
||||
List<String> players = runtime.players().keySet().stream().sorted().toList();
|
||||
for (int i = 0; i < players.size(); i++) {
|
||||
String playerId = players.get(i);
|
||||
TeamState team = teams.get(i % teams.size());
|
||||
runtime.players().get(playerId).teamId(team.teamId());
|
||||
team.players().add(playerId);
|
||||
}
|
||||
|
||||
debugMessage(context, TYPE_ID, "FIRED balanced players=" + players.size() + " teams=" + teams.size());
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static List<TeamState> balanceTeams(@Nonnull MinigameRuntime runtime) {
|
||||
List<TeamState> teams = new ArrayList<>(runtime.teams().values());
|
||||
if (teams.isEmpty()) {
|
||||
int count = Math.max(0, runtime.definition().getTeamCount());
|
||||
if (count == 0) {
|
||||
int perTeam = runtime.definition().getPlayersPerTeam();
|
||||
if (perTeam > 0) {
|
||||
count = (int) Math.ceil((double) runtime.players().size() / perTeam);
|
||||
}
|
||||
}
|
||||
for (int i = 1; i <= count; i++) {
|
||||
String id = "team" + i;
|
||||
teams.add(runtime.teams().computeIfAbsent(id, key -> new TeamState(key, key, null)));
|
||||
}
|
||||
}
|
||||
teams.sort(Comparator.comparing(TeamState::teamId));
|
||||
return teams;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package net.kewwbec.minigames.volume.effect;
|
||||
|
||||
import com.hypixel.hytale.builtin.mounts.MountPlugin;
|
||||
import com.hypixel.hytale.builtin.mounts.NPCMountComponent;
|
||||
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.component.Ref;
|
||||
import com.hypixel.hytale.component.RemoveReason;
|
||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
||||
import net.kewwbec.minigames.mount.LockMountComponent;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import com.hypixel.hytale.server.npc.entities.NPCEntity;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class DismountPlayerEffect extends MinigameRuntimeEffect {
|
||||
public static final String TYPE_ID = "DismountPlayer";
|
||||
|
||||
@Nonnull
|
||||
public static final BuilderCodec<DismountPlayerEffect> CODEC =
|
||||
BuilderCodec.builder(DismountPlayerEffect.class, DismountPlayerEffect::new, MINIGAME_BASE_CODEC)
|
||||
.<String>append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId)
|
||||
.add()
|
||||
.<String>append(new KeyedCodec<>("NpcRoleId", Codec.STRING, false), (effect, value) -> effect.npcRoleId = value, effect -> effect.npcRoleId)
|
||||
.add()
|
||||
.<Boolean>append(new KeyedCodec<>("RemoveNpc", Codec.BOOLEAN, false), (effect, value) -> effect.removeNpc = value, effect -> effect.removeNpc)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private String playerId;
|
||||
private String npcRoleId;
|
||||
private boolean removeNpc = true;
|
||||
|
||||
@Override
|
||||
public void execute(@Nonnull TriggerContext context) {
|
||||
String resolvedPlayerId = resolvePlayerId(context, playerId);
|
||||
if (resolvedPlayerId == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED no player resolved");
|
||||
return;
|
||||
}
|
||||
|
||||
Ref<EntityStore> playerRef = resolvePlayerRef(context, resolvedPlayerId);
|
||||
if (playerRef == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED player not in this world: " + resolvedPlayerId);
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerRef playerRefComponent = context.getStore().getComponent(playerRef, PlayerRef.getComponentType());
|
||||
Player playerComponent = context.getStore().getComponent(playerRef, Player.getComponentType());
|
||||
if (playerRefComponent == null || playerComponent == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED target is not a player: " + resolvedPlayerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (playerComponent.getMountEntityId() == 0) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED player is not mounted");
|
||||
return;
|
||||
}
|
||||
|
||||
if (MountPlugin.getInstance() == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED mount plugin unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
Ref<EntityStore> npcRef = findMountedNpc(context, playerRefComponent);
|
||||
|
||||
if (npcRef != null && npcRoleId != null && !npcRoleId.isBlank()) {
|
||||
NPCEntity npc = context.getStore().getComponent(npcRef, NPCEntity.getComponentType());
|
||||
if (npc == null || !npcRoleId.equalsIgnoreCase(npc.getRoleName())) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED mounted NPC role does not match npcRoleId='" + npcRoleId + "'");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
context.getStore().tryRemoveComponent(playerRef, LockMountComponent.getComponentType());
|
||||
MountPlugin.checkDismountNpc(context.getStore(), playerRef, playerComponent);
|
||||
|
||||
if (removeNpc && npcRef != null && npcRef.isValid()) {
|
||||
context.getStore().removeEntity(npcRef, RemoveReason.REMOVE);
|
||||
}
|
||||
|
||||
debugMessage(context, TYPE_ID, "FIRED player dismounted removeNpc=" + removeNpc);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Ref<EntityStore> findMountedNpc(@Nonnull TriggerContext context, @Nonnull PlayerRef playerRefComponent) {
|
||||
for (Ref<EntityStore> ref : context.getVolume().getTrackedEntities().values()) {
|
||||
if (!ref.isValid()) {
|
||||
continue;
|
||||
}
|
||||
NPCMountComponent mount = context.getStore().getComponent(ref, NPCMountComponent.getComponentType());
|
||||
if (mount != null && playerRefComponent.equals(mount.getOwnerPlayerRef())) {
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Ref<EntityStore> resolvePlayerRef(@Nonnull TriggerContext context, @Nonnull String resolvedPlayerId) {
|
||||
PlayerRef triggeringPlayer = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType());
|
||||
if (triggeringPlayer != null && resolvedPlayerId.equals(triggeringPlayer.getUuid().toString())) {
|
||||
return context.getEntityRef();
|
||||
}
|
||||
|
||||
try {
|
||||
UUID targetUuid = UUID.fromString(resolvedPlayerId);
|
||||
for (Ref<EntityStore> ref : context.getVolume().getTrackedEntities().values()) {
|
||||
if (ref.isValid()) {
|
||||
PlayerRef player = context.getStore().getComponent(ref, PlayerRef.getComponentType());
|
||||
if (player != null && player.getUuid().equals(targetUuid)) {
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package net.kewwbec.minigames.volume.effect;
|
||||
|
||||
import com.hypixel.hytale.builtin.mounts.MountPlugin;
|
||||
import com.hypixel.hytale.builtin.mounts.NPCMountComponent;
|
||||
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.component.Ref;
|
||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||
import com.hypixel.hytale.server.core.entity.entities.Player;
|
||||
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementConfig;
|
||||
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager;
|
||||
import com.hypixel.hytale.server.core.entity.EntityUtils;
|
||||
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import com.hypixel.hytale.server.npc.NPCPlugin;
|
||||
import com.hypixel.hytale.server.npc.entities.NPCEntity;
|
||||
import com.hypixel.hytale.server.npc.systems.RoleChangeSystem;
|
||||
import net.kewwbec.minigames.mount.LockMountComponent;
|
||||
import org.joml.Vector3d;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class MountPlayerEffect extends MinigameRuntimeEffect {
|
||||
public static final String TYPE_ID = "MountPlayer";
|
||||
|
||||
@Nonnull
|
||||
public static final BuilderCodec<MountPlayerEffect> CODEC =
|
||||
BuilderCodec.builder(MountPlayerEffect.class, MountPlayerEffect::new, MINIGAME_BASE_CODEC)
|
||||
.<String>append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId)
|
||||
.add()
|
||||
.<String>append(new KeyedCodec<>("NpcId", Codec.STRING, false), (effect, value) -> effect.npcId = value, effect -> effect.npcId)
|
||||
.add()
|
||||
.<String>append(new KeyedCodec<>("NpcRoleId", Codec.STRING, false), (effect, value) -> effect.npcRoleId = value, effect -> effect.npcRoleId)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("MaxDistance", Codec.DOUBLE, false), (effect, value) -> effect.maxDistance = value, effect -> effect.maxDistance)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("AnchorX", Codec.DOUBLE, false), (effect, value) -> effect.anchorX = value, effect -> effect.anchorX)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("AnchorY", Codec.DOUBLE, false), (effect, value) -> effect.anchorY = value, effect -> effect.anchorY)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("AnchorZ", Codec.DOUBLE, false), (effect, value) -> effect.anchorZ = value, effect -> effect.anchorZ)
|
||||
.add()
|
||||
.<String>append(new KeyedCodec<>("MovementConfig", Codec.STRING, false), (effect, value) -> effect.movementConfig = value, effect -> effect.movementConfig)
|
||||
.add()
|
||||
.<String>append(new KeyedCodec<>("EmptyRoleId", Codec.STRING, false), (effect, value) -> effect.emptyRoleId = value, effect -> effect.emptyRoleId)
|
||||
.add()
|
||||
.<Boolean>append(new KeyedCodec<>("PreventManualDismount", Codec.BOOLEAN, false), (effect, value) -> effect.preventManualDismount = value, effect -> effect.preventManualDismount)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private String playerId;
|
||||
private String npcId;
|
||||
private String npcRoleId;
|
||||
private double maxDistance = 8.0D;
|
||||
private double anchorX = 0.0D;
|
||||
private double anchorY = 1.0D;
|
||||
private double anchorZ = 0.0D;
|
||||
private String movementConfig;
|
||||
private String emptyRoleId = "Empty_Role";
|
||||
private boolean preventManualDismount = false;
|
||||
|
||||
@Override
|
||||
public void execute(@Nonnull TriggerContext context) {
|
||||
String resolvedPlayerId = resolvePlayerId(context, playerId);
|
||||
if (resolvedPlayerId == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED no player resolved");
|
||||
return;
|
||||
}
|
||||
|
||||
Ref<EntityStore> playerRef = resolvePlayerRef(context, resolvedPlayerId);
|
||||
if (playerRef == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED player not in this world: " + resolvedPlayerId);
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerRef playerRefComponent = context.getStore().getComponent(playerRef, PlayerRef.getComponentType());
|
||||
Player playerComponent = context.getStore().getComponent(playerRef, Player.getComponentType());
|
||||
if (playerRefComponent == null || playerComponent == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED target is not a player: " + resolvedPlayerId);
|
||||
return;
|
||||
}
|
||||
|
||||
Ref<EntityStore> npcRef = resolveNpcRef(context, playerRef);
|
||||
if (npcRef == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED no matching tracked NPC");
|
||||
return;
|
||||
}
|
||||
|
||||
mount(context, playerRef, playerRefComponent, playerComponent, npcRef);
|
||||
}
|
||||
|
||||
private void mount(
|
||||
@Nonnull TriggerContext context,
|
||||
@Nonnull Ref<EntityStore> playerRef,
|
||||
@Nonnull PlayerRef playerRefComponent,
|
||||
@Nonnull Player playerComponent,
|
||||
@Nonnull Ref<EntityStore> npcRef
|
||||
) {
|
||||
if (MountPlugin.getInstance() == null || NPCPlugin.get() == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED mount/NPC plugin unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
NPCEntity npc = context.getStore().getComponent(npcRef, NPCEntity.getComponentType());
|
||||
if (npc == null || npc.getRole() == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED target NPC role not ready");
|
||||
return;
|
||||
}
|
||||
if (context.getStore().getComponent(npcRef, NPCMountComponent.getComponentType()) != null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED NPC already mounted");
|
||||
return;
|
||||
}
|
||||
|
||||
int emptyRoleIndex = NPCPlugin.get().getIndex(emptyRoleId != null && !emptyRoleId.isBlank() ? emptyRoleId : "Empty_Role");
|
||||
if (emptyRoleIndex == Integer.MIN_VALUE) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED missing empty role='" + emptyRoleId + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
MountPlugin.checkDismountNpc(context.getStore(), playerRef, playerComponent);
|
||||
|
||||
NPCMountComponent mount = context.getStore().ensureAndGetComponent(npcRef, NPCMountComponent.getComponentType());
|
||||
mount.setOriginalRoleIndex(resolveOriginalRoleIndex(npc));
|
||||
mount.setOwnerPlayerRef(playerRefComponent);
|
||||
mount.setAnchor((float) anchorX, (float) anchorY, (float) anchorZ);
|
||||
|
||||
RoleChangeSystem.requestRoleChange(npcRef, npc.getRole(), emptyRoleIndex, false, null, null, context.getStore());
|
||||
applyMovementConfig(context, playerRef, playerComponent, playerRefComponent);
|
||||
if (preventManualDismount) {
|
||||
context.getStore().ensureComponent(playerRef, LockMountComponent.getComponentType());
|
||||
}
|
||||
debugMessage(context, TYPE_ID, "FIRED player mounted npcRole='" + npc.getRoleName() + "' locked=" + preventManualDismount);
|
||||
}
|
||||
|
||||
private int resolveOriginalRoleIndex(@Nonnull NPCEntity npc) {
|
||||
if (npc.getRoleIndex() != Integer.MIN_VALUE) {
|
||||
return npc.getRoleIndex();
|
||||
}
|
||||
return NPCPlugin.get().getIndex(npc.getRoleName());
|
||||
}
|
||||
|
||||
private void applyMovementConfig(
|
||||
@Nonnull TriggerContext context,
|
||||
@Nonnull Ref<EntityStore> playerRef,
|
||||
@Nonnull Player playerComponent,
|
||||
@Nonnull PlayerRef playerRefComponent
|
||||
) {
|
||||
if (movementConfig == null || movementConfig.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
MovementConfig config = MovementConfig.getAssetMap().getAsset(movementConfig);
|
||||
if (config == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED movement config missing='" + movementConfig + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
MovementManager movementManager = context.getStore().getComponent(playerRef, MovementManager.getComponentType());
|
||||
if (movementManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
movementManager.setDefaultSettings(config.toPacket(), EntityUtils.getPhysicsValues(playerRef, context.getStore()), playerComponent.getGameMode());
|
||||
movementManager.applyDefaultSettings();
|
||||
movementManager.update(playerRefComponent.getPacketHandler());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Ref<EntityStore> resolvePlayerRef(@Nonnull TriggerContext context, @Nonnull String resolvedPlayerId) {
|
||||
PlayerRef triggeringPlayer = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType());
|
||||
if (triggeringPlayer != null && resolvedPlayerId.equals(triggeringPlayer.getUuid().toString())) {
|
||||
return context.getEntityRef();
|
||||
}
|
||||
|
||||
try {
|
||||
UUID targetUuid = UUID.fromString(resolvedPlayerId);
|
||||
for (Ref<EntityStore> ref : context.getVolume().getTrackedEntities().values()) {
|
||||
if (ref.isValid()) {
|
||||
PlayerRef player = context.getStore().getComponent(ref, PlayerRef.getComponentType());
|
||||
if (player != null && player.getUuid().equals(targetUuid)) {
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Ref<EntityStore> resolveNpcRef(@Nonnull TriggerContext context, @Nonnull Ref<EntityStore> playerRef) {
|
||||
Ref<EntityStore> exact = resolveExactNpcRef(context);
|
||||
if (exact != null) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
TransformComponent playerTransform = context.getStore().getComponent(playerRef, TransformComponent.getComponentType());
|
||||
Vector3d playerPosition = playerTransform != null ? playerTransform.getPosition() : context.getVolume().getPosition();
|
||||
Ref<EntityStore> best = candidateNpc(context, context.getEntityRef(), playerPosition, null, Double.MAX_VALUE);
|
||||
double bestDistanceSq = best != null ? distanceSq(context, best, playerPosition) : Double.MAX_VALUE;
|
||||
|
||||
for (Ref<EntityStore> ref : context.getVolume().getTrackedEntities().values()) {
|
||||
best = candidateNpc(context, ref, playerPosition, best, bestDistanceSq);
|
||||
if (best == ref) {
|
||||
bestDistanceSq = distanceSq(context, ref, playerPosition);
|
||||
}
|
||||
}
|
||||
|
||||
if (best == null) {
|
||||
return null;
|
||||
}
|
||||
if (maxDistance > 0.0D && bestDistanceSq > maxDistance * maxDistance) {
|
||||
return null;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Ref<EntityStore> resolveExactNpcRef(@Nonnull TriggerContext context) {
|
||||
if (npcId == null || npcId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
UUID targetUuid = UUID.fromString(npcId);
|
||||
for (Ref<EntityStore> ref : context.getVolume().getTrackedEntities().values()) {
|
||||
if (ref.isValid()) {
|
||||
UUIDComponent uuid = context.getStore().getComponent(ref, UUIDComponent.getComponentType());
|
||||
if (uuid != null && uuid.getUuid().equals(targetUuid) && matchesNpc(context, ref)) {
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Ref<EntityStore> candidateNpc(
|
||||
@Nonnull TriggerContext context,
|
||||
@Nonnull Ref<EntityStore> ref,
|
||||
@Nonnull Vector3d playerPosition,
|
||||
@Nullable Ref<EntityStore> best,
|
||||
double bestDistanceSq
|
||||
) {
|
||||
if (!ref.isValid() || !matchesNpc(context, ref)) {
|
||||
return best;
|
||||
}
|
||||
|
||||
double distanceSq = distanceSq(context, ref, playerPosition);
|
||||
return distanceSq < bestDistanceSq ? ref : best;
|
||||
}
|
||||
|
||||
private boolean matchesNpc(@Nonnull TriggerContext context, @Nonnull Ref<EntityStore> ref) {
|
||||
NPCEntity npc = context.getStore().getComponent(ref, NPCEntity.getComponentType());
|
||||
if (npc == null) {
|
||||
return false;
|
||||
}
|
||||
return npcRoleId == null || npcRoleId.isBlank() || npcRoleId.equalsIgnoreCase(npc.getRoleName());
|
||||
}
|
||||
|
||||
private double distanceSq(@Nonnull TriggerContext context, @Nonnull Ref<EntityStore> ref, @Nonnull Vector3d playerPosition) {
|
||||
TransformComponent transform = context.getStore().getComponent(ref, TransformComponent.getComponentType());
|
||||
if (transform == null) {
|
||||
return Double.MAX_VALUE;
|
||||
}
|
||||
return transform.getPosition().distanceSquared(playerPosition);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.math.util.ChunkUtil;
|
||||
import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public final class PlaceBlocksEffect extends MinigameRuntimeEffect {
|
||||
public static final String TYPE_ID = "PlaceBlocks";
|
||||
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
private static final int DEFAULT_MAX_BLOCKS = 32768;
|
||||
|
||||
@Nonnull
|
||||
public static final BuilderCodec<PlaceBlocksEffect> CODEC =
|
||||
BuilderCodec.builder(PlaceBlocksEffect.class, PlaceBlocksEffect::new, MINIGAME_BASE_CODEC)
|
||||
.<String>append(new KeyedCodec<>("BlockId", Codec.STRING), (effect, value) -> effect.blockId = value, effect -> effect.blockId)
|
||||
.add()
|
||||
.<Integer>append(new KeyedCodec<>("X1", Codec.INTEGER), (effect, value) -> effect.x1 = value, effect -> effect.x1)
|
||||
.add()
|
||||
.<Integer>append(new KeyedCodec<>("Y1", Codec.INTEGER), (effect, value) -> effect.y1 = value, effect -> effect.y1)
|
||||
.add()
|
||||
.<Integer>append(new KeyedCodec<>("Z1", Codec.INTEGER), (effect, value) -> effect.z1 = value, effect -> effect.z1)
|
||||
.add()
|
||||
.<Integer>append(new KeyedCodec<>("X2", Codec.INTEGER), (effect, value) -> effect.x2 = value, effect -> effect.x2)
|
||||
.add()
|
||||
.<Integer>append(new KeyedCodec<>("Y2", Codec.INTEGER), (effect, value) -> effect.y2 = value, effect -> effect.y2)
|
||||
.add()
|
||||
.<Integer>append(new KeyedCodec<>("Z2", Codec.INTEGER), (effect, value) -> effect.z2 = value, effect -> effect.z2)
|
||||
.add()
|
||||
.<Integer>append(new KeyedCodec<>("MaxBlocks", Codec.INTEGER, false), (effect, value) -> effect.maxBlocks = value, effect -> effect.maxBlocks)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private String blockId = "";
|
||||
private int x1 = 0;
|
||||
private int y1 = 0;
|
||||
private int z1 = 0;
|
||||
private int x2 = 0;
|
||||
private int y2 = 0;
|
||||
private int z2 = 0;
|
||||
private int maxBlocks = DEFAULT_MAX_BLOCKS;
|
||||
|
||||
@Override
|
||||
public void execute(@Nonnull TriggerContext context) {
|
||||
if (blockId == null || blockId.isBlank()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED missing BlockId");
|
||||
return;
|
||||
}
|
||||
|
||||
BlockType blockType = BlockType.getAssetMap().getAsset(blockId);
|
||||
int blockTypeIndex = BlockType.getAssetMap().getIndex(blockId);
|
||||
if (blockType == null || blockType == BlockType.UNKNOWN || blockTypeIndex == Integer.MIN_VALUE) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED unknown BlockId='" + blockId + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
int minX = Math.min(x1, x2);
|
||||
int maxX = Math.max(x1, x2);
|
||||
int minY = Math.max(0, Math.min(y1, y2));
|
||||
int maxY = Math.min(319, Math.max(y1, y2));
|
||||
int minZ = Math.min(z1, z2);
|
||||
int maxZ = Math.max(z1, z2);
|
||||
|
||||
long totalBlocks = blockCount(minX, maxX, minY, maxY, minZ, maxZ);
|
||||
int limit = maxBlocks > 0 ? maxBlocks : DEFAULT_MAX_BLOCKS;
|
||||
if (totalBlocks <= 0) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED empty region");
|
||||
return;
|
||||
}
|
||||
if (totalBlocks > limit) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED block count " + totalBlocks + " exceeds MaxBlocks=" + limit);
|
||||
return;
|
||||
}
|
||||
|
||||
World world = context.getStore().getExternalData().getWorld();
|
||||
Map<Long, List<BlockPosition>> positionsByChunk = new HashMap<>();
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
long chunkIndex = ChunkUtil.indexChunkFromBlock(x, z);
|
||||
List<BlockPosition> positions = positionsByChunk.computeIfAbsent(chunkIndex, ignored -> new ArrayList<>());
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
positions.add(new BlockPosition(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
positionsByChunk.forEach((chunkIndex, positions) ->
|
||||
world.getChunkAsync(chunkIndex).thenAcceptAsync(chunk -> {
|
||||
for (BlockPosition position : positions) {
|
||||
chunk.setBlock(position.x(), position.y(), position.z(), blockType);
|
||||
}
|
||||
}, world).exceptionally(throwable -> {
|
||||
LOGGER.at(Level.WARNING).withCause(throwable).log(
|
||||
"PlaceBlocks failed loading chunk=%s block=%s region=(%s,%s,%s)-(%s,%s,%s)",
|
||||
chunkIndex, blockId, minX, minY, minZ, maxX, maxY, maxZ
|
||||
);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
debugMessage(context, TYPE_ID, "FIRED block='" + blockId + "' count=" + totalBlocks);
|
||||
}
|
||||
|
||||
private static long blockCount(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) {
|
||||
if (minX > maxX || minY > maxY || minZ > maxZ) return 0L;
|
||||
return (long) (maxX - minX + 1) * (maxY - minY + 1) * (maxZ - minZ + 1);
|
||||
}
|
||||
|
||||
private record BlockPosition(int x, int y, int z) {}
|
||||
}
|
||||
@@ -40,18 +40,12 @@ public final class RespawnPlayerEffect extends MinigameRuntimeEffect {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
|
||||
return;
|
||||
}
|
||||
String dest = (destinationId != null && !destinationId.isBlank()) ? destinationId : player.checkpointId();
|
||||
if ((dest == null || dest.isBlank()) && player.teamId() != null && !player.teamId().isBlank()) {
|
||||
var services = services();
|
||||
if (services != null) {
|
||||
dest = services.maps().teamSpawnDestination(context.getStore(), rt.get(), player.teamId());
|
||||
}
|
||||
}
|
||||
if (dest == null || dest.isBlank()) {
|
||||
var services = services();
|
||||
String dest = services != null ? services.maps().respawnDestination(context.getStore(), rt.get(), player, destinationId) : "";
|
||||
if (dest.isBlank()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' has no spawn point or team spawn set)");
|
||||
return;
|
||||
}
|
||||
var services = services();
|
||||
if (services != null) {
|
||||
services.adapters().teleport(id, dest);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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 net.kewwbec.minigames.model.LoadoutConfig;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public final class SetPlayerLoadoutEffect extends MinigameRuntimeEffect {
|
||||
public static final String TYPE_ID = "SetPlayerLoadout";
|
||||
|
||||
@Nonnull
|
||||
public static final BuilderCodec<SetPlayerLoadoutEffect> CODEC =
|
||||
BuilderCodec.builder(SetPlayerLoadoutEffect.class, SetPlayerLoadoutEffect::new, MINIGAME_BASE_CODEC)
|
||||
.append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId)
|
||||
.add()
|
||||
.append(new KeyedCodec<>("LoadoutId", Codec.STRING, false), (effect, value) -> effect.loadoutId = value, effect -> effect.loadoutId)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private String playerId;
|
||||
private String loadoutId;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
if (loadoutId != null && !loadoutId.isBlank()) {
|
||||
boolean found = false;
|
||||
for (LoadoutConfig loadout : rt.get().definition().getStartLoadouts()) {
|
||||
if (loadout != null && loadoutId.equals(loadout.getId())) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (loadout='" + loadoutId + "' not found in definition)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
String previous = player.loadoutId();
|
||||
player.loadoutId(loadoutId);
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' loadout " + previous + "->" + loadoutId);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,13 @@ public final class SetPlayerStatusEffect extends MinigameRuntimeEffect {
|
||||
} else {
|
||||
rt.get().spectators().remove(id);
|
||||
}
|
||||
if (resolvedStatus == PlayerStatus.ELIMINATED) {
|
||||
if (resolvedStatus == PlayerStatus.ACTIVE) {
|
||||
rt.get().eliminatedPlayers().remove(id);
|
||||
var svc = services();
|
||||
if (svc != null) {
|
||||
svc.runtime().onPlayerActivated(rt.get(), id);
|
||||
}
|
||||
} else if (resolvedStatus == PlayerStatus.ELIMINATED) {
|
||||
rt.get().eliminatedPlayers().add(id);
|
||||
var svc = services();
|
||||
if (svc != null) {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
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.component.Ref;
|
||||
import com.hypixel.hytale.math.vector.Rotation3f;
|
||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import com.hypixel.hytale.server.npc.NPCPlugin;
|
||||
import org.joml.Vector3d;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class SpawnNpcEffect extends MinigameRuntimeEffect {
|
||||
public static final String TYPE_ID = "SpawnNpc";
|
||||
|
||||
@Nonnull
|
||||
public static final BuilderCodec<SpawnNpcEffect> CODEC =
|
||||
BuilderCodec.builder(SpawnNpcEffect.class, SpawnNpcEffect::new, MINIGAME_BASE_CODEC)
|
||||
.<String>append(new KeyedCodec<>("RoleId", Codec.STRING), (effect, value) -> effect.roleId = value, effect -> effect.roleId)
|
||||
.add()
|
||||
.<Integer>append(new KeyedCodec<>("Count", Codec.INTEGER, false), (effect, value) -> effect.count = value, effect -> effect.count)
|
||||
.add()
|
||||
.<Integer>append(
|
||||
new KeyedCodec<>("RespawnDelaySeconds", Codec.INTEGER, false),
|
||||
(effect, value) -> effect.respawnDelaySeconds = value,
|
||||
effect -> effect.respawnDelaySeconds
|
||||
)
|
||||
.add()
|
||||
.<String>append(new KeyedCodec<>("SpawnKey", Codec.STRING, false), (effect, value) -> effect.spawnKey = value, effect -> effect.spawnKey)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("OffsetX", Codec.DOUBLE, false), (effect, value) -> effect.offsetX = value, effect -> effect.offsetX)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("OffsetY", Codec.DOUBLE, false), (effect, value) -> effect.offsetY = value, effect -> effect.offsetY)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("OffsetZ", Codec.DOUBLE, false), (effect, value) -> effect.offsetZ = value, effect -> effect.offsetZ)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("Yaw", Codec.DOUBLE, false), (effect, value) -> effect.yaw = value, effect -> effect.yaw)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("Pitch", Codec.DOUBLE, false), (effect, value) -> effect.pitch = value, effect -> effect.pitch)
|
||||
.add()
|
||||
.<Double>append(new KeyedCodec<>("Roll", Codec.DOUBLE, false), (effect, value) -> effect.roll = value, effect -> effect.roll)
|
||||
.add()
|
||||
.<Double>append(
|
||||
new KeyedCodec<>("SpreadRadius", Codec.DOUBLE, false),
|
||||
(effect, value) -> effect.spreadRadius = value,
|
||||
effect -> effect.spreadRadius
|
||||
)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
private static final double GOLDEN_ANGLE = Math.PI * (3.0D - Math.sqrt(5.0D));
|
||||
|
||||
private String roleId = "";
|
||||
private int count = 1;
|
||||
private int respawnDelaySeconds = 0;
|
||||
private String spawnKey = "";
|
||||
private double offsetX = 0.0D;
|
||||
private double offsetY = 0.0D;
|
||||
private double offsetZ = 0.0D;
|
||||
private double yaw = 0.0D;
|
||||
private double pitch = 0.0D;
|
||||
private double roll = 0.0D;
|
||||
private double spreadRadius = 0.0D;
|
||||
|
||||
private final transient Map<String, List<Ref<EntityStore>>> activeNpcs = new ConcurrentHashMap<>();
|
||||
private final transient Map<String, Instant> nextSpawnTimes = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void execute(@Nonnull TriggerContext context) {
|
||||
if (roleId == null || roleId.isBlank()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED missing RoleId");
|
||||
return;
|
||||
}
|
||||
|
||||
String key = key(context);
|
||||
List<Ref<EntityStore>> active = pruneActive(key);
|
||||
int targetCount = Math.max(1, count);
|
||||
if (active.size() >= targetCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active.isEmpty()) {
|
||||
spawnMissing(context, key, active, targetCount);
|
||||
return;
|
||||
}
|
||||
|
||||
Instant nextSpawn = nextSpawnTimes.get(key);
|
||||
if (nextSpawn != null && Instant.now().isBefore(nextSpawn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextSpawn == null && activeNpcs.containsKey(key)) {
|
||||
int delay = Math.max(0, respawnDelaySeconds);
|
||||
if (delay > 0) {
|
||||
nextSpawnTimes.put(key, Instant.now().plus(Duration.ofSeconds(delay)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
spawnMissing(context, key, active, targetCount);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private List<Ref<EntityStore>> pruneActive(@Nonnull String key) {
|
||||
List<Ref<EntityStore>> active = activeNpcs.get(key);
|
||||
if (active == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
active.removeIf(ref -> ref == null || !ref.isValid());
|
||||
return active;
|
||||
}
|
||||
|
||||
private void spawnMissing(@Nonnull TriggerContext context, @Nonnull String key, @Nonnull List<Ref<EntityStore>> active, int targetCount) {
|
||||
NPCPlugin npcPlugin = NPCPlugin.get();
|
||||
if (npcPlugin == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED NPC plugin unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
int roleIndex = npcPlugin.getIndex(roleId);
|
||||
var roleInfo = npcPlugin.getRoleBuilderInfo(roleIndex);
|
||||
if (roleInfo == null || roleInfo.getBuilder() == null || !roleInfo.getBuilder().isSpawnable()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED invalid spawnable role='" + roleId + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
int before = active.size();
|
||||
for (int i = before; i < targetCount; i++) {
|
||||
spawnOne(context, npcPlugin, roleIndex, active, i, targetCount);
|
||||
}
|
||||
|
||||
activeNpcs.put(key, active);
|
||||
nextSpawnTimes.remove(key);
|
||||
debugMessage(context, TYPE_ID, "FIRED spawned role='" + roleId + "' count=" + (active.size() - before));
|
||||
}
|
||||
|
||||
private void spawnOne(
|
||||
@Nonnull TriggerContext context,
|
||||
@Nonnull NPCPlugin npcPlugin,
|
||||
int roleIndex,
|
||||
@Nonnull List<Ref<EntityStore>> active,
|
||||
int index,
|
||||
int targetCount
|
||||
) {
|
||||
Vector3d position = new Vector3d(context.getVolume().getPosition())
|
||||
.add(offsetX, offsetY, offsetZ)
|
||||
.add(spreadOffset(index, targetCount));
|
||||
Rotation3f rotation = new Rotation3f(
|
||||
(float) Math.toRadians(pitch),
|
||||
(float) Math.toRadians(yaw),
|
||||
(float) Math.toRadians(roll)
|
||||
);
|
||||
var npc = npcPlugin.spawnEntity(context.getStore(), roleIndex, position, rotation, null, null);
|
||||
if (npc == null || npc.first() == null || !npc.first().isValid()) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED failed to spawn role='" + roleId + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
Ref<EntityStore> ref = npc.first();
|
||||
active.add(ref);
|
||||
runtime(context).ifPresent(runtime -> {
|
||||
UUIDComponent uuid = context.getStore().getComponent(ref, UUIDComponent.getComponentType());
|
||||
if (uuid != null) {
|
||||
runtime.temporaryEntities().add(uuid.getUuid().toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Vector3d spreadOffset(int index, int targetCount) {
|
||||
double radius = Math.max(0.0D, spreadRadius);
|
||||
if (radius <= 0.0D || targetCount <= 1) {
|
||||
return new Vector3d();
|
||||
}
|
||||
|
||||
double distance = radius * Math.sqrt((index + 0.5D) / targetCount);
|
||||
double angle = index * GOLDEN_ANGLE;
|
||||
return new Vector3d(Math.cos(angle) * distance, 0.0D, Math.sin(angle) * distance);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private String key(@Nonnull TriggerContext context) {
|
||||
String configured = spawnKey != null ? spawnKey.trim() : "";
|
||||
String volumeId = context.getVolume().getId();
|
||||
return volumeId + "|" + (!configured.isBlank() ? configured : roleId);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,28 @@ customUI.triggerVolumeEffectEditor.field.SpawnItem.SpawnKey.tooltip = Optional u
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetX.tooltip = Spawn position offset on the X axis.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetY.tooltip = Spawn position offset on the Y axis.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetZ.tooltip = Spawn position offset on the Z axis.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.MinigameId.tooltip = Optional minigame runtime to associate this spawned NPC with.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.RoleId.tooltip = The spawnable NPC role id to spawn.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.Count.tooltip = Number of NPCs to keep active for this spawn key.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.RespawnDelaySeconds.tooltip = Seconds to wait before respawning after all active NPCs for this spawn key are gone.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.SpawnKey.tooltip = Optional unique id for this NPC spawner.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.OffsetX.tooltip = Spawn position offset on the X axis.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.OffsetY.tooltip = Spawn position offset on the Y axis.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.OffsetZ.tooltip = Spawn position offset on the Z axis.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.Yaw.tooltip = Spawn rotation yaw in degrees.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.Pitch.tooltip = Spawn rotation pitch in degrees.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.Roll.tooltip = Spawn rotation roll in degrees.
|
||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.SpreadRadius.tooltip = Radius used to spread multiple spawned NPCs around the spawn position.
|
||||
customUI.triggerVolumeEffectEditor.field.MountPlayer.MinigameId.tooltip = Optional minigame runtime this mount action belongs to.
|
||||
customUI.triggerVolumeEffectEditor.field.MountPlayer.PlayerId.tooltip = Optional player UUID. Leave empty to mount the triggering player.
|
||||
customUI.triggerVolumeEffectEditor.field.MountPlayer.NpcId.tooltip = Optional exact NPC UUID to mount onto.
|
||||
customUI.triggerVolumeEffectEditor.field.MountPlayer.NpcRoleId.tooltip = Optional NPC role filter, such as a horse role.
|
||||
customUI.triggerVolumeEffectEditor.field.MountPlayer.MaxDistance.tooltip = Maximum distance from the player to the matched NPC. Set 0 or less for no distance limit.
|
||||
customUI.triggerVolumeEffectEditor.field.MountPlayer.AnchorX.tooltip = Rider anchor offset on the X axis.
|
||||
customUI.triggerVolumeEffectEditor.field.MountPlayer.AnchorY.tooltip = Rider anchor offset on the Y axis.
|
||||
customUI.triggerVolumeEffectEditor.field.MountPlayer.AnchorZ.tooltip = Rider anchor offset on the Z axis.
|
||||
customUI.triggerVolumeEffectEditor.field.MountPlayer.MovementConfig.tooltip = Optional movement config applied to the player while mounted.
|
||||
customUI.triggerVolumeEffectEditor.field.MountPlayer.EmptyRoleId.tooltip = NPC role used while mounted. Defaults to Empty_Role.
|
||||
customUI.triggerVolumeEffectEditor.field.AwardKillScore.MinigameId.tooltip = Optional minigame id this kill scoring rule applies to.
|
||||
customUI.triggerVolumeEffectEditor.field.AwardKillScore.TargetIds.tooltip = Player, NPC role ids such as fox, or * for any supported kill target.
|
||||
customUI.triggerVolumeEffectEditor.field.AwardKillScore.Points.tooltip = Points awarded to the killer when the victim matches this rule.
|
||||
@@ -191,3 +213,5 @@ customUI.triggerVolumeEffectEditor.effectType.LeaveMinigameQueue = Leave Minigam
|
||||
customUI.triggerVolumeEffectEditor.effectType.VoteForMap = Vote For Map
|
||||
customUI.triggerVolumeEffectEditor.effectType.StartQueuedMinigame = Start Queued Minigame
|
||||
customUI.triggerVolumeEffectEditor.effectType.SpawnItem = Spawn Item
|
||||
customUI.triggerVolumeEffectEditor.effectType.SpawnNpc = Spawn NPC
|
||||
customUI.triggerVolumeEffectEditor.effectType.MountPlayer = Mount Player
|
||||
|
||||
Reference in New Issue
Block a user