Bosses (Adrian, 2026-11-02: Eternatus too small and too easy; guardians way bigger and tougher; only defensive stats up; no attack takes more than 25 %): - Guardians scale to about 8 blocks tall (1.5x-12x), Eternatus to 15 blocks (2.5x, was 0.2x). Cave guardians look for a spot with room above them. Bosses already in a world grow on the next check outside battle. - Perfect IVs and 170 EVs each in HP, Defense and Sp. Def, nothing offensive. - The Guardian's Ward, a Showdown-only held item (held_items/guardiansward.js) handed to every boss by a HeldItemProvider: Defense and Sp. Def x2 while defending, and one attack (all hits together) takes at most 25 % of max HP. - Area attacks that start at the boss reach half its width further. Machines (Adrian, 2026-11-02): a working core is active: it glows, its front animates (flickering fire on the furnace core, pulsing emblems elsewhere), and particles show in the chamber and at the core (client side). The Pokemon inside flinches about every 15 s (red flash, Cobblemon's hit sound, no damage). Roadmap Phase 8: every item that can drop gets a JEI page naming its sources. Self-tests: practice_bosses_spawn checks size and training, boss_takes_a_quarter_per_attack runs a real battle (each Lv 100 Bullet Seed takes exactly a quarter), heat_core_cooks_with_hoppers checks the active state and the flinch. Self-tests and the island check pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
9.1 KiB
Kotlin
192 lines
9.1 KiB
Kotlin
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]. Shapes that start at the boss reach [body] further
|
|
* (the boss's half width): a big boss's ring starts at its edge, not inside it.
|
|
*/
|
|
fun area(boss: Vec3, target: Vec3, body: Double = 0.0): Area {
|
|
val origin = if (aim == Aim.BOSS) boss else target
|
|
val grow = if (aim == Aim.BOSS) body else 0.0
|
|
return when (shape) {
|
|
Shape.CIRCLE -> Area.Circle(origin, radius + grow)
|
|
Shape.RING -> Area.Ring(origin, inner + grow, outer + grow)
|
|
Shape.LINE -> Area.Line(boss, heading(boss, target), length + body, width / 2)
|
|
Shape.CONE -> Area.Cone(boss, heading(boss, target), length + body, 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>
|
|
/** 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)
|
|
|
|
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)
|
|
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
|
|
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 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
|
|
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.5
|
|
private const val FILL_STEP = 1.25
|
|
}
|
|
}
|