Phase 7: one End boss at a time; more visible attack telegraphs

Adrian (2026-11-02): a second Eternatus appeared on his second visit
to the End; the attacks should be more visible.

- EndBoss waits until the entities around its spot are loaded before
  deciding it's missing, refuses a second copy while the recorded one
  is alive (join check, like the guardians), and removes extra copies
  near the spot (cleans up worlds that already have two).
- Telegraphs: 2.2-size dust redrawn every 2 ticks, the inside filled
  (Area.fill), rising sparks on the outline, a low bell on cast.
- Tests: no second End boss (after the End's middle is loaded), fill
  points inside their areas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-02 16:50:58 +01:00
1 parent c451c58483
commit 4e7b16abae
6 files changed
+89 -25

No files matched your search

@@ -103,6 +103,16 @@ sealed class Area(val origin: Vec3) {
abstract fun containsFlat(x: Double, z: Double): Boolean
/** Points along the outline, for the telegraph's particles. */
abstract fun outline(): List<Vec3>
/** How far from [origin] the area reaches. */
abstract val reach: Double
/** Points spread over the inside, so the whole area shows, not just its edge. */
fun fill(): List<Vec3> {
val steps = (reach / FILL_STEP).toInt()
return (-steps..steps).flatMap { i -> (-steps..steps).map { j -> origin.x + i * FILL_STEP to origin.z + j * FILL_STEP } }
.filter { (x, z) -> containsFlat(x, z) }
.map { (x, z) -> at(x, z) }
}
fun contains(pos: Vec3) = kotlin.math.abs(pos.y - origin.y) <= REACH_Y && containsFlat(pos.x, pos.z)
@@ -114,16 +124,19 @@ sealed class Area(val origin: Vec3) {
class Circle(origin: Vec3, val radius: Double) : Area(origin) {
override fun containsFlat(x: Double, z: Double) = hypot(x - origin.x, z - origin.z) <= radius
override fun outline() = circle(radius) + circle(radius / 2)
override fun outline() = circle(radius)
override val reach get() = radius
}
class Ring(origin: Vec3, val inner: Double, val outer: Double) : Area(origin) {
override fun containsFlat(x: Double, z: Double) = hypot(x - origin.x, z - origin.z).let { it in inner..outer }
override fun outline() = circle(inner) + circle(outer)
override val reach get() = outer
}
/** From [origin] along [heading] (radians, x towards z), [length] long, [halfWidth] to each side. */
class Line(origin: Vec3, val heading: Double, val length: Double, val halfWidth: Double) : Area(origin) {
override val reach get() = length
override fun containsFlat(x: Double, z: Double): Boolean {
val dx = x - origin.x
val dz = z - origin.z
@@ -142,6 +155,7 @@ sealed class Area(val origin: Vec3) {
/** From [origin] along [heading], [length] far, [halfAngle] radians to each side. */
class Cone(origin: Vec3, val heading: Double, val length: Double, val halfAngle: Double) : Area(origin) {
override val reach get() = length
override fun containsFlat(x: Double, z: Double): Boolean {
val distance = hypot(x - origin.x, z - origin.z)
if (distance > length) return false
@@ -167,6 +181,7 @@ sealed class Area(val origin: Vec3) {
companion object {
const val REACH_Y = 3.0
private const val STEP = 0.6
private const val STEP = 0.5
private const val FILL_STEP = 1.25
}
}
@@ -32,8 +32,9 @@ import kotlin.math.hypot
* and stays, nothing drops). Area attacks hit players only, never Pokémon.
*/
object BossFights {
/** Telegraph particles every so many ticks. */
private const val SHOW_EVERY = 4
/** Telegraph particles every so many ticks (Adrian, 2026-11-02: make the attacks more visible). */
private const val SHOW_EVERY = 2
private const val DUST_SIZE = 2.2f
/** Persistent-data key naming a boss file for any Pokémon (the self-tests use it). */
const val TAG = "cobblesurvival_boss"
@@ -136,6 +137,8 @@ object BossFights {
val level = fight.boss.level() as ServerLevel
val telegraph = Telegraph(pattern, pattern.area(fight.boss.position(), target), level.gameTime + pattern.telegraphTicks)
fight.telegraphs += telegraph
val origin = telegraph.area.origin
level.playSound(null, origin.x, origin.y, origin.z, SoundEvents.NOTE_BLOCK_BELL.value(), SoundSource.HOSTILE, 1.2f, 0.6f)
show(level, telegraph)
return telegraph
}
@@ -147,9 +150,15 @@ object BossFights {
BossDefs.Effect.NONE -> Vector3f(1f, 0.15f, 0.1f)
}
/** The outline in the attack's colour, the inside a little paler and sparser, rising sparks on the outline. */
private fun show(level: ServerLevel, telegraph: Telegraph) {
val dust = DustParticleOptions(color(telegraph.pattern.effect), 1.2f)
telegraph.area.outline().forEach { level.sendParticles(dust, it.x, it.y, it.z, 1, 0.0, 0.0, 0.0, 0.0) }
val color = color(telegraph.pattern.effect)
val edge = DustParticleOptions(color, DUST_SIZE)
val inside = DustParticleOptions(Vector3f(color).lerp(Vector3f(1f, 1f, 1f), 0.35f), DUST_SIZE * 0.7f)
telegraph.area.outline().forEach { level.sendParticles(edge, it.x, it.y, it.z, 1, 0.0, 0.0, 0.0, 0.0) }
telegraph.area.fill().forEach { level.sendParticles(inside, it.x, it.y, it.z, 1, 0.15, 0.0, 0.15, 0.0) }
telegraph.area.outline().filterIndexed { i, _ -> i % 4 == 0 }
.forEach { level.sendParticles(ParticleTypes.END_ROD, it.x, it.y, it.z, 0, 0.0, 0.08, 0.0, 1.0) }
}
private fun land(fight: Fight, level: ServerLevel, telegraph: Telegraph) {
@@ -14,7 +14,11 @@ import net.minecraft.server.level.ServerBossEvent
import net.minecraft.server.level.ServerLevel
import net.minecraft.world.entity.EntityType
import net.minecraft.world.entity.boss.enderdragon.EnderDragon
import net.minecraft.world.entity.Entity
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.level.Level
import net.minecraft.world.phys.AABB
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
import net.minecraft.world.level.dimension.end.EndDragonFight
import net.minecraft.world.level.levelgen.Heightmap
import net.neoforged.neoforge.common.NeoForge
@@ -46,9 +50,26 @@ object EndBoss {
NeoForge.EVENT_BUS.addListener<ServerTickEvent.Post> { event ->
if (event.server.tickCount % CHECK_TICKS == 0) event.server.getLevel(Level.END)?.let(::check)
}
NeoForge.EVENT_BUS.addListener(::onJoin)
CobblemonEvents.BATTLE_FAINTED.subscribe(Priority.NORMAL, ::onFainted)
}
fun isEndBoss(entity: Entity?) = entity is PokemonEntity && entity.pokemon.persistentData.contains(TAG)
/** A second End boss never joins while the recorded one is alive (Adrian, 2026-11-02: a second one appeared). */
private fun onJoin(event: EntityJoinLevelEvent) {
val entity = event.entity
val level = event.level as? ServerLevel ?: return
if (!isEndBoss(entity)) return
val entry = state(level)
val recorded = entry.entity
if (recorded == null || recorded == entity.uuid) {
entry.entity = entity.uuid
return
}
if ((level.getEntity(recorded) as? PokemonEntity)?.isAlive == true) event.isCanceled = true else entry.entity = entity.uuid
}
private fun state(level: ServerLevel) = GuardianState.of(level.server.overworld()).entry(STATE)
fun current(level: ServerLevel): PokemonEntity? =
@@ -57,9 +78,17 @@ object EndBoss {
private fun check(level: ServerLevel) {
val def = BossDefs.end?.end ?: return
val near = level.players().any { it.horizontalDistanceToSqr0() < NEAR * NEAR }
// Until the entities around the spot are loaded, the boss may be there unseen: don't put a second one.
val spotChunk = ChunkPos(spot(level))
val reach = (LEASH / 16).toInt() + 1
if ((-reach..reach).any { dx -> (-reach..reach).any { dz -> !level.areEntitiesLoaded(ChunkPos.asLong(spotChunk.x + dx, spotChunk.z + dz)) } }) return
val boss = current(level)
// Copies from before this check existed: keep the recorded one (or the first), drop the rest.
val spot = spot(level)
level.getEntitiesOfClass(PokemonEntity::class.java, AABB(spot).inflate(LEASH * 2)) { isEndBoss(it) }
.filter { it !== (boss ?: it) }
.forEach { it.discard() }
if (boss != null) {
val spot = spot(level)
if (boss.distanceToSqr(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) > LEASH * LEASH) boss.teleportTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5)
level.dragonFight?.let { fight ->
(bossBar.get(fight) as ServerBossEvent).apply {
@@ -71,7 +71,8 @@ object BossTests {
s.check(line.contains(Vec3(8.0, 64.0, 0.5)) && !line.contains(Vec3(8.0, 64.0, 2.0)) && !line.contains(Vec3(-2.0, 64.0, 0.0))) { "line" }
val cone = Area.Cone(o, Math.PI / 2, 10.0, Math.toRadians(30.0)) // along +z
s.check(cone.contains(Vec3(2.0, 64.0, 8.0)) && !cone.contains(Vec3(8.0, 64.0, 2.0)) && !cone.contains(Vec3(0.0, 64.0, 11.0))) { "cone" }
s.check(listOf(circle, ring, line, cone).all { it.outline().isNotEmpty() }) { "a shape without telegraph points" }
s.check(listOf(circle, ring, line, cone).all { it.outline().isNotEmpty() && it.fill().isNotEmpty() }) { "a shape without telegraph points" }
s.check(listOf(circle, ring, line, cone).all { area -> area.fill().all { area.contains(it) } }) { "telegraph points outside their area" }
s.helper.succeed()
}
@@ -160,26 +161,35 @@ object BossTests {
private fun endBoss(s: Scenario) {
val end = s.level.server.getLevel(Level.END) ?: throw GameTestAssertException("no End")
val chunks = (-2..2).flatMap { x -> (-2..2).map { z -> ChunkPos(x, z) } }
val chunks = (-3..3).flatMap { x -> (-3..3).map { z -> ChunkPos(x, z) } }
chunks.forEach { end.setChunkForced(it.x, it.z, true) }
val player = s.player()
s.afterTest {
EndBoss.current(end)?.discard()
chunks.forEach { end.setChunkForced(it.x, it.z, false) }
}
val dragon = EnderDragon(EntityType.ENDER_DRAGON, end)
dragon.moveTo(0.0, 80.0, 0.0)
s.check(!end.addFreshEntity(dragon) || dragon.isRemoved) { "an Ender Dragon could join the End" }
val boss = EndBoss.spawn(end) ?: throw GameTestAssertException("the End boss didn't appear")
s.check(boss.pokemon.species.name.equals(BossDefs.end!!.end!!.species, ignoreCase = true) && boss.pokemon.isUncatchable()) {
"the End boss is a catchable ${boss.pokemon.species.name}"
}
val xp = player.totalExperience
EndBoss.defeated(end, listOf(player))
s.check(player.totalExperience > xp) { "no experience for beating the End boss" }
val area = BlockPos.betweenClosed(BlockPos(-4, 0, -4), BlockPos(4, 255, 4))
s.check(area.any { end.getBlockState(it).`is`(Blocks.END_PORTAL) }) { "no exit portal after the End boss was beaten" }
s.check(area.any { end.getBlockState(it).`is`(Blocks.DRAGON_EGG) }) { "no dragon egg after the first win" }
s.helper.succeed()
s.helper.startSequence()
.thenWaitUntil { s.check(chunks.all { end.areEntitiesLoaded(it.toLong()) }) { "the End's middle isn't loaded yet" } }
.thenExecute {
val dragon = EnderDragon(EntityType.ENDER_DRAGON, end)
dragon.moveTo(0.0, 80.0, 0.0)
s.check(!end.addFreshEntity(dragon) || dragon.isRemoved) { "an Ender Dragon could join the End" }
val boss = EndBoss.spawn(end) ?: throw GameTestAssertException("the End boss didn't appear")
s.check(boss.pokemon.species.name.equals(BossDefs.end!!.end!!.species, ignoreCase = true) && boss.pokemon.isUncatchable()) {
"the End boss is a catchable ${boss.pokemon.species.name}"
}
// A second one may not join while the first is alive (Adrian saw one appear on each visit).
val second = EndBoss.spawn(end)
s.check(second == null || second.isRemoved) { "a second End boss joined beside the first" }
s.check(EndBoss.current(end) === boss) { "the End boss on record isn't the first one" }
val xp = player.totalExperience
EndBoss.defeated(end, listOf(player))
s.check(player.totalExperience > xp) { "no experience for beating the End boss" }
val area = BlockPos.betweenClosed(BlockPos(-4, 0, -4), BlockPos(4, 255, 4))
s.check(area.any { end.getBlockState(it).`is`(Blocks.END_PORTAL) }) { "no exit portal after the End boss was beaten" }
s.check(area.any { end.getBlockState(it).`is`(Blocks.DRAGON_EGG) }) { "no dragon egg after the first win" }
boss.discard()
}
.thenSucceed()
}
}