import { ISystem, System, Components, Config } from '../engine/system'; import { IEntity, State, DoSet, IAction } from '../engine/state'; import { DoDamage } from '../system/stats'; require('ammo')(); const equals = require('deep-equal'); interface ITransformComponent { size?: number | number[]; rotation?: number[]; position?: number[]; quaternion?: number[]; } const defaults: IBodyData = { mass: 1, size: [1, 1, 1], type: 'box' }; export interface IBodyData { mass?: number; size?: number[]; velocity?: number[]; type?: 'box' | 'sphere' | 'cylinder'; pos?: number[]; } export interface IBodyComponent extends IBodyData { bodies?: IBodyData[]; } export interface IImpulseAction extends IAction { type: 'IMPULSE'; entityId: string; force: number[]; } export const DoImpulse = (entity: IEntity, force: number[]): IImpulseAction => { return { type: 'IMPULSE', entityId: entity.id, force }; }; export interface ITorqueImpulseAction extends IAction { type: 'TORQUE_IMPULSE'; entityId: string; force: number[]; } export const DoTorqueImpulse = (entity: IEntity, force: number[]): ITorqueImpulseAction => { return { type: 'TORQUE_IMPULSE', entityId: entity.id, force }; }; export interface IAttackAction extends IAction { type: 'ATTACK'; entityId: string; } export const DoAttack = (entity: IEntity): IAttackAction => { return { type: 'ATTACK', entityId: entity.id }; }; type OverlapCallbackType = (target: IEntity, ghostOwnerId: string) => void; const CallDoDamage = (target: IEntity, instigator: string) => DoDamage(target, 'health', 1); export interface IOverlapAction extends IAction { type: 'OVERLAP'; targetId: string; instigatorId: string; } const DoOverlap = (target: IEntity, instigator: IEntity): IOverlapAction => { return { type: 'OVERLAP', targetId: target.id, instigatorId: instigator.id, }; }; @Components('body') export class Physics extends System { private collisionObjects: { [key: string]: Ammo.btCollisionObject } = {}; private childCollisionObjects: { [key: string]: Ammo.btRigidBody[] } = {}; private dynamicsWorld: Ammo.btSoftRigidDynamicsWorld; private index: number = 0; private byIndex: { [key: number]: string } = {}; constructor() { super(); const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration( new Ammo.btDefaultCollisionConstructionInfo()); const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration); const overlappingPairCache = new Ammo.btDbvtBroadphase(); const solver = new Ammo.btSequentialImpulseConstraintSolver(); this.dynamicsWorld = new Ammo.btSoftRigidDynamicsWorld( dispatcher, overlappingPairCache, solver, collisionConfiguration, null); this.dynamicsWorld.getPairCache().setInternalGhostPairCallback(new Ammo.btGhostPairCallback()); } @Config('gravity') gravity() { const gravity = this.config.gravity || [0, -9.8, 0]; this.dynamicsWorld.setGravity(new Ammo.btVector3(gravity[0], gravity[1], gravity[2])); } makeBody(transform: Ammo.btTransform, mass: number, size: number[]): Ammo.btRigidBody { const localInertia = new Ammo.btVector3(0, 0, 0); const centerOfMassTransform = new Ammo.btTransform(); centerOfMassTransform.setIdentity(); const myMotionState = new Ammo.btDefaultMotionState(transform, centerOfMassTransform); const boxShape = new Ammo.btBoxShape(new Ammo.btVector3(0.5 * size[0], 0.5 * size[1], 0.5 * size[2])); if (mass !== 0) boxShape.calculateLocalInertia(mass, localInertia); const rbInfo = new Ammo.btRigidBodyConstructionInfo(mass, myMotionState, boxShape, localInertia); const body = new Ammo.btRigidBody(rbInfo); return body; } makeGhost(transform: any, size: number[]): Ammo.btGhostObject { const boxShape = new Ammo.btBoxShape(new Ammo.btVector3(0.5 * size[0], 0.5 * size[1], 0.5 * size[2])); const ghost = new Ammo.btGhostObject(); ghost.setCollisionShape(boxShape); ghost.setWorldTransform(transform); const CF_NO_CONTACT_RESPONSE = 4; ghost.setCollisionFlags(CF_NO_CONTACT_RESPONSE); return ghost; } makeTransform(pos: number[], rot: number[]): any { const transform = new Ammo.btTransform(); transform.setIdentity(); const vec = new Ammo.btVector3(pos[0], pos[1], pos[2]); transform.setOrigin(vec); const quat = new Ammo.btQuaternion(0, 0, 0, 1); quat.setEulerZYX(rot[2], rot[1], rot[0]); transform.setRotation(quat); return transform; } add(entity: IEntity) { super.add(entity); this.update(entity, 'body'); if(!entity.body.size) return; const pos = [].concat(entity.position || [0, 0, 0]); const rot = [].concat(entity.rotation || [0, 0, 0]).map(x => x * Math.PI / 180); const transform = this.makeTransform(pos, rot); const size = entity.body ? ([].concat(entity.body.size || [1, 1, 1])) : [1, 1, 1]; let collisionObject = null; if (!entity.body.ghost) { const mass = entity.body ? (entity.body.mass !== undefined ? entity.body.mass : 1) : 1; const body = collisionObject = this.makeBody(transform, mass, size); // TODO - live update of these configs should update all Ammo bodies body.setFriction(this.config.default_body_friction); body.setRestitution(this.config.default_body_restitution); body.setAngularFactor(new Ammo.btVector3(0.3,1,0.3)); this.dynamicsWorld.addRigidBody(body); } else { const ghost = collisionObject = this.makeGhost(transform, size); this.dynamicsWorld.addCollisionObject(ghost); } ++this.index; collisionObject.activate(); collisionObject.setUserIndex(this.index); // BAD TOUCH, doesn't DoSet and stores a nonprimitive on the entity entity.touches = new Set(); // [a.entityId]); // entity.onOverlap = DoCreateGhostOverlap; this.byIndex[this.index] = entity.id; this.collisionObjects[entity.id] = collisionObject; } update(entity: IEntity, component: string) { super.update(entity, component); if(entity.body.bodies) { const pos = [].concat(entity.position || [0, 0, 0]); const rot = [].concat(entity.rotation || [0, 0, 0]).map(x => x * Math.PI / 180); if(this.childCollisionObjects[entity.id]) { this.childCollisionObjects[entity.id].forEach(body => { this.dynamicsWorld.removeRigidBody(body); }); } this.childCollisionObjects[entity.id] = entity.body.bodies.map((bodyData: IBodyData) => { const mass = 0; const size = bodyData.size || [1, 1, 1]; const body = this.makeBody(this.makeTransform(bodyData.pos.map((n, i) => n + pos[i]), rot), mass, size); body.activate(); // TODO - live update of these configs should update all Ammo bodies body.setFriction(this.config.default_body_friction); body.setRestitution(this.config.default_body_restitution); this.dynamicsWorld.addRigidBody(body); return body; }); } } remove(entity: IEntity) { super.remove(entity); this.dynamicsWorld.removeCollisionObject(this.collisionObjects[entity.id]); delete this.collisionObjects[entity.id]; } tick(delta) { this.dynamicsWorld.stepSimulation(1 / 60, 10, 1 / 60); this.actions({ IMPULSE: this.HandleImpulse, TORQUE_IMPULSE: this.HandleTorqueImpulse, ATTACK: this.HandleAttack }); /* this.ghosts = this.ghosts.filter(ghost => { if (ghost.lifetime > 0) { ghost.lifetime -= delta; if (ghost.lifetime <= 0) { this.dynamicsWorld.removeCollisionObject(ghost); const userIdx = ghost.getUserIndex(); if (userIdx > 0) delete this.byIndex[userIdx]; return false; } } return true; });*/ // TODO - this should be change-based const numManifolds = this.dynamicsWorld.getDispatcher().getNumManifolds(); for (let i = 0; i < numManifolds; i++) { const contactManifold = this.dynamicsWorld.getDispatcher().getManifoldByIndexInternal(i); const obA = contactManifold.getBody0(); const obB = contactManifold.getBody1(); let touching = false; const numContacts = contactManifold.getNumContacts(); for (let j = 0; j < numContacts; j++) { const pt = contactManifold.getContactPoint(j); if (pt.getDistance() < 0.0) { touching = true; break; // const ptA = pt.getPositionWorldOnA(); // const ptB = pt.getPositionWorldOnB(); // const normalOnB = pt.m_normalWorldOnB; } } if (!touching) continue; const entityAId = this.byIndex[obA.getUserIndex()]; if (!entityAId) continue; const entityA = this.entities[entityAId]; if (!entityA) continue; const entityBId = this.byIndex[obB.getUserIndex()]; if (!entityBId) continue; const entityB = this.entities[entityBId]; if (!entityB) continue; if (!entityA.touches || entityA.touches.has(entityBId)) continue; if (!entityB.touches || entityB.touches.has(entityAId)) continue; entityA.touches.add(entityBId); entityB.touches.add(entityAId); if (entityA.tags && entityA.tags.length > 0) { if (!entityB.tags || entityB.tags.length === 0) continue; if (!entityA.tags.some(tag => (entityB.tags.indexOf(tag) !== -1))) // any tags in both? continue; } this.dispatch(DoOverlap(entityA, entityB), true); this.dispatch(DoOverlap(entityB, entityA), true); } Object.keys(this.collisionObjects).forEach(async id => { const body = Ammo.btRigidBody.prototype.upcast(this.collisionObjects[id]); if (!body) return; if (!body.isActive()) return; const entity = this.entities[id]; if (!entity) return; if (entity.body.ghost) return; // artificial 'air' friction // TODO - make data-driven let av = body.getAngularVelocity(); av = av.op_mul((1.0 - this.config.rotational_air_friction)); body.setAngularVelocity(av); const lv = body.getLinearVelocity(); lv.setX(lv.x() * (1.0 - this.config.velocity_air_friction[0])); lv.setY(lv.y() * (1.0 - this.config.velocity_air_friction[1])); lv.setZ(lv.z() * (1.0 - this.config.velocity_air_friction[2])); body.setLinearVelocity(lv); const transform = new Ammo.btTransform(); body.getMotionState().getWorldTransform(transform); const p = transform.getOrigin(); const pos = [p.x(), p.y(), p.z()]; const q = transform.getRotation(); const quat = [q.x(), q.y(), q.z(), q.w()]; const velocity = [lv.x(), lv.y(), lv.z()]; if (!equals(entity.position, pos)) { this.dispatch(DoSet(entity.id, 'position', pos)); } if (!equals(entity.quaternion, quat)) { this.dispatch(DoSet(entity.id, 'quaternion', quat)); } }); } private HandleImpulse(a: IImpulseAction) { const body = Ammo.btRigidBody.prototype.upcast(this.collisionObjects[a.entityId]); if (!body) return; const force = new Ammo.btVector3(a.force[0], a.force[1], a.force[2]); body.activate(false); body.applyCentralImpulse(force); } private HandleTorqueImpulse(a: IImpulseAction) { const body = Ammo.btRigidBody.prototype.upcast(this.collisionObjects[a.entityId]); if (!body) return; const force = new Ammo.btVector3(a.force[0], a.force[1], a.force[2]); body.activate(false); body.applyTorqueImpulse(force); } private HandleAttack(a: IAttackAction) { const entity = this.entities[a.entityId]; const body = Ammo.btRigidBody.prototype.upcast(this.collisionObjects[a.entityId]); if (!body) return; const transform = new Ammo.btTransform(); body.getMotionState().getWorldTransform(transform); const pos = transform.getOrigin(); const size = [10, 10, 10]; // TODO - lifetime system? // ghost.lifetime = 2.0; // TODO - create attack entity, attach, animate, touch, etc } }