Spaces:
Paused
Paused
File size: 22,052 Bytes
17641e3 19ca109 17641e3 ec7c7c8 17641e3 19ca109 17641e3 19ca109 1c88bda 19ca109 ac7d42f 19ca109 49b784a 03f71c7 ec7c7c8 d228ca7 ec7c7c8 d228ca7 03f71c7 e3658fa 8338353 e3658fa 4f1cb54 17641e3 8338353 17641e3 19ca109 17641e3 19ca109 17641e3 49b784a 19ca109 17641e3 19ca109 17641e3 19ca109 49b784a 19ca109 1c88bda ac7d42f 19ca109 ac7d42f 19ca109 ac7d42f a99fcf2 19ca109 a672b7d df20f1d 19ca109 df20f1d 19ca109 6d7e8fa 03f71c7 17641e3 ffc36f9 19ca109 03f71c7 01bf120 d228ca7 01bf120 d228ca7 01bf120 03f71c7 01bf120 d228ca7 01bf120 d228ca7 01bf120 03f71c7 19ca109 df20f1d ffc36f9 1c88bda ffc36f9 19ca109 1c88bda ffc36f9 19ca109 ffc36f9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 | /* ============================================================
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.
============================================================ */
"use strict";
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);
}
}
|