diff --git a/modules.json b/modules.json index de3832b5a..ff9b1acd2 100644 --- a/modules.json +++ b/modules.json @@ -85,5 +85,10 @@ }, "remote_execution": { "tabs": [] + }, + "physics_2d": { + "tabs": [ + "physics_2d" + ] } } diff --git a/package.json b/package.json index 5c708683d..bfcda3991 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,8 @@ "@blueprintjs/core": "^4.6.1", "@blueprintjs/icons": "^4.4.0", "@blueprintjs/popover2": "^1.4.3", + "@box2d/core": "^0.10.0", + "@box2d/debug-draw": "^0.10.0", "@jscad/modeling": "^2.9.5", "@jscad/regl-renderer": "^2.6.1", "@jscad/stl-serializer": "^2.1.13", diff --git a/src/bundles/physics_2d/PhysicsObject.ts b/src/bundles/physics_2d/PhysicsObject.ts new file mode 100644 index 000000000..c4c495db4 --- /dev/null +++ b/src/bundles/physics_2d/PhysicsObject.ts @@ -0,0 +1,192 @@ +/* eslint-disable new-cap */ +// We have to disable linting rules since Box2D functions do not +// follow the same guidelines as the rest of the codebase. + +import { + type b2Body, + type b2Shape, + type b2Fixture, + b2BodyType, + b2CircleShape, + b2PolygonShape, + b2Vec2, +} from '@box2d/core'; +import { type ReplResult } from '../../typings/type_helpers'; + +import { ACCURACY, type Force, type ForceWithPos } from './types'; +import { type PhysicsWorld } from './PhysicsWorld'; + +export class PhysicsObject implements ReplResult { + private body: b2Body; + private shape: b2Shape; + private fixture: b2Fixture; + private forcesCentered: Force[] = []; + private forcesAtAPoint: ForceWithPos[] = []; + + constructor( + position: b2Vec2, + rotation: number, + shape: b2Shape, + isStatic: boolean, + world: PhysicsWorld, + ) { + this.body = world.createBody({ + type: isStatic ? b2BodyType.b2_staticBody : b2BodyType.b2_dynamicBody, + position, + angle: rotation, + }); + this.shape = shape; + + this.fixture = this.body.CreateFixture({ + shape: this.shape, + density: 1, + friction: 1, + }); + } + + public getFixture() { + return this.fixture; + } + + public getMass() { + return this.body.GetMass(); + } + + public setDensity(density: number) { + this.fixture.SetDensity(density); + this.body.ResetMassData(); + } + + public setFriction(friction: number) { + this.fixture.SetFriction(friction); + } + + public getPosition() { + return this.body.GetPosition(); + } + + public setPosition(pos: b2Vec2) { + this.body.SetTransformVec(pos, this.getRotation()); + } + + public getRotation() { + return this.body.GetAngle(); + } + + public setRotation(rot: number) { + this.body.SetAngle(rot); + } + + public getVelocity() { + return this.body.GetLinearVelocity(); + } + + public setVelocity(velc: b2Vec2) { + this.body.SetLinearVelocity(velc); + } + + public getAngularVelocity() { + return this.body.GetAngularVelocity(); + } + + public setAngularVelocity(velc: number) { + this.body.SetAngularVelocity(velc); + } + + public addForceCentered(force: Force) { + this.forcesCentered.push(force); + } + + public addForceAtAPoint(force: Force, pos: b2Vec2) { + this.forcesAtAPoint.push({ + force, + pos, + }); + } + + private applyForcesToCenter(world_time: number) { + this.forcesCentered = this.forcesCentered.filter( + (force: Force) => force.start_time + force.duration > world_time, + ); + + const resForce = this.forcesCentered + .filter((force: Force) => force.start_time < world_time) + .reduce( + (res: b2Vec2, force: Force) => res.Add(force.direction.Scale(force.magnitude)), + new b2Vec2(), + ); + + this.body.ApplyForceToCenter(resForce); + } + + private applyForcesAtAPoint(world_time: number) { + this.forcesAtAPoint = this.forcesAtAPoint.filter( + (forceWithPos: ForceWithPos) => forceWithPos.force.start_time + forceWithPos.force.duration > world_time, + ); + + this.forcesAtAPoint.forEach((forceWithPos) => { + const force = forceWithPos.force; + this.body.ApplyForce( + force.direction.Scale(force.magnitude), + forceWithPos.pos, + ); + }); + } + + public applyForces(world_time: number) { + this.applyForcesToCenter(world_time); + this.applyForcesAtAPoint(world_time); + } + + public isTouching(obj2: PhysicsObject) { + let ce = this.body.GetContactList(); + while (ce !== null) { + if (ce.other === obj2.body && ce.contact.IsTouching()) { + return true; + } + ce = ce.next; + } + return false; + } + + public toReplString = () => ` + Mass: ${this.getMass() + .toFixed(ACCURACY)} + Position: [${this.getPosition().x.toFixed( + ACCURACY, + )},${this.getPosition().y.toFixed(ACCURACY)}] + Velocity: [${this.getVelocity().x.toFixed( + ACCURACY, + )},${this.getVelocity().y.toFixed(ACCURACY)}] + + Rotation: ${this.getRotation() + .toFixed(ACCURACY)} + AngularVelocity: [${this.getAngularVelocity() + .toFixed(ACCURACY)}]`; + + public scale_size(scale: number) { + if (this.shape instanceof b2CircleShape) { + this.shape.m_radius *= scale; + } else if (this.shape instanceof b2PolygonShape) { + let centroid: b2Vec2 = this.shape.m_centroid; + let arr: b2Vec2[] = []; + this.shape.m_vertices.forEach((vec) => { + arr.push( + new b2Vec2( + centroid.x + scale * (vec.x - centroid.x), + centroid.y + scale * (vec.y - centroid.y), + ), + ); + }); + this.shape = new b2PolygonShape() + .Set(arr); + } + let f: b2Fixture = this.fixture; + this.body.DestroyFixture(this.fixture); + this.fixture = this.body.CreateFixture({ + shape: this.shape, + density: f.GetDensity(), + friction: f.GetFriction(), + }); + } +} diff --git a/src/bundles/physics_2d/PhysicsWorld.ts b/src/bundles/physics_2d/PhysicsWorld.ts new file mode 100644 index 000000000..1f4712778 --- /dev/null +++ b/src/bundles/physics_2d/PhysicsWorld.ts @@ -0,0 +1,136 @@ +/* eslint-disable new-cap */ +// We have to disable linting rules since Box2D functions do not +// follow the same guidelines as the rest of the codebase. + +import { + type b2Body, + type b2Fixture, + type b2BodyDef, + b2BodyType, + b2PolygonShape, + type b2StepConfig, + b2Vec2, + b2World, + b2ContactListener, + type b2Contact, +} from '@box2d/core'; +import { type PhysicsObject } from './PhysicsObject'; +import { Timer } from './types'; + +export class PhysicsWorld { + private b2World: b2World; + private physicsObjects: PhysicsObject[]; + private timer: Timer; + private touchingObjects: Map>; + + private iterationsConfig: b2StepConfig = { + velocityIterations: 8, + positionIterations: 3, + }; + + constructor() { + this.b2World = b2World.Create(new b2Vec2()); + this.physicsObjects = []; + this.timer = new Timer(); + this.touchingObjects = new Map>(); + + const contactListener: b2ContactListener = new b2ContactListener(); + contactListener.BeginContact = (contact: b2Contact) => { + let m = this.touchingObjects.get(contact.GetFixtureA()); + if (m === undefined) { + let newMap = new Map(); + newMap.set(contact.GetFixtureB(), this.timer.getTime()); + this.touchingObjects.set(contact.GetFixtureA(), newMap); + } else { + m.set(contact.GetFixtureB(), this.timer.getTime()); + } + }; + contactListener.EndContact = (contact: b2Contact) => { + const contacts = this.touchingObjects.get(contact.GetFixtureA()); + if (contacts) { + contacts.delete(contact.GetFixtureB()); + } + }; + + this.b2World.SetContactListener(contactListener); + } + + public setGravity(gravity: b2Vec2) { + this.b2World.SetGravity(gravity); + } + + public addObject(obj: PhysicsObject) { + this.physicsObjects.push(obj); + return obj; + } + + public createBody(bodyDef: b2BodyDef) { + return this.b2World.CreateBody(bodyDef); + } + + public makeGround(height: number, friction: number) { + const groundBody: b2Body = this.createBody({ + type: b2BodyType.b2_staticBody, + position: new b2Vec2(0, height - 10), + }); + const groundShape: b2PolygonShape = new b2PolygonShape() + .SetAsBox( + 10000, + 10, + ); + + groundBody.CreateFixture({ + shape: groundShape, + density: 1, + friction, + }); + } + + public update(dt: number) { + for (let obj of this.physicsObjects) { + obj.applyForces(this.timer.getTime()); + } + this.b2World.Step(dt, this.iterationsConfig); + this.timer.step(dt); + } + + public simulate(total_time: number) { + const dt = 0.01; + for (let i = 0; i < total_time; i += dt) { + this.update(dt); + } + } + + public getB2World() { + return this.b2World; + } + + public getWorldStatus(): String { + let world_status: String = ` + World time: ${this.timer.toString()} + + Objects: + `; + this.physicsObjects.forEach((obj) => { + console.log(obj.getMass()); + world_status += ` + ------------------------ + ${obj.toReplString()} + ------------------------ + `; + }); + return world_status; + } + + public findImpact(obj1: PhysicsObject, obj2: PhysicsObject) { + let m = this.touchingObjects.get(obj1.getFixture()); + if (m === undefined) { + return -1; + } + let time = m.get(obj2.getFixture()); + if (time === undefined) { + return -1; + } + return time; + } +} diff --git a/src/bundles/physics_2d/functions.ts b/src/bundles/physics_2d/functions.ts new file mode 100644 index 000000000..f1b0c468d --- /dev/null +++ b/src/bundles/physics_2d/functions.ts @@ -0,0 +1,487 @@ +/* eslint-disable new-cap */ +// We have to disable linting rules since Box2D functions do not +// follow the same guidelines as the rest of the codebase. + +/** + * @module physics_2d + * @author Muhammad Fikri Bin Abdul Kalam + * @author Yu Jiali + */ + +import context from 'js-slang/context'; + +import { b2CircleShape, b2PolygonShape } from '@box2d/core'; + +import { type Force, Vector2 } from './types'; +import { PhysicsObject } from './PhysicsObject'; +import { PhysicsWorld } from './PhysicsWorld'; + +// Global Variables + +let world: PhysicsWorld | null = null; +const NO_WORLD = new Error('Please call set_gravity first!'); +const MULTIPLE_WORLDS = new Error('You may only call set_gravity once!'); + +// Module's Exposed Functions + +/** + * Make a 2d vector with the given x and y components. + * + * @param x x-component of new vector + * @param y y-component of new vector + * @returns with x, y as components + * + * @category Main + */ +export function make_vector(x: number, y: number): Vector2 { + return new Vector2(x, y); +} + +/** + * Make a force with direction vector, magnitude, force duration and start time. + * + * @param dir direction of force + * @param mag magnitude of force + * @param dur duration of force + * @param start start time of force + * @returns new force + * + * @category Dynamics + */ +export function make_force( + dir: Vector2, + mag: number, + dur: number, + start: number, +): Force { + let force: Force = { + direction: dir, + magnitude: mag, + duration: dur, + start_time: start, + }; + return force; +} + +/** + * Create a new physics world and set gravity of world. + * + * @param v gravity vector + * @example + * ``` + * set_gravity(0, -9.8); // gravity vector for real world + * ``` + * + * @category Main + */ +export function set_gravity(v: Vector2) { + if (world) { + throw MULTIPLE_WORLDS; + } + + world = new PhysicsWorld(); + context.moduleContexts.physics_2d.state = { + world, + }; + world.setGravity(v); +} + +/** + * Make the ground body of the world. + * + * @param height height of ground + * @param friction friction of ground + * + * @category Main + */ +export function make_ground(height: number, friction: number) { + if (!world) { + throw NO_WORLD; + } + + world.makeGround(height, friction); +} + +/** + * Make a wall (static box object with no velocity). + * + * @param pos position of the wall + * @param rot rotation of the wall + * @param size size of the wall + * @returns new box (wall) object + * + * @category Main + */ +export function add_wall(pos: Vector2, rot: number, size: Vector2) { + if (!world) { + throw NO_WORLD; + } + + return world.addObject( + new PhysicsObject( + pos, + rot, + new b2PolygonShape() + .SetAsBox(size.x / 2, size.y / 2), + true, + world, + ), + ); +} + +/** + * Make a box object with given initial position, rotation, velocity, size and add it to the world. + * + * @param pos initial position vector of center + * @param rot initial rotation + * @param velc initial velocity vector + * @param size size + * @returns new box object + * + * @category Body + */ +export function add_box_object( + pos: Vector2, + rot: number, + velc: Vector2, + size: Vector2, + isStatic: boolean, +): PhysicsObject { + if (!world) { + throw NO_WORLD; + } + const newObj: PhysicsObject = new PhysicsObject( + pos, + rot, + new b2PolygonShape() + .SetAsBox(size.x / 2, size.y / 2), + isStatic, + world, + ); + newObj.setVelocity(velc); + return world.addObject(newObj); +} + +/** + * Make a circle object with given initial position, rotation, velocity, radius and add it to the world. + * + * @param pos initial position vector of center + * @param rot initial rotation + * @param velc initial velocity vector + * @param radius radius + * @returns new circle object + * + * @category Body + */ +export function add_circle_object( + pos: Vector2, + rot: number, + velc: Vector2, + radius: number, + isStatic: boolean, +): PhysicsObject { + if (!world) { + throw NO_WORLD; + } + const newObj: PhysicsObject = new PhysicsObject( + pos, + rot, + new b2CircleShape() + .Set(new Vector2(), radius), + isStatic, + world, + ); + newObj.setVelocity(velc); + return world.addObject(newObj); +} + +/** + * Make a triangle object with given initial position, rotation, velocity, base, height and add it to the world. + * + * @param pos initial position vector of center + * @param rot initial rotation + * @param velc initial velocity vector + * @param base base + * @param height height + * @returns new triangle object + * + * @category Body + */ +export function add_triangle_object( + pos: Vector2, + rot: number, + velc: Vector2, + base: number, + height: number, + isStatic: boolean, +): PhysicsObject { + if (!world) { + throw NO_WORLD; + } + const newObj: PhysicsObject = new PhysicsObject( + pos, + rot, + new b2PolygonShape() + .Set([ + new Vector2(-base / 2, -height / 2), + new Vector2(base / 2, -height / 2), + new Vector2(0, height / 2), + ]), + isStatic, + world, + ); + newObj.setVelocity(velc); + return world.addObject(newObj); +} + +/** + * Update the world with a fixed time step. + * + * @param dt value of fixed time step + * + * @category Main + */ +export function update_world(dt: number) { + if (!world) { + throw NO_WORLD; + } + + world.update(dt); +} + +/** + * Simulate the world. + * + * @param total_time total time to simulate + * + * @category Main + */ +export function simulate_world(total_time: number) { + if (!world) { + throw NO_WORLD; + } + + world.simulate(total_time); +} + +/** + * Get position of the object at current world time. + * + * @param obj existing object + * @returns position of center + * + * @category Body + */ +export function get_position(obj: PhysicsObject): Vector2 { + return new Vector2(obj.getPosition().x, obj.getPosition().y); +} + +/** + * Get rotation of the object at current world time. + * + * @param obj existing object + * @returns rotation of object + * + * @category Body + */ +export function get_rotation(obj: PhysicsObject): number { + return obj.getRotation(); +} + +/** + * Get current velocity of the object. + * + * @param obj exisiting object + * @returns velocity vector + * + * @category Body + */ +export function get_velocity(obj: PhysicsObject): Vector2 { + return new Vector2(obj.getVelocity().x, obj.getVelocity().y); +} + +/** + * Get current angular velocity of the object. + * + * @param obj exisiting object + * @returns angular velocity vector + * + * @category Body + */ +export function get_angular_velocity(obj: PhysicsObject): Vector2 { + return new Vector2(obj.getAngularVelocity()); +} + +/** + * Sets the position of the object. + * + * @param obj existing object + * @param pos new position + * + * @category Body + */ +export function set_position(obj: PhysicsObject, pos: Vector2): void { + obj.setPosition(pos); +} + +/** + * Sets the rotation of the object. + * + * @param obj existing object + * @param rot new rotation + * + * @category Body + */ +export function set_rotation(obj: PhysicsObject, rot: number): void { + obj.setRotation(rot); +} + +/** + * Sets current velocity of the object. + * + * @param obj exisiting object + * @param velc new velocity + * + * @category Body + */ +export function set_velocity(obj: PhysicsObject, velc: Vector2): void { + obj.setVelocity(velc); +} + +/** + * Get current angular velocity of the object. + * + * @param obj exisiting object + * @param velc angular velocity number + * + * @category Body + */ +export function set_angular_velocity(obj: PhysicsObject, velc: number): void { + return obj.setAngularVelocity(velc); +} + +/** + * Set density of the object. + * + * @param obj existing object + * @param density density + * + * @category Body + */ +export function set_density(obj: PhysicsObject, density: number) { + obj.setDensity(density); +} + +/** + * Resize the object. + * + * @param obj existinig object + * @param scale scaling size + * + * @category Body + */ +export function scale_size(obj: PhysicsObject, scale: number) { + if (!world) { + throw NO_WORLD; + } + obj.scale_size(scale); +} + +/** + * Set friction of the object. + * + * @param obj + * @param friction + * + * @category Body + */ +export function set_friction(obj: PhysicsObject, friction: number) { + obj.setFriction(friction); +} + +/** + * Check if two objects is touching. + * + * @param obj1 + * @param obj2 + * @returns touching state + * + * @category Dynamics + */ +export function is_touching(obj1: PhysicsObject, obj2: PhysicsObject) { + return obj1.isTouching(obj2); +} + +/** + * Impact start time of two currently touching objects. + * + * @param obj1 + * @param obj2 + * @returns impact start time + * + * @category Dynamics + */ +export function impact_start_time(obj1: PhysicsObject, obj2: PhysicsObject) { + if (!world) { + throw NO_WORLD; + } + + return world.findImpact(obj1, obj2); +} + +/** + * Apply force to an object. + * + * @param force existing force + * @param obj existing object the force applies on + * + * @category Dynamics + */ +export function apply_force_to_center(force: Force, obj: PhysicsObject) { + obj.addForceCentered(force); +} + +/** + * Apply force to an object at a given world point. + * + * @param force existing force + * @param pos world point the force is applied on + * @param obj existing object the force applies on + * + * @category Dynamics + */ +export function apply_force(force: Force, pos: Vector2, obj: PhysicsObject) { + obj.addForceAtAPoint(force, pos); +} + +/** + * Converts a 2d vector into an array. + * + * @param vec 2D vector to convert + * @returns array with [x, y] + * + * @category Main + */ +export function vector_to_array(vec: Vector2) { + return [vec.x, vec.y]; +} + +/** + * Converts an array of 2 numbers into a 2d vector. + * + * @param arr array with [x, y] + * @returns vector 2d + * + * @category Main + */ +export function array_to_vector([x, y]: [number, number]) { + return new Vector2(x, y); +} + +export function add_vector(vec1: Vector2, vec2: Vector2) { + return new Vector2(vec1.x + vec2.x, vec1.y + vec2.y); +} + +export function subtract_vector(vec1: Vector2, vec2: Vector2) { + return new Vector2(vec1.x - vec2.x, vec1.y - vec2.y); +} diff --git a/src/bundles/physics_2d/index.ts b/src/bundles/physics_2d/index.ts new file mode 100644 index 000000000..30ac33a62 --- /dev/null +++ b/src/bundles/physics_2d/index.ts @@ -0,0 +1,79 @@ +/** + * 2D Phyiscs simulation module for Source Academy. + * + * A *vector* is defined by its coordinates (x and y). The 2D vector is used to + * represent direction, size, position, velocity and other physical attributes in + * the physics world. Vector manipulation and transformations between vector and array + * types are supported. + * + * A *world* is the single physics world the module is based on. + * `set_gravity` needs to be called to initialize the world and set the gravity. It has + * to be called exactly once and at the start of the program. + * `make_ground` and `add_wall` are optional settings of the world. + * After adding objects to the world, calling `update_world` will simulate the world. + * The suggested time step is 1/60 (seconds). + * + * An *object* is defined by its shape, position, velocity, size, rotation and state + * with the add function. Get and set functions are provided to change the objects' + * physical attributes like density and friction. Two options of `apply_force` are + * useful to change behavior of objects at specific time. Detection of collision and + * precise starting time are provided by `is_touching` and `impact_start_time`. + * + * Visualization of the physics world can be seen in the display tab. + * + * The following example simulates a free fall of a circle object. + * ``` + * import { set_gravity, make_vector, add_circle_object, update_world } from "physics_2d"; + * const gravity = make_vector(0, -9.8); + * set_gravity(gravity); + * const pos = make_vector(0, 100); + * const velc = make_vector(0, 0); + * add_circle_object(pos, 0, velc, 10, false); + * for (let i = 0; i < 10; i = i + 1) { + * update_world(1/60); + * } + * ``` + * + * + * @author Muhammad Fikri Bin Abdul Kalam + * @author Yu Jiali + */ +export { + set_gravity, + make_ground, + add_wall, + + make_vector, + make_force, + + add_box_object, + add_circle_object, + add_triangle_object, + + set_density, + set_friction, + scale_size, + + get_position, + set_position, + get_rotation, + set_rotation, + get_velocity, + set_velocity, + get_angular_velocity, + set_angular_velocity, + + apply_force, + apply_force_to_center, + + is_touching, + impact_start_time, + + update_world, + simulate_world, + + vector_to_array, + array_to_vector, + add_vector, + subtract_vector, +} from './functions'; diff --git a/src/bundles/physics_2d/types.ts b/src/bundles/physics_2d/types.ts new file mode 100644 index 000000000..95e890440 --- /dev/null +++ b/src/bundles/physics_2d/types.ts @@ -0,0 +1,44 @@ +/* eslint-disable new-cap */ +// We have to disable linting rules since Box2D functions do not +// follow the same guidelines as the rest of the codebase. + +import { b2Vec2 } from '@box2d/core'; +import { type ReplResult } from '../../typings/type_helpers'; + +export const ACCURACY = 2; +export class Vector2 extends b2Vec2 implements ReplResult { + public toReplString = () => `Vector2D: [${this.x}, ${this.y}]`; +} + +export type Force = { + direction: b2Vec2; + magnitude: number; + duration: number; + start_time: number; +}; + +export type ForceWithPos = { + force: Force; + pos: b2Vec2; +}; + +export class Timer { + private time: number; + + constructor() { + this.time = 0; + } + + public step(dt: number) { + this.time += dt; + return this.time; + } + + public getTime() { + return this.time; + } + + public toString() { + return `${this.time.toFixed(4)}`; + } +} diff --git a/src/tabs/physics_2d/DebugDrawCanvas.tsx b/src/tabs/physics_2d/DebugDrawCanvas.tsx new file mode 100644 index 000000000..a82bdd341 --- /dev/null +++ b/src/tabs/physics_2d/DebugDrawCanvas.tsx @@ -0,0 +1,315 @@ +/* eslint-disable new-cap */ +// We have to disable linting rules since Box2D functions do not +// follow the same guidelines as the rest of the codebase. + +import { Button, Icon, Slider } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { Tooltip2 } from '@blueprintjs/popover2'; +import React from 'react'; +import { DebugDraw } from '@box2d/debug-draw'; +import { DrawShapes, type b2World } from '@box2d/core'; + +import WebGLCanvas from '../common/webgl_canvas'; +import { type PhysicsWorld } from '../../bundles/physics_2d/PhysicsWorld'; + +type DebugDrawCanvasProps = { + world: PhysicsWorld; +}; + +type DebugDrawCanvasState = { + /** Timestamp of the animation */ + animTimestamp: number; + + /** Boolean value indicating if the animation is playing */ + isPlaying: boolean; + + /** Number value for the zoom level of the debug draw world */ + zoomLevel: number; + + /** Number value for the camera x coordinate in the debug draw world */ + camX: number; + + /** Number value for the update step in the debug draw world */ + updateStep: number; +}; + +/** + * Canvas to display glAnimations + */ +// For some reason, I can't get this component to build +// with the blueprint/js components if it's located in +// another file so it's here for now +export default class DebugDrawCanvas extends React.Component< +DebugDrawCanvasProps, +DebugDrawCanvasState +> { + private canvas: HTMLCanvasElement | null; + + /** + * The duration of one frame in milliseconds + */ + private readonly frameDuration: number; + + /** + * Last timestamp since the previous `requestAnimationFrame` call + */ + private callbackTimestamp: number | null; + + private debugDraw: DebugDraw | null; + + private world: PhysicsWorld; + private b2World: b2World; + + constructor(props: DebugDrawCanvasProps | Readonly) { + super(props); + + this.state = { + animTimestamp: 0, + isPlaying: false, + zoomLevel: 1, + camX: 0, + updateStep: 1 / 60, + }; + + this.canvas = null; + this.frameDuration = 10; + this.callbackTimestamp = null; + this.debugDraw = null; + this.world = props.world; + this.b2World = this.world.getB2World(); + } + + public componentDidMount() { + this.drawFrame(); + } + + /** + * Call this to actually draw a frame onto the canvas + */ + private drawFrame = () => { + if (this.canvas) { + if (!this.debugDraw) { + const ctx: CanvasRenderingContext2D | null + = this.canvas.getContext('2d'); + if (ctx) { + this.debugDraw = new DebugDraw(ctx); + } + } + + if (this.debugDraw && this.world) { + this.debugDraw.Prepare(this.state.camX, 0, this.state.zoomLevel, true); + DrawShapes(this.debugDraw, this.b2World); + this.debugDraw.Finish(); + } + } + }; + + private reqFrame = () => requestAnimationFrame(this.animationCallback); + + private startAnimation = () => this.setState( + { + isPlaying: true, + }, + this.reqFrame, + ); + + private stopAnimation = () => this.setState( + { + isPlaying: false, + }, + () => { + this.callbackTimestamp = null; + }, + ); + + /** + * Callback to use with `requestAnimationFrame` + */ + private animationCallback = (timeInMs: number) => { + if (!this.canvas || !this.state.isPlaying) return; + + if (!this.callbackTimestamp) { + this.callbackTimestamp = timeInMs; + this.drawFrame(); + this.reqFrame(); + return; + } + + const currentFrame = timeInMs - this.callbackTimestamp; + + if (currentFrame < this.frameDuration) { + // Not time to draw a new frame yet + this.reqFrame(); + return; + } + + this.callbackTimestamp = timeInMs; + + this.setState( + (prev) => ({ + animTimestamp: prev.animTimestamp + currentFrame, + }), + () => { + this.drawFrame(); + this.reqFrame(); + }, + ); + + this.world.update(this.state.updateStep); + }; + + /** + * Play button click handler + */ + private onPlayButtonClick = () => { + if (this.state.isPlaying) { + this.stopAnimation(); + } else { + this.startAnimation(); + } + }; + + /** + * Event handler for slider component. Updates the canvas for any change in + * zoomLevel. + * + * @param newValue new zoom level + */ + private onZoomSliderChangeHandler = (newValue: number) => { + this.setState({ zoomLevel: newValue }, () => {}); + }; + + /** + * Event handler for slider component. Updates the canvas for any change in + * camX. + * + * @param newValue new camera x coordinate + */ + private onCameraSliderChangeHandler = (newValue: number) => { + this.setState({ camX: newValue }, () => {}); + }; + + /** + * Event handler for slider component. Updates the canvas for any change in + * updateStep. + * + * @param newValue new camera x coordinate + */ + private onUpdateStepSliderChangeHandler = (newValue: number) => { + this.setState({ updateStep: newValue }, () => {}); + }; + + public render() { + const buttons = ( +
+
+ + + +
+
+
+

Zoom level: {this.state.zoomLevel.toFixed(2)}

+ +
+
+

Camera X: {this.state.camX.toFixed(2)}

+ +
+
+

Update step: {this.state.updateStep.toFixed(4)}

+ +
+
+
+ ); + + return ( + <> +
+ { + this.canvas = r; + }} + /> +
+
+ {buttons} +
+
+ {this.world.getWorldStatus()} +
+ + ); + } +} diff --git a/src/tabs/physics_2d/index.tsx b/src/tabs/physics_2d/index.tsx new file mode 100644 index 000000000..3c00e2f6a --- /dev/null +++ b/src/tabs/physics_2d/index.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + + +import type { DebuggerContext } from '../../typings/type_helpers'; +import DebugDrawCanvas from './DebugDrawCanvas'; + +/** + * + * @author + * @author + */ + + + +export default { + /** + * This function will be called to determine if the component will be + * rendered. + * @param {DebuggerContext} context + * @returns {boolean} + */ + toSpawn: () => true, + + /** + * This function will be called to render the module tab in the side contents + * on Source Academy frontend. + * @param {DebuggerContext} context + */ + body(context: DebuggerContext) { + const { context: { moduleContexts: { physics_2d: { state: { world } } } } } = context; + + return ( +
+ +
+ ); + }, + + /** + * The Tab's icon tooltip in the side contents on Source Academy frontend. + */ + label: 'Physics 2D', + + /** + * BlueprintJS IconName element's name, used to render the icon which will be + * displayed in the side contents panel. + * @see https://blueprintjs.com/docs/#icons + */ + iconName: 'wind', +}; diff --git a/yarn.lock b/yarn.lock index 4d81fd9cb..bcf0369f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -354,6 +354,18 @@ react-popper "^2.3.0" tslib "~2.3.1" +"@box2d/core@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@box2d/core/-/core-0.10.0.tgz#99c8893970b33f68ab22a426ca8d23166b9d7de5" + integrity sha512-rVawB+VokPnE2IarLPXE9blbpV26KOEYdadsnXf7ToyXzceXMADPrSIvYIyTzXLteiLE9i+UTMmTcCvYXl+QEA== + +"@box2d/debug-draw@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@box2d/debug-draw/-/debug-draw-0.10.0.tgz#53f57523be4ab1c9c6922c268b938271f4b789a2" + integrity sha512-cPkgwRD2Na/p/lIUjeBFXiMpAebbiE3PL+SpSGyrILfeJqF/WEZW3J+mzPNougwezcc8HKKHcd5B0jZZFUQhIA== + dependencies: + "@box2d/core" "^0.10.0" + "@esbuild/android-arm64@0.17.11": version "0.17.11" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.11.tgz#52c3e6cabc19c5e4c1c0c01cb58f0442338e1c14"