Restructure project as Worldshaper

This commit is contained in:
Andraxion 2026-06-26 20:30:30 -04:00
parent ab891a315c
commit b4dbd4ee8e
583 changed files with 279 additions and 189269 deletions

View file

@ -1,153 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="2100" viewBox="0 0 1600 2100">
<defs>
<style>
.title { font: 700 34px 'Segoe UI', Tahoma, sans-serif; fill: #0f172a; }
.label { font: 600 18px 'Segoe UI', Tahoma, sans-serif; fill: #0f172a; }
.small { font: 500 16px 'Segoe UI', Tahoma, sans-serif; fill: #1f2937; }
.edge { font: 600 15px 'Segoe UI', Tahoma, sans-serif; fill: #334155; }
.node { fill: #eef4ff; stroke: #355caa; stroke-width: 2.2; rx: 12; }
.decision { fill: #fff6db; stroke: #ad7c00; stroke-width: 2.2; }
.terminal { fill: #ffe5e5; stroke: #a23434; stroke-width: 2.2; rx: 20; }
.group { fill: #f8fafc; stroke: #94a3b8; stroke-width: 1.5; rx: 14; stroke-dasharray: 8 6; }
.arrow { stroke: #334155; stroke-width: 2.2; fill: none; marker-end: url(#arrowHead); }
</style>
<marker id="arrowHead" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#334155" />
</marker>
</defs>
<rect x="20" y="20" width="1560" height="2060" fill="#ffffff" stroke="#cbd5e1" stroke-width="2" rx="18" />
<text x="800" y="72" text-anchor="middle" class="title">New RPG Dialogue Runtime Flowchart</text>
<text x="800" y="103" text-anchor="middle" class="small">From interaction start to dialogue close, including choice/condition routing and reactions</text>
<rect x="90" y="140" width="1420" height="420" class="group" />
<text x="110" y="168" class="label">A) Open Dialogue and Build Runtime Graph</text>
<rect x="620" y="190" width="360" height="64" class="node" />
<text x="800" y="228" text-anchor="middle" class="label">Player presses E near NPC</text>
<rect x="620" y="286" width="360" height="64" class="node" />
<text x="800" y="324" text-anchor="middle" class="label">openNpcDialogue()</text>
<rect x="620" y="382" width="360" height="64" class="node" />
<text x="800" y="420" text-anchor="middle" class="label">updateQuestProgression()</text>
<rect x="620" y="478" width="360" height="64" class="node" />
<text x="800" y="516" text-anchor="middle" class="label">Build runtime graph from content</text>
<path d="M800 254 L800 286" class="arrow" />
<path d="M800 350 L800 382" class="arrow" />
<path d="M800 446 L800 478" class="arrow" />
<rect x="90" y="590" width="1420" height="430" class="group" />
<text x="110" y="618" class="label">B) Start Node Selection</text>
<rect x="150" y="655" width="380" height="76" class="node" />
<text x="340" y="688" text-anchor="middle" class="label">defaultDialogueNodeId exists</text>
<text x="340" y="712" text-anchor="middle" class="small">and resolves to a node?</text>
<polygon points="800,650 940,708 800,766 660,708" class="decision" />
<text x="800" y="703" text-anchor="middle" class="label">Yes</text>
<text x="800" y="724" text-anchor="middle" class="small">use default node</text>
<rect x="1070" y="655" width="360" height="76" class="node" />
<text x="1250" y="688" text-anchor="middle" class="label">Sort nodes by order, then id</text>
<text x="1250" y="712" text-anchor="middle" class="small">find candidate roots</text>
<rect x="1070" y="760" width="360" height="76" class="node" />
<text x="1250" y="793" text-anchor="middle" class="label">Filter by dialogueNodeMatchesContext</text>
<text x="1250" y="817" text-anchor="middle" class="small">pick highest priority</text>
<rect x="620" y="892" width="360" height="76" class="node" />
<text x="800" y="925" text-anchor="middle" class="label">DialogueSession.openGraph()</text>
<text x="800" y="949" text-anchor="middle" class="small">set currentNodeId</text>
<path d="M980 510 L980 708 L940 708" class="arrow" />
<path d="M530 693 L660 708" class="arrow" />
<path d="M940 708 L1070 708" class="arrow" />
<path d="M1250 731 L1250 760" class="arrow" />
<path d="M1250 836 L980 930" class="arrow" />
<polygon points="800,1015 930,1068 800,1121 670,1068" class="decision" />
<text x="800" y="1062" text-anchor="middle" class="label">Start node</text>
<text x="800" y="1083" text-anchor="middle" class="small">exists?</text>
<rect x="1090" y="1035" width="300" height="64" class="terminal" />
<text x="1240" y="1072" text-anchor="middle" class="label">Close dialogue</text>
<rect x="620" y="1155" width="360" height="64" class="node" />
<text x="800" y="1192" text-anchor="middle" class="label">applyNodeReactions(startNode)</text>
<path d="M800 968 L800 1015" class="arrow" />
<path d="M930 1068 L1090 1068" class="arrow" />
<path d="M800 1121 L800 1155" class="arrow" />
<rect x="90" y="1250" width="1420" height="780" class="group" />
<text x="110" y="1278" class="label">C) Main Dialogue Loop (Input + Routing)</text>
<polygon points="800,1310 940,1368 800,1426 660,1368" class="decision" />
<text x="800" y="1362" text-anchor="middle" class="label">Current node</text>
<text x="800" y="1383" text-anchor="middle" class="small">has choices?</text>
<polygon points="430,1460 570,1518 430,1576 290,1518" class="decision" />
<text x="430" y="1512" text-anchor="middle" class="label">Continue key</text>
<text x="430" y="1533" text-anchor="middle" class="small">pressed?</text>
<rect x="230" y="1610" width="400" height="76" class="node" />
<text x="430" y="1643" text-anchor="middle" class="label">resolveNodeConditionalNext()</text>
<text x="430" y="1667" text-anchor="middle" class="small">first passing condition wins</text>
<polygon points="430,1722 560,1775 430,1828 300,1775" class="decision" />
<text x="430" y="1769" text-anchor="middle" class="label">Next id exists?</text>
<rect x="230" y="1860" width="400" height="76" class="node" />
<text x="430" y="1893" text-anchor="middle" class="label">advanceTo(next)</text>
<text x="430" y="1917" text-anchor="middle" class="small">apply entered node reactions</text>
<polygon points="1170,1460 1310,1518 1170,1576 1030,1518" class="decision" />
<text x="1170" y="1512" text-anchor="middle" class="label">Any visible</text>
<text x="1170" y="1533" text-anchor="middle" class="small">choices?</text>
<polygon points="980,1610 1120,1668 980,1726 840,1668" class="decision" />
<text x="980" y="1662" text-anchor="middle" class="label">Continue key</text>
<text x="980" y="1683" text-anchor="middle" class="small">pressed?</text>
<polygon points="1300,1610 1440,1668 1300,1726 1160,1668" class="decision" />
<text x="1300" y="1662" text-anchor="middle" class="label">Number key</text>
<text x="1300" y="1683" text-anchor="middle" class="small">1..9 pressed?</text>
<rect x="1110" y="1760" width="380" height="76" class="node" />
<text x="1300" y="1793" text-anchor="middle" class="label">Map visible index to actual choice</text>
<text x="1300" y="1817" text-anchor="middle" class="small">apply choice reaction</text>
<rect x="1110" y="1868" width="380" height="76" class="node" />
<text x="1300" y="1901" text-anchor="middle" class="label">resolveChoiceTarget()</text>
<text x="1300" y="1925" text-anchor="middle" class="small">met: nextId, else unmetNextId</text>
<path d="M800 1219 L800 1310" class="arrow" />
<path d="M660 1368 L430 1518" class="arrow" />
<path d="M940 1368 L1170 1518" class="arrow" />
<path d="M430 1576 L430 1610" class="arrow" />
<path d="M430 1686 L430 1722" class="arrow" />
<path d="M430 1828 L430 1860" class="arrow" />
<path d="M1170 1576 L980 1668" class="arrow" />
<path d="M1170 1576 L1300 1668" class="arrow" />
<path d="M980 1726 L430 1722" class="arrow" />
<path d="M1300 1726 L1300 1760" class="arrow" />
<path d="M1300 1836 L1300 1868" class="arrow" />
<path d="M1110 1906 L630 1898" class="arrow" />
<path d="M430 1936 L430 1984 L800 1984 L800 1426" class="arrow" />
<path d="M1300 1944 L1300 1984 L800 1984" class="arrow" />
<rect x="690" y="2010" width="220" height="52" class="terminal" />
<text x="800" y="2042" text-anchor="middle" class="label">Dialogue closed</text>
<path d="M560 1775 L690 2036" class="arrow" />
<path d="M1240 1068 L1240 2036 L910 2036" class="arrow" />
<text x="96" y="2068" class="small">Note: Text rendering uses resolveNodeConditionalText() and displays the first passing condition text. Continue hint uses condition-resolved next id.</text>
</svg>

Before

Width:  |  Height:  |  Size: 8.2 KiB

View file

@ -1,233 +0,0 @@
# Dialogue System Runtime Spec
This document is a code-aligned reference for how dialogue is parsed, evaluated, and executed in-game.
## Quick Visual
![Dialogue Runtime Flowchart](DIALOGUE_SYSTEM_FLOWCHART.svg)
## 1) Data Model (Current Canonical Shape)
Dialogue nodes are authored primarily with arrays:
- `conditions[]`
- `text`
- `conditionType`
- `conditionValue` (for `item`, use `itemId:quantity`)
- `conditionStepId`
- `conditionNot`
- `nextId`
- `reactions[]`
- `reactionType`
- `reactionValue`
- `choices[]`
- `text`
- `nextId`
- `conditionType`
- `conditionValue`
- `conditionStepId`
- `conditionNot`
- `reactionType`
- `reactionValue`
Compatibility fields still exist in runtime structures (`text`, `conditionType`, `conditionValue`, `conditionStepId`, `conditionNot`, `nextId`, `reactionType`, `reactionValue`) and are used as fallback when needed.
## 2) Parsing Order (JSON -> Runtime Content)
Parsing entrypoint:
- `ContentManager::loadAll()`
- `readDialogueNodes()` for each NPC
Per dialogue node, parsing order is:
1. Read legacy/base fields first (`id`, `text`, legacy condition/reaction/next/order fields).
2. Read `conditions[]`.
- Each condition uses `condition.text`, defaulting to base `node.text` if missing.
3. If `conditions[]` is empty, synthesize exactly one condition from legacy/base fields.
4. Read `reactions[]`.
5. If `reactions[]` is empty, only synthesize a fallback reaction if legacy reaction fields were present in JSON.
- This allows intentionally empty reaction arrays.
6. Read `choices[]`.
- Supports migration path for very old choice payloads missing `conditionType`.
## 3) Runtime Graph Build Order
At interaction start (`E` near NPC):
1. `openNpcDialogue()` calls `updateQuestProgression()`.
2. NPC node defs are converted to runtime `game::DialogueNode` objects.
3. Conditions, reactions, and choices are copied into runtime node vectors.
4. Start node is selected with `selectDialogueStartNodeId()`.
5. `DialogueSession::openGraph()` is called.
6. If a start node exists, node reactions are applied immediately (`applyNodeReactions()`).
## 4) Start Node Selection Logic
`selectDialogueStartNodeId()` order:
1. If `defaultDialogueNodeId` exists and is valid, use it.
2. Else, sort nodes by `order`, then `id`.
3. Build inbound edge set from:
- node `nextId`
- choice `nextId`
4. Candidate roots are nodes with no inbound references.
- If none, all nodes become candidates.
5. Filter candidates by `dialogueNodeMatchesContext()`.
6. Pick highest `dialogueNodePriority()`.
- Priority: `quest_step_completed` > `quest_started/quest_completed` > `item/flag` > default.
7. Tie-break by smaller `order`.
8. If no contextual match, fall back to first sorted node.
## 5) Condition Evaluation Order
Core function: `doesConditionPass(type, value, stepId, conditionNot)`.
Evaluation sequence:
1. Evaluate base condition type.
2. Supported types include:
- `always`
- `flag`
- `item` (supports `itemId:quantity`; quantity defaults to `1` if omitted)
- `level`
- `currency` (supports `key:amount` format)
- `skill`
- `quest_started`, `quest_completed`
- `quest_step_completed`
3. Apply NOT inversion if `conditionNot == true`.
Node-level match order:
- `resolveMatchedNodeCondition(node)` scans `node.conditions` from index 0 upward.
- First passing condition wins.
That winning condition is then used for:
- Display text via `resolveNodeConditionalText()` (`condition.text`, else legacy `node.text`).
- Continue target via `resolveNodeConditionalNext()` (`condition.nextId`, else legacy `node.nextNodeId`).
## 6) Choice Visibility and Selection Order
Choice visibility:
- `buildVisibleChoiceIndices(node)` iterates choices in list order.
- Includes only choices where `doesChoiceMeetConditions(choice)` returns true.
When user presses numeric choice key:
1. Map key `1..9` to visible index.
2. Resolve real choice index from `visibleChoiceIndices`.
3. Apply choice reaction first (`applyDialogueReaction`).
4. Resolve target with `resolveChoiceTarget(choice)`:
- If condition passes -> `nextId`
- Else close
5. Advance to target with `advanceTo()`.
6. Apply entered-node reactions.
7. `updateQuestProgression()`.
## 7) Reactions Execution Order
`applyNodeReactions(node)`:
1. If `node.reactions` is non-empty, execute each in array order.
2. Else fallback to legacy single reaction fields.
`applyDialogueReaction(type, value)` supports:
- `grant_flag` / `grant_quest_flag`
- `grant_item` using `itemId:quantity` values such as `copper_ore:1`
- `start_quest`
- `complete_quest`
## 8) Input Execution Paths
### A) Dialogue open + no choices
- Continue key (`E` / Enter / Space):
1. Resolve condition-based next node
2. Advance or close
3. Apply entered-node reactions (if advanced)
### B) Dialogue open + choices exist
- If visible choices exist:
- Number keys choose branch
- If no visible choices:
- Continue key uses node condition-based next/close path
### C) Escape
- `Esc` closes dialogue immediately.
## 9) Rendering Order (Dialogue Box)
`renderDialogueBox()` draws:
1. Speaker bar
2. Body text from `resolveNodeConditionalText()`
3. Visible choices list (if any)
4. Footer hint:
- `[E] Continue` if condition-resolved next exists
- `[E] Close` otherwise
## 10) Flowchart
```mermaid
flowchart TD
A[Player presses E near NPC] --> B[openNpcDialogue]
B --> C[updateQuestProgression]
C --> D[Build runtime graph from content nodes]
D --> E[selectDialogueStartNodeId]
E --> F[DialogueSession.openGraph]
F --> G{Start node exists?}
G -- No --> Z[Close]
G -- Yes --> H[applyNodeReactions on start node]
H --> I[Dialogue loop]
I --> J{Current node has choices?}
J -- No --> K{Continue key pressed?}
K -- No --> I
K -- Yes --> L[resolveNodeConditionalNext via first passing condition]
L --> M{next exists?}
M -- No --> Z
M -- Yes --> N[advanceTo next]
N --> O[applyNodeReactions on entered node]
O --> I
J -- Yes --> P[buildVisibleChoiceIndices]
P --> Q{Any visible choices?}
Q -- No --> R{Continue key pressed?}
R -- No --> I
R -- Yes --> L
Q -- Yes --> S{Number key 1..9?}
S -- No --> I
S -- Yes --> T[Map visible index -> actual choice]
T --> U[apply choice reaction]
U --> V[resolveChoiceTarget]
V --> W{target exists?}
W -- No --> Z
W -- Yes --> X[advanceTo target]
X --> Y[applyNodeReactions on entered node]
Y --> I
```
## 11) Practical Authoring Implications
- Condition order is behavior-critical. Put the most specific conditions first.
- Item conditions should be authored as `itemId:quantity` so runtime quantity checks are explicit.
- In the editor, `conditionType=item` surfaces item picker + quantity input and writes `conditionValue` in that format.
- Dialogue line text should be authored on condition entries (`conditions[].text`).
- Empty `reactions[]` is valid and now preserved.
- Choice order affects both visual order and numeric selection mapping.
## 12) Primary Source Files
- `src/content/ContentManager.cpp`
- `src/game/Game.cpp`
- `src/game/Dialogue.cpp`
- `src/content/ContentTypes.hpp`
- `src/game/Dialogue.hpp`

View file

@ -1,134 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1320" viewBox="0 0 1600 1320">
<defs>
<style>
.title { font: 700 34px 'Segoe UI', Tahoma, sans-serif; fill: #0f172a; }
.subtitle { font: 500 16px 'Segoe UI', Tahoma, sans-serif; fill: #334155; }
.section { fill: #f8fbff; stroke: #94a3b8; stroke-width: 1.6; rx: 16; stroke-dasharray: 8 6; }
.node { fill: #eef4ff; stroke: #355caa; stroke-width: 2.1; rx: 14; }
.node2 { fill: #edfdf5; stroke: #2f855a; stroke-width: 2.1; rx: 14; }
.node3 { fill: #fff7ed; stroke: #c2410c; stroke-width: 2.1; rx: 14; }
.terminal { fill: #ffe8e8; stroke: #b42318; stroke-width: 2.1; rx: 20; }
.label { font: 700 18px 'Segoe UI', Tahoma, sans-serif; fill: #0f172a; }
.small { font: 500 15px 'Segoe UI', Tahoma, sans-serif; fill: #1f2937; }
.tiny { font: 500 13px 'Segoe UI', Tahoma, sans-serif; fill: #475569; }
.edge { stroke: #334155; stroke-width: 2.4; fill: none; marker-end: url(#arrowHead); }
.edge2 { stroke: #2f855a; stroke-width: 2.4; fill: none; marker-end: url(#arrowHeadGreen); }
.edge3 { stroke: #c2410c; stroke-width: 2.4; fill: none; marker-end: url(#arrowHeadOrange); }
.hint { fill: #475569; font: 600 13px 'Segoe UI', Tahoma, sans-serif; }
.pill { fill: #dbeafe; stroke: #60a5fa; stroke-width: 1.2; rx: 999; }
.pillText { font: 700 12px 'Segoe UI', Tahoma, sans-serif; fill: #1d4ed8; }
</style>
<marker id="arrowHead" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#334155" />
</marker>
<marker id="arrowHeadGreen" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#2f855a" />
</marker>
<marker id="arrowHeadOrange" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#c2410c" />
</marker>
</defs>
<rect x="18" y="18" width="1564" height="1284" fill="#ffffff" stroke="#cbd5e1" stroke-width="2" rx="20" />
<text x="800" y="64" text-anchor="middle" class="title">New RPG Map Editor Request Flow</text>
<text x="800" y="94" text-anchor="middle" class="subtitle">From clicking a map record, to editing in the popup, to saving, refreshing, and closing</text>
<rect x="70" y="132" width="1460" height="250" class="section" />
<text x="92" y="162" class="label">A) Open the popup editor</text>
<rect x="100" y="190" width="220" height="64" class="node" />
<text x="210" y="226" text-anchor="middle" class="label">Click Maps record</text>
<rect x="390" y="190" width="240" height="64" class="node" />
<text x="510" y="226" text-anchor="middle" class="label">Select Editor tab</text>
<rect x="710" y="180" width="360" height="84" class="node" />
<text x="890" y="214" text-anchor="middle" class="label">MapEditorPanels opens popup</text>
<text x="890" y="238" text-anchor="middle" class="small">buildRoomEditorHtml() injects serialized map + NPC + sprite data</text>
<rect x="1140" y="180" width="340" height="84" class="node" />
<text x="1310" y="214" text-anchor="middle" class="label">Popup boots ready</text>
<text x="1310" y="238" text-anchor="middle" class="small">layers, palette, history, NPC overlays</text>
<path d="M320 222 L390 222" class="edge" />
<path d="M630 222 L710 222" class="edge" />
<path d="M1070 222 L1140 222" class="edge" />
<rect x="70" y="414" width="1460" height="410" class="section" />
<text x="92" y="444" class="label">B) Work inside the popup</text>
<rect x="100" y="482" width="280" height="74" class="node2" />
<text x="240" y="515" text-anchor="middle" class="label">Paint tiles</text>
<text x="240" y="539" text-anchor="middle" class="small">brush palette + grouped history</text>
<rect x="430" y="482" width="280" height="74" class="node2" />
<text x="570" y="515" text-anchor="middle" class="label">Move / add NPCs</text>
<text x="570" y="539" text-anchor="middle" class="small">drag, snap, assign templates</text>
<rect x="760" y="482" width="280" height="74" class="node2" />
<text x="900" y="515" text-anchor="middle" class="label">Edit map info</text>
<text x="900" y="539" text-anchor="middle" class="small">name, width, height</text>
<rect x="1090" y="482" width="340" height="74" class="node2" />
<text x="1260" y="515" text-anchor="middle" class="label">Switch layers or maps</text>
<text x="1260" y="539" text-anchor="middle" class="small">posts map-open request to host</text>
<path d="M380 519 L430 519" class="edge2" />
<path d="M710 519 L760 519" class="edge2" />
<path d="M1040 519 L1090 519" class="edge2" />
<rect x="240" y="610" width="1120" height="140" class="node3" />
<text x="800" y="646" text-anchor="middle" class="label">Popup mutates local state first</text>
<text x="800" y="672" text-anchor="middle" class="small">selected layer, tile rows, NPC record, history, and selection state stay in memory until save or close</text>
<text x="800" y="698" text-anchor="middle" class="tiny">The host editor does not change until the popup posts a message or the API save completes.</text>
<path d="M800 556 L800 610" class="edge3" />
<rect x="70" y="852" width="1460" height="250" class="section" />
<text x="92" y="882" class="label">C) Save and synchronize</text>
<rect x="120" y="920" width="250" height="74" class="terminal" />
<text x="245" y="952" text-anchor="middle" class="label">Click Save</text>
<text x="245" y="976" text-anchor="middle" class="small">popup collects current state</text>
<rect x="430" y="910" width="330" height="94" class="node3" />
<text x="595" y="944" text-anchor="middle" class="label">POST /api/content/maps</text>
<text x="595" y="968" text-anchor="middle" class="small">writes map index + per-map files</text>
<rect x="810" y="910" width="330" height="94" class="node3" />
<text x="975" y="944" text-anchor="middle" class="label">POST /api/content/npcs</text>
<text x="975" y="968" text-anchor="middle" class="small">writes map-local NPC instances</text>
<rect x="1190" y="920" width="250" height="74" class="node3" />
<text x="1315" y="952" text-anchor="middle" class="label">Notify host</text>
<text x="1315" y="976" text-anchor="middle" class="small">map-editor-saved</text>
<path d="M370 957 L430 957" class="edge3" />
<path d="M760 957 L810 957" class="edge3" />
<path d="M1140 957 L1190 957" class="edge3" />
<rect x="70" y="1124" width="1460" height="160" class="section" />
<text x="92" y="1154" class="label">D) Close or change map</text>
<rect x="110" y="1188" width="320" height="66" class="terminal" />
<text x="270" y="1220" text-anchor="middle" class="label">Leave Editor tab / close popup</text>
<rect x="490" y="1188" width="360" height="66" class="node" />
<text x="670" y="1220" text-anchor="middle" class="label">Host closes popup and revokes blob URL</text>
<rect x="910" y="1188" width="300" height="66" class="terminal" />
<text x="1060" y="1216" text-anchor="middle" class="label">Switch map in popup</text>
<text x="1060" y="1237" text-anchor="middle" class="tiny">map-editor-open-map</text>
<rect x="1250" y="1188" width="240" height="66" class="node" />
<text x="1370" y="1220" text-anchor="middle" class="label">Host reloads data</text>
<path d="M1220 1188 C1100 1140, 920 1138, 670 1188" class="edge" />
<path d="M430 1221 L490 1221" class="edge" />
<path d="M1210 1221 L1250 1221" class="edge" />
<rect x="120" y="104" width="250" height="20" class="pill" />
<text x="245" y="118" text-anchor="middle" class="pillText">Popup-owned workflow</text>
<rect x="1200" y="104" width="220" height="20" class="pill" />
<text x="1310" y="118" text-anchor="middle" class="pillText">Host sync + save callbacks</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -1,550 +0,0 @@
# Open World Chunking V1
This document proposes a concrete v1 data model and editor/runtime architecture for replacing the current finite `map` model with a streamed open world.
The goal is not "fake endless maps glued together." The goal is:
- one continuous world coordinate space
- chunked storage and loading
- seamless editing across chunk boundaries
- sparse height overrides
- chunk-local save/write operations
- renderer and editor surfaces that only load and draw what is nearby
## Plain-English Model
Think of the world as graph paper that goes on forever.
- The player and editor work in one global coordinate space: `(worldX, worldY)`.
- That graph paper is cut into fixed-size square sheets called `chunks`.
- Each chunk stores only the tiles, height patches, and instances that belong to that square.
- The game and editor only load the nearby chunks.
The user should never feel like they are "switching maps." They should feel like they are scrolling around one world.
## V1 Recommendation
V1 should introduce a new top-level concept: `world`.
Current:
- `maps.json`
- one map entry owns rows, layers, height layers, instances
Proposed:
- `worlds.json`
- one world entry defines chunk rules and metadata
- chunk files hold actual terrain/layer/instance data
Keep support for multiple worlds. Even in an "open world" game, multiple worlds are still useful for:
- overworld
- interiors or special dimensions
- test worlds
- developer sandboxes
## Goals
- Seamless editing across arbitrary world coordinates
- Automatic chunk creation when painting or placing far away
- No giant monolithic world file for tile data
- Chunk-local save/load for performance
- Reuse current room-layer and height-patch ideas where possible
- Replace minimap with world navigation, coordinates, and bookmarks
## Non-Goals For V1
- Infinite procedural terrain generation
- Networked world streaming
- Region compression or advanced binary storage
- Fully general LOD terrain synthesis
- Rewriting the whole engine to 3D or voxel storage
## Chosen Chunk Size
The chosen v1 chunk size is `32x32`.
Reason:
- simpler mental math in tooling and code
- slightly better fit for common viewport batching
- cleaner chunk index math
- easier future optimization
`25x25` would also work, but v1 should standardize on `32x32` and avoid making chunk size configurable until the new architecture is stable.
## Coordinate Model
The world uses two coordinate spaces:
1. World coordinates
- Absolute tile coordinates in the world
- Example: `(73, -12)`
2. Chunk-local coordinates
- Tile coordinates within a chunk
- Example: chunk `(2, -1)` local tile `(9, 20)`
Conversion:
```txt
chunkX = floor(worldX / chunkWidth)
chunkY = floor(worldY / chunkHeight)
localX = worldX - (chunkX * chunkWidth)
localY = worldY - (chunkY * chunkHeight)
```
This is the core of the whole system.
## Proposed File Structure
```txt
content/
worlds.json
worlds/
overworld/
world.json
bookmarks.json
chunks/
0_0.json
1_0.json
0_1.json
-1_0.json
```
### `content/worlds.json`
Index of available worlds.
```json
{
"schemaVersion": 1,
"worlds": [
{
"id": "overworld",
"name": "Overworld",
"worldDir": "worlds/overworld"
}
]
}
```
### `content/worlds/overworld/world.json`
World-level metadata only.
```json
{
"schemaVersion": 1,
"id": "overworld",
"name": "Overworld",
"chunkWidth": 32,
"chunkHeight": 32,
"tileSize": 32,
"defaultBackgroundTileId": "tile_grass_01",
"spawn": { "x": 0, "y": 0 },
"editor": {
"defaultZoom": 1,
"gridVisible": true
}
}
```
### `content/worlds/overworld/bookmarks.json`
Replaces the current minimap's "jump around the finite map" role with saved navigation targets.
```json
{
"schemaVersion": 1,
"worldId": "overworld",
"bookmarks": [
{ "id": "town_center", "label": "Town Center", "x": 120, "y": 84 },
{ "id": "north_tower", "label": "North Tower", "x": 145, "y": 32 }
]
}
```
### Chunk file
```json
{
"schemaVersion": 1,
"worldId": "overworld",
"chunkX": 2,
"chunkY": -1,
"width": 32,
"height": 32,
"backgroundTileId": "tile_grass_01",
"roomLayers": [
{
"layer": 0,
"name": "Ground",
"rows": ["................................", "..."],
"instanceIds": []
},
{
"layer": 1,
"name": "Walls",
"rows": [" ", "..."],
"instanceIds": ["inst_gatekeeper_001"]
}
],
"heightLayers": [
{
"id": "height_001",
"name": "Tower Level 1",
"z": 1,
"x": 10,
"y": 8,
"rows": [
" 4444 ",
" 4 4 ",
" 4444 "
]
}
],
"instances": [
{
"id": "inst_gatekeeper_001",
"templateId": "npc_gatekeeper_bubbles",
"layer": 1,
"x": 12,
"y": 9,
"record": {
"name": "Bubbles",
"spriteId": "npc_human_style_13",
"faction": "dangerous_gatekeeper"
}
}
]
}
```
## Why This Structure
This splits data by ownership:
- `world.json`: rules and metadata
- `bookmarks.json`: editor navigation helpers
- chunk files: actual editable world content
That keeps save operations local and avoids turning one file into a bottleneck.
## Editor Data Model
The editor runtime should stop treating one "map" as the active document.
Instead it should have:
```ts
type OpenWorldSession = {
worldId: string;
chunkWidth: number;
chunkHeight: number;
tileSize: number;
cameraWorldX: number;
cameraWorldY: number;
loadedChunks: Record<string, WorldChunk>;
dirtyChunks: Record<string, boolean>;
activeLayer: number;
activeHeightLayerId: string;
editingTargetKind: "room" | "height";
bookmarks: WorldBookmark[];
};
```
The main change is that the editor should think in `world coordinates` first and `chunk ownership` second.
## Editing Rules
### Painting tiles
When the user paints:
1. Convert pointer position to `(worldX, worldY)`
2. Resolve chunk and local coordinate
3. Create chunk if it does not exist
4. Edit the right chunk layer row
5. Mark that chunk dirty
6. Patch only the visible surfaces touched by the stroke
If a rectangle, circle, or fill crosses chunk boundaries, the brush simply touches multiple chunks in one action.
### Placing instances
When the user drops an instance:
1. Resolve world tile
2. Resolve chunk
3. Create chunk if missing
4. Add instance to that chunk
5. Mark chunk dirty
### Painting far away
Yes, the editor should create all needed empty chunks automatically if the user paints or places content in a region that has never been created before.
It should not create a giant range of intermediate chunks just because the camera moved there. Chunks should be created lazily:
- create on first edit
- optionally create on explicit "stamp empty region" tooling
## Runtime Load Model
The game should not load the whole world.
Instead:
1. Determine the player's current chunk
2. Load a chunk radius around the player
3. Keep nearby chunks hot
4. Unload distant chunks
Example:
- player is in chunk `(10, 4)`
- active chunk radius is `1`
- load chunks from `(9..11, 3..5)`
That gives a `3x3` neighborhood.
This is the same core idea the editor should use for panning and visible drawing.
## Renderer Model
The renderer should operate in visible chunk slices, not full-world arrays.
Suggested v1 pipeline:
1. Determine visible world tile bounds from camera
2. Determine which chunks intersect that area
3. Ensure those chunks are loaded
4. Draw visible base layers from those chunks
5. Apply active height patches for current Z
6. Draw instances
7. Draw overlays and cursors
## Surface Caching
The current tile-surface work already points in the right direction.
V1 should move to:
- one cached surface per visible chunk layer
- one cached patch surface per height patch
- redraw only invalidated chunks or patches
That is much better than treating the whole world as one giant canvas.
## Height Layer Model In Open World
The current direction of sparse patch-based height overrides is still the right one.
V1 should keep height layers separate from ordinary draw layers.
Normal chunk data:
- base room layers define the world at Z0
Height patch data:
- sparse overrides define what appears at higher Z values
When the player goes up a ladder:
- current Z changes
- renderer continues drawing visible Z0 world
- renderer applies any visible patch data matching that Z
This is exactly the kind of thing chunking helps with, because only nearby patches need to be considered.
## Instance Model
Instances should remain data-driven and chunk-owned.
Recommended v1 storage:
- store instances inside the chunk that owns their tile origin
- keep instance `x` and `y` local to the chunk file
- derive world position in memory
Alternative:
- store world `x` and `y` directly in the chunk file
Either can work. For consistency with chunk-local tile rows, chunk-local instance positions are cleaner on disk.
Example:
```json
{
"id": "inst_blacksmith_001",
"templateId": "npc_blacksmith",
"layer": 1,
"x": 18,
"y": 7,
"record": {
"name": "Mera"
}
}
```
## World Navigation UI
The current minimap should be replaced.
Recommended v1 navigation tools:
- current world coordinate display
- jump to X/Y
- bookmarks / waypoints
- "center on player"
- chunk grid overlay toggle
- optional chunk overview panel showing loaded and dirty chunks
This is much more useful for an open world than a single minimap squeezed into a corner.
## Save Model
The editor should save only dirty chunks and world metadata.
Recommended split:
- save `world.json` only when world-level settings change
- save `bookmarks.json` only when bookmarks change
- save only touched chunk files when content changes
This is the core performance and safety advantage.
## Undo / History Model
History can no longer assume one finite map snapshot as the main unit.
Recommended v1 history operation format:
```ts
type ChunkEditOperation = {
type: "chunk_edit";
chunkKey: string;
before: Partial<WorldChunk>;
after: Partial<WorldChunk>;
};
```
For multi-chunk brushes:
```ts
type MultiChunkEditOperation = {
type: "multi_chunk_edit";
chunks: ChunkEditOperation[];
};
```
This is important because world editing will regularly cross chunk boundaries.
## API Shape
Suggested API direction:
- `GET /api/worlds`
- `GET /api/world/:worldId`
- `GET /api/world/:worldId/bookmarks`
- `GET /api/world/:worldId/chunks?x=10&y=4&radius=1`
- `POST /api/world/:worldId/chunk/:chunkX/:chunkY`
- `POST /api/world/:worldId/chunks/batch-save`
- `POST /api/world/:worldId/bookmarks`
The important change is that APIs become chunk-aware instead of map-aware.
## Migration From Current Map Model
The easiest migration path is not "rewrite everything at once."
Recommended migration steps:
1. Introduce `world` and `chunk` data structures alongside current maps
2. Build an importer that converts one existing map into one or more chunks
3. Keep existing tile/layer/height patch formats as similar as possible
4. Build a separate open-world editor mode first
5. Retire the old map model only after the new one is stable
For migration:
- one `100x100` map can become `4x4` chunks at `25x25`
- or `4x4` chunks at `32x32` with padded edges
## What Must Be Rewritten
This is a major architecture change. The following systems would need meaningful refactors:
- popup bootstrap and document loading
- editor runtime state
- renderer surface caching
- minimap/navigation UI
- history model
- persistence API
- map switcher UX
The following ideas can be reused:
- tile symbol rendering
- tile/instance palettes
- sparse height patch logic
- layer editing concepts
- chunk/surface invalidation patterns
## V1 Risks
- Trying to preserve too much of the finite-map abstraction
- Storing too much redundant empty chunk data
- Keeping full-world snapshots in history
- Letting chunk creation happen implicitly on camera movement
- Rebuilding all visible chunk surfaces too often
## Recommended V1 Boundaries
To keep this realistic, v1 should do only this:
- one open world
- fixed chunk size
- chunk-local room layers
- sparse chunk-local height patches
- chunk-owned instances
- bookmarks instead of minimap
- batch save of dirty chunks
- visible-chunk-only rendering
That is already a large and meaningful overhaul.
## Implementation Order
The safest order is:
1. Add new world/chunk schemas and API routes without removing current map routes
2. Build a converter that turns one current map into one chunked world
3. Add a chunk-aware loader in parallel with the current popup bootstrap
4. Replace the popup minimap with coordinates, bookmarks, and jump controls
5. Convert rendering from one-map surfaces to per-chunk surfaces
6. Convert tile painting, height painting, and instance placement to world-coordinate edits
7. Convert save/history to batch dirty chunks
8. Only then remove the old finite-map assumptions
## Final Recommendation
This direction makes sense.
The strongest version of it is:
- stop thinking in "many maps stitched together"
- start thinking in "one world partitioned into chunks"
That keeps the user experience seamless while keeping storage and rendering manageable.
If we choose to proceed, the next best step is:
1. finalize the exact chunk JSON schema
2. build the map-to-world importer
3. add a parallel open-world editor path before deleting the current map editor

View file

@ -1,255 +0,0 @@
# VPS Deployment
This project is much easier to manage if we stop treating your desktop folder as the "deployment artifact" and instead treat the app like a normal repository with a normal deploy target.
## Recommended Setup
Use three directories on the VPS:
```txt
/srv/content-editor-v2/
repo.git/ # bare git repo, receives pushes
app/ # checked out working tree, built + run from here
shared/
content/ # persistent content, survives redeploys
```
Why this shape works:
- `repo.git` is the deployment remote.
- `app/` is the live checkout your process manager runs.
- `shared/content/` keeps your authored data outside the release tree.
That means redeploying code does not overwrite your content.
## Best Fit For This Project
This app is not just a static front-end build. It also has:
- `server.js` for the API
- `content/` for writable data
- `docs/` for wiki assets
- `dist/` for the built front-end
Because of that, the cleanest production model is:
1. Run `server.js` on the VPS from a stable app directory.
2. Let `server.js` serve `dist/` and `/wiki`.
3. Put Nginx or your hosting panel in front of it as a reverse proxy.
That is better than manually uploading a folder to a public web root.
## One-Time VPS Setup
Run these on the VPS.
### 1. Create directories
```bash
sudo mkdir -p /srv/content-editor-v2/repo.git
sudo mkdir -p /srv/content-editor-v2/app
sudo mkdir -p /srv/content-editor-v2/shared/content
```
### 2. Initialize the bare repo
```bash
cd /srv/content-editor-v2/repo.git
git init --bare
```
### 3. Create the live checkout
```bash
git --work-tree=/srv/content-editor-v2/app --git-dir=/srv/content-editor-v2/repo.git checkout -f
```
If this is the first time and there is no pushed branch yet, that checkout will not fully populate until the first push.
### 4. Keep content outside the app tree
This project already supports `CONTENT_ROOT`.
Production should use:
```bash
export CONTENT_ROOT=/srv/content-editor-v2/shared/content
```
If you already have good content locally, copy it once:
```bash
cp -R /srv/content-editor-v2/app/content/. /srv/content-editor-v2/shared/content/
```
### 5. Install Node dependencies in the live app dir
```bash
cd /srv/content-editor-v2/app
npm install
```
### 6. Add a process manager
PM2 is the easiest option:
```bash
npm install -g pm2
```
Then start the app:
```bash
cd /srv/content-editor-v2/app
CONTENT_ROOT=/srv/content-editor-v2/shared/content PORT=5180 pm2 start server.js --name content-editor-v2
pm2 save
```
## Automatic Deploy On Push
The cleanest version is a `post-receive` hook in the bare repo.
Create:
```txt
/srv/content-editor-v2/repo.git/hooks/post-receive
```
Use this:
```bash
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/srv/content-editor-v2/app"
GIT_DIR="/srv/content-editor-v2/repo.git"
CONTENT_ROOT="/srv/content-editor-v2/shared/content"
PORT="5180"
echo "[deploy] checking out latest code"
git --work-tree="$APP_DIR" --git-dir="$GIT_DIR" checkout -f
cd "$APP_DIR"
echo "[deploy] installing dependencies"
npm install
echo "[deploy] validating content"
npm run validate:content
echo "[deploy] building"
npm run build
echo "[deploy] reloading app"
CONTENT_ROOT="$CONTENT_ROOT" PORT="$PORT" pm2 restart content-editor-v2 || \
CONTENT_ROOT="$CONTENT_ROOT" PORT="$PORT" pm2 start server.js --name content-editor-v2
```
Make it executable:
```bash
chmod +x /srv/content-editor-v2/repo.git/hooks/post-receive
```
## Local Machine Setup
On your home computer, initialize git in this project if you have not already:
```powershell
git init
git add .
git commit -m "Initial project import"
```
Add the VPS as a remote:
```powershell
git remote add vps ssh://YOUR_USER@YOUR_HOST:/srv/content-editor-v2/repo.git
```
Then deploy with:
```powershell
git push vps master
```
Or `main`, if that is your branch name.
## Windows-Friendly Deploy Helper
This repo includes a PowerShell helper:
```powershell
.\scripts\deploy-vps.ps1 -Remote vps -Branch master
```
What it does:
1. Runs `npm run validate:content`
2. Runs `npm run build`
3. Pushes your branch to the chosen remote
That means your local build fails before a bad deploy reaches the server.
## Reverse Proxy
If your VPS uses Nginx, proxy your public domain to the Node app:
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:5180;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## If You Really Need A Separate Hosted Directory
If your panel requires a specific public directory, there are two workable options:
### Option A: still run Node, proxy to it
Best option. Keep the app in `/srv/content-editor-v2/app` and point the panel or reverse proxy at the Node port.
### Option B: copy only `dist/` into a public web root
This works only if you separate the API and front-end hosting model. For this project today, that is not the clean default because `server.js` also serves static files and wiki docs.
## Recommended Workflow
Day to day:
1. Work locally in a normal git repo.
2. Commit changes.
3. Run `.\scripts\deploy-vps.ps1`.
4. Let the VPS hook pull, build, validate, and restart.
That replaces:
- random desktop folder
- manual upload
- dragging files through a VPS file manager
- wondering what version is actually live
## What I Recommend You Do Next
1. Initialize this folder as a git repo.
2. Set up the bare repo + app + shared content structure on the VPS.
3. Put the `post-receive` hook in place.
4. Start the app under PM2.
5. Switch deployment to `git push vps <branch>`.
Once you want, we can also add:
- a production `.env` loader
- branch-based staging vs production deploys
- backup/restore scripts for `content/`
- a one-command VPS bootstrap script

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff