feat: Add spectator functionality and late join support for minigames
This commit is contained in:
@@ -23,6 +23,12 @@ public final class HytaleAdapters {
|
||||
String getDisplayName(String playerId);
|
||||
void setGameMode(String playerId, GameMode gameMode);
|
||||
void giveItem(String playerId, String itemId, int amount);
|
||||
|
||||
/** Makes the player a true spectator: invulnerable, intangible (noclip), flying, and hidden from adventure players. */
|
||||
void applySpectatorState(String playerId);
|
||||
|
||||
/** Reverts {@link #applySpectatorState}: removes invulnerability/intangibility/hiding and disables flight. */
|
||||
void clearSpectatorState(String playerId);
|
||||
}
|
||||
|
||||
public interface WorldAdapter {
|
||||
|
||||
@@ -18,6 +18,10 @@ import com.hypixel.hytale.protocol.packets.inventory.UpdatePlayerInventory;
|
||||
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
|
||||
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
|
||||
import com.hypixel.hytale.server.core.inventory.ItemStack;
|
||||
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager;
|
||||
import com.hypixel.hytale.server.core.modules.entity.component.HiddenFromAdventurePlayers;
|
||||
import com.hypixel.hytale.server.core.modules.entity.component.Intangible;
|
||||
import com.hypixel.hytale.server.core.modules.entity.component.Invulnerable;
|
||||
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;
|
||||
@@ -84,6 +88,48 @@ public final class HytaleServerAdapters implements
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void applySpectatorState(String playerId) {
|
||||
withPlayerRefAsync(playerId, ref -> {
|
||||
var store = ref.getStore();
|
||||
store.putComponent(ref, Invulnerable.getComponentType(), Invulnerable.INSTANCE);
|
||||
store.putComponent(ref, Intangible.getComponentType(), Intangible.INSTANCE);
|
||||
store.putComponent(ref, HiddenFromAdventurePlayers.getComponentType(), HiddenFromAdventurePlayers.INSTANCE);
|
||||
setFlying(store, ref, true);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearSpectatorState(String playerId) {
|
||||
withPlayerRefAsync(playerId, ref -> {
|
||||
var store = ref.getStore();
|
||||
store.tryRemoveComponent(ref, Invulnerable.getComponentType());
|
||||
store.tryRemoveComponent(ref, Intangible.getComponentType());
|
||||
store.tryRemoveComponent(ref, HiddenFromAdventurePlayers.getComponentType());
|
||||
setFlying(store, ref, false);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggles the player's flight capability, mirroring {@link Player#setGameMode}'s MovementManager handling. */
|
||||
private void setFlying(com.hypixel.hytale.component.Store<EntityStore> store, Ref<EntityStore> ref, boolean canFly) {
|
||||
MovementManager movement = store.getComponent(ref, MovementManager.getComponentType());
|
||||
if (movement == null) {
|
||||
return;
|
||||
}
|
||||
if (movement.getDefaultSettings() != null) {
|
||||
movement.getDefaultSettings().canFly = canFly;
|
||||
}
|
||||
if (movement.getSettings() != null) {
|
||||
movement.getSettings().canFly = canFly;
|
||||
}
|
||||
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
|
||||
if (playerRef != null) {
|
||||
movement.update(playerRef.getPacketHandler());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void giveItem(String playerId, String itemId, int amount) {
|
||||
if (itemId == null || itemId.isBlank() || amount <= 0) return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.kewwbec.minigames.command.sub;
|
||||
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
||||
@@ -7,8 +8,12 @@ import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredAr
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.service.MinigameQueueService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
import net.kewwbec.minigames.volume.effect.SetPlayerLoadoutEffect;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
@@ -40,7 +45,17 @@ public final class MinigameJoinCommand extends CommandBase {
|
||||
return;
|
||||
}
|
||||
|
||||
MinigameQueueService.JoinResult result = services.queue().join(defOpt.get(), target.getUuid().toString());
|
||||
String playerId = target.getUuid().toString();
|
||||
MinigameDefinition def = defOpt.get();
|
||||
|
||||
// A game is already running for this minigame: route to late-join / spectate instead of queueing.
|
||||
var runtimes = services.runtime().runtimesForMinigame(id);
|
||||
if (!runtimes.isEmpty()) {
|
||||
joinRunning(ctx, def, runtimes.iterator().next(), target, playerId, id);
|
||||
return;
|
||||
}
|
||||
|
||||
MinigameQueueService.JoinResult result = services.queue().join(def, playerId);
|
||||
switch (result) {
|
||||
case JOINED -> ctx.sendMessage(Message.translation("minigames.command.join.success").param("id", id));
|
||||
case ALREADY_QUEUED -> ctx.sendMessage(Message.translation("minigames.command.join.already_queued").param("id", id));
|
||||
@@ -48,4 +63,52 @@ public final class MinigameJoinCommand extends CommandBase {
|
||||
case DISABLED -> ctx.sendMessage(Message.translation("minigames.queue.disabled").param("id", id));
|
||||
}
|
||||
}
|
||||
|
||||
private void joinRunning(CommandContext ctx, MinigameDefinition def, MinigameRuntime runtime,
|
||||
PlayerRef target, String playerId, String id) {
|
||||
if (services.runtime().runtimeForPlayer(playerId).isPresent()) {
|
||||
ctx.sendMessage(Message.translation("minigames.command.join.already_in_game").param("id", id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (def.isAllowJoinMidgame()) {
|
||||
MinigameQueueService.JoinResult result = services.runtime().onPlayerJoinMidgame(runtime, playerId);
|
||||
switch (result) {
|
||||
case JOINED -> {
|
||||
spawnAndEquip(target, runtime, playerId);
|
||||
ctx.sendMessage(Message.translation("minigames.command.join.midgame").param("id", id));
|
||||
}
|
||||
case QUEUE_FULL -> ctx.sendMessage(Message.translation("minigames.queue.full").param("id", id));
|
||||
default -> ctx.sendMessage(Message.translation("minigames.command.join.already_in_game").param("id", id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (def.isAllowSpectators()) {
|
||||
services.runtime().onPlayerSpectate(runtime, playerId);
|
||||
SpectatorArenaSupport.teleportToArena(services, target, runtime);
|
||||
ctx.sendMessage(Message.translation("minigames.command.join.as_spectator").param("id", id));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.sendMessage(Message.translation("minigames.command.join.in_progress").param("id", id));
|
||||
}
|
||||
|
||||
/** Late joiners miss the start/round volume triggers, so teleport + equip them explicitly. */
|
||||
private void spawnAndEquip(PlayerRef target, MinigameRuntime runtime, String playerId) {
|
||||
var player = runtime.players().get(playerId);
|
||||
var ref = target.getReference();
|
||||
Store<EntityStore> store = ref != null && ref.isValid() ? ref.getStore() : null;
|
||||
|
||||
String dest = player != null ? services.maps().respawnDestination(store, runtime, player, null) : "";
|
||||
if (!dest.isBlank()) {
|
||||
services.adapters().teleport(playerId, dest);
|
||||
} else {
|
||||
// No team / checkpoint spawn resolvable (e.g. FFA): fall back to the main arena volume.
|
||||
SpectatorArenaSupport.teleportToArena(services, target, runtime);
|
||||
}
|
||||
|
||||
// Grant the definition's default kit (StartItems) — mirrors a blank-loadout SetPlayerLoadout.
|
||||
SetPlayerLoadoutEffect.grantLoadout(services, runtime, playerId, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package net.kewwbec.minigames.command.sub;
|
||||
|
||||
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
||||
@@ -11,16 +7,10 @@ import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredAr
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
import static net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||
|
||||
public final class MinigameSpectateCommand extends CommandBase {
|
||||
private final MinigameServices services;
|
||||
@@ -67,52 +57,9 @@ public final class MinigameSpectateCommand extends CommandBase {
|
||||
}
|
||||
|
||||
MinigameRuntime runtime = runtimes.iterator().next();
|
||||
PlayerMinigameState state = runtime.players().computeIfAbsent(playerId,
|
||||
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
|
||||
state.status(PlayerStatus.SPECTATOR);
|
||||
runtime.spectators().add(playerId);
|
||||
services.runtime().onPlayerSpectate(runtime, playerId);
|
||||
|
||||
teleportToArena(target, runtime);
|
||||
SpectatorArenaSupport.teleportToArena(services, target, runtime);
|
||||
ctx.sendMessage(Message.translation("minigames.command.spectate.success").param("id", id));
|
||||
}
|
||||
|
||||
/** Best effort: drop the spectator at the arena's MainArena volume if one is visible from their world. */
|
||||
private void teleportToArena(PlayerRef target, MinigameRuntime runtime) {
|
||||
var ref = target.getReference();
|
||||
if (ref == null || !ref.isValid()) {
|
||||
return;
|
||||
}
|
||||
Store<EntityStore> store = ref.getStore();
|
||||
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
|
||||
TriggerVolumeManager manager = tvPlugin != null ? store.getResource(tvPlugin.getManagerResourceType()) : null;
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
VolumeEntry arena = findMainArena(manager, runtime);
|
||||
if (arena == null) {
|
||||
return;
|
||||
}
|
||||
var pos = arena.getPosition();
|
||||
String destination = pos.x() + "," + pos.y() + "," + pos.z();
|
||||
services.adapters().teleport(target.getUuid().toString(), destination);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static VolumeEntry findMainArena(TriggerVolumeManager manager, MinigameRuntime runtime) {
|
||||
for (VolumeEntry volume : manager.getVolumes()) {
|
||||
var tags = volume.getRawTags();
|
||||
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||
continue;
|
||||
}
|
||||
if (!MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(tags.get(MinigameMapDiscoveryService.TAG_MAP_ROLE))) {
|
||||
continue;
|
||||
}
|
||||
String mapId = tags.getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "");
|
||||
if (!runtime.mapId().isBlank() && !mapId.isBlank() && !runtime.mapId().equals(mapId)) {
|
||||
continue;
|
||||
}
|
||||
return volume;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package net.kewwbec.minigames.command.sub;
|
||||
|
||||
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/** Shared helper for dropping a player at a runtime's MainArena volume (used by spectate + late join). */
|
||||
final class SpectatorArenaSupport {
|
||||
private SpectatorArenaSupport() {
|
||||
}
|
||||
|
||||
/** Best effort: drop the player at the arena's MainArena volume if one is visible from their world. */
|
||||
static void teleportToArena(MinigameServices services, PlayerRef target, MinigameRuntime runtime) {
|
||||
var ref = target.getReference();
|
||||
if (ref == null || !ref.isValid()) {
|
||||
return;
|
||||
}
|
||||
Store<EntityStore> store = ref.getStore();
|
||||
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
|
||||
TriggerVolumeManager manager = tvPlugin != null ? store.getResource(tvPlugin.getManagerResourceType()) : null;
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
VolumeEntry arena = findMainArena(manager, runtime);
|
||||
if (arena == null) {
|
||||
return;
|
||||
}
|
||||
var pos = arena.getPosition();
|
||||
String destination = pos.x() + "," + pos.y() + "," + pos.z();
|
||||
services.adapters().teleport(target.getUuid().toString(), destination);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static VolumeEntry findMainArena(TriggerVolumeManager manager, MinigameRuntime runtime) {
|
||||
for (VolumeEntry volume : manager.getVolumes()) {
|
||||
var tags = volume.getRawTags();
|
||||
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||
continue;
|
||||
}
|
||||
if (!MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(tags.get(MinigameMapDiscoveryService.TAG_MAP_ROLE))) {
|
||||
continue;
|
||||
}
|
||||
String mapId = tags.getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "");
|
||||
if (!runtime.mapId().isBlank() && !mapId.isBlank() && !runtime.mapId().equals(mapId)) {
|
||||
continue;
|
||||
}
|
||||
return volume;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -357,6 +357,8 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
}
|
||||
|
||||
if (playerAdapter != null) {
|
||||
// A reactivated spectator must lose the spectator presentation before becoming playable.
|
||||
playerAdapter.clearSpectatorState(playerId);
|
||||
playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
|
||||
}
|
||||
if (def.isShowHud() && hudService != null) {
|
||||
@@ -364,12 +366,72 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerSpectate(MinigameRuntime runtime, String playerId) {
|
||||
var state = runtime.players().computeIfAbsent(playerId,
|
||||
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
|
||||
state.status(PlayerStatus.SPECTATOR);
|
||||
runtime.spectators().add(playerId);
|
||||
runtime.eliminatedPlayers().remove(playerId);
|
||||
if (playerAdapter != null) {
|
||||
playerAdapter.applySpectatorState(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public MinigameQueueService.JoinResult onPlayerJoinMidgame(MinigameRuntime runtime, String playerId) {
|
||||
var existing = runtime.players().get(playerId);
|
||||
if (existing != null && existing.status() != PlayerStatus.LEFT && existing.status() != PlayerStatus.SPECTATOR) {
|
||||
return MinigameQueueService.JoinResult.ALREADY_QUEUED;
|
||||
}
|
||||
var def = runtime.definition();
|
||||
long participants = runtime.players().values().stream()
|
||||
.filter(p -> p.status() != PlayerStatus.LEFT && p.status() != PlayerStatus.SPECTATOR)
|
||||
.count();
|
||||
if (participants >= def.getMaxPlayers()) {
|
||||
return MinigameQueueService.JoinResult.QUEUE_FULL;
|
||||
}
|
||||
|
||||
var player = runtime.players().computeIfAbsent(playerId,
|
||||
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
|
||||
player.lives(Math.max(0, def.getDefaultPlayerLives()));
|
||||
player.status(PlayerStatus.ACTIVE);
|
||||
runtime.spectators().remove(playerId);
|
||||
runtime.eliminatedPlayers().remove(playerId);
|
||||
if (def.getTeamMode() == TeamMode.TEAMS) {
|
||||
assignToSmallestTeam(runtime, playerId);
|
||||
}
|
||||
onPlayerActivated(runtime, playerId);
|
||||
|
||||
var payload = new HashMap<>(eventPayload(runtime));
|
||||
payload.put("player_id", playerId);
|
||||
dispatch(new MinigameEvent("on_player_joined_midgame", runtime.minigameId(), Map.copyOf(payload)));
|
||||
return MinigameQueueService.JoinResult.JOINED;
|
||||
}
|
||||
|
||||
/** Places a late joiner on the team with the fewest players (vs. setupTeams which reshuffles everyone). */
|
||||
private void assignToSmallestTeam(MinigameRuntime runtime, String playerId) {
|
||||
List<TeamState> teams = ensureTeams(runtime);
|
||||
if (teams.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
TeamState smallest = teams.get(0);
|
||||
for (TeamState team : teams) {
|
||||
if (team.players().size() < smallest.players().size()) {
|
||||
smallest = team;
|
||||
}
|
||||
}
|
||||
runtime.players().get(playerId).teamId(smallest.teamId());
|
||||
smallest.players().add(playerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePlayer(MinigameRuntime runtime, String playerId, String reason) {
|
||||
var player = runtime.players().get(playerId);
|
||||
if (player == null || player.status() == PlayerStatus.LEFT) {
|
||||
return;
|
||||
}
|
||||
boolean wasSpectator = runtime.spectators().contains(playerId);
|
||||
player.status(PlayerStatus.LEFT);
|
||||
if (player.teamId() != null) {
|
||||
var team = runtime.teams().get(player.teamId());
|
||||
@@ -378,6 +440,9 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
}
|
||||
}
|
||||
runtime.spectators().remove(playerId);
|
||||
if (wasSpectator && playerAdapter != null) {
|
||||
playerAdapter.clearSpectatorState(playerId);
|
||||
}
|
||||
if (hudService != null) {
|
||||
hudService.removeForPlayer(playerId);
|
||||
}
|
||||
@@ -573,6 +638,12 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
||||
// 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));
|
||||
// Strip spectator presentation before dropping the set so invuln/noclip/flight/hidden don't persist.
|
||||
if (playerAdapter != null) {
|
||||
for (String spectatorId : runtime.spectators()) {
|
||||
playerAdapter.clearSpectatorState(spectatorId);
|
||||
}
|
||||
}
|
||||
runtime.players().clear();
|
||||
runtime.teams().clear();
|
||||
runtime.objectives().clear();
|
||||
|
||||
@@ -2,6 +2,7 @@ package net.kewwbec.minigames.runtime;
|
||||
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||
import net.kewwbec.minigames.service.MinigameQueueService.JoinResult;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Collection;
|
||||
@@ -42,6 +43,21 @@ public interface MinigameRuntimeService {
|
||||
|
||||
void onPlayerActivated(MinigameRuntime runtime, String playerId);
|
||||
|
||||
/**
|
||||
* Makes the player a spectator of the runtime: sets {@code SPECTATOR} status, registers them in
|
||||
* {@code runtime.spectators()}, and applies the spectator presentation (invulnerable / noclip /
|
||||
* flight / hidden). The caller is responsible for the initial teleport.
|
||||
*/
|
||||
void onPlayerSpectate(MinigameRuntime runtime, String playerId);
|
||||
|
||||
/**
|
||||
* Adds a brand-new participant to an already-running runtime as {@code ACTIVE}. Honors the
|
||||
* runtime's player cap and (for team modes) balances them onto the smallest team. Does not
|
||||
* teleport or equip the player — the caller handles spawn + loadout. Returns {@code JOINED},
|
||||
* {@code ALREADY_QUEUED} (already in a game) or {@code QUEUE_FULL}.
|
||||
*/
|
||||
JoinResult onPlayerJoinMidgame(MinigameRuntime runtime, String playerId);
|
||||
|
||||
/** Auto-creates and balances teams when the definition uses TeamMode.TEAMS. Call after players are added. */
|
||||
void setupTeams(MinigameRuntime runtime);
|
||||
|
||||
|
||||
@@ -41,6 +41,16 @@ public final class MinigameRuleEnforcementSystems {
|
||||
return services.runtime().runtimeForPlayer(player.getUuid().toString()).orElse(null);
|
||||
}
|
||||
|
||||
/** True when the player behind {@code ref} is a spectator of their current runtime. */
|
||||
private static boolean isSpectator(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
|
||||
if (player == null) {
|
||||
return false;
|
||||
}
|
||||
MinigameRuntime runtime = runtimeFor(store, ref);
|
||||
return runtime != null && runtime.spectators().contains(player.getUuid().toString());
|
||||
}
|
||||
|
||||
/** Cancels player-vs-player damage when either party's minigame disables PvP. */
|
||||
public static final class PvpGuard extends DamageEventSystem {
|
||||
@Nullable
|
||||
@@ -75,6 +85,11 @@ public final class MinigameRuleEnforcementSystems {
|
||||
}
|
||||
|
||||
Ref<EntityStore> victimRef = archetypeChunk.getReferenceTo(index);
|
||||
// Spectators can neither be hit nor deal damage, regardless of the definition's PvP flag.
|
||||
if (isSpectator(store, victimRef) || isSpectator(store, attackerRef)) {
|
||||
damage.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
MinigameRuntime victimRuntime = runtimeFor(store, victimRef);
|
||||
if (victimRuntime != null && !victimRuntime.definition().isAllowPvp()) {
|
||||
damage.setCancelled(true);
|
||||
@@ -110,7 +125,12 @@ public final class MinigameRuleEnforcementSystems {
|
||||
if (event.isCancelled()) {
|
||||
return;
|
||||
}
|
||||
MinigameRuntime runtime = runtimeFor(store, archetypeChunk.getReferenceTo(index));
|
||||
Ref<EntityStore> ref = archetypeChunk.getReferenceTo(index);
|
||||
if (isSpectator(store, ref)) {
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
MinigameRuntime runtime = runtimeFor(store, ref);
|
||||
if (runtime != null && !runtime.definition().isAllowBlockBreaking()) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
@@ -140,7 +160,12 @@ public final class MinigameRuleEnforcementSystems {
|
||||
if (event.isCancelled()) {
|
||||
return;
|
||||
}
|
||||
MinigameRuntime runtime = runtimeFor(store, archetypeChunk.getReferenceTo(index));
|
||||
Ref<EntityStore> ref = archetypeChunk.getReferenceTo(index);
|
||||
if (isSpectator(store, ref)) {
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
MinigameRuntime runtime = runtimeFor(store, ref);
|
||||
if (runtime != null && !runtime.definition().isAllowBlockPlacing()) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.hypixel.hytale.codec.KeyedCodec;
|
||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||
import net.kewwbec.minigames.model.ItemStackConfig;
|
||||
import net.kewwbec.minigames.model.LoadoutConfig;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
@@ -38,8 +39,7 @@ public final class SetPlayerLoadoutEffect extends MinigameRuntimeEffect {
|
||||
return;
|
||||
}
|
||||
|
||||
var player = rt.get().players().get(id);
|
||||
if (player == null) {
|
||||
if (rt.get().players().get(id) == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
|
||||
return;
|
||||
}
|
||||
@@ -50,44 +50,53 @@ public final class SetPlayerLoadoutEffect extends MinigameRuntimeEffect {
|
||||
return;
|
||||
}
|
||||
|
||||
// Items are only ever granted through this effect — nothing is given automatically
|
||||
// on activation. A blank LoadoutId grants the definition's StartItems instead.
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' " + grantLoadout(services, rt.get(), id, loadoutId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants a loadout's items to a player already present in {@code runtime}. A blank {@code loadoutId}
|
||||
* grants the definition's StartItems. Items are only ever granted through here — never automatically
|
||||
* on activation. Returns a short outcome string for logging. Reused by mid-game late-join equipping.
|
||||
*/
|
||||
public static String grantLoadout(@Nonnull MinigameServices services, @Nonnull net.kewwbec.minigames.runtime.MinigameRuntime runtime, @Nonnull String playerId, String loadoutId) {
|
||||
var player = runtime.players().get(playerId);
|
||||
if (player == null) {
|
||||
return "SKIPPED (not in runtime)";
|
||||
}
|
||||
|
||||
if (loadoutId == null || loadoutId.isBlank()) {
|
||||
for (ItemStackConfig item : rt.get().definition().getStartItems()) {
|
||||
for (ItemStackConfig item : runtime.definition().getStartItems()) {
|
||||
if (item != null) {
|
||||
services.adapters().giveItem(id, item.getItemId(), item.getAmount());
|
||||
services.adapters().giveItem(playerId, item.getItemId(), item.getAmount());
|
||||
}
|
||||
}
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' granted StartItems");
|
||||
return;
|
||||
return "granted StartItems";
|
||||
}
|
||||
|
||||
LoadoutConfig selected = null;
|
||||
for (LoadoutConfig loadout : rt.get().definition().getStartLoadouts()) {
|
||||
for (LoadoutConfig loadout : runtime.definition().getStartLoadouts()) {
|
||||
if (loadout != null && loadoutId.equals(loadout.getId())) {
|
||||
selected = loadout;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected == null) {
|
||||
debugMessage(context, TYPE_ID, "SKIPPED (loadout='" + loadoutId + "' not found in definition)");
|
||||
return;
|
||||
return "SKIPPED (loadout='" + loadoutId + "' not found in definition)";
|
||||
}
|
||||
|
||||
String previous = player.loadoutId();
|
||||
player.loadoutId(loadoutId);
|
||||
|
||||
if (selected.isPlayerCustomizable() && services.menus() != null) {
|
||||
services.menus().requestLoadoutSelection(id, rt.get());
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' opened loadout selection");
|
||||
return;
|
||||
services.menus().requestLoadoutSelection(playerId, runtime);
|
||||
return "opened loadout selection";
|
||||
}
|
||||
|
||||
for (ItemStackConfig item : selected.getItems()) {
|
||||
if (item != null) {
|
||||
services.adapters().giveItem(id, item.getItemId(), item.getAmount());
|
||||
services.adapters().giveItem(playerId, item.getItemId(), item.getAmount());
|
||||
}
|
||||
}
|
||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' loadout " + previous + "->" + loadoutId);
|
||||
return "loadout " + previous + "->" + loadoutId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,21 +38,29 @@ public final class SetPlayerStatusEffect extends MinigameRuntimeEffect {
|
||||
var player = rt.get().players().get(id);
|
||||
if (player != null) {
|
||||
PlayerStatus resolvedStatus = status != null ? status : PlayerStatus.ACTIVE;
|
||||
var svc = services();
|
||||
boolean wasSpectator = rt.get().spectators().contains(id);
|
||||
player.status(resolvedStatus);
|
||||
if (resolvedStatus == PlayerStatus.SPECTATOR) {
|
||||
if (svc != null) {
|
||||
svc.runtime().onPlayerSpectate(rt.get(), id);
|
||||
} else {
|
||||
rt.get().spectators().add(id);
|
||||
}
|
||||
} else {
|
||||
rt.get().spectators().remove(id);
|
||||
// ACTIVE clears spectator presentation itself (onPlayerActivated); handle other exits here.
|
||||
if (wasSpectator && resolvedStatus != PlayerStatus.ACTIVE && svc != null) {
|
||||
svc.adapters().clearSpectatorState(id);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
svc.runtime().dispatch(rt.get(), "on_player_eliminated", Map.of("player_id", id));
|
||||
}
|
||||
|
||||
@@ -162,6 +162,10 @@ command.edit.error.unknown_property = Unknown property '{property}'. Valid prope
|
||||
command.edit.error.save_failed = Could not save '{id}': {error}
|
||||
command.join.success = Joined the queue for {id}.
|
||||
command.join.already_queued = You are already queued for {id}.
|
||||
command.join.already_in_game = You are already in a minigame.
|
||||
command.join.midgame = Joined {id} in progress.
|
||||
command.join.as_spectator = {id} is in progress — joined as a spectator.
|
||||
command.join.in_progress = {id} has already started and does not allow joining or spectating.
|
||||
command.leave.success = You left {id}.
|
||||
command.leave.not_in_game = You are not in a minigame.
|
||||
command.spectate.success = Now spectating {id}.
|
||||
|
||||
Reference in New Issue
Block a user