Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.onelitefeather.cygnus.stamina;

import net.minestom.server.event.EventDispatcher;
import net.minestom.server.timer.ExecutionType;
import net.onelitefeather.cygnus.movement.PlayerStopSprintingEvent;
import net.onelitefeather.cygnus.player.CygnusPlayer;

Expand Down Expand Up @@ -30,7 +31,7 @@ public non-sealed class FoodBar extends StaminaBar {
* @param player who owns the bar
*/
FoodBar(CygnusPlayer player) {
super(player, ChronoUnit.MILLIS, 1000);
super(player, ChronoUnit.MILLIS, 1000, ExecutionType.TICK_START);
state = State.READY;
this.currentSpeedCount = MAX_FOOD;
}
Expand Down
166 changes: 109 additions & 57 deletions game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBar.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,93 @@
import net.minestom.server.event.EventDispatcher;
import net.minestom.server.instance.Instance;
import net.minestom.server.sound.SoundEvent;
import net.minestom.server.timer.ExecutionType;
import net.onelitefeather.cygnus.common.Tags;
import net.onelitefeather.cygnus.event.StaminaStateChangeEvent;
import net.onelitefeather.cygnus.player.CygnusPlayer;

import java.time.temporal.ChronoUnit;

/**
* Manages the stamina/stealth ability of the slender player.
* <p>
* The bar cycles through three {@link State}s:
* <ul>
* <li>{@link State#READY} - idle, hidden, ability can be activated</li>
* <li>{@link State#DRAINING} - the ability is active: the slender is visible and vulnerable
* (blinded, slowed, can't sprint) and damages nearby survivors, while {@link #currentTime} counts down</li>
* <li>{@link State#REGENERATING} - the slender is hidden again and recovers (night vision, normal
* speed) while {@link #currentTime} counts back up, reached either by manually cancelling DRAINING or
* automatically once it runs out</li>
* </ul>
* Regeneration must reach {@value #MIN_TIME_TO_REACTIVATE} before {@link #changeStatus()} allows draining again.
*
* @author theEvilReaper
* @version 1.0.0
* @since 1.0.0
**/
@SuppressWarnings("java:S3252")
public non-sealed class SlenderBar extends StaminaBar implements SlenderBarHelper {
public final class SlenderBar extends StaminaBar implements SlenderBarHelper {

private static final Sound LEVEL = Sound.sound(SoundEvent.ENTITY_PLAYER_LEVELUP, Sound.Source.MASTER, 1F, 1F);

// Constants
/**
* Upper bound of {@link #currentTime}, in stamina units and also the number of segments rendered in the
* action-bar progress display.
*/
private static final int MAX_TIME = 16;

/**
* Amount {@link #currentTime} changes per tick (the bar ticks every 500ms, see the constructor).
*/
private static final float TIME_STEP = 0.5f;

/**
* Minimum {@link #currentTime} regeneration must reach before {@link #changeStatus()} allows draining
* again, preventing the ability from being immediately re-triggered after it ran dry.
*/
private static final int MIN_TIME_TO_REACTIVATE = 10;

/**
* Movement speed while {@link State#DRAINING} - deliberately slow since the slender is visible then.
*/
private static final double DRAINING_MOVEMENT_SPEED = 0.0669;

private static final float HIDDEN_MOVEMENT_SPEED = 0.1f;
private static final int DAMAGE_RANGE = 3;

private final String tileChar;
private final int time;
private double currentTime;
private StaminaColors colorState;

/**
* Runs its periodic {@link #consume()} at {@link ExecutionType#TICK_END} rather than the default
* {@code TICK_START}: incoming player packets (e.g. a manual state change via
* {@link net.minestom.server.event.player.PlayerUseItemEvent}) are handled between those two phases,
* so this guarantees {@link #consume()} always sees a state already updated by a same-tick manual
* transition instead of racing it with stale data.
*/
SlenderBar(CygnusPlayer player) {
super(player, ChronoUnit.MILLIS, 500);
super(player, ChronoUnit.MILLIS, 500, ExecutionType.TICK_END);
this.tileChar = "▋";
this.time = MAX_TIME;
this.currentTime = time;
this.colorState = StaminaColors.DRAINING;
}

/**
* {@inheritDoc}
*/
@Override
protected void onStart() {
this.state = State.READY;
this.player.addEffect(NIGHT_VISION.potion());
}

/**
* {@inheritDoc}
*/
@Override
public void consume() {
if (state == State.READY) return;
Expand All @@ -57,71 +106,74 @@ private void handleDraining() {
if (currentTime >= 0) {
currentTime -= TIME_STEP;
Instance instance = player.getInstance();
applyDamage(instance, player.getUuid(), player.getPosition(), 3, TIME_STEP);
this.colorState.sendProgressBar(player, tileChar, (int) currentTime);
applyDamage(instance, player.getUuid(), player.getPosition(), DAMAGE_RANGE, TIME_STEP);
this.colorState.sendProgressBar(player, tileChar, currentTime, time);
return;
}
state = State.REGENERATING;
colorState = StaminaColors.REGENERATING;
player.setTag(Tags.HIDDEN, HIDDEN);
EventDispatcher.call(new StaminaStateChangeEvent(player, state));
this.applyNightVision(player);
player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.1f);
player.sendSpringPackets();
player.setBlockedSprinting(false);
this.colorState.sendProgressBar(player, tileChar, (int) currentTime);
enterRegenerating();
}

private void handleRegeneration() {
this.colorState.sendProgressBar(player, tileChar, (int) currentTime);
if (currentTime <= time + TIME_STEP) {
currentTime += TIME_STEP;
} else {
state = State.READY;
colorState = StaminaColors.DRAINING;
player.playSound(LEVEL, player.getPosition());

if (currentTime < time) {
currentTime = Math.min(time, currentTime + TIME_STEP);
this.colorState.sendProgressBar(player, tileChar, currentTime, time);
return;
}
enterReady();
}

/**
* Toggles the ability for the current {@link State}: activates draining from {@link State#READY} or
* {@link State#REGENERATING}, or cancels an active drain back into {@link State#REGENERATING}.
*
* @return {@code false} if regeneration hasn't reached {@link #MIN_TIME_TO_REACTIVATE} yet and the
* status could not be changed, {@code true} otherwise
*/
public boolean changeStatus() {
if (state == State.REGENERATING && this.time <= 10) return false;
if (state == State.REGENERATING && this.currentTime < MIN_TIME_TO_REACTIVATE) return false;
switch (state) {
case READY -> {
state = State.DRAINING;
colorState = StaminaColors.DRAINING;
player.setTag(Tags.HIDDEN, HIDDEN);
this.applyBlindness(player);
player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.0669);
player.sendSpringPackets();
player.setSprinting(false);
player.setBlockedSprinting(true);
EventDispatcher.call(new StaminaStateChangeEvent(player, state));
}
case REGENERATING -> {
state = State.DRAINING;
colorState = StaminaColors.DRAINING;
player.setTag(Tags.HIDDEN, HIDDEN);
this.playSpawnSound(player.getInstance(), player.getPosition(), player.getUuid());
this.applyBlindness(player);
player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.0669);
player.sendSpringPackets();
player.setSprinting(false);
player.setBlockedSprinting(true);
EventDispatcher.call(new StaminaStateChangeEvent(player, state));
}
case DRAINING -> {
state = State.REGENERATING;
colorState = StaminaColors.REGENERATING;
player.setTag(Tags.HIDDEN, VISIBLE);
this.playTeleportSound(player.getInstance(), player.getPosition(), player.getUuid());
this.applyNightVision(player);
player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(0.1f);
player.sendSpringPackets();
player.setBlockedSprinting(false);
EventDispatcher.call(new StaminaStateChangeEvent(player, state));
}
case READY -> enterDraining(false);
case REGENERATING -> enterDraining(true);
case DRAINING -> enterRegenerating();
}
return true;
}

/**
* @param fromRegenerating whether this transition interrupts an ongoing regeneration, which plays a spawn sound
*/
private void enterDraining(boolean fromRegenerating) {
state = State.DRAINING;
colorState = StaminaColors.DRAINING;
player.setTag(Tags.HIDDEN, VISIBLE);
if (fromRegenerating) {
this.playSpawnSound(player.getInstance(), player.getPosition(), player.getUuid());
}
this.applyBlindness(player);
player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(DRAINING_MOVEMENT_SPEED);
player.sendSpringPackets();
player.setSprinting(false);
player.setBlockedSprinting(true);
EventDispatcher.call(new StaminaStateChangeEvent(player, state));
this.colorState.sendProgressBar(player, tileChar, currentTime, time);
}

private void enterRegenerating() {
state = State.REGENERATING;
colorState = StaminaColors.REGENERATING;
player.setTag(Tags.HIDDEN, HIDDEN);
this.playTeleportSound(player.getInstance(), player.getPosition(), player.getUuid());
this.applyNightVision(player);
player.getAttribute(Attribute.MOVEMENT_SPEED).setBaseValue(HIDDEN_MOVEMENT_SPEED);
player.sendSpringPackets();
player.setBlockedSprinting(false);
EventDispatcher.call(new StaminaStateChangeEvent(player, state));
this.colorState.sendProgressBar(player, tileChar, currentTime, time);
}

private void enterReady() {
state = State.READY;
colorState = StaminaColors.DRAINING;
player.playSound(LEVEL, player.getPosition());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import net.kyori.adventure.sound.Sound;
import net.minestom.server.entity.Player;
import net.minestom.server.sound.SoundEvent;
import net.onelitefeather.cygnus.common.Tags;
import org.jetbrains.annotations.Nullable;

import java.util.function.Supplier;
Expand Down Expand Up @@ -56,19 +55,9 @@ public void trigger(Player player) {
}
lastSoundTimeStamp = System.currentTimeMillis() + COOLDOWN_TIME;
if (slenderBar.changeStatus()) {
this.changeVisibilityStatus(player);
this.updateRuneFunction.accept(player);
return;
}
}

/**
* Changes the visibility status of the player.
*
* @param player the player to change the visibility status
*/
private void changeVisibilityStatus(Player player) {
Byte value = player.getTag(Tags.HIDDEN);
byte currentValue = value != null ? value : SlenderBarHelper.VISIBLE;
player.setTag(Tags.HIDDEN, currentValue == SlenderBarHelper.VISIBLE ? SlenderBarHelper.HIDDEN : SlenderBarHelper.VISIBLE);
player.playSound(ABORT_SOUND);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.onelitefeather.cygnus.stamina;

import net.minestom.server.MinecraftServer;
import net.minestom.server.timer.ExecutionType;
import net.minestom.server.timer.Task;
import net.onelitefeather.cygnus.player.CygnusPlayer;
import org.jetbrains.annotations.Nullable;
Expand All @@ -21,21 +22,24 @@ public abstract sealed class StaminaBar implements Runnable permits SlenderBar,

protected final CygnusPlayer player;
private final ChronoUnit chronoUnit;
private final ExecutionType executionType;
protected int period;
protected State state;
private @Nullable Task task;

/**
* Creates a new reference from an {@link StaminaBar}.
*
* @param player the player who owns the bar
* @param chronoUnit the tick interval for the bar
* @param period the tick period for the par
* @param player the player who owns the bar
* @param chronoUnit the tick interval for the bar
* @param period the tick period for the par
* @param executionType when in the server tick the periodic {@link #consume()} task runs
*/
protected StaminaBar(CygnusPlayer player, ChronoUnit chronoUnit, int period) {
protected StaminaBar(CygnusPlayer player, ChronoUnit chronoUnit, int period, ExecutionType executionType) {
this.player = player;
this.chronoUnit = chronoUnit;
this.period = period;
this.executionType = executionType;
}

protected abstract void onStart();
Expand All @@ -48,6 +52,7 @@ public void start() {
this.onStart();
task = MinecraftServer.getSchedulerManager()
.buildTask(this::consume)
.executionType(executionType)
.repeat(this.period, this.chronoUnit).schedule();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package net.onelitefeather.cygnus.stamina;

import net.theevilreaper.aves.util.Components;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.entity.Player;
Expand All @@ -18,6 +17,8 @@ enum StaminaColors {
DRAINING(NamedTextColor.GOLD, NamedTextColor.GRAY),
REGENERATING(NamedTextColor.GREEN, NamedTextColor.GRAY);

private static final String HALF_TILE_CHAR = "▍";

private final NamedTextColor completeColor;
private final NamedTextColor emptyColor;

Expand All @@ -33,14 +34,25 @@ enum StaminaColors {
}

/**
* Sends a progress bar to the player
* Sends a progress bar to the player. A dangling half stamina unit is rendered as its own half tile
* instead of being rounded away, so the bar keeps the same width ({@code maxTime} tiles) while still
* reflecting every half-step change.
*
* @param player the player to send the progress bar
* @param tileChar the character to use for the progress bar
* @param tileChar the character to use for a full tile
* @param currentTime the current time to display
* @param maxTime the maximum time the bar represents
*/
public void sendProgressBar(Player player, String tileChar, int currentTime) {
Component progressBar = Components.getProgressBar(currentTime, 17, 17, tileChar, this.completeColor, this.emptyColor);
public void sendProgressBar(Player player, String tileChar, double currentTime, int maxTime) {
int fullTiles = (int) currentTime;
boolean hasHalfTile = currentTime - fullTiles >= 0.5;
int emptyTiles = maxTime - fullTiles - (hasHalfTile ? 1 : 0);

Component progressBar = Component.text(tileChar.repeat(fullTiles), this.completeColor);
if (hasHalfTile) {
progressBar = progressBar.append(Component.text(HALF_TILE_CHAR, this.completeColor));
}
progressBar = progressBar.append(Component.text(tileChar.repeat(emptyTiles), this.emptyColor));
player.sendActionBar(progressBar);
}

Expand Down
Loading
Loading