Веб-сайт самохостера Lotigara

summaryrefslogtreecommitdiff
path: root/source/game/StarMovementController.cpp
diff options
context:
space:
mode:
authorKae <80987908+Novaenia@users.noreply.github.com>2023-06-20 14:33:09 +1000
committerKae <80987908+Novaenia@users.noreply.github.com>2023-06-20 14:33:09 +1000
commit6352e8e3196f78388b6c771073f9e03eaa612673 (patch)
treee23772f79a7fbc41bc9108951e9e136857484bf4 /source/game/StarMovementController.cpp
parent6741a057e5639280d85d0f88ba26f000baa58f61 (diff)
everything everywhere
all at once
Diffstat (limited to 'source/game/StarMovementController.cpp')
-rw-r--r--source/game/StarMovementController.cpp1109
1 files changed, 1109 insertions, 0 deletions
diff --git a/source/game/StarMovementController.cpp b/source/game/StarMovementController.cpp
new file mode 100644
index 0000000..26e540a
--- /dev/null
+++ b/source/game/StarMovementController.cpp
@@ -0,0 +1,1109 @@
+#include "StarMovementController.hpp"
+#include "StarJsonExtra.hpp"
+#include "StarDataStreamExtra.hpp"
+#include "StarRoot.hpp"
+#include "StarWorld.hpp"
+#include "StarAssets.hpp"
+#include "StarRandom.hpp"
+
+namespace Star {
+
+MovementParameters MovementParameters::sensibleDefaults() {
+ return MovementParameters(Root::singleton().assets()->json("/default_movement.config").toObject());
+}
+
+MovementParameters::MovementParameters(Json const& config) {
+ if (config.isNull())
+ return;
+
+ mass = config.optFloat("mass");
+ gravityMultiplier = config.optFloat("gravityMultiplier");
+ liquidBuoyancy = config.optFloat("liquidBuoyancy");
+ airBuoyancy = config.optFloat("airBuoyancy");
+ bounceFactor = config.optFloat("bounceFactor");
+ stopOnFirstBounce = config.optBool("stopOnFirstBounce");
+ enableSurfaceSlopeCorrection = config.optBool("enableSurfaceSlopeCorrection");
+ slopeSlidingFactor = config.optFloat("slopeSlidingFactor");
+ maxMovementPerStep = config.optFloat("maxMovementPerStep");
+ maximumCorrection = config.optFloat("maximumCorrection");
+ speedLimit = config.optFloat("speedLimit");
+ discontinuityThreshold = config.optFloat("discontinuityThreshold");
+ collisionPoly = config.opt("collisionPoly").apply(jsonToPolyF);
+ stickyCollision = config.optBool("stickyCollision");
+ stickyForce = config.optFloat("stickyForce");
+ airFriction = config.optFloat("airFriction");
+ liquidFriction = config.optFloat("liquidFriction");
+ groundFriction = config.optFloat("groundFriction");
+ collisionEnabled = config.optBool("collisionEnabled");
+ frictionEnabled = config.optBool("frictionEnabled");
+ gravityEnabled = config.optBool("gravityEnabled");
+ ignorePlatformCollision = config.optBool("ignorePlatformCollision");
+ maximumPlatformCorrection = config.optFloat("maximumPlatformCorrection");
+ maximumPlatformCorrectionVelocityFactor = config.optFloat("maximumPlatformCorrectionVelocityFactor");
+ physicsEffectCategories = config.opt("physicsEffectCategories").apply(jsonToStringSet);
+ restDuration = config.optInt("restDuration");
+}
+
+MovementParameters MovementParameters::merge(MovementParameters const& rhs) const {
+ MovementParameters merged;
+
+ merged.mass = rhs.mass.orMaybe(mass);
+ merged.gravityMultiplier = rhs.gravityMultiplier.orMaybe(gravityMultiplier);
+ merged.liquidBuoyancy = rhs.liquidBuoyancy.orMaybe(liquidBuoyancy);
+ merged.airBuoyancy = rhs.airBuoyancy.orMaybe(airBuoyancy);
+ merged.bounceFactor = rhs.bounceFactor.orMaybe(bounceFactor);
+ merged.stopOnFirstBounce = rhs.stopOnFirstBounce.orMaybe(stopOnFirstBounce);
+ merged.enableSurfaceSlopeCorrection = rhs.enableSurfaceSlopeCorrection.orMaybe(enableSurfaceSlopeCorrection);
+ merged.slopeSlidingFactor = rhs.slopeSlidingFactor.orMaybe(slopeSlidingFactor);
+ merged.maxMovementPerStep = rhs.maxMovementPerStep.orMaybe(maxMovementPerStep);
+ merged.maximumCorrection = rhs.maximumCorrection.orMaybe(maximumCorrection);
+ merged.speedLimit = rhs.speedLimit.orMaybe(speedLimit);
+ merged.discontinuityThreshold = rhs.discontinuityThreshold.orMaybe(discontinuityThreshold);
+ merged.collisionPoly = rhs.collisionPoly.orMaybe(collisionPoly);
+ merged.stickyCollision = rhs.stickyCollision.orMaybe(stickyCollision);
+ merged.stickyForce = rhs.stickyForce.orMaybe(stickyForce);
+ merged.airFriction = rhs.airFriction.orMaybe(airFriction);
+ merged.liquidFriction = rhs.liquidFriction.orMaybe(liquidFriction);
+ merged.groundFriction = rhs.groundFriction.orMaybe(groundFriction);
+ merged.collisionEnabled = rhs.collisionEnabled.orMaybe(collisionEnabled);
+ merged.frictionEnabled = rhs.frictionEnabled.orMaybe(frictionEnabled);
+ merged.gravityEnabled = rhs.gravityEnabled.orMaybe(gravityEnabled);
+ merged.ignorePlatformCollision = rhs.ignorePlatformCollision.orMaybe(ignorePlatformCollision);
+ merged.maximumPlatformCorrection = rhs.maximumPlatformCorrection.orMaybe(maximumPlatformCorrection);
+ merged.maximumPlatformCorrectionVelocityFactor = rhs.maximumPlatformCorrectionVelocityFactor.orMaybe(maximumPlatformCorrectionVelocityFactor);
+ merged.physicsEffectCategories = rhs.physicsEffectCategories.orMaybe(physicsEffectCategories);
+ merged.restDuration = rhs.restDuration.orMaybe(restDuration);
+
+ return merged;
+}
+
+Json MovementParameters::toJson() const {
+ return JsonObject{
+ {"mass", jsonFromMaybe(mass)},
+ {"gravityMultiplier", jsonFromMaybe(gravityMultiplier)},
+ {"liquidBuoyancy", jsonFromMaybe(liquidBuoyancy)},
+ {"airBuoyancy", jsonFromMaybe(airBuoyancy)},
+ {"stopOnFirstBounce", jsonFromMaybe(stopOnFirstBounce)},
+ {"enableSurfaceSlopeCorrection", jsonFromMaybe(enableSurfaceSlopeCorrection)},
+ {"slopeSlidingFactor", jsonFromMaybe(slopeSlidingFactor)},
+ {"maxMovementPerStep", jsonFromMaybe(maxMovementPerStep)},
+ {"maximumCorrection", jsonFromMaybe(maximumCorrection)},
+ {"speedLimit", jsonFromMaybe(speedLimit)},
+ {"discontinuityThreshold", jsonFromMaybe(discontinuityThreshold)},
+ {"collisionPoly", jsonFromMaybe(collisionPoly, jsonFromPolyF)},
+ {"stickyCollision", jsonFromMaybe(stickyCollision)},
+ {"stickyForce", jsonFromMaybe(stickyForce)},
+ {"airFriction", jsonFromMaybe(airFriction)},
+ {"liquidFriction", jsonFromMaybe(liquidFriction)},
+ {"groundFriction", jsonFromMaybe(groundFriction)},
+ {"collisionEnabled", jsonFromMaybe(collisionEnabled)},
+ {"frictionEnabled", jsonFromMaybe(frictionEnabled)},
+ {"gravityEnabled", jsonFromMaybe(gravityEnabled)},
+ {"ignorePlatformCollision", jsonFromMaybe(ignorePlatformCollision)},
+ {"maximumPlatformCorrection", jsonFromMaybe(maximumPlatformCorrection)},
+ {"maximumPlatformCorrectionVelocityFactor", jsonFromMaybe(maximumPlatformCorrectionVelocityFactor)},
+ {"physicsEffectCategories", jsonFromMaybe(physicsEffectCategories, jsonFromStringSet)},
+ {"restDuration", jsonFromMaybe(restDuration)}
+ };
+}
+
+DataStream& operator>>(DataStream& ds, MovementParameters& movementParameters) {
+ ds.read(movementParameters.mass);
+ ds.read(movementParameters.gravityMultiplier);
+ ds.read(movementParameters.liquidBuoyancy);
+ ds.read(movementParameters.airBuoyancy);
+ ds.read(movementParameters.stopOnFirstBounce);
+ ds.read(movementParameters.enableSurfaceSlopeCorrection);
+ ds.read(movementParameters.slopeSlidingFactor);
+ ds.read(movementParameters.maxMovementPerStep);
+ ds.read(movementParameters.maximumCorrection);
+ ds.read(movementParameters.speedLimit);
+ ds.read(movementParameters.discontinuityThreshold);
+ ds.read(movementParameters.collisionPoly);
+ ds.read(movementParameters.stickyCollision);
+ ds.read(movementParameters.stickyForce);
+ ds.read(movementParameters.airFriction);
+ ds.read(movementParameters.liquidFriction);
+ ds.read(movementParameters.groundFriction);
+ ds.read(movementParameters.collisionEnabled);
+ ds.read(movementParameters.frictionEnabled);
+ ds.read(movementParameters.gravityEnabled);
+ ds.read(movementParameters.ignorePlatformCollision);
+ ds.read(movementParameters.maximumPlatformCorrection);
+ ds.read(movementParameters.maximumPlatformCorrectionVelocityFactor);
+ ds.read(movementParameters.physicsEffectCategories);
+ ds.read(movementParameters.restDuration);
+
+ return ds;
+}
+
+DataStream& operator<<(DataStream& ds, MovementParameters const& movementParameters) {
+ ds.write(movementParameters.mass);
+ ds.write(movementParameters.gravityMultiplier);
+ ds.write(movementParameters.liquidBuoyancy);
+ ds.write(movementParameters.airBuoyancy);
+ ds.write(movementParameters.stopOnFirstBounce);
+ ds.write(movementParameters.enableSurfaceSlopeCorrection);
+ ds.write(movementParameters.slopeSlidingFactor);
+ ds.write(movementParameters.maxMovementPerStep);
+ ds.write(movementParameters.maximumCorrection);
+ ds.write(movementParameters.speedLimit);
+ ds.write(movementParameters.discontinuityThreshold);
+ ds.write(movementParameters.collisionPoly);
+ ds.write(movementParameters.stickyCollision);
+ ds.write(movementParameters.stickyForce);
+ ds.write(movementParameters.airFriction);
+ ds.write(movementParameters.liquidFriction);
+ ds.write(movementParameters.groundFriction);
+ ds.write(movementParameters.collisionEnabled);
+ ds.write(movementParameters.frictionEnabled);
+ ds.write(movementParameters.gravityEnabled);
+ ds.write(movementParameters.ignorePlatformCollision);
+ ds.write(movementParameters.maximumPlatformCorrection);
+ ds.write(movementParameters.maximumPlatformCorrectionVelocityFactor);
+ ds.write(movementParameters.physicsEffectCategories);
+ ds.write(movementParameters.restDuration);
+
+ return ds;
+}
+
+MovementController::MovementController(MovementParameters const& parameters) {
+ m_resting = false;
+
+ m_liquidPercentage = 0.0f;
+ m_liquidId = EmptyLiquidId;
+
+ m_xPosition.setFixedPointBase(0.0125f);
+ m_yPosition.setFixedPointBase(0.0125f);
+ m_xVelocity.setFixedPointBase(0.00625f);
+ m_yVelocity.setFixedPointBase(0.00625f);
+ m_rotation.setFixedPointBase(0.01f);
+ m_xRelativeSurfaceMovingCollisionPosition.setFixedPointBase(0.0125f);
+ m_yRelativeSurfaceMovingCollisionPosition.setFixedPointBase(0.0125f);
+
+ m_xVelocity.setInterpolator(lerp<float, float>);
+ m_yVelocity.setInterpolator(lerp<float, float>);
+ m_rotation.setInterpolator(lerp<float, float>);
+ m_xRelativeSurfaceMovingCollisionPosition.setInterpolator(lerp<float, float>);
+ m_yRelativeSurfaceMovingCollisionPosition.setInterpolator(lerp<float, float>);
+
+ addNetElement(&m_collisionPoly);
+ addNetElement(&m_mass);
+ addNetElement(&m_xPosition);
+ addNetElement(&m_yPosition);
+ addNetElement(&m_xVelocity);
+ addNetElement(&m_yVelocity);
+ addNetElement(&m_rotation);
+ addNetElement(&m_colliding);
+ addNetElement(&m_collisionStuck);
+ addNetElement(&m_nullColliding);
+ addNetElement(&m_stickingDirection);
+ addNetElement(&m_onGround);
+ addNetElement(&m_zeroG);
+
+ addNetElement(&m_surfaceMovingCollision);
+ addNetElement(&m_xRelativeSurfaceMovingCollisionPosition);
+ addNetElement(&m_yRelativeSurfaceMovingCollisionPosition);
+
+ m_world = nullptr;
+
+ resetParameters(parameters);
+}
+
+MovementParameters const& MovementController::parameters() const {
+ return m_parameters;
+}
+
+void MovementController::applyParameters(MovementParameters const& parameters) {
+ updateParameters(m_parameters.merge(parameters));
+}
+
+void MovementController::resetParameters(MovementParameters const& parameters) {
+ updateParameters(MovementParameters::sensibleDefaults().merge(parameters));
+}
+
+Json MovementController::storeState() const {
+ return JsonObject{
+ {"position", jsonFromVec2F(position())},
+ {"velocity", jsonFromVec2F(velocity())},
+ {"rotation", rotation()}
+ };
+}
+
+void MovementController::loadState(Json const& state) {
+ setPosition(jsonToVec2F(state.get("position")));
+ setVelocity(jsonToVec2F(state.get("velocity")));
+ setRotation(state.getFloat("rotation"));
+}
+
+float MovementController::mass() const {
+ return m_mass.get();
+}
+
+PolyF const& MovementController::collisionPoly() const {
+ return m_collisionPoly.get();
+}
+
+Vec2F MovementController::position() const {
+ return {m_xPosition.get(), m_yPosition.get()};
+}
+
+float MovementController::xPosition() const {
+ return m_xPosition.get();
+}
+
+float MovementController::yPosition() const {
+ return m_yPosition.get();
+}
+
+Vec2F MovementController::velocity() const {
+ return {m_xVelocity.get(), m_yVelocity.get()};
+}
+
+float MovementController::xVelocity() const {
+ return m_xVelocity.get();
+}
+
+float MovementController::yVelocity() const {
+ return m_yVelocity.get();
+}
+
+float MovementController::rotation() const {
+ return m_rotation.get();
+}
+
+PolyF MovementController::collisionBody() const {
+ auto poly = collisionPoly();
+ poly.rotate(rotation());
+ poly.translate(position());
+ return poly;
+}
+
+RectF MovementController::localBoundBox() const {
+ auto poly = collisionPoly();
+ poly.rotate(rotation());
+ return poly.boundBox();
+}
+
+RectF MovementController::collisionBoundBox() const {
+ return collisionBody().boundBox();
+}
+
+bool MovementController::isColliding() const {
+ return m_colliding.get();
+}
+
+bool MovementController::isNullColliding() const {
+ return m_nullColliding.get();
+}
+
+bool MovementController::isCollisionStuck() const {
+ return m_collisionStuck.get();
+}
+
+Maybe<float> MovementController::stickingDirection() const {
+ return m_stickingDirection.get();
+}
+
+float MovementController::liquidPercentage() const {
+ return m_liquidPercentage;
+}
+
+LiquidId MovementController::liquidId() const {
+ return m_liquidId;
+}
+
+bool MovementController::onGround() const {
+ return m_onGround.get();
+}
+
+bool MovementController::zeroG() const {
+ return m_zeroG.get();
+}
+
+bool MovementController::atWorldLimit(bool bottomOnly) const {
+ if (m_world) {
+ if (!collisionPoly().isNull()) {
+ auto bounds = collisionBoundBox();
+ return bounds.yMin() <= 0 || (!bottomOnly && bounds.yMax() >= m_world->geometry().height());
+ } else {
+ return yPosition() <= 0 || (!bottomOnly && yPosition() >= m_world->geometry().height());
+ }
+ }
+ return false;
+}
+
+void MovementController::setPosition(Vec2F position) {
+ if (m_world)
+ position = m_world->geometry().limit(position);
+
+ if (position[0] != m_xPosition.get() || position[1] != m_yPosition.get())
+ m_resting = false;
+
+ m_xPosition.set(position[0]);
+ m_yPosition.set(position[1]);
+}
+
+void MovementController::setXPosition(float xPosition) {
+ setPosition({xPosition, yPosition()});
+}
+
+void MovementController::setYPosition(float yPosition) {
+ setPosition({xPosition(), yPosition});
+}
+
+void MovementController::translate(Vec2F const& direction) {
+ setPosition(position() + direction);
+}
+
+void MovementController::setVelocity(Vec2F vel) {
+ if (m_parameters.speedLimit && vel.magnitude() > *m_parameters.speedLimit) {
+ vel.normalize();
+ vel *= *m_parameters.speedLimit;
+ }
+
+ if ((velocity() - vel).magnitude() > 0.0001)
+ m_resting = false;
+
+ m_xVelocity.set(vel[0]);
+ m_yVelocity.set(vel[1]);
+}
+
+void MovementController::setXVelocity(float xVelocity) {
+ setVelocity({xVelocity, yVelocity()});
+}
+
+void MovementController::setYVelocity(float yVelocity) {
+ setVelocity({xVelocity(), yVelocity});
+}
+
+void MovementController::addMomentum(Vec2F const& momentum) {
+ setVelocity(velocity() + momentum / mass());
+}
+
+void MovementController::setRotation(float rotation) {
+ m_resting = false;
+
+ m_rotation.set(rotation);
+}
+
+void MovementController::rotate(float rotationRate) {
+ if (rotationRate == 0.0)
+ return;
+
+ m_resting = false;
+ m_rotation.set(fmod(rotation() + rotationRate * WorldTimestep, 2 * Constants::pi));
+}
+
+void MovementController::accelerate(Vec2F const& acceleration) {
+ setVelocity(velocity() + acceleration * WorldTimestep);
+}
+
+void MovementController::force(Vec2F const& force) {
+ setVelocity(velocity() + force / mass() * WorldTimestep);
+}
+
+void MovementController::approachVelocity(Vec2F const& targetVelocity, float maxControlForce) {
+ // Instead of applying the force directly, work backwards and figure out the
+ // maximum acceleration that could be achieved by the current control force,
+ // and maximize the change in velocity based on that.
+
+ Vec2F diff = targetVelocity - velocity();
+ float diffMagnitude = vmag(diff);
+
+ if (diffMagnitude == 0.0f)
+ return;
+
+ float maximumAcceleration = maxControlForce / mass() * WorldTimestep;
+ float clampedMagnitude = clamp(diffMagnitude, 0.0f, maximumAcceleration);
+
+ setVelocity(velocity() + diff * (clampedMagnitude / diffMagnitude));
+}
+
+void MovementController::approachVelocityAlongAngle(float angle, float targetVelocity, float maxControlForce, bool positiveOnly) {
+ // Same strategy as approachVelocity, work backwards to figure out the
+ // maximum acceleration and apply that.
+
+ // Project the current velocity along the axis normal, the velocity
+ // difference is the difference between the targetVelocity and this
+ // projection.
+
+ Vec2F axis = Vec2F::withAngle(angle, 1.0f);
+
+ float velocityAlongAxis = velocity() * axis;
+ float diff = targetVelocity - velocityAlongAxis;
+ if (diff == 0.0f)
+ return;
+ if (positiveOnly && diff < 0)
+ return;
+
+ float maximumAcceleration = maxControlForce / mass() * WorldTimestep;
+
+ float diffMagnitude = std::fabs(diff);
+ float clampedMagnitude = clamp(diffMagnitude, 0.0f, maximumAcceleration);
+
+ setVelocity(velocity() + axis * diff * (clampedMagnitude / diffMagnitude));
+}
+
+void MovementController::approachXVelocity(float targetXVelocity, float maxControlForce) {
+ approachVelocityAlongAngle(0.0f, targetXVelocity, maxControlForce);
+}
+
+void MovementController::approachYVelocity(float targetYVelocity, float maxControlForce) {
+ approachVelocityAlongAngle(Constants::pi / 2, targetYVelocity, maxControlForce);
+}
+
+void MovementController::init(World* world) {
+ m_world = world;
+ setPosition(position());
+ updatePositionInterpolators();
+}
+
+void MovementController::uninit() {
+ m_world = nullptr;
+ updatePositionInterpolators();
+}
+
+void MovementController::tickMaster() {
+ auto geometry = world()->geometry();
+
+ m_zeroG.set(!*m_parameters.gravityEnabled || *m_parameters.gravityMultiplier == 0 || world()->gravity(position()) == 0);
+
+ Maybe<PhysicsMovingCollision> surfaceCollision;
+ if (auto movingCollisionId = m_surfaceMovingCollision.get()) {
+ if (auto physicsEntity = world()->get<PhysicsEntity>(movingCollisionId->physicsEntityId))
+ surfaceCollision = physicsEntity->movingCollision(movingCollisionId->collisionIndex);
+ }
+
+ if (surfaceCollision) {
+ Vec2F surfacePositionDelta = geometry.diff(surfaceCollision->position, m_surfaceMovingCollisionPosition);
+ setPosition(position() + surfacePositionDelta);
+ Vec2F newSurfaceVelocity = surfacePositionDelta / WorldTimestep;
+ setVelocity(velocity() - m_surfaceVelocity + newSurfaceVelocity);
+ m_surfaceVelocity = newSurfaceVelocity;
+ } else {
+ m_surfaceMovingCollision.set({});
+ m_surfaceMovingCollisionPosition = {};
+ m_surfaceVelocity = {};
+ }
+
+ if (m_resting) {
+ m_restTicks -= 1;
+ if (m_restTicks < 0) {
+ m_resting = false;
+ }
+ }
+
+ // don't integrate velocity when resting
+ Vec2F relativeVelocity = m_resting ? Vec2F() : velocity();
+ Vec2F originalMovement = relativeVelocity * WorldTimestep;
+ if (surfaceCollision)
+ relativeVelocity -= m_surfaceVelocity;
+
+ m_collisionCorrection = {};
+ m_surfaceSlope = Vec2F(1, 0);
+ m_surfaceMovingCollision.set({});
+
+ unsigned steps;
+ float maxMovementPerStep = *m_parameters.maxMovementPerStep;
+ if (maxMovementPerStep > 0.0f)
+ steps = std::floor(vmag(relativeVelocity) * WorldTimestep / maxMovementPerStep) + 1;
+ else
+ steps = 1;
+
+ // skip collision checks when resting (there's no movement anyway)
+ if (m_resting)
+ steps = 0;
+
+ for (unsigned i = 0; i < steps; ++i) {
+ float dt = WorldTimestep / steps;
+ Vec2F movement = relativeVelocity * dt;
+
+ if (!*m_parameters.collisionEnabled || collisionPoly().isNull()) {
+ setPosition(position() + movement);
+ m_surfaceSlope = Vec2F(1, 0);
+ m_surfaceVelocity = Vec2F(0, 0);
+
+ m_colliding.set(false);
+ m_collisionStuck.set(false);
+ m_nullColliding.set(false);
+ m_stickingDirection.set({});
+ m_onGround.set(false);
+
+ } else {
+ auto body = collisionBody();
+
+ float velocityMagnitude = vmag(relativeVelocity);
+ Vec2F velocityDirection = relativeVelocity / velocityMagnitude;
+
+ bool ignorePlatforms = *m_parameters.ignorePlatformCollision || relativeVelocity[1] > 0;
+ float maximumCorrection = *m_parameters.maximumCorrection;
+ float maximumPlatformCorrection = *m_parameters.maximumPlatformCorrection
+ + *m_parameters.maximumPlatformCorrectionVelocityFactor * velocityMagnitude;
+ Vec2F bodyCenter = body.center();
+
+ RectF queryBounds = body.boundBox().padded(maximumCorrection);
+ queryBounds.combine(queryBounds.translated(movement));
+ queryCollisions(queryBounds);
+ auto result = collisionMove(m_workingCollisions, body, movement, ignorePlatforms, *m_parameters.enableSurfaceSlopeCorrection && !zeroG(),
+ maximumCorrection, maximumPlatformCorrection, bodyCenter);
+
+ setPosition(position() + result.movement);
+
+ if (result.collisionKind == CollisionKind::Null) {
+ m_nullColliding.set(true);
+ break;
+ } else {
+ m_nullColliding.set(false);
+ }
+
+ Vec2F correction = result.correction;
+ Vec2F normCorrection = vnorm(correction);
+
+ m_surfaceSlope = result.groundSlope;
+ m_surfaceMovingCollision.set(result.surfaceMovingCollisionId);
+ m_collisionCorrection += correction;
+ m_colliding.set(correction != Vec2F() || result.isStuck);
+ m_onGround.set(!zeroG() && result.onGround);
+ m_collisionStuck.set(result.isStuck);
+
+ // If we have collided, apply either sticky or normal (bouncing) collision physics
+ if (correction != Vec2F()) {
+ if (*m_parameters.stickyCollision && result.collisionKind != CollisionKind::Slippery) {
+ // When sticking, cancel all velocity and apply stickyForce in the
+ // opposite of the direction of collision correction.
+ relativeVelocity = -normCorrection * *m_parameters.stickyForce / mass() * dt;
+ m_stickingDirection.set(-normCorrection.angle());
+ break;
+ } else {
+ m_stickingDirection.set({});
+
+ float correctionMagnitude = vmag(correction);
+ Vec2F correctionDirection = correction / correctionMagnitude;
+
+ if (*m_parameters.bounceFactor != 0.0f) {
+ Vec2F adjustment = correctionDirection * (velocityMagnitude * (correctionDirection * -velocityDirection));
+ relativeVelocity += adjustment + *m_parameters.bounceFactor * adjustment;
+ if (*m_parameters.stopOnFirstBounce) {
+ // When bouncing, stop integrating at the moment of bounce. This
+ // prevents the frame of contact from being missed due to multiple
+ // iterations per frame.
+ break;
+ }
+ } else {
+ // Only adjust the velocity to the extent that the collision was
+ // caused by the velocity in each axis, to eliminate collision
+ // induced velocity in a platformery way (each axis considered
+ // independently).
+
+ if (relativeVelocity[0] < 0 && correction[0] > 0)
+ relativeVelocity[0] = min(0.0f, relativeVelocity[0] + correction[0] / WorldTimestep);
+ else if (relativeVelocity[0] > 0 && correction[0] < 0)
+ relativeVelocity[0] = max(0.0f, relativeVelocity[0] + correction[0] / WorldTimestep);
+
+ if (relativeVelocity[1] < 0 && correction[1] > 0)
+ relativeVelocity[1] = min(0.0f, relativeVelocity[1] + correction[1] / WorldTimestep);
+ else if (relativeVelocity[1] > 0 && correction[1] < 0)
+ relativeVelocity[1] = max(0.0f, relativeVelocity[1] + correction[1] / WorldTimestep);
+ }
+ }
+ }
+ }
+ }
+
+ Vec2F newVelocity = relativeVelocity + m_surfaceVelocity;
+
+ Vec2F pos = position();
+ PolyF body = collisionBody();
+ RectF boundBox = body.boundBox();
+
+ updateLiquidPercentage();
+
+ if (auto movingCollisionId = m_surfaceMovingCollision.get()) {
+ if (auto physicsEntity = world()->get<PhysicsEntity>(movingCollisionId->physicsEntityId))
+ surfaceCollision = physicsEntity->movingCollision(movingCollisionId->collisionIndex);
+ }
+
+ if (surfaceCollision) {
+ m_surfaceMovingCollisionPosition = surfaceCollision->position;
+ m_xRelativeSurfaceMovingCollisionPosition.set(geometry.diff(xPosition(), m_surfaceMovingCollisionPosition[0]));
+ m_yRelativeSurfaceMovingCollisionPosition.set(yPosition() - m_surfaceMovingCollisionPosition[1]);
+ } else {
+ m_surfaceMovingCollisionPosition = {};
+ m_surfaceVelocity = {};
+ }
+
+ // In order to make control work accurately, passive forces need to be
+ // applied to velocity *after* integrating. This prevents control from
+ // having to account for one timestep of passive forces in order to result
+ // in the correct controlled movement.
+ if (!zeroG() && !stickingDirection()) {
+ float buoyancy = *m_parameters.liquidBuoyancy * m_liquidPercentage + *m_parameters.airBuoyancy * (1.0f - liquidPercentage());
+ float gravity = world()->gravity(position()) * *m_parameters.gravityMultiplier * (1.0f - buoyancy);
+ Vec2F environmentVelocity;
+ environmentVelocity[1] -= gravity * WorldTimestep;
+
+ if (onGround() && *m_parameters.slopeSlidingFactor != 0 && m_surfaceSlope[1] != 0)
+ environmentVelocity += -m_surfaceSlope * (m_surfaceSlope[0] * m_surfaceSlope[1]) * *m_parameters.slopeSlidingFactor;
+
+ newVelocity += environmentVelocity;
+ }
+
+ // If original movement was entirely (almost) in the direction of gravity
+ // and was entirely (almost) cancelled by collision correction, put the
+ // entity into rest for restDuration
+ if (!m_resting &&
+ abs(originalMovement[0]) < 0.0001 &&
+ originalMovement[1] * gravity() <= 0.0 &&
+ abs(originalMovement[1] + m_collisionCorrection[1]) < 0.0001) {
+ m_resting = true;
+ m_restTicks = m_parameters.restDuration.value(0);
+ }
+
+ if (*m_parameters.frictionEnabled) {
+ Vec2F refVel;
+ float friction = liquidPercentage() * *m_parameters.liquidFriction + (1.0f - liquidPercentage()) * *m_parameters.airFriction;
+ if (onGround()) {
+ friction = max(friction, *m_parameters.groundFriction);
+ refVel = m_surfaceVelocity;
+ }
+
+ // The equation for friction here is effectively:
+ // frictionForce = friction * (refVel - velocity)
+ // but it is applied here as a multiplicitave factor from [0, 1] so it does
+ // not induce oscillation at very high friction and so it cannot be
+ // negative.
+ float frictionFactor = clamp(friction / mass() * WorldTimestep, 0.0f, 1.0f);
+ newVelocity = lerp(frictionFactor, newVelocity, refVel);
+ }
+
+ setVelocity(newVelocity);
+
+ updateForceRegions();
+}
+
+void MovementController::tickSlave() {
+ if (auto movingCollisionId = m_surfaceMovingCollision.get()) {
+ if (auto physicsEntity = world()->get<PhysicsEntity>(movingCollisionId->physicsEntityId)) {
+ if (auto collision = physicsEntity->movingCollision(movingCollisionId->collisionIndex)) {
+ m_xPosition.set(m_xRelativeSurfaceMovingCollisionPosition.get() + collision->position[0]);
+ m_yPosition.set(m_yRelativeSurfaceMovingCollisionPosition.get() + collision->position[1]);
+ }
+ }
+ }
+
+ LiquidLevel cll = world()->liquidLevel(collisionBody().boundBox());
+ m_liquidPercentage = clamp(cll.level, 0.0f, 1.0f);
+ m_liquidId = cll.liquid;
+}
+
+void MovementController::setIgnorePhysicsEntities(Set<EntityId> ignorePhysicsEntities) {
+ m_ignorePhysicsEntities = ignorePhysicsEntities;
+}
+
+void MovementController::forEachMovingCollision(RectF const& region, function<bool(MovingCollisionId id, PhysicsMovingCollision, PolyF, RectF)> callback) {
+ auto geometry = world()->geometry();
+ for (auto& physicsEntity : world()->query<PhysicsEntity>(region)) {
+ if (m_ignorePhysicsEntities.contains(physicsEntity->entityId()))
+ continue;
+ for (size_t i = 0; i < physicsEntity->movingCollisionCount(); ++i) {
+ if (auto mc = physicsEntity->movingCollision(i)) {
+ if (mc->categoryFilter.check(m_parameters.physicsEffectCategories.value())) {
+ PolyF poly = move(mc->collision);
+ poly.translate(geometry.nearestTo(region.min(), mc->position));
+ RectF polyBounds = poly.boundBox();
+
+ if (region.intersects(polyBounds)) {
+ // early exit if the callback returns false
+ if(callback({physicsEntity->entityId(), i}, *mc, poly, polyBounds) == false)
+ return;
+ }
+ }
+ }
+ }
+ }
+}
+
+void MovementController::updateForceRegions() {
+ auto geometry = world()->geometry();
+ auto pos = position();
+ auto body = collisionBody();
+ RectF boundBox = body.boundBox();
+
+ m_appliedForceRegion = false;
+ auto handleForceRegions = [&](List<PhysicsForceRegion> const& forces) {
+ for (auto const& force : forces) {
+ bool categoryCheck = force.call([myCategories = m_parameters.physicsEffectCategories.value()](auto& fr) {
+ return fr.categoryFilter.check(myCategories);
+ });
+ if (!categoryCheck)
+ continue;
+
+ bool boundsCheck = force.call([geometry, myBounds = collisionBoundBox()](auto& fr) {
+ return geometry.rectIntersectsRect(myBounds, fr.boundBox());
+ });
+ if (!boundsCheck)
+ continue;
+
+ m_appliedForceRegion = true;
+ if (auto directionalForceRegion = force.ptr<DirectionalForceRegion>()) {
+ float forceEffect = geometry.polyOverlapArea(directionalForceRegion->region, body) / body.convexArea();
+ if (directionalForceRegion->xTargetVelocity)
+ approachXVelocity(*directionalForceRegion->xTargetVelocity, directionalForceRegion->controlForce * forceEffect);
+ if (directionalForceRegion->yTargetVelocity)
+ approachYVelocity(*directionalForceRegion->yTargetVelocity, directionalForceRegion->controlForce * forceEffect);
+
+ } else if (auto radialForceRegion = force.ptr<RadialForceRegion>()) {
+ Vec2F direction = geometry.diff(pos, radialForceRegion->center);
+ float distance = vmag(direction);
+ if (distance > 0 && distance < radialForceRegion->outerRadius) {
+ float incidence = min(1.0f - (distance - radialForceRegion->innerRadius) / (radialForceRegion->outerRadius - radialForceRegion->innerRadius), distance / radialForceRegion->innerRadius);
+ if (radialForceRegion->targetRadialVelocity < 0)
+ direction = -direction;
+ approachVelocityAlongAngle(direction.angle(),
+ abs(radialForceRegion->targetRadialVelocity),
+ radialForceRegion->controlForce * incidence,
+ true);
+ }
+ } else if (auto gradientForceRegion = force.ptr<GradientForceRegion>()) {
+ float overlapFactor = geometry.polyOverlapArea(gradientForceRegion->region, body) / body.convexArea();
+
+ Vec2F gNorm = gradientForceRegion->gradient.direction();
+ Vec2F pDiff = geometry.diff(pos, gradientForceRegion->gradient.min());
+ float projected = pDiff[0] * gNorm[0] + pDiff[1] * gNorm[1];
+ float gradientFactor = 1.0 - clamp(projected / gradientForceRegion->gradient.length(), -1.0f, 1.0f);
+
+ approachVelocityAlongAngle(gradientForceRegion->gradient.angle(),
+ gradientForceRegion->baseTargetVelocity * overlapFactor * gradientFactor,
+ gradientForceRegion->baseControlForce * overlapFactor * gradientFactor,
+ true);
+ }
+ }
+ };
+
+ for (auto& physicsEntity : world()->query<PhysicsEntity>(boundBox)) {
+ if (m_ignorePhysicsEntities.contains(physicsEntity->entityId()))
+ continue;
+
+ handleForceRegions(physicsEntity->forceRegions());
+ }
+
+ handleForceRegions(world()->forceRegions());
+}
+
+void MovementController::updateLiquidPercentage() {
+ auto geometry = world()->geometry();
+ auto pos = position();
+ auto body = collisionBody();
+ RectF boundBox = body.boundBox();
+
+ LiquidLevel cll;
+ if (boundBox.isEmpty())
+ cll = world()->liquidLevel(Vec2I::floor(pos));
+ else
+ cll = world()->liquidLevel(boundBox);
+
+ m_liquidPercentage = clamp(cll.level, 0.0f, 1.0f);
+ m_liquidId = cll.liquid;
+}
+
+void MovementController::setOnGround(bool onGround) {
+ m_onGround.set(onGround);
+}
+
+bool MovementController::appliedForceRegion() const {
+ return m_appliedForceRegion;
+}
+
+Vec2F MovementController::collisionCorrection() const {
+ return m_collisionCorrection;
+}
+
+Vec2F MovementController::surfaceSlope() const {
+ return m_surfaceSlope;
+}
+
+Vec2F MovementController::surfaceVelocity() const {
+ return m_surfaceVelocity;
+}
+
+World* MovementController::world() {
+ if (!m_world)
+ throw MovementControllerException("MovementController not initialized!");
+ return m_world;
+}
+
+CollisionKind MovementController::maxOrNullCollision(CollisionKind a, CollisionKind b) {
+ if (a == CollisionKind::Null || b == CollisionKind::Null)
+ return CollisionKind::Null;
+ else
+ return max(a, b);
+}
+
+MovementController::CollisionResult MovementController::collisionMove(List<CollisionPoly>& collisionPolys, PolyF const& body, Vec2F const& movement,
+ bool ignorePlatforms, bool enableSurfaceSlopeCorrection, float maximumCorrection, float maximumPlatformCorrection, Vec2F sortCenter) {
+ unsigned const MaximumSeparationLoops = 3;
+ float const SlideAngle = Constants::pi / 3;
+ float const SlideCorrectionLimit = 0.2f;
+ float const SeparationTolerance = 0.001f;
+
+ if (body.isNull())
+ return {movement, Vec2F(), {}, false, false, Vec2F(1, 0), CollisionKind::None};
+
+ PolyF translatedBody = body;
+ translatedBody.translate(movement);
+ PolyF checkBody = translatedBody;
+ Vec2F totalCorrection = Vec2F();
+ CollisionKind maxCollided = CollisionKind::None;
+ Maybe<MovingCollisionId> surfaceMovingCollisionId;
+
+ CollisionSeparation separation = {};
+
+ if (enableSurfaceSlopeCorrection) {
+ // First try separating with our ground sliding cheat.
+ separation = collisionSeparate(collisionPolys, checkBody, ignorePlatforms, maximumPlatformCorrection, sortCenter, true, SeparationTolerance);
+ totalCorrection += separation.correction;
+ checkBody.translate(separation.correction);
+ maxCollided = maxOrNullCollision(maxCollided, separation.collisionKind);
+ surfaceMovingCollisionId = separation.movingCollisionId;
+ Vec2F upwardResult = movement + separation.correction;
+ float upMag = upwardResult.magnitude();
+ // Angle off of horizontal (minimum of either direction)
+ float angleHoriz = std::min(Vec2F(1, 0).angleBetweenNormalized(upwardResult / upMag),
+ Vec2F(-1, 0).angleBetweenNormalized(upwardResult / upMag));
+
+ // We need to make sure that even if we found a solution with the sliding
+ // cheat, we are not beyond the angle and correction limits for the ground
+ // cheat correction.
+ if (separation.solutionFound)
+ separation.solutionFound = upMag < SlideCorrectionLimit || angleHoriz < SlideAngle;
+
+ if (separation.solutionFound) {
+ if (totalCorrection.magnitude() > maximumCorrection)
+ separation.solutionFound = false;
+ }
+ }
+
+ if (!separation.solutionFound) {
+ checkBody = translatedBody;
+ totalCorrection = Vec2F();
+ for (size_t i = 0; i < MaximumSeparationLoops; ++i) {
+ separation = collisionSeparate(collisionPolys, checkBody, ignorePlatforms,
+ maximumPlatformCorrection, sortCenter, false, SeparationTolerance);
+ totalCorrection += separation.correction;
+ checkBody.translate(separation.correction);
+ maxCollided = maxOrNullCollision(maxCollided, separation.collisionKind);
+ surfaceMovingCollisionId = {};
+
+ if (totalCorrection.magnitude() > maximumCorrection) {
+ separation.solutionFound = false;
+ break;
+ }
+
+ if (separation.solutionFound)
+ break;
+ }
+ }
+
+ if (!separation.solutionFound && movement != Vec2F()) {
+ // No collision solution found! Move checkBody back to original body before
+ // applying movement and try one last time to correct.
+ checkBody = body;
+ totalCorrection = -movement;
+ for (size_t i = 0; i < MaximumSeparationLoops; ++i) {
+ separation = collisionSeparate(collisionPolys, checkBody, true, maximumPlatformCorrection, sortCenter, false, SeparationTolerance);
+ totalCorrection += separation.correction;
+ checkBody.translate(separation.correction);
+ maxCollided = maxOrNullCollision(maxCollided, separation.collisionKind);
+
+ if (totalCorrection.magnitude() > maximumCorrection) {
+ separation.solutionFound = false;
+ break;
+ }
+
+ if (separation.solutionFound)
+ break;
+ }
+ }
+
+ if (separation.solutionFound) {
+ CollisionResult result;
+ result.movement = movement + totalCorrection;
+ result.correction = totalCorrection;
+ result.isStuck = false;
+ result.onGround = result.correction[1] > SeparationTolerance;
+ result.surfaceMovingCollisionId = surfaceMovingCollisionId;
+ result.collisionKind = maxCollided;
+ result.groundSlope = Vec2F(1, 0);
+ if (result.onGround) {
+ // If we are on the ground and need to find the ground slope, look for a
+ // vertex on the body being moved that is touching an edge of one of the
+ // collision polys. We only want a slope to be produced from an edge of
+ // colision geometry, not an edge of the colliding body. Pick the
+ // touching edge that is the most horizontally overlapped with the
+ // geometry, rather than off to the side.
+ float maxSideHorizontalOverlap = 0.0f;
+ RectF touchingBounds = checkBody.boundBox();
+ touchingBounds.pad(SeparationTolerance);
+ for (auto const& cp : collisionPolys) {
+ if (!cp.polyBounds.intersects(touchingBounds))
+ continue;
+
+ for (size_t i = 0; i < cp.poly.sides(); ++i) {
+ auto side = cp.poly.side(i);
+ RectF sideBounds = RectF::boundBoxOf(side.min(), side.max());
+ float thisSideHorizontalOverlap = sideBounds.overlap(touchingBounds).width();
+
+ if (thisSideHorizontalOverlap > maxSideHorizontalOverlap) {
+ for (auto const& bodyVertex : checkBody) {
+ float t = clamp(side.lineProjection(bodyVertex), 0.0f, 1.0f);
+ Vec2F nearPoint = side.eval(t);
+ if (nearPoint[1] > cp.sortPosition[1]) {
+ if (vmagSquared(bodyVertex - nearPoint) <= square(SeparationTolerance)) {
+ maxSideHorizontalOverlap = thisSideHorizontalOverlap;
+ result.groundSlope = side.diff().normalized();
+ if (result.groundSlope[0] < 0)
+ result.groundSlope *= -1;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return result;
+ } else {
+ return {Vec2F(), -movement, {}, true, true, Vec2F(1, 0), maxCollided};
+ }
+}
+
+MovementController::CollisionSeparation MovementController::collisionSeparate(List<CollisionPoly>& collisionPolys, PolyF const& poly,
+ bool ignorePlatforms, float maximumPlatformCorrection, Vec2F const& sortCenter, bool upward, float separationTolerance) {
+ CollisionSeparation separation = {};
+ separation.collisionKind = CollisionKind::None;
+ bool intersects = false;
+
+ for (auto& cp : collisionPolys)
+ cp.sortDistance = vmagSquared(cp.sortPosition - sortCenter);
+
+ sort(collisionPolys, [](auto const& a, auto const& b) {
+ return a.sortDistance < b.sortDistance;
+ });
+
+ PolyF::IntersectResult intersectResult;
+ PolyF correctedPoly = poly;
+ RectF correctedBoundBox = correctedPoly.boundBox();
+ for (auto const& cp : collisionPolys) {
+ if ((ignorePlatforms && cp.collisionKind == CollisionKind::Platform) || !correctedBoundBox.intersects(cp.polyBounds, false))
+ continue;
+
+ if (upward)
+ intersectResult = correctedPoly.directionalSatIntersection(cp.poly, Vec2F(0, 1), false);
+ else if (cp.collisionKind == CollisionKind::Platform)
+ intersectResult = correctedPoly.directionalSatIntersection(cp.poly, Vec2F(0, 1), true);
+ else
+ intersectResult = correctedPoly.satIntersection(cp.poly);
+
+ if (cp.collisionKind == CollisionKind::Platform && intersectResult.intersects) {
+ if (intersectResult.overlap[1] <= 0 || intersectResult.overlap[1] > maximumPlatformCorrection)
+ intersectResult.intersects = false;
+ }
+
+ if (intersectResult.intersects) {
+ intersects = true;
+ correctedPoly.translate(intersectResult.overlap);
+ correctedBoundBox = correctedPoly.boundBox();
+ separation.correction += intersectResult.overlap;
+ if (cp.movingCollisionId)
+ separation.movingCollisionId = cp.movingCollisionId;
+ separation.collisionKind = maxOrNullCollision(separation.collisionKind, cp.collisionKind);
+ }
+ }
+
+ separation.solutionFound = true;
+ float separationToleranceSquared = square(separationTolerance);
+ if (intersects) {
+ for (auto const& cp : collisionPolys) {
+ if (cp.collisionKind == CollisionKind::Platform || !correctedBoundBox.intersects(cp.polyBounds, false))
+ continue;
+
+ intersectResult = correctedPoly.satIntersection(cp.poly);
+ if (intersectResult.intersects && intersectResult.overlap.magnitudeSquared() > separationToleranceSquared) {
+ separation.collisionKind = maxOrNullCollision(separation.collisionKind, cp.collisionKind);
+ separation.solutionFound = false;
+ break;
+ }
+ }
+ }
+
+ return separation;
+}
+
+void MovementController::updateParameters(MovementParameters parameters) {
+ m_parameters = move(parameters);
+ m_collisionPoly.set(*m_parameters.collisionPoly);
+ m_mass.set(*m_parameters.mass);
+ updatePositionInterpolators();
+}
+
+void MovementController::updatePositionInterpolators() {
+ if (m_world)
+ m_xPosition.setInterpolator(m_world->geometry().xLerpFunction(m_parameters.discontinuityThreshold));
+ else
+ m_xPosition.setInterpolator(bind(lerpWithLimit<float, float>, m_parameters.discontinuityThreshold, _1, _2, _3));
+ m_yPosition.setInterpolator(bind(lerpWithLimit<float, float>, m_parameters.discontinuityThreshold, _1, _2, _3));
+}
+
+void MovementController::queryCollisions(RectF const& region) {
+ while (!m_workingCollisions.empty()) {
+ m_collisionBuffers.append(m_workingCollisions.takeLast().poly);
+ }
+
+ auto newCollisionPoly = [this]() -> CollisionPoly& {
+ if (!m_collisionBuffers.empty())
+ return m_workingCollisions.emplaceAppend(CollisionPoly{
+ m_collisionBuffers.takeLast(), {}, {}, {}, {}, {}
+ });
+ else
+ return m_workingCollisions.emplaceAppend(CollisionPoly{});
+ };
+
+ auto geometry = world()->geometry();
+
+ world()->forEachCollisionBlock(RectI::integral(region.padded(1)), [&](CollisionBlock const& block) {
+ if (block.kind != CollisionKind::None && !block.poly.isNull()) {
+ RectF polyBounds = block.polyBounds;
+ Vec2F basePosition = block.poly.vertex(0);
+ Vec2F nearTranslation = geometry.nearestTo(region.min(), basePosition) - basePosition;
+ polyBounds.translate(nearTranslation);
+
+ if (region.intersects(polyBounds)) {
+ CollisionPoly& collisionPoly = newCollisionPoly();
+ collisionPoly.poly = block.poly;
+ collisionPoly.poly.translate(nearTranslation);
+ collisionPoly.polyBounds = polyBounds;
+ collisionPoly.sortPosition = centerOfTile(block.space);
+ collisionPoly.movingCollisionId = {};
+ collisionPoly.collisionKind = block.kind;
+ }
+ }
+ });
+
+ forEachMovingCollision(region, [&](MovingCollisionId id, PhysicsMovingCollision mc, PolyF poly, RectF bounds) {
+ CollisionPoly& collisionPoly = newCollisionPoly();
+ collisionPoly.poly = move(poly);
+ collisionPoly.polyBounds = bounds;
+ collisionPoly.sortPosition = collisionPoly.poly.center();
+ collisionPoly.movingCollisionId = id;
+ collisionPoly.collisionKind = mc.collisionKind;
+ return true;
+ });
+}
+
+float MovementController::gravity() {
+ float buoyancy = *m_parameters.liquidBuoyancy * m_liquidPercentage + *m_parameters.airBuoyancy * (1.0f - liquidPercentage());
+ return world()->gravity(position()) * *m_parameters.gravityMultiplier * (1.0f - buoyancy);
+}
+
+} \ No newline at end of file