From b5b9e220912a8ac4af56236c183ebe88a161ebb7 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sun, 8 Feb 2026 20:02:39 +0900 Subject: [PATCH] platform wip3 --- .../gameactors/ActorMovingPlatform.kt | 168 ++--------------- .../terrarum/gameactors/ActorWithBody.kt | 30 +-- .../terrarum/gameactors/PhysContraption.kt | 177 ++++++++++++++++++ .../terrarum/modulebasegame/TerrarumIngame.kt | 8 +- .../gameactors/ActorTestPlatform.kt | 8 +- 5 files changed, 222 insertions(+), 169 deletions(-) create mode 100644 src/net/torvald/terrarum/gameactors/PhysContraption.kt diff --git a/src/net/torvald/terrarum/gameactors/ActorMovingPlatform.kt b/src/net/torvald/terrarum/gameactors/ActorMovingPlatform.kt index 496e24e92..fb59835dd 100644 --- a/src/net/torvald/terrarum/gameactors/ActorMovingPlatform.kt +++ b/src/net/torvald/terrarum/gameactors/ActorMovingPlatform.kt @@ -5,63 +5,28 @@ import com.badlogic.gdx.graphics.Pixmap import com.badlogic.gdx.graphics.Texture import com.badlogic.gdx.graphics.g2d.SpriteBatch import com.badlogic.gdx.graphics.g2d.TextureRegion -import net.torvald.terrarum.INGAME import net.torvald.terrarum.TerrarumAppConfiguration.TILE_SIZE -import net.torvald.terrarum.blockproperties.Block -import net.torvald.terrarum.gameitems.ItemID -import net.torvald.terrarum.modulebasegame.gameactors.ActorHumanoid -import org.dyn4j.geometry.Vector2 /** - * Base class for autonomously moving platforms that carry other actors standing on them. + * A horizontal moving platform that carries actors standing on top of it. * - * Subclasses must set [platformVelocity] before calling `super.updateImpl(delta)`. - * - * TODO: in the future this must be generalised as a PhysContraption + * Subclasses must set [contraptionVelocity] before calling `super.updateImpl(delta)`. * * Created by minjaesong on 2022-02-28. */ -open class ActorMovingPlatform() : ActorWithBody() { +open class ActorMovingPlatform() : PhysContraption() { protected var tilewiseWidth = 3 // default fallback value when no args were given constructor(newTilewiseWidth: Int) : this() { this.tilewiseWidth = newTilewiseWidth - - physProp = PhysProperties.MOBILE_OBJECT() - collisionType = COLLISION_KINEMATIC - setHitboxDimension(TILE_SIZE * newTilewiseWidth, TILE_SIZE, 0, 0) } - /** Actors currently riding this platform, stored by ActorID for serialisation. */ - @Transient protected val actorsRiding = ArrayList() - - /** Velocity the platform intends to move this tick. Subclasses set this before calling super.updateImpl(). */ - @Transient protected val platformVelocity = Vector2(0.0, 0.0) - - /** Actual displacement applied this tick (after clampHitbox). */ - @Transient private val appliedVelocity = Vector2(0.0, 0.0) - - /** Tolerance above platform top for "feet on top" detection (pixels). */ - @Transient private val MOUNT_TOLERANCE_ABOVE = (TILE_SIZE / 2).toDouble()//2.0 - - /** Tolerance below platform top — how far feet can sink before dismount (pixels). */ - @Transient private val MOUNT_TOLERANCE_BELOW = (TILE_SIZE / 2).toDouble() - - /** Minimum combined Y velocity to count as "jumping up" (prevents mount while jumping). */ - @Transient private val JUMP_THRESHOLD_Y = -0.5 - - /** Block whose friction this platform impersonates. Riders use this for feet friction. */ - var surfaceBlock: ItemID = Block.STONE - @Transient private var platformTexture: Texture? = null @Transient private var platformTextureRegion: TextureRegion? = null init { - physProp = PhysProperties.MOBILE_OBJECT() - collisionType = COLLISION_KINEMATIC - setHitboxDimension(TILE_SIZE * tilewiseWidth, TILE_SIZE, 0, 0) } @@ -82,123 +47,26 @@ open class ActorMovingPlatform() : ActorWithBody() { } } - override fun updateImpl(delta: Float) { - // Snapshot position before movement - val oldX = hitbox.startX - val oldY = hitbox.startY + override fun isActorOnTop(actor: ActorWithBody): Boolean { + val feetY = actor.hitbox.endY + val headY = actor.hitbox.startY + val platTop = hitbox.startY - // Set externalV to our platform velocity so super translates the hitbox - externalV.set(platformVelocity.x, platformVelocity.y) + // Feet are near platform top: slightly above or sunk partway in + val feetNearPlatTop = feetY >= platTop - MOUNT_TOLERANCE_ABOVE && + feetY <= platTop + MOUNT_TOLERANCE_BELOW - // super.updateImpl handles: - // - sprite updates - // - hitbox.translate(externalV) (since usePhysics=false -> isNoCollideWorld=true) - // - clampHitbox - // - tilewise hitbox cache updates - // - position vector updates - super.updateImpl(delta) + // Actor's head must be above platform top (prevents mounting from below) + val comingFromAbove = headY < platTop - // Compute actual displacement (clampHitbox may have wrapped coordinates) - appliedVelocity.set(hitbox.startX - oldX, hitbox.startY - oldY) + // Check horizontal overlap + val horizontalOverlap = actor.hitbox.endX > hitbox.startX && actor.hitbox.startX < hitbox.endX - // --- Step 1: Move existing riders BEFORE mount detection --- - // This keeps riders aligned with the platform's new position so the - // mount check doesn't fail when the platform is moving fast. - for (riderId in actorsRiding.toList()) { - val rider = INGAME.getActorByID(riderId) as? ActorWithBody - if (rider != null) { - rider.hitbox.translate(appliedVelocity) - rider.hitbox.setPositionY(hitbox.startY - rider.hitbox.height) - if (rider.externalV.y > 0.0) { - rider.externalV.y = 0.0 - } - rider.walledBottom = true - } - } + // Check not jumping upward + val combinedVelY = actor.externalV.y + (actor.controllerV?.y ?: 0.0) + val notJumping = combinedVelY >= JUMP_THRESHOLD_Y - // --- Step 2: Mount detection (riders are now at correct positions) --- - - val ridersToRemove = ArrayList() - val currentRiders = ArrayList() - - // Check all active actors + actorNowPlaying - val candidates = ArrayList() - INGAME.actorContainerActive.forEach { - if (it is ActorWithBody && it !== this && it !is ActorMovingPlatform) { - candidates.add(it) - } - } - INGAME.actorNowPlaying?.let { candidates.add(it) } - - for (actor in candidates) { - val feetY = actor.hitbox.endY - val headY = actor.hitbox.startY - val platTop = hitbox.startY - - // Feet are near platform top: slightly above or sunk partway in - val feetNearPlatTop = feetY >= platTop - MOUNT_TOLERANCE_ABOVE && - feetY <= platTop + MOUNT_TOLERANCE_BELOW - - // Actor's head must be above platform top (prevents mounting from below) - val comingFromAbove = headY < platTop - - // Check horizontal overlap - val horizontalOverlap = actor.hitbox.endX > hitbox.startX && actor.hitbox.startX < hitbox.endX - - // Check not jumping upward - val combinedVelY = actor.externalV.y + (actor.controllerV?.y ?: 0.0) - val notJumping = combinedVelY >= JUMP_THRESHOLD_Y - - if (feetNearPlatTop && comingFromAbove && horizontalOverlap && notJumping) { - if (!actorsRiding.contains(actor.referenceID)) { - // New rider — mount and snap - mount(actor) - actor.hitbox.setPositionY(hitbox.startY - actor.hitbox.height) - if (actor.externalV.y > 0.0) { - actor.externalV.y = 0.0 - } - actor.walledBottom = true - } - currentRiders.add(actor) - } - } - - // --- Step 3: Dismount actors no longer on top --- - val currentRiderIds = currentRiders.map { it.referenceID }.toSet() - for (riderId in actorsRiding.toList()) { - if (riderId !in currentRiderIds) { - val rider = INGAME.getActorByID(riderId) - if (rider is ActorWithBody) { - dismount(rider) - } - else { - ridersToRemove.add(riderId) - } - } - } - ridersToRemove.forEach { actorsRiding.remove(it) } - } - - /** - * Add an actor to the rider list. - */ - fun mount(actor: ActorWithBody) { - if (!actorsRiding.contains(actor.referenceID)) { - actorsRiding.add(actor.referenceID) - actor.platformsRiding.add(this.referenceID) - } - } - - /** - * Remove an actor from the rider list and apply dismount impulse. - */ - fun dismount(actor: ActorWithBody) { - actorsRiding.remove(actor.referenceID) - actor.platformsRiding.remove(this.referenceID) - - // Conservation of momentum: add platform velocity as impulse - actor.externalV.x += platformVelocity.x - actor.externalV.y += platformVelocity.y + return feetNearPlatTop && comingFromAbove && horizontalOverlap && notJumping } override fun drawBody(frameDelta: Float, batch: SpriteBatch) { diff --git a/src/net/torvald/terrarum/gameactors/ActorWithBody.kt b/src/net/torvald/terrarum/gameactors/ActorWithBody.kt index 03447a814..1a54ac91a 100644 --- a/src/net/torvald/terrarum/gameactors/ActorWithBody.kt +++ b/src/net/torvald/terrarum/gameactors/ActorWithBody.kt @@ -328,9 +328,9 @@ open class ActorWithBody : Actor { var isPickedUp = false /** - * Redundant entry for ActorMovingPlatform.actorsRiding. This field must be modified by the platforms! + * Redundant entry for PhysContraption.actorsRiding. This field must be modified by the contraptions! * - * Also see [net.torvald.terrarum.modulebasegame.gameactors.ActorMovingPlatform.actorsRiding] + * Also see [PhysContraption.actorsRiding] */ @Transient internal val platformsRiding = ArrayList() @@ -604,14 +604,7 @@ open class ActorWithBody : Actor { * If and only if: * This body is NON-STATIC and the other body is STATIC */ - if (platformsRiding.isNotEmpty()) { - // Riding a platform: skip displaceHitbox entirely. - // The CCD/collision solver doesn't know about platforms and - // will corrupt the rider's position (bounce, stair-step, etc.). - // Only apply horizontal movement; the platform owns Y. - hitbox.translate(vecSum.x, 0.0) - } - else if (!isNoCollideWorld) { + if (!isNoCollideWorld) { val (collisionStatus, collisionDamage) = displaceHitbox(true) @@ -640,6 +633,21 @@ open class ActorWithBody : Actor { hitbox.translate(vecSum) } + // Re-snap to platform after displaceHitbox: terrain collision may + // have shifted Y (stair-stepping, two-side resolution, etc.) but the + // platform, not terrain, owns the rider's vertical position. + // Only snap if it wouldn't push the rider into a ceiling. + if (platformsRiding.isNotEmpty()) { + val platform = INGAME.getActorByID(platformsRiding[0]) + if (platform is ActorWithBody) { + val preSnapY = hitbox.startY + hitbox.setPositionY(platform.hitbox.startY - hitbox.height) + if (isWalled(hitbox, COLLIDING_TOP)) { + hitbox.setPositionY(preSnapY) + } + } + } + ////////////////////////////////////////////////////////////// // Codes that modifies velocity (after hitbox displacement) // ////////////////////////////////////////////////////////////// @@ -1696,7 +1704,7 @@ open class ActorWithBody : Actor { // When riding a platform, use the platform's surface block friction if (platformsRiding.isNotEmpty()) { val platform = INGAME.getActorByID(platformsRiding[0]) - if (platform is ActorMovingPlatform) { + if (platform is PhysContraption) { return getTileFriction(platform.surfaceBlock) } } diff --git a/src/net/torvald/terrarum/gameactors/PhysContraption.kt b/src/net/torvald/terrarum/gameactors/PhysContraption.kt new file mode 100644 index 000000000..ab9e96c4b --- /dev/null +++ b/src/net/torvald/terrarum/gameactors/PhysContraption.kt @@ -0,0 +1,177 @@ +package net.torvald.terrarum.gameactors + +import net.torvald.terrarum.INGAME +import net.torvald.terrarum.TerrarumAppConfiguration.TILE_SIZE +import net.torvald.terrarum.TerrarumAppConfiguration.TILE_SIZED +import net.torvald.terrarum.abs +import net.torvald.terrarum.blockproperties.Block +import net.torvald.terrarum.gameitems.ItemID +import net.torvald.terrarum.sqrt +import org.dyn4j.geometry.Vector2 + +/** + * Abstract base class for autonomously moving contraptions that carry other actors. + * + * Handles rider management (mount/dismount), momentum conservation, and + * velocity-driven hitbox translation. Subclasses provide geometry-specific + * mount detection via [isActorOnTop] and set [contraptionVelocity] before + * calling `super.updateImpl(delta)`. + * + * Created by minjaesong on 2026-02-08. + */ +abstract class PhysContraption() : ActorWithBody() { + + /** Actors currently riding this contraption, stored by ActorID for serialisation. */ + protected val actorsRiding = ArrayList() + + /** Velocity the contraption intends to move this tick. Subclasses set this before calling super.updateImpl(). */ + protected val contraptionVelocity = Vector2(0.0, 0.0) + + /** Actual displacement applied this tick (after clampHitbox). */ + private val appliedVelocity = Vector2(0.0, 0.0) + + /** Tolerance above contraption top for "feet on top" detection (pixels). */ + @Transient protected open val MOUNT_TOLERANCE_ABOVE: Double = INGAME.world.gravitation.y.abs().sqrt() + + /** Tolerance below contraption top — how far feet can sink before dismount (pixels). */ + @Transient protected open val MOUNT_TOLERANCE_BELOW: Double = TILE_SIZED + + /** Minimum combined Y velocity to count as "jumping up" (prevents mount while jumping). */ + @Transient protected open val JUMP_THRESHOLD_Y: Double = -0.5 + + /** Block whose friction this contraption impersonates. Riders use this for feet friction. */ + var surfaceBlock: ItemID = Block.STONE + + init { + physProp = PhysProperties.MOBILE_OBJECT() + collisionType = COLLISION_KINEMATIC + } + + override fun updateImpl(delta: Float) { + // Snapshot position before movement + val oldX = hitbox.startX + val oldY = hitbox.startY + + // Set externalV to our contraption velocity so super translates the hitbox + externalV.set(contraptionVelocity.x, contraptionVelocity.y) + + // super.updateImpl handles: + // - sprite updates + // - hitbox.translate(externalV) (since usePhysics=false -> isNoCollideWorld=true) + // - clampHitbox + // - tilewise hitbox cache updates + // - position vector updates + super.updateImpl(delta) + + // Compute actual displacement (clampHitbox may have wrapped coordinates) + appliedVelocity.set(hitbox.startX - oldX, hitbox.startY - oldY) + + // --- Step 1: Move existing riders BEFORE mount detection --- + // This keeps riders aligned with the contraption's new position so the + // mount check doesn't fail when the contraption is moving fast. + for (riderId in actorsRiding.toList()) { + val rider = INGAME.getActorByID(riderId) as? ActorWithBody ?: continue + + val oldRiderX = rider.hitbox.startX + val oldRiderY = rider.hitbox.startY + + // Apply horizontal displacement, then check for wall collision + rider.hitbox.translatePosX(appliedVelocity.x) + if (!rider.isNoCollideWorld && rider.isWalled(rider.hitbox, COLLIDING_LR)) { + rider.hitbox.setPositionX(oldRiderX) + } + + // Snap to contraption surface (sets Y), then check for ceiling collision + snapRiderToSurface(rider) + if (!rider.isNoCollideWorld && rider.isWalled(rider.hitbox, COLLIDING_TOP)) { + rider.hitbox.setPositionY(oldRiderY) + } + + if (rider.externalV.y > 0.0) { + rider.externalV.y = 0.0 + } + rider.walledBottom = true + } + + // --- Step 2: Mount detection (riders are now at correct positions) --- + + val ridersToRemove = ArrayList() + val currentRiders = ArrayList() + + // Check all active actors + actorNowPlaying + val candidates = ArrayList() + INGAME.actorContainerActive.forEach { + if (it is ActorWithBody && it !== this && it !is PhysContraption) { + candidates.add(it) + } + } + INGAME.actorNowPlaying?.let { candidates.add(it) } + + for (actor in candidates) { + if (isActorOnTop(actor)) { + if (!actorsRiding.contains(actor.referenceID)) { + // New rider — mount and snap + mount(actor) + snapRiderToSurface(actor) + if (actor.externalV.y > 0.0) { + actor.externalV.y = 0.0 + } + actor.walledBottom = true + } + currentRiders.add(actor) + } + } + + // --- Step 3: Dismount actors no longer on top --- + val currentRiderIds = currentRiders.map { it.referenceID }.toSet() + for (riderId in actorsRiding.toList()) { + if (riderId !in currentRiderIds) { + val rider = INGAME.getActorByID(riderId) + if (rider is ActorWithBody) { + dismount(rider) + } + else { + ridersToRemove.add(riderId) + } + } + } + ridersToRemove.forEach { actorsRiding.remove(it) } + } + + /** + * Geometry check: is this actor positioned on top of the contraption such that + * it should be considered a rider? Subclasses override for different geometries. + */ + abstract fun isActorOnTop(actor: ActorWithBody): Boolean + + /** + * Snap a rider's vertical position to this contraption's surface. + * Default implementation places the rider on top (feet at contraption top). + * Override for contraptions that carry riders differently. + */ + open fun snapRiderToSurface(rider: ActorWithBody) { + rider.hitbox.setPositionY(hitbox.startY - rider.hitbox.height) + } + + /** + * Add an actor to the rider list. + */ + fun mount(actor: ActorWithBody) { + if (!actorsRiding.contains(actor.referenceID)) { + actorsRiding.add(actor.referenceID) + actor.platformsRiding.add(this.referenceID) + } + } + + /** + * Remove an actor from the rider list and apply dismount impulse. + */ + fun dismount(actor: ActorWithBody) { + actorsRiding.remove(actor.referenceID) + actor.platformsRiding.remove(this.referenceID) + + // Conservation of momentum: add contraption velocity as impulse + actor.externalV.x += contraptionVelocity.x + actor.externalV.y += contraptionVelocity.y + } +} diff --git a/src/net/torvald/terrarum/modulebasegame/TerrarumIngame.kt b/src/net/torvald/terrarum/modulebasegame/TerrarumIngame.kt index 5dc376b51..69c81ff24 100644 --- a/src/net/torvald/terrarum/modulebasegame/TerrarumIngame.kt +++ b/src/net/torvald/terrarum/modulebasegame/TerrarumIngame.kt @@ -1480,16 +1480,16 @@ open class TerrarumIngame(batch: FlippingSpriteBatch) : IngameInstance(batch) { actorNowPlaying?.update(delta)*/ } else { - // Pass 1: update moving platforms first so riders get displaced before their own update + // Pass 1: update contraptions first so riders get displaced before their own update actorContainerActive.forEach { - if (it is ActorMovingPlatform && it != actorNowPlaying) { + if (it is PhysContraption && it != actorNowPlaying) { it.update(delta) } } - // Pass 2: update all non-platform actors with existing callbacks + // Pass 2: update all non-contraption actors with existing callbacks actorContainerActive.forEach { - if (it !is ActorMovingPlatform && it != actorNowPlaying) { + if (it !is PhysContraption && it != actorNowPlaying) { it.update(delta) if (it is Pocketed) { diff --git a/src/net/torvald/terrarum/modulebasegame/gameactors/ActorTestPlatform.kt b/src/net/torvald/terrarum/modulebasegame/gameactors/ActorTestPlatform.kt index 7a09108a0..dc329f50d 100644 --- a/src/net/torvald/terrarum/modulebasegame/gameactors/ActorTestPlatform.kt +++ b/src/net/torvald/terrarum/modulebasegame/gameactors/ActorTestPlatform.kt @@ -51,24 +51,24 @@ class ActorTestPlatform : ActorMovingPlatform(8) { // Horizontal pingpong: position = A * sin(phase) // Velocity = finite difference to prevent float drift val dx = amplitude * (sin(phase) - sin(oldPhase)) - platformVelocity.set(dx, 0.0) + contraptionVelocity.set(dx, 0.0) } 1 -> { // Vertical pingpong: position = A * sin(phase) val dy = amplitude * (sin(phase) - sin(oldPhase)) - platformVelocity.set(0.0, dy) + contraptionVelocity.set(0.0, dy) } 2 -> { // Clockwise circular: position on circle (cos, sin) val dx = amplitude * (cos(phase) - cos(oldPhase)) val dy = amplitude * (sin(phase) - sin(oldPhase)) - platformVelocity.set(dx, dy) + contraptionVelocity.set(dx, dy) } 3 -> { // Counter-clockwise circular: negate Y component val dx = amplitude * (cos(phase) - cos(oldPhase)) val dy = -(amplitude * (sin(phase) - sin(oldPhase))) - platformVelocity.set(dx, dy) + contraptionVelocity.set(dx, dy) } }