Phase 7: boss fights with area attacks, guardians reviewed, End boss
Adrian (2026-11-02): every zone guardian gets area attacks (harder in harder zones); a boss for the Basalt Furnace; a Pokémon replaces the Ender Dragon; dying or leaving the arena loses; the Deep Dark gets a legendary and every guardian should fit its zone. Groudon, Giratina and Meloetta have no model in Cobblemon 1.8.4: Moltres, Eternatus and Regigigas stand in. - Guardians: Mudsdale, Flygon, Abomasnow, Regigigas (normal size), new Moltres for the Basalt Furnace; zone_spawns.py takes a guardian "scale". - tools/zone_bosses.py writes bosses/<zone>.json and end.json from a tier table (circle, line, cone, rings; telegraph, damage, triggers, fire/ice/poison effects, arena). - boss/: BossFights runs patterns during a guardian or End boss battle (particle telegraphs, hits players inside, stops on battle end), enforces the arena and death as a loss (battle ends, boss heals); EndBoss keeps the dragon out (allowlist emptied), spawns Eternatus on the main island with the dragon's boss bar, and on defeat opens the exit portal, gateway and egg via the vanilla dragon fight, plus XP. - Self-tests: coverage (incl. Cobblemon models), shapes, landing and stopping, arena loss, End boss. Docs, balance, Phase 7 checklist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc4568e023
commit
cd232bdf6a
32 files changed
+1420
-24
No files matched your search
@@ -0,0 +1,172 @@
|
||||
package dev.cobblesurvival.boss
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.packs.resources.ResourceManager
|
||||
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener
|
||||
import net.minecraft.util.profiling.ProfilerFiller
|
||||
import net.minecraft.world.phys.Vec3
|
||||
import net.neoforged.neoforge.common.NeoForge
|
||||
import net.neoforged.neoforge.event.AddReloadListenerEvent
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Boss fights (roadmap Phase 7), `data/cobblesurvival/bosses/<id>.json` (tools/zone_bosses.py): which fight (a zone's
|
||||
* guardian, or the End boss), the arena radius and the area attacks. See docs/design.md.
|
||||
*/
|
||||
object BossDefs : SimpleJsonResourceReloadListener(Gson(), "bosses") {
|
||||
enum class Shape { CIRCLE, RING, LINE, CONE }
|
||||
enum class Aim { PLAYER, BOSS }
|
||||
enum class Effect { NONE, FIRE, ICE, POISON }
|
||||
|
||||
class Trigger(val everyTicks: Int = 0, val offset: Int = 0, val everyTurns: Int = 0, val belowHealth: Double = 0.0)
|
||||
|
||||
class Pattern(
|
||||
val shape: Shape,
|
||||
val aim: Aim,
|
||||
val radius: Double, val inner: Double, val outer: Double, val length: Double, val width: Double, val angle: Double,
|
||||
val telegraphTicks: Int,
|
||||
val damage: Float,
|
||||
val effect: Effect,
|
||||
val trigger: Trigger,
|
||||
) {
|
||||
/** Where this pattern lands, aimed from [boss] at [target]. */
|
||||
fun area(boss: Vec3, target: Vec3): Area {
|
||||
val origin = if (aim == Aim.BOSS) boss else target
|
||||
return when (shape) {
|
||||
Shape.CIRCLE -> Area.Circle(origin, radius)
|
||||
Shape.RING -> Area.Ring(origin, inner, outer)
|
||||
Shape.LINE -> Area.Line(boss, heading(boss, target), length, width / 2)
|
||||
Shape.CONE -> Area.Cone(boss, heading(boss, target), length, Math.toRadians(angle / 2))
|
||||
}
|
||||
}
|
||||
|
||||
private fun heading(from: Vec3, to: Vec3) = atan2(to.z - from.z, to.x - from.x)
|
||||
}
|
||||
|
||||
class EndBoss(val species: String, val level: Int, val scale: Float, val respawnHours: Double, val xpFirstWin: Int, val xp: Int)
|
||||
|
||||
class Def(val id: ResourceLocation, val guardian: String?, val end: EndBoss?, val arena: Double, val patterns: List<Pattern>)
|
||||
|
||||
var byId: Map<ResourceLocation, Def> = emptyMap()
|
||||
private set
|
||||
/** For the self-tests: a boss with a 24-block arena and no attacks of its own (they cast theirs). */
|
||||
val SELFTEST = Def(ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "selftest"), null, null, 24.0, emptyList())
|
||||
var byGuardian: Map<String, Def> = emptyMap()
|
||||
private set
|
||||
var end: Def? = null
|
||||
private set
|
||||
|
||||
fun register() {
|
||||
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
|
||||
}
|
||||
|
||||
override fun apply(files: Map<ResourceLocation, JsonElement>, resources: ResourceManager, profiler: ProfilerFiller) {
|
||||
val defs = files.mapNotNull { (id, element) ->
|
||||
runCatching { parse(id, element.asJsonObject) }
|
||||
.onFailure { CobbleSurvival.LOGGER.error("Bad boss definition {}: {}", id, it.message) }
|
||||
.getOrNull()
|
||||
}
|
||||
byId = defs.associateBy { it.id }
|
||||
byGuardian = defs.filter { it.guardian != null }.associateBy { it.guardian!! }
|
||||
end = defs.firstOrNull { it.end != null }
|
||||
CobbleSurvival.LOGGER.info("Loaded {} boss fights ({} guardians, end: {})", defs.size, byGuardian.size, end?.end?.species)
|
||||
}
|
||||
|
||||
private fun parse(id: ResourceLocation, json: JsonObject): Def {
|
||||
val end = json.getAsJsonObject("end")?.let {
|
||||
EndBoss(it.get("species").asString, it.get("level").asInt, it.get("scale").asFloat, it.get("respawn_hours").asDouble,
|
||||
it.get("xp_first_win").asInt, it.get("xp").asInt)
|
||||
}
|
||||
val patterns = json.getAsJsonArray("patterns").map { it.asJsonObject }.map { p ->
|
||||
fun num(key: String) = p.get(key)?.asDouble ?: 0.0
|
||||
val t = p.getAsJsonObject("trigger")
|
||||
Pattern(
|
||||
Shape.valueOf(p.get("shape").asString.uppercase()), Aim.valueOf(p.get("aim").asString.uppercase()),
|
||||
num("radius"), num("inner"), num("outer"), num("length"), num("width"), num("angle"),
|
||||
p.get("telegraph_ticks").asInt, p.get("damage").asFloat, Effect.valueOf(p.get("effect")?.asString?.uppercase() ?: "NONE"),
|
||||
Trigger(t.get("every_ticks")?.asInt ?: 0, t.get("offset")?.asInt ?: 0, t.get("every_turns")?.asInt ?: 0, t.get("below_health")?.asDouble ?: 0.0),
|
||||
)
|
||||
}
|
||||
return Def(id, json.get("guardian")?.asString, end, json.get("arena").asDouble, patterns)
|
||||
}
|
||||
}
|
||||
|
||||
/** Where an area attack lands, flat on the ground at its origin's height (±[REACH_Y] blocks). */
|
||||
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>
|
||||
|
||||
fun contains(pos: Vec3) = kotlin.math.abs(pos.y - origin.y) <= REACH_Y && containsFlat(pos.x, pos.z)
|
||||
|
||||
protected fun at(x: Double, z: Double) = Vec3(x, origin.y + 0.1, z)
|
||||
protected fun circle(r: Double) = (0 until maxOf(12, (r * 2 * Math.PI / STEP).toInt())).map { i ->
|
||||
val a = i * 2 * Math.PI / maxOf(12, (r * 2 * Math.PI / STEP).toInt())
|
||||
at(origin.x + r * cos(a), origin.z + r * sin(a))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** 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 fun containsFlat(x: Double, z: Double): Boolean {
|
||||
val dx = x - origin.x
|
||||
val dz = z - origin.z
|
||||
val along = dx * cos(heading) + dz * sin(heading)
|
||||
val across = -dx * sin(heading) + dz * cos(heading)
|
||||
return along in 0.0..length && kotlin.math.abs(across) <= halfWidth
|
||||
}
|
||||
|
||||
override fun outline() = (0..(length / STEP).toInt()).flatMap { i ->
|
||||
val along = i * STEP
|
||||
listOf(-halfWidth, halfWidth).map { side ->
|
||||
at(origin.x + along * cos(heading) - side * sin(heading), origin.z + along * sin(heading) + side * cos(heading))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 fun containsFlat(x: Double, z: Double): Boolean {
|
||||
val distance = hypot(x - origin.x, z - origin.z)
|
||||
if (distance > length) return false
|
||||
if (distance < 0.5) return true
|
||||
var off = atan2(z - origin.z, x - origin.x) - heading
|
||||
while (off > Math.PI) off -= 2 * Math.PI
|
||||
while (off < -Math.PI) off += 2 * Math.PI
|
||||
return kotlin.math.abs(off) <= halfAngle
|
||||
}
|
||||
|
||||
override fun outline(): List<Vec3> {
|
||||
val edges = (0..(length / STEP).toInt()).flatMap { i ->
|
||||
listOf(heading - halfAngle, heading + halfAngle).map { a -> at(origin.x + i * STEP * cos(a), origin.z + i * STEP * sin(a)) }
|
||||
}
|
||||
val arcSteps = maxOf(4, (length * 2 * halfAngle / STEP).toInt())
|
||||
val arc = (0..arcSteps).map { i ->
|
||||
val a = heading - halfAngle + 2 * halfAngle * i / arcSteps
|
||||
at(origin.x + length * cos(a), origin.z + length * sin(a))
|
||||
}
|
||||
return edges + arc
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REACH_Y = 3.0
|
||||
private const val STEP = 0.6
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user