Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 69 additions & 51 deletions game/src/main/java/net/onelitefeather/cygnus/phase/LobbyPhase.java
Original file line number Diff line number Diff line change
@@ -1,89 +1,92 @@
package net.onelitefeather.cygnus.phase;

import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.Player;
import net.minestom.server.network.ConnectionManager;
import net.minestom.server.event.EventDispatcher;
import net.onelitefeather.cygnus.common.config.GameConfig;
import net.onelitefeather.cygnus.map.event.GameMapLoadEvent;
import net.onelitefeather.cygnus.map.event.GamePrepareEvent;
import net.onelitefeather.cygnus.phase.task.LobbyWaitingTask;
import net.theevilreaper.xerus.api.phase.TickDirection;
import net.theevilreaper.xerus.api.phase.TimedPhase;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.Player;
import net.minestom.server.network.ConnectionManager;
import net.minestom.server.timer.Task;

import java.time.temporal.ChronoUnit;

import static net.onelitefeather.cygnus.common.config.GameConfig.FORCE_START_TIME;

/**
* Represents the lobby phase of the game.
*
* <p>During this phase the game waits until enough players joined to start the
* <p>
* During this phase, the game waits until enough players have joined to start the
* match countdown. The phase updates the player level and experience bar to
* visualize the remaining time until the game starts.</p>
*
* <p>The phase can be paused automatically if the required player count is not
* reached anymore. It also supports force starting the game with a reduced
* countdown duration.</p>
*
* <p>While the countdown is running, the game map loading and stamina
* initialization are triggered at specific countdown timestamps.</p>
* visualize the remaining time until the game starts, and manages the action bar
* waiting display using a tick-aligned scheduler.
* </p>
*
* @author theEvilReaper
* @version 1.0.0
* @version 1.2.0
* @since 1.0.0
*/
public final class LobbyPhase extends TimedPhase {

private static final ConnectionManager CONNECTION_MANAGER = MinecraftServer.getConnectionManager();
private final Task waitingTask;

private final int lobbyTime;
private final int minPlayers;
private final LobbyWaitingTask waitingDisplay;
private boolean forceStarted;
private Component displayComponent;

/**
* Constructs a new LobbyPhase.
*
* @param gameConfig the configuration settings for the game
*/
public LobbyPhase(GameConfig gameConfig) {
super("Lobby", ChronoUnit.SECONDS, 1);
this.lobbyTime = gameConfig.lobbyTime();
this.minPlayers = gameConfig.minPlayers();
this.setPaused(true);
this.setCurrentTicks(lobbyTime);
this.setTickDirection(TickDirection.DOWN);
this.updateComponent();
this.waitingTask = MinecraftServer.getSchedulerManager().buildTask(() -> {
if (isPaused() || isFinished() || CONNECTION_MANAGER.getOnlinePlayers().isEmpty())
return;
CONNECTION_MANAGER.getOnlinePlayers().forEach(
player -> player.sendActionBar(this.displayComponent)
);
}).repeat(30, ChronoUnit.SECONDS).schedule();
}

private void updateComponent() {
int playerDifference = Math.max(0, this.minPlayers - CONNECTION_MANAGER.getOnlinePlayers().size());
this.displayComponent = Component.text("Need", NamedTextColor.GRAY)
.append(Component.space())
.append(Component.text(playerDifference, NamedTextColor.RED))
.append(Component.space())
.append(Component.text("players to start", NamedTextColor.GRAY));
// Instantiate the waiting display only once during initialization
this.waitingDisplay = new LobbyWaitingTask(this.minPlayers);
this.waitingDisplay.update(CONNECTION_MANAGER.getOnlinePlayers().size());
}

/**
* Checks if the start condition is met.
* If the online player count reaches the minimum requirement, the countdown starts
* and the waiting action bar display is stopped.
*/
public void checkStartCondition() {
this.updateComponent();
if (isPaused() && CONNECTION_MANAGER.getOnlinePlayers().size() >= this.minPlayers) {
int onlineCount = CONNECTION_MANAGER.getOnlinePlayers().size();

if (isPaused() && onlineCount >= this.minPlayers) {
this.setPaused(false);
this.waitingDisplay.stop(); // Stop the loop, but keep the final object
} else {
this.waitingDisplay.update(onlineCount);
}
}

/**
* Checks if the countdown should stop when players leave the server.
* Re-activates the waiting display if the player count drops below the requirement.
*/
public void checkStopCondition() {
if (waitingTask.isAlive() && CONNECTION_MANAGER.getOnlinePlayers().size() - 1 <= this.minPlayers) {
int onlineCount = CONNECTION_MANAGER.getOnlinePlayers().size();

if (onlineCount - 1 < this.minPlayers) {
this.setPaused(true);
this.updateComponent();
this.setCurrentTicks(this.lobbyTime);
this.forceStarted = false;
setLevel();
this.setLevel();

// Simply restart the existing display instead of allocating a new one
this.waitingDisplay.start();
this.waitingDisplay.update(onlineCount);
}
}

Expand All @@ -95,7 +98,7 @@ public void start() {

@Override
protected void onFinish() {
this.waitingTask.cancel();
this.waitingDisplay.stop(); // Stop the tick-aligned loop
}

@Override
Expand All @@ -113,14 +116,20 @@ public void onUpdate() {

/**
* Checks if the player requirements are met.
* If the player requirements are not met, the phase will be paused.
* Re-creates the display if the player count drops below the threshold.
*/
public void checkPlayerRequirements() {
if (CONNECTION_MANAGER.getOnlinePlayers().size() - 1 < this.minPlayers) {
int onlineCount = CONNECTION_MANAGER.getOnlinePlayers().size();

if (onlineCount - 1 < this.minPlayers) {
this.setPaused(true);
this.setForceStarted(false);
this.setCurrentTicks(this.lobbyTime);
this.setLevel(this.lobbyTime);

// Simply restart the existing display instead of allocating a new one
this.waitingDisplay.start();
this.waitingDisplay.update(onlineCount);
}
}

Expand All @@ -129,11 +138,9 @@ private void setLevel() {
}

/**
* Sets the level for all online players.
* The level is calculated by the current ticks.
* The experience is calculated by the current ticks divided by the lobby phase time.
* Sets the level and XP progress for all online players.
*
* @param amount the amount of the level
* @param amount the current countdown level
*/
private void setLevel(int amount) {
if (amount < 0) return;
Expand All @@ -146,29 +153,40 @@ private void setLevel(int amount) {
}

/**
* Sets the level for the given player.
* Sets the level for a specific player (typically called on join).
* Also immediately sends the waiting action bar if the lobby is paused.
*
* @param player the player to set the level
* @param player the player to set the level for
*/
public void setLevel(Player player) {
if (getCurrentTicks() < 0) return;
player.setLevel(getCurrentTicks());
player.setExp(getCurrentTicks() / (float) this.lobbyTime);

// Immediately send the action bar to the newly spawned player
if (isPaused()) {
this.waitingDisplay.send(player);
}
}

/**
* Returns if the phase was force started.
*
* @return True if the phase was force started otherwise false
* @return true if the phase was force started, otherwise false
*/
public boolean isForceStarted() {
return forceStarted;
}

/**
* Sets whether the phase is force started.
*
* @param forceStarted true to force start, otherwise false
*/
public void setForceStarted(boolean forceStarted) {
if (forceStarted) {
this.setCurrentTicks(FORCE_START_TIME);
}
this.forceStarted = forceStarted;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package net.onelitefeather.cygnus.phase.task;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.Player;

import java.util.Collection;

/**
* Handles the action bar display logic for players while the lobby is waiting
* for the minimum required player count to start the match countdown.
* <p>
* This class is designed to be reusable. The tick-aligned scheduler can be started
* and stopped dynamically without re-instantiating the object.
* </p>
*
* @author theEvilReaper
* @version 1.3.0
* @since 1.0.0
**/
public final class LobbyWaitingTask {

private static final Component PREFIX_NEED = Component.text("Need", NamedTextColor.GRAY);
private static final Component SUFFIX_TO_START = Component.text("players to start", NamedTextColor.GRAY);
private static final Component SPACE = Component.space();
private static final int DISPLAY_REFRESH_INTERVAL_TICKS = 40; // Approx. 2 seconds

private final int minPlayers;
private final Component[] cachedComponents;
private final Runnable tickRunnable;

private Component displayComponent = Component.empty();
private int tickCounter = 0;
private boolean running = false;

/**
* Creates a new instance of the waiting display and starts the tick-aligned loop.
*
* @param minPlayers the minimum number of players required to start the game
*/
public LobbyWaitingTask(int minPlayers) {
this.minPlayers = minPlayers;
this.cachedComponents = new Component[minPlayers + 1];

// Pre-build all possible text components for player differences [0 ... minPlayers]
for (int diff = 0; diff <= minPlayers; diff++) {
this.cachedComponents[diff] = PREFIX_NEED
.append(SPACE)
.append(Component.text(diff, NamedTextColor.RED))
.append(SPACE)
.append(SUFFIX_TO_START);
}

// Define the self-scheduling tick task
this.tickRunnable = new Runnable() {
@Override
public void run() {
if (!running) {
return; // Gracefully exit the loop when stopped
}

// Send the action bar only at the specified tick interval
if (tickCounter++ % DISPLAY_REFRESH_INTERVAL_TICKS == 0) {
sendToAll(MinecraftServer.getConnectionManager().getOnlinePlayers());
}

// Schedule this task again for the next server tick
MinecraftServer.getSchedulerManager().scheduleNextTick(this);
}
};

// Start the loop automatically on creation
this.start();
}

/**
* Starts the tick-aligned display loop if it is not already running.
*/
public void start() {
if (this.running) {
return; // Prevent duplicate scheduler registrations
}
this.running = true;
this.tickCounter = 0;
MinecraftServer.getSchedulerManager().scheduleNextTick(this.tickRunnable);
}

/**
* Stops the tick-aligned loop.
* The loop will automatically terminate on the next scheduled tick.
*/
public void stop() {
this.running = false;
}

/**
* Updates the player count difference and immediately refreshes the action bar
* for all online players using pre-cached components.
*
* @param onlineCount the current number of online players
*/
public void update(int onlineCount) {
int playerDifference = Math.clamp(this.minPlayers - onlineCount, 0, this.minPlayers);
this.displayComponent = this.cachedComponents[playerDifference];

if (running) {
sendToAll(MinecraftServer.getConnectionManager().getOnlinePlayers());
}
}

/**
* Sends the current action bar message immediately to a specific player
* (e.g., when a player joins the server).
*
* @param player the player to send the message to
*/
public void send(Player player) {
if (running && displayComponent != Component.empty()) {
player.sendActionBar(this.displayComponent);
}
}

private void sendToAll(Collection<Player> players) {
if (players.isEmpty() || displayComponent == Component.empty()) {
return;
}
for (Player player : players) {
player.sendActionBar(this.displayComponent);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@NotNullByDefault
package net.onelitefeather.cygnus.phase.task;

import org.jetbrains.annotations.NotNullByDefault;
Loading
Loading