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);
|
String getDisplayName(String playerId);
|
||||||
void setGameMode(String playerId, GameMode gameMode);
|
void setGameMode(String playerId, GameMode gameMode);
|
||||||
void giveItem(String playerId, String itemId, int amount);
|
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 {
|
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.InventoryComponent;
|
||||||
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
|
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
|
||||||
import com.hypixel.hytale.server.core.inventory.ItemStack;
|
import com.hypixel.hytale.server.core.inventory.ItemStack;
|
||||||
|
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.modules.entity.teleport.Teleport;
|
||||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
import com.hypixel.hytale.server.core.universe.Universe;
|
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
|
@Override
|
||||||
public void giveItem(String playerId, String itemId, int amount) {
|
public void giveItem(String playerId, String itemId, int amount) {
|
||||||
if (itemId == null || itemId.isBlank() || amount <= 0) return;
|
if (itemId == null || itemId.isBlank() || amount <= 0) return;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package net.kewwbec.minigames.command.sub;
|
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.Message;
|
||||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||||
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
import com.hypixel.hytale.server.core.command.system.arguments.system.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.arguments.types.ArgTypes;
|
||||||
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
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.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.MinigameQueueService;
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
import net.kewwbec.minigames.volume.effect.SetPlayerLoadoutEffect;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
@@ -40,7 +45,17 @@ public final class MinigameJoinCommand extends CommandBase {
|
|||||||
return;
|
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) {
|
switch (result) {
|
||||||
case JOINED -> ctx.sendMessage(Message.translation("minigames.command.join.success").param("id", id));
|
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));
|
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));
|
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;
|
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.Message;
|
||||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||||
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
import com.hypixel.hytale.server.core.command.system.arguments.system.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.arguments.types.ArgTypes;
|
||||||
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
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.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.runtime.MinigameRuntime;
|
||||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import javax.annotation.Nullable;
|
|
||||||
|
|
||||||
import static net.kewwbec.minigames.model.Enums.PlayerStatus;
|
|
||||||
|
|
||||||
public final class MinigameSpectateCommand extends CommandBase {
|
public final class MinigameSpectateCommand extends CommandBase {
|
||||||
private final MinigameServices services;
|
private final MinigameServices services;
|
||||||
@@ -67,52 +57,9 @@ public final class MinigameSpectateCommand extends CommandBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MinigameRuntime runtime = runtimes.iterator().next();
|
MinigameRuntime runtime = runtimes.iterator().next();
|
||||||
PlayerMinigameState state = runtime.players().computeIfAbsent(playerId,
|
services.runtime().onPlayerSpectate(runtime, playerId);
|
||||||
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
|
|
||||||
state.status(PlayerStatus.SPECTATOR);
|
|
||||||
runtime.spectators().add(playerId);
|
|
||||||
|
|
||||||
teleportToArena(target, runtime);
|
SpectatorArenaSupport.teleportToArena(services, target, runtime);
|
||||||
ctx.sendMessage(Message.translation("minigames.command.spectate.success").param("id", id));
|
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) {
|
if (playerAdapter != null) {
|
||||||
|
// A reactivated spectator must lose the spectator presentation before becoming playable.
|
||||||
|
playerAdapter.clearSpectatorState(playerId);
|
||||||
playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
|
playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
|
||||||
}
|
}
|
||||||
if (def.isShowHud() && hudService != null) {
|
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
|
@Override
|
||||||
public void removePlayer(MinigameRuntime runtime, String playerId, String reason) {
|
public void removePlayer(MinigameRuntime runtime, String playerId, String reason) {
|
||||||
var player = runtime.players().get(playerId);
|
var player = runtime.players().get(playerId);
|
||||||
if (player == null || player.status() == PlayerStatus.LEFT) {
|
if (player == null || player.status() == PlayerStatus.LEFT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
boolean wasSpectator = runtime.spectators().contains(playerId);
|
||||||
player.status(PlayerStatus.LEFT);
|
player.status(PlayerStatus.LEFT);
|
||||||
if (player.teamId() != null) {
|
if (player.teamId() != null) {
|
||||||
var team = runtime.teams().get(player.teamId());
|
var team = runtime.teams().get(player.teamId());
|
||||||
@@ -378,6 +440,9 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
runtime.spectators().remove(playerId);
|
runtime.spectators().remove(playerId);
|
||||||
|
if (wasSpectator && playerAdapter != null) {
|
||||||
|
playerAdapter.clearSpectatorState(playerId);
|
||||||
|
}
|
||||||
if (hudService != null) {
|
if (hudService != null) {
|
||||||
hudService.removeForPlayer(playerId);
|
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.
|
// 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.players().values().forEach(player -> player.score(0));
|
||||||
runtime.teams().values().forEach(team -> team.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.players().clear();
|
||||||
runtime.teams().clear();
|
runtime.teams().clear();
|
||||||
runtime.objectives().clear();
|
runtime.objectives().clear();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package net.kewwbec.minigames.runtime;
|
|||||||
|
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||||
|
import net.kewwbec.minigames.service.MinigameQueueService.JoinResult;
|
||||||
|
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@@ -42,6 +43,21 @@ public interface MinigameRuntimeService {
|
|||||||
|
|
||||||
void onPlayerActivated(MinigameRuntime runtime, String playerId);
|
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. */
|
/** Auto-creates and balances teams when the definition uses TeamMode.TEAMS. Call after players are added. */
|
||||||
void setupTeams(MinigameRuntime runtime);
|
void setupTeams(MinigameRuntime runtime);
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ public final class MinigameRuleEnforcementSystems {
|
|||||||
return services.runtime().runtimeForPlayer(player.getUuid().toString()).orElse(null);
|
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. */
|
/** Cancels player-vs-player damage when either party's minigame disables PvP. */
|
||||||
public static final class PvpGuard extends DamageEventSystem {
|
public static final class PvpGuard extends DamageEventSystem {
|
||||||
@Nullable
|
@Nullable
|
||||||
@@ -75,6 +85,11 @@ public final class MinigameRuleEnforcementSystems {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ref<EntityStore> victimRef = archetypeChunk.getReferenceTo(index);
|
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);
|
MinigameRuntime victimRuntime = runtimeFor(store, victimRef);
|
||||||
if (victimRuntime != null && !victimRuntime.definition().isAllowPvp()) {
|
if (victimRuntime != null && !victimRuntime.definition().isAllowPvp()) {
|
||||||
damage.setCancelled(true);
|
damage.setCancelled(true);
|
||||||
@@ -110,7 +125,12 @@ public final class MinigameRuleEnforcementSystems {
|
|||||||
if (event.isCancelled()) {
|
if (event.isCancelled()) {
|
||||||
return;
|
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()) {
|
if (runtime != null && !runtime.definition().isAllowBlockBreaking()) {
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
}
|
}
|
||||||
@@ -140,7 +160,12 @@ public final class MinigameRuleEnforcementSystems {
|
|||||||
if (event.isCancelled()) {
|
if (event.isCancelled()) {
|
||||||
return;
|
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()) {
|
if (runtime != null && !runtime.definition().isAllowBlockPlacing()) {
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.hypixel.hytale.codec.KeyedCodec;
|
|||||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||||
import net.kewwbec.minigames.model.ItemStackConfig;
|
import net.kewwbec.minigames.model.ItemStackConfig;
|
||||||
import net.kewwbec.minigames.model.LoadoutConfig;
|
import net.kewwbec.minigames.model.LoadoutConfig;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
@@ -38,8 +39,7 @@ public final class SetPlayerLoadoutEffect extends MinigameRuntimeEffect {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var player = rt.get().players().get(id);
|
if (rt.get().players().get(id) == null) {
|
||||||
if (player == null) {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
|
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -50,44 +50,53 @@ public final class SetPlayerLoadoutEffect extends MinigameRuntimeEffect {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Items are only ever granted through this effect — nothing is given automatically
|
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' " + grantLoadout(services, rt.get(), id, loadoutId));
|
||||||
// on activation. A blank LoadoutId grants the definition's StartItems instead.
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()) {
|
if (loadoutId == null || loadoutId.isBlank()) {
|
||||||
for (ItemStackConfig item : rt.get().definition().getStartItems()) {
|
for (ItemStackConfig item : runtime.definition().getStartItems()) {
|
||||||
if (item != null) {
|
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 "granted StartItems";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadoutConfig selected = null;
|
LoadoutConfig selected = null;
|
||||||
for (LoadoutConfig loadout : rt.get().definition().getStartLoadouts()) {
|
for (LoadoutConfig loadout : runtime.definition().getStartLoadouts()) {
|
||||||
if (loadout != null && loadoutId.equals(loadout.getId())) {
|
if (loadout != null && loadoutId.equals(loadout.getId())) {
|
||||||
selected = loadout;
|
selected = loadout;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (loadout='" + loadoutId + "' not found in definition)");
|
return "SKIPPED (loadout='" + loadoutId + "' not found in definition)";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String previous = player.loadoutId();
|
String previous = player.loadoutId();
|
||||||
player.loadoutId(loadoutId);
|
player.loadoutId(loadoutId);
|
||||||
|
|
||||||
if (selected.isPlayerCustomizable() && services.menus() != null) {
|
if (selected.isPlayerCustomizable() && services.menus() != null) {
|
||||||
services.menus().requestLoadoutSelection(id, rt.get());
|
services.menus().requestLoadoutSelection(playerId, runtime);
|
||||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' opened loadout selection");
|
return "opened loadout selection";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ItemStackConfig item : selected.getItems()) {
|
for (ItemStackConfig item : selected.getItems()) {
|
||||||
if (item != null) {
|
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);
|
var player = rt.get().players().get(id);
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
PlayerStatus resolvedStatus = status != null ? status : PlayerStatus.ACTIVE;
|
PlayerStatus resolvedStatus = status != null ? status : PlayerStatus.ACTIVE;
|
||||||
|
var svc = services();
|
||||||
|
boolean wasSpectator = rt.get().spectators().contains(id);
|
||||||
player.status(resolvedStatus);
|
player.status(resolvedStatus);
|
||||||
if (resolvedStatus == PlayerStatus.SPECTATOR) {
|
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 {
|
} else {
|
||||||
rt.get().spectators().remove(id);
|
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) {
|
if (resolvedStatus == PlayerStatus.ACTIVE) {
|
||||||
rt.get().eliminatedPlayers().remove(id);
|
rt.get().eliminatedPlayers().remove(id);
|
||||||
var svc = services();
|
|
||||||
if (svc != null) {
|
if (svc != null) {
|
||||||
svc.runtime().onPlayerActivated(rt.get(), id);
|
svc.runtime().onPlayerActivated(rt.get(), id);
|
||||||
}
|
}
|
||||||
} else if (resolvedStatus == PlayerStatus.ELIMINATED) {
|
} else if (resolvedStatus == PlayerStatus.ELIMINATED) {
|
||||||
rt.get().eliminatedPlayers().add(id);
|
rt.get().eliminatedPlayers().add(id);
|
||||||
var svc = services();
|
|
||||||
if (svc != null) {
|
if (svc != null) {
|
||||||
svc.runtime().dispatch(rt.get(), "on_player_eliminated", Map.of("player_id", id));
|
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.edit.error.save_failed = Could not save '{id}': {error}
|
||||||
command.join.success = Joined the queue for {id}.
|
command.join.success = Joined the queue for {id}.
|
||||||
command.join.already_queued = You are already queued 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.success = You left {id}.
|
||||||
command.leave.not_in_game = You are not in a minigame.
|
command.leave.not_in_game = You are not in a minigame.
|
||||||
command.spectate.success = Now spectating {id}.
|
command.spectate.success = Now spectating {id}.
|
||||||
|
|||||||
Reference in New Issue
Block a user