diff --git a/src/main/java/net/kewwbec/minigames/adapter/HytaleAdapters.java b/src/main/java/net/kewwbec/minigames/adapter/HytaleAdapters.java index aae77b5..6079b12 100644 --- a/src/main/java/net/kewwbec/minigames/adapter/HytaleAdapters.java +++ b/src/main/java/net/kewwbec/minigames/adapter/HytaleAdapters.java @@ -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 { diff --git a/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java b/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java index 7a33f3c..c888698 100644 --- a/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java +++ b/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java @@ -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 store, Ref 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; diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameJoinCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameJoinCommand.java index bf4cb34..65bb5fe 100644 --- a/src/main/java/net/kewwbec/minigames/command/sub/MinigameJoinCommand.java +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameJoinCommand.java @@ -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 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); + } } diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameSpectateCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameSpectateCommand.java index da99e8d..d0f637b 100644 --- a/src/main/java/net/kewwbec/minigames/command/sub/MinigameSpectateCommand.java +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameSpectateCommand.java @@ -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 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; - } } diff --git a/src/main/java/net/kewwbec/minigames/command/sub/SpectatorArenaSupport.java b/src/main/java/net/kewwbec/minigames/command/sub/SpectatorArenaSupport.java new file mode 100644 index 0000000..61921c8 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/SpectatorArenaSupport.java @@ -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 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; + } +} diff --git a/src/main/java/net/kewwbec/minigames/runtime/DefaultMinigameRuntimeService.java b/src/main/java/net/kewwbec/minigames/runtime/DefaultMinigameRuntimeService.java index 5c74caa..081fa5b 100644 --- a/src/main/java/net/kewwbec/minigames/runtime/DefaultMinigameRuntimeService.java +++ b/src/main/java/net/kewwbec/minigames/runtime/DefaultMinigameRuntimeService.java @@ -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 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(); diff --git a/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java b/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java index 2a471ba..a03d768 100644 --- a/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java +++ b/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java @@ -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); diff --git a/src/main/java/net/kewwbec/minigames/system/MinigameRuleEnforcementSystems.java b/src/main/java/net/kewwbec/minigames/system/MinigameRuleEnforcementSystems.java index 2211640..2d25c7f 100644 --- a/src/main/java/net/kewwbec/minigames/system/MinigameRuleEnforcementSystems.java +++ b/src/main/java/net/kewwbec/minigames/system/MinigameRuleEnforcementSystems.java @@ -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 store, @Nonnull Ref 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 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 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 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); } diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerLoadoutEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerLoadoutEffect.java index 55b0c47..85d2f65 100644 --- a/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerLoadoutEffect.java +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerLoadoutEffect.java @@ -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; } } diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerStatusEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerStatusEffect.java index a9e7f41..ecb4f40 100644 --- a/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerStatusEffect.java +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SetPlayerStatusEffect.java @@ -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) { - rt.get().spectators().add(id); + 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)); } diff --git a/src/main/resources/Server/Languages/en-US/minigames.lang b/src/main/resources/Server/Languages/en-US/minigames.lang index e9779e0..557f4b4 100644 --- a/src/main/resources/Server/Languages/en-US/minigames.lang +++ b/src/main/resources/Server/Languages/en-US/minigames.lang @@ -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}.