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/.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) var byId: Map = 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 = emptyMap() private set var end: Def? = null private set fun register() { NeoForge.EVENT_BUS.addListener { it.addListener(this) } } override fun apply(files: Map, 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 /** 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 { 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 { 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 } }