2342 lines
91 KiB
HTML
2342 lines
91 KiB
HTML
|
|
<!doctype html>
|
||
|
|
<html lang="en" data-theme="azure">
|
||
|
|
<head>
|
||
|
|
<meta charset="utf-8" />
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
|
|
<title>Dialogue Graph Builder Prototype</title>
|
||
|
|
<style>
|
||
|
|
:root {
|
||
|
|
color-scheme: dark;
|
||
|
|
--editor-shell-bg: #0A1020;
|
||
|
|
--editor-shell-fg: #D8E8FF;
|
||
|
|
--editor-menu-grad-1: #152645;
|
||
|
|
--editor-menu-grad-2: #10203C;
|
||
|
|
--editor-sidebar-bg: #0E1A33;
|
||
|
|
--editor-stage-bg: #060A14;
|
||
|
|
--editor-border: #2E426C;
|
||
|
|
--editor-border-strong: #3C5E95;
|
||
|
|
--editor-panel-bg: #121F3B;
|
||
|
|
--editor-panel-bg-alt: #132B4F;
|
||
|
|
--editor-panel-bg-elevated: #10284B;
|
||
|
|
--editor-panel-bg-hover: #1A3F6D;
|
||
|
|
--editor-control-bg: #1A345E;
|
||
|
|
--editor-control-bg-hover: #214679;
|
||
|
|
--editor-control-bg-active: #1E4B82;
|
||
|
|
--editor-control-border: #3C5E95;
|
||
|
|
--editor-control-fg: #D6E7FF;
|
||
|
|
--editor-muted: #9FB8E5;
|
||
|
|
--editor-muted-strong: #CFE2FF;
|
||
|
|
--editor-accent: #64AAF8;
|
||
|
|
--editor-accent-strong: #8FD0FF;
|
||
|
|
--editor-accent-soft: #22466E;
|
||
|
|
--editor-warn: #FFD166;
|
||
|
|
--editor-danger: #3C1A1A;
|
||
|
|
--editor-danger-border: #7F4C4C;
|
||
|
|
--editor-danger-hover: #5A2323;
|
||
|
|
--editor-status-ok: #B9CFEF;
|
||
|
|
--editor-status-error: #FF9E9E;
|
||
|
|
--editor-tab-shadow: rgba(3, 8, 18, 0.8);
|
||
|
|
}
|
||
|
|
|
||
|
|
html[data-theme="verdant"] {
|
||
|
|
--editor-shell-bg: #081510;
|
||
|
|
--editor-shell-fg: #DDF7EA;
|
||
|
|
--editor-menu-grad-1: #16352B;
|
||
|
|
--editor-menu-grad-2: #0E251E;
|
||
|
|
--editor-sidebar-bg: #0D221B;
|
||
|
|
--editor-stage-bg: #06100D;
|
||
|
|
--editor-border: #305847;
|
||
|
|
--editor-border-strong: #46806A;
|
||
|
|
--editor-panel-bg: #122A22;
|
||
|
|
--editor-panel-bg-alt: #17362C;
|
||
|
|
--editor-panel-bg-elevated: #133127;
|
||
|
|
--editor-panel-bg-hover: #21503F;
|
||
|
|
--editor-control-bg: #1B4335;
|
||
|
|
--editor-control-bg-hover: #245844;
|
||
|
|
--editor-control-bg-active: #2D7258;
|
||
|
|
--editor-control-border: #46806A;
|
||
|
|
--editor-control-fg: #DDF7EA;
|
||
|
|
--editor-muted: #A5D2BE;
|
||
|
|
--editor-muted-strong: #D2F0E0;
|
||
|
|
--editor-accent: #70D8A6;
|
||
|
|
--editor-accent-strong: #8AE8BE;
|
||
|
|
--editor-accent-soft: #275845;
|
||
|
|
--editor-warn: #F5D66D;
|
||
|
|
--editor-danger: #472123;
|
||
|
|
--editor-danger-border: #8B5559;
|
||
|
|
--editor-danger-hover: #633034;
|
||
|
|
--editor-status-ok: #C0E9D5;
|
||
|
|
--editor-status-error: #FFADAD;
|
||
|
|
--editor-tab-shadow: rgba(2, 10, 8, 0.76);
|
||
|
|
}
|
||
|
|
|
||
|
|
html[data-theme="ember"] {
|
||
|
|
--editor-shell-bg: #160C0B;
|
||
|
|
--editor-shell-fg: #FFE8D9;
|
||
|
|
--editor-menu-grad-1: #432018;
|
||
|
|
--editor-menu-grad-2: #24110F;
|
||
|
|
--editor-sidebar-bg: #21110E;
|
||
|
|
--editor-stage-bg: #100706;
|
||
|
|
--editor-border: #6A3B33;
|
||
|
|
--editor-border-strong: #9A5A4D;
|
||
|
|
--editor-panel-bg: #311A16;
|
||
|
|
--editor-panel-bg-alt: #3F211B;
|
||
|
|
--editor-panel-bg-elevated: #341B17;
|
||
|
|
--editor-panel-bg-hover: #5A2E26;
|
||
|
|
--editor-control-bg: #4A261F;
|
||
|
|
--editor-control-bg-hover: #67352B;
|
||
|
|
--editor-control-bg-active: #8B4937;
|
||
|
|
--editor-control-border: #9A5A4D;
|
||
|
|
--editor-control-fg: #FFE8D9;
|
||
|
|
--editor-muted: #E2B6A2;
|
||
|
|
--editor-muted-strong: #FFE0CF;
|
||
|
|
--editor-accent: #FFB36C;
|
||
|
|
--editor-accent-strong: #FFD08E;
|
||
|
|
--editor-accent-soft: #684133;
|
||
|
|
--editor-warn: #FFE17A;
|
||
|
|
--editor-danger: #512225;
|
||
|
|
--editor-danger-border: #A16063;
|
||
|
|
--editor-danger-hover: #6A2E32;
|
||
|
|
--editor-status-ok: #F6C8AF;
|
||
|
|
--editor-status-error: #FFB1A3;
|
||
|
|
--editor-tab-shadow: rgba(16, 6, 2, 0.76);
|
||
|
|
}
|
||
|
|
|
||
|
|
html[data-theme="amethyst"] {
|
||
|
|
--editor-shell-bg: #0F0B19;
|
||
|
|
--editor-shell-fg: #F0E6FF;
|
||
|
|
--editor-menu-grad-1: #2A1F45;
|
||
|
|
--editor-menu-grad-2: #171125;
|
||
|
|
--editor-sidebar-bg: #17112A;
|
||
|
|
--editor-stage-bg: #0A0712;
|
||
|
|
--editor-border: #4E4474;
|
||
|
|
--editor-border-strong: #7662A9;
|
||
|
|
--editor-panel-bg: #211A39;
|
||
|
|
--editor-panel-bg-alt: #2A2149;
|
||
|
|
--editor-panel-bg-elevated: #241D41;
|
||
|
|
--editor-panel-bg-hover: #3B3066;
|
||
|
|
--editor-control-bg: #35295D;
|
||
|
|
--editor-control-bg-hover: #473678;
|
||
|
|
--editor-control-bg-active: #5B4594;
|
||
|
|
--editor-control-border: #7662A9;
|
||
|
|
--editor-control-fg: #F0E6FF;
|
||
|
|
--editor-muted: #C6B3E6;
|
||
|
|
--editor-muted-strong: #E5D9FF;
|
||
|
|
--editor-accent: #C38BFF;
|
||
|
|
--editor-accent-strong: #DDB5FF;
|
||
|
|
--editor-accent-soft: #493C72;
|
||
|
|
--editor-warn: #F7D37E;
|
||
|
|
--editor-danger: #4A213F;
|
||
|
|
--editor-danger-border: #935C8A;
|
||
|
|
--editor-danger-hover: #632D56;
|
||
|
|
--editor-status-ok: #D9C5FF;
|
||
|
|
--editor-status-error: #FFB6DE;
|
||
|
|
--editor-tab-shadow: rgba(10, 6, 20, 0.78);
|
||
|
|
}
|
||
|
|
|
||
|
|
* {
|
||
|
|
box-sizing: border-box;
|
||
|
|
}
|
||
|
|
|
||
|
|
html,
|
||
|
|
body {
|
||
|
|
width: 100%;
|
||
|
|
height: 100%;
|
||
|
|
margin: 0;
|
||
|
|
overflow: hidden;
|
||
|
|
background: var(--editor-shell-bg);
|
||
|
|
color: var(--editor-shell-fg);
|
||
|
|
font-family: "Segoe UI", Arial, sans-serif;
|
||
|
|
font-size: 13px;
|
||
|
|
}
|
||
|
|
|
||
|
|
button,
|
||
|
|
input,
|
||
|
|
select,
|
||
|
|
textarea {
|
||
|
|
font: inherit;
|
||
|
|
}
|
||
|
|
|
||
|
|
button {
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.shell {
|
||
|
|
display: grid;
|
||
|
|
grid-template-rows: 40px minmax(0, 1fr);
|
||
|
|
width: 100vw;
|
||
|
|
height: 100vh;
|
||
|
|
background: var(--editor-shell-bg);
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-bar {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: minmax(260px, 1fr) auto minmax(260px, 1fr);
|
||
|
|
align-items: center;
|
||
|
|
gap: 10px;
|
||
|
|
padding: 4px 8px;
|
||
|
|
border-bottom: 1px solid var(--editor-border-strong);
|
||
|
|
background: linear-gradient(180deg, var(--editor-menu-grad-1), var(--editor-menu-grad-2));
|
||
|
|
box-shadow: 0 8px 18px var(--editor-tab-shadow);
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-left,
|
||
|
|
.menu-right {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-right {
|
||
|
|
justify-content: flex-end;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-title {
|
||
|
|
font-size: 13px;
|
||
|
|
font-weight: 800;
|
||
|
|
color: var(--editor-muted-strong);
|
||
|
|
text-align: center;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-btn,
|
||
|
|
.small-btn,
|
||
|
|
.choice-btn {
|
||
|
|
border: 1px solid var(--editor-control-border);
|
||
|
|
border-radius: 7px;
|
||
|
|
color: var(--editor-control-fg);
|
||
|
|
background: linear-gradient(180deg, var(--editor-control-bg-hover), var(--editor-control-bg));
|
||
|
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-btn {
|
||
|
|
min-height: 30px;
|
||
|
|
padding: 5px 11px;
|
||
|
|
font-weight: 700;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-btn:hover,
|
||
|
|
.small-btn:hover,
|
||
|
|
.choice-btn:hover {
|
||
|
|
background: linear-gradient(180deg, var(--editor-control-bg-active), var(--editor-control-bg-hover));
|
||
|
|
}
|
||
|
|
|
||
|
|
.theme-preset-bar {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(4, 40px);
|
||
|
|
justify-content: end;
|
||
|
|
gap: 9px;
|
||
|
|
margin-left: 8px;
|
||
|
|
min-width: 187px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.theme-preset-btn {
|
||
|
|
display: grid;
|
||
|
|
place-items: center;
|
||
|
|
width: 36px;
|
||
|
|
height: 36px;
|
||
|
|
border: 0;
|
||
|
|
border-radius: 8px;
|
||
|
|
background: transparent;
|
||
|
|
padding: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.theme-preset-swatch {
|
||
|
|
position: relative;
|
||
|
|
width: 30px;
|
||
|
|
height: 30px;
|
||
|
|
border: 2px solid var(--editor-control-border);
|
||
|
|
border-radius: 8px;
|
||
|
|
background:
|
||
|
|
linear-gradient(135deg, var(--theme-swatch-a) 0 50%, var(--theme-swatch-b) 50% 100%);
|
||
|
|
transition: width 120ms ease, height 120ms ease, border-color 120ms ease;
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
.theme-preset-swatch::before,
|
||
|
|
.theme-preset-swatch::after {
|
||
|
|
content: "";
|
||
|
|
position: absolute;
|
||
|
|
inset: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.theme-preset-swatch::before {
|
||
|
|
background: linear-gradient(135deg, transparent 0 50%, var(--theme-swatch-c) 50% 75%, transparent 75% 100%);
|
||
|
|
}
|
||
|
|
|
||
|
|
.theme-preset-swatch::after {
|
||
|
|
background: linear-gradient(315deg, transparent 0 52%, var(--theme-swatch-d) 52% 100%);
|
||
|
|
opacity: 0.9;
|
||
|
|
}
|
||
|
|
|
||
|
|
.theme-preset-btn.active .theme-preset-swatch {
|
||
|
|
width: 36px;
|
||
|
|
height: 36px;
|
||
|
|
border-color: var(--editor-accent-strong);
|
||
|
|
}
|
||
|
|
|
||
|
|
.workspace {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 325px minmax(0, 1.25fr) minmax(390px, 0.8fr);
|
||
|
|
min-height: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.sidebar,
|
||
|
|
.stage,
|
||
|
|
.inspector {
|
||
|
|
min-height: 0;
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
.sidebar {
|
||
|
|
display: grid;
|
||
|
|
grid-template-rows: auto minmax(0, 1fr);
|
||
|
|
border-right: 1px solid var(--editor-border-strong);
|
||
|
|
background: var(--editor-sidebar-bg);
|
||
|
|
}
|
||
|
|
|
||
|
|
.sidebar-head,
|
||
|
|
.inspector-head {
|
||
|
|
padding: 10px;
|
||
|
|
border-bottom: 1px solid var(--editor-border);
|
||
|
|
background: linear-gradient(180deg, var(--editor-panel-bg), var(--editor-sidebar-bg));
|
||
|
|
}
|
||
|
|
|
||
|
|
.eyebrow {
|
||
|
|
margin: 0 0 3px;
|
||
|
|
color: var(--editor-accent-strong);
|
||
|
|
font-size: 11px;
|
||
|
|
font-weight: 800;
|
||
|
|
letter-spacing: 0.08em;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
|
||
|
|
h1,
|
||
|
|
h2,
|
||
|
|
h3,
|
||
|
|
p {
|
||
|
|
margin-top: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
h1 {
|
||
|
|
margin-bottom: 4px;
|
||
|
|
font-size: 20px;
|
||
|
|
line-height: 1.1;
|
||
|
|
}
|
||
|
|
|
||
|
|
h2 {
|
||
|
|
margin-bottom: 8px;
|
||
|
|
font-size: 15px;
|
||
|
|
color: var(--editor-muted-strong);
|
||
|
|
}
|
||
|
|
|
||
|
|
h3 {
|
||
|
|
margin: 0;
|
||
|
|
font-size: 13px;
|
||
|
|
color: var(--editor-muted-strong);
|
||
|
|
}
|
||
|
|
|
||
|
|
.hint {
|
||
|
|
color: var(--editor-muted);
|
||
|
|
line-height: 1.4;
|
||
|
|
}
|
||
|
|
|
||
|
|
.palette-scroll,
|
||
|
|
.builder-scroll,
|
||
|
|
.inspector-scroll {
|
||
|
|
min-height: 0;
|
||
|
|
overflow: auto;
|
||
|
|
scrollbar-gutter: stable;
|
||
|
|
}
|
||
|
|
|
||
|
|
.palette-scroll {
|
||
|
|
padding: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.palette-section,
|
||
|
|
.panel,
|
||
|
|
.node-card,
|
||
|
|
.branch-card,
|
||
|
|
.choice-card {
|
||
|
|
border: 1px solid var(--editor-border);
|
||
|
|
border-radius: 8px;
|
||
|
|
background: var(--editor-panel-bg);
|
||
|
|
}
|
||
|
|
|
||
|
|
.palette-section {
|
||
|
|
margin-bottom: 10px;
|
||
|
|
padding: 9px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.palette-section h2 {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: space-between;
|
||
|
|
gap: 8px;
|
||
|
|
margin: 0 0 8px;
|
||
|
|
padding-bottom: 7px;
|
||
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||
|
|
}
|
||
|
|
|
||
|
|
.block-grid {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
gap: 7px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.block-button {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 24px 1fr;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
min-height: 38px;
|
||
|
|
width: 100%;
|
||
|
|
border: 1px solid var(--editor-control-border);
|
||
|
|
border-radius: 7px;
|
||
|
|
padding: 6px 8px;
|
||
|
|
text-align: left;
|
||
|
|
color: var(--editor-control-fg);
|
||
|
|
background: linear-gradient(180deg, var(--editor-control-bg), var(--editor-panel-bg-alt));
|
||
|
|
}
|
||
|
|
|
||
|
|
.block-button:hover {
|
||
|
|
background: linear-gradient(180deg, var(--editor-control-bg-hover), var(--editor-control-bg));
|
||
|
|
}
|
||
|
|
|
||
|
|
.block-icon {
|
||
|
|
display: grid;
|
||
|
|
place-items: center;
|
||
|
|
width: 24px;
|
||
|
|
height: 24px;
|
||
|
|
border-radius: 6px;
|
||
|
|
color: var(--editor-shell-bg);
|
||
|
|
background: var(--editor-accent);
|
||
|
|
font-weight: 900;
|
||
|
|
}
|
||
|
|
|
||
|
|
.block-meta {
|
||
|
|
display: block;
|
||
|
|
color: var(--editor-muted);
|
||
|
|
font-size: 11px;
|
||
|
|
line-height: 1.2;
|
||
|
|
}
|
||
|
|
|
||
|
|
.stage {
|
||
|
|
display: grid;
|
||
|
|
grid-template-rows: 32px minmax(0, 1fr);
|
||
|
|
background: var(--editor-stage-bg);
|
||
|
|
}
|
||
|
|
|
||
|
|
.meta {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: space-between;
|
||
|
|
gap: 10px;
|
||
|
|
padding: 6px 10px;
|
||
|
|
border-bottom: 1px solid var(--editor-border);
|
||
|
|
color: var(--editor-muted-strong);
|
||
|
|
background: #020611;
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.builder-scroll {
|
||
|
|
padding: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.builder-layout {
|
||
|
|
display: grid;
|
||
|
|
gap: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.panel {
|
||
|
|
padding: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.dialogue-fields {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: minmax(160px, 0.7fr) minmax(220px, 1fr) minmax(180px, 0.7fr);
|
||
|
|
gap: 8px;
|
||
|
|
align-items: end;
|
||
|
|
}
|
||
|
|
|
||
|
|
label {
|
||
|
|
display: grid;
|
||
|
|
gap: 4px;
|
||
|
|
color: var(--editor-muted);
|
||
|
|
font-size: 11px;
|
||
|
|
font-weight: 700;
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.04em;
|
||
|
|
}
|
||
|
|
|
||
|
|
input,
|
||
|
|
select,
|
||
|
|
textarea {
|
||
|
|
width: 100%;
|
||
|
|
border: 1px solid var(--editor-control-border);
|
||
|
|
border-radius: 7px;
|
||
|
|
color: var(--editor-control-fg);
|
||
|
|
background: var(--editor-stage-bg);
|
||
|
|
padding: 7px 8px;
|
||
|
|
outline: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
textarea {
|
||
|
|
resize: vertical;
|
||
|
|
min-height: 58px;
|
||
|
|
line-height: 1.4;
|
||
|
|
}
|
||
|
|
|
||
|
|
input:focus,
|
||
|
|
select:focus,
|
||
|
|
textarea:focus {
|
||
|
|
border-color: var(--editor-accent);
|
||
|
|
box-shadow: 0 0 0 2px var(--editor-drop-shadow, rgba(100, 170, 248, 0.25));
|
||
|
|
}
|
||
|
|
|
||
|
|
.drop-zone,
|
||
|
|
.drop-zone *,
|
||
|
|
.node-card input,
|
||
|
|
.node-card select,
|
||
|
|
.node-card textarea {
|
||
|
|
text-transform: none;
|
||
|
|
letter-spacing: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.drop-zone {
|
||
|
|
min-height: 44px;
|
||
|
|
border: 1px dashed color-mix(in srgb, var(--editor-accent) 55%, var(--editor-border));
|
||
|
|
border-radius: 8px;
|
||
|
|
background: rgba(255, 255, 255, 0.025);
|
||
|
|
padding: 7px;
|
||
|
|
transition: background 120ms ease, border-color 120ms ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.drop-zone.empty::before {
|
||
|
|
content: attr(data-empty);
|
||
|
|
display: block;
|
||
|
|
color: var(--editor-muted);
|
||
|
|
font-size: 12px;
|
||
|
|
padding: 5px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.drop-zone.drag-over {
|
||
|
|
border-color: var(--editor-accent-strong);
|
||
|
|
background: color-mix(in srgb, var(--editor-accent-soft) 48%, transparent);
|
||
|
|
}
|
||
|
|
|
||
|
|
.node-stack {
|
||
|
|
display: grid;
|
||
|
|
gap: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.node-card {
|
||
|
|
overflow: hidden;
|
||
|
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
|
||
|
|
}
|
||
|
|
|
||
|
|
.node-card.selected {
|
||
|
|
border-color: var(--editor-accent);
|
||
|
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--editor-accent) 30%, transparent);
|
||
|
|
}
|
||
|
|
|
||
|
|
.node-head,
|
||
|
|
.branch-head,
|
||
|
|
.choice-head {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 1fr auto;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
padding: 9px;
|
||
|
|
border-bottom: 1px solid var(--editor-border);
|
||
|
|
background: linear-gradient(180deg, var(--editor-panel-bg-alt), var(--editor-panel-bg));
|
||
|
|
}
|
||
|
|
|
||
|
|
.node-head-main {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: minmax(160px, 0.7fr) minmax(220px, 1fr);
|
||
|
|
gap: 8px;
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.node-actions,
|
||
|
|
.branch-actions-top,
|
||
|
|
.choice-actions-top,
|
||
|
|
.row-actions {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 6px;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
justify-content: flex-end;
|
||
|
|
}
|
||
|
|
|
||
|
|
.small-btn {
|
||
|
|
min-height: 28px;
|
||
|
|
padding: 4px 8px;
|
||
|
|
font-size: 12px;
|
||
|
|
font-weight: 700;
|
||
|
|
}
|
||
|
|
|
||
|
|
.small-btn.danger {
|
||
|
|
border-color: var(--editor-danger-border);
|
||
|
|
background: linear-gradient(180deg, var(--editor-danger-hover), var(--editor-danger));
|
||
|
|
}
|
||
|
|
|
||
|
|
.small-btn.ghost {
|
||
|
|
background: transparent;
|
||
|
|
}
|
||
|
|
|
||
|
|
.node-body,
|
||
|
|
.branch-body,
|
||
|
|
.choice-body {
|
||
|
|
display: grid;
|
||
|
|
gap: 9px;
|
||
|
|
padding: 9px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.branch-stack,
|
||
|
|
.choice-stack,
|
||
|
|
.logic-stack {
|
||
|
|
display: grid;
|
||
|
|
gap: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.branch-card {
|
||
|
|
background: color-mix(in srgb, var(--editor-panel-bg) 88%, var(--editor-accent-soft));
|
||
|
|
}
|
||
|
|
|
||
|
|
.branch-card.selected,
|
||
|
|
.choice-card.selected {
|
||
|
|
border-color: var(--editor-accent);
|
||
|
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--editor-accent) 28%, transparent);
|
||
|
|
}
|
||
|
|
|
||
|
|
.choice-card {
|
||
|
|
background: var(--editor-panel-bg-elevated);
|
||
|
|
}
|
||
|
|
|
||
|
|
.logic-row {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: minmax(90px, 0.55fr) minmax(130px, 0.85fr) minmax(72px, 0.45fr) auto;
|
||
|
|
align-items: end;
|
||
|
|
gap: 7px;
|
||
|
|
padding: 7px;
|
||
|
|
border: 1px solid var(--editor-border);
|
||
|
|
border-radius: 7px;
|
||
|
|
background: rgba(0, 0, 0, 0.16);
|
||
|
|
}
|
||
|
|
|
||
|
|
.action-row {
|
||
|
|
grid-template-columns: minmax(110px, 0.7fr) minmax(150px, 1fr) auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.logic-type {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
min-height: 30px;
|
||
|
|
border-radius: 6px;
|
||
|
|
padding: 6px 8px;
|
||
|
|
color: var(--editor-shell-bg);
|
||
|
|
background: var(--editor-accent);
|
||
|
|
font-weight: 900;
|
||
|
|
overflow-wrap: anywhere;
|
||
|
|
}
|
||
|
|
|
||
|
|
.check-label {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 6px;
|
||
|
|
min-height: 30px;
|
||
|
|
color: var(--editor-muted-strong);
|
||
|
|
font-size: 12px;
|
||
|
|
text-transform: none;
|
||
|
|
letter-spacing: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.check-label input {
|
||
|
|
width: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inspector {
|
||
|
|
display: grid;
|
||
|
|
grid-template-rows: auto minmax(0, 1fr);
|
||
|
|
border-left: 1px solid var(--editor-border-strong);
|
||
|
|
background: var(--editor-sidebar-bg);
|
||
|
|
}
|
||
|
|
|
||
|
|
.inspector-scroll {
|
||
|
|
display: grid;
|
||
|
|
gap: 10px;
|
||
|
|
align-content: start;
|
||
|
|
padding: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.json-area {
|
||
|
|
min-height: 330px;
|
||
|
|
font-family: Consolas, "Courier New", monospace;
|
||
|
|
font-size: 12px;
|
||
|
|
white-space: pre;
|
||
|
|
}
|
||
|
|
|
||
|
|
.validation-list,
|
||
|
|
.log-list {
|
||
|
|
display: grid;
|
||
|
|
gap: 5px;
|
||
|
|
margin: 0;
|
||
|
|
padding: 0;
|
||
|
|
list-style: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.validation-list li,
|
||
|
|
.log-list li,
|
||
|
|
.state-chip {
|
||
|
|
border: 1px solid var(--editor-border);
|
||
|
|
border-radius: 7px;
|
||
|
|
background: rgba(0, 0, 0, 0.18);
|
||
|
|
padding: 6px 7px;
|
||
|
|
color: var(--editor-muted-strong);
|
||
|
|
}
|
||
|
|
|
||
|
|
.validation-list li.ok {
|
||
|
|
border-color: color-mix(in srgb, var(--editor-accent) 55%, var(--editor-border));
|
||
|
|
}
|
||
|
|
|
||
|
|
.validation-list li.warn {
|
||
|
|
border-color: var(--editor-warn);
|
||
|
|
color: var(--editor-warn);
|
||
|
|
}
|
||
|
|
|
||
|
|
.sim-screen {
|
||
|
|
display: grid;
|
||
|
|
gap: 9px;
|
||
|
|
border: 1px solid var(--editor-border-strong);
|
||
|
|
border-radius: 8px;
|
||
|
|
padding: 10px;
|
||
|
|
background: #020611;
|
||
|
|
}
|
||
|
|
|
||
|
|
.npc-line {
|
||
|
|
min-height: 82px;
|
||
|
|
border: 1px solid var(--editor-border);
|
||
|
|
border-radius: 8px;
|
||
|
|
padding: 10px;
|
||
|
|
color: var(--editor-muted-strong);
|
||
|
|
background: linear-gradient(180deg, var(--editor-panel-bg), var(--editor-stage-bg));
|
||
|
|
line-height: 1.45;
|
||
|
|
}
|
||
|
|
|
||
|
|
.choice-list {
|
||
|
|
display: grid;
|
||
|
|
gap: 7px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.choice-btn {
|
||
|
|
min-height: 34px;
|
||
|
|
padding: 7px 9px;
|
||
|
|
text-align: left;
|
||
|
|
font-weight: 700;
|
||
|
|
}
|
||
|
|
|
||
|
|
.state-grid {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
|
|
gap: 6px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.state-controls {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
|
|
gap: 6px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-pill {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 6px;
|
||
|
|
border: 1px solid var(--editor-border);
|
||
|
|
border-radius: 999px;
|
||
|
|
padding: 4px 8px;
|
||
|
|
color: var(--editor-muted-strong);
|
||
|
|
background: var(--editor-panel-bg);
|
||
|
|
font-size: 12px;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.mini-note {
|
||
|
|
margin: 0;
|
||
|
|
color: var(--editor-muted);
|
||
|
|
font-size: 12px;
|
||
|
|
line-height: 1.35;
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 1250px) {
|
||
|
|
.workspace {
|
||
|
|
grid-template-columns: 300px minmax(0, 1fr);
|
||
|
|
}
|
||
|
|
|
||
|
|
.inspector {
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 900px) {
|
||
|
|
.workspace {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.sidebar {
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-bar {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
height: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.shell {
|
||
|
|
grid-template-rows: auto minmax(0, 1fr);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div class="shell">
|
||
|
|
<header class="menu-bar">
|
||
|
|
<div class="menu-left">
|
||
|
|
<button class="menu-btn" type="button" onclick="window.location.href='/wiki'">Wiki</button>
|
||
|
|
<button class="menu-btn" id="resetExampleBtn" type="button">Reset Example</button>
|
||
|
|
<button class="menu-btn" id="copyJsonBtn" type="button">Copy JSON</button>
|
||
|
|
</div>
|
||
|
|
<div class="menu-title">Dialogue Graph Builder Prototype</div>
|
||
|
|
<div class="menu-right">
|
||
|
|
<span class="status-pill" id="statusPill">Declarative graph model</span>
|
||
|
|
<div class="theme-preset-bar" role="group" aria-label="Theme presets">
|
||
|
|
<button class="theme-preset-btn active" data-theme="azure" type="button" title="Azure theme" aria-label="Azure theme">
|
||
|
|
<span class="theme-preset-swatch" style="--theme-swatch-a:#17325d; --theme-swatch-b:#244c84; --theme-swatch-c:#7fd1ff; --theme-swatch-d:#10203c;"></span>
|
||
|
|
</button>
|
||
|
|
<button class="theme-preset-btn" data-theme="verdant" type="button" title="Verdant theme" aria-label="Verdant theme">
|
||
|
|
<span class="theme-preset-swatch" style="--theme-swatch-a:#17372E; --theme-swatch-b:#285B4A; --theme-swatch-c:#7CE0AF; --theme-swatch-d:#0E251E;"></span>
|
||
|
|
</button>
|
||
|
|
<button class="theme-preset-btn" data-theme="ember" type="button" title="Ember theme" aria-label="Ember theme">
|
||
|
|
<span class="theme-preset-swatch" style="--theme-swatch-a:#4F231C; --theme-swatch-b:#87412F; --theme-swatch-c:#FFB36C; --theme-swatch-d:#24110F;"></span>
|
||
|
|
</button>
|
||
|
|
<button class="theme-preset-btn" data-theme="amethyst" type="button" title="Amethyst theme" aria-label="Amethyst theme">
|
||
|
|
<span class="theme-preset-swatch" style="--theme-swatch-a:#342456; --theme-swatch-b:#5A3B8A; --theme-swatch-c:#D3A8FF; --theme-swatch-d:#171125;"></span>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<main class="workspace">
|
||
|
|
<aside class="sidebar">
|
||
|
|
<div class="sidebar-head">
|
||
|
|
<p class="eyebrow">Block Palette</p>
|
||
|
|
<h1>Drag Rules Into Story</h1>
|
||
|
|
<p class="hint">Drag blocks onto the builder, or click a block to add it to the selected branch or choice.</p>
|
||
|
|
</div>
|
||
|
|
<div class="palette-scroll">
|
||
|
|
<section class="palette-section">
|
||
|
|
<h2>Structure <span class="status-pill">graph</span></h2>
|
||
|
|
<div class="block-grid" id="structurePalette"></div>
|
||
|
|
</section>
|
||
|
|
<section class="palette-section">
|
||
|
|
<h2>Conditions <span class="status-pill">pure checks</span></h2>
|
||
|
|
<div class="block-grid" id="conditionPalette"></div>
|
||
|
|
</section>
|
||
|
|
<section class="palette-section">
|
||
|
|
<h2>System Actions <span class="status-pill">state changes</span></h2>
|
||
|
|
<div class="block-grid" id="actionPalette"></div>
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
</aside>
|
||
|
|
|
||
|
|
<section class="stage">
|
||
|
|
<div class="meta">
|
||
|
|
<span>Drop nodes, branches, choices, conditions, and actions here. First passing branch wins.</span>
|
||
|
|
<span id="selectionMeta">No selection</span>
|
||
|
|
</div>
|
||
|
|
<div class="builder-scroll">
|
||
|
|
<div class="builder-layout">
|
||
|
|
<section class="panel">
|
||
|
|
<p class="eyebrow">Runtime JSON</p>
|
||
|
|
<div class="dialogue-fields">
|
||
|
|
<label>Dialogue ID
|
||
|
|
<input data-edit="dialogue-id" id="dialogueIdInput" />
|
||
|
|
</label>
|
||
|
|
<label>Name
|
||
|
|
<input data-edit="dialogue-name" id="dialogueNameInput" />
|
||
|
|
</label>
|
||
|
|
<label>Start Node
|
||
|
|
<select data-edit="start-node" id="startNodeSelect"></select>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="panel">
|
||
|
|
<div class="drop-zone" data-target="nodes" data-empty="Drop a Node block here to add a new conversation stop.">
|
||
|
|
<div class="node-stack" id="nodeStack"></div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<aside class="inspector">
|
||
|
|
<div class="inspector-head">
|
||
|
|
<p class="eyebrow">Export + Simulator</p>
|
||
|
|
<h1>Prove The Story</h1>
|
||
|
|
<p class="hint">The simulator uses the same JSON: evaluate branch, run actions, show valid choices, move to next node.</p>
|
||
|
|
</div>
|
||
|
|
<div class="inspector-scroll">
|
||
|
|
<section class="panel">
|
||
|
|
<h2>Simulator</h2>
|
||
|
|
<div class="sim-screen">
|
||
|
|
<div class="npc-line" id="simLine"></div>
|
||
|
|
<div class="choice-list" id="simChoices"></div>
|
||
|
|
<div class="row-actions">
|
||
|
|
<button class="small-btn" id="simRestartBtn" type="button">Restart</button>
|
||
|
|
<button class="small-btn ghost" id="simContinueBtn" type="button">Continue</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="panel">
|
||
|
|
<h2>Simulator State</h2>
|
||
|
|
<div class="state-grid" id="stateGrid"></div>
|
||
|
|
<div class="state-controls" style="margin-top:8px;">
|
||
|
|
<button class="small-btn" data-state-action="gold" type="button">+25 Gold</button>
|
||
|
|
<button class="small-btn" data-state-action="speech" type="button">+1 Speech</button>
|
||
|
|
<button class="small-btn" data-state-action="level" type="button">+1 Level</button>
|
||
|
|
<button class="small-btn" data-state-action="mage" type="button">Mage Rank 2</button>
|
||
|
|
<button class="small-btn" data-state-action="step2" type="button">Quest Step 2</button>
|
||
|
|
<button class="small-btn danger" data-state-action="clear" type="button">Clear State</button>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="panel">
|
||
|
|
<h2>Validation</h2>
|
||
|
|
<ul class="validation-list" id="validationList"></ul>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="panel">
|
||
|
|
<h2>Export JSON</h2>
|
||
|
|
<p class="mini-note">This box contains only the dialogue graph. Simulator state stays outside the content payload.</p>
|
||
|
|
<textarea class="json-area" id="jsonExport"></textarea>
|
||
|
|
<div class="row-actions" style="margin-top:8px;">
|
||
|
|
<button class="small-btn" id="importJsonBtn" type="button">Import From Textbox</button>
|
||
|
|
<button class="small-btn" id="formatJsonBtn" type="button">Format</button>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="panel">
|
||
|
|
<h2>Event Log</h2>
|
||
|
|
<ul class="log-list" id="simLog"></ul>
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
</aside>
|
||
|
|
</main>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
(function () {
|
||
|
|
const THEME_KEY = "tes8-dialogue-builder-theme";
|
||
|
|
|
||
|
|
const CONDITION_DEFS = [
|
||
|
|
{ type: "currency", label: "Currency", icon: "$", hint: "value: 25" },
|
||
|
|
{ type: "skill", label: "Skill", icon: "S", hint: "value: speech:3" },
|
||
|
|
{ type: "always", label: "Always", icon: "*", hint: "fallback branch" },
|
||
|
|
{ type: "item", label: "Item", icon: "I", hint: "value: ash_writ:1" },
|
||
|
|
{ type: "flag", label: "Flag", icon: "F", hint: "value: flag_id" },
|
||
|
|
{ type: "quest_started", label: "Quest Started", icon: "Q", hint: "value: 301" },
|
||
|
|
{ type: "quest_completed", label: "Quest Completed", icon: "C", hint: "value: 301" },
|
||
|
|
{ type: "quest_step_completed", label: "Quest Step Completed", icon: "2", hint: "value: 301, step: 2" },
|
||
|
|
{ type: "faction_rank", label: "Faction Rank", icon: "R", hint: "value: mages:2" },
|
||
|
|
{ type: "level", label: "Level", icon: "L", hint: "value: 3" },
|
||
|
|
];
|
||
|
|
|
||
|
|
const ACTION_DEFS = [
|
||
|
|
{ type: "grant_item", label: "Grant Item", icon: "+I", hint: "value: ash_writ:1" },
|
||
|
|
{ type: "grant_money", label: "Grant Money", icon: "+$", hint: "value: 40" },
|
||
|
|
{ type: "remove_item", label: "Remove Item", icon: "-I", hint: "value: ash_writ:1" },
|
||
|
|
{ type: "modify_mana_player", label: "Modify Mana", icon: "M", hint: "value: -5" },
|
||
|
|
{ type: "modify_health_player", label: "Modify Health", icon: "H", hint: "value: -8" },
|
||
|
|
{ type: "start_quest", label: "Start Quest", icon: "SQ", hint: "value: 301" },
|
||
|
|
{ type: "complete_quest", label: "Complete Quest", icon: "CQ", hint: "value: 301" },
|
||
|
|
];
|
||
|
|
|
||
|
|
const STRUCTURE_DEFS = [
|
||
|
|
{ kind: "node", type: "node", label: "Node", icon: "N", hint: "conversation stop" },
|
||
|
|
{ kind: "branch", type: "branch", label: "Branch", icon: "B", hint: "first passing branch wins" },
|
||
|
|
{ kind: "choice", type: "choice", label: "Choice", icon: ">", hint: "player option" },
|
||
|
|
];
|
||
|
|
|
||
|
|
const conditionTypes = new Set(CONDITION_DEFS.map((entry) => entry.type));
|
||
|
|
const actionTypes = new Set(ACTION_DEFS.map((entry) => entry.type));
|
||
|
|
|
||
|
|
let dialogue = createExampleDialogue();
|
||
|
|
let selected = { nodeId: dialogue.startNodeId, branchId: "offer", choiceId: "" };
|
||
|
|
let sim = createSimulator();
|
||
|
|
|
||
|
|
const els = {
|
||
|
|
structurePalette: document.getElementById("structurePalette"),
|
||
|
|
conditionPalette: document.getElementById("conditionPalette"),
|
||
|
|
actionPalette: document.getElementById("actionPalette"),
|
||
|
|
nodeStack: document.getElementById("nodeStack"),
|
||
|
|
dialogueIdInput: document.getElementById("dialogueIdInput"),
|
||
|
|
dialogueNameInput: document.getElementById("dialogueNameInput"),
|
||
|
|
startNodeSelect: document.getElementById("startNodeSelect"),
|
||
|
|
selectionMeta: document.getElementById("selectionMeta"),
|
||
|
|
jsonExport: document.getElementById("jsonExport"),
|
||
|
|
validationList: document.getElementById("validationList"),
|
||
|
|
simLine: document.getElementById("simLine"),
|
||
|
|
simChoices: document.getElementById("simChoices"),
|
||
|
|
simContinueBtn: document.getElementById("simContinueBtn"),
|
||
|
|
simLog: document.getElementById("simLog"),
|
||
|
|
stateGrid: document.getElementById("stateGrid"),
|
||
|
|
statusPill: document.getElementById("statusPill"),
|
||
|
|
};
|
||
|
|
|
||
|
|
function createExampleDialogue() {
|
||
|
|
return {
|
||
|
|
schemaVersion: 2,
|
||
|
|
id: "dlg_ember_gate",
|
||
|
|
name: "The Ember Gate",
|
||
|
|
startNodeId: "gate_captain",
|
||
|
|
nodes: [
|
||
|
|
{
|
||
|
|
id: "gate_captain",
|
||
|
|
title: "Captain Vela at the Ember Gate",
|
||
|
|
branches: [
|
||
|
|
{
|
||
|
|
id: "already_complete",
|
||
|
|
text: "Captain Vela nods as the Ember Gate opens. \"The road remembers who kept faith with it.\"",
|
||
|
|
when: [{ type: "quest_completed", value: "301", stepId: "", not: false }],
|
||
|
|
actions: [],
|
||
|
|
nextNodeId: "",
|
||
|
|
choices: [
|
||
|
|
{ id: "enter_city", text: "Enter the city.", when: [], actions: [], nextNodeId: "" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "field_step_done",
|
||
|
|
text: "\"The scouts say the archive seal is broken. Bring me the writ and I can close this.\"",
|
||
|
|
when: [{ type: "quest_step_completed", value: "301", stepId: "2", not: false }],
|
||
|
|
actions: [],
|
||
|
|
nextNodeId: "",
|
||
|
|
choices: [
|
||
|
|
{ id: "return_archive", text: "Return to the archive.", when: [], actions: [], nextNodeId: "archive_door" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "has_writ",
|
||
|
|
text: "\"That is the Ash Writ. You did it.\" Vela seals it, pays you, and waves the gate crew aside.",
|
||
|
|
when: [
|
||
|
|
{ type: "quest_started", value: "301", stepId: "", not: false },
|
||
|
|
{ type: "item", value: "ash_writ:1", stepId: "", not: false },
|
||
|
|
],
|
||
|
|
actions: [
|
||
|
|
{ type: "remove_item", value: "ash_writ:1" },
|
||
|
|
{ type: "complete_quest", value: "301" },
|
||
|
|
{ type: "grant_money", value: "40" },
|
||
|
|
],
|
||
|
|
nextNodeId: "",
|
||
|
|
choices: [
|
||
|
|
{ id: "thank_her", text: "Take the coin and enter.", when: [], actions: [], nextNodeId: "" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "in_progress",
|
||
|
|
text: "\"No writ yet? The market archivists know where Sable Hall hid it. Ask carefully.\"",
|
||
|
|
when: [
|
||
|
|
{ type: "quest_started", value: "301", stepId: "", not: false },
|
||
|
|
{ type: "quest_completed", value: "301", stepId: "", not: true },
|
||
|
|
],
|
||
|
|
actions: [],
|
||
|
|
nextNodeId: "",
|
||
|
|
choices: [
|
||
|
|
{ id: "go_market", text: "Head back to the market.", when: [], actions: [], nextNodeId: "market_square" },
|
||
|
|
{ id: "try_gate_rank", text: "Invoke your mage rank.", when: [{ type: "faction_rank", value: "mages:2", stepId: "", not: false }], actions: [{ type: "grant_item", value: "ash_writ:1" }], nextNodeId: "gate_captain" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "offer",
|
||
|
|
text: "\"The Ember Gate is closed. Bring me the Ash Writ from Sable Hall and I will open it.\"",
|
||
|
|
when: [{ type: "always", value: "", stepId: "", not: false }],
|
||
|
|
actions: [],
|
||
|
|
nextNodeId: "",
|
||
|
|
choices: [
|
||
|
|
{ id: "accept", text: "I will find the writ.", when: [], actions: [{ type: "start_quest", value: "301" }], nextNodeId: "market_square" },
|
||
|
|
{ id: "pay_toll", text: "Offer a 25 gold road pledge.", when: [{ type: "currency", value: "25", stepId: "", not: false }], actions: [{ type: "grant_item", value: "road_pledge:1" }], nextNodeId: "market_square" },
|
||
|
|
{ id: "walk_away", text: "Walk away.", when: [], actions: [], nextNodeId: "" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "market_square",
|
||
|
|
title: "Sable Market",
|
||
|
|
branches: [
|
||
|
|
{
|
||
|
|
id: "has_map",
|
||
|
|
text: "With the clerk's map in hand, the route to Sable Hall is suddenly obvious.",
|
||
|
|
when: [{ type: "item", value: "archive_map:1", stepId: "", not: false }],
|
||
|
|
actions: [],
|
||
|
|
nextNodeId: "",
|
||
|
|
choices: [
|
||
|
|
{ id: "to_archive_from_map", text: "Follow the marked alley.", when: [], actions: [], nextNodeId: "archive_door" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "apprentice_ready",
|
||
|
|
text: "The apprentice has marked the right shelf in chalk. The archive door should be easy now.",
|
||
|
|
when: [{ type: "item", value: "apprentice_note:1", stepId: "", not: false }],
|
||
|
|
actions: [],
|
||
|
|
nextNodeId: "",
|
||
|
|
choices: [
|
||
|
|
{ id: "to_archive_from_help", text: "Go to the archive.", when: [], actions: [], nextNodeId: "archive_door" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "market_open",
|
||
|
|
text: "Sable Market is all smoke, lamps, and whispered directions. You need a clean lead before the guard changes.",
|
||
|
|
when: [{ type: "always", value: "", stepId: "", not: false }],
|
||
|
|
actions: [],
|
||
|
|
nextNodeId: "",
|
||
|
|
choices: [
|
||
|
|
{ id: "ask_apprentice", text: "Convince the archive apprentice.", when: [{ type: "skill", value: "speech:3", stepId: "", not: false }], actions: [{ type: "grant_item", value: "apprentice_note:1" }], nextNodeId: "market_square" },
|
||
|
|
{ id: "buy_map", text: "Buy a smudged archive map for 25 gold.", when: [{ type: "currency", value: "25", stepId: "", not: false }], actions: [{ type: "grant_item", value: "archive_map:1" }], nextNodeId: "market_square" },
|
||
|
|
{ id: "scale_wall", text: "Scale the rear wall and force the old lock.", when: [{ type: "level", value: "3", stepId: "", not: false }], actions: [{ type: "modify_health_player", value: "-8" }, { type: "grant_item", value: "shadow_key:1" }], nextNodeId: "archive_door" },
|
||
|
|
{ id: "guild_seal", text: "Show your Mage Guild seal.", when: [{ type: "faction_rank", value: "mages:2", stepId: "", not: false }], actions: [{ type: "grant_item", value: "guild_seal:1" }], nextNodeId: "archive_door" },
|
||
|
|
{ id: "return_gate_empty", text: "Return to Vela with nothing.", when: [], actions: [], nextNodeId: "gate_captain" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "archive_door",
|
||
|
|
title: "Sable Hall Archive",
|
||
|
|
branches: [
|
||
|
|
{
|
||
|
|
id: "guild_solution",
|
||
|
|
text: "The door recognizes the guild seal. Inside, the Ash Writ floats above a blue flame.",
|
||
|
|
when: [{ type: "item", value: "guild_seal:1", stepId: "", not: false }],
|
||
|
|
actions: [{ type: "modify_mana_player", value: "-5" }, { type: "grant_item", value: "ash_writ:1" }],
|
||
|
|
nextNodeId: "gate_captain",
|
||
|
|
choices: [],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "guided_solution",
|
||
|
|
text: "The apprentice's chalk mark leads to a false shelf. You find the Ash Writ without waking the wards.",
|
||
|
|
when: [{ type: "item", value: "apprentice_note:1", stepId: "", not: false }],
|
||
|
|
actions: [{ type: "grant_item", value: "ash_writ:1" }],
|
||
|
|
nextNodeId: "gate_captain",
|
||
|
|
choices: [],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "key_solution",
|
||
|
|
text: "The shadow key fits, but the lock bites back. You still pull the Ash Writ free.",
|
||
|
|
when: [{ type: "item", value: "shadow_key:1", stepId: "", not: false }],
|
||
|
|
actions: [{ type: "remove_item", value: "shadow_key:1" }, { type: "modify_mana_player", value: "-4" }, { type: "grant_item", value: "ash_writ:1" }],
|
||
|
|
nextNodeId: "gate_captain",
|
||
|
|
choices: [],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "map_solution",
|
||
|
|
text: "The bought map is incomplete, but it gets you close enough to grab the Ash Writ as the wards wake.",
|
||
|
|
when: [{ type: "item", value: "archive_map:1", stepId: "", not: false }],
|
||
|
|
actions: [{ type: "modify_health_player", value: "-6" }, { type: "grant_item", value: "ash_writ:1" }],
|
||
|
|
nextNodeId: "gate_captain",
|
||
|
|
choices: [],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "fallback_trap",
|
||
|
|
text: "You guess wrong. The archive wards flare and the shelves rearrange themselves.",
|
||
|
|
when: [{ type: "always", value: "", stepId: "", not: false }],
|
||
|
|
actions: [{ type: "modify_health_player", value: "-10" }, { type: "modify_mana_player", value: "-5" }],
|
||
|
|
nextNodeId: "",
|
||
|
|
choices: [
|
||
|
|
{ id: "try_market_again", text: "Retreat and find a better lead.", when: [], actions: [], nextNodeId: "market_square" },
|
||
|
|
{ id: "limp_home", text: "Give up for now.", when: [], actions: [], nextNodeId: "" },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function createSimulator() {
|
||
|
|
return {
|
||
|
|
state: createInitialState(),
|
||
|
|
currentNodeId: "",
|
||
|
|
activeBranchId: "",
|
||
|
|
activeBranch: null,
|
||
|
|
ended: true,
|
||
|
|
log: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function createInitialState() {
|
||
|
|
return {
|
||
|
|
currency: 35,
|
||
|
|
level: 2,
|
||
|
|
health: 30,
|
||
|
|
mana: 18,
|
||
|
|
skills: { speech: 2, lockpicking: 1 },
|
||
|
|
inventory: { copper_ore: 3 },
|
||
|
|
flags: [],
|
||
|
|
quests: [],
|
||
|
|
factionRanks: { mages: 0, merchants: 1 },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function clone(value) {
|
||
|
|
return JSON.parse(JSON.stringify(value));
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeId(prefix) {
|
||
|
|
return `${prefix}_${Math.random().toString(36).slice(2, 8)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function slug(value, fallback) {
|
||
|
|
const clean = String(value || "")
|
||
|
|
.trim()
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/[^a-z0-9_]+/g, "_")
|
||
|
|
.replace(/^_+|_+$/g, "");
|
||
|
|
return clean || fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
function findNode(nodeId) {
|
||
|
|
return dialogue.nodes.find((node) => node.id === nodeId) || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function findBranch(nodeId, branchId) {
|
||
|
|
const node = findNode(nodeId);
|
||
|
|
return node ? node.branches.find((branch) => branch.id === branchId) || null : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function findChoice(nodeId, branchId, choiceId) {
|
||
|
|
const branch = findBranch(nodeId, branchId);
|
||
|
|
return branch ? branch.choices.find((choice) => choice.id === choiceId) || null : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderPalette() {
|
||
|
|
els.structurePalette.innerHTML = STRUCTURE_DEFS.map((entry) => renderBlockButton(entry.kind, entry.type, entry.icon, entry.label, entry.hint)).join("");
|
||
|
|
els.conditionPalette.innerHTML = CONDITION_DEFS.map((entry) => renderBlockButton("condition", entry.type, entry.icon, entry.label, entry.hint)).join("");
|
||
|
|
els.actionPalette.innerHTML = ACTION_DEFS.map((entry) => renderBlockButton("action", entry.type, entry.icon, entry.label, entry.hint)).join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderBlockButton(kind, type, icon, label, hint) {
|
||
|
|
return `
|
||
|
|
<button class="block-button" draggable="true" data-block-kind="${escapeAttr(kind)}" data-block-type="${escapeAttr(type)}" type="button">
|
||
|
|
<span class="block-icon">${escapeHtml(icon)}</span>
|
||
|
|
<span><strong>${escapeHtml(label)}</strong><span class="block-meta">${escapeHtml(hint)}</span></span>
|
||
|
|
</button>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderAll(options) {
|
||
|
|
const keepJsonText = options && options.keepJsonText;
|
||
|
|
renderTopFields();
|
||
|
|
renderBuilder();
|
||
|
|
renderValidation();
|
||
|
|
if (!keepJsonText) {
|
||
|
|
els.jsonExport.value = JSON.stringify(dialogue, null, 2);
|
||
|
|
}
|
||
|
|
renderSimulator();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderTopFields() {
|
||
|
|
els.dialogueIdInput.value = dialogue.id || "";
|
||
|
|
els.dialogueNameInput.value = dialogue.name || "";
|
||
|
|
els.startNodeSelect.innerHTML = dialogue.nodes.map((node) => {
|
||
|
|
return `<option value="${escapeAttr(node.id)}"${node.id === dialogue.startNodeId ? " selected" : ""}>${escapeHtml(node.id)}</option>`;
|
||
|
|
}).join("");
|
||
|
|
const parts = [];
|
||
|
|
if (selected.nodeId) parts.push(`Node: ${selected.nodeId}`);
|
||
|
|
if (selected.branchId) parts.push(`Branch: ${selected.branchId}`);
|
||
|
|
if (selected.choiceId) parts.push(`Choice: ${selected.choiceId}`);
|
||
|
|
els.selectionMeta.textContent = parts.length ? parts.join(" / ") : "No selection";
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderBuilder() {
|
||
|
|
els.nodeStack.innerHTML = dialogue.nodes.map(renderNode).join("");
|
||
|
|
markEmptyDropZones();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderNode(node) {
|
||
|
|
const isSelected = selected.nodeId === node.id && !selected.branchId;
|
||
|
|
return `
|
||
|
|
<article class="node-card ${isSelected ? "selected" : ""}" data-node-id="${escapeAttr(node.id)}">
|
||
|
|
<div class="node-head">
|
||
|
|
<div class="node-head-main">
|
||
|
|
<label>Node ID
|
||
|
|
<input data-edit="node-id" data-node-id="${escapeAttr(node.id)}" value="${escapeAttr(node.id)}" />
|
||
|
|
</label>
|
||
|
|
<label>Title
|
||
|
|
<input data-edit="node-title" data-node-id="${escapeAttr(node.id)}" value="${escapeAttr(node.title || "")}" />
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
<div class="node-actions">
|
||
|
|
${dialogue.startNodeId === node.id ? '<span class="status-pill">Start</span>' : `<button class="small-btn" data-action="set-start" data-node-id="${escapeAttr(node.id)}" type="button">Set Start</button>`}
|
||
|
|
<button class="small-btn" data-action="select-node" data-node-id="${escapeAttr(node.id)}" type="button">Select</button>
|
||
|
|
<button class="small-btn" data-action="add-branch" data-node-id="${escapeAttr(node.id)}" type="button">Add Branch</button>
|
||
|
|
<button class="small-btn danger" data-action="delete-node" data-node-id="${escapeAttr(node.id)}" type="button">Delete</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="node-body">
|
||
|
|
<div class="drop-zone" data-target="node-branches" data-node-id="${escapeAttr(node.id)}" data-empty="Drop Branch here. Branches are checked top-to-bottom.">
|
||
|
|
<div class="branch-stack">
|
||
|
|
${node.branches.map((branch) => renderBranch(node.id, branch)).join("")}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</article>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderBranch(nodeId, branch) {
|
||
|
|
const isSelected = selected.nodeId === nodeId && selected.branchId === branch.id && !selected.choiceId;
|
||
|
|
return `
|
||
|
|
<article class="branch-card ${isSelected ? "selected" : ""}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}">
|
||
|
|
<div class="branch-head">
|
||
|
|
<h3>Branch: ${escapeHtml(branch.id)}</h3>
|
||
|
|
<div class="branch-actions-top">
|
||
|
|
<button class="small-btn" data-action="select-branch" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" type="button">Select</button>
|
||
|
|
<button class="small-btn" data-action="move-branch-up" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" type="button">Up</button>
|
||
|
|
<button class="small-btn" data-action="move-branch-down" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" type="button">Down</button>
|
||
|
|
<button class="small-btn" data-action="add-choice" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" type="button">Add Choice</button>
|
||
|
|
<button class="small-btn danger" data-action="delete-branch" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" type="button">Delete</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="branch-body">
|
||
|
|
<div class="dialogue-fields" style="grid-template-columns:minmax(150px,.45fr) minmax(220px,1fr) minmax(150px,.45fr);">
|
||
|
|
<label>Branch ID
|
||
|
|
<input data-edit="branch-id" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" value="${escapeAttr(branch.id)}" />
|
||
|
|
</label>
|
||
|
|
<label>Say
|
||
|
|
<textarea data-edit="branch-text" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}">${escapeHtml(branch.text || "")}</textarea>
|
||
|
|
</label>
|
||
|
|
<label>Auto Next
|
||
|
|
<select data-edit="branch-next" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}">
|
||
|
|
${renderNodeOptions(branch.nextNodeId || "")}
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
<label>When
|
||
|
|
<div class="drop-zone" data-target="branch-conditions" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" data-empty="Drop condition blocks here. Empty means always.">
|
||
|
|
<div class="logic-stack">${(branch.when || []).map((condition, index) => renderConditionRow("branch", nodeId, branch.id, "", condition, index)).join("")}</div>
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
<label>Do On Entry
|
||
|
|
<div class="drop-zone" data-target="branch-actions" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" data-empty="Drop action blocks here. Actions run once when this branch is entered.">
|
||
|
|
<div class="logic-stack">${(branch.actions || []).map((action, index) => renderActionRow("branch", nodeId, branch.id, "", action, index)).join("")}</div>
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
<label>Choices
|
||
|
|
<div class="drop-zone" data-target="branch-choices" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" data-empty="Drop Choice blocks here. Choices are filtered after the branch wins.">
|
||
|
|
<div class="choice-stack">${(branch.choices || []).map((choice) => renderChoice(nodeId, branch.id, choice)).join("")}</div>
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</article>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderChoice(nodeId, branchId, choice) {
|
||
|
|
const isSelected = selected.nodeId === nodeId && selected.branchId === branchId && selected.choiceId === choice.id;
|
||
|
|
return `
|
||
|
|
<article class="choice-card ${isSelected ? "selected" : ""}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}">
|
||
|
|
<div class="choice-head">
|
||
|
|
<h3>Choice: ${escapeHtml(choice.id)}</h3>
|
||
|
|
<div class="choice-actions-top">
|
||
|
|
<button class="small-btn" data-action="select-choice" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" type="button">Select</button>
|
||
|
|
<button class="small-btn danger" data-action="delete-choice" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" type="button">Delete</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="choice-body">
|
||
|
|
<div class="dialogue-fields" style="grid-template-columns:minmax(130px,.45fr) minmax(220px,1fr) minmax(150px,.45fr);">
|
||
|
|
<label>Choice ID
|
||
|
|
<input data-edit="choice-id" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" value="${escapeAttr(choice.id)}" />
|
||
|
|
</label>
|
||
|
|
<label>Player Text
|
||
|
|
<input data-edit="choice-text" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" value="${escapeAttr(choice.text || "")}" />
|
||
|
|
</label>
|
||
|
|
<label>Go To
|
||
|
|
<select data-edit="choice-next" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}">
|
||
|
|
${renderNodeOptions(choice.nextNodeId || "")}
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
<label>Choice Visible When
|
||
|
|
<div class="drop-zone" data-target="choice-conditions" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" data-empty="Drop condition blocks here. Empty means visible.">
|
||
|
|
<div class="logic-stack">${(choice.when || []).map((condition, index) => renderConditionRow("choice", nodeId, branchId, choice.id, condition, index)).join("")}</div>
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
<label>Choice Does
|
||
|
|
<div class="drop-zone" data-target="choice-actions" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" data-empty="Drop action blocks here. Actions run before Go To.">
|
||
|
|
<div class="logic-stack">${(choice.actions || []).map((action, index) => renderActionRow("choice", nodeId, branchId, choice.id, action, index)).join("")}</div>
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</article>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderConditionRow(scope, nodeId, branchId, choiceId, condition, index) {
|
||
|
|
const stepVisible = condition.type === "quest_step_completed" ? "" : "display:none;";
|
||
|
|
return `
|
||
|
|
<div class="logic-row">
|
||
|
|
<span class="logic-type">${escapeHtml(condition.type)}</span>
|
||
|
|
<label>Value
|
||
|
|
<input data-edit="condition-value" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" value="${escapeAttr(condition.value || "")}" />
|
||
|
|
</label>
|
||
|
|
<label style="${stepVisible}">Step
|
||
|
|
<input data-edit="condition-step" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" value="${escapeAttr(condition.stepId || "")}" />
|
||
|
|
</label>
|
||
|
|
<div class="row-actions">
|
||
|
|
<label class="check-label"><input data-edit="condition-not" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" type="checkbox"${condition.not ? " checked" : ""} /> Not</label>
|
||
|
|
<button class="small-btn danger" data-action="delete-condition" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" type="button">X</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderActionRow(scope, nodeId, branchId, choiceId, action, index) {
|
||
|
|
return `
|
||
|
|
<div class="logic-row action-row">
|
||
|
|
<span class="logic-type">${escapeHtml(action.type)}</span>
|
||
|
|
<label>Value
|
||
|
|
<input data-edit="action-value" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" value="${escapeAttr(action.value || "")}" />
|
||
|
|
</label>
|
||
|
|
<div class="row-actions">
|
||
|
|
<button class="small-btn danger" data-action="delete-action" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" type="button">X</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderNodeOptions(selectedValue) {
|
||
|
|
const options = ['<option value="">End conversation</option>'];
|
||
|
|
dialogue.nodes.forEach((node) => {
|
||
|
|
options.push(`<option value="${escapeAttr(node.id)}"${node.id === selectedValue ? " selected" : ""}>${escapeHtml(node.id)}</option>`);
|
||
|
|
});
|
||
|
|
return options.join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
function markEmptyDropZones() {
|
||
|
|
document.querySelectorAll(".drop-zone").forEach((zone) => {
|
||
|
|
const hasContent = zone.querySelector(".node-card, .branch-card, .choice-card, .logic-row");
|
||
|
|
zone.classList.toggle("empty", !hasContent);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function addNode() {
|
||
|
|
const id = uniqueNodeId("node");
|
||
|
|
dialogue.nodes.push({
|
||
|
|
id,
|
||
|
|
title: "New Node",
|
||
|
|
branches: [createBranch("fallback", "This fallback keeps the conversation safe.", [{ type: "always", value: "", stepId: "", not: false }])],
|
||
|
|
});
|
||
|
|
selected = { nodeId: id, branchId: "", choiceId: "" };
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
function addBranch(nodeId, preferredConditionType) {
|
||
|
|
const node = findNode(nodeId);
|
||
|
|
if (!node) return;
|
||
|
|
const condition = preferredConditionType ? [createCondition(preferredConditionType)] : [{ type: "always", value: "", stepId: "", not: false }];
|
||
|
|
const branch = createBranch(uniqueBranchId(node, preferredConditionType || "branch"), "New branch text.", condition);
|
||
|
|
node.branches.push(branch);
|
||
|
|
selected = { nodeId, branchId: branch.id, choiceId: "" };
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
function createBranch(id, text, when) {
|
||
|
|
return { id, text, when: when || [], actions: [], nextNodeId: "", choices: [] };
|
||
|
|
}
|
||
|
|
|
||
|
|
function addChoice(nodeId, branchId) {
|
||
|
|
const branch = findBranch(nodeId, branchId);
|
||
|
|
if (!branch) return;
|
||
|
|
const id = uniqueChoiceId(branch, "choice");
|
||
|
|
branch.choices.push({ id, text: "New choice", when: [], actions: [], nextNodeId: "" });
|
||
|
|
selected = { nodeId, branchId, choiceId: id };
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
function createCondition(type) {
|
||
|
|
const defaults = {
|
||
|
|
currency: "25",
|
||
|
|
skill: "speech:3",
|
||
|
|
always: "",
|
||
|
|
item: "ash_writ:1",
|
||
|
|
flag: "apprentice_help",
|
||
|
|
quest_started: "301",
|
||
|
|
quest_completed: "301",
|
||
|
|
quest_step_completed: "301",
|
||
|
|
faction_rank: "mages:2",
|
||
|
|
level: "3",
|
||
|
|
};
|
||
|
|
return {
|
||
|
|
type,
|
||
|
|
value: defaults[type] || "",
|
||
|
|
stepId: type === "quest_step_completed" ? "2" : "",
|
||
|
|
not: false,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function createAction(type) {
|
||
|
|
const defaults = {
|
||
|
|
grant_item: "ash_writ:1",
|
||
|
|
grant_money: "40",
|
||
|
|
remove_item: "ash_writ:1",
|
||
|
|
modify_mana_player: "-5",
|
||
|
|
modify_health_player: "-8",
|
||
|
|
start_quest: "301",
|
||
|
|
complete_quest: "301",
|
||
|
|
};
|
||
|
|
return { type, value: defaults[type] || "" };
|
||
|
|
}
|
||
|
|
|
||
|
|
function addConditionToTarget(target, type) {
|
||
|
|
const condition = createCondition(type);
|
||
|
|
if (target === "branch") {
|
||
|
|
const branch = findBranch(selected.nodeId, selected.branchId);
|
||
|
|
if (branch) branch.when.push(condition);
|
||
|
|
} else {
|
||
|
|
const choice = findChoice(selected.nodeId, selected.branchId, selected.choiceId);
|
||
|
|
if (choice) choice.when.push(condition);
|
||
|
|
}
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
function addActionToTarget(target, type) {
|
||
|
|
const action = createAction(type);
|
||
|
|
if (target === "branch") {
|
||
|
|
const branch = findBranch(selected.nodeId, selected.branchId);
|
||
|
|
if (branch) branch.actions.push(action);
|
||
|
|
} else {
|
||
|
|
const choice = findChoice(selected.nodeId, selected.branchId, selected.choiceId);
|
||
|
|
if (choice) choice.actions.push(action);
|
||
|
|
}
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
function uniqueNodeId(seed) {
|
||
|
|
let base = slug(seed, "node");
|
||
|
|
let next = base;
|
||
|
|
let index = 2;
|
||
|
|
const ids = new Set(dialogue.nodes.map((node) => node.id));
|
||
|
|
while (ids.has(next)) {
|
||
|
|
next = `${base}_${index}`;
|
||
|
|
index += 1;
|
||
|
|
}
|
||
|
|
return next;
|
||
|
|
}
|
||
|
|
|
||
|
|
function uniqueBranchId(node, seed) {
|
||
|
|
let base = slug(seed, "branch");
|
||
|
|
let next = base;
|
||
|
|
let index = 2;
|
||
|
|
const ids = new Set(node.branches.map((branch) => branch.id));
|
||
|
|
while (ids.has(next)) {
|
||
|
|
next = `${base}_${index}`;
|
||
|
|
index += 1;
|
||
|
|
}
|
||
|
|
return next;
|
||
|
|
}
|
||
|
|
|
||
|
|
function uniqueChoiceId(branch, seed) {
|
||
|
|
let base = slug(seed, "choice");
|
||
|
|
let next = base;
|
||
|
|
let index = 2;
|
||
|
|
const ids = new Set(branch.choices.map((choice) => choice.id));
|
||
|
|
while (ids.has(next)) {
|
||
|
|
next = `${base}_${index}`;
|
||
|
|
index += 1;
|
||
|
|
}
|
||
|
|
return next;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renameNode(oldId, rawNextId) {
|
||
|
|
const nextId = uniqueRenameId(dialogue.nodes.map((node) => node.id), oldId, rawNextId, "node");
|
||
|
|
if (nextId === oldId) return;
|
||
|
|
const node = findNode(oldId);
|
||
|
|
if (!node) return;
|
||
|
|
node.id = nextId;
|
||
|
|
if (dialogue.startNodeId === oldId) dialogue.startNodeId = nextId;
|
||
|
|
dialogue.nodes.forEach((entry) => {
|
||
|
|
entry.branches.forEach((branch) => {
|
||
|
|
if (branch.nextNodeId === oldId) branch.nextNodeId = nextId;
|
||
|
|
branch.choices.forEach((choice) => {
|
||
|
|
if (choice.nextNodeId === oldId) choice.nextNodeId = nextId;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
if (selected.nodeId === oldId) selected.nodeId = nextId;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renameBranch(nodeId, oldId, rawNextId) {
|
||
|
|
const node = findNode(nodeId);
|
||
|
|
if (!node) return;
|
||
|
|
const branch = findBranch(nodeId, oldId);
|
||
|
|
if (!branch) return;
|
||
|
|
const nextId = uniqueRenameId(node.branches.map((entry) => entry.id), oldId, rawNextId, "branch");
|
||
|
|
branch.id = nextId;
|
||
|
|
if (selected.branchId === oldId) selected.branchId = nextId;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renameChoice(nodeId, branchId, oldId, rawNextId) {
|
||
|
|
const branch = findBranch(nodeId, branchId);
|
||
|
|
if (!branch) return;
|
||
|
|
const choice = findChoice(nodeId, branchId, oldId);
|
||
|
|
if (!choice) return;
|
||
|
|
const nextId = uniqueRenameId(branch.choices.map((entry) => entry.id), oldId, rawNextId, "choice");
|
||
|
|
choice.id = nextId;
|
||
|
|
if (selected.choiceId === oldId) selected.choiceId = nextId;
|
||
|
|
}
|
||
|
|
|
||
|
|
function uniqueRenameId(existingIds, oldId, rawNextId, fallback) {
|
||
|
|
const base = slug(rawNextId, fallback);
|
||
|
|
if (base === oldId) return oldId;
|
||
|
|
const ids = new Set(existingIds.filter((id) => id !== oldId));
|
||
|
|
let next = base;
|
||
|
|
let index = 2;
|
||
|
|
while (ids.has(next)) {
|
||
|
|
next = `${base}_${index}`;
|
||
|
|
index += 1;
|
||
|
|
}
|
||
|
|
return next;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getConditionList(scope, nodeId, branchId, choiceId) {
|
||
|
|
if (scope === "choice") {
|
||
|
|
const choice = findChoice(nodeId, branchId, choiceId);
|
||
|
|
return choice ? choice.when : null;
|
||
|
|
}
|
||
|
|
const branch = findBranch(nodeId, branchId);
|
||
|
|
return branch ? branch.when : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getActionList(scope, nodeId, branchId, choiceId) {
|
||
|
|
if (scope === "choice") {
|
||
|
|
const choice = findChoice(nodeId, branchId, choiceId);
|
||
|
|
return choice ? choice.actions : null;
|
||
|
|
}
|
||
|
|
const branch = findBranch(nodeId, branchId);
|
||
|
|
return branch ? branch.actions : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function moveBranch(nodeId, branchId, direction) {
|
||
|
|
const node = findNode(nodeId);
|
||
|
|
if (!node) return;
|
||
|
|
const index = node.branches.findIndex((branch) => branch.id === branchId);
|
||
|
|
const nextIndex = index + direction;
|
||
|
|
if (index < 0 || nextIndex < 0 || nextIndex >= node.branches.length) return;
|
||
|
|
const [branch] = node.branches.splice(index, 1);
|
||
|
|
node.branches.splice(nextIndex, 0, branch);
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
function deleteNode(nodeId) {
|
||
|
|
if (dialogue.nodes.length <= 1) {
|
||
|
|
setStatus("Keep at least one node.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
dialogue.nodes = dialogue.nodes.filter((node) => node.id !== nodeId);
|
||
|
|
dialogue.nodes.forEach((node) => {
|
||
|
|
node.branches.forEach((branch) => {
|
||
|
|
if (branch.nextNodeId === nodeId) branch.nextNodeId = "";
|
||
|
|
branch.choices.forEach((choice) => {
|
||
|
|
if (choice.nextNodeId === nodeId) choice.nextNodeId = "";
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
if (dialogue.startNodeId === nodeId) dialogue.startNodeId = dialogue.nodes[0].id;
|
||
|
|
selected = { nodeId: dialogue.startNodeId, branchId: "", choiceId: "" };
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
function deleteBranch(nodeId, branchId) {
|
||
|
|
const node = findNode(nodeId);
|
||
|
|
if (!node || node.branches.length <= 1) {
|
||
|
|
setStatus("Keep at least one branch on each node.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
node.branches = node.branches.filter((branch) => branch.id !== branchId);
|
||
|
|
selected = { nodeId, branchId: node.branches[0].id, choiceId: "" };
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
function deleteChoice(nodeId, branchId, choiceId) {
|
||
|
|
const branch = findBranch(nodeId, branchId);
|
||
|
|
if (!branch) return;
|
||
|
|
branch.choices = branch.choices.filter((choice) => choice.id !== choiceId);
|
||
|
|
selected = { nodeId, branchId, choiceId: "" };
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleBlockClick(kind, type) {
|
||
|
|
if (kind === "node") {
|
||
|
|
addNode();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (kind === "branch") {
|
||
|
|
addBranch(selected.nodeId || dialogue.startNodeId);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (kind === "choice") {
|
||
|
|
if (!selected.branchId) {
|
||
|
|
const node = findNode(selected.nodeId || dialogue.startNodeId);
|
||
|
|
selected.branchId = node && node.branches[0] ? node.branches[0].id : "";
|
||
|
|
}
|
||
|
|
addChoice(selected.nodeId || dialogue.startNodeId, selected.branchId);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (kind === "condition") {
|
||
|
|
if (selected.choiceId) addConditionToTarget("choice", type);
|
||
|
|
else {
|
||
|
|
ensureSelectedBranch();
|
||
|
|
addConditionToTarget("branch", type);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (kind === "action") {
|
||
|
|
if (selected.choiceId) addActionToTarget("choice", type);
|
||
|
|
else {
|
||
|
|
ensureSelectedBranch();
|
||
|
|
addActionToTarget("branch", type);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureSelectedBranch() {
|
||
|
|
if (selected.branchId) return;
|
||
|
|
const node = findNode(selected.nodeId || dialogue.startNodeId);
|
||
|
|
if (node && node.branches[0]) {
|
||
|
|
selected = { nodeId: node.id, branchId: node.branches[0].id, choiceId: "" };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleDrop(zone, payload) {
|
||
|
|
const target = zone.dataset.target;
|
||
|
|
const nodeId = zone.dataset.nodeId || selected.nodeId || dialogue.startNodeId;
|
||
|
|
const branchId = zone.dataset.branchId || selected.branchId;
|
||
|
|
const choiceId = zone.dataset.choiceId || selected.choiceId;
|
||
|
|
if (!payload) return;
|
||
|
|
|
||
|
|
if (target === "nodes" && payload.kind === "node") {
|
||
|
|
addNode();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (target === "node-branches") {
|
||
|
|
if (payload.kind === "branch") addBranch(nodeId);
|
||
|
|
if (payload.kind === "condition") addBranch(nodeId, payload.type);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (target === "branch-conditions" && payload.kind === "condition") {
|
||
|
|
const branch = findBranch(nodeId, branchId);
|
||
|
|
if (branch) branch.when.push(createCondition(payload.type));
|
||
|
|
selected = { nodeId, branchId, choiceId: "" };
|
||
|
|
renderAll();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (target === "branch-actions" && payload.kind === "action") {
|
||
|
|
const branch = findBranch(nodeId, branchId);
|
||
|
|
if (branch) branch.actions.push(createAction(payload.type));
|
||
|
|
selected = { nodeId, branchId, choiceId: "" };
|
||
|
|
renderAll();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (target === "branch-choices" && payload.kind === "choice") {
|
||
|
|
addChoice(nodeId, branchId);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (target === "choice-conditions" && payload.kind === "condition") {
|
||
|
|
const choice = findChoice(nodeId, branchId, choiceId);
|
||
|
|
if (choice) choice.when.push(createCondition(payload.type));
|
||
|
|
selected = { nodeId, branchId, choiceId };
|
||
|
|
renderAll();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (target === "choice-actions" && payload.kind === "action") {
|
||
|
|
const choice = findChoice(nodeId, branchId, choiceId);
|
||
|
|
if (choice) choice.actions.push(createAction(payload.type));
|
||
|
|
selected = { nodeId, branchId, choiceId };
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function validateDialogue() {
|
||
|
|
const issues = [];
|
||
|
|
const nodeIds = new Set();
|
||
|
|
if (!dialogue.id) issues.push({ level: "warn", text: "Dialogue ID is empty." });
|
||
|
|
if (!dialogue.startNodeId) issues.push({ level: "warn", text: "Start node is empty." });
|
||
|
|
dialogue.nodes.forEach((node) => {
|
||
|
|
if (!node.id) issues.push({ level: "warn", text: "A node has an empty id." });
|
||
|
|
if (nodeIds.has(node.id)) issues.push({ level: "warn", text: `Duplicate node id: ${node.id}.` });
|
||
|
|
nodeIds.add(node.id);
|
||
|
|
});
|
||
|
|
if (!nodeIds.has(dialogue.startNodeId)) issues.push({ level: "warn", text: `Start node "${dialogue.startNodeId}" does not exist.` });
|
||
|
|
dialogue.nodes.forEach((node) => {
|
||
|
|
if (!node.branches || node.branches.length === 0) issues.push({ level: "warn", text: `${node.id} has no branches.` });
|
||
|
|
const hasFallback = (node.branches || []).some((branch) => (branch.when || []).length === 0 || (branch.when || []).some((condition) => condition.type === "always" && !condition.not));
|
||
|
|
if (!hasFallback) issues.push({ level: "warn", text: `${node.id} has no always fallback branch.` });
|
||
|
|
const branchIds = new Set();
|
||
|
|
(node.branches || []).forEach((branch) => {
|
||
|
|
if (branchIds.has(branch.id)) issues.push({ level: "warn", text: `${node.id} has duplicate branch id ${branch.id}.` });
|
||
|
|
branchIds.add(branch.id);
|
||
|
|
if (branch.nextNodeId && !nodeIds.has(branch.nextNodeId)) issues.push({ level: "warn", text: `${node.id}/${branch.id} auto-next points to missing node ${branch.nextNodeId}.` });
|
||
|
|
(branch.when || []).forEach((condition) => {
|
||
|
|
if (!conditionTypes.has(condition.type)) issues.push({ level: "warn", text: `${node.id}/${branch.id} uses unknown condition ${condition.type}.` });
|
||
|
|
});
|
||
|
|
(branch.actions || []).forEach((action) => {
|
||
|
|
if (!actionTypes.has(action.type)) issues.push({ level: "warn", text: `${node.id}/${branch.id} uses unknown action ${action.type}.` });
|
||
|
|
});
|
||
|
|
const choiceIds = new Set();
|
||
|
|
(branch.choices || []).forEach((choice) => {
|
||
|
|
if (choiceIds.has(choice.id)) issues.push({ level: "warn", text: `${node.id}/${branch.id} has duplicate choice id ${choice.id}.` });
|
||
|
|
choiceIds.add(choice.id);
|
||
|
|
if (choice.nextNodeId && !nodeIds.has(choice.nextNodeId)) issues.push({ level: "warn", text: `${node.id}/${branch.id}/${choice.id} points to missing node ${choice.nextNodeId}.` });
|
||
|
|
(choice.when || []).forEach((condition) => {
|
||
|
|
if (!conditionTypes.has(condition.type)) issues.push({ level: "warn", text: `${choice.id} uses unknown condition ${condition.type}.` });
|
||
|
|
});
|
||
|
|
(choice.actions || []).forEach((action) => {
|
||
|
|
if (!actionTypes.has(action.type)) issues.push({ level: "warn", text: `${choice.id} uses unknown action ${action.type}.` });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
if (issues.length === 0) issues.push({ level: "ok", text: "Graph is deterministic: start exists, links resolve, condition/action keys are known." });
|
||
|
|
return issues;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderValidation() {
|
||
|
|
const issues = validateDialogue();
|
||
|
|
els.validationList.innerHTML = issues.map((issue) => `<li class="${issue.level}">${escapeHtml(issue.text)}</li>`).join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
function enterNode(nodeId) {
|
||
|
|
const node = findNode(nodeId);
|
||
|
|
if (!node) {
|
||
|
|
sim.currentNodeId = "";
|
||
|
|
sim.activeBranch = null;
|
||
|
|
sim.activeBranchId = "";
|
||
|
|
sim.ended = true;
|
||
|
|
sim.log.unshift(`Missing node: ${nodeId}`);
|
||
|
|
renderSimulator();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const branch = findActiveBranch(node, sim.state);
|
||
|
|
if (!branch) {
|
||
|
|
sim.currentNodeId = node.id;
|
||
|
|
sim.activeBranch = null;
|
||
|
|
sim.activeBranchId = "";
|
||
|
|
sim.ended = true;
|
||
|
|
sim.log.unshift(`No branch passed in ${node.id}.`);
|
||
|
|
renderSimulator();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
sim.currentNodeId = node.id;
|
||
|
|
sim.activeBranchId = branch.id;
|
||
|
|
sim.activeBranch = clone(branch);
|
||
|
|
sim.ended = false;
|
||
|
|
sim.log.unshift(`Entered ${node.id}/${branch.id}.`);
|
||
|
|
applyActions(sim.activeBranch.actions || [], sim.state, "branch");
|
||
|
|
renderSimulator();
|
||
|
|
}
|
||
|
|
|
||
|
|
function findActiveBranch(node, state) {
|
||
|
|
return (node.branches || []).find((branch) => conditionsPass(branch.when || [], state)) || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function conditionsPass(conditions, state) {
|
||
|
|
if (!conditions || conditions.length === 0) return true;
|
||
|
|
return conditions.every((condition) => {
|
||
|
|
const result = evaluateCondition(condition, state);
|
||
|
|
return condition.not ? !result : result;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function evaluateCondition(condition, state) {
|
||
|
|
const type = condition.type;
|
||
|
|
const value = String(condition.value || "").trim();
|
||
|
|
if (type === "always") return true;
|
||
|
|
if (type === "currency") return Number(state.currency || 0) >= parseNumber(value, 0);
|
||
|
|
if (type === "level") return Number(state.level || 0) >= parseNumber(value, 0);
|
||
|
|
if (type === "skill") {
|
||
|
|
const parsed = parseKeyNumber(value);
|
||
|
|
return Number(state.skills[parsed.key] || 0) >= parsed.amount;
|
||
|
|
}
|
||
|
|
if (type === "item") {
|
||
|
|
const parsed = parseKeyNumber(value);
|
||
|
|
return Number(state.inventory[parsed.key] || 0) >= parsed.amount;
|
||
|
|
}
|
||
|
|
if (type === "flag") return state.flags.includes(value);
|
||
|
|
if (type === "quest_started") {
|
||
|
|
const quest = findQuest(state, value);
|
||
|
|
return !!quest && quest.stepID > 0;
|
||
|
|
}
|
||
|
|
if (type === "quest_completed") {
|
||
|
|
const quest = findQuest(state, value);
|
||
|
|
return !!quest && quest.stepID === -1;
|
||
|
|
}
|
||
|
|
if (type === "quest_step_completed") {
|
||
|
|
const quest = findQuest(state, value);
|
||
|
|
const stepId = parseNumber(condition.stepId, 0);
|
||
|
|
return !!quest && (quest.stepID === -1 || (quest.completedSteps || []).includes(stepId));
|
||
|
|
}
|
||
|
|
if (type === "faction_rank") {
|
||
|
|
const parsed = parseKeyNumber(value);
|
||
|
|
return Number(state.factionRanks[parsed.key] || 0) >= parsed.amount;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyActions(actions, state, source) {
|
||
|
|
(actions || []).forEach((action) => {
|
||
|
|
const type = action.type;
|
||
|
|
const value = String(action.value || "").trim();
|
||
|
|
if (type === "grant_item") {
|
||
|
|
const parsed = parseKeyNumber(value);
|
||
|
|
state.inventory[parsed.key] = Number(state.inventory[parsed.key] || 0) + parsed.amount;
|
||
|
|
sim.log.unshift(`${source}: gained ${parsed.amount} ${parsed.key}.`);
|
||
|
|
} else if (type === "remove_item") {
|
||
|
|
const parsed = parseKeyNumber(value);
|
||
|
|
state.inventory[parsed.key] = Math.max(0, Number(state.inventory[parsed.key] || 0) - parsed.amount);
|
||
|
|
sim.log.unshift(`${source}: removed ${parsed.amount} ${parsed.key}.`);
|
||
|
|
} else if (type === "grant_money") {
|
||
|
|
const amount = parseNumber(value, 0);
|
||
|
|
state.currency = Number(state.currency || 0) + amount;
|
||
|
|
sim.log.unshift(`${source}: money changed by ${amount}.`);
|
||
|
|
} else if (type === "modify_mana_player") {
|
||
|
|
const amount = parseNumber(value, 0);
|
||
|
|
state.mana = Math.max(0, Number(state.mana || 0) + amount);
|
||
|
|
sim.log.unshift(`${source}: mana changed by ${amount}.`);
|
||
|
|
} else if (type === "modify_health_player") {
|
||
|
|
const amount = parseNumber(value, 0);
|
||
|
|
state.health = Math.max(0, Number(state.health || 0) + amount);
|
||
|
|
sim.log.unshift(`${source}: health changed by ${amount}.`);
|
||
|
|
} else if (type === "start_quest") {
|
||
|
|
const quest = ensureQuest(state, value);
|
||
|
|
if (quest.stepID !== -1) quest.stepID = Math.max(1, quest.stepID || 1);
|
||
|
|
sim.log.unshift(`${source}: quest ${value} started.`);
|
||
|
|
} else if (type === "complete_quest") {
|
||
|
|
const quest = ensureQuest(state, value);
|
||
|
|
quest.stepID = -1;
|
||
|
|
sim.log.unshift(`${source}: quest ${value} completed.`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
sim.log = sim.log.slice(0, 10);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderSimulator() {
|
||
|
|
const branch = sim.activeBranch;
|
||
|
|
if (!branch || sim.ended) {
|
||
|
|
els.simLine.textContent = sim.currentNodeId ? "Conversation ended." : "Press Restart to run the graph from the start node.";
|
||
|
|
els.simChoices.innerHTML = "";
|
||
|
|
els.simContinueBtn.disabled = true;
|
||
|
|
} else {
|
||
|
|
els.simLine.innerHTML = `<strong>${escapeHtml(sim.currentNodeId)} / ${escapeHtml(branch.id)}</strong><br>${escapeHtml(branch.text || "")}`;
|
||
|
|
const visibleChoices = (branch.choices || []).filter((choice) => conditionsPass(choice.when || [], sim.state));
|
||
|
|
if (visibleChoices.length > 0) {
|
||
|
|
els.simChoices.innerHTML = visibleChoices.map((choice, index) => {
|
||
|
|
return `<button class="choice-btn" data-sim-choice="${index}" type="button">${escapeHtml(choice.text || choice.id)}</button>`;
|
||
|
|
}).join("");
|
||
|
|
} else {
|
||
|
|
els.simChoices.innerHTML = branch.nextNodeId
|
||
|
|
? '<span class="status-pill">No choices. Use Continue for auto-next.</span>'
|
||
|
|
: '<span class="status-pill">No visible choices. Conversation can end here.</span>';
|
||
|
|
}
|
||
|
|
els.simContinueBtn.disabled = !branch.nextNodeId;
|
||
|
|
}
|
||
|
|
renderState();
|
||
|
|
els.simLog.innerHTML = sim.log.map((entry) => `<li>${escapeHtml(entry)}</li>`).join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderState() {
|
||
|
|
const state = sim.state;
|
||
|
|
const questText = state.quests.length
|
||
|
|
? state.quests.map((quest) => `${quest.questId}:${quest.stepID}${quest.completedSteps && quest.completedSteps.length ? `[${quest.completedSteps.join(",")}]` : ""}`).join(", ")
|
||
|
|
: "none";
|
||
|
|
const inventoryText = Object.keys(state.inventory).filter((key) => state.inventory[key] > 0).map((key) => `${key}:${state.inventory[key]}`).join(", ") || "none";
|
||
|
|
const flagsText = state.flags.join(", ") || "none";
|
||
|
|
const skillText = Object.keys(state.skills).map((key) => `${key}:${state.skills[key]}`).join(", ");
|
||
|
|
const factionText = Object.keys(state.factionRanks).map((key) => `${key}:${state.factionRanks[key]}`).join(", ");
|
||
|
|
els.stateGrid.innerHTML = [
|
||
|
|
`Gold: ${state.currency}`,
|
||
|
|
`Level: ${state.level}`,
|
||
|
|
`Health: ${state.health}`,
|
||
|
|
`Mana: ${state.mana}`,
|
||
|
|
`Skills: ${skillText}`,
|
||
|
|
`Factions: ${factionText}`,
|
||
|
|
`Items: ${inventoryText}`,
|
||
|
|
`Flags: ${flagsText}`,
|
||
|
|
`Quests: ${questText}`,
|
||
|
|
].map((text) => `<div class="state-chip">${escapeHtml(text)}</div>`).join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
function clickChoice(index) {
|
||
|
|
const branch = sim.activeBranch;
|
||
|
|
if (!branch || sim.ended) return;
|
||
|
|
const visibleChoices = (branch.choices || []).filter((choice) => conditionsPass(choice.when || [], sim.state));
|
||
|
|
const choice = visibleChoices[index];
|
||
|
|
if (!choice) return;
|
||
|
|
sim.log.unshift(`Chose: ${choice.text || choice.id}.`);
|
||
|
|
applyActions(choice.actions || [], sim.state, "choice");
|
||
|
|
if (choice.nextNodeId) {
|
||
|
|
enterNode(choice.nextNodeId);
|
||
|
|
} else {
|
||
|
|
sim.ended = true;
|
||
|
|
renderSimulator();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function continueSim() {
|
||
|
|
const branch = sim.activeBranch;
|
||
|
|
if (branch && branch.nextNodeId) enterNode(branch.nextNodeId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function findQuest(state, questId) {
|
||
|
|
return state.quests.find((quest) => String(quest.questId) === String(questId)) || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureQuest(state, questId) {
|
||
|
|
let quest = findQuest(state, questId);
|
||
|
|
if (!quest) {
|
||
|
|
quest = { questId: String(questId), stepID: 0, completedSteps: [] };
|
||
|
|
state.quests.push(quest);
|
||
|
|
}
|
||
|
|
return quest;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseKeyNumber(value) {
|
||
|
|
const parts = String(value || "").split(":");
|
||
|
|
const key = String(parts[0] || "").trim();
|
||
|
|
const amount = parseNumber(parts[1], 1);
|
||
|
|
return { key, amount };
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseNumber(value, fallback) {
|
||
|
|
const next = Number(String(value || "").trim());
|
||
|
|
return Number.isFinite(next) ? next : fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
function setStatus(message) {
|
||
|
|
els.statusPill.textContent = message;
|
||
|
|
window.setTimeout(() => {
|
||
|
|
els.statusPill.textContent = "Declarative graph model";
|
||
|
|
}, 2200);
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeHtml(value) {
|
||
|
|
return String(value == null ? "" : value)
|
||
|
|
.replace(/&/g, "&")
|
||
|
|
.replace(/</g, "<")
|
||
|
|
.replace(/>/g, ">")
|
||
|
|
.replace(/"/g, """)
|
||
|
|
.replace(/'/g, "'");
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeAttr(value) {
|
||
|
|
return escapeHtml(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
document.addEventListener("dragstart", (event) => {
|
||
|
|
const button = event.target.closest("[data-block-kind]");
|
||
|
|
if (!button) return;
|
||
|
|
const payload = {
|
||
|
|
kind: button.dataset.blockKind,
|
||
|
|
type: button.dataset.blockType,
|
||
|
|
};
|
||
|
|
event.dataTransfer.setData("application/json", JSON.stringify(payload));
|
||
|
|
event.dataTransfer.effectAllowed = "copy";
|
||
|
|
});
|
||
|
|
|
||
|
|
document.addEventListener("dragover", (event) => {
|
||
|
|
const zone = event.target.closest(".drop-zone");
|
||
|
|
if (!zone) return;
|
||
|
|
event.preventDefault();
|
||
|
|
zone.classList.add("drag-over");
|
||
|
|
});
|
||
|
|
|
||
|
|
document.addEventListener("dragleave", (event) => {
|
||
|
|
const zone = event.target.closest(".drop-zone");
|
||
|
|
if (!zone) return;
|
||
|
|
zone.classList.remove("drag-over");
|
||
|
|
});
|
||
|
|
|
||
|
|
document.addEventListener("drop", (event) => {
|
||
|
|
const zone = event.target.closest(".drop-zone");
|
||
|
|
if (!zone) return;
|
||
|
|
event.preventDefault();
|
||
|
|
zone.classList.remove("drag-over");
|
||
|
|
let payload = null;
|
||
|
|
try {
|
||
|
|
payload = JSON.parse(event.dataTransfer.getData("application/json") || "null");
|
||
|
|
} catch (_err) {}
|
||
|
|
handleDrop(zone, payload);
|
||
|
|
});
|
||
|
|
|
||
|
|
document.addEventListener("click", (event) => {
|
||
|
|
const block = event.target.closest("[data-block-kind]");
|
||
|
|
if (block) {
|
||
|
|
handleBlockClick(block.dataset.blockKind, block.dataset.blockType);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const simChoice = event.target.closest("[data-sim-choice]");
|
||
|
|
if (simChoice) {
|
||
|
|
clickChoice(Number(simChoice.dataset.simChoice));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const stateAction = event.target.closest("[data-state-action]");
|
||
|
|
if (stateAction) {
|
||
|
|
const action = stateAction.dataset.stateAction;
|
||
|
|
if (action === "gold") sim.state.currency += 25;
|
||
|
|
if (action === "speech") sim.state.skills.speech = Number(sim.state.skills.speech || 0) + 1;
|
||
|
|
if (action === "level") sim.state.level += 1;
|
||
|
|
if (action === "mage") sim.state.factionRanks.mages = 2;
|
||
|
|
if (action === "step2") {
|
||
|
|
const quest = ensureQuest(sim.state, "301");
|
||
|
|
quest.stepID = Math.max(quest.stepID, 1);
|
||
|
|
if (!quest.completedSteps.includes(2)) quest.completedSteps.push(2);
|
||
|
|
}
|
||
|
|
if (action === "clear") {
|
||
|
|
sim.state = createInitialState();
|
||
|
|
sim.log.unshift("State reset.");
|
||
|
|
}
|
||
|
|
if (sim.currentNodeId && !sim.ended) enterNode(sim.currentNodeId);
|
||
|
|
else renderSimulator();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const actionButton = event.target.closest("[data-action]");
|
||
|
|
if (!actionButton) return;
|
||
|
|
const action = actionButton.dataset.action;
|
||
|
|
const nodeId = actionButton.dataset.nodeId || "";
|
||
|
|
const branchId = actionButton.dataset.branchId || "";
|
||
|
|
const choiceId = actionButton.dataset.choiceId || "";
|
||
|
|
if (action === "select-node") selected = { nodeId, branchId: "", choiceId: "" };
|
||
|
|
if (action === "select-branch") selected = { nodeId, branchId, choiceId: "" };
|
||
|
|
if (action === "select-choice") selected = { nodeId, branchId, choiceId };
|
||
|
|
if (action === "set-start") dialogue.startNodeId = nodeId;
|
||
|
|
if (action === "add-branch") addBranch(nodeId);
|
||
|
|
if (action === "add-choice") addChoice(nodeId, branchId);
|
||
|
|
if (action === "move-branch-up") moveBranch(nodeId, branchId, -1);
|
||
|
|
if (action === "move-branch-down") moveBranch(nodeId, branchId, 1);
|
||
|
|
if (action === "delete-node") deleteNode(nodeId);
|
||
|
|
if (action === "delete-branch") deleteBranch(nodeId, branchId);
|
||
|
|
if (action === "delete-choice") deleteChoice(nodeId, branchId, choiceId);
|
||
|
|
if (action === "delete-condition") {
|
||
|
|
const list = getConditionList(actionButton.dataset.scope, nodeId, branchId, choiceId);
|
||
|
|
if (list) list.splice(Number(actionButton.dataset.index), 1);
|
||
|
|
}
|
||
|
|
if (action === "delete-action") {
|
||
|
|
const list = getActionList(actionButton.dataset.scope, nodeId, branchId, choiceId);
|
||
|
|
if (list) list.splice(Number(actionButton.dataset.index), 1);
|
||
|
|
}
|
||
|
|
renderAll();
|
||
|
|
});
|
||
|
|
|
||
|
|
document.addEventListener("input", (event) => {
|
||
|
|
const input = event.target;
|
||
|
|
const edit = input.dataset ? input.dataset.edit : "";
|
||
|
|
if (!edit) return;
|
||
|
|
const nodeId = input.dataset.nodeId || "";
|
||
|
|
const branchId = input.dataset.branchId || "";
|
||
|
|
const choiceId = input.dataset.choiceId || "";
|
||
|
|
if (edit === "dialogue-id") dialogue.id = input.value;
|
||
|
|
if (edit === "dialogue-name") dialogue.name = input.value;
|
||
|
|
if (edit === "node-title") {
|
||
|
|
const node = findNode(nodeId);
|
||
|
|
if (node) node.title = input.value;
|
||
|
|
}
|
||
|
|
if (edit === "branch-text") {
|
||
|
|
const branch = findBranch(nodeId, branchId);
|
||
|
|
if (branch) branch.text = input.value;
|
||
|
|
}
|
||
|
|
if (edit === "branch-next") {
|
||
|
|
const branch = findBranch(nodeId, branchId);
|
||
|
|
if (branch) branch.nextNodeId = input.value;
|
||
|
|
}
|
||
|
|
if (edit === "choice-text") {
|
||
|
|
const choice = findChoice(nodeId, branchId, choiceId);
|
||
|
|
if (choice) choice.text = input.value;
|
||
|
|
}
|
||
|
|
if (edit === "choice-next") {
|
||
|
|
const choice = findChoice(nodeId, branchId, choiceId);
|
||
|
|
if (choice) choice.nextNodeId = input.value;
|
||
|
|
}
|
||
|
|
if (edit === "condition-value" || edit === "condition-step" || edit === "condition-not") {
|
||
|
|
const list = getConditionList(input.dataset.scope, nodeId, branchId, choiceId);
|
||
|
|
const condition = list ? list[Number(input.dataset.index)] : null;
|
||
|
|
if (condition) {
|
||
|
|
if (edit === "condition-value") condition.value = input.value;
|
||
|
|
if (edit === "condition-step") condition.stepId = input.value;
|
||
|
|
if (edit === "condition-not") condition.not = input.checked;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (edit === "action-value") {
|
||
|
|
const list = getActionList(input.dataset.scope, nodeId, branchId, choiceId);
|
||
|
|
const action = list ? list[Number(input.dataset.index)] : null;
|
||
|
|
if (action) action.value = input.value;
|
||
|
|
}
|
||
|
|
if (edit !== "json") {
|
||
|
|
els.jsonExport.value = JSON.stringify(dialogue, null, 2);
|
||
|
|
renderValidation();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
document.addEventListener("change", (event) => {
|
||
|
|
const input = event.target;
|
||
|
|
const edit = input.dataset ? input.dataset.edit : "";
|
||
|
|
if (edit === "start-node") {
|
||
|
|
dialogue.startNodeId = input.value;
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
if (edit === "node-id") {
|
||
|
|
renameNode(input.dataset.nodeId, input.value);
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
if (edit === "branch-id") {
|
||
|
|
renameBranch(input.dataset.nodeId, input.dataset.branchId, input.value);
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
if (edit === "choice-id") {
|
||
|
|
renameChoice(input.dataset.nodeId, input.dataset.branchId, input.dataset.choiceId, input.value);
|
||
|
|
renderAll();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById("resetExampleBtn").addEventListener("click", () => {
|
||
|
|
dialogue = createExampleDialogue();
|
||
|
|
selected = { nodeId: dialogue.startNodeId, branchId: "offer", choiceId: "" };
|
||
|
|
sim = createSimulator();
|
||
|
|
enterNode(dialogue.startNodeId);
|
||
|
|
renderAll();
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById("copyJsonBtn").addEventListener("click", async () => {
|
||
|
|
try {
|
||
|
|
await navigator.clipboard.writeText(JSON.stringify(dialogue, null, 2));
|
||
|
|
setStatus("JSON copied.");
|
||
|
|
} catch (_err) {
|
||
|
|
setStatus("Clipboard unavailable.");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById("formatJsonBtn").addEventListener("click", () => {
|
||
|
|
try {
|
||
|
|
els.jsonExport.value = JSON.stringify(JSON.parse(els.jsonExport.value), null, 2);
|
||
|
|
} catch (_err) {
|
||
|
|
setStatus("JSON is not parseable.");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById("importJsonBtn").addEventListener("click", () => {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(els.jsonExport.value);
|
||
|
|
if (!parsed || !Array.isArray(parsed.nodes)) throw new Error("Missing nodes array.");
|
||
|
|
dialogue = normalizeImportedDialogue(parsed);
|
||
|
|
selected = { nodeId: dialogue.startNodeId, branchId: "", choiceId: "" };
|
||
|
|
sim = createSimulator();
|
||
|
|
enterNode(dialogue.startNodeId);
|
||
|
|
renderAll();
|
||
|
|
setStatus("Imported JSON.");
|
||
|
|
} catch (err) {
|
||
|
|
setStatus(`Import failed: ${String(err.message || err).slice(0, 60)}`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById("simRestartBtn").addEventListener("click", () => {
|
||
|
|
sim = createSimulator();
|
||
|
|
enterNode(dialogue.startNodeId);
|
||
|
|
});
|
||
|
|
|
||
|
|
els.simContinueBtn.addEventListener("click", continueSim);
|
||
|
|
|
||
|
|
document.querySelectorAll(".theme-preset-btn").forEach((button) => {
|
||
|
|
button.addEventListener("click", () => {
|
||
|
|
const theme = button.dataset.theme || "azure";
|
||
|
|
document.documentElement.setAttribute("data-theme", theme);
|
||
|
|
document.querySelectorAll(".theme-preset-btn").forEach((entry) => {
|
||
|
|
entry.classList.toggle("active", entry === button);
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
localStorage.setItem(THEME_KEY, theme);
|
||
|
|
} catch (_err) {}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
function normalizeImportedDialogue(source) {
|
||
|
|
const normalized = {
|
||
|
|
schemaVersion: 2,
|
||
|
|
id: String(source.id || "dlg_imported"),
|
||
|
|
name: String(source.name || "Imported Dialogue"),
|
||
|
|
startNodeId: String(source.startNodeId || ""),
|
||
|
|
nodes: [],
|
||
|
|
};
|
||
|
|
normalized.nodes = source.nodes.map((node, nodeIndex) => ({
|
||
|
|
id: String(node.id || `node_${nodeIndex + 1}`),
|
||
|
|
title: String(node.title || node.id || `Node ${nodeIndex + 1}`),
|
||
|
|
branches: Array.isArray(node.branches) ? node.branches.map((branch, branchIndex) => ({
|
||
|
|
id: String(branch.id || `branch_${branchIndex + 1}`),
|
||
|
|
text: String(branch.text || ""),
|
||
|
|
when: Array.isArray(branch.when) ? branch.when.map((condition) => ({
|
||
|
|
type: String(condition.type || "always"),
|
||
|
|
value: String(condition.value || ""),
|
||
|
|
stepId: String(condition.stepId || ""),
|
||
|
|
not: !!condition.not,
|
||
|
|
})) : [],
|
||
|
|
actions: Array.isArray(branch.actions) ? branch.actions.map((action) => ({
|
||
|
|
type: String(action.type || ""),
|
||
|
|
value: String(action.value || ""),
|
||
|
|
})) : [],
|
||
|
|
nextNodeId: String(branch.nextNodeId || ""),
|
||
|
|
choices: Array.isArray(branch.choices) ? branch.choices.map((choice, choiceIndex) => ({
|
||
|
|
id: String(choice.id || `choice_${choiceIndex + 1}`),
|
||
|
|
text: String(choice.text || ""),
|
||
|
|
when: Array.isArray(choice.when) ? choice.when.map((condition) => ({
|
||
|
|
type: String(condition.type || "always"),
|
||
|
|
value: String(condition.value || ""),
|
||
|
|
stepId: String(condition.stepId || ""),
|
||
|
|
not: !!condition.not,
|
||
|
|
})) : [],
|
||
|
|
actions: Array.isArray(choice.actions) ? choice.actions.map((action) => ({
|
||
|
|
type: String(action.type || ""),
|
||
|
|
value: String(action.value || ""),
|
||
|
|
})) : [],
|
||
|
|
nextNodeId: String(choice.nextNodeId || ""),
|
||
|
|
})) : [],
|
||
|
|
})) : [],
|
||
|
|
}));
|
||
|
|
if (!normalized.startNodeId && normalized.nodes[0]) normalized.startNodeId = normalized.nodes[0].id;
|
||
|
|
normalized.nodes.forEach((node) => {
|
||
|
|
if (node.branches.length === 0) {
|
||
|
|
node.branches.push(createBranch("fallback", "", [{ type: "always", value: "", stepId: "", not: false }]));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return normalized;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const savedTheme = localStorage.getItem(THEME_KEY);
|
||
|
|
if (savedTheme) {
|
||
|
|
document.documentElement.setAttribute("data-theme", savedTheme);
|
||
|
|
document.querySelectorAll(".theme-preset-btn").forEach((entry) => {
|
||
|
|
entry.classList.toggle("active", entry.dataset.theme === savedTheme);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (_err) {}
|
||
|
|
|
||
|
|
renderPalette();
|
||
|
|
enterNode(dialogue.startNodeId);
|
||
|
|
renderAll();
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|