482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
import * as B from "@babylonjs/core";
|
|
import type { Entity, Project, Vec3 } from "./schema.ts";
|
|
import { tileRectangles } from "./two-d.ts";
|
|
let modulePromise: Promise<any> | undefined;
|
|
export const loadRapier2D = () =>
|
|
(modulePromise ??= import("@dimforge/rapier2d-compat").then(async (r) => {
|
|
await r.init();
|
|
return r;
|
|
}));
|
|
interface BodyEntry {
|
|
node: Entity;
|
|
root: B.TransformNode;
|
|
body: any;
|
|
colliders: any[];
|
|
controller?: any;
|
|
velocity: { x: number; y: number };
|
|
grounded: boolean;
|
|
contacts: any[];
|
|
coyote: number;
|
|
halfHeight: number;
|
|
offsetY: number;
|
|
previousY: number;
|
|
}
|
|
export class Physics2D {
|
|
world: any;
|
|
queue: any;
|
|
entries = new Map<string, BodyEntry>();
|
|
owners = new Map<number, string>();
|
|
events: any[] = [];
|
|
joints = new Map<string, any>();
|
|
private controllerPairs = new Map<string, [string, string]>();
|
|
private input: any = {};
|
|
private jump = false;
|
|
private lastJump = false;
|
|
constructor(
|
|
private R: any,
|
|
gravity: number[] = [0, -9.81],
|
|
) {
|
|
this.world = new R.World({ x: gravity[0], y: gravity[1] });
|
|
this.world.timestep = 1 / 60;
|
|
this.queue = new R.EventQueue(true);
|
|
}
|
|
static async create(project: Project) {
|
|
return new Physics2D(
|
|
await loadRapier2D(),
|
|
project.settings.physics2d?.gravity,
|
|
);
|
|
}
|
|
add(node: Entity, root: B.TransformNode) {
|
|
const c = node.components.collider2d,
|
|
tile = node.components.tilemap;
|
|
if (!c && !tile?.collisions) return;
|
|
const R = this.R,
|
|
rb = node.components.rigidbody2d || { type: "fixed" };
|
|
root.computeWorldMatrix(true);
|
|
const pos = root.getAbsolutePosition(),
|
|
scale = root.absoluteScaling,
|
|
rotation = root.absoluteRotationQuaternion.toEulerAngles().z;
|
|
const desc = (
|
|
rb.type === "dynamic"
|
|
? R.RigidBodyDesc.dynamic()
|
|
: rb.type === "kinematic"
|
|
? R.RigidBodyDesc.kinematicPositionBased()
|
|
: R.RigidBodyDesc.fixed()
|
|
)
|
|
.setTranslation(pos.x, pos.y)
|
|
.setRotation(rotation)
|
|
.setGravityScale(rb.gravityScale ?? 1)
|
|
.setLinearDamping(rb.linearDamping ?? 0)
|
|
.setAngularDamping(rb.angularDamping ?? 0)
|
|
.setCcdEnabled(rb.ccd !== false);
|
|
if (rb.lockRotation) desc.lockRotations();
|
|
const body = this.world.createRigidBody(desc),
|
|
colliders: any[] = [];
|
|
let halfHeight = 0.5,
|
|
offsetY = (c?.offset?.[1] || 0) * scale.y;
|
|
const add = (shape: any, offset: number[], sensor = false) => {
|
|
shape
|
|
.setTranslation(offset[0], offset[1])
|
|
.setMass(rb.mass ?? 1)
|
|
.setFriction(rb.friction ?? 0.5)
|
|
.setRestitution(rb.restitution ?? 0)
|
|
.setSensor(sensor)
|
|
.setCollisionGroups(
|
|
(((c?.membership ?? 1) << 16) | (c?.mask ?? 65535)) >>> 0,
|
|
)
|
|
.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS)
|
|
.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL);
|
|
if (c?.oneWay) shape.setActiveHooks(R.ActiveHooks.FILTER_CONTACT_PAIRS);
|
|
const col = this.world.createCollider(shape, body);
|
|
colliders.push(col);
|
|
this.owners.set(col.handle, node.id);
|
|
};
|
|
if (tile?.collisions) {
|
|
for (const rect of tileRectangles(tile.cells))
|
|
add(
|
|
R.ColliderDesc.cuboid(
|
|
(rect.width * tile.tileSize[0] * Math.abs(scale.x)) / 2,
|
|
(rect.height * tile.tileSize[1] * Math.abs(scale.y)) / 2,
|
|
),
|
|
[
|
|
(rect.x + rect.width / 2) * tile.tileSize[0] * scale.x,
|
|
(rect.y + rect.height / 2) * tile.tileSize[1] * scale.y,
|
|
],
|
|
);
|
|
} else {
|
|
let shape;
|
|
const size = c.size || [1, 1];
|
|
halfHeight = (size[1] * Math.abs(scale.y)) / 2;
|
|
if (c.shape === "circle") {
|
|
halfHeight =
|
|
(c.radius || 0.5) * Math.max(Math.abs(scale.x), Math.abs(scale.y));
|
|
shape = R.ColliderDesc.ball(halfHeight);
|
|
} else if (c.shape === "capsule") {
|
|
const radius = (c.radius || 0.3) * Math.abs(scale.x);
|
|
halfHeight = Math.max(
|
|
radius,
|
|
((c.height || 1.8) * Math.abs(scale.y)) / 2,
|
|
);
|
|
shape = R.ColliderDesc.capsule(halfHeight - radius, radius);
|
|
} else if (c.shape === "polygon") {
|
|
const vertices = new Float32Array(
|
|
c.points.flatMap((p: number[]) => [p[0] * scale.x, p[1] * scale.y]),
|
|
);
|
|
shape = R.ColliderDesc.convexHull(vertices);
|
|
halfHeight = Math.max(
|
|
...c.points.map((p: number[]) => Math.abs(p[1] * scale.y)),
|
|
);
|
|
if (!shape) throw Error("Не удалось построить Collider2D");
|
|
} else
|
|
shape = R.ColliderDesc.cuboid(
|
|
(size[0] * Math.abs(scale.x)) / 2,
|
|
halfHeight,
|
|
);
|
|
add(shape, [(c.offset?.[0] || 0) * scale.x, offsetY], !!c.sensor);
|
|
}
|
|
const e: BodyEntry = {
|
|
node,
|
|
root,
|
|
body,
|
|
colliders,
|
|
velocity: { x: 0, y: 0 },
|
|
grounded: false,
|
|
contacts: [],
|
|
coyote: 0,
|
|
halfHeight,
|
|
offsetY,
|
|
previousY: pos.y,
|
|
};
|
|
if (rb.type === "kinematic" && colliders.length === 1 && !c?.sensor) {
|
|
e.controller = this.world.createCharacterController(0.015);
|
|
e.controller.setUp({ x: 0, y: 1 });
|
|
const step = node.components.character2d?.autostep ?? 0;
|
|
if (step > 0) e.controller.enableAutostep(step, 0.15, true);
|
|
e.controller.disableSnapToGround();
|
|
e.controller.setApplyImpulsesToDynamicBodies(true);
|
|
}
|
|
this.entries.set(node.id, e);
|
|
this.setEnabled(node.id);
|
|
}
|
|
connect() {
|
|
for (const [id, e] of this.entries) {
|
|
const j = e.node.components.joint2d,
|
|
target = j && this.entries.get(j.targetId);
|
|
if (!j || !target || this.joints.has(id)) continue;
|
|
const a = { x: j.anchor[0], y: j.anchor[1] },
|
|
b = { x: j.targetAnchor[0], y: j.targetAnchor[1] },
|
|
J = this.R.JointData;
|
|
let data;
|
|
if (j.type === "revolute") data = J.revolute(a, b);
|
|
else if (j.type === "rope") data = J.rope(j.length || 1, a, b);
|
|
else if (j.type === "spring")
|
|
data = J.spring(j.length || 1, j.stiffness ?? 50, j.damping ?? 5, a, b);
|
|
else data = J.fixed(a, 0, b, 0);
|
|
const joint = this.world.createImpulseJoint(
|
|
data,
|
|
e.body,
|
|
target.body,
|
|
true,
|
|
);
|
|
joint.setContactsEnabled(false);
|
|
this.joints.set(id, joint);
|
|
}
|
|
}
|
|
setInput(input: any) {
|
|
this.input = input;
|
|
const jump = !!(input.jump || input.jumpPressed);
|
|
if (jump && !this.lastJump) this.jump = true;
|
|
this.lastJump = jump;
|
|
}
|
|
setEnabled(id: string) {
|
|
const e = this.entries.get(id);
|
|
if (!e) return;
|
|
const enabled =
|
|
e.root.isEnabled() && e.node.components.collider2d?.enabled !== false;
|
|
e.body.setEnabled(enabled);
|
|
for (const c of e.colliders) c.setEnabled(enabled);
|
|
}
|
|
velocity(id: string, value: any) {
|
|
const e = this.entries.get(id);
|
|
if (!e) return;
|
|
if (e.body.isDynamic()) e.velocity = { ...e.body.linvel() };
|
|
for (const k of ["x", "y"] as const)
|
|
if (value[k] !== undefined) {
|
|
if (!Number.isFinite(value[k])) throw Error("Неверная скорость 2D");
|
|
e.velocity[k] = Math.max(-200, Math.min(200, value[k]));
|
|
}
|
|
if (value.gravityScale !== undefined) {
|
|
if (!Number.isFinite(value.gravityScale))
|
|
throw Error("Неверная гравитация");
|
|
e.body.setGravityScale(value.gravityScale, true);
|
|
}
|
|
if (e.body.isDynamic()) e.body.setLinvel(e.velocity, true);
|
|
}
|
|
impulse(id: string, value: number[]) {
|
|
if (
|
|
!Array.isArray(value) ||
|
|
value.length !== 2 ||
|
|
!value.every(Number.isFinite)
|
|
)
|
|
throw Error("Импульс 2D: [x,y]");
|
|
this.entries.get(id)?.body.applyImpulse({ x: value[0], y: value[1] }, true);
|
|
}
|
|
teleport(id: string, position: number[]) {
|
|
const e = this.entries.get(id);
|
|
if (!e) return;
|
|
if (
|
|
!Array.isArray(position) ||
|
|
position.length !== 3 ||
|
|
!position.every(Number.isFinite)
|
|
)
|
|
throw Error("Позиция 2D: [x,y,z]");
|
|
e.node.transform.position = [...position] as Vec3;
|
|
e.root.position.copyFromFloats(...e.node.transform.position);
|
|
e.root.computeWorldMatrix(true);
|
|
this.patch(id, true);
|
|
e.body.setLinvel({ x: 0, y: 0 }, true);
|
|
e.velocity = { x: 0, y: 0 };
|
|
e.grounded = false;
|
|
e.contacts = [];
|
|
}
|
|
patch(id: string, position = false, rotation = false) {
|
|
const e = this.entries.get(id);
|
|
if (!e) return;
|
|
e.root.computeWorldMatrix(true);
|
|
if (position) {
|
|
const p = e.root.getAbsolutePosition();
|
|
e.body.setTranslation({ x: p.x, y: p.y }, true);
|
|
if (e.body.isKinematic())
|
|
e.body.setNextKinematicTranslation({ x: p.x, y: p.y });
|
|
e.previousY = p.y;
|
|
}
|
|
if (rotation) {
|
|
const angle = e.root.absoluteRotationQuaternion.toEulerAngles().z;
|
|
e.body.setRotation(angle, true);
|
|
if (e.body.isKinematic()) e.body.setNextKinematicRotation(angle);
|
|
}
|
|
this.setEnabled(id);
|
|
}
|
|
private accepts(platform: BodyEntry, other: BodyEntry) {
|
|
const top = platform.previousY + platform.offsetY + platform.halfHeight;
|
|
const vel = other.velocity;
|
|
return (
|
|
vel.y <= 0.05 &&
|
|
other.previousY + other.offsetY - other.halfHeight >= top - 0.08
|
|
);
|
|
}
|
|
step(dt: number, moves: Map<string, Vec3>, divisor = 1) {
|
|
for (const e of this.entries.values()) {
|
|
e.previousY = e.body.translation().y;
|
|
if (e.body.isDynamic()) e.velocity = { ...e.body.linvel() };
|
|
}
|
|
for (const [id, e] of this.entries) {
|
|
e.contacts = [];
|
|
if (!e.body.isEnabled()) continue;
|
|
const c = e.node.components.character2d,
|
|
move = moves.get(id) || [0, 0, 0];
|
|
if (e.controller) {
|
|
if (c) {
|
|
if (c.controls !== false) {
|
|
e.velocity.x = (this.input.x || 0) * (c.speed ?? 5);
|
|
if (c.mode === "topDown") {
|
|
e.velocity.y =
|
|
(this.input.y ?? this.input.z ?? 0) * (c.speed ?? 5);
|
|
const length = Math.hypot(e.velocity.x, e.velocity.y);
|
|
if (length > (c.speed ?? 5)) {
|
|
e.velocity.x *= (c.speed ?? 5) / length;
|
|
e.velocity.y *= (c.speed ?? 5) / length;
|
|
}
|
|
}
|
|
}
|
|
if (c.mode !== "topDown") {
|
|
e.coyote = e.grounded ? 0.1 : Math.max(0, e.coyote - dt);
|
|
if (this.jump && c.controls !== false && e.coyote > 0) {
|
|
e.velocity.y = c.jumpSpeed ?? 8;
|
|
e.coyote = 0;
|
|
e.grounded = false;
|
|
}
|
|
e.velocity.y = Math.max(-50, e.velocity.y - (c.gravity ?? 20) * dt);
|
|
}
|
|
}
|
|
const desired = {
|
|
x: e.velocity.x * dt + move[0] / divisor,
|
|
y: e.velocity.y * dt + move[1] / divisor,
|
|
};
|
|
e.controller.computeColliderMovement(
|
|
e.colliders[0],
|
|
desired,
|
|
this.R.QueryFilterFlags.EXCLUDE_SENSORS,
|
|
e.colliders[0].collisionGroups(),
|
|
(col: any) => {
|
|
const owner = this.entries.get(this.owners.get(col.handle)!);
|
|
return (
|
|
!owner?.node.components.collider2d?.oneWay ||
|
|
this.accepts(owner, e)
|
|
);
|
|
},
|
|
);
|
|
const delta = e.controller.computedMovement(),
|
|
p = e.body.translation();
|
|
e.body.setNextKinematicTranslation({
|
|
x: p.x + delta.x,
|
|
y: p.y + delta.y,
|
|
});
|
|
e.grounded = e.controller.computedGrounded();
|
|
for (let i = 0; i < e.controller.numComputedCollisions(); i++) {
|
|
const hit = e.controller.computedCollision(i);
|
|
if (hit?.collider)
|
|
e.contacts.push({
|
|
entityId: this.owners.get(hit.collider.handle),
|
|
normal: [hit.normal1.x, hit.normal1.y, 0],
|
|
});
|
|
}
|
|
if (e.grounded && e.velocity.y < 0) e.velocity.y = 0;
|
|
if (e.contacts.some((c) => c.normal[1] < -0.5) && e.velocity.y > 0)
|
|
e.velocity.y = 0;
|
|
} else if (e.body.isKinematic()) {
|
|
const p = e.body.translation();
|
|
e.body.setNextKinematicTranslation({
|
|
x: p.x + e.velocity.x * dt + move[0] / divisor,
|
|
y: p.y + e.velocity.y * dt + move[1] / divisor,
|
|
});
|
|
} else if (move[0] || move[1]) {
|
|
const p = e.body.translation();
|
|
e.body.setTranslation(
|
|
{ x: p.x + move[0] / divisor, y: p.y + move[1] / divisor },
|
|
true,
|
|
);
|
|
}
|
|
}
|
|
this.jump = false;
|
|
this.world.step(this.queue, {
|
|
filterContactPair: (a: number, b: number) => {
|
|
const ea = this.entries.get(this.owners.get(a)!),
|
|
eb = this.entries.get(this.owners.get(b)!);
|
|
if (
|
|
ea &&
|
|
eb &&
|
|
((ea.node.components.collider2d?.oneWay && !this.accepts(ea, eb)) ||
|
|
(eb.node.components.collider2d?.oneWay && !this.accepts(eb, ea)))
|
|
)
|
|
return null;
|
|
return this.R.SolverFlags.COMPUTE_IMPULSE;
|
|
},
|
|
filterIntersectionPair: () => true,
|
|
});
|
|
this.queue.drainCollisionEvents(
|
|
(a: number, b: number, started: boolean) => {
|
|
const id = this.owners.get(a),
|
|
otherId = this.owners.get(b);
|
|
if (!id || !otherId) return;
|
|
const sensor =
|
|
this.world.getCollider(a)?.isSensor() ||
|
|
this.world.getCollider(b)?.isSensor();
|
|
if (
|
|
!sensor &&
|
|
(this.entries.get(id)?.controller ||
|
|
this.entries.get(otherId)?.controller)
|
|
)
|
|
return;
|
|
for (const [entityId, other] of [
|
|
[id, otherId],
|
|
[otherId, id],
|
|
])
|
|
this.events.push({
|
|
entityId,
|
|
otherId: other,
|
|
started,
|
|
sensor: !!sensor,
|
|
});
|
|
},
|
|
);
|
|
// Character-controller contacts stop at a small gap, so no solver collision is generated.
|
|
const pairs = new Map<string, [string, string]>();
|
|
for (const [id, e] of this.entries)
|
|
for (const hit of e.contacts)
|
|
if (hit.entityId) {
|
|
const pair = [id, hit.entityId].sort() as [string, string];
|
|
pairs.set(pair.join("|"), pair);
|
|
}
|
|
for (const [current, previous, started] of [
|
|
[pairs, this.controllerPairs, true],
|
|
[this.controllerPairs, pairs, false],
|
|
] as const)
|
|
for (const [key, [a, b]] of current)
|
|
if (!previous.has(key)) {
|
|
this.events.push(
|
|
{ entityId: a, otherId: b, started, sensor: false },
|
|
{ entityId: b, otherId: a, started, sensor: false },
|
|
);
|
|
}
|
|
this.controllerPairs = pairs;
|
|
if (this.events.length > 2000)
|
|
this.events.splice(0, this.events.length - 2000);
|
|
// Parents first: child physics poses are converted through their current world matrix.
|
|
const depth = (e: BodyEntry) => {
|
|
let n = e.root.parent,
|
|
d = 0;
|
|
while (n) {
|
|
d++;
|
|
n = n.parent;
|
|
}
|
|
return d;
|
|
};
|
|
for (const e of [...this.entries.values()].sort(
|
|
(a, b) => depth(a) - depth(b),
|
|
)) {
|
|
if (!e.body.isEnabled()) continue;
|
|
const pos = e.body.translation();
|
|
e.root.computeWorldMatrix(true);
|
|
const z = e.root.getAbsolutePosition().z;
|
|
let local = new B.Vector3(pos.x, pos.y, z);
|
|
if (e.root.parent)
|
|
local = B.Vector3.TransformCoordinates(
|
|
local,
|
|
B.Matrix.Invert(e.root.parent.getWorldMatrix()),
|
|
);
|
|
e.root.position.copyFrom(local);
|
|
e.node.transform.position = local.asArray() as Vec3;
|
|
if (e.body.isDynamic() || e.body.isKinematic()) {
|
|
let q = B.Quaternion.RotationAxis(B.Axis.Z, e.body.rotation());
|
|
if (e.root.parent instanceof B.TransformNode)
|
|
q = e.root.parent.absoluteRotationQuaternion.conjugate().multiply(q);
|
|
e.root.rotationQuaternion = q;
|
|
e.node.transform.rotation = q.toEulerAngles().asArray() as Vec3;
|
|
}
|
|
e.root.computeWorldMatrix(true);
|
|
}
|
|
}
|
|
snapshot() {
|
|
return Object.fromEntries(
|
|
[...this.entries].map(([id, e]) => [
|
|
id,
|
|
{
|
|
dimension: 2,
|
|
grounded: e.grounded,
|
|
velocity: {
|
|
...(e.body.isDynamic() ? e.body.linvel() : e.velocity),
|
|
z: 0,
|
|
},
|
|
contacts: e.contacts,
|
|
enabled: e.body.isEnabled(),
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
drainEvents() {
|
|
const result = this.events;
|
|
this.events = [];
|
|
return result;
|
|
}
|
|
dispose() {
|
|
this.controllerPairs.clear();
|
|
this.entries.clear();
|
|
this.owners.clear();
|
|
this.joints.clear();
|
|
this.events = [];
|
|
this.queue.free();
|
|
this.world.free();
|
|
}
|
|
}
|