- `;
-}
-
-export function getWorldshaperStudioBodyMarkup(): string {
- return buildWorldshaperStudioPopupMarkup()
- .replace(/^/i, "")
- .replace(/<\/body>s*$/i, "");
-}
-
+export { buildWorldshaperStudioStyles } from "./domStyles";
+export { buildWorldshaperStudioPopupMarkup, getWorldshaperStudioBodyMarkup } from "./domMarkup";
diff --git a/src/worldshaperStudio/domMarkup.ts b/src/worldshaperStudio/domMarkup.ts
new file mode 100644
index 0000000..940322a
--- /dev/null
+++ b/src/worldshaperStudio/domMarkup.ts
@@ -0,0 +1,34 @@
+import { WORLDSHAPER_THEME_PRESETS } from "./themePresets";
+import {
+ WORLDSHAPER_STUDIO_MARKUP_SHELL,
+ WORLDSHAPER_STUDIO_MARKUP_SIDEBAR,
+ WORLDSHAPER_STUDIO_MARKUP_STAGE,
+} from "./domMarkupSections";
+
+export function buildWorldshaperStudioPopupMarkup(): string {
+ const themePresetButtons = WORLDSHAPER_THEME_PRESETS.map((preset) => `
+
+ `).join("");
+ return (
+ WORLDSHAPER_STUDIO_MARKUP_SHELL.replace("__THEME_PRESET_BUTTONS__", themePresetButtons)
+ + WORLDSHAPER_STUDIO_MARKUP_SIDEBAR
+ + WORLDSHAPER_STUDIO_MARKUP_STAGE
+ );
+}
+
+export function getWorldshaperStudioBodyMarkup(): string {
+ return buildWorldshaperStudioPopupMarkup()
+ .replace(/^/i, "")
+ .replace(/<\/body>\s*$/i, "");
+}
diff --git a/src/worldshaperStudio/domMarkupSections.ts b/src/worldshaperStudio/domMarkupSections.ts
new file mode 100644
index 0000000..793ec28
--- /dev/null
+++ b/src/worldshaperStudio/domMarkupSections.ts
@@ -0,0 +1,475 @@
+export const WORLDSHAPER_STUDIO_MARKUP_SHELL = `
+
+
+
+
+
+
+`;
+
+export const WORLDSHAPER_STUDIO_MARKUP_SIDEBAR = `
+
+
+`;
diff --git a/src/worldshaperStudio/domStyleSections.ts b/src/worldshaperStudio/domStyleSections.ts
new file mode 100644
index 0000000..11a177b
--- /dev/null
+++ b/src/worldshaperStudio/domStyleSections.ts
@@ -0,0 +1,4237 @@
+export const WORLDSHAPER_STUDIO_STYLE_SHELL = `
+ :root { color-scheme: dark; }
+ * { box-sizing: border-box; }
+ html, body {
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ background: #0a1020;
+ color: #d8e8ff;
+ font-family: Segoe UI, Arial, sans-serif;
+ }
+ .shell {
+ display: grid;
+ grid-template-rows: 40px 1fr;
+ width: 100vw;
+ height: 100vh;
+ }
+
+ /* Top menu bar, clamped to 40px */
+ .menu-bar {
+ width: 100%;
+ height: 40px;
+ min-height: 40px;
+ max-height: 40px;
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ border-bottom: 1px solid #2e426c;
+ background: linear-gradient(180deg, #152645 0%, #10203c 100%);
+ overflow: hidden;
+ user-select: none;
+ }
+ .menu-btn {
+ height: 30px;
+ padding: 0 12px;
+ border: 1px solid #3c5e95;
+ border-radius: 8px;
+ background: #1a345e;
+ color: #d6e7ff;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ white-space: nowrap;
+ }
+ .menu-btn:hover { background: #214679; }
+ .menu-btn.menu-btn-right {
+ margin-left: auto;
+ }
+ .menu-bar-center {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ pointer-events: none;
+ }
+ .menu-bar-center > * {
+ pointer-events: auto;
+ }
+ .menu-layer-label {
+ color: #cfe2ff;
+ font-size: 12px;
+ font-weight: 700;
+ white-space: nowrap;
+ }
+ .menu-layer-select {
+ height: 30px;
+ min-width: 220px;
+ max-width: min(320px, 40vw);
+ border: 1px solid #3c5e95;
+ border-radius: 8px;
+ background: #10284b;
+ color: #d6e7ff;
+ font-size: 12px;
+ padding: 0 10px;
+ }
+
+ .body {
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 325px 1fr;
+ position: relative;
+ }
+
+`;
+
+export const WORLDSHAPER_STUDIO_STYLE_SIDEBAR = `
+ .sidebar {
+ min-height: 0;
+ border-right: 1px solid #2e426c;
+ background: #0e1a33;
+ display: flex;
+ flex-direction: column;
+ padding: 10px;
+ overflow: hidden;
+ position: relative;
+ }
+ .sidebar-panels-host {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ flex: 1 1 auto;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding-right: 2px;
+ }
+ .sidebar-panels-host.sidebar-drop-target {
+ outline: 1px solid rgba(255, 209, 102, 0.78);
+ outline-offset: 4px;
+ border-radius: 10px;
+ }
+ .sidebar h3 {
+ margin: 0 0 8px;
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #9bb5e3;
+ text-align: center;
+ }
+ .sidebar-tabs {
+ display: grid;
+ gap: 6px;
+ margin-bottom: 10px;
+ position: sticky;
+ top: 0;
+ z-index: 30;
+ background: #0e1a33;
+ border: 1px solid #274472;
+ border-radius: 10px;
+ padding: 6px;
+ box-shadow: 0 8px 14px rgba(3, 8, 18, 0.8);
+ isolation: isolate;
+ }
+ .sidebar-tabs::before {
+ content: "";
+ position: absolute;
+ inset: -1px;
+ background: #0e1a33;
+ border-radius: 10px;
+ z-index: -1;
+ }
+ .sidebar-tabs.dock-target {
+ border-color: #ffd166;
+ box-shadow: 0 0 0 1px rgba(255, 209, 102, 0.75), 0 8px 14px rgba(3, 8, 18, 0.8);
+ }
+ .sidebar-tab-row {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ }
+ .sidebar-tab-btn {
+ height: 28px;
+ width: 100%;
+ padding: 0 8px;
+ border: 1px solid #3f5e90;
+ border-radius: 7px;
+ background: #1a2f53;
+ color: #cfe2ff;
+ font-size: 12px;
+ line-height: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ }
+ .sidebar-tab-btn.active {
+ background: #1e4b82;
+ border-color: #64aaf8;
+ color: #e7f1ff;
+ }
+ .sidebar-tab-btn.tool-active-hidden {
+ border-color: var(--editor-tool-armed, #7ee8c6);
+ background: linear-gradient(
+ 180deg,
+ var(--editor-tool-armed-soft, #1a3c40) 0%,
+ color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 78%, black 22%) 100%
+ );
+ color: var(--editor-tool-armed, #7ee8c6);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--editor-tool-armed, #7ee8c6) 28%, transparent);
+ }
+ .sidebar-tab-btn.popped.tool-active-hidden {
+ border-style: dashed;
+ border-color: var(--editor-tool-armed, #7ee8c6);
+ color: var(--editor-tool-armed, #7ee8c6);
+ }
+ .sidebar-tab-btn.popped {
+ border-style: dashed;
+ border-color: #ffd166;
+ background: #173962;
+ color: #f6e4a4;
+ }
+ .sidebar-tab-btn.drag-armed {
+ border-color: #ffd166;
+ box-shadow: 0 0 0 1px rgba(255, 209, 102, 0.4);
+ }
+ .hidden {
+ display: none !important;
+ }
+ .sidebar-panel {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+ min-height: 0;
+ flex: 1 1 auto;
+ }
+ .sidebar-panel > * {
+ width: 100%;
+ max-width: 100%;
+ }
+ .sidebar-panel.hidden {
+ display: none;
+ }
+ .sidebar-static-footer {
+ flex: 0 0 auto;
+ display: grid;
+ gap: 6px;
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid rgba(86, 118, 171, 0.4);
+ }
+ .layer-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 10px;
+ }
+ .layer-row {
+ width: 100%;
+ text-align: left;
+ padding: 6px;
+ border: 1px solid #2e426c;
+ border-radius: 8px;
+ background: #121f3b;
+ color: #d6e7ff;
+ font-size: 12px;
+ min-height: 32px;
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+ }
+ .layer-row.active {
+ border-color: #5fa8ff;
+ background: #19355e;
+ }
+ .layer-row.layer-add-row {
+ border-style: dashed;
+ border-color: #4e78b7;
+ background: #132848;
+ color: #cce3ff;
+ margin-top: 4px;
+ }
+ .layer-row-wrap {
+ display: grid;
+ grid-template-columns: auto auto minmax(0, 1fr) auto auto;
+ gap: 6px;
+ align-items: center;
+ }
+ .layer-drag-handle {
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid #3c5e95;
+ border-radius: 8px;
+ background: #132b4f;
+ color: #d6e7ff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: grab;
+ }
+ .layer-drag-handle:hover {
+ background: #1a3f6d;
+ }
+ .layer-drag-handle:active {
+ cursor: grabbing;
+ }
+ .layer-drag-handle:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+ .layer-drag-icon {
+ display: block;
+ font-size: 15px;
+ line-height: 1;
+ }
+ .layer-visibility-btn {
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid #3c5e95;
+ border-radius: 8px;
+ background: #132b4f;
+ color: #d6e7ff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ }
+ .layer-visibility-btn.active {
+ border-color: #64aaf8;
+ background: #1e4b82;
+ }
+ .layer-visibility-btn.hidden {
+ border-color: #35537f;
+ background: #0f1d35;
+ color: #7f9dca;
+ opacity: 0.82;
+ }
+ .layer-visibility-icon {
+ display: block;
+ font-size: 16px;
+ line-height: 1;
+ }
+ .layer-row-wrap.reorder-dragging {
+ opacity: 0.55;
+ }
+ .layer-row-wrap.reorder-drop-before {
+ position: relative;
+ }
+ .layer-row-wrap.reorder-drop-before::before {
+ content: "";
+ position: absolute;
+ left: 4px;
+ right: 4px;
+ top: -4px;
+ height: 2px;
+ border-radius: 999px;
+ background: #64aaf8;
+ box-shadow: 0 0 0 1px rgba(100, 170, 248, 0.28);
+ }
+ .layer-row-wrap.reorder-drop-after {
+ position: relative;
+ }
+ .layer-row-wrap.reorder-drop-after::after {
+ content: "";
+ position: absolute;
+ left: 4px;
+ right: 4px;
+ bottom: -4px;
+ height: 2px;
+ border-radius: 999px;
+ background: #64aaf8;
+ box-shadow: 0 0 0 1px rgba(100, 170, 248, 0.28);
+ }
+ .layer-delete-btn {
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid #516993;
+ border-radius: 7px;
+ background: #11213d;
+ color: #d6e7ff;
+ font-size: 14px;
+ line-height: 1;
+ cursor: pointer;
+ }
+ .layer-delete-btn:hover {
+ background: #173058;
+ }
+ .layer-delete-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ .layer-z-select {
+ width: 58px;
+ height: 28px;
+ border: 1px solid #516993;
+ border-radius: 7px;
+ background: #11213d;
+ color: #d6e7ff;
+ font-size: 11px;
+ padding: 0 4px;
+ }
+ .layer-actions {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 12px;
+ justify-content: center;
+ flex-wrap: wrap;
+ }
+ .map-manager {
+ display: grid;
+ gap: 8px;
+ }
+ .information-panel-layout {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-height: 0;
+ height: 100%;
+ flex: 1 1 auto;
+ }
+ .information-utility-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+ .information-utility-actions .mini-btn {
+ flex: 1 1 auto;
+ }
+ .information-bottom-stack {
+ display: grid;
+ gap: 10px;
+ }
+ .info-help-panel,
+ .info-footer-bar {
+ border: 1px solid var(--editor-border, #2e426c);
+ border-radius: 8px;
+ background: var(--editor-panel-bg, #121f3b);
+ }
+ .experimental-import-panel {
+ border: 1px solid var(--editor-border, #2e426c);
+ border-radius: 8px;
+ background: var(--editor-panel-bg, #121f3b);
+ overflow: hidden;
+ }
+ .experimental-import-toggle {
+ width: 100%;
+ padding: 10px;
+ border: 0;
+ background: transparent;
+ color: var(--editor-control-fg, #d6e7ff);
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+ text-align: left;
+ cursor: pointer;
+ }
+ .experimental-import-toggle:hover {
+ background: rgba(255, 255, 255, 0.03);
+ }
+ .experimental-import-toggle.expanded {
+ background: rgba(255, 255, 255, 0.04);
+ }
+ .experimental-import-check {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--editor-warn, #ffd166);
+ white-space: nowrap;
+ }
+ .experimental-import-copy {
+ min-width: 0;
+ display: grid;
+ gap: 3px;
+ }
+ .experimental-import-title {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--editor-shell-fg, #d8e8ff);
+ }
+ .experimental-import-meta {
+ font-size: 11px;
+ line-height: 1.35;
+ color: var(--editor-muted, #9fb8e5);
+ }
+ .experimental-import-chevron {
+ font-size: 12px;
+ color: var(--editor-muted, #9fb8e5);
+ }
+ .experimental-import-body {
+ padding: 0 10px 10px;
+ display: grid;
+ gap: 8px;
+ }
+ .experimental-import-warning {
+ border: 1px solid rgba(255, 209, 102, 0.32);
+ border-radius: 7px;
+ background: rgba(255, 209, 102, 0.08);
+ color: var(--editor-muted-strong, #cfe2ff);
+ font-size: 11px;
+ line-height: 1.4;
+ padding: 8px 9px;
+ }
+ .experimental-import-actions {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 36px;
+ gap: 8px;
+ align-items: stretch;
+ }
+ .experimental-import-actions .mini-btn {
+ width: 100%;
+ min-width: 0;
+ }
+ .experimental-import-icon-btn {
+ width: 36px;
+ min-width: 36px;
+ padding: 0;
+ font-size: 16px;
+ line-height: 1;
+ }
+ .experimental-import-modal {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 16px;
+ background: rgba(4, 9, 18, 0.72);
+ z-index: 1200;
+ }
+ .experimental-import-modal-card {
+ width: min(100%, 520px);
+ display: grid;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid var(--editor-border, #2e426c);
+ border-radius: 10px;
+ background: var(--editor-panel-bg-elevated, #10284b);
+ box-shadow: 0 18px 40px rgba(2, 8, 18, 0.5);
+ }
+ .experimental-import-modal-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+ .experimental-import-modal-title {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--editor-shell-fg, #d8e8ff);
+ }
+ .experimental-import-modal-copy {
+ font-size: 11px;
+ line-height: 1.4;
+ color: var(--editor-muted, #9fb8e5);
+ }
+ .experimental-import-modal-body {
+ display: grid;
+ gap: 10px;
+ }
+ .experimental-import-modal-body textarea {
+ width: 100%;
+ min-height: 212px;
+ resize: vertical;
+ border: 1px solid var(--editor-control-border, #3c5e95);
+ border-radius: 8px;
+ background: var(--editor-control-bg, #1a345e);
+ color: var(--editor-control-fg, #d6e7ff);
+ padding: 9px 10px;
+ font: 12px/1.4 Consolas, "Courier New", monospace;
+ box-sizing: border-box;
+ }
+ .experimental-import-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ }
+ .info-help-panel {
+ padding: 10px;
+ display: grid;
+ align-content: start;
+ flex: 1 1 auto;
+ min-height: 0;
+ gap: 8px;
+ }
+ .info-help-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--editor-muted, #9fb8e5);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+ .info-help-list {
+ display: grid;
+ gap: 6px;
+ }
+ .shortcut-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(110px, auto);
+ gap: 10px;
+ align-items: center;
+ }
+ .shortcut-keys {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ min-width: 0;
+ }
+ .shortcut-plus {
+ color: var(--editor-muted, #9fb8e5);
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1;
+ }
+ .shortcut-keycap,
+ .shortcut-mouse-shell {
+ min-height: 24px;
+ padding: 0 8px;
+ border: 1px solid var(--editor-control-border, #3c5e95);
+ border-radius: 7px;
+ background: var(--editor-control-bg, #1a345e);
+ color: var(--editor-control-fg, #d6e7ff);
+ font-size: 11px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ white-space: nowrap;
+ }
+ .shortcut-mouse-shell {
+ gap: 4px;
+ padding-right: 9px;
+ }
+ .shortcut-mouse-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
+ }
+ .shortcut-mouse-label {
+ font-size: 10px;
+ color: var(--editor-muted-strong, #cfe2ff);
+ text-transform: uppercase;
+ }
+ .shortcut-action {
+ font-size: 11px;
+ line-height: 1.35;
+ color: var(--editor-muted-strong, #cfe2ff);
+ text-align: right;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 6px;
+ flex-wrap: wrap;
+ }
+ .shortcut-shape-icon {
+ width: 14px;
+ height: 14px;
+ flex: 0 0 14px;
+ border: 2px solid var(--editor-accent, #64aaf8);
+ background: transparent;
+ box-sizing: border-box;
+ }
+ .shortcut-shape-icon.square {
+ border-radius: 3px;
+ }
+ .shortcut-shape-icon.circle {
+ border-radius: 999px;
+ }
+ .info-footer-stack,
+ .sidebar-footer-links {
+ display: grid;
+ gap: 6px;
+ }
+ .info-footer-bar,
+ .sidebar-footer-linkbar {
+ min-height: 34px;
+ padding: 0 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(
+ 180deg,
+ var(--editor-menu-grad-1, #152645) 0%,
+ var(--editor-menu-grad-2, #10203c) 100%
+ );
+ }
+ .info-footer-link,
+ .sidebar-footer-link {
+ color: var(--editor-accent-strong, #8fd0ff);
+ font-size: 12px;
+ font-weight: 600;
+ text-decoration: none;
+ }
+ .info-footer-link:hover,
+ .info-footer-link:focus-visible,
+ .sidebar-footer-link:hover,
+ .sidebar-footer-link:focus-visible {
+ color: var(--editor-shell-fg, #d8e8ff);
+ text-decoration: underline;
+ }
+ .field-row {
+ display: grid;
+ grid-template-columns: 82px minmax(0, 1fr);
+ gap: 8px;
+ align-items: center;
+ }
+ .info-readonly + .info-dim-controls {
+ display: none;
+ }
+ .field-row label {
+ font-size: 11px;
+ color: #9fb8e5;
+ white-space: nowrap;
+ }
+ .field-row input {
+ min-width: 0;
+ }
+ .map-manager label {
+ font-size: 11px;
+ color: #9fb8e5;
+ }
+ .map-manager select {
+ height: 30px;
+ width: 100%;
+ border: 1px solid #3c5e95;
+ border-radius: 7px;
+ background: #10284b;
+ color: #d6e7ff;
+ font-size: 12px;
+ padding: 0 8px;
+ }
+ .mini-btn {
+ height: 28px;
+ padding: 0 10px;
+ border: 1px solid #3f5e90;
+ border-radius: 7px;
+ background: #1a2f53;
+ color: #cfe2ff;
+ font-size: 12px;
+ cursor: pointer;
+ }
+ .mini-btn.active {
+ border-color: #64aaf8;
+ background: #245081;
+ color: #eef7ff;
+ }
+ .history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-height: 0;
+ }
+ .npc-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+ .history-row {
+ width: 100%;
+ text-align: left;
+ padding: 7px;
+ border: 1px solid #2e426c;
+ border-radius: 8px;
+ background: #121f3b;
+ color: #d6e7ff;
+ font-size: 11px;
+ cursor: pointer;
+ line-height: 1.35;
+ }
+ .history-row.active {
+ border-color: #ffd166;
+ background: #2a3e5f;
+ }
+ .npc-row.active {
+ border-color: #64aaf8;
+ background: #22466e;
+ }
+ .npc-row-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+ .npc-row-main {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px;
+ }
+ .npc-row-edit-btn {
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid var(--editor-control-border, #3c5e95);
+ border-radius: 8px;
+ background: var(--editor-panel-bg-alt, #132b4f);
+ color: var(--editor-control-fg, #d6e7ff);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex: 0 0 auto;
+ font-size: 13px;
+ line-height: 1;
+ }
+ .npc-row-edit-btn:hover {
+ background: var(--editor-panel-bg-hover, #1a3f6d);
+ }
+ .npc-thumb {
+ width: 28px;
+ height: 28px;
+ flex: 0 0 28px;
+ border: 1px solid #35537f;
+ border-radius: 6px;
+ background: #0c1730;
+ object-fit: contain;
+ image-rendering: pixelated;
+ }
+ .npc-thumb-fallback {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ flex: 0 0 28px;
+ border: 1px solid #35537f;
+ border-radius: 6px;
+ background: #0c1730;
+ color: #9fb8e5;
+ font-size: 10px;
+ }
+ .npc-editor-panel {
+ margin-top: 8px;
+ padding: 8px;
+ border: 1px solid #2d426e;
+ border-radius: 8px;
+ background: #0e1d36;
+ display: grid;
+ gap: 8px;
+ }
+ .npc-top-toolbar {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ flex-wrap: wrap;
+ }
+ .npc-icon-btn {
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: 1px solid #3c5e95;
+ border-radius: 8px;
+ background: #132b4f;
+ color: #d6e7ff;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ }
+ .npc-icon-btn.active {
+ border-color: #64aaf8;
+ background: #1e4b82;
+ }
+ .npc-compact-menu {
+ display: grid;
+ gap: 4px;
+ margin-top: 2px;
+ padding: 4px;
+ border: 1px solid #3c5e95;
+ border-radius: 8px;
+ background: #0f2344;
+ }
+ .npc-compact-menu button {
+ height: 30px;
+ border: 1px solid #35537f;
+ border-radius: 7px;
+ background: #132b4f;
+ color: #d6e7ff;
+ font-size: 11px;
+ text-align: left;
+ padding: 0 8px;
+ cursor: pointer;
+ }
+ .npc-description-box {
+ min-height: 72px;
+ resize: vertical;
+ }
+ .selector-toolbar {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ margin-top: -2px;
+ }
+ .entity-filter-tabs {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ margin: -2px 0 10px;
+ }
+ .entity-filter-tab {
+ min-height: 32px;
+ padding: 0 10px;
+ border: 1px solid var(--editor-control-border, #3c5e95);
+ border-radius: 9px;
+ background: var(--editor-panel-bg-alt, #132b4f);
+ color: var(--editor-control-fg, #d6e7ff);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ }
+ .entity-filter-tab:hover {
+ background: var(--editor-panel-bg-hover, #1a3f6d);
+ }
+ .entity-filter-tab.active {
+ border-color: var(--editor-accent, #64aaf8);
+ background: color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, var(--editor-panel-bg-alt, #132b4f));
+ color: var(--editor-shell-fg, #eef6ff);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 28%, transparent);
+ }
+ .entity-type-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 62px;
+ height: 18px;
+ padding: 0 7px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--editor-accent, #64aaf8) 18%, var(--editor-preview-bg, #0c1730));
+ color: var(--editor-shell-fg, #eef6ff);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ }
+ .panel-square-btn {
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid var(--editor-control-border, #3c5e95);
+ border-radius: 8px;
+ background: var(--editor-panel-bg-alt, #132b4f);
+ color: var(--editor-control-fg, #d6e7ff);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ }
+ .panel-square-btn:hover {
+ background: var(--editor-panel-bg-hover, #1a3f6d);
+ }
+ .panel-square-btn.active {
+ border-color: var(--editor-accent, #64aaf8);
+ background: var(--editor-control-bg-active, #1e4b82);
+ color: var(--editor-shell-fg, #eef6ff);
+ }
+ .panel-icon-image-plus {
+ position: relative;
+ display: inline-flex;
+ width: 18px;
+ height: 16px;
+ align-items: center;
+ justify-content: center;
+ }
+ .panel-icon-image-frame {
+ position: absolute;
+ left: 1px;
+ top: 2px;
+ width: 13px;
+ height: 10px;
+ border: 1px solid currentColor;
+ border-radius: 3px;
+ }
+ .panel-icon-image-frame::before {
+ content: "";
+ position: absolute;
+ left: 2px;
+ bottom: 2px;
+ width: 7px;
+ height: 4px;
+ background: currentColor;
+ clip-path: polygon(0 100%, 28% 36%, 50% 68%, 73% 18%, 100% 100%);
+ opacity: 0.92;
+ }
+ .panel-icon-image-frame::after {
+ content: "";
+ position: absolute;
+ right: 2px;
+ top: 2px;
+ width: 2px;
+ height: 2px;
+ border-radius: 999px;
+ background: currentColor;
+ opacity: 0.9;
+ }
+ .panel-icon-image-plus-mark {
+ position: absolute;
+ right: -1px;
+ bottom: -1px;
+ color: var(--editor-tool-armed, #7ee8c6);
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ text-shadow: 0 0 3px rgba(6, 12, 20, 0.95);
+ }
+ .panel-icon-search {
+ font-size: 15px;
+ line-height: 1;
+ transform: translateY(-0.5px);
+ }
+ .tile-search-mode {
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ gap: 8px;
+ min-height: 0;
+ }
+ .tile-search-field {
+ width: 100%;
+ min-width: 0;
+ height: 32px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 8px;
+ background: var(--editor-panel-bg-alt, #132b4f);
+ color: var(--editor-control-fg, #eef6ff);
+ padding: 0 10px;
+ font-size: 12px;
+ }
+ .tile-search-field:focus {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-search-meta {
+ color: var(--editor-muted, #9fb8e5);
+ font-size: 11px;
+ line-height: 1.4;
+ min-height: 16px;
+ }
+ .tile-search-results {
+ min-height: 0;
+ display: grid;
+ align-content: start;
+ gap: 6px;
+ overflow: auto;
+ padding-right: 2px;
+ }
+ .tile-search-empty {
+ padding: 12px 10px;
+ border: 1px dashed color-mix(in srgb, var(--editor-muted, #8fb2e1) 28%, transparent);
+ border-radius: 10px;
+ color: var(--editor-muted, #8fb2e1);
+ font-size: 11px;
+ line-height: 1.45;
+ background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 55%, transparent);
+ }
+ .tile-search-result-row {
+ width: 100%;
+ text-align: left;
+ cursor: pointer;
+ }
+ .tile-search-result-row .npc-row-header {
+ cursor: pointer;
+ }
+ .selector-section {
+ display: grid;
+ gap: 8px;
+ }
+ .selector-section-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px;
+ }
+ .selector-section-toggle {
+ min-width: 0;
+ width: 100%;
+ flex: 1 1 auto;
+ height: 32px;
+ padding: 0 12px;
+ border: 1px solid #3f5e90;
+ border-radius: 8px;
+ background: #13284b;
+ color: #d6e7ff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ }
+ .selector-section-label {
+ min-width: 0;
+ width: 100%;
+ flex: 1 1 auto;
+ height: 32px;
+ padding: 0 12px;
+ border: 1px solid #3f5e90;
+ border-radius: 8px;
+ background: #13284b;
+ color: #d6e7ff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 700;
+ }
+ .selector-section-chevron {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 12px;
+ color: #8fd0ff;
+ font-size: 10px;
+ transition: transform 120ms ease;
+ }
+ .selector-section-toggle[aria-expanded="false"] .selector-section-chevron {
+ transform: rotate(-90deg);
+ }
+ .selector-section-actions {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ }
+ .selector-section-body {
+ display: grid;
+ gap: 8px;
+ }
+ .selector-section-body.hidden {
+ display: none !important;
+ }
+ .selector-hint {
+ margin: 0;
+ text-align: center;
+ }
+ .elevation-toolbar {
+ display: grid;
+ gap: 10px;
+ }
+ .elevation-z-select {
+ height: 30px;
+ width: 100%;
+ border: 1px solid #3c5e95;
+ border-radius: 7px;
+ background: #10284b;
+ color: #d6e7ff;
+ font-size: 12px;
+ padding: 0 8px;
+ }
+ .elevation-brush-group {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ }
+ .elevation-summary {
+ padding: 9px 10px;
+ border: 1px solid #2e426c;
+ border-radius: 8px;
+ background: #121f3b;
+ color: #cfe2ff;
+ font-size: 11px;
+ text-align: center;
+ }
+ .folder-list-root {
+ display: grid;
+ gap: 8px;
+ width: 100%;
+ }
+ .selector-drag-handle {
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid #3c5e95;
+ border-radius: 7px;
+ background: #132b4f;
+ color: #d6e7ff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: grab;
+ flex: 0 0 28px;
+ }
+ .selector-drag-handle.dragging,
+ .selector-drag-handle:active {
+ cursor: grabbing;
+ background: #1d4777;
+ }
+ .selector-drag-icon {
+ display: block;
+ font-size: 14px;
+ line-height: 1;
+ }
+ .folder-block {
+ display: grid;
+ gap: 6px;
+ width: 100%;
+ }
+ .folder-block.collapsed .folder-children {
+ display: none;
+ }
+ .folder-row {
+ background: #162844;
+ border-color: #47618f;
+ }
+ .folder-row-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ }
+ .folder-toggle-btn {
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 1px solid #3b567f;
+ border-radius: 6px;
+ background: #10233f;
+ color: #b7d7ff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex: 0 0 24px;
+ }
+ .folder-toggle-icon {
+ display: block;
+ font-size: 10px;
+ line-height: 1;
+ }
+ .folder-row-icon {
+ width: 22px;
+ height: 22px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ flex: 0 0 22px;
+ }
+ .folder-row-copy {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+ }
+ .folder-children {
+ display: grid;
+ gap: 6px;
+ padding-left: 12px;
+ border-left: 1px dashed rgba(100, 170, 248, 0.35);
+ margin-left: 8px;
+ width: calc(100% - 8px);
+ }
+ .folder-empty {
+ min-height: 36px;
+ border: 1px dashed #42648f;
+ border-radius: 8px;
+ color: #8da7d3;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ background: rgba(11, 24, 47, 0.68);
+ }
+ .folder-root-drop-zone {
+ min-height: 34px;
+ border: 1px dashed #466b99;
+ border-radius: 8px;
+ color: #93b2df;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ background: rgba(12, 25, 48, 0.72);
+ text-align: center;
+ }
+ .folder-drop-before,
+ .folder-drop-after,
+ .folder-drop-inside,
+ .folder-root-drop-active {
+ position: relative;
+ }
+ .folder-drop-before::before {
+ content: "";
+ position: absolute;
+ left: 6px;
+ right: 6px;
+ top: -5px;
+ height: 2px;
+ border-radius: 999px;
+ background: #64aaf8;
+ box-shadow: 0 0 0 1px rgba(100, 170, 248, 0.3);
+ }
+ .folder-drop-after::after {
+ content: "";
+ position: absolute;
+ left: 6px;
+ right: 6px;
+ bottom: -5px;
+ height: 2px;
+ border-radius: 999px;
+ background: #64aaf8;
+ box-shadow: 0 0 0 1px rgba(100, 170, 248, 0.3);
+ }
+ .folder-drop-inside {
+ box-shadow: inset 0 0 0 1px rgba(100, 170, 248, 0.8);
+ border-radius: 8px;
+ }
+ .folder-root-drop-active {
+ border-style: solid;
+ border-color: #64aaf8;
+ color: #d9ecff;
+ background: rgba(25, 67, 112, 0.46);
+ }
+ .folder-list-empty {
+ margin: 0;
+ text-align: center;
+ }
+ .info-cell-value {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+ .info-dim-input {
+ flex: 1 1 auto;
+ transition: flex-basis 120ms ease;
+ }
+ .info-dim-value.dirty .info-dim-input {
+ flex-basis: 68%;
+ }
+ .info-dim-controls {
+ display: none;
+ gap: 6px;
+ flex: 0 0 auto;
+ }
+ .info-dim-controls.visible {
+ display: flex;
+ }
+ .icon-action-btn {
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: 1px solid #3c5e95;
+ border-radius: 8px;
+ background: #132b4f;
+ color: #d6e7ff;
+ font-size: 14px;
+ cursor: pointer;
+ }
+ .icon-action-btn:hover { background: #1e4b82; }
+ .icon-action-btn.danger { border-color: #7f4c4c; background: #3c1a1a; }
+ .icon-action-btn.danger:hover { background: #5a2323; }
+ .info-readonly {
+ opacity: 0.75;
+ cursor: default;
+ }
+ .background-mode-btn {
+ min-height: 46px;
+ width: 100%;
+ padding: 6px 8px;
+ border: 1px solid #3c5e95;
+ border-radius: 8px;
+ background: #10284b;
+ color: #d6e7ff;
+ display: grid;
+ grid-template-columns: 32px minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ text-align: left;
+ }
+ .background-mode-btn:hover {
+ background: #16345d;
+ }
+ .background-mode-preview {
+ width: 32px;
+ height: 32px;
+ border: 1px solid #4f6992;
+ border-radius: 6px;
+ background: #0d1b34;
+ overflow: hidden;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ }
+ .background-mode-preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ image-rendering: pixelated;
+ display: block;
+ }
+ .background-mode-preview-hole::before,
+ .background-mode-preview-hole::after {
+ content: "";
+ position: absolute;
+ background: rgba(255, 91, 145, 0.95);
+ border-radius: 999px;
+ }
+ .background-mode-preview-hole::before {
+ width: 18px;
+ height: 2px;
+ transform: rotate(45deg);
+ }
+ .background-mode-preview-hole::after {
+ width: 18px;
+ height: 2px;
+ transform: rotate(-45deg);
+ }
+ .background-mode-preview-inherit::before {
+ content: "";
+ position: absolute;
+ inset: 6px;
+ border: 2px dashed rgba(111, 196, 255, 0.95);
+ border-radius: 5px;
+ }
+ .background-mode-copy {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+ }
+ .background-mode-title {
+ font-size: 12px;
+ font-weight: 700;
+ color: #e1eeff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .background-mode-meta {
+ font-size: 10px;
+ color: #9fb8e5;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .npc-editor-row {
+ display: grid;
+ gap: 4px;
+ }
+ .npc-editor-row label {
+ font-size: 11px;
+ color: #9fb8e5;
+ }
+ .npc-editor-row input,
+ .npc-editor-row select,
+ .sprite-dropdown-btn {
+ height: 28px;
+ width: 100%;
+ border: 1px solid #3c5e95;
+ border-radius: 7px;
+ background: #10284b;
+ color: #d6e7ff;
+ font-size: 12px;
+ padding: 0 8px;
+ }
+ .sprite-dropdown-wrap {
+ position: relative;
+ }
+ .sprite-dropdown-btn {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ gap: 8px;
+ }
+ .sprite-dropdown-current {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ }
+ .sprite-dropdown-menu {
+ margin-top: 4px;
+ max-height: 180px;
+ overflow: auto;
+ border: 1px solid #3c5e95;
+ border-radius: 8px;
+ background: #0f2344;
+ display: grid;
+ gap: 4px;
+ padding: 4px;
+ }
+ .sprite-option-btn {
+ height: 30px;
+ border: 1px solid #35537f;
+ border-radius: 7px;
+ background: #132b4f;
+ color: #d6e7ff;
+ font-size: 11px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ text-align: left;
+ padding: 0 6px;
+ }
+ .sprite-option-btn.active {
+ border-color: #64aaf8;
+ background: #1a3f6d;
+ }
+ .history-meta {
+ color: #9fb8e5;
+ display: block;
+ margin-top: 3px;
+ font-size: 10px;
+ }
+ .history-preview {
+ padding: 8px;
+ border: 1px solid #2d426e;
+ border-radius: 8px;
+ background: #101d38;
+ font-size: 11px;
+ color: #d6e7ff;
+ min-height: 0;
+ overflow: auto;
+ }
+ .history-preview h4 {
+ margin: 0 0 6px;
+ font-size: 12px;
+ color: #e6f0ff;
+ }
+ .history-preview ul {
+ margin: 0;
+ padding-left: 16px;
+ }
+ .history-preview-empty {
+ color: #9fb8e5;
+ }
+ .history-panel-layout {
+ min-height: 0;
+ }
+ .history-stack {
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto minmax(96px, auto);
+ gap: 8px;
+ min-height: 0;
+ flex: 1 1 auto;
+ }
+ .history-list-scroll {
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 2px;
+ }
+ .history-current {
+ padding: 8px;
+ border: 1px solid #40628f;
+ border-radius: 8px;
+ background: linear-gradient(180deg, #153155 0%, #132846 100%);
+ font-size: 11px;
+ color: #eef6ff;
+ }
+ .history-current-label {
+ margin-bottom: 4px;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #ffd166;
+ }
+ .history-current-empty {
+ color: #c7d8ef;
+ font-size: 11px;
+ }
+ .history-row.current-row {
+ border-color: #ffd166;
+ background: #28476c;
+ cursor: default;
+ }
+ .tool-window-layer {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 40;
+ }
+ .tool-popout-window {
+ position: absolute;
+ min-width: 260px;
+ min-height: 220px;
+ max-width: calc(100% - 12px);
+ max-height: calc(100% - 12px);
+ border: 1px solid var(--editor-border-strong, #4f79af);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 88%, transparent);
+ color: var(--editor-shell-fg, #d8e8ff);
+ box-shadow: 0 16px 34px color-mix(in srgb, var(--editor-tab-shadow, rgba(3, 8, 18, 0.8)) 58%, transparent);
+ overflow: hidden;
+ pointer-events: auto;
+ display: grid;
+ grid-template-rows: 34px minmax(0, 1fr);
+ backdrop-filter: blur(6px);
+ }
+ .tool-popout-window.is-focused {
+ border-color: var(--editor-warn, #ffd166);
+ box-shadow:
+ 0 18px 36px color-mix(in srgb, var(--editor-tab-shadow, rgba(3, 8, 18, 0.8)) 68%, transparent),
+ 0 0 0 1px color-mix(in srgb, var(--editor-warn, #ffd166) 22%, transparent);
+ }
+ .tool-popout-window.tool-popout-window-inline {
+ position: relative;
+ inset: auto;
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ min-height: 0;
+ max-height: none;
+ margin-bottom: 10px;
+ box-shadow: 0 10px 20px rgba(2, 8, 18, 0.32);
+ }
+ .tool-popout-titlebar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 10px;
+ background: linear-gradient(180deg, var(--editor-menu-grad-1, #1b365e) 0%, var(--editor-menu-grad-2, #122743) 100%);
+ border-bottom: 1px solid var(--editor-border, #365782);
+ cursor: grab;
+ user-select: none;
+ }
+ .tool-popout-titlebar:active {
+ cursor: grabbing;
+ }
+ .tool-popout-window-inline .tool-popout-titlebar {
+ cursor: grab;
+ }
+ .tool-popout-window-inline .tool-popout-titlebar:active {
+ cursor: grabbing;
+ }
+ .tool-popout-window-inline .tool-popout-dock-btn {
+ display: none;
+ }
+ .tool-popout-title {
+ min-width: 0;
+ flex: 1 1 auto;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--editor-shell-fg, #eef6ff);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .tool-popout-hint {
+ font-size: 10px;
+ color: var(--editor-muted, #a9c2ec);
+ white-space: nowrap;
+ }
+ .tool-popout-dock-btn {
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 1px solid var(--editor-control-border, #3c5e95);
+ border-radius: 7px;
+ background: var(--editor-panel-bg-alt, #132b4f);
+ color: var(--editor-control-fg, #d6e7ff);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex: 0 0 auto;
+ font-size: 12px;
+ line-height: 1;
+ }
+ .tool-popout-dock-btn:hover {
+ background: var(--editor-panel-bg-hover, #1a3f6d);
+ }
+ .tool-popout-close-btn {
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 1px solid var(--editor-danger-border, #6f4a56);
+ border-radius: 7px;
+ background: var(--editor-danger, #3a1a24);
+ color: var(--editor-shell-fg, #ffe3ea);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex: 0 0 auto;
+ font-size: 11px;
+ line-height: 1;
+ font-weight: 700;
+ }
+ .tool-popout-close-btn:hover {
+ background: var(--editor-danger-hover, #572234);
+ }
+ .tool-popout-dock-icon {
+ font-weight: 700;
+ letter-spacing: -0.08em;
+ color: var(--editor-status-error, #ff8e8e);
+ transform: translateX(-0.5px);
+ }
+ .tool-popout-body {
+ min-height: 0;
+ overflow: auto;
+ padding: 10px;
+ }
+ .tool-popout-body > .sidebar-panel {
+ min-height: 100%;
+ gap: 6px;
+ }
+ .tool-popout-body > .sidebar-panel > h3 {
+ display: none;
+ }
+ .tool-popout-window .history-stack {
+ grid-template-rows: minmax(90px, 1fr) auto minmax(88px, auto);
+ }
+ .tool-popout-window-inline .history-stack {
+ grid-template-rows: minmax(160px, 1fr) auto minmax(96px, auto);
+ }
+ .tool-popout-resize {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ width: 18px;
+ height: 18px;
+ cursor: nwse-resize;
+ background:
+ linear-gradient(135deg, transparent 0 45%, color-mix(in srgb, var(--editor-shell-fg, #ffffff) 8%, transparent) 45% 52%, transparent 52% 62%, color-mix(in srgb, var(--editor-shell-fg, #ffffff) 18%, transparent) 62% 69%, transparent 69% 100%);
+ }
+ .tool-popout-window-inline .tool-popout-resize {
+ position: relative;
+ right: auto;
+ bottom: auto;
+ width: 100%;
+ height: 12px;
+ cursor: ns-resize;
+ background:
+ linear-gradient(180deg, color-mix(in srgb, var(--editor-shell-fg, #ffffff) 4%, transparent) 0%, color-mix(in srgb, var(--editor-shell-fg, #ffffff) 2%, transparent) 45%, transparent 45%, transparent 100%),
+ repeating-linear-gradient(90deg, transparent 0 10px, color-mix(in srgb, var(--editor-muted, #9fb8e5) 18%, transparent) 10px 12px, transparent 12px 22px);
+ border-top: 1px solid color-mix(in srgb, var(--editor-border-strong, #3c5e95) 55%, transparent);
+ }
+ .world-overview-window {
+ min-width: 320px;
+ min-height: 264px;
+ grid-template-rows: 34px 40px minmax(0, 1fr);
+ }
+ .entity-editor-window {
+ min-width: 380px;
+ min-height: 420px;
+ }
+ .entity-editor-card {
+ min-height: 100%;
+ display: grid;
+ grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+ gap: 10px;
+ }
+ .entity-editor-head {
+ display: grid;
+ gap: 3px;
+ }
+ .entity-editor-title {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--editor-shell-fg, #eef6ff);
+ }
+ .entity-editor-subtitle {
+ font-size: 11px;
+ color: var(--editor-muted, #9fb8e5);
+ }
+ .entity-editor-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 10px;
+ }
+ .entity-editor-pane {
+ min-height: 0;
+ display: grid;
+ gap: 10px;
+ align-content: start;
+ }
+ .entity-editor-field {
+ display: grid;
+ gap: 5px;
+ min-width: 0;
+ }
+ .entity-editor-label {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--editor-shell-fg, #d6e7ff);
+ }
+ .entity-editor-field input,
+ .entity-editor-field select,
+ .entity-editor-field textarea,
+ .entity-editor-static {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 8px;
+ background: var(--editor-panel-bg-alt, #132b4f);
+ color: var(--editor-control-fg, #eef6ff);
+ padding: 8px 10px;
+ font-size: 12px;
+ box-sizing: border-box;
+ }
+ .entity-editor-field textarea {
+ resize: vertical;
+ min-height: 110px;
+ }
+ .entity-editor-static {
+ min-height: 34px;
+ display: inline-flex;
+ align-items: center;
+ }
+ .entity-editor-footer {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+ }
+ .entity-editor-status {
+ font-size: 11px;
+ color: var(--editor-muted, #9fb8e5);
+ }
+ .entity-editor-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ }
+ .status-log-window {
+ min-width: 360px;
+ min-height: 240px;
+ }
+ .changelog-splash-window {
+ min-width: 520px;
+ min-height: 360px;
+ }
+ .changelog-splash-card {
+ min-height: 100%;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ gap: 12px;
+ }
+ .changelog-splash-hero {
+ padding: 14px 16px;
+ border: 1px solid var(--editor-border-strong, #4f79af);
+ border-radius: 12px;
+ background:
+ radial-gradient(circle at top right, color-mix(in srgb, var(--editor-accent, #64aaf8) 24%, transparent) 0%, transparent 48%),
+ linear-gradient(180deg, color-mix(in srgb, var(--editor-menu-grad-1, #1b365e) 64%, transparent) 0%, color-mix(in srgb, var(--editor-panel-bg, #11203f) 82%, transparent) 100%);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--editor-shell-bg, #0a1020) 22%, transparent);
+ }
+ .changelog-splash-kicker {
+ color: var(--editor-warn, #ffd166);
+ font-size: 10px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ margin-bottom: 6px;
+ }
+ .changelog-splash-title {
+ color: var(--editor-shell-fg, #eef6ff);
+ font-size: 22px;
+ font-weight: 800;
+ line-height: 1.1;
+ margin-bottom: 6px;
+ }
+ .changelog-splash-meta {
+ color: var(--editor-muted, #a9c2ec);
+ font-size: 12px;
+ line-height: 1.45;
+ }
+ .changelog-splash-list {
+ min-height: 0;
+ overflow: auto;
+ display: grid;
+ gap: 10px;
+ padding-right: 4px;
+ }
+ .changelog-splash-section {
+ padding: 12px 14px;
+ border: 1px solid var(--editor-border, #365782);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--editor-shell-bg, #0a1020) 14%, transparent);
+ }
+ .changelog-splash-section-title {
+ margin: 0 0 8px;
+ color: var(--editor-shell-fg, #eef6ff);
+ font-size: 13px;
+ font-weight: 800;
+ letter-spacing: 0.01em;
+ }
+ .changelog-splash-bullets {
+ margin: 0;
+ padding-left: 18px;
+ display: grid;
+ gap: 5px;
+ color: var(--editor-text-soft, #d7e7ff);
+ font-size: 12px;
+ line-height: 1.45;
+ }
+ .changelog-splash-bullet-note {
+ margin-top: 3px;
+ color: var(--editor-muted, #a9c2ec);
+ font-size: 11px;
+ line-height: 1.4;
+ font-style: italic;
+ }
+ .changelog-splash-footer {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+ }
+ .changelog-splash-footnote {
+ color: var(--editor-muted, #9fb8e5);
+ font-size: 11px;
+ line-height: 1.4;
+ }
+ .engine-overrides-launch-btn {
+ width: 100%;
+ justify-content: space-between;
+ gap: 10px;
+ }
+ .engine-overrides-summary {
+ margin-top: 6px;
+ color: var(--editor-muted, #9fb8e5);
+ font-size: 11px;
+ line-height: 1.4;
+ white-space: pre-wrap;
+ }
+ .engine-override-window {
+ min-width: 420px;
+ min-height: 260px;
+ }
+ .engine-override-card {
+ min-height: 100%;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 10px;
+ }
+ .engine-override-head {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 6px 12px;
+ align-items: start;
+ }
+ .engine-override-title {
+ grid-column: 1 / 2;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--editor-shell-fg, #eef6ff);
+ }
+ .engine-override-meta {
+ grid-column: 1 / 2;
+ font-size: 11px;
+ color: var(--editor-muted, #9fb8e5);
+ }
+ .engine-override-head .mini-btn {
+ grid-column: 2 / 3;
+ grid-row: 1 / 3;
+ align-self: start;
+ }
+ .engine-override-list {
+ display: grid;
+ gap: 10px;
+ min-height: 0;
+ overflow: auto;
+ padding-right: 2px;
+ }
+ .engine-override-empty {
+ border: 1px dashed var(--editor-control-border, #3f5e90);
+ border-radius: 10px;
+ padding: 12px;
+ background: var(--editor-panel-bg-alt, #132848);
+ color: var(--editor-muted, #9fb8e5);
+ font-size: 12px;
+ line-height: 1.5;
+ }
+ .engine-override-row {
+ display: grid;
+ gap: 8px;
+ border: 1px solid var(--editor-border, #2e426c);
+ border-radius: 10px;
+ padding: 10px;
+ background: var(--editor-panel-bg, #121f3b);
+ }
+ .engine-override-row-head {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+ }
+ .engine-override-select,
+ .engine-override-number-input {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--editor-control-border, #3c5e95);
+ border-radius: 8px;
+ background: var(--editor-control-bg, #1a345e);
+ color: var(--editor-control-fg, #d6e7ff);
+ padding: 7px 9px;
+ font-size: 12px;
+ }
+ .engine-override-description {
+ color: var(--editor-muted, #9fb8e5);
+ font-size: 11px;
+ line-height: 1.45;
+ }
+ .engine-override-value-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 10px;
+ align-items: center;
+ }
+ .engine-override-value-label {
+ color: var(--editor-muted-strong, #cfe2ff);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ }
+ .engine-override-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--editor-control-fg, #d6e7ff);
+ font-size: 12px;
+ }
+ .status-log-card {
+ min-height: 100%;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ gap: 10px;
+ }
+ .status-log-head {
+ display: grid;
+ gap: 3px;
+ }
+ .status-log-title {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--editor-shell-fg, #eef6ff);
+ }
+ .status-log-meta {
+ font-size: 11px;
+ color: var(--editor-muted, #9fb8e5);
+ }
+ .status-log-actions {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+ .status-log-list {
+ min-height: 0;
+ overflow: auto;
+ display: grid;
+ align-content: start;
+ gap: 8px;
+ padding: 2px;
+ }
+ .status-log-empty {
+ color: var(--editor-muted, #9fb8e5);
+ font-size: 11px;
+ line-height: 1.45;
+ }
+ .status-log-row {
+ display: grid;
+ gap: 6px;
+ padding: 9px 10px;
+ border: 1px solid var(--editor-border, #2e426c);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--editor-panel-bg, #121f3b) 96%, transparent);
+ }
+ .status-log-row-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+ .status-log-row-level {
+ display: inline-flex;
+ align-items: center;
+ min-height: 22px;
+ padding: 0 8px;
+ border-radius: 999px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ background: var(--editor-panel-bg-alt, #132b4f);
+ color: var(--editor-control-fg, #eef6ff);
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+ .status-log-row-level-error {
+ border-color: color-mix(in srgb, var(--editor-status-error, #ff7b93) 50%, transparent);
+ color: var(--editor-status-error, #ffb7c3);
+ }
+ .status-log-row-level-information {
+ border-color: color-mix(in srgb, var(--editor-accent, #64aaf8) 42%, transparent);
+ color: var(--editor-shell-fg, #eef6ff);
+ }
+ .status-log-row-time {
+ font-size: 11px;
+ color: var(--editor-muted, #9fb8e5);
+ }
+ .status-log-row-message {
+ font-size: 12px;
+ line-height: 1.45;
+ color: var(--editor-shell-fg, #dbe9ff);
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+ .world-overview-body {
+ display: grid;
+ grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
+ gap: 8px;
+ overflow: hidden;
+ }
+ .world-overview-sidebar {
+ min-width: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid var(--editor-border, #2e426c);
+ border-radius: 10px;
+ background:
+ linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--editor-panel-bg, #121f3b) 96%, transparent) 0%,
+ color-mix(in srgb, var(--editor-shell-bg, #0a1020) 98%, transparent) 100%
+ );
+ overflow: hidden;
+ }
+ .world-overview-sidebar-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ color: var(--editor-shell-fg, #dbe9ff);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ }
+ .world-overview-sidebar-meta {
+ color: var(--editor-muted, #8fb2e1);
+ font-size: 10px;
+ font-weight: 600;
+ }
+ .world-overview-poi-list {
+ min-height: 0;
+ display: grid;
+ align-content: start;
+ gap: 6px;
+ overflow: auto;
+ padding-right: 2px;
+ }
+ .world-overview-poi-empty {
+ padding: 12px 10px;
+ border: 1px dashed color-mix(in srgb, var(--editor-muted, #8fb2e1) 28%, transparent);
+ border-radius: 10px;
+ color: var(--editor-muted, #8fb2e1);
+ font-size: 11px;
+ line-height: 1.45;
+ background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 55%, transparent);
+ }
+ .world-overview-poi-row {
+ width: 100%;
+ display: grid;
+ gap: 2px;
+ padding: 8px 9px;
+ border: 1px solid color-mix(in srgb, var(--editor-border, #2e426c) 92%, black 8%);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 88%, transparent);
+ color: var(--editor-shell-fg, #dbe9ff);
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
+ }
+ .world-overview-poi-row:hover {
+ border-color: var(--editor-border-strong, #4a73ae);
+ background: color-mix(in srgb, var(--editor-panel-bg-hover, #1a3f6d) 92%, transparent);
+ transform: translateY(-1px);
+ }
+ .world-overview-poi-row.is-active {
+ border-color: var(--editor-warn, #ffd166);
+ background: color-mix(in srgb, var(--editor-accent-soft, #22466e) 72%, var(--editor-panel-bg-alt, #132b4f));
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--editor-warn, #ffd166) 20%, transparent);
+ }
+ .world-overview-poi-title {
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1.25;
+ color: var(--editor-shell-fg, #f4f8ff);
+ }
+ .world-overview-poi-coords {
+ color: var(--editor-muted, #8fb2e1);
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+ }
+ .world-overview-main {
+ min-width: 0;
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ gap: 8px;
+ overflow: hidden;
+ }
+ .world-overview-action-banner {
+ display: flex;
+ align-items: center;
+ min-height: 0;
+ padding: 0 10px;
+ border: 1px solid color-mix(in srgb, var(--editor-border, #2e426c) 88%, transparent);
+ border-radius: 0;
+ border-left: 0;
+ border-right: 0;
+ border-top: 0;
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 90%, transparent);
+ color: var(--editor-text, #e6eefc);
+ font-size: 11px;
+ line-height: 1.3;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ .world-overview-action-banner.hidden {
+ display: none;
+ }
+ .world-overview-action-banner.is-idle {
+ border-color: color-mix(in srgb, var(--editor-border, #2e426c) 88%, transparent);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 90%, transparent);
+ color: var(--editor-muted-strong, #bcd2f4);
+ }
+ .world-overview-action-banner.is-move {
+ border-color: color-mix(in srgb, #ff7070 70%, var(--editor-border, #2e426c));
+ background: color-mix(in srgb, #5c1116 42%, var(--editor-panel-bg, #11203f));
+ }
+ .world-overview-action-banner.is-duplicate {
+ border-color: color-mix(in srgb, #ffd166 70%, var(--editor-border, #2e426c));
+ background: color-mix(in srgb, #5f4308 44%, var(--editor-panel-bg, #11203f));
+ }
+ .world-overview-viewport {
+ position: relative;
+ min-height: 0;
+ border: 1px solid var(--editor-border, #2e426c);
+ border-radius: 10px;
+ background:
+ linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--editor-preview-bg, #0d1b34) 92%, transparent) 0%,
+ color-mix(in srgb, var(--editor-stage-bg, #060a14) 96%, transparent) 100%
+ );
+ overflow: hidden;
+ cursor: grab;
+ }
+ .world-overview-viewport.is-panning {
+ cursor: grabbing;
+ }
+ .world-overview-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ image-rendering: pixelated;
+ }
+ .world-overview-empty {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 18px;
+ text-align: center;
+ color: var(--editor-muted-strong, #bcd2f4);
+ font-size: 12px;
+ background: color-mix(in srgb, var(--editor-stage-bg, #060a14) 82%, transparent);
+ }
+ .world-overview-empty.hidden {
+ display: none;
+ }
+ .world-overview-meta {
+ color: #b8d0f3;
+ font-size: 11px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-variant-numeric: tabular-nums;
+ }
+ .tile-art-window {
+ min-width: 520px;
+ width: 520px;
+ min-height: 628px;
+ }
+ .tile-art-preview-window {
+ min-width: 252px;
+ width: 252px;
+ min-height: 288px;
+ height: 288px;
+ }
+ .tile-art-preview-body {
+ min-height: 0;
+ display: block;
+ overflow: hidden;
+ }
+ .tile-art-preview-card {
+ height: 100%;
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto auto;
+ gap: 10px;
+ }
+ .tile-art-preview-stage {
+ min-height: 0;
+ border: 1px solid var(--editor-border, #2d426b);
+ border-radius: 12px;
+ background:
+ linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--editor-preview-bg, #0d1b34) 94%, transparent) 0%,
+ color-mix(in srgb, var(--editor-stage-bg, #060a14) 98%, transparent) 100%
+ );
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ }
+ .tile-art-preview-image {
+ width: 192px;
+ height: 192px;
+ object-fit: contain;
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
+ filter: drop-shadow(0 10px 18px rgba(3, 8, 18, 0.32));
+ }
+ .tile-art-preview-image.hidden,
+ .tile-art-preview-empty.hidden {
+ display: none;
+ }
+ .tile-art-preview-empty {
+ color: var(--editor-muted-strong, #bcd2f4);
+ font-size: 12px;
+ text-align: center;
+ padding: 16px;
+ }
+ .tile-art-preview-frame-label {
+ color: var(--editor-shell-fg, #eef6ff);
+ font-size: 13px;
+ font-weight: 700;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .tile-art-preview-meta {
+ color: var(--editor-muted-strong, #bcd2f4);
+ font-size: 11px;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+ }
+ .tile-art-window.is-tags-tab {
+ min-height: 0;
+ }
+ .tile-art-body {
+ min-height: 0;
+ display: block;
+ overflow: hidden;
+ }
+ .tile-art-window.is-tags-tab .tile-art-body {
+ overflow: hidden;
+ }
+ .tile-art-window .tool-popout-resize {
+ display: none;
+ }
+ .tile-art-card {
+ height: 100%;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
+ gap: 10px;
+ }
+ .tile-art-window.is-tags-tab .tile-art-card {
+ height: auto;
+ grid-template-rows: auto auto auto;
+ }
+ .tile-art-head {
+ display: grid;
+ gap: 2px;
+ }
+ .tile-art-title-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ gap: 10px;
+ }
+ .tile-art-title-stack {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+ }
+ .tile-art-title-display {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ min-width: 0;
+ }
+ .tile-art-record-title {
+ color: #e4efff;
+ font-size: 16px;
+ font-weight: 700;
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ flex: 0 1 auto;
+ }
+ .tile-art-record-meta {
+ color: #8fb2e1;
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ }
+ .tile-art-title-input {
+ width: 100%;
+ min-width: 0;
+ height: 30px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent);
+ color: var(--editor-control-fg, #eef6ff);
+ padding: 0 10px;
+ font-size: 12px;
+ }
+ .tile-art-title-input:focus {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-title-edit-btn {
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent);
+ color: var(--editor-control-fg, #eef6ff);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex: 0 0 auto;
+ }
+ .tile-art-title-edit-btn:hover {
+ border-color: var(--editor-border-strong, #5e84bd);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent);
+ }
+ .tile-art-title-edit-btn:focus-visible {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-title-edit-icon {
+ position: relative;
+ display: inline-block;
+ width: 13px;
+ height: 4px;
+ border-radius: 999px;
+ background: #7ee8c6;
+ transform: rotate(-35deg);
+ box-shadow: inset -3px 0 0 #ffcf70;
+ }
+ .tile-art-title-edit-icon::before {
+ content: "";
+ position: absolute;
+ left: -3px;
+ top: 0;
+ width: 0;
+ height: 0;
+ border-top: 2px solid transparent;
+ border-bottom: 2px solid transparent;
+ border-right: 3px solid #eef6ff;
+ }
+ .tile-art-title-edit-icon::after {
+ content: "";
+ position: absolute;
+ right: -2px;
+ top: 0;
+ width: 2px;
+ height: 4px;
+ border-radius: 999px;
+ background: #ff7f9f;
+ }
+ .tile-art-tabs {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 6px;
+ flex: 0 0 auto;
+ align-self: start;
+ padding-top: 0;
+ margin-top: 0;
+ }
+ .tile-art-tab-help-btn {
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent);
+ color: var(--editor-text-soft, #c7dbfb);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ flex: 0 0 auto;
+ }
+ .tile-art-tab-help-btn:hover {
+ border-color: var(--editor-border-strong, #5e84bd);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent);
+ }
+ .tile-art-tab-help-btn:focus-visible {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-tab-help-icon {
+ position: relative;
+ display: inline-block;
+ width: 14px;
+ height: 12px;
+ border: 1px solid color-mix(in srgb, var(--editor-shell-fg, #eef6ff) 88%, transparent);
+ border-radius: 3px;
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 76%, transparent);
+ box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08);
+ }
+ .tile-art-tab-help-icon::before {
+ content: "";
+ position: absolute;
+ left: 2px;
+ right: 2px;
+ top: 2px;
+ height: 2px;
+ border-radius: 999px;
+ background: #7ee8c6;
+ box-shadow:
+ 0 3px 0 #ffd166,
+ 0 6px 0 #64aaf8;
+ }
+ .tile-art-tab-help-icon::after {
+ content: "?";
+ position: absolute;
+ right: -4px;
+ bottom: -5px;
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ background: #ff5f6d;
+ color: #fff5f6;
+ font-size: 8px;
+ font-weight: 800;
+ line-height: 10px;
+ text-align: center;
+ box-shadow: 0 1px 4px rgba(3, 8, 18, 0.32);
+ }
+ .tile-art-tab-btn {
+ min-width: 74px;
+ height: 30px;
+ padding: 0 12px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent);
+ color: var(--editor-text-soft, #c7dbfb);
+ font-size: 11px;
+ font-weight: 700;
+ cursor: pointer;
+ white-space: nowrap;
+ }
+ .tile-art-tab-btn:hover {
+ border-color: var(--editor-border-strong, #5e84bd);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent);
+ }
+ .tile-art-tab-btn.is-active {
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 92%, transparent);
+ color: var(--editor-shell-fg, #eef6ff);
+ }
+ .tile-art-tab-btn:focus-visible {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-pane {
+ min-height: 0;
+ }
+ .tile-art-design-pane {
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ gap: 10px;
+ }
+ .tile-art-animation-pane {
+ min-width: 0;
+ padding: 8px;
+ border: 1px solid var(--editor-border, #2d426b);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 90%, transparent);
+ overflow: hidden;
+ }
+ .tile-art-animation-controls {
+ display: grid;
+ grid-template-columns: 60px minmax(0, 1fr);
+ align-items: start;
+ gap: 8px;
+ margin-bottom: 8px;
+ }
+ .tile-art-animation-speed-host {
+ grid-column: 2;
+ display: flex;
+ justify-content: flex-start;
+ }
+ .tile-art-animation-speed-host.hidden {
+ display: none;
+ }
+ .tile-art-animation-speed-menu {
+ min-width: 220px;
+ display: grid;
+ gap: 8px;
+ padding: 8px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 90%, transparent);
+ }
+ .tile-art-playback-options {
+ display: grid;
+ gap: 6px;
+ }
+ .tile-art-playback-option {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+ padding: 8px 10px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent);
+ color: var(--editor-control-fg, #eef6ff);
+ text-align: left;
+ cursor: pointer;
+ }
+ .tile-art-playback-option:hover {
+ border-color: var(--editor-border-strong, #5e84bd);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 92%, transparent);
+ }
+ .tile-art-playback-option.is-active {
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-playback-option-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--editor-shell-fg, #eef6ff);
+ }
+ .tile-art-playback-option-help {
+ font-size: 10px;
+ color: var(--editor-muted-strong, #bcd2f4);
+ }
+ .tile-art-animation-timeline-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: stretch;
+ gap: 8px;
+ min-width: 0;
+ }
+ .tile-art-animation-timeline {
+ display: flex;
+ align-items: stretch;
+ gap: 8px;
+ min-width: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding-bottom: 2px;
+ }
+ .tile-art-animation-add-host {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ }
+ .tile-art-frame-card {
+ width: 84px;
+ min-width: 84px;
+ min-height: 86px;
+ padding: 7px 7px 8px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent);
+ color: var(--editor-control-fg, #eef6ff);
+ display: grid;
+ grid-template-rows: 56px auto;
+ gap: 7px;
+ cursor: pointer;
+ text-align: left;
+ flex: 0 0 auto;
+ }
+ .tile-art-frame-card:hover {
+ border-color: var(--editor-border-strong, #5e84bd);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent);
+ }
+ .tile-art-frame-card.is-active {
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-frame-card.is-disabled {
+ opacity: 0.65;
+ }
+ .tile-art-frame-card.is-dragging {
+ opacity: 0.5;
+ }
+ .tile-art-frame-card.is-drop-before,
+ .tile-art-frame-card.is-drop-after {
+ position: relative;
+ }
+ .tile-art-frame-card.is-drop-before::before,
+ .tile-art-frame-card.is-drop-after::after {
+ content: "";
+ position: absolute;
+ top: 6px;
+ bottom: 6px;
+ width: 3px;
+ border-radius: 999px;
+ background: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 28%, transparent);
+ pointer-events: none;
+ }
+ .tile-art-frame-card.is-drop-before::before {
+ left: -6px;
+ }
+ .tile-art-frame-card.is-drop-after::after {
+ right: -6px;
+ }
+ .tile-art-frame-card:focus-visible {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-frame-preview {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid color-mix(in srgb, var(--editor-border, #2d426b) 82%, transparent);
+ border-radius: 8px;
+ background:
+ linear-gradient(45deg, rgba(255,255,255,0.05) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.05) 75%),
+ linear-gradient(45deg, rgba(255,255,255,0.05) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.05) 75%);
+ background-size: 10px 10px;
+ background-position: 0 0, 5px 5px;
+ overflow: hidden;
+ }
+ .tile-art-frame-preview-image {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ image-rendering: pixelated;
+ display: block;
+ }
+ .tile-art-frame-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--editor-text-soft, #c7dbfb);
+ min-width: 0;
+ }
+ .tile-art-frame-label-icons {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ flex: 0 0 auto;
+ }
+ .tile-art-frame-label-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 1.1em;
+ height: 1.1em;
+ border-radius: 999px;
+ font-size: 0.9em;
+ line-height: 1;
+ font-weight: 800;
+ border: 1px solid color-mix(in srgb, var(--editor-control-border, #35537f) 72%, transparent);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 82%, transparent);
+ }
+ .tile-art-frame-label-icon.is-check {
+ color: #7ee8c6;
+ }
+ .tile-art-frame-label-icon.is-x {
+ color: #ff9cad;
+ }
+ .tile-art-frame-label-icon.is-key {
+ color: #ffd166;
+ }
+ .tile-art-frame-label-text {
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1 1 auto;
+ }
+ .tile-art-frame-add-btn {
+ width: 50px;
+ min-width: 50px;
+ min-height: 52px;
+ padding: 5px;
+ justify-content: center;
+ align-items: center;
+ grid-template-rows: 1fr;
+ text-align: center;
+ border-style: dashed;
+ }
+ .tile-art-frame-add-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--editor-accent, #64aaf8) 48%, transparent);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent);
+ color: var(--editor-shell-fg, #eef6ff);
+ font-size: 14px;
+ line-height: 1;
+ font-weight: 500;
+ margin: 0 auto;
+ }
+ .tile-art-tool-icon-speed {
+ width: 14px;
+ height: 14px;
+ border: 1.5px solid #eef6ff;
+ border-radius: 999px;
+ box-sizing: border-box;
+ }
+ .tile-art-tool-icon-speed::before {
+ content: "";
+ position: absolute;
+ left: 6px;
+ top: 2px;
+ width: 1.5px;
+ height: 4px;
+ border-radius: 999px;
+ background: #ffd166;
+ transform-origin: bottom center;
+ }
+ .tile-art-tool-icon-speed::after {
+ content: "";
+ position: absolute;
+ left: 6px;
+ top: 6px;
+ width: 4px;
+ height: 1.5px;
+ border-radius: 999px;
+ background: #7ee8c6;
+ transform-origin: left center;
+ transform: rotate(28deg);
+ }
+ .tile-art-tool-icon-play {
+ position: relative;
+ width: 0;
+ height: 0;
+ border-top: 8px solid transparent;
+ border-bottom: 8px solid transparent;
+ border-left: 14px solid #4edb7f;
+ margin-left: 3px;
+ filter: drop-shadow(0 0 4px rgba(78, 219, 127, 0.3));
+ }
+ .tile-art-tool-icon-play::after {
+ content: "";
+ position: absolute;
+ left: -16px;
+ top: -9px;
+ width: 18px;
+ height: 18px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, #4edb7f 74%, transparent);
+ background: color-mix(in srgb, #4edb7f 10%, transparent);
+ }
+ .tile-art-tool-icon-playback {
+ position: relative;
+ width: 18px;
+ height: 16px;
+ }
+ .tile-art-tool-icon-playback::before {
+ content: "";
+ position: absolute;
+ left: 1px;
+ right: 1px;
+ top: 7px;
+ height: 2px;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #56db86 0%, #56db86 100%);
+ box-shadow:
+ -5px 0 0 -1px #56db86,
+ 5px 0 0 -1px #56db86;
+ }
+ .tile-art-tool-icon-playback::after {
+ content: "";
+ position: absolute;
+ left: 2px;
+ top: 0;
+ width: 12px;
+ height: 12px;
+ border-left: 2px solid #ff6f7f;
+ border-top: 2px solid #ff6f7f;
+ border-radius: 10px 0 0 0;
+ transform: rotate(-18deg);
+ }
+ .tile-art-tool-icon-playback .tile-art-tool-icon-playback-arrow-a,
+ .tile-art-tool-icon-playback .tile-art-tool-icon-playback-arrow-b {
+ position: absolute;
+ display: block;
+ width: 0;
+ height: 0;
+ border-top: 3px solid transparent;
+ border-bottom: 3px solid transparent;
+ }
+ .tile-art-tool-icon-playback .tile-art-tool-icon-playback-arrow-a {
+ right: -1px;
+ top: 5px;
+ border-left: 5px solid #56db86;
+ }
+ .tile-art-tool-icon-playback .tile-art-tool-icon-playback-arrow-b {
+ left: -1px;
+ top: 0;
+ border-right: 5px solid #ff6f7f;
+ transform: rotate(-30deg);
+ }
+ .tile-art-color-strip {
+ display: grid;
+ grid-template-columns: max-content max-content;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ }
+ .tile-art-current {
+ display: grid;
+ grid-template-rows: auto auto;
+ align-content: start;
+ gap: 8px;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ }
+ .tile-art-current-row {
+ display: flex;
+ justify-content: center;
+ }
+ .tile-art-current-swatch {
+ position: relative;
+ display: block;
+ width: 42px;
+ height: 42px;
+ border-radius: 6px;
+ border: 1px solid color-mix(in srgb, var(--editor-border, #2d426b) 72%, white 12%);
+ background: var(--swatch-color, transparent);
+ overflow: hidden;
+ box-shadow: inset 0 0 0 1px rgba(8, 17, 29, 0.34);
+ }
+ .tile-art-current-swatch.is-transparent {
+ background:
+ linear-gradient(45deg, rgba(255,255,255,0.12) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.12) 75%),
+ linear-gradient(45deg, rgba(255,255,255,0.12) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.12) 75%);
+ background-size: 12px 12px;
+ background-position: 0 0, 6px 6px;
+ border-style: dashed;
+ }
+ .tile-art-current-indicator {
+ position: absolute;
+ right: 3px;
+ bottom: 3px;
+ width: 14px;
+ height: 18px;
+ border: 1.5px solid rgba(226, 240, 255, 0.95);
+ border-radius: 8px 8px 9px 9px;
+ background: rgba(5, 10, 18, 0.62);
+ box-shadow: 0 2px 6px rgba(3, 8, 18, 0.28);
+ pointer-events: none;
+ overflow: hidden;
+ }
+ .tile-art-current-indicator::before {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 3px;
+ width: 1.5px;
+ height: 5px;
+ background: rgba(226, 240, 255, 0.9);
+ transform: translateX(-50%);
+ border-radius: 999px;
+ }
+ .tile-art-current-indicator-left,
+ .tile-art-current-indicator-right {
+ position: absolute;
+ top: 0;
+ width: 50%;
+ height: 7px;
+ background: rgba(226, 240, 255, 0.14);
+ }
+ .tile-art-current-indicator-left {
+ left: 0;
+ border-right: 1px solid rgba(226, 240, 255, 0.22);
+ border-radius: 7px 0 0 0;
+ }
+ .tile-art-current-indicator-right {
+ right: 0;
+ border-radius: 0 7px 0 0;
+ }
+ .tile-art-current-indicator.is-primary .tile-art-current-indicator-left,
+ .tile-art-current-indicator.is-secondary .tile-art-current-indicator-right {
+ background: #ff5f6d;
+ box-shadow: inset 0 0 0 1px rgba(111, 16, 25, 0.35);
+ }
+ .tile-art-current-meta {
+ color: #b7cdf5;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ line-height: 1.35;
+ padding-top: 2px;
+ }
+ .tile-art-tools {
+ display: grid;
+ grid-template-columns: 60px minmax(0, 1fr);
+ align-items: start;
+ gap: 8px;
+ }
+ .tile-art-tools-label {
+ color: #dbe9ff;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ }
+ .tile-art-tool-buttons {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+ .tile-art-tool-menu-host {
+ grid-column: 2;
+ display: flex;
+ justify-content: flex-start;
+ }
+ .tile-art-tool-menu-host.hidden {
+ display: none;
+ }
+ .tile-art-tool-btn {
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent);
+ color: var(--editor-control-fg, #eef6ff);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 700;
+ }
+ .tile-art-tool-btn:hover {
+ border-color: var(--editor-border-strong, #5e84bd);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 84%, transparent);
+ }
+ .tile-art-tool-btn.is-active {
+ border-color: var(--editor-warn, #ffd166);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-warn, #ffd166) 24%, transparent);
+ background: color-mix(in srgb, #5f4308 36%, var(--editor-panel-bg, #11203f));
+ color: #fff2c8;
+ }
+ .tile-art-tool-btn.is-open {
+ border-color: var(--editor-tool-armed, #7ee8c6);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-tool-armed, #7ee8c6) 24%, transparent);
+ }
+ .tile-art-tool-btn:focus-visible {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-tool-copy {
+ display: none;
+ }
+ .tile-art-tool-icon {
+ position: relative;
+ display: inline-block;
+ flex: 0 0 auto;
+ }
+ .tile-art-tool-icon-pencil {
+ width: 13px;
+ height: 4px;
+ border-radius: 999px;
+ background: #7ee8c6;
+ transform: rotate(-35deg);
+ box-shadow: inset -3px 0 0 #ffcf70;
+ }
+ .tile-art-tool-icon-pencil::before {
+ content: "";
+ position: absolute;
+ left: -3px;
+ top: 0;
+ width: 0;
+ height: 0;
+ border-top: 2px solid transparent;
+ border-bottom: 2px solid transparent;
+ border-right: 3px solid #eef6ff;
+ }
+ .tile-art-tool-icon-pencil::after {
+ content: "";
+ position: absolute;
+ right: -2px;
+ top: 0;
+ width: 2px;
+ height: 4px;
+ border-radius: 999px;
+ background: #ff7f9f;
+ }
+ .tile-art-tool-icon-line {
+ width: 15px;
+ height: 12px;
+ }
+ .tile-art-tool-icon-line::before {
+ content: "";
+ position: absolute;
+ left: 1px;
+ top: 8px;
+ width: 13px;
+ height: 2px;
+ border-radius: 999px;
+ background: #7ee8c6;
+ transform: rotate(-35deg);
+ transform-origin: left center;
+ box-shadow: 0 0 0 1px rgba(8, 17, 29, 0.24);
+ }
+ .tile-art-tool-icon-line::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 15px;
+ height: 12px;
+ background:
+ radial-gradient(circle at 2px 9px, #eef6ff 0 1.4px, transparent 1.5px),
+ radial-gradient(circle at 13px 2px, #ffd166 0 1.4px, transparent 1.5px);
+ }
+ .tile-art-tool-icon-bucket {
+ width: 13px;
+ height: 11px;
+ border: 2px solid #7ee8c6;
+ border-top: 0;
+ border-radius: 2px 2px 5px 5px;
+ transform: rotate(-18deg);
+ box-sizing: border-box;
+ }
+ .tile-art-tool-icon-bucket::before {
+ content: "";
+ position: absolute;
+ left: 1px;
+ top: -6px;
+ width: 7px;
+ height: 6px;
+ border: 2px solid #7ee8c6;
+ border-bottom: 0;
+ border-radius: 7px 7px 0 0;
+ box-sizing: border-box;
+ }
+ .tile-art-tool-icon-bucket::after {
+ content: "";
+ position: absolute;
+ right: -4px;
+ bottom: -2px;
+ width: 4px;
+ height: 6px;
+ border-radius: 999px;
+ background: #64aaf8;
+ transform: rotate(20deg);
+ opacity: 0.92;
+ }
+ .tile-art-tool-icon-shape {
+ width: 14px;
+ height: 11px;
+ border: 2px solid #7ee8c6;
+ border-radius: 2px;
+ box-sizing: border-box;
+ }
+ .tile-art-tool-icon-shape::before {
+ content: "";
+ position: absolute;
+ left: 3px;
+ top: 2px;
+ width: 6px;
+ height: 5px;
+ border: 2px solid #ffd166;
+ border-radius: 1px;
+ box-sizing: border-box;
+ background: rgba(255, 209, 102, 0.18);
+ }
+ .tile-art-tool-icon-eraser {
+ width: 14px;
+ height: 9px;
+ border-radius: 3px;
+ background: linear-gradient(135deg, #ff8aa6 0 48%, #eef6ff 48% 100%);
+ transform: rotate(-20deg);
+ box-shadow: inset 0 0 0 1px rgba(17, 29, 53, 0.45);
+ }
+ .tile-art-tool-icon-transform {
+ width: 14px;
+ height: 10px;
+ }
+ .tile-art-tool-icon-transform::before,
+ .tile-art-tool-icon-transform::after {
+ content: "";
+ position: absolute;
+ top: 4px;
+ width: 5px;
+ height: 2px;
+ background: #7ee8c6;
+ }
+ .tile-art-tool-icon-transform::before {
+ left: 1px;
+ box-shadow: -2px 0 0 #7ee8c6;
+ }
+ .tile-art-tool-icon-transform::after {
+ right: 1px;
+ box-shadow: 2px 0 0 #7ee8c6;
+ }
+ .tile-art-tool-icon-transform {
+ border-left: 5px solid #7ee8c6;
+ border-right: 5px solid #7ee8c6;
+ border-top: 5px solid transparent;
+ border-bottom: 5px solid transparent;
+ box-sizing: border-box;
+ }
+ .tile-art-tool-icon-opacity {
+ width: 14px;
+ height: 14px;
+ border-radius: 999px;
+ background: linear-gradient(135deg, #05070d 0%, #f5f8ff 100%);
+ box-shadow: inset 0 0 0 1px rgba(17, 29, 53, 0.55);
+ }
+ .tile-art-tool-icon-shift {
+ width: 14px;
+ height: 14px;
+ background: center / contain no-repeat url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'%3E%3Cg fill='none' stroke='%237ee8c6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 1.5v11M1.5 7h11'/%3E%3Cpath d='M7 1.5 5.4 3.1M7 1.5l1.6 1.6M7 12.5l-1.6-1.6M7 12.5l1.6-1.6M1.5 7l1.6-1.6M1.5 7l1.6 1.6M12.5 7l-1.6-1.6M12.5 7l-1.6 1.6'/%3E%3C/g%3E%3C/svg%3E");
+ }
+ .tile-art-tool-menu {
+ width: min(100%, 360px);
+ display: grid;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 94%, transparent);
+ box-shadow: 0 12px 28px rgba(5, 10, 22, 0.28);
+ }
+ .tile-art-tool-menu-title {
+ color: #eef6ff;
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ }
+ .tile-art-tool-menu-help {
+ color: #9db8e4;
+ font-size: 11px;
+ line-height: 1.4;
+ }
+ .tile-art-tool-menu-row {
+ display: grid;
+ gap: 6px;
+ }
+ .tile-art-tool-menu-label {
+ color: #dbe9ff;
+ font-size: 11px;
+ font-weight: 700;
+ }
+ .tile-art-tool-menu-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+ .tile-art-tool-menu-btn {
+ min-height: 28px;
+ padding: 0 10px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 86%, transparent);
+ color: var(--editor-control-fg, #eef6ff);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 700;
+ }
+ .tile-art-tool-menu-btn:hover {
+ border-color: var(--editor-border-strong, #5e84bd);
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 92%, transparent);
+ }
+ .tile-art-tool-menu-btn.is-active {
+ border-color: var(--editor-warn, #ffd166);
+ background: color-mix(in srgb, #5f4308 36%, var(--editor-panel-bg, #11203f));
+ color: #fff2c8;
+ }
+ .tile-art-tool-menu-btn:focus-visible {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-shape-menu-layout {
+ display: grid;
+ grid-template-columns: minmax(124px, auto) minmax(142px, 1fr);
+ gap: 8px;
+ align-items: start;
+ }
+ .tile-art-shape-menu-primary,
+ .tile-art-shape-menu-submenu {
+ display: grid;
+ gap: 6px;
+ }
+ .tile-art-shape-menu-submenu {
+ min-width: 0;
+ padding-left: 8px;
+ border-left: 1px solid color-mix(in srgb, var(--editor-control-border, #35537f) 72%, transparent);
+ }
+ .tile-art-shape-menu-subtitle {
+ margin-bottom: 2px;
+ }
+ .tile-art-shape-menu-trigger {
+ justify-content: space-between;
+ text-align: left;
+ }
+ .tile-art-opacity-controls {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 64px auto;
+ align-items: center;
+ gap: 8px;
+ }
+ .tile-art-opacity-range {
+ width: 100%;
+ accent-color: var(--editor-accent, #64aaf8);
+ appearance: none;
+ -webkit-appearance: none;
+ height: 6px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--editor-border, #2d426b) 78%, black 22%);
+ outline: none;
+ }
+ .tile-art-opacity-range::-webkit-slider-runnable-track {
+ height: 6px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ color-mix(in srgb, var(--editor-accent, #64aaf8) 88%, white 12%) 0%,
+ color-mix(in srgb, var(--editor-border, #2d426b) 78%, black 22%) 100%
+ );
+ }
+ .tile-art-opacity-range::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ margin-top: -4px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--editor-accent, #64aaf8) 62%, white 20%);
+ background: var(--editor-shell-fg, #eef6ff);
+ box-shadow: 0 2px 6px rgba(3, 8, 18, 0.34);
+ cursor: pointer;
+ }
+ .tile-art-opacity-range::-moz-range-track {
+ height: 6px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--editor-border, #2d426b) 78%, black 22%);
+ }
+ .tile-art-opacity-range::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--editor-accent, #64aaf8) 62%, white 20%);
+ background: var(--editor-shell-fg, #eef6ff);
+ box-shadow: 0 2px 6px rgba(3, 8, 18, 0.34);
+ cursor: pointer;
+ }
+ .tile-art-opacity-range:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 24%, transparent);
+ }
+ .tile-art-opacity-number {
+ width: 64px;
+ height: 30px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--editor-panel-bg, #11203f) 86%, transparent);
+ color: var(--editor-control-fg, #eef6ff);
+ padding: 0 8px;
+ font-size: 12px;
+ }
+ .tile-art-opacity-number:focus-visible {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-opacity-suffix {
+ color: #dbe9ff;
+ font-size: 11px;
+ font-weight: 700;
+ }
+ .tile-art-swatches {
+ display: grid;
+ grid-template-rows: repeat(3, 21px);
+ grid-auto-flow: column;
+ grid-auto-columns: 21px;
+ align-content: center;
+ align-self: center;
+ justify-content: start;
+ gap: 6px;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ }
+ .tile-art-swatch-btn {
+ position: relative;
+ width: 21px;
+ height: 21px;
+ padding: 0;
+ border: 1px solid var(--editor-border, #2d426b);
+ border-radius: 4px;
+ cursor: pointer;
+ background: var(--swatch-color, transparent);
+ overflow: hidden;
+ color: var(--editor-shell-fg, #f5f8ff);
+ }
+ .tile-art-swatch-indicator {
+ position: absolute;
+ width: 10px;
+ height: 13px;
+ border: 1px solid rgba(226, 240, 255, 0.95);
+ border-radius: 6px 6px 7px 7px;
+ background: rgba(5, 10, 18, 0.7);
+ box-shadow: 0 1px 3px rgba(3, 8, 18, 0.28);
+ pointer-events: none;
+ overflow: hidden;
+ z-index: 1;
+ }
+ .tile-art-swatch-indicator.is-primary {
+ left: 1px;
+ top: 1px;
+ }
+ .tile-art-swatch-indicator.is-secondary {
+ right: 1px;
+ bottom: 1px;
+ }
+ .tile-art-swatch-indicator::before {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 2px;
+ width: 1px;
+ height: 4px;
+ background: rgba(226, 240, 255, 0.92);
+ transform: translateX(-50%);
+ border-radius: 999px;
+ }
+ .tile-art-swatch-indicator-left,
+ .tile-art-swatch-indicator-right {
+ position: absolute;
+ top: 0;
+ width: 50%;
+ height: 5px;
+ background: rgba(226, 240, 255, 0.14);
+ }
+ .tile-art-swatch-indicator-left {
+ left: 0;
+ border-right: 1px solid rgba(226, 240, 255, 0.2);
+ border-radius: 5px 0 0 0;
+ }
+ .tile-art-swatch-indicator-right {
+ right: 0;
+ border-radius: 0 5px 0 0;
+ }
+ .tile-art-swatch-indicator.is-primary .tile-art-swatch-indicator-left,
+ .tile-art-swatch-indicator.is-secondary .tile-art-swatch-indicator-right {
+ background: #ff5f6d;
+ box-shadow: inset 0 0 0 1px rgba(111, 16, 25, 0.35);
+ }
+ .tile-art-swatch-btn:hover {
+ border-color: var(--editor-border-strong, #5e84bd);
+ }
+ .tile-art-swatch-btn:focus-visible {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-swatch-btn.is-active {
+ border-color: var(--editor-warn, #ffd166);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-warn, #ffd166) 24%, transparent);
+ }
+ .tile-art-swatch-btn.is-secondary {
+ border-color: #7ee8c6;
+ box-shadow: inset 0 0 0 1px rgba(126, 232, 198, 0.4);
+ }
+ .tile-art-swatch-btn.is-transparent {
+ background:
+ linear-gradient(45deg, rgba(255,255,255,0.08) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.08) 75%),
+ linear-gradient(45deg, rgba(255,255,255,0.08) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.08) 75%);
+ background-size: 12px 12px;
+ background-position: 0 0, 6px 6px;
+ border-style: dashed;
+ }
+ .tile-art-swatch-btn.is-transparent-action {
+ width: 32px;
+ height: 32px;
+ border-width: 2px;
+ border-color: rgba(214, 231, 255, 0.38);
+ border-radius: 6px;
+ box-shadow: inset 0 0 0 1px rgba(8, 17, 29, 0.58);
+ }
+ .tile-art-swatch-btn.is-transparent-action:hover {
+ border-color: #8fd0ff;
+ }
+ .tile-art-swatch-btn.is-transparent-action.is-active {
+ border-color: var(--editor-warn, #ffd166);
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--editor-warn, #ffd166) 24%, transparent),
+ inset 0 0 0 1px rgba(8, 17, 29, 0.58);
+ }
+ .tile-art-swatch-btn.is-transparent-action.is-secondary {
+ border-color: #7ee8c6;
+ box-shadow:
+ inset 0 0 0 1px rgba(8, 17, 29, 0.58),
+ 0 0 0 1px rgba(126, 232, 198, 0.26);
+ }
+ .tile-art-grid-wrap {
+ min-height: 0;
+ overflow: hidden;
+ padding: 8px;
+ border: 1px solid var(--editor-border, #2d426b);
+ border-radius: 12px;
+ background:
+ linear-gradient(var(--preview-bg-color, transparent), var(--preview-bg-color, transparent)),
+ color-mix(in srgb, var(--editor-shell-bg, #0a1020) 92%, transparent);
+ }
+ .tile-art-grid-stage {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: start;
+ gap: 8px;
+ min-width: 0;
+ }
+ .tile-art-grid-stage.is-template-drop-target .tile-art-grid-wrap {
+ border-color: var(--editor-tool-armed, #7ee8c6);
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--editor-tool-armed, #7ee8c6) 28%, transparent),
+ inset 0 0 0 1px rgba(126, 232, 198, 0.16);
+ }
+ .tile-art-grid-stage.is-template-drop-target .tile-art-used-swatches {
+ border-color: var(--editor-tool-armed, #7ee8c6);
+ box-shadow: inset 0 0 0 1px rgba(126, 232, 198, 0.14);
+ }
+ .tile-art-used-swatches {
+ width: 38px;
+ min-height: 40px;
+ padding: 5px 6px;
+ border: 1px solid var(--editor-border, #2d426b);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 88%, transparent);
+ display: grid;
+ grid-auto-rows: min-content;
+ align-content: start;
+ justify-items: center;
+ justify-content: center;
+ gap: 6px;
+ box-sizing: border-box;
+ }
+ .tile-art-used-swatch-btn {
+ width: 24px;
+ height: 24px;
+ border-radius: 5px;
+ }
+ .tile-art-used-swatch-btn.is-transparent-action {
+ width: 24px;
+ height: 24px;
+ border-width: 1px;
+ border-radius: 5px;
+ }
+ .tile-art-grid {
+ display: grid;
+ gap: 2px;
+ width: max-content;
+ margin: 0 auto;
+ touch-action: none;
+ }
+ .tile-art-grid.is-eyedropper .tile-art-cell {
+ cursor: inherit;
+ }
+ .tile-art-preview-hint {
+ color: var(--editor-muted, #8fb2e1);
+ font-size: 11px;
+ line-height: 1.35;
+ text-align: center;
+ padding: 0 4px;
+ }
+ .tile-art-shortcut-help-panel {
+ min-width: 250px;
+ display: grid;
+ gap: 8px;
+ }
+ .tile-art-shortcut-help-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--editor-muted, #9fb8e5);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+ .tile-art-shortcut-help-list {
+ display: grid;
+ gap: 6px;
+ }
+ .tile-art-shortcut-row {
+ grid-template-columns: minmax(0, 1fr) auto;
+ }
+ .tile-art-shortcut-action {
+ min-width: 72px;
+ }
+ .shortcut-mouse-dot.is-secondary {
+ background: #7ee8c6;
+ }
+ .tile-art-tags-pane {
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 10px;
+ padding: 2px 0 0;
+ }
+ .tile-art-window.is-tags-tab .tile-art-tags-pane {
+ grid-template-rows: auto auto;
+ align-content: start;
+ }
+ .tile-art-tag-field {
+ display: grid;
+ gap: 6px;
+ }
+ .tile-art-tag-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ }
+ .tile-art-tag-label {
+ color: var(--editor-shell-fg, #dbe9ff);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ }
+ .tile-art-tag-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ }
+ .tile-art-tag-input {
+ width: 100%;
+ min-width: 0;
+ height: 32px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent);
+ color: var(--editor-control-fg, #eef6ff);
+ padding: 0 10px;
+ font-size: 12px;
+ }
+ .tile-art-tag-input:focus {
+ outline: none;
+ border-color: var(--editor-accent, #64aaf8);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--editor-accent, #64aaf8) 22%, transparent);
+ }
+ .tile-art-tag-list {
+ min-height: 0;
+ display: flex;
+ align-content: flex-start;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 8px;
+ overflow: auto;
+ padding: 8px;
+ border: 1px solid var(--editor-border, #2d426b);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--editor-shell-bg, #0a1020) 92%, transparent);
+ }
+ .tile-art-window.is-tags-tab .tile-art-tag-list {
+ min-height: 80px;
+ max-height: 240px;
+ }
+ .tile-art-tags-empty {
+ color: var(--editor-muted, #8fb2e1);
+ font-size: 11px;
+ line-height: 1.45;
+ }
+ .tile-art-tag-chip {
+ max-width: 100%;
+ min-height: 28px;
+ padding: 0 10px;
+ border: 1px solid var(--editor-control-border, #35537f);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--editor-panel-bg-alt, #132b4f) 86%, transparent);
+ color: var(--editor-control-fg, #eef6ff);
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 700;
+ }
+ .tile-art-tag-chip:hover {
+ border-color: var(--editor-danger-border, #ff9aa7);
+ background: color-mix(in srgb, var(--editor-danger, #3c1a1a) 88%, transparent);
+ }
+ .tile-art-tag-chip-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .tile-art-tag-chip-remove {
+ color: var(--editor-status-error, #ffb7c3);
+ font-size: 10px;
+ line-height: 1;
+ text-transform: uppercase;
+ }
+ .tile-art-cell {
+ width: 21px;
+ height: 21px;
+ border: 1px solid rgba(255,255,255,0.08);
+ padding: 0;
+ cursor: crosshair;
+ background:
+ linear-gradient(var(--paint-color, transparent), var(--paint-color, transparent)),
+ linear-gradient(45deg, rgba(255,255,255,0.09) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.09) 75%),
+ linear-gradient(45deg, rgba(255,255,255,0.09) 25%, transparent 25%, transparent 75%, rgba(255,255,255,0.09) 75%),
+ linear-gradient(var(--preview-bg-color, transparent), var(--preview-bg-color, transparent));
+ background-size: cover, 12px 12px, 12px 12px, cover;
+ background-position: 0 0, 0 0, 6px 6px, 0 0;
+ }
+ .tile-art-cell:hover {
+ border-color: color-mix(in srgb, var(--editor-warn, #ffd166) 60%, transparent);
+ }
+ .tile-art-cell.is-transparent {
+ border-style: dashed;
+ }
+ .tile-art-footer {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ }
+ .tile-art-window.is-tags-tab .tile-art-footer {
+ display: none;
+ }
+ .tile-art-status {
+ color: var(--editor-muted, #8fb2e1);
+ font-size: 11px;
+ line-height: 1.4;
+ min-height: 16px;
+ }
+ .tile-art-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ }
+ .paint-palette {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 12px;
+ }
+ .paint-swatch-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 28px;
+ padding: 0 8px;
+ border: 1px solid #2d426e;
+ border-radius: 7px;
+ background: #132447;
+ color: #d6e7ff;
+ font-size: 11px;
+ cursor: pointer;
+ }
+ .paint-swatch-btn.active {
+ border-color: #ffd166;
+ background: #2b4669;
+ }
+ /* ââ€Âۉâ€Â€ AtTooltip floating menu ââ€Âۉâ€Â€ */
+ .at-tooltip-panel {
+ position: fixed;
+ z-index: 9999;
+ background: #0f2344;
+ border: 1px solid #3c5e95;
+ border-radius: 10px;
+ padding: 5px;
+ min-width: 190px;
+ max-width: 290px;
+ overflow-y: auto;
+ display: grid;
+ gap: 3px;
+ box-shadow: 0 6px 28px rgba(0,0,20,0.8);
+ outline: none;
+ }
+ .at-tooltip-panel:focus-visible {
+ border-color: #64aaf8;
+ box-shadow: 0 0 0 1px rgba(100,170,248,0.4), 0 6px 28px rgba(0,0,20,0.8);
+ }
+ .at-tooltip-panel:empty::after {
+ content: 'No options';
+ color: #7a9acc;
+ font-size: 11px;
+ padding: 6px 8px;
+ }
+ .at-tooltip-label {
+ padding: 4px 8px 2px;
+ color: #9fb8e5;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+ .at-tooltip-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 7px;
+ height: 32px;
+ padding: 0 8px;
+ border: 1px solid #35537f;
+ border-radius: 7px;
+ background: #132b4f;
+ color: #d6e7ff;
+ font-size: 11px;
+ text-align: left;
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+ .at-tooltip-item:hover, .at-tooltip-item.active, .at-tooltip-item.is-active {
+ background: #1a3f6d;
+ border-color: #64aaf8;
+ }
+ .at-tooltip-item:focus-visible {
+ background: #1a3f6d;
+ border-color: #64aaf8;
+ outline: none;
+ }
+ .at-tooltip-item:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ }
+ .at-tooltip-item img {
+ flex: 0 0 22px;
+ width: 22px;
+ height: 22px;
+ border-radius: 4px;
+ object-fit: contain;
+ image-rendering: pixelated;
+ background: #0c1730;
+ }
+ .at-tooltip-item.has-submenu {
+ padding-right: 6px;
+ }
+ .at-tooltip-item.at-tooltip-icon-item {
+ width: 34px;
+ height: 34px;
+ min-height: 34px;
+ padding: 0;
+ justify-content: center;
+ border-radius: 10px;
+ overflow: hidden;
+ }
+ .at-tooltip-item.at-tooltip-icon-item.has-submenu {
+ padding-right: 0;
+ }
+ .at-tooltip-panel.at-tooltip-icon-stack-panel,
+ .at-tooltip-panel.at-tooltip-icon-row-panel,
+ .at-tooltip-panel.at-tooltip-icon-grid-panel {
+ min-width: 0;
+ width: max-content;
+ max-width: none;
+ grid-auto-rows: 34px;
+ gap: 6px;
+ padding: 6px;
+ }
+ .at-tooltip-panel.at-tooltip-icon-stack-panel {
+ grid-template-columns: 34px;
+ }
+ .at-tooltip-panel.at-tooltip-icon-row-panel {
+ grid-auto-flow: column;
+ grid-auto-columns: 34px;
+ grid-template-columns: none;
+ }
+ .at-tooltip-panel.at-tooltip-icon-grid-panel {
+ grid-template-columns: repeat(3, 34px);
+ }
+ .at-tooltip-panel.at-tooltip-icon-grid-panel.at-tooltip-icon-grid-panel-wide {
+ grid-template-columns: repeat(4, 34px);
+ }
+ .at-tooltip-panel.at-tooltip-icon-stack-panel:empty::after,
+ .at-tooltip-panel.at-tooltip-icon-row-panel:empty::after,
+ .at-tooltip-panel.at-tooltip-icon-grid-panel:empty::after {
+ display: none;
+ }
+ .at-tooltip-panel.at-tooltip-icon-stack-panel .at-tooltip-label,
+ .at-tooltip-panel.at-tooltip-icon-row-panel .at-tooltip-label,
+ .at-tooltip-panel.at-tooltip-icon-grid-panel .at-tooltip-label,
+ .at-tooltip-item.at-tooltip-icon-item .at-tooltip-submenu-arrow {
+ display: none;
+ }
+ .at-tooltip-submenu-arrow {
+ margin-left: auto;
+ color: #9fb8e5;
+ font-size: 14px;
+ line-height: 1;
+ opacity: 0.92;
+ }
+ .tile-art-menu-shape-icon,
+ .tile-art-menu-line-icon,
+ .tile-art-menu-transform-icon {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ flex: 0 0 18px;
+ }
+ .tile-art-menu-shape-outline,
+ .tile-art-menu-shape-fill,
+ .tile-art-menu-line-stroke,
+ .tile-art-menu-transform-part {
+ position: absolute;
+ box-sizing: border-box;
+ pointer-events: none;
+ }
+ .tile-art-menu-shape-icon.is-rectangle .tile-art-menu-shape-outline {
+ inset: 3px;
+ border: 2px solid #7ee8c6;
+ border-radius: 3px;
+ background: transparent;
+ }
+ .tile-art-menu-shape-icon.is-rectangle .tile-art-menu-shape-fill {
+ inset: 6px;
+ border-radius: 2px;
+ background: #ffd166;
+ }
+ .tile-art-menu-shape-icon.is-circle .tile-art-menu-shape-outline {
+ inset: 3px;
+ border: 2px solid #7ee8c6;
+ border-radius: 999px;
+ background: transparent;
+ }
+ .tile-art-menu-shape-icon.is-circle .tile-art-menu-shape-fill {
+ inset: 6px;
+ border-radius: 999px;
+ background: #ffd166;
+ }
+ .tile-art-menu-shape-icon.is-triangle .tile-art-menu-shape-outline {
+ inset: 1px;
+ clip-path: polygon(50% 12%, 16% 84%, 84% 84%);
+ background: #7ee8c6;
+ }
+ .tile-art-menu-shape-icon.is-triangle .tile-art-menu-shape-fill {
+ inset: 1px;
+ clip-path: polygon(50% 27%, 31% 72%, 69% 72%);
+ background: #ffd166;
+ }
+ .tile-art-menu-shape-icon.is-outline .tile-art-menu-shape-fill {
+ display: none;
+ }
+ .tile-art-menu-shape-icon.is-fill .tile-art-menu-shape-outline {
+ display: none;
+ }
+ .tile-art-menu-shape-icon.is-fill.is-draw .tile-art-menu-shape-fill,
+ .tile-art-menu-shape-icon.is-two-tone .tile-art-menu-shape-fill {
+ background: #ffd166;
+ }
+ .tile-art-menu-shape-icon.is-fill.is-erase .tile-art-menu-shape-fill,
+ .tile-art-menu-shape-icon.is-two-tone.is-erase .tile-art-menu-shape-fill {
+ background: #ff8aa6;
+ }
+ .tile-art-menu-shape-icon.is-outline.is-erase.is-rectangle .tile-art-menu-shape-outline,
+ .tile-art-menu-shape-icon.is-outline.is-erase.is-circle .tile-art-menu-shape-outline {
+ border-color: #ff8aa6;
+ }
+ .tile-art-menu-shape-icon.is-outline.is-erase.is-triangle .tile-art-menu-shape-outline {
+ background: #ff8aa6;
+ }
+ .tile-art-menu-shape-icon.is-fill.is-erase .tile-art-menu-shape-fill {
+ background: #ff8aa6;
+ }
+ .tile-art-menu-line-stroke {
+ left: 1px;
+ top: 8px;
+ width: 16px;
+ height: 0;
+ border-top: 3px solid #ffd166;
+ transform: rotate(-34deg);
+ transform-origin: center;
+ border-radius: 999px;
+ }
+ .tile-art-menu-line-icon.is-erase .tile-art-menu-line-stroke {
+ border-top-color: #ff8aa6;
+ }
+ .tile-art-menu-transform-icon .tile-art-menu-transform-part {
+ background: transparent;
+ }
+ .tile-art-menu-transform-icon.is-rotate .part-a,
+ .tile-art-menu-transform-icon.is-rotate-cw .part-a,
+ .tile-art-menu-transform-icon.is-rotate-ccw .part-a {
+ left: 2px;
+ top: 2px;
+ width: 2px;
+ height: 14px;
+ border-radius: 999px;
+ background: #36e07a;
+ }
+ .tile-art-menu-transform-icon.is-rotate .part-b,
+ .tile-art-menu-transform-icon.is-rotate-cw .part-b,
+ .tile-art-menu-transform-icon.is-rotate-ccw .part-b {
+ left: 2px;
+ bottom: 2px;
+ width: 14px;
+ height: 2px;
+ border-radius: 999px;
+ background: #ff4b57;
+ }
+ .tile-art-menu-transform-icon.is-rotate-ccw .part-a {
+ background: #ff4b57;
+ }
+ .tile-art-menu-transform-icon.is-rotate-ccw .part-b {
+ background: #36e07a;
+ }
+ .tile-art-menu-transform-icon.is-flip .part-a,
+ .tile-art-menu-transform-icon.is-flip-h .part-a,
+ .tile-art-menu-transform-icon.is-flip-h .part-b {
+ top: 3px;
+ width: 7px;
+ height: 12px;
+ }
+ .tile-art-menu-transform-icon.is-flip .part-a,
+ .tile-art-menu-transform-icon.is-flip-h .part-a {
+ left: 1px;
+ clip-path: polygon(100% 0, 0 50%, 100% 100%);
+ background: #ff4b57;
+ }
+ .tile-art-menu-transform-icon.is-flip .part-b,
+ .tile-art-menu-transform-icon.is-flip-h .part-b {
+ right: 1px;
+ clip-path: polygon(0 0, 100% 50%, 0 100%);
+ background: #36e07a;
+ }
+ .tile-art-menu-transform-icon.is-flip-v .part-a,
+ .tile-art-menu-transform-icon.is-flip-v .part-b {
+ left: 2px;
+ width: 14px;
+ height: 7px;
+ }
+ .tile-art-menu-transform-icon.is-flip-v .part-a {
+ top: 2px;
+ clip-path: polygon(50% 0, 100% 100%, 0 100%);
+ background: #ff4b57;
+ }
+ .tile-art-menu-transform-icon.is-flip-v .part-b {
+ bottom: 2px;
+ clip-path: polygon(0 0, 100% 0, 50% 100%);
+ background: #36e07a;
+ }
+ .at-tooltip-separator {
+ height: 1px;
+ background: #2a426a;
+ margin: 2px 0;
+ }
+
+ .legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ font-size: 11px;
+ color: #b6caed;
+ }
+ .legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ border: 1px solid #2d426e;
+ border-radius: 6px;
+ padding: 3px 6px;
+ background: #132447;
+ }
+ .swatch {
+ width: 11px;
+ height: 11px;
+ border-radius: 2px;
+ border: 1px solid rgba(255,255,255,0.2);
+ }
+
+
+`;
+
+export const WORLDSHAPER_STUDIO_STYLE_STAGE = `
+ .stage {
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ background: #060a14;
+ position: relative;
+ }
+ .meta {
+ height: 28px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 10px;
+ border-bottom: 1px solid #2a3d63;
+ font-size: 12px;
+ color: #a8bfeb;
+ flex-shrink: 0;
+ }
+ .meta-main {
+ min-width: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .meta-stats {
+ margin-left: auto;
+ color: #d7e7ff;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ }
+ .viewport {
+ position: relative;
+ min-height: 0;
+ flex: 1;
+ overflow: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ cursor: crosshair;
+ user-select: none;
+ background: var(--editor-stage-bg, #060a14);
+ }
+ .viewport::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ display: none;
+ }
+ .canvas-tool-btn {
+ position: absolute;
+ top: 38px;
+ left: 10px;
+ z-index: 4;
+ width: 42px;
+ height: 42px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #35507f;
+ border-radius: 10px;
+ background: rgba(10, 20, 37, 0.92);
+ color: #d9ebff;
+ box-shadow: 0 8px 20px rgba(3, 8, 18, 0.28);
+ cursor: pointer;
+ transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
+ }
+ .canvas-tool-btn:hover {
+ background: #17315b;
+ border-color: #4f78b5;
+ transform: translateY(-1px);
+ }
+ .canvas-tool-btn.active {
+ border-color: #5fc3ff;
+ background: #173d57;
+ color: #f4fbff;
+ }
+ .canvas-tool-btn:focus-visible {
+ outline: 2px solid rgba(95,195,255,0.75);
+ outline-offset: 2px;
+ }
+ .canvas-tool-btn-icon {
+ position: relative;
+ width: 20px;
+ height: 20px;
+ display: block;
+ }
+ .canvas-tool-btn-icon::before {
+ content: "";
+ position: absolute;
+ left: 1px;
+ top: 1px;
+ width: 12px;
+ height: 12px;
+ border: 2px dashed currentColor;
+ border-radius: 2px;
+ box-sizing: border-box;
+ }
+ .canvas-tool-btn-icon::after {
+ content: "";
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ width: 0;
+ height: 0;
+ border-left: 7px solid currentColor;
+ border-top: 11px solid transparent;
+ transform: rotate(-18deg);
+ transform-origin: center;
+ }
+ .viewport-layer {
+ position: sticky;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 0;
+ overflow: visible;
+ z-index: 1;
+ pointer-events: none;
+ }
+ .pixi-host {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ overflow: hidden;
+ }
+ .pixi-stage-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ image-rendering: pixelated;
+ }
+ .viewport-layer canvas {
+ position: absolute;
+ inset: 0;
+ display: block;
+ width: 100%;
+ height: 100%;
+ pointer-events: auto;
+ }
+ .viewport-spacer {
+ position: relative;
+ z-index: 0;
+ pointer-events: none;
+ }
+
+`;
diff --git a/src/worldshaperStudio/domStyles.ts b/src/worldshaperStudio/domStyles.ts
new file mode 100644
index 0000000..716a10b
--- /dev/null
+++ b/src/worldshaperStudio/domStyles.ts
@@ -0,0 +1,15 @@
+import { buildWorldshaperStudioThemeOverrideCss } from "./themePresets";
+import {
+ WORLDSHAPER_STUDIO_STYLE_SHELL,
+ WORLDSHAPER_STUDIO_STYLE_SIDEBAR,
+ WORLDSHAPER_STUDIO_STYLE_STAGE,
+} from "./domStyleSections";
+
+export function buildWorldshaperStudioStyles(): string {
+ return (
+ WORLDSHAPER_STUDIO_STYLE_SHELL
+ + WORLDSHAPER_STUDIO_STYLE_SIDEBAR
+ + WORLDSHAPER_STUDIO_STYLE_STAGE
+ + buildWorldshaperStudioThemeOverrideCss()
+ );
+}
diff --git a/src/worldshaperStudio/runtime.ts b/src/worldshaperStudio/runtime.ts
index 3c6c8d0..74c6eb7 100644
--- a/src/worldshaperStudio/runtime.ts
+++ b/src/worldshaperStudio/runtime.ts
@@ -1,4 +1,4 @@
-/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars, no-empty, no-useless-escape */
+/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars, no-empty, no-useless-escape */
// @ts-nocheck
import {
buildSpritePreviewDataUrl,
@@ -10,12 +10,7 @@ import {
normalizeImageRecordForSave,
normalizeTileRecordForSave,
} from "../editorCore";
-import {
- buildSpriteCatalog,
- buildTileCatalogById,
- DEFAULT_MAP_BACKGROUND_COLOR,
- DEFAULT_TILE_COLOR,
-} from "../components/worldshaperShared";
+import { DEFAULT_TILE_COLOR } from "../components/worldshaperShared";
import type { WorldshaperStudioBootstrap } from "./bootstrap";
import {
cacheStandaloneWorldshaperBootstrap,
@@ -31,13 +26,9 @@ import {
openWorldshaperHeightViewerWindow,
persistWorldshaperStudioBounds,
} from "./windowing";
-import { createHistoryController } from "./historyController";
import { createHistoryStateStore } from "./historyStateStore";
-import { createInteractionController } from "./interactionController";
-import { createImportController } from "./importController";
import { createMapDocumentController } from "./mapDocumentController";
import { createMapDocumentStore } from "./mapDocumentStore";
-import { createNpcController } from "./npcController";
import {
createPanelFolderLayoutFolder,
deletePanelFolderLayoutFolder,
@@ -46,22 +37,11 @@ import {
togglePanelFolderLayoutFolder,
} from "./panelFolders";
import { createEditorUiStore } from "./editorUiStore";
-import { createChangelogSplashWindowController } from "./changelogSplashWindowController";
-import { createEntityEditorWindowController } from "./entityEditorWindowController";
-import { createEngineOverrideWindowController } from "./engineOverrideWindowController";
import {
getEngineOverrideValue,
normalizeEngineOverrideEntries,
} from "./engineOverrides";
-import { createPersistenceController } from "./persistenceController";
import { createPopupSessionStore } from "./popupSessionStore";
-import { createRenderController } from "./renderController";
-import { createSidebarController } from "./sidebarController";
-import { createStatusLogWindowController } from "./statusLogWindowController";
-import { createTileArtEditorWindowController } from "./tileArtEditorWindowController";
-import { createToolWindowController } from "./toolWindowController";
-import { createWorldOverviewWindowController } from "./worldOverviewWindowController";
-import { createDebouncedCallback } from "./debounce";
import {
buildImageRecordFromSpriteRecord,
buildImageRecordFromTileRecord,
@@ -80,215 +60,42 @@ import {
persistEditorSettings,
} from "./themePresets";
import { createAtTooltip } from "./tooltip";
-
-function cloneValue(value) {
- if (typeof structuredClone === "function") {
- return structuredClone(value);
- }
- return value == null ? value : JSON.parse(JSON.stringify(value));
-}
-
-function createFilledRows(width, height, fillChar) {
- return Array.from({ length: Math.max(1, Number(height) || 1) }, () => String(fillChar || " ").repeat(Math.max(1, Number(width) || 1)));
-}
-
-function writeRowSegment(rows, y, x, segment) {
- if (!Array.isArray(rows) || !segment) {
- return;
- }
- const targetY = Math.floor(Number(y) || 0);
- if (targetY < 0 || targetY >= rows.length) {
- return;
- }
- const safeX = Math.max(0, Math.floor(Number(x) || 0));
- const sourceRow = String(rows[targetY] || "");
- const paddedRow = sourceRow.length >= safeX
- ? sourceRow
- : (sourceRow + " ".repeat(Math.max(0, safeX - sourceRow.length)));
- const before = paddedRow.slice(0, safeX);
- const afterStart = safeX + segment.length;
- const after = afterStart < paddedRow.length ? paddedRow.slice(afterStart) : "";
- rows[targetY] = before + segment + after;
-}
-
-function composeWorldRoomLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY, worldWidth, worldHeight) {
- const layerMap = new Map();
- (Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
- const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
- const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
- const offsetX = (baseChunkX - originChunkX) * chunkWidth;
- const offsetY = (baseChunkY - originChunkY) * chunkHeight;
- const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
- rawLayers.forEach((rawLayer) => {
- const layerNumber = Number(rawLayer?.layer) || 0;
- const fillChar = layerNumber === 0 ? "." : " ";
- if (!layerMap.has(layerNumber)) {
- layerMap.set(layerNumber, {
- layer: layerNumber,
- name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined,
- rows: createFilledRows(worldWidth, worldHeight, fillChar),
- instanceIds: [],
- });
- }
- const targetLayer = layerMap.get(layerNumber);
- const sourceRows = Array.isArray(rawLayer?.rows) ? rawLayer.rows.map((row) => String(row || "")) : [];
- sourceRows.forEach((row, localY) => {
- const targetY = offsetY + localY;
- if (targetY < 0 || targetY >= targetLayer.rows.length) {
- return;
- }
- const maxWidth = Math.max(0, worldWidth - offsetX);
- writeRowSegment(targetLayer.rows, targetY, offsetX, row.slice(0, maxWidth));
- });
- const sourceInstanceIds = Array.isArray(rawLayer?.instanceIds)
- ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean)
- : [];
- targetLayer.instanceIds = Array.from(new Set([...(targetLayer.instanceIds || []), ...sourceInstanceIds]));
- });
- });
- if (!layerMap.has(0)) {
- layerMap.set(0, {
- layer: 0,
- rows: createFilledRows(worldWidth, worldHeight, "."),
- instanceIds: [],
- });
- }
- return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
-}
-
-function composeWorldHeightLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY) {
- const patches = [];
- (Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
- const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
- const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
- const offsetX = (baseChunkX - originChunkX) * chunkWidth;
- const offsetY = (baseChunkY - originChunkY) * chunkHeight;
- const rawHeightLayers = Array.isArray(chunk?.heightLayers) ? chunk.heightLayers : [];
- rawHeightLayers.forEach((entry, index) => {
- const fallbackId = `height_${baseChunkX}_${baseChunkY}_${index + 1}`;
- patches.push({
- id: String(entry?.id || fallbackId).trim() || fallbackId,
- name: typeof entry?.name === "string" && String(entry.name).trim() ? String(entry.name).trim() : undefined,
- z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
- x: offsetX + Math.max(0, Number(entry?.x) || 0),
- y: offsetY + Math.max(0, Number(entry?.y) || 0),
- rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
- });
- });
- });
- return patches.sort((a, b) => {
- if (a.z !== b.z) {
- return a.z - b.z;
- }
- return String(a.name || a.id).localeCompare(String(b.name || b.id));
- });
-}
-
-function buildWorldLayerMetadata(chunks) {
- const layerMap = new Map();
- (Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
- const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
- rawLayers.forEach((rawLayer) => {
- const layerNumber = Number(rawLayer?.layer) || 0;
- if (layerMap.has(layerNumber)) {
- return;
- }
- layerMap.set(layerNumber, {
- layer: layerNumber,
- name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined,
- rows: [],
- instanceIds: Array.isArray(rawLayer?.instanceIds) ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean) : [],
- });
- });
- });
- if (!layerMap.has(0)) {
- layerMap.set(0, {
- layer: 0,
- rows: [],
- instanceIds: [],
- });
- }
- if (!Array.from(layerMap.keys()).some((layerNumber) => layerNumber > 0)) {
- layerMap.set(1, {
- layer: 1,
- rows: [],
- instanceIds: [],
- });
- }
- return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
-}
-
-function getContentRecords(payload, key) {
- const records = payload && Array.isArray(payload[key]) ? payload[key] : [];
- return records.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
-}
-
-function buildSpriteCatalogFromBootstrap(bootstrap) {
- const spriteRecords = getContentRecords(bootstrap?.contentByType?.sprites, "sprites");
- if (spriteRecords.length > 0) {
- return buildSpriteCatalog(spriteRecords, buildSpritePreviewDataUrl);
- }
- return cloneValue(bootstrap?.spriteCatalog) || {};
-}
-
-function buildTileCatalogByIdFromBootstrap(bootstrap) {
- const tileRecords = getContentRecords(bootstrap?.contentByType?.tiles, "tiles");
- if (tileRecords.length > 0) {
- return buildTileCatalogById(tileRecords, buildSpritePreviewDataUrl);
- }
- return cloneValue(bootstrap?.tileCatalogById) || {};
-}
-
-function buildNpcOverlaysFromWorldChunks(chunks, spriteCatalog, chunkWidth, chunkHeight, originChunkX, originChunkY) {
- return (Array.isArray(chunks) ? chunks : []).flatMap((chunk) => {
- const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
- const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
- const offsetX = (baseChunkX - originChunkX) * chunkWidth;
- const offsetY = (baseChunkY - originChunkY) * chunkHeight;
- const instances = Array.isArray(chunk?.instances) ? chunk.instances : [];
- return instances
- .filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
- .map((entry) => {
- const record = entry.record && typeof entry.record === "object" && !Array.isArray(entry.record)
- ? cloneValue(entry.record)
- : {};
- const spriteId = String(record.spriteId || entry.spriteId || "").trim();
- const spriteEntry = spriteCatalog[spriteId] || null;
- const overlayX = offsetX + Math.max(0, Number(entry.x) || 0);
- const overlayY = offsetY + Math.max(0, Number(entry.y) || 0);
- record.position = {
- x: overlayX,
- y: overlayY,
- };
- return {
- id: String(entry.id || "").trim(),
- layer: Number(entry.layer) || 0,
- name: String(record.name || entry.id || "NPC"),
- spriteId,
- x: overlayX,
- y: overlayY,
- dataUrl: spriteEntry ? spriteEntry.dataUrl : null,
- spriteWidth: spriteEntry ? spriteEntry.spriteWidth : 28,
- spriteHeight: spriteEntry ? spriteEntry.spriteHeight : 28,
- opacity: spriteEntry ? spriteEntry.opacity : 1,
- record,
- };
- })
- .filter((entry) => entry.id);
- });
-}
-
-const MAX_WORLD_CHUNK_CACHE_ENTRIES = 256;
-const MAX_DYNAMIC_WORLD_CHUNK_RADIUS = 4;
-const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~=";
+import { initializeRuntimeControllers } from "./runtimeControllerBootstrap";
+import {
+ buildNpcOverlaysFromWorldChunks,
+ buildSpriteCatalogFromBootstrap,
+ buildTileCatalogByIdFromBootstrap,
+ cloneRuntimeValue,
+ createInitialWorldRuntimeState,
+ MAX_DYNAMIC_WORLD_CHUNK_RADIUS,
+ MAX_WORLD_CHUNK_CACHE_ENTRIES,
+ normalizeMapBackgroundColor,
+ TILE_SYMBOL_POOL,
+} from "./runtimeBootstrapHelpers";
+import { createRuntimeLogging } from "./runtimeLogging";
+import {
+ buildChunkHeightLayersFromDocument as buildChunkHeightLayersFromDocumentHelper,
+ buildChunkInstancesFromDocument as buildChunkInstancesFromDocumentHelper,
+ buildWorldChunkLayerInstanceIds,
+ buildWorldLayerMetadata,
+ cloneWorldChunkHeightLayers,
+ composeWorldHeightLayers,
+ composeWorldRoomLayers,
+ createEmptyWorldChunkPayload as createEmptyWorldChunkPayloadHelper,
+ createFilledRows,
+ isChunkFillSymbol,
+ isWorldChunkPayloadEmpty as isWorldChunkPayloadEmptyHelper,
+ normalizeCachedWorldChunkPayload as normalizeCachedWorldChunkPayloadHelper,
+ normalizeWorldChunkInstances as normalizeWorldChunkInstancesHelper,
+ normalizeWorldChunkRows,
+ sliceNormalizedRows,
+ transformChunkHeightPatch,
+ transformChunkLocalCoord,
+ transformChunkRows,
+ transformWorldChunkPayload as transformWorldChunkPayloadHelper,
+} from "./worldChunkRuntimeHelpers";
export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, initialEditorSettings: unknown = getDefaultEditorSettings()): void {
- function normalizeMapBackgroundColor(value, fallback) {
- const f = fallback || DEFAULT_MAP_BACKGROUND_COLOR;
- const raw = String(value || "").trim();
- return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toUpperCase() : f;
- }
-
const baseTileSize = Math.max(8, Number(bootstrap.tileSize) || 32);
const minZoomLevel = 0.5;
const maxZoomLevel = 4;
@@ -296,51 +103,12 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
let tileSize = baseTileSize;
let currentMapId = String(bootstrap.mapId || "").trim();
let currentBaseRows = Array.isArray(bootstrap.baseRows) ? bootstrap.baseRows.map((row) => String(row ?? "")) : [];
- const worldRuntimeState = {
- enabled: !!String(bootstrap.worldId || bootstrap.mapId || "").trim(),
- worldId: String(bootstrap.worldId || bootstrap.mapId || "").trim(),
- worldName: String(bootstrap.worldName || bootstrap.mapName || bootstrap.worldId || bootstrap.mapId || "World").trim() || "World",
- defaultBackgroundTileId: String(bootstrap.backgroundTileId || "").trim(),
- heightBlurStep: Math.max(0, Math.min(1, Number(bootstrap.heightBlurStep ?? bootstrap.heightDetailStep) || 0.1)),
- chunkWidth: Math.max(1, Number(bootstrap.worldChunkWidth) || 32),
- chunkHeight: Math.max(1, Number(bootstrap.worldChunkHeight) || 32),
- chunkRadius: Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0)),
- originChunkX: Math.floor(Number(bootstrap.worldOriginChunkX) || 0),
- originChunkY: Math.floor(Number(bootstrap.worldOriginChunkY) || 0),
- tileOffsetX: Math.floor(Number(bootstrap.worldTileOffsetX) || 0),
- tileOffsetY: Math.floor(Number(bootstrap.worldTileOffsetY) || 0),
- spawnX: Math.floor(Number(bootstrap.worldSpawnX) || 0),
- spawnY: Math.floor(Number(bootstrap.worldSpawnY) || 0),
- centerChunkX: Math.floor(Number(bootstrap.worldOriginChunkX) || 0) + Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0)),
- centerChunkY: Math.floor(Number(bootstrap.worldOriginChunkY) || 0) + Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0)),
- sourceChunks: Array.isArray(bootstrap.sourceChunks)
- ? bootstrap.sourceChunks.map((entry) => ({
- chunkX: Math.floor(Number(entry?.chunkX) || 0),
- chunkY: Math.floor(Number(entry?.chunkY) || 0),
- }))
- : [],
- bookmarks: Array.isArray(bootstrap.worldBookmarks)
- ? bootstrap.worldBookmarks.map((entry, index) => ({
- id: String(entry?.id || `poi_${index + 1}`).trim() || `poi_${index + 1}`,
- label: String(entry?.label || entry?.id || `POI ${index + 1}`).trim() || `POI ${index + 1}`,
- x: Math.floor(Number(entry?.x) || 0),
- y: Math.floor(Number(entry?.y) || 0),
- }))
- : [],
- chunkCache: new Map(),
- dirtyChunkKeys: new Set(),
- pendingNeighborhoodFetches: new Map(),
- prefetchedNeighborhoodKeys: new Set(),
- pendingLoadKey: "",
- pendingLoadPromise: null,
- requestSerial: 0,
- documentDirty: false,
- };
+ const worldRuntimeState = createInitialWorldRuntimeState(bootstrap);
function isWorldModeActive() {
return worldRuntimeState.enabled && !!worldRuntimeState.worldId;
}
const defaultTileColor = DEFAULT_TILE_COLOR;
- const tileColors = cloneValue(bootstrap.tileColors) || {};
+ const tileColors = cloneRuntimeValue(bootstrap.tileColors) || {};
let graphicsVisualRevision = 0;
function applyGraphicsVisualRevision(dataUrl, revision = graphicsVisualRevision) {
const normalizedDataUrl = String(dataUrl || "").trim();
@@ -511,9 +279,9 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return merged;
}
const tileCatalog = buildMergedTileCatalog();
- const contentByType = cloneValue(bootstrap.contentByType) || {};
+ const contentByType = cloneRuntimeValue(bootstrap.contentByType) || {};
const spriteCatalog = applySpriteCatalogVisualRevision(buildSpriteCatalogFromBootstrap(bootstrap));
- const defaultNpcTemplate = cloneValue(bootstrap.defaultNpcTemplate) || {};
+ const defaultNpcTemplate = cloneRuntimeValue(bootstrap.defaultNpcTemplate) || {};
const apiBase = String(bootstrap.apiBase || "").replace(/\/+$/, "");
function deriveHistoryStorageKey(mapIdValue) {
return "worldshaper:world-history:v2:" + String(mapIdValue || "").trim();
@@ -848,7 +616,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
: normalizedBackgroundTileId;
const existingChunk = worldRuntimeState.chunkCache.get(chunkKey);
const chunkValue = existingChunk
- ? cloneValue(existingChunk)
+ ? cloneRuntimeValue(existingChunk)
: (rebuildWorldChunkPayloadFromDocument(safeChunkX, safeChunkY) || {
worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
chunkX: safeChunkX,
@@ -942,7 +710,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return;
}
touchWorldChunkCacheEntry(String(chunkKey || "").trim(), {
- ...cloneValue(existingChunk),
+ ...cloneRuntimeValue(existingChunk),
backgroundTileId: nextBackgroundTileId,
});
changed = true;
@@ -1081,7 +849,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return Array.from(worldRuntimeState.dirtyChunkKeys.values())
.map((chunkKey) => worldRuntimeState.chunkCache.get(chunkKey) || null)
.filter(Boolean)
- .map((entry) => cloneValue(entry));
+ .map((entry) => cloneRuntimeValue(entry));
}
function pruneWorldChunkCache() {
@@ -1131,102 +899,28 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
);
}
- function sliceNormalizedRows(rows, startX, startY, width, height, fillChar) {
- return Array.from({ length: Math.max(1, Number(height) || 1) }, (_, rowOffset) => {
- const sourceRow = String((Array.isArray(rows) ? rows[startY + rowOffset] : "") || "");
- const paddedRow = sourceRow.length >= startX + width
- ? sourceRow
- : sourceRow + String(fillChar || " ").repeat(Math.max(0, (startX + width) - sourceRow.length));
- return paddedRow.slice(startX, startX + width);
- });
- }
-
function buildChunkHeightLayersFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight) {
- return (Array.isArray(mapDocument.heightLayers) ? cloneHeightLayers(mapDocument.heightLayers) : [])
- .map((entry) => {
- const patchX = Math.max(0, Number(entry?.x) || 0);
- const patchY = Math.max(0, Number(entry?.y) || 0);
- const rows = Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [];
- const patchWidth = rows.reduce((max, row) => Math.max(max, row.length), 0);
- const patchHeight = rows.length;
- const patchRight = patchX + patchWidth;
- const patchBottom = patchY + patchHeight;
- const chunkRight = baseTileX + chunkWidth;
- const chunkBottom = baseTileY + chunkHeight;
- const overlapLeft = Math.max(baseTileX, patchX);
- const overlapTop = Math.max(baseTileY, patchY);
- const overlapRight = Math.min(chunkRight, patchRight);
- const overlapBottom = Math.min(chunkBottom, patchBottom);
- if (overlapRight <= overlapLeft || overlapBottom <= overlapTop) {
- return null;
- }
- const localRows = [];
- for (let y = overlapTop; y < overlapBottom; y += 1) {
- const sourceRow = String(rows[y - patchY] || "");
- localRows.push(sourceRow.slice(overlapLeft - patchX, overlapRight - patchX).replace(/\s+$/g, ""));
- }
- return {
- id: String(entry?.id || "").trim(),
- name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
- z: Math.max(1, Number(entry?.z) || 1),
- x: overlapLeft - baseTileX,
- y: overlapTop - baseTileY,
- rows: localRows,
- };
- })
- .filter((entry) => entry && entry.id);
+ return buildChunkHeightLayersFromDocumentHelper({
+ mapDocument,
+ cloneHeightLayers,
+ baseTileX,
+ baseTileY,
+ chunkWidth,
+ chunkHeight,
+ });
}
function buildChunkInstancesFromDocument(baseTileX, baseTileY, chunkWidth, chunkHeight) {
- const chunkInstances = cloneValue(mapDocument.npcOverlays)
- .filter((npc) => {
- const localX = Math.floor(Number(npc?.x));
- const localY = Math.floor(Number(npc?.y));
- return Number.isFinite(localX)
- && Number.isFinite(localY)
- && localX >= baseTileX
- && localX < baseTileX + chunkWidth
- && localY >= baseTileY
- && localY < baseTileY + chunkHeight;
- })
- .map((npc) => ({
- id: String(npc.id || "").trim(),
- templateId: String(npc?.record?.templateId || "").trim(),
- layer: Number(npc.layer) || 0,
- x: Math.floor(Number(npc.x) || 0) - baseTileX,
- y: Math.floor(Number(npc.y) || 0) - baseTileY,
- record: {
- ...cloneValue(npc.record || {}),
- id: String(npc.id || "").trim(),
- layer: Number(npc.layer) || 0,
- templateId: String(npc?.record?.templateId || "").trim(),
- name: String(npc.name || npc?.record?.name || ""),
- entityType: String(npc?.record?.entityType || npc?.entityType || "friendly"),
- faction: String(npc.faction || npc?.record?.faction || ""),
- spriteId: String(npc.spriteId || npc?.record?.spriteId || ""),
- dialogueId: String(npc.dialogueId || npc?.record?.dialogueId || ""),
- description: String(npc.description || npc?.record?.description || ""),
- tags: cloneValue(npc?.record?.tags) || [],
- enabled: typeof npc?.record?.enabled === "boolean" ? npc.record.enabled : true,
- position: {
- x: Math.floor(Number(npc.x) || 0) + worldRuntimeState.tileOffsetX,
- y: Math.floor(Number(npc.y) || 0) + worldRuntimeState.tileOffsetY,
- },
- },
- }))
- .filter((entry) => entry.id);
- const npcIdsByLayer = new Map();
- chunkInstances.forEach((entry) => {
- const layerNumber = Number(entry.layer) || 0;
- if (!npcIdsByLayer.has(layerNumber)) {
- npcIdsByLayer.set(layerNumber, []);
- }
- npcIdsByLayer.get(layerNumber).push(entry.id);
+ return buildChunkInstancesFromDocumentHelper({
+ mapDocument,
+ cloneValue,
+ baseTileX,
+ baseTileY,
+ chunkWidth,
+ chunkHeight,
+ tileOffsetX: worldRuntimeState.tileOffsetX,
+ tileOffsetY: worldRuntimeState.tileOffsetY,
});
- return {
- chunkInstances,
- npcIdsByLayer,
- };
}
function rebuildWorldChunkPayloadFromDocument(chunkX, chunkY) {
@@ -1366,7 +1060,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
? fallbackLayerNumber
: (layerNumberMap ? (layerNumberMap[String(previousLayer)] ?? previousLayer) : previousLayer);
return {
- ...cloneValue(entry),
+ ...cloneRuntimeValue(entry),
layer: nextLayer,
};
});
@@ -1390,7 +1084,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
const metadata = metadataByLayer.get(nextLayer) || { layer: nextLayer, name: undefined };
const fillChar = nextLayer === 0 ? "." : " ";
return {
- ...cloneValue(entry),
+ ...cloneRuntimeValue(entry),
layer: nextLayer,
name: metadata.name,
rows: Array.isArray(entry?.rows)
@@ -1413,7 +1107,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
continue;
}
touchWorldChunkCacheEntry(chunkKey, {
- ...cloneValue(chunkValue),
+ ...cloneRuntimeValue(chunkValue),
roomLayers: nextRoomLayers,
instances: nextInstances,
});
@@ -1512,59 +1206,12 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
activeHeightLayerId: "",
});
popupSessionStore.restorePersistedLayout(window);
- const editorLogEntries = [];
- const EDITOR_LOG_LIMIT = 500;
let statusLogWindowController = null;
-
- function formatEditorLogTimestamp(timestamp) {
- try {
- return new Date(timestamp).toLocaleString();
- } catch {
- return String(timestamp || "");
- }
- }
-
- function appendEditorLogEntry(level, message) {
- const normalizedMessage = String(message || "").trim();
- if (!normalizedMessage) {
- return null;
- }
- const timestamp = Date.now();
- const entry = {
- id: runtimeUniqueId(),
- timestamp,
- timestampLabel: formatEditorLogTimestamp(timestamp),
- level: String(level || "Information").trim() || "Information",
- message: normalizedMessage,
- };
- editorLogEntries.push(entry);
- while (editorLogEntries.length > EDITOR_LOG_LIMIT) {
- editorLogEntries.shift();
- }
- statusLogWindowController?.refresh?.();
- return entry;
- }
-
- function getEditorLogEntries() {
- return editorLogEntries.slice();
- }
-
- function clearEditorLogEntries() {
- editorLogEntries.splice(0, editorLogEntries.length);
- statusLogWindowController?.refresh?.();
- }
-
- window.addEventListener("error", (event) => {
- const message = String(event?.message || event?.error?.message || "Unknown runtime error");
- appendEditorLogEntry("Error", message);
- });
- window.addEventListener("unhandledrejection", (event) => {
- const reason = event?.reason;
- const message = typeof reason === "string"
- ? reason
- : String(reason?.message || reason || "Unhandled promise rejection");
- appendEditorLogEntry("Error", message);
+ const runtimeLogging = createRuntimeLogging({
+ windowRef: window,
+ runtimeUniqueId,
});
+ const { appendEditorLogEntry, getEditorLogEntries, clearEditorLogEntries } = runtimeLogging;
let renderController = null;
const documentController = createMapDocumentController({
mapId: currentMapId,
@@ -1585,7 +1232,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
});
let editorSettingsState = normalizeEditorSettings(initialEditorSettings);
- // ── AtTooltip: reusable anchored floating context menu ──────────────
+ // ── AtTooltip: reusable anchored floating context menu ──────────────
const atTooltip = createAtTooltip();
const initialEditorUiState = bootstrap.editorUi;
@@ -1798,7 +1445,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return null;
}
return {
- ...cloneValue(entry),
+ ...cloneRuntimeValue(entry),
id: metadata.id,
name: metadata.name,
z: metadata.z,
@@ -1806,7 +1453,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
})
.filter(Boolean);
nextEntries.push([chunkKey, {
- ...cloneValue(chunkValue),
+ ...cloneRuntimeValue(chunkValue),
heightLayers: nextHeightLayers,
}]);
}
@@ -1901,349 +1548,65 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return emptyChunk;
}
- function normalizeWorldChunkRows(rows, width, height, fillChar) {
- const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
- const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
- return Array.from({ length: safeHeight }, (_entry, rowIndex) => {
- const sourceRow = String((Array.isArray(rows) ? rows[rowIndex] : "") || "");
- return sourceRow.length >= safeWidth
- ? sourceRow.slice(0, safeWidth)
- : (sourceRow + String(fillChar || " ").repeat(Math.max(0, safeWidth - sourceRow.length)));
- });
- }
-
- function cloneWorldChunkHeightLayers(source) {
- return (Array.isArray(source) ? source : [])
- .map((entry, index) => ({
- id: String(entry?.id || `height_patch_${index + 1}`).trim() || `height_patch_${index + 1}`,
- name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
- z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
- x: Math.max(0, Math.floor(Number(entry?.x) || 0)),
- y: Math.max(0, Math.floor(Number(entry?.y) || 0)),
- rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
- }))
- .filter((entry) => entry.id);
- }
-
- function buildWorldChunkLayerInstanceIds(roomLayers, instances, width, height) {
- const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
- const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
- const nextLayers = new Map();
- (Array.isArray(roomLayers) ? roomLayers : []).forEach((layer) => {
- const layerNumber = Math.max(0, Math.floor(Number(layer?.layer) || 0));
- nextLayers.set(layerNumber, {
- layer: layerNumber,
- name: typeof layer?.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
- rows: normalizeWorldChunkRows(layer?.rows, safeWidth, safeHeight, layerNumber === 0 ? "." : " "),
- instanceIds: [],
- });
- });
- if (!nextLayers.has(0)) {
- nextLayers.set(0, {
- layer: 0,
- rows: normalizeWorldChunkRows([], safeWidth, safeHeight, "."),
- instanceIds: [],
- });
- }
- if (!Array.from(nextLayers.keys()).some((layerNumber) => layerNumber > 0)) {
- nextLayers.set(1, {
- layer: 1,
- rows: normalizeWorldChunkRows([], safeWidth, safeHeight, " "),
- instanceIds: [],
- });
- }
- (Array.isArray(instances) ? instances : []).forEach((entry) => {
- const layerNumber = Math.max(0, Math.floor(Number(entry?.layer) || 0));
- const instanceId = String(entry?.id || "").trim();
- if (!instanceId) {
- return;
- }
- if (!nextLayers.has(layerNumber)) {
- nextLayers.set(layerNumber, {
- layer: layerNumber,
- rows: normalizeWorldChunkRows([], safeWidth, safeHeight, layerNumber === 0 ? "." : " "),
- instanceIds: [],
- });
- }
- nextLayers.get(layerNumber).instanceIds.push(instanceId);
- });
- return Array.from(nextLayers.values())
- .map((entry) => ({
- ...entry,
- instanceIds: Array.from(new Set((Array.isArray(entry.instanceIds) ? entry.instanceIds : []).map((id) => String(id || "").trim()).filter(Boolean))),
- }))
- .sort((left, right) => (Number(left.layer) || 0) - (Number(right.layer) || 0));
- }
-
function normalizeWorldChunkInstances(sourceInstances, chunkX, chunkY, width, height, options) {
- const config = options && typeof options === "object" ? options : {};
- const duplicateIds = config.duplicateIds === true;
- const safeChunkX = Math.floor(Number(chunkX) || 0);
- const safeChunkY = Math.floor(Number(chunkY) || 0);
- const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
- const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
- return (Array.isArray(sourceInstances) ? sourceInstances : [])
- .map((entry) => {
- const record = entry?.record && typeof entry.record === "object" && !Array.isArray(entry.record)
- ? cloneValue(entry.record)
- : {};
- const nextId = duplicateIds
- ? runtimeUniqueId()
- : (String(entry?.id || record?.id || runtimeUniqueId()).trim() || runtimeUniqueId());
- const nextLayer = Math.max(0, Math.floor(Number(entry?.layer ?? record?.layer) || 0));
- const nextX = Math.max(0, Math.min(safeWidth - 1, Math.floor(Number(entry?.x) || 0)));
- const nextY = Math.max(0, Math.min(safeHeight - 1, Math.floor(Number(entry?.y) || 0)));
- const nextTemplateId = String(entry?.templateId || record?.templateId || "").trim();
- record.id = nextId;
- record.layer = nextLayer;
- record.templateId = nextTemplateId;
- record.position = {
- x: (safeChunkX * safeWidth) + nextX,
- y: (safeChunkY * safeHeight) + nextY,
- };
- return {
- id: nextId,
- templateId: nextTemplateId,
- layer: nextLayer,
- x: nextX,
- y: nextY,
- record,
- };
- })
- .filter((entry) => entry.id);
+ return normalizeWorldChunkInstancesHelper({
+ sourceInstances,
+ chunkX,
+ chunkY,
+ width,
+ height,
+ options,
+ cloneValue,
+ runtimeUniqueId,
+ });
}
function createEmptyWorldChunkPayload(chunkX, chunkY) {
- const safeChunkX = Math.floor(Number(chunkX) || 0);
- const safeChunkY = Math.floor(Number(chunkY) || 0);
- const chunkWidth = Math.max(1, Number(worldRuntimeState.chunkWidth) || 32);
- const chunkHeight = Math.max(1, Number(worldRuntimeState.chunkHeight) || 32);
- return {
- schemaVersion: 1,
+ return createEmptyWorldChunkPayloadHelper({
+ chunkX,
+ chunkY,
+ chunkWidth: Math.max(1, Number(worldRuntimeState.chunkWidth) || 32),
+ chunkHeight: Math.max(1, Number(worldRuntimeState.chunkHeight) || 32),
worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
- chunkX: safeChunkX,
- chunkY: safeChunkY,
- width: chunkWidth,
- height: chunkHeight,
- backgroundTileId: "",
- roomLayers: [
- {
- layer: 0,
- rows: Array.from({ length: chunkHeight }, () => ".".repeat(chunkWidth)),
- instanceIds: [],
- },
- {
- layer: 1,
- rows: Array.from({ length: chunkHeight }, () => " ".repeat(chunkWidth)),
- instanceIds: [],
- },
- ],
- heightLayers: [],
- instances: [],
- };
+ });
}
function normalizeCachedWorldChunkPayload(chunkPayload, chunkX, chunkY, options) {
- const safeChunkX = Math.floor(Number(chunkX ?? chunkPayload?.chunkX) || 0);
- const safeChunkY = Math.floor(Number(chunkY ?? chunkPayload?.chunkY) || 0);
- const safeWidth = Math.max(1, Math.floor(Number(chunkPayload?.width) || Number(worldRuntimeState.chunkWidth) || 32));
- const safeHeight = Math.max(1, Math.floor(Number(chunkPayload?.height) || Number(worldRuntimeState.chunkHeight) || 32));
- const instances = normalizeWorldChunkInstances(chunkPayload?.instances, safeChunkX, safeChunkY, safeWidth, safeHeight, options);
- const roomLayers = buildWorldChunkLayerInstanceIds(chunkPayload?.roomLayers, instances, safeWidth, safeHeight);
- return {
- schemaVersion: Math.max(1, Math.floor(Number(chunkPayload?.schemaVersion) || 1)),
- worldId: String(chunkPayload?.worldId || worldRuntimeState.worldId || currentMapId || "").trim(),
- chunkX: safeChunkX,
- chunkY: safeChunkY,
- width: safeWidth,
- height: safeHeight,
- backgroundTileId: String(chunkPayload?.backgroundTileId || "").trim(),
- roomLayers,
- heightLayers: cloneWorldChunkHeightLayers(chunkPayload?.heightLayers),
- instances,
- };
- }
-
- function isChunkFillSymbol(ch, fillChar) {
- const symbol = String(ch || "").charAt(0);
- return !symbol || symbol === fillChar || symbol === "." || symbol === " ";
+ return normalizeCachedWorldChunkPayloadHelper({
+ chunkPayload,
+ chunkX,
+ chunkY,
+ chunkWidth: Number(worldRuntimeState.chunkWidth) || 32,
+ chunkHeight: Number(worldRuntimeState.chunkHeight) || 32,
+ worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
+ cloneValue,
+ runtimeUniqueId,
+ options,
+ });
}
function isWorldChunkPayloadEmpty(chunkPayload) {
- const normalized = normalizeCachedWorldChunkPayload(chunkPayload, chunkPayload?.chunkX, chunkPayload?.chunkY);
- if (String(normalized?.backgroundTileId || "").trim()) {
- return false;
- }
- if (Array.isArray(normalized?.instances) && normalized.instances.length > 0) {
- return false;
- }
- if ((Array.isArray(normalized?.heightLayers) ? normalized.heightLayers : []).some((entry) => (
- Array.isArray(entry?.rows) && entry.rows.some((row) => /[^ .]/.test(String(row || "")))
- ))) {
- return false;
- }
- return !(Array.isArray(normalized?.roomLayers) ? normalized.roomLayers : []).some((layer) => {
- const fillChar = (Number(layer?.layer) || 0) === 0 ? "." : " ";
- return (Array.isArray(layer?.rows) ? layer.rows : []).some((row) => {
- const sourceRow = String(row || "");
- for (let index = 0; index < sourceRow.length; index += 1) {
- if (!isChunkFillSymbol(sourceRow.charAt(index), fillChar)) {
- return true;
- }
- }
- return false;
- });
+ return isWorldChunkPayloadEmptyHelper({
+ chunkPayload,
+ chunkWidth: Number(worldRuntimeState.chunkWidth) || 32,
+ chunkHeight: Number(worldRuntimeState.chunkHeight) || 32,
+ worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
+ cloneValue,
+ runtimeUniqueId,
});
}
- function transformChunkLocalCoord(localX, localY, width, height, operation) {
- const safeX = Math.floor(Number(localX) || 0);
- const safeY = Math.floor(Number(localY) || 0);
- const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
- const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
- switch (String(operation || "").trim()) {
- case "flipHorizontal":
- return { x: (safeWidth - 1) - safeX, y: safeY };
- case "flipVertical":
- return { x: safeX, y: (safeHeight - 1) - safeY };
- case "rotate180":
- return { x: (safeWidth - 1) - safeX, y: (safeHeight - 1) - safeY };
- case "rotate90cw":
- if (safeWidth !== safeHeight) {
- return null;
- }
- return { x: (safeWidth - 1) - safeY, y: safeX };
- case "rotate90ccw":
- if (safeWidth !== safeHeight) {
- return null;
- }
- return { x: safeY, y: (safeHeight - 1) - safeX };
- default:
- return { x: safeX, y: safeY };
- }
- }
-
- function transformChunkRows(rows, width, height, fillChar, operation) {
- const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
- const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
- const sourceRows = normalizeWorldChunkRows(rows, safeWidth, safeHeight, fillChar);
- const nextRows = Array.from({ length: safeHeight }, () => Array.from({ length: safeWidth }, () => String(fillChar || " ").charAt(0) || " "));
- for (let rowIndex = 0; rowIndex < safeHeight; rowIndex += 1) {
- const sourceRow = sourceRows[rowIndex];
- for (let columnIndex = 0; columnIndex < safeWidth; columnIndex += 1) {
- const char = String(sourceRow.charAt(columnIndex) || fillChar).charAt(0) || String(fillChar || " ").charAt(0) || " ";
- if (isChunkFillSymbol(char, fillChar)) {
- continue;
- }
- const nextCoord = transformChunkLocalCoord(columnIndex, rowIndex, safeWidth, safeHeight, operation);
- if (!nextCoord) {
- continue;
- }
- nextRows[nextCoord.y][nextCoord.x] = char;
- }
- }
- return nextRows.map((row) => row.join(""));
- }
-
- function transformChunkHeightPatch(patch, width, height, operation) {
- const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
- const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
- const sourceRows = Array.isArray(patch?.rows) ? patch.rows.map((row) => String(row || "")) : [];
- const patchWidth = sourceRows.reduce((max, row) => Math.max(max, row.length), 0);
- const patchHeight = sourceRows.length;
- const transformedCells = [];
- for (let localY = 0; localY < patchHeight; localY += 1) {
- const row = sourceRows[localY] || "";
- for (let localX = 0; localX < patchWidth; localX += 1) {
- const char = String(row.charAt(localX) || " ").charAt(0) || " ";
- if (char === " " || char === ".") {
- continue;
- }
- const worldX = Math.max(0, Math.floor(Number(patch?.x) || 0)) + localX;
- const worldY = Math.max(0, Math.floor(Number(patch?.y) || 0)) + localY;
- if (worldX < 0 || worldY < 0 || worldX >= safeWidth || worldY >= safeHeight) {
- continue;
- }
- const nextCoord = transformChunkLocalCoord(worldX, worldY, safeWidth, safeHeight, operation);
- if (!nextCoord) {
- continue;
- }
- transformedCells.push({
- x: nextCoord.x,
- y: nextCoord.y,
- char,
- });
- }
- }
- if (transformedCells.length <= 0) {
- return null;
- }
- const minX = transformedCells.reduce((min, entry) => Math.min(min, entry.x), transformedCells[0].x);
- const maxX = transformedCells.reduce((max, entry) => Math.max(max, entry.x), transformedCells[0].x);
- const minY = transformedCells.reduce((min, entry) => Math.min(min, entry.y), transformedCells[0].y);
- const maxY = transformedCells.reduce((max, entry) => Math.max(max, entry.y), transformedCells[0].y);
- const nextRows = Array.from({ length: (maxY - minY) + 1 }, () => Array.from({ length: (maxX - minX) + 1 }, () => " "));
- transformedCells.forEach((entry) => {
- nextRows[entry.y - minY][entry.x - minX] = entry.char;
- });
- return {
- id: String(patch?.id || "").trim(),
- name: typeof patch?.name === "string" && patch.name.trim() ? patch.name.trim() : undefined,
- z: Math.max(1, Math.floor(Number(patch?.z) || 1)),
- x: minX,
- y: minY,
- rows: nextRows.map((row) => row.join("").replace(/\s+$/g, "")),
- };
- }
-
function transformWorldChunkPayload(chunkPayload, operation, options) {
- const config = options && typeof options === "object" ? options : {};
- const normalized = normalizeCachedWorldChunkPayload(chunkPayload, chunkPayload?.chunkX, chunkPayload?.chunkY, config);
- const safeWidth = Math.max(1, Math.floor(Number(normalized?.width) || 1));
- const safeHeight = Math.max(1, Math.floor(Number(normalized?.height) || 1));
- const normalizedOperation = String(operation || "").trim();
- if ((normalizedOperation === "rotate90cw" || normalizedOperation === "rotate90ccw") && safeWidth !== safeHeight) {
- throw new Error("Chunk rotation requires square chunks.");
- }
- const instances = normalizeWorldChunkInstances(
- (Array.isArray(normalized.instances) ? normalized.instances : []).map((entry) => {
- const nextCoord = transformChunkLocalCoord(entry.x, entry.y, safeWidth, safeHeight, normalizedOperation);
- return {
- ...cloneValue(entry),
- x: nextCoord?.x ?? entry.x,
- y: nextCoord?.y ?? entry.y,
- };
- }),
- normalized.chunkX,
- normalized.chunkY,
- safeWidth,
- safeHeight,
- config,
- );
- const roomLayers = buildWorldChunkLayerInstanceIds(
- (Array.isArray(normalized.roomLayers) ? normalized.roomLayers : []).map((layer) => ({
- ...cloneValue(layer),
- rows: transformChunkRows(layer?.rows, safeWidth, safeHeight, (Number(layer?.layer) || 0) === 0 ? "." : " ", normalizedOperation),
- })),
- instances,
- safeWidth,
- safeHeight,
- );
- const heightLayers = cloneWorldChunkHeightLayers(normalized.heightLayers)
- .map((entry) => transformChunkHeightPatch(entry, safeWidth, safeHeight, normalizedOperation))
- .filter(Boolean)
- .sort((left, right) => {
- if ((Number(left?.z) || 0) !== (Number(right?.z) || 0)) {
- return (Number(left?.z) || 0) - (Number(right?.z) || 0);
- }
- return String(left?.name || left?.id || "").localeCompare(String(right?.name || right?.id || ""));
- });
- return {
- ...normalized,
- roomLayers,
- heightLayers,
- instances,
- };
+ return transformWorldChunkPayloadHelper({
+ chunkPayload,
+ operation,
+ chunkWidth: Number(worldRuntimeState.chunkWidth) || 32,
+ chunkHeight: Number(worldRuntimeState.chunkHeight) || 32,
+ worldId: String(worldRuntimeState.worldId || currentMapId || "").trim(),
+ cloneValue,
+ runtimeUniqueId,
+ options,
+ });
}
function commitWorldChunkPayloads(nextChunks, reason) {
@@ -2283,7 +1646,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return { ok: false, reason: "same-chunk" };
}
const sourceChunk = normalizeCachedWorldChunkPayload(
- cloneValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY),
+ cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY),
safeSourceChunkX,
safeSourceChunkY,
);
@@ -2292,7 +1655,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return { ok: false, reason: "source-empty" };
}
const destinationChunk = normalizeCachedWorldChunkPayload(
- cloneValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY),
+ cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY),
safeTargetChunkX,
safeTargetChunkY,
);
@@ -2333,7 +1696,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return { ok: false, reason: "same-chunk" };
}
const sourceChunk = normalizeCachedWorldChunkPayload(
- cloneValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY),
+ cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeSourceChunkX, safeSourceChunkY)) || createEmptyWorldChunkPayload(safeSourceChunkX, safeSourceChunkY),
safeSourceChunkX,
safeSourceChunkY,
);
@@ -2342,7 +1705,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return { ok: false, reason: "source-empty" };
}
const destinationChunk = normalizeCachedWorldChunkPayload(
- cloneValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY),
+ cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeTargetChunkX, safeTargetChunkY)) || createEmptyWorldChunkPayload(safeTargetChunkX, safeTargetChunkY),
safeTargetChunkX,
safeTargetChunkY,
);
@@ -2351,7 +1714,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return { ok: false, reason: "destination-occupied" };
}
const duplicatedChunk = normalizeCachedWorldChunkPayload({
- ...cloneValue(sourceChunk),
+ ...cloneRuntimeValue(sourceChunk),
instances: [],
}, safeTargetChunkX, safeTargetChunkY);
commitWorldChunkPayloads([duplicatedChunk], "world-chunk-duplicate");
@@ -2381,7 +1744,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
const safeChunkY = Math.floor(Number(chunkY) || 0);
const normalizedOperation = String(operation || "").trim();
const sourceChunk = normalizeCachedWorldChunkPayload(
- cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY),
+ cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY),
safeChunkX,
safeChunkY,
);
@@ -2414,7 +1777,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
const safeChunkX = Math.floor(Number(chunkX) || 0);
const safeChunkY = Math.floor(Number(chunkY) || 0);
const existingChunk = normalizeCachedWorldChunkPayload(
- cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY),
+ cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || createEmptyWorldChunkPayload(safeChunkX, safeChunkY),
safeChunkX,
safeChunkY,
);
@@ -2452,7 +1815,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
? normalizedBackgroundTileId
: "";
const chunkKey = buildChunkKey(safeChunkX, safeChunkY);
- const existingChunk = cloneValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || {
+ const existingChunk = cloneRuntimeValue(await ensureWorldChunkCachedForEdit(safeChunkX, safeChunkY)) || {
chunkX: safeChunkX,
chunkY: safeChunkY,
width: chunkWidth,
@@ -2871,13 +2234,13 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
backgroundTileId: normalizeBackgroundTileId(mapDocument.backgroundTileId),
roomLayers: cloneLayers(mapDocument.roomLayers),
heightLayers: cloneHeightLayers(mapDocument.heightLayers),
- tileColors: cloneValue(tileColors),
+ tileColors: cloneRuntimeValue(tileColors),
baseRows,
npcOverlays: cloneNpcOverlays(mapDocument.npcOverlays),
- contentByType: cloneValue(mapDocument.contentBundle),
- spriteCatalog: cloneValue(spriteCatalog),
- tileCatalogById: cloneValue(tileCatalogById),
- defaultNpcTemplate: cloneValue(defaultNpcTemplate),
+ contentByType: cloneRuntimeValue(mapDocument.contentBundle),
+ spriteCatalog: cloneRuntimeValue(spriteCatalog),
+ tileCatalogById: cloneRuntimeValue(tileCatalogById),
+ defaultNpcTemplate: cloneRuntimeValue(defaultNpcTemplate),
apiBase,
backgroundColor: normalizeMapBackgroundColor(mapDocument.backgroundColor),
heightBlurStep: Math.max(0, Math.min(1, Number(mapDocument.heightBlurStep ?? mapDocument.heightDetailStep) || 0.1)),
@@ -2895,7 +2258,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
worldSpawnX: isWorldModeActive() ? worldRuntimeState.spawnX : undefined,
worldSpawnY: isWorldModeActive() ? worldRuntimeState.spawnY : undefined,
worldBookmarks: isWorldModeActive() ? cloneWorldBookmarks() : undefined,
- sourceChunks: isWorldModeActive() ? cloneValue(worldRuntimeState.sourceChunks) : undefined,
+ sourceChunks: isWorldModeActive() ? cloneRuntimeValue(worldRuntimeState.sourceChunks) : undefined,
};
}
@@ -2983,7 +2346,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
}
function getImagesPayload() {
- return cloneValue(ensureDocumentContentPayload("images", { schemaVersion: 1, images: [] })) || { schemaVersion: 1, images: [] };
+ return cloneRuntimeValue(ensureDocumentContentPayload("images", { schemaVersion: 1, images: [] })) || { schemaVersion: 1, images: [] };
}
function buildDuplicateGraphicName(baseName, imagesPayload) {
@@ -3036,9 +2399,9 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
const nextTilesPayload = buildTilesPayloadFromImagesPayload(normalizedImagesPayload);
const nextSpritesPayload = buildSpritesPayloadFromImagesPayload(normalizedImagesPayload);
graphicsVisualRevision += 1;
- setDocumentContentPayload("images", cloneValue(normalizedImagesPayload) || { schemaVersion: 1, images: [] });
- setDocumentContentPayload("tiles", cloneValue(nextTilesPayload) || { schemaVersion: 1, tiles: [] });
- setDocumentContentPayload("sprites", cloneValue(nextSpritesPayload) || { schemaVersion: 1, sprites: [] });
+ setDocumentContentPayload("images", cloneRuntimeValue(normalizedImagesPayload) || { schemaVersion: 1, images: [] });
+ setDocumentContentPayload("tiles", cloneRuntimeValue(nextTilesPayload) || { schemaVersion: 1, tiles: [] });
+ setDocumentContentPayload("sprites", cloneRuntimeValue(nextSpritesPayload) || { schemaVersion: 1, sprites: [] });
replaceObjectContents(
tileCatalogById,
applyTileCatalogVisualRevision(
@@ -3179,14 +2542,14 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
}
}
const nextRecord = normalizeImageRecordForSave({
- ...cloneValue(sourceRecord),
+ ...cloneRuntimeValue(sourceRecord),
id: (normalizedType === "tile" ? "tile_" : "sprite_") + runtimeUniqueId().replace(/^inst_/, ""),
name: buildDuplicateGraphicName(String(sourceRecord.name || normalizedId || "Graphic"), imagesPayload),
tileSymbol: normalizedType === "tile" ? nextTileSymbol : String(sourceRecord.tileSymbol || "").trim().charAt(0),
rows: Array.isArray(sourceRecord.rows) ? sourceRecord.rows.map((row) => String(row || "")) : [],
- frames: Array.isArray(sourceRecord.frames) ? cloneValue(sourceRecord.frames) : [],
- tags: Array.isArray(sourceRecord.tags) ? cloneValue(sourceRecord.tags) : [],
- roles: Array.isArray(sourceRecord.roles) ? cloneValue(sourceRecord.roles) : [],
+ frames: Array.isArray(sourceRecord.frames) ? cloneRuntimeValue(sourceRecord.frames) : [],
+ tags: Array.isArray(sourceRecord.tags) ? cloneRuntimeValue(sourceRecord.tags) : [],
+ roles: Array.isArray(sourceRecord.roles) ? cloneRuntimeValue(sourceRecord.roles) : [],
});
const nextImages = Array.isArray(imagesPayload.images) ? imagesPayload.images.slice() : [];
nextImages.push(nextRecord);
@@ -3235,7 +2598,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
}
const existing = nextImages[existingIndex] || {};
nextImages[existingIndex] = buildImageRecordFromTileRecord({
- ...cloneValue(record),
+ ...cloneRuntimeValue(record),
symbol: String(record?.symbol || existing?.tileSymbol || "").charAt(0) || takeNextAvailableTileSymbol() || "T",
}, existing, cloneValue);
const nextPayload = {
@@ -3284,7 +2647,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
return false;
}
const nextRecord = buildImageRecordFromTileRecord({
- ...cloneValue(sourceRecord),
+ ...cloneRuntimeValue(sourceRecord),
symbol: nextSymbol,
}, existing, cloneValue);
if (existingIndex >= 0) {
@@ -3412,7 +2775,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
clearedBackgrounds += 1;
}
touchWorldChunkCacheEntry(chunkKey, {
- ...cloneValue(chunkValue),
+ ...cloneRuntimeValue(chunkValue),
backgroundTileId: clearsBackground ? "" : String(chunkValue.backgroundTileId || "").trim(),
roomLayers: scrubbedLayers.roomLayers,
heightLayers: scrubbedHeightLayers.heightLayers,
@@ -3439,7 +2802,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return entry;
}
- const nextEntry = cloneValue(entry) || {};
+ const nextEntry = cloneRuntimeValue(entry) || {};
const nextRecord = nextEntry.record && typeof nextEntry.record === "object" && !Array.isArray(nextEntry.record)
? { ...nextEntry.record }
: {};
@@ -3467,7 +2830,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
updatedChunks += 1;
scrubbedEntities += changedEntities;
touchWorldChunkCacheEntry(chunkKey, {
- ...cloneValue(chunkValue),
+ ...cloneRuntimeValue(chunkValue),
instances: nextInstances,
});
}
@@ -4413,7 +3776,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
Object.keys(target || {}).forEach((key) => {
delete target[key];
});
- Object.assign(target, cloneValue(nextValue) || {});
+ Object.assign(target, cloneRuntimeValue(nextValue) || {});
return target;
}
@@ -4430,7 +3793,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
const nextImagesPayload = mergeImagesPayloadWithSpritesPayload(getImagesPayload(), payload);
syncRuntimeGraphicsFromImagesPayload(nextImagesPayload, config);
} else {
- setDocumentContentPayload(normalizedType, cloneValue(payload) || {});
+ setDocumentContentPayload(normalizedType, cloneRuntimeValue(payload) || {});
}
if (!config.deferRefresh) {
@@ -4870,7 +4233,7 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
duplicateWorldChunkContent,
transformWorldChunkAt,
clearWorldChunkAt,
- getCachedWorldChunkPayloads: () => Array.from(worldRuntimeState.chunkCache.values()).map((entry) => cloneValue(entry)),
+ getCachedWorldChunkPayloads: () => Array.from(worldRuntimeState.chunkCache.values()).map((entry) => cloneRuntimeValue(entry)),
getDirtyWorldChunkKeys,
getDirtyWorldChunkPayloads,
clearDirtyWorldChunks,
@@ -5352,142 +4715,44 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
scope.historyScope = historyScope;
scope.uiScope = uiScope;
scope.sessionScope = sessionScope;
- const toolWindowController = createToolWindowController(scope);
- const tileArtEditorWindowController = createTileArtEditorWindowController(scope);
- const entityEditorWindowController = createEntityEditorWindowController(scope);
- const engineOverrideWindowController = createEngineOverrideWindowController(scope);
- const worldOverviewWindowController = createWorldOverviewWindowController(scope);
- const changelogSplashWindowController = createChangelogSplashWindowController(scope);
- statusLogWindowController = createStatusLogWindowController(scope);
- const syncToolPanels = () => toolWindowController.syncPanels();
- const handleSidebarTabButtonClick = (tab) => toolWindowController.handleTabButtonClick(tab);
- const restoreAllToolWindows = () => toolWindowController.restoreAllWindows();
- const openTileArtEditorWindow = (recordTypeOrId, maybeRecordId) => tileArtEditorWindowController.open(recordTypeOrId, maybeRecordId);
- const closeTileArtEditorWindow = () => tileArtEditorWindowController.close();
- const openEntityEditorWindow = (entityId) => entityEditorWindowController.open(entityId);
- const closeEntityEditorWindow = () => entityEditorWindowController.close();
- const openEngineOverrideWindow = () => engineOverrideWindowController.open();
- const closeEngineOverrideWindow = () => engineOverrideWindowController.close();
- const refreshEngineOverrideWindow = () => engineOverrideWindowController.refresh();
- const refreshEngineOverrideSummary = () => engineOverrideWindowController.updateSummary();
- const openWorldOverviewWindow = () => worldOverviewWindowController.open();
- const closeWorldOverviewWindow = () => worldOverviewWindowController.close();
- const refreshWorldOverviewWindow = () => worldOverviewWindowController.refresh();
- const invalidateWorldOverviewChunkSurfaces = (chunkKeys, options) => worldOverviewWindowController.invalidateChunkSurfaces?.(chunkKeys, options);
- const openStatusLogWindow = () => statusLogWindowController.open();
- const closeStatusLogWindow = () => statusLogWindowController.close();
- const openNewsWindow = (options = {}) => changelogSplashWindowController.open({ markSeen: false, ...options });
- const resetWorkspaceLayoutFlow = () => {
- resetWorkspaceLayout();
- toolWindowController.restoreAllWindows();
- setStatus("Workspace layout reset.", false);
- };
- scope.syncToolPanels = syncToolPanels;
- scope.handleSidebarTabButtonClick = handleSidebarTabButtonClick;
- scope.restoreAllToolWindows = restoreAllToolWindows;
- scope.resetWorkspaceLayout = resetWorkspaceLayoutFlow;
- scope.createNewTile = createNewTile;
- scope.createNewSpriteGraphic = createNewSpriteGraphic;
- scope.duplicateGraphicRecord = duplicateGraphicRecord;
- scope.openTileArtEditorWindow = openTileArtEditorWindow;
- scope.closeTileArtEditorWindow = closeTileArtEditorWindow;
- scope.openEntityEditorWindow = openEntityEditorWindow;
- scope.closeEntityEditorWindow = closeEntityEditorWindow;
- scope.openEngineOverrideWindow = openEngineOverrideWindow;
- scope.closeEngineOverrideWindow = closeEngineOverrideWindow;
- scope.refreshEngineOverrideWindow = refreshEngineOverrideWindow;
- scope.refreshEngineOverrideSummary = refreshEngineOverrideSummary;
- scope.openWorldOverviewWindow = openWorldOverviewWindow;
- scope.closeWorldOverviewWindow = closeWorldOverviewWindow;
- scope.refreshWorldOverviewWindow = refreshWorldOverviewWindow;
- scope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces;
- scope.openStatusLogWindow = openStatusLogWindow;
- scope.closeStatusLogWindow = closeStatusLogWindow;
- scope.openNewsWindow = openNewsWindow;
- scope.openTilePaletteContextMenu = openTilePaletteContextMenu;
- scope.openPlacedEntityContextMenu = openPlacedEntityContextMenu;
- scope.applyNpcEditorChange = applyNpcEditorChange;
- scope.getEditorEngineOverrides = getEditorEngineOverrides;
- scope.saveEditorEngineOverrides = saveEditorEngineOverrides;
- scope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep;
- scope.isRendererDebugEnabled = isRendererDebugEnabled;
- scope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi;
- uiScope.syncToolPanels = syncToolPanels;
- uiScope.handleSidebarTabButtonClick = handleSidebarTabButtonClick;
- uiScope.restoreAllToolWindows = restoreAllToolWindows;
- uiScope.resetWorkspaceLayout = resetWorkspaceLayoutFlow;
- uiScope.createNewTile = createNewTile;
- uiScope.createNewSpriteGraphic = createNewSpriteGraphic;
- uiScope.duplicateGraphicRecord = duplicateGraphicRecord;
- uiScope.openEntityEditorWindow = openEntityEditorWindow;
- uiScope.closeEntityEditorWindow = closeEntityEditorWindow;
- uiScope.openEngineOverrideWindow = openEngineOverrideWindow;
- uiScope.closeEngineOverrideWindow = closeEngineOverrideWindow;
- uiScope.refreshEngineOverrideWindow = refreshEngineOverrideWindow;
- uiScope.refreshEngineOverrideSummary = refreshEngineOverrideSummary;
- uiScope.openWorldOverviewWindow = openWorldOverviewWindow;
- uiScope.closeWorldOverviewWindow = closeWorldOverviewWindow;
- uiScope.refreshWorldOverviewWindow = refreshWorldOverviewWindow;
- uiScope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces;
- uiScope.openStatusLogWindow = openStatusLogWindow;
- uiScope.closeStatusLogWindow = closeStatusLogWindow;
- uiScope.openNewsWindow = openNewsWindow;
- uiScope.openTilePaletteContextMenu = openTilePaletteContextMenu;
- uiScope.openPlacedEntityContextMenu = openPlacedEntityContextMenu;
- uiScope.applyNpcEditorChange = applyNpcEditorChange;
- uiScope.getEditorEngineOverrides = getEditorEngineOverrides;
- uiScope.saveEditorEngineOverrides = saveEditorEngineOverrides;
- uiScope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep;
- uiScope.isRendererDebugEnabled = isRendererDebugEnabled;
- uiScope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi;
-
- syncDocumentTitle();
- const historyController = createHistoryController(scope);
- const npcController = createNpcController(scope);
- const sidebarController = createSidebarController(scope);
- renderController = createRenderController(scope);
- const persistenceController = createPersistenceController(scope);
- const importController = createImportController(scope);
- const interactionController = createInteractionController(scope);
const persistPopupBounds = () => {
persistWorldshaperStudioBounds(window);
};
- const persistPopupBoundsDeferred = createDebouncedCallback(() => {
- persistPopupBounds();
- }, 160);
- syncCanvasDimensionsToTileSize();
- toolWindowController.initialize();
- tileArtEditorWindowController.initialize();
- entityEditorWindowController.initialize();
- engineOverrideWindowController.initialize();
- worldOverviewWindowController.initialize();
- changelogSplashWindowController.initialize();
- statusLogWindowController.initialize();
- renderController.initializeRenderAssets();
- interactionController.initializeEditorState();
- interactionController.bindDomEvents();
- interactionController.initializeUi();
- refreshEditorEngineOverridesUi();
- cacheStandaloneMapBootstrap(currentMapId);
- if (isWorldModeActive()) {
- window.requestAnimationFrame(() => {
- const initialWorldView = getInitialWorldViewTile();
- centerViewportOnWorldTile(initialWorldView.worldTileX, initialWorldView.worldTileY);
- prefetchAdjacentWorldNeighborhoods(worldRuntimeState.centerChunkX, worldRuntimeState.centerChunkY);
- syncWorldNeighborhoodForViewport();
- drawNow();
- setStatus("World mode loaded. Endless navigation is active.", false);
- });
- }
- window.requestAnimationFrame(() => {
- changelogSplashWindowController.maybeOpenForCurrentVersion();
- });
- window.addEventListener("resize", () => {
- persistPopupBoundsDeferred();
- });
- window.addEventListener("beforeunload", () => {
- popupSessionStore.flushPersistedLayout(window);
- persistPopupBounds();
+ const runtimeControllerBootstrap = initializeRuntimeControllers({
+ scope,
+ uiScope,
+ resetWorkspaceLayout,
+ setStatus,
+ createNewTile,
+ createNewSpriteGraphic,
+ duplicateGraphicRecord,
+ openTilePaletteContextMenu,
+ openPlacedEntityContextMenu,
+ applyNpcEditorChange,
+ getEditorEngineOverrides,
+ saveEditorEngineOverrides,
+ getEffectiveHeightBlurStep,
+ isRendererDebugEnabled,
+ reloadGraphicsContentFromApi,
+ syncDocumentTitle,
+ syncCanvasDimensionsToTileSize,
+ refreshEditorEngineOverridesUi,
+ cacheStandaloneMapBootstrap,
+ currentMapId,
+ persistPopupBounds,
+ popupSessionStore,
+ windowRef: window,
+ isWorldModeActive,
+ getInitialWorldViewTile,
+ centerViewportOnWorldTile,
+ prefetchAdjacentWorldNeighborhoods,
+ worldRuntimeState,
+ syncWorldNeighborhoodForViewport,
+ drawNow,
});
+ renderController = runtimeControllerBootstrap.renderController;
+ statusLogWindowController = runtimeControllerBootstrap.statusLogWindowController;
+ runtimeLogging.setStatusLogWindowController(statusLogWindowController);
}
+
diff --git a/src/worldshaperStudio/runtimeBootstrapHelpers.ts b/src/worldshaperStudio/runtimeBootstrapHelpers.ts
new file mode 100644
index 0000000..8b19ee2
--- /dev/null
+++ b/src/worldshaperStudio/runtimeBootstrapHelpers.ts
@@ -0,0 +1,141 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars, no-empty, no-useless-escape */
+// @ts-nocheck
+import { buildSpritePreviewDataUrl } from "../editorCore";
+import {
+ buildSpriteCatalog,
+ buildTileCatalogById,
+ DEFAULT_MAP_BACKGROUND_COLOR,
+} from "../components/worldshaperShared";
+import type { WorldshaperStudioBootstrap } from "./bootstrap";
+
+function getContentRecords(payload: unknown, key: string) {
+ const records = payload && Array.isArray(payload[key]) ? payload[key] : [];
+ return records.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
+}
+
+export function cloneRuntimeValue
(value: T): T {
+ if (typeof structuredClone === "function") {
+ return structuredClone(value);
+ }
+ return value == null ? value : JSON.parse(JSON.stringify(value));
+}
+
+export function buildSpriteCatalogFromBootstrap(bootstrap: WorldshaperStudioBootstrap) {
+ const spriteRecords = getContentRecords(bootstrap?.contentByType?.sprites, "sprites");
+ if (spriteRecords.length > 0) {
+ return buildSpriteCatalog(spriteRecords, buildSpritePreviewDataUrl);
+ }
+ return cloneRuntimeValue(bootstrap?.spriteCatalog) || {};
+}
+
+export function buildTileCatalogByIdFromBootstrap(bootstrap: WorldshaperStudioBootstrap) {
+ const tileRecords = getContentRecords(bootstrap?.contentByType?.tiles, "tiles");
+ if (tileRecords.length > 0) {
+ return buildTileCatalogById(tileRecords, buildSpritePreviewDataUrl);
+ }
+ return cloneRuntimeValue(bootstrap?.tileCatalogById) || {};
+}
+
+export function buildNpcOverlaysFromWorldChunks(
+ chunks: unknown[],
+ spriteCatalog: Record,
+ chunkWidth: number,
+ chunkHeight: number,
+ originChunkX: number,
+ originChunkY: number,
+) {
+ return (Array.isArray(chunks) ? chunks : []).flatMap((chunk) => {
+ const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
+ const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
+ const offsetX = (baseChunkX - originChunkX) * chunkWidth;
+ const offsetY = (baseChunkY - originChunkY) * chunkHeight;
+ const instances = Array.isArray(chunk?.instances) ? chunk.instances : [];
+ return instances
+ .filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
+ .map((entry) => {
+ const record = entry.record && typeof entry.record === "object" && !Array.isArray(entry.record)
+ ? cloneRuntimeValue(entry.record)
+ : {};
+ const spriteId = String(record.spriteId || entry.spriteId || "").trim();
+ const spriteEntry = spriteCatalog[spriteId] || null;
+ const overlayX = offsetX + Math.max(0, Number(entry.x) || 0);
+ const overlayY = offsetY + Math.max(0, Number(entry.y) || 0);
+ record.position = {
+ x: overlayX,
+ y: overlayY,
+ };
+ return {
+ id: String(entry.id || "").trim(),
+ layer: Number(entry.layer) || 0,
+ name: String(record.name || entry.id || "NPC"),
+ spriteId,
+ x: overlayX,
+ y: overlayY,
+ dataUrl: spriteEntry ? spriteEntry.dataUrl : null,
+ spriteWidth: spriteEntry ? spriteEntry.spriteWidth : 28,
+ spriteHeight: spriteEntry ? spriteEntry.spriteHeight : 28,
+ opacity: spriteEntry ? spriteEntry.opacity : 1,
+ record,
+ };
+ })
+ .filter((entry) => entry.id);
+ });
+}
+
+export function normalizeMapBackgroundColor(value: unknown, fallback?: string) {
+ const safeFallback = fallback || DEFAULT_MAP_BACKGROUND_COLOR;
+ const raw = String(value || "").trim();
+ return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toUpperCase() : safeFallback;
+}
+
+export function createInitialWorldRuntimeState(bootstrap: WorldshaperStudioBootstrap) {
+ const worldId = String(bootstrap.worldId || bootstrap.mapId || "").trim();
+ const chunkRadius = Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0));
+ const originChunkX = Math.floor(Number(bootstrap.worldOriginChunkX) || 0);
+ const originChunkY = Math.floor(Number(bootstrap.worldOriginChunkY) || 0);
+
+ return {
+ enabled: !!worldId,
+ worldId,
+ worldName: String(bootstrap.worldName || bootstrap.mapName || bootstrap.worldId || bootstrap.mapId || "World").trim() || "World",
+ defaultBackgroundTileId: String(bootstrap.backgroundTileId || "").trim(),
+ heightBlurStep: Math.max(0, Math.min(1, Number(bootstrap.heightBlurStep ?? bootstrap.heightDetailStep) || 0.1)),
+ chunkWidth: Math.max(1, Number(bootstrap.worldChunkWidth) || 32),
+ chunkHeight: Math.max(1, Number(bootstrap.worldChunkHeight) || 32),
+ chunkRadius,
+ originChunkX,
+ originChunkY,
+ tileOffsetX: Math.floor(Number(bootstrap.worldTileOffsetX) || 0),
+ tileOffsetY: Math.floor(Number(bootstrap.worldTileOffsetY) || 0),
+ spawnX: Math.floor(Number(bootstrap.worldSpawnX) || 0),
+ spawnY: Math.floor(Number(bootstrap.worldSpawnY) || 0),
+ centerChunkX: originChunkX + chunkRadius,
+ centerChunkY: originChunkY + chunkRadius,
+ sourceChunks: Array.isArray(bootstrap.sourceChunks)
+ ? bootstrap.sourceChunks.map((entry) => ({
+ chunkX: Math.floor(Number(entry?.chunkX) || 0),
+ chunkY: Math.floor(Number(entry?.chunkY) || 0),
+ }))
+ : [],
+ bookmarks: Array.isArray(bootstrap.worldBookmarks)
+ ? bootstrap.worldBookmarks.map((entry, index) => ({
+ id: String(entry?.id || `poi_${index + 1}`).trim() || `poi_${index + 1}`,
+ label: String(entry?.label || entry?.id || `POI ${index + 1}`).trim() || `POI ${index + 1}`,
+ x: Math.floor(Number(entry?.x) || 0),
+ y: Math.floor(Number(entry?.y) || 0),
+ }))
+ : [],
+ chunkCache: new Map(),
+ dirtyChunkKeys: new Set(),
+ pendingNeighborhoodFetches: new Map(),
+ prefetchedNeighborhoodKeys: new Set(),
+ pendingLoadKey: "",
+ pendingLoadPromise: null,
+ requestSerial: 0,
+ documentDirty: false,
+ };
+}
+
+export const MAX_WORLD_CHUNK_CACHE_ENTRIES = 256;
+export const MAX_DYNAMIC_WORLD_CHUNK_RADIUS = 4;
+export const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~=";
diff --git a/src/worldshaperStudio/runtimeControllerBootstrap.ts b/src/worldshaperStudio/runtimeControllerBootstrap.ts
new file mode 100644
index 0000000..9149b45
--- /dev/null
+++ b/src/worldshaperStudio/runtimeControllerBootstrap.ts
@@ -0,0 +1,201 @@
+// @ts-nocheck
+
+import { createHistoryController } from "./historyController";
+import { createInteractionController } from "./interactionController";
+import { createImportController } from "./importController";
+import { createNpcController } from "./npcController";
+import { createChangelogSplashWindowController } from "./changelogSplashWindowController";
+import { createEntityEditorWindowController } from "./entityEditorWindowController";
+import { createEngineOverrideWindowController } from "./engineOverrideWindowController";
+import { createPersistenceController } from "./persistenceController";
+import { createRenderController } from "./renderController";
+import { createSidebarController } from "./sidebarController";
+import { createStatusLogWindowController } from "./statusLogWindowController";
+import { createTileArtEditorWindowController } from "./tileArtEditorWindowController";
+import { createToolWindowController } from "./toolWindowController";
+import { createWorldOverviewWindowController } from "./worldOverviewWindowController";
+import { createDebouncedCallback } from "./debounce";
+
+export function initializeRuntimeControllers(config) {
+ const {
+ scope,
+ uiScope,
+ resetWorkspaceLayout,
+ setStatus,
+ createNewTile,
+ createNewSpriteGraphic,
+ duplicateGraphicRecord,
+ openTilePaletteContextMenu,
+ openPlacedEntityContextMenu,
+ applyNpcEditorChange,
+ getEditorEngineOverrides,
+ saveEditorEngineOverrides,
+ getEffectiveHeightBlurStep,
+ isRendererDebugEnabled,
+ reloadGraphicsContentFromApi,
+ syncDocumentTitle,
+ syncCanvasDimensionsToTileSize,
+ refreshEditorEngineOverridesUi,
+ cacheStandaloneMapBootstrap,
+ currentMapId,
+ persistPopupBounds,
+ popupSessionStore,
+ windowRef,
+ isWorldModeActive,
+ getInitialWorldViewTile,
+ centerViewportOnWorldTile,
+ prefetchAdjacentWorldNeighborhoods,
+ worldRuntimeState,
+ syncWorldNeighborhoodForViewport,
+ drawNow,
+ } = config;
+
+ const toolWindowController = createToolWindowController(scope);
+ const tileArtEditorWindowController = createTileArtEditorWindowController(scope);
+ const entityEditorWindowController = createEntityEditorWindowController(scope);
+ const engineOverrideWindowController = createEngineOverrideWindowController(scope);
+ const worldOverviewWindowController = createWorldOverviewWindowController(scope);
+ const changelogSplashWindowController = createChangelogSplashWindowController(scope);
+ const statusLogWindowController = createStatusLogWindowController(scope);
+
+ const syncToolPanels = () => toolWindowController.syncPanels();
+ const handleSidebarTabButtonClick = (tab) => toolWindowController.handleTabButtonClick(tab);
+ const restoreAllToolWindows = () => toolWindowController.restoreAllWindows();
+ const openTileArtEditorWindow = (recordTypeOrId, maybeRecordId) => tileArtEditorWindowController.open(recordTypeOrId, maybeRecordId);
+ const closeTileArtEditorWindow = () => tileArtEditorWindowController.close();
+ const openEntityEditorWindow = (entityId) => entityEditorWindowController.open(entityId);
+ const closeEntityEditorWindow = () => entityEditorWindowController.close();
+ const openEngineOverrideWindow = () => engineOverrideWindowController.open();
+ const closeEngineOverrideWindow = () => engineOverrideWindowController.close();
+ const refreshEngineOverrideWindow = () => engineOverrideWindowController.refresh();
+ const refreshEngineOverrideSummary = () => engineOverrideWindowController.updateSummary();
+ const openWorldOverviewWindow = () => worldOverviewWindowController.open();
+ const closeWorldOverviewWindow = () => worldOverviewWindowController.close();
+ const refreshWorldOverviewWindow = () => worldOverviewWindowController.refresh();
+ const invalidateWorldOverviewChunkSurfaces = (chunkKeys, options) => worldOverviewWindowController.invalidateChunkSurfaces?.(chunkKeys, options);
+ const openStatusLogWindow = () => statusLogWindowController.open();
+ const closeStatusLogWindow = () => statusLogWindowController.close();
+ const openNewsWindow = (options = {}) => changelogSplashWindowController.open({ markSeen: false, ...options });
+ const resetWorkspaceLayoutFlow = () => {
+ resetWorkspaceLayout();
+ toolWindowController.restoreAllWindows();
+ setStatus("Workspace layout reset.", false);
+ };
+
+ scope.syncToolPanels = syncToolPanels;
+ scope.handleSidebarTabButtonClick = handleSidebarTabButtonClick;
+ scope.restoreAllToolWindows = restoreAllToolWindows;
+ scope.resetWorkspaceLayout = resetWorkspaceLayoutFlow;
+ scope.createNewTile = createNewTile;
+ scope.createNewSpriteGraphic = createNewSpriteGraphic;
+ scope.duplicateGraphicRecord = duplicateGraphicRecord;
+ scope.openTileArtEditorWindow = openTileArtEditorWindow;
+ scope.closeTileArtEditorWindow = closeTileArtEditorWindow;
+ scope.openEntityEditorWindow = openEntityEditorWindow;
+ scope.closeEntityEditorWindow = closeEntityEditorWindow;
+ scope.openEngineOverrideWindow = openEngineOverrideWindow;
+ scope.closeEngineOverrideWindow = closeEngineOverrideWindow;
+ scope.refreshEngineOverrideWindow = refreshEngineOverrideWindow;
+ scope.refreshEngineOverrideSummary = refreshEngineOverrideSummary;
+ scope.openWorldOverviewWindow = openWorldOverviewWindow;
+ scope.closeWorldOverviewWindow = closeWorldOverviewWindow;
+ scope.refreshWorldOverviewWindow = refreshWorldOverviewWindow;
+ scope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces;
+ scope.openStatusLogWindow = openStatusLogWindow;
+ scope.closeStatusLogWindow = closeStatusLogWindow;
+ scope.openNewsWindow = openNewsWindow;
+ scope.openTilePaletteContextMenu = openTilePaletteContextMenu;
+ scope.openPlacedEntityContextMenu = openPlacedEntityContextMenu;
+ scope.applyNpcEditorChange = applyNpcEditorChange;
+ scope.getEditorEngineOverrides = getEditorEngineOverrides;
+ scope.saveEditorEngineOverrides = saveEditorEngineOverrides;
+ scope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep;
+ scope.isRendererDebugEnabled = isRendererDebugEnabled;
+ scope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi;
+
+ uiScope.syncToolPanels = syncToolPanels;
+ uiScope.handleSidebarTabButtonClick = handleSidebarTabButtonClick;
+ uiScope.restoreAllToolWindows = restoreAllToolWindows;
+ uiScope.resetWorkspaceLayout = resetWorkspaceLayoutFlow;
+ uiScope.createNewTile = createNewTile;
+ uiScope.createNewSpriteGraphic = createNewSpriteGraphic;
+ uiScope.duplicateGraphicRecord = duplicateGraphicRecord;
+ uiScope.openEntityEditorWindow = openEntityEditorWindow;
+ uiScope.closeEntityEditorWindow = closeEntityEditorWindow;
+ uiScope.openEngineOverrideWindow = openEngineOverrideWindow;
+ uiScope.closeEngineOverrideWindow = closeEngineOverrideWindow;
+ uiScope.refreshEngineOverrideWindow = refreshEngineOverrideWindow;
+ uiScope.refreshEngineOverrideSummary = refreshEngineOverrideSummary;
+ uiScope.openWorldOverviewWindow = openWorldOverviewWindow;
+ uiScope.closeWorldOverviewWindow = closeWorldOverviewWindow;
+ uiScope.refreshWorldOverviewWindow = refreshWorldOverviewWindow;
+ uiScope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces;
+ uiScope.openStatusLogWindow = openStatusLogWindow;
+ uiScope.closeStatusLogWindow = closeStatusLogWindow;
+ uiScope.openNewsWindow = openNewsWindow;
+ uiScope.openTilePaletteContextMenu = openTilePaletteContextMenu;
+ uiScope.openPlacedEntityContextMenu = openPlacedEntityContextMenu;
+ uiScope.applyNpcEditorChange = applyNpcEditorChange;
+ uiScope.getEditorEngineOverrides = getEditorEngineOverrides;
+ uiScope.saveEditorEngineOverrides = saveEditorEngineOverrides;
+ uiScope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep;
+ uiScope.isRendererDebugEnabled = isRendererDebugEnabled;
+ uiScope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi;
+
+ syncDocumentTitle();
+ createHistoryController(scope);
+ createNpcController(scope);
+ createSidebarController(scope);
+ const renderController = createRenderController(scope);
+ createPersistenceController(scope);
+ createImportController(scope);
+ const interactionController = createInteractionController(scope);
+
+ const persistPopupBoundsDeferred = createDebouncedCallback(() => {
+ persistPopupBounds();
+ }, 160);
+
+ syncCanvasDimensionsToTileSize();
+ toolWindowController.initialize();
+ tileArtEditorWindowController.initialize();
+ entityEditorWindowController.initialize();
+ engineOverrideWindowController.initialize();
+ worldOverviewWindowController.initialize();
+ changelogSplashWindowController.initialize();
+ statusLogWindowController.initialize();
+ renderController.initializeRenderAssets();
+ interactionController.initializeEditorState();
+ interactionController.bindDomEvents();
+ interactionController.initializeUi();
+ refreshEditorEngineOverridesUi();
+ cacheStandaloneMapBootstrap(currentMapId);
+
+ if (isWorldModeActive()) {
+ windowRef.requestAnimationFrame(() => {
+ const initialWorldView = getInitialWorldViewTile();
+ centerViewportOnWorldTile(initialWorldView.worldTileX, initialWorldView.worldTileY);
+ prefetchAdjacentWorldNeighborhoods(worldRuntimeState.centerChunkX, worldRuntimeState.centerChunkY);
+ syncWorldNeighborhoodForViewport();
+ drawNow();
+ setStatus("World mode loaded. Endless navigation is active.", false);
+ });
+ }
+
+ windowRef.requestAnimationFrame(() => {
+ changelogSplashWindowController.maybeOpenForCurrentVersion();
+ });
+ windowRef.addEventListener("resize", () => {
+ persistPopupBoundsDeferred();
+ });
+ windowRef.addEventListener("beforeunload", () => {
+ popupSessionStore.flushPersistedLayout(windowRef);
+ persistPopupBounds();
+ });
+
+ return {
+ renderController,
+ statusLogWindowController,
+ changelogSplashWindowController,
+ persistPopupBoundsDeferred,
+ };
+}
diff --git a/src/worldshaperStudio/runtimeLogging.ts b/src/worldshaperStudio/runtimeLogging.ts
new file mode 100644
index 0000000..8d08890
--- /dev/null
+++ b/src/worldshaperStudio/runtimeLogging.ts
@@ -0,0 +1,67 @@
+// @ts-nocheck
+
+export function createRuntimeLogging({ windowRef, runtimeUniqueId }) {
+ const editorLogEntries = [];
+ const EDITOR_LOG_LIMIT = 500;
+ let statusLogWindowController = null;
+
+ function formatEditorLogTimestamp(timestamp) {
+ try {
+ return new Date(timestamp).toLocaleString();
+ } catch {
+ return String(timestamp || "");
+ }
+ }
+
+ function appendEditorLogEntry(level, message) {
+ const normalizedMessage = String(message || "").trim();
+ if (!normalizedMessage) {
+ return null;
+ }
+ const timestamp = Date.now();
+ const entry = {
+ id: runtimeUniqueId(),
+ timestamp,
+ timestampLabel: formatEditorLogTimestamp(timestamp),
+ level: String(level || "Information").trim() || "Information",
+ message: normalizedMessage,
+ };
+ editorLogEntries.push(entry);
+ while (editorLogEntries.length > EDITOR_LOG_LIMIT) {
+ editorLogEntries.shift();
+ }
+ statusLogWindowController?.refresh?.();
+ return entry;
+ }
+
+ function getEditorLogEntries() {
+ return editorLogEntries.slice();
+ }
+
+ function clearEditorLogEntries() {
+ editorLogEntries.splice(0, editorLogEntries.length);
+ statusLogWindowController?.refresh?.();
+ }
+
+ windowRef.addEventListener("error", (event) => {
+ const message = String(event?.message || event?.error?.message || "Unknown runtime error");
+ appendEditorLogEntry("Error", message);
+ });
+
+ windowRef.addEventListener("unhandledrejection", (event) => {
+ const reason = event?.reason;
+ const message = typeof reason === "string"
+ ? reason
+ : String(reason?.message || reason || "Unhandled promise rejection");
+ appendEditorLogEntry("Error", message);
+ });
+
+ return {
+ appendEditorLogEntry,
+ getEditorLogEntries,
+ clearEditorLogEntries,
+ setStatusLogWindowController(nextController) {
+ statusLogWindowController = nextController;
+ },
+ };
+}
diff --git a/src/worldshaperStudio/tileArtEditorHelpers.ts b/src/worldshaperStudio/tileArtEditorHelpers.ts
new file mode 100644
index 0000000..2110c56
--- /dev/null
+++ b/src/worldshaperStudio/tileArtEditorHelpers.ts
@@ -0,0 +1,420 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+// @ts-nocheck
+
+import {
+ buildSpritePreviewDataUrl,
+ getSpritePalette,
+ normalizeImagePlayback,
+} from "../editorCore";
+import { normalizeEditorTags } from "./tagUtils";
+
+export const TILE_ART_SIZE = 16;
+
+export const EYEDROPPER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
+ `
+
+
+
+ `,
+)}") 4 28, crosshair`;
+
+export function cloneValue(value) {
+ if (typeof structuredClone === "function") {
+ return structuredClone(value);
+ }
+ return value == null ? value : JSON.parse(JSON.stringify(value));
+}
+
+function normalizeRoleList(value) {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return Array.from(new Set(
+ value
+ .map((entry) => String(entry || "").trim().toLowerCase())
+ .filter((entry) => entry === "tile" || entry === "sprite"),
+ ));
+}
+
+export function normalizeTimelineRows(rows) {
+ return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
+ const row = Array.isArray(rows) ? String(rows[rowIndex] || "") : "";
+ return row.padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
+ });
+}
+
+export function normalizeWorkingFrames(record) {
+ const rawFrames = Array.isArray(record?.frames) ? record.frames.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : [];
+ const normalizedFrames = rawFrames.map((entry, index) => ({
+ ...cloneValue(entry),
+ id: String(entry.id || `frame_${index}`).trim() || `frame_${index}`,
+ enabled: entry.enabled !== false,
+ index: Number.isFinite(Number(entry.index)) ? Math.max(0, Math.floor(Number(entry.index))) : index,
+ rows: normalizeTimelineRows(entry.rows),
+ }));
+ if (normalizedFrames.length > 0) {
+ return normalizedFrames;
+ }
+ return [{
+ id: "frame_0",
+ enabled: true,
+ index: 0,
+ rows: normalizeTimelineRows(record?.rows),
+ }];
+}
+
+export function sortWorkingFrames(frames) {
+ return frames
+ .map((frame, sourceIndex) => ({
+ frame,
+ sourceIndex,
+ sortIndex: Number.isFinite(Number(frame?.index)) ? Number(frame.index) : sourceIndex,
+ }))
+ .sort((left, right) => (
+ left.sortIndex !== right.sortIndex
+ ? left.sortIndex - right.sortIndex
+ : left.sourceIndex - right.sourceIndex
+ ))
+ .map((entry) => entry.frame);
+}
+
+export function normalizeWorkingGraphicRecord(recordType, record) {
+ const source = cloneValue(record) || {};
+ const roles = normalizeRoleList(source.roles);
+ const nextRoles = recordType === "tile"
+ ? Array.from(new Set([...roles, "tile"]))
+ : (
+ recordType === "sprite"
+ ? Array.from(new Set([...roles, "sprite"]))
+ : roles.filter((entry) => entry !== "sprite")
+ );
+ const frames = normalizeWorkingFrames(source).map((frame, index) => ({
+ ...frame,
+ index,
+ }));
+ const requestedDefaultFrameId = String(source.defaultFrame || "").trim();
+ const defaultFrameId = String(
+ frames.find((frame) => String(frame.id || "").trim() === requestedDefaultFrameId)?.id
+ || frames[0]?.id
+ || "frame_0",
+ ).trim() || "frame_0";
+ const workingRows = normalizeTimelineRows(
+ Array.isArray(source.rows) && source.rows.length > 0
+ ? source.rows
+ : (frames.find((frame) => String(frame.id || "").trim() === defaultFrameId)?.rows || frames[0]?.rows || [])
+ );
+ return {
+ ...source,
+ id: String(source.id || `${recordType === "tile" ? "tile" : "sprite"}_${Date.now()}`).trim(),
+ name: typeof source.name === "string" ? source.name : "",
+ description: typeof source.description === "string" ? source.description : "",
+ width: TILE_ART_SIZE,
+ height: TILE_ART_SIZE,
+ pixelScale: Math.max(1, Number(source.pixelScale) || 2),
+ opacity: Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1,
+ tags: normalizeEditorTags(source.tags),
+ roles: nextRoles,
+ tileSymbol: nextRoles.includes("tile")
+ ? (String(source.tileSymbol ?? source.symbol ?? source.id ?? "T").trim().charAt(0) || "T")
+ : "",
+ defaultFrame: defaultFrameId,
+ speed: Number.isFinite(Number(source.speed)) && Number(source.speed) >= 0 ? Number(source.speed) : 0,
+ playback: normalizeImagePlayback(source.playback),
+ frames,
+ rows: workingRows,
+ };
+}
+
+export function normalizeOpacityValue(value, fallback = 1) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) {
+ return fallback;
+ }
+ return Math.max(0, Math.min(1, parsed));
+}
+
+export function formatOpacityValue(value) {
+ const normalized = normalizeOpacityValue(value, 1);
+ return normalized.toFixed(2).replace(/\.?0+$/, "");
+}
+
+export function cloneRows(rows) {
+ return Array.isArray(rows)
+ ? rows.map((row) => String(row || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE))
+ : Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE));
+}
+
+export function buildRowsPreviewRecord(rows) {
+ return {
+ width: TILE_ART_SIZE,
+ height: TILE_ART_SIZE,
+ rows: cloneRows(rows),
+ };
+}
+
+export function formatPlaybackLabel(value) {
+ const normalized = normalizeImagePlayback(value);
+ if (normalized === "rewind") {
+ return "Rewind";
+ }
+ if (normalized === "stop") {
+ return "Stop";
+ }
+ return "Normal";
+}
+
+export function getWorkingCellSymbol(record, x, y) {
+ const rows = Array.isArray(record?.rows) ? record.rows : [];
+ const row = String(rows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
+ return String(row.charAt(x) || ".").charAt(0) || ".";
+}
+
+export function paintWorkingRowsCell(rows, x, y, symbol) {
+ const nextRows = cloneRows(rows);
+ const nextSymbol = String(symbol || ".").charAt(0) || ".";
+ const targetRow = String(nextRows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
+ nextRows[y] = `${targetRow.slice(0, x)}${nextSymbol}${targetRow.slice(x + 1)}`;
+ return nextRows;
+}
+
+export function getRowsMatrix(rows) {
+ return cloneRows(rows).map((row) => Array.from(row));
+}
+
+export function buildRowsFromMatrix(matrix) {
+ return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
+ const sourceRow = Array.isArray(matrix?.[rowIndex]) ? matrix[rowIndex] : [];
+ return sourceRow.join("").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
+ });
+}
+
+export function getAlternatePaintSymbol(record, preferredSymbol) {
+ const normalizedPreferred = String(preferredSymbol || "").charAt(0) || ".";
+ const palette = getSpritePalette(record || undefined);
+ const nextSymbol = Object.keys(palette)
+ .map((symbol) => String(symbol || "").charAt(0))
+ .find((symbol) => symbol && symbol !== normalizedPreferred && symbol !== ".");
+ return nextSymbol || ".";
+}
+
+export function shiftRows(rows, offsetX, offsetY) {
+ const nextRows = Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE).split(""));
+ const sourceRows = cloneRows(rows);
+ for (let y = 0; y < TILE_ART_SIZE; y += 1) {
+ const row = sourceRows[y] || ".".repeat(TILE_ART_SIZE);
+ for (let x = 0; x < TILE_ART_SIZE; x += 1) {
+ const nextX = x + offsetX;
+ const nextY = y + offsetY;
+ if (nextX < 0 || nextX >= TILE_ART_SIZE || nextY < 0 || nextY >= TILE_ART_SIZE) {
+ continue;
+ }
+ nextRows[nextY][nextX] = String(row.charAt(x) || ".").charAt(0) || ".";
+ }
+ }
+ return buildRowsFromMatrix(nextRows);
+}
+
+export function flipRowsHorizontally(rows) {
+ return cloneRows(rows).map((row) => row.split("").reverse().join(""));
+}
+
+export function flipRowsVertically(rows) {
+ return cloneRows(rows).slice().reverse();
+}
+
+export function rotateRowsClockwise(rows) {
+ const matrix = getRowsMatrix(rows);
+ const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
+ for (let y = 0; y < TILE_ART_SIZE; y += 1) {
+ for (let x = 0; x < TILE_ART_SIZE; x += 1) {
+ nextMatrix[x][TILE_ART_SIZE - 1 - y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
+ }
+ }
+ return buildRowsFromMatrix(nextMatrix);
+}
+
+export function rotateRowsCounterClockwise(rows) {
+ const matrix = getRowsMatrix(rows);
+ const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
+ for (let y = 0; y < TILE_ART_SIZE; y += 1) {
+ for (let x = 0; x < TILE_ART_SIZE; x += 1) {
+ nextMatrix[TILE_ART_SIZE - 1 - x][y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
+ }
+ }
+ return buildRowsFromMatrix(nextMatrix);
+}
+
+export function buildShapeFillMask(shapeKind, startX, startY, endX, endY) {
+ const minX = Math.max(0, Math.min(startX, endX));
+ const maxX = Math.min(TILE_ART_SIZE - 1, Math.max(startX, endX));
+ const minY = Math.max(0, Math.min(startY, endY));
+ const maxY = Math.min(TILE_ART_SIZE - 1, Math.max(startY, endY));
+ const fillMask = new Set();
+ const shape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
+ const width = Math.max(1, (maxX - minX) + 1);
+ const height = Math.max(1, (maxY - minY) + 1);
+ const centerX = minX + (width / 2);
+ const centerY = minY + (height / 2);
+ const denomX = Math.max(0.5, width / 2);
+ const denomY = Math.max(0.5, height / 2);
+ const triangleAx = minX + (width - 1) / 2;
+ const triangleAy = minY;
+ const triangleBx = minX;
+ const triangleBy = maxY;
+ const triangleCx = maxX;
+ const triangleCy = maxY;
+ const triangleDenominator = ((triangleBy - triangleCy) * (triangleAx - triangleCx)) + ((triangleCx - triangleBx) * (triangleAy - triangleCy));
+ for (let y = minY; y <= maxY; y += 1) {
+ for (let x = minX; x <= maxX; x += 1) {
+ let include;
+ const sampleX = x + 0.5;
+ const sampleY = y + 0.5;
+ if (shape === "rectangle") {
+ include = true;
+ } else if (shape === "circle") {
+ const normX = (sampleX - centerX) / denomX;
+ const normY = (sampleY - centerY) / denomY;
+ include = (normX * normX) + (normY * normY) <= 1;
+ } else if (triangleDenominator !== 0) {
+ const a = (((triangleBy - triangleCy) * (sampleX - triangleCx)) + ((triangleCx - triangleBx) * (sampleY - triangleCy))) / triangleDenominator;
+ const b = (((triangleCy - triangleAy) * (sampleX - triangleCx)) + ((triangleAx - triangleCx) * (sampleY - triangleCy))) / triangleDenominator;
+ const c = 1 - a - b;
+ include = a >= 0 && b >= 0 && c >= 0;
+ } else {
+ include = x === Math.round(triangleAx) && y >= minY && y <= maxY;
+ }
+ if (include === true) {
+ fillMask.add(`${x}:${y}`);
+ }
+ }
+ }
+ return fillMask;
+}
+
+export function buildOutlineMask(fillMask) {
+ const outlineMask = new Set();
+ fillMask.forEach((key) => {
+ const [xText, yText] = String(key || "").split(":");
+ const x = Number(xText);
+ const y = Number(yText);
+ const neighbors = [
+ `${x - 1}:${y}`,
+ `${x + 1}:${y}`,
+ `${x}:${y - 1}`,
+ `${x}:${y + 1}`,
+ ];
+ if (neighbors.some((neighbor) => !fillMask.has(neighbor))) {
+ outlineMask.add(key);
+ }
+ });
+ return outlineMask;
+}
+
+export function applyMaskToRows(baseRows, mask, symbol) {
+ const matrix = getRowsMatrix(baseRows);
+ mask.forEach((key) => {
+ const [xText, yText] = String(key || "").split(":");
+ const x = Number(xText);
+ const y = Number(yText);
+ if (x < 0 || x >= TILE_ART_SIZE || y < 0 || y >= TILE_ART_SIZE) {
+ return;
+ }
+ matrix[y][x] = String(symbol || ".").charAt(0) || ".";
+ });
+ return buildRowsFromMatrix(matrix);
+}
+
+export function getLineRows(baseRows, startX, startY, endX, endY, symbol) {
+ const normalizedSymbol = String(symbol || ".").charAt(0) || ".";
+ const matrix = getRowsMatrix(baseRows);
+ let x0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startX) || 0));
+ let y0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startY) || 0));
+ const x1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endX) || 0));
+ const y1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endY) || 0));
+ const deltaX = Math.abs(x1 - x0);
+ const deltaY = Math.abs(y1 - y0);
+ const stepX = x0 < x1 ? 1 : -1;
+ const stepY = y0 < y1 ? 1 : -1;
+ let error = deltaX - deltaY;
+ while (true) {
+ matrix[y0][x0] = normalizedSymbol;
+ if (x0 === x1 && y0 === y1) {
+ break;
+ }
+ const nextError = error * 2;
+ if (nextError > -deltaY) {
+ error -= deltaY;
+ x0 += stepX;
+ }
+ if (nextError < deltaX) {
+ error += deltaX;
+ y0 += stepY;
+ }
+ }
+ return buildRowsFromMatrix(matrix);
+}
+
+export function buildShapeOptionIconMarkup(shapeKind, variant, tone = "draw") {
+ const normalizedShape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
+ const normalizedVariant = variant === "outline" || variant === "two-tone" ? variant : "fill";
+ const normalizedTone = tone === "erase" ? "erase" : "draw";
+ return ""
+ + `";
+}
+
+export function buildLineOptionIconMarkup(tone = "draw") {
+ const normalizedTone = tone === "erase" ? "erase" : "draw";
+ return ""
+ + `";
+}
+
+export function buildCurrentShapeToolIconMarkup(state) {
+ if (state?.activeTool === "line" || String(state?.activeShapeMenuId || "").trim() === "line") {
+ return buildLineOptionIconMarkup("draw");
+ }
+ return buildShapeOptionIconMarkup(
+ state?.activeShapeKind || "rectangle",
+ state?.activeShapeVariant || "outline",
+ "draw",
+ );
+}
+
+export function buildCurrentEraseToolIconMarkup(state) {
+ return buildShapeOptionIconMarkup(
+ state?.activeEraseKind || "rectangle",
+ "fill",
+ "erase",
+ );
+}
+
+export function buildTransformCategoryIconMarkup(kind) {
+ const normalizedKind = kind === "flip" ? "flip" : "rotate";
+ return ""
+ + `";
+}
+
+export function buildTransformOptionIconMarkup(kind) {
+ const normalizedKind = [
+ "rotate-cw",
+ "rotate-ccw",
+ "flip-h",
+ "flip-v",
+ ].includes(String(kind || "").trim()) ? String(kind || "").trim() : "rotate-cw";
+ return ""
+ + `";
+}
+
+export function buildFramePreviewDataUrl(rows, scale = 10) {
+ return buildSpritePreviewDataUrl(buildRowsPreviewRecord(rows), scale);
+}
diff --git a/src/worldshaperStudio/tileArtEditorWindowController.ts b/src/worldshaperStudio/tileArtEditorWindowController.ts
index 597f0ee..8087005 100644
--- a/src/worldshaperStudio/tileArtEditorWindowController.ts
+++ b/src/worldshaperStudio/tileArtEditorWindowController.ts
@@ -22,9 +22,40 @@ import {
} from "./textTransferUtils";
import { clampFloatingWindowRect } from "./floatingWindowUtils";
import { appendContextMenuItems, menuItem, menuSubmenu, openContextMenuAtPoint } from "./contextMenuSchema";
+import {
+ applyMaskToRows,
+ buildCurrentEraseToolIconMarkup,
+ buildCurrentShapeToolIconMarkup,
+ buildFramePreviewDataUrl,
+ buildLineOptionIconMarkup,
+ buildOutlineMask,
+ buildShapeFillMask,
+ buildShapeOptionIconMarkup,
+ buildTransformCategoryIconMarkup,
+ buildTransformOptionIconMarkup,
+ cloneRows,
+ cloneValue,
+ EYEDROPPER_CURSOR,
+ flipRowsHorizontally,
+ flipRowsVertically,
+ formatOpacityValue,
+ formatPlaybackLabel,
+ getAlternatePaintSymbol,
+ getLineRows,
+ getWorkingCellSymbol,
+ normalizeOpacityValue,
+ normalizeTimelineRows,
+ normalizeWorkingFrames,
+ normalizeWorkingGraphicRecord,
+ paintWorkingRowsCell,
+ rotateRowsClockwise,
+ rotateRowsCounterClockwise,
+ shiftRows,
+ sortWorkingFrames,
+ TILE_ART_SIZE,
+} from "./tileArtEditorHelpers";
const TILE_ART_WINDOW_KEY = "tileArtEditor";
-const TILE_ART_SIZE = 16;
const GRID_CELL_SIZE = 21;
const MIN_WIDTH = 452;
const MIN_HEIGHT = 628;
@@ -38,415 +69,11 @@ const TOOL_MENU_TAG_PREFIX = "tile-art-tool-menu:";
const SHORTCUT_HELP_TOOLTIP_TAG = "tile-art-shortcut-help";
const ANIMATION_SPEED_TOOLTIP_TAG = "tile-art-animation-speed";
const ANIMATION_PLAYBACK_TOOLTIP_TAG = "tile-art-animation-playback";
-const EYEDROPPER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
- `
-
-
-
- `,
-)}") 4 28, crosshair`;
-
-function cloneValue(value) {
- if (typeof structuredClone === "function") {
- return structuredClone(value);
- }
- return value == null ? value : JSON.parse(JSON.stringify(value));
-}
function clampWindowRect(layerRect, left, top, width, height) {
return clampFloatingWindowRect(layerRect, left, top, width, height, MIN_WIDTH, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT);
}
-function normalizeRoleList(value) {
- if (!Array.isArray(value)) {
- return [];
- }
- return Array.from(new Set(
- value
- .map((entry) => String(entry || "").trim().toLowerCase())
- .filter((entry) => entry === "tile" || entry === "sprite"),
- ));
-}
-
-function normalizeTimelineRows(rows) {
- return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
- const row = Array.isArray(rows) ? String(rows[rowIndex] || "") : "";
- return row.padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
- });
-}
-
-function normalizeWorkingFrames(record) {
- const rawFrames = Array.isArray(record?.frames) ? record.frames.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : [];
- const normalizedFrames = rawFrames.map((entry, index) => ({
- ...cloneValue(entry),
- id: String(entry.id || `frame_${index}`).trim() || `frame_${index}`,
- enabled: entry.enabled !== false,
- index: Number.isFinite(Number(entry.index)) ? Math.max(0, Math.floor(Number(entry.index))) : index,
- rows: normalizeTimelineRows(entry.rows),
- }));
- if (normalizedFrames.length > 0) {
- return normalizedFrames;
- }
- return [{
- id: "frame_0",
- enabled: true,
- index: 0,
- rows: normalizeTimelineRows(record?.rows),
- }];
-}
-
-function sortWorkingFrames(frames) {
- return frames
- .map((frame, sourceIndex) => ({
- frame,
- sourceIndex,
- sortIndex: Number.isFinite(Number(frame?.index)) ? Number(frame.index) : sourceIndex,
- }))
- .sort((left, right) => (
- left.sortIndex !== right.sortIndex
- ? left.sortIndex - right.sortIndex
- : left.sourceIndex - right.sourceIndex
- ))
- .map((entry) => entry.frame);
-}
-
-function normalizeWorkingGraphicRecord(recordType, record) {
- const source = cloneValue(record) || {};
- const roles = normalizeRoleList(source.roles);
- const nextRoles = recordType === "tile"
- ? Array.from(new Set([...roles, "tile"]))
- : (
- recordType === "sprite"
- ? Array.from(new Set([...roles, "sprite"]))
- : roles.filter((entry) => entry !== "sprite")
- );
- const frames = normalizeWorkingFrames(source).map((frame, index) => ({
- ...frame,
- index,
- }));
- const requestedDefaultFrameId = String(source.defaultFrame || "").trim();
- const defaultFrameId = String(
- frames.find((frame) => String(frame.id || "").trim() === requestedDefaultFrameId)?.id
- || frames[0]?.id
- || "frame_0",
- ).trim() || "frame_0";
- const workingRows = normalizeTimelineRows(
- Array.isArray(source.rows) && source.rows.length > 0
- ? source.rows
- : (frames.find((frame) => String(frame.id || "").trim() === defaultFrameId)?.rows || frames[0]?.rows || [])
- );
- return {
- ...source,
- id: String(source.id || `${recordType === "tile" ? "tile" : "sprite"}_${Date.now()}`).trim(),
- name: typeof source.name === "string" ? source.name : "",
- description: typeof source.description === "string" ? source.description : "",
- width: TILE_ART_SIZE,
- height: TILE_ART_SIZE,
- pixelScale: Math.max(1, Number(source.pixelScale) || 2),
- opacity: Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1,
- tags: normalizeEditorTags(source.tags),
- roles: nextRoles,
- tileSymbol: nextRoles.includes("tile")
- ? (String(source.tileSymbol ?? source.symbol ?? source.id ?? "T").trim().charAt(0) || "T")
- : "",
- defaultFrame: defaultFrameId,
- speed: Number.isFinite(Number(source.speed)) && Number(source.speed) >= 0 ? Number(source.speed) : 0,
- playback: normalizeImagePlayback(source.playback),
- frames,
- rows: workingRows,
- };
-}
-
-function normalizeOpacityValue(value, fallback = 1) {
- const parsed = Number(value);
- if (!Number.isFinite(parsed)) {
- return fallback;
- }
- return Math.max(0, Math.min(1, parsed));
-}
-
-function formatOpacityValue(value) {
- const normalized = normalizeOpacityValue(value, 1);
- return normalized.toFixed(2).replace(/\.?0+$/, "");
-}
-
-function buildRowsPreviewRecord(rows) {
- return {
- width: TILE_ART_SIZE,
- height: TILE_ART_SIZE,
- rows: cloneRows(rows),
- };
-}
-
-function formatPlaybackLabel(value) {
- const normalized = normalizeImagePlayback(value);
- if (normalized === "rewind") {
- return "Rewind";
- }
- if (normalized === "stop") {
- return "Stop";
- }
- return "Normal";
-}
-
-function cloneRows(rows) {
- return Array.isArray(rows)
- ? rows.map((row) => String(row || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE))
- : Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE));
-}
-
-function getWorkingCellSymbol(record, x, y) {
- const rows = Array.isArray(record?.rows) ? record.rows : [];
- const row = String(rows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
- return String(row.charAt(x) || ".").charAt(0) || ".";
-}
-
-function paintWorkingRowsCell(rows, x, y, symbol) {
- const nextRows = cloneRows(rows);
- const nextSymbol = String(symbol || ".").charAt(0) || ".";
- const targetRow = String(nextRows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
- nextRows[y] = `${targetRow.slice(0, x)}${nextSymbol}${targetRow.slice(x + 1)}`;
- return nextRows;
-}
-
-function getRowsMatrix(rows) {
- return cloneRows(rows).map((row) => Array.from(row));
-}
-
-function buildRowsFromMatrix(matrix) {
- return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
- const sourceRow = Array.isArray(matrix?.[rowIndex]) ? matrix[rowIndex] : [];
- return sourceRow.join("").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
- });
-}
-
-function getAlternatePaintSymbol(record, preferredSymbol) {
- const normalizedPreferred = String(preferredSymbol || "").charAt(0) || ".";
- const palette = getSpritePalette(record || undefined);
- const nextSymbol = Object.keys(palette)
- .map((symbol) => String(symbol || "").charAt(0))
- .find((symbol) => symbol && symbol !== normalizedPreferred && symbol !== ".");
- return nextSymbol || ".";
-}
-
-function shiftRows(rows, offsetX, offsetY) {
- const nextRows = Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE).split(""));
- const sourceRows = cloneRows(rows);
- for (let y = 0; y < TILE_ART_SIZE; y += 1) {
- const row = sourceRows[y] || ".".repeat(TILE_ART_SIZE);
- for (let x = 0; x < TILE_ART_SIZE; x += 1) {
- const nextX = x + offsetX;
- const nextY = y + offsetY;
- if (nextX < 0 || nextX >= TILE_ART_SIZE || nextY < 0 || nextY >= TILE_ART_SIZE) {
- continue;
- }
- nextRows[nextY][nextX] = String(row.charAt(x) || ".").charAt(0) || ".";
- }
- }
- return buildRowsFromMatrix(nextRows);
-}
-
-function flipRowsHorizontally(rows) {
- return cloneRows(rows).map((row) => row.split("").reverse().join(""));
-}
-
-function flipRowsVertically(rows) {
- return cloneRows(rows).slice().reverse();
-}
-
-function rotateRowsClockwise(rows) {
- const matrix = getRowsMatrix(rows);
- const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
- for (let y = 0; y < TILE_ART_SIZE; y += 1) {
- for (let x = 0; x < TILE_ART_SIZE; x += 1) {
- nextMatrix[x][TILE_ART_SIZE - 1 - y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
- }
- }
- return buildRowsFromMatrix(nextMatrix);
-}
-
-function rotateRowsCounterClockwise(rows) {
- const matrix = getRowsMatrix(rows);
- const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
- for (let y = 0; y < TILE_ART_SIZE; y += 1) {
- for (let x = 0; x < TILE_ART_SIZE; x += 1) {
- nextMatrix[TILE_ART_SIZE - 1 - x][y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
- }
- }
- return buildRowsFromMatrix(nextMatrix);
-}
-
-function buildShapeFillMask(shapeKind, startX, startY, endX, endY) {
- const minX = Math.max(0, Math.min(startX, endX));
- const maxX = Math.min(TILE_ART_SIZE - 1, Math.max(startX, endX));
- const minY = Math.max(0, Math.min(startY, endY));
- const maxY = Math.min(TILE_ART_SIZE - 1, Math.max(startY, endY));
- const fillMask = new Set();
- const shape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
- const width = Math.max(1, (maxX - minX) + 1);
- const height = Math.max(1, (maxY - minY) + 1);
- const centerX = minX + (width / 2);
- const centerY = minY + (height / 2);
- const denomX = Math.max(0.5, width / 2);
- const denomY = Math.max(0.5, height / 2);
- const triangleAx = minX + (width - 1) / 2;
- const triangleAy = minY;
- const triangleBx = minX;
- const triangleBy = maxY;
- const triangleCx = maxX;
- const triangleCy = maxY;
- const triangleDenominator = ((triangleBy - triangleCy) * (triangleAx - triangleCx)) + ((triangleCx - triangleBx) * (triangleAy - triangleCy));
- for (let y = minY; y <= maxY; y += 1) {
- for (let x = minX; x <= maxX; x += 1) {
- let include;
- const sampleX = x + 0.5;
- const sampleY = y + 0.5;
- if (shape === "rectangle") {
- include = true;
- } else if (shape === "circle") {
- const normX = (sampleX - centerX) / denomX;
- const normY = (sampleY - centerY) / denomY;
- include = (normX * normX) + (normY * normY) <= 1;
- } else if (triangleDenominator !== 0) {
- const a = (((triangleBy - triangleCy) * (sampleX - triangleCx)) + ((triangleCx - triangleBx) * (sampleY - triangleCy))) / triangleDenominator;
- const b = (((triangleCy - triangleAy) * (sampleX - triangleCx)) + ((triangleAx - triangleCx) * (sampleY - triangleCy))) / triangleDenominator;
- const c = 1 - a - b;
- include = a >= 0 && b >= 0 && c >= 0;
- } else {
- include = x === Math.round(triangleAx) && y >= minY && y <= maxY;
- }
- if (include === true) {
- fillMask.add(`${x}:${y}`);
- }
- }
- }
- return fillMask;
-}
-
-function buildOutlineMask(fillMask) {
- const outlineMask = new Set();
- fillMask.forEach((key) => {
- const [xText, yText] = String(key || "").split(":");
- const x = Number(xText);
- const y = Number(yText);
- const neighbors = [
- `${x - 1}:${y}`,
- `${x + 1}:${y}`,
- `${x}:${y - 1}`,
- `${x}:${y + 1}`,
- ];
- if (neighbors.some((neighbor) => !fillMask.has(neighbor))) {
- outlineMask.add(key);
- }
- });
- return outlineMask;
-}
-
-function applyMaskToRows(baseRows, mask, symbol) {
- const matrix = getRowsMatrix(baseRows);
- mask.forEach((key) => {
- const [xText, yText] = String(key || "").split(":");
- const x = Number(xText);
- const y = Number(yText);
- if (x < 0 || x >= TILE_ART_SIZE || y < 0 || y >= TILE_ART_SIZE) {
- return;
- }
- matrix[y][x] = String(symbol || ".").charAt(0) || ".";
- });
- return buildRowsFromMatrix(matrix);
-}
-
-function getLineRows(baseRows, startX, startY, endX, endY, symbol) {
- const normalizedSymbol = String(symbol || ".").charAt(0) || ".";
- const matrix = getRowsMatrix(baseRows);
- let x0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startX) || 0));
- let y0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startY) || 0));
- const x1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endX) || 0));
- const y1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endY) || 0));
- const deltaX = Math.abs(x1 - x0);
- const deltaY = Math.abs(y1 - y0);
- const stepX = x0 < x1 ? 1 : -1;
- const stepY = y0 < y1 ? 1 : -1;
- let error = deltaX - deltaY;
- while (true) {
- matrix[y0][x0] = normalizedSymbol;
- if (x0 === x1 && y0 === y1) {
- break;
- }
- const nextError = error * 2;
- if (nextError > -deltaY) {
- error -= deltaY;
- x0 += stepX;
- }
- if (nextError < deltaX) {
- error += deltaX;
- y0 += stepY;
- }
- }
- return buildRowsFromMatrix(matrix);
-}
-
-function buildShapeOptionIconMarkup(shapeKind, variant, tone = "draw") {
- const normalizedShape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
- const normalizedVariant = variant === "outline" || variant === "two-tone" ? variant : "fill";
- const normalizedTone = tone === "erase" ? "erase" : "draw";
- return ""
- + `";
-}
-
-function buildLineOptionIconMarkup(tone = "draw") {
- const normalizedTone = tone === "erase" ? "erase" : "draw";
- return ""
- + `";
-}
-
-function buildCurrentShapeToolIconMarkup(state) {
- if (state?.activeTool === "line" || String(state?.activeShapeMenuId || "").trim() === "line") {
- return buildLineOptionIconMarkup("draw");
- }
- return buildShapeOptionIconMarkup(
- state?.activeShapeKind || "rectangle",
- state?.activeShapeVariant || "outline",
- "draw",
- );
-}
-
-function buildCurrentEraseToolIconMarkup(state) {
- return buildShapeOptionIconMarkup(
- state?.activeEraseKind || "rectangle",
- "fill",
- "erase",
- );
-}
-
-function buildTransformCategoryIconMarkup(kind) {
- const normalizedKind = kind === "flip" ? "flip" : "rotate";
- return ""
- + `";
-}
-
-function buildTransformOptionIconMarkup(kind) {
- const normalizedKind = [
- "rotate-cw",
- "rotate-ccw",
- "flip-h",
- "flip-v",
- ].includes(String(kind || "").trim()) ? String(kind || "").trim() : "rotate-cw";
- return ""
- + `";
-}
-
export function createTileArtEditorWindowController(scope) {
let initialized = false;
const uiScope = scope.uiScope || scope;
@@ -833,7 +460,7 @@ export function createTileArtEditorWindowController(scope) {
}
const currentFrame = playbackFrames.find((frame) => String(frame.id || "").trim() === String(state.animationPreviewFrameId || "").trim()) || playbackFrames[0];
state.animationPreviewFrameId = String(currentFrame?.id || "").trim();
- const previewUrl = buildSpritePreviewDataUrl(buildRowsPreviewRecord(currentFrame?.rows), 10);
+ const previewUrl = buildFramePreviewDataUrl(currentFrame?.rows, 10);
if (previewUrl) {
state.animationPreviewImageEl.src = previewUrl;
state.animationPreviewImageEl.classList.remove("hidden");
diff --git a/src/worldshaperStudio/windowing.ts b/src/worldshaperStudio/windowing.ts
index 4a0b176..6038ed8 100644
--- a/src/worldshaperStudio/windowing.ts
+++ b/src/worldshaperStudio/windowing.ts
@@ -1,210 +1 @@
-export type PopupBounds = {
- left: number;
- top: number;
- width: number;
- height: number;
-};
-
-export const WORLDSHAPER_STUDIO_WINDOW_NAME = "worldshaper-studio";
-export const WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY = "worldshaper:studio-window-bounds";
-export const WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME = "worldshaper-height-viewer";
-export const WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY = "worldshaper:height-viewer-window-bounds";
-
-export function buildWorldshaperStudioUrl(mapId: string, hostWindow: Window = window, options?: { worldId?: string }): string {
- const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-studio.html`, hostWindow.location.origin);
- const normalizedMapId = String(mapId || "").trim();
- const normalizedWorldId = String(options?.worldId || "").trim();
- if (normalizedMapId) {
- popupUrl.searchParams.set("mapId", normalizedMapId);
- }
- if (normalizedWorldId) {
- popupUrl.searchParams.set("worldId", normalizedWorldId);
- }
- return popupUrl.toString();
-}
-
-export function buildWorldshaperHeightViewerUrl(mapId: string, token = "", hostWindow: Window = window): string {
- const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-height-viewer.html`, hostWindow.location.origin);
- const normalizedMapId = String(mapId || "").trim();
- const normalizedToken = String(token || "").trim();
- if (normalizedMapId) {
- popupUrl.searchParams.set("mapId", normalizedMapId);
- }
- if (normalizedToken) {
- popupUrl.searchParams.set("token", normalizedToken);
- }
- return popupUrl.toString();
-}
-
-export function getCenteredWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds {
- const width = 1360;
- const height = 900;
- const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0;
- const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0;
- const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0
- ? hostWindow.outerWidth
- : hostWindow.innerWidth;
- const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0
- ? hostWindow.outerHeight
- : hostWindow.innerHeight;
- const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2));
- const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2));
- return { left, top, width, height };
-}
-
-export function getCenteredWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds {
- const width = 1280;
- const height = 820;
- const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0;
- const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0;
- const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0
- ? hostWindow.outerWidth
- : hostWindow.innerWidth;
- const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0
- ? hostWindow.outerHeight
- : hostWindow.innerHeight;
- const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2));
- const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2));
- return { left, top, width, height };
-}
-
-export function readWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds {
- try {
- const raw = hostWindow.localStorage.getItem(WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY);
- if (!raw) {
- return getCenteredWorldshaperStudioBounds(hostWindow);
- }
- const parsed = JSON.parse(raw) as Partial;
- const width = Math.max(640, Number(parsed.width) || 0);
- const height = Math.max(480, Number(parsed.height) || 0);
- const left = Math.max(0, Number(parsed.left) || 0);
- const top = Math.max(0, Number(parsed.top) || 0);
- if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) {
- return getCenteredWorldshaperStudioBounds(hostWindow);
- }
- return { left, top, width, height };
- } catch {
- return getCenteredWorldshaperStudioBounds(hostWindow);
- }
-}
-
-export function readWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds {
- try {
- const raw = hostWindow.localStorage.getItem(WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY);
- if (!raw) {
- return getCenteredWorldshaperHeightViewerBounds(hostWindow);
- }
- const parsed = JSON.parse(raw) as Partial;
- const width = Math.max(640, Number(parsed.width) || 0);
- const height = Math.max(480, Number(parsed.height) || 0);
- const left = Math.max(0, Number(parsed.left) || 0);
- const top = Math.max(0, Number(parsed.top) || 0);
- if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) {
- return getCenteredWorldshaperHeightViewerBounds(hostWindow);
- }
- return { left, top, width, height };
- } catch {
- return getCenteredWorldshaperHeightViewerBounds(hostWindow);
- }
-}
-
-export function persistWorldshaperStudioBounds(sourceWindow: Window = window): void {
- if (sourceWindow.closed) {
- return;
- }
- try {
- const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0));
- const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0));
- const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0));
- const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0));
- sourceWindow.localStorage.setItem(
- WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY,
- JSON.stringify({ left, top, width, height }),
- );
- } catch {
- // Ignore storage and same-origin failures.
- }
-}
-
-export function persistWorldshaperHeightViewerBounds(sourceWindow: Window = window): void {
- if (sourceWindow.closed) {
- return;
- }
- try {
- const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0));
- const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0));
- const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0));
- const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0));
- sourceWindow.localStorage.setItem(
- WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY,
- JSON.stringify({ left, top, width, height }),
- );
- } catch {
- // Ignore storage and same-origin failures.
- }
-}
-
-export function openWorldshaperStudioWindow(
- mapId: string,
- hostWindow: Window = window,
- options?: { worldId?: string },
-): Window | null {
- const popupUrl = buildWorldshaperStudioUrl(mapId, hostWindow, options);
- const initialBounds = readWorldshaperStudioBounds(hostWindow);
- const popupFeatures = [
- "popup=yes",
- "resizable=yes",
- "scrollbars=no",
- "width=" + initialBounds.width,
- "height=" + initialBounds.height,
- "left=" + initialBounds.left,
- "top=" + initialBounds.top,
- ].join(",");
-
- const popup = hostWindow.open(popupUrl, WORLDSHAPER_STUDIO_WINDOW_NAME, popupFeatures);
- if (!popup) {
- return null;
- }
-
- try {
- popup.moveTo(initialBounds.left, initialBounds.top);
- popup.resizeTo(initialBounds.width, initialBounds.height);
- } catch {
- // Ignore browser restrictions.
- }
-
- popup.location.href = popupUrl;
- popup.focus();
- return popup;
-}
-
-export function openWorldshaperHeightViewerWindow(mapId: string, token = "", hostWindow: Window = window): Window | null {
- const popupUrl = buildWorldshaperHeightViewerUrl(mapId, token, hostWindow);
- const initialBounds = readWorldshaperHeightViewerBounds(hostWindow);
- const popupFeatures = [
- "popup=yes",
- "resizable=yes",
- "scrollbars=no",
- "width=" + initialBounds.width,
- "height=" + initialBounds.height,
- "left=" + initialBounds.left,
- "top=" + initialBounds.top,
- ].join(",");
-
- const popup = hostWindow.open(popupUrl, WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME, popupFeatures);
- if (!popup) {
- return null;
- }
-
- try {
- popup.moveTo(initialBounds.left, initialBounds.top);
- popup.resizeTo(initialBounds.width, initialBounds.height);
- } catch {
- // Ignore browser restrictions.
- }
-
- popup.location.href = popupUrl;
- popup.focus();
- return popup;
-}
-
+export * from "../shared/windowing";
diff --git a/src/worldshaperStudio/worldChunkRuntimeHelpers.ts b/src/worldshaperStudio/worldChunkRuntimeHelpers.ts
new file mode 100644
index 0000000..857d1b4
--- /dev/null
+++ b/src/worldshaperStudio/worldChunkRuntimeHelpers.ts
@@ -0,0 +1,602 @@
+// @ts-nocheck
+
+export function createFilledRows(width, height, fillChar) {
+ return Array.from({ length: Math.max(1, Number(height) || 1) }, () => String(fillChar || " ").repeat(Math.max(1, Number(width) || 1)));
+}
+
+function writeRowSegment(rows, y, x, segment) {
+ if (!Array.isArray(rows) || !segment) {
+ return;
+ }
+ const targetY = Math.floor(Number(y) || 0);
+ if (targetY < 0 || targetY >= rows.length) {
+ return;
+ }
+ const safeX = Math.max(0, Math.floor(Number(x) || 0));
+ const sourceRow = String(rows[targetY] || "");
+ const paddedRow = sourceRow.length >= safeX
+ ? sourceRow
+ : (sourceRow + " ".repeat(Math.max(0, safeX - sourceRow.length)));
+ const before = paddedRow.slice(0, safeX);
+ const afterStart = safeX + segment.length;
+ const after = afterStart < paddedRow.length ? paddedRow.slice(afterStart) : "";
+ rows[targetY] = before + segment + after;
+}
+
+export function composeWorldRoomLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY, worldWidth, worldHeight) {
+ const layerMap = new Map();
+ (Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
+ const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
+ const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
+ const offsetX = (baseChunkX - originChunkX) * chunkWidth;
+ const offsetY = (baseChunkY - originChunkY) * chunkHeight;
+ const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
+ rawLayers.forEach((rawLayer) => {
+ const layerNumber = Number(rawLayer?.layer) || 0;
+ const fillChar = layerNumber === 0 ? "." : " ";
+ if (!layerMap.has(layerNumber)) {
+ layerMap.set(layerNumber, {
+ layer: layerNumber,
+ name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined,
+ rows: createFilledRows(worldWidth, worldHeight, fillChar),
+ instanceIds: [],
+ });
+ }
+ const targetLayer = layerMap.get(layerNumber);
+ const sourceRows = Array.isArray(rawLayer?.rows) ? rawLayer.rows.map((row) => String(row || "")) : [];
+ sourceRows.forEach((row, localY) => {
+ const targetY = offsetY + localY;
+ if (targetY < 0 || targetY >= targetLayer.rows.length) {
+ return;
+ }
+ const maxWidth = Math.max(0, worldWidth - offsetX);
+ writeRowSegment(targetLayer.rows, targetY, offsetX, row.slice(0, maxWidth));
+ });
+ const sourceInstanceIds = Array.isArray(rawLayer?.instanceIds)
+ ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean)
+ : [];
+ targetLayer.instanceIds = Array.from(new Set([...(targetLayer.instanceIds || []), ...sourceInstanceIds]));
+ });
+ });
+ if (!layerMap.has(0)) {
+ layerMap.set(0, {
+ layer: 0,
+ rows: createFilledRows(worldWidth, worldHeight, "."),
+ instanceIds: [],
+ });
+ }
+ return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
+}
+
+export function composeWorldHeightLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY) {
+ const patches = [];
+ (Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
+ const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
+ const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
+ const offsetX = (baseChunkX - originChunkX) * chunkWidth;
+ const offsetY = (baseChunkY - originChunkY) * chunkHeight;
+ const rawHeightLayers = Array.isArray(chunk?.heightLayers) ? chunk.heightLayers : [];
+ rawHeightLayers.forEach((entry, index) => {
+ const fallbackId = `height_${baseChunkX}_${baseChunkY}_${index + 1}`;
+ patches.push({
+ id: String(entry?.id || fallbackId).trim() || fallbackId,
+ name: typeof entry?.name === "string" && String(entry.name).trim() ? String(entry.name).trim() : undefined,
+ z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
+ x: offsetX + Math.max(0, Number(entry?.x) || 0),
+ y: offsetY + Math.max(0, Number(entry?.y) || 0),
+ rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
+ });
+ });
+ });
+ return patches.sort((a, b) => {
+ if (a.z !== b.z) {
+ return a.z - b.z;
+ }
+ return String(a.name || a.id).localeCompare(String(b.name || b.id));
+ });
+}
+
+export function buildWorldLayerMetadata(chunks) {
+ const layerMap = new Map();
+ (Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
+ const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
+ rawLayers.forEach((rawLayer) => {
+ const layerNumber = Number(rawLayer?.layer) || 0;
+ if (layerMap.has(layerNumber)) {
+ return;
+ }
+ layerMap.set(layerNumber, {
+ layer: layerNumber,
+ name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined,
+ rows: [],
+ instanceIds: Array.isArray(rawLayer?.instanceIds) ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean) : [],
+ });
+ });
+ });
+ if (!layerMap.has(0)) {
+ layerMap.set(0, {
+ layer: 0,
+ rows: [],
+ instanceIds: [],
+ });
+ }
+ if (!Array.from(layerMap.keys()).some((layerNumber) => layerNumber > 0)) {
+ layerMap.set(1, {
+ layer: 1,
+ rows: [],
+ instanceIds: [],
+ });
+ }
+ return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
+}
+
+export function sliceNormalizedRows(rows, startX, startY, width, height, fillChar) {
+ return Array.from({ length: Math.max(1, Number(height) || 1) }, (_, rowOffset) => {
+ const sourceRow = String((Array.isArray(rows) ? rows[startY + rowOffset] : "") || "");
+ const paddedRow = sourceRow.length >= startX + width
+ ? sourceRow
+ : sourceRow + String(fillChar || " ").repeat(Math.max(0, (startX + width) - sourceRow.length));
+ return paddedRow.slice(startX, startX + width);
+ });
+}
+
+export function buildChunkHeightLayersFromDocument({ mapDocument, cloneHeightLayers, baseTileX, baseTileY, chunkWidth, chunkHeight }) {
+ return (Array.isArray(mapDocument.heightLayers) ? cloneHeightLayers(mapDocument.heightLayers) : [])
+ .map((entry) => {
+ const patchX = Math.max(0, Number(entry?.x) || 0);
+ const patchY = Math.max(0, Number(entry?.y) || 0);
+ const rows = Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [];
+ const patchWidth = rows.reduce((max, row) => Math.max(max, row.length), 0);
+ const patchHeight = rows.length;
+ const patchRight = patchX + patchWidth;
+ const patchBottom = patchY + patchHeight;
+ const chunkRight = baseTileX + chunkWidth;
+ const chunkBottom = baseTileY + chunkHeight;
+ const overlapLeft = Math.max(baseTileX, patchX);
+ const overlapTop = Math.max(baseTileY, patchY);
+ const overlapRight = Math.min(chunkRight, patchRight);
+ const overlapBottom = Math.min(chunkBottom, patchBottom);
+ if (overlapRight <= overlapLeft || overlapBottom <= overlapTop) {
+ return null;
+ }
+ const localRows = [];
+ for (let y = overlapTop; y < overlapBottom; y += 1) {
+ const sourceRow = String(rows[y - patchY] || "");
+ localRows.push(sourceRow.slice(overlapLeft - patchX, overlapRight - patchX).replace(/\s+$/g, ""));
+ }
+ return {
+ id: String(entry?.id || "").trim(),
+ name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
+ z: Math.max(1, Number(entry?.z) || 1),
+ x: overlapLeft - baseTileX,
+ y: overlapTop - baseTileY,
+ rows: localRows,
+ };
+ })
+ .filter((entry) => entry && entry.id);
+}
+
+export function buildChunkInstancesFromDocument({ mapDocument, cloneValue, baseTileX, baseTileY, chunkWidth, chunkHeight, tileOffsetX, tileOffsetY }) {
+ const chunkInstances = cloneValue(mapDocument.npcOverlays)
+ .filter((npc) => {
+ const localX = Math.floor(Number(npc?.x));
+ const localY = Math.floor(Number(npc?.y));
+ return Number.isFinite(localX)
+ && Number.isFinite(localY)
+ && localX >= baseTileX
+ && localX < baseTileX + chunkWidth
+ && localY >= baseTileY
+ && localY < baseTileY + chunkHeight;
+ })
+ .map((npc) => ({
+ id: String(npc.id || "").trim(),
+ templateId: String(npc?.record?.templateId || "").trim(),
+ layer: Number(npc.layer) || 0,
+ x: Math.floor(Number(npc.x) || 0) - baseTileX,
+ y: Math.floor(Number(npc.y) || 0) - baseTileY,
+ record: {
+ ...cloneValue(npc.record || {}),
+ id: String(npc.id || "").trim(),
+ layer: Number(npc.layer) || 0,
+ templateId: String(npc?.record?.templateId || "").trim(),
+ name: String(npc.name || npc?.record?.name || ""),
+ entityType: String(npc?.record?.entityType || npc?.entityType || "friendly"),
+ faction: String(npc.faction || npc?.record?.faction || ""),
+ spriteId: String(npc.spriteId || npc?.record?.spriteId || ""),
+ dialogueId: String(npc.dialogueId || npc?.record?.dialogueId || ""),
+ description: String(npc.description || npc?.record?.description || ""),
+ tags: cloneValue(npc?.record?.tags) || [],
+ enabled: typeof npc?.record?.enabled === "boolean" ? npc.record.enabled : true,
+ position: {
+ x: Math.floor(Number(npc.x) || 0) + tileOffsetX,
+ y: Math.floor(Number(npc.y) || 0) + tileOffsetY,
+ },
+ },
+ }))
+ .filter((entry) => entry.id);
+ const npcIdsByLayer = new Map();
+ chunkInstances.forEach((entry) => {
+ const layerNumber = Number(entry.layer) || 0;
+ if (!npcIdsByLayer.has(layerNumber)) {
+ npcIdsByLayer.set(layerNumber, []);
+ }
+ npcIdsByLayer.get(layerNumber).push(entry.id);
+ });
+ return {
+ chunkInstances,
+ npcIdsByLayer,
+ };
+}
+
+export function normalizeWorldChunkRows(rows, width, height, fillChar) {
+ const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
+ const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
+ return Array.from({ length: safeHeight }, (_entry, rowIndex) => {
+ const sourceRow = String((Array.isArray(rows) ? rows[rowIndex] : "") || "");
+ return sourceRow.length >= safeWidth
+ ? sourceRow.slice(0, safeWidth)
+ : (sourceRow + String(fillChar || " ").repeat(Math.max(0, safeWidth - sourceRow.length)));
+ });
+}
+
+export function cloneWorldChunkHeightLayers(source) {
+ return (Array.isArray(source) ? source : [])
+ .map((entry, index) => ({
+ id: String(entry?.id || `height_patch_${index + 1}`).trim() || `height_patch_${index + 1}`,
+ name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
+ z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
+ x: Math.max(0, Math.floor(Number(entry?.x) || 0)),
+ y: Math.max(0, Math.floor(Number(entry?.y) || 0)),
+ rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
+ }))
+ .filter((entry) => entry.id);
+}
+
+export function buildWorldChunkLayerInstanceIds(roomLayers, instances, width, height) {
+ const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
+ const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
+ const nextLayers = new Map();
+ (Array.isArray(roomLayers) ? roomLayers : []).forEach((layer) => {
+ const layerNumber = Math.max(0, Math.floor(Number(layer?.layer) || 0));
+ nextLayers.set(layerNumber, {
+ layer: layerNumber,
+ name: typeof layer?.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
+ rows: normalizeWorldChunkRows(layer?.rows, safeWidth, safeHeight, layerNumber === 0 ? "." : " "),
+ instanceIds: [],
+ });
+ });
+ if (!nextLayers.has(0)) {
+ nextLayers.set(0, {
+ layer: 0,
+ rows: normalizeWorldChunkRows([], safeWidth, safeHeight, "."),
+ instanceIds: [],
+ });
+ }
+ if (!Array.from(nextLayers.keys()).some((layerNumber) => layerNumber > 0)) {
+ nextLayers.set(1, {
+ layer: 1,
+ rows: normalizeWorldChunkRows([], safeWidth, safeHeight, " "),
+ instanceIds: [],
+ });
+ }
+ (Array.isArray(instances) ? instances : []).forEach((entry) => {
+ const layerNumber = Math.max(0, Math.floor(Number(entry?.layer) || 0));
+ const instanceId = String(entry?.id || "").trim();
+ if (!instanceId) {
+ return;
+ }
+ if (!nextLayers.has(layerNumber)) {
+ nextLayers.set(layerNumber, {
+ layer: layerNumber,
+ rows: normalizeWorldChunkRows([], safeWidth, safeHeight, layerNumber === 0 ? "." : " "),
+ instanceIds: [],
+ });
+ }
+ nextLayers.get(layerNumber).instanceIds.push(instanceId);
+ });
+ return Array.from(nextLayers.values())
+ .map((entry) => ({
+ ...entry,
+ instanceIds: Array.from(new Set((Array.isArray(entry.instanceIds) ? entry.instanceIds : []).map((id) => String(id || "").trim()).filter(Boolean))),
+ }))
+ .sort((left, right) => (Number(left.layer) || 0) - (Number(right.layer) || 0));
+}
+
+export function normalizeWorldChunkInstances({ sourceInstances, chunkX, chunkY, width, height, options, cloneValue, runtimeUniqueId }) {
+ const config = options && typeof options === "object" ? options : {};
+ const duplicateIds = config.duplicateIds === true;
+ const safeChunkX = Math.floor(Number(chunkX) || 0);
+ const safeChunkY = Math.floor(Number(chunkY) || 0);
+ const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
+ const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
+ return (Array.isArray(sourceInstances) ? sourceInstances : [])
+ .map((entry) => {
+ const record = entry?.record && typeof entry.record === "object" && !Array.isArray(entry.record)
+ ? cloneValue(entry.record)
+ : {};
+ const nextId = duplicateIds
+ ? runtimeUniqueId()
+ : (String(entry?.id || record?.id || runtimeUniqueId()).trim() || runtimeUniqueId());
+ const nextLayer = Math.max(0, Math.floor(Number(entry?.layer ?? record?.layer) || 0));
+ const nextX = Math.max(0, Math.min(safeWidth - 1, Math.floor(Number(entry?.x) || 0)));
+ const nextY = Math.max(0, Math.min(safeHeight - 1, Math.floor(Number(entry?.y) || 0)));
+ const nextTemplateId = String(entry?.templateId || record?.templateId || "").trim();
+ record.id = nextId;
+ record.layer = nextLayer;
+ record.templateId = nextTemplateId;
+ record.position = {
+ x: (safeChunkX * safeWidth) + nextX,
+ y: (safeChunkY * safeHeight) + nextY,
+ };
+ return {
+ id: nextId,
+ templateId: nextTemplateId,
+ layer: nextLayer,
+ x: nextX,
+ y: nextY,
+ record,
+ };
+ })
+ .filter((entry) => entry.id);
+}
+
+export function createEmptyWorldChunkPayload({ chunkX, chunkY, chunkWidth, chunkHeight, worldId }) {
+ const safeChunkX = Math.floor(Number(chunkX) || 0);
+ const safeChunkY = Math.floor(Number(chunkY) || 0);
+ return {
+ schemaVersion: 1,
+ worldId: String(worldId || "").trim(),
+ chunkX: safeChunkX,
+ chunkY: safeChunkY,
+ width: chunkWidth,
+ height: chunkHeight,
+ backgroundTileId: "",
+ roomLayers: [
+ {
+ layer: 0,
+ rows: Array.from({ length: chunkHeight }, () => ".".repeat(chunkWidth)),
+ instanceIds: [],
+ },
+ {
+ layer: 1,
+ rows: Array.from({ length: chunkHeight }, () => " ".repeat(chunkWidth)),
+ instanceIds: [],
+ },
+ ],
+ heightLayers: [],
+ instances: [],
+ };
+}
+
+export function normalizeCachedWorldChunkPayload({ chunkPayload, chunkX, chunkY, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId, options }) {
+ const safeChunkX = Math.floor(Number(chunkX ?? chunkPayload?.chunkX) || 0);
+ const safeChunkY = Math.floor(Number(chunkY ?? chunkPayload?.chunkY) || 0);
+ const safeWidth = Math.max(1, Math.floor(Number(chunkPayload?.width) || Number(chunkWidth) || 32));
+ const safeHeight = Math.max(1, Math.floor(Number(chunkPayload?.height) || Number(chunkHeight) || 32));
+ const instances = normalizeWorldChunkInstances({
+ sourceInstances: chunkPayload?.instances,
+ chunkX: safeChunkX,
+ chunkY: safeChunkY,
+ width: safeWidth,
+ height: safeHeight,
+ options,
+ cloneValue,
+ runtimeUniqueId,
+ });
+ const roomLayers = buildWorldChunkLayerInstanceIds(chunkPayload?.roomLayers, instances, safeWidth, safeHeight);
+ return {
+ schemaVersion: Math.max(1, Math.floor(Number(chunkPayload?.schemaVersion) || 1)),
+ worldId: String(chunkPayload?.worldId || worldId || "").trim(),
+ chunkX: safeChunkX,
+ chunkY: safeChunkY,
+ width: safeWidth,
+ height: safeHeight,
+ backgroundTileId: String(chunkPayload?.backgroundTileId || "").trim(),
+ roomLayers,
+ heightLayers: cloneWorldChunkHeightLayers(chunkPayload?.heightLayers),
+ instances,
+ };
+}
+
+export function isChunkFillSymbol(ch, fillChar) {
+ const symbol = String(ch || "").charAt(0);
+ return !symbol || symbol === fillChar || symbol === "." || symbol === " ";
+}
+
+export function isWorldChunkPayloadEmpty({ chunkPayload, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId }) {
+ const normalized = normalizeCachedWorldChunkPayload({
+ chunkPayload,
+ chunkX: chunkPayload?.chunkX,
+ chunkY: chunkPayload?.chunkY,
+ chunkWidth,
+ chunkHeight,
+ worldId,
+ cloneValue,
+ runtimeUniqueId,
+ });
+ if (String(normalized?.backgroundTileId || "").trim()) {
+ return false;
+ }
+ if (Array.isArray(normalized?.instances) && normalized.instances.length > 0) {
+ return false;
+ }
+ if ((Array.isArray(normalized?.heightLayers) ? normalized.heightLayers : []).some((entry) => (
+ Array.isArray(entry?.rows) && entry.rows.some((row) => /[^ .]/.test(String(row || "")))
+ ))) {
+ return false;
+ }
+ return !(Array.isArray(normalized?.roomLayers) ? normalized.roomLayers : []).some((layer) => {
+ const fillChar = (Number(layer?.layer) || 0) === 0 ? "." : " ";
+ return (Array.isArray(layer?.rows) ? layer.rows : []).some((row) => {
+ const sourceRow = String(row || "");
+ for (let index = 0; index < sourceRow.length; index += 1) {
+ if (!isChunkFillSymbol(sourceRow.charAt(index), fillChar)) {
+ return true;
+ }
+ }
+ return false;
+ });
+ });
+}
+
+export function transformChunkLocalCoord(localX, localY, width, height, operation) {
+ const safeX = Math.floor(Number(localX) || 0);
+ const safeY = Math.floor(Number(localY) || 0);
+ const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
+ const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
+ switch (String(operation || "").trim()) {
+ case "flipHorizontal":
+ return { x: (safeWidth - 1) - safeX, y: safeY };
+ case "flipVertical":
+ return { x: safeX, y: (safeHeight - 1) - safeY };
+ case "rotate180":
+ return { x: (safeWidth - 1) - safeX, y: (safeHeight - 1) - safeY };
+ case "rotate90cw":
+ if (safeWidth !== safeHeight) {
+ return null;
+ }
+ return { x: (safeWidth - 1) - safeY, y: safeX };
+ case "rotate90ccw":
+ if (safeWidth !== safeHeight) {
+ return null;
+ }
+ return { x: safeY, y: (safeHeight - 1) - safeX };
+ default:
+ return { x: safeX, y: safeY };
+ }
+}
+
+export function transformChunkRows(rows, width, height, fillChar, operation) {
+ const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
+ const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
+ const sourceRows = normalizeWorldChunkRows(rows, safeWidth, safeHeight, fillChar);
+ const nextRows = Array.from({ length: safeHeight }, () => Array.from({ length: safeWidth }, () => String(fillChar || " ").charAt(0) || " "));
+ for (let rowIndex = 0; rowIndex < safeHeight; rowIndex += 1) {
+ const sourceRow = sourceRows[rowIndex];
+ for (let columnIndex = 0; columnIndex < safeWidth; columnIndex += 1) {
+ const char = String(sourceRow.charAt(columnIndex) || fillChar).charAt(0) || String(fillChar || " ").charAt(0) || " ";
+ if (isChunkFillSymbol(char, fillChar)) {
+ continue;
+ }
+ const nextCoord = transformChunkLocalCoord(columnIndex, rowIndex, safeWidth, safeHeight, operation);
+ if (!nextCoord) {
+ continue;
+ }
+ nextRows[nextCoord.y][nextCoord.x] = char;
+ }
+ }
+ return nextRows.map((row) => row.join(""));
+}
+
+export function transformChunkHeightPatch(patch, width, height, operation) {
+ const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
+ const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
+ const sourceRows = Array.isArray(patch?.rows) ? patch.rows.map((row) => String(row || "")) : [];
+ const patchWidth = sourceRows.reduce((max, row) => Math.max(max, row.length), 0);
+ const patchHeight = sourceRows.length;
+ const transformedCells = [];
+ for (let localY = 0; localY < patchHeight; localY += 1) {
+ const row = sourceRows[localY] || "";
+ for (let localX = 0; localX < patchWidth; localX += 1) {
+ const char = String(row.charAt(localX) || " ").charAt(0) || " ";
+ if (char === " " || char === ".") {
+ continue;
+ }
+ const worldX = Math.max(0, Math.floor(Number(patch?.x) || 0)) + localX;
+ const worldY = Math.max(0, Math.floor(Number(patch?.y) || 0)) + localY;
+ if (worldX < 0 || worldY < 0 || worldX >= safeWidth || worldY >= safeHeight) {
+ continue;
+ }
+ const nextCoord = transformChunkLocalCoord(worldX, worldY, safeWidth, safeHeight, operation);
+ if (!nextCoord) {
+ continue;
+ }
+ transformedCells.push({
+ x: nextCoord.x,
+ y: nextCoord.y,
+ char,
+ });
+ }
+ }
+ if (transformedCells.length <= 0) {
+ return null;
+ }
+ const minX = transformedCells.reduce((min, entry) => Math.min(min, entry.x), transformedCells[0].x);
+ const maxX = transformedCells.reduce((max, entry) => Math.max(max, entry.x), transformedCells[0].x);
+ const minY = transformedCells.reduce((min, entry) => Math.min(min, entry.y), transformedCells[0].y);
+ const maxY = transformedCells.reduce((max, entry) => Math.max(max, entry.y), transformedCells[0].y);
+ const nextRows = Array.from({ length: (maxY - minY) + 1 }, () => Array.from({ length: (maxX - minX) + 1 }, () => " "));
+ transformedCells.forEach((entry) => {
+ nextRows[entry.y - minY][entry.x - minX] = entry.char;
+ });
+ return {
+ id: String(patch?.id || "").trim(),
+ name: typeof patch?.name === "string" && patch.name.trim() ? patch.name.trim() : undefined,
+ z: Math.max(1, Math.floor(Number(patch?.z) || 1)),
+ x: minX,
+ y: minY,
+ rows: nextRows.map((row) => row.join("").replace(/\s+$/g, "")),
+ };
+}
+
+export function transformWorldChunkPayload({ chunkPayload, operation, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId, options }) {
+ const config = options && typeof options === "object" ? options : {};
+ const normalized = normalizeCachedWorldChunkPayload({
+ chunkPayload,
+ chunkX: chunkPayload?.chunkX,
+ chunkY: chunkPayload?.chunkY,
+ chunkWidth,
+ chunkHeight,
+ worldId,
+ cloneValue,
+ runtimeUniqueId,
+ options: config,
+ });
+ const safeWidth = Math.max(1, Math.floor(Number(normalized?.width) || 1));
+ const safeHeight = Math.max(1, Math.floor(Number(normalized?.height) || 1));
+ const normalizedOperation = String(operation || "").trim();
+ if ((normalizedOperation === "rotate90cw" || normalizedOperation === "rotate90ccw") && safeWidth !== safeHeight) {
+ throw new Error("Chunk rotation requires square chunks.");
+ }
+ const instances = normalizeWorldChunkInstances({
+ sourceInstances: (Array.isArray(normalized.instances) ? normalized.instances : []).map((entry) => {
+ const nextCoord = transformChunkLocalCoord(entry.x, entry.y, safeWidth, safeHeight, normalizedOperation);
+ return {
+ ...cloneValue(entry),
+ x: nextCoord?.x ?? entry.x,
+ y: nextCoord?.y ?? entry.y,
+ };
+ }),
+ chunkX: normalized.chunkX,
+ chunkY: normalized.chunkY,
+ width: safeWidth,
+ height: safeHeight,
+ options: config,
+ cloneValue,
+ runtimeUniqueId,
+ });
+ const roomLayers = buildWorldChunkLayerInstanceIds(
+ (Array.isArray(normalized.roomLayers) ? normalized.roomLayers : []).map((layer) => ({
+ ...cloneValue(layer),
+ rows: transformChunkRows(layer?.rows, safeWidth, safeHeight, (Number(layer?.layer) || 0) === 0 ? "." : " ", normalizedOperation),
+ })),
+ instances,
+ safeWidth,
+ safeHeight,
+ );
+ const heightLayers = cloneWorldChunkHeightLayers(normalized.heightLayers)
+ .map((entry) => transformChunkHeightPatch(entry, safeWidth, safeHeight, normalizedOperation))
+ .filter(Boolean)
+ .sort((left, right) => {
+ if ((Number(left?.z) || 0) !== (Number(right?.z) || 0)) {
+ return (Number(left?.z) || 0) - (Number(right?.z) || 0);
+ }
+ return String(left?.name || left?.id || "").localeCompare(String(right?.name || right?.id || ""));
+ });
+ return {
+ ...normalized,
+ roomLayers,
+ heightLayers,
+ instances,
+ };
+}