Files
2026-09-12 04:58:59 +03:00

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();
}
}