Wenn das möglich ist, umsetzen. Folgende Dinge wären dafür praktisch:
1. sequence node
diese hat momentan fixe 3 outputs, diese könnten somit ebenfalls dynamisch sein
2. eine variable vom typ struct
sprich eine variable die mehreren properties (key mit allen bisher möglichen variablen typen + value).
dazu passende nodes um von einer variable alle properties zu expanden und umgekehrt (alle werte eines structs setzen)
variablen vom typ struct und list im inspector
bei typ list: momentan steht es einfach nur als string geparsed in einem textfeld.
für die liste bräuchten wir eine view, sprich liste mit variablen.
für typ struct: hier wäre natürlich auch eine passende view gut, wo man bei der variable type + key + default value angeben kann.
lass uns das mal ausarbeiten. wie weit das sinnvoll bzw. möglich ist.
Umgesetzt (2026-09-10), Teil 1 und der Werttyp. Was offen bleibt, steht unten.
Dynamische Ports gab es schon — nur nicht aus Node-Einstellungen. Verschachtelte Behaviors
leben seit jeher davon: GraphNode.ports speichert eine Deklaration pro Node, resolveDef
loest den Def pro Node auf, portSignature ist der Ausloeser, an dem View und Inspector merken,
dass sich eine abgeleitete Portliste geaendert hat. Die Regel dafuer stand als Dreier-Switch auf
Node-Typen in nested.ts — die einzige Stelle im Projekt, die Node-Typen namentlich kannte.
Der Switch ist jetzt aufgeloest in zwei Vertraege am NodeDef:
declaresPorts: 'inputs' | 'outputs' — der Node traegt seine eigene Portliste, editierbar im
Inspector. Das haben Start, End und neu Pack/Expand.
derivePorts(source) — der Node rechnet seine Ports aus sich und dem Graphen aus, in dem er
sitzt. Das haben Sequence (aus den eigenen Kanten) und Run behavior (Ports des benannten
Behaviors).
Sequence hatte zuerst eine Einstellung Outputs (2…26). Die ist wieder weg — siehe den
Nachtrag unten: die Ports wachsen jetzt mit den Kanten. Die Keys bleiben a, b, c, … statt out1..N, weil im Jump-and-Run zwei Nodes an einer Sequence haengen und deren Kanten heil bleiben
sollen.
Die einzige echte Falle war das Schrumpfen.SetNodeParamCommand war ein reiner Bag-Schreiber:
verschwand ein Port, blieb seine Kante liegen — unsichtbar (drawEdge steigt bei unbekanntem Port
still aus), aber sie ueberlebte jedes Speichern und waere beim Hochzaehlen wieder angesprungen.
Der Befehl ist jetzt port-bewusst und nimmt solche Kanten mit, Undo gibt sie zurueck. Das schliesst einen bestehenden Fehler mit: derselbe Verwaisungs-Effekt trat schon immer auf, wenn man an
einem Run behavior-Node das Behavior wechselte.
Struct als echter Werttyp (Weg C).PropertyValue ist jetzt number | string | boolean | ListValue | StructValue. Felder sind Primitive oder Listen, nie
wieder ein Struct — aus demselben Grund, aus dem eine Liste flach ist.
Zwei Dinge waren dafuer noetig, und beide sind der eigentliche Preis von C:
Ein Objekt auf einer Datenkante hiess bisher zwangslaeufig Entity. An drei Stellen in values.ts bedeutete „Objekt, kein Array" ein Entity-Handle. EntityHandle traegt jetzt eine Symbol-Marke (ENTITY_HANDLE) — ein Symbol, damit kein Struct-Feldname sie imitieren kann,
und es serialisiert nie, was fuer etwas richtig ist, das nur waehrend des Spiels existiert.
Der Typechecker hat jede betroffene Stelle selbst benannt.
String(value) haette einen Struct als [object Object] geschrieben — an 39 Stellen.
Die gehen jetzt alle ueber textOf(). Das war kein Stilfehler, sondern eine echte Luecke, die
erst der neue Typ sichtbar gemacht hat.
Dazu ein gemessener, lebender Fehler, der dabei aufflog und mitrepariert ist: der List-Node
verlor seinen Wert beim Speichern. normalizeGraph liess bei Node-Params nur Primitive durch,
Arrays fielen weg ([3,1,4] → undefined nach Round-Trip). Listen-Variablen waren nicht
betroffen — die haben ein Schema. Params haben keins, weil sie Einstellungen und Inline-Werte
mischen.
Belegt (tests/core/dynamic-ports.test.ts): ein Bündel faehrt ueber eine Kante durch ein
verschachteltes Behavior — Pack{x:30,y:12} → Run behavior-Port vom Typ struct → Expand → Add → 42. Genau die Form, die Tetris heute von Hand parallel fuehrt (Next move: 8 rein,
7 raus; 49 deklarierte Ports ueber 9 Behaviors).
Offen, bewusst nicht gebaut:
Variablen vom Typ struct (Teil 2 der Notiz). Dafuer braucht eine Variable eine editierbare
Feldliste im Inspector — die Deklaration liegt bisher nur am Node (Pack/Expand) und am
Behavior-Port. writeTyped/mergeStruct sind schon darauf vorbereitet: ein Struct-Slot behaelt
seine Felder, was ankommt fuellt die benannten, der Rest bleibt stehen.
Die Views im Inspector (Teil 3). [Die Listen-Haelfte ist inzwischen gebaut — siehe den
Nachtrag „der List-Tab steht". Der Struct-Teil ist weiter offen.] Das Textfeld war nicht nur
Kosmetik: ['red, green', 'blue'] wurde als "red, green, blue" geschrieben und las sich als drei Items zurueck — ein Komma in einem Text-Item war nicht darstellbar. Fuer den Struct ist
Zeile-pro-Feld richtig, das sind wenige.
Ein unverbundener list-Eingang zeigt weiterhin ein Zahlenfeld (inlineInputDef hat keinen list-Zweig), und eine Listen-Variable kann nur Zahlen-Items: AddVariableCommand nimmt items
gar nicht entgegen.
EDIT:
Eine Liste braucht eher einen eigenen Editor als Overlay
ok, eigener tab "List"
dann machen wir es so: es gibt dann einfach statt dem textfeld ein button wo drinnen steht "42 items".
klickt man darauf switch zu Tab "List" und dort wird die liste aller items dargestellt die man bearbeiten kann.
dort soll auch der selbe "renderer" verwendet werden wie beim inspector.
wenn wir dann struct (teil 3) umsetzen, kann es auch eine liste aus struct variablen geben (bitte das bedenken) und
im tab "List" sollen variablen (egal welcher type) im prinzip genauso dargestellt werden wie im inspector
Antwort auf den Edit (2026-09-10): geht, mit drei Auflagen
Der Tab ist hier besser als das Overlay, aus einem Grund, der erst beim Nachsehen auffiel: der
Inspector haengt nicht im Tab-Bereich, sondern als eigene Spalte daneben (Editor.ts:156). Er
bleibt in jedem Tab sichtbar, der Sprung verliert also keinen Kontext.
„Derselbe Renderer" ist fast geschenkt.createField (fieldFactory.ts:171) braucht als
Kontext nur { project(): Project } — keine Inspector-Instanz, keinen Command-Stack. Die Zeile
baut selbst kein Kommando, sie ruft onInput/onChange. previewingHandlers benutzt vom InspectorHost ausschliesslich host.state; den Parametertyp zu verengen macht auch die
Undo-Mechanik wiederverwendbar. Ein Struct kommt spaeter an einer Stelle dazu: dem switch (def.kind) in fieldFactory.ts:177.
Der Weg in den Tab muss ueber den Zustand laufen, nicht ueber showTab. Kein Tab kennt den Editor; Tab-Wechsel von innen gibt es nur selektionsgetrieben, gebuendelt in Editor.ts:169-179.
Woran der Tab haengt
Die anderen Tabs haengen an einer Asset-Id. Eine Liste ist kein Asset, sondern ein Slot:
Asset-Property (assetId+key), Objekt-Override (holderId+objectId+key) oder Node-Param
(assetId+nodeId+key). Also eine explizite Adresse mit Pfad, die der Tab behaelt wie die
anderen ihre Asset-Id und die auf „nichts" zurueckfaellt, wenn das Ziel verschwindet
(AssetTabEditor.refresh() macht genau das). Der Drill-down spaeter ist derselbe Mechanismus
eine Ebene tiefer.
Zeile-pro-Item traegt — gemessen
Zeilen echt ueber createFieldRow gebaut, mit GPU im Browser, dabei jeden Frame gescrollt:
Zeilen
Aufbau
Display-Objekte
fps
0 (Grundlinie)
–
11
58
200
15 ms
2 011
51
300
17 ms
3 011
46
3 000
122 ms
30 011
18
Dagegen die laengsten Listen im echten Bestand: tetris 224 (shapes), 224 (eine
Variablen-Vorgabe), 200 (grid); jumpRun und fixture haben keine. Der reale Hoechstfall liegt
also bei 224 — kein Windowing noetig. Bei 3 000 Eintraegen faellt es auseinander, aber nichts
kommt dem nahe.
Der eigentliche Fund steckt in der Aufbau-Spalte: 15 ms pro Rebuild bei 200 Zeilen. Deshalb kam
der Fokus-Schutz zuerst (siehe Nachtrag oben) — ein Rebuild pro Tastendruck waere dort die Regel.
Was „Liste aus Structs" kostet
Gibt es heute nicht: ListValue = readonly (number | string | boolean)[] (Properties.ts:5), FieldValue schliesst Struct aus (:8), sameValue verlaesst sich auf „recurses exactly one level
deep" (:50), LIST_ITEM_KINDS kennt kein struct, coerceListItem gibt ein Primitiv zurueck.
Das macht ListValue und StructValuegegenseitig rekursiv (beliebige Tiefe, echter
Deep-Compare) und die Items brauchen eine deklarierte Feldform — wieder „eine geteilte Struct-Form
hat keine Heimat" (~29 Dateien fuer einen neuen Asset-Typ). Deshalb: Tab jetzt fuer flache Listen,
aber um einen Pfad herum; die Typerweiterung kommt mit den Struct-Variablen.
Beibehalten: eine Liste ist homogen (ein ListItemKind), der Struct ist das heterogene
Ding. Das Textfeld sollte als Massenbearbeitung im Tab bleiben, sonst geht das Einfuegen einer
Komma-Liste verloren. Der unverbundene list-Eingang (heute faelschlich ein Zahlenfeld) loest
sich mit dem Knopf mit.
Nachtrag (2026-09-10): Sequence waechst mit den Kanten, statt gezaehlt zu werden
Das Zahlenfeld Outputs war unbedienbar: tippen ging nicht, Pfeil rauf/runter ging nicht, nur
das Ziehen mit der Maus. Die Ursache ist allgemein und nicht NumberField zuzuschreiben:
onInput (jeder Tastendruck, auch jede Pfeiltaste) → previewingHandlers schreibt in node.params → state.preview() → Event project → Inspector.refreshValues() → die nodeSignature enthaelt die Portliste, hat sich also geaendert → rebuild() wirft die Zeile
weg, in der gerade getippt wird. Ziehen ueberlebt das, weil ein laufender Drag seine globalpointermove-Events weiter am alten (zerstoerten) Widget bekommt; Tippen braucht den Fokus,
und den gibt es nach dem Rebuild nicht mehr.
Diese Falle ist inzwischen zu (siehe „List-Tab" unten): refreshValues() verschiebt einen
faelligen Rebuild, solange der Fokus im Inspector sitzt, und holt ihn nach, sobald das Feld
verlassen wird (FocusManager.changeHandlers + isWithin). Werte sind waehrenddessen ohnehin
sicher — TextField.setValue ignoriert eine laufende Eingabe (TextField.ts:92). Damit war der
Fehler auch nie NumberField zuzuschreiben.
Statt das Feld zu reparieren, ist es nach Wunsch des Users ersetzt: die Sequence haelt immer einen freien Ausgang unter dem letzten verbundenen. Nichts verbunden = 1 Ausgang; den
Ersatz-Ausgang verbinden = ein neuer erscheint; die unterste Kante loesen = schrumpft wieder.
Gezaehlt wird der letzte verbundene Port, nicht die Anzahl der Kanten — so kann keine Kante
verschwinden, wenn in der Mitte eine geloest wird (dann bleibt eine Luecke stehen).
Preis dafuer: derivePorts bekommt jetzt den Graphen statt nur der Params
(resolveDef(node, graph, lookup), ~15 Aufrufstellen, alle hatten den Graphen schon zur Hand),
und run bekommt als viertes Argument den aufgeloesten Def — anders kann ein Node mit
abgeleiteten Ports nicht wissen, wie viele er hat. SetNodeParamCommand bleibt port-bewusst, das
braucht Run behavior weiterhin.
Nebeneffekt: die Sequence braucht keinen Param mehr, also auch kein Aufraeumen von Kanten beim
Verkleinern — eine Kante zu loesen ist das Verkleinern.
Nachtrag (2026-09-10): der List-Tab steht
Gebaut wie im Edit gewuenscht: eine Listen-Property ist im Inspector ein Knopf N items;
er schaltet auf den neuen sechsten Tab List, wo jedes Item eine Zeile mit demselben
Widget bekommt, das der Inspector fuer diese Item-Art baut (createField — Zahl, Text, Ja/Nein
oder Farbe). Der Inspector bleibt daneben stehen, der Kontext geht also nicht verloren.
Der Weg dorthin laeuft ueber den Zustand, nicht ueber showTab: EditorState.listSlot + openList(slot) + Event list; der Editor hoert darauf und schaltet um. Ein Slot ist eine Asset-Property, ein Objekt-Override oder eine Node-Einstellung — ListSlot steht in state.ts neben Selection, weil state.ts sonst in einen UI-Ordner importieren muesste. resolveList(state, slot) in src/editor/list/slot.ts liefert Wert, Item-Art, Titel und die
zwei Schreibwege; die drei Faelle benutzen exakt die Spec der passenden Inspector-Sektion,
also dasselbe Kommando, dieselbe Beschichtung, denselben Undo-Schritt.
Abweichung vom eigenen Vorschlag: kein path in der Adresse. Ein immer leeres Feld, das
niemand liest, ist Spekulation; die Naht, an der ein Pfad spaeter ansetzt, ist die eine
Funktion resolveList — das ist dann eine Signaturaenderung, kein Umbau. Ehrlicher so.
Zwei Dinge, die erst die Gegenprobe gezeigt hat:
Die Zeilen-Handler duerfen nicht auf einer Momentaufnahme der Liste sitzen. Sie ueberleben
einen Commit (der Tab baut nur bei geaenderter Laenge neu), also wuerde die zweite bearbeitete
Zeile die erste zuruecksetzen. Gemessen: mit Momentaufnahme faellt genau dieser Test.
Die Beschichtung in replace war redundant — alle drei Kommandos laufen selbst durch coerceValue. Raus. In toModel ist sie noetig: dort entsteht der Wert, der als Vorschau
am Kommando vorbei direkt ins Modell geschrieben wird. Auch das ist jetzt bewacht.
Nebenbei mitgenommen:showTab schaltete fuenf visible-Zeilen von Hand — nicht
typgeschuetzt, ein sechster Tab waere dort still vergessen worden. Jetzt ein Record<CentreTab, Widget>, das jede Auslassung zum Typfehler macht.
Der unverbundene list-Eingang zeigt weiterhin ein Zahlenfeld: inlineInputDef hat keinen list-Zweig, und der Inline-Pfad kann gar keine Liste erzeugen. Der Knopf loest das nicht mit.
Zahlen stehen mit drei Nachkommastellen (30.000). Das ist Hausentscheidung, kein Versehen: formatNumber zeigt immer alle Stellen, „so a value can never look whole when it is not".
Weiterhin offen: Variablen vom Typ struct, die Struct-View im Inspector, und „Liste aus
Structs" (macht ListValue/StructValue gegenseitig rekursiv — siehe oben).
Nachtrag (2026-09-10): drei Korrekturen am List-Tab
Kurze Liste baute sich nicht auf (User: „muss die Fenstergroesse kurz aendern"). Die Ursache
lag nicht im Tab, sondern im ScrollContainer — und traf jeden seiner Benutzer, auch Menu:
rebuild() uebergibt die Zeilen-VStack an setContent(), solange sie noch leer ist. Der
leere Inhalt wird ueber Math.max(size.h, this.h) auf die Viewporthoehe gesetzt. Danach kommen
die Zeilen hinein, und relayoutContent() rechnet fuer eine kurze Liste wieder dieselbe Hoehe aus
— Widget.resize() sieht keine Aenderung und ueberspringt layout() (Widget.ts:41). Alle
Zeilen bleiben auf dem Ursprung gestapelt. Eine lange Liste aendert die Hoehe und faellt deshalb
nie auf; die erste Oeffnung ueberhaupt auch nicht, weil der Tab dann noch w=h=0 hat.
Gemessen an Tetris, bevor etwas geaendert wurde: colors (7) nach shapes (224) → alle vier
Zeilen auf {245,116}; grid (200) → sauber gestapelt; test (16) → wieder kaputt.
Fix in ScrollContainer.relayoutContent(): wenn die Groesse gleich bleibt, layout() selbst
rufen. Die Methode wird genau dann gerufen, wenn der Inhalt sich geaendert hat — sich auf die
Aenderungserkennung von resize zu verlassen ist dort grundsaetzlich falsch.
Die Kommazeile im Fuss ist raus (User-Wunsch). Damit ist auch der Komma-Fallback in fieldFactory unerreichbar geworden — jede Sektion, die eine Liste tragen kann, liefert openList (assetSection:135, nodeSection:134, objectSection:325). Fallback, listToText
und listFromText sind geloescht; der Test dafuer ist auf coerceValue umgeschrieben statt
gestrichen, sonst haette coerceListItem seine Abdeckung verloren (Boolean-Schreibweise, Farbe).
JSON-Speicherung war bereits da. Gemessen an der Projektdatei: props.shapes ist ein
JSON-Array mit 224 Zahlen, kein Komma-String; die PHP-API ist reines JSON. Das Komma war nur die Anzeige im geloeschten Textfeld. Es bleibt genau eine Stelle, an der ein Komma-Format wirklich
persistiert wird: eine Liste, die in ein Text-Property rutscht, wird ueber textOf() zu "1, 2, 3" (Properties.ts:33, via coerceValue kind text) — betrifft auch die Ausgabe des Log-Nodes. Der User hat entschieden, das so zu lassen; falls eine Liste je verlustfrei durch
ein Text-Feld muss, ist das die Stelle.
Nachtrag (2026-09-10): Variablen vom Typ struct — Teil 2 und 3 sind zu
Der Werttyp war fertig, die Deklaration fehlte.StructValue, textOf, sameValue, writeTyped/mergeStruct und der Port-Typ struct gab es seit dem ersten Teil; Get/Set variable haben any-Ports, ein Bündel fliesst also ohne Änderung durch den Graphen. Neu ist
allein, dass eine Variable eine Feldliste deklarieren kann: PropertyDef bekommt kind: 'struct' mit fields: StructFieldDef[], VARIABLE_KINDS das
fünfte Kind, variableDef einen fünften Parameter.
Ein deklariertes Feld ist ein Primitiv (ListItemKind — Zahl, Text, Ja/Nein, Farbe), obwohl FieldValue auch eine Liste zulässt. Grund: ein Listen-Feld bräuchte einen verschachtelten
Editor und damit den Pfad in der Slot-Adresse — genau der Schritt, auf den auch „Liste aus
Structs" wartet. Der Werttyp bleibt unverändert, die Erweiterung ist additiv.
coerceValue gibt jetzt PropertyValue statt FieldValue zurück — der erste Werttyp, der
kein Feld ist. Gemessen: das bricht keine der fünf Aufrufstellen.
Die UI: Block statt Zeile — und nicht der Tab
Der List-Tab entstand, weil hunderte Zeilen in keine Inspector-Zeile passen. Ein Struct ist der
Gegenfall: wenige Felder, die untereinander passen. Also kein siebter Tab, keine zweite
Navigation. Eine Struct-Variable ist ein Block:
⊞ [piece ] [+ Field ▾] [×]
# [x ] [ 30.000 ] [×]
Aa [tag ] [ tee ] [×]
Das ist der Port-Editor, den Pack schon hat (portSection.ts), eine Ebene tiefer und mit
Wertspalte: Typ, Key und Default-Wert an einer Stelle — genau der Wortlaut aus Teil 3 der
Notiz. Ein platziertes Objekt sieht dieselben Felder als reine Wertzeilen (createField
liefert für struct einen Stapel beschrifteter Zeilen): überschreiben ja, deklarieren nein.
Drei Dinge, die den Ausschlag gaben
Eine Feldänderung muss jeden Wert erreichen.AddStructFieldCommand &co. fassen die
Deklaration, den Default, den Wert des Behaviors und jeden Instanz-Override in einem
Kommando an (structSites); sonst behält ein gespeicherter Wert eine Form, die seine
Deklaration nicht mehr verspricht. Gegenprobe: ohne die Override-Sites fallen 3 Unit-Tests.
Kein Retype-Knopf — der Port-Editor hat auch keinen. Feld löschen und neu anlegen.
Die Handler dürfen nicht auf einer Momentaufnahme sitzen, dieselbe Falle wie beim List-Tab:
ein Feld schreibt das ganze Bündel, muss es also im Moment der Änderung lesen. Gegenprobe im
Browser: mit Momentaufnahme wird {"x":42,"tag":"ell"} zu {"x":30,"tag":"ell"} — die zweite
bearbeitete Zeile belebt den alten Wert der ersten wieder.
Die Signatur trägt jetzt die Felder (defSignature): Felder sind eigene Zeilen, ein neues
Feld muss die Sektion also neu bauen. Ohne das erscheint die Zeile erst beim nächsten Rebuild —
gemessen, genau ein Browser-Check fällt.
Bewusst nicht gebaut: „Liste aus Structs", ein Struct-Feld vom Typ Liste und der unverbundene list-Eingang. Die drei stehen als eigene Notiz in todo-notes/struct-und-listen-restposten.md.
Der teuerste Fund lag nicht im Struct
Der Schritt lief solo grün und in der vollen Suite mit drei Fehlschlägen — identische Klick-
koordinaten, das Menü ging auf, Boolean lag genau unter dem Zeiger, und trotzdem passierte
nichts. Kein Notice, also war es kein CommandError, sondern eine echte Ausnahme, die das
Menü (closeThen: erst schliessen, dann ausfuehren) als „Klick auf den Hintergrund" aussehen
liess.
Halbiert (GM_STEPS=1-9,22 gruen, 1-10,22 rot) und dann im Fenster abgefangen: Cannot convert undefined or null to object. Die Ursache ist ein Fixture-Fehler: Schritt 10
schiebt sein Text-Objekt per mutate von Hand in die Szene — ohne instanceEdits, obwohl SceneObject das Feld verlangt. overrideSites laeuft mit Object.entries darueber. Mein AddStructFieldCommand ist die erste Stelle, die overrideSites aus diesem Zustand heraus ruft.
Das Produkt bleibt unangetastet: geladen wird das Feld von normalizeObjects gefuellt, angelegt
von den Kommandos — ein Objekt ohne instanceEdits kann echt nicht entstehen. Repariert ist das
Objektliteral im Schritt.
Lehre fuer den naechsten Schritt: ein Schritt, der solo gruen ist, ist nicht belegt. Nur der
volle Lauf sieht, was die Vorgaenger hinterlassen.
Übernommen aus todo-notes/done/nodes-mit-dynamischer-port-zahl.md beim Umzug auf das Issue-System (2026-09-12).
Angelegt: 2026-09-10 · 0449218 — Let a variable declare struct fields
Abgeschlossen: 2026-09-10 · 9d62dfe — Pull the three open items out of the finished port note
Gemeldet von: admin
## Dynamische Portzahl bei Nodes ermöglichen
Wenn das möglich ist, umsetzen. Folgende Dinge wären dafür praktisch:
### 1. sequence node
diese hat momentan fixe 3 outputs, diese könnten somit ebenfalls dynamisch sein
### 2. eine variable vom typ struct
sprich eine variable die mehreren properties (key mit allen bisher möglichen variablen typen + value).
dazu passende nodes um von einer variable alle properties zu expanden und umgekehrt (alle werte eines structs setzen)
## variablen vom typ struct und list im inspector
bei typ list: momentan steht es einfach nur als string geparsed in einem textfeld.
für die liste bräuchten wir eine view, sprich liste mit variablen.
für typ struct: hier wäre natürlich auch eine passende view gut, wo man bei der variable type + key + default value angeben kann.
---
lass uns das mal ausarbeiten. wie weit das sinnvoll bzw. möglich ist.
---
**Umgesetzt (2026-09-10), Teil 1 und der Werttyp. Was offen bleibt, steht unten.**
**Dynamische Ports gab es schon** — nur nicht aus Node-Einstellungen. Verschachtelte Behaviors
leben seit jeher davon: `GraphNode.ports` speichert eine Deklaration **pro Node**, `resolveDef`
loest den Def pro Node auf, `portSignature` ist der Ausloeser, an dem View und Inspector merken,
dass sich eine abgeleitete Portliste geaendert hat. Die Regel dafuer stand als Dreier-Switch auf
Node-Typen in `nested.ts` — die einzige Stelle im Projekt, die Node-Typen namentlich kannte.
Der Switch ist jetzt **aufgeloest** in zwei Vertraege am `NodeDef`:
- `declaresPorts: 'inputs' | 'outputs'` — der Node traegt seine eigene Portliste, editierbar im
Inspector. Das haben `Start`, `End` und neu `Pack`/`Expand`.
- `derivePorts(source)` — der Node *rechnet* seine Ports aus sich und dem Graphen aus, in dem er
sitzt. Das haben `Sequence` (aus den eigenen Kanten) und `Run behavior` (Ports des benannten
Behaviors).
**Sequence** hatte zuerst eine Einstellung *Outputs* (2…26). Die ist **wieder weg** — siehe den
Nachtrag unten: die Ports wachsen jetzt mit den Kanten. Die Keys bleiben `a`, `b`, `c`, … statt
`out1..N`, weil im Jump-and-Run zwei Nodes an einer Sequence haengen und deren Kanten heil bleiben
sollen.
**Die einzige echte Falle war das Schrumpfen.** `SetNodeParamCommand` war ein reiner Bag-Schreiber:
verschwand ein Port, blieb seine Kante liegen — unsichtbar (`drawEdge` steigt bei unbekanntem Port
still aus), aber sie ueberlebte jedes Speichern und waere beim Hochzaehlen wieder angesprungen.
Der Befehl ist jetzt port-bewusst und nimmt solche Kanten mit, Undo gibt sie zurueck. Das schliesst
**einen bestehenden Fehler mit**: derselbe Verwaisungs-Effekt trat schon immer auf, wenn man an
einem `Run behavior`-Node das Behavior wechselte.
**Struct als echter Werttyp (Weg C).** `PropertyValue` ist jetzt
`number | string | boolean | ListValue | StructValue`. Felder sind Primitive oder Listen, **nie**
wieder ein Struct — aus demselben Grund, aus dem eine Liste flach ist.
Zwei Dinge waren dafuer noetig, und beide sind der eigentliche Preis von C:
1. **Ein Objekt auf einer Datenkante hiess bisher zwangslaeufig Entity.** An drei Stellen in
`values.ts` bedeutete „Objekt, kein Array" ein Entity-Handle. `EntityHandle` traegt jetzt eine
**Symbol-Marke** (`ENTITY_HANDLE`) — ein Symbol, damit kein Struct-Feldname sie imitieren kann,
und es serialisiert nie, was fuer etwas richtig ist, das nur waehrend des Spiels existiert.
Der Typechecker hat jede betroffene Stelle selbst benannt.
2. **`String(value)` haette einen Struct als `[object Object]` geschrieben** — an **39** Stellen.
Die gehen jetzt alle ueber `textOf()`. Das war kein Stilfehler, sondern eine echte Luecke, die
erst der neue Typ sichtbar gemacht hat.
Dazu ein **gemessener, lebender Fehler**, der dabei aufflog und mitrepariert ist: der `List`-Node
verlor seinen Wert beim Speichern. `normalizeGraph` liess bei Node-Params nur Primitive durch,
Arrays fielen weg (`[3,1,4]` → `undefined` nach Round-Trip). Listen-*Variablen* waren nicht
betroffen — die haben ein Schema. Params haben keins, weil sie Einstellungen und Inline-Werte
mischen.
**Belegt** (`tests/core/dynamic-ports.test.ts`): ein Bündel faehrt ueber **eine** Kante durch ein
verschachteltes Behavior — `Pack{x:30,y:12}` → `Run behavior`-Port vom Typ `struct` → `Expand` →
`Add` → 42. Genau die Form, die Tetris heute von Hand parallel fuehrt (`Next move`: 8 rein,
7 raus; 49 deklarierte Ports ueber 9 Behaviors).
**Offen, bewusst nicht gebaut:**
- **Variablen vom Typ struct** (Teil 2 der Notiz). Dafuer braucht eine Variable eine editierbare
Feldliste im Inspector — die Deklaration liegt bisher nur am Node (`Pack`/`Expand`) und am
Behavior-Port. `writeTyped`/`mergeStruct` sind schon darauf vorbereitet: ein Struct-Slot behaelt
seine Felder, was ankommt fuellt die benannten, der Rest bleibt stehen.
- **Die Views im Inspector** (Teil 3). *[Die Listen-Haelfte ist inzwischen gebaut — siehe den
Nachtrag „der List-Tab steht". Der Struct-Teil ist weiter offen.]* Das Textfeld war nicht nur
Kosmetik: `['red, green', 'blue']` wurde als `"red, green, blue"` geschrieben und las sich als
**drei** Items zurueck — ein Komma in einem Text-Item war nicht darstellbar. Fuer den Struct ist
Zeile-pro-Feld richtig, das sind wenige.
- Ein unverbundener `list`-Eingang zeigt weiterhin ein **Zahlenfeld** (`inlineInputDef` hat keinen
`list`-Zweig), und eine Listen-Variable kann nur Zahlen-Items: `AddVariableCommand` nimmt `items`
gar nicht entgegen.
---
EDIT:
> Eine Liste braucht eher einen **eigenen Editor als Overlay**
ok, eigener tab "List"
dann machen wir es so: es gibt dann einfach statt dem textfeld ein button wo drinnen steht "42 items".
klickt man darauf switch zu Tab "List" und dort wird die liste aller items dargestellt die man bearbeiten kann.
dort soll auch der selbe "renderer" verwendet werden wie beim inspector.
wenn wir dann struct (teil 3) umsetzen, kann es auch eine liste aus struct variablen geben (bitte das bedenken) und
im tab "List" sollen variablen (egal welcher type) im prinzip genauso dargestellt werden wie im inspector
---
## Antwort auf den Edit (2026-09-10): geht, mit drei Auflagen
**Der Tab ist hier besser als das Overlay**, aus einem Grund, der erst beim Nachsehen auffiel: der
Inspector haengt **nicht** im Tab-Bereich, sondern als eigene Spalte daneben (`Editor.ts:156`). Er
bleibt in jedem Tab sichtbar, der Sprung verliert also keinen Kontext.
**„Derselbe Renderer" ist fast geschenkt.** `createField` (`fieldFactory.ts:171`) braucht als
Kontext nur `{ project(): Project }` — keine Inspector-Instanz, keinen Command-Stack. Die Zeile
baut selbst kein Kommando, sie ruft `onInput`/`onChange`. `previewingHandlers` benutzt vom
`InspectorHost` ausschliesslich `host.state`; den Parametertyp zu verengen macht auch die
Undo-Mechanik wiederverwendbar. Ein Struct kommt spaeter an *einer* Stelle dazu: dem
`switch (def.kind)` in `fieldFactory.ts:177`.
**Der Weg in den Tab muss ueber den Zustand laufen, nicht ueber `showTab`.** Kein Tab kennt den
`Editor`; Tab-Wechsel von innen gibt es nur selektionsgetrieben, gebuendelt in `Editor.ts:169-179`.
### Woran der Tab haengt
Die anderen Tabs haengen an einer Asset-Id. Eine Liste ist kein Asset, sondern ein *Slot*:
Asset-Property (`assetId+key`), Objekt-Override (`holderId+objectId+key`) oder Node-Param
(`assetId+nodeId+key`). Also eine explizite Adresse **mit Pfad**, die der Tab behaelt wie die
anderen ihre Asset-Id und die auf „nichts" zurueckfaellt, wenn das Ziel verschwindet
(`AssetTabEditor.refresh()` macht genau das). Der Drill-down spaeter ist derselbe Mechanismus
eine Ebene tiefer.
### Zeile-pro-Item traegt — gemessen
Zeilen echt ueber `createFieldRow` gebaut, mit GPU im Browser, dabei **jeden Frame gescrollt**:
| Zeilen | Aufbau | Display-Objekte | fps |
|---|---|---|---|
| 0 (Grundlinie) | – | 11 | 58 |
| 200 | 15 ms | 2 011 | 51 |
| 300 | 17 ms | 3 011 | 46 |
| 3 000 | 122 ms | 30 011 | 18 |
Dagegen die laengsten Listen im echten Bestand: **tetris 224** (`shapes`), **224** (eine
Variablen-Vorgabe), **200** (`grid`); jumpRun und fixture haben keine. Der reale Hoechstfall liegt
also bei 224 — **kein Windowing noetig**. Bei 3 000 Eintraegen faellt es auseinander, aber nichts
kommt dem nahe.
Der eigentliche Fund steckt in der Aufbau-Spalte: 15 ms pro Rebuild bei 200 Zeilen. Deshalb kam
der Fokus-Schutz zuerst (siehe Nachtrag oben) — ein Rebuild pro Tastendruck waere dort die Regel.
### Was „Liste aus Structs" kostet
Gibt es heute **nicht**: `ListValue = readonly (number | string | boolean)[]` (`Properties.ts:5`),
`FieldValue` schliesst Struct aus (`:8`), `sameValue` verlaesst sich auf „recurses exactly one level
deep" (`:50`), `LIST_ITEM_KINDS` kennt kein `struct`, `coerceListItem` gibt ein Primitiv zurueck.
Das macht `ListValue` und `StructValue` **gegenseitig rekursiv** (beliebige Tiefe, echter
Deep-Compare) und die Items brauchen eine deklarierte Feldform — wieder „eine geteilte Struct-Form
hat keine Heimat" (~29 Dateien fuer einen neuen Asset-Typ). Deshalb: Tab jetzt fuer flache Listen,
aber um einen Pfad herum; die Typerweiterung kommt mit den Struct-Variablen.
Beibehalten: eine **Liste ist homogen** (ein `ListItemKind`), der **Struct** ist das heterogene
Ding. Das Textfeld sollte als Massenbearbeitung im Tab bleiben, sonst geht das Einfuegen einer
Komma-Liste verloren. Der unverbundene `list`-Eingang (heute faelschlich ein Zahlenfeld) loest
sich mit dem Knopf mit.
---
## Nachtrag (2026-09-10): Sequence waechst mit den Kanten, statt gezaehlt zu werden
Das Zahlenfeld *Outputs* war **unbedienbar**: tippen ging nicht, Pfeil rauf/runter ging nicht, nur
das Ziehen mit der Maus. Die Ursache ist allgemein und nicht `NumberField` zuzuschreiben:
`onInput` (jeder Tastendruck, auch jede Pfeiltaste) → `previewingHandlers` schreibt in
`node.params` → `state.preview()` → Event `project` → `Inspector.refreshValues()` → die
`nodeSignature` enthaelt die Portliste, hat sich also geaendert → **`rebuild()` wirft die Zeile
weg, in der gerade getippt wird.** Ziehen ueberlebt das, weil ein laufender Drag seine
`globalpointermove`-Events weiter am alten (zerstoerten) Widget bekommt; Tippen braucht den Fokus,
und den gibt es nach dem Rebuild nicht mehr.
**Diese Falle ist inzwischen zu** (siehe „List-Tab" unten): `refreshValues()` verschiebt einen
faelligen Rebuild, solange der Fokus im Inspector sitzt, und holt ihn nach, sobald das Feld
verlassen wird (`FocusManager.changeHandlers` + `isWithin`). Werte sind waehrenddessen ohnehin
sicher — `TextField.setValue` ignoriert eine laufende Eingabe (`TextField.ts:92`). Damit war der
Fehler auch nie `NumberField` zuzuschreiben.
Statt das Feld zu reparieren, ist es nach Wunsch des Users **ersetzt**: die Sequence haelt immer
**einen freien Ausgang unter dem letzten verbundenen**. Nichts verbunden = 1 Ausgang; den
Ersatz-Ausgang verbinden = ein neuer erscheint; die unterste Kante loesen = schrumpft wieder.
Gezaehlt wird der letzte *verbundene* Port, nicht die Anzahl der Kanten — so kann keine Kante
verschwinden, wenn in der Mitte eine geloest wird (dann bleibt eine Luecke stehen).
Preis dafuer: `derivePorts` bekommt jetzt den **Graphen** statt nur der Params
(`resolveDef(node, graph, lookup)`, ~15 Aufrufstellen, alle hatten den Graphen schon zur Hand),
und `run` bekommt als viertes Argument den **aufgeloesten Def** — anders kann ein Node mit
abgeleiteten Ports nicht wissen, wie viele er hat. `SetNodeParamCommand` bleibt port-bewusst, das
braucht `Run behavior` weiterhin.
Nebeneffekt: die Sequence braucht keinen Param mehr, also auch kein Aufraeumen von Kanten beim
Verkleinern — eine Kante zu loesen *ist* das Verkleinern.
---
## Nachtrag (2026-09-10): der List-Tab steht
**Gebaut wie im Edit gewuenscht:** eine Listen-Property ist im Inspector ein Knopf `N items`;
er schaltet auf den neuen sechsten Tab **List**, wo jedes Item eine Zeile mit *demselben*
Widget bekommt, das der Inspector fuer diese Item-Art baut (`createField` — Zahl, Text, Ja/Nein
oder Farbe). Der Inspector bleibt daneben stehen, der Kontext geht also nicht verloren.
**Der Weg dorthin laeuft ueber den Zustand**, nicht ueber `showTab`: `EditorState.listSlot` +
`openList(slot)` + Event `list`; der Editor hoert darauf und schaltet um. Ein Slot ist eine
**Asset-Property, ein Objekt-Override oder eine Node-Einstellung** — `ListSlot` steht in
`state.ts` neben `Selection`, weil `state.ts` sonst in einen UI-Ordner importieren muesste.
`resolveList(state, slot)` in `src/editor/list/slot.ts` liefert Wert, Item-Art, Titel und die
zwei Schreibwege; die drei Faelle benutzen **exakt die Spec der passenden Inspector-Sektion**,
also dasselbe Kommando, dieselbe Beschichtung, denselben Undo-Schritt.
**Abweichung vom eigenen Vorschlag:** kein `path` in der Adresse. Ein immer leeres Feld, das
niemand liest, ist Spekulation; die Naht, an der ein Pfad spaeter ansetzt, ist die **eine**
Funktion `resolveList` — das ist dann eine Signaturaenderung, kein Umbau. Ehrlicher so.
**Zwei Dinge, die erst die Gegenprobe gezeigt hat:**
- Die Zeilen-Handler duerfen **nicht** auf einer Momentaufnahme der Liste sitzen. Sie ueberleben
einen Commit (der Tab baut nur bei geaenderter *Laenge* neu), also wuerde die zweite bearbeitete
Zeile die erste zuruecksetzen. Gemessen: mit Momentaufnahme faellt genau dieser Test.
- Die Beschichtung in `replace` war **redundant** — alle drei Kommandos laufen selbst durch
`coerceValue`. Raus. In `toModel` ist sie noetig: dort entsteht der Wert, der als *Vorschau*
am Kommando vorbei direkt ins Modell geschrieben wird. Auch das ist jetzt bewacht.
**Nebenbei mitgenommen:** `showTab` schaltete fuenf `visible`-Zeilen von Hand — nicht
typgeschuetzt, ein sechster Tab waere dort still vergessen worden. Jetzt ein
`Record<CentreTab, Widget>`, das jede Auslassung zum Typfehler macht.
Der unverbundene `list`-Eingang zeigt weiterhin ein **Zahlenfeld**: `inlineInputDef` hat keinen
`list`-Zweig, und der Inline-Pfad kann gar keine Liste erzeugen. Der Knopf loest das nicht mit.
**Zahlen stehen mit drei Nachkommastellen** (`30.000`). Das ist Hausentscheidung, kein Versehen:
`formatNumber` zeigt immer alle Stellen, „so a value can never look whole when it is not".
**Weiterhin offen:** Variablen vom Typ struct, die Struct-View im Inspector, und „Liste aus
Structs" (macht `ListValue`/`StructValue` gegenseitig rekursiv — siehe oben).
---
## Nachtrag (2026-09-10): drei Korrekturen am List-Tab
**Kurze Liste baute sich nicht auf** (User: „muss die Fenstergroesse kurz aendern"). Die Ursache
lag **nicht** im Tab, sondern im `ScrollContainer` — und traf jeden seiner Benutzer, auch `Menu`:
`rebuild()` uebergibt die Zeilen-`VStack` an `setContent()`, solange sie noch **leer** ist. Der
leere Inhalt wird ueber `Math.max(size.h, this.h)` auf die **Viewporthoehe** gesetzt. Danach kommen
die Zeilen hinein, und `relayoutContent()` rechnet fuer eine kurze Liste wieder dieselbe Hoehe aus
— `Widget.resize()` sieht keine Aenderung und **ueberspringt `layout()`** (`Widget.ts:41`). Alle
Zeilen bleiben auf dem Ursprung gestapelt. Eine *lange* Liste aendert die Hoehe und faellt deshalb
nie auf; die erste Oeffnung ueberhaupt auch nicht, weil der Tab dann noch `w=h=0` hat.
Gemessen an Tetris, bevor etwas geaendert wurde: `colors` (7) nach `shapes` (224) → alle vier
Zeilen auf `{245,116}`; `grid` (200) → sauber gestapelt; `test` (16) → wieder kaputt.
Fix in `ScrollContainer.relayoutContent()`: wenn die Groesse gleich bleibt, `layout()` selbst
rufen. Die Methode wird genau dann gerufen, wenn der Inhalt sich geaendert hat — sich auf die
Aenderungserkennung von `resize` zu verlassen ist dort grundsaetzlich falsch.
**Die Kommazeile im Fuss ist raus** (User-Wunsch). Damit ist auch der Komma-Fallback in
`fieldFactory` unerreichbar geworden — jede Sektion, die eine Liste tragen kann, liefert
`openList` (`assetSection:135`, `nodeSection:134`, `objectSection:325`). Fallback, `listToText`
und `listFromText` sind geloescht; der Test dafuer ist auf `coerceValue` umgeschrieben statt
gestrichen, sonst haette `coerceListItem` seine Abdeckung verloren (Boolean-Schreibweise, Farbe).
**JSON-Speicherung war bereits da.** Gemessen an der Projektdatei: `props.shapes` ist ein
JSON-Array mit 224 Zahlen, kein Komma-String; die PHP-API ist reines JSON. Das Komma war nur die
*Anzeige* im geloeschten Textfeld. Es bleibt genau eine Stelle, an der ein Komma-Format wirklich
persistiert wird: eine Liste, die in ein **Text**-Property rutscht, wird ueber `textOf()` zu
`"1, 2, 3"` (`Properties.ts:33`, via `coerceValue` kind `text`) — betrifft auch die Ausgabe des
`Log`-Nodes. Der User hat entschieden, das **so zu lassen**; falls eine Liste je verlustfrei durch
ein Text-Feld muss, ist das die Stelle.
---
## Nachtrag (2026-09-10): Variablen vom Typ struct — Teil 2 und 3 sind zu
**Der Werttyp war fertig, die Deklaration fehlte.** `StructValue`, `textOf`, `sameValue`,
`writeTyped`/`mergeStruct` und der Port-Typ `struct` gab es seit dem ersten Teil; `Get`/`Set
variable` haben `any`-Ports, ein Bündel fliesst also ohne Änderung durch den Graphen. Neu ist
allein, dass eine Variable eine **Feldliste** deklarieren kann:
`PropertyDef` bekommt `kind: 'struct'` mit `fields: StructFieldDef[]`, `VARIABLE_KINDS` das
fünfte Kind, `variableDef` einen fünften Parameter.
**Ein deklariertes Feld ist ein Primitiv** (`ListItemKind` — Zahl, Text, Ja/Nein, Farbe), obwohl
`FieldValue` auch eine Liste zulässt. Grund: ein Listen-Feld bräuchte einen verschachtelten
Editor und damit den Pfad in der Slot-Adresse — genau der Schritt, auf den auch „Liste aus
Structs" wartet. Der Werttyp bleibt unverändert, die Erweiterung ist additiv.
**`coerceValue` gibt jetzt `PropertyValue` statt `FieldValue` zurück** — der erste Werttyp, der
kein Feld ist. Gemessen: das bricht **keine** der fünf Aufrufstellen.
### Die UI: Block statt Zeile — und *nicht* der Tab
Der List-Tab entstand, weil hunderte Zeilen in keine Inspector-Zeile passen. Ein Struct ist der
Gegenfall: wenige Felder, die untereinander passen. Also kein siebter Tab, keine zweite
Navigation. Eine Struct-Variable ist ein **Block**:
```
⊞ [piece ] [+ Field ▾] [×]
# [x ] [ 30.000 ] [×]
Aa [tag ] [ tee ] [×]
```
Das ist der Port-Editor, den `Pack` schon hat (`portSection.ts`), eine Ebene tiefer und **mit
Wertspalte**: Typ, Key und Default-Wert an einer Stelle — genau der Wortlaut aus Teil 3 der
Notiz. Ein **platziertes Objekt** sieht dieselben Felder als reine Wertzeilen (`createField`
liefert für `struct` einen Stapel beschrifteter Zeilen): überschreiben ja, deklarieren nein.
### Drei Dinge, die den Ausschlag gaben
**Eine Feldänderung muss jeden Wert erreichen.** `AddStructFieldCommand` &co. fassen die
Deklaration, den Default, den Wert des Behaviors **und jeden Instanz-Override** in *einem*
Kommando an (`structSites`); sonst behält ein gespeicherter Wert eine Form, die seine
Deklaration nicht mehr verspricht. Gegenprobe: ohne die Override-Sites fallen 3 Unit-Tests.
**Kein Retype-Knopf** — der Port-Editor hat auch keinen. Feld löschen und neu anlegen.
**Die Handler dürfen nicht auf einer Momentaufnahme sitzen**, dieselbe Falle wie beim List-Tab:
ein Feld schreibt das *ganze* Bündel, muss es also im Moment der Änderung lesen. Gegenprobe im
Browser: mit Momentaufnahme wird `{"x":42,"tag":"ell"}` zu `{"x":30,"tag":"ell"}` — die zweite
bearbeitete Zeile belebt den alten Wert der ersten wieder.
**Die Signatur trägt jetzt die Felder** (`defSignature`): Felder sind eigene Zeilen, ein neues
Feld muss die Sektion also neu bauen. Ohne das erscheint die Zeile erst beim nächsten Rebuild —
gemessen, genau ein Browser-Check fällt.
**Bewusst nicht gebaut:** „Liste aus Structs", ein Struct-Feld vom Typ Liste und der unverbundene
`list`-Eingang. Die drei stehen als eigene Notiz in `todo-notes/struct-und-listen-restposten.md`.
### Der teuerste Fund lag nicht im Struct
Der Schritt lief **solo grün und in der vollen Suite mit drei Fehlschlägen** — identische Klick-
koordinaten, das Menü ging auf, `Boolean` lag genau unter dem Zeiger, und trotzdem passierte
nichts. Kein Notice, also war es kein `CommandError`, sondern eine **echte Ausnahme**, die das
Menü (`closeThen`: erst schliessen, dann ausfuehren) als „Klick auf den Hintergrund" aussehen
liess.
Halbiert (`GM_STEPS=1-9,22` gruen, `1-10,22` rot) und dann im Fenster abgefangen:
`Cannot convert undefined or null to object`. Die Ursache ist ein **Fixture-Fehler**: Schritt 10
schiebt sein Text-Objekt per `mutate` von Hand in die Szene — **ohne `instanceEdits`**, obwohl
`SceneObject` das Feld verlangt. `overrideSites` laeuft mit `Object.entries` darueber. Mein
`AddStructFieldCommand` ist die erste Stelle, die `overrideSites` aus diesem Zustand heraus ruft.
Das Produkt bleibt unangetastet: geladen wird das Feld von `normalizeObjects` gefuellt, angelegt
von den Kommandos — ein Objekt ohne `instanceEdits` kann echt nicht entstehen. Repariert ist das
Objektliteral im Schritt.
**Lehre fuer den naechsten Schritt:** ein Schritt, der solo gruen ist, ist nicht belegt. Nur der
volle Lauf sieht, was die Vorgaenger hinterlassen.
---
*Übernommen aus `todo-notes/done/nodes-mit-dynamischer-port-zahl.md` beim Umzug auf das Issue-System (2026-09-12).*
- **Angelegt:** 2026-09-10 · `0449218` — Let a variable declare struct fields
- **Abgeschlossen:** 2026-09-10 · `9d62dfe` — Pull the three open items out of the finished port note
- **Gemeldet von:** admin
admin
added the todo-note label 2026-09-12 15:03:54 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Dynamische Portzahl bei Nodes ermöglichen
Wenn das möglich ist, umsetzen. Folgende Dinge wären dafür praktisch:
1. sequence node
diese hat momentan fixe 3 outputs, diese könnten somit ebenfalls dynamisch sein
2. eine variable vom typ struct
sprich eine variable die mehreren properties (key mit allen bisher möglichen variablen typen + value).
dazu passende nodes um von einer variable alle properties zu expanden und umgekehrt (alle werte eines structs setzen)
variablen vom typ struct und list im inspector
bei typ list: momentan steht es einfach nur als string geparsed in einem textfeld.
für die liste bräuchten wir eine view, sprich liste mit variablen.
für typ struct: hier wäre natürlich auch eine passende view gut, wo man bei der variable type + key + default value angeben kann.
lass uns das mal ausarbeiten. wie weit das sinnvoll bzw. möglich ist.
Umgesetzt (2026-09-10), Teil 1 und der Werttyp. Was offen bleibt, steht unten.
Dynamische Ports gab es schon — nur nicht aus Node-Einstellungen. Verschachtelte Behaviors
leben seit jeher davon:
GraphNode.portsspeichert eine Deklaration pro Node,resolveDefloest den Def pro Node auf,
portSignatureist der Ausloeser, an dem View und Inspector merken,dass sich eine abgeleitete Portliste geaendert hat. Die Regel dafuer stand als Dreier-Switch auf
Node-Typen in
nested.ts— die einzige Stelle im Projekt, die Node-Typen namentlich kannte.Der Switch ist jetzt aufgeloest in zwei Vertraege am
NodeDef:declaresPorts: 'inputs' | 'outputs'— der Node traegt seine eigene Portliste, editierbar imInspector. Das haben
Start,Endund neuPack/Expand.derivePorts(source)— der Node rechnet seine Ports aus sich und dem Graphen aus, in dem ersitzt. Das haben
Sequence(aus den eigenen Kanten) undRun behavior(Ports des benanntenBehaviors).
Sequence hatte zuerst eine Einstellung Outputs (2…26). Die ist wieder weg — siehe den
Nachtrag unten: die Ports wachsen jetzt mit den Kanten. Die Keys bleiben
a,b,c, … stattout1..N, weil im Jump-and-Run zwei Nodes an einer Sequence haengen und deren Kanten heil bleibensollen.
Die einzige echte Falle war das Schrumpfen.
SetNodeParamCommandwar ein reiner Bag-Schreiber:verschwand ein Port, blieb seine Kante liegen — unsichtbar (
drawEdgesteigt bei unbekanntem Portstill aus), aber sie ueberlebte jedes Speichern und waere beim Hochzaehlen wieder angesprungen.
Der Befehl ist jetzt port-bewusst und nimmt solche Kanten mit, Undo gibt sie zurueck. Das schliesst
einen bestehenden Fehler mit: derselbe Verwaisungs-Effekt trat schon immer auf, wenn man an
einem
Run behavior-Node das Behavior wechselte.Struct als echter Werttyp (Weg C).
PropertyValueist jetztnumber | string | boolean | ListValue | StructValue. Felder sind Primitive oder Listen, niewieder ein Struct — aus demselben Grund, aus dem eine Liste flach ist.
Zwei Dinge waren dafuer noetig, und beide sind der eigentliche Preis von C:
values.tsbedeutete „Objekt, kein Array" ein Entity-Handle.EntityHandletraegt jetzt eineSymbol-Marke (
ENTITY_HANDLE) — ein Symbol, damit kein Struct-Feldname sie imitieren kann,und es serialisiert nie, was fuer etwas richtig ist, das nur waehrend des Spiels existiert.
Der Typechecker hat jede betroffene Stelle selbst benannt.
String(value)haette einen Struct als[object Object]geschrieben — an 39 Stellen.Die gehen jetzt alle ueber
textOf(). Das war kein Stilfehler, sondern eine echte Luecke, dieerst der neue Typ sichtbar gemacht hat.
Dazu ein gemessener, lebender Fehler, der dabei aufflog und mitrepariert ist: der
List-Nodeverlor seinen Wert beim Speichern.
normalizeGraphliess bei Node-Params nur Primitive durch,Arrays fielen weg (
[3,1,4]→undefinednach Round-Trip). Listen-Variablen waren nichtbetroffen — die haben ein Schema. Params haben keins, weil sie Einstellungen und Inline-Werte
mischen.
Belegt (
tests/core/dynamic-ports.test.ts): ein Bündel faehrt ueber eine Kante durch einverschachteltes Behavior —
Pack{x:30,y:12}→Run behavior-Port vom Typstruct→Expand→Add→ 42. Genau die Form, die Tetris heute von Hand parallel fuehrt (Next move: 8 rein,7 raus; 49 deklarierte Ports ueber 9 Behaviors).
Offen, bewusst nicht gebaut:
Feldliste im Inspector — die Deklaration liegt bisher nur am Node (
Pack/Expand) und amBehavior-Port.
writeTyped/mergeStructsind schon darauf vorbereitet: ein Struct-Slot behaeltseine Felder, was ankommt fuellt die benannten, der Rest bleibt stehen.
Nachtrag „der List-Tab steht". Der Struct-Teil ist weiter offen.] Das Textfeld war nicht nur
Kosmetik:
['red, green', 'blue']wurde als"red, green, blue"geschrieben und las sich alsdrei Items zurueck — ein Komma in einem Text-Item war nicht darstellbar. Fuer den Struct ist
Zeile-pro-Feld richtig, das sind wenige.
list-Eingang zeigt weiterhin ein Zahlenfeld (inlineInputDefhat keinenlist-Zweig), und eine Listen-Variable kann nur Zahlen-Items:AddVariableCommandnimmtitemsgar nicht entgegen.
EDIT:
ok, eigener tab "List"
dann machen wir es so: es gibt dann einfach statt dem textfeld ein button wo drinnen steht "42 items".
klickt man darauf switch zu Tab "List" und dort wird die liste aller items dargestellt die man bearbeiten kann.
dort soll auch der selbe "renderer" verwendet werden wie beim inspector.
wenn wir dann struct (teil 3) umsetzen, kann es auch eine liste aus struct variablen geben (bitte das bedenken) und
im tab "List" sollen variablen (egal welcher type) im prinzip genauso dargestellt werden wie im inspector
Antwort auf den Edit (2026-09-10): geht, mit drei Auflagen
Der Tab ist hier besser als das Overlay, aus einem Grund, der erst beim Nachsehen auffiel: der
Inspector haengt nicht im Tab-Bereich, sondern als eigene Spalte daneben (
Editor.ts:156). Erbleibt in jedem Tab sichtbar, der Sprung verliert also keinen Kontext.
„Derselbe Renderer" ist fast geschenkt.
createField(fieldFactory.ts:171) braucht alsKontext nur
{ project(): Project }— keine Inspector-Instanz, keinen Command-Stack. Die Zeilebaut selbst kein Kommando, sie ruft
onInput/onChange.previewingHandlersbenutzt vomInspectorHostausschliesslichhost.state; den Parametertyp zu verengen macht auch dieUndo-Mechanik wiederverwendbar. Ein Struct kommt spaeter an einer Stelle dazu: dem
switch (def.kind)infieldFactory.ts:177.Der Weg in den Tab muss ueber den Zustand laufen, nicht ueber
showTab. Kein Tab kennt denEditor; Tab-Wechsel von innen gibt es nur selektionsgetrieben, gebuendelt inEditor.ts:169-179.Woran der Tab haengt
Die anderen Tabs haengen an einer Asset-Id. Eine Liste ist kein Asset, sondern ein Slot:
Asset-Property (
assetId+key), Objekt-Override (holderId+objectId+key) oder Node-Param(
assetId+nodeId+key). Also eine explizite Adresse mit Pfad, die der Tab behaelt wie dieanderen ihre Asset-Id und die auf „nichts" zurueckfaellt, wenn das Ziel verschwindet
(
AssetTabEditor.refresh()macht genau das). Der Drill-down spaeter ist derselbe Mechanismuseine Ebene tiefer.
Zeile-pro-Item traegt — gemessen
Zeilen echt ueber
createFieldRowgebaut, mit GPU im Browser, dabei jeden Frame gescrollt:Dagegen die laengsten Listen im echten Bestand: tetris 224 (
shapes), 224 (eineVariablen-Vorgabe), 200 (
grid); jumpRun und fixture haben keine. Der reale Hoechstfall liegtalso bei 224 — kein Windowing noetig. Bei 3 000 Eintraegen faellt es auseinander, aber nichts
kommt dem nahe.
Der eigentliche Fund steckt in der Aufbau-Spalte: 15 ms pro Rebuild bei 200 Zeilen. Deshalb kam
der Fokus-Schutz zuerst (siehe Nachtrag oben) — ein Rebuild pro Tastendruck waere dort die Regel.
Was „Liste aus Structs" kostet
Gibt es heute nicht:
ListValue = readonly (number | string | boolean)[](Properties.ts:5),FieldValueschliesst Struct aus (:8),sameValueverlaesst sich auf „recurses exactly one leveldeep" (
:50),LIST_ITEM_KINDSkennt keinstruct,coerceListItemgibt ein Primitiv zurueck.Das macht
ListValueundStructValuegegenseitig rekursiv (beliebige Tiefe, echterDeep-Compare) und die Items brauchen eine deklarierte Feldform — wieder „eine geteilte Struct-Form
hat keine Heimat" (~29 Dateien fuer einen neuen Asset-Typ). Deshalb: Tab jetzt fuer flache Listen,
aber um einen Pfad herum; die Typerweiterung kommt mit den Struct-Variablen.
Beibehalten: eine Liste ist homogen (ein
ListItemKind), der Struct ist das heterogeneDing. Das Textfeld sollte als Massenbearbeitung im Tab bleiben, sonst geht das Einfuegen einer
Komma-Liste verloren. Der unverbundene
list-Eingang (heute faelschlich ein Zahlenfeld) loestsich mit dem Knopf mit.
Nachtrag (2026-09-10): Sequence waechst mit den Kanten, statt gezaehlt zu werden
Das Zahlenfeld Outputs war unbedienbar: tippen ging nicht, Pfeil rauf/runter ging nicht, nur
das Ziehen mit der Maus. Die Ursache ist allgemein und nicht
NumberFieldzuzuschreiben:onInput(jeder Tastendruck, auch jede Pfeiltaste) →previewingHandlersschreibt innode.params→state.preview()→ Eventproject→Inspector.refreshValues()→ dienodeSignatureenthaelt die Portliste, hat sich also geaendert →rebuild()wirft die Zeileweg, in der gerade getippt wird. Ziehen ueberlebt das, weil ein laufender Drag seine
globalpointermove-Events weiter am alten (zerstoerten) Widget bekommt; Tippen braucht den Fokus,und den gibt es nach dem Rebuild nicht mehr.
Diese Falle ist inzwischen zu (siehe „List-Tab" unten):
refreshValues()verschiebt einenfaelligen Rebuild, solange der Fokus im Inspector sitzt, und holt ihn nach, sobald das Feld
verlassen wird (
FocusManager.changeHandlers+isWithin). Werte sind waehrenddessen ohnehinsicher —
TextField.setValueignoriert eine laufende Eingabe (TextField.ts:92). Damit war derFehler auch nie
NumberFieldzuzuschreiben.Statt das Feld zu reparieren, ist es nach Wunsch des Users ersetzt: die Sequence haelt immer
einen freien Ausgang unter dem letzten verbundenen. Nichts verbunden = 1 Ausgang; den
Ersatz-Ausgang verbinden = ein neuer erscheint; die unterste Kante loesen = schrumpft wieder.
Gezaehlt wird der letzte verbundene Port, nicht die Anzahl der Kanten — so kann keine Kante
verschwinden, wenn in der Mitte eine geloest wird (dann bleibt eine Luecke stehen).
Preis dafuer:
derivePortsbekommt jetzt den Graphen statt nur der Params(
resolveDef(node, graph, lookup), ~15 Aufrufstellen, alle hatten den Graphen schon zur Hand),und
runbekommt als viertes Argument den aufgeloesten Def — anders kann ein Node mitabgeleiteten Ports nicht wissen, wie viele er hat.
SetNodeParamCommandbleibt port-bewusst, dasbraucht
Run behaviorweiterhin.Nebeneffekt: die Sequence braucht keinen Param mehr, also auch kein Aufraeumen von Kanten beim
Verkleinern — eine Kante zu loesen ist das Verkleinern.
Nachtrag (2026-09-10): der List-Tab steht
Gebaut wie im Edit gewuenscht: eine Listen-Property ist im Inspector ein Knopf
N items;er schaltet auf den neuen sechsten Tab List, wo jedes Item eine Zeile mit demselben
Widget bekommt, das der Inspector fuer diese Item-Art baut (
createField— Zahl, Text, Ja/Neinoder Farbe). Der Inspector bleibt daneben stehen, der Kontext geht also nicht verloren.
Der Weg dorthin laeuft ueber den Zustand, nicht ueber
showTab:EditorState.listSlot+openList(slot)+ Eventlist; der Editor hoert darauf und schaltet um. Ein Slot ist eineAsset-Property, ein Objekt-Override oder eine Node-Einstellung —
ListSlotsteht instate.tsnebenSelection, weilstate.tssonst in einen UI-Ordner importieren muesste.resolveList(state, slot)insrc/editor/list/slot.tsliefert Wert, Item-Art, Titel und diezwei Schreibwege; die drei Faelle benutzen exakt die Spec der passenden Inspector-Sektion,
also dasselbe Kommando, dieselbe Beschichtung, denselben Undo-Schritt.
Abweichung vom eigenen Vorschlag: kein
pathin der Adresse. Ein immer leeres Feld, dasniemand liest, ist Spekulation; die Naht, an der ein Pfad spaeter ansetzt, ist die eine
Funktion
resolveList— das ist dann eine Signaturaenderung, kein Umbau. Ehrlicher so.Zwei Dinge, die erst die Gegenprobe gezeigt hat:
einen Commit (der Tab baut nur bei geaenderter Laenge neu), also wuerde die zweite bearbeitete
Zeile die erste zuruecksetzen. Gemessen: mit Momentaufnahme faellt genau dieser Test.
replacewar redundant — alle drei Kommandos laufen selbst durchcoerceValue. Raus. IntoModelist sie noetig: dort entsteht der Wert, der als Vorschauam Kommando vorbei direkt ins Modell geschrieben wird. Auch das ist jetzt bewacht.
Nebenbei mitgenommen:
showTabschaltete fuenfvisible-Zeilen von Hand — nichttypgeschuetzt, ein sechster Tab waere dort still vergessen worden. Jetzt ein
Record<CentreTab, Widget>, das jede Auslassung zum Typfehler macht.Der unverbundene
list-Eingang zeigt weiterhin ein Zahlenfeld:inlineInputDefhat keinenlist-Zweig, und der Inline-Pfad kann gar keine Liste erzeugen. Der Knopf loest das nicht mit.Zahlen stehen mit drei Nachkommastellen (
30.000). Das ist Hausentscheidung, kein Versehen:formatNumberzeigt immer alle Stellen, „so a value can never look whole when it is not".Weiterhin offen: Variablen vom Typ struct, die Struct-View im Inspector, und „Liste aus
Structs" (macht
ListValue/StructValuegegenseitig rekursiv — siehe oben).Nachtrag (2026-09-10): drei Korrekturen am List-Tab
Kurze Liste baute sich nicht auf (User: „muss die Fenstergroesse kurz aendern"). Die Ursache
lag nicht im Tab, sondern im
ScrollContainer— und traf jeden seiner Benutzer, auchMenu:rebuild()uebergibt die Zeilen-VStackansetContent(), solange sie noch leer ist. Derleere Inhalt wird ueber
Math.max(size.h, this.h)auf die Viewporthoehe gesetzt. Danach kommendie Zeilen hinein, und
relayoutContent()rechnet fuer eine kurze Liste wieder dieselbe Hoehe aus—
Widget.resize()sieht keine Aenderung und ueberspringtlayout()(Widget.ts:41). AlleZeilen bleiben auf dem Ursprung gestapelt. Eine lange Liste aendert die Hoehe und faellt deshalb
nie auf; die erste Oeffnung ueberhaupt auch nicht, weil der Tab dann noch
w=h=0hat.Gemessen an Tetris, bevor etwas geaendert wurde:
colors(7) nachshapes(224) → alle vierZeilen auf
{245,116};grid(200) → sauber gestapelt;test(16) → wieder kaputt.Fix in
ScrollContainer.relayoutContent(): wenn die Groesse gleich bleibt,layout()selbstrufen. Die Methode wird genau dann gerufen, wenn der Inhalt sich geaendert hat — sich auf die
Aenderungserkennung von
resizezu verlassen ist dort grundsaetzlich falsch.Die Kommazeile im Fuss ist raus (User-Wunsch). Damit ist auch der Komma-Fallback in
fieldFactoryunerreichbar geworden — jede Sektion, die eine Liste tragen kann, liefertopenList(assetSection:135,nodeSection:134,objectSection:325). Fallback,listToTextund
listFromTextsind geloescht; der Test dafuer ist aufcoerceValueumgeschrieben stattgestrichen, sonst haette
coerceListItemseine Abdeckung verloren (Boolean-Schreibweise, Farbe).JSON-Speicherung war bereits da. Gemessen an der Projektdatei:
props.shapesist einJSON-Array mit 224 Zahlen, kein Komma-String; die PHP-API ist reines JSON. Das Komma war nur die
Anzeige im geloeschten Textfeld. Es bleibt genau eine Stelle, an der ein Komma-Format wirklich
persistiert wird: eine Liste, die in ein Text-Property rutscht, wird ueber
textOf()zu"1, 2, 3"(Properties.ts:33, viacoerceValuekindtext) — betrifft auch die Ausgabe desLog-Nodes. Der User hat entschieden, das so zu lassen; falls eine Liste je verlustfrei durchein Text-Feld muss, ist das die Stelle.
Nachtrag (2026-09-10): Variablen vom Typ struct — Teil 2 und 3 sind zu
Der Werttyp war fertig, die Deklaration fehlte.
StructValue,textOf,sameValue,writeTyped/mergeStructund der Port-Typstructgab es seit dem ersten Teil;Get/Set variablehabenany-Ports, ein Bündel fliesst also ohne Änderung durch den Graphen. Neu istallein, dass eine Variable eine Feldliste deklarieren kann:
PropertyDefbekommtkind: 'struct'mitfields: StructFieldDef[],VARIABLE_KINDSdasfünfte Kind,
variableDefeinen fünften Parameter.Ein deklariertes Feld ist ein Primitiv (
ListItemKind— Zahl, Text, Ja/Nein, Farbe), obwohlFieldValueauch eine Liste zulässt. Grund: ein Listen-Feld bräuchte einen verschachteltenEditor und damit den Pfad in der Slot-Adresse — genau der Schritt, auf den auch „Liste aus
Structs" wartet. Der Werttyp bleibt unverändert, die Erweiterung ist additiv.
coerceValuegibt jetztPropertyValuestattFieldValuezurück — der erste Werttyp, derkein Feld ist. Gemessen: das bricht keine der fünf Aufrufstellen.
Die UI: Block statt Zeile — und nicht der Tab
Der List-Tab entstand, weil hunderte Zeilen in keine Inspector-Zeile passen. Ein Struct ist der
Gegenfall: wenige Felder, die untereinander passen. Also kein siebter Tab, keine zweite
Navigation. Eine Struct-Variable ist ein Block:
Das ist der Port-Editor, den
Packschon hat (portSection.ts), eine Ebene tiefer und mitWertspalte: Typ, Key und Default-Wert an einer Stelle — genau der Wortlaut aus Teil 3 der
Notiz. Ein platziertes Objekt sieht dieselben Felder als reine Wertzeilen (
createFieldliefert für
structeinen Stapel beschrifteter Zeilen): überschreiben ja, deklarieren nein.Drei Dinge, die den Ausschlag gaben
Eine Feldänderung muss jeden Wert erreichen.
AddStructFieldCommand&co. fassen dieDeklaration, den Default, den Wert des Behaviors und jeden Instanz-Override in einem
Kommando an (
structSites); sonst behält ein gespeicherter Wert eine Form, die seineDeklaration nicht mehr verspricht. Gegenprobe: ohne die Override-Sites fallen 3 Unit-Tests.
Kein Retype-Knopf — der Port-Editor hat auch keinen. Feld löschen und neu anlegen.
Die Handler dürfen nicht auf einer Momentaufnahme sitzen, dieselbe Falle wie beim List-Tab:
ein Feld schreibt das ganze Bündel, muss es also im Moment der Änderung lesen. Gegenprobe im
Browser: mit Momentaufnahme wird
{"x":42,"tag":"ell"}zu{"x":30,"tag":"ell"}— die zweitebearbeitete Zeile belebt den alten Wert der ersten wieder.
Die Signatur trägt jetzt die Felder (
defSignature): Felder sind eigene Zeilen, ein neuesFeld muss die Sektion also neu bauen. Ohne das erscheint die Zeile erst beim nächsten Rebuild —
gemessen, genau ein Browser-Check fällt.
Bewusst nicht gebaut: „Liste aus Structs", ein Struct-Feld vom Typ Liste und der unverbundene
list-Eingang. Die drei stehen als eigene Notiz intodo-notes/struct-und-listen-restposten.md.Der teuerste Fund lag nicht im Struct
Der Schritt lief solo grün und in der vollen Suite mit drei Fehlschlägen — identische Klick-
koordinaten, das Menü ging auf,
Booleanlag genau unter dem Zeiger, und trotzdem passiertenichts. Kein Notice, also war es kein
CommandError, sondern eine echte Ausnahme, die dasMenü (
closeThen: erst schliessen, dann ausfuehren) als „Klick auf den Hintergrund" aussehenliess.
Halbiert (
GM_STEPS=1-9,22gruen,1-10,22rot) und dann im Fenster abgefangen:Cannot convert undefined or null to object. Die Ursache ist ein Fixture-Fehler: Schritt 10schiebt sein Text-Objekt per
mutatevon Hand in die Szene — ohneinstanceEdits, obwohlSceneObjectdas Feld verlangt.overrideSiteslaeuft mitObject.entriesdarueber. MeinAddStructFieldCommandist die erste Stelle, dieoverrideSitesaus diesem Zustand heraus ruft.Das Produkt bleibt unangetastet: geladen wird das Feld von
normalizeObjectsgefuellt, angelegtvon den Kommandos — ein Objekt ohne
instanceEditskann echt nicht entstehen. Repariert ist dasObjektliteral im Schritt.
Lehre fuer den naechsten Schritt: ein Schritt, der solo gruen ist, ist nicht belegt. Nur der
volle Lauf sieht, was die Vorgaenger hinterlassen.
Übernommen aus
todo-notes/done/nodes-mit-dynamischer-port-zahl.mdbeim Umzug auf das Issue-System (2026-09-12).0449218— Let a variable declare struct fields9d62dfe— Pull the three open items out of the finished port note