new: world update ahchoring

This commit is contained in:
minjaesong
2026-01-19 17:08:31 +09:00
parent 104481a7d5
commit 63566a507b
7 changed files with 299 additions and 49 deletions

View File

@@ -121,6 +121,46 @@ emph {
val uptime = App.getTIME_T() - App.startupTime val uptime = App.getTIME_T() - App.startupTime
// print out the error
printStream.println("<h3>The Error Info</h3>")
System.err.println("== The Error Info ==")
printStream.println("<pre>")
e.printStackTrace(printStream)
printStream.println("</pre>")
e.printStackTrace(System.err)
printStream.println("<h3>Module Info</h3>")
printStream.println("<h4>Load Order</h4>")
printStream.println("<ol>${ModMgr.loadOrder.joinToString(separator = "") { "<li>" +
"$it&ensp;<small>(" +
"${moduleMetaToText(ModMgr.moduleInfo[it] ?: ModMgr.moduleInfoErrored[it])}" +
")</small></li>" }
}</ol>")
// print out loaded modules
ModMgr.errorLogs.let {
if (it.size > 0) {
printStream.println("<h4>Module Errors</h4>")
System.err.println("== Module Errors ==")
it.forEach {
printStream.println("<p>From Module <strong>${it.moduleName}</strong> (${it.type.toHTML()}):</p>")
printStream.println("<pre>")
it.cause?.printStackTrace(printStream)
printStream.println("</pre>")
it.cause?.printStackTrace(System.err)
}
}
}
// print out device info // print out device info
printStream.println("<h3>System Info</h3>") printStream.println("<h3>System Info</h3>")
printStream.println("<ul>") printStream.println("<ul>")
@@ -146,39 +186,6 @@ emph {
printStream.println("<p><emph>GL not initialised</emph></p>") printStream.println("<p><emph>GL not initialised</emph></p>")
} }
printStream.println("<h3>Module Info</h3>")
printStream.println("<h4>Load Order</h4>")
printStream.println("<ol>${ModMgr.loadOrder.joinToString(separator = "") { "<li>" +
"$it&ensp;<small>(" +
"${moduleMetaToText(ModMgr.moduleInfo[it] ?: ModMgr.moduleInfoErrored[it])}" +
")</small></li>" }
}</ol>")
ModMgr.errorLogs.let {
if (it.size > 0) {
printStream.println("<h4>Module Errors</h4>")
System.err.println("== Module Errors ==")
it.forEach {
printStream.println("<p>From Module <strong>${it.moduleName}</strong> (${it.type.toHTML()}):</p>")
printStream.println("<pre>")
it.cause?.printStackTrace(printStream)
printStream.println("</pre>")
it.cause?.printStackTrace(System.err)
}
}
}
printStream.println("<h3>The Error Info</h3>")
System.err.println("== The Error Info ==")
printStream.println("<pre>")
e.printStackTrace(printStream)
printStream.println("</pre>")
e.printStackTrace(System.err)
textArea.text = "<html><style type=\"text/css\">$css</style><body>$htmlSB</body></html>" textArea.text = "<html><style type=\"text/css\">$css</style><body>$htmlSB</body></html>"
} }

View File

@@ -8,6 +8,7 @@ import net.torvald.terrarum.gameactors.ActorID
import net.torvald.terrarum.gameactors.ActorWithBody import net.torvald.terrarum.gameactors.ActorWithBody
import net.torvald.terrarum.gameactors.ActorWithBody.Companion.PHYS_EPSILON_DIST import net.torvald.terrarum.gameactors.ActorWithBody.Companion.PHYS_EPSILON_DIST
import net.torvald.terrarum.gameactors.BlockMarkerActor import net.torvald.terrarum.gameactors.BlockMarkerActor
import net.torvald.terrarum.gameactors.WorldUpdater
import net.torvald.terrarum.gamecontroller.KeyToggler import net.torvald.terrarum.gamecontroller.KeyToggler
import net.torvald.terrarum.gamecontroller.TerrarumKeyboardEvent import net.torvald.terrarum.gamecontroller.TerrarumKeyboardEvent
import net.torvald.terrarum.gameitems.ItemID import net.torvald.terrarum.gameitems.ItemID
@@ -147,6 +148,13 @@ open class IngameInstance(val batch: FlippingSpriteBatch, val isMultiplayer: Boo
val actorAdditionQueue = ArrayList<Triple<Actor, Throwable, (Actor) -> Unit>>() // actor, stacktrace object, onSpawn val actorAdditionQueue = ArrayList<Triple<Actor, Throwable, (Actor) -> Unit>>() // actor, stacktrace object, onSpawn
val actorRemovalQueue = ArrayList<Triple<Actor, Throwable, (Actor) -> Unit>>() // actor, stacktrace object, onDespawn val actorRemovalQueue = ArrayList<Triple<Actor, Throwable, (Actor) -> Unit>>() // actor, stacktrace object, onDespawn
/**
* Registry of actors that implement [WorldUpdater].
* World simulation (fluids, wires, tile updates) is active around these actors.
* Automatically maintained when actors are added/removed.
*/
val worldUpdaters: MutableSet<ActorWithBody> = HashSet()
/** /**
* ## BIG NOTE: Calculated actor distance is the **Euclidean distance SQUARED** * ## BIG NOTE: Calculated actor distance is the **Euclidean distance SQUARED**
* *
@@ -378,6 +386,10 @@ open class IngameInstance(val batch: FlippingSpriteBatch, val isMultiplayer: Boo
actorContainer.removeAt(indexToDelete) actorContainer.removeAt(indexToDelete)
} }
} }
// Unregister from worldUpdaters if applicable
if (actor is WorldUpdater && actor is ActorWithBody) {
worldUpdaters.remove(actor)
}
} }
protected open fun forceAddActor(actor: Actor?, caller: Throwable = StackTraceRecorder()) { protected open fun forceAddActor(actor: Actor?, caller: Throwable = StackTraceRecorder()) {
@@ -388,6 +400,10 @@ open class IngameInstance(val batch: FlippingSpriteBatch, val isMultiplayer: Boo
} }
else { else {
actorContainerActive.add(actor) actorContainerActive.add(actor)
// Register to worldUpdaters if applicable
if (actor is WorldUpdater && actor is ActorWithBody) {
worldUpdaters.add(actor)
}
} }
} }

View File

@@ -0,0 +1,47 @@
package net.torvald.terrarum.gameactors
import com.badlogic.gdx.graphics.g2d.SpriteBatch
/**
* Minimal anchor actor for world updates.
*
* This actor has no visible representation and does not participate in physics.
* It serves purely as a point around which world simulation (fluids, wires, tile updates)
* remains active.
*
* Created by minjaesong on 2026-01-19.
*/
class WorldUpdateAnchor : ActorWithBody, WorldUpdater, NoSerialise {
constructor() : super(RenderOrder.MIDDLE, PhysProperties.IMMOBILE()) {
isVisible = false
chunkAnchoring = true
setHitboxDimension(1, 1, 0, 0)
}
constructor(id: ActorID) : super(RenderOrder.MIDDLE, PhysProperties.IMMOBILE(), id) {
isVisible = false
chunkAnchoring = true
setHitboxDimension(1, 1, 0, 0)
}
override fun updateImpl(delta: Float) {
// No-op; this actor exists solely as a world update anchor
}
override fun drawBody(frameDelta: Float, batch: SpriteBatch) {
// No-op; this actor is invisible
}
override fun drawGlow(frameDelta: Float, batch: SpriteBatch) {
// No-op; this actor is invisible
}
override fun drawEmissive(frameDelta: Float, batch: SpriteBatch) {
// No-op; this actor is invisible
}
override fun dispose() {
// Nothing to dispose
}
}

View File

@@ -0,0 +1,13 @@
package net.torvald.terrarum.gameactors
/**
* Marker interface for actors that serve as "world update anchors".
*
* Actors implementing this interface cause world simulation (fluids, wires, tile updates)
* to be active in their vicinity, regardless of camera position.
*
* All implementations must also extend [ActorWithBody].
*
* Created by minjaesong on 2026-01-19.
*/
interface WorldUpdater

View File

@@ -150,8 +150,23 @@ open class TerrarumIngame(batch: FlippingSpriteBatch) : IngameInstance(batch) {
(a.hitbox.centeredY - WorldCamera.yCentre).sqr() (a.hitbox.centeredY - WorldCamera.yCentre).sqr()
) )
/** whether the actor is within update range */ /**
fun ActorWithBody.inUpdateRange(world: GameWorld) = distToCameraSqr(world, this) <= ACTOR_UPDATE_RANGE.sqr() * Returns the minimum squared distance from the actor to any living WorldUpdater.
* Falls back to camera distance if no WorldUpdaters exist.
*/
fun distToNearestUpdaterSqr(world: GameWorld, actor: ActorWithBody): Double {
val worldUpdaters = Terrarum.ingame?.worldUpdaters
if (worldUpdaters.isNullOrEmpty()) {
return distToCameraSqr(world, actor)
}
return worldUpdaters.minOf { updater ->
if (updater.flagDespawn || updater.despawned) Double.MAX_VALUE
else distToActorSqr(world, actor, updater)
}
}
/** whether the actor is within update range of any WorldUpdater */
fun ActorWithBody.inUpdateRange(world: GameWorld) = distToNearestUpdaterSqr(world, this) <= ACTOR_UPDATE_RANGE.sqr()
/** whether the actor is within screen */ /** whether the actor is within screen */
fun ActorWithBody.inScreen(world: GameWorld) = fun ActorWithBody.inScreen(world: GameWorld) =

View File

@@ -23,6 +23,7 @@ import net.torvald.terrarum.realestate.LandUtil.CHUNK_H
import net.torvald.terrarum.realestate.LandUtil.CHUNK_W import net.torvald.terrarum.realestate.LandUtil.CHUNK_W
import org.dyn4j.geometry.Vector2 import org.dyn4j.geometry.Vector2
import kotlin.math.cosh import kotlin.math.cosh
import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -50,6 +51,9 @@ object WorldSimulator {
private val fluidNewMap = Array(DOUBLE_RADIUS) { FloatArray(DOUBLE_RADIUS) } private val fluidNewMap = Array(DOUBLE_RADIUS) { FloatArray(DOUBLE_RADIUS) }
private val fluidNewTypeMap = Array(DOUBLE_RADIUS) { Array(DOUBLE_RADIUS) { Fluid.NULL } } private val fluidNewTypeMap = Array(DOUBLE_RADIUS) { Array(DOUBLE_RADIUS) { Fluid.NULL } }
// Mask to track tiles that have already been fluid-simulated this frame (for overlap deduplication)
private val processedFluidTiles = HashSet<Long>()
const val FLUID_MAX_MASS = 1f // The normal, un-pressurized mass of a full water cell const val FLUID_MAX_MASS = 1f // The normal, un-pressurized mass of a full water cell
const val FLUID_MAX_COMP = 0.01f // How much excess water a cell can store, compared to the cell above it. A tile of fluid can contain more than MaxMass water. const val FLUID_MAX_COMP = 0.01f // How much excess water a cell can store, compared to the cell above it. A tile of fluid can contain more than MaxMass water.
// const val FLUID_MIN_MASS = net.torvald.terrarum.gameworld.FLUID_MIN_MASS //Ignore cells that are almost dry (smaller than epsilon of float16) // const val FLUID_MIN_MASS = net.torvald.terrarum.gameworld.FLUID_MIN_MASS //Ignore cells that are almost dry (smaller than epsilon of float16)
@@ -66,6 +70,97 @@ object WorldSimulator {
/** Bottom-right point */ /** Bottom-right point */
var updateYTo = 0 var updateYTo = 0
/**
* Represents a rectangular region for world updates.
*/
data class UpdateRegion(val xFrom: Int, val yFrom: Int, val xTo: Int, val yTo: Int) {
fun overlaps(other: UpdateRegion): Boolean {
return xFrom <= other.xTo && xTo >= other.xFrom &&
yFrom <= other.yTo && yTo >= other.yFrom
}
fun merge(other: UpdateRegion): UpdateRegion {
return UpdateRegion(
min(xFrom, other.xFrom),
min(yFrom, other.yFrom),
max(xTo, other.xTo),
max(yTo, other.yTo)
)
}
val width get() = xTo - xFrom
val height get() = yTo - yFrom
}
/**
* Merges overlapping regions into non-overlapping ones.
* Uses iterative merging until no more merges are possible.
*/
private fun mergeOverlappingRegions(regions: List<UpdateRegion>): List<UpdateRegion> {
if (regions.size <= 1) return regions
val result = regions.toMutableList()
var merged = true
while (merged) {
merged = false
outer@ for (i in result.indices) {
for (j in i + 1 until result.size) {
if (result[i].overlaps(result[j])) {
val mergedRegion = result[i].merge(result[j])
result.removeAt(j)
result[i] = mergedRegion
merged = true
break@outer
}
}
}
}
return result
}
/**
* Computes update regions from all living WorldUpdaters.
* Returns a list of non-overlapping merged regions.
*/
private fun computeUpdateRegions(): List<UpdateRegion> {
val worldUpdaters = ingame.worldUpdaters.filter { !it.flagDespawn && !it.despawned }
if (worldUpdaters.isEmpty()) return emptyList()
val regions = worldUpdaters.map { updater ->
val cx = updater.hitbox.centeredX.div(TILE_SIZE).roundToInt()
val cy = updater.hitbox.centeredY.div(TILE_SIZE).roundToInt()
UpdateRegion(
cx - FLUID_UPDATING_SQUARE_RADIUS,
cy - FLUID_UPDATING_SQUARE_RADIUS,
cx + FLUID_UPDATING_SQUARE_RADIUS,
cy + FLUID_UPDATING_SQUARE_RADIUS
)
}
return mergeOverlappingRegions(regions)
}
/**
* Computes individual (unmerged) fluid regions for each WorldUpdater.
* Each region is exactly DOUBLE_RADIUS × DOUBLE_RADIUS to fit the fixed fluid arrays.
* Overlap deduplication is handled by processedFluidTiles mask during fluidmapToWorld().
*/
private fun computeIndividualFluidRegions(): List<UpdateRegion> {
val worldUpdaters = ingame.worldUpdaters.filter { !it.flagDespawn && !it.despawned }
if (worldUpdaters.isEmpty()) return emptyList()
return worldUpdaters.map { updater ->
val cx = updater.hitbox.centeredX.div(TILE_SIZE).roundToInt()
val cy = updater.hitbox.centeredY.div(TILE_SIZE).roundToInt()
UpdateRegion(
cx - FLUID_UPDATING_SQUARE_RADIUS,
cy - FLUID_UPDATING_SQUARE_RADIUS,
cx + FLUID_UPDATING_SQUARE_RADIUS,
cy + FLUID_UPDATING_SQUARE_RADIUS
)
}
}
private val ingame: TerrarumIngame private val ingame: TerrarumIngame
get() = Terrarum.ingame!! as TerrarumIngame get() = Terrarum.ingame!! as TerrarumIngame
private val world: GameWorld private val world: GameWorld
@@ -82,21 +177,66 @@ object WorldSimulator {
//printdbg(this, "============================") //printdbg(this, "============================")
if (player != null) { // Compute non-overlapping merged regions for general simulations
updateXFrom = player.hitbox.centeredX.div(TILE_SIZE).minus(FLUID_UPDATING_SQUARE_RADIUS).roundToInt() val mergedRegions = computeUpdateRegions()
updateYFrom = player.hitbox.centeredY.div(TILE_SIZE).minus(FLUID_UPDATING_SQUARE_RADIUS).roundToInt()
updateXTo = updateXFrom + DOUBLE_RADIUS // Compute individual (unmerged) regions for fluid simulation
updateYTo = updateYFrom + DOUBLE_RADIUS // Each WorldUpdater gets its own fluid region; overlap is handled by processedFluidTiles mask
val fluidRegions = computeIndividualFluidRegions()
// Fallback to player-based region if no WorldUpdaters exist
val regionsToProcess = if (mergedRegions.isNotEmpty()) {
mergedRegions
} else if (player != null) {
val px = player.hitbox.centeredX.div(TILE_SIZE).minus(FLUID_UPDATING_SQUARE_RADIUS).roundToInt()
val py = player.hitbox.centeredY.div(TILE_SIZE).minus(FLUID_UPDATING_SQUARE_RADIUS).roundToInt()
listOf(UpdateRegion(px, py, px + DOUBLE_RADIUS, py + DOUBLE_RADIUS))
} else {
emptyList()
} }
if (ingame.terrainChangeQueue.isNotEmpty()) { App.measureDebugTime("WorldSimulator.degrass") { buryGrassImmediately() } } val fluidRegionsToProcess = if (fluidRegions.isNotEmpty()) {
App.measureDebugTime("WorldSimulator.growGrass") { growOrKillGrass() } fluidRegions
App.measureDebugTime("WorldSimulator.fluids") { moveFluids(delta) } } else if (player != null) {
App.measureDebugTime("WorldSimulator.fallables") { displaceFallables(delta) } val px = player.hitbox.centeredX.div(TILE_SIZE).minus(FLUID_UPDATING_SQUARE_RADIUS).roundToInt()
App.measureDebugTime("WorldSimulator.wires") { simulateWires(delta) } val py = player.hitbox.centeredY.div(TILE_SIZE).minus(FLUID_UPDATING_SQUARE_RADIUS).roundToInt()
App.measureDebugTime("WorldSimulator.collisionDroppedItem") { collideDroppedItems() } listOf(UpdateRegion(px, py, px + DOUBLE_RADIUS, py + DOUBLE_RADIUS))
App.measureDebugTime("WorldSimulator.dropTreeLeaves") { dropTreeLeaves() } } else {
emptyList()
}
// buryGrassImmediately doesn't depend on update region
if (ingame.terrainChangeQueue.isNotEmpty()) {
App.measureDebugTime("WorldSimulator.degrass") { buryGrassImmediately() }
}
// Process fluids for each WorldUpdater individually, using mask for overlap deduplication
processedFluidTiles.clear()
App.measureDebugTime("WorldSimulator.fluids") {
for (region in fluidRegionsToProcess) {
updateXFrom = region.xFrom
updateYFrom = region.yFrom
updateXTo = region.xTo
updateYTo = region.yTo
moveFluids(delta)
}
}
// Process other simulations using merged non-overlapping regions
for (region in regionsToProcess) {
updateXFrom = region.xFrom
updateYFrom = region.yFrom
updateXTo = region.xTo
updateYTo = region.yTo
App.measureDebugTime("WorldSimulator.growGrass") { growOrKillGrass() }
App.measureDebugTime("WorldSimulator.fallables") { displaceFallables(delta) }
App.measureDebugTime("WorldSimulator.wires") { simulateWires(delta) }
App.measureDebugTime("WorldSimulator.dropTreeLeaves") { dropTreeLeaves() }
}
// collideDroppedItems doesn't depend on update region (uses actor list)
App.measureDebugTime("WorldSimulator.collisionDroppedItem") { collideDroppedItems() }
//printdbg(this, "============================") //printdbg(this, "============================")
} }
@@ -502,10 +642,21 @@ object WorldSimulator {
} }
} }
/** Packs world coordinates into a single Long for use as a HashSet key */
private fun packFluidCoords(worldX: Int, worldY: Int): Long =
(worldX.toLong() shl 32) or (worldY.toLong() and 0xFFFFFFFFL)
private fun fluidmapToWorld() { private fun fluidmapToWorld() {
for (y in fluidMap.indices) { for (y in fluidMap.indices) {
for (x in fluidMap[0].indices) { for (x in fluidMap[0].indices) {
world.setFluid(x + updateXFrom, y + updateYFrom, fluidNewTypeMap[y][x], fluidNewMap[y][x]) val worldX = x + updateXFrom
val worldY = y + updateYFrom
val key = packFluidCoords(worldX, worldY)
// Only write if this tile hasn't been processed yet (deduplication for overlapping regions)
if (key !in processedFluidTiles) {
world.setFluid(worldX, worldY, fluidNewTypeMap[y][x], fluidNewMap[y][x])
processedFluidTiles.add(key)
}
} }
} }
} }

View File

@@ -7,6 +7,7 @@ import net.torvald.terrarum.App
import net.torvald.terrarum.Terrarum import net.torvald.terrarum.Terrarum
import net.torvald.terrarum.gameactors.AVKey import net.torvald.terrarum.gameactors.AVKey
import net.torvald.terrarum.gameactors.NoSerialise import net.torvald.terrarum.gameactors.NoSerialise
import net.torvald.terrarum.gameactors.WorldUpdater
import net.torvald.terrarum.itemproperties.ItemRemapTable import net.torvald.terrarum.itemproperties.ItemRemapTable
import net.torvald.terrarum.itemproperties.ItemTable import net.torvald.terrarum.itemproperties.ItemTable
import net.torvald.terrarum.spriteassembler.ADProperties import net.torvald.terrarum.spriteassembler.ADProperties
@@ -20,7 +21,7 @@ import java.util.*
* Created by minjaesong on 2015-12-31. * Created by minjaesong on 2015-12-31.
*/ */
class IngamePlayer : ActorHumanoid, HasAssembledSprite, NoSerialise { class IngamePlayer : ActorHumanoid, HasAssembledSprite, NoSerialise, WorldUpdater {
val creationTime = App.getTIME_T() val creationTime = App.getTIME_T()
var lastPlayTime = App.getTIME_T() // cumulative value for the savegame var lastPlayTime = App.getTIME_T() // cumulative value for the savegame