math-tasks/tasks/space-route/scripts/generate-route.mjs

743 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 `<div class="w-[210mm] h-[297mm] mx-auto bg-white flex items-center justify-center text-red-500 text-xl">Page ${pageIndex + 1}: Generation failed</div>`;
// ---- 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 += `<line data-edge="${e.i}-${e.j}" x1="${n1.x.toFixed(1)}mm" y1="${n1.y.toFixed(1)}mm" x2="${n2.x.toFixed(1)}mm" y2="${n2.y.toFixed(1)}mm" stroke="#a5b4fc" stroke-width="0.7" stroke-dasharray="2.5 2" stroke-linecap="round" opacity="0.45"/>`;
}
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 <div class="absolute flex items-center justify-center rounded-full ${borderColor} ${bgColor} ${shadow}" data-node-id="${n.id}" data-neighbors="${n.neighbors.join(',')}" style="left:${n.x.toFixed(1)}mm;top:${n.y.toFixed(1)}mm;width:${size.toFixed(1)}mm;height:${size.toFixed(1)}mm;margin-left:-${offset.toFixed(1)}mm;margin-top:-${offset.toFixed(1)}mm;z-index:15;border-width:${borderWidth};"><span class="font-extrabold text-indigo-950 leading-none" style="font-size:${fontSize};">${n.value}</span></div>`;
}
const destNode = nodes[dest];
const dw = config.destObject.w, dh = config.destObject.h;
const destObjHtml = `\n <img src="${config.destObject.img}" class="absolute object-contain space-obj" data-node-id="${dest}" data-type="dest" style="left:${destNode.x.toFixed(1)}mm;top:${destNode.y.toFixed(1)}mm;width:${dw}mm;height:${dh}mm;margin-left:-${dw/2}mm;margin-top:-${dh/2}mm;z-index:4;" alt="">`;
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 <img src="${config.decoObjects[di].img}" class="absolute object-contain space-obj" data-node-id="${decoNodeIds[di]}" data-type="deco" style="left:${dn.x.toFixed(1)}mm;top:${dn.y.toFixed(1)}mm;width:${ow}mm;height:${oh}mm;margin-left:-${ow/2}mm;margin-top:-${oh/2}mm;z-index:4;" alt="">`;
}
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 += `<span class="absolute text-indigo-300" style="left:${sx}mm;top:${sy}mm;font-size:${size}px;opacity:${opacity};z-index:2;pointer-events:none;">${symbols[rand(0, symbols.length - 1)]}</span>`;
}
const enemyDiffs = [];
for (let i = 1; i < enemyRoute.length; i++) enemyDiffs.push(nodes[enemyRoute[i]].value - nodes[enemyRoute[i - 1]].value);
let enemyRouteCells = `<div class="inline-flex items-center justify-center rounded-full border-[2px] border-red-400 bg-red-50 shrink-0" style="width:8mm;height:8mm;"><span class="text-[12px] font-extrabold text-indigo-950">${nodes[enemyStart].value}</span></div>`;
for (const diff of enemyDiffs) {
const diffStr = (diff > 0 ? '+' : '') + diff;
enemyRouteCells += `<div class="inline-flex items-center justify-center shrink-0 border-[1.5px] border-indigo-300 rounded bg-white" style="width:9mm;height:7mm;margin-left:1mm;"><span class="text-[11px] font-bold text-indigo-950">${diffStr}</span></div>`;
}
const cellCount = Math.max(PLAYER_ROUTE_CELLS, playerRoute.length - 1);
let playerRouteCells = `<div class="inline-flex items-center justify-center rounded-full border-[2px] border-sky-400 bg-sky-50 shrink-0" style="width:8mm;height:8mm;"><span class="text-[12px] font-extrabold text-indigo-950">${nodes[playerStart].value}</span></div>`;
for (let i = 0; i < cellCount; i++) {
playerRouteCells += `<div class="inline-flex items-center justify-center shrink-0 border-[1.5px] border-indigo-200 border-dashed rounded bg-white" style="width:9mm;height:7mm;margin-left:1mm;"></div>`;
}
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 `
<div class="w-[210mm] h-[297mm] relative overflow-hidden mx-auto bg-white" data-enemy-route="${enemyRoute.join(',')}" data-player-route="${playerRoute.join(',')}" style="${pageBreak}">
<div class="absolute bottom-0 left-0 right-0 h-[80mm] z-0">
<div class="absolute top-0 left-0 right-0 h-full z-10" style="background: linear-gradient(to bottom, white 0%, rgba(255,255,255,0.6) 25%, transparent 50%);"></div>
<img src="${config.footerImg}" class="w-full h-full object-cover object-top" alt="">
</div>
<div class="absolute inset-0" style="z-index:3;">
${starsHtml}
${destObjHtml}
${decosHtml}
<svg class="absolute inset-0 w-full h-full" style="z-index:5;" xmlns="http://www.w3.org/2000/svg">
${edgeSvg}
</svg>
${nodesHtml}
<!-- Header with enemy ship (under nodes and edges) -->
<div class="absolute top-0 left-0 right-0" style="z-index:3;">
<div class="flex items-start">
<img src="${config.enemyShipImg}" class="shrink-0 object-contain" style="width:48%;max-height:50mm;${config.flipEnemy ? 'transform:scaleX(-1);' : ''}" alt="">
<div class="flex-1 flex flex-col items-center justify-center text-center pt-[5mm]">
<h1 class="text-[1.4rem] font-extrabold leading-tight tracking-tight text-indigo-950">Проложи Маршрут</h1>
<p class="text-[0.75rem] font-medium text-indigo-300 mt-0">Найди путь в космосе!</p>
</div>
</div>
</div>
<!-- Enemy route card -->
<div class="absolute z-30" style="top:19mm;right:8mm;">
<div class="flex items-center gap-[2mm] px-[3mm] py-[1.5mm] rounded-lg border-[1.5px] border-indigo-100" style="background:rgba(255,255,255,0.75);">
<div class="text-[11px] font-bold text-indigo-900 whitespace-nowrap">${config.enemyLabel}:</div>
<div class="flex items-center flex-nowrap">${enemyRouteCells}</div>
</div>
<div class="text-[10px] text-indigo-400 mt-[0.5mm] text-right pr-[2mm]">📍 Проследи маршрут — куда он летит?</div>
</div>
<!-- Player ship -->
<img src="${config.playerShipImg}" class="absolute object-contain" style="bottom:8mm;left:0;width:48%;max-height:60mm;z-index:4;${config.flipPlayer ? 'transform:scaleX(-1);' : ''}" alt="">
<!-- Player route card -->
<div class="absolute z-30" style="bottom:5mm;right:8mm;">
<div class="text-[12px] font-semibold text-gray-800 mb-[1mm] text-right pr-[2mm]">Рассчитай маршрут до цели перехвата</div>
<div class="flex items-center gap-[2mm] px-[3mm] py-[1.5mm] rounded-lg border-[1.5px] border-indigo-100" style="background:rgba(255,255,255,0.75);">
<div class="text-[11px] font-bold text-indigo-900 whitespace-nowrap">Твой истребитель:</div>
<div class="flex items-center flex-nowrap">${playerRouteCells}</div>
</div>
<div class="text-[12px] font-semibold text-gray-800 mt-[1mm] text-right pr-[2mm]">✏️ Запиши разницу чисел, чтобы добраться до цели!</div>
</div>
<!-- Debug -->
<div class="absolute z-40 text-[7px] text-indigo-300" style="bottom:3mm;left:3mm;">
[${minVal},${maxSpread}] | ${nodes.length} nodes | values ${actualMin}-${actualMax} | enemy ${enemyRoute.length} steps | player shortest ${playerRoute.length - 1} steps
</div>
</div>
</div>`;
}
// ============================================================
// MAIN
// ============================================================
let pagesHtml = '';
for (let i = 0; i < PAGES.length; i++) {
console.log(`Generating page ${i + 1}...`);
pagesHtml += generatePage(PAGES[i], i);
}
const fullHtml = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<title>Проложи Маршрут</title>
<script>
tailwind.config = {
theme: { extend: { fontFamily: { nunito: ['Nunito', 'sans-serif'] } } }
}
</script>
<style>
@page { size: A4; margin: 0; }
body { font-family: 'Nunito', sans-serif; }
</style>
</head>
<body class="bg-gray-200 font-nunito">
${pagesHtml}
</body>
</html>
`;
const outPath = join(ROOT, 'output', 'html', 'space-route-1.html');
writeFileSync(outPath, fullHtml, 'utf-8');
console.log(`Written to ${outPath}`);