Spaces:
Paused
Paused
Claude
Claude Sonnet 5
Frontier belts: bigger maps seed a contested mid-map deposit belt
a99fcf2 unverified Download engine/map.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 22.1 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/engine/map.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities/engine/map.js
-
curl -L -o map.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/engine/map.js
22.1 kB
| /* ============================================================ | |
| Skirmish map generation. | |
| Picks one charted world from data.js and scatters resource nodes | |
| mirrored across the map, sized by that world's own deposit yields β | |
| so which planet you fight over changes what the map plays like. | |
| Configurable from the splash screen (see main.js): a size multiplier | |
| (Small 1x β¦ Gigantic 4x) scales the whole map self-similarly, and a | |
| resource multiplier (Rare β¦ Abundant) scales every deposit's amount. | |
| At sizeMult=1, resourceMult=1 the layout is byte-identical to the | |
| original small map. | |
| ============================================================ */ | |
| ; | |
| import { PLANETS } from "../data.js"; | |
| import { factionTrait } from "./factions.js"; | |
| // The "Small" map β every other size is a whole-number multiple of this. | |
| export const MAP_WIDTH = 1600; | |
| export const MAP_HEIGHT = 1000; | |
| // Fraction of the (scaled) map width that counts as "near a base" for the | |
| // build-critical resource guarantee below. 500/1600 β exactly 500 on a Small | |
| // map, and proportional on bigger ones. | |
| const NEAR_BASE_FRAC = 500 / MAP_WIDTH; | |
| // Every build ultimately needs ore (all units/buildings), crystals (Turret, | |
| // Reinforced Plating) and radioactives (Breacher, Overcharged Weapons). A | |
| // planet's deposit table is its *specificity* β how much of each it holds β | |
| // but every map must still let you make everything, so any of these three the | |
| // surface doesn't provide near a base gets a lean guaranteed seam. A world | |
| // rich in a commodity keeps its big deposits; a world without it gets just | |
| // this minimum. Ore's floor is highest since it funds the whole economy. | |
| const BUILD_CRITICAL = ["ore", "crystals", "radioactives"]; | |
| const MIN_GUARANTEE = { ore: 480, crystals: 300, radioactives: 300 }; | |
| // Vertical offset (fraction of height) each guaranteed seam sits at, so the | |
| // three don't pile onto one point when a world needs several of them. | |
| const GUARANTEE_Y = { ore: 0, crystals: -0.12, radioactives: 0.12 }; | |
| const CACHE_BASE_AMOUNT = 360; // ~0.6x a normal 600 cluster β a real bonus, not a second economy | |
| // A guaranteed ore cluster right on the doorstep of each Command Center, at a | |
| // FIXED absolute distance regardless of map size. The deposit clusters sit at | |
| // fractions of the map width, so on a Gigantic (4x) map they drift far from the | |
| // base and the opening economy crawls. These home nodes never move: whatever the | |
| // map size, every base opens onto ore it can reach in seconds β enough to fund a | |
| // second Command Center (400 ore) and push out toward the contested deposits and | |
| // the enemy. Offsets face the map interior (mirrored for the AI) so they never | |
| // fall off the edge, and carry NO rng draw, so the deposit/cache layout and map | |
| // determinism are byte-for-byte untouched. Flagged `home` so the deposit-count | |
| // tests can tell them apart from the surface deposit table. | |
| const HOME_ORE_AMOUNT = 350; // per node; 3 nodes β ~1050 ore on the doorstep | |
| const HOME_ORE_OFFSETS = [ // absolute px from the base, interior-facing | |
| { dx: 130, dy: -95 }, | |
| { dx: 165, dy: 0 }, | |
| { dx: 130, dy: 95 }, | |
| ]; | |
| /* ---------- terrain ---------- */ | |
| // A coarse per-cell terrain field (a flat Uint8Array of type codes, same idiom | |
| // as the fog grid), sampled O(1) by movement/fog/combat/colliders. Deliberately | |
| // NOT impassable β a slow cell still has speed > 0, so no unit can ever be | |
| // trapped (the engine has no pathfinding) and a wave can never deadlock. Rough | |
| // fields flanking an open lane read as a soft choke; high ground is a strong | |
| // point worth holding. Terrain is static for the whole match and drawn from | |
| // fixed fractional specs, so it consumes ZERO rng draws β map determinism and | |
| // the byte-identical node layout are untouched. | |
| export const TERRAIN_CELL_SIZE = 40; // aligned with FOG_CELL_SIZE so a future LOS pass can share cell coords | |
| export const TERRAIN = { | |
| 0: { name: "open", speedMult: 1, sightMult: 1, buildable: true, combatMult: 1 }, | |
| 1: { name: "rough", speedMult: 0.6, sightMult: 1, buildable: false, combatMult: 1 }, // slow, unbuildable field | |
| 2: { name: "high", speedMult: 1, sightMult: 1.25, buildable: true, combatMult: 1.15 }, // high ground: sees + hits farther/harder | |
| }; | |
| // Feature specs are [xFrac, yFrac, wFrac, hFrac, code, mirror?] β a rectangular | |
| // blob centred at (xFrac,yFrac) in fractions of the scaled map, stamped into the | |
| // grid. `mirror` reflects it across the vertical centreline for fairness (both | |
| // sides face the same ground). Scales self-similarly with sizeMult. | |
| function generateTerrain(width, height, specs) { | |
| const cols = Math.ceil(width / TERRAIN_CELL_SIZE); | |
| const rows = Math.ceil(height / TERRAIN_CELL_SIZE); | |
| const type = new Uint8Array(cols * rows); // 0 = open everywhere by default | |
| const stamp = (xf, yf, wf, hf, code) => { | |
| const cx0 = Math.floor(((xf - wf / 2) * width) / TERRAIN_CELL_SIZE); | |
| const cx1 = Math.floor(((xf + wf / 2) * width) / TERRAIN_CELL_SIZE); | |
| const cy0 = Math.floor(((yf - hf / 2) * height) / TERRAIN_CELL_SIZE); | |
| const cy1 = Math.floor(((yf + hf / 2) * height) / TERRAIN_CELL_SIZE); | |
| for (let gy = Math.max(0, cy0); gy <= Math.min(rows - 1, cy1); gy++) | |
| for (let gx = Math.max(0, cx0); gx <= Math.min(cols - 1, cx1); gx++) | |
| type[gy * cols + gx] = code; | |
| }; | |
| for (const [xf, yf, wf, hf, code, mirror] of specs) { | |
| stamp(xf, yf, wf, hf, code); | |
| if (mirror) stamp(1 - xf, yf, wf, hf, code); | |
| } | |
| return { cols, rows, cell: TERRAIN_CELL_SIZE, type }; | |
| } | |
| // A world-and-faction modifier as seen by ONE side. Two independent layers, | |
| // multiplied together: | |
| // 1. The WORLD. Most worlds tilt both sides equally (a plain `modifiers[key]`), | |
| // but a world may carry an `asym: { player, ai }` block that overrides a key | |
| // for just one owner. Lookup: the owner's asym override, then the shared | |
| // modifier, then the default. | |
| // 2. The FACTION (factions.js). The owner's chosen faction contributes its own | |
| // trait multiplier for the same key (1 when it has none, or on a map-less / | |
| // player-less test stub) β so a faction's edge lands exactly where a world's | |
| // does, through this one seam, and every existing consumer picks it up for | |
| // free. `neutral` (the test/default faction) contributes 1, leaving the | |
| // long-standing symmetric behaviour and every exact-value test unchanged. | |
| export function sideMod(state, owner, key, dflt = 1) { | |
| const m = state && state.map && state.map.modifiers; | |
| let world = dflt; | |
| if (m) { | |
| const a = m.asym && m.asym[owner]; | |
| world = a && a[key] != null ? a[key] : (m[key] ?? dflt); | |
| } | |
| return world * factionTrait(state, owner, key); | |
| } | |
| // The TERRAIN entry at a world point. Returns OPEN for a missing grid or an | |
| // out-of-bounds point, so every consumer degrades to "no terrain effect" | |
| // safely (map-less test stubs, off-map coords). | |
| export function sampleTerrain(terrain, x, y) { | |
| if (!terrain) return TERRAIN[0]; | |
| const gx = Math.floor(x / terrain.cell), gy = Math.floor(y / terrain.cell); | |
| if (gx < 0 || gy < 0 || gx >= terrain.cols || gy >= terrain.rows) return TERRAIN[0]; | |
| return TERRAIN[terrain.type[gy * terrain.cols + gx]] || TERRAIN[0]; | |
| } | |
| /** | |
| * Deterministically generate a world's map (deposits, bases, terrain) from a seeded rng. | |
| * @param {string} [planetId] | |
| * @param {() => number} [rng] | |
| * @param {{ sizeMult?: number, resourceMult?: number, swapAsym?: boolean }} [opts] | |
| * @returns {GameMap} | |
| */ | |
| export function generateMap(planetId = "ferros", rng = Math.random, opts = {}) { // deterministic-exempt: unseeded default rng | |
| const planet = PLANETS.find(p => p.id === planetId); | |
| if (!planet) throw new Error(`Unknown planet: ${planetId}`); | |
| const worldModifiers = PLANET_MODIFIERS[planetId] || {}; | |
| // Pick your side of an asymmetric matchup (Oort, Nimbus): opts.swapAsym exchanges the | |
| // player/ai halves of `asym`. Attach a shallow COPY β never mutate `worldModifiers` in | |
| // place, since it's the SAME object every game on this planet reads by reference | |
| // (PLANET_MODIFIERS[planetId]); mutating it would corrupt the next game that reads it. | |
| // Consumes no rng draw, so the map layout below is byte-identical either way. | |
| const modifiers = (opts.swapAsym && worldModifiers.asym) | |
| ? { ...worldModifiers, asym: { player: worldModifiers.asym.ai, ai: worldModifiers.asym.player } } | |
| : worldModifiers; | |
| const sizeMult = opts.sizeMult || 1; | |
| const resourceMult = opts.resourceMult || 1; | |
| const width = MAP_WIDTH * sizeMult; | |
| const height = MAP_HEIGHT * sizeMult; | |
| // A node's final amount: its base yield, the world's own richness modifier, | |
| // and the player's Rare/Normal/Abundant resource choice, all folded in. | |
| const amountOf = base => Math.max(1, Math.round(base * (modifiers.nodeAmountMult || 1) * resourceMult)); | |
| const bases = { | |
| player: { x: width * 0.1, y: height * 0.5 }, | |
| ai: { x: width * 0.9, y: height * 0.5 }, | |
| }; | |
| const nodes = []; | |
| let nid = 0; | |
| // Home ore, on every base's doorstep at a fixed absolute distance (see | |
| // HOME_ORE_OFFSETS). Fractional offsets from each base, mirrored across the | |
| // centreline so both starts open onto the same head start. No rng β added | |
| // before the rng-driven clusters so the draw sequence, and thus the rest of | |
| // the map, is untouched. `home` marks them out from the deposit table. | |
| const homeAmount = amountOf(HOME_ORE_AMOUNT); | |
| for (const { dx, dy } of HOME_ORE_OFFSETS) { | |
| nodes.push({ id: `n${nid++}`, com: "ore", amount: homeAmount, max: homeAmount, | |
| x: bases.player.x + dx, y: bases.player.y + dy, home: true }); | |
| nodes.push({ id: `n${nid++}`, com: "ore", amount: homeAmount, max: homeAmount, | |
| x: bases.ai.x - dx, y: bases.ai.y + dy, home: true }); | |
| } | |
| // A near-base cluster on each side, mirrored, sized by the planet's yield. | |
| // x is drawn independently per side (matching the original generator), y | |
| // spreads the clusters down the map. All in fractions of the scaled dims. | |
| Object.entries(planet.deposits).forEach(([com, yieldMult]) => { | |
| const clusters = Math.max(1, Math.round(yieldMult * 1.5)); | |
| for (let i = 0; i < clusters; i++) { | |
| const t = (i + 1) / (clusters + 1); | |
| const y = height * 0.12 + t * height * 0.76; | |
| const amount = amountOf(600 * yieldMult); | |
| nodes.push({ id: `n${nid++}`, com, amount, max: amount, x: width * 0.2 + rng() * width * 0.1, y }); | |
| nodes.push({ id: `n${nid++}`, com, amount, max: amount, x: width * 0.8 - rng() * width * 0.1, y }); | |
| } | |
| }); | |
| // Build-critical minimums: any of ore/crystals/radioactives the surface | |
| // doesn't already offer near the player base gets a lean mirrored seam, so | |
| // every build is possible on every world. Checked (and added) in a fixed | |
| // order so the rng draw sequence β and thus the map β stays deterministic. | |
| // Placed before the caches so a hidden cache can never satisfy the check. | |
| const nearBase = width * NEAR_BASE_FRAC; | |
| for (const com of BUILD_CRITICAL) { | |
| // Home ore is excluded here so the seam logic is exactly as it always was: | |
| // the deposit table alone decides whether a world needs a guaranteed seam, | |
| // keeping the rng draw sequence and node layout byte-identical. | |
| const has = nodes.some(n => n.com === com && !n.home && | |
| Math.hypot(n.x - bases.player.x, n.y - bases.player.y) <= nearBase); | |
| if (has) continue; | |
| const y = height * (0.5 + GUARANTEE_Y[com]); | |
| const amount = amountOf(MIN_GUARANTEE[com]); | |
| nodes.push({ id: `n${nid++}`, com, amount, max: amount, x: width * 0.2 + rng() * width * 0.1, y }); | |
| nodes.push({ id: `n${nid++}`, com, amount, max: amount, x: width * 0.8 - rng() * width * 0.1, y }); | |
| } | |
| // A world can seed extra deposit clusters (helix's dense crystal belt), | |
| // mirrored per side, stacked around mid-map. Before resolveNodeOverlaps so | |
| // the newcomers get spread apart from the deposit-table nodes just the same. | |
| Object.entries(modifiers.extraClusters || {}).forEach(([com, extra]) => { | |
| for (let i = 0; i < extra; i++) { | |
| const y = height * 0.5 + (i - (extra - 1) / 2) * height * 0.12; | |
| const amount = amountOf(600 * (planet.deposits[com] || 1)); | |
| nodes.push({ id: `n${nid++}`, com, amount, max: amount, x: width * 0.2 + rng() * width * 0.1, y }); | |
| nodes.push({ id: `n${nid++}`, com, amount, max: amount, x: width * 0.8 - rng() * width * 0.1, y }); | |
| } | |
| }); | |
| // Frontier belt: on bigger maps (sizeMult >= 2), a mirrored belt of full-size | |
| // VISIBLE deposit clusters seeded in the contested middle (x ~0.35-0.45), | |
| // one additional mirrored set per size step above 1, cycling the world's own | |
| // deposit commodities. sizeMult used to only grow the hidden caches | |
| // (0.6x singletons below) β a Gigantic map was the same economy stretched | |
| // over 16x area with nothing contestable in the middle. Now each size tier | |
| // adds a real fight over new ground, not just a longer walk. Gated strictly | |
| // on sizeMult >= 2 and placed after every earlier rng-consuming block, so a | |
| // sizeMult=1 game's rng draw sequence β and thus its node layout β stays | |
| // byte-identical (test/map.test.js's byte-for-byte pin). `frontier` marks | |
| // these out from the deposit-table nodes, same idiom as `home`/`hidden`. | |
| if (sizeMult >= 2) { | |
| const beltComs = Object.keys(planet.deposits); | |
| const beltSteps = sizeMult - 1; | |
| for (let i = 0; i < beltSteps; i++) { | |
| const com = beltComs[i % beltComs.length]; | |
| const y = height * 0.5 + (i - (beltSteps - 1) / 2) * height * 0.12; | |
| const amount = amountOf(600 * (planet.deposits[com] || 1)); | |
| nodes.push({ id: `n${nid++}`, com, amount, max: amount, x: width * 0.35 + rng() * width * 0.10, y, frontier: true }); | |
| nodes.push({ id: `n${nid++}`, com, amount, max: amount, x: width * 0.65 - rng() * width * 0.10, y, frontier: true }); | |
| } | |
| } | |
| // Hidden resource caches: extra deposits the survey missed, out in the | |
| // contested middle and along the vertical extremes, invisible until a unit | |
| // scouts their cell (fog.js's isNodeDiscovered). Fixed, mirrored fractional | |
| // positions β the find is gated by fog, not placement luck β and more of | |
| // them on bigger maps so exploring the larger space keeps paying off. | |
| const cacheAmount = amountOf(CACHE_BASE_AMOUNT); | |
| for (const [xf, yf, com, mirror] of cacheSpecs(sizeMult)) { | |
| // Per-match position jitter so cache spots aren't memorizable map knowledge: | |
| // each seed hides them somewhere a little different. A mirrored pair jitters | |
| // its anchor and reflects it (both sides stay equidistant β fair); a | |
| // centerline cache keeps x=0.5 and only shifts vertically. The jitter is | |
| // small and the anchors sit far from both bases, so a cache never lands in | |
| // reach of a start (map.test guards the >300 clearance). | |
| const jx = mirror ? (rng() - 0.5) * 0.08 : 0; // Β±4% of width; centerline stays centered | |
| const jy = (rng() - 0.5) * 0.10; // Β±5% of height | |
| const cx = width * (xf + jx), cy = height * (yf + jy); | |
| nodes.push({ id: `n${nid++}`, com, amount: cacheAmount, max: cacheAmount, x: cx, y: cy, hidden: true }); | |
| if (mirror) nodes.push({ id: `n${nid++}`, com, amount: cacheAmount, max: cacheAmount, x: width - cx, y: cy, hidden: true }); | |
| } | |
| resolveNodeOverlaps(nodes, width, height); | |
| // Index by id so the per-tick node lookups (gather, render, AI) are O(1) | |
| // instead of a linear .find over a node list that grows with map size. Nodes | |
| // are never added or removed after generation (they deplete in place), so the | |
| // Map stays valid for the whole match and holds live references. | |
| const nodesById = new Map(nodes.map(n => [n.id, n])); | |
| // Static terrain field from this world's fixed specs (none β an all-open | |
| // grid). Built after nodes, consumes no rng β determinism unaffected. | |
| const terrain = generateTerrain(width, height, modifiers.terrain || []); | |
| return { planet, width, height, bases, nodes, nodesById, terrain, modifiers }; | |
| } | |
| // Hidden-cache placements as [xFrac, yFrac, commodity, mirror?]: mirror pairs | |
| // the spot across the map's vertical centerline for fairness; a centerline | |
| // spot (xFrac 0.5) is left single (equidistant from both bases). All sit clear | |
| // of the base-side deposit clusters, out where you have to explore. Bigger | |
| // maps add extra mirrored pairs tiling the wider middle, cycling commodities. | |
| function cacheSpecs(sizeMult) { | |
| const specs = [ | |
| [0.375, 0.20, "crystals", true], | |
| [0.375, 0.80, "radioactives", true], | |
| [0.4375, 0.50, "ore", true], | |
| [0.5, 0.15, "radioactives", false], | |
| [0.5, 0.85, "crystals", false], | |
| ]; | |
| const coms = ["crystals", "radioactives", "ore"]; | |
| let k = 0; | |
| for (let layer = 1; layer < sizeMult; layer++) { | |
| const xf = 0.30 + (layer / sizeMult) * 0.18; | |
| for (const yf of [0.30, 0.50, 0.70]) specs.push([xf, yf, coms[k++ % coms.length], true]); | |
| } | |
| return specs; | |
| } | |
| /* ---------- per-planet rule modifiers ---------- */ | |
| // Per-planet RTS-only combat/economy tweaks, keyed by planet id. These live | |
| // engine-side (data.js is carried over verbatim from the turn-based game and | |
| // stays pure flavor data) and get threaded into movement/fog/combat/production | |
| // as `state.map.modifiers`. A world with no entry here plays by the defaults β | |
| // which is why ferros/korrath/vesper (the original three) deliberately carry | |
| // none, keeping their long-established sim behavior unchanged. | |
| export const PLANET_MODIFIERS = { | |
| // `terrain` (optional) is a list of feature specs (see generateTerrain): | |
| // [xFrac, yFrac, wFrac, hFrac, code, mirror?], code 1=rough, 2=high ground. | |
| glacius: { | |
| speedMult: 0.9, label: "Frozen ground: all units 10% slower; ice fields flank a central lane", | |
| // Rough ice fields top and bottom of the midline pinch armies through an | |
| // open central corridor β a soft choke on top of the world's global slow. | |
| terrain: [[0.5, 0.13, 0.34, 0.2, 1, false], [0.5, 0.87, 0.34, 0.2, 1, false]], | |
| }, | |
| nimbus: { | |
| sightMult: 0.75, label: "Storm front (asymmetric): your skies are clearer; the enemy surges out of the murk", | |
| // On a short-sight world, high ground (which extends sight) is doubly worth | |
| // taking β a way to see over the storm. Two vantages, north and south of | |
| // the midline, kept off the centre so neither base overlooks the field. | |
| terrain: [[0.5, 0.28, 0.12, 0.14, 2, false], [0.5, 0.72, 0.12, 0.14, 2, false]], | |
| // Asymmetric matchup: the storm has half-cleared YOUR side (you see almost | |
| // normally, 0.95 vs the enemy's 0.75), but the enemy strikes fast out of it | |
| // (units 12% quicker). You out-scout; they out-tempo. | |
| asym: { player: { sightMult: 0.95 }, ai: { speedMult: 1.12 } }, | |
| }, | |
| pyralis: { | |
| sightMult: 1.15, label: "Open dunes: long sightlines, and a central mesa worth holding", | |
| // High-ground mesa in the contested middle: extra sight and a damage edge | |
| // for whoever seizes it β a real objective on an otherwise open field. | |
| terrain: [[0.5, 0.5, 0.16, 0.26, 2, false]], | |
| }, | |
| helix: { | |
| extraClusters: { crystals: 1 }, label: "Dense belt: an extra crystal field per side, and a central ridge to hold", | |
| // A crystalline high-ground ridge down the centreline β the contested spine | |
| // of the belt, giving sight and a combat edge to whoever seizes the middle. | |
| terrain: [[0.5, 0.5, 0.1, 0.38, 2, false]], | |
| }, | |
| oort: { | |
| nodeAmountMult: 1.3, label: "Contested frontier (asymmetric): your claim is richer; the enemy's foundry runs hotter", | |
| // Rugged rough ground on the flanks funnels the fight through the open | |
| // centre β the price of the world's rich but broken frontier. | |
| terrain: [[0.4, 0.28, 0.12, 0.18, 1, true], [0.4, 0.72, 0.12, 0.18, 1, true]], | |
| // Asymmetric matchup: YOUR claim struck a rich vein (every haul banks 20% | |
| // more), while the enemy's forward base is a war factory (18% faster | |
| // construction and production). You out-mine; they out-build. | |
| asym: { player: { gatherMult: 1.2 }, ai: { buildTimeMult: 0.82 } }, | |
| }, | |
| forge: { | |
| buildTimeMult: 0.85, label: "Factory world: 15% faster construction; rough industrial sprawl midfield", | |
| // Scattered rough ground on the approach makes the flanks slow going and | |
| // the direct centre the fast lane. | |
| terrain: [[0.4, 0.32, 0.13, 0.18, 1, true], [0.4, 0.68, 0.13, 0.18, 1, true]], | |
| }, | |
| }; | |
| // Each commodity picks its cluster spots independently, so two different | |
| // deposit types can land on (or right next to) the same point β same | |
| // stacking problem as units, just at generation time instead of every | |
| // tick. A fixed number of relaxation passes nudges every overlapping pair | |
| // apart regardless of what they are, until none are left (or the budget | |
| // runs out on a pathological case rather than looping forever). | |
| // Matches drawNodes' max render radius (7 + 9) in render.js. Exported | |
| // because colliders.js treats it as the node's physical footprint too β | |
| // what the map draws and what a building must keep clear of stay one number. | |
| export const NODE_RADIUS = 16; | |
| const RESOLVE_ITERATIONS = 40; | |
| function resolveNodeOverlaps(nodes, width, height) { | |
| const minDist = NODE_RADIUS * 2; | |
| for (let iter = 0; iter < RESOLVE_ITERATIONS; iter++) { | |
| let moved = false; | |
| for (let i = 0; i < nodes.length; i++) { | |
| for (let j = i + 1; j < nodes.length; j++) { | |
| const a = nodes[i], b = nodes[j]; | |
| let dx = b.x - a.x, dy = b.y - a.y; | |
| let dist = Math.hypot(dx, dy); | |
| if (dist >= minDist) continue; | |
| moved = true; | |
| if (dist < 1e-4) { dx = 1; dy = 0; dist = 1; } | |
| const push = (minDist - dist) / 2; | |
| const nx = dx / dist, ny = dy / dist; | |
| a.x -= nx * push; a.y -= ny * push; | |
| b.x += nx * push; b.y += ny * push; | |
| } | |
| } | |
| if (!moved) break; | |
| } | |
| for (const n of nodes) { | |
| n.x = Math.min(Math.max(n.x, 20), width - 20); | |
| n.y = Math.min(Math.max(n.y, 20), height - 20); | |
| } | |
| } | |