@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
547 lines (416 loc) • 13.1 kB
JavaScript
/**
*
* @param {Array.<AABB2>} boxes
*/
import AABB2 from "../../../geom/2d/aabb/AABB2.js";
import Vector2 from "../../../geom/Vector2.js";
import { computeDisconnectedSubGraphs } from "../computeDisconnectedSubGraphs.js";
import { Connection } from "../Connection.js";
import { applyCentralGravityAABB2 } from "./applyCentralGravityAABB2.js";
import { resolveAABB2Overlap } from "./resolveAABB2Overlap.js";
/**
*
* @param {Connection} edge
* @returns {number}
*/
function evaluateEdgeCost(edge) {
const first = edge.source;
const second = edge.target;
//compute center points
const x0 = first.midX();
const y0 = first.midY();
const x1 = second.midX();
const y1 = second.midY();
return Vector2._distance(x0, y0, x1, y1);
}
/**
* Computes a bounding box for a group of {@link AABB2}s
* @param {AABB2[]} boxes
* @returns {AABB2}
*/
function computeBoundingBox(boxes) {
const footprint = new AABB2();
footprint.setNegativelyInfiniteBounds();
const n = boxes.length;
for (let i = 0; i < n; i++){
const b = boxes[i];
footprint.expandToFit(b);
}
return footprint;
}
/**
*
* @param {Array.<AABB2>} boxes
* @param {Array.<Connection>} edges
* @returns {number}
*/
function evaluateLayout(boxes, edges) {
const footprint = computeBoundingBox(boxes);
//overall size of the layout plays a role
const totalArea = footprint.getHeight() * footprint.getWidth();
const totalConnectionLength = edges.reduce(function (sum, edge) {
return sum + evaluateEdgeCost(edge);
}, 0);
return totalConnectionLength + totalArea * 0.1;
}
/**
*
* @param {AABB2} box
* @returns {number}
*/
function evaluateBoxPositionCost(box) {
let result = 0;
//compute current edge cost
const connections = box.connections;
const n = connections.length;
for (let i = 0; i < n; i++) {
const connection = connections[i];
result += evaluateEdgeCost(connection);
}
return result;
}
/**
*
* @param {AABB2} box0
* @param {AABB2} box1
* @param {function(AABB2):number} costFunction
* @returns {boolean}
*/
function trySwapBoxes(box0, box1, costFunction) {
if (box0.locked === true || box1.locked === true) {
return false;
}
const costBefore = costFunction(box0) + costFunction(box1);
const temp = new AABB2();
temp.copy(box0);
box0.copy(box1);
box1.copy(temp);
const costAfter = costFunction(box0) + costFunction(box1);
if (costAfter > costBefore) {
//revert swap
box1.copy(box0);
box0.copy(temp);
return false;
}
return true;
}
/**
*
* @param {Array.<AABB2>} boxes
* @returns {number}
*/
function applyNodeSwapPass(boxes) {
let swaps = 0;
const numBoxes = boxes.length;
for (let i = 0; i < numBoxes; i++) {
const b0 = boxes[i];
for (let j = i + 1; j < numBoxes; j++) {
const b1 = boxes[j];
if (trySwapBoxes(b0, b1, evaluateBoxPositionCost)) {
swaps++;
}
}
}
return swaps;
}
/**
*
* @param {Array.<AABB2>} boxes
* @param {number} allowedSteps
* @returns {number}
*/
function applyNodeSwaps(boxes, allowedSteps) {
let swaps;
do {
swaps = applyNodeSwapPass(boxes);
} while (swaps > 0 && --allowedSteps > 0);
}
/**
*
* @param {Array.<Connection>} edges
* @param {number} strength
*/
function applyPull(edges, strength) {
const d = new Vector2();
for (let i = 0, l = edges.length; i < l; i++) {
const edge = edges[i];
const first = edge.source;
const second = edge.target;
d.set(second.midX(), second.midY());
d._sub(first.midX(), first.midY());
d.multiplyScalar(strength);
//apply pull
first.move(d.x, d.y);
second.move(-d.x, -d.y);
}
}
function resolveBoxConnectionOverlaps(boxes, connections, maxSteps) {
for (let i = 0; i < maxSteps; i++) {
const shiftsMade = resolveBoxConnectionOverlapsStep(boxes, connections);
if (shiftsMade === 0) {
//we're done, no changes were made
break;
}
}
}
function resolveBoxConnectionOverlapsStep(boxes, connections) {
const forces = new Map();
//initialize forces
boxes.forEach(b => forces.set(b, new Vector2()));
function addForce(box, x, y) {
const force = forces.get(box);
force._add(x, y);
}
/**
*
* @param {AABB2} box
* @param {Connection} connection
*/
function resolveOverlap(box, connection) {
const source = connection.source;
const target = connection.target;
if (box === source || box === target) {
//connection is attached to the box, no overlap resolution required
return false;
}
const p0 = new Vector2();
const p1 = new Vector2();
if (!AABB2.computeLineBetweenTwoBoxes(source, target, p0, p1)) {
return false;
}
const intersectionPoint = new Vector2();
const intersectionExists = box.lineIntersectionPoint(p0, p1, intersectionPoint);
if (!intersectionExists) {
return false;
}
//localize intersection point
const left = intersectionPoint.x - box.x0;
const right = box.x1 - intersectionPoint.x;
const top = intersectionPoint.y - box.y0;
const bottom = box.y1 - intersectionPoint.y;
//figure out smallest move necessary to resolve overlap
const move = new Vector2(0, 0);
if (left !== 0 && right !== 0) {
if (left < right) {
//move right
move.x = left;
} else {
move.x = -right;
}
} else {
if (top < bottom) {
move.y = top;
} else {
move.y = -bottom;
}
}
const dy = move.y / 2;
const dx = move.x / 2;
if (box.locked !== true) {
addForce(box, dx, dy);
}
//nudge connected pair other way
if (source.locked !== true) {
addForce(source, -dx, -dy);
}
if (target.locked !== true) {
addForce(target, -dx, -dy);
}
return true;
}
let overlapsResolved = 0;
for (let i = 0, il = boxes.length; i < il; i++) {
const box = boxes[i];
for (let j = 0, jl = connections.length; j < jl; j++) {
const connection = connections[j];
if (resolveOverlap(box, connection)) {
overlapsResolved++;
}
}
}
forces.forEach(function (force, box) {
box.move(force.x, force.y);
});
return overlapsResolved;
}
function applyClusteredLayoutStep(boxes) {
const subGraphs = computeDisconnectedSubGraphs(boxes);
const boundingBoxes = [];
const boundingBoxesPositions = [];
subGraphs.forEach(function (boxes, clusterIndex) {
//collect edges
const connections = [];
boxes.forEach(function (box) {
const edges = box.connections;
edges.forEach(function (e) {
if (connections.indexOf(e) === -1) {
connections.push(e);
}
});
});
applyPull(connections, 0.2);
applyNodeSwaps(boxes);
resolveAABB2Overlap(boxes, 3);
resolveBoxConnectionOverlaps(boxes, connections, 3);
const bbox = computeBoundingBox(boxes);
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) {
box.move(dX, dY);
});
}
});
}
function doLayerLayout(boxes) {
const remaining = boxes.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, boxes, offsetY);
break;
}
let offsetX = 0;
let layerHeight = 0;
for (let i = 0, l = layer.length; i < l; i++) {
const box = layer[i];
const y0 = box.y0;
const y1 = box.y1;
const x0 = box.x0;
const x1 = box.x1;
const h = y1 - y0;
const w = x1 - x0;
box.y0 = offsetY;
box.y1 = offsetY + h;
box.x0 = offsetX;
box.x1 = offsetX + w;
offsetX += w;
layerHeight = Math.max(h, layerHeight);
}
offsetY += layerHeight;
}
}
/**
*
* @param {AABB2[]} boxes
* @param {Vector2} center
*/
export function centerAABB2CollectionOn(boxes, center) {
const bBox = computeBoundingBox(boxes);
const dX = bBox.centerX - center.x;
const dY = bBox.centerY - center.y;
boxes.forEach(function (b) {
b.move(-dX, -dY);
});
}
function alignOnOrigin(boxes) {
const bBox = computeBoundingBox(boxes);
boxes.forEach(function (b) {
b.move(-bBox.x0, -bBox.y0);
});
}
/**
*
* @param {Array.<AABB2>} input
* @param {Graph} graph
* @param {Vector2} [center]
*/
function forceLayout(input, graph, center = null) {
if (input.length === 0) {
//nothing to layout, we're done
return;
}
const connections = [];
const boxes = input.map(function (b) {
const node = b.model;
const box = new AABB2(b.x0, b.y0, b.x1, b.y1);
box.node = node;
box.source = b;
box.connections = [];
box.locked = b.locked === true;
return box;
});
graph.traverseEdges(function (edge) {
const first = edge.first;
const second = edge.second;
const firstBox = boxes.find(function (box) {
return box.node === first;
});
const secondBox = boxes.find(function (box) {
return box.node === second;
});
const connection = new Connection(firstBox, secondBox);
connection.edge = edge;
firstBox.connections.push(connection);
secondBox.connections.push(connection);
connections.push(connection);
});
let constBefore = evaluateLayout(boxes, connections);
function step() {
applyClusteredLayoutStep(boxes);
// applyGravityClustered(boxes, 0.05);
// applyPull(connections, 0.2);
// applyNodeSwaps(boxes);
// resolveOverlap(boxes);
}
console.time("layout");
doLayerLayout(boxes);
for (let i = 0; i < 20; i++) {
step();
}
for (let i = 0; i < 10; i++) {
resolveBoxConnectionOverlaps(boxes, connections, 20);
resolveAABB2Overlap(boxes, 20);
}
console.timeEnd("layout");
const costAfter = evaluateLayout(boxes, connections);
console.log("Fitness change:", constBefore - costAfter);
if (center !== null) {
//align on center
centerAABB2CollectionOn(boxes, center);
} else {
//re-center boxes
alignOnOrigin(boxes);
}
//apply layout
boxes.forEach(function (b) {
b.source.copy(b);
});
}
export {
forceLayout,
alignOnOrigin
};