Hustler-Pixelwelt T1+T2: Import der Zeichenfläche (Bilder, CSS mit hk-Vorsatz, Geometrie), helles Haus mit grauen Raum-Ebenen, Press Start 2P selbst gehostet
- scripts/hustler-import.mjs liest pixelwelt-v2 (+ pixelwelt-woms/haus) und schreibt src/assets/hustler, src/styles/hustler/{hochhaus,keller,pult}.css und src/lib/hustlerWelt.js; idempotent
- haus.png mit allen Räumen hell, acht graue Ebenen für die Such-Räume (GRAUE_RAEUME, .hk-raum-grau-<raum>)
- Press Start 2P lateinisch aus @fontsource/press-start-2p@5.2.7 (OFL), noch nirgends importiert
- 40 neue Tests (Assets, Geometrie, CSS-Umformung)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e24868995
commit
8bd365b1fc
195 files changed
+2697
No files matched your search
@@ -0,0 +1,954 @@
|
||||
#!/usr/bin/env node
|
||||
// Spielt die Pixel-Oberfläche „Hustler“ aus der Claude-Design-Zeichenfläche (pixelwelt-v2) in WOMS ein.
|
||||
//
|
||||
// Aufruf: node scripts/hustler-import.mjs <pfad-zu-pixelwelt-v2> [<pfad-zu-pixelwelt-woms>]
|
||||
//
|
||||
// Erzeugt (alles deterministisch, ein zweiter Lauf ändert keine Datei):
|
||||
// src/assets/hustler/<quellpfad ohne "entwurf/"> die benutzten PNGs (Vite hängt beim Bauen einen Hash an)
|
||||
// src/styles/hustler/{hochhaus,keller,pult}.css das CSS der drei Artboards, auf WOMS zugeschnitten
|
||||
// src/lib/hustlerWelt.js Geometrie des Hochhauses (reine Daten, eingefroren)
|
||||
//
|
||||
// Der optionale zweite Ordner (pixelwelt-woms) enthält das neu gezeichnete Haus. Eine Datei dort unter
|
||||
// haus/ ersetzt die gleichnamige aus pixelwelt-v2/haus/. Nennt dort haus/manifest.json „graueRaeume“,
|
||||
// entstehen zusätzlich die Ebenen für abgedunkelte Räume (CSS-Klassen hk-raum-grau-<raum> und GRAUE_RAEUME).
|
||||
// Erwartetes Format: graueRaeume = { "<raum>": { "file": "raum-grau-<raum>.png", "x": …, "y": …, "w": …, "h": … } }
|
||||
// (x/y in Weltkoordinaten wie alle Werte der Haus-Manifeste; file, w und h sind optional – ohne Angabe gilt
|
||||
// raum-grau-<raum>.png in seiner Pixelgröße). Statt eines Objekts geht auch eine Liste mit „raum“ je Eintrag.
|
||||
//
|
||||
// Nur Node-Bordmittel. Schreibt ausschließlich in die drei Zielorte oben; gelöscht werden höchstens alte
|
||||
// Ebenen src/assets/hustler/haus/raum-grau-*.png, die der aktuelle Lauf nicht mehr erzeugt.
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const HIER = path.dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = path.resolve(HIER, '..')
|
||||
const ROSTER_DATEI = path.join(REPO, 'src/lib/workersRoster.js')
|
||||
const QUELLEN_DATEI = path.join(HIER, 'hustler-import/quellen.json')
|
||||
|
||||
// Ausgabeorte (relativ zur Repo-Wurzel, mit /)
|
||||
const ASSETS = 'src/assets/hustler'
|
||||
const STYLES = 'src/styles/hustler'
|
||||
const WELT_DATEI = 'src/lib/hustlerWelt.js'
|
||||
|
||||
// Die drei Bildschirme: Artboard, Blob-Zuordnung, Wurzelklasse, Keyframe-Vorsatz, Ausgabedatei
|
||||
export const BILDSCHIRME = Object.freeze({
|
||||
hochhaus: { artboard: 'Main.dc.html', titel: 'Hochhaus', html: 'entwurf/Main.dc.html', blobs: 'entwurf/blobs.json', wurzel: 'hk-hochhaus', kfVorsatz: 'hk-h-', css: 'hochhaus.css' },
|
||||
keller: { artboard: 'BotLeads.dc.html', titel: 'Keller', html: 'keller/entwurf/BotLeads.dc.html', blobs: 'keller/entwurf/blobs.json', wurzel: 'hk-keller', kfVorsatz: 'hk-k-', css: 'keller.css' },
|
||||
pult: { artboard: 'Freigabe.dc.html', titel: 'Pult', html: 'pult/entwurf/Freigabe.dc.html', blobs: 'pult/entwurf/blobs.json', wurzel: 'hk-pult', kfVorsatz: 'hk-p-', css: 'pult.css' },
|
||||
})
|
||||
|
||||
// Raum-Schlüssel im Haus-Manifest -> Raum-Schlüssel in ROOM_TITLES (workersRoster.js)
|
||||
const RAUM_NACH_ROSTER = Object.freeze({
|
||||
bruecke: 'bruecke', radar: 'netz', klein: 'kleinanzeigen', portale: 'portale', foren: 'foren',
|
||||
aerzte: 'aerzte', handwerk: 'handwerk', selbst: 'selbst', laeden: 'laden', waage: 'waage',
|
||||
mail: 'mail', forum: 'forum', post: 'posteingang', abschluss: 'abschluss',
|
||||
tisch1: 'tisch1', tisch2: 'tisch2', tisch3: 'tisch3', pruef: 'pruefung',
|
||||
freigabe: 'pult', tresor: 'tresor', keller: 'archiv',
|
||||
})
|
||||
|
||||
export class ImportFehler extends Error {
|
||||
constructor(text) {
|
||||
super(text)
|
||||
this.name = 'ImportFehler'
|
||||
}
|
||||
}
|
||||
const fehler = (text) => new ImportFehler(text)
|
||||
|
||||
const sha256 = (daten) => createHash('sha256').update(daten).digest('hex')
|
||||
const kurz = (text, n = 70) => {
|
||||
const t = String(text).replace(/\s+/g, ' ').trim()
|
||||
return t.length > n ? `${t.slice(0, n)}…` : t
|
||||
}
|
||||
|
||||
// ───────────────────────────── CSS-Tokenizer ─────────────────────────────
|
||||
// Kleiner, strenger Zerleger: Kommentare, Zeichenketten, url(…), verschachtelte @media/@supports, @keyframes.
|
||||
|
||||
function stringEnde(s, i) {
|
||||
const q = s[i]
|
||||
let j = i + 1
|
||||
while (j < s.length) {
|
||||
const c = s[j]
|
||||
if (c === '\\') { j += 2; continue }
|
||||
if (c === q) return j + 1
|
||||
if (c === '\n') break
|
||||
j += 1
|
||||
}
|
||||
throw fehler(`CSS: Zeichenkette ohne Ende bei „${kurz(s.slice(i, i + 40))}“.`)
|
||||
}
|
||||
|
||||
// Entfernt /* … */ (Zeichenketten und ungequotetes url(…) bleiben unberührt)
|
||||
function ohneKommentare(s) {
|
||||
let aus = ''
|
||||
let i = 0
|
||||
while (i < s.length) {
|
||||
const c = s[i]
|
||||
if (c === '"' || c === "'") { const e = stringEnde(s, i); aus += s.slice(i, e); i = e; continue }
|
||||
if (c === '\\') { aus += s.slice(i, i + 2); i += 2; continue }
|
||||
if (c === '/' && s[i + 1] === '*') {
|
||||
const e = s.indexOf('*/', i + 2)
|
||||
if (e < 0) throw fehler('CSS: Kommentar ohne Ende.')
|
||||
aus += ' '
|
||||
i = e + 2
|
||||
continue
|
||||
}
|
||||
if ((c === 'u' || c === 'U') && /^url\(\s*[^\s"')]/i.test(s.slice(i, i + 12)) && !/[\w-]/.test(s[i - 1] ?? ' ')) {
|
||||
const e = s.indexOf(')', i)
|
||||
if (e < 0) throw fehler('CSS: url( ohne schließende Klammer.')
|
||||
aus += s.slice(i, e + 1)
|
||||
i = e + 1
|
||||
continue
|
||||
}
|
||||
aus += c
|
||||
i += 1
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
// Erste Stelle ab i, an der ein Zeichen aus `stopps` auf oberster Ebene steht (außerhalb von (), [] und Zeichenketten)
|
||||
function sucheOben(s, i, stopps) {
|
||||
let klammer = 0
|
||||
let eckig = 0
|
||||
while (i < s.length) {
|
||||
const c = s[i]
|
||||
if (c === '"' || c === "'") { i = stringEnde(s, i); continue }
|
||||
if (c === '\\') { i += 2; continue }
|
||||
if (c === '(') klammer += 1
|
||||
else if (c === ')') klammer = Math.max(0, klammer - 1)
|
||||
else if (c === '[') eckig += 1
|
||||
else if (c === ']') eckig = Math.max(0, eckig - 1)
|
||||
else if (klammer === 0 && eckig === 0 && stopps.includes(c)) return i
|
||||
i += 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function teileOben(s, trenner) {
|
||||
const teile = []
|
||||
let start = 0
|
||||
for (;;) {
|
||||
const e = sucheOben(s, start, trenner)
|
||||
if (e < 0) { teile.push(s.slice(start)); return teile }
|
||||
teile.push(s.slice(start, e))
|
||||
start = e + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Index der Klammer, die zu s[offen] ('(' oder '[') gehört; Zeichenketten und Escapes zählen nicht mit
|
||||
function passende(s, offen) {
|
||||
const auf = s[offen]
|
||||
const zu = auf === '(' ? ')' : ']'
|
||||
let tiefe = 0
|
||||
for (let i = offen; i < s.length; i += 1) {
|
||||
const c = s[i]
|
||||
if (c === '"' || c === "'") { i = stringEnde(s, i) - 1; continue }
|
||||
if (c === '\\') { i += 1; continue }
|
||||
if (c === auf) tiefe += 1
|
||||
else if (c === zu) {
|
||||
tiefe -= 1
|
||||
if (tiefe === 0) return i
|
||||
}
|
||||
}
|
||||
throw fehler(`CSS: „${auf}“ ohne passende schließende Klammer in „${kurz(s.slice(offen))}“.`)
|
||||
}
|
||||
|
||||
const GRUPPEN_REGELN = new Set(['media', 'supports', 'layer', 'container'])
|
||||
|
||||
// → { knoten, ende }; knoten: { art: 'regel'|'gruppe'|'keyframes'|'weg', … }
|
||||
function parseListe(s, start, innen) {
|
||||
const knoten = []
|
||||
let i = start
|
||||
for (;;) {
|
||||
while (i < s.length && /\s/.test(s[i])) i += 1
|
||||
if (i >= s.length) {
|
||||
if (innen) throw fehler('CSS: Block ohne schließende Klammer.')
|
||||
return { knoten, ende: i }
|
||||
}
|
||||
if (s[i] === '}') {
|
||||
if (!innen) throw fehler('CSS: unerwartete schließende Klammer.')
|
||||
return { knoten, ende: i + 1 }
|
||||
}
|
||||
const stopp = sucheOben(s, i, ';{}')
|
||||
if (stopp < 0) throw fehler(`CSS: Regel „${kurz(s.slice(i))}“ endet nicht.`)
|
||||
const kopf = s.slice(i, stopp).trim()
|
||||
if (s[stopp] === '}') throw fehler(`CSS: „${kurz(kopf)}“ steht ohne Block.`)
|
||||
if (s[stopp] === ';') {
|
||||
// @import/@charset ohne Block entfallen, andere Anweisungen kennen wir nicht
|
||||
if (/^@(import|charset)\b/i.test(kopf)) { knoten.push({ art: 'weg' }); i = stopp + 1; continue }
|
||||
throw fehler(`CSS: nicht unterstützte Anweisung „${kurz(kopf)}“.`)
|
||||
}
|
||||
// Block
|
||||
if (kopf.startsWith('@')) {
|
||||
const name = (kopf.match(/^@([A-Za-z-]+)/)?.[1] ?? '').toLowerCase()
|
||||
if (name === 'font-face' || name === 'import') {
|
||||
const e = blockEnde(s, stopp)
|
||||
knoten.push({ art: 'weg' })
|
||||
i = e + 1
|
||||
continue
|
||||
}
|
||||
if (/(^|-)keyframes$/.test(name)) {
|
||||
const kfName = kopf.slice(kopf.indexOf(name) + name.length).trim()
|
||||
if (!/^[A-Za-z_][\w-]*$/.test(kfName)) throw fehler(`CSS: ungewöhnlicher Keyframe-Name „${kurz(kfName)}“.`)
|
||||
const { knoten: bilder, ende } = parseListe(s, stopp + 1, true)
|
||||
knoten.push({ art: 'keyframes', at: `@${name}`, name: kfName, bilder })
|
||||
i = ende
|
||||
continue
|
||||
}
|
||||
if (GRUPPEN_REGELN.has(name)) {
|
||||
const { knoten: kinder, ende } = parseListe(s, stopp + 1, true)
|
||||
knoten.push({ art: 'gruppe', kopf, kinder })
|
||||
i = ende
|
||||
continue
|
||||
}
|
||||
throw fehler(`CSS: nicht unterstützte At-Regel „${kurz(kopf)}“.`)
|
||||
}
|
||||
const e = blockEnde(s, stopp)
|
||||
knoten.push({ art: 'regel', selektor: kopf, koerper: s.slice(stopp + 1, e) })
|
||||
i = e + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Index der schließenden Klammer zum Block, der bei s[offen] === '{' beginnt (ohne Verschachtelung)
|
||||
function blockEnde(s, offen) {
|
||||
const e = sucheOben(s, offen + 1, '{}')
|
||||
if (e < 0) throw fehler('CSS: Block ohne schließende Klammer.')
|
||||
if (s[e] === '{') throw fehler(`CSS: verschachtelte Regel in „${kurz(s.slice(offen + 1, e))}“ wird nicht unterstützt.`)
|
||||
return e
|
||||
}
|
||||
|
||||
// ───────────────────────────── Selektoren ─────────────────────────────
|
||||
|
||||
function istIdentStart(s, j) {
|
||||
const c = s[j]
|
||||
if (c === undefined) return false
|
||||
if (/[A-Za-z_\\]/.test(c) || c.charCodeAt(0) > 127) return true
|
||||
return c === '-' && /[A-Za-z_\\-]/.test(s[j + 1] ?? '')
|
||||
}
|
||||
|
||||
// Jede Klasse (.name) bekommt hk- davor; Zeichenketten und [attribute] bleiben unberührt
|
||||
function praefixKlassen(s) {
|
||||
let aus = ''
|
||||
let i = 0
|
||||
while (i < s.length) {
|
||||
const c = s[i]
|
||||
if (c === '"' || c === "'") { const e = stringEnde(s, i); aus += s.slice(i, e); i = e; continue }
|
||||
if (c === '\\') { aus += s.slice(i, i + 2); i += 2; continue }
|
||||
if (c === '[') {
|
||||
const ende = passende(s, i)
|
||||
aus += s.slice(i, ende + 1)
|
||||
i = ende + 1
|
||||
continue
|
||||
}
|
||||
if (c === '.' && istIdentStart(s, i + 1)) { aus += '.hk-'; i += 1; continue }
|
||||
aus += c
|
||||
i += 1
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
const WURZEL_ANFANG = /^(?:html|body|:root)(?![\w-])/i
|
||||
const WURZEL_FOLGE = /^\s*(?:>\s*)?(?=(?:html|body|:root)(?![\w-]))/i
|
||||
|
||||
export function wandleSelektor(selektor, wurzel) {
|
||||
let rest = selektor.trim()
|
||||
if (!rest) throw fehler('CSS: leerer Selektor.')
|
||||
let istWurzel = false
|
||||
while (WURZEL_ANFANG.test(rest)) {
|
||||
istWurzel = true
|
||||
rest = rest.replace(WURZEL_ANFANG, '')
|
||||
const folge = rest.match(WURZEL_FOLGE)
|
||||
if (!folge) break
|
||||
rest = rest.slice(folge[0].length)
|
||||
}
|
||||
if (/(^|[\s>+~(,])(?:html|body|:root)(?![\w-])/i.test(rest)) {
|
||||
throw fehler(`CSS: html/body/:root mitten im Selektor „${kurz(selektor)}“ lässt sich nicht sicher umformen.`)
|
||||
}
|
||||
const mitKlassen = praefixKlassen(rest)
|
||||
return istWurzel ? `.${wurzel}${mitKlassen}` : `.${wurzel} ${mitKlassen}`
|
||||
}
|
||||
|
||||
function wandleSelektorListe(liste, wurzel) {
|
||||
const neu = []
|
||||
for (const teil of teileOben(liste, ',')) {
|
||||
const s = wandleSelektor(teil, wurzel)
|
||||
if (!neu.includes(s)) neu.push(s)
|
||||
}
|
||||
return neu.join(',')
|
||||
}
|
||||
|
||||
// ───────────────────────────── Werte ─────────────────────────────
|
||||
|
||||
const BLOB_ADRESSE = /^\/_blob\/([0-9A-Za-z]+)$/
|
||||
|
||||
function blobInhalt(inhalt, blobPfad) {
|
||||
const m = inhalt.trim().match(BLOB_ADRESSE)
|
||||
if (m) return blobPfad(m[1])
|
||||
if (inhalt.includes('/_blob/')) throw fehler(`CSS: unerwartete Blob-Adresse „${kurz(inhalt)}“.`)
|
||||
return inhalt
|
||||
}
|
||||
|
||||
// url(/_blob/…) und Zeichenketten mit Blob-Adresse -> Asset-Pfad
|
||||
function ersetzeBlobs(wert, blobPfad) {
|
||||
let aus = ''
|
||||
let i = 0
|
||||
while (i < wert.length) {
|
||||
const c = wert[i]
|
||||
if (c === '"' || c === "'") {
|
||||
const e = stringEnde(wert, i)
|
||||
aus += c + blobInhalt(wert.slice(i + 1, e - 1), blobPfad) + c
|
||||
i = e
|
||||
continue
|
||||
}
|
||||
if (c === '\\') { aus += wert.slice(i, i + 2); i += 2; continue }
|
||||
if ((c === 'u' || c === 'U') && /^url\(/i.test(wert.slice(i, i + 4)) && !/[\w-]/.test(wert[i - 1] ?? ' ')) {
|
||||
let j = i + 4
|
||||
while (j < wert.length && /\s/.test(wert[j])) j += 1
|
||||
if (wert[j] === '"' || wert[j] === "'") { aus += wert.slice(i, j); i = j; continue } // die Zeichenkette folgt in der Schleife
|
||||
const e = wert.indexOf(')', j)
|
||||
if (e < 0) throw fehler(`CSS: url( ohne schließende Klammer in „${kurz(wert)}“.`)
|
||||
aus += `url(${blobInhalt(wert.slice(j, e), blobPfad)})`
|
||||
i = e + 1
|
||||
continue
|
||||
}
|
||||
aus += c
|
||||
i += 1
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
const ZAHL = /[+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?[A-Za-z%]*/y
|
||||
const IDENT = /(?:[A-Za-z_]|[^\u0000-\u007f]|\\.)(?:[\w-]|[^\u0000-\u007f]|\\.)*|-(?:[A-Za-z_-]|[^\u0000-\u007f]|\\.)(?:[\w-]|[^\u0000-\u007f]|\\.)*/y
|
||||
|
||||
// Bekannte Keyframe-Namen in animation/animation-name umbenennen; Schlüsselwörter, Zahlen mit Einheit
|
||||
// (3200ms, -1.3s, .5s) und Funktionen (steps(1,end)) bleiben unberührt
|
||||
function benenneAnimationen(wert, umbenennen) {
|
||||
let aus = ''
|
||||
let i = 0
|
||||
while (i < wert.length) {
|
||||
const c = wert[i]
|
||||
if (c === '"' || c === "'") { const e = stringEnde(wert, i); aus += wert.slice(i, e); i = e; continue }
|
||||
ZAHL.lastIndex = i
|
||||
const zahl = ZAHL.exec(wert)
|
||||
if (zahl) {
|
||||
aus += zahl[0]
|
||||
i += zahl[0].length
|
||||
continue
|
||||
}
|
||||
IDENT.lastIndex = i
|
||||
const ident = IDENT.exec(wert)
|
||||
if (ident) {
|
||||
const name = ident[0]
|
||||
i += name.length
|
||||
if (wert[i] === '(') {
|
||||
// Funktion (steps, cubic-bezier, var …): unverändert bis zur passenden Klammer
|
||||
const ende = passende(wert, i)
|
||||
aus += name + wert.slice(i, ende + 1)
|
||||
i = ende + 1
|
||||
} else {
|
||||
aus += umbenennen.has(name) ? umbenennen.get(name) : name
|
||||
}
|
||||
continue
|
||||
}
|
||||
aus += c
|
||||
i += 1
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
function wandleDeklarationen(koerper, ctx) {
|
||||
const teile = []
|
||||
for (const roh of teileOben(koerper, ';')) {
|
||||
const d = roh.trim()
|
||||
if (!d) continue
|
||||
const k = sucheOben(d, 0, ':')
|
||||
if (k < 0) throw fehler(`CSS: Deklaration ohne „:“ in „${kurz(d)}“.`)
|
||||
const eigenschaft = d.slice(0, k).trim()
|
||||
let wert = d.slice(k + 1).trim().replace(/\s*\n\s*/g, ' ')
|
||||
wert = ersetzeBlobs(wert, ctx.blobPfad)
|
||||
if (/^(?:-webkit-)?animation(?:-name)?$/i.test(eigenschaft)) wert = benenneAnimationen(wert, ctx.kfNamen)
|
||||
teile.push(`${eigenschaft}:${wert}`)
|
||||
}
|
||||
return teile.join('; ')
|
||||
}
|
||||
|
||||
function sammleKeyframes(knoten, namen) {
|
||||
for (const k of knoten) {
|
||||
if (k.art === 'keyframes') namen.add(k.name)
|
||||
else if (k.art === 'gruppe') sammleKeyframes(k.kinder, namen)
|
||||
}
|
||||
}
|
||||
|
||||
function schreibeKnoten(knoten, ctx, zeilen) {
|
||||
for (const k of knoten) {
|
||||
if (k.art === 'weg') continue
|
||||
if (k.art === 'regel') {
|
||||
zeilen.push(`${wandleSelektorListe(k.selektor, ctx.wurzel)}{${wandleDeklarationen(k.koerper, ctx)}}`)
|
||||
} else if (k.art === 'keyframes') {
|
||||
const bilder = k.bilder.map((b) => {
|
||||
if (b.art !== 'regel') throw fehler(`CSS: unerwarteter Inhalt in @keyframes ${k.name}.`)
|
||||
return `${b.selektor.replace(/\s+/g, ' ')}{${wandleDeklarationen(b.koerper, ctx)}}`
|
||||
})
|
||||
zeilen.push(`${k.at} ${ctx.kfNamen.get(k.name)}{${bilder.join('')}}`)
|
||||
} else if (k.art === 'gruppe') {
|
||||
const innen = []
|
||||
schreibeKnoten(k.kinder, ctx, innen)
|
||||
zeilen.push(`${k.kopf.replace(/\s+/g, ' ')}{`, ...innen, '}')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formt das CSS eines Artboards für WOMS um.
|
||||
* @param {string} css zusammengesetzter Inhalt der <style>-Blöcke
|
||||
* @param {{ wurzel: string, kfVorsatz: string, blobPfad: (id: string) => string }} opt
|
||||
* @returns {{ css: string, keyframes: string[] }}
|
||||
*/
|
||||
export function wandleCss(css, { wurzel, kfVorsatz, blobPfad }) {
|
||||
const { knoten } = parseListe(ohneKommentare(css), 0, false)
|
||||
const namen = new Set()
|
||||
sammleKeyframes(knoten, namen)
|
||||
const kfNamen = new Map([...namen].map((n) => [n, `${kfVorsatz}${n}`]))
|
||||
const zeilen = []
|
||||
schreibeKnoten(knoten, { wurzel, kfNamen, blobPfad }, zeilen)
|
||||
const aus = zeilen.join('\n')
|
||||
if (aus.includes('/_blob/')) throw fehler('CSS: nach der Umformung steht noch eine Blob-Adresse im Ergebnis.')
|
||||
return { css: aus, keyframes: [...kfNamen.values()] }
|
||||
}
|
||||
|
||||
// ───────────────────────────── HTML der Artboards ─────────────────────────────
|
||||
|
||||
function styleBloecke(html, artboard) {
|
||||
const teile = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1])
|
||||
if (teile.length === 0) throw fehler(`${artboard}: kein <style>-Block gefunden.`)
|
||||
return teile.join('\n')
|
||||
}
|
||||
|
||||
const TAG = /<([A-Za-z][\w-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>]+))?)*)\s*\/?>/g
|
||||
const ATTR = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>]+)))?/g
|
||||
|
||||
function entschluessele(t) {
|
||||
return t.replace(/ /g, '\u00a0').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/&/g, '&')
|
||||
}
|
||||
|
||||
function elemente(bereich) {
|
||||
const liste = []
|
||||
for (const m of bereich.matchAll(TAG)) {
|
||||
const attr = {}
|
||||
for (const a of m[2].matchAll(ATTR)) attr[a[1]] = a[2] ?? a[3] ?? a[4] ?? ''
|
||||
const ende = m.index + m[0].length
|
||||
const naechstes = bereich.indexOf('<', ende)
|
||||
liste.push({
|
||||
tag: m[1].toLowerCase(),
|
||||
attr,
|
||||
klassen: new Set((attr.class ?? '').split(/\s+/).filter(Boolean)),
|
||||
stil: stilMap(attr.style),
|
||||
text: entschluessele(bereich.slice(ende, naechstes < 0 ? undefined : naechstes)).trim(),
|
||||
})
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
function stilMap(s = '') {
|
||||
const o = {}
|
||||
for (const d of teileOben(s, ';')) {
|
||||
const k = d.indexOf(':')
|
||||
if (k > 0) o[d.slice(0, k).trim().toLowerCase()] = d.slice(k + 1).trim()
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
function px(wert, was) {
|
||||
const m = typeof wert === 'string' ? wert.match(/^(-?\d+(?:\.\d+)?)px$/) : null
|
||||
if (!m) throw fehler(`Hochhaus: ${was} ist keine px-Angabe (${wert === undefined ? 'fehlt' : `„${wert}“`}).`)
|
||||
return Number(m[1])
|
||||
}
|
||||
|
||||
const rechteck = (e, was) => ({ left: px(e.stil.left, `${was}: left`), top: px(e.stil.top, `${was}: top`), w: px(e.stil.width, `${was}: width`), h: px(e.stil.height, `${was}: height`) })
|
||||
const punkt = (e, was) => ({ left: px(e.stil.left, `${was}: left`), top: px(e.stil.top, `${was}: top`) })
|
||||
|
||||
function genau(liste, was, bedingung) {
|
||||
const treffer = liste.filter(bedingung)
|
||||
if (treffer.length !== 1) throw fehler(`Hochhaus: ${was} kommt ${treffer.length}-mal in Main.dc.html vor, erwartet genau 1.`)
|
||||
return treffer[0]
|
||||
}
|
||||
|
||||
// ───────────────────────────── Geometrie des Hochhauses ─────────────────────────────
|
||||
|
||||
function leseHochhaus({ html, hausManifest, roster }) {
|
||||
const { FLOORS, FLOOR_TITLES, ROLES, ROOM_TITLES } = roster
|
||||
const kopfEnde = html.indexOf('</helmet>')
|
||||
const skriptStart = html.search(/<script\b[^>]*type="text\/x-dc"/i)
|
||||
if (kopfEnde < 0 || skriptStart < 0 || skriptStart < kopfEnde) throw fehler('Main.dc.html: Aufbau unbekannt (</helmet> oder das Logik-Skript fehlt).')
|
||||
const markup = elemente(html.slice(kopfEnde, skriptStart))
|
||||
const skript = html.slice(skriptStart)
|
||||
const css = styleBloecke(html, 'Main.dc.html')
|
||||
|
||||
// Welt und Haus (Kamerarahmen) wie welt() in der Logik
|
||||
const w = skript.match(/welt\(\)\s*\{\s*return\s*\{\s*w:\s*(\d+),\s*h:\s*(\d+),\s*haus:\s*\{\s*x:\s*(\d+),\s*y:\s*(\d+),\s*w:\s*(\d+),\s*h:\s*(\d+)\s*\},\s*hud:\s*(\d+)\s*\}/)
|
||||
if (!w) throw fehler('Main.dc.html: welt() in der Logik nicht gefunden.')
|
||||
const WELT = { w: Number(w[1]), h: Number(w[2]), hud: Number(w[7]) }
|
||||
const HAUS = { x: Number(w[3]), y: Number(w[4]), w: Number(w[5]), h: Number(w[6]) }
|
||||
const pl = hausManifest.placement
|
||||
const HAUS_BILD = { x: pl.x, y: pl.y, w: pl.w, h: pl.h }
|
||||
|
||||
// Etagen: Klickbereiche aus den Etagen-Knöpfen, Öffnung aus dem Haus-Manifest
|
||||
const knoepfe = markup.filter((e) => e.klassen.has('etage'))
|
||||
if (knoepfe.length !== FLOORS.length) throw fehler(`Hochhaus: ${knoepfe.length} Etagen-Knöpfe in Main.dc.html, erwartet ${FLOORS.length}.`)
|
||||
if (hausManifest.floors.length !== FLOORS.length) throw fehler(`Hochhaus: ${hausManifest.floors.length} Etagen im Haus-Manifest, erwartet ${FLOORS.length}.`)
|
||||
const spalten = new Set()
|
||||
const ETAGEN = FLOORS.map((id, i) => {
|
||||
const b = rechteck(knoepfe[i], `Etagen-Knopf ${i + 1}`)
|
||||
const beschriftung = knoepfe[i].attr['aria-label'] ?? ''
|
||||
if (!beschriftung.startsWith(FLOOR_TITLES[id])) throw fehler(`Hochhaus: Etagen-Knopf ${i + 1} heißt „${beschriftung}“, erwartet ${FLOOR_TITLES[id]} (id ${id}).`)
|
||||
spalten.add(`${b.left}/${b.w}`)
|
||||
const f = hausManifest.floors[i]
|
||||
if (f.name !== FLOOR_TITLES[id]) throw fehler(`Hochhaus: Etage ${i} heißt im Haus-Manifest „${f.name}“, erwartet ${FLOOR_TITLES[id]}.`)
|
||||
const oeffnung = [f.openingY0, f.openingY1]
|
||||
if (!(b.top <= oeffnung[0] && oeffnung[1] <= b.top + b.h)) throw fehler(`Hochhaus: Öffnung ${oeffnung.join('–')} von ${id} liegt nicht im Klickbereich.`)
|
||||
return { id, oben: b.top, unten: b.top + b.h, oeffnung }
|
||||
})
|
||||
if (spalten.size !== 1) throw fehler('Hochhaus: Die Etagen-Knöpfe stehen nicht alle in derselben Spalte.')
|
||||
const [spalteLinks, spalteBreite] = [...spalten][0].split('/').map(Number)
|
||||
ETAGEN.forEach((e, i) => {
|
||||
if (e.oben >= e.unten) throw fehler(`Hochhaus: Klickbereich von ${e.id} ist leer.`)
|
||||
if (i > 0 && ETAGEN[i - 1].unten !== e.oben) throw fehler(`Hochhaus: Klickbereiche von ${ETAGEN[i - 1].id} und ${e.id} schließen nicht lückenlos an.`)
|
||||
})
|
||||
const ETAGEN_SPALTE = { x: spalteLinks, w: spalteBreite }
|
||||
|
||||
// Räume aus dem Haus-Manifest; Etiketten-Punkt = x0 + 20 / Öffnung oben + 3, gegen die Schilder in Main.dc.html geprüft
|
||||
const raumReihe = Object.keys(ROOM_TITLES)
|
||||
const nachRoster = new Map()
|
||||
for (const r of hausManifest.rooms) {
|
||||
const schluessel = RAUM_NACH_ROSTER[r.key]
|
||||
if (!schluessel) throw fehler(`Hochhaus: Raum „${r.key}“ aus dem Haus-Manifest ist in RAUM_NACH_ROSTER unbekannt.`)
|
||||
if (nachRoster.has(schluessel)) throw fehler(`Hochhaus: Raum ${schluessel} steht doppelt im Haus-Manifest.`)
|
||||
nachRoster.set(schluessel, r)
|
||||
}
|
||||
const beschriftungen = markup.filter((e) => e.tag === 'span' && e.stil['font-size'] === '8px' && e.stil.color === '#4a5266')
|
||||
// Die drei Räume ohne Anschluss tragen in der Vorlage statt des Etiketts einen Zettel „<Raum> / nicht angeschlossen“
|
||||
const suchRaeume = roster.SEARCH_ROOMS
|
||||
const zettel = markup.filter((e) => e.tag === 'span' && e.stil.background === '#f1ede4' && suchRaeume.some((r) => ROOM_TITLES[r] === e.text))
|
||||
const zettelRaeume = {}
|
||||
let zettelBreite = null
|
||||
for (const z of zettel) {
|
||||
const raumSchluessel = suchRaeume.find((r) => ROOM_TITLES[r] === z.text)
|
||||
const breite = px(z.stil.width, `Zettel ${raumSchluessel}: width`)
|
||||
if (zettelBreite !== null && zettelBreite !== breite) throw fehler('Hochhaus: Die „nicht angeschlossen“-Zettel sind nicht gleich breit.')
|
||||
zettelBreite = breite
|
||||
zettelRaeume[raumSchluessel] = punkt(z, `Zettel ${raumSchluessel}`)
|
||||
}
|
||||
const RAEUME = {}
|
||||
for (const schluessel of raumReihe) {
|
||||
const r = nachRoster.get(schluessel)
|
||||
if (!r) throw fehler(`Hochhaus: Raum ${schluessel} fehlt im Haus-Manifest.`)
|
||||
const etage = FLOORS[r.floor]
|
||||
if (!etage) throw fehler(`Hochhaus: Raum ${schluessel} liegt auf unbekannter Etage ${r.floor}.`)
|
||||
const raum = { etage, x0: r.x, x1: r.x + r.w, y0: r.y, y1: r.y + r.h, etikett: { x: r.x + 20, y: r.y + 3 } }
|
||||
const schilder = beschriftungen.filter((e) => e.text === ROOM_TITLES[schluessel])
|
||||
if (schilder.length > 1) throw fehler(`Hochhaus: Raumschild „${ROOM_TITLES[schluessel]}“ kommt ${schilder.length}-mal in Main.dc.html vor.`)
|
||||
if (schilder.length === 1) {
|
||||
const sp = punkt(schilder[0], `Raumschild ${schluessel}`)
|
||||
if (sp.left !== raum.etikett.x || sp.top !== raum.etikett.y) {
|
||||
throw fehler(`Hochhaus: Raumschild „${ROOM_TITLES[schluessel]}“ steht in Main.dc.html bei ${sp.left}/${sp.top}, nach dem Haus-Manifest bei ${raum.etikett.x}/${raum.etikett.y}.`)
|
||||
}
|
||||
} else if (!zettelRaeume[schluessel]) {
|
||||
throw fehler(`Hochhaus: Für Raum „${ROOM_TITLES[schluessel]}“ gibt es in Main.dc.html weder ein Raumschild noch einen „nicht angeschlossen“-Zettel.`)
|
||||
}
|
||||
const eta = ETAGEN.find((e) => e.id === etage)
|
||||
if (raum.y0 !== eta.oeffnung[0] || raum.y1 !== eta.oeffnung[1]) throw fehler(`Hochhaus: Raum ${schluessel} passt nicht zur Öffnung der Etage ${etage}.`)
|
||||
RAEUME[schluessel] = raum
|
||||
}
|
||||
|
||||
// Hustler: Anker aus dem Haus-Manifest, Knopf-Rechtecke und Bildschirme aus Main.dc.html
|
||||
const hustlerKnoepfe = markup.filter((e) => e.klassen.has('hustler'))
|
||||
const bildschirme = markup.filter((e) => e.klassen.has('bildschirm') && e.klassen.has('spr')).map((e) => punkt(e, 'Bildschirm'))
|
||||
const HUSTLER = {}
|
||||
const gesehen = new Set()
|
||||
for (const rolle of ROLES) {
|
||||
const anzeigeName = rolle.workerId === 'otto' ? 'Overseer' : rolle.name
|
||||
const knopf = genau(hustlerKnoepfe, `Hustler-Knopf ${anzeigeName}`, (e) => (e.attr['aria-label'] ?? '').split(',')[0].trim() === anzeigeName)
|
||||
const k = rechteck(knopf, `Knopf ${anzeigeName}`)
|
||||
const arbeiter = hausManifest.workers.find((x) => x.name === anzeigeName)
|
||||
if (!arbeiter) throw fehler(`Hochhaus: ${anzeigeName} fehlt unter workers im Haus-Manifest.`)
|
||||
const [a, f] = arbeiter.anchor
|
||||
const eintrag = { a, f, knopf: k }
|
||||
if (rolle.workerId === 'otto') {
|
||||
const sb = arbeiter.spriteBox
|
||||
if (k.left !== sb.x || k.top !== sb.y || k.w !== sb.w || k.h !== sb.h) throw fehler('Hochhaus: Knopf des Overseers passt nicht zur Sprite-Box im Haus-Manifest.')
|
||||
} else {
|
||||
if (k.left !== a - 12 || k.top !== f - 31 || k.w !== 24 || k.h !== 32) throw fehler(`Hochhaus: Knopf von ${anzeigeName} (${k.left}/${k.top}) passt nicht zum Anker ${a}/${f}.`)
|
||||
eintrag.bildschirm = { left: a - 40, top: f - 26 }
|
||||
const s = arbeiter.screen
|
||||
if (s && (s.x !== eintrag.bildschirm.left || s.y !== eintrag.bildschirm.top)) throw fehler(`Hochhaus: Bildschirm von ${anzeigeName} passt nicht zum Haus-Manifest.`)
|
||||
}
|
||||
const raum = RAEUME[rolle.room]
|
||||
if (!(raum.x0 <= a && a <= raum.x1 && raum.y0 <= f && f <= raum.y1)) throw fehler(`Hochhaus: Anker von ${anzeigeName} liegt nicht in Raum ${rolle.room}.`)
|
||||
HUSTLER[rolle.workerId] = eintrag
|
||||
gesehen.add(knopf)
|
||||
}
|
||||
if (gesehen.size !== hustlerKnoepfe.length) throw fehler(`Hochhaus: Main.dc.html hat ${hustlerKnoepfe.length} Hustler-Knöpfe, das Roster ${ROLES.length} Rollen.`)
|
||||
for (const b of bildschirme) {
|
||||
const passt = Object.values(HUSTLER).some((h) => h.bildschirm && h.bildschirm.left === b.left && h.bildschirm.top === b.top)
|
||||
if (!passt) throw fehler(`Hochhaus: Bildschirm bei ${b.left}/${b.top} in Main.dc.html gehört zu keinem Hustler.`)
|
||||
}
|
||||
const radar = punkt(genau(markup, 'Radar', (e) => e.klassen.has('radar') && e.klassen.has('spr')), 'Radar')
|
||||
const ritaRaum = RAEUME[ROLES.find((r) => r.workerId === 'rita').room]
|
||||
if (!(ritaRaum.x0 <= radar.left && radar.left <= ritaRaum.x1 && ritaRaum.y0 <= radar.top && radar.top <= ritaRaum.y1)) throw fehler('Hochhaus: Das Radar liegt nicht in Ritas Raum.')
|
||||
HUSTLER.rita.radar = radar
|
||||
|
||||
// Schilder
|
||||
const zaehler = genau(markup, 'Briefkasten-Zähler', (e) => e.klassen.has('huepf'))
|
||||
const tresor = genau(markup, 'Tresor-Text', (e) => e.tag === 'span' && /^\d+ Deals$/.test(e.text))
|
||||
const keller = genau(markup, 'Keller-Text', (e) => e.tag === 'span' && /^\d+ Bot-Leads$/.test(e.text))
|
||||
const neon = genau(markup, 'Neon-Fläche', (e) => e.klassen.has('neon'))
|
||||
const antenne = genau(markup, 'Antenne', (e) => e.klassen.has('antenne'))
|
||||
const schrift = (e, was) => ({ schrift: px(e.stil['font-size'], `${was}: font-size`), zeile: px(e.stil['line-height'], `${was}: line-height`) })
|
||||
const SCHILDER = {
|
||||
briefkasten: { left: px(zaehler.stil.left, 'Zähler: left'), top: px(zaehler.stil.top, 'Zähler: top'), minW: px(zaehler.stil['min-width'], 'Zähler: min-width'), h: px(zaehler.stil.height, 'Zähler: height'), schrift: px(zaehler.stil['font-size'], 'Zähler: font-size') },
|
||||
tresor: { ...punkt(tresor, 'Tresor-Text'), ...schrift(tresor, 'Tresor-Text') },
|
||||
keller: { ...punkt(keller, 'Keller-Text'), ...schrift(keller, 'Keller-Text') },
|
||||
neon: { ...rechteck(neon, 'Neon'), schrift: px(neon.stil['font-size'], 'Neon: font-size') },
|
||||
antenne: rechteck(antenne, 'Antenne'),
|
||||
nichtAngeschlossen: { breite: zettelBreite, raeume: zettelRaeume },
|
||||
}
|
||||
|
||||
// Aufzug: Seil und Kabine aus Main.dc.html, Halte (Oberkante der Kabine je Etage) aus dem Haus-Manifest
|
||||
const seil = genau(markup, 'Aufzug-Seil', (e) => e.klassen.has('seil') && e.klassen.has('haus'))
|
||||
const kabine = genau(markup, 'Aufzug-Kabine', (e) => e.klassen.has('aufzug') && e.klassen.has('haus'))
|
||||
const halte = hausManifest.aufzug.stopsY
|
||||
if (!Array.isArray(halte) || halte.length !== FLOORS.length) throw fehler('Hochhaus: Aufzug-Halte im Haus-Manifest passen nicht zur Etagenzahl.')
|
||||
const AUFZUG = { seil: rechteck(seil, 'Seil'), kabine: rechteck(kabine, 'Kabine'), halte }
|
||||
if (AUFZUG.kabine.top !== halte[0]) throw fehler('Hochhaus: Die Kabine startet nicht am ersten Halt.')
|
||||
|
||||
// Detailansicht: Figurposition (Logik) und Overlays (Markup)
|
||||
const fx = skript.match(/fx:\s*p\.hut\s*===\s*'overseer'\s*\?\s*(\d+)\s*:\s*\(p\.st\s*===\s*'nicht'\s*\?\s*(\d+)\s*:\s*(\d+)\)/)
|
||||
const fy = skript.match(/fy:\s*p\.hut\s*===\s*'overseer'\s*\?\s*(\d+)\s*:\s*\(p\.st\s*===\s*'nicht'\s*\?\s*(\d+)\s*:\s*(\d+)\)/)
|
||||
if (!fx || !fy) throw fehler('Main.dc.html: Figurposition (fx/fy) in der Logik nicht gefunden.')
|
||||
const szene = css.match(/\.sz\{[^}]*?width:\s*(\d+)px;\s*height:\s*(\d+)px/)
|
||||
if (!szene) throw fehler('Main.dc.html: CSS-Regel .sz (Größe der Detail-Szene) nicht gefunden.')
|
||||
const overlay = (klasse) => punkt(genau(markup, `Overlay ${klasse}`, (e) => e.klassen.has(klasse) && e.klassen.has('spr')), `Overlay ${klasse}`)
|
||||
const schild = genau(markup, 'Detail-Schild', (e) => e.text === 'noch nicht angeschlossen')
|
||||
const DETAIL = {
|
||||
szene: { w: Number(szene[1]), h: Number(szene[2]) },
|
||||
figur: {
|
||||
bruecke: { left: Number(fx[1]), top: Number(fy[1]) },
|
||||
nicht: { left: Number(fx[2]), top: Number(fy[2]) },
|
||||
buero: { left: Number(fx[3]), top: Number(fy[3]) },
|
||||
},
|
||||
monitor: overlay('monitor-gross'),
|
||||
dampf: overlay('dampf'),
|
||||
zzz: overlay('zzz-gross'),
|
||||
wirbel: overlay('wirbel'),
|
||||
schild: rechteck(schild, 'Detail-Schild'),
|
||||
}
|
||||
|
||||
return { WELT, HAUS, HAUS_BILD, ETAGEN, ETAGEN_SPALTE, RAEUME, HUSTLER, SCHILDER, AUFZUG, DETAIL }
|
||||
}
|
||||
|
||||
// ───────────────────────────── JS-Ausgabe (reine Daten, eingefroren) ─────────────────────────────
|
||||
|
||||
const SCHLUESSEL = /^[A-Za-z_$][\w$]*$/
|
||||
const blatt = (x) => x === null || typeof x !== 'object'
|
||||
const einfach = (x) => blatt(x) || (Array.isArray(x) && x.every(blatt))
|
||||
function jsLiteral(v, ein = 0) {
|
||||
const pad = ' '.repeat(ein)
|
||||
if (Array.isArray(v)) {
|
||||
const teile = v.map((x) => jsLiteral(x, ein + 1))
|
||||
if (v.every(blatt)) return `Object.freeze([${teile.join(', ')}])`
|
||||
return `Object.freeze([\n${teile.map((t) => `${pad} ${t}`).join(',\n')},\n${pad}])`
|
||||
}
|
||||
if (v && typeof v === 'object') {
|
||||
const eintraege = Object.entries(v)
|
||||
if (eintraege.length === 0) return 'Object.freeze({})'
|
||||
const teile = eintraege.map(([k, x]) => `${SCHLUESSEL.test(k) ? k : jsLiteral(k)}: ${jsLiteral(x, ein + 1)}`)
|
||||
const flach = eintraege.every(([, x]) => einfach(x))
|
||||
if (flach && teile.join(', ').length <= 100) return `Object.freeze({ ${teile.join(', ')} })`
|
||||
return `Object.freeze({\n${teile.map((t) => `${pad} ${t}`).join(',\n')},\n${pad}})`
|
||||
}
|
||||
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`
|
||||
if (typeof v === 'number' && !Number.isFinite(v)) throw fehler('Geometrie: keine endliche Zahl.')
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function weltDatei(d, kopf) {
|
||||
const abschnitte = [
|
||||
['WELT', 'Weltgröße in Kunstpixeln; hud = Höhe der Kopfzeile, die beim Einpassen frei bleibt', d.WELT],
|
||||
['HAUS', 'Kamerarahmen um das Haus (Einpassen, „ganzes Haus“)', d.HAUS],
|
||||
['HAUS_BILD', 'Lage von haus.png und den Ebenen gleicher Größe (Fenster, Schild, graue Räume)', d.HAUS_BILD],
|
||||
['ETAGEN_SPALTE', 'Spalte der Etagen-Knöpfe (links, Breite)', d.ETAGEN_SPALTE],
|
||||
['ETAGEN', 'Etagen von oben nach unten: id wie FLOORS; oben/unten = Klickbereich (lückenlos); oeffnung = [oben, unten] des sichtbaren Innenraums', d.ETAGEN],
|
||||
['RAEUME', 'Räume nach ROOM_TITLES-Schlüssel: Rechteck x0/x1/y0/y1, Etage, Etiketten-Punkt (x0 + 20, Öffnung oben + 3)', d.RAEUME],
|
||||
['HUSTLER', 'Hustler nach workerId: Anker a/f (Fußpunkt), Knopf-Rechteck, Bildschirm-Overlay, Radar (nur Rita); otto = Overseer', d.HUSTLER],
|
||||
['SCHILDER', 'Briefkasten-Zähler, Tresor-/Keller-Text, Neon-Fläche, Antenne, „nicht angeschlossen“-Zettel', d.SCHILDER],
|
||||
['AUFZUG', 'Seil, Kabine und Halte (Oberkante der Kabine je Etage, Reihenfolge wie ETAGEN)', d.AUFZUG],
|
||||
['DETAIL', 'Detailansicht: Szenengröße, Figurposition je Szene (Büro, nicht angeschlossen, Brücke) und Overlays', d.DETAIL],
|
||||
['GRAUE_RAEUME', 'Abgedunkelte Raum-Ebenen (nur wenn das neue Haus welche liefert): Raum -> left/top/width/height', d.GRAUE_RAEUME],
|
||||
]
|
||||
const teile = [`// ${kopf}`, '// Geometrie des Hochhauses in Weltkoordinaten. Reine Daten, tief eingefroren, keine Importe.', '']
|
||||
for (const [name, hinweis, wert] of abschnitte) {
|
||||
teile.push(`// ${hinweis}`, `export const ${name} = ${jsLiteral(wert)}`, '')
|
||||
}
|
||||
return teile.join('\n')
|
||||
}
|
||||
|
||||
// ───────────────────────────── Eingaben lesen ─────────────────────────────
|
||||
|
||||
function lies(pfad, was) {
|
||||
if (!existsSync(pfad)) throw fehler(`${was} fehlt: ${pfad}`)
|
||||
return readFileSync(pfad)
|
||||
}
|
||||
|
||||
function liesJson(pfad, was) {
|
||||
const text = lies(pfad, was).toString('utf8')
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch (e) {
|
||||
throw fehler(`${was} ist kein gültiges JSON (${pfad}): ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeRoster() {
|
||||
const quelle = readFileSync(ROSTER_DATEI)
|
||||
// als Daten-URL laden: die Datei hat keine Importe, und es gibt keine „Modultyp“-Warnung wegen package.json
|
||||
return import(`data:text/javascript;base64,${quelle.toString('base64')}`)
|
||||
}
|
||||
|
||||
function pngMasse(inhalt, was) {
|
||||
const kopf = inhalt.subarray(0, 24)
|
||||
if (kopf.length < 24 || kopf.toString('latin1', 1, 4) !== 'PNG' || kopf.readUInt32BE(0) !== 0x89504e47) throw fehler(`Keine gültige PNG-Datei: ${was}`)
|
||||
return { w: kopf.readUInt32BE(16), h: kopf.readUInt32BE(20) }
|
||||
}
|
||||
|
||||
function ladeHashes(v2) {
|
||||
const datei = path.join(v2, 'entwurf/hochgeladen.sha256')
|
||||
const karte = new Map()
|
||||
if (!existsSync(datei)) return karte
|
||||
for (const zeile of readFileSync(datei, 'utf8').split('\n')) {
|
||||
const m = zeile.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/)
|
||||
if (m) karte.set(m[2], m[1])
|
||||
}
|
||||
return karte
|
||||
}
|
||||
|
||||
function leseGraueRaeume({ woms, roster }) {
|
||||
if (!woms) return []
|
||||
const manifestPfad = path.join(woms, 'haus/manifest.json')
|
||||
if (!existsSync(manifestPfad)) return []
|
||||
const g = liesJson(manifestPfad, 'haus/manifest.json im Ordner pixelwelt-woms').graueRaeume
|
||||
if (g === undefined || g === null) return []
|
||||
const eintraege = Array.isArray(g) ? g.map((e) => [e?.raum ?? e?.room ?? e?.key, e]) : Object.entries(g)
|
||||
const liste = []
|
||||
for (const [raum, e] of eintraege) {
|
||||
if (!roster.SEARCH_ROOMS.includes(raum)) throw fehler(`graueRaeume: „${raum}“ ist kein Such-Raum (erlaubt: ${roster.SEARCH_ROOMS.join(', ')}).`)
|
||||
if (liste.some((x) => x.raum === raum)) throw fehler(`graueRaeume: Raum „${raum}“ steht doppelt.`)
|
||||
const datei = e.datei ?? e.file ?? `raum-grau-${raum}.png`
|
||||
if (path.basename(datei) !== datei || !/\.png$/i.test(datei)) throw fehler(`graueRaeume: ungültiger Dateiname „${datei}“ für ${raum}.`)
|
||||
const inhalt = lies(path.join(woms, 'haus', datei), `Ebene ${datei} (graueRaeume.${raum})`)
|
||||
const masse = pngMasse(inhalt, `haus/${datei}`)
|
||||
const x = e.x ?? e.left
|
||||
const y = e.y ?? e.top
|
||||
const w = e.w ?? e.width ?? masse.w
|
||||
const h = e.h ?? e.height ?? masse.h
|
||||
for (const [n, v] of [['x', x], ['y', y], ['w', w], ['h', h]]) {
|
||||
if (!Number.isInteger(v)) throw fehler(`graueRaeume.${raum}: „${n}“ fehlt oder ist keine ganze Zahl.`)
|
||||
}
|
||||
if (w !== masse.w || h !== masse.h) throw fehler(`graueRaeume.${raum}: ${w}×${h} im Manifest, aber ${datei} ist ${masse.w}×${masse.h}.`)
|
||||
liste.push({ raum, datei, x, y, w, h, inhalt })
|
||||
}
|
||||
// feste Reihenfolge wie SEARCH_ROOMS
|
||||
return roster.SEARCH_ROOMS.map((r) => liste.find((x) => x.raum === r)).filter(Boolean)
|
||||
}
|
||||
|
||||
// ───────────────────────────── Planen ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Berechnet alle Ausgaben im Speicher (schreibt nichts).
|
||||
* @returns {Promise<{ dateien: Map<string, Buffer|string>, bericht: object }>} Schlüssel = Pfad relativ zur Repo-Wurzel
|
||||
*/
|
||||
export async function baue({ v2, woms = null, quellen = null }) {
|
||||
if (!existsSync(v2) || !statSync(v2).isDirectory()) throw fehler(`Der Ordner pixelwelt-v2 existiert nicht: ${v2}`)
|
||||
const tabellen = quellen ?? liesJson(QUELLEN_DATEI, 'scripts/hustler-import/quellen.json')
|
||||
const roster = await ladeRoster()
|
||||
const hashes = ladeHashes(v2)
|
||||
const wombsHaus = woms && existsSync(path.join(woms, 'haus')) ? path.join(woms, 'haus') : null
|
||||
|
||||
const dateien = new Map()
|
||||
const bericht = { bildschirme: {}, warnungen: [] }
|
||||
const quellInfo = new Map() // Quellpfad (relativ) -> { ziel, inhalt, ueberschrieben }
|
||||
|
||||
function holeQuelle(rel, wo) {
|
||||
if (quellInfo.has(rel)) return quellInfo.get(rel)
|
||||
if (typeof rel !== 'string' || !rel || path.isAbsolute(rel) || rel.split('/').includes('..') || !/\.png$/i.test(rel)) {
|
||||
throw fehler(`quellen.json: ungültiger Quellpfad „${rel}“ (${wo}).`)
|
||||
}
|
||||
let pfad = path.join(v2, rel)
|
||||
let ueberschrieben = false
|
||||
if (wombsHaus && rel.startsWith('haus/') && existsSync(path.join(wombsHaus, rel.slice('haus/'.length)))) {
|
||||
pfad = path.join(wombsHaus, rel.slice('haus/'.length))
|
||||
ueberschrieben = true
|
||||
}
|
||||
if (!existsSync(pfad) || !statSync(pfad).isFile()) throw fehler(`Quelldatei fehlt: ${rel} (${wo}); erwartet unter ${pfad}`)
|
||||
const inhalt = readFileSync(pfad)
|
||||
pngMasse(inhalt, rel)
|
||||
if (!ueberschrieben && hashes.has(rel) && sha256(inhalt) !== hashes.get(rel)) {
|
||||
throw fehler(`Prüfsumme stimmt nicht: ${rel} (entwurf/hochgeladen.sha256 nennt ${hashes.get(rel).slice(0, 12)}…, die Datei hat ${sha256(inhalt).slice(0, 12)}…).`)
|
||||
}
|
||||
const ziel = `${ASSETS}/${rel.replace(/(^|\/)entwurf\//g, '$1')}`
|
||||
const vorhanden = dateien.get(ziel)
|
||||
if (vorhanden && !vorhanden.equals(inhalt)) throw fehler(`Zwei verschiedene Quellen würden nach ${ziel} kopiert (${rel}).`)
|
||||
dateien.set(ziel, inhalt)
|
||||
const info = { ziel, inhalt, ueberschrieben }
|
||||
quellInfo.set(rel, info)
|
||||
return info
|
||||
}
|
||||
|
||||
const namen = Object.keys(BILDSCHIRME)
|
||||
for (const k of Object.keys(tabellen)) if (!namen.includes(k)) throw fehler(`quellen.json: unbekannter Bildschirm „${k}“.`)
|
||||
|
||||
const htmls = {}
|
||||
const css = {}
|
||||
for (const name of namen) {
|
||||
const cfg = BILDSCHIRME[name]
|
||||
const tabelle = tabellen[name]
|
||||
if (!tabelle) throw fehler(`quellen.json: Bildschirm „${name}“ fehlt.`)
|
||||
htmls[name] = lies(path.join(v2, cfg.html), `Artboard ${cfg.artboard}`).toString('utf8')
|
||||
const blobs = liesJson(path.join(v2, cfg.blobs), `Blob-Zuordnung ${cfg.blobs}`)
|
||||
for (const k of Object.keys(tabelle)) {
|
||||
if (!(k in blobs)) throw fehler(`quellen.json (${name}): Schlüssel „${k}“ steht nicht in ${cfg.blobs}.`)
|
||||
}
|
||||
const idZuSchluessel = new Map()
|
||||
for (const [k, v] of Object.entries(blobs)) {
|
||||
const id = String(v).replace(/^\/_blob\//, '')
|
||||
if (idZuSchluessel.has(id)) throw fehler(`${cfg.blobs}: die Adresse ${v} gehört zu zwei Schlüsseln (${idZuSchluessel.get(id)}, ${k}).`)
|
||||
idZuSchluessel.set(id, k)
|
||||
}
|
||||
const benutzt = new Set()
|
||||
const umgeformt = wandleCss(styleBloecke(htmls[name], cfg.artboard), {
|
||||
wurzel: cfg.wurzel,
|
||||
kfVorsatz: cfg.kfVorsatz,
|
||||
blobPfad: (id) => {
|
||||
const schluessel = idZuSchluessel.get(id)
|
||||
if (!schluessel) throw fehler(`Im CSS von ${cfg.artboard} steht /_blob/${id}, aber ${cfg.blobs} kennt diese Adresse nicht.`)
|
||||
const rel = tabelle[schluessel]
|
||||
if (!rel) throw fehler(`Der Schlüssel „${schluessel}“ (${cfg.blobs}, Bildschirm ${name}) fehlt in scripts/hustler-import/quellen.json.`)
|
||||
const info = holeQuelle(rel, `Schlüssel „${schluessel}“, Bildschirm ${name}`)
|
||||
benutzt.add(rel)
|
||||
return `../../${info.ziel.slice('src/'.length)}`
|
||||
},
|
||||
})
|
||||
css[name] = { text: umgeformt.css, keyframes: umgeformt.keyframes }
|
||||
bericht.bildschirme[name] = {
|
||||
dateien: benutzt.size,
|
||||
bytes: [...benutzt].reduce((s, rel) => s + quellInfo.get(rel).inhalt.length, 0),
|
||||
keyframes: umgeformt.keyframes.length,
|
||||
ueberschrieben: [...benutzt].filter((rel) => quellInfo.get(rel).ueberschrieben),
|
||||
}
|
||||
}
|
||||
|
||||
// graue Räume (optional, nur mit pixelwelt-woms)
|
||||
const graue = leseGraueRaeume({ woms, roster })
|
||||
const grauZeilen = []
|
||||
const GRAUE_RAEUME = {}
|
||||
for (const g of graue) {
|
||||
const ziel = `${ASSETS}/haus/${g.datei}`
|
||||
dateien.set(ziel, g.inhalt)
|
||||
grauZeilen.push(`.${BILDSCHIRME.hochhaus.wurzel} .hk-raum-grau-${g.raum}{position:absolute; left:${g.x}px; top:${g.y}px; width:${g.w}px; height:${g.h}px; background-image:url(../../${ziel.slice('src/'.length)}); image-rendering:pixelated; pointer-events:none}`)
|
||||
GRAUE_RAEUME[g.raum] = { left: g.x, top: g.y, width: g.w, height: g.h }
|
||||
}
|
||||
bericht.graueRaeume = graue.map((g) => g.raum)
|
||||
|
||||
// Geometrie
|
||||
const hausManifest = liesJson(path.join(v2, 'haus/manifest.json'), 'haus/manifest.json im Ordner pixelwelt-v2')
|
||||
const geometrie = leseHochhaus({ html: htmls.hochhaus, hausManifest, roster })
|
||||
geometrie.GRAUE_RAEUME = GRAUE_RAEUME
|
||||
|
||||
for (const name of namen) {
|
||||
const cfg = BILDSCHIRME[name]
|
||||
const kopf = `Erzeugt von scripts/hustler-import.mjs aus ${cfg.artboard} (${cfg.titel}), nicht von Hand ändern.`
|
||||
const zeilen = [`/* ${kopf} */`, css[name].text]
|
||||
if (name === 'hochhaus' && grauZeilen.length) zeilen.push('/* Abgedunkelte Räume (nur sichtbar, wenn der Dienst „nicht angeschlossen“ meldet) */', ...grauZeilen)
|
||||
dateien.set(`${STYLES}/${cfg.css}`, `${zeilen.join('\n')}\n`)
|
||||
}
|
||||
dateien.set(WELT_DATEI, weltDatei(geometrie, `Erzeugt von scripts/hustler-import.mjs aus ${BILDSCHIRME.hochhaus.artboard} (${BILDSCHIRME.hochhaus.titel}), nicht von Hand ändern.`))
|
||||
|
||||
bericht.einzigartig = {
|
||||
dateien: [...quellInfo.keys()].length + graue.length,
|
||||
bytes: [...quellInfo.values()].reduce((s, i) => s + i.inhalt.length, 0) + graue.reduce((s, g) => s + g.inhalt.length, 0),
|
||||
}
|
||||
return { dateien, bericht }
|
||||
}
|
||||
|
||||
// Prüfsumme über alle Ausgaben (sortierte Pfade + Inhalte): zwei Läufe mit gleichen Eingaben müssen sie gleich liefern
|
||||
export function pruefsumme(dateien) {
|
||||
const h = createHash('sha256')
|
||||
for (const pfad of [...dateien.keys()].sort()) h.update(`${pfad}\0${sha256(dateien.get(pfad))}\n`)
|
||||
return h.digest('hex')
|
||||
}
|
||||
|
||||
// ───────────────────────────── Schreiben ─────────────────────────────
|
||||
|
||||
export function schreibe(plan, wurzel = REPO) {
|
||||
const erlaubt = [ASSETS, STYLES].map((p) => path.join(wurzel, p) + path.sep)
|
||||
const zaehler = { geschrieben: 0, unveraendert: 0, entfernt: [] }
|
||||
for (const [rel, inhalt] of plan.dateien) {
|
||||
const ziel = path.join(wurzel, rel)
|
||||
if (!(rel === WELT_DATEI || erlaubt.some((e) => ziel.startsWith(e)))) throw fehler(`Interner Fehler: ${rel} liegt außerhalb der erlaubten Zielorte.`)
|
||||
const neu = typeof inhalt === 'string' ? Buffer.from(inhalt, 'utf8') : inhalt
|
||||
if (existsSync(ziel) && readFileSync(ziel).equals(neu)) { zaehler.unveraendert += 1; continue }
|
||||
mkdirSync(path.dirname(ziel), { recursive: true })
|
||||
writeFileSync(ziel, neu)
|
||||
zaehler.geschrieben += 1
|
||||
}
|
||||
// alte graue Ebenen, die dieser Lauf nicht mehr erzeugt (nur diese Dateinamen, nur dieser eine Ordner)
|
||||
const hausOrdner = path.join(wurzel, ASSETS, 'haus')
|
||||
if (existsSync(hausOrdner)) {
|
||||
for (const name of readdirSync(hausOrdner)) {
|
||||
if (/^raum-grau-[a-z0-9_-]+\.png$/i.test(name) && !plan.dateien.has(`${ASSETS}/haus/${name}`)) {
|
||||
unlinkSync(path.join(hausOrdner, name))
|
||||
zaehler.entfernt.push(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return zaehler
|
||||
}
|
||||
|
||||
// ───────────────────────────── Programm ─────────────────────────────
|
||||
|
||||
const mb = (b) => `${(b / 1024 / 1024).toFixed(2).replace('.', ',')} MB`
|
||||
|
||||
export async function main(argumente) {
|
||||
if (argumente.includes('--help') || argumente.includes('-h') || argumente.length < 1 || argumente.length > 2) {
|
||||
console.log('Aufruf: node scripts/hustler-import.mjs <pfad-zu-pixelwelt-v2> [<pfad-zu-pixelwelt-woms>]')
|
||||
return argumente.length >= 1 && argumente.length <= 2 ? 0 : 2
|
||||
}
|
||||
const v2 = path.resolve(argumente[0])
|
||||
let woms = argumente[1] ? path.resolve(argumente[1]) : null
|
||||
const hinweise = []
|
||||
if (woms && !existsSync(woms)) {
|
||||
hinweise.push(`Hinweis: Der Ordner ${woms} gibt es noch nicht; es wird nur mit pixelwelt-v2 gearbeitet.`)
|
||||
woms = null
|
||||
}
|
||||
const plan = await baue({ v2, woms })
|
||||
const z = schreibe(plan)
|
||||
for (const h of hinweise) console.log(h)
|
||||
for (const [name, b] of Object.entries(plan.bericht.bildschirme)) {
|
||||
console.log(`${name.padEnd(9)} ${String(b.dateien).padStart(3)} PNG (${mb(b.bytes)}), ${b.keyframes} Keyframes${b.ueberschrieben.length ? `, ${b.ueberschrieben.length} Datei(en) aus pixelwelt-woms` : ''}`)
|
||||
}
|
||||
console.log(`zusammen ${String(plan.bericht.einzigartig.dateien).padStart(3)} verschiedene PNG (${mb(plan.bericht.einzigartig.bytes)})`)
|
||||
if (plan.bericht.graueRaeume.length) console.log(`graue Räume: ${plan.bericht.graueRaeume.join(', ')}`)
|
||||
console.log(`geschrieben: ${z.geschrieben}, unverändert: ${z.unveraendert}${z.entfernt.length ? `, entfernt: ${z.entfernt.join(', ')}` : ''}`)
|
||||
console.log(`Prüfsumme aller Ausgaben: ${pruefsumme(plan.dateien)}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
main(process.argv.slice(2)).then(
|
||||
(code) => { process.exitCode = code },
|
||||
(e) => {
|
||||
console.error(e instanceof ImportFehler ? `Abbruch: ${e.message}` : e)
|
||||
process.exitCode = 1
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user