Guardian boss bars; Phase 7 approved

Adrian, 2026-11-03: guardians get a boss health bar like the Wither's, which
also leads players to them. Every player within 128 blocks of a living
guardian sees a red bar of their own (GuardianBars): name, zone, how many
blocks away, and HP. The End boss keeps the dragon's bar.
guardian_appears_and_returns checks the bar appears and goes once beaten.
Self-tests and the island check pass.

Phase 7 approved ("Then begin with the next phase"); README status updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-03 10:26:26 +01:00
1 parent 120c664cb0
commit fe6b8dfe57
7 files changed
+72 -1

No files matched your search

@@ -21,6 +21,7 @@ import dev.cobblesurvival.selftest.SelfTest
import dev.cobblesurvival.world.OnlyPokemon
import dev.cobblesurvival.world.SharedLoot
import dev.cobblesurvival.world.SpecialPokemon
import dev.cobblesurvival.zone.GuardianBars
import dev.cobblesurvival.zone.Guardians
import dev.cobblesurvival.zone.InZoneCondition
import dev.cobblesurvival.zone.LevelPressure
@@ -78,6 +79,7 @@ class CobbleSurvival {
ZoneCondition.register()
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
Guardians.register()
GuardianBars.register()
BossFights.register()
BossToughness.register()
EndBoss.register()
@@ -4,6 +4,7 @@ import com.cobblemon.mod.common.battles.BattleBuilder
import com.cobblemon.mod.common.battles.MoveActionResponse
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
import com.cobblemon.mod.common.util.isInBattle
import dev.cobblesurvival.zone.GuardianBars
import dev.cobblesurvival.zone.Guardians
import dev.cobblesurvival.zone.Zones
import net.minecraft.core.registries.BuiltInRegistries
@@ -49,6 +50,12 @@ object GuardianTests {
s.check(pokemon.isUncatchable()) { "the guardian can be caught" }
s.check(abs(pokemon.scaleModifier - definition.scale) < 0.01f) { "the guardian is size ${pokemon.scaleModifier}, not ${definition.scale}" }
s.check(expectedLevel == null || pokemon.level == expectedLevel) { "the guardian is level ${pokemon.level}, not $expectedLevel" }
// Adrian, 2026-11-03: a boss bar like the Wither's, with how far it is, leads the player to it.
GuardianBars.update(level)
val bar = GuardianBars.barFor(ZONE, player) ?: throw GameTestAssertException("no boss bar for the guardian 6 blocks away")
s.check(player in bar.players && bar.progress == 1f && bar.name.string.contains(pokemon.species.name)) {
"the guardian's bar is wrong: '${bar.name.string}' at ${bar.progress}"
}
s.moveTo(player, guardian.x + 3, guardian.y, guardian.z)
}
.thenWaitUntil { s.check(guardian.pokemon.entity === guardian) { "the guardian has not ticked yet" } }
@@ -65,6 +72,7 @@ object GuardianTests {
.thenIdle(100)
.thenExecute {
s.check(Guardians.guardianOf(level, ZONE) == null) { "the guardian came back right away" }
s.check(GuardianBars.barFor(ZONE, player) == null) { "the beaten guardian's bar is still shown" }
Guardians.expireDefeatForTest(level, ZONE)
}
.thenWaitUntil {
@@ -0,0 +1,54 @@
package dev.cobblesurvival.zone
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerBossEvent
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.BossEvent
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.entity.player.PlayerEvent
import net.neoforged.neoforge.event.tick.ServerTickEvent
import java.util.UUID
/**
* Every guardian shows a boss health bar, like the Wither's (Adrian, 2026-11-03), to each player within [RANGE]
* blocks: its name, its zone and how far away it is, so the bar leads players to it, and its HP, which falls in
* battle. Each player has a bar of their own, since the distance is theirs. The End boss has the dragon's bar
* ([dev.cobblesurvival.boss.EndBoss]).
*/
object GuardianBars {
const val RANGE = 128.0
private const val EVERY_TICKS = 10
private val bars = HashMap<Pair<String, UUID>, ServerBossEvent>()
fun register() {
NeoForge.EVENT_BUS.addListener<ServerTickEvent.Post> { if (it.server.tickCount % EVERY_TICKS == 0) update(it.server.overworld()) }
NeoForge.EVENT_BUS.addListener<PlayerEvent.PlayerLoggedOutEvent> { event ->
bars.keys.filter { it.second == event.entity.uuid }.forEach { bars.remove(it)?.removeAllPlayers() }
}
}
/** The bar [player] sees for the guardian of [zone], if any. */
fun barFor(zone: String, player: ServerPlayer): ServerBossEvent? = bars[zone to player.uuid]
fun update(level: ServerLevel) {
val seen = HashSet<Pair<String, UUID>>()
for (zone in Guardians.definitions.keys) {
val guardian = Guardians.guardianOf(level, zone) ?: continue
val pokemon = guardian.pokemon
for (player in level.players()) {
val distance = player.distanceTo(guardian)
if (distance > RANGE || player.isSpectator) continue
val key = zone to player.uuid
seen += key
val bar = bars.getOrPut(key) {
ServerBossEvent(Component.empty(), BossEvent.BossBarColor.RED, BossEvent.BossBarOverlay.PROGRESS).also { it.addPlayer(player) }
}
bar.name = Component.translatableWithFallback("bossbar.cobblesurvival.guardian", "%s, guardian of %s (%s blocks away)",
pokemon.getDisplayName(), Zones.byId[zone]?.name ?: zone, distance.toInt())
bar.progress = (pokemon.currentHealth.toFloat() / pokemon.maxHealth.coerceAtLeast(1)).coerceIn(0f, 1f)
}
}
bars.keys.filter { it !in seen }.forEach { bars.remove(it)?.removeAllPlayers() }
}
}