#!/usr/bin/env node /** * Generate static HTML for space-route worksheets. * Run: node src/scripts/generate-space-route.mjs * Output: output/html/space-route-1.html */ import { writeFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..', '..'); // ============================================================ // CONFIGURATION PER PAGE // ============================================================ // Asset path shortcuts const A = (n) => `../../assets/icons/pack3-asteroids/asteroid${n}.png`; const P = (n) => `../../assets/items/freighters/pod${n}.png`; const F = (n) => `../../assets/items/freighters/freighter${n}.png`; const PAGES = [ // Page 1: [3,8] — dest: asteroid { enemyShipImg: '../../assets/hero-images/spaceship4.jpeg', enemyLabel: 'Пиратский крейсер', playerShipImg: '../../assets/hero-images/spaceship2.png', footerImg: '../../assets/footers/planet3.jpeg', destObject: { img: A(5), w: 30, h: 30 }, decoObjects: [ { img: A(3), w: 24, h: 24 }, { img: A(7), w: 24, h: 24 }, { img: A(11), w: 24, h: 24 }, { img: P(1), w: 28, h: 18 }, { img: P(2), w: 28, h: 18 }, { img: F(1), w: 38, h: 20 }, ], diffRange: [3, 8], routeLength: 8, seed: 42001, flipEnemy: false, flipPlayer: true, }, // Page 2: [4,10] — dest: pod (radioactive) { enemyShipImg: '../../assets/hero-images/spaceship6.jpeg', enemyLabel: 'Патрульный разведчик', playerShipImg: '../../assets/hero-images/spaceship7.png', footerImg: '../../assets/footers/planet5.jpeg', destObject: { img: P(6), w: 30, h: 30 }, decoObjects: [ { img: A(9), w: 24, h: 24 }, { img: A(14), w: 24, h: 24 }, { img: A(2), w: 24, h: 24 }, { img: P(4), w: 28, h: 18 }, { img: P(5), w: 28, h: 18 }, { img: F(2), w: 38, h: 20 }, ], diffRange: [4, 10], routeLength: 8, seed: 42002, flipEnemy: true, flipPlayer: true, }, // Page 3: [2,18] — dest: freighter (stealth scout) { enemyShipImg: '../../assets/hero-images/spaceship3.jpeg', enemyLabel: 'Контрабандист', playerShipImg: '../../assets/hero-images/spaceship9.png', footerImg: '../../assets/footers/planet1.jpeg', destObject: { img: F(8), w: 38, h: 24 }, decoObjects: [ { img: A(1), w: 24, h: 24 }, { img: A(10), w: 24, h: 24 }, { img: A(6), w: 24, h: 24 }, { img: P(8), w: 28, h: 18 }, { img: P(10), w: 28, h: 18 }, { img: F(5), w: 38, h: 20 }, ], diffRange: [2, 18], routeLength: 9, seed: 42003, flipEnemy: false, flipPlayer: true, }, // Page 4: [6,12] — dest: pod (zoo biodome) { enemyShipImg: '../../assets/hero-images/spaceship1.jpeg', enemyLabel: 'Шахтёрский транспорт', playerShipImg: '../../assets/hero-images/spaceship8.png', footerImg: '../../assets/footers/planet7.jpeg', destObject: { img: P(16), w: 32, h: 28 }, decoObjects: [ { img: A(12), w: 24, h: 24 }, { img: A(4), w: 24, h: 24 }, { img: A(15), w: 24, h: 24 }, { img: P(11), w: 28, h: 18 }, { img: P(13), w: 28, h: 18 }, { img: F(3), w: 38, h: 20 }, ], diffRange: [6, 12], routeLength: 8, seed: 42004, flipEnemy: true, flipPlayer: true, }, // Page 5: [4,15] — dest: asteroid { enemyShipImg: '../../assets/hero-images/spaceship5.jpeg', enemyLabel: 'Корабль-призрак', playerShipImg: '../../assets/hero-images/spaceship3.png', footerImg: '../../assets/footers/planet2.jpeg', destObject: { img: A(16), w: 30, h: 30 }, decoObjects: [ { img: A(8), w: 24, h: 24 }, { img: A(13), w: 24, h: 24 }, { img: A(3), w: 24, h: 24 }, { img: P(7), w: 28, h: 18 }, { img: P(9), w: 28, h: 18 }, { img: F(6), w: 38, h: 20 }, ], diffRange: [4, 15], routeLength: 8, seed: 42005, flipEnemy: true, flipPlayer: false, }, // Page 6: [8,20] — HARD — dest: freighter (military) { enemyShipImg: '../../assets/hero-images/spaceship7.jpeg', enemyLabel: 'Военный фрегат', playerShipImg: '../../assets/hero-images/spaceship2.png', footerImg: '../../assets/footers/planet4.jpeg', destObject: { img: F(2), w: 38, h: 24 }, decoObjects: [ { img: F(9), w: 38, h: 20 }, { img: F(10), w: 38, h: 20 }, { img: P(14), w: 28, h: 18 }, { img: P(15), w: 28, h: 18 }, { img: P(17), w: 28, h: 18 }, { img: A(3), w: 24, h: 24 }, { img: A(11), w: 24, h: 24 }, ], diffRange: [8, 20], routeLength: 9, seed: 42006, flipEnemy: true, flipPlayer: true, }, // Page 7: [3,12] — dest: pod (comms relay) { enemyShipImg: '../../assets/hero-images/spaceship9.jpeg', enemyLabel: 'Торговый караван', playerShipImg: '../../assets/hero-images/spaceship8.png', footerImg: '../../assets/footers/planet6.jpeg', destObject: { img: P(18), w: 32, h: 28 }, decoObjects: [ { img: A(7), w: 24, h: 24 }, { img: A(15), w: 24, h: 24 }, { img: A(5), w: 24, h: 24 }, { img: P(3), w: 28, h: 18 }, { img: P(12), w: 28, h: 18 }, { img: F(4), w: 38, h: 20 }, ], diffRange: [3, 12], routeLength: 8, seed: 42007, flipEnemy: true, flipPlayer: true, }, // Page 8: [2,8] — dest: freighter (science) { enemyShipImg: '../../assets/hero-images/spaceship2.jpeg', enemyLabel: 'Исследователь', playerShipImg: '../../assets/hero-images/spaceship3.png', footerImg: '../../assets/footers/planet8.jpeg', destObject: { img: F(12), w: 38, h: 20 }, decoObjects: [ { img: A(9), w: 24, h: 24 }, { img: A(8), w: 24, h: 24 }, { img: A(14), w: 24, h: 24 }, { img: P(1), w: 28, h: 18 }, { img: P(16), w: 28, h: 18 }, { img: F(7), w: 38, h: 20 }, ], diffRange: [2, 8], routeLength: 7, seed: 42008, flipEnemy: true, flipPlayer: false, }, // Page 9: [10,30] — HARD — dest: pod (VIP) { enemyShipImg: '../../assets/hero-images/spaceship8.jpeg', enemyLabel: 'Рейдер', playerShipImg: '../../assets/hero-images/spaceship9.png', footerImg: '../../assets/footers/planet9.jpeg', destObject: { img: P(14), w: 32, h: 22 }, decoObjects: [ { img: F(11), w: 38, h: 20 }, { img: F(5), w: 38, h: 20 }, { img: P(18), w: 28, h: 18 }, { img: P(6), w: 28, h: 18 }, { img: P(10), w: 28, h: 18 }, { img: A(16), w: 24, h: 24 }, { img: A(1), w: 24, h: 24 }, ], diffRange: [10, 30], routeLength: 9, seed: 42009, flipEnemy: true, flipPlayer: true, }, ]; const PLAYER_ROUTE_CELLS = 10; const GRID_COLS = 7; const GRID_ROWS = 9; const NODE_R_MM = 5; const MAP_TOP = 44; const MAP_BOTTOM = 242; const MAP_LEFT = 14; const MAP_RIGHT = 196; // ============================================================ // SEEDED PRNG // ============================================================ let _seed = 0; function seedRng(s) { _seed = s | 0; } function seededRandom() { _seed |= 0; _seed = _seed + 0x6D2B79F5 | 0; let t = Math.imul(_seed ^ _seed >>> 15, 1 | _seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; } function rand(min, max) { return Math.floor(seededRandom() * (max - min + 1)) + min; } function randF(min, max) { return min + seededRandom() * (max - min); } function distN(a, b) { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); } // ============================================================ // GRAPH // ============================================================ function generateNodes() { const nodes = []; const cellW = (MAP_RIGHT - MAP_LEFT) / GRID_COLS; const cellH = (MAP_BOTTOM - MAP_TOP) / GRID_ROWS; const jitter = Math.min(cellW, cellH) * 0.25; let id = 0; for (let row = 0; row < GRID_ROWS; row++) { for (let col = 0; col < GRID_COLS; col++) { const hexOffset = (row % 2 === 1) ? cellW * 0.5 : 0; const baseX = MAP_LEFT + col * cellW + cellW / 2 + hexOffset; const baseY = MAP_TOP + row * cellH + cellH / 2; if (baseX < MAP_LEFT + 5 || baseX > MAP_RIGHT - 5) continue; const x = baseX + randF(-jitter, jitter); const y = baseY + randF(-jitter, jitter); const cx = Math.max(MAP_LEFT + NODE_R_MM, Math.min(MAP_RIGHT - NODE_R_MM, x)); const cy = Math.max(MAP_TOP + NODE_R_MM, Math.min(MAP_BOTTOM - NODE_R_MM, y)); nodes.push({ x: cx, y: cy, id: id++, value: 0, neighbors: [] }); } } return nodes; } function generateEdges(nodes) { const edges = []; const maxEdgeDist = 45; const candidates = []; for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const d = distN(nodes[i], nodes[j]); if (d <= maxEdgeDist) candidates.push({ i, j, d }); } } candidates.sort((a, b) => a.d - b.d); const MAX_DEGREE = 5; for (const { i, j } of candidates) { if (nodes[i].neighbors.length >= MAX_DEGREE || nodes[j].neighbors.length >= MAX_DEGREE) continue; let crosses = false; for (const e of edges) { if (e.i === i || e.j === i || e.i === j || e.j === j) continue; if (segmentsIntersect(nodes[i], nodes[j], nodes[e.i], nodes[e.j])) { crosses = true; break; } } if (!crosses) { edges.push({ i, j }); nodes[i].neighbors.push(j); nodes[j].neighbors.push(i); } } ensureConnected(nodes, edges); for (const n of nodes) { if (n.neighbors.length < 2) { let best = null, bestDist = Infinity; for (const other of nodes) { if (other.id === n.id || n.neighbors.includes(other.id)) continue; const d = distN(n, other); if (d < bestDist) { bestDist = d; best = other; } } if (best) { edges.push({ i: n.id, j: best.id }); n.neighbors.push(best.id); best.neighbors.push(n.id); } } } return edges; } function trimNodeDegree(nodes, edges, nodeId, targetDegree) { const n = nodes[nodeId]; while (n.neighbors.length > targetDegree) { let farthestNb = -1, farthestDist = 0; for (const nb of n.neighbors) { const d = distN(n, nodes[nb]); if (d > farthestDist) { farthestDist = d; farthestNb = nb; } } n.neighbors = n.neighbors.filter(nb => nb !== farthestNb); nodes[farthestNb].neighbors = nodes[farthestNb].neighbors.filter(nb => nb !== nodeId); for (let ei = edges.length - 1; ei >= 0; ei--) { const e = edges[ei]; if ((e.i === nodeId && e.j === farthestNb) || (e.j === nodeId && e.i === farthestNb)) edges.splice(ei, 1); } } } function ensureConnected(nodes, edges) { const component = new Array(nodes.length).fill(-1); let compId = 0; for (let i = 0; i < nodes.length; i++) { if (component[i] !== -1) continue; const queue = [i]; component[i] = compId; while (queue.length) { const cur = queue.shift(); for (const nb of nodes[cur].neighbors) { if (component[nb] === -1) { component[nb] = compId; queue.push(nb); } } } compId++; } for (let c = 1; c < compId; c++) { let best = null, bestDist = Infinity; for (let i = 0; i < nodes.length; i++) { if (component[i] !== 0) continue; for (let j = 0; j < nodes.length; j++) { if (component[j] !== c) continue; const d = distN(nodes[i], nodes[j]); if (d < bestDist) { bestDist = d; best = { i, j }; } } } if (best) { edges.push(best); nodes[best.i].neighbors.push(best.j); nodes[best.j].neighbors.push(best.i); for (let k = 0; k < nodes.length; k++) { if (component[k] === c) component[k] = 0; } } } } function segmentsIntersect(p1, p2, p3, p4) { const d1 = crossP(p3, p4, p1), d2 = crossP(p3, p4, p2); const d3 = crossP(p1, p2, p3), d4 = crossP(p1, p2, p4); return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); } function crossP(pi, pj, pk) { return (pk.x - pi.x) * (pj.y - pi.y) - (pj.x - pi.x) * (pk.y - pi.y); } // ============================================================ // VALUES // ============================================================ function assignValues(nodes, minVal, maxSpread) { const maxVal = minVal + Math.ceil(maxSpread * 1.3); const order = [...Array(nodes.length).keys()]; order.sort((a, b) => nodes[b].neighbors.length - nodes[a].neighbors.length); for (let attempt = 0; attempt < 300; attempt++) { if (tryAssign(nodes, order, minVal, maxSpread, maxVal)) { if (validateGraph(nodes, minVal, maxSpread)) return true; } for (let i = order.length - 1; i > 0; i--) { if (seededRandom() < 0.4) { const j = rand(0, i); [order[i], order[j]] = [order[j], order[i]]; } } } return false; } function tryAssign(nodes, order, minVal, maxSpread, maxVal) { for (const n of nodes) n.value = -1; for (const idx of order) { const candidates = []; for (let v = minVal; v <= maxVal; v++) { let valid = true; const signedDiffs = []; for (const nb of nodes[idx].neighbors) { if (nodes[nb].value === -1) continue; const absDiff = Math.abs(v - nodes[nb].value); if (absDiff < 1 || absDiff > maxSpread) { valid = false; break; } const sd = v - nodes[nb].value; if (signedDiffs.includes(sd)) { valid = false; break; } signedDiffs.push(sd); } if (!valid) continue; for (const nb of nodes[idx].neighbors) { if (nodes[nb].value === -1) continue; const myDiff = v - nodes[nb].value; for (const nb2 of nodes[nb].neighbors) { if (nb2 === idx || nodes[nb2].value === -1) continue; if (nodes[nb2].value - nodes[nb].value === myDiff) { valid = false; break; } } if (!valid) break; } if (valid) candidates.push(v); } if (candidates.length === 0) return false; const center = minVal + maxSpread * 0.5; candidates.sort((a, b) => Math.abs(a - center) - Math.abs(b - center)); const pick = Math.min(candidates.length - 1, Math.floor(seededRandom() * Math.min(candidates.length, 4))); nodes[idx].value = candidates[pick]; } return true; } function validateGraph(nodes, minVal, maxSpread) { for (const n of nodes) { if (n.value < minVal) return false; const diffs = []; for (const nb of n.neighbors) { const d = nodes[nb].value - n.value; if (Math.abs(d) < 1 || Math.abs(d) > maxSpread) return false; if (diffs.includes(d)) return false; diffs.push(d); } } return true; } // ============================================================ // ROUTES // ============================================================ function findCornerNode(nodes, corner) { let sorted; if (corner === 'top-left') sorted = [...nodes].sort((a, b) => (a.x + a.y * 2) - (b.x + b.y * 2)); else sorted = [...nodes].sort((a, b) => (a.x - a.y * 2) - (b.x - b.y * 2)); return sorted[0]; } function findDirectionalRoute(nodes, startId, length, targetArea) { const best = { path: null, score: -Infinity }; for (let trial = 0; trial < 30; trial++) { const path = [startId]; if (buildDirectionalPath(nodes, path, length, targetArea)) { const score = routeQualityScore(nodes, path, targetArea); if (score > best.score) { best.path = [...path]; best.score = score; } } } return best.path; } // Route uses a waypoint system: go toward waypoint first, then toward final target. // This creates natural direction changes without looping. function buildDirectionalPath(nodes, path, targetLen, target) { // Pick a waypoint roughly halfway through the route — offset from straight line const start = nodes[path[0]]; const midX = (start.x + target.x) / 2; const midY = (start.y + target.y) / 2; // Offset waypoint perpendicular to the start→target line const dx = target.x - start.x; const dy = target.y - start.y; const perpX = -dy * 0.4 * (seededRandom() > 0.5 ? 1 : -1); const perpY = dx * 0.4 * (seededRandom() > 0.5 ? 1 : -1); const waypoint = { x: Math.max(MAP_LEFT + 20, Math.min(MAP_RIGHT - 20, midX + perpX)), y: Math.max(MAP_TOP + 20, Math.min(MAP_BOTTOM - 20, midY + perpY)) }; return buildPathWithWaypoint(nodes, path, targetLen, waypoint, target); } function buildPathWithWaypoint(nodes, path, targetLen, waypoint, finalTarget) { if (path.length === targetLen + 1) return true; const current = nodes[path[path.length - 1]]; const nbs = [...current.neighbors].filter(nb => !path.includes(nb)); if (nbs.length === 0) return false; // First ~40% of route: head toward waypoint. Rest: head toward final target. const progress = path.length / (targetLen + 1); const activeTarget = progress < 0.4 ? waypoint : finalTarget; const scored = nbs.map(nb => { const n = nodes[nb]; const distToTarget = Math.sqrt((n.x - activeTarget.x) ** 2 + (n.y - activeTarget.y) ** 2); const curDist = Math.sqrt((current.x - activeTarget.x) ** 2 + (current.y - activeTarget.y) ** 2); const stepsLeft = targetLen - path.length; const randomFactor = stepsLeft <= 2 ? randF(-25, 25) : randF(-10, 10); return { nb, score: (curDist - distToTarget) + randomFactor }; }); scored.sort((a, b) => b.score - a.score); for (const { nb } of scored) { path.push(nb); if (buildPathWithWaypoint(nodes, path, targetLen, waypoint, finalTarget)) return true; path.pop(); } return false; } function routeQualityScore(nodes, path, target) { const start = nodes[path[0]], end = nodes[path[path.length - 1]]; const endDist = Math.sqrt((end.x - target.x) ** 2 + (end.y - target.y) ** 2); // Prefer routes that cover more area (spread out) let totalDist = 0; for (let i = 1; i < path.length; i++) totalDist += distN(nodes[path[i - 1]], nodes[path[i]]); const straightLine = distN(start, end); // Reward: close to target, long travel distance (not straight), reasonable spread return -endDist * 0.5 + totalDist * 0.1 + straightLine * 0.2; } function findShortestPath(nodes, fromId, toId) { const prev = new Array(nodes.length).fill(-1); const visited = new Set([fromId]); const queue = [fromId]; while (queue.length) { const cur = queue.shift(); if (cur === toId) break; for (const nb of nodes[cur].neighbors) { if (!visited.has(nb)) { visited.add(nb); prev[nb] = cur; queue.push(nb); } } } if (!visited.has(toId)) return null; const path = []; let cur = toId; while (cur !== -1) { path.unshift(cur); cur = prev[cur]; } return path; } // ============================================================ // PAGE GENERATION // ============================================================ function generatePage(config, pageIndex) { seedRng(config.seed); let nodes, edges, enemyRoute, playerRoute; let success = false; for (let attempt = 0; attempt < 80; attempt++) { nodes = generateNodes(); edges = generateEdges(nodes); const enemyStartNode = findCornerNode(nodes, 'top-left'); const playerStartNode = findCornerNode(nodes, 'bottom-left'); trimNodeDegree(nodes, edges, enemyStartNode.id, 2); trimNodeDegree(nodes, edges, playerStartNode.id, 2); const [minVal, maxSpread] = config.diffRange; if (!assignValues(nodes, minVal, maxSpread)) continue; const targetArea = { x: MAP_RIGHT - 30, y: MAP_BOTTOM - 40 }; enemyRoute = findDirectionalRoute(nodes, enemyStartNode.id, config.routeLength, targetArea); if (!enemyRoute) continue; const dest = enemyRoute[enemyRoute.length - 1]; if (playerStartNode.id === dest || enemyRoute.includes(playerStartNode.id)) continue; playerRoute = findShortestPath(nodes, playerStartNode.id, dest); if (playerRoute && playerRoute.length >= 3) { success = true; break; } } if (!success) return `
Page ${pageIndex + 1}: Generation failed
`; // ---- ROUTE VALIDATION ---- const [minVal, maxSpread] = config.diffRange; console.log(` Page ${pageIndex + 1} validation:`); // Validate enemy route console.log(` Enemy route (${enemyRoute.length} nodes): ${enemyRoute.map(id => `[${id}]=${nodes[id].value}`).join(' → ')}`); const enemyDiffs = []; let enemyValid = true; for (let i = 0; i < enemyRoute.length - 1; i++) { const from = enemyRoute[i], to = enemyRoute[i + 1]; const isNeighbor = nodes[from].neighbors.includes(to); const diff = nodes[to].value - nodes[from].value; enemyDiffs.push(diff); if (!isNeighbor) { console.log(` ❌ EDGE MISSING: node ${from}(${nodes[from].value}) → ${to}(${nodes[to].value}) are NOT neighbors!`); enemyValid = false; } } console.log(` Enemy diffs: ${enemyDiffs.map(d => (d > 0 ? '+' : '') + d).join(', ')}`); // Check uniqueness at each step for (let i = 0; i < enemyRoute.length - 1; i++) { const from = enemyRoute[i]; const diff = enemyDiffs[i]; // Check no other neighbor of 'from' has the same diff const conflicting = nodes[from].neighbors.filter(nb => nb !== enemyRoute[i + 1] && (nodes[nb].value - nodes[from].value) === diff); if (conflicting.length > 0) { console.log(` ❌ AMBIGUITY at node ${from}(${nodes[from].value}): diff ${diff > 0 ? '+' : ''}${diff} also leads to node(s) ${conflicting.map(c => `${c}(${nodes[c].value})`).join(', ')}`); enemyValid = false; } } if (enemyValid) console.log(` ✅ Enemy route valid`); // Validate player route console.log(` Player route (${playerRoute.length} nodes): ${playerRoute.map(id => `[${id}]=${nodes[id].value}`).join(' → ')}`); let playerValid = true; for (let i = 0; i < playerRoute.length - 1; i++) { const from = playerRoute[i], to = playerRoute[i + 1]; if (!nodes[from].neighbors.includes(to)) { console.log(` ❌ EDGE MISSING: node ${from}(${nodes[from].value}) → ${to}(${nodes[to].value}) are NOT neighbors!`); playerValid = false; } } // Check player reaches same destination as enemy const playerDest = playerRoute[playerRoute.length - 1]; const enemyDest = enemyRoute[enemyRoute.length - 1]; if (playerDest !== enemyDest) { console.log(` ❌ DESTINATION MISMATCH: player ends at ${playerDest}(${nodes[playerDest].value}), enemy ends at ${enemyDest}(${nodes[enemyDest].value})`); playerValid = false; } else { console.log(` ✅ Player reaches enemy destination: node ${enemyDest}(${nodes[enemyDest].value})`); } if (playerValid) console.log(` ✅ Player route valid`); return renderPage(nodes, edges, enemyRoute, playerRoute, config, pageIndex); } function renderPage(nodes, edges, enemyRoute, playerRoute, config, pageIndex) { const dest = enemyRoute[enemyRoute.length - 1]; const enemyStart = enemyRoute[0]; const playerStart = playerRoute[0]; const decoNodeIds = []; // Exclude all route nodes + neighbors of start nodes (no asteroids near starting points) const usedIds = new Set([enemyStart, playerStart, dest, ...enemyRoute, ...playerRoute, ...nodes[enemyStart].neighbors, ...nodes[playerStart].neighbors]); const availableNodes = nodes.filter(n => !usedIds.has(n.id)); const sortedByRegion = [...availableNodes].sort((a, b) => a.y - b.y); const regionSize = Math.floor(sortedByRegion.length / (config.decoObjects.length + 1)); for (let di = 0; di < config.decoObjects.length; di++) { const regionStart = regionSize * (di + 0.5); const idx = Math.min(Math.floor(regionStart + randF(-2, 2)), sortedByRegion.length - 1); if (idx >= 0 && !decoNodeIds.includes(sortedByRegion[idx].id)) decoNodeIds.push(sortedByRegion[Math.max(0, idx)].id); } let edgeSvg = ''; for (const e of edges) { const n1 = nodes[e.i], n2 = nodes[e.j]; edgeSvg += ``; } const asteroidNodeIds = new Set([dest, ...decoNodeIds]); let nodesHtml = ''; for (const n of nodes) { const isEnemyStart = n.id === enemyStart; const isPlayerStart = n.id === playerStart; const isStartNode = isEnemyStart || isPlayerStart; const hasAsteroid = asteroidNodeIds.has(n.id); const borderWidth = isStartNode ? '2.5px' : '1.5px'; const borderColor = isEnemyStart ? 'border-red-400' : isPlayerStart ? 'border-sky-400' : 'border-indigo-300'; const bgColor = isEnemyStart ? 'bg-red-50' : isPlayerStart ? 'bg-sky-50' : 'bg-white'; const fontSize = isStartNode ? '13px' : '11px'; const shadow = isEnemyStart ? 'shadow-md shadow-red-200' : isPlayerStart ? 'shadow-md shadow-sky-200' : 'shadow-sm'; const baseSize = isStartNode ? NODE_R_MM * 2.4 : NODE_R_MM * 2; const size = hasAsteroid ? baseSize / 1.5 : baseSize; const offset = size / 2; nodesHtml += `\n
${n.value}
`; } const destNode = nodes[dest]; const dw = config.destObject.w, dh = config.destObject.h; const destObjHtml = `\n `; let decosHtml = ''; for (let di = 0; di < config.decoObjects.length && di < decoNodeIds.length; di++) { const dn = nodes[decoNodeIds[di]]; const ow = config.decoObjects[di].w, oh = config.decoObjects[di].h; decosHtml += `\n `; } let starsHtml = ''; for (let i = 0; i < 70; i++) { const sx = randF(6, 204).toFixed(1); const sy = randF(30, 270).toFixed(1); const size = randF(10, 22).toFixed(0); const opacity = randF(0.12, 0.35).toFixed(2); const symbols = ['✦', '✧', '⋆', '·', '∗', '⊹']; starsHtml += `${symbols[rand(0, symbols.length - 1)]}`; } const enemyDiffs = []; for (let i = 1; i < enemyRoute.length; i++) enemyDiffs.push(nodes[enemyRoute[i]].value - nodes[enemyRoute[i - 1]].value); let enemyRouteCells = `
${nodes[enemyStart].value}
`; for (const diff of enemyDiffs) { const diffStr = (diff > 0 ? '+' : '') + diff; enemyRouteCells += `
${diffStr}
`; } const cellCount = Math.max(PLAYER_ROUTE_CELLS, playerRoute.length - 1); let playerRouteCells = `
${nodes[playerStart].value}
`; for (let i = 0; i < cellCount; i++) { playerRouteCells += `
`; } const isLastPage = pageIndex === PAGES.length - 1; const pageBreak = isLastPage ? '' : 'break-after: page;'; const [minVal, maxSpread] = config.diffRange; const nodeValues = nodes.map(n => n.value); const actualMin = Math.min(...nodeValues); const actualMax = Math.max(...nodeValues); return `
${starsHtml} ${destObjHtml} ${decosHtml} ${edgeSvg} ${nodesHtml}

Проложи Маршрут

Найди путь в космосе!

${config.enemyLabel}:
${enemyRouteCells}
📍 Проследи маршрут — куда он летит?
Рассчитай маршрут до цели перехвата
Твой истребитель:
${playerRouteCells}
✏️ Запиши разницу чисел, чтобы добраться до цели!
[${minVal},${maxSpread}] | ${nodes.length} nodes | values ${actualMin}-${actualMax} | enemy ${enemyRoute.length} steps | player shortest ${playerRoute.length - 1} steps
`; } // ============================================================ // MAIN // ============================================================ let pagesHtml = ''; for (let i = 0; i < PAGES.length; i++) { console.log(`Generating page ${i + 1}...`); pagesHtml += generatePage(PAGES[i], i); } const fullHtml = ` Проложи Маршрут ${pagesHtml} `; const outPath = join(ROOT, 'output', 'html', 'space-route-1.html'); writeFileSync(outPath, fullHtml, 'utf-8'); console.log(`Written to ${outPath}`);