From 3660b7fc4ec4581ded6c290cd1584bbeb9f570eb Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 4 Aug 2026 10:52:33 +0200 Subject: [PATCH 1/4] feat(game): add heartbeat logic --- .../cygnus/player/CygnusPlayer.java | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/player/CygnusPlayer.java b/game/src/main/java/net/onelitefeather/cygnus/player/CygnusPlayer.java index f38ec1b..9291c26 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/player/CygnusPlayer.java +++ b/game/src/main/java/net/onelitefeather/cygnus/player/CygnusPlayer.java @@ -1,13 +1,15 @@ package net.onelitefeather.cygnus.player; import net.kyori.adventure.key.Key; -import net.minestom.server.entity.Player; +import net.kyori.adventure.sound.Sound; import net.minestom.server.entity.attribute.Attribute; import net.minestom.server.entity.attribute.AttributeModifier; import net.minestom.server.entity.attribute.AttributeOperation; import net.minestom.server.network.packet.server.play.EntityAttributesPacket; +import net.minestom.server.network.packet.server.play.WorldBorderWarningReachPacket; import net.minestom.server.network.player.GameProfile; import net.minestom.server.network.player.PlayerConnection; +import net.minestom.server.sound.SoundEvent; import net.onelitefeather.cygnus.common.player.InstanceSwitchChunkPlayer; @SuppressWarnings("java:S3252") @@ -19,11 +21,19 @@ public final class CygnusPlayer extends InstanceSwitchChunkPlayer { private static final AttributeModifier DISABLED_SPRINT_MODIFIER = new AttributeModifier(Key.key("minecraft:sprinting"), 0.0, AttributeOperation.ADD_MULTIPLIED_TOTAL); + private static final float HEALTH_THRESHOLD = 6.0f; // 3 hearts + private static final int MAX_INTERVAL_TICKS = 30; // Every 1.5s + private static final int MIN_INTERVAL_TICKS = 8; // Every 0.4s + private boolean blockedSprinting; + private int heartbeatTicks; + private boolean heartbeatActive; public CygnusPlayer(PlayerConnection playerConnection, GameProfile gameProfile) { super(playerConnection, gameProfile); this.blockedSprinting = false; + this.heartbeatTicks = 0; + this.heartbeatActive = false; } /** @@ -76,8 +86,67 @@ public boolean hasBlockedSprinting() { return blockedSprinting; } + /** + * Updates the heartbeat sound and red border vignette effect on player tick. + */ + public void tickHeartbeat() { + float health = getHealth(); + + if (health > HEALTH_THRESHOLD || health <= 0 || isDead()) { + if (heartbeatActive) { + resetHeartbeat(); + } + return; + } + + heartbeatActive = true; + + float intensity = Math.clamp(1.0f - (health / HEALTH_THRESHOLD), 0.0f, 1.0f); + + int warningBlocks = (int) (intensity * 45); + sendPacket(new WorldBorderWarningReachPacket(warningBlocks)); + + int targetInterval = (int) (MAX_INTERVAL_TICKS - (intensity * (MAX_INTERVAL_TICKS - MIN_INTERVAL_TICKS))); + heartbeatTicks++; + + if (heartbeatTicks >= targetInterval) { + playHeartbeatSound(intensity); + heartbeatTicks = 0; + } + } + + private void playHeartbeatSound(float intensity) { + float volume = 0.3f + (intensity * 0.7f); + float pitch = 0.8f + (intensity * 0.4f); + + Sound heartbeat = Sound.sound( + SoundEvent.ENTITY_WARDEN_HEARTBEAT, + Sound.Source.MASTER, + volume, + pitch + ); + + playSound(heartbeat, getPosition()); + } + + private void resetHeartbeat() { + heartbeatActive = false; + heartbeatTicks = 0; + sendPacket(new WorldBorderWarningReachPacket(0)); + } + + /** + * Returns whether the heartbeat effect is currently active for this player. + * + * @return {@code true} if heartbeat is active, otherwise {@code false}. + */ + public boolean isHeartbeatActive() { + return heartbeatActive; + } + @Override public EntityAttributesPacket getPropertiesPacket() { return super.getPropertiesPacket(); } } + From 515cc85037a4e05e527e4a82c5c754736552b11f Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 4 Aug 2026 10:53:17 +0200 Subject: [PATCH 2/4] chore(tick): improve tick listener --- .../listener/player/CygnusPlayerTickListener.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListener.java b/game/src/main/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListener.java index 768c940..51bbc24 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListener.java +++ b/game/src/main/java/net/onelitefeather/cygnus/listener/player/CygnusPlayerTickListener.java @@ -13,8 +13,12 @@ public void accept(PlayerTickEvent event) { Player player = event.getPlayer(); if (!(player instanceof CygnusPlayer cygnusPlayer)) return; - if (!cygnusPlayer.hasBlockedSprinting()) return; - cygnusPlayer.sendPacket(cygnusPlayer.getPropertiesPacket()); - cygnusPlayer.sendPacket(cygnusPlayer.getMetadataPacket()); + + if (cygnusPlayer.hasBlockedSprinting()) { + cygnusPlayer.sendPacket(cygnusPlayer.getPropertiesPacket()); + cygnusPlayer.sendPacket(cygnusPlayer.getMetadataPacket()); + } + + cygnusPlayer.tickHeartbeat(); } } From f0c64843fcfec16f2319e83db7a9b529e7f10995 Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 4 Aug 2026 10:53:40 +0200 Subject: [PATCH 3/4] test(player): add heartbeat case --- .../player/CygnusPlayerHeartbeatTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 game/src/test/java/net/onelitefeather/cygnus/player/CygnusPlayerHeartbeatTest.java diff --git a/game/src/test/java/net/onelitefeather/cygnus/player/CygnusPlayerHeartbeatTest.java b/game/src/test/java/net/onelitefeather/cygnus/player/CygnusPlayerHeartbeatTest.java new file mode 100644 index 0000000..d8fcaab --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/player/CygnusPlayerHeartbeatTest.java @@ -0,0 +1,54 @@ +package net.onelitefeather.cygnus.player; + +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CygnusPlayerHeartbeatTest extends CygnusPlayerTestBase { + + @Test + void testHeartbeatInactiveAboveThreshold(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + + player.setHealth(20.0f); + player.tickHeartbeat(); + + assertFalse(player.isHeartbeatActive()); + + env.destroyInstance(instance, true); + } + + @Test + void testHeartbeatActiveBelowThreshold(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + + player.setHealth(4.0f); + player.tickHeartbeat(); + + assertTrue(player.isHeartbeatActive()); + + env.destroyInstance(instance, true); + } + + @Test + void testHeartbeatResetWhenHealed(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + CygnusPlayer player = (CygnusPlayer) env.createPlayer(instance); + + player.setHealth(4.0f); + player.tickHeartbeat(); + assertTrue(player.isHeartbeatActive()); + + player.setHealth(20.0f); + player.tickHeartbeat(); + assertFalse(player.isHeartbeatActive()); + + env.destroyInstance(instance, true); + } +} From 81d4f6c6ae583273bb5556b25485b0019878b0ef Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Tue, 4 Aug 2026 11:40:03 +0200 Subject: [PATCH 4/4] chore(player): adjust values --- .../cygnus/player/CygnusPlayer.java | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/player/CygnusPlayer.java b/game/src/main/java/net/onelitefeather/cygnus/player/CygnusPlayer.java index 9291c26..f1d5eba 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/player/CygnusPlayer.java +++ b/game/src/main/java/net/onelitefeather/cygnus/player/CygnusPlayer.java @@ -12,6 +12,8 @@ import net.minestom.server.sound.SoundEvent; import net.onelitefeather.cygnus.common.player.InstanceSwitchChunkPlayer; +import java.util.concurrent.ThreadLocalRandom; + @SuppressWarnings("java:S3252") public final class CygnusPlayer extends InstanceSwitchChunkPlayer { @@ -22,8 +24,8 @@ public final class CygnusPlayer extends InstanceSwitchChunkPlayer { new AttributeModifier(Key.key("minecraft:sprinting"), 0.0, AttributeOperation.ADD_MULTIPLIED_TOTAL); private static final float HEALTH_THRESHOLD = 6.0f; // 3 hearts - private static final int MAX_INTERVAL_TICKS = 30; // Every 1.5s - private static final int MIN_INTERVAL_TICKS = 8; // Every 0.4s + private static final int MAX_INTERVAL_TICKS = 36; // Every 1.8s (slow, subtle pulse at start) + private static final int MIN_INTERVAL_TICKS = 12; // Every 0.6s (fast & tense without sound overlapping) private boolean blockedSprinting; private int heartbeatTicks; @@ -103,10 +105,24 @@ public void tickHeartbeat() { float intensity = Math.clamp(1.0f - (health / HEALTH_THRESHOLD), 0.0f, 1.0f); - int warningBlocks = (int) (intensity * 45); + double distanceToBorder = 29_999_984.0; + var instance = getInstance(); + if (instance != null && instance.getWorldBorder() != null) { + var border = instance.getWorldBorder(); + double radius = border.diameter() / 2.0; + double dx = Math.abs(getPosition().x() - border.centerX()); + double dz = Math.abs(getPosition().z() - border.centerZ()); + distanceToBorder = Math.max(1.0, radius - Math.max(dx, dz)); + } + + // Non-linear visual curve makes the red border vignette stronger earlier and very intense at low HP + float visualIntensity = (float) Math.pow(intensity, 0.6); + float clampedIntensity = Math.min(visualIntensity, 0.995f); + int warningBlocks = (int) (distanceToBorder / (1.0f - clampedIntensity)); sendPacket(new WorldBorderWarningReachPacket(warningBlocks)); - int targetInterval = (int) (MAX_INTERVAL_TICKS - (intensity * (MAX_INTERVAL_TICKS - MIN_INTERVAL_TICKS))); + float intervalFactor = (float) Math.pow(intensity, 0.85); + int targetInterval = (int) (MAX_INTERVAL_TICKS - (intervalFactor * (MAX_INTERVAL_TICKS - MIN_INTERVAL_TICKS))); heartbeatTicks++; if (heartbeatTicks >= targetInterval) { @@ -116,8 +132,9 @@ public void tickHeartbeat() { } private void playHeartbeatSound(float intensity) { - float volume = 0.3f + (intensity * 0.7f); - float pitch = 0.8f + (intensity * 0.4f); + float volume = 0.4f + (intensity * 0.6f); + float randomPitchOffset = (float) (ThreadLocalRandom.current().nextDouble(-0.03, 0.03)); + float pitch = 0.75f + (intensity * 0.30f) + randomPitchOffset; Sound heartbeat = Sound.sound( SoundEvent.ENTITY_WARDEN_HEARTBEAT,