@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
928 lines (690 loc) • 23 kB
JavaScript
import { assert } from "../../assert.js";
import AABB2 from "../../geom/2d/aabb/AABB2.js";
import Circle from "../../geom/2d/circle/Circle.js";
import {
line_segment_line_segment_intersection_exists_2d
} from "../../geom/2d/line/line_segment_line_segment_intersection_exists_2d.js";
import { v2_distance } from "../../geom/vec2/v2_distance.js";
import Vector2 from "../../geom/Vector2.js";
import { max2 } from "../../math/max2.js";
import { min2 } from "../../math/min2.js";
import { applyCentralGravityAABB2 } from "./box/applyCentralGravityAABB2.js";
import { resolveAABB2Overlap } from "./box/resolveAABB2Overlap.js";
import { computeDisconnectedSubGraphs } from "./computeDisconnectedSubGraphs.js";
import { Connection } from "./Connection.js";
/**
*
* @param {Circle[]} circles
*/
function doLayerLayout(circles) {
const remaining = circles.filter(function (b) {
return b.locked !== true;
});
function buildLayer() {
const layer = [];
main_loop:for (let i = remaining.length - 1; i >= 0; i--) {
const box = remaining[i];
const connections = box.connections;
for (let j = 0; j < connections.length; j++) {
const connection = connections[j];
const source = connection.source;
if (source !== box && remaining.indexOf(source) !== -1) {
//not closed yet
continue main_loop;
}
}
remaining.splice(i, 1);
//all sources are already laid out
layer.push(box);
}
return layer;
}
let offsetY = 0;
while (remaining.length > 0) {
const layer = buildLayer();
if (layer.length === 0) {
console.error("Layer is empty", remaining, circles, offsetY);
break;
}
let offsetX = 0;
let layerHeight = 0;
for (let i = 0, l = layer.length; i < l; i++) {
const circle = layer[i];
const r = circle.r;
circle.x0 = offsetX + r;
circle.y0 = offsetY + r;
offsetX += r;
layerHeight = Math.max(r, layerHeight);
}
offsetY += layerHeight;
}
}
/**
*
* @param {Circle[]}input
*/
function applyClusteredLayoutStep(input) {
const subGraphs = computeDisconnectedSubGraphs(input);
const boundingBoxes = [];
const boundingBoxesPositions = [];
subGraphs.forEach(function (circles, clusterIndex) {
//collect edges
const connections = [];
circles.forEach(function (circle) {
const edges = circle.connections;
edges.forEach(function (e) {
if (connections.indexOf(e) === -1) {
connections.push(e);
}
});
});
// applyPush(circles, 0.2);
applyNodeSwaps(circles, connections, 10);
applyPull(connections, 0.2);
resolveCircleOverlaps(circles, connections, 5);
const bbox = computeBoundingBox(circles);
boundingBoxes[clusterIndex] = bbox;
boundingBoxesPositions[clusterIndex] = new Vector2(bbox.x0, bbox.y0);
});
//apply gravity to clusters as a whole
applyCentralGravityAABB2(boundingBoxes, 0.3);
resolveAABB2Overlap(boundingBoxes, 20);
//translate original boxes
boundingBoxes.forEach(function (bbox, clusterIndex) {
const bboxOrigin = boundingBoxesPositions[clusterIndex];
const dX = bbox.x0 - bboxOrigin.x;
const dY = bbox.y0 - bboxOrigin.y;
if (dX !== 0 || dY !== 0) {
//box moved
const clusterBoxes = subGraphs[clusterIndex];
clusterBoxes.forEach(function (box) {
if (box.locked) {
//node is locked
return;
}
box.move(dX, dY);
});
}
});
}
/**
*
* @param {Circle[]} input
* @param {Graph} graph
* @param {Vector2} center
*/
export function layoutCircleGraph(input, graph, center = new Vector2()) {
const connections = [];
const numInputs = input.length;
const circles = input.map(function (c) {
const node = c.model;
const circle = new Circle(c.x, c.y, c.r);
circle.node = node;
circle.source = c;
circle.connections = [];
circle.locked = c.locked === true;
circle.force = new Vector2();
return circle;
});
function indexByNode(n) {
let i;
for (i = 0; i < numInputs; i++) {
const circle = circles[i];
if (circle.node === n) {
return i;
}
}
return -1;
}
graph.traverseEdges(function (edge) {
const first = edge.first;
const second = edge.second;
const i0 = indexByNode(first);
const i1 = indexByNode(second);
const c0 = circles[i0];
const c1 = circles[i1];
const connection = new Connection(c0, c1);
connection.edge = edge;
c0.connections.push(connection);
c1.connections.push(connection);
connections.push(connection);
});
doLayerLayout(circles);
for (let i = 0; i < 20; i++) {
applyClusteredLayoutStep(circles);
}
//global optimization phase
resolveCircleOverlaps(circles, connections, 5);
//align on center
centerCircleCollectionOn(circles, center);
//apply layout
circles.forEach(function (b) {
b.source.copy(b);
});
}
/**
* Computes a bounding box for a group of {@link Circle}s
* @param {Circle[]} circles
* @returns {AABB2}
*/
function computeBoundingBox(circles) {
const footprint = new AABB2();
footprint.setNegativelyInfiniteBounds();
circles.forEach(function (c) {
footprint.x0 = min2(footprint.x0, c.x - c.r);
footprint.y0 = min2(footprint.y0, c.y - c.r);
footprint.x1 = max2(footprint.x1, c.x + c.r);
footprint.y1 = max2(footprint.y1, c.y + c.r);
});
return footprint;
}
/**
*
* @param {Circle[]} circles
* @param {Vector2} center
*/
export function centerCircleCollectionOn(circles, center) {
const bBox = computeBoundingBox(circles);
const dX = bBox.centerX - center.x;
const dY = bBox.centerY - center.y;
circles.forEach(function (b) {
if (b.locked) {
//node is locked and can't be moved
return;
}
b.move(-dX, -dY);
});
}
/**
*
* @param {Connection[]} edges
* @param {number} strength
*/
function applyPull(edges, strength) {
const str_i = 1 - strength;
for (let i = 0, l = edges.length; i < l; i++) {
const edge = edges[i];
const c0 = edge.source;
const c1 = edge.target;
const x0 = c0.x;
const y0 = c0.y;
const x1 = c1.x;
const y1 = c1.y;
//compute vector C1 - C0
const dX = x1 - x0;
const dY = y1 - y0;
const length = Math.sqrt(dX * dX + dY * dY);
const r0 = c0.r;
const r1 = c1.r;
const minD = r0 + r1;
const fixedPull = minD * 0.01;
const td = max2(minD, length * str_i + fixedPull);
const m = 1 - td / length;
const m_2 = m / 2;
const nX = dX * m_2;
const nY = dY * m_2;
if (c0.locked && c1.locked) {
//can't apply pull, both nodes are locked
continue;
} else if (c0.locked) {
c1.x -= nX * 2;
c1.y -= nY * 2;
} else if (c1.locked) {
c0.x += nX * 2;
c0.y += nY * 2;
} else {
//neither node is locked
c0.x += nX;
c0.y += nY;
c1.x -= nX;
c1.y -= nY;
}
}
}
/**
*
* @param {Connection} edge
* @returns {number}
*/
function evaluateEdgeCost(edge) {
const c0 = edge.source;
const c1 = edge.target;
//compute center points
const x0 = c0.x;
const y0 = c0.y;
const x1 = c1.x;
const y1 = c1.y;
return v2_distance(x0, y0, x1, y1);
}
function computeNumberOfCrossOvers(edges, numEdges) {
let result = 0;
let i, j;
for (i = 0; i < numEdges; i++) {
const e0 = edges[i];
const a0 = e0.source;
const a1 = e0.target;
for (j = i + 1; j < numEdges; j++) {
const e1 = edges[j];
const b0 = e1.source;
const b1 = e1.target;
if (line_segment_line_segment_intersection_exists_2d(
a0.x, a0.y,
a1.x, a1.y,
b0.x, b0.y,
b1.x, b1.y
)) {
result++;
}
}
}
return result;
}
/**
*
* @param {Circle[]} nodes
* @param {Connection[]} edges
* @return {number}
*/
function computeNumberOfNodeEdgeOverlaps(nodes, edges) {
let i, j;
let result = 0;
const numNodes = nodes.length;
const numEdges = edges.length;
for (i = 0; i < numNodes; i++) {
const node = nodes[i];
for (j = 0; j < numEdges; j++) {
const edge = edges[j];
if (circleOverlapsConnection(node, edge)) {
result++;
}
}
}
return result;
}
/**
*
* @param {Circle} c0
* @param {Circle} c1
* @param {Circle[]} circles
* @param {Connection[]} edges
* @param {number} numEdges
* @returns {boolean}
*/
function trySwap(c0, c1, circles, edges, numEdges) {
if (c0.locked === true || c1.locked === true) {
return false;
}
function evaluatePositionCost() {
let result = 0;
function addToCost(edge) {
result += evaluateEdgeCost(edge);
}
//compute current edge cost
c0.connections.forEach(addToCost);
c1.connections.forEach(addToCost);
const crossOvers = computeNumberOfCrossOvers(edges, numEdges);
const overlaps = computeNumberOfNodeEdgeOverlaps(circles, edges);
result += crossOvers * 1000;
result += overlaps * 500;
return result;
}
const costBefore = evaluatePositionCost();
const tempX = c0.x;
const tempY = c0.y;
c0.x = c1.x;
c0.y = c1.y;
c1.x = tempX;
c1.y = tempY;
const costAfter = evaluatePositionCost();
if (costAfter >= costBefore) {
//revert swap
c1.x = c0.x;
c1.y = c0.y;
c0.x = tempX;
c0.y = tempY;
return false;
}
return true;
}
/**
*
* @param {Circle[]} candidates
* @param {number} numCandidates
* @param {Connection[]} edges
* @param {number} numEdges
* @returns {number}
*/
function applyNodeSwapPass(candidates, numCandidates, edges, numEdges) {
let swaps = 0;
for (let i = 0; i < numCandidates; i++) {
const b0 = candidates[i];
if (b0.locked) {
//locked nodes can not be moved
continue;
}
for (let j = i + 1; j < numCandidates; j++) {
const b1 = candidates[j];
if (b1.locked) {
//locked nodes can not be moved
continue;
}
if (trySwap(b0, b1, candidates, edges, numEdges)) {
swaps++;
}
}
}
return swaps;
}
/**
*
* @param {Circle[]} candidates
* @param {Connection[]} edges
* @param {number} allowedSteps
* @returns {number}
*/
function applyNodeSwaps(candidates, edges, allowedSteps) {
assert.isNumber(allowedSteps, 'allowedSteps');
const numCandidates = candidates.length;
const numEdges = edges.length;
let swaps;
do {
swaps = applyNodeSwapPass(candidates, numCandidates, edges, numEdges);
} while (swaps > 0 && --allowedSteps > 0);
}
/**
*
* @param {Circle[]} candidates
* @param {Connection} connections
* @return {number}
*/
function resolveCircleConnectionOverlapsStep(candidates, connections) {
assert.ok(Array.isArray(candidates), 'candidates is not an array');
assert.ok(Array.isArray(connections), 'connections is not an array');
/**
*
* @param {Circle} circle
* @param {Connection} connection
*/
function resolveOverlap(circle, connection) {
const source = connection.source;
const target = connection.target;
if (circle === source || circle === target) {
//connection is attached, no overlap resolution required
return false;
}
return resolveCircleLineOverlap(circle, source, target);
}
let overlapsResolved = 0;
for (let i = 0, il = candidates.length; i < il; i++) {
const circle = candidates[i];
for (let j = 0, jl = connections.length; j < jl; j++) {
const connection = connections[j];
if (resolveOverlap(circle, connection)) {
overlapsResolved++;
}
}
}
return overlapsResolved;
}
export function resolveCircleOverlaps(candidateCircles, connections, maxSteps) {
const numCircles = candidateCircles.length;
let totalChanges = 0;
let stepIndex;
for (stepIndex = 0; stepIndex < maxSteps; stepIndex++) {
let changes = resolveCircleOverlapStep(numCircles, candidateCircles);
changes += resolveCircleConnectionOverlapsStep(candidateCircles, connections);
if (changes === 0) {
break;
}
//apply forces
applyCircleForces(numCircles, candidateCircles);
totalChanges += changes;
}
return totalChanges;
}
/**
*
* @param {Circle} circle
* @param {Connection} edge
* @return {boolean}
*/
function circleOverlapsConnection(circle, edge) {
const c0 = edge.source;
const c1 = edge.target;
const x0 = c0.x;
const y0 = c0.y;
const x1 = c1.x;
const y1 = c1.y;
const r = circle.r;
const cX = circle.x;
const cY = circle.y;
//establish overlap
//NOTE: taken from https://stackoverflow.com/questions/1073336/circle-line-segment-collision-detection-algorithm
//Direction vector of ray, from start to end
const dX = x1 - x0;
const dY = y1 - y0;
//Vector from center sphere to ray start
const fX = x1 - cX;
const fY = y1 - cY;
const a = dX * dX + dY * dY;
const b = 2 * (fX * dX + fY * dY);
const c = (fX * fX + fY * fY) - r * r;
const discriminantSq = b * b - 4 * a * c;
if (discriminantSq < 0) {
// no intersection
return false;
} else {
// ray didn't totally miss sphere,
// so there is a solution to
// the equation.
const discriminant = Math.sqrt(discriminantSq);
// either solution may be on or off the ray so need to test both
// t1 is always the smaller value, because BOTH discriminant and
// a are nonnegative.
const t1 = (-b - discriminant) / (2 * a);
const t2 = (-b + discriminant) / (2 * a);
// 3x HIT cases:
// -o-> --|--> | | --|->
// Impale(t1 hit,t2 hit), Poke(t1 hit,t2>1), ExitWound(t1<0, t2 hit),
// 3x MISS cases:
// -> o o -> | -> |
// FallShort (t1>1,t2>1), Past (t1<0,t2<0), CompletelyInside(t1<0, t2>1)
//We are only interested in Impale case
if (t1 >= 0 && t1 <= 1 && t2 >= 0 && t2 <= 1) {
return true;
}
}
return false;
}
/**
*
* @param {Circle} circle
* @param {Circle} c0
* @param {Circle} c1
*/
function resolveCircleLineOverlap(circle, c0, c1) {
const x0 = c0.x;
const y0 = c0.y;
const x1 = c1.x;
const y1 = c1.y;
const r = circle.r;
const cX = circle.x;
const cY = circle.y;
//establish overlap
//NOTE: taken from https://stackoverflow.com/questions/1073336/circle-line-segment-collision-detection-algorithm
//Direction vector of ray, from start to end
const dX = x1 - x0;
const dY = y1 - y0;
//Vector from center sphere to ray start
const fX = x1 - cX;
const fY = y1 - cY;
const a = dX * dX + dY * dY;
const b = 2 * (fX * dX + fY * dY);
const c = (fX * fX + fY * fY) - r * r;
const discriminantSq = b * b - 4 * a * c;
if (discriminantSq < 0) {
// no intersection
return false;
} else {
// ray didn't totally miss sphere,
// so there is a solution to
// the equation.
const discriminant = Math.sqrt(discriminantSq);
// either solution may be on or off the ray so need to test both
// t1 is always the smaller value, because BOTH discriminant and
// a are nonnegative.
const t1 = (-b - discriminant) / (2 * a);
const t2 = (-b + discriminant) / (2 * a);
// 3x HIT cases:
// -o-> --|--> | | --|->
// Impale(t1 hit,t2 hit), Poke(t1 hit,t2>1), ExitWound(t1<0, t2 hit),
// 3x MISS cases:
// -> o o -> | -> |
// FallShort (t1>1,t2>1), Past (t1<0,t2<0), CompletelyInside(t1<0, t2>1)
//We are only interested in Impale case
if (t1 >= 0 && t1 <= 1 && t2 >= 0 && t2 <= 1) {
//Impale
const mid = (t1 + t2) / 2;
//find closest point on the line to the circle center
const nX = x1 + mid * dX;
const nY = y1 + mid * dY;
//compute distance to center
const ndX = cX - nX;
const ndY = cY - nY;
const ncD = Math.sqrt(ndX * ndX + ndY * ndY);
//compute penetration distance
const penD = r - ncD;
//compute exit vector
const exitX = (ndX / ncD) * penD;
const exitY = (ndY / ncD) * penD;
if (circle.locked === true) {
if (c0.locked === true) {
if (c1.locked === true) {
//no resolution possible, all objects fixed
return false;
}
c1.force._add(exitX, exitY);
} else if (c1.locked) {
c0.force._add(exitX, exitY);
} else {
//move both
c0.force._add(exitX, exitY);
c1.force._add(exitX, exitY);
}
} else {
//main object is not fixed
const exitX_2 = exitX / 2;
const exitY_2 = exitY / 2;
if (c0.locked === true) {
if (c1.locked === true) {
circle.force._add(-exitX, -exitY);
} else {
c1.force._add(exitX_2, exitY_2);
circle.force._add(-exitX_2, -exitY_2);
}
} else if (c1.locked) {
c0.force._add(exitX_2, exitY_2);
circle.force._add(-exitX_2, -exitY_2);
} else {
//move all both
c0.force._add(exitX_2, exitY_2);
c1.force._add(exitX_2, exitY_2);
circle.force._add(-exitX_2, -exitY_2);
}
}
//moved stuff
return true;
}
// No resolution or no overlap
return false;
}
}
/**
*
* @param {number} numCircles
* @param {Circle[]} circles
*/
export function applyCircleForces(numCircles, circles) {
assert.isNumber(numCircles, 'numCircles');
for (let i = 0; i < numCircles; i++) {
const circle = circles[i];
const force = circle.force;
circle.move(force.x, force.y);
//reset forces
force.x = 0;
force.y = 0;
}
}
/**
*
* @param {Circle[]} circles
* @param {Vector2} target
* @param {number} strength
*/
export function applyPullToCircles(circles, target, strength) {
let i = 0;
const numCircles = circles.length;
for (; i < numCircles - 1; i++) {
const circle = circles[i];
const dX = target.x - circle.x;
const dY = target.y - circle.y;
circle.x += dX * strength;
circle.y += dY * strength;
}
}
/**
*
* @param {number} numCircles
* @param {Circle[]} circles
* @return {number}
*/
export function resolveCircleOverlapStep(numCircles, circles) {
let i, j;
let moveCount = 0;
let mdX, mdY;
for (i = 0; i < numCircles - 1; i++) {
const c0 = circles[i];
const r0 = c0.r;
const x0 = c0.x;
const y0 = c0.y;
for (j = i + 1; j < numCircles; j++) {
const c1 = circles[j];
const r1 = c1.r;
const x1 = c1.x;
const y1 = c1.y;
const dx = x1 - x0;
const dy = y1 - y0;
const distance = Math.sqrt(dx * dx + dy * dy);
const minSeparation = r0 + r1;
const overlapDistance = minSeparation - distance;
if (overlapDistance > 0) {
if (distance === 0) {
mdX = 1;
mdY = 0;
} else {
//normalize overlap vector
mdX = dx / distance;
mdY = dy / distance;
}
const odX = mdX * overlapDistance;
const odY = mdY * overlapDistance;
if (c0.locked === true) {
if (c1.locked !== true) {
c1.force._add(odX, odY);
moveCount++;
}
} else if (c1.locked === true) {
c0.force._add(-odX, -odY);
moveCount++;
} else {
const hdX = odX / 2;
const hdY = odY / 2;
c0.force._add(-hdX, -hdY);
c1.force._add(hdX, hdY);
moveCount += 2;
}
}
}
}
return moveCount;
}