platform wip3

This commit is contained in:
minjaesong
2026-02-08 20:02:39 +09:00
parent 5b1d0ca049
commit b5b9e22091
5 changed files with 222 additions and 169 deletions

View File

@@ -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<ActorID>()
/** 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,55 +47,7 @@ open class ActorMovingPlatform() : ActorWithBody() {
}
}
override fun updateImpl(delta: Float) {
// Snapshot position before movement
val oldX = hitbox.startX
val oldY = hitbox.startY
// Set externalV to our platform velocity so super translates the hitbox
externalV.set(platformVelocity.x, platformVelocity.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 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
}
}
// --- Step 2: Mount detection (riders are now at correct positions) ---
val ridersToRemove = ArrayList<ActorID>()
val currentRiders = ArrayList<ActorWithBody>()
// Check all active actors + actorNowPlaying
val candidates = ArrayList<ActorWithBody>()
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) {
override fun isActorOnTop(actor: ActorWithBody): Boolean {
val feetY = actor.hitbox.endY
val headY = actor.hitbox.startY
val platTop = hitbox.startY
@@ -149,56 +66,7 @@ open class ActorMovingPlatform() : ActorWithBody() {
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) {

View File

@@ -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<ActorID>()
@@ -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)
}
}

View File

@@ -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<ActorID>()
/** 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<ActorID>()
val currentRiders = ArrayList<ActorWithBody>()
// Check all active actors + actorNowPlaying
val candidates = ArrayList<ActorWithBody>()
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
}
}

View File

@@ -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) {

View File

@@ -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)
}
}