All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.almasb.fxgl.physics.PhysicsWorld Maven / Gradle / Ivy

There is a newer version: 21.1
Show newest version
/*
 * FXGL - JavaFX Game Library. The MIT License (MIT).
 * Copyright (c) AlmasB ([email protected]).
 * See LICENSE for details.
 */
package com.almasb.fxgl.physics;

import com.almasb.fxgl.core.collection.Array;
import com.almasb.fxgl.core.collection.UnorderedArray;
import com.almasb.fxgl.core.collection.UnorderedPairMap;
import com.almasb.fxgl.core.math.Vec2;
import com.almasb.fxgl.core.pool.Pool;
import com.almasb.fxgl.core.pool.Pools;
import com.almasb.fxgl.entity.Entity;
import com.almasb.fxgl.entity.EntityWorldListener;
import com.almasb.fxgl.entity.components.BoundingBoxComponent;
import com.almasb.fxgl.entity.components.CollidableComponent;
import com.almasb.fxgl.logging.Logger;
import com.almasb.fxgl.physics.box2d.callbacks.ContactFilter;
import com.almasb.fxgl.physics.box2d.callbacks.ContactImpulse;
import com.almasb.fxgl.physics.box2d.callbacks.ContactListener;
import com.almasb.fxgl.physics.box2d.collision.Manifold;
import com.almasb.fxgl.physics.box2d.collision.shapes.Shape;
import com.almasb.fxgl.physics.box2d.dynamics.*;
import com.almasb.fxgl.physics.box2d.dynamics.contacts.Contact;
import com.almasb.fxgl.physics.box2d.dynamics.joints.*;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Point2D;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
 * Manages collision handling and performs the physics tick.
 * Contains methods to convert pixel coordinates to meters and vice versa.
 * Collision handling unifies how different collisions (with and without PhysicsComponent) are processed.
 *
 * @author Almas Baimagambetov (AlmasB) ([email protected])
 */
public final class PhysicsWorld implements EntityWorldListener, ContactListener, PhysicsUnitConverter {

    private static final Logger log = Logger.get(PhysicsWorld.class);

    private final double PIXELS_PER_METER;
    private final double METERS_PER_PIXELS;

    private World jboxWorld = new World(new Vec2(0, -10));

    private Array entities = new UnorderedArray<>(128);

    private UnorderedPairMap collisionHandlers = new UnorderedPairMap<>(16);

    // stores active collisions
    private UnorderedPairMap collisionsMap = new UnorderedPairMap<>(128);

    private CollisionDetectionStrategy strategy;

    private int appHeight;

    public PhysicsWorld(int appHeight, double ppm) {
        this(appHeight, ppm, CollisionDetectionStrategy.BRUTE_FORCE);
    }

    public PhysicsWorld(int appHeight, double ppm, CollisionDetectionStrategy strategy) {
        this.appHeight = appHeight;
        this.strategy = strategy;

        PIXELS_PER_METER = ppm;
        METERS_PER_PIXELS = 1 / PIXELS_PER_METER;

        initCollisionPool();
        initContactListener();
        initParticles();

        jboxWorld.setContactFilter(new CollisionFilterCallback());

        log.debugf("Physics world initialized: appHeight=%d, physics.ppm=%.1f",
                appHeight, ppm);
        log.debug("Using strategy: " + strategy);
    }

    private void initCollisionPool() {
        Pools.set(CollisionPair.class, new Pool() {
            @Override
            protected CollisionPair newObject() {
                return new CollisionPair();
            }
        });
    }

    /**
     * Registers contact listener to JBox2D world so that collisions are
     * registered for subsequent notification.
     * Only collidable entities are checked.
     */
    private void initContactListener() {
        jboxWorld.setContactListener(this);
    }

    private void initParticles() {
        jboxWorld.setParticleGravityScale(1f);
        jboxWorld.setParticleDensity(1.2f);
        jboxWorld.setParticleRadius(toMetersF(1));    // 0.5 for super realistic effect, but slow
    }

    private Array delayedBodiesAdd = new UnorderedArray<>();
    private Array delayedBodiesRemove = new UnorderedArray<>();

    private Map > scaleListeners = new HashMap<>();

    @Override
    public void onEntityAdded(Entity entity) {
        entities.add(entity);

        if (entity.hasComponent(PhysicsComponent.class)) {
            onPhysicsEntityAdded(entity);
        }
    }

    private void onPhysicsEntityAdded(Entity entity) {
        if (!jboxWorld.isLocked()) {
            createBody(entity);
        } else {
            delayedBodiesAdd.add(entity);
        }

        ChangeListener scaleChangeListener = (observable, oldValue, newValue) -> {
            Body b = entity.getComponent(PhysicsComponent.class).body;

            if (b != null) {
                List fixtures = List.copyOf(b.getFixtures());

                fixtures.forEach(b::destroyFixture);

                createFixtures(entity);
                createSensors(entity);
            }
        };

        scaleListeners.put(entity, scaleChangeListener);

        entity.getTransformComponent().scaleXProperty().addListener(scaleChangeListener);
        entity.getTransformComponent().scaleYProperty().addListener(scaleChangeListener);
    }

    @Override
    public void onEntityRemoved(Entity entity) {
        entities.removeValueByIdentity(entity);

        if (entity.hasComponent(PhysicsComponent.class)) {
            onPhysicsEntityRemoved(entity);
        }
    }

    private void onPhysicsEntityRemoved(Entity entity) {
        if (scaleListeners.containsKey(entity)) {
            ChangeListener scaleChangeListener = scaleListeners.get(entity);

            entity.getTransformComponent().scaleXProperty().removeListener(scaleChangeListener);
            entity.getTransformComponent().scaleYProperty().removeListener(scaleChangeListener);

            scaleListeners.remove(entity);
        }

        if (!jboxWorld.isLocked()) {
            destroyBody(entity);
        } else {
            delayedBodiesRemove.add(entity.getComponent(PhysicsComponent.class).getBody());
        }
    }

    public void onUpdate(double tpf) {
        jboxWorld.step((float) tpf, 8, 3);
        postStep();

        checkCollisions();
        notifyCollisions();
    }

    private void postStep() {
        for (Entity e : delayedBodiesAdd)
            createBody(e);

        delayedBodiesAdd.clear();

        for (Body body : delayedBodiesRemove)
            jboxWorld.destroyBody(body);

        delayedBodiesRemove.clear();
    }

    /**
     * Clears collidable entities and active collisions.
     * Does not clear collision handlers.
     */
    public void clear() {
        log.debug("Clearing physics world");

        entities.clear();
        collisionsMap.clear();
    }

    public void clearCollisionHandlers() {
        collisionHandlers.clear();
    }

    @Override
    public void beginContact(Contact contact) {
        Entity e1 = contact.getFixtureA().getBody().getEntity();
        Entity e2 = contact.getFixtureB().getBody().getEntity();

        // check sensors first

        if (contact.getFixtureA().isSensor()) {
            notifySensorCollisionBegin(e1, e2, contact.getFixtureA().getHitBox());

            return;
        } else if (contact.getFixtureB().isSensor()) {
            notifySensorCollisionBegin(e2, e1, contact.getFixtureB().getHitBox());

            return;
        }

        if (!areCollidable(e1, e2))
            return;

        CollisionHandler handler = getHandler(e1, e2);
        if (handler != null) {
            HitBox a = contact.getFixtureA().getHitBox();
            HitBox b = contact.getFixtureB().getHitBox();

            collisionBeginFor(handler, e1, e2, a, b);
        }
    }

    @Override
    public void endContact(Contact contact) {
        Entity e1 = contact.getFixtureA().getBody().getEntity();
        Entity e2 = contact.getFixtureB().getBody().getEntity();

        // check sensors first

        if (contact.getFixtureA().isSensor()) {
            notifySensorCollisionEnd(e1, e2, contact.getFixtureA().getHitBox());

            return;
        } else if (contact.getFixtureB().isSensor()) {
            notifySensorCollisionEnd(e2, e1, contact.getFixtureB().getHitBox());

            return;
        }

        if (!areCollidable(e1, e2))
            return;

        CollisionHandler handler = getHandler(e1, e2);
        if (handler != null) {
            collisionEndFor(e1, e2);
        }
    }

    /**
     * Note: certain modifications to the jbox2d world directly may not be
     * recognized by FXGL.
     *
     * @return raw jbox2d physics world
     */
    public World getJBox2DWorld() {
        return jboxWorld;
    }

    private boolean isCollidable(Entity e) {
        if (!e.isActive())
            return false;

        return e.getComponentOptional(CollidableComponent.class)
                .map(CollidableComponent::getValue)
                .orElse(false);
    }

    private boolean areCollidable(Entity e1, Entity e2) {
        return isCollidable(e1) && isCollidable(e2);
    }

    @SuppressWarnings("PMD.UselessParentheses")
    private boolean needManualCheck(Entity e1, Entity e2) {
        // if no physics -> check manually

        BodyType type1 = e1.getComponentOptional(PhysicsComponent.class)
                .map(p -> p.body.getType())
                .orElse(null);

        if (type1 == null)
            return true;

        BodyType type2 = e2.getComponentOptional(PhysicsComponent.class)
                .map(p -> p.body.getType())
                .orElse(null);

        if (type2 == null)
            return true;

        // if one is kinematic and the other is static -> check manually
        return (type1 == BodyType.KINEMATIC && type2 == BodyType.STATIC)
                || (type2 == BodyType.KINEMATIC && type1 == BodyType.STATIC);
    }

    /**
     * @param e1 entity 1
     * @param e2 entity 2
     * @return collision handler for e1 and e2 based on their types or null if no such handler exists
     */
    private CollisionHandler getHandler(Entity e1, Entity e2) {
        if (!e1.isActive() || !e2.isActive())
            return null;

        return collisionHandlers.get(e1.getType(), e2.getType());
    }

    private void notifySensorCollisionBegin(Entity eWithSensor, Entity eTriggered, HitBox box) {
        var handler = eWithSensor.getComponent(PhysicsComponent.class).getSensorHandlers().get(box);
        handler.onCollisionBegin(eTriggered);
    }

    private void notifySensorCollisionEnd(Entity eWithSensor, Entity eTriggered, HitBox box) {
        var handler = eWithSensor.getComponent(PhysicsComponent.class).getSensorHandlers().get(box);
        handler.onCollisionEnd(eTriggered);
    }

    @Override
    public void preSolve(Contact contact, Manifold oldManifold) { }

    @Override
    public void postSolve(Contact contact, ContactImpulse impulse) { }

    private Array collidables = new UnorderedArray<>(128);
    private CollisionResult collisionResult = new CollisionResult();

    private CollisionGrid collisionGrid = new CollisionGrid(64, 64);

    /**
     * Perform collision detection for all entities that have
     * setCollidable(true) and if at least one entity does not have PhysicsComponent.
     * Subsequently fire collision handlers for all entities that have
     * setCollidable(true).
     */
    private void checkCollisions() {
        if (strategy == CollisionDetectionStrategy.GRID_INDEXING) {
            for (Entity e : entities) {
                if (isCollidable(e)) {
                    e.getBoundingBoxComponent().applyTransformToHitBoxes$fxgl_entity();
                    collisionGrid.insert(e);
                }
            }

            collisionGrid.getCells().forEach((p, cell) -> {
                checkCollisionsInGroup(cell.getEntities());
            });

            collisionGrid.getCells().clear();

        } else {
            for (Entity e : entities) {
                if (isCollidable(e)) {
                    e.getBoundingBoxComponent().applyTransformToHitBoxes$fxgl_entity();
                    collidables.add(e);
                }
            }

            checkCollisionsInGroup(collidables);

            collidables.clear();
        }
    }

    private void checkCollisionsInGroup(Array group) {
        for (int i = 0; i < group.size(); i++) {
            Entity e1 = group.get(i);

            for (int j = i + 1; j < group.size(); j++) {
                Entity e2 = group.get(j);

                CollisionHandler handler = getHandler(e1, e2);

                // if no handler registered, no need to check for this pair
                if (handler == null)
                    continue;

                // if no need for manual check, let jbox handle it
                if (!needManualCheck(e1, e2)) {
                    continue;
                }

                // check if e1 ignores e2, or e2 ignores e1
                if (isIgnored(e1, e2))
                    continue;

                // check if colliding
                var collision = e1.getBoundingBoxComponent().checkCollisionPAT(e2.getBoundingBoxComponent(), collisionResult);

                if (collision) {
                    collisionBeginFor(handler, e1, e2, collisionResult.getBoxA(), collisionResult.getBoxB());
                } else {
                    collisionEndFor(e1, e2);
                }
            }
        }
    }

    private boolean isIgnored(Entity e1, Entity e2) {
        if (!e1.hasComponent(CollidableComponent.class) || !e2.hasComponent(CollidableComponent.class))
            return false;

        CollidableComponent c1 = e1.getComponent(CollidableComponent.class);

        for (Serializable t1 : c1.getIgnoredTypes()) {
            if (e2.isType(t1)) {
                return true;
            }
        }

        CollidableComponent c2 = e2.getComponent(CollidableComponent.class);

        for (Serializable t2 : c2.getIgnoredTypes()) {
            if (e1.isType(t2)) {
                return true;
            }
        }

        return false;
    }

    private void collisionBeginFor(CollisionHandler handler, Entity e1, Entity e2, HitBox a, HitBox b) {
        CollisionPair pair = collisionsMap.get(e1, e2);

        // null means e1 and e2 were not colliding before
        // if not null, then ignore because e1 and e2 are still colliding
        if (pair == null) {
            pair = Pools.obtain(CollisionPair.class);
            pair.init(e1, e2, handler);

            // add pair to list of collisions so we still use it
            collisionsMap.put(pair.getA(), pair.getB(), pair);

            handler.onHitBoxTrigger(
                    pair.getA(), pair.getB(),
                    e1 == pair.getA() ? a : b,
                    e2 == pair.getB() ? b : a
            );
            pair.collisionBegin();
        }
    }

    private void collisionEndFor(Entity e1, Entity e2) {
        CollisionPair pair = collisionsMap.get(e1, e2);

        // if not null, then collision registered, so end the collision
        // and remove it and put pair back to pool
        // if null then collision was not present before either
        if (pair != null) {
            collisionsMap.remove(pair.getA(), pair.getB());

            pair.collisionEnd();
            Pools.free(pair);
        }
    }

    private void notifyCollisions() {
        for (Iterator it = collisionsMap.getValues().iterator(); it.hasNext(); ) {
            CollisionPair pair = it.next();

            // if a pair no longer qualifies for collision then just remove it
            if (!isCollidable(pair.getA()) || !isCollidable(pair.getB())) {

                // tell the pair that collision ended
                pair.collisionEnd();

                it.remove();
                Pools.free(pair);
                continue;
            }

            pair.collision();
        }
    }

    /**
     * Registers a collision handler.
     * The order in which the types are passed to this method
     * decides the order of objects being passed into the collision handler
     * 

*

     * Example:
     * PhysicsWorld physics = ...
     * physics.addCollisionHandler(new CollisionHandler(Type.PLAYER, Type.ENEMY) {
     *      public void onCollisionBegin(Entity a, Entity b) {
     *          // called when entities start touching
     *      }
     *      public void onCollision(Entity a, Entity b) {
     *          // called when entities are touching
     *      }
     *      public void onCollisionEnd(Entity a, Entity b) {
     *          // called when entities are separated and no longer touching
     *      }
     * });
     *
     * 
* * @param handler collision handler */ public void addCollisionHandler(CollisionHandler handler) { collisionHandlers.put(handler.getA(), handler.getB(), handler); } /** * Removes a collision handler * * @param handler collision handler to remove */ public void removeCollisionHandler(CollisionHandler handler) { collisionHandlers.remove(handler.getA(), handler.getB()); } /** * Set global world gravity. * * @param x x component (in pixels) * @param y y component (in pixels) */ public void setGravity(double x, double y) { jboxWorld.setGravity(toVector(new Point2D(x, y))); } /** * Create physics body and attach to physics world. * * @param e physics entity */ private void createBody(Entity e) { PhysicsComponent physics = e.getComponent(PhysicsComponent.class); physics.setWorld(this); // if position is 0, 0 then probably not set, so set ourselves if (physics.bodyDef.getPosition().x == 0 && physics.bodyDef.getPosition().y == 0) { physics.bodyDef.getPosition().set(toPoint(e.getCenter())); } if (physics.bodyDef.getAngle() == 0) { physics.bodyDef.setAngle((float) -Math.toRadians(e.getRotation())); } physics.body = jboxWorld.createBody(physics.bodyDef); createFixtures(e); createSensors(e); physics.body.setEntity(e); physics.onInitPhysics(); } private void createFixtures(Entity e) { BoundingBoxComponent bbox = e.getBoundingBoxComponent(); PhysicsComponent physics = e.getComponent(PhysicsComponent.class); FixtureDef fd = physics.fixtureDef; for (HitBox box : bbox.hitBoxesProperty()) { Shape b2Shape = createShape(box, e); // we use definitions from user, but override shape fd.setShape(b2Shape); Fixture fixture = physics.body.createFixture(fd); fixture.setHitBox(box); } } private void createSensors(Entity e) { PhysicsComponent physics = e.getComponent(PhysicsComponent.class); if (physics.getSensorHandlers().isEmpty()) return; physics.getSensorHandlers().keySet().forEach(box -> { box.bindXY(e.getTransformComponent()); Shape polygonShape = createShape(box, e); FixtureDef fd = new FixtureDef() .sensor(true) .shape(polygonShape); Fixture f = physics.body.createFixture(fd); f.setHitBox(box); }); } private Shape createShape(HitBox box, Entity e) { if (e.getComponent(PhysicsComponent.class).body.getType() != BodyType.STATIC && box.getShape() instanceof ChainShapeData) { throw new IllegalArgumentException("BoundingShape.chain() can only be used with BodyType.STATIC"); } return box.toBox2DShape(e.getBoundingBoxComponent(), this); } void destroyFixture(Body body, HitBox box) { body.getFixtures() .stream() .filter(f -> f.getHitBox() == box) .findAny() .ifPresent(body::destroyFixture); } /** * Destroy body and remove from physics world. * * @param e physics entity */ private void destroyBody(Entity e) { jboxWorld.destroyBody(e.getComponent(PhysicsComponent.class).body); } private EdgeCallback raycastCallback = new EdgeCallback(); /** * Performs a ray cast from start point to end point. * * @param start start point * @param end end point * @return ray cast result */ public RaycastResult raycast(Point2D start, Point2D end) { raycastCallback.reset(); jboxWorld.raycast(raycastCallback, toPoint(start), toPoint(end)); Entity entity = null; Point2D point = null; if (raycastCallback.getFixture() != null) entity = raycastCallback.getFixture().getBody().getEntity(); if (raycastCallback.getPoint() != null) point = toPoint(raycastCallback.getPoint()); if (entity == null && point == null) return RaycastResult.NONE; return new RaycastResult(entity, point); } /* JOINTS BEGIN */ /** * Add revolute joint between two entities. * * @param e1 entity1 * @param e2 entity2 * @param localAnchor1 point in entity1 local coordinate system to which entity2 is attached * @param localAnchor2 point in entity2 local coordinate system to which entity1 is attached */ public RevoluteJoint addRevoluteJoint(Entity e1, Entity e2, Point2D localAnchor1, Point2D localAnchor2) { checkJointRequirements(e1, e2); var p1 = e1.getComponent(PhysicsComponent.class); var p2 = e2.getComponent(PhysicsComponent.class); RevoluteJointDef def = new RevoluteJointDef(); def.localAnchorA = toPoint(e1.getAnchoredPosition().add(localAnchor1)).subLocal(p1.getBody().getWorldCenter()); def.localAnchorB = toPoint(e2.getAnchoredPosition().add(localAnchor2)).subLocal(p2.getBody().getWorldCenter()); return addJoint(e1, e2, def); } /** * Add rope joint between two entities. * The joint runs between the center of e1 to the center of e2, using this distance as max length. */ public RopeJoint addRopeJoint(Entity e1, Entity e2) { var c1 = e1.getBoundingBoxComponent().getCenterLocal(); var c2 = e2.getBoundingBoxComponent().getCenterLocal(); return addRopeJoint(e1, e2, c1, c2, e1.getCenter().distance(e2.getCenter())); } /** * Add rope joint between two entities. * * @param e1 entity1 * @param e2 entity2 * @param localAnchor1 point in entity1 local coordinate system to which entity2 is attached * @param localAnchor2 point in entity2 local coordinate system to which entity1 is attached * @param length the maximum length of the rope joint in pixels */ public RopeJoint addRopeJoint(Entity e1, Entity e2, Point2D localAnchor1, Point2D localAnchor2, double length) { checkJointRequirements(e1, e2); var p1 = e1.getComponent(PhysicsComponent.class); var p2 = e2.getComponent(PhysicsComponent.class); RopeJointDef def = new RopeJointDef(); def.localAnchorA.set(toPoint(e1.getAnchoredPosition().add(localAnchor1)).subLocal(p1.getBody().getWorldCenter())); def.localAnchorB.set(toPoint(e2.getAnchoredPosition().add(localAnchor2)).subLocal(p2.getBody().getWorldCenter())); def.maxLength = toMetersF(length); return addJoint(e1, e2, def); } /** * Add a joint constraining two entities with PhysicsComponent. * The entities must already be in the game world. * * @return joint created using the provided definition */ public T addJoint(Entity e1, Entity e2, JointDef def) { checkJointRequirements(e1, e2); var p1 = e1.getComponent(PhysicsComponent.class); var p2 = e2.getComponent(PhysicsComponent.class); def.setBodyA(p1.body); def.setBodyB(p2.body); return jboxWorld.createJoint(def); } private void checkJointRequirements(Entity e1, Entity e2) { if (!e1.hasComponent(PhysicsComponent.class) || !e2.hasComponent(PhysicsComponent.class)) { throw new IllegalArgumentException("Cannot create a joint: both entities must have PhysicsComponent"); } } /** * Remove given joint from the physics world. */ public void removeJoint(Joint joint) { jboxWorld.destroyJoint(joint); } /* JOINTS END */ @Override public double toMeters(double pixels) { return pixels * METERS_PER_PIXELS; } @Override public double toPixels(double meters) { return meters * PIXELS_PER_METER; } /** * Converts a point in pixel space to a point in physics space. * * @param p point in pixel space * @return point in physics space */ @Override public Vec2 toPoint(Point2D p) { return new Vec2(toMetersF(p.getX()), toMetersF(appHeight - p.getY())); } /** * Converts a point in physics space to a point in pixel space. * * @param p point in physics space * @return point in pixel space */ @Override public Point2D toPoint(Vec2 p) { return new Point2D(toPixels(p.x), toPixels(toMeters(appHeight) - p.y)); } private class CollisionFilterCallback extends ContactFilter { @Override public boolean shouldCollide(Fixture fixtureA, Fixture fixtureB) { Entity e1 = fixtureA.getBody().getEntity(); Entity e2 = fixtureB.getBody().getEntity(); if (areCollidable(e1, e2) && isIgnored(e1, e2)) return false; return super.shouldCollide(fixtureA, fixtureB); } } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy