refactor: new structure
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* EditorCore — shared framework for all task-type visual editors.
|
||||
*
|
||||
* Handles: worksheet loading, page management, toolbar wiring,
|
||||
* toast/tooltip, save endpoint, copy/export.
|
||||
*
|
||||
* Task-specific logic (selectors, drag, keyboard, serialization)
|
||||
* stays in each editor.html.
|
||||
*
|
||||
* Usage:
|
||||
* const core = EditorCore.init({
|
||||
* taskType: 'collecting-asteroids',
|
||||
* onReady: (pages, mmToPx) => { ... setup draggables, keyboard, etc. }
|
||||
* });
|
||||
*/
|
||||
|
||||
window.EditorCore = (function () {
|
||||
|
||||
let _pages = [];
|
||||
let _currentPage = 0;
|
||||
let _mmToPx = 1;
|
||||
let _config = {};
|
||||
let _serializeFn = null;
|
||||
|
||||
/**
|
||||
* Initialize the editor core.
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.taskType - e.g. 'collecting-asteroids'
|
||||
* @param {Function} opts.onReady - called after worksheet loaded: (pages, mmToPx) => void
|
||||
* @param {Function} [opts.serialize] - fn(changesOnly) => config object
|
||||
* @param {Function} [opts.onReset] - fn(pageNum) => void
|
||||
*/
|
||||
function init(opts) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const fileParam = params.get('file');
|
||||
|
||||
if (!fileParam) {
|
||||
document.getElementById('worksheet-container').innerHTML =
|
||||
'<p style="padding:80px 20px;color:#f38ba8;">No file specified. Use ?file=<docId></p>';
|
||||
return null;
|
||||
}
|
||||
|
||||
_config = {
|
||||
taskType: opts.taskType || '',
|
||||
fileParam: fileParam,
|
||||
docId: fileParam,
|
||||
filePath: 'docs/' + fileParam + '.template.html',
|
||||
};
|
||||
|
||||
if (opts.serialize) _serializeFn = opts.serialize;
|
||||
|
||||
// Wire standard toolbar buttons
|
||||
_wireButton('btn-prev', () => scrollToPage(_currentPage - 1));
|
||||
_wireButton('btn-next', () => scrollToPage(_currentPage + 1));
|
||||
_wireButton('btn-copy', () => _copyConfig(false));
|
||||
_wireButton('btn-copy-changes', () => _copyConfig(true));
|
||||
_wireButton('btn-save', _saveToServer);
|
||||
if (opts.onReset) {
|
||||
_wireButton('btn-reset', () => opts.onReset(_currentPage + 1));
|
||||
}
|
||||
|
||||
// Load worksheet
|
||||
_loadWorksheet(_config.filePath, opts.onReady);
|
||||
|
||||
return {
|
||||
get pages() { return _pages; },
|
||||
get currentPage() { return _currentPage; },
|
||||
get mmToPx() { return _mmToPx; },
|
||||
get docId() { return _config.docId; },
|
||||
scrollToPage,
|
||||
showToast,
|
||||
showTooltip,
|
||||
hideTooltip,
|
||||
setSerializer: (fn) => { _serializeFn = fn; },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Worksheet Loading ----
|
||||
|
||||
function _loadWorksheet(filePath, onReady) {
|
||||
fetch(filePath)
|
||||
.then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const container = document.getElementById('worksheet-container');
|
||||
|
||||
// Tailwind CDN
|
||||
const tw = document.createElement('script');
|
||||
tw.src = 'https://cdn.tailwindcss.com';
|
||||
document.head.appendChild(tw);
|
||||
|
||||
// Copy head elements
|
||||
doc.querySelectorAll('head script:not([src])').forEach(s => {
|
||||
const ns = document.createElement('script');
|
||||
ns.textContent = s.textContent;
|
||||
document.head.appendChild(ns);
|
||||
});
|
||||
doc.querySelectorAll('head style').forEach(s => {
|
||||
const ns = document.createElement('style');
|
||||
ns.textContent = s.textContent;
|
||||
document.head.appendChild(ns);
|
||||
});
|
||||
doc.querySelectorAll('head link').forEach(l => {
|
||||
document.head.appendChild(l.cloneNode(true));
|
||||
});
|
||||
|
||||
container.innerHTML = doc.body.innerHTML;
|
||||
|
||||
// Wait for Tailwind, then init pages and call onReady
|
||||
setTimeout(() => {
|
||||
_initPages();
|
||||
if (onReady) onReady(_pages, _mmToPx);
|
||||
}, 350);
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('worksheet-container').innerHTML =
|
||||
`<p style="padding:80px 20px;color:#f38ba8;">Failed to load ${filePath}: ${err.message}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Pages ----
|
||||
|
||||
function _initPages() {
|
||||
_pages = Array.from(document.querySelectorAll('.w-\\[210mm\\]'));
|
||||
_pages.forEach((p, i) => {
|
||||
p.dataset.page = i + 1;
|
||||
p.style.position = 'relative';
|
||||
const label = document.createElement('div');
|
||||
label.className = 'page-label';
|
||||
label.textContent = `Page ${i + 1}`;
|
||||
p.appendChild(label);
|
||||
});
|
||||
|
||||
document.getElementById('page-indicator').textContent = `Page 1 / ${_pages.length}`;
|
||||
|
||||
if (_pages.length > 0) {
|
||||
_mmToPx = _pages[0].getBoundingClientRect().width / 210;
|
||||
}
|
||||
|
||||
// Scroll tracking
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && entry.intersectionRatio > 0.3) {
|
||||
_currentPage = parseInt(entry.target.dataset.page) - 1;
|
||||
document.getElementById('page-indicator').textContent =
|
||||
`Page ${_currentPage + 1} / ${_pages.length}`;
|
||||
}
|
||||
});
|
||||
}, { threshold: [0.3] });
|
||||
_pages.forEach(p => observer.observe(p));
|
||||
|
||||
// Resize handler
|
||||
window.addEventListener('resize', () => {
|
||||
if (_pages.length > 0) _mmToPx = _pages[0].getBoundingClientRect().width / 210;
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToPage(index) {
|
||||
if (index < 0 || index >= _pages.length) return;
|
||||
_pages[index].scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// ---- Toast ----
|
||||
|
||||
function showToast(msg) {
|
||||
const toast = document.getElementById('toast');
|
||||
if (!toast) return;
|
||||
toast.textContent = msg;
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 1500);
|
||||
}
|
||||
|
||||
// ---- Tooltip ----
|
||||
|
||||
function showTooltip(e, text) {
|
||||
const tooltip = document.getElementById('coord-tooltip');
|
||||
if (!tooltip) return;
|
||||
tooltip.style.display = 'block';
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.left = (e.clientX + 15) + 'px';
|
||||
tooltip.style.top = (e.clientY - 10) + 'px';
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
const tooltip = document.getElementById('coord-tooltip');
|
||||
if (tooltip) tooltip.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---- Save / Copy ----
|
||||
|
||||
async function _saveToServer() {
|
||||
if (!_serializeFn) { showToast('No serializer'); return; }
|
||||
const data = _serializeFn(false);
|
||||
try {
|
||||
const resp = await fetch('/api/save-edits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
taskType: _config.taskType,
|
||||
docId: _config.docId,
|
||||
data: data
|
||||
})
|
||||
});
|
||||
const result = await resp.json();
|
||||
showToast(result.ok ? 'Saved!' : 'Error: ' + result.error);
|
||||
} catch (e) {
|
||||
showToast('Save failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function _copyConfig(changesOnly) {
|
||||
if (!_serializeFn) { showToast('No serializer'); return; }
|
||||
const data = _serializeFn(changesOnly);
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
showToast(changesOnly ? 'Changes copied!' : 'Config copied!');
|
||||
}).catch(() => {
|
||||
console.log(json);
|
||||
showToast('Copied to console (clipboard blocked)');
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function _wireButton(id, handler) {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.addEventListener('click', handler);
|
||||
}
|
||||
|
||||
return { init };
|
||||
|
||||
})();
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Apply editor JSON edits to space-route HTML.
|
||||
* Usage: node src/scripts/apply-route-edits.mjs output/html/space-route-edits.json
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
const editsPath = process.argv[2] || join(ROOT, 'output', 'html', 'space-route-edits.json');
|
||||
const edits = JSON.parse(readFileSync(editsPath, 'utf-8'));
|
||||
const htmlPath = join(ROOT, 'output', 'html', edits.file);
|
||||
let html = readFileSync(htmlPath, 'utf-8');
|
||||
|
||||
// Split HTML into pages
|
||||
const pageRegex = /<div class="w-\[210mm\] h-\[297mm\][^>]*>/g;
|
||||
const pageStarts = [];
|
||||
let m;
|
||||
while ((m = pageRegex.exec(html)) !== null) {
|
||||
pageStarts.push(m.index);
|
||||
}
|
||||
pageStarts.push(html.length);
|
||||
|
||||
function getPageHtml(pageIndex) {
|
||||
return html.slice(pageStarts[pageIndex], pageStarts[pageIndex + 1]);
|
||||
}
|
||||
|
||||
function setPageHtml(pageIndex, newPageHtml) {
|
||||
html = html.slice(0, pageStarts[pageIndex]) + newPageHtml + html.slice(pageStarts[pageIndex + 1]);
|
||||
// Recalculate starts
|
||||
const diff = newPageHtml.length - (pageStarts[pageIndex + 1] - pageStarts[pageIndex]);
|
||||
for (let i = pageIndex + 1; i < pageStarts.length; i++) {
|
||||
pageStarts[i] += diff;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pageEdit of edits.pages) {
|
||||
const pi = pageEdit.page - 1;
|
||||
let pageHtml = getPageHtml(pi);
|
||||
console.log(`Applying page ${pageEdit.page}...`);
|
||||
|
||||
// Apply object edits
|
||||
if (pageEdit.objects) {
|
||||
for (const obj of pageEdit.objects) {
|
||||
// Find the img with matching data-node-id and data-type on this page
|
||||
const nodeId = obj.nodeId;
|
||||
const type = obj.type;
|
||||
|
||||
// Build transform string
|
||||
let transform = '';
|
||||
if (obj.rotate) transform += `rotate(${obj.rotate}deg)`;
|
||||
if (obj.flipH) transform += ` scaleX(-1)`;
|
||||
transform = transform.trim();
|
||||
const transformAttr = transform ? `transform:${transform};` : '';
|
||||
|
||||
const w = parseFloat(obj.w);
|
||||
const h = parseFloat(obj.h);
|
||||
const left = parseFloat(obj.left);
|
||||
const top = parseFloat(obj.top);
|
||||
|
||||
// Match the img element by data-node-id and data-type
|
||||
const imgRegex = new RegExp(
|
||||
`(<img[^>]*data-node-id="${nodeId}"[^>]*data-type="${type}"[^>]*style=")([^"]*)(")`,
|
||||
'g'
|
||||
);
|
||||
|
||||
const newStyle = `left:${left.toFixed(1)}mm;top:${top.toFixed(1)}mm;width:${w}mm;height:${h}mm;margin-left:-${w/2}mm;margin-top:-${h/2}mm;z-index:4;${transformAttr}`;
|
||||
|
||||
const before = pageHtml;
|
||||
pageHtml = pageHtml.replace(imgRegex, `$1${newStyle}$3`);
|
||||
if (pageHtml === before) {
|
||||
// Try alternate order (data-type before data-node-id)
|
||||
const imgRegex2 = new RegExp(
|
||||
`(<img[^>]*data-type="${type}"[^>]*data-node-id="${nodeId}"[^>]*style=")([^"]*)(")`,
|
||||
'g'
|
||||
);
|
||||
pageHtml = pageHtml.replace(imgRegex2, `$1${newStyle}$3`);
|
||||
}
|
||||
console.log(` obj node:${nodeId} type:${type} → ${left.toFixed(1)},${top.toFixed(1)} ${w}×${h}mm${obj.rotate ? ' rot:'+obj.rotate+'°' : ''}${obj.flipH ? ' FLIP' : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply node edits
|
||||
if (pageEdit.nodes) {
|
||||
for (const nd of pageEdit.nodes) {
|
||||
const nodeId = nd.nodeId;
|
||||
const left = parseFloat(nd.left);
|
||||
const top = parseFloat(nd.top);
|
||||
|
||||
// Update node div position
|
||||
const nodeRegex = new RegExp(
|
||||
`(<div[^>]*data-node-id="${nodeId}"[^>]*style=")(left:[^;]*;top:[^;]*)`,
|
||||
'g'
|
||||
);
|
||||
pageHtml = pageHtml.replace(nodeRegex, `$1left:${left.toFixed(1)}mm;top:${top.toFixed(1)}mm`);
|
||||
|
||||
// Update all edges connected to this node
|
||||
// Edge format: data-edge="A-B" where A or B matches nodeId
|
||||
// Update x1,y1 if nodeId is the first, x2,y2 if second
|
||||
const edgeRegex1 = new RegExp(
|
||||
`(<line data-edge="${nodeId}-\\d+"[^>]*)(x1="[^"]*")(\\s*)(y1="[^"]*")`,
|
||||
'g'
|
||||
);
|
||||
pageHtml = pageHtml.replace(edgeRegex1, `$1x1="${left.toFixed(1)}mm"$3y1="${top.toFixed(1)}mm"`);
|
||||
|
||||
const edgeRegex2 = new RegExp(
|
||||
`(<line data-edge="\\d+-${nodeId}"[^>]*)(x2="[^"]*")(\\s*)(y2="[^"]*")`,
|
||||
'g'
|
||||
);
|
||||
pageHtml = pageHtml.replace(edgeRegex2, `$1x2="${left.toFixed(1)}mm"$3y2="${top.toFixed(1)}mm"`);
|
||||
}
|
||||
console.log(` ${pageEdit.nodes.length} nodes updated`);
|
||||
}
|
||||
|
||||
setPageHtml(pi, pageHtml);
|
||||
}
|
||||
|
||||
writeFileSync(htmlPath, html, 'utf-8');
|
||||
console.log(`\nWritten to ${htmlPath}`);
|
||||
@@ -1,742 +0,0 @@
|
||||
#!/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}`);
|
||||
@@ -1,311 +0,0 @@
|
||||
/**
|
||||
* Generate space-worksheet3.html — 9-page static HTML worksheet
|
||||
* combining task patterns from worksheet1 and worksheet2.
|
||||
*
|
||||
* Usage: node src/scripts/generate-worksheet3.mjs
|
||||
* Output: output/html/space-worksheet3.html
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
// ── Icon pools ──────────────────────────────────────────────────────────────
|
||||
const pack1Icons = fs.readdirSync(path.join(ROOT, 'assets/icons/pack1'))
|
||||
.filter(f => f.endsWith('.png'))
|
||||
.map(f => `assets/icons/pack1/${f}`);
|
||||
|
||||
const pack2Icons = fs.readdirSync(path.join(ROOT, 'assets/icons/pack2'))
|
||||
.filter(f => f.endsWith('.png'))
|
||||
.map(f => `assets/icons/pack2/${f}`);
|
||||
|
||||
function shuffle(arr) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function randInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function randSign() {
|
||||
return Math.random() < 0.5 ? 1 : -1;
|
||||
}
|
||||
|
||||
function pickAlignment() {
|
||||
const opts = ['justify-start', 'justify-center', 'justify-end'];
|
||||
return opts[Math.floor(Math.random() * opts.length)];
|
||||
}
|
||||
|
||||
// ── Problem generators ──────────────────────────────────────────────────────
|
||||
|
||||
// Pattern 1: A + B ± C (A:8-16, B:4-8, C:1-3)
|
||||
function genPattern1() {
|
||||
const problems = [];
|
||||
const seen = new Set();
|
||||
while (problems.length < 20) {
|
||||
const A = randInt(8, 16);
|
||||
const B = randInt(4, 8);
|
||||
const C = randInt(1, 3);
|
||||
const sign = randSign();
|
||||
const expr = sign > 0 ? `${A} + ${B} + ${C}` : `${A} + ${B} − ${C}`;
|
||||
const result = A + B + sign * C;
|
||||
if (result >= 0 && !seen.has(expr)) {
|
||||
seen.add(expr);
|
||||
problems.push(expr);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
// Pattern 2: 5 × N ± C (N:1-5, C:1-6)
|
||||
function genPattern2() {
|
||||
const problems = [];
|
||||
const seen = new Set();
|
||||
while (problems.length < 20) {
|
||||
const N = randInt(1, 5);
|
||||
const C = randInt(1, 6);
|
||||
const sign = randSign();
|
||||
const expr = sign > 0 ? `5 × ${N} + ${C}` : `5 × ${N} − ${C}`;
|
||||
const result = 5 * N + sign * C;
|
||||
if (result >= 0 && !seen.has(expr)) {
|
||||
seen.add(expr);
|
||||
problems.push(expr);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
// Pattern 3: A × B ± C (A,B:1-4, C:1-8)
|
||||
function genPattern3() {
|
||||
const problems = [];
|
||||
const seen = new Set();
|
||||
while (problems.length < 20) {
|
||||
const A = randInt(1, 4);
|
||||
const B = randInt(1, 4);
|
||||
const C = randInt(1, 8);
|
||||
const sign = randSign();
|
||||
const expr = sign > 0 ? `${A} × ${B} + ${C}` : `${A} × ${B} − ${C}`;
|
||||
const result = A * B + sign * C;
|
||||
if (result >= 0 && !seen.has(expr)) {
|
||||
seen.add(expr);
|
||||
problems.push(expr);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
// Pattern 4: A + B + C (A:12-24, B:±6-10, C:±2-5, result 0-40)
|
||||
function genPattern4() {
|
||||
const problems = [];
|
||||
const seen = new Set();
|
||||
while (problems.length < 20) {
|
||||
const A = randInt(12, 24);
|
||||
const signB = randSign();
|
||||
const B = randInt(6, 10);
|
||||
const signC = randSign();
|
||||
const C = randInt(2, 5);
|
||||
const result = A + signB * B + signC * C;
|
||||
if (result >= 0 && result <= 40) {
|
||||
const bPart = signB > 0 ? `+ ${B}` : `− ${B}`;
|
||||
const cPart = signC > 0 ? `+ ${C}` : `− ${C}`;
|
||||
const expr = `${A} ${bPart} ${cPart}`;
|
||||
if (!seen.has(expr)) {
|
||||
seen.add(expr);
|
||||
problems.push(expr);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
// Pattern 5: mixed fives (5+5+5, 5×N, 5×N±5)
|
||||
function genPattern5() {
|
||||
const problems = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Type A: 5+5+5 (2,3,4 fives)
|
||||
for (const count of [2, 3, 4]) {
|
||||
const expr = Array(count).fill('5').join(' + ');
|
||||
problems.push(expr);
|
||||
seen.add(expr);
|
||||
}
|
||||
|
||||
// Type B: 5×N (N:1-12)
|
||||
const ns = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
for (const n of ns) {
|
||||
if (problems.length >= 20) break;
|
||||
const expr = `5 × ${n}`;
|
||||
if (!seen.has(expr)) {
|
||||
seen.add(expr);
|
||||
problems.push(expr);
|
||||
}
|
||||
}
|
||||
|
||||
// Type C: 5×N±5 (fill remaining)
|
||||
while (problems.length < 20) {
|
||||
const N = randInt(2, 12);
|
||||
const sign = randSign();
|
||||
const expr = sign > 0 ? `5 × ${N} + 5` : `5 × ${N} − 5`;
|
||||
if (!seen.has(expr)) {
|
||||
seen.add(expr);
|
||||
problems.push(expr);
|
||||
}
|
||||
}
|
||||
|
||||
return shuffle(problems);
|
||||
}
|
||||
|
||||
// Pattern 6: A×B + C×D (A:2-4, B:1-3, C:2-3, D:1-2)
|
||||
function genPattern6() {
|
||||
const problems = [];
|
||||
const seen = new Set();
|
||||
while (problems.length < 20) {
|
||||
const A = randInt(2, 4);
|
||||
const B = randInt(1, 3);
|
||||
const C = randInt(2, 3);
|
||||
const D = randInt(1, 2);
|
||||
const expr = `${A} × ${B} + ${C} × ${D}`;
|
||||
if (!seen.has(expr)) {
|
||||
seen.add(expr);
|
||||
problems.push(expr);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
const generators = [genPattern1, genPattern2, genPattern3, genPattern4, genPattern5, genPattern6, genPattern1, genPattern2, genPattern3];
|
||||
|
||||
// ── Page config ─────────────────────────────────────────────────────────────
|
||||
const pages = [
|
||||
{ hero: 'assets/hero-images/spaceship1.jpeg', footer: 'assets/footers/planet1.jpeg', dir: 'flex-row-reverse', pack: 'pack1', gen: 0 },
|
||||
{ hero: 'assets/hero-images/spaceship2.jpeg', footer: 'assets/footers/planet2.jpeg', dir: '', pack: 'pack1', gen: 1 },
|
||||
{ hero: 'assets/hero-images/spaceship3.png', footer: 'assets/footers/planet3.jpeg', dir: 'flex-row-reverse', pack: 'pack1', gen: 2 },
|
||||
{ hero: 'assets/hero-images/spaceship4.jpeg', footer: 'assets/footers/planet4.jpeg', dir: '', pack: 'pack2', gen: 3 },
|
||||
{ hero: 'assets/hero-images/spaceship5.jpeg', footer: 'assets/footers/planet5.jpeg', dir: 'flex-row-reverse', pack: 'pack2', gen: 4, fivesHint: true },
|
||||
{ hero: 'assets/hero-images/spaceship6.jpeg', footer: 'assets/footers/planet6.jpeg', dir: '', pack: 'pack2', gen: 5 },
|
||||
{ hero: 'assets/hero-images/spaceship7.jpeg', footer: 'assets/footers/planet7.jpeg', dir: 'flex-row-reverse', pack: 'pack1', gen: 6 },
|
||||
{ hero: 'assets/hero-images/spaceship8.jpeg', footer: 'assets/footers/planet8.jpeg', dir: '', pack: 'pack1', gen: 7 },
|
||||
{ hero: 'assets/hero-images/spaceship9.jpeg', footer: 'assets/footers/planet9.jpeg', dir: 'flex-row-reverse', pack: 'pack1', gen: 8 },
|
||||
];
|
||||
|
||||
// ── Assign icons per section (3 pages share a pool) ─────────────────────────
|
||||
// Section 1 (pages 0-2): pack1 shuffled
|
||||
// Section 2 (pages 3-5): pack2 shuffled
|
||||
// Section 3 (pages 6-8): pack1 re-shuffled
|
||||
const iconSections = [
|
||||
shuffle(pack1Icons), // pages 0-2: 60 icons
|
||||
shuffle(pack2Icons), // pages 3-5: 60 icons
|
||||
shuffle(pack1Icons), // pages 6-8: 60 icons (reuse pack1, different shuffle)
|
||||
];
|
||||
|
||||
// ── Build HTML ──────────────────────────────────────────────────────────────
|
||||
function buildProblemCard(icon, expression, alignment, iconSize) {
|
||||
return ` <div class="flex ${alignment}">
|
||||
<div class="flex items-center gap-1">
|
||||
<img src="../../${icon}" class="w-[${iconSize}px] h-[${iconSize}px] shrink-0 object-contain" alt="">
|
||||
<div class="pl-3 pr-4 py-1 border-[1.5px] border-indigo-100 rounded-full bg-gradient-to-br from-white to-indigo-50/40 shadow-sm shadow-indigo-100/30">
|
||||
<span class="text-[1.2rem] font-semibold text-indigo-950 whitespace-nowrap">${expression} = <span class="inline-block w-16 border-b-[2px] border-indigo-300"> </span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildPage(pageIdx, isLast) {
|
||||
const pg = pages[pageIdx];
|
||||
const sectionIdx = Math.floor(pageIdx / 3);
|
||||
const posInSection = pageIdx % 3;
|
||||
const sectionIcons = iconSections[sectionIdx];
|
||||
const pageIcons = sectionIcons.slice(posInSection * 20, posInSection * 20 + 20);
|
||||
const iconSize = pg.pack === 'pack2' ? 58 : 58;
|
||||
|
||||
const problems = generators[pg.gen]();
|
||||
const dirClass = pg.dir ? ` ${pg.dir}` : '';
|
||||
const breakStyle = isLast ? '' : ' style="break-after: page;"';
|
||||
|
||||
const fivesHint = pg.fivesHint
|
||||
? `\n <div class="font-semibold text-indigo-950 mb-3 text-center" style="font-size: 0.8rem; margin-top: -10px;">Посчитай пятёрками: <span style="font-size: 1rem; word-spacing: 0.25em;">5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60</span> для зарядки расщепителя</div>\n`
|
||||
: '';
|
||||
|
||||
const cards = problems.map((expr, i) => {
|
||||
return buildProblemCard(pageIcons[i], expr, pickAlignment(), iconSize);
|
||||
}).join('\n\n');
|
||||
|
||||
return ` <!-- PAGE ${pageIdx + 1} -->
|
||||
<div class="w-[210mm] h-[297mm] relative overflow-hidden mx-auto bg-white"${breakStyle}>
|
||||
|
||||
<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="../../${pg.footer}" class="w-full h-full object-cover object-top" alt="">
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-[12mm] left-0 right-0 z-20 flex justify-center">
|
||||
<div style="background: rgba(255,255,255,0.85);" class="rounded-full px-6 py-1.5 border-[1.5px] border-indigo-200 shadow-md">
|
||||
<span class="text-[0.95rem] font-bold text-indigo-950">Итого собрано на планете: <span class="inline-block w-14 border-b-2 border-indigo-400 text-center ml-1"> </span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 w-full h-full px-[12mm] pt-[4mm] pb-[65mm] flex flex-col">
|
||||
|
||||
<div class="flex items-center gap-4 mb-4${dirClass}">
|
||||
<img src="../../${pg.hero}" class="w-[48%] shrink-0 object-contain" alt="">
|
||||
<div class="flex-1 flex flex-col items-center justify-center text-center">
|
||||
<h1 class="text-2xl font-extrabold leading-tight tracking-tight text-indigo-950">Исследуй Планету</h1>
|
||||
<p class="text-sm font-medium text-indigo-400 mt-1">Собери ресурсы, решая примеры!</p>
|
||||
</div>
|
||||
</div>
|
||||
${fivesHint}
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-[3px] flex-1 content-start">
|
||||
|
||||
${cards}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const htmlPages = pages.map((_, i) => buildPage(i, i === pages.length - 1)).join('\n\n');
|
||||
|
||||
const html = `<!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; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-200 font-nunito">
|
||||
|
||||
${htmlPages}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
// ── Write output ────────────────────────────────────────────────────────────
|
||||
const outDir = path.join(ROOT, 'output/html');
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const outPath = path.join(outDir, 'space-worksheet3.html');
|
||||
fs.writeFileSync(outPath, html, 'utf-8');
|
||||
console.log(`Generated: ${outPath} (${pages.length} pages, ${pages.length * 20} problems)`);
|
||||
@@ -1,203 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Transform collecting-asteroids-2.html into collecting-asteroids-3.html
|
||||
* Output goes to stdout for review before saving.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..');
|
||||
const v2Html = readFileSync(resolve(root, 'output/html/collecting-asteroids-2.html'), 'utf-8');
|
||||
|
||||
// Page configs for v3
|
||||
const pages = [
|
||||
{
|
||||
comment: 'PAGE 1: ships 11(2), 9(2), 13(6) — R-L-R',
|
||||
hero: 'splitter4', footer: 'cabin3',
|
||||
cargoBays: [7, 8, 9],
|
||||
ships: [11, 9, 13],
|
||||
oldAsteroidTypes: ['asteroid7', 'asteroid14'],
|
||||
newAsteroidTypes: ['asteroid3', 'asteroid10'],
|
||||
asteroidValues: [3, 2, 1, 4, 5, 2, 3, 6, 5, 4, 1, 6],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 2: ships 12(3), 12(3), 18(4) — L-R-L',
|
||||
hero: 'splitter7', footer: 'cabin6',
|
||||
cargoBays: [1, 2, 3],
|
||||
ships: [12, 12, 18],
|
||||
oldAsteroidTypes: ['asteroid4', 'asteroid11'],
|
||||
newAsteroidTypes: ['asteroid6', 'asteroid13'],
|
||||
asteroidValues: [4, 5, 3, 6, 2, 7, 4, 3, 5, 2, 6, 4],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 3: ships 9(5), 11(3), 11(2) — R-L-R',
|
||||
hero: 'splitter1', footer: 'cabin9',
|
||||
cargoBays: [4, 5, 6],
|
||||
ships: [9, 11, 11],
|
||||
oldAsteroidTypes: ['asteroid10', 'asteroid3'],
|
||||
newAsteroidTypes: ['asteroid2', 'asteroid8'],
|
||||
asteroidValues: [2, 4, 1, 3, 5, 2, 1, 3, 4, 6, 5, 2],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 4: ships 15(4), 17(5), 11(2) — L-R-L',
|
||||
hero: 'splitter6', footer: 'cabin2',
|
||||
cargoBays: [7, 8, 9],
|
||||
ships: [15, 17, 11],
|
||||
oldAsteroidTypes: ['asteroid16', 'asteroid5'],
|
||||
newAsteroidTypes: ['asteroid12', 'asteroid15'],
|
||||
asteroidValues: [4, 3, 5, 2, 6, 1, 3, 4, 6, 2, 5, 4],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 5: ships 7(2), 9(4), 7(2) — R-L-R',
|
||||
hero: 'splitter2', footer: 'cabin5',
|
||||
cargoBays: [1, 2, 3],
|
||||
ships: [7, 9, 7],
|
||||
oldAsteroidTypes: ['asteroid2', 'asteroid13'],
|
||||
newAsteroidTypes: ['asteroid1', 'asteroid9'],
|
||||
asteroidValues: [3, 1, 4, 2, 5, 1, 6, 2, 3, 4, 5],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 6: ships 18(4), 18(5), 14(3) — L-R-L',
|
||||
hero: 'splitter8', footer: 'cabin7',
|
||||
cargoBays: [4, 5, 6],
|
||||
ships: [18, 18, 14],
|
||||
oldAsteroidTypes: ['asteroid8', 'asteroid12'],
|
||||
newAsteroidTypes: ['asteroid5', 'asteroid16'],
|
||||
asteroidValues: [3, 4, 5, 6, 2, 4, 7, 3, 2, 5, 6, 4, 3, 7],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 7: ships 10(2), 16(3), 22(6) — R-L-R',
|
||||
hero: 'splitter9', footer: 'cabin4',
|
||||
cargoBays: [7, 8, 9],
|
||||
ships: [10, 16, 22],
|
||||
oldAsteroidTypes: ['asteroid15', 'asteroid6'],
|
||||
newAsteroidTypes: ['asteroid11', 'asteroid7'],
|
||||
asteroidValues: [4, 3, 2, 6, 5, 3, 7, 4, 5, 2, 6, 4],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 8: ships 26(0), 18(0), 30(0) — UNSOLVABLE — L-R-L',
|
||||
hero: 'splitter5', footer: 'cabin1',
|
||||
cargoBays: [1, 2, 3],
|
||||
ships: [26, 18, 30],
|
||||
oldAsteroidTypes: ['asteroid9', 'asteroid1'],
|
||||
newAsteroidTypes: ['asteroid14', 'asteroid4'],
|
||||
asteroidValues: [6, 4, 5, 3, 7, 2, 4, 6, 5, 7, 3, 4, 6],
|
||||
},
|
||||
{
|
||||
comment: 'PAGE 9: ships 8(2), 18(3), 28(8) — R-L-R',
|
||||
hero: 'splitter3', footer: 'cabin8',
|
||||
cargoBays: [4, 5, 6],
|
||||
ships: [8, 18, 28],
|
||||
oldAsteroidTypes: ['asteroid11', 'asteroid4'],
|
||||
newAsteroidTypes: ['asteroid10', 'asteroid6'],
|
||||
asteroidValues: [4, 3, 2, 5, 6, 3, 4, 7, 2, 5, 4, 6, 3, 7, 5],
|
||||
},
|
||||
];
|
||||
|
||||
// Split HTML into pages
|
||||
// Pages are delimited by <!-- PAGE N comments
|
||||
const pageRegex = /<!-- PAGE \d+/g;
|
||||
const matches = [...v2Html.matchAll(pageRegex)];
|
||||
|
||||
// Extract page blocks
|
||||
const pageBlocks = [];
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const start = matches[i].index;
|
||||
const end = i + 1 < matches.length ? matches[i + 1].index : v2Html.lastIndexOf('</body>');
|
||||
pageBlocks.push(v2Html.substring(start, end));
|
||||
}
|
||||
|
||||
// Extract header (before first page) and footer (after last page)
|
||||
const header = v2Html.substring(0, matches[0].index);
|
||||
const footer = v2Html.substring(v2Html.lastIndexOf('</body>'));
|
||||
|
||||
function transformPage(block, config, pageIdx) {
|
||||
let html = block;
|
||||
|
||||
// 1. Replace page comment
|
||||
html = html.replace(/<!-- PAGE \d+:.*?-->/, `<!-- ${config.comment} -->`);
|
||||
|
||||
// 2. Replace hero image
|
||||
html = html.replace(
|
||||
/splitters\/splitter\d+\.png/,
|
||||
`splitters/${config.hero}.png`
|
||||
);
|
||||
|
||||
// 3. Replace footer image
|
||||
html = html.replace(
|
||||
/footers\/cabin\d+\.jpeg/,
|
||||
`footers/${config.footer}.jpeg`
|
||||
);
|
||||
|
||||
// 4. Replace cargo bay images (3 per page, in order)
|
||||
let cbIdx = 0;
|
||||
html = html.replace(
|
||||
/pack4-cargobay\/cargo-bay\d+\.png/g,
|
||||
(match) => `pack4-cargobay/cargo-bay${config.cargoBays[cbIdx++]}.png`
|
||||
);
|
||||
|
||||
// 5. Replace ship capacity numbers (text-xl spans inside blue circles)
|
||||
let shipIdx = 0;
|
||||
html = html.replace(
|
||||
/(<span class="text-white font-extrabold text-xl">)(\d+)(<\/span>)/g,
|
||||
(match, pre, val, post) => `${pre}${config.ships[shipIdx++]}${post}`
|
||||
);
|
||||
|
||||
// 6. Replace asteroid type images
|
||||
for (let i = 0; i < config.oldAsteroidTypes.length; i++) {
|
||||
const oldType = config.oldAsteroidTypes[i];
|
||||
const newType = config.newAsteroidTypes[i];
|
||||
html = html.replaceAll(
|
||||
`pack3-asteroids/${oldType}.png`,
|
||||
`pack3-asteroids/${newType}.png`
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Replace asteroid values (text-2xl spans, sequential)
|
||||
let avIdx = 0;
|
||||
html = html.replace(
|
||||
/(<span class="text-white font-extrabold text-2xl"[^>]*>)(\d+)(<\/span>)/g,
|
||||
(match, pre, val, post) => {
|
||||
if (avIdx < config.asteroidValues.length) {
|
||||
return `${pre}${config.asteroidValues[avIdx++]}${post}`;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
);
|
||||
|
||||
// Sanity check
|
||||
if (shipIdx !== 3) {
|
||||
console.error(`WARNING: Page ${pageIdx + 1} — replaced ${shipIdx} ship values (expected 3)`);
|
||||
}
|
||||
if (avIdx !== config.asteroidValues.length) {
|
||||
console.error(`WARNING: Page ${pageIdx + 1} — replaced ${avIdx} asteroid values (expected ${config.asteroidValues.length})`);
|
||||
}
|
||||
if (cbIdx !== 3) {
|
||||
console.error(`WARNING: Page ${pageIdx + 1} — replaced ${cbIdx} cargo bay refs (expected 3)`);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Transform all pages
|
||||
const newPages = pageBlocks.map((block, i) => transformPage(block, pages[i], i));
|
||||
|
||||
// Assemble
|
||||
const result = header + newPages.join('') + footer;
|
||||
|
||||
// Write to temp file
|
||||
import { writeFileSync } from 'fs';
|
||||
const tmpPath = resolve(root, 'output/html/.collecting-asteroids-3-tmp.html');
|
||||
writeFileSync(tmpPath, result);
|
||||
console.log(`Output written to: ${tmpPath}`);
|
||||
console.log(`Total lines: ${result.split('\n').length}`);
|
||||
|
||||
// Print summary of changes per page
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const p = pages[i];
|
||||
console.log(`\nPage ${i + 1}: hero=${p.hero} footer=${p.footer} cargoBays=[${p.cargoBays}]`);
|
||||
console.log(` Ships: [${p.ships}] Asteroids: [${p.asteroidValues}]`);
|
||||
console.log(` Types: ${p.oldAsteroidTypes} → ${p.newAsteroidTypes}`);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Asteroid partition solver.
|
||||
* Verifies that disjoint subsets of asteroid values can sum to each ship capacity.
|
||||
*
|
||||
* Usage:
|
||||
* node verify-asteroids.mjs --ships 10,10,14 --asteroids 3,2,1,4,5,2,3,6,5,4,1,6
|
||||
*
|
||||
* Output: prints whether a valid partition exists and one example solution.
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function parseArg(flag) {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx === -1 || idx + 1 >= args.length) return null;
|
||||
return args[idx + 1].split(',').map(Number);
|
||||
}
|
||||
|
||||
const ships = parseArg('--ships');
|
||||
const asteroids = parseArg('--asteroids');
|
||||
|
||||
if (!ships || !asteroids) {
|
||||
console.log('Usage: node verify-asteroids.mjs --ships 10,10,14 --asteroids 3,2,1,4,5,2,3,6,5,4,1,6');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Ships: [${ships.join(', ')}] (sum=${ships.reduce((a, b) => a + b, 0)})`);
|
||||
console.log(`Asteroids: [${asteroids.join(', ')}] (count=${asteroids.length}, sum=${asteroids.reduce((a, b) => a + b, 0)})`);
|
||||
|
||||
/**
|
||||
* Find disjoint subsets of `available` (by index) that sum to each capacity in `capacities`.
|
||||
* Returns array of index arrays, or null if no solution.
|
||||
*/
|
||||
function findPartition(available, capacities, usedSet = new Set()) {
|
||||
if (capacities.length === 0) return [];
|
||||
|
||||
const target = capacities[0];
|
||||
const remaining = capacities.slice(1);
|
||||
const remainingSum = remaining.reduce((a, b) => a + b, 0);
|
||||
|
||||
// Available sum check
|
||||
let availSum = 0;
|
||||
for (let i = 0; i < available.length; i++) {
|
||||
if (!usedSet.has(i)) availSum += available[i];
|
||||
}
|
||||
if (availSum < target + remainingSum) return null;
|
||||
|
||||
// Find all subsets summing to target using DFS
|
||||
const subset = [];
|
||||
|
||||
function dfs(startIdx, currentSum) {
|
||||
if (currentSum === target) {
|
||||
// Try solving remaining capacities
|
||||
const newUsed = new Set(usedSet);
|
||||
for (const idx of subset) newUsed.add(idx);
|
||||
const result = findPartition(available, remaining, newUsed);
|
||||
if (result !== null) {
|
||||
return [subset.slice(), ...result];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentSum > target) return null;
|
||||
|
||||
for (let i = startIdx; i < available.length; i++) {
|
||||
if (usedSet.has(i)) continue;
|
||||
if (currentSum + available[i] > target) continue;
|
||||
|
||||
// Pruning: check if remaining available sum can reach target
|
||||
let futureSum = 0;
|
||||
for (let j = i; j < available.length; j++) {
|
||||
if (!usedSet.has(j) && !subset.includes(j)) futureSum += available[j];
|
||||
}
|
||||
if (currentSum + futureSum < target) return null;
|
||||
|
||||
subset.push(i);
|
||||
const result = dfs(i + 1, currentSum + available[i]);
|
||||
if (result) return result;
|
||||
subset.pop();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return dfs(0, 0);
|
||||
}
|
||||
|
||||
// Sort ships descending for better pruning (hardest to fill first)
|
||||
const sortedShips = [...ships].sort((a, b) => b - a);
|
||||
|
||||
console.log(`\nSolving for ships (sorted desc): [${sortedShips.join(', ')}]`);
|
||||
|
||||
const solution = findPartition(asteroids, sortedShips);
|
||||
|
||||
if (solution) {
|
||||
console.log('\n✓ SOLVABLE! Solution found:');
|
||||
// Map back to original ship order
|
||||
const shipOrder = ships.map((cap, idx) => ({ cap, idx }))
|
||||
.sort((a, b) => b.cap - a.cap);
|
||||
|
||||
for (let i = 0; i < sortedShips.length; i++) {
|
||||
const indices = solution[i];
|
||||
const values = indices.map(j => asteroids[j]);
|
||||
const origShipIdx = shipOrder[i].idx;
|
||||
console.log(` Ship ${origShipIdx + 1} (capacity ${sortedShips[i]}): asteroids [${values.join(' + ')}] = ${values.reduce((a, b) => a + b, 0)} (indices: ${indices.join(',')})`);
|
||||
}
|
||||
|
||||
const usedIndices = new Set(solution.flat());
|
||||
const unused = asteroids.map((v, i) => ({ v, i })).filter(x => !usedIndices.has(x.i));
|
||||
if (unused.length > 0) {
|
||||
console.log(` Unused asteroids: [${unused.map(x => `${x.v}(idx ${x.i})`).join(', ')}]`);
|
||||
}
|
||||
} else {
|
||||
console.log('\n✗ NO SOLUTION found.');
|
||||
console.log(` Ship sum: ${ships.reduce((a, b) => a + b, 0)}, Asteroid sum: ${asteroids.reduce((a, b) => a + b, 0)}`);
|
||||
if (asteroids.reduce((a, b) => a + b, 0) < ships.reduce((a, b) => a + b, 0)) {
|
||||
console.log(' Reason: asteroid sum < ship sum (mathematically impossible)');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user