Compare commits

...

3 Commits

Author SHA1 Message Date
Oleg Proskurin 9621d0ca63 add paths-tasks 2026-04-20 14:18:04 +07:00
Oleg Proskurin b6579f29a8 feat: asteroids 2026-04-09 19:31:46 +07:00
Oleg Proskurin cb183d687c assets: upd splitters 2026-03-01 22:04:06 +07:00
80 changed files with 1167 additions and 32 deletions

View File

@ -40,7 +40,11 @@
"Bash(npx skills:*)", "Bash(npx skills:*)",
"Bash(identify:*)", "Bash(identify:*)",
"Bash(pnpm pdf -- output/html/collecting-asteroids-1.html)", "Bash(pnpm pdf -- output/html/collecting-asteroids-1.html)",
"Bash(pnpm pdf output/html/collecting-asteroids-1.html)" "Bash(pnpm pdf output/html/collecting-asteroids-1.html)",
"Bash(pnpm pdf:*)",
"Bash(mv freighter-trade1.png freighter1.png)",
"Bash(mv freighter-mining1.png freighter11.png)",
"Bash(mv freighter-science1.png freighter12.png)"
] ]
} }
} }

View File

@ -134,9 +134,10 @@ The base template defines the visual design for all space-themed worksheets. The
When generating HTML worksheets: When generating HTML worksheets:
- **OUTPUT MUST BE STATIC HTML.** Generated HTML files in `output/html/` must contain only static markup — no embedded `<script>` that computes, generates, or modifies content at runtime. If you need algorithms or calculations (graph generation, random placement, etc.), run them in a separate Node.js script in `src/scripts/`, then write the computed results as static HTML to `output/html/`. The user works with the final HTML directly and expects it to be stable across page reloads.
- **Always read** `src/templates/space-base.html` (structure) and `src/examples/space-worksheet.html` (finished output) before generating - **Always read** `src/templates/space-base.html` (structure) and `src/examples/space-worksheet.html` (finished output) before generating
- **Page size:** A4 = 210mm × 297mm - **Page size:** A4 = 210mm × 297mm
- **CSS:** Uses Tailwind CDN in the HTML `<script>` tag (not the compiled CSS file) - **CSS:** Uses Tailwind CDN in the HTML `<script>` tag (not the compiled CSS file) — this is the only allowed script
- **Page breaks:** Use `break-after: page` between pages - **Page breaks:** Use `break-after: page` between pages
- **Icons:** 58×58px inline images from `assets/icons/` next to each problem (outside the pill card) - **Icons:** 58×58px inline images from `assets/icons/` next to each problem (outside the pill card)
- **Fonts:** Nunito from Google Fonts via `<link>` - **Fonts:** Nunito from Google Fonts via `<link>`
@ -179,12 +180,70 @@ Puppeteer settings for A4 worksheets:
- Margins: zero (CSS handles margins) - Margins: zero (CSS handles margins)
- `preferCSSPageSize: true` - `preferCSSPageSize: true`
## Space Route Task Type
Generator script: `src/scripts/generate-space-route.mjs`
Output: `output/html/space-route-{N}.html`
### Generation rules
- **Grid:** 7×9 hex grid with jitter, nodes evenly distributed across map area
- **Start nodes:** Enemy = top-left corner, Player = bottom-left corner. Start nodes trimmed to exactly 2 edges
- **No asteroids on route nodes** — decorative objects must not be placed on any node belonging to enemy or player routes, nor on neighbors of start nodes
- **Routes use waypoints** — route goes toward an offset waypoint for ~40% of steps, then changes direction toward the final target. This prevents obvious straight-line paths
- **Route length:** 7-9 steps for enemy, player gets 10 empty cells
- **Ship orientation:** check each ship image and set `flipEnemy`/`flipPlayer` flags so all ships face right
- **Difficulty via diffRange:** `[minNodeValue, maxSpread]` — first number = minimum node value, second = max |difference| between connected neighbors
## Visual Position Editor
`output/html/editor.html` — a standalone drag-and-drop editor for repositioning elements (asteroids, icons, etc.) in generated worksheets.
### Usage
```
http://localhost:3300/html/editor.html?file=collecting-asteroids-1.html
```
- **Mouse drag** — move elements freely
- **Arrow keys** — nudge 1mm (Shift+Arrow = 5mm)
- **Copy All JSON** — export all positions to clipboard
- **Copy Changes** — export only moved elements
- **`window.getConfig()`** — same via console
### How it works
The editor `fetch()`es the worksheet HTML and injects it into its own DOM, adding drag-and-drop behavior. The worksheet HTML stays clean — no editor code touches it. The editor identifies draggable elements via CSS selector (e.g., `img[src*="pack3-asteroids"]` for asteroids).
### Applying positions from editor
User copies JSON from editor, gives it to Claude. Claude applies positions via a Node.js one-liner:
```js
// Pattern: regex-replace positions in order per page
html.replace(
/(<div class="absolute" style="left: )-?\d+mm; top: -?\d+mm(;"><div class="relative w-\[88px\]...pack3-asteroids)/g,
(match, prefix, suffix) => { /* replace with new left/top from JSON */ }
);
```
### When to use this pattern
Use the visual editor approach whenever a worksheet requires **manual fine-tuning of element positions** — any time automated placement can't fully replace human judgment (e.g., avoiding overlaps with irregular ship shapes, achieving visual balance). The workflow is:
1. Claude generates initial positions algorithmically
2. User opens editor, adjusts positions visually
3. User exports JSON → Claude applies to HTML
4. Generate PDF
This pattern can be extended to other element types by adding new CSS selectors to the editor's identification logic.
## Workflow ## Workflow
1. User describes the math task idea (or runs `/new-doc`) 1. User describes the math task idea (or runs `/new-doc`)
2. Claude creates/updates a JSON config in `tasks/` with `pages[].task` descriptions 2. Claude creates/updates a JSON config in `tasks/` with `pages[].task` descriptions
3. Claude reads `src/templates/space-base.html` + `src/examples/space-worksheet.html` as references 3. Claude reads `src/templates/space-base.html` + `src/examples/space-worksheet.html` as references
4. Claude generates HTML file in `output/html/` — creates concrete problems from `task` text, assigns unique shuffled icons, builds all pages 4. Claude generates HTML file in `output/html/` — creates concrete problems from `task` text, assigns unique shuffled icons, builds all pages
5. Add a link to the new document in `output/index.html` (card with title and path) 5. **(Optional)** User fine-tunes element positions via visual editor at `editor.html?file=<name>.html`, exports JSON, Claude applies
6. Run `pnpm pdf -- output/html/<file>.html` to create PDF 6. Add a link to the new document in `output/index.html` (card with title and path)
7. Preview with `pnpm preview` at localhost:3000 7. Run `node src/scripts/generate-pdf.mjs output/html/<file>.html` to create PDF
8. Preview with `pnpm preview` at localhost:3300

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 857 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 852 KiB

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 983 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 927 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

View File

@ -10,7 +10,7 @@ module.exports = {
"output/html/**/*.html", "output/html/**/*.html",
"assets/**/*" "assets/**/*"
], ],
port: 3000, port: 3300,
open: false, open: false,
notify: false, notify: false,
ui: false ui: false

View File

@ -0,0 +1,122 @@
#!/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}`);

View File

@ -0,0 +1,742 @@
#!/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}`);

View File

@ -1,7 +1,7 @@
{ {
"id": "collecting-asteroids-1", "id": "collecting-asteroids-1",
"title": "Собери Астероиды", "title": "Собери Астероиды",
"description": "Match asteroids to cargo ships by weight sum — 3 pages with increasing difficulty", "description": "9-page asteroid-matching worksheet: match asteroids to cargo ships by weight sum, increasing difficulty",
"labels": { "labels": {
"title": "Собери Астероиды", "title": "Собери Астероиды",
"subtitle": "Загрузи трюмы кораблей!", "subtitle": "Загрузи трюмы кораблей!",
@ -14,41 +14,118 @@
}, },
"layout": { "layout": {
"type": "asteroid-matching", "type": "asteroid-matching",
"shipsPerPage": 3 "shipsPerPage": 3,
"asteroidTypesPerPage": 2,
"asteroidTypeRatio": "70:30"
}, },
"pages": [ "pages": [
{ {
"heroImage": "assets/hero-images/spaceship2.jpeg",
"footerImage": "assets/footers/planet3.jpeg",
"heroDirection": "row-reverse",
"overlapLevel": 30,
"ships": [ "ships": [
{ "capacity": 9, "asteroids": [3, 2, 4, 5] }, { "capacity": 5, "fillCount": 2 },
{ "capacity": 11, "asteroids": [5, 4, 2, 6] }, { "capacity": 5, "fillCount": 2 },
{ "capacity": 8, "asteroids": [1, 3, 4, 2] } { "capacity": 7, "fillCount": 3 }
] ],
"heroImage": "assets/hero-images/splitters/splitter1.png",
"footerImage": "assets/footers/cabin1.jpeg",
"heroDirection": "row-reverse",
"cargoBays": ["cargo-bay1", "cargo-bay2", "cargo-bay3"],
"asteroidTypes": ["asteroid1", "asteroid6"]
}, },
{ {
"heroImage": "assets/hero-images/spaceship5.jpeg", "ships": [
"footerImage": "assets/footers/planet5.jpeg", { "capacity": 7, "fillCount": 3 },
{ "capacity": 7, "fillCount": 3 },
{ "capacity": 10, "fillCount": 4 }
],
"heroImage": "assets/hero-images/splitters/splitter2.png",
"footerImage": "assets/footers/cabin2.jpeg",
"heroDirection": "row", "heroDirection": "row",
"overlapLevel": 50, "cargoBays": ["cargo-bay4", "cargo-bay5", "cargo-bay6"],
"ships": [ "asteroidTypes": ["asteroid3", "asteroid9"]
{ "capacity": 10, "asteroids": [6, 1, 3, 4] },
{ "capacity": 12, "asteroids": [5, 4, 3, 6] },
{ "capacity": 14, "asteroids": [6, 5, 2, 1, 3] }
]
}, },
{ {
"heroImage": "assets/hero-images/spaceship7.jpeg",
"footerImage": "assets/footers/planet8.jpeg",
"heroDirection": "row-reverse",
"overlapLevel": 70,
"ships": [ "ships": [
{ "capacity": 13, "asteroids": [5, 4, 3, 1, 6] }, { "capacity": 5, "fillCount": 2 },
{ "capacity": 10, "asteroids": [4, 4, 2, 3] }, { "capacity": 5, "fillCount": 3 },
{ "capacity": 15, "asteroids": [6, 5, 3, 1, 4] } { "capacity": 6, "fillCount": 3 }
] ],
"heroImage": "assets/hero-images/splitters/splitter3.png",
"footerImage": "assets/footers/cabin3.jpeg",
"heroDirection": "row-reverse",
"cargoBays": ["cargo-bay7", "cargo-bay8", "cargo-bay9"],
"asteroidTypes": ["asteroid5", "asteroid11"]
},
{
"ships": [
{ "capacity": 8, "fillCount": 2 },
{ "capacity": 8, "fillCount": 3 },
{ "capacity": 6, "fillCount": 2 }
],
"heroImage": "assets/hero-images/splitters/splitter4.png",
"footerImage": "assets/footers/cabin4.jpeg",
"heroDirection": "row",
"cargoBays": ["cargo-bay1", "cargo-bay2", "cargo-bay3"],
"asteroidTypes": ["asteroid2", "asteroid8"]
},
{
"ships": [
{ "capacity": 4, "fillCount": 1 },
{ "capacity": 4, "fillCount": 2 },
{ "capacity": 4, "fillCount": 3 }
],
"heroImage": "assets/hero-images/splitters/splitter5.png",
"footerImage": "assets/footers/cabin5.jpeg",
"heroDirection": "row-reverse",
"cargoBays": ["cargo-bay4", "cargo-bay5", "cargo-bay6"],
"asteroidTypes": ["asteroid4", "asteroid10"]
},
{
"ships": [
{ "capacity": 10, "fillCount": 4 },
{ "capacity": 10, "fillCount": 3 },
{ "capacity": 8, "fillCount": 3 }
],
"heroImage": "assets/hero-images/splitters/splitter6.png",
"footerImage": "assets/footers/cabin6.jpeg",
"heroDirection": "row",
"cargoBays": ["cargo-bay7", "cargo-bay8", "cargo-bay9"],
"asteroidTypes": ["asteroid7", "asteroid13"]
},
{
"ships": [
{ "capacity": 6, "fillCount": 2 },
{ "capacity": 9, "fillCount": 3 },
{ "capacity": 12, "fillCount": 4 }
],
"heroImage": "assets/hero-images/splitters/splitter7.png",
"footerImage": "assets/footers/cabin7.jpeg",
"heroDirection": "row-reverse",
"cargoBays": ["cargo-bay1", "cargo-bay2", "cargo-bay3"],
"asteroidTypes": ["asteroid12", "asteroid16"]
},
{
"ships": [
{ "capacity": 14, "fillCount": 3 },
{ "capacity": 10, "fillCount": 2 },
{ "capacity": 16, "fillCount": 4 }
],
"heroImage": "assets/hero-images/splitters/splitter8.png",
"footerImage": "assets/footers/cabin8.jpeg",
"heroDirection": "row",
"cargoBays": ["cargo-bay4", "cargo-bay5", "cargo-bay6"],
"asteroidTypes": ["asteroid14", "asteroid15"]
},
{
"ships": [
{ "capacity": 5, "fillCount": 2 },
{ "capacity": 10, "fillCount": 4 },
{ "capacity": 15, "fillCount": 5 }
],
"heroImage": "assets/hero-images/splitters/splitter9.png",
"footerImage": "assets/footers/cabin9.jpeg",
"heroDirection": "row-reverse",
"cargoBays": ["cargo-bay7", "cargo-bay8", "cargo-bay9"],
"asteroidTypes": ["asteroid1", "asteroid9"]
} }
] ]
} }

View File

@ -0,0 +1,131 @@
{
"id": "collecting-asteroids-2",
"title": "Собери Астероиды",
"description": "9-page asteroid-matching worksheet v2: reshuffled assets, doubled difficulty",
"labels": {
"title": "Собери Астероиды",
"subtitle": "Загрузи трюмы кораблей!",
"footerBubble": "Итого загружено:"
},
"theme": {
"style": "space-asteroids",
"template": "space-base",
"asteroids": "assets/icons/pack3-asteroids/"
},
"layout": {
"type": "asteroid-matching",
"shipsPerPage": 3,
"asteroidTypesPerPage": 2,
"asteroidTypeRatio": "70:30"
},
"pages": [
{
"ships": [
{ "capacity": 10, "fillCount": 2 },
{ "capacity": 10, "fillCount": 2 },
{ "capacity": 14, "fillCount": 3 }
],
"heroImage": "assets/hero-images/splitters/splitter5.png",
"footerImage": "assets/footers/cabin7.jpeg",
"heroDirection": "row",
"cargoBays": ["cargo-bay4", "cargo-bay5", "cargo-bay6"],
"asteroidTypes": ["asteroid7", "asteroid14"]
},
{
"ships": [
{ "capacity": 14, "fillCount": 3 },
{ "capacity": 14, "fillCount": 3 },
{ "capacity": 20, "fillCount": 4 }
],
"heroImage": "assets/hero-images/splitters/splitter2.png",
"footerImage": "assets/footers/cabin4.jpeg",
"heroDirection": "row-reverse",
"cargoBays": ["cargo-bay7", "cargo-bay8", "cargo-bay9"],
"asteroidTypes": ["asteroid4", "asteroid11"]
},
{
"ships": [
{ "capacity": 10, "fillCount": 2 },
{ "capacity": 10, "fillCount": 3 },
{ "capacity": 12, "fillCount": 3 }
],
"heroImage": "assets/hero-images/splitters/splitter8.png",
"footerImage": "assets/footers/cabin1.jpeg",
"heroDirection": "row",
"cargoBays": ["cargo-bay1", "cargo-bay2", "cargo-bay3"],
"asteroidTypes": ["asteroid10", "asteroid3"]
},
{
"ships": [
{ "capacity": 16, "fillCount": 2 },
{ "capacity": 16, "fillCount": 3 },
{ "capacity": 12, "fillCount": 2 }
],
"heroImage": "assets/hero-images/splitters/splitter3.png",
"footerImage": "assets/footers/cabin9.jpeg",
"heroDirection": "row-reverse",
"cargoBays": ["cargo-bay4", "cargo-bay5", "cargo-bay6"],
"asteroidTypes": ["asteroid16", "asteroid5"]
},
{
"ships": [
{ "capacity": 8, "fillCount": 1 },
{ "capacity": 8, "fillCount": 2 },
{ "capacity": 8, "fillCount": 3 }
],
"heroImage": "assets/hero-images/splitters/splitter9.png",
"footerImage": "assets/footers/cabin2.jpeg",
"heroDirection": "row",
"cargoBays": ["cargo-bay7", "cargo-bay8", "cargo-bay9"],
"asteroidTypes": ["asteroid2", "asteroid13"]
},
{
"ships": [
{ "capacity": 20, "fillCount": 4 },
{ "capacity": 20, "fillCount": 3 },
{ "capacity": 16, "fillCount": 3 }
],
"heroImage": "assets/hero-images/splitters/splitter1.png",
"footerImage": "assets/footers/cabin6.jpeg",
"heroDirection": "row-reverse",
"cargoBays": ["cargo-bay1", "cargo-bay2", "cargo-bay3"],
"asteroidTypes": ["asteroid8", "asteroid12"]
},
{
"ships": [
{ "capacity": 12, "fillCount": 2 },
{ "capacity": 18, "fillCount": 3 },
{ "capacity": 24, "fillCount": 4 }
],
"heroImage": "assets/hero-images/splitters/splitter6.png",
"footerImage": "assets/footers/cabin3.jpeg",
"heroDirection": "row",
"cargoBays": ["cargo-bay4", "cargo-bay5", "cargo-bay6"],
"asteroidTypes": ["asteroid15", "asteroid6"]
},
{
"ships": [
{ "capacity": 28, "fillCount": 3 },
{ "capacity": 20, "fillCount": 2 },
{ "capacity": 32, "fillCount": 4 }
],
"heroImage": "assets/hero-images/splitters/splitter4.png",
"footerImage": "assets/footers/cabin8.jpeg",
"heroDirection": "row-reverse",
"cargoBays": ["cargo-bay7", "cargo-bay8", "cargo-bay9"],
"asteroidTypes": ["asteroid9", "asteroid1"]
},
{
"ships": [
{ "capacity": 10, "fillCount": 2 },
{ "capacity": 20, "fillCount": 4 },
{ "capacity": 30, "fillCount": 5 }
],
"heroImage": "assets/hero-images/splitters/splitter7.png",
"footerImage": "assets/footers/cabin5.jpeg",
"heroDirection": "row",
"cargoBays": ["cargo-bay1", "cargo-bay2", "cargo-bay3"],
"asteroidTypes": ["asteroid11", "asteroid4"]
}
]
}