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.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.entity.components.TransformComponent;
import com.almasb.fxgl.entity.components.TypeComponent;
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.ChainShape;
import com.almasb.fxgl.physics.box2d.collision.shapes.CircleShape;
import com.almasb.fxgl.physics.box2d.collision.shapes.PolygonShape;
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.sslogger.Logger;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Dimension2D;
import javafx.geometry.Point2D;

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

/**
 * Manages physics entities, collision handling and performs the physics tick.
 * 

* Contains several static and instance methods * to convert pixels coordinates to meters and vice versa. *

* Collision handling unifies how they are processed. * * @author Almas Baimagambetov (AlmasB) ([email protected]) */ public final class PhysicsWorld implements EntityWorldListener, ContactListener { 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 Array collisionHandlers = new UnorderedArray<>(16); private Array collisions = new UnorderedArray<>(128); private int appHeight; /** * 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(c -> c.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; Object type1 = e1.getComponent(TypeComponent.class).getValue(); Object type2 = e2.getComponent(TypeComponent.class).getValue(); for (CollisionHandler handler : collisionHandlers) { if (handler.equal(type1, type2)) { return handler; } } return null; } private CollisionPair getPair(Entity e1, Entity e2) { int index = getPairIndex(e1, e2); return index == -1 ? null : collisions.get(index); } private int getPairIndex(Entity e1, Entity e2) { for (int i = 0; i < collisions.size(); i++) { CollisionPair pair = collisions.get(i); if (pair.equal(e1, e2)) { return i; } } return -1; } public PhysicsWorld(int appHeight, double ppm) { this.appHeight = appHeight; 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); } 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(0.4f); jboxWorld.setParticleDensity(1.2f); jboxWorld.setParticleRadius(toMetersF(1)); // 0.5 for super realistic effect, but slow } private Array delayedBodiesAdd = new UnorderedArray<>(); private Array delayedParticlesAdd = new UnorderedArray<>(); private Array delayedBodiesRemove = new UnorderedArray<>(); @Override public void onEntityAdded(Entity entity) { entities.add(entity); if (entity.hasComponent(PhysicsComponent.class)) { onPhysicsEntityAdded(entity); } // else if (entity.hasComponent(PhysicsParticleComponent.class)) { // onPhysicsParticleEntityAdded(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); } }; // TODO: clean listeners on remove entity.getTransformComponent().scaleXProperty().addListener(scaleChangeListener); entity.getTransformComponent().scaleYProperty().addListener(scaleChangeListener); } @SuppressWarnings("PMD.UnusedPrivateMethod") private void onPhysicsParticleEntityAdded(Entity entity) { if (!jboxWorld.isLocked()) { createPhysicsParticles(entity); } else { delayedParticlesAdd.add(entity); } } @Override public void onEntityRemoved(Entity entity) { entities.removeValueByIdentity(entity); if (entity.hasComponent(PhysicsComponent.class)) { onPhysicsEntityRemoved(entity); } } private void onPhysicsEntityRemoved(Entity 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 (Entity e : delayedParticlesAdd) createPhysicsParticles(e); delayedParticlesAdd.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(); collisions.clear(); } public void clearCollisionHandlers() { collisionHandlers.clear(); } @Override public void beginContact(Contact contact) { Entity e1 = contact.getFixtureA().getBody().getEntity(); Entity e2 = contact.getFixtureB().getBody().getEntity(); // TODO: we do not have sensor collision(), ony begin() and end() // 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) { CollisionPair pair = getPair(e1, e2); // no collision registered, so add the pair if (pair == null) { pair = Pools.obtain(CollisionPair.class); pair.init(e1, e2, handler); // add pair to list of collisions so we still use it collisions.add(pair); HitBox boxA = contact.getFixtureA().getHitBox(); HitBox boxB = contact.getFixtureB().getHitBox(); handler.onHitBoxTrigger(pair.getA(), pair.getB(), e1 == pair.getA() ? boxA : boxB, e2 == pair.getB() ? boxB : boxA); pair.collisionBegin(); } } } @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) { int pairIndex = getPairIndex(e1, e2); // collision registered, so remove it and put pair back to pool if (pairIndex != -1) { CollisionPair pair = collisions.get(pairIndex); collisions.removeIndex(pairIndex); pair.collisionEnd(); Pools.free(pair); } } } 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); /** * Perform collision detection for all entities that have * setCollidable(true) and if at least one entity is not PhysicsEntity. * Subsequently fire collision handlers for all entities that have * setCollidable(true). */ private void checkCollisions() { for (Entity e : entities) { if (isCollidable(e)) { collidables.add(e); } } for (int i = 0; i < collidables.size(); i++) { Entity e1 = collidables.get(i); for (int j = i + 1; j < collidables.size(); j++) { Entity e2 = collidables.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 CollisionResult result = e1.getBoundingBoxComponent().checkCollision(e2.getBoundingBoxComponent()); if (result.hasCollided()) { collisionBeginFor(handler, e1, e2, result.getBoxA(), result.getBoxB()); // put result back to pool only if collided Pools.free(result); } else { collisionEndFor(e1, e2); } } } collidables.clear(); } 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 = getPair(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 collisions.add(pair); handler.onHitBoxTrigger(pair.getA(), pair.getB(), a, b); pair.collisionBegin(); } } private void collisionEndFor(Entity e1, Entity e2) { int pairIndex = getPairIndex(e1, e2); // if not -1, then collision registered, so end the collision // and remove it and put pair back to pool // if -1 then collision was not present before either if (pairIndex != -1) { CollisionPair pair = collisions.get(pairIndex); collisions.removeIndex(pairIndex); pair.collisionEnd(); Pools.free(pair); } } /** * Fires all collision handlers' collision() callback based on currently registered collisions. */ private void notifyCollisions() { for (Iterator it = collisions.iterator(); it.hasNext(); ) { CollisionPair pair = it.next(); // if a pair no longer qualifies for collision then just remove it if (!pair.getA().isActive() || !pair.getB().isActive() || !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.add(handler); } /** * Removes a collision handler * * @param handler collision handler to remove */ public void removeCollisionHandler(CollisionHandler handler) { collisionHandlers.removeValueByIdentity(handler); } /** * 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); // TODO: same fixture def for every fixture? 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) { // take world center bounds and subtract from entity center (all in pixels) to get local center // because box2d operates on vector offsets from the body center, also in local coordinates Point2D boundsCenterWorld = new Point2D((box.getMinXWorld() + box.getMaxXWorld()) / 2, (box.getMinYWorld() + box.getMaxYWorld()) / 2); Point2D boundsCenterLocal = boundsCenterWorld.subtract(e.getCenter()); double w = box.getMaxXWorld() - box.getMinXWorld(); double h = box.getMaxYWorld() - box.getMinYWorld(); BoundingShape boundingShape = box.getShape(); switch (boundingShape.type) { case CIRCLE: return circle(w, boundsCenterLocal); case POLYGON: // TODO: clean up if (boundingShape.data instanceof Dimension2D) { return polygonAsBox(w, h, boundsCenterLocal); } else { return polygon((Point2D[]) boundingShape.data, boundsCenterLocal, e.getBoundingBoxComponent().getCenterLocal(), e.getTransformComponent(), box, e.getBoundingBoxComponent()); } case CHAIN: if (e.getComponent(PhysicsComponent.class).body.getType() != BodyType.STATIC) { throw new IllegalArgumentException("BoundingShape.chain() can only be used with BodyType.STATIC"); } return chain((Point2D[]) boundingShape.data, boundsCenterLocal, e.getBoundingBoxComponent().getCenterLocal()); case EDGE: default: log.warning("Unsupported hit box shape"); throw new UnsupportedOperationException("Using unsupported shape: " + boundingShape.type); } } /** * @param w circle diameter * @param boundsCenterLocal center of bounds in local coordinates * @return circle shape */ private Shape circle(double w, Point2D boundsCenterLocal) { CircleShape shape = new CircleShape(); shape.setRadius(toMetersF(w / 2)); shape.center.set(toVector(boundsCenterLocal)); return shape; } private Shape polygonAsBox(double w, double h, Point2D boundsCenterLocal) { PolygonShape shape = new PolygonShape(); shape.setAsBox(toMetersF(w / 2), toMetersF(h / 2), toVector(boundsCenterLocal), 0); return shape; } private Shape polygon(Point2D[] points, Point2D boundsCenterLocal, Point2D bboxCenterLocal, TransformComponent t, HitBox box, BoundingBoxComponent bboxComp) { Vec2[] vertices = new Vec2[points.length]; var bboxCenterLocalNew = new Point2D( bboxCenterLocal.getX() * t.getScaleX() + (1 - t.getScaleX()) * t.getScaleOrigin().getX(), bboxCenterLocal.getY() * t.getScaleY() + (1 - t.getScaleY()) * t.getScaleOrigin().getY() ); var boundsCenterLocalNew = new Point2D( boundsCenterLocal.getX() * t.getScaleX() + (1 - t.getScaleX()) * t.getScaleOrigin().getX(), boundsCenterLocal.getY() * t.getScaleY() + (1 - t.getScaleY()) * t.getScaleOrigin().getY() ); for (int i = 0; i < vertices.length; i++) { var p = new Point2D( (points[i].getX() + box.getMinX()) * t.getScaleX() + (1 - t.getScaleX()) * t.getScaleOrigin().getX(), (points[i].getY() + box.getMinY()) * t.getScaleY() + (1 - t.getScaleY()) * t.getScaleOrigin().getY() ); vertices[i] = toVector(p.subtract(boundsCenterLocalNew)) .subLocal(toVector(bboxCenterLocalNew)) .addLocal(toVector(boundsCenterLocalNew)) .subLocal(toMeters(bboxComp.getMinXLocal()), -toMeters(bboxComp.getMinYLocal())); } PolygonShape shape = new PolygonShape(); shape.set(vertices); return shape; } private Shape chain(Point2D[] points, Point2D boundsCenterLocal, Point2D bboxCenterLocal) { Vec2[] vertices = new Vec2[points.length]; for (int i = 0; i < vertices.length; i++) { vertices[i] = toVector(points[i].subtract(boundsCenterLocal)).subLocal(toVector(bboxCenterLocal)); } ChainShape shape = new ChainShape(); shape.createLoop(vertices, vertices.length); return shape; } @SuppressWarnings("PMD.UnusedFormalParameter") private void createPhysicsParticles(Entity e) { // double x = e.getX(); // double y = e.getY(); // double width = e.getWidth(); // double height = e.getHeight(); // // ParticleGroupDef def = e.getComponent(PhysicsParticleComponent.class).getDefinition(); // def.setPosition(toMetersF(x + width / 2), toMetersF(appHeight - (y + height / 2))); // // Shape shape = null; // // BoundingBoxComponent bbox = e.getBoundingBoxComponent(); // // if (!bbox.hitBoxesProperty().isEmpty()) { // if (bbox.hitBoxesProperty().get(0).getShape().type == ShapeType.POLYGON) { // PolygonShape rectShape = new PolygonShape(); // rectShape.setAsBox(toMetersF(width / 2), toMetersF(height / 2)); // shape = rectShape; // } else if (bbox.hitBoxesProperty().get(0).getShape().type == ShapeType.CIRCLE) { // CircleShape circleShape = new CircleShape(); // circleShape.setRadius(toMetersF(width / 2)); // shape = circleShape; // } else { // log.warning("Unknown hit box shape: " + bbox.hitBoxesProperty().get(0).getShape().type); // throw new UnsupportedOperationException(); // } // } // // if (shape == null) { // PolygonShape rectShape = new PolygonShape(); // rectShape.setAsBox(toMetersF(width / 2), toMetersF(height / 2)); // shape = rectShape; // } // def.setShape(shape); // // e.getComponent(PhysicsParticleComponent.class).setGroup(jboxWorld.createParticleGroup(def)); } /** * 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); } /** * Converts pixels to meters * * @param pixels value in pixels * @return value in meters */ public float toMetersF(double pixels) { return (float) toMeters(pixels); } public double toMeters(double pixels) { return pixels * METERS_PER_PIXELS; } /** * Converts meters to pixels * * @param meters value in meters * @return value in pixels */ public float toPixelsF(double meters) { return (float) toPixels(meters); } public double toPixels(double meters) { return meters * PIXELS_PER_METER; } /** * Converts a vector of type Point2D to vector of type Vec2 * * @param v vector in pixels * @return vector in meters */ public Vec2 toVector(Point2D v) { return new Vec2(toMetersF(v.getX()), toMetersF(-v.getY())); } /** * Converts a vector of type Vec2 to vector of type Point2D * * @param v vector in meters * @return vector in pixels */ public Point2D toVector(Vec2 v) { return new Point2D(toPixels(v.x), toPixels(-v.y)); } /** * Converts a point in pixel space to a point in physics space. * * @param p point in pixel space * @return point in physics space */ 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 */ 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