Skip to content

Commit 76a74d5

Browse files
committed
feat(hud): replace speed+points display with speed, ring progress, elapsed time and bracket pace
- Actionbar during race: '{speed} m/s · {passed}/{total} · {mm:ss.t} [BRACKET]' where bracket is colored (aqua=DIAMOND, gold=GOLD, gray=SILVER, bronze=BRONZE, red=FINISH) and projected from current pace (elapsedMs / passed * total) - After finish: '{speed} m/s · MEDAL · {finish time}' - Ring pass feedback: 'Ring X/N!' instead of '+1 pts' — ring points no longer meaningful to show per-ring since bracket score is awarded at finish - ScoreDisplaySystem now injects EntityManager to read ElapsedTimeComponent, BracketConfigComponent and ActiveMapComponent from the game entity - Updated translation keys: hud.actionbar (4 args), hud.actionbar.finished, hud.ring_passed (ring count instead of points)
1 parent 5898293 commit 76a74d5

6 files changed

Lines changed: 151 additions & 50 deletions

File tree

server/src/main/java/net/elytrarace/server/ecs/component/HudComponent.java

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import net.elytrarace.common.game.scoring.MedalTier;
55
import net.kyori.adventure.bossbar.BossBar;
66
import net.kyori.adventure.sound.Sound;
7+
import net.kyori.adventure.text.TextComponent;
78
import net.kyori.adventure.text.format.NamedTextColor;
9+
import net.kyori.adventure.text.format.TextColor;
810
import net.kyori.adventure.text.format.TextDecoration;
911
import net.kyori.adventure.title.Title;
1012
import net.minestom.server.entity.Player;
@@ -24,6 +26,8 @@
2426
*/
2527
public class HudComponent implements Component {
2628

29+
private static final TextColor BRONZE_COLOR = TextColor.color(0xCD7F32);
30+
2731
private final Player player;
2832
private BossBar cupProgressBar;
2933

@@ -32,38 +36,48 @@ public HudComponent(Player player) {
3236
}
3337

3438
/**
35-
* Sends the speed/score actionbar line.
39+
* Sends the live race actionbar: speed, ring progress, elapsed time, and the
40+
* bracket tier the player is currently on pace for (projected finish bracket).
3641
*
37-
* @param speedBlocksPerSec current speed in blocks per second
38-
* @param currentPoints accumulated ring points
42+
* @param speedBlocksPerSec current speed in blocks/s
43+
* @param passed rings passed so far
44+
* @param total total rings on this map
45+
* @param elapsedMs race time elapsed in milliseconds
46+
* @param pace projected bracket if the player finished right now
3947
*/
40-
public void updateActionbar(double speedBlocksPerSec, int currentPoints) {
48+
public void updateActionbar(double speedBlocksPerSec, int passed, int total,
49+
long elapsedMs, MedalTier pace) {
50+
net.kyori.adventure.text.Component timeAndPace = buildTimeAndPace(elapsedMs, pace);
4151
player.sendActionBar(net.kyori.adventure.text.Component.translatable(
4252
"hud.actionbar",
4353
net.kyori.adventure.text.Component.text(String.format("%.1f", speedBlocksPerSec)),
44-
net.kyori.adventure.text.Component.text(currentPoints)));
54+
net.kyori.adventure.text.Component.text(passed),
55+
net.kyori.adventure.text.Component.text(total),
56+
timeAndPace));
4557
}
4658

4759
/**
48-
* Sends the speed/score actionbar line with the player's earned medal tier
49-
* appended. Use this once the player has crossed the last ring on the map.
60+
* Sends the post-finish actionbar: speed, medal tier earned, and finish time.
61+
* Replaces the race actionbar once {@link ScoreComponent#hasFinished()} is true.
5062
*
51-
* @param speedBlocksPerSec current speed in blocks per second
52-
* @param currentPoints accumulated ring points
53-
* @param medalTier tier earned for the just-finished map
63+
* @param speedBlocksPerSec current speed in blocks/s
64+
* @param medal medal tier earned on this map
65+
* @param finishMs map completion time in milliseconds
5466
*/
55-
public void updateActionbarWithMedal(double speedBlocksPerSec, int currentPoints, MedalTier medalTier) {
67+
public void updateActionbarFinished(double speedBlocksPerSec, MedalTier medal, long finishMs) {
68+
net.kyori.adventure.text.Component medalComponent = net.kyori.adventure.text.Component
69+
.text(medal.name(), tierColor(medal), TextDecoration.BOLD);
5670
player.sendActionBar(net.kyori.adventure.text.Component.translatable(
57-
"hud.actionbar.medal",
71+
"hud.actionbar.finished",
5872
net.kyori.adventure.text.Component.text(String.format("%.1f", speedBlocksPerSec)),
59-
net.kyori.adventure.text.Component.text(currentPoints),
60-
net.kyori.adventure.text.Component.text(medalTier.name())));
73+
medalComponent,
74+
net.kyori.adventure.text.Component.text(formatTime(finishMs))));
6175
}
6276

6377
/**
6478
* Shows or replaces the cup-progress boss bar.
6579
*
66-
* @param cupName current cup name
80+
* @param cupName current cup name
6781
* @param currentMap 1-based map index
6882
* @param totalMaps total maps in the cup
6983
*/
@@ -126,14 +140,16 @@ public void showCountdown(int seconds) {
126140
}
127141

128142
/**
129-
* Shows ring-pass feedback: green "+N" actionbar and experience-orb sound.
143+
* Shows ring-pass feedback: "Ring X/N!" in the actionbar and a pickup sound.
130144
*
131-
* @param points points awarded for the ring
145+
* @param passed rings passed so far (including this one)
146+
* @param total total rings on the map
132147
*/
133-
public void showRingPassed(int points) {
148+
public void showRingPassed(int passed, int total) {
134149
player.sendActionBar(net.kyori.adventure.text.Component.translatable(
135150
"hud.ring_passed",
136-
net.kyori.adventure.text.Component.text(points)));
151+
net.kyori.adventure.text.Component.text(passed),
152+
net.kyori.adventure.text.Component.text(total)));
137153
player.playSound(Sound.sound(
138154
SoundEvent.ENTITY_EXPERIENCE_ORB_PICKUP,
139155
Sound.Source.MASTER,
@@ -148,4 +164,26 @@ public void cleanup() {
148164
cupProgressBar = null;
149165
}
150166
}
167+
168+
private static net.kyori.adventure.text.Component buildTimeAndPace(long elapsedMs, MedalTier pace) {
169+
TextColor color = tierColor(pace);
170+
return net.kyori.adventure.text.Component.text(
171+
formatTime(elapsedMs) + " [" + pace.name() + "]", color);
172+
}
173+
174+
static TextColor tierColor(MedalTier tier) {
175+
return switch (tier) {
176+
case DIAMOND -> NamedTextColor.AQUA;
177+
case GOLD -> NamedTextColor.GOLD;
178+
case SILVER -> NamedTextColor.GRAY;
179+
case BRONZE -> BRONZE_COLOR;
180+
case FINISH, DNF -> NamedTextColor.RED;
181+
};
182+
}
183+
184+
static String formatTime(long elapsedMs) {
185+
long seconds = elapsedMs / 1000;
186+
long tenths = (elapsedMs % 1000) / 100;
187+
return String.format("%d:%02d.%d", seconds / 60, seconds % 60, tenths);
188+
}
151189
}

server/src/main/java/net/elytrarace/server/ecs/system/RingCollisionSystem.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ public void process(Entity entity, float deltaTime) {
9595

9696
var hud = entity.getComponent(HudComponent.class);
9797
if (hud != null) {
98-
hud.showRingPassed(ring.points());
98+
hud.showRingPassed(tracker.passedCount(), rings.size());
9999
}
100100
}
101101
}

server/src/main/java/net/elytrarace/server/ecs/system/ScoreDisplaySystem.java

Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,47 +2,56 @@
22

33
import net.elytrarace.common.ecs.Component;
44
import net.elytrarace.common.ecs.Entity;
5+
import net.elytrarace.common.ecs.EntityManager;
56
import net.elytrarace.common.game.scoring.MedalTier;
7+
import net.elytrarace.server.ecs.component.ActiveMapComponent;
8+
import net.elytrarace.server.ecs.component.BracketConfigComponent;
9+
import net.elytrarace.server.ecs.component.ElapsedTimeComponent;
610
import net.elytrarace.server.ecs.component.ElytraFlightComponent;
711
import net.elytrarace.server.ecs.component.HudComponent;
812
import net.elytrarace.server.ecs.component.PlayerRefComponent;
13+
import net.elytrarace.server.ecs.component.RingTrackerComponent;
914
import net.elytrarace.server.ecs.component.ScoreComponent;
1015

16+
import java.time.Duration;
1117
import java.util.HashMap;
1218
import java.util.Map;
1319
import java.util.Set;
1420
import java.util.UUID;
1521

1622
/**
17-
* Updates each player's actionbar HUD with their current speed and score, and
18-
* appends the medal tier once they have completed the map.
23+
* Updates each player's actionbar HUD every 4 ticks (5 Hz).
1924
* <p>
20-
* Only updates while the player is actively flying to avoid spamming the
21-
* actionbar during lobby or end phases. Throttled to every 4 ticks (5 Hz) and
22-
* only re-sends when the displayed values change, to prevent visual flickering.
25+
* <b>During the race</b>: shows speed, ring progress (X/N), elapsed time, and a
26+
* live bracket-pace indicator (projected finish bracket based on current pace).
27+
* <br>
28+
* <b>After finishing</b>: shows speed, the medal tier earned, and finish time.
2329
* <p>
24-
* All rendering is delegated to {@link HudComponent} so that formatting logic
25-
* lives in one place.
30+
* Values are only re-sent when they change to prevent visual flickering. All
31+
* game-entity state (elapsed time, bracket config, total rings) is looked up via
32+
* the {@link EntityManager} on each display cycle.
2633
*/
2734
public class ScoreDisplaySystem implements net.elytrarace.common.ecs.System {
2835

2936
private static final int TICK_INTERVAL = 4;
3037

38+
private final EntityManager entityManager;
3139
private final Map<UUID, Integer> tickCounters = new HashMap<>();
3240
private final Map<UUID, Long> lastDisplayHash = new HashMap<>();
3341

42+
public ScoreDisplaySystem(EntityManager entityManager) {
43+
this.entityManager = entityManager;
44+
}
45+
3446
@Override
3547
public Set<Class<? extends Component>> getRequiredComponents() {
3648
return Set.of(PlayerRefComponent.class, ScoreComponent.class,
37-
ElytraFlightComponent.class, HudComponent.class);
49+
ElytraFlightComponent.class, HudComponent.class, RingTrackerComponent.class);
3850
}
3951

4052
@Override
4153
public void process(Entity entity, float deltaTime) {
4254
var flight = entity.getComponent(ElytraFlightComponent.class);
43-
var score = entity.getComponent(ScoreComponent.class);
44-
var hud = entity.getComponent(HudComponent.class);
45-
4655
if (!flight.isFlying()) {
4756
return;
4857
}
@@ -55,24 +64,77 @@ public void process(Entity entity, float deltaTime) {
5564
}
5665
tickCounters.put(entityId, 0);
5766

58-
double speedBps = flight.getSpeedBlocksPerSecond();
59-
int totalScore = score.getTotal();
60-
MedalTier medalTier = score.hasFinished() ? score.getMedalTier() : null;
67+
var score = entity.getComponent(ScoreComponent.class);
68+
var hud = entity.getComponent(HudComponent.class);
69+
var tracker = entity.getComponent(RingTrackerComponent.class);
6170

62-
// Only re-send if values changed (speed rounded to 1 decimal + score + medal ordinal)
63-
long speedKey = Math.round(speedBps * 10);
64-
long medalKey = medalTier == null ? 0xFFL : (medalTier.ordinal() & 0xFFL);
65-
long displayHash = (medalKey << 56) | (speedKey << 32) | (totalScore & 0xFFFFFFFFL);
66-
Long previous = lastDisplayHash.get(entityId);
67-
if (previous != null && previous == displayHash) {
68-
return;
69-
}
70-
lastDisplayHash.put(entityId, displayHash);
71+
double speedBps = flight.getSpeedBlocksPerSecond();
72+
int passed = tracker.passedCount();
73+
int total = findTotalRings();
74+
long elapsedMs = findElapsedMs();
75+
76+
if (score.hasFinished()) {
77+
MedalTier medal = score.getMedalTier() != null ? score.getMedalTier() : MedalTier.FINISH;
78+
long finishMs = score.getCompletionTimeMs();
7179

72-
if (medalTier != null) {
73-
hud.updateActionbarWithMedal(speedBps, totalScore, medalTier);
80+
long hash = ((long) medal.ordinal() << 48) | (finishMs & 0x0000FFFFFFFFFFFFL);
81+
if (!hash(entityId, hash)) return;
82+
83+
hud.updateActionbarFinished(speedBps, medal, finishMs);
7484
} else {
75-
hud.updateActionbar(speedBps, totalScore);
85+
MedalTier pace = computePace(elapsedMs, passed, total);
86+
long speedKey = Math.round(speedBps * 10);
87+
long hash = (speedKey << 40) | ((long) passed << 20) | (elapsedMs / 1000L);
88+
if (!hash(entityId, hash)) return;
89+
90+
hud.updateActionbar(speedBps, passed, total, elapsedMs, pace);
91+
}
92+
}
93+
94+
/**
95+
* Projects the finish time based on current pace and maps it to a bracket.
96+
* Returns DIAMOND when no data is available yet (early in the race).
97+
*/
98+
private MedalTier computePace(long elapsedMs, int passed, int total) {
99+
if (passed <= 0 || total <= 0) {
100+
return MedalTier.DIAMOND;
76101
}
102+
long projectedMs = elapsedMs * total / passed;
103+
104+
for (Entity e : entityManager.getEntities()) {
105+
if (e.hasComponent(BracketConfigComponent.class)) {
106+
var config = e.getComponent(BracketConfigComponent.class);
107+
return config.brackets().classify(
108+
Duration.ofMillis(projectedMs), config.reference());
109+
}
110+
}
111+
return MedalTier.GOLD;
112+
}
113+
114+
private long findElapsedMs() {
115+
for (Entity e : entityManager.getEntities()) {
116+
if (e.hasComponent(ElapsedTimeComponent.class)) {
117+
return e.getComponent(ElapsedTimeComponent.class).elapsedMs();
118+
}
119+
}
120+
return 0L;
121+
}
122+
123+
private int findTotalRings() {
124+
for (Entity e : entityManager.getEntities()) {
125+
if (e.hasComponent(ActiveMapComponent.class)) {
126+
var map = e.getComponent(ActiveMapComponent.class).getCurrentMap();
127+
if (map != null) return map.rings().size();
128+
}
129+
}
130+
return 0;
131+
}
132+
133+
/** Returns true when the hash changed (new display needed), false when unchanged. */
134+
private boolean hash(UUID id, long newHash) {
135+
Long prev = lastDisplayHash.get(id);
136+
if (prev != null && prev == newHash) return false;
137+
lastDisplayHash.put(id, newHash);
138+
return true;
77139
}
78140
}

server/src/main/java/net/elytrarace/server/game/GameOrchestrator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ public void startGame(CupDefinition cup) {
135135
entityManager.addSystem(new RingEffectSystem());
136136
entityManager.addSystem(new RingVisualizationSystem(entityManager));
137137
entityManager.addSystem(new SplineVisualizationSystem());
138-
entityManager.addSystem(new ScoreDisplaySystem());
138+
entityManager.addSystem(new ScoreDisplaySystem(entityManager));
139139

140140
// Create player entities for all currently online players
141141
for (Player player : playerService.getOnlinePlayers()) {

server/src/main/resources/elytrarace_en_US.properties

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ phase.lobby.force=<lang:plugin.prefix> <green>Force start Lobby Phase. Set time
44
phase.lobby.time=<lang:plugin.prefix> <green>Lobby starts in <arg:0>
55
phase.end.time=<lang:plugin.prefix> <green>Game ends in <arg:0>
66
phase.lobby.player.join=<lang:plugin.prefix> <green><arg:0> <white>joined the game (<arg:1>/<arg:2>)
7-
hud.actionbar=<white>Speed: <arg:0> m/s | Points: <arg:1>
7+
hud.actionbar=<white><arg:0> m/s · <arg:1>/<arg:2> · <arg:3>
8+
hud.actionbar.finished=<white><arg:0> m/s · <arg:1> · <arg:2>
89
hud.cup_progress=<arg:0> - Map <arg:1>/<arg:2>
9-
hud.ring_passed=<green><bold>+<arg:0>
10+
hud.ring_passed=<green><bold>Ring <arg:0>/<arg:1>!
1011
hud.countdown.go=<red><bold>GO!
1112
end.race_complete=<gold><bold>Race Complete!
1213
end.winner=<green>Winner: <arg:0>

server/src/test/java/net/elytrarace/server/perf/EcsGameLoopLoadTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ private static Harness buildHarness(Env env, int playerCount) {
184184
em.addSystem(new RingEffectSystem());
185185
em.addSystem(new RingVisualizationSystem(em));
186186
em.addSystem(new SplineVisualizationSystem());
187-
em.addSystem(new ScoreDisplaySystem());
187+
em.addSystem(new ScoreDisplaySystem(em));
188188

189189
// Pre-load all chunks that spawn positions will fall into before creating players
190190
for (int i = 0; i < playerCount; i++) {

0 commit comments

Comments
 (0)