Initial import

This commit is contained in:
Andraxion 2026-06-26 18:18:14 -04:00
commit ab891a315c
773 changed files with 257255 additions and 0 deletions

184
src/App.css Normal file
View file

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

1786
src/App.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,295 @@
import type { CatalogEntry } from "../editorCore";
type ConfigSectionProps = {
activeConfigTab: string;
activeConfigKey: string;
activeConfigEntries: CatalogEntry[];
selectedConfigIndex: number;
selectedConfigEntry: CatalogEntry | null;
rootKeys: string[];
typeLabels: Record<string, string>;
getCatalogEntryIdValue: (entry: CatalogEntry | null | undefined, fallback?: string) => string;
normalizeStringList: (value: unknown) => string[];
parseCsv: (value: string) => string[];
getContentFieldKeysByType: (type: string) => string[];
onSetSelectedConfigIndex: (index: number) => void;
onUpdateActiveConfigEntry: (entryIndex: number, patch: Partial<CatalogEntry>) => void;
};
export default function ConfigSection(props: ConfigSectionProps) {
const {
activeConfigTab,
activeConfigKey,
activeConfigEntries,
selectedConfigIndex,
selectedConfigEntry,
rootKeys,
typeLabels,
getCatalogEntryIdValue,
normalizeStringList,
parseCsv,
getContentFieldKeysByType,
onSetSelectedConfigIndex,
onUpdateActiveConfigEntry,
} = props;
const isColorsTab = activeConfigTab === "Colors";
if (isColorsTab) {
const selectedColor = selectedConfigEntry;
return (
<div className="structured-layout">
<aside className="record-list-panel">
<h2>{activeConfigTab}</h2>
<div className="record-list">
<button
type="button"
className="record-row is-active"
onClick={() => onSetSelectedConfigIndex(0)}
>
Palette 1
</button>
</div>
</aside>
<section className="record-editor-panel">
<h2>Palette 1</h2>
{activeConfigEntries.length === 0 ? <p className="muted">No color entries yet.</p> : null}
<div className="color-palette-grid5">
{activeConfigEntries.map((entry, index) => {
const color = /^#[0-9a-fA-F]{6}$/.test(String(entry.color || "")) ? String(entry.color) : "#000000";
const symbol = getCatalogEntryIdValue(entry, String(index)).slice(0, 1) || "?";
return (
<button
key={`cfg-color-cell-${index}-${symbol}`}
type="button"
className={`color-pixel-btn ${index === selectedConfigIndex ? "is-active" : ""}`}
style={{ backgroundColor: color }}
title={`${symbol} = ${color}`}
onClick={() => onSetSelectedConfigIndex(index)}
>
<span className="color-pixel-label">{symbol}</span>
</button>
);
})}
</div>
{selectedColor ? (
<div className="fields-grid color-palette-editor">
<div className="field-row">
<label htmlFor="cfg-id">Symbol</label>
<input
id="cfg-id"
value={getCatalogEntryIdValue(selectedColor)}
maxLength={1}
disabled
readOnly
/>
</div>
<div className="field-row">
<label htmlFor="cfg-color">Color</label>
<input
id="cfg-color"
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(String(selectedColor.color || "")) ? String(selectedColor.color) : "#ffffff"}
disabled
readOnly
/>
</div>
<div className="field-row">
<label htmlFor="cfg-description">Name</label>
<input
id="cfg-description"
value={String(selectedColor.description || "")}
disabled
readOnly
/>
</div>
</div>
) : null}
<p className="muted">Color definitions are locked here because other editors depend on this palette structure.</p>
</section>
</div>
);
}
return (
<div className="structured-layout">
<aside className="record-list-panel">
<h2>{activeConfigTab}</h2>
<div className="record-list">
{activeConfigEntries.length === 0 ? <p className="muted">No entries yet.</p> : null}
{activeConfigEntries.map((entry, index) => {
const key = getCatalogEntryIdValue(entry, String(entry.entryId || `entry-${index + 1}`));
const label = key || `Entry ${index + 1}`;
return (
<button
key={`cfg-${activeConfigKey}-${key}-${index}`}
type="button"
className={`record-row ${index === selectedConfigIndex ? "is-active" : ""}`}
onClick={() => onSetSelectedConfigIndex(index)}
>
{label}
</button>
);
})}
</div>
</aside>
<section className="record-editor-panel">
<h2>{activeConfigTab} Entry</h2>
{!selectedConfigEntry ? <p className="muted">Select or add an entry to edit.</p> : null}
{selectedConfigEntry ? (
<div className="fields-grid">
<div className="field-row">
<label htmlFor="cfg-id">Id</label>
<input
id="cfg-id"
value={getCatalogEntryIdValue(selectedConfigEntry)}
onChange={(event) => onUpdateActiveConfigEntry(selectedConfigIndex, {
key: activeConfigTab === "Colors" ? String(event.target.value || "").slice(0, 1) : event.target.value,
sourceKey: activeConfigTab === "Colors" ? String(event.target.value || "").slice(0, 1) : event.target.value,
originalName: activeConfigTab === "Colors" ? String(event.target.value || "").slice(0, 1) : event.target.value,
})}
/>
</div>
{activeConfigTab === "Colors" ? (
<div className="field-row">
<label htmlFor="cfg-color">Color</label>
<input
id="cfg-color"
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(String(selectedConfigEntry.color || "")) ? String(selectedConfigEntry.color) : "#ffffff"}
onChange={(event) => onUpdateActiveConfigEntry(selectedConfigIndex, { color: event.target.value.toUpperCase() })}
/>
</div>
) : null}
<div className="field-row">
<label htmlFor="cfg-description">{activeConfigTab === "Colors" ? "Name" : "Description"}</label>
<textarea
id="cfg-description"
value={String(selectedConfigEntry.description || "")}
onChange={(event) => onUpdateActiveConfigEntry(selectedConfigIndex, { description: event.target.value })}
/>
</div>
{activeConfigTab === "Colors" ? (
<p className="muted">Colors tab maps a single renderable symbol (Id) to a hex color for sprite/tile palette authoring.</p>
) : null}
<div className="field-row">
<label htmlFor="cfg-sublist">Sublist Type</label>
{activeConfigTab === "Colors" ? (
<input
id="cfg-sublist"
value=""
disabled
placeholder="Not used for Colors"
/>
) : activeConfigTab === "Conditions" || activeConfigTab === "Item Actions" || activeConfigTab === "System Actions" ? (
<select
id="cfg-sublist"
value={String(selectedConfigEntry.sublistType || "")}
onChange={(event) => {
const nextSublistType = String(event.target.value || "").trim();
if (!nextSublistType) {
onUpdateActiveConfigEntry(selectedConfigIndex, {
sublistType: "",
displayKeys: [],
passKeys: [],
});
return;
}
const nextKeys = getContentFieldKeysByType(nextSublistType);
onUpdateActiveConfigEntry(selectedConfigIndex, {
sublistType: nextSublistType,
displayKeys: nextKeys.includes("name") ? ["name"] : nextKeys.slice(0, 2),
passKeys: nextKeys.includes("id") ? ["id"] : nextKeys.slice(0, 1),
});
}}
>
<option value="">None</option>
{rootKeys.map((type) => (
<option key={`cfg-sublist-${type}`} value={type}>{typeLabels[type] || type}</option>
))}
</select>
) : (
<input
id="cfg-sublist"
value={String(selectedConfigEntry.sublistType || "")}
placeholder="items, quests, npcs..."
onChange={(event) => onUpdateActiveConfigEntry(selectedConfigIndex, { sublistType: event.target.value })}
/>
)}
</div>
{activeConfigTab === "Colors" ? null : activeConfigTab === "Conditions" || activeConfigTab === "Item Actions" || activeConfigTab === "System Actions" ? (() => {
const sublistType = String(selectedConfigEntry.sublistType || "").trim();
const availableKeys = sublistType ? getContentFieldKeysByType(sublistType) : [];
const selectedDisplayKeys = normalizeStringList(selectedConfigEntry.displayKeys);
const selectedPassKeys = normalizeStringList(selectedConfigEntry.passKeys);
return sublistType ? (
<div className="field-row">
<p className="muted">Keys available in {typeLabels[sublistType] || sublistType}: {availableKeys.length > 0 ? availableKeys.join(", ") : "(no records loaded)"}</p>
<div className="condition-key-mapping">
<div className="condition-key-column">
<p className="muted">Display Keys</p>
{availableKeys.length > 0 ? availableKeys.map((key) => (
<label key={`cfg-display-${sublistType}-${key}`} className="inline-check">
<input
type="checkbox"
checked={selectedDisplayKeys.includes(key)}
onChange={(event) => onUpdateActiveConfigEntry(selectedConfigIndex, {
displayKeys: event.target.checked
? normalizeStringList([...(selectedConfigEntry.displayKeys || []), key])
: normalizeStringList((selectedConfigEntry.displayKeys || []).filter((value) => value !== key)),
})}
/>
{key}
</label>
)) : <p className="muted">No keys available.</p>}
</div>
<div className="condition-key-column">
<p className="muted">Pass Keys</p>
{availableKeys.length > 0 ? availableKeys.map((key) => (
<label key={`cfg-pass-${sublistType}-${key}`} className="inline-check">
<input
type="checkbox"
checked={selectedPassKeys.includes(key)}
onChange={(event) => onUpdateActiveConfigEntry(selectedConfigIndex, {
passKeys: event.target.checked
? normalizeStringList([...(selectedConfigEntry.passKeys || []), key])
: normalizeStringList((selectedConfigEntry.passKeys || []).filter((value) => value !== key)),
})}
/>
{key}
</label>
)) : <p className="muted">No keys available.</p>}
</div>
</div>
</div>
) : null;
})() : (
<>
<div className="field-row">
<label htmlFor="cfg-display">Display Keys (comma separated)</label>
<input
id="cfg-display"
value={Array.isArray(selectedConfigEntry.displayKeys) ? selectedConfigEntry.displayKeys.join(", ") : ""}
onChange={(event) => onUpdateActiveConfigEntry(selectedConfigIndex, { displayKeys: parseCsv(event.target.value) })}
/>
</div>
<div className="field-row">
<label htmlFor="cfg-pass">Pass Keys (comma separated)</label>
<input
id="cfg-pass"
value={Array.isArray(selectedConfigEntry.passKeys) ? selectedConfigEntry.passKeys.join(", ") : ""}
onChange={(event) => onUpdateActiveConfigEntry(selectedConfigIndex, { passKeys: parseCsv(event.target.value) })}
/>
</div>
</>
)}
</div>
) : null}
</section>
</div>
);
}

View file

@ -0,0 +1,70 @@
import ItemQuestAdvancedPanels from "./ItemQuestAdvancedPanels";
import NpcDialogueEditorPanel from "./NpcDialogueEditorPanel";
import RawJsonSection from "./RawJsonSection";
import RecordEditorBasePanel from "./RecordEditorBasePanel";
import RecordListPanel from "./RecordListPanel";
import type { FullContentContext } from "./editorContexts";
type ContentSectionProps = {
ctx: FullContentContext;
};
export default function ContentSection({ ctx }: ContentSectionProps) {
const {
activeType,
records,
selectedIndex,
isAllRecordsSelected,
hasPendingRecordChanges,
getRecordLabel,
buildSpritePreviewDataUrl,
normalizeHexColor,
selectRecordIndex,
commitPendingRecordChanges,
revertPendingRecordChanges,
recordDraftError,
isLoading,
isSaving,
selectedRecord,
recordJsonDraft,
jsonText,
handleRawJsonEditorChange,
} = ctx;
return (
<>
<div className="structured-layout">
<RecordListPanel
activeType={activeType}
records={records}
selectedIndex={selectedIndex}
isAllRecordsSelected={isAllRecordsSelected}
hasPendingRecordChanges={hasPendingRecordChanges}
getRecordLabel={getRecordLabel}
buildSpritePreviewDataUrl={buildSpritePreviewDataUrl}
normalizeHexColor={normalizeHexColor}
onSelectRecordIndex={selectRecordIndex}
onCommitPendingRecordChanges={commitPendingRecordChanges}
onRevertPendingRecordChanges={revertPendingRecordChanges}
recordDraftError={recordDraftError}
isLoading={isLoading}
isSaving={isSaving}
/>
<section className="record-editor-panel">
<RecordEditorBasePanel ctx={ctx} />
<ItemQuestAdvancedPanels ctx={ctx} />
<NpcDialogueEditorPanel ctx={ctx} />
</section>
</div>
<RawJsonSection
isAllRecordsSelected={isAllRecordsSelected}
hasSelectedRecord={Boolean(selectedRecord)}
recordJsonDraft={recordJsonDraft}
jsonText={jsonText}
onRawJsonEditorChange={handleRawJsonEditorChange}
/>
</>
);
}

View file

@ -0,0 +1,91 @@
type EditorToolbarProps = {
activeSection: "content" | "config";
activeType: string;
activeConfigTab?: string;
activeConfigEntriesLength: number;
selectedConfigIndex: number;
selectedConfigEntry: unknown;
hasUnsavedContentChanges: boolean;
hasUnsavedConfigChanges: boolean;
parsedJsonError: string;
validationIssuesLength: number;
hasPendingRecordChanges: boolean;
recordDraftError: string;
isLoading: boolean;
isSaving: boolean;
onSaveContent: () => void;
onAddRecord: () => void;
onDeleteRecord: () => void;
onSaveConfig: () => void;
onAddConfigEntry: () => void;
onDeleteConfigEntry: () => void;
onMoveConfigEntry: (direction: -1 | 1) => void;
error: string;
};
export default function EditorToolbar(props: EditorToolbarProps) {
const {
activeSection,
activeType,
activeConfigTab,
activeConfigEntriesLength,
selectedConfigIndex,
selectedConfigEntry,
hasUnsavedContentChanges,
hasUnsavedConfigChanges,
parsedJsonError,
validationIssuesLength,
hasPendingRecordChanges,
recordDraftError,
isLoading,
isSaving,
onSaveContent,
onAddRecord,
onDeleteRecord,
onSaveConfig,
onAddConfigEntry,
onDeleteConfigEntry,
onMoveConfigEntry,
error,
} = props;
const isReadOnlyColorsTab = activeConfigTab === "Colors";
return (
<div className="toolbar">
{activeSection === "content" ? (
<>
<button
type="button"
className={hasUnsavedContentChanges ? "primary save-dirty" : "primary"}
onClick={onSaveContent}
disabled={!activeType || Boolean(parsedJsonError) || validationIssuesLength > 0 || hasPendingRecordChanges || Boolean(recordDraftError) || isLoading || isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
<button type="button" className="success" onClick={onAddRecord} disabled={Boolean(parsedJsonError) || isLoading || isSaving}>
Add Record
</button>
<button type="button" className="danger" onClick={onDeleteRecord} disabled={isLoading || isSaving}>
Delete Record
</button>
</>
) : (
<>
<button
type="button"
className={hasUnsavedConfigChanges ? "primary save-dirty" : "primary"}
onClick={onSaveConfig}
disabled={isReadOnlyColorsTab || isLoading || isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
<button type="button" className="success" onClick={onAddConfigEntry} disabled={isReadOnlyColorsTab || isLoading || isSaving}>Add Record</button>
<button type="button" className="danger" onClick={onDeleteConfigEntry} disabled={isReadOnlyColorsTab || !selectedConfigEntry || isLoading || isSaving}>Delete Record</button>
<button type="button" onClick={() => onMoveConfigEntry(-1)} disabled={isReadOnlyColorsTab || !selectedConfigEntry || selectedConfigIndex <= 0 || isLoading || isSaving}>Move Up</button>
<button type="button" onClick={() => onMoveConfigEntry(1)} disabled={isReadOnlyColorsTab || !selectedConfigEntry || selectedConfigIndex >= activeConfigEntriesLength - 1 || isLoading || isSaving}>Move Down</button>
</>
)}
{error ? <p className="toolbar-error">{error}</p> : null}
</div>
);
}

View file

@ -0,0 +1,379 @@
import type { DialogueFlowStep, JsonObject } from "../editorCore";
import type { ItemQuestAdvancedContext } from "./editorContexts";
type ItemQuestAdvancedPanelsProps = {
ctx: ItemQuestAdvancedContext;
};
export default function ItemQuestAdvancedPanels({ ctx }: ItemQuestAdvancedPanelsProps) {
const {
activeType,
selectedRecord,
activeEditTab,
addItemActionEntry,
selectedItemActions,
selectedIndex,
isStepCollapsed,
setStepCollapsed,
getItemActionSummary,
moveItemActionEntry,
deleteItemActionEntry,
getPlainObjectArray,
patchSelectedRecordArray,
patchItemActionFlowSteps,
createFlowStep,
FLOW_KIND_LABELS,
getFlowStepSummary,
conditionTypeOptions,
getDefaultConditionValue,
renderConditionValueField,
patchSelectedRecord,
selectedQuestSteps,
getQuestStepSummary,
moveQuestStepEntry,
deleteQuestStepEntry,
patchQuestSteps,
addQuestStepEntry,
} = ctx;
return (
<>
{activeType === "items" && selectedRecord && activeEditTab === "Actions" ? (
<div className="mini-editor">
<div className="mini-editor-head">
<h4>Item Action Flow</h4>
<div className="flow-add-buttons">
<button type="button" className="success" onClick={addItemActionEntry}>+ Action</button>
</div>
</div>
{selectedItemActions.length === 0 ? <p className="muted">No item actions yet. Add one above.</p> : null}
{selectedItemActions.map((actionEntry, actionIndex) => {
const actionCardKey = `item-action-${selectedIndex}-${actionIndex}`;
const collapsed = isStepCollapsed(actionCardKey);
const actionFlowSteps = getPlainObjectArray(actionEntry.flowSteps) as unknown as DialogueFlowStep[];
return (
<div
key={actionCardKey}
className={`flow-step ${collapsed ? "collapsed" : "expanded"}`}
>
<div
className="flow-step-head flow-step-toggle"
role="button"
tabIndex={0}
onClick={() => setStepCollapsed(actionCardKey, !collapsed)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setStepCollapsed(actionCardKey, !collapsed);
}
}}
>
<div>
<strong>{actionIndex + 1}. {String(actionEntry.action || "Action")}</strong>
{collapsed ? <p className="flow-summary">{getItemActionSummary(actionEntry, actionIndex)}</p> : null}
</div>
{!collapsed ? (
<div className="flow-step-actions flow-expanded-actions" onClick={(event) => event.stopPropagation()}>
<button type="button" onClick={() => moveItemActionEntry(actionIndex, -1)} disabled={actionIndex === 0}>-</button>
<button type="button" onClick={() => moveItemActionEntry(actionIndex, 1)} disabled={actionIndex >= selectedItemActions.length - 1}>+</button>
<button type="button" className="danger" onClick={() => deleteItemActionEntry(actionIndex)}>Remove</button>
</div>
) : null}
</div>
{!collapsed ? (
<div className="dialogue-node-editor">
<label>Action Name</label>
<input
value={String(actionEntry.action || "")}
onChange={(event) => patchSelectedRecordArray("actionsList", (entries) => entries.map((entry, idx) => (idx === actionIndex ? { ...entry, action: event.target.value } : entry)))}
/>
<label>Action Flow</label>
<div className="mini-editor">
<div className="mini-editor-head">
<h4>Timeline</h4>
<div className="flow-add-buttons">
<button type="button" className="success" onClick={() => patchItemActionFlowSteps(actionIndex, (steps) => [...steps, createFlowStep("condition", actionCardKey)])}>+ Condition</button>
<button type="button" className="success" onClick={() => patchItemActionFlowSteps(actionIndex, (steps) => [...steps, createFlowStep("action", actionCardKey)])}>+ Action</button>
<button type="button" className="success" onClick={() => patchItemActionFlowSteps(actionIndex, (steps) => [...steps, createFlowStep("jump", actionCardKey)])}>+ Jump</button>
<button type="button" className="success" onClick={() => patchItemActionFlowSteps(actionIndex, (steps) => [...steps, createFlowStep("choice", actionCardKey)])}>+ Choice</button>
<button type="button" className="success" onClick={() => patchItemActionFlowSteps(actionIndex, (steps) => [...steps, createFlowStep("text", actionCardKey)])}>+ Text Screen</button>
<button type="button" className="success" onClick={() => patchItemActionFlowSteps(actionIndex, (steps) => [...steps, createFlowStep("end", actionCardKey)])}>+ End</button>
</div>
</div>
{actionFlowSteps.length === 0 ? <p className="muted">No nested steps yet.</p> : null}
{actionFlowSteps.map((step, stepIndex) => {
const stepCollapsed = isStepCollapsed(step.id);
const nextStep = actionFlowSteps[stepIndex + 1];
const stepConditionType = String(step.conditionType || "always");
return (
<div key={step.id} className={`flow-step ${stepCollapsed ? "collapsed" : "expanded"}`}>
<div
className="flow-step-head flow-step-toggle"
role="button"
tabIndex={0}
onClick={() => setStepCollapsed(step.id, !stepCollapsed)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setStepCollapsed(step.id, !stepCollapsed);
}
}}
>
<div>
<strong>{stepIndex + 1}. {FLOW_KIND_LABELS[step.kind]}</strong>
{stepCollapsed ? <p className="flow-summary">{getFlowStepSummary(step)}</p> : null}
</div>
{stepCollapsed && nextStep ? <p className="flow-next-preview">Next: ({stepIndex + 2}) {FLOW_KIND_LABELS[nextStep.kind]} - {getFlowStepSummary(nextStep).slice(0, 45)}</p> : null}
{!stepCollapsed ? (
<div className="flow-step-actions flow-expanded-actions" onClick={(event) => event.stopPropagation()}>
<button type="button" onClick={() => patchItemActionFlowSteps(actionIndex, (steps) => {
const target = stepIndex - 1;
if (target < 0) { return steps; }
const next = [...steps];
const [moved] = next.splice(stepIndex, 1);
next.splice(target, 0, moved);
return next;
})} disabled={stepIndex === 0}>-</button>
<button type="button" onClick={() => patchItemActionFlowSteps(actionIndex, (steps) => {
const target = stepIndex + 1;
if (target >= steps.length) { return steps; }
const next = [...steps];
const [moved] = next.splice(stepIndex, 1);
next.splice(target, 0, moved);
return next;
})} disabled={stepIndex >= actionFlowSteps.length - 1}>+</button>
<button type="button" className="danger" onClick={() => patchItemActionFlowSteps(actionIndex, (steps) => steps.filter((_, idx) => idx !== stepIndex))}>Remove</button>
</div>
) : null}
</div>
{!stepCollapsed && step.kind === "text" ? (
<textarea
value={String(step.text || "")}
onChange={(event) => patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, text: event.target.value } : entry)))}
placeholder="Screen text"
/>
) : null}
{!stepCollapsed && step.kind === "jump" ? (
<input
value={String(step.nextId || "")}
onChange={(event) => patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, nextId: event.target.value } : entry)))}
placeholder="Next step or target"
/>
) : null}
{!stepCollapsed && step.kind === "action" ? (
<div className="flow-step-grid">
<input
value={String(step.reactionType || "none")}
onChange={(event) => patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, reactionType: event.target.value } : entry)))}
placeholder="Action type"
/>
<input
value={String(step.reactionValue || "")}
onChange={(event) => patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, reactionValue: event.target.value } : entry)))}
placeholder="Action value"
/>
</div>
) : null}
{!stepCollapsed && step.kind === "condition" ? (
<div className="flow-step-grid">
<select
value={stepConditionType}
onChange={(event) => patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, conditionType: event.target.value, conditionValue: getDefaultConditionValue(event.target.value) } : entry)))}
>
{conditionTypeOptions.map((opt: string) => (
<option key={`item-action-cond-${actionIndex}-${step.id}-${opt}`} value={opt}>{opt}</option>
))}
</select>
{renderConditionValueField(stepConditionType, String(step.conditionValue || ""), (nextValue: string) => {
patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, conditionValue: nextValue } : entry)));
})}
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(step.conditionNot)}
onChange={(event) => patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, conditionNot: event.target.checked } : entry)))}
/>
not
</label>
<input
value={String(step.text || "")}
onChange={(event) => patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, text: event.target.value } : entry)))}
placeholder="Outcome text"
/>
</div>
) : null}
{!stepCollapsed && step.kind === "choice" ? (
<div className="flow-step-grid">
<input
value={String(step.text || "")}
onChange={(event) => patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, text: event.target.value } : entry)))}
placeholder="Choice label"
/>
<input
value={String(step.nextId || "")}
onChange={(event) => patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, nextId: event.target.value } : entry)))}
placeholder="Next target"
/>
<select
value={stepConditionType}
onChange={(event) => patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, conditionType: event.target.value, conditionValue: getDefaultConditionValue(event.target.value) } : entry)))}
>
{conditionTypeOptions.map((opt: string) => (
<option key={`item-action-choice-cond-${actionIndex}-${step.id}-${opt}`} value={opt}>{opt}</option>
))}
</select>
{renderConditionValueField(stepConditionType, String(step.conditionValue || ""), (nextValue: string) => {
patchItemActionFlowSteps(actionIndex, (steps) => steps.map((entry, idx) => (idx === stepIndex ? { ...entry, conditionValue: nextValue } : entry)));
})}
</div>
) : null}
{!stepCollapsed && step.kind === "end" ? <p className="muted">Stops this action flow here.</p> : null}
</div>
);
})}
</div>
</div>
) : null}
</div>
);
})}
</div>
) : null}
{activeType === "quests" && selectedRecord && activeEditTab === "Conditions" ? (
<div className="mini-editor">
<div className="mini-editor-head">
<h4>Quest Requirements</h4>
<div className="flow-add-buttons">
<button type="button" className="success" onClick={() => patchSelectedRecord((record) => ({
...record,
requirements: [...getPlainObjectArray(record.requirements), { conditionType: "always", conditionValue: "" }],
}))}>+ Requirement</button>
</div>
</div>
{getPlainObjectArray(selectedRecord.requirements).length === 0 ? <p className="muted">No requirements yet.</p> : null}
{getPlainObjectArray(selectedRecord.requirements).map((requirement: JsonObject, requirementIndex: number) => {
const requirementCollapsed = isStepCollapsed(`quest-req-${selectedIndex}-${requirementIndex}`);
const requirementType = String(requirement.conditionType || "always");
return (
<div key={`quest-req-${selectedIndex}-${requirementIndex}`} className={`flow-step ${requirementCollapsed ? "collapsed" : "expanded"}`}>
<div className="flow-step-head flow-step-toggle" role="button" tabIndex={0} onClick={() => setStepCollapsed(`quest-req-${selectedIndex}-${requirementIndex}`, !requirementCollapsed)}>
<div>
<strong>{requirementIndex + 1}. {requirementType}</strong>
{requirementCollapsed ? <p className="flow-summary">{String(requirement.conditionValue || "Always")}</p> : null}
</div>
{!requirementCollapsed ? (
<div className="flow-step-actions flow-expanded-actions">
<button type="button" className="danger" onClick={() => patchSelectedRecord((record) => ({
...record,
requirements: getPlainObjectArray(record.requirements).filter((_, idx: number) => idx !== requirementIndex),
}))}>Remove</button>
</div>
) : null}
</div>
{!requirementCollapsed ? (
<div className="dialogue-node-editor">
<label>Condition Type</label>
<select
value={requirementType}
onChange={(event) => patchSelectedRecord((record) => ({
...record,
requirements: getPlainObjectArray(record.requirements).map((entry, idx: number) => idx === requirementIndex ? { ...entry, conditionType: event.target.value } : entry),
}))}
>
{conditionTypeOptions.map((opt: string) => (
<option key={`quest-req-${selectedIndex}-${requirementIndex}-${opt}`} value={opt}>{opt}</option>
))}
</select>
<label>Condition Value</label>
{renderConditionValueField(requirementType, String(requirement.conditionValue || ""), (nextValue: string) => patchSelectedRecord((record) => ({
...record,
requirements: getPlainObjectArray(record.requirements).map((entry, idx: number) => idx === requirementIndex ? { ...entry, conditionValue: nextValue } : entry),
})))}
</div>
) : null}
</div>
);
})}
</div>
) : null}
{activeType === "quests" && selectedRecord && activeEditTab === "Steps" ? (
<div className="mini-editor">
<div className="mini-editor-head">
<h4>Quest Steps</h4>
<div className="flow-add-buttons">
<button type="button" className="success" onClick={addQuestStepEntry}>+ Step</button>
</div>
</div>
{selectedQuestSteps.length === 0 ? <p className="muted">No quest steps yet.</p> : null}
{selectedQuestSteps.map((step: JsonObject, stepIndex: number) => {
const stepCardKey = `quest-step-${selectedIndex}-${stepIndex}`;
const stepCollapsed = isStepCollapsed(stepCardKey);
return (
<div key={stepCardKey} className={`flow-step ${stepCollapsed ? "collapsed" : "expanded"}`}>
<div className="flow-step-head flow-step-toggle" role="button" tabIndex={0} onClick={() => setStepCollapsed(stepCardKey, !stepCollapsed)}>
<div>
<strong>{stepIndex + 1}. {getQuestStepSummary(step, stepIndex)}</strong>
{stepCollapsed ? <p className="flow-summary">{String(step.conditionType || "always")}</p> : null}
</div>
{!stepCollapsed ? (
<div className="flow-step-actions flow-expanded-actions">
<button type="button" onClick={() => moveQuestStepEntry(stepIndex, -1)} disabled={stepIndex === 0}>-</button>
<button type="button" onClick={() => moveQuestStepEntry(stepIndex, 1)} disabled={stepIndex >= selectedQuestSteps.length - 1}>+</button>
<button type="button" className="danger" onClick={() => deleteQuestStepEntry(stepIndex)}>Remove</button>
</div>
) : null}
</div>
{!stepCollapsed ? (
<div className="dialogue-node-editor">
<label>Step ID</label>
<input
value={String(step.stepID || stepIndex + 1)}
onChange={(event) => patchQuestSteps((entries) => entries.map((entry, idx) => idx === stepIndex ? { ...entry, stepID: Math.max(1, Number(event.target.value) || stepIndex + 1) } : entry))}
/>
<label>Step Key</label>
<input
value={String(step.id || "")}
onChange={(event) => patchQuestSteps((entries) => entries.map((entry, idx) => idx === stepIndex ? { ...entry, id: event.target.value } : entry))}
/>
<label>Step Name</label>
<input
value={String(step.name || "")}
onChange={(event) => patchQuestSteps((entries) => entries.map((entry, idx) => idx === stepIndex ? { ...entry, name: event.target.value } : entry))}
/>
<label>Condition Type</label>
<select
value={String(step.conditionType || "always")}
onChange={(event) => patchQuestSteps((entries) => entries.map((entry, idx) => idx === stepIndex ? { ...entry, conditionType: event.target.value } : entry))}
>
{conditionTypeOptions.map((opt: string) => (
<option key={`quest-step-cond-${stepIndex}-${opt}`} value={opt}>{opt}</option>
))}
</select>
<label>Condition Value</label>
{renderConditionValueField(String(step.conditionType || "always"), String(step.conditionValue || ""), (nextValue: string) => {
patchQuestSteps((entries) => entries.map((entry, idx) => idx === stepIndex ? { ...entry, conditionValue: nextValue } : entry));
})}
</div>
) : null}
</div>
);
})}
</div>
) : null}
</>
);
}

View file

@ -0,0 +1,382 @@
import NpcDialogueSimulationPanel from "./NpcDialogueSimulationPanel";
import type { NpcDialogueEditorContext } from "./editorContexts";
type NpcDialogueEditorPanelProps = {
ctx: NpcDialogueEditorContext;
};
export default function NpcDialogueEditorPanel({ ctx }: NpcDialogueEditorPanelProps) {
const {
activeType,
selectedRecord,
activeEditTab,
addDialogueNode,
deleteDialogueNode,
selectedDialogueNode,
moveDialogueNode,
selectedDialogueNodeIndex,
dialogueNodes,
setSelectedDialogueNodeIndex,
selectedDialogueFieldEntries,
toFieldLabel,
handleDialogueNodeFieldChange,
patchFlowSteps,
createFlowStep,
selectedFlowSteps,
isStepCollapsed,
dropTargetStepId,
draggingStepId,
setDraggingStepId,
setDropTargetStepId,
moveFlowStepById,
setStepCollapsed,
FLOW_KIND_LABELS,
getFlowStepSummary,
moveFlowStepByDirection,
conditionTypeOptions,
getDefaultConditionValue,
renderConditionValueField,
dialogueNodeIds,
DIALOGUE_REACTION_TYPES,
simSandbox,
simCurrentNodeId,
simText,
simChoices,
simFallbackNextId,
simEnded,
startSimulation,
updateSandbox,
chooseSimChoice,
continueSimulation,
isLoading,
isSaving,
} = ctx;
if (!(activeType === "dialogues" && selectedRecord && activeEditTab === "Dialogue")) {
return null;
}
return (
<div className="dialogue-editor">
<div className="dialogue-editor-head">
<h3>Dialogue Nodes</h3>
<div className="dialogue-actions">
<button type="button" className="success" onClick={addDialogueNode} disabled={isLoading || isSaving}>Add Node</button>
<button type="button" className="danger" onClick={deleteDialogueNode} disabled={!selectedDialogueNode || isLoading || isSaving}>Delete Node</button>
<button type="button" onClick={() => moveDialogueNode(-1)} disabled={!selectedDialogueNode || selectedDialogueNodeIndex <= 0 || isLoading || isSaving}>Move Up</button>
<button type="button" onClick={() => moveDialogueNode(1)} disabled={!selectedDialogueNode || selectedDialogueNodeIndex >= dialogueNodes.length - 1 || isLoading || isSaving}>Move Down</button>
</div>
</div>
<div className="dialogue-layout">
<div className="dialogue-node-list">
{dialogueNodes.length === 0 ? <p className="muted">No dialogue nodes yet.</p> : null}
{dialogueNodes.map((node, index) => {
const nodeId = String(node.id || `node-${index + 1}`);
const preview = String(node.description || node.text || "").trim();
return (
<button
key={`${nodeId}-${index}`}
type="button"
className={`record-row ${index === selectedDialogueNodeIndex ? "is-active" : ""}`}
onClick={() => setSelectedDialogueNodeIndex(index)}
>
{nodeId}{preview ? ` - ${preview.slice(0, 48)}` : ""}
</button>
);
})}
</div>
<div className="dialogue-node-fields">
{!selectedDialogueNode ? <p className="muted">Select a node to edit.</p> : null}
{selectedDialogueNode ? (
<>
<div className="fields-grid">
{selectedDialogueFieldEntries.map(([key, value]) => {
if (["choices", "conditions", "reactions"].includes(key)) {
return null;
}
if (Array.isArray(value) || (value !== null && typeof value === "object")) {
const itemCount = Array.isArray(value) ? value.length : Object.keys(value as object).length;
return (
<div key={`dialogue-${key}`} className="field-row">
<label>{toFieldLabel(key)}</label>
<p className="muted">Complex value ({itemCount}). Edit in Raw JSON below.</p>
</div>
);
}
if (typeof value === "boolean") {
return (
<div key={`dialogue-${key}`} className="field-row">
<label htmlFor={`dialogue-field-${key}`}>{toFieldLabel(key)}</label>
<select id={`dialogue-field-${key}`} value={String(value)} onChange={(event) => handleDialogueNodeFieldChange(key, event.target.value)}>
<option value="true">true</option>
<option value="false">false</option>
</select>
</div>
);
}
return (
<div key={`dialogue-${key}`} className="field-row">
<label htmlFor={`dialogue-field-${key}`}>{toFieldLabel(key)}</label>
<input
id={`dialogue-field-${key}`}
type={typeof value === "number" ? "number" : "text"}
value={value === null ? "" : String(value)}
onChange={(event) => handleDialogueNodeFieldChange(key, event.target.value)}
/>
</div>
);
})}
</div>
<div className="mini-editor">
<div className="mini-editor-head">
<h4>Flow Timeline</h4>
<div className="flow-add-buttons">
<button type="button" onClick={() => patchFlowSteps((steps) => [...steps, createFlowStep("condition")])}>+ Condition</button>
<button type="button" onClick={() => patchFlowSteps((steps) => [...steps, createFlowStep("action")])}>+ Action</button>
<button type="button" onClick={() => patchFlowSteps((steps) => [...steps, createFlowStep("jump")])}>+ Jump</button>
<button type="button" onClick={() => patchFlowSteps((steps) => [...steps, createFlowStep("choice")])}>+ Choice</button>
<button type="button" onClick={() => patchFlowSteps((steps) => [...steps, createFlowStep("text")])}>+ Text Screen</button>
<button type="button" onClick={() => patchFlowSteps((steps) => [...steps, createFlowStep("end")])}>+ End</button>
</div>
</div>
{selectedFlowSteps.length === 0 ? <p className="muted">No flow steps yet. Add a step above.</p> : null}
{selectedFlowSteps.map((step, index) => {
const stepConditionType = String(step.conditionType || "always");
const collapsed = isStepCollapsed(step.id);
const nextStep = selectedFlowSteps[index + 1];
return (
<div
key={step.id}
className={`flow-step ${collapsed ? "collapsed" : "expanded"} ${dropTargetStepId === step.id ? "drop-target" : ""}`}
draggable
onDragStart={() => setDraggingStepId(step.id)}
onDragOver={(event) => {
event.preventDefault();
setDropTargetStepId(step.id);
}}
onDrop={(event) => {
event.preventDefault();
moveFlowStepById(draggingStepId, step.id);
setDraggingStepId("");
setDropTargetStepId("");
}}
onDragEnd={() => {
setDraggingStepId("");
setDropTargetStepId("");
}}
>
<div
className="flow-step-head flow-step-toggle"
role="button"
tabIndex={0}
onClick={() => setStepCollapsed(step.id, !collapsed)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setStepCollapsed(step.id, !collapsed);
}
}}
>
<div>
<strong>{index + 1}. {FLOW_KIND_LABELS[step.kind]}</strong>
{collapsed ? <p className="flow-summary">{getFlowStepSummary(step)}</p> : null}
</div>
{collapsed && nextStep ? <p className="flow-next-preview">Next: ({index + 2}) {FLOW_KIND_LABELS[nextStep.kind]} - {getFlowStepSummary(nextStep).slice(0, 45)}</p> : null}
{!collapsed ? (
<div className="flow-step-actions flow-expanded-actions" onClick={(event) => event.stopPropagation()}>
<button
type="button"
onClick={() => moveFlowStepByDirection(step.id, -1)}
disabled={index === 0}
title="Move up"
>
-
</button>
<button
type="button"
onClick={() => moveFlowStepByDirection(step.id, 1)}
disabled={index >= selectedFlowSteps.length - 1}
title="Move down"
>
+
</button>
<button type="button" onClick={() => patchFlowSteps((steps) => steps.filter((_, idx) => idx !== index))}>Remove</button>
</div>
) : null}
</div>
{!collapsed && step.kind === "text" ? (
<textarea
value={String(step.text || "")}
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, text: event.target.value } : entry)))}
placeholder="Screen text"
/>
) : null}
{!collapsed && step.kind === "jump" ? (
<select
value={String(step.nextId || "")}
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, nextId: event.target.value } : entry)))}
>
<option value="">Select node</option>
{dialogueNodeIds.map((id: string) => (
<option key={`flow-jump-${step.id}-${id}`} value={id}>{id}</option>
))}
</select>
) : null}
{!collapsed && step.kind === "action" ? (
<div className="flow-step-grid">
<select
value={String(step.reactionType || "none")}
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, reactionType: event.target.value } : entry)))}
>
{DIALOGUE_REACTION_TYPES.map((opt: string) => (
<option key={`flow-action-${step.id}-${opt}`} value={opt}>{opt}</option>
))}
</select>
<input
value={String(step.reactionValue || "")}
placeholder="Action value"
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, reactionValue: event.target.value } : entry)))}
/>
</div>
) : null}
{!collapsed && step.kind === "condition" ? (
<div className="flow-step-grid">
<select
value={stepConditionType}
onChange={(event) => {
const nextType = event.target.value;
patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? {
...entry,
conditionType: nextType,
conditionValue: getDefaultConditionValue(nextType),
} : entry)));
}}
>
{conditionTypeOptions.map((opt: string) => (
<option key={`flow-cond-type-${step.id}-${opt}`} value={opt}>{opt}</option>
))}
</select>
{renderConditionValueField(stepConditionType, String(step.conditionValue || ""), (nextValue: string) => {
patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, conditionValue: nextValue } : entry)));
})}
<input
value={String(step.text || "")}
placeholder="Output text"
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, text: event.target.value } : entry)))}
/>
<select
value={String(step.nextId || "")}
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, nextId: event.target.value } : entry)))}
>
<option value="">No jump</option>
{dialogueNodeIds.map((id: string) => (
<option key={`flow-cond-next-${step.id}-${id}`} value={id}>{id}</option>
))}
</select>
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(step.conditionNot)}
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, conditionNot: event.target.checked } : entry)))}
/>
not
</label>
</div>
) : null}
{!collapsed && step.kind === "choice" ? (
<div className="flow-step-grid">
<input
value={String(step.text || "")}
placeholder="Choice text"
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, text: event.target.value } : entry)))}
/>
<select
value={String(step.nextId || "")}
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, nextId: event.target.value } : entry)))}
>
<option value="">End</option>
{dialogueNodeIds.map((id: string) => (
<option key={`flow-choice-next-${step.id}-${id}`} value={id}>{id}</option>
))}
</select>
<select
value={stepConditionType}
onChange={(event) => {
const nextType = event.target.value;
patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? {
...entry,
conditionType: nextType,
conditionValue: getDefaultConditionValue(nextType),
} : entry)));
}}
>
{conditionTypeOptions.map((opt: string) => (
<option key={`flow-choice-cond-${step.id}-${opt}`} value={opt}>{opt}</option>
))}
</select>
{renderConditionValueField(stepConditionType, String(step.conditionValue || ""), (nextValue: string) => {
patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, conditionValue: nextValue } : entry)));
})}
<label className="inline-check">
<input
type="checkbox"
checked={Boolean(step.conditionNot)}
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, conditionNot: event.target.checked } : entry)))}
/>
not
</label>
<select
value={String(step.reactionType || "none")}
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, reactionType: event.target.value } : entry)))}
>
{DIALOGUE_REACTION_TYPES.map((opt: string) => (
<option key={`flow-choice-react-${step.id}-${opt}`} value={opt}>{opt}</option>
))}
</select>
<input
value={String(step.reactionValue || "")}
placeholder="Choice action value"
onChange={(event) => patchFlowSteps((steps) => steps.map((entry, idx) => (idx === index ? { ...entry, reactionValue: event.target.value } : entry)))}
/>
</div>
) : null}
{!collapsed && step.kind === "end" ? <p className="muted">Ends the conversation immediately when reached.</p> : null}
</div>
);
})}
</div>
</>
) : null}
</div>
</div>
<NpcDialogueSimulationPanel
dialogueNodesLength={dialogueNodes.length}
simSandbox={simSandbox}
simCurrentNodeId={simCurrentNodeId}
simText={simText}
simChoices={simChoices}
simFallbackNextId={simFallbackNextId}
simEnded={simEnded}
onStartSimulation={startSimulation}
onUpdateSandbox={updateSandbox}
onChooseSimChoice={chooseSimChoice}
onContinueSimulation={continueSimulation}
/>
</div>
);
}

View file

@ -0,0 +1,113 @@
import type { DialogueChoice, DialogueSandbox } from "../editorCore";
type NpcDialogueSimulationPanelProps = {
dialogueNodesLength: number;
simSandbox: DialogueSandbox;
simCurrentNodeId: string;
simText: string;
simChoices: DialogueChoice[];
simFallbackNextId: string;
simEnded: boolean;
onStartSimulation: () => void;
onUpdateSandbox: (sandbox: DialogueSandbox) => void;
onChooseSimChoice: (choice: DialogueChoice) => void;
onContinueSimulation: () => void;
};
export default function NpcDialogueSimulationPanel(props: NpcDialogueSimulationPanelProps) {
const {
dialogueNodesLength,
simSandbox,
simCurrentNodeId,
simText,
simChoices,
simFallbackNextId,
simEnded,
onStartSimulation,
onUpdateSandbox,
onChooseSimChoice,
onContinueSimulation,
} = props;
return (
<div className="dialogue-sim">
<div className="dialogue-sim-head">
<h3>Dialogue Sandbox Preview</h3>
<button type="button" onClick={onStartSimulation} disabled={dialogueNodesLength === 0}>Start</button>
</div>
<div className="sandbox-row">
<label htmlFor="quest0-started">Quest 0 Started</label>
<input
id="quest0-started"
type="checkbox"
checked={simSandbox.questStarted.includes(0)}
onChange={(event) => {
const nextStarted = event.target.checked
? Array.from(new Set([...simSandbox.questStarted, 0]))
: simSandbox.questStarted.filter((id) => id !== 0);
onUpdateSandbox({
...simSandbox,
questStarted: nextStarted,
});
}}
/>
<label htmlFor="quest0-complete">Quest 0 Complete</label>
<input
id="quest0-complete"
type="checkbox"
checked={simSandbox.questCompleted.includes(0)}
onChange={(event) => {
const nextCompleted = event.target.checked
? Array.from(new Set([...simSandbox.questCompleted, 0]))
: simSandbox.questCompleted.filter((id) => id !== 0);
onUpdateSandbox({
...simSandbox,
questCompleted: nextCompleted,
});
}}
/>
<label htmlFor="copper-ore">Copper Ore</label>
<input
id="copper-ore"
type="number"
min="0"
value={simSandbox.inventory.copper_ore || 0}
onChange={(event) => {
const nextQty = Math.max(0, Number(event.target.value) || 0);
onUpdateSandbox({
...simSandbox,
inventory: {
...simSandbox.inventory,
copper_ore: nextQty,
},
});
}}
/>
</div>
<div className="sim-box">
<p className="sim-node">Node: {simCurrentNodeId || "(none)"}</p>
<p className="sim-text">{simText || "Press Start to preview this NPC dialogue."}</p>
{simChoices.length > 0 ? (
<div className="sim-choices">
{simChoices.map((choice, index) => (
<button key={`sim-choice-${index}`} type="button" onClick={() => onChooseSimChoice(choice)} disabled={simEnded}>
{index + 1}. {choice.text}
</button>
))}
</div>
) : null}
{simChoices.length === 0 ? (
<button type="button" onClick={onContinueSimulation} disabled={simEnded}>
{simFallbackNextId ? "Continue" : "End"}
</button>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,30 @@
type RawJsonSectionProps = {
isAllRecordsSelected: boolean;
hasSelectedRecord: boolean;
recordJsonDraft: string;
jsonText: string;
onRawJsonEditorChange: (nextText: string) => void;
};
export default function RawJsonSection(props: RawJsonSectionProps) {
const {
isAllRecordsSelected,
hasSelectedRecord,
recordJsonDraft,
jsonText,
onRawJsonEditorChange,
} = props;
return (
<>
<h2 className="raw-heading">Raw JSON</h2>
<textarea
value={!isAllRecordsSelected && hasSelectedRecord ? recordJsonDraft : jsonText}
onChange={(event) => onRawJsonEditorChange(event.target.value)}
spellCheck={false}
aria-label="JSON editor"
className="json-editor"
/>
</>
);
}

View file

@ -0,0 +1,396 @@
import { useRef, useState } from "react";
import type { JsonObject } from "../editorCore";
import type { RecordEditorBaseContext } from "./editorContexts";
type RecordEditorBasePanelProps = {
ctx: RecordEditorBaseContext;
};
export default function RecordEditorBasePanel({ ctx }: RecordEditorBasePanelProps) {
const SPRITE_CANVAS_SIZE = 16;
const {
isAllRecordsSelected,
selectedRecord,
activeEditTabs,
activeType,
activeEditTab,
setActiveEditTabByType,
selectedFieldEntriesForTab,
isPlainObject,
toFieldLabel,
handlePrimitiveFieldChange,
npcFactionOptions,
handleNpcPositionFieldChange,
normalizeHexColor,
npcSpriteSearchQuery,
setNpcSpriteSearchQuery,
filteredNpcSpriteOptions,
buildSpritePreviewDataUrl,
npcSpriteOptions,
contentDataByType,
patchSelectedRecord,
activeSpritePaintSymbol,
setActiveSpritePaintSymbol,
selectedSpriteEditorSize,
getSpriteCellSymbol,
getSpritePalette,
paintSpriteCell,
colorPaletteEntries,
} = ctx;
const isPointerPaintingRef = useRef(false);
const selectedSpriteInfoPreviewUrl = (activeType === "sprites" || activeType === "tiles") && selectedRecord
? buildSpritePreviewDataUrl(selectedRecord, 4)
: null;
const [spriteSelectorOpen, setSpriteSelectorOpen] = useState(false);
const getColorSymbol = (entry: { key?: string; sourceKey?: string; originalName?: string }) => String(entry.key || entry.sourceKey || entry.originalName || "").trim().charAt(0);
const normalizedColorEntries = colorPaletteEntries
.map((entry) => ({
symbol: getColorSymbol(entry),
color: String(entry.color || "#ffffff"),
}))
.filter((entry) => Boolean(entry.symbol));
const gridColumns = 5;
const transparentEntry = { symbol: ".", color: "#00000000" };
const transparentPadCount = (gridColumns - 1 - (normalizedColorEntries.length % gridColumns) + gridColumns) % gridColumns;
const paletteGridEntries = [
...normalizedColorEntries,
...Array.from({ length: transparentPadCount }, (_, idx) => ({ symbol: `__pad_${idx}`, color: "", isSpacer: true })),
transparentEntry,
];
const currentColorEntry = (activeSpritePaintSymbol === ".")
? transparentEntry
: (normalizedColorEntries.find((entry) => entry.symbol === activeSpritePaintSymbol) || normalizedColorEntries[0] || transparentEntry);
const displayColor = currentColorEntry.symbol === "." ? "transparent" : currentColorEntry.color;
const displaySymbol = currentColorEntry ? currentColorEntry.symbol : (activeSpritePaintSymbol || "?");
const painterWidth = activeType === "sprites" ? SPRITE_CANVAS_SIZE : selectedSpriteEditorSize.width;
const painterHeight = activeType === "sprites" ? SPRITE_CANVAS_SIZE : selectedSpriteEditorSize.height;
const paintAt = (x: number, y: number) => {
patchSelectedRecord((record) => paintSpriteCell(record, x, y, activeSpritePaintSymbol));
};
return (
<>
{!isAllRecordsSelected ? <h2>Selected Record</h2> : null}
{!isAllRecordsSelected && selectedRecord && activeEditTabs.length > 1 ? (
<div className="edit-page-tabs">
{activeEditTabs.map((tab) => (
<button
key={`edit-tab-${activeType}-${tab}`}
type="button"
className={`edit-tab ${activeEditTab === tab ? "is-active" : ""}`}
onClick={() => setActiveEditTabByType((prev) => ({ ...prev, [activeType]: tab }))}
>
{tab}
</button>
))}
</div>
) : null}
{!isAllRecordsSelected && !selectedRecord ? <p className="muted">Select or add a record to start editing.</p> : null}
{!isAllRecordsSelected && selectedRecord ? (
<div className="fields-grid">
{selectedFieldEntriesForTab.map(([key, value]) => {
const keyLower = String(key || "").toLowerCase();
if ((activeType === "sprites" || activeType === "tiles") && activeEditTab === "Data" && (key === "palette" || key === "rows")) {
return null;
}
if (activeType === "npc_templates" && activeEditTab === "General" && keyLower === "spriteid") {
return null;
}
if (activeType === "npc_templates" && activeEditTab === "General" && keyLower === "defaultdialogueid") {
const allDialogues = (() => {
const payload = contentDataByType.dialogues;
const raw = payload && Array.isArray(payload.dialogues) ? payload.dialogues as unknown[] : [];
return raw.filter((d): d is Record<string, unknown> => Boolean(d) && typeof d === "object" && !Array.isArray(d));
})();
const currentValue = String(selectedRecord.defaultDialogueId || "");
return (
<div key={key} className="field-row">
<label htmlFor="field-default-dialogue-id">Default Dialogue</label>
<select
id="field-default-dialogue-id"
value={currentValue}
onChange={(event) => handlePrimitiveFieldChange("defaultDialogueId", event.target.value)}
>
<option value="">None</option>
{allDialogues.map((dlg, idx) => {
const dlgId = String(dlg.id || idx);
const dlgName = String(dlg.name || dlgId);
return (
<option key={`dlg-opt-${dlgId}`} value={dlgId}>
{dlgName !== dlgId ? `${dlgName} (${dlgId})` : dlgId}
</option>
);
})}
</select>
</div>
);
}
if ((activeType === "npcs" && (activeEditTab === "General" || activeEditTab === "Overrides") && keyLower === "faction")
|| (activeType === "npc_templates" && activeEditTab === "General" && keyLower === "faction")) {
const factionField = "faction";
const currentFaction = String(selectedRecord[factionField] || "");
return (
<div key={key} className="field-row">
<label htmlFor="field-faction-id">Faction</label>
<select
id="field-faction-id"
value={currentFaction}
onChange={(event) => handlePrimitiveFieldChange(factionField, event.target.value)}
>
<option value="">None</option>
{npcFactionOptions.map((faction) => (
<option key={`faction-opt-${faction.id}`} value={faction.id}>
{faction.name ? `${faction.name} (${faction.id})` : faction.id}
</option>
))}
</select>
</div>
);
}
if (activeType === "npcs" && activeEditTab === "General" && keyLower === "position") {
const position = isPlainObject(selectedRecord.position) ? (selectedRecord.position as JsonObject) : {};
const posX = Number(position.x ?? 10);
const posY = Number(position.y ?? 10);
return (
<div key={key} className="field-row">
<label>{toFieldLabel(key)}</label>
<div className="grid-2">
<div>
<label htmlFor="field-position-x">X</label>
<input
id="field-position-x"
type="number"
value={Number.isFinite(posX) ? String(posX) : "10"}
onChange={(event) => handleNpcPositionFieldChange("x", event.target.value)}
/>
</div>
<div>
<label htmlFor="field-position-y">Y</label>
<input
id="field-position-y"
type="number"
value={Number.isFinite(posY) ? String(posY) : "10"}
onChange={(event) => handleNpcPositionFieldChange("y", event.target.value)}
/>
</div>
</div>
</div>
);
}
if (activeType === "factions" && keyLower === "color") {
const colorValue = normalizeHexColor(value);
return (
<div key={key} className="field-row">
<label htmlFor="field-faction-color">Color</label>
<div className="faction-color-row">
<input
id="field-faction-color"
type="color"
value={colorValue}
onChange={(event) => handlePrimitiveFieldChange("color", event.target.value)}
/>
<input
value={String(value === null ? "" : value || "")}
onChange={(event) => handlePrimitiveFieldChange("color", event.target.value)}
placeholder="#7aa2ff"
/>
</div>
</div>
);
}
if (Array.isArray(value) || (value !== null && typeof value === "object")) {
return (
<div key={key} className="field-row">
<label>{toFieldLabel(key)}</label>
<p className="muted">Complex value. Edit in Raw JSON below.</p>
</div>
);
}
if (typeof value === "boolean") {
return (
<div key={key} className="field-row">
<label htmlFor={`field-${key}`}>{toFieldLabel(key)}</label>
<select id={`field-${key}`} value={String(value)} onChange={(event) => handlePrimitiveFieldChange(key, event.target.value)}>
<option value="true">true</option>
<option value="false">false</option>
</select>
</div>
);
}
return (
<div key={key} className="field-row">
<label htmlFor={`field-${key}`}>{toFieldLabel(key)}</label>
<input
id={`field-${key}`}
type={typeof value === "number" ? "number" : "text"}
value={value === null ? "" : String(value)}
onChange={(event) => handlePrimitiveFieldChange(key, event.target.value)}
/>
</div>
);
})}
{(activeType === "npcs" && activeEditTab === "Overrides") || (activeType === "npc_templates" && activeEditTab === "General") ? (
<div key="sprite-picker" className="field-row">
<div
className="sprite-picker-header"
role="button"
tabIndex={0}
onClick={() => setSpriteSelectorOpen((prev) => !prev)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSpriteSelectorOpen((prev) => !prev); } }}
>
<span className="sprite-picker-header-label">{toFieldLabel("spriteId")}</span>
{(() => {
const activeSpriteId = String(selectedRecord.spriteId || "").trim();
const activeSpriteRecord = npcSpriteOptions.find((s) => String(s.id || "").trim() === activeSpriteId);
const previewUrl = activeSpriteRecord ? buildSpritePreviewDataUrl(activeSpriteRecord, 2) : null;
return activeSpriteId ? (
<span className="sprite-picker-header-preview">
{previewUrl ? <img className="sprite-thumb sprite-thumb-list" src={previewUrl} alt="" aria-hidden="true" /> : null}
<span className="sprite-picker-header-id">{activeSpriteId}</span>
</span>
) : <span className="sprite-picker-header-id muted">None selected</span>;
})()}
<span className="sprite-picker-header-chevron">{spriteSelectorOpen ? "▲" : "▼"}</span>
</div>
{spriteSelectorOpen ? (
<>
<input
className="npc-sprite-search"
placeholder="Search sprites by id or name"
value={npcSpriteSearchQuery}
onChange={(event) => setNpcSpriteSearchQuery(event.target.value)}
/>
<div className="npc-sprite-picker">
{filteredNpcSpriteOptions.map((spriteRecord, spriteIndex) => {
const spriteId = String(spriteRecord.id || `sprite_${spriteIndex}`).trim();
const spriteName = String(spriteRecord.name || spriteId || `Sprite ${spriteIndex + 1}`);
const previewUrl = buildSpritePreviewDataUrl(spriteRecord, 2);
return (
<button
key={`npc-sprite-option-${spriteId}-${spriteIndex}`}
type="button"
className={`npc-sprite-card ${String(selectedRecord.spriteId || "").trim() === spriteId ? "is-active" : ""}`}
onClick={() => { handlePrimitiveFieldChange("spriteId", spriteId); setSpriteSelectorOpen(false); }}
title={`${spriteId} - ${spriteName}`}
>
{previewUrl ? (
<img className="sprite-thumb sprite-thumb-list" src={previewUrl} alt="" aria-hidden="true" />
) : (
<span className="sprite-thumb sprite-thumb-list sprite-thumb-empty" aria-hidden="true" />
)}
<span className="npc-sprite-card-label">{spriteName}</span>
</button>
);
})}
</div>
{npcSpriteOptions.length === 0 ? <p className="muted">No sprites available yet. Add sprites under the Sprites content tab.</p> : null}
{npcSpriteOptions.length > 0 && filteredNpcSpriteOptions.length === 0 ? <p className="muted">No sprite matches that search.</p> : null}
</>
) : null}
</div>
) : null}
{(activeType === "sprites" || activeType === "tiles") && activeEditTab === "Information" ? (
<div className="field-row sprite-preview-field">
<label>Preview</label>
{selectedSpriteInfoPreviewUrl ? (
<img className="sprite-thumb sprite-thumb-info" src={selectedSpriteInfoPreviewUrl} alt="Sprite preview" />
) : (
<p className="muted">No sprite preview available yet. Add rows in the Data tab.</p>
)}
</div>
) : null}
{(activeType === "sprites" || activeType === "tiles") && activeEditTab === "Data" ? (
<div className="sprite-data-editor">
<div className="sprite-data-card">
<div className="sprite-data-head">
<h4>Paint Brush</h4>
</div>
<p className="muted">Select a color from the palette below to paint with.</p>
<div className="sprite-color-current">
<div className="sprite-color-current-label">Current Color</div>
<div className={`sprite-color-current-swatch ${displaySymbol === "." ? "is-transparent" : ""}`} style={{ backgroundColor: displayColor }} />
<div className="sprite-color-current-value">{displaySymbol}</div>
</div>
<div className="color-palette-grid5">
{paletteGridEntries.map((colorEntry, colorIndex) => {
if ("isSpacer" in colorEntry) {
return <span key={`sprite-palette-spacer-${colorIndex}`} className="color-pixel-spacer" aria-hidden="true" />;
}
const isTransparent = colorEntry.symbol === ".";
return (
<button
key={`sprite-palette-color-${colorIndex}`}
type="button"
className={`color-pixel-btn ${activeSpritePaintSymbol === colorEntry.symbol ? "is-active" : ""} ${isTransparent ? "is-transparent" : ""}`}
style={{ backgroundColor: isTransparent ? "transparent" : colorEntry.color }}
onClick={() => setActiveSpritePaintSymbol(colorEntry.symbol)}
title={isTransparent ? "Paint with . (transparent / no color)" : `Paint with ${colorEntry.symbol} - ${colorEntry.color}`}
>
<span className="color-pixel-label">{colorEntry.symbol}</span>
</button>
);
})}
</div>
<div
className="sprite-painter-grid"
style={{ gridTemplateColumns: `repeat(${painterWidth}, 30px)` }}
onPointerUp={() => {
isPointerPaintingRef.current = false;
}}
onPointerLeave={() => {
isPointerPaintingRef.current = false;
}}
>
{Array.from({ length: painterHeight }).map((_, y) => (
Array.from({ length: painterWidth }).map((_, x) => {
const symbol = getSpriteCellSymbol(selectedRecord, x, y);
const color = getSpritePalette(selectedRecord)[symbol] || "#00000000";
const patternColors = ["#c0c0c0", "#9a9a9a"];
const cellPattern = patternColors[(x + y) % patternColors.length];
return (
<button
key={`sprite-cell-${x}-${y}`}
type="button"
className={`sprite-cell ${symbol === "." ? "is-transparent" : ""}`}
style={{
["--cell-bg" as string]: cellPattern,
["--paint-color" as string]: symbol === "." ? "transparent" : color,
}}
onPointerDown={(event) => {
event.preventDefault();
isPointerPaintingRef.current = true;
paintAt(x, y);
}}
onPointerEnter={() => {
if (!isPointerPaintingRef.current) {
return;
}
paintAt(x, y);
}}
title={`(${x + 1},${y + 1}) '${symbol}'`}
/>
);
})
))}
</div>
</div>
</div>
) : null}
{selectedFieldEntriesForTab.length === 0 ? <p className="muted">No direct fields in this tab. Use Raw JSON if needed.</p> : null}
</div>
) : null}
</>
);
}

View file

@ -0,0 +1,374 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { JsonObject, JsonValue } from "../editorCore";
type ValueFilterOperator = "contains" | "equals" | "starts_with" | "is_empty" | "is_not_empty";
type ValueFilterRule = {
id: string;
key: string;
operator: ValueFilterOperator;
value: string;
};
type RecordListPanelProps = {
activeType: string;
records: JsonObject[];
selectedIndex: number;
isAllRecordsSelected: boolean;
hasPendingRecordChanges: boolean;
getRecordLabel: (record: JsonObject, index: number) => string;
buildSpritePreviewDataUrl: (record: JsonObject, pixelSize: number) => string | null;
normalizeHexColor: (value: JsonValue | undefined, fallback?: string) => string;
onSelectRecordIndex: (index: number) => void;
onCommitPendingRecordChanges: () => void;
onRevertPendingRecordChanges: () => void;
recordDraftError: string;
isLoading: boolean;
isSaving: boolean;
};
export default function RecordListPanel(props: RecordListPanelProps) {
const {
activeType,
records,
selectedIndex,
isAllRecordsSelected,
hasPendingRecordChanges,
getRecordLabel,
buildSpritePreviewDataUrl,
normalizeHexColor,
onSelectRecordIndex,
onCommitPendingRecordChanges,
onRevertPendingRecordChanges,
recordDraftError,
isLoading,
isSaving,
} = props;
const npcTemplateFilterKeys = useMemo(() => {
const preferredOrder = [
"id",
"name",
"description",
"title",
"faction",
"spriteId",
"defaultDialogueId",
"job",
"shopInventoryId",
"lootTableId",
];
if (activeType !== "npc_templates") {
return [] as string[];
}
return preferredOrder;
}, [activeType]);
const [recordSearchQuery, setRecordSearchQuery] = useState("");
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const [valueRules, setValueRules] = useState<ValueFilterRule[]>([]);
const [draftRuleKey, setDraftRuleKey] = useState<string>("");
const [draftRuleOperator, setDraftRuleOperator] = useState<ValueFilterOperator>("contains");
const [draftRuleValue, setDraftRuleValue] = useState("");
const filterDropdownRef = useRef<HTMLDivElement | null>(null);
const isNpcTemplateFilterMode = activeType === "npc_templates";
const activeRecordSearchQuery = isNpcTemplateFilterMode ? recordSearchQuery : "";
const activeValueRules = useMemo(() => (isNpcTemplateFilterMode ? valueRules : []), [isNpcTemplateFilterMode, valueRules]);
const effectiveDraftRuleKey = useMemo(() => {
if (!isNpcTemplateFilterMode) {
return "";
}
if (draftRuleKey && npcTemplateFilterKeys.includes(draftRuleKey)) {
return draftRuleKey;
}
return npcTemplateFilterKeys[0] || "";
}, [draftRuleKey, isNpcTemplateFilterMode, npcTemplateFilterKeys]);
const effectiveDraftRuleOperator = isNpcTemplateFilterMode ? draftRuleOperator : "contains";
const effectiveDraftRuleValue = isNpcTemplateFilterMode ? draftRuleValue : "";
const isFilterMenuVisible = isNpcTemplateFilterMode && isFilterMenuOpen;
useEffect(() => {
if (!isFilterMenuVisible) {
return;
}
function handleOutsideClick(event: MouseEvent): void {
if (!filterDropdownRef.current) {
return;
}
const target = event.target as Node | null;
if (target && filterDropdownRef.current.contains(target)) {
return;
}
setIsFilterMenuOpen(false);
}
window.addEventListener("mousedown", handleOutsideClick);
return () => window.removeEventListener("mousedown", handleOutsideClick);
}, [isFilterMenuVisible]);
const valueSuggestions = useMemo(() => {
if (!isNpcTemplateFilterMode || !effectiveDraftRuleKey) {
return [] as Array<{ value: string; count: number }>;
}
const countByValue = new Map<string, number>();
records.forEach((record) => {
const raw = record[effectiveDraftRuleKey];
if (Array.isArray(raw) || (raw !== null && typeof raw === "object")) {
return;
}
const nextValue = String(raw ?? "").trim();
if (!nextValue) {
return;
}
countByValue.set(nextValue, (countByValue.get(nextValue) || 0) + 1);
});
return Array.from(countByValue.entries())
.map(([value, count]) => ({ value, count }))
.sort((a, b) => (b.count - a.count) || a.value.localeCompare(b.value))
.slice(0, 8);
}, [effectiveDraftRuleKey, isNpcTemplateFilterMode, records]);
function matchesRule(record: JsonObject, rule: ValueFilterRule): boolean {
const raw = record[rule.key];
const normalized = (Array.isArray(raw) || (raw !== null && typeof raw === "object"))
? ""
: String(raw ?? "");
const source = normalized.toLowerCase();
const expected = rule.value.toLowerCase();
if (rule.operator === "is_empty") {
return source.trim().length === 0;
}
if (rule.operator === "is_not_empty") {
return source.trim().length > 0;
}
if (rule.operator === "equals") {
return source === expected;
}
if (rule.operator === "starts_with") {
return source.startsWith(expected);
}
return source.includes(expected);
}
const filteredRecords = useMemo(() => {
if (!isNpcTemplateFilterMode) {
return records.map((record, index) => ({ record, index }));
}
const query = activeRecordSearchQuery.trim().toLowerCase();
const keySet = new Set(npcTemplateFilterKeys);
const list = records.map((record, index) => ({ record, index }));
return list.filter(({ record }) => {
const passesSearch = !query || Object.entries(record || {}).some(([key, value]) => {
if (!keySet.has(key)) {
return false;
}
if (Array.isArray(value) || (value !== null && typeof value === "object")) {
return false;
}
return String(value ?? "").toLowerCase().includes(query);
});
if (!passesSearch) {
return false;
}
return activeValueRules.every((rule) => matchesRule(record, rule));
});
}, [activeRecordSearchQuery, activeValueRules, isNpcTemplateFilterMode, npcTemplateFilterKeys, records]);
function addValueRule(): void {
if (!isNpcTemplateFilterMode || !effectiveDraftRuleKey) {
return;
}
if (
effectiveDraftRuleOperator !== "is_empty"
&& effectiveDraftRuleOperator !== "is_not_empty"
&& !effectiveDraftRuleValue.trim()
) {
return;
}
setValueRules((prev) => [
...prev,
{
id: `rule_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
key: effectiveDraftRuleKey,
operator: effectiveDraftRuleOperator,
value: effectiveDraftRuleValue.trim(),
},
]);
setDraftRuleValue("");
}
function removeValueRule(id: string): void {
setValueRules((prev) => prev.filter((rule) => rule.id !== id));
}
function clearAllFilters(): void {
setRecordSearchQuery("");
setValueRules([]);
setDraftRuleOperator("contains");
setDraftRuleValue("");
}
function operatorLabel(operator: ValueFilterOperator): string {
if (operator === "starts_with") return "starts with";
if (operator === "is_empty") return "is empty";
if (operator === "is_not_empty") return "is not empty";
return operator;
}
return (
<aside className="record-list-panel">
<div className="records-head">
<h2>Records</h2>
{hasPendingRecordChanges ? (
<div className="record-actions">
<button type="button" className="success" onClick={onCommitPendingRecordChanges} disabled={Boolean(recordDraftError) || isLoading || isSaving}>Commit</button>
<button type="button" className="danger" onClick={onRevertPendingRecordChanges} disabled={isLoading || isSaving}>Revert</button>
</div>
) : null}
</div>
{activeType === "npc_templates" ? (
<div className="record-list-tools">
<input
type="text"
className="record-list-search"
placeholder="Search NPC records..."
value={activeRecordSearchQuery}
onChange={(event) => setRecordSearchQuery(event.target.value)}
/>
<div className="record-filter-dropdown" ref={filterDropdownRef}>
<button
type="button"
className="record-filter-toggle"
onClick={() => setIsFilterMenuOpen((prev) => !prev)}
>
Filters ({activeValueRules.length})
</button>
<button
type="button"
className="record-filter-clear"
onClick={clearAllFilters}
disabled={!activeRecordSearchQuery && activeValueRules.length === 0}
>
Clear
</button>
{isFilterMenuVisible ? (
<div className="record-filter-menu">
<div className="record-filter-section-title">Value Rule</div>
<div className="record-rule-builder">
<select value={effectiveDraftRuleKey} onChange={(event) => setDraftRuleKey(event.target.value)}>
{npcTemplateFilterKeys.map((key) => (
<option key={`rule-key-${key}`} value={key}>{key}</option>
))}
</select>
<select
value={effectiveDraftRuleOperator}
onChange={(event) => setDraftRuleOperator(event.target.value as ValueFilterOperator)}
>
<option value="contains">contains</option>
<option value="equals">equals</option>
<option value="starts_with">starts with</option>
<option value="is_empty">is empty</option>
<option value="is_not_empty">is not empty</option>
</select>
{effectiveDraftRuleOperator !== "is_empty" && effectiveDraftRuleOperator !== "is_not_empty" ? (
<input
type="text"
value={effectiveDraftRuleValue}
onChange={(event) => setDraftRuleValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addValueRule();
}
}}
placeholder="value"
/>
) : null}
<button type="button" className="record-rule-add-btn" onClick={addValueRule}>+ Add Rule</button>
</div>
{valueSuggestions.length > 0 && effectiveDraftRuleOperator !== "is_empty" && effectiveDraftRuleOperator !== "is_not_empty" ? (
<div className="record-suggestion-list">
{valueSuggestions.map((entry) => (
<button
key={`rule-suggestion-${entry.value}`}
type="button"
className="record-suggestion-chip"
onClick={() => setDraftRuleValue(entry.value)}
>
{entry.value} ({entry.count})
</button>
))}
</div>
) : null}
</div>
) : null}
</div>
</div>
) : null}
{activeType === "npc_templates" && activeValueRules.length > 0 ? (
<div className="active-rule-list">
{activeValueRules.map((rule) => (
<button
key={rule.id}
type="button"
className="active-rule-chip"
onClick={() => removeValueRule(rule.id)}
title="Remove rule"
>
{rule.key} {operatorLabel(rule.operator)}{rule.operator === "is_empty" || rule.operator === "is_not_empty" ? "" : ` ${rule.value}`} x
</button>
))}
</div>
) : null}
<div className="record-list">
<button
type="button"
className={`record-row ${isAllRecordsSelected ? "is-active" : ""} ${hasPendingRecordChanges && isAllRecordsSelected ? "has-pending" : ""}`}
onClick={() => onSelectRecordIndex(-1)}
>
All
</button>
{filteredRecords.length === 0 ? <p className="muted">No records match the current filters.</p> : null}
{filteredRecords.map(({ record, index }) => (
(() => {
const previewUrl = (activeType === "sprites" || activeType === "tiles") ? buildSpritePreviewDataUrl(record, 2) : null;
return (
<button
key={`${activeType}-${index}`}
type="button"
className={`record-row ${index === selectedIndex ? "is-active" : ""} ${hasPendingRecordChanges && index === selectedIndex ? "has-pending" : ""}`}
onClick={() => onSelectRecordIndex(index)}
>
{activeType === "sprites" || activeType === "tiles" ? (
<span className="record-row-with-thumb">
{previewUrl ? (
<img
className="sprite-thumb sprite-thumb-list"
src={previewUrl}
alt=""
aria-hidden="true"
/>
) : (
<span className="sprite-thumb sprite-thumb-list sprite-thumb-empty" aria-hidden="true" />
)}
<span>{getRecordLabel(record, index)}</span>
</span>
) : activeType === "factions" ? (
<span className="record-row-with-thumb">
<span className="faction-record-label" style={{ color: normalizeHexColor(record.color) }}>
{getRecordLabel(record, index)}
</span>
</span>
) : (
getRecordLabel(record, index)
)}
</button>
);
})()
))}
</div>
</aside>
);
}

View file

@ -0,0 +1,26 @@
type StatusFooterProps = {
status: string;
validationIssues: string[];
parsedJsonError: string;
recordDraftError: string;
};
export default function StatusFooter(props: StatusFooterProps) {
const { status, validationIssues, parsedJsonError, recordDraftError } = props;
return (
<div className="status-row">
<p className="status-text">{status}</p>
{validationIssues.length > 0 ? <p className="status-error">Validation issues: {validationIssues.length}</p> : null}
{validationIssues.length > 0 ? (
<ul className="validation-list">
{validationIssues.slice(0, 8).map((issue, index) => (
<li key={`${issue}-${index}`}>{issue}</li>
))}
</ul>
) : null}
{parsedJsonError ? <p className="status-error">JSON error: {parsedJsonError}</p> : null}
{recordDraftError ? <p className="status-error">Record draft error: {recordDraftError}</p> : null}
</div>
);
}

View file

@ -0,0 +1,88 @@
import type { ConfigTabLabel } from "../editorCore";
type TopNavTabsProps = {
contentTypes: string[];
configTabLabels: ConfigTabLabel[];
activeSection: "content" | "config";
activeType: string;
activeConfigTab: ConfigTabLabel;
hasPendingRecordChanges: boolean;
isLoading: boolean;
isSaving: boolean;
formatTypeLabel: (type: string) => string;
onError: (message: string) => void;
onSetActiveSection: (section: "content" | "config") => void;
onSetActiveType: (type: string) => void;
onSetActiveConfigTab: (label: ConfigTabLabel) => void;
};
export default function TopNavTabs(props: TopNavTabsProps) {
const {
contentTypes,
configTabLabels,
activeSection,
activeType,
activeConfigTab,
hasPendingRecordChanges,
isLoading,
isSaving,
formatTypeLabel,
onError,
onSetActiveSection,
onSetActiveType,
onSetActiveConfigTab,
} = props;
return (
<div className="top-nav-tabs">
<div className="nav-cluster">
<p className="nav-cluster-title">Content</p>
<div className="nav-tab-row">
{contentTypes.map((type) => (
<button
key={`content-tab-${type}`}
type="button"
className={`nav-pill ${activeSection === "content" && activeType === type ? "is-active" : ""}`}
onClick={() => {
if (hasPendingRecordChanges && type !== activeType) {
onError("Pending changes are unsaved");
return;
}
onError("");
onSetActiveSection("content");
onSetActiveType(type);
}}
disabled={isLoading || isSaving}
>
{formatTypeLabel(type)}
</button>
))}
</div>
</div>
<div className="nav-cluster">
<p className="nav-cluster-title">Configuration</p>
<div className="nav-tab-row">
{configTabLabels.map((label) => (
<button
key={`config-tab-${label}`}
type="button"
className={`nav-pill ${activeSection === "config" && activeConfigTab === label ? "is-active" : ""}`}
onClick={() => {
if (hasPendingRecordChanges) {
onError("Pending changes are unsaved");
return;
}
onError("");
onSetActiveSection("config");
onSetActiveConfigTab(label);
}}
>
{label}
</button>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,145 @@
import type {
CatalogEntry,
DialogueChoice,
DialogueFlowKind,
DialogueFlowStep,
DialogueSandbox,
JsonObject,
JsonValue,
} from "../editorCore";
export type ConditionValueRenderer = (conditionType: string, currentValue: string, onValueChange: (value: string) => void) => React.ReactNode;
export type ContentSectionContext = {
activeType: string;
setActiveType: (type: string) => void;
setActiveSection: (section: "content" | "config") => void;
records: JsonObject[];
selectedIndex: number;
isAllRecordsSelected: boolean;
hasPendingRecordChanges: boolean;
getRecordLabel: (record: JsonObject, index: number) => string;
buildSpritePreviewDataUrl: (record: JsonObject, pixelSize: number) => string | null;
normalizeHexColor: (value: JsonValue | undefined, fallback?: string) => string;
selectRecordIndex: (index: number) => void;
commitPendingRecordChanges: () => void;
revertPendingRecordChanges: () => void;
recordDraftError: string;
isLoading: boolean;
isSaving: boolean;
selectedRecord: JsonObject | null;
recordJsonDraft: string;
jsonText: string;
handleRawJsonEditorChange: (nextText: string) => void;
};
export type RecordEditorBaseContext = {
isAllRecordsSelected: boolean;
selectedRecord: JsonObject | null;
activeEditTabs: string[];
activeType: string;
activeEditTab: string;
setActiveEditTabByType: React.Dispatch<React.SetStateAction<Record<string, string>>>;
selectedFieldEntriesForTab: Array<[string, JsonValue]>;
isPlainObject: (value: JsonValue | undefined) => value is JsonObject;
toFieldLabel: (rawKey: string) => string;
handlePrimitiveFieldChange: (key: string, nextRaw: string) => void;
npcTownOptions: string[];
npcFactionOptions: Array<{ id: string; name: string }>;
handleNpcPositionFieldChange: (axis: "x" | "y", nextRaw: string) => void;
normalizeHexColor: (value: JsonValue | undefined, fallback?: string) => string;
npcSpriteSearchQuery: string;
setNpcSpriteSearchQuery: React.Dispatch<React.SetStateAction<string>>;
filteredNpcSpriteOptions: JsonObject[];
buildSpritePreviewDataUrl: (record: JsonObject, pixelSize: number) => string | null;
npcSpriteOptions: JsonObject[];
allNpcRecords: JsonObject[];
allNpcTemplateRecords: JsonObject[];
contentDataByType: Record<string, JsonObject>;
patchSelectedRecord: (mutator: (record: JsonObject) => JsonObject) => void;
activeSpritePaintSymbol: string;
setActiveSpritePaintSymbol: React.Dispatch<React.SetStateAction<string>>;
selectedSpriteEditorSize: { width: number; height: number };
getSpriteCellSymbol: (record: JsonObject, x: number, y: number) => string;
getSpritePalette: (record: JsonObject) => Record<string, string>;
paintSpriteCell: (record: JsonObject, x: number, y: number, symbol: string) => JsonObject;
colorPaletteEntries: CatalogEntry[];
};
export type ItemQuestAdvancedContext = {
activeType: string;
selectedRecord: JsonObject | null;
activeEditTab: string;
addItemActionEntry: () => void;
selectedItemActions: JsonObject[];
selectedIndex: number;
isStepCollapsed: (stepId: string) => boolean;
setStepCollapsed: (stepId: string, collapsed: boolean) => void;
getItemActionSummary: (actionEntry: JsonObject, actionIndex: number) => string;
moveItemActionEntry: (actionIndex: number, direction: -1 | 1) => void;
deleteItemActionEntry: (actionIndex: number) => void;
getPlainObjectArray: (value: JsonValue | undefined) => JsonObject[];
patchSelectedRecordArray: (key: string, updater: (entries: JsonObject[]) => JsonObject[]) => void;
patchItemActionFlowSteps: (actionIndex: number, updater: (steps: DialogueFlowStep[]) => DialogueFlowStep[]) => void;
createFlowStep: (kind: DialogueFlowKind, namespace?: string) => DialogueFlowStep;
FLOW_KIND_LABELS: Record<DialogueFlowKind, string>;
getFlowStepSummary: (step: DialogueFlowStep) => string;
conditionTypeOptions: string[];
getDefaultConditionValue: (conditionType: string) => string;
renderConditionValueField: ConditionValueRenderer;
patchSelectedRecord: (mutator: (record: JsonObject) => JsonObject) => void;
selectedQuestSteps: JsonObject[];
getQuestStepSummary: (step: JsonObject, stepIndex: number) => string;
moveQuestStepEntry: (stepIndex: number, direction: -1 | 1) => void;
deleteQuestStepEntry: (stepIndex: number) => void;
patchQuestSteps: (updater: (steps: JsonObject[]) => JsonObject[]) => void;
addQuestStepEntry: () => void;
};
export type NpcDialogueEditorContext = {
activeType: string;
selectedRecord: JsonObject | null;
activeEditTab: string;
addDialogueNode: () => void;
deleteDialogueNode: () => void;
selectedDialogueNode: JsonObject | null;
moveDialogueNode: (direction: -1 | 1) => void;
selectedDialogueNodeIndex: number;
dialogueNodes: JsonObject[];
setSelectedDialogueNodeIndex: React.Dispatch<React.SetStateAction<number>>;
selectedDialogueFieldEntries: Array<[string, JsonValue]>;
toFieldLabel: (rawKey: string) => string;
handleDialogueNodeFieldChange: (key: string, nextRaw: string) => void;
patchFlowSteps: (updater: (steps: DialogueFlowStep[]) => DialogueFlowStep[]) => void;
createFlowStep: (kind: DialogueFlowKind, namespace?: string) => DialogueFlowStep;
selectedFlowSteps: DialogueFlowStep[];
isStepCollapsed: (stepId: string) => boolean;
dropTargetStepId: string;
draggingStepId: string;
setDraggingStepId: React.Dispatch<React.SetStateAction<string>>;
setDropTargetStepId: React.Dispatch<React.SetStateAction<string>>;
moveFlowStepById: (sourceStepId: string, targetStepId: string) => void;
setStepCollapsed: (stepId: string, collapsed: boolean) => void;
FLOW_KIND_LABELS: Record<DialogueFlowKind, string>;
getFlowStepSummary: (step: DialogueFlowStep) => string;
moveFlowStepByDirection: (stepId: string, direction: -1 | 1) => void;
conditionTypeOptions: string[];
getDefaultConditionValue: (conditionType: string) => string;
renderConditionValueField: ConditionValueRenderer;
dialogueNodeIds: string[];
DIALOGUE_REACTION_TYPES: string[];
simSandbox: DialogueSandbox;
simCurrentNodeId: string;
simText: string;
simChoices: DialogueChoice[];
simFallbackNextId: string;
simEnded: boolean;
startSimulation: () => void;
updateSandbox: (nextSandbox: DialogueSandbox) => void;
chooseSimChoice: (choice: DialogueChoice) => void;
continueSimulation: () => void;
isLoading: boolean;
isSaving: boolean;
};
export type FullContentContext = ContentSectionContext & RecordEditorBaseContext & ItemQuestAdvancedContext & NpcDialogueEditorContext;

View file

@ -0,0 +1,359 @@
import { resolveUnifiedColorSymbol } from "../editorCore";
import type { JsonObject } from "../editorCore";
export const TILE_COLORS: Record<string, string> = {
"#": resolveUnifiedColorSymbol("L", "#3d4f6a"),
".": resolveUnifiedColorSymbol("1", "#12182a"),
"+": resolveUnifiedColorSymbol("K", "#7a5a2a"),
"N": resolveUnifiedColorSymbol("S", "#1d4ed8"),
"@": resolveUnifiedColorSymbol("J", "#065f46"),
"!": resolveUnifiedColorSymbol("O", "#991b1b"),
" ": "#060a14",
};
export const DEFAULT_TILE_COLOR = resolveUnifiedColorSymbol("C", "#4338ca");
export const DEFAULT_MAP_BACKGROUND_COLOR = "#060A14";
export type RoomLayerPayload = {
layer: number;
name?: string;
zIndex?: number;
rows: string[];
instanceIds: string[];
};
export type HeightLayerPatchPayload = {
id: string;
name?: string;
z: number;
x: number;
y: number;
rows: string[];
};
export type SpriteCatalogEntry = {
dataUrl: string | null;
spriteWidth: number;
spriteHeight: number;
opacity?: number;
};
export type TileCatalogEntry = {
id: string;
symbol: string;
name: string;
color: string;
dataUrl: string | null;
width: number;
height: number;
pixelScale?: number;
opacity?: number;
rows?: string[];
tags?: string[];
};
export type NpcOverlay = {
id: string;
layer: number;
name: string;
spriteId: string;
isPlacementSlot?: boolean;
x: number;
y: number;
dataUrl: string | null;
spriteWidth: number;
spriteHeight: number;
opacity?: number;
record: JsonObject;
};
export function getMapRows(record: JsonObject): string[] {
const rawRows = record.rows;
if (!Array.isArray(rawRows)) return [];
return rawRows.map((row) => String(row ?? ""));
}
export function getMapDims(record: JsonObject) {
const rows = getMapRows(record);
const height = typeof record.height === "number" ? record.height : rows.length;
const width = typeof record.width === "number"
? record.width
: rows.reduce((max, row) => Math.max(max, row.length), 0);
const tileSize = typeof record.tileSize === "number" ? record.tileSize : 32;
return {
rows,
width: Math.max(1, Number(width) || 1),
height: Math.max(1, Number(height) || 1),
tileSize: Math.max(8, Math.min(128, Number(tileSize) || 32)),
};
}
export function resizeRows(rows: string[], width: number, height: number, fillChar = "."): string[] {
return Array.from({ length: Math.max(0, height) }, (_, y) => {
const src = String(rows[y] ?? "");
if (src.length >= width) return src.slice(0, width);
return src + fillChar.repeat(Math.max(0, width - src.length));
});
}
export function normalizeMapBackgroundColor(value: unknown, fallback = DEFAULT_MAP_BACKGROUND_COLOR): string {
const raw = String(value || "").trim();
return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toUpperCase() : fallback;
}
export function getMapBackgroundTileId(record: JsonObject): string {
const topLevel = String(record.backgroundTileId || "").trim();
if (topLevel) {
return topLevel;
}
const rawTiles = (record as Record<string, unknown>).tiles;
if (!rawTiles || typeof rawTiles !== "object" || Array.isArray(rawTiles)) {
return "";
}
return String((rawTiles as Record<string, unknown>).backgroundTileId || "").trim();
}
export function parseRoomLayers(record: JsonObject, width: number, height: number): RoomLayerPayload[] {
const raw = record.roomLayers;
const parsed: RoomLayerPayload[] = Array.isArray(raw)
? raw.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
const obj = entry as Record<string, unknown>;
const layer = Number(obj.layer);
if (!Number.isFinite(layer)) return [];
const name = typeof obj.name === "string" ? String(obj.name).trim() : "";
const zIndex = Number(layer) === 0 ? 0 : Math.max(0, Math.min(5, Number(obj.zIndex) || 0));
const rows = Array.isArray(obj.rows) ? obj.rows.map((r) => String(r ?? "")) : [];
const instanceIds = Array.isArray(obj.instanceIds) ? obj.instanceIds.map((id) => String(id ?? "").trim()).filter(Boolean) : [];
return [{
layer,
name: name || undefined,
zIndex,
rows: resizeRows(rows, width, height, layer === 0 ? "." : " "),
instanceIds,
}];
})
: [];
const sorted = parsed.sort((a, b) => a.layer - b.layer);
if (!sorted.some((entry) => entry.layer === 0)) {
sorted.unshift({
layer: 0,
name: undefined,
zIndex: 0,
rows: resizeRows(getMapRows(record), width, height, "."),
instanceIds: [],
});
}
if (sorted.length === 0) {
return [{ layer: 0, name: undefined, zIndex: 0, rows: resizeRows(getMapRows(record), width, height, "."), instanceIds: [] }];
}
return sorted;
}
function trimHeightPatchRows(rows: string[], x: number, y: number): { x: number; y: number; rows: string[] } {
const normalizedRows = Array.isArray(rows)
? rows.map((row) => String(row || "").replace(/\./g, " ").replace(/\s+$/g, ""))
: [];
let top = 0;
let bottom = normalizedRows.length - 1;
while (top <= bottom && !normalizedRows[top].split("").some((ch) => ch !== " ")) {
top += 1;
}
while (bottom >= top && !normalizedRows[bottom].split("").some((ch) => ch !== " ")) {
bottom -= 1;
}
if (top > bottom) {
return {
x: Math.max(0, Number(x) || 0),
y: Math.max(0, Number(y) || 0),
rows: [],
};
}
const croppedRows = normalizedRows.slice(top, bottom + 1);
let left = Number.POSITIVE_INFINITY;
let right = -1;
croppedRows.forEach((row) => {
row.split("").forEach((ch, index) => {
if (ch === " ") {
return;
}
left = Math.min(left, index);
right = Math.max(right, index);
});
});
if (!Number.isFinite(left) || right < left) {
return {
x: Math.max(0, Number(x) || 0),
y: Math.max(0, Number(y) || 0),
rows: [],
};
}
return {
x: Math.max(0, Number(x) || 0) + left,
y: Math.max(0, Number(y) || 0) + top,
rows: croppedRows.map((row) => row.slice(left, right + 1).replace(/\s+$/g, "")),
};
}
function normalizeHeightPatchRowsToMapBounds(
rows: string[],
x: number,
y: number,
mapWidth: number,
mapHeight: number,
): { x: number; y: number; rows: string[] } {
const safeMapWidth = Math.max(1, Number(mapWidth) || 1);
const safeMapHeight = Math.max(1, Number(mapHeight) || 1);
let nextX = Math.floor(Number(x) || 0);
let nextY = Math.floor(Number(y) || 0);
let nextRows = Array.isArray(rows) ? rows.map((row) => String(row || "").replace(/\./g, " ")) : [];
if (nextY < 0) {
nextRows = nextRows.slice(-nextY);
nextY = 0;
}
if (nextX < 0) {
nextRows = nextRows.map((row) => row.slice(-nextX));
nextX = 0;
}
if (nextY >= safeMapHeight || nextX >= safeMapWidth) {
return { x: nextX, y: nextY, rows: [] };
}
nextRows = nextRows.slice(0, Math.max(0, safeMapHeight - nextY));
nextRows = nextRows.map((row) => row.slice(0, Math.max(0, safeMapWidth - nextX)));
return trimHeightPatchRows(nextRows, nextX, nextY);
}
export function parseHeightLayers(record: JsonObject, width: number, height: number): HeightLayerPatchPayload[] {
const raw = record.heightLayers;
if (!Array.isArray(raw)) {
return [];
}
const seenIds = new Set<string>();
return raw
.flatMap((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const obj = entry as Record<string, unknown>;
const fallbackId = "height_" + String(index + 1);
const id = String(obj.id || fallbackId).trim() || fallbackId;
if (seenIds.has(id)) {
return [];
}
seenIds.add(id);
const normalized = normalizeHeightPatchRowsToMapBounds(
Array.isArray(obj.rows) ? obj.rows.map((row) => String(row ?? "")) : [],
Number(obj.x) || 0,
Number(obj.y) || 0,
width,
height,
);
return [{
id,
name: typeof obj.name === "string" && String(obj.name).trim() ? String(obj.name).trim() : undefined,
z: Math.max(1, Math.floor(Number(obj.z) || 1)),
x: normalized.x,
y: normalized.y,
rows: normalized.rows,
}];
})
.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 buildSpriteCatalog(
allSpriteRecords: JsonObject[],
buildSpriteDataUrl: (record: JsonObject, pixelSize: number) => string | null,
): Record<string, SpriteCatalogEntry> {
const catalog: Record<string, SpriteCatalogEntry> = {};
allSpriteRecords.forEach((spriteRec) => {
const spriteId = String(spriteRec.id || "").trim();
if (!spriteId) {
return;
}
const rawPixelScale = Math.max(1, Number(spriteRec.pixelScale) || 1);
catalog[spriteId] = {
dataUrl: buildSpriteDataUrl(spriteRec, rawPixelScale),
spriteWidth: Math.max(8, (Number(spriteRec.width) || 16) * rawPixelScale),
spriteHeight: Math.max(8, (Number(spriteRec.height) || 16) * rawPixelScale),
opacity: Number.isFinite(Number(spriteRec.opacity)) ? Math.max(0, Math.min(1, Number(spriteRec.opacity))) : 1,
};
});
return catalog;
}
export function buildTileCatalogById(
allTileRecords: JsonObject[],
buildSpriteDataUrl: (record: JsonObject, pixelSize: number) => string | null,
): Record<string, TileCatalogEntry> {
const catalog: Record<string, TileCatalogEntry> = {};
allTileRecords.forEach((tileRec) => {
const id = String(tileRec.id || "").trim();
const symbol = String(tileRec.symbol || "").trim().charAt(0);
if (!id || !symbol) {
return;
}
const rawPixelScale = Math.max(1, Number(tileRec.pixelScale) || 1);
const rows = Array.isArray(tileRec.rows) ? tileRec.rows.map((row) => String(row || "")) : [];
const firstRow = rows[0] || "";
const firstSymbol = String(firstRow.charAt(0) || "").toUpperCase();
const fallbackColor = firstSymbol && firstSymbol !== "."
? resolveUnifiedColorSymbol(firstSymbol, DEFAULT_TILE_COLOR)
: DEFAULT_TILE_COLOR;
catalog[id] = {
id,
symbol,
name: String(tileRec.name || id),
color: fallbackColor,
dataUrl: buildSpriteDataUrl(tileRec, rawPixelScale),
width: Math.max(1, Number(tileRec.width) || 1),
height: Math.max(1, Number(tileRec.height) || 1),
pixelScale: rawPixelScale,
opacity: Number.isFinite(Number(tileRec.opacity)) ? Math.max(0, Math.min(1, Number(tileRec.opacity))) : 1,
rows,
tags: Array.isArray(tileRec.tags) ? tileRec.tags.map((tag) => String(tag || "").trim()).filter(Boolean) : [],
};
});
return catalog;
}
export function buildNpcOverlays(
mapId: string,
allNpcRecords: JsonObject[],
spriteCatalog: Record<string, SpriteCatalogEntry>,
): NpcOverlay[] {
const recordsToRender = allNpcRecords.filter((npc) => String(npc.mapId || "").trim() === mapId);
return recordsToRender
.map((npc) => {
const pos = (npc.position && typeof npc.position === "object" && !Array.isArray(npc.position))
? npc.position as Record<string, unknown>
: {};
const x = typeof pos.x === "number" ? pos.x : (typeof npc.x === "number" ? npc.x : 0);
const y = typeof pos.y === "number" ? pos.y : (typeof npc.y === "number" ? npc.y : 0);
const layer = Number(npc.layer ?? 0) || 0;
const spriteId = String(npc.spriteIdOverride || npc.spriteId || "").trim();
const spriteEntry = spriteCatalog[spriteId] || null;
const name = String(npc.nameOverride || npc.name || npc.id || "NPC");
return {
id: String(npc.id || ""),
layer,
name,
spriteId,
x,
y,
dataUrl: spriteEntry ? spriteEntry.dataUrl : null,
spriteWidth: spriteEntry ? spriteEntry.spriteWidth : 28,
spriteHeight: spriteEntry ? spriteEntry.spriteHeight : 28,
opacity: spriteEntry ? spriteEntry.opacity : 1,
record: { ...npc },
};
});
}

View file

@ -0,0 +1,118 @@
import { useState } from "react";
import type { JsonObject } from "../editorCore";
import {
getMapDims,
getMapRows,
resizeRows,
} from "./mapEditorShared";
export function MapLayoutPanel({
record,
patchSelectedRecord,
onReloadFromSource,
hasPendingRecordChanges,
isLoading,
}: {
record: JsonObject;
patchSelectedRecord: (mutator: (rec: JsonObject) => JsonObject) => void;
onReloadFromSource: () => Promise<void>;
hasPendingRecordChanges: boolean;
isLoading: boolean;
}) {
const { width, height, tileSize, rows } = getMapDims(record);
const rowsText = rows.join("\n");
const [isReloading, setIsReloading] = useState(false);
return (
<div className="map-layout-panel">
<div className="fields-grid">
<div className="field-row">
<label htmlFor="map-width">Width</label>
<input
id="map-width"
type="number"
min={1}
max={512}
value={width}
onChange={(event) => {
const nextWidth = Math.max(1, parseInt(event.target.value, 10) || 1);
patchSelectedRecord((rec) => ({
...rec,
width: nextWidth,
rows: resizeRows(getMapRows(rec), nextWidth, getMapDims(rec).height),
}));
}}
/>
</div>
<div className="field-row">
<label htmlFor="map-height">Height</label>
<input
id="map-height"
type="number"
min={1}
max={512}
value={height}
onChange={(event) => {
const nextHeight = Math.max(1, parseInt(event.target.value, 10) || 1);
patchSelectedRecord((rec) => ({
...rec,
height: nextHeight,
rows: resizeRows(getMapRows(rec), getMapDims(rec).width, nextHeight),
}));
}}
/>
</div>
<div className="field-row">
<label htmlFor="map-tile-size">Tile Size (px)</label>
<input
id="map-tile-size"
type="number"
min={8}
max={128}
step={8}
value={tileSize}
onChange={(event) => {
const nextSize = Math.max(8, Math.min(128, parseInt(event.target.value, 10) || 32));
patchSelectedRecord((rec) => ({ ...rec, tileSize: nextSize }));
}}
/>
</div>
</div>
<div className="field-row" style={{ marginTop: 8 }}>
<button
type="button"
className="mini-btn"
disabled={isReloading || isLoading || hasPendingRecordChanges}
onClick={() => {
setIsReloading(true);
onReloadFromSource().finally(() => setIsReloading(false));
}}
>
{isReloading ? "Reloading..." : "Reload Map Data"}
</button>
</div>
<div className="map-rows-field">
<label htmlFor="map-rows-textarea">Rows</label>
<p className="muted">One row string per line. Width/height will pad or trim rows.</p>
<textarea
id="map-rows-textarea"
className="map-rows-textarea"
value={rowsText}
spellCheck={false}
onChange={(event) => {
const nextRows = event.target.value.split("\n");
patchSelectedRecord((rec) => ({
...rec,
rows: nextRows,
height: nextRows.length,
}));
}}
/>
</div>
</div>
);
}

1567
src/editorCore.ts Normal file

File diff suppressed because it is too large Load diff

1337
src/index.css Normal file

File diff suppressed because it is too large Load diff

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,593 @@
import {
buildSpritesPayloadFromImagesPayload,
buildTilesPayloadFromImagesPayload,
buildDefaultRecord,
buildSpritePreviewDataUrl,
fetchJsonOrThrow,
normalizeNpcRecordForLoad,
type JsonObject,
} from "../editorCore";
import type {
HeightLayerPatchPayload,
NpcOverlay,
RoomLayerPayload,
SpriteCatalogEntry,
TileCatalogEntry,
} from "../components/mapEditorShared";
import {
TILE_COLORS,
buildSpriteCatalog,
buildTileCatalogById,
normalizeMapBackgroundColor,
resizeRows,
} from "../components/mapEditorShared";
import { normalizeImagesPayloadSnapshot } from "./graphicsDocumentHelpers";
export type MapEditorPopupBootstrap = {
mapId: string;
mapName: string;
width: number;
height: number;
tileSize: number;
backgroundTileId: string;
roomLayers: RoomLayerPayload[];
heightLayers: HeightLayerPatchPayload[];
tileColors: Record<string, string>;
baseRows: string[];
npcOverlays: NpcOverlay[];
contentByType: Record<string, JsonObject>;
spriteCatalog: Record<string, SpriteCatalogEntry>;
tileCatalogById: Record<string, TileCatalogEntry>;
defaultNpcTemplate: JsonObject;
apiBase: string;
backgroundColor: string;
heightBlurStep?: number;
editorUi?: Record<string, unknown>;
sourceMode?: "world";
worldId?: string;
worldName?: string;
worldChunkWidth?: number;
worldChunkHeight?: number;
worldOriginChunkX?: number;
worldOriginChunkY?: number;
worldChunkRadius?: number;
worldTileOffsetX?: number;
worldTileOffsetY?: number;
worldSpawnX?: number;
worldSpawnY?: number;
worldBookmarks?: Array<{ id: string; label: string; x: number; y: number }>;
sourceChunks?: Array<{ chunkX: number; chunkY: number }>;
};
declare global {
interface Window {
__NEW_RPG_MAP_EDITOR_BOOTSTRAPS__?: Record<string, MapEditorPopupBootstrap>;
}
}
const POPUP_BOOTSTRAP_STORAGE_KEY_PREFIX = "new-rpg-map-editor-bootstrap:";
const STANDALONE_WORLD_BOOTSTRAP_STORAGE_KEY_PREFIX = "new-rpg-map-editor-standalone-world-bootstrap:";
const DEFAULT_WORLD_CHUNK_RADIUS = 1;
const DEFAULT_HEIGHT_BLUR_STEP = 0.1;
function normalizeHeightBlurStep(value: unknown, fallback = DEFAULT_HEIGHT_BLUR_STEP): number {
const normalized = Number(value);
if (!Number.isFinite(normalized)) {
return fallback;
}
return Math.max(0, Math.min(1, normalized));
}
function normalizeBootstrapEditorUi(value: unknown): Record<string, unknown> {
const source = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
if (!source) {
return { panelLayouts: {} };
}
const panelLayouts = source.panelLayouts && typeof source.panelLayouts === "object" && !Array.isArray(source.panelLayouts)
? JSON.parse(JSON.stringify(source.panelLayouts))
: {};
return {
panelLayouts,
};
}
function hasMeaningfulBootstrapEditorUi(value: unknown): boolean {
const normalized = normalizeBootstrapEditorUi(value) as { panelLayouts?: Record<string, unknown> };
const panelLayouts = normalized.panelLayouts && typeof normalized.panelLayouts === "object" && !Array.isArray(normalized.panelLayouts)
? normalized.panelLayouts
: {};
return Object.keys(panelLayouts).length > 0;
}
function cloneBootstrap(bootstrap: MapEditorPopupBootstrap): MapEditorPopupBootstrap {
if (typeof structuredClone === "function") {
return structuredClone(bootstrap);
}
return JSON.parse(JSON.stringify(bootstrap)) as MapEditorPopupBootstrap;
}
function getBootstrapRegistry(hostWindow: Window): Record<string, MapEditorPopupBootstrap> {
if (!hostWindow.__NEW_RPG_MAP_EDITOR_BOOTSTRAPS__) {
hostWindow.__NEW_RPG_MAP_EDITOR_BOOTSTRAPS__ = {};
}
return hostWindow.__NEW_RPG_MAP_EDITOR_BOOTSTRAPS__;
}
function getPopupBootstrapStorageKey(token: string): string {
return POPUP_BOOTSTRAP_STORAGE_KEY_PREFIX + token;
}
function getStandaloneWorldBootstrapStorageKey(worldId: string): string {
return STANDALONE_WORLD_BOOTSTRAP_STORAGE_KEY_PREFIX + String(worldId || "").trim();
}
function readBootstrapFromOpener(token: string, popupWindow: Window): MapEditorPopupBootstrap | null {
try {
const opener = popupWindow.opener;
if (!opener || opener.closed) {
return null;
}
const registry = opener.__NEW_RPG_MAP_EDITOR_BOOTSTRAPS__;
const bootstrap = registry?.[token];
return bootstrap ? cloneBootstrap(bootstrap) : null;
} catch {
return null;
}
}
function cacheBootstrap(token: string, bootstrap: MapEditorPopupBootstrap, popupWindow: Window): void {
try {
popupWindow.sessionStorage.setItem(
getPopupBootstrapStorageKey(token),
JSON.stringify(bootstrap),
);
} catch {
// Ignore storage failures and keep the opener handoff path.
}
}
function readCachedBootstrap(token: string, popupWindow: Window): MapEditorPopupBootstrap | null {
try {
const raw = popupWindow.sessionStorage.getItem(getPopupBootstrapStorageKey(token));
if (!raw) {
return null;
}
return JSON.parse(raw) as MapEditorPopupBootstrap;
} catch {
return null;
}
}
export function createMapEditorPopupToken(): string {
return "map-editor-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10);
}
export function registerMapEditorPopupBootstrap(
token: string,
bootstrap: MapEditorPopupBootstrap,
hostWindow: Window = window,
): void {
getBootstrapRegistry(hostWindow)[token] = cloneBootstrap(bootstrap);
}
export function clearMapEditorPopupBootstrap(token: string, hostWindow: Window = window): void {
if (!token) {
return;
}
const registry = hostWindow.__NEW_RPG_MAP_EDITOR_BOOTSTRAPS__;
if (!registry) {
return;
}
delete registry[token];
if (Object.keys(registry).length === 0) {
delete hostWindow.__NEW_RPG_MAP_EDITOR_BOOTSTRAPS__;
}
}
export function loadMapEditorPopupBootstrap(
token: string,
popupWindow: Window = window,
): MapEditorPopupBootstrap | null {
if (!token) {
return null;
}
const openerBootstrap = readBootstrapFromOpener(token, popupWindow);
if (openerBootstrap) {
cacheBootstrap(token, openerBootstrap, popupWindow);
return openerBootstrap;
}
return readCachedBootstrap(token, popupWindow);
}
export function cacheStandaloneWorldEditorPopupBootstrap(
bootstrap: MapEditorPopupBootstrap,
popupWindow: Window = window,
): boolean {
const worldId = String(bootstrap?.worldId || bootstrap?.mapId || "").trim();
if (!worldId) {
return false;
}
try {
popupWindow.sessionStorage.setItem(
getStandaloneWorldBootstrapStorageKey(worldId),
JSON.stringify(cloneBootstrap(bootstrap)),
);
return true;
} catch {
return false;
}
}
export function readStandaloneWorldEditorPopupBootstrap(
requestedWorldId: string,
popupWindow: Window = window,
): MapEditorPopupBootstrap | null {
const worldId = String(requestedWorldId || "").trim();
if (!worldId) {
return null;
}
try {
const raw = popupWindow.sessionStorage.getItem(getStandaloneWorldBootstrapStorageKey(worldId));
if (!raw) {
return null;
}
return JSON.parse(raw) as MapEditorPopupBootstrap;
} catch {
return null;
}
}
function getContentApiUrl(pathname: string, apiBase: string): string {
return new URL(pathname, apiBase).toString();
}
function normalizeContentPayload(type: string, payload: JsonObject): JsonObject {
if (type !== "npcs" || !Array.isArray(payload.npcs)) {
return payload;
}
return {
...payload,
npcs: payload.npcs.map((record) => {
if (!record || typeof record !== "object" || Array.isArray(record)) {
return record;
}
return normalizeNpcRecordForLoad(record as JsonObject);
}),
};
}
async function fetchMapEditorContentBundle(apiBase: string): Promise<Record<string, JsonObject>> {
const typesPayload = await fetchJsonOrThrow<{ types?: string[] }>(getContentApiUrl("/api/types", apiBase));
const fallbackTypes = ["npcs", "npc_templates", "dialogues", "factions", "images"];
const requestedTypes = Array.isArray(typesPayload.types) && typesPayload.types.length > 0
? typesPayload.types
: fallbackTypes;
const normalizedTypes = Array.from(new Set(
requestedTypes
.map((type) => String(type || "").trim())
.filter((type) => type !== "tiles" && type !== "sprites")
.concat("images")
.filter(Boolean),
));
const payloads = await Promise.all(
normalizedTypes.map((type) => fetchJsonOrThrow<JsonObject>(getContentApiUrl(`/api/content/${type}`, apiBase))),
);
return normalizedTypes.reduce<Record<string, JsonObject>>((acc, type, index) => {
acc[type] = normalizeContentPayload(type, payloads[index] || {});
return acc;
}, {});
}
type WorldChunkRoutePayload = {
schemaVersion?: number;
worldId?: string;
chunkX?: number;
chunkY?: number;
width?: number;
height?: number;
backgroundTileId?: string;
roomLayers?: Array<Record<string, unknown>>;
heightLayers?: Array<Record<string, unknown>>;
instances?: Array<Record<string, unknown>>;
};
type WorldInfoRoutePayload = {
ok?: boolean;
world?: {
schemaVersion?: number;
id?: string;
name?: string;
chunkWidth?: number;
chunkHeight?: number;
tileSize?: number;
backgroundColor?: string;
defaultBackgroundTileId?: string;
heightBlurStep?: number;
heightDetailStep?: number;
editorUi?: Record<string, unknown>;
spawn?: { x?: number; y?: number };
editor?: { defaultZoom?: number; gridVisible?: boolean };
};
bookmarks?: {
schemaVersion?: number;
worldId?: string;
bookmarks?: Array<{ id?: string; label?: string; x?: number; y?: number }>;
};
};
type WorldChunksRoutePayload = {
ok?: boolean;
world?: WorldInfoRoutePayload["world"];
center?: { chunkX?: number; chunkY?: number };
radius?: number;
chunks?: WorldChunkRoutePayload[];
};
function createFilledRows(width: number, height: number, fillChar: string): string[] {
return Array.from({ length: Math.max(1, height) }, () => String(fillChar || " ").repeat(Math.max(1, width)));
}
function writeRowSegment(rows: string[], y: number, x: number, segment: string): void {
if (!Array.isArray(rows) || y < 0 || y >= rows.length || !segment) {
return;
}
const sourceRow = String(rows[y] || "");
const safeX = Math.max(0, x);
const padded = sourceRow.length >= safeX
? sourceRow
: (sourceRow + " ".repeat(Math.max(0, safeX - sourceRow.length)));
const before = padded.slice(0, safeX);
const afterStart = safeX + segment.length;
const after = afterStart < padded.length ? padded.slice(afterStart) : "";
rows[y] = before + segment + after;
}
function composeWorldRoomLayers(
chunks: WorldChunkRoutePayload[],
chunkWidth: number,
chunkHeight: number,
originChunkX: number,
originChunkY: number,
worldWidth: number,
worldHeight: number,
): RoomLayerPayload[] {
const layerMap = new Map<number, RoomLayerPayload>();
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) as RoomLayerPayload;
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;
}
writeRowSegment(targetLayer.rows, targetY, offsetX, row.slice(0, worldWidth - offsetX));
});
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) => a.layer - b.layer);
}
function composeWorldHeightLayers(
chunks: WorldChunkRoutePayload[],
chunkWidth: number,
chunkHeight: number,
originChunkX: number,
originChunkY: number,
): HeightLayerPatchPayload[] {
const patches: HeightLayerPatchPayload[] = [];
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 buildNpcOverlaysFromWorldChunks(
chunks: WorldChunkRoutePayload[],
spriteCatalog: Record<string, SpriteCatalogEntry>,
chunkWidth: number,
chunkHeight: number,
originChunkX: number,
originChunkY: number,
): NpcOverlay[] {
return 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): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
.map((entry) => {
const record = entry.record && typeof entry.record === "object" && !Array.isArray(entry.record)
? { ...(entry.record as JsonObject) }
: {} as JsonObject;
const spriteId = String(record.spriteId || entry.spriteId || "").trim();
const spriteEntry = spriteCatalog[spriteId] || null;
const localX = Math.max(0, Number(entry.x) || 0);
const localY = Math.max(0, Number(entry.y) || 0);
const overlayX = offsetX + localX;
const overlayY = offsetY + localY;
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 async function loadStandaloneWorldEditorPopupBootstrap(
requestedWorldId: string,
apiBase: string = window.location.origin,
): Promise<MapEditorPopupBootstrap> {
const worldId = String(requestedWorldId || "").trim();
if (!worldId) {
throw new Error("A world id is required.");
}
const cachedBootstrap = readStandaloneWorldEditorPopupBootstrap(worldId, window);
try {
const contentByType = await fetchMapEditorContentBundle(apiBase);
const worldInfoPayload = await fetchJsonOrThrow<WorldInfoRoutePayload>(getContentApiUrl(`/api/world/${encodeURIComponent(worldId)}`, apiBase));
const world = worldInfoPayload?.world;
if (!world) {
throw new Error(`World ${worldId} was not found.`);
}
const bookmarks = Array.isArray(worldInfoPayload?.bookmarks?.bookmarks)
? worldInfoPayload.bookmarks.bookmarks.map((entry, index) => ({
id: String(entry?.id || `bookmark_${index + 1}`).trim() || `bookmark_${index + 1}`,
label: String(entry?.label || entry?.id || `Bookmark ${index + 1}`).trim() || `Bookmark ${index + 1}`,
x: Math.floor(Number(entry?.x) || 0),
y: Math.floor(Number(entry?.y) || 0),
}))
: [];
const chunkWidth = Math.max(1, Number(world.chunkWidth) || DEFAULT_WORLD_CHUNK_RADIUS * 32);
const chunkHeight = Math.max(1, Number(world.chunkHeight) || DEFAULT_WORLD_CHUNK_RADIUS * 32);
const spawnX = Math.floor(Number(world.spawn?.x) || 0);
const spawnY = Math.floor(Number(world.spawn?.y) || 0);
const initialViewBookmark = bookmarks[0] || null;
const initialViewTileX = initialViewBookmark ? Math.floor(Number(initialViewBookmark.x) || 0) : spawnX;
const initialViewTileY = initialViewBookmark ? Math.floor(Number(initialViewBookmark.y) || 0) : spawnY;
const centerChunkX = Math.floor(initialViewTileX / chunkWidth);
const centerChunkY = Math.floor(initialViewTileY / chunkHeight);
const chunkRadius = DEFAULT_WORLD_CHUNK_RADIUS;
const chunksPayload = await fetchJsonOrThrow<WorldChunksRoutePayload>(
getContentApiUrl(
`/api/world/${encodeURIComponent(worldId)}/chunks?chunkX=${centerChunkX}&chunkY=${centerChunkY}&radius=${chunkRadius}&createIfMissing=1`,
apiBase,
),
);
const chunks = Array.isArray(chunksPayload?.chunks) ? chunksPayload.chunks : [];
const originChunkX = centerChunkX - chunkRadius;
const originChunkY = centerChunkY - chunkRadius;
const composedWidth = ((chunkRadius * 2) + 1) * chunkWidth;
const composedHeight = ((chunkRadius * 2) + 1) * chunkHeight;
const imagesPayload = normalizeImagesPayloadSnapshot(contentByType.images || { schemaVersion: 1, images: [] });
contentByType.images = imagesPayload;
const spritePayload = buildSpritesPayloadFromImagesPayload(imagesPayload);
const spriteRecords = spritePayload && Array.isArray(spritePayload.sprites)
? spritePayload.sprites.filter((entry): entry is JsonObject => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
: [];
const tilePayload = buildTilesPayloadFromImagesPayload(imagesPayload);
const tileRecords = tilePayload && Array.isArray(tilePayload.tiles)
? tilePayload.tiles.filter((entry): entry is JsonObject => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
: [];
const npcsPayload = contentByType.npcs;
const allNpcRecords = npcsPayload && Array.isArray(npcsPayload.npcs)
? npcsPayload.npcs.filter((entry): entry is JsonObject => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
: [];
const spriteCatalog = buildSpriteCatalog(spriteRecords, buildSpritePreviewDataUrl);
const tileCatalogById = buildTileCatalogById(tileRecords, buildSpritePreviewDataUrl);
const roomLayers = composeWorldRoomLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY, composedWidth, composedHeight);
const heightLayers = composeWorldHeightLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY);
const npcOverlays = buildNpcOverlaysFromWorldChunks(chunks, spriteCatalog, chunkWidth, chunkHeight, originChunkX, originChunkY);
const bootstrap: MapEditorPopupBootstrap = {
mapId: worldId,
mapName: String(world.name || worldId),
width: composedWidth,
height: composedHeight,
tileSize: Math.max(8, Number(world.tileSize) || 32),
backgroundTileId: String(world.defaultBackgroundTileId || "").trim(),
roomLayers,
heightLayers,
tileColors: TILE_COLORS,
baseRows: resizeRows(roomLayers.find((layer) => Number(layer.layer) === 0)?.rows || [], composedWidth, composedHeight, "."),
npcOverlays,
contentByType,
spriteCatalog,
tileCatalogById,
defaultNpcTemplate: normalizeNpcRecordForLoad(buildDefaultRecord("npcs", allNpcRecords)),
apiBase,
backgroundColor: normalizeMapBackgroundColor((world as Record<string, unknown>).backgroundColor || "#060A14"),
heightBlurStep: normalizeHeightBlurStep(world.heightBlurStep ?? world.heightDetailStep),
editorUi: hasMeaningfulBootstrapEditorUi(world?.editorUi)
? normalizeBootstrapEditorUi(world?.editorUi)
: normalizeBootstrapEditorUi(cachedBootstrap?.editorUi),
sourceMode: "world",
worldId,
worldName: String(world.name || worldId),
worldChunkWidth: chunkWidth,
worldChunkHeight: chunkHeight,
worldOriginChunkX: originChunkX,
worldOriginChunkY: originChunkY,
worldChunkRadius: chunkRadius,
worldTileOffsetX: originChunkX * chunkWidth,
worldTileOffsetY: originChunkY * chunkHeight,
worldSpawnX: spawnX,
worldSpawnY: spawnY,
worldBookmarks: bookmarks,
sourceChunks: chunks.map((chunk) => ({
chunkX: Math.floor(Number(chunk.chunkX) || 0),
chunkY: Math.floor(Number(chunk.chunkY) || 0),
})),
};
cacheStandaloneWorldEditorPopupBootstrap(bootstrap, window);
return bootstrap;
} catch (error) {
if (cachedBootstrap) {
return cachedBootstrap;
}
throw error;
}
}

View file

@ -0,0 +1,470 @@
import { clampFloatingWindowRect } from "./floatingWindowUtils";
const CHANGELOG_SPLASH_WINDOW_KEY = "changelogSplash";
const CHANGELOG_SPLASH_VERSION = "2026-06-22-world-editor-release-v6";
const CHANGELOG_SPLASH_STORAGE_KEY = `content-editor-v2:map-editor:changelog-seen:${CHANGELOG_SPLASH_VERSION}`;
const DEFAULT_WIDTH = 700;
const DEFAULT_HEIGHT = 560;
const MIN_WIDTH = 520;
const MIN_HEIGHT = 360;
type LayerRect = {
left: number;
top: number;
width: number;
height: number;
};
type PersistedWindowState = {
visible?: boolean;
x?: number;
y?: number;
width?: number;
height?: number;
};
type ControllerScope = {
uiScope?: {
toolWindowLayerEl?: HTMLElement | null;
editorBodyEl?: HTMLElement | null;
};
sessionScope?: {
getPersistedToolWindowState?: (key: string) => PersistedWindowState | null;
setPersistedToolWindowState?: (key: string, value: Record<string, unknown>) => void;
persistPopupSessionLayout?: () => void;
};
persistPopupSessionLayout?: () => void;
};
type SplashState = {
visible: boolean;
x: number;
y: number;
width: number;
height: number;
shellEl: HTMLDivElement | null;
titleEl: HTMLDivElement | null;
metaEl: HTMLDivElement | null;
sectionListEl: HTMLDivElement | null;
actionBtnEl: HTMLButtonElement | null;
resizeEl: HTMLDivElement | null;
nextZIndex: number;
};
type OpenOptions = {
center?: boolean;
markSeen?: boolean;
};
type ChangelogItem = string | {
text: string;
note?: string;
};
const CHANGELOG_SECTIONS = [
{
title: "World Rendering",
items: [
"Added live image opacity support in the renderer for both tiles and entity sprites.",
{
text: "Fixed world painting placement drift on very wide displays.",
note: "For the many of you out there using this on superultrawide monitors.",
},
],
},
{
title: "Graphics & Animation",
items: [
"Added animation frame timelines to the Graphic Painter.",
"Added frame duplication, enable/disable, delete, drag reorder, and default-frame selection.",
"Added animation speed and playback settings to graphics data.",
"Added animation preview support from the Graphic Painter.",
],
},
{
title: "World Overview",
items: [
"Added chunk move, duplicate, rotate, flip, and delete workflows from the world overview.",
"Added safer chunk management feedback and confirmation flows.",
],
},
{
title: "Editor Stability",
items: [
"Fixed unified graphics conversion so saved image properties flow correctly into runtime tiles and sprites.",
"Improved live refresh behavior for renderer-facing graphic updates.",
],
},
{
title: "TBI (To Be Implemented [Or thought about really hard])",
items: [
"Image animations on renderer with hotkey toggle and engine override toggle.",
"Add premade frame duplication effects, like duplicate and shift by direction.",
"Add animation effects that change opacity over time.",
"Add animation effects that shift saturation over time.",
"Work on a system to use an image with animation frames as a tile strip, placing the instance once but specifying which subimage to use.",
"Add an elevation system where some tiles can appear at different z-indexes and show a different subframe.",
"Allow unsnapped tile placement, possibly via hotkey, writing into a chunk patch instead of chunk rows.",
"Explore whether unsnapped placement should be true free placement or an additional sub-layer drawn over an existing layer.",
"Add custom prompts for text input and confirmation dialogs.",
"Prototype terrain painters for meta chunk painting and tile replacement, like rivers, mountains, and woods.",
"Talk to Justin more about zone and subzone music regions via chunk painting.",
"Autotilers.",
"Prefab stamps.",
],
},
] as const;
function clampWindowRect(layerRect: LayerRect, left: number, top: number, width: number, height: number) {
return clampFloatingWindowRect(layerRect, left, top, width, height, MIN_WIDTH, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT);
}
export function createChangelogSplashWindowController(scope: ControllerScope) {
let initialized = false;
const uiScope = (scope.uiScope || scope) as NonNullable<ControllerScope["uiScope"]>;
const sessionScope = (scope.sessionScope || scope) as NonNullable<ControllerScope["sessionScope"]>;
const persistedState = typeof sessionScope.getPersistedToolWindowState === "function"
? sessionScope.getPersistedToolWindowState(CHANGELOG_SPLASH_WINDOW_KEY)
: null;
const state: SplashState = {
visible: false,
x: Number(persistedState?.x) || 88,
y: Number(persistedState?.y) || 56,
width: Number(persistedState?.width) || DEFAULT_WIDTH,
height: Number(persistedState?.height) || DEFAULT_HEIGHT,
shellEl: null,
titleEl: null,
metaEl: null,
sectionListEl: null,
actionBtnEl: null,
resizeEl: null,
nextZIndex: 132,
};
function getLayerRect(): LayerRect {
return uiScope.toolWindowLayerEl?.getBoundingClientRect() || uiScope.editorBodyEl?.getBoundingClientRect() || {
left: 0,
top: 0,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
};
}
function persistState() {
if (typeof sessionScope.setPersistedToolWindowState === "function") {
sessionScope.setPersistedToolWindowState(CHANGELOG_SPLASH_WINDOW_KEY, {
visible: state.visible === true,
mode: "floating",
x: state.x,
y: state.y,
width: state.width,
height: state.height,
order: 994,
});
}
scope.persistPopupSessionLayout?.();
sessionScope.persistPopupSessionLayout?.();
}
function markSeen() {
try {
window.localStorage.setItem(CHANGELOG_SPLASH_STORAGE_KEY, "1");
} catch {
// Ignore localStorage write errors and keep the splash functional.
}
}
function hasSeenCurrentVersion() {
try {
return window.localStorage.getItem(CHANGELOG_SPLASH_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function focusWindow() {
if (!state.shellEl || state.visible !== true) {
return;
}
state.nextZIndex += 1;
state.shellEl.style.zIndex = String(state.nextZIndex);
state.shellEl.classList.add("is-focused");
}
function clearFocus() {
state.shellEl?.classList.remove("is-focused");
}
function applyWindowRect() {
if (!state.shellEl) {
return;
}
state.shellEl.style.left = `${Math.round(state.x)}px`;
state.shellEl.style.top = `${Math.round(state.y)}px`;
state.shellEl.style.width = `${Math.round(state.width)}px`;
state.shellEl.style.height = `${Math.round(state.height)}px`;
}
function centerWindow() {
const layerRect = getLayerRect();
const centeredLeft = Math.max(0, ((Number(layerRect.width) || DEFAULT_WIDTH) - state.width) / 2);
const centeredTop = Math.max(0, ((Number(layerRect.height) || DEFAULT_HEIGHT) - state.height) / 2);
const nextRect = clampWindowRect(layerRect, centeredLeft, centeredTop, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
}
function refresh() {
if (state.titleEl) {
state.titleEl.textContent = "What's New";
}
if (state.metaEl) {
state.metaEl.textContent = `Release ${CHANGELOG_SPLASH_VERSION}`;
}
if (!state.sectionListEl) {
return;
}
state.sectionListEl.innerHTML = "";
CHANGELOG_SECTIONS.forEach((section) => {
const sectionEl = document.createElement("section");
sectionEl.className = "changelog-splash-section";
const headingEl = document.createElement("h3");
headingEl.className = "changelog-splash-section-title";
headingEl.textContent = section.title;
const listEl = document.createElement("ul");
listEl.className = "changelog-splash-bullets";
section.items.forEach((item) => {
const itemEl = document.createElement("li");
const normalizedItem: ChangelogItem = item;
if (typeof normalizedItem === "string") {
itemEl.textContent = normalizedItem;
} else {
const textEl = document.createElement("div");
textEl.textContent = normalizedItem.text;
itemEl.appendChild(textEl);
if (normalizedItem.note) {
const noteEl = document.createElement("div");
noteEl.className = "changelog-splash-bullet-note";
noteEl.textContent = normalizedItem.note;
itemEl.appendChild(noteEl);
}
}
listEl.appendChild(itemEl);
});
sectionEl.appendChild(headingEl);
sectionEl.appendChild(listEl);
state.sectionListEl?.appendChild(sectionEl);
});
}
function ensureShell() {
if (state.shellEl && state.shellEl.isConnected) {
return state.shellEl;
}
const shellEl = document.createElement("div");
shellEl.className = "tool-popout-window changelog-splash-window hidden";
const titlebarEl = document.createElement("div");
titlebarEl.className = "tool-popout-titlebar";
titlebarEl.innerHTML =
`<div class="tool-popout-title">What's New</div>` +
`<div class="tool-popout-hint">Shown once per release</div>` +
`<button class="tool-popout-close-btn" type="button" aria-label="Close changelog">X</button>`;
const bodyEl = document.createElement("div");
bodyEl.className = "tool-popout-body";
const cardEl = document.createElement("div");
cardEl.className = "changelog-splash-card";
const heroEl = document.createElement("div");
heroEl.className = "changelog-splash-hero";
const kickerEl = document.createElement("div");
kickerEl.className = "changelog-splash-kicker";
kickerEl.textContent = "Mid-release update";
const titleEl = document.createElement("div");
titleEl.className = "changelog-splash-title";
const metaEl = document.createElement("div");
metaEl.className = "changelog-splash-meta";
heroEl.appendChild(kickerEl);
heroEl.appendChild(titleEl);
heroEl.appendChild(metaEl);
const sectionListEl = document.createElement("div");
sectionListEl.className = "changelog-splash-list";
const footerEl = document.createElement("div");
footerEl.className = "changelog-splash-footer";
const footnoteEl = document.createElement("div");
footnoteEl.className = "changelog-splash-footnote";
footnoteEl.textContent = "Major features and fixes only. Removed experiments are intentionally omitted.";
const actionBtnEl = document.createElement("button");
actionBtnEl.type = "button";
actionBtnEl.className = "mini-btn";
actionBtnEl.textContent = "Let's go";
actionBtnEl.addEventListener("click", () => {
close();
});
footerEl.appendChild(footnoteEl);
footerEl.appendChild(actionBtnEl);
cardEl.appendChild(heroEl);
cardEl.appendChild(sectionListEl);
cardEl.appendChild(footerEl);
bodyEl.appendChild(cardEl);
const resizeEl = document.createElement("div");
resizeEl.className = "tool-popout-resize";
const closeBtnEl = titlebarEl.querySelector<HTMLButtonElement>(".tool-popout-close-btn");
shellEl.appendChild(titlebarEl);
shellEl.appendChild(bodyEl);
shellEl.appendChild(resizeEl);
shellEl.addEventListener("pointerdown", () => {
focusWindow();
});
titlebarEl.addEventListener("pointerdown", (event: PointerEvent) => {
if (closeBtnEl && event.target instanceof Node && closeBtnEl.contains(event.target)) {
return;
}
if (event.button !== 0) {
return;
}
event.preventDefault();
focusWindow();
const layerRect = getLayerRect();
const originLeft = Number(state.x) || 0;
const originTop = Number(state.y) || 0;
const startX = event.clientX;
const startY = event.clientY;
const move = (moveEvent: PointerEvent) => {
const nextRect = clampWindowRect(
layerRect,
originLeft + (moveEvent.clientX - startX),
originTop + (moveEvent.clientY - startY),
state.width,
state.height,
);
state.x = nextRect.left;
state.y = nextRect.top;
applyWindowRect();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
closeBtnEl?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
close();
});
resizeEl.addEventListener("pointerdown", (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
focusWindow();
const layerRect = getLayerRect();
const startX = event.clientX;
const startY = event.clientY;
const originWidth = Number(state.width) || DEFAULT_WIDTH;
const originHeight = Number(state.height) || DEFAULT_HEIGHT;
const move = (moveEvent: PointerEvent) => {
const nextRect = clampWindowRect(
layerRect,
state.x,
state.y,
Math.max(MIN_WIDTH, originWidth + (moveEvent.clientX - startX)),
Math.max(MIN_HEIGHT, originHeight + (moveEvent.clientY - startY)),
);
state.width = nextRect.width;
state.height = nextRect.height;
applyWindowRect();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
state.shellEl = shellEl;
state.titleEl = titleEl;
state.metaEl = metaEl;
state.sectionListEl = sectionListEl;
state.actionBtnEl = actionBtnEl;
state.resizeEl = resizeEl;
uiScope.toolWindowLayerEl?.appendChild(shellEl);
applyWindowRect();
shellEl.classList.toggle("hidden", state.visible !== true);
refresh();
return shellEl;
}
function open(options: OpenOptions = {}) {
ensureShell();
if (options.center === true) {
centerWindow();
} else {
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
}
state.visible = true;
if (options.markSeen !== false) {
markSeen();
}
refresh();
state.shellEl?.classList.remove("hidden");
applyWindowRect();
focusWindow();
persistState();
return true;
}
function close() {
state.visible = false;
clearFocus();
state.shellEl?.classList.add("hidden");
persistState();
return true;
}
function maybeOpenForCurrentVersion() {
if (hasSeenCurrentVersion()) {
return false;
}
return open({ markSeen: true, center: true });
}
function initialize() {
if (initialized) {
return;
}
initialized = true;
ensureShell();
window.addEventListener("resize", () => {
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
applyWindowRect();
persistState();
});
state.visible = false;
state.shellEl?.classList.add("hidden");
}
return {
initialize,
open,
close,
refresh,
maybeOpenForCurrentVersion,
isOpen: () => state.visible === true,
version: CHANGELOG_SPLASH_VERSION,
};
}

View file

@ -0,0 +1,294 @@
export function menuLabel(text: unknown) {
return {
kind: "label",
text: String(text || ""),
};
}
export function menuSeparator() {
return {
kind: "separator",
};
}
function isIconPresentation(options: unknown) {
return String((options as { presentation?: string } | null | undefined)?.presentation || "").trim().toLowerCase() === "icon";
}
function hasExplicitIconPanelLayout(panel: HTMLDivElement | null | undefined) {
if (!panel?.classList) {
return false;
}
return (
panel.classList.contains("at-tooltip-icon-stack-panel")
|| panel.classList.contains("at-tooltip-icon-row-panel")
|| panel.classList.contains("at-tooltip-icon-grid-panel")
);
}
function applyDefaultIconPanelLayout(
panel: HTMLDivElement | null | undefined,
items: Array<Record<string, unknown>> | null | undefined,
) {
if (!panel || hasExplicitIconPanelLayout(panel) || !Array.isArray(items) || items.length <= 0) {
return;
}
const interactiveItems = items.filter((item) => {
const kind = String(item?.kind || "").trim().toLowerCase();
return kind === "item" || kind === "submenu";
});
if (interactiveItems.length <= 0) {
return;
}
const iconOnlyMenu = interactiveItems.every((item) => isIconPresentation(item?.options));
if (iconOnlyMenu) {
panel.classList.add("at-tooltip-icon-stack-panel");
}
}
function resolveChildPanelLayoutClass(options: {
presentation?: string;
layout?: "horizontal" | "vertical";
} | null | undefined) {
if (options?.layout === "horizontal") {
return "at-tooltip-icon-row-panel";
}
if (options?.layout === "vertical") {
return "at-tooltip-icon-stack-panel";
}
if (isIconPresentation(options)) {
return "at-tooltip-icon-stack-panel";
}
return "";
}
export function menuItem(
innerHtml: string,
onSelect: (() => void) | undefined,
extraClass = "",
options: {
disabled?: boolean;
presentation?: string;
title?: string;
ariaLabel?: string;
layout?: "horizontal" | "vertical";
} = {},
) {
return {
kind: "item",
innerHtml,
onSelect,
extraClass,
options,
};
}
export function menuSubmenu(
innerHtml: string,
children: Array<Record<string, unknown>> | (() => Array<Record<string, unknown>>),
extraClass = "",
options: {
disabled?: boolean;
presentation?: string;
title?: string;
ariaLabel?: string;
childPanelClassName?: string;
layout?: "horizontal" | "vertical";
} = {},
) {
return {
kind: "submenu",
innerHtml,
children,
extraClass,
options,
};
}
export function buildPickerMenuItems<T>(
entries: T[] | null | undefined,
options: {
getInnerHtml: (entry: T, index: number) => string;
onSelect: (entry: T, index: number) => void;
getExtraClass?: (entry: T, index: number) => string;
getDisabled?: (entry: T, index: number) => boolean;
},
) {
const validEntries = Array.isArray(entries) ? entries : [];
return validEntries.map((entry, index) => menuItem(
options.getInnerHtml(entry, index),
() => options.onSelect(entry, index),
options.getExtraClass?.(entry, index) || "",
{ disabled: options.getDisabled?.(entry, index) === true },
));
}
export function appendContextMenuItems(
tooltip: {
makeLabel?: (text: string) => HTMLElement;
makeItem?: (
innerHtml: string,
onClick: () => void,
extraClass?: string,
options?: { disabled?: boolean; presentation?: string; title?: string; ariaLabel?: string; layout?: "horizontal" | "vertical" },
) => HTMLElement;
makeSubmenuItem?: (
innerHtml: string,
extraClass?: string,
options?: { disabled?: boolean; presentation?: string; title?: string; ariaLabel?: string; childPanelClassName?: string; layout?: "horizontal" | "vertical" },
) => HTMLElement;
makeSeparator?: () => HTMLElement;
openChild?: (anchorEl: HTMLElement, builder: (panel: HTMLDivElement) => void, tag?: string) => boolean;
closeChildren?: (anchorEl: HTMLElement) => boolean;
} | null | undefined,
panel: HTMLDivElement,
items: Array<Record<string, unknown>> | null | undefined,
menuPath = "root",
) {
if (!tooltip || !panel || !Array.isArray(items)) {
return;
}
applyDefaultIconPanelLayout(panel, items);
items.forEach((item) => {
const kind = String(item?.kind || "").trim().toLowerCase();
if (kind === "separator") {
const separatorEl = tooltip.makeSeparator?.();
if (separatorEl) {
panel.appendChild(separatorEl);
}
return;
}
if (kind === "label") {
const labelEl = tooltip.makeLabel?.(String(item?.text || ""));
if (labelEl) {
panel.appendChild(labelEl);
}
return;
}
if (kind === "item") {
const itemEl = tooltip.makeItem?.(
String(item?.innerHtml || ""),
typeof item?.onSelect === "function" ? item.onSelect as () => void : (() => {}),
String(item?.extraClass || ""),
item?.options && typeof item.options === "object"
? item.options as { disabled?: boolean; presentation?: string; title?: string; ariaLabel?: string; layout?: "horizontal" | "vertical" }
: {},
);
if (itemEl) {
const htmlItemEl = itemEl as HTMLButtonElement;
if (tooltip.closeChildren && !htmlItemEl.disabled) {
const collapseChildren = () => {
tooltip.closeChildren?.(htmlItemEl);
};
htmlItemEl.addEventListener("mouseenter", collapseChildren);
htmlItemEl.addEventListener("focus", collapseChildren);
}
panel.appendChild(htmlItemEl);
}
return;
}
if (kind === "submenu") {
const options = item?.options && typeof item.options === "object"
? item.options as {
disabled?: boolean;
presentation?: string;
title?: string;
ariaLabel?: string;
childPanelClassName?: string;
layout?: "horizontal" | "vertical";
}
: {};
const itemEl = tooltip.makeSubmenuItem
? tooltip.makeSubmenuItem(
String(item?.innerHtml || ""),
String(item?.extraClass || ""),
options,
)
: tooltip.makeItem?.(
`${String(item?.innerHtml || "")}<span class="at-tooltip-submenu-arrow" aria-hidden="true"></span>`,
() => {},
`has-submenu ${String(item?.extraClass || "")}`.trim(),
options,
);
if (!itemEl) {
return;
}
const childEntries = typeof item?.children === "function"
? item.children()
: (Array.isArray(item?.children) ? item.children : []);
const htmlItemEl = itemEl as HTMLButtonElement;
if (!htmlItemEl.disabled && tooltip.openChild) {
const childTag = `${menuPath}:${String(item?.innerHtml || "").replace(/\s+/g, "-").toLowerCase()}`;
const openChildMenu = () => {
tooltip.openChild?.(htmlItemEl, (childPanel) => {
const extraPanelClassName = String(options.childPanelClassName || "").trim();
if (extraPanelClassName) {
childPanel.classList.add(...extraPanelClassName.split(/\s+/).filter(Boolean));
}
const childPanelLayoutClass = resolveChildPanelLayoutClass(options);
if (childPanelLayoutClass) {
childPanel.classList.add(childPanelLayoutClass);
}
appendContextMenuItems(tooltip, childPanel, childEntries, childTag);
}, childTag);
};
htmlItemEl.addEventListener("mouseenter", openChildMenu);
htmlItemEl.addEventListener("focus", openChildMenu);
htmlItemEl.addEventListener("click", (event) => {
event.preventDefault();
openChildMenu();
});
}
panel.appendChild(htmlItemEl);
}
});
}
export function openContextMenuAtPoint(
tooltip: {
openAtPoint?: (clientX: number, clientY: number, builder: (panel: HTMLDivElement) => void, tag?: string) => void;
makeSubmenuItem?: (
innerHtml: string,
extraClass?: string,
options?: { disabled?: boolean; presentation?: string; title?: string; ariaLabel?: string; childPanelClassName?: string; layout?: "horizontal" | "vertical" },
) => HTMLElement;
openChild?: (anchorEl: HTMLElement, builder: (panel: HTMLDivElement) => void, tag?: string) => boolean;
closeChildren?: (anchorEl: HTMLElement) => boolean;
} | null | undefined,
clientX: number,
clientY: number,
items: Array<Record<string, unknown>>,
tag?: string,
) {
if (!tooltip?.openAtPoint) {
return false;
}
tooltip.openAtPoint(clientX, clientY, (panel) => {
appendContextMenuItems(tooltip as never, panel, items, tag || "point-menu");
}, tag);
return true;
}
export function openContextMenuAtAnchor(
tooltip: {
open?: (anchorEl: HTMLElement, builder: (panel: HTMLDivElement) => void, tag?: string) => void;
makeSubmenuItem?: (
innerHtml: string,
extraClass?: string,
options?: { disabled?: boolean; presentation?: string; title?: string; ariaLabel?: string; childPanelClassName?: string; layout?: "horizontal" | "vertical" },
) => HTMLElement;
openChild?: (anchorEl: HTMLElement, builder: (panel: HTMLDivElement) => void, tag?: string) => boolean;
closeChildren?: (anchorEl: HTMLElement) => boolean;
} | null | undefined,
anchorEl: HTMLElement | null | undefined,
items: Array<Record<string, unknown>>,
tag?: string,
) {
if (!tooltip?.open || !anchorEl) {
return false;
}
tooltip.open(anchorEl, (panel) => {
appendContextMenuItems(tooltip as never, panel, items, tag || "anchor-menu");
}, tag);
return true;
}

View file

@ -0,0 +1,47 @@
export function createDebouncedCallback<T extends unknown[]>(
callback: (...args: T) => void,
delayMs = 120,
) {
let timerId = 0;
let lastArgs: T | null = null;
const run = () => {
timerId = 0;
if (!lastArgs) {
return;
}
const args = lastArgs;
lastArgs = null;
callback(...args);
};
const debounced = (...args: T) => {
lastArgs = args;
if (timerId) {
window.clearTimeout(timerId);
}
timerId = window.setTimeout(run, Math.max(0, Number(delayMs) || 0));
};
debounced.flush = () => {
if (timerId) {
window.clearTimeout(timerId);
run();
return true;
}
return false;
};
debounced.cancel = () => {
if (!timerId) {
lastArgs = null;
return false;
}
window.clearTimeout(timerId);
timerId = 0;
lastArgs = null;
return true;
};
return debounced;
}

4722
src/mapEditorPopup/dom.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,90 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { normalizePanelFolderLayout } from "./panelFolders";
function cloneValue(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return value == null ? value : JSON.parse(JSON.stringify(value));
}
export function normalizeEditorUiState(rawState) {
const source = rawState && typeof rawState === "object" && !Array.isArray(rawState)
? rawState
: {};
const rawLayouts = source.panelLayouts && typeof source.panelLayouts === "object" && !Array.isArray(source.panelLayouts)
? source.panelLayouts
: {};
return {
panelLayouts: { ...rawLayouts },
};
}
export function createEditorUiStore(initialState) {
let state = normalizeEditorUiState(initialState);
function getState() {
return state;
}
function setState(nextState) {
state = normalizeEditorUiState(nextState);
return state;
}
function cloneState(sourceState) {
if (sourceState !== undefined) {
return normalizeEditorUiState(cloneValue(sourceState) || { panelLayouts: {} });
}
return normalizeEditorUiState(cloneValue(state) || { panelLayouts: {} });
}
function getPanelLayout(panelKey, itemIds) {
const normalizedKey = String(panelKey || "").trim();
if (!normalizedKey) {
return normalizePanelFolderLayout({}, itemIds);
}
const nextLayout = normalizePanelFolderLayout(state.panelLayouts[normalizedKey], itemIds);
state = {
...state,
panelLayouts: {
...state.panelLayouts,
[normalizedKey]: nextLayout,
},
};
return nextLayout;
}
function setPanelLayout(panelKey, nextLayout, itemIds) {
const normalizedKey = String(panelKey || "").trim();
if (!normalizedKey) {
return normalizePanelFolderLayout({}, itemIds);
}
const normalizedLayout = normalizePanelFolderLayout(nextLayout, itemIds);
state = {
...state,
panelLayouts: {
...state.panelLayouts,
[normalizedKey]: normalizedLayout,
},
};
return normalizedLayout;
}
function updatePanelLayout(panelKey, itemIds, updater) {
const currentLayout = getPanelLayout(panelKey, itemIds);
const nextLayout = typeof updater === "function" ? updater(cloneValue(currentLayout)) : currentLayout;
return setPanelLayout(panelKey, nextLayout, itemIds);
}
return {
getState,
setState,
cloneState,
getPanelLayout,
setPanelLayout,
updatePanelLayout,
};
}

View file

@ -0,0 +1,518 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import {
buildEngineOverrideEntry,
describeEngineOverrideValue,
ENGINE_OVERRIDE_SPECS,
getDefaultEngineOverrideValue,
getEngineOverrideSpec,
getFirstUnusedEngineOverrideKey,
normalizeEngineOverrideEntries,
normalizeEngineOverrideValue,
} from "./engineOverrides";
import { clampFloatingWindowRect } from "./floatingWindowUtils";
const ENGINE_OVERRIDE_WINDOW_KEY = "engineOverrides";
const DEFAULT_WIDTH = 620;
const DEFAULT_HEIGHT = 420;
const MIN_WIDTH = 420;
const MIN_HEIGHT = 260;
function clampWindowRect(layerRect, left, top, width, height) {
return clampFloatingWindowRect(layerRect, left, top, width, height, MIN_WIDTH, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT);
}
export function createEngineOverrideWindowController(scope) {
let initialized = false;
const uiScope = scope.uiScope || scope;
const sessionScope = scope.sessionScope || scope;
const persistedState = typeof sessionScope.getPersistedToolWindowState === "function"
? sessionScope.getPersistedToolWindowState(ENGINE_OVERRIDE_WINDOW_KEY)
: null;
const state = {
visible: persistedState?.visible === true,
x: Number(persistedState?.x) || 116,
y: Number(persistedState?.y) || 88,
width: Number(persistedState?.width) || DEFAULT_WIDTH,
height: Number(persistedState?.height) || DEFAULT_HEIGHT,
shellEl: null,
titleEl: null,
metaEl: null,
listEl: null,
emptyEl: null,
addBtnEl: null,
resizeEl: null,
nextZIndex: 136,
};
function getLayerRect() {
return uiScope.toolWindowLayerEl?.getBoundingClientRect() || uiScope.editorBodyEl?.getBoundingClientRect() || {
left: 0,
top: 0,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
};
}
function persistState() {
if (typeof sessionScope.setPersistedToolWindowState === "function") {
sessionScope.setPersistedToolWindowState(ENGINE_OVERRIDE_WINDOW_KEY, {
visible: state.visible === true,
mode: "floating",
x: state.x,
y: state.y,
width: state.width,
height: state.height,
order: 997,
});
}
scope.persistPopupSessionLayout?.();
sessionScope.persistPopupSessionLayout?.();
}
function focusWindow() {
if (!state.shellEl || state.visible !== true) {
return;
}
state.nextZIndex += 1;
state.shellEl.style.zIndex = String(state.nextZIndex);
state.shellEl.classList.add("is-focused");
}
function clearFocus() {
state.shellEl?.classList.remove("is-focused");
}
function applyWindowRect() {
if (!state.shellEl) {
return;
}
state.shellEl.style.left = Math.round(state.x) + "px";
state.shellEl.style.top = Math.round(state.y) + "px";
state.shellEl.style.width = Math.round(state.width) + "px";
state.shellEl.style.height = Math.round(state.height) + "px";
}
function updateSummary() {
const entries = normalizeEngineOverrideEntries(scope.getEditorEngineOverrides?.() || []);
const summary = entries.length <= 0
? "No engine overrides active."
: `${entries.length} override${entries.length === 1 ? "" : "s"} active`;
if (uiScope.engineOverridesSummaryEl) {
uiScope.engineOverridesSummaryEl.textContent = summary;
uiScope.engineOverridesSummaryEl.title = entries.length > 0
? entries.map((entry) => {
const spec = getEngineOverrideSpec(entry.key);
return `${spec?.label || entry.key}: ${describeEngineOverrideValue(entry)}`;
}).join("\n")
: summary;
}
}
async function saveEntries(nextEntries, successMessage) {
const normalizedEntries = normalizeEngineOverrideEntries(nextEntries);
try {
await scope.saveEditorEngineOverrides?.(normalizedEntries);
if (successMessage) {
scope.setStatus?.(successMessage, false);
}
refresh();
return true;
} catch (error) {
scope.setStatus?.(String(error), true);
refresh();
return false;
}
}
function handleAddEntry() {
const currentEntries = normalizeEngineOverrideEntries(scope.getEditorEngineOverrides?.() || []);
const nextKey = getFirstUnusedEngineOverrideKey(currentEntries);
if (!nextKey) {
scope.setStatus?.("All available engine overrides already exist.", false);
refresh();
return;
}
const nextEntry = buildEngineOverrideEntry(nextKey, currentEntries);
if (!nextEntry) {
scope.setStatus?.("Unable to create that engine override.", true);
return;
}
void saveEntries([...currentEntries, nextEntry], "Added engine override.");
}
function handleDeleteEntry(entryId) {
const currentEntries = normalizeEngineOverrideEntries(scope.getEditorEngineOverrides?.() || []);
const nextEntries = currentEntries.filter((entry) => String(entry.id || "").trim() !== String(entryId || "").trim());
void saveEntries(nextEntries, "Removed engine override.");
}
function handleEntryKeyChange(entryId, nextKey) {
const currentEntries = normalizeEngineOverrideEntries(scope.getEditorEngineOverrides?.() || []);
const normalizedKey = String(nextKey || "").trim();
const duplicate = currentEntries.find((entry) => entry.key === normalizedKey && String(entry.id || "").trim() !== String(entryId || "").trim());
if (duplicate) {
scope.setStatus?.("That engine override already exists.", true);
refresh();
return;
}
const spec = getEngineOverrideSpec(normalizedKey);
if (!spec) {
scope.setStatus?.("Unknown engine override selected.", true);
refresh();
return;
}
const nextEntries = currentEntries.map((entry) => {
if (String(entry.id || "").trim() !== String(entryId || "").trim()) {
return entry;
}
return {
...entry,
key: spec.key,
value: getDefaultEngineOverrideValue(spec.key),
};
});
void saveEntries(nextEntries, "Updated engine override type.");
}
function handleEntryValueChange(entryId, rawValue) {
const currentEntries = normalizeEngineOverrideEntries(scope.getEditorEngineOverrides?.() || []);
const currentEntry = currentEntries.find((entry) => String(entry.id || "").trim() === String(entryId || "").trim()) || null;
if (!currentEntry) {
return;
}
const normalizedValue = normalizeEngineOverrideValue(currentEntry.key, rawValue);
if (normalizedValue == null) {
scope.setStatus?.("That override value was invalid.", true);
refresh();
return;
}
const nextEntries = currentEntries.map((entry) => {
if (String(entry.id || "").trim() !== String(entryId || "").trim()) {
return entry;
}
return {
...entry,
value: normalizedValue,
};
});
void saveEntries(nextEntries, "Updated engine override.");
}
function renderEntryRow(entry) {
const spec = getEngineOverrideSpec(entry.key);
if (!spec || !state.listEl) {
return;
}
const rowEl = document.createElement("div");
rowEl.className = "engine-override-row";
const headEl = document.createElement("div");
headEl.className = "engine-override-row-head";
const selectEl = document.createElement("select");
selectEl.className = "engine-override-select";
ENGINE_OVERRIDE_SPECS.forEach((candidate) => {
const optionEl = document.createElement("option");
optionEl.value = candidate.key;
optionEl.textContent = candidate.label;
selectEl.appendChild(optionEl);
});
selectEl.value = spec.key;
selectEl.addEventListener("change", () => {
handleEntryKeyChange(entry.id, selectEl.value);
});
const deleteBtnEl = document.createElement("button");
deleteBtnEl.type = "button";
deleteBtnEl.className = "icon-action-btn danger";
deleteBtnEl.textContent = "X";
deleteBtnEl.title = "Remove override";
deleteBtnEl.addEventListener("click", () => {
handleDeleteEntry(entry.id);
});
headEl.appendChild(selectEl);
headEl.appendChild(deleteBtnEl);
const descriptionEl = document.createElement("div");
descriptionEl.className = "engine-override-description";
descriptionEl.textContent = spec.description;
const valueRowEl = document.createElement("div");
valueRowEl.className = "engine-override-value-row";
const valueLabelEl = document.createElement("label");
valueLabelEl.className = "engine-override-value-label";
valueLabelEl.textContent = "Value";
valueRowEl.appendChild(valueLabelEl);
if (spec.type === "boolean") {
const toggleWrapEl = document.createElement("label");
toggleWrapEl.className = "engine-override-toggle";
const checkboxEl = document.createElement("input");
checkboxEl.type = "checkbox";
checkboxEl.checked = Boolean(entry.value);
checkboxEl.addEventListener("change", () => {
handleEntryValueChange(entry.id, checkboxEl.checked);
});
const copyEl = document.createElement("span");
copyEl.textContent = checkboxEl.checked ? "On" : "Off";
checkboxEl.addEventListener("change", () => {
copyEl.textContent = checkboxEl.checked ? "On" : "Off";
});
toggleWrapEl.appendChild(checkboxEl);
toggleWrapEl.appendChild(copyEl);
valueRowEl.appendChild(toggleWrapEl);
} else {
const inputEl = document.createElement("input");
inputEl.className = "engine-override-number-input";
inputEl.type = "number";
if (Number.isFinite(Number(spec.min))) {
inputEl.min = String(Number(spec.min));
}
if (Number.isFinite(Number(spec.max))) {
inputEl.max = String(Number(spec.max));
}
if (Number.isFinite(Number(spec.step))) {
inputEl.step = String(Number(spec.step));
}
inputEl.value = String(entry.value);
inputEl.addEventListener("change", () => {
handleEntryValueChange(entry.id, inputEl.value);
});
valueRowEl.appendChild(inputEl);
}
rowEl.appendChild(headEl);
rowEl.appendChild(descriptionEl);
rowEl.appendChild(valueRowEl);
state.listEl.appendChild(rowEl);
}
function refresh() {
const entries = normalizeEngineOverrideEntries(scope.getEditorEngineOverrides?.() || []);
updateSummary();
if (state.titleEl) {
state.titleEl.textContent = "Engine Overrides";
}
if (state.metaEl) {
state.metaEl.textContent = entries.length <= 0
? "No overrides"
: entries.length === 1
? "1 override"
: `${entries.length} overrides`;
}
if (state.addBtnEl) {
state.addBtnEl.disabled = !getFirstUnusedEngineOverrideKey(entries);
}
if (!state.listEl) {
return;
}
state.listEl.innerHTML = "";
if (entries.length <= 0) {
if (state.emptyEl) {
state.emptyEl.classList.remove("hidden");
state.listEl.appendChild(state.emptyEl);
}
return;
}
if (state.emptyEl) {
state.emptyEl.classList.add("hidden");
}
entries.forEach((entry) => {
renderEntryRow(entry);
});
}
function ensureShell() {
if (state.shellEl && state.shellEl.isConnected) {
return state.shellEl;
}
const shellEl = document.createElement("div");
shellEl.className = "tool-popout-window engine-override-window hidden";
const titlebarEl = document.createElement("div");
titlebarEl.className = "tool-popout-titlebar";
titlebarEl.innerHTML =
'<div class="tool-popout-title">Engine Overrides</div>' +
'<div class="tool-popout-hint">Danger zone</div>' +
'<button class="tool-popout-close-btn" type="button" aria-label="Close engine overrides">X</button>';
const bodyEl = document.createElement("div");
bodyEl.className = "tool-popout-body";
const cardEl = document.createElement("div");
cardEl.className = "engine-override-card";
const headEl = document.createElement("div");
headEl.className = "engine-override-head";
const titleEl = document.createElement("div");
titleEl.className = "engine-override-title";
const metaEl = document.createElement("div");
metaEl.className = "engine-override-meta";
const addBtnEl = document.createElement("button");
addBtnEl.type = "button";
addBtnEl.className = "mini-btn";
addBtnEl.textContent = "Add";
addBtnEl.addEventListener("click", () => {
handleAddEntry();
});
const listEl = document.createElement("div");
listEl.className = "engine-override-list";
const emptyEl = document.createElement("div");
emptyEl.className = "engine-override-empty";
emptyEl.textContent = "No overrides yet. Add one to override engine behavior.";
headEl.appendChild(titleEl);
headEl.appendChild(metaEl);
headEl.appendChild(addBtnEl);
listEl.appendChild(emptyEl);
cardEl.appendChild(headEl);
cardEl.appendChild(listEl);
bodyEl.appendChild(cardEl);
const resizeEl = document.createElement("div");
resizeEl.className = "tool-popout-resize";
const closeBtnEl = titlebarEl.querySelector(".tool-popout-close-btn");
shellEl.appendChild(titlebarEl);
shellEl.appendChild(bodyEl);
shellEl.appendChild(resizeEl);
shellEl.addEventListener("pointerdown", () => {
focusWindow();
});
titlebarEl.addEventListener("pointerdown", (event) => {
if (closeBtnEl && closeBtnEl.contains(event.target)) {
return;
}
if (event.button !== 0) {
return;
}
event.preventDefault();
focusWindow();
const layerRect = getLayerRect();
const originLeft = Number(state.x) || 0;
const originTop = Number(state.y) || 0;
const startX = event.clientX;
const startY = event.clientY;
const move = (moveEvent) => {
const nextRect = clampWindowRect(
layerRect,
originLeft + (moveEvent.clientX - startX),
originTop + (moveEvent.clientY - startY),
state.width,
state.height,
);
state.x = nextRect.left;
state.y = nextRect.top;
applyWindowRect();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
closeBtnEl?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
close();
});
resizeEl.addEventListener("pointerdown", (event) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
focusWindow();
const layerRect = getLayerRect();
const startX = event.clientX;
const startY = event.clientY;
const originWidth = Number(state.width) || DEFAULT_WIDTH;
const originHeight = Number(state.height) || DEFAULT_HEIGHT;
const move = (moveEvent) => {
const nextRect = clampWindowRect(
layerRect,
state.x,
state.y,
Math.max(MIN_WIDTH, originWidth + (moveEvent.clientX - startX)),
Math.max(MIN_HEIGHT, originHeight + (moveEvent.clientY - startY)),
);
state.width = nextRect.width;
state.height = nextRect.height;
applyWindowRect();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
state.shellEl = shellEl;
state.titleEl = titleEl;
state.metaEl = metaEl;
state.listEl = listEl;
state.emptyEl = emptyEl;
state.addBtnEl = addBtnEl;
state.resizeEl = resizeEl;
uiScope.toolWindowLayerEl?.appendChild(shellEl);
applyWindowRect();
shellEl.classList.toggle("hidden", state.visible !== true);
refresh();
return shellEl;
}
function open() {
ensureShell();
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
state.visible = true;
refresh();
state.shellEl?.classList.remove("hidden");
applyWindowRect();
focusWindow();
persistState();
return true;
}
function close() {
state.visible = false;
clearFocus();
state.shellEl?.classList.add("hidden");
persistState();
return true;
}
function initialize() {
if (initialized) {
return;
}
initialized = true;
ensureShell();
updateSummary();
window.addEventListener("resize", () => {
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
applyWindowRect();
persistState();
});
if (state.visible) {
open();
} else {
state.visible = false;
state.shellEl?.classList.add("hidden");
}
}
return {
initialize,
open,
close,
refresh,
updateSummary,
isOpen: () => state.visible === true,
};
}

View file

@ -0,0 +1,169 @@
export type EngineOverrideKind = "number" | "boolean";
export type EngineOverrideSpec = {
key: string;
label: string;
description: string;
type: EngineOverrideKind;
defaultValue: number | boolean;
min?: number;
max?: number;
step?: number;
};
export type EngineOverrideEntry = {
id: string;
key: string;
value: number | boolean;
};
export const ENGINE_OVERRIDE_SPECS: EngineOverrideSpec[] = [
{
key: "heightBlurStep",
label: "Height Blur Step",
description: "Controls how much blur is added per height level while previewing stacked height layers.",
type: "number",
defaultValue: 0.1,
min: 0,
max: 1,
step: 0.05,
},
{
key: "rendererDebug",
label: "Renderer Debug",
description: "Shows the live renderer diagnostics panel and enables extra render debugging helpers.",
type: "boolean",
defaultValue: false,
},
];
const ENGINE_OVERRIDE_SPEC_BY_KEY = new Map(ENGINE_OVERRIDE_SPECS.map((spec) => [spec.key, spec]));
export function getEngineOverrideSpec(key: unknown): EngineOverrideSpec | null {
const normalizedKey = String(key || "").trim();
return ENGINE_OVERRIDE_SPEC_BY_KEY.get(normalizedKey) || null;
}
export function getDefaultEngineOverrideValue(key: unknown): number | boolean | null {
const spec = getEngineOverrideSpec(key);
return spec ? spec.defaultValue : null;
}
export function normalizeEngineOverrideValue(key: unknown, value: unknown): number | boolean | null {
const spec = getEngineOverrideSpec(key);
if (!spec) {
return null;
}
if (spec.type === "boolean") {
if (typeof value === "string") {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
return false;
}
}
return Boolean(value);
}
const normalizedNumber = Number(value);
const fallbackNumber = Number(spec.defaultValue) || 0;
const safeNumber = Number.isFinite(normalizedNumber) ? normalizedNumber : fallbackNumber;
const min = Number.isFinite(Number(spec.min)) ? Number(spec.min) : safeNumber;
const max = Number.isFinite(Number(spec.max)) ? Number(spec.max) : safeNumber;
return Math.max(min, Math.min(max, safeNumber));
}
export function normalizeEngineOverrideEntry(value: unknown, fallbackIndex = 0): EngineOverrideEntry | null {
const source = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
if (!source) {
return null;
}
const spec = getEngineOverrideSpec(source.key);
if (!spec) {
return null;
}
const normalizedValue = normalizeEngineOverrideValue(spec.key, source.value);
if (normalizedValue == null) {
return null;
}
const fallbackId = `override_${spec.key}_${Math.max(1, Number(fallbackIndex) || 1)}`;
return {
id: String(source.id || fallbackId).trim() || fallbackId,
key: spec.key,
value: normalizedValue,
};
}
export function normalizeEngineOverrideEntries(value: unknown): EngineOverrideEntry[] {
const entries = Array.isArray(value) ? value : [];
const byKey = new Map<string, EngineOverrideEntry>();
entries.forEach((entry, index) => {
const normalized = normalizeEngineOverrideEntry(entry, index + 1);
if (!normalized) {
return;
}
byKey.set(normalized.key, normalized);
});
return ENGINE_OVERRIDE_SPECS
.map((spec) => byKey.get(spec.key) || null)
.filter((entry): entry is EngineOverrideEntry => Boolean(entry));
}
export function buildEngineOverrideEntry(key: unknown, existingEntries: unknown = []): EngineOverrideEntry | null {
const spec = getEngineOverrideSpec(key);
if (!spec) {
return null;
}
const existingIds = new Set(
normalizeEngineOverrideEntries(existingEntries)
.map((entry) => String(entry.id || "").trim())
.filter(Boolean),
);
let nextIndex = existingIds.size + 1;
let nextId = `override_${spec.key}_${nextIndex}`;
while (existingIds.has(nextId)) {
nextIndex += 1;
nextId = `override_${spec.key}_${nextIndex}`;
}
return {
id: nextId,
key: spec.key,
value: spec.defaultValue,
};
}
export function getFirstUnusedEngineOverrideKey(entries: unknown): string {
const usedKeys = new Set(
normalizeEngineOverrideEntries(entries)
.map((entry) => String(entry.key || "").trim())
.filter(Boolean),
);
const nextSpec = ENGINE_OVERRIDE_SPECS.find((spec) => !usedKeys.has(spec.key));
return nextSpec ? nextSpec.key : "";
}
export function getEngineOverrideValue(entries: unknown, key: unknown, fallback: unknown = null): number | boolean | null {
const normalizedKey = String(key || "").trim();
const match = normalizeEngineOverrideEntries(entries).find((entry) => entry.key === normalizedKey) || null;
if (!match) {
return fallback as number | boolean | null;
}
return match.value;
}
export function describeEngineOverrideValue(entry: EngineOverrideEntry | null | undefined): string {
if (!entry) {
return "";
}
const spec = getEngineOverrideSpec(entry.key);
if (!spec) {
return String(entry.value ?? "");
}
if (spec.type === "boolean") {
return entry.value ? "On" : "Off";
}
return String(entry.value);
}

View file

@ -0,0 +1,854 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import {
normalizeEditorTagValue,
normalizeEditorTags,
parseImportedEditorTags,
serializeEditorTags,
} from "./tagUtils";
import {
confirmDiscardChanges,
copyTextWithClipboardFallback,
promptForImportText,
} from "./textTransferUtils";
import { clampFloatingWindowRect } from "./floatingWindowUtils";
const ENTITY_EDITOR_WINDOW_KEY = "entityEditor";
const DEFAULT_WIDTH = 468;
const DEFAULT_HEIGHT = 648;
const MIN_WIDTH = 404;
const MIN_HEIGHT = 468;
function clampWindowRect(layerRect, left, top, width, height) {
return clampFloatingWindowRect(layerRect, left, top, width, height, MIN_WIDTH, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT);
}
export function createEntityEditorWindowController(scope) {
let initialized = false;
const uiScope = scope.uiScope || scope;
const sessionScope = scope.sessionScope || scope;
const persistedState = typeof sessionScope.getPersistedToolWindowState === "function"
? sessionScope.getPersistedToolWindowState(ENTITY_EDITOR_WINDOW_KEY)
: null;
const state = {
visible: persistedState?.visible === true,
x: Number(persistedState?.x) || 84,
y: Number(persistedState?.y) || 54,
width: Number(persistedState?.width) || DEFAULT_WIDTH,
height: Number(persistedState?.height) || DEFAULT_HEIGHT,
entityId: "",
working: null,
activeTab: "information",
dirty: false,
saving: false,
shellEl: null,
bodyEl: null,
titleEl: null,
subtitleEl: null,
statusEl: null,
saveBtnEl: null,
resizeEl: null,
nameInputEl: null,
tabButtonsEl: null,
informationTabBtnEl: null,
tagsTabBtnEl: null,
informationPaneEl: null,
tagsPaneEl: null,
typeSelectEl: null,
layerSelectEl: null,
factionSelectEl: null,
spriteSelectEl: null,
dialogueSelectEl: null,
descriptionInputEl: null,
positionValueEl: null,
tagInputEl: null,
tagListEl: null,
nextZIndex: 118,
};
function getLayerRect() {
return uiScope.toolWindowLayerEl?.getBoundingClientRect() || uiScope.editorBodyEl?.getBoundingClientRect() || {
left: 0,
top: 0,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
};
}
function getNpcById(entityId) {
const normalizedId = String(entityId || "").trim();
if (!normalizedId) {
return null;
}
return scope.npcOverlays.find((entry) => String(entry?.id || "").trim() === normalizedId) || null;
}
function buildWorkingFromNpc(npc) {
return {
id: String(npc?.id || ""),
name: String(npc?.record?.name || ""),
entityType: scope.normalizeEntityType?.(npc?.record?.entityType || npc?.entityType, "friendly") || "friendly",
layer: String(Number(npc?.layer) || 0),
faction: String(npc?.record?.faction || ""),
spriteId: String(npc?.record?.spriteId || ""),
dialogueId: String(npc?.record?.dialogueId || ""),
description: String(npc?.record?.description || ""),
tags: normalizeEditorTags(npc?.record?.tags),
position: (Number.isFinite(Number(npc?.x)) && Number.isFinite(Number(npc?.y)) && Number(npc?.x) >= 0 && Number(npc?.y) >= 0)
? "(" + Math.floor(Number(npc.x) || 0) + "," + Math.floor(Number(npc.y) || 0) + ")"
: "unplaced",
};
}
function persistState() {
if (typeof sessionScope.setPersistedToolWindowState === "function") {
sessionScope.setPersistedToolWindowState(ENTITY_EDITOR_WINDOW_KEY, {
visible: state.visible === true,
mode: "floating",
x: state.x,
y: state.y,
width: state.width,
height: state.height,
order: 997,
});
}
scope.persistPopupSessionLayout?.();
sessionScope.persistPopupSessionLayout?.();
}
function focusWindow() {
if (!state.shellEl || state.visible !== true) {
return;
}
state.nextZIndex += 1;
state.shellEl.style.zIndex = String(state.nextZIndex);
state.shellEl.classList.add("is-focused");
}
function clearFocus() {
state.shellEl?.classList.remove("is-focused");
}
function applyWindowRect() {
if (!state.shellEl) {
return;
}
state.shellEl.style.left = Math.round(state.x) + "px";
state.shellEl.style.top = Math.round(state.y) + "px";
state.shellEl.style.width = Math.round(state.width) + "px";
state.shellEl.style.height = Math.round(state.height) + "px";
}
function updateStatus(message, isError) {
if (!state.statusEl) {
return;
}
state.statusEl.textContent = String(message || "");
state.statusEl.style.color = isError ? "var(--editor-status-error)" : "var(--editor-status-ok)";
}
function refreshHeader() {
if (state.titleEl) {
state.titleEl.textContent = String(state.working?.name || state.entityId || "Entity").trim() || "Entity";
}
if (state.subtitleEl) {
const position = String(state.working?.position || "unplaced");
const typeLabel = scope.getEntityTypeLabel?.(state.working?.entityType || "friendly") || "Entity";
state.subtitleEl.textContent = `${state.entityId || "entity"} | ${typeLabel} | ${position}`;
}
if (state.nameInputEl && state.nameInputEl !== document.activeElement) {
const nextValue = String(state.working?.name || "");
if (state.nameInputEl.value !== nextValue) {
state.nameInputEl.value = nextValue;
}
}
if (state.informationTabBtnEl) {
state.informationTabBtnEl.classList.toggle("is-active", state.activeTab === "information");
state.informationTabBtnEl.setAttribute("aria-pressed", state.activeTab === "information" ? "true" : "false");
}
if (state.tagsTabBtnEl) {
state.tagsTabBtnEl.classList.toggle("is-active", state.activeTab === "tags");
state.tagsTabBtnEl.setAttribute("aria-pressed", state.activeTab === "tags" ? "true" : "false");
}
state.informationPaneEl?.classList.toggle("hidden", state.activeTab !== "information");
state.tagsPaneEl?.classList.toggle("hidden", state.activeTab !== "tags");
if (state.saveBtnEl) {
state.saveBtnEl.disabled = state.saving || !state.dirty || !state.working;
state.saveBtnEl.textContent = state.saving ? "Saving..." : "Save";
}
}
function setActiveTab(nextTab) {
state.activeTab = nextTab === "tags" ? "tags" : "information";
refreshHeader();
}
function markDirty(message = "Unsaved entity changes.") {
state.dirty = true;
refreshHeader();
updateStatus(message, false);
}
function populateSelect(selectEl, items, currentValue, placeholderLabel) {
if (!selectEl) {
return;
}
selectEl.innerHTML = "";
const placeholderEl = document.createElement("option");
placeholderEl.value = "";
placeholderEl.textContent = placeholderLabel;
selectEl.appendChild(placeholderEl);
(Array.isArray(items) ? items : []).forEach((item) => {
const optionEl = document.createElement("option");
optionEl.value = String(item?.id || "");
optionEl.textContent = String(item?.name || item?.id || item?.label || item?.value || "");
selectEl.appendChild(optionEl);
});
selectEl.value = String(currentValue || "");
}
function refreshFormValues() {
if (!state.working) {
return;
}
if (state.nameInputEl) {
state.nameInputEl.value = String(state.working.name || "");
}
if (state.typeSelectEl) {
state.typeSelectEl.value = String(state.working.entityType || "friendly");
}
if (state.layerSelectEl) {
state.layerSelectEl.innerHTML = "";
scope.roomLayers
.slice()
.sort((left, right) => (Number(left?.layer) || 0) - (Number(right?.layer) || 0))
.forEach((layerEntry) => {
const optionEl = document.createElement("option");
optionEl.value = String(Number(layerEntry?.layer) || 0);
optionEl.textContent = scope.getLayerDisplayName(layerEntry);
state.layerSelectEl.appendChild(optionEl);
});
state.layerSelectEl.value = String(state.working.layer || "0");
}
populateSelect(state.factionSelectEl, scope.getFactionRecords?.() || [], state.working.faction, "(None)");
populateSelect(state.spriteSelectEl, scope.getSpriteCatalogRecords?.() || [], state.working.spriteId, "(Select sprite)");
populateSelect(state.dialogueSelectEl, scope.getDialogueCatalogRecords?.() || [], state.working.dialogueId, "(None)");
if (state.descriptionInputEl) {
state.descriptionInputEl.value = String(state.working.description || "");
}
if (state.positionValueEl) {
state.positionValueEl.textContent = String(state.working.position || "unplaced");
}
renderTags();
refreshHeader();
}
function renderTags() {
if (!state.tagListEl) {
return;
}
const tags = normalizeEditorTags(state.working?.tags);
state.tagListEl.innerHTML = "";
if (tags.length <= 0) {
const emptyEl = document.createElement("div");
emptyEl.className = "tile-art-tags-empty";
emptyEl.textContent = "No tags yet. Type a tag and press Enter.";
state.tagListEl.appendChild(emptyEl);
return;
}
tags.forEach((tag) => {
const chipEl = document.createElement("button");
chipEl.type = "button";
chipEl.className = "tile-art-tag-chip";
chipEl.title = `Remove tag "${tag}"`;
chipEl.setAttribute("aria-label", `Remove tag ${tag}`);
chipEl.innerHTML = `<span class="tile-art-tag-chip-label">${tag}</span><span class="tile-art-tag-chip-remove" aria-hidden="true">x</span>`;
chipEl.addEventListener("click", () => {
if (!state.working) {
return;
}
state.working = {
...state.working,
tags: normalizeEditorTags((state.working.tags || []).filter((entry) => String(entry || "").toLocaleLowerCase() !== tag.toLocaleLowerCase())),
};
renderTags();
markDirty();
});
state.tagListEl.appendChild(chipEl);
});
}
function addTag(rawValue) {
if (!state.working) {
return false;
}
const normalizedTag = normalizeEditorTagValue(rawValue);
if (!normalizedTag) {
return false;
}
const nextTags = normalizeEditorTags([...(state.working.tags || []), normalizedTag]);
if (nextTags.length === normalizeEditorTags(state.working.tags).length) {
if (state.tagInputEl) {
state.tagInputEl.value = "";
}
updateStatus("That tag already exists.", false);
return false;
}
state.working = {
...state.working,
tags: nextTags,
};
if (state.tagInputEl) {
state.tagInputEl.value = "";
}
renderTags();
markDirty();
return true;
}
async function exportTags() {
if (!state.working) {
return false;
}
const serialized = serializeEditorTags(state.working.tags);
return copyTextWithClipboardFallback(
serialized,
"Copy tag export string",
() => updateStatus("Copied entity tags to clipboard.", false),
(clipboardAvailable) => updateStatus(
clipboardAvailable
? "Clipboard unavailable. Tag export string opened for manual copy."
: "Tag export string ready to copy.",
false,
),
);
}
function importTags() {
if (!state.working) {
return false;
}
const pasted = promptForImportText("Paste tag export string", "");
if (pasted === null) {
return false;
}
const importedTags = parseImportedEditorTags(pasted);
if (importedTags.length <= 0) {
updateStatus("No valid tags were found in that import string.", true);
return false;
}
const previousTags = normalizeEditorTags(state.working.tags);
const nextTags = normalizeEditorTags([...(state.working.tags || []), ...importedTags]);
if (nextTags.length === previousTags.length) {
updateStatus("All imported tags already exist on this entity.", false);
return false;
}
state.working = {
...state.working,
tags: nextTags,
};
renderTags();
markDirty(`Imported ${nextTags.length - previousTags.length} tag${nextTags.length - previousTags.length === 1 ? "" : "s"}.`);
return true;
}
function confirmDiscardIfDirty() {
return confirmDiscardChanges("Discard unsaved entity changes?", state.dirty);
}
function loadEntity(entityId) {
const npc = getNpcById(entityId);
if (!npc) {
return false;
}
state.entityId = String(entityId || "").trim();
state.working = buildWorkingFromNpc(npc);
state.activeTab = "information";
state.dirty = false;
refreshFormValues();
updateStatus("Edit the entity, then save your changes.", false);
return true;
}
async function save() {
if (!state.working || !state.entityId || state.saving) {
return false;
}
const npc = getNpcById(state.entityId);
if (!npc) {
updateStatus("Entity no longer exists.", true);
return false;
}
state.saving = true;
refreshHeader();
try {
const nextType = scope.normalizeEntityType?.(state.working.entityType || "friendly", "friendly") || "friendly";
const nextLayer = Number(state.working.layer || 0);
const nextTags = normalizeEditorTags(state.working.tags);
scope.applyNpcEditorChange?.(npc, (target) => {
target.record.name = String(state.working.name || "");
target.record.entityType = nextType;
target.layer = Number.isFinite(nextLayer) ? nextLayer : 0;
target.record.layer = target.layer;
target.record.faction = String(state.working.faction || "");
target.record.spriteId = String(state.working.spriteId || "");
target.record.dialogueId = String(state.working.dialogueId || "");
target.record.description = String(state.working.description || "");
target.record.tags = nextTags;
}, "Entity");
if (scope.activeEntityCategory !== nextType) {
scope.activeEntityCategory = nextType;
uiScope.refreshEntityTypeTabs?.();
}
uiScope.renderInstancePalette?.();
uiScope.renderNpcList?.();
loadEntity(state.entityId);
updateStatus("Entity saved.", false);
return true;
} finally {
state.saving = false;
refreshHeader();
}
}
function buildField(labelText, controlEl) {
const fieldEl = document.createElement("label");
fieldEl.className = "entity-editor-field";
const labelEl = document.createElement("span");
labelEl.className = "entity-editor-label";
labelEl.textContent = labelText;
fieldEl.appendChild(labelEl);
fieldEl.appendChild(controlEl);
return fieldEl;
}
function ensureShell() {
if (state.shellEl && state.shellEl.isConnected) {
return state.shellEl;
}
const shellEl = document.createElement("div");
shellEl.className = "tool-popout-window entity-editor-window hidden";
const titlebarEl = document.createElement("div");
titlebarEl.className = "tool-popout-titlebar";
titlebarEl.innerHTML = `
<div class="tool-popout-title">Entity Editor</div>
<div class="tool-popout-hint">Placed entity details</div>
<button class="tool-popout-close-btn" type="button" aria-label="Close entity editor">X</button>
`;
const bodyEl = document.createElement("div");
bodyEl.className = "tool-popout-body";
const cardEl = document.createElement("div");
cardEl.className = "entity-editor-card";
const headEl = document.createElement("div");
headEl.className = "entity-editor-head";
const titleEl = document.createElement("div");
titleEl.className = "entity-editor-title";
const subtitleEl = document.createElement("div");
subtitleEl.className = "entity-editor-subtitle";
headEl.appendChild(titleEl);
headEl.appendChild(subtitleEl);
const nameRowEl = document.createElement("div");
nameRowEl.className = "tile-art-name-row";
const nameFieldEl = document.createElement("label");
nameFieldEl.className = "tile-art-name-field";
const nameLabelEl = document.createElement("span");
nameLabelEl.className = "tile-art-name-label";
nameLabelEl.textContent = "Entity Name";
const nameInputEl = document.createElement("input");
nameInputEl.type = "text";
nameInputEl.className = "tile-art-name-input";
nameInputEl.maxLength = 80;
nameInputEl.spellcheck = false;
nameInputEl.placeholder = "Entity name";
nameInputEl.addEventListener("input", () => {
if (!state.working) {
return;
}
state.working = {
...state.working,
name: String(nameInputEl.value || ""),
};
refreshHeader();
markDirty();
});
nameFieldEl.appendChild(nameLabelEl);
nameFieldEl.appendChild(nameInputEl);
const tabButtonsEl = document.createElement("div");
tabButtonsEl.className = "tile-art-tabs";
const informationTabBtnEl = document.createElement("button");
informationTabBtnEl.type = "button";
informationTabBtnEl.className = "tile-art-tab-btn";
informationTabBtnEl.textContent = "Information";
informationTabBtnEl.addEventListener("click", () => {
setActiveTab("information");
});
const tagsTabBtnEl = document.createElement("button");
tagsTabBtnEl.type = "button";
tagsTabBtnEl.className = "tile-art-tab-btn";
tagsTabBtnEl.textContent = "Tags";
tagsTabBtnEl.addEventListener("click", () => {
setActiveTab("tags");
});
tabButtonsEl.appendChild(informationTabBtnEl);
tabButtonsEl.appendChild(tagsTabBtnEl);
nameRowEl.appendChild(nameFieldEl);
nameRowEl.appendChild(tabButtonsEl);
const informationPaneEl = document.createElement("div");
informationPaneEl.className = "entity-editor-pane";
const gridEl = document.createElement("div");
gridEl.className = "entity-editor-grid";
const typeSelectEl = document.createElement("select");
["friendly", "hostile", "prop"].forEach((type) => {
const optionEl = document.createElement("option");
optionEl.value = type;
optionEl.textContent = scope.getEntityTypeLabel?.(type) || type;
typeSelectEl.appendChild(optionEl);
});
typeSelectEl.addEventListener("change", () => {
if (!state.working) {
return;
}
state.working = {
...state.working,
entityType: scope.normalizeEntityType?.(typeSelectEl.value, "friendly") || "friendly",
};
refreshHeader();
markDirty();
});
const layerSelectEl = document.createElement("select");
layerSelectEl.addEventListener("change", () => {
if (!state.working) {
return;
}
state.working = {
...state.working,
layer: String(layerSelectEl.value || "0"),
};
markDirty();
});
const factionSelectEl = document.createElement("select");
factionSelectEl.addEventListener("change", () => {
if (!state.working) {
return;
}
state.working = {
...state.working,
faction: String(factionSelectEl.value || ""),
};
markDirty();
});
const spriteSelectEl = document.createElement("select");
spriteSelectEl.addEventListener("change", () => {
if (!state.working) {
return;
}
state.working = {
...state.working,
spriteId: String(spriteSelectEl.value || ""),
};
markDirty();
});
const dialogueSelectEl = document.createElement("select");
dialogueSelectEl.addEventListener("change", () => {
if (!state.working) {
return;
}
state.working = {
...state.working,
dialogueId: String(dialogueSelectEl.value || ""),
};
markDirty();
});
const positionValueEl = document.createElement("div");
positionValueEl.className = "entity-editor-static";
const descriptionInputEl = document.createElement("textarea");
descriptionInputEl.className = "entity-editor-textarea";
descriptionInputEl.rows = 6;
descriptionInputEl.addEventListener("input", () => {
if (!state.working) {
return;
}
state.working = {
...state.working,
description: String(descriptionInputEl.value || ""),
};
markDirty();
});
gridEl.appendChild(buildField("Type", typeSelectEl));
gridEl.appendChild(buildField("Layer", layerSelectEl));
gridEl.appendChild(buildField("Faction", factionSelectEl));
gridEl.appendChild(buildField("Sprite", spriteSelectEl));
gridEl.appendChild(buildField("Dialogue", dialogueSelectEl));
gridEl.appendChild(buildField("Position", positionValueEl));
gridEl.appendChild(buildField("Description", descriptionInputEl));
informationPaneEl.appendChild(gridEl);
const tagsPaneEl = document.createElement("div");
tagsPaneEl.className = "tile-art-pane tile-art-tags-pane hidden";
const tagFieldEl = document.createElement("label");
tagFieldEl.className = "tile-art-tag-field";
const tagHeadEl = document.createElement("div");
tagHeadEl.className = "tile-art-tag-head";
const tagLabelEl = document.createElement("span");
tagLabelEl.className = "tile-art-tag-label";
tagLabelEl.textContent = "Add Tag";
const tagActionsEl = document.createElement("div");
tagActionsEl.className = "tile-art-tag-actions";
const exportTagsBtnEl = document.createElement("button");
exportTagsBtnEl.type = "button";
exportTagsBtnEl.className = "mini-btn";
exportTagsBtnEl.textContent = "Export";
exportTagsBtnEl.addEventListener("click", () => {
void exportTags();
});
const importTagsBtnEl = document.createElement("button");
importTagsBtnEl.type = "button";
importTagsBtnEl.className = "mini-btn";
importTagsBtnEl.textContent = "Import";
importTagsBtnEl.addEventListener("click", () => {
importTags();
});
tagActionsEl.appendChild(exportTagsBtnEl);
tagActionsEl.appendChild(importTagsBtnEl);
tagHeadEl.appendChild(tagLabelEl);
tagHeadEl.appendChild(tagActionsEl);
const tagInputEl = document.createElement("input");
tagInputEl.type = "text";
tagInputEl.className = "tile-art-tag-input";
tagInputEl.maxLength = 48;
tagInputEl.spellcheck = false;
tagInputEl.placeholder = "Type a tag and press Enter";
tagInputEl.addEventListener("keydown", (event) => {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
addTag(tagInputEl.value);
});
const tagListEl = document.createElement("div");
tagListEl.className = "tile-art-tag-list";
tagFieldEl.appendChild(tagHeadEl);
tagFieldEl.appendChild(tagInputEl);
tagsPaneEl.appendChild(tagFieldEl);
tagsPaneEl.appendChild(tagListEl);
const footerEl = document.createElement("div");
footerEl.className = "entity-editor-footer";
const statusEl = document.createElement("div");
statusEl.className = "entity-editor-status";
const actionsEl = document.createElement("div");
actionsEl.className = "entity-editor-actions";
const saveBtnEl = document.createElement("button");
saveBtnEl.type = "button";
saveBtnEl.className = "mini-btn";
saveBtnEl.textContent = "Save";
saveBtnEl.addEventListener("click", () => {
void save();
});
actionsEl.appendChild(saveBtnEl);
footerEl.appendChild(statusEl);
footerEl.appendChild(actionsEl);
const resizeEl = document.createElement("div");
resizeEl.className = "tool-popout-resize";
cardEl.appendChild(headEl);
cardEl.appendChild(nameRowEl);
cardEl.appendChild(informationPaneEl);
cardEl.appendChild(tagsPaneEl);
cardEl.appendChild(footerEl);
bodyEl.appendChild(cardEl);
const closeBtnEl = titlebarEl.querySelector(".tool-popout-close-btn");
shellEl.appendChild(titlebarEl);
shellEl.appendChild(bodyEl);
shellEl.appendChild(resizeEl);
shellEl.addEventListener("pointerdown", () => {
focusWindow();
});
titlebarEl.addEventListener("pointerdown", (event) => {
if (closeBtnEl && closeBtnEl.contains(event.target)) {
return;
}
if (event.button !== 0) {
return;
}
event.preventDefault();
focusWindow();
const layerRect = getLayerRect();
const originLeft = Number(state.x) || 0;
const originTop = Number(state.y) || 0;
const startX = event.clientX;
const startY = event.clientY;
const move = (moveEvent) => {
const nextRect = clampWindowRect(
layerRect,
originLeft + (moveEvent.clientX - startX),
originTop + (moveEvent.clientY - startY),
state.width,
state.height,
);
state.x = nextRect.left;
state.y = nextRect.top;
applyWindowRect();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
closeBtnEl?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
close();
});
resizeEl.addEventListener("pointerdown", (event) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
focusWindow();
const layerRect = getLayerRect();
const startX = event.clientX;
const startY = event.clientY;
const originWidth = Number(state.width) || DEFAULT_WIDTH;
const originHeight = Number(state.height) || DEFAULT_HEIGHT;
const move = (moveEvent) => {
const nextRect = clampWindowRect(
layerRect,
state.x,
state.y,
Math.max(MIN_WIDTH, originWidth + (moveEvent.clientX - startX)),
Math.max(MIN_HEIGHT, originHeight + (moveEvent.clientY - startY)),
);
state.width = nextRect.width;
state.height = nextRect.height;
applyWindowRect();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
state.shellEl = shellEl;
state.bodyEl = bodyEl;
state.titleEl = titleEl;
state.subtitleEl = subtitleEl;
state.statusEl = statusEl;
state.saveBtnEl = saveBtnEl;
state.resizeEl = resizeEl;
state.nameInputEl = nameInputEl;
state.tabButtonsEl = tabButtonsEl;
state.informationTabBtnEl = informationTabBtnEl;
state.tagsTabBtnEl = tagsTabBtnEl;
state.informationPaneEl = informationPaneEl;
state.tagsPaneEl = tagsPaneEl;
state.typeSelectEl = typeSelectEl;
state.layerSelectEl = layerSelectEl;
state.factionSelectEl = factionSelectEl;
state.spriteSelectEl = spriteSelectEl;
state.dialogueSelectEl = dialogueSelectEl;
state.descriptionInputEl = descriptionInputEl;
state.positionValueEl = positionValueEl;
state.tagInputEl = tagInputEl;
state.tagListEl = tagListEl;
uiScope.toolWindowLayerEl?.appendChild(shellEl);
applyWindowRect();
shellEl.classList.toggle("hidden", state.visible !== true);
refreshHeader();
renderTags();
return shellEl;
}
function open(entityId) {
const normalizedId = String(entityId || "").trim();
if (!normalizedId) {
return false;
}
if (state.entityId && state.entityId !== normalizedId && !confirmDiscardIfDirty()) {
return false;
}
ensureShell();
if (!loadEntity(normalizedId)) {
scope.setStatus?.("Entity not found: " + normalizedId, true);
return false;
}
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
state.visible = true;
state.shellEl?.classList.remove("hidden");
applyWindowRect();
focusWindow();
persistState();
return true;
}
function close() {
if (!confirmDiscardIfDirty()) {
return false;
}
state.visible = false;
state.dirty = false;
clearFocus();
state.shellEl?.classList.add("hidden");
persistState();
return true;
}
function initialize() {
if (initialized) {
return;
}
initialized = true;
ensureShell();
window.addEventListener("resize", () => {
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
applyWindowRect();
persistState();
});
if (state.visible && state.entityId) {
open(state.entityId);
} else {
state.visible = false;
state.shellEl?.classList.add("hidden");
}
}
return {
initialize,
open,
close,
isOpen: () => state.visible === true,
};
}

View file

@ -0,0 +1,39 @@
export type FloatingWindowLayerRect = {
width?: number;
height?: number;
};
export function clampFloatingWindowRect(
layerRect: FloatingWindowLayerRect | null | undefined,
left: unknown,
top: unknown,
width: unknown,
height: unknown,
minWidth: number,
minHeight: number,
defaultWidth: number,
defaultHeight: number,
) {
const safeWidth = Math.max(
minWidth,
Math.min(
Math.max(minWidth, Number(width) || defaultWidth),
Math.max(minWidth, (Number(layerRect?.width) || defaultWidth) - 12),
),
);
const safeHeight = Math.max(
minHeight,
Math.min(
Math.max(minHeight, Number(height) || defaultHeight),
Math.max(minHeight, (Number(layerRect?.height) || defaultHeight) - 12),
),
);
const maxLeft = Math.max(0, (Number(layerRect?.width) || safeWidth) - safeWidth);
const maxTop = Math.max(0, (Number(layerRect?.height) || safeHeight) - safeHeight);
return {
left: Math.max(0, Math.min(maxLeft, Number(left) || 0)),
top: Math.max(0, Math.min(maxTop, Number(top) || 0)),
width: safeWidth,
height: safeHeight,
};
}

View file

@ -0,0 +1,354 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import {
getFolderIdFromNodeId,
getItemIdFromNodeId,
} from "./panelFolders";
import {
menuItem,
menuLabel,
openContextMenuAtPoint,
} from "./contextMenuSchema";
function clearDropClasses(container) {
if (!container) {
return;
}
container.querySelectorAll(".folder-drop-before, .folder-drop-after, .folder-drop-inside, .folder-root-drop-active")
.forEach((node) => {
node.classList.remove("folder-drop-before", "folder-drop-after", "folder-drop-inside", "folder-root-drop-active");
});
}
function beginRowDrag(scope, panelKey, dragDescriptor, handle, container, event) {
if (!dragDescriptor || !dragDescriptor.kind || !dragDescriptor.id) {
event.preventDefault();
return;
}
scope.organizedListDrag = {
panelKey,
kind: dragDescriptor.kind,
id: String(dragDescriptor.id || ""),
};
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setData("text/plain", JSON.stringify(scope.organizedListDrag));
}
handle.classList.add("dragging");
container.classList.add("folder-list-dragging");
}
function finishRowDrag(scope, handle, container) {
scope.organizedListDrag = null;
if (handle) {
handle.classList.remove("dragging");
}
if (container) {
container.classList.remove("folder-list-dragging");
clearDropClasses(container);
}
}
function bindDragHandle(scope, panelKey, container, row, dragDescriptor) {
const header = row.querySelector(".npc-row-header") || row.querySelector(".folder-row-header") || row;
const handle = document.createElement("button");
handle.type = "button";
handle.className = "selector-drag-handle";
handle.title = "Drag to reorder";
handle.setAttribute("aria-label", handle.title);
handle.innerHTML = '<span class="selector-drag-icon">&#8597;</span>';
handle.setAttribute("draggable", "true");
handle.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
});
handle.addEventListener("dragstart", (event) => {
event.stopPropagation();
beginRowDrag(scope, panelKey, dragDescriptor, handle, container, event);
});
handle.addEventListener("dragend", () => {
finishRowDrag(scope, handle, container);
});
header.insertBefore(handle, header.firstChild);
}
function bindDropTarget(scope, panelKey, container, targetEl, resolveDropInfo, onMove) {
if (!targetEl) {
return;
}
targetEl.addEventListener("dragover", (event) => {
const dragging = scope.organizedListDrag;
if (!dragging || dragging.panelKey !== panelKey) {
return;
}
const dropInfo = resolveDropInfo(event, dragging);
if (!dropInfo) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
clearDropClasses(container);
if (dropInfo.kind === "root") {
targetEl.classList.add("folder-root-drop-active");
} else if (dropInfo.position === "inside") {
targetEl.classList.add("folder-drop-inside");
} else if (dropInfo.position === "after") {
targetEl.classList.add("folder-drop-after");
} else {
targetEl.classList.add("folder-drop-before");
}
});
targetEl.addEventListener("drop", (event) => {
const dragging = scope.organizedListDrag;
if (!dragging || dragging.panelKey !== panelKey) {
return;
}
const dropInfo = resolveDropInfo(event, dragging);
if (!dropInfo) {
return;
}
event.preventDefault();
clearDropClasses(container);
onMove(dragging, dropInfo);
});
}
function openFolderContextMenu(scope, event, options) {
const {
panelKey,
folder,
folderId,
onToggleFolder,
onRenameFolder,
onDeleteFolder,
} = options || {};
if (!scope?.atTooltip || !event || !folderId) {
return false;
}
event.preventDefault();
event.stopPropagation();
return openContextMenuAtPoint(scope.atTooltip, event.clientX, event.clientY, [
menuLabel(folder?.name || "New Folder"),
menuItem("<span>" + (folder?.collapsed ? "Expand folder" : "Collapse folder") + "</span>", () => {
onToggleFolder?.(folderId);
scope.atTooltip.close();
}),
menuItem("<span>Rename folder</span>", () => {
onRenameFolder?.(folderId);
scope.atTooltip.close();
}),
menuItem("<span>Delete folder</span>", () => {
onDeleteFolder?.(folderId);
scope.atTooltip.close();
}),
], String(panelKey || "") + ":folder:" + String(folderId || ""));
}
export function renderFolderedSelectorList(options) {
const {
scope,
container,
panelKey,
items,
getItemId,
renderItemRow,
emptyMessage,
baseLabel,
onMove,
onToggleFolder,
onRenameFolder,
onDeleteFolder,
} = options;
if (!container) {
return;
}
const validItems = Array.isArray(items) ? items.slice() : [];
const itemById = new Map();
const itemIds = validItems
.map((entry) => {
const itemId = String(getItemId(entry) || "").trim();
if (!itemId) {
return "";
}
itemById.set(itemId, entry);
return itemId;
})
.filter(Boolean);
const layout = scope.getPanelLayout(panelKey, itemIds);
container.innerHTML = "";
const root = document.createElement("div");
root.className = "folder-list-root";
container.appendChild(root);
const appendFolderNode = (folderId) => {
const folder = layout.folders[folderId];
if (!folder) {
return;
}
const folderWrap = document.createElement("div");
folderWrap.className = "folder-block" + (folder.collapsed ? " collapsed" : "");
const folderHeader = document.createElement("div");
folderHeader.className = "history-row npc-row folder-row";
const headerInner = document.createElement("div");
headerInner.className = "folder-row-header";
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.className = "folder-toggle-btn";
toggleBtn.innerHTML = '<span class="folder-toggle-icon">' + (folder.collapsed ? "&#9656;" : "&#9662;") + "</span>";
toggleBtn.title = folder.collapsed ? "Expand folder" : "Collapse folder";
toggleBtn.setAttribute("aria-label", toggleBtn.title);
toggleBtn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onToggleFolder(folderId);
});
headerInner.appendChild(toggleBtn);
const folderIcon = document.createElement("span");
folderIcon.className = "folder-row-icon";
folderIcon.innerHTML = "&#128193;";
headerInner.appendChild(folderIcon);
const titleWrap = document.createElement("div");
titleWrap.className = "folder-row-copy";
titleWrap.innerHTML =
"<span>" + scope.runtimeEscapeHtml(folder.name || "New Folder") + "</span>" +
'<span class="history-meta">' + String(folder.itemOrder.length) + " item" + (folder.itemOrder.length === 1 ? "" : "s") + "</span>";
headerInner.appendChild(titleWrap);
folderHeader.appendChild(headerInner);
folderHeader.addEventListener("click", () => onToggleFolder(folderId));
folderHeader.addEventListener("contextmenu", (event) => {
openFolderContextMenu(scope, event, {
panelKey,
folder,
folderId,
onToggleFolder,
onRenameFolder,
onDeleteFolder,
});
});
bindDragHandle(scope, panelKey, root, folderHeader, { kind: "folder", id: folderId });
bindDropTarget(scope, panelKey, root, folderHeader, (event, dragging) => {
if (dragging.kind === "item") {
return { kind: "folder", id: folderId, position: "inside" };
}
if (dragging.kind === "folder") {
const rect = folderHeader.getBoundingClientRect();
return {
kind: "folder",
id: folderId,
position: event.clientY < rect.top + (rect.height / 2) ? "before" : "after",
};
}
return null;
}, onMove);
folderWrap.appendChild(folderHeader);
const folderBody = document.createElement("div");
folderBody.className = "folder-children";
bindDropTarget(scope, panelKey, root, folderBody, (_event, dragging) => {
if (dragging.kind !== "item") {
return null;
}
return { kind: "folder", id: folderId, position: "inside" };
}, onMove);
if (!folder.collapsed) {
folder.itemOrder.forEach((itemId) => {
const item = itemById.get(itemId);
if (!item) {
return;
}
const row = renderItemRow(item, { parentFolderId: folderId });
bindDragHandle(scope, panelKey, root, row, { kind: "item", id: itemId, parentFolderId: folderId });
bindDropTarget(scope, panelKey, root, row, (event, dragging) => {
if (dragging.kind === "folder") {
return null;
}
const rect = row.getBoundingClientRect();
return {
kind: "item",
id: itemId,
parentFolderId: folderId,
position: event.clientY < rect.top + (rect.height / 2) ? "before" : "after",
};
}, onMove);
folderBody.appendChild(row);
});
if (folder.itemOrder.length === 0) {
const emptyFolder = document.createElement("div");
emptyFolder.className = "folder-empty";
emptyFolder.textContent = "Drop selectors here";
folderBody.appendChild(emptyFolder);
}
}
folderWrap.appendChild(folderBody);
root.appendChild(folderWrap);
};
layout.rootOrder.forEach((nodeId) => {
const folderId = getFolderIdFromNodeId(nodeId);
if (folderId) {
appendFolderNode(folderId);
return;
}
const itemId = getItemIdFromNodeId(nodeId);
const item = itemById.get(itemId);
if (!item) {
return;
}
const row = renderItemRow(item, { parentFolderId: "" });
bindDragHandle(scope, panelKey, root, row, { kind: "item", id: itemId, parentFolderId: "" });
bindDropTarget(scope, panelKey, root, row, (event, dragging) => {
if (dragging.kind === "folder") {
const rect = row.getBoundingClientRect();
return {
kind: "item",
id: itemId,
parentFolderId: "",
position: event.clientY < rect.top + (rect.height / 2) ? "before" : "after",
};
}
const rect = row.getBoundingClientRect();
return {
kind: "item",
id: itemId,
parentFolderId: "",
position: event.clientY < rect.top + (rect.height / 2) ? "before" : "after",
};
}, onMove);
root.appendChild(row);
});
const hasRootNodes = layout.rootOrder.length > 0;
if (!hasRootNodes && emptyMessage) {
const empty = document.createElement("p");
empty.className = "muted folder-list-empty";
empty.textContent = emptyMessage;
root.appendChild(empty);
}
if (hasRootNodes) {
const baseDropZone = document.createElement("div");
baseDropZone.className = "folder-root-drop-zone";
baseDropZone.textContent = baseLabel || "Base Panel";
bindDropTarget(scope, panelKey, root, baseDropZone, (_event, dragging) => {
if (!dragging || !dragging.kind) {
return null;
}
return {
kind: "root",
id: "",
position: "inside",
};
}, onMove);
root.appendChild(baseDropZone);
}
}

View file

@ -0,0 +1,145 @@
import {
getSpriteRows,
normalizeImageRecordForSave,
normalizeImagesPayloadForSave,
normalizeTileRecordForSave,
type JsonObject,
type JsonValue,
} from "../editorCore";
export type GraphicRole = "tile" | "sprite" | "other";
export function normalizeGraphicRoles(value: unknown): string[] {
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 hydrateImageRecordRows(entry: JsonObject | null | undefined): JsonObject | null {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return null;
}
return {
...entry,
rows: getSpriteRows(entry),
};
}
export function getImageRecordFromPayload(
imagesPayload: JsonObject | null | undefined,
recordId: string,
): JsonObject | null {
const normalizedId = String(recordId || "").trim();
if (!normalizedId) {
return null;
}
const records = Array.isArray(imagesPayload?.images) ? imagesPayload.images : [];
const matchedEntry = (records.find((entry) => (
entry
&& typeof entry === "object"
&& !Array.isArray(entry)
&& String((entry as JsonObject).id || "").trim() === normalizedId
)) as JsonObject | undefined) || null;
return hydrateImageRecordRows(matchedEntry);
}
export function buildImageRecordFromTileRecord(
record: JsonObject,
existingRecord?: JsonObject | null,
cloneValue: <T>(value: T) => T = structuredClone,
): JsonObject {
const existing = existingRecord && typeof existingRecord === "object" && !Array.isArray(existingRecord)
? existingRecord
: {};
const existingRoles = normalizeGraphicRoles(existing.roles);
return normalizeImageRecordForSave({
...cloneValue(existing),
id: String(record?.id || existing.id || "").trim(),
name: String(record?.name || existing.name || "").trim(),
description: String(record?.description || existing.description || "").trim(),
width: Math.max(1, Number(record?.width) || Number(existing.width) || 16),
height: Math.max(1, Number(record?.height) || Number(existing.height) || 16),
pixelScale: Math.max(1, Number(record?.pixelScale) || Number(existing.pixelScale) || 1),
opacity: Math.max(0, Math.min(1, Number(record?.opacity ?? existing.opacity ?? 1))),
rows: Array.isArray(record?.rows)
? record.rows.map((row) => String(row || ""))
: getSpriteRows(existing),
tags: cloneValue(record?.tags) || cloneValue(existing.tags) || [],
roles: Array.from(new Set([...existingRoles, "tile"])),
tileSymbol: String(record?.tileSymbol || record?.symbol || existing.tileSymbol || "").trim().charAt(0),
});
}
export function buildImageRecordFromSpriteRecord(
record: JsonObject,
graphicRole: GraphicRole,
existingRecord?: JsonObject | null,
cloneValue: <T>(value: T) => T = structuredClone,
): JsonObject {
const existing = existingRecord && typeof existingRecord === "object" && !Array.isArray(existingRecord)
? existingRecord
: {};
const existingRoles = normalizeGraphicRoles(existing.roles);
const wantsSpriteRole = graphicRole !== "other";
const nextRoles = wantsSpriteRole
? Array.from(new Set([...existingRoles, "sprite"]))
: existingRoles.filter((entry) => entry !== "sprite");
return normalizeImageRecordForSave({
...cloneValue(existing),
id: String(record?.id || existing.id || "").trim(),
name: String(record?.name || existing.name || "").trim(),
description: String(record?.description || existing.description || "").trim(),
width: Math.max(1, Number(record?.width) || Number(existing.width) || 16),
height: Math.max(1, Number(record?.height) || Number(existing.height) || 16),
pixelScale: Math.max(1, Number(record?.pixelScale) || Number(existing.pixelScale) || 1),
opacity: Math.max(0, Math.min(1, Number(record?.opacity ?? existing.opacity ?? 1))),
rows: Array.isArray(record?.rows)
? record.rows.map((row) => String(row || ""))
: getSpriteRows(existing),
tags: cloneValue(record?.tags) || cloneValue(existing.tags) || [],
roles: nextRoles,
tileSymbol: String(existing.tileSymbol || record?.tileSymbol || record?.symbol || "").trim().charAt(0),
});
}
export function normalizeImagesPayloadSnapshot(
payload: JsonObject | null | undefined,
cloneValue: <T>(value: T) => T = structuredClone,
): JsonObject {
const normalized = (normalizeImagesPayloadForSave(cloneValue(payload || { schemaVersion: 1, images: [] }) as JsonValue) as JsonObject) || {
schemaVersion: 1,
images: [],
};
const records = Array.isArray(normalized.images) ? normalized.images : [];
return {
...normalized,
images: records.map((entry) => {
const hydratedEntry = hydrateImageRecordRows(
entry && typeof entry === "object" && !Array.isArray(entry)
? entry as JsonObject
: null,
);
return hydratedEntry || entry;
}),
};
}
export function buildTileRecordFromImageRecord(entry: JsonObject, cloneValue: <T>(value: T) => T = structuredClone): JsonObject {
return normalizeTileRecordForSave({
id: String(entry.id || "").trim(),
symbol: String(entry.tileSymbol || entry.symbol || "").trim().charAt(0),
name: String(entry.name || "").trim(),
description: String(entry.description || "").trim(),
width: Number(entry.width) || 16,
height: Number(entry.height) || 16,
pixelScale: Number(entry.pixelScale) || 1,
opacity: Number(entry.opacity ?? 1),
rows: getSpriteRows(entry),
tags: cloneValue(entry.tags) || [],
});
}

View file

@ -0,0 +1,852 @@
/* eslint-disable @typescript-eslint/ban-ts-comment, no-empty */
// @ts-nocheck
export function createHistoryController(scope) {
const documentScope = scope.documentScope || scope;
const renderScope = scope.renderScope || scope;
const historyScope = scope.historyScope || scope;
const uiScope = scope.uiScope || scope;
const sessionScope = scope.sessionScope || scope;
const MAX_HISTORY_ENTRIES = 40;
const MAX_PERSISTED_HISTORY_CHARS = 1_500_000;
const OPERATION_CHECKPOINT_INTERVAL = 12;
let pendingPersistTimer = 0;
function cloneValue(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function clearPendingPersistTimer() {
if (!pendingPersistTimer) {
return;
}
window.clearTimeout(pendingPersistTimer);
pendingPersistTimer = 0;
}
function persistHistoryState() {
clearPendingPersistTimer();
try {
const savedIndex = Math.max(0, Math.min(
scope.historyEntries.findIndex((entry) => Number(entry?.id) === Number(historyScope.lastSavedHistoryId)),
scope.historyEntries.length - 1,
));
const savedState = scope.historyEntries.length > 0
? captureHistoryStateAtIndex(savedIndex >= 0 ? savedIndex : scope.historyIndex)
: captureState();
const payload = {
mapId: String(documentScope.mapId || scope.mapId || ""),
savedStateSignature: getStateSignature(savedState),
historyEntries: historyScope.historyEntries,
historyIndex: historyScope.historyIndex,
historySelectionIndex: historyScope.historySelectionIndex,
nextHistoryId: historyScope.nextHistoryId,
lastSavedHistoryId: historyScope.lastSavedHistoryId,
};
const serialized = JSON.stringify(payload);
if (serialized.length > MAX_PERSISTED_HISTORY_CHARS) {
window.localStorage.removeItem(historyScope.historyStorageKey);
return false;
}
window.localStorage.setItem(historyScope.historyStorageKey, serialized);
return true;
} catch {}
return false;
}
function schedulePersistHistoryState() {
clearPendingPersistTimer();
pendingPersistTimer = window.setTimeout(() => {
pendingPersistTimer = 0;
persistHistoryState();
}, 120);
return true;
}
function restoreHistoryState() {
try {
const raw = window.localStorage.getItem(historyScope.historyStorageKey);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return null;
}
return parsed;
} catch {
return null;
}
}
function applyHistorySnapshot(snapshot) {
if (!snapshot || typeof snapshot !== "object") {
return false;
}
const snapshotMapId = String(snapshot.mapId || "").trim();
const currentMapId = String(documentScope.mapId || scope.mapId || "").trim();
if (snapshotMapId && currentMapId && snapshotMapId !== currentMapId) {
return false;
}
const entries = Array.isArray(snapshot.historyEntries) ? snapshot.historyEntries : null;
if (!entries || entries.length === 0) {
return false;
}
const savedId = Number(snapshot.lastSavedHistoryId) || 0;
const currentId = Number(snapshot.historyEntries?.[Number(snapshot.historyIndex) || 0]?.id) || 0;
if (!savedId || !currentId || savedId !== currentId) {
return false;
}
const savedStateSignature = String(snapshot.savedStateSignature || "").trim();
if (savedStateSignature) {
const currentLoadedSignature = getStateSignature(captureState());
if (savedStateSignature !== currentLoadedSignature) {
return false;
}
}
historyScope.historyEntries = entries;
historyScope.historyIndex = Math.max(0, Math.min(Number(snapshot.historyIndex) || 0, historyScope.historyEntries.length - 1));
historyScope.historySelectionIndex = Math.max(0, Math.min(Number(snapshot.historySelectionIndex) || historyScope.historyIndex, historyScope.historyEntries.length - 1));
historyScope.nextHistoryId = Math.max(1, Number(snapshot.nextHistoryId) || (historyScope.historyEntries[historyScope.historyEntries.length - 1]?.seq || 0) + 1);
historyScope.lastSavedHistoryId = Math.max(1, Number(snapshot.lastSavedHistoryId) || historyScope.historyEntries[historyScope.historyIndex]?.id || 1);
if (!restoreToHistoryIndex(historyScope.historyIndex)) {
const currentState = historyScope.historyEntries[historyScope.historyIndex] && historyScope.historyEntries[historyScope.historyIndex].state ? historyScope.historyEntries[historyScope.historyIndex].state : null;
if (currentState) {
applyState(currentState);
}
}
return true;
}
function captureState() {
return {
width: Number(documentScope.width) || 1,
height: Number(documentScope.height) || 1,
mapName: String(documentScope.mapName || scope.mapId || ""),
backgroundColor: documentScope.normalizeMapBackgroundColor(documentScope.backgroundColor),
backgroundTileId: String(documentScope.backgroundTileId || "").trim(),
heightBlurStep: Math.max(0, Math.min(1, Number(documentScope.heightBlurStep ?? documentScope.heightDetailStep) || 0.1)),
layers: documentScope.cloneLayers(documentScope.roomLayers),
heightLayers: documentScope.cloneHeightLayers(documentScope.heightLayers),
npcs: documentScope.cloneNpcOverlays(documentScope.npcOverlays),
worldChunkBackgrounds: typeof scope.captureWorldChunkBackgroundState === "function"
? scope.captureWorldChunkBackgroundState()
: {},
worldBookmarks: typeof scope.captureWorldBookmarkState === "function"
? scope.captureWorldBookmarkState()
: [],
editorUi: documentScope.cloneEditorUiState(),
};
}
function refreshUiAfterHistoryMutation() {
documentScope.ensureBaseLayer();
sessionScope.activeLayer = documentScope.roomLayers.some((layer) => layer.layer === sessionScope.activeLayer) ? sessionScope.activeLayer : 0;
if (!documentScope.npcOverlays.some((npc) => npc.id === sessionScope.selectedNpcId)) {
sessionScope.selectedNpcId = documentScope.npcOverlays[0] ? String(documentScope.npcOverlays[0].id || "") : "";
}
if (uiScope.refreshInstanceSectionState) {
uiScope.refreshInstanceSectionState();
}
uiScope.renderPaintPalette();
if (uiScope.renderHeightLayerList) {
uiScope.renderHeightLayerList();
}
uiScope.renderInstancePalette();
uiScope.renderLayerList();
uiScope.renderNpcList();
if (uiScope.renderTriggerList) {
uiScope.renderTriggerList();
}
if (uiScope.renderMonsterList) {
uiScope.renderMonsterList();
}
if (uiScope.renderPathList) {
uiScope.renderPathList();
}
if (uiScope.renderTransitionList) {
uiScope.renderTransitionList();
}
uiScope.refreshInformationPanel();
if (typeof scope.refreshWorldOverviewWindow === "function") {
scope.refreshWorldOverviewWindow();
}
renderScope.draw();
}
function applyState(state, options) {
const config = options && typeof options === "object" ? options : {};
documentScope.width = Math.max(1, Number(state?.width) || documentScope.width || 1);
documentScope.height = Math.max(1, Number(state?.height) || documentScope.height || 1);
documentScope.mapName = String(state?.mapName || scope.mapId || documentScope.mapName || "");
documentScope.backgroundColor = documentScope.normalizeMapBackgroundColor(state?.backgroundColor || documentScope.backgroundColor);
documentScope.backgroundTileId = documentScope.normalizeBackgroundTileId(state?.backgroundTileId);
documentScope.heightBlurStep = Math.max(0, Math.min(1, Number(state?.heightBlurStep ?? state?.heightDetailStep) || documentScope.heightBlurStep || documentScope.heightDetailStep || 0.1));
documentScope.roomLayers = documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []);
documentScope.heightLayers = documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []);
const nextNpcs = documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []);
sessionScope.editorUiState = state && state.editorUi ? documentScope.cloneEditorUiState(state.editorUi) : { panelLayouts: {} };
if (!documentScope.getHeightLayerById(sessionScope.activeHeightLayerId)) {
sessionScope.activeHeightLayerId = String(documentScope.heightLayers[0]?.id || "").trim();
}
if (sessionScope.editingTargetKind === "height" && !sessionScope.activeHeightLayerId) {
sessionScope.editingTargetKind = "room";
}
nextNpcs.forEach((npc) => documentScope.syncNpcOverlayFromRecord(npc));
documentScope.npcOverlays.length = 0;
nextNpcs.forEach((npc) => documentScope.npcOverlays.push(npc));
if (typeof scope.applyWorldChunkBackgroundState === "function" && scope.isWorldModeActive?.()) {
scope.applyWorldChunkBackgroundState(state?.worldChunkBackgrounds || {});
}
if (typeof scope.applyWorldBookmarkState === "function" && scope.isWorldModeActive?.()) {
scope.applyWorldBookmarkState(state?.worldBookmarks || []);
}
if (typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
scope.rebuildVisibleWorldChunksFromDocument();
}
if (!config.deferRefresh) {
refreshUiAfterHistoryMutation();
}
}
function ensureLayerForOperation(layerNumber) {
const normalizedLayer = Number(layerNumber) || 0;
let layerEntry = scope.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || null;
if (layerEntry) {
return layerEntry;
}
layerEntry = {
layer: normalizedLayer,
name: undefined,
zIndex: 0,
rows: scope.normalizeRows([], normalizedLayer === 0 ? "." : " "),
instanceIds: [],
};
scope.roomLayers.push(layerEntry);
scope.roomLayers = scope.roomLayers
.slice()
.sort((left, right) => Number(left.layer) - Number(right.layer));
return scope.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || layerEntry;
}
function setStoredTileCharAt(layerNumber, tileX, tileY, nextStoredChar) {
if (tileX < 0 || tileX >= scope.width || tileY < 0 || tileY >= scope.height) {
return false;
}
const normalizedLayer = Number(layerNumber) || 0;
const layerEntry = ensureLayerForOperation(normalizedLayer);
const fillChar = normalizedLayer === 0 ? "." : " ";
const rows = scope.normalizeRows(layerEntry.rows, fillChar);
const row = rows[tileY] || fillChar.repeat(scope.width);
const safeChar = String(nextStoredChar || fillChar).charAt(0) || fillChar;
if ((row.charAt(tileX) || fillChar) === safeChar) {
return false;
}
rows[tileY] = row.slice(0, tileX) + safeChar + row.slice(tileX + 1);
layerEntry.rows = rows;
if (typeof scope.syncWorldChunkCellFromLocalTile === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
scope.syncWorldChunkCellFromLocalTile(normalizedLayer, tileX, tileY, safeChar);
}
return true;
}
function resolveTileOperationCellCoord(cell) {
if (typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
const worldX = Number(cell?.worldX);
const worldY = Number(cell?.worldY);
if (Number.isFinite(worldX) && Number.isFinite(worldY)) {
return {
x: Math.floor(worldX - (Number(scope.worldTileOffsetX) || 0)),
y: Math.floor(worldY - (Number(scope.worldTileOffsetY) || 0)),
};
}
}
return {
x: Math.floor(Number(cell?.x) || 0),
y: Math.floor(Number(cell?.y) || 0),
};
}
function applyTileCellsOperation(operation, direction) {
const isRedo = direction !== "undo";
const nextBackgroundTileId = isRedo
? operation.afterBackgroundTileId
: operation.beforeBackgroundTileId;
if (nextBackgroundTileId !== undefined) {
scope.backgroundTileId = scope.normalizeBackgroundTileId(nextBackgroundTileId);
}
const cells = Array.isArray(operation.cells) ? operation.cells : [];
cells.forEach((cell) => {
const resolvedCoord = resolveTileOperationCellCoord(cell, scope.width, scope.height);
const nextStoredChar = isRedo ? cell.afterStoredChar : cell.beforeStoredChar;
setStoredTileCharAt(cell.layer, resolvedCoord.x, resolvedCoord.y, nextStoredChar);
});
if (nextBackgroundTileId !== undefined && typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
scope.rebuildVisibleWorldChunksFromDocument();
}
scope.invalidateTileSurface();
}
function buildNpcTargetEntries(operation, direction) {
const useAfter = direction !== "undo";
const rawEntries = Array.isArray(operation.entries) ? operation.entries : [];
return rawEntries
.map((entry) => {
const snapshot = useAfter ? entry.after : entry.before;
const targetIndex = useAfter ? entry.afterIndex : entry.beforeIndex;
if (!snapshot || typeof snapshot !== "object") {
return null;
}
const cloned = scope.cloneNpcOverlays([cloneValue(snapshot)])[0];
if (!cloned) {
return null;
}
scope.syncNpcOverlayFromRecord(cloned);
return {
npc: cloned,
index: Math.max(0, Number(targetIndex) || 0),
};
})
.filter((entry) => entry !== null)
.sort((left, right) => left.index - right.index);
}
function applyNpcEntriesOperation(operation, direction) {
const rawEntries = Array.isArray(operation.entries) ? operation.entries : [];
const touchedPositions = [];
rawEntries.forEach((entry) => {
const beforePos = entry?.before && typeof entry.before === "object" ? entry.before : null;
const afterPos = entry?.after && typeof entry.after === "object" ? entry.after : null;
if (beforePos) {
const x = Math.floor(Number(beforePos.x));
const y = Math.floor(Number(beforePos.y));
if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0) {
touchedPositions.push({ x, y });
}
}
if (afterPos) {
const x = Math.floor(Number(afterPos.x));
const y = Math.floor(Number(afterPos.y));
if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0) {
touchedPositions.push({ x, y });
}
}
});
const affectedIds = new Set(
rawEntries.flatMap((entry) => {
const ids = [];
const beforeId = String(entry?.before?.id || "").trim();
const afterId = String(entry?.after?.id || "").trim();
if (beforeId) {
ids.push(beforeId);
}
if (afterId) {
ids.push(afterId);
}
return ids;
}),
);
if (affectedIds.size === 0) {
return;
}
const remainingNpcs = scope.npcOverlays.filter((npc) => !affectedIds.has(String(npc.id || "").trim()));
scope.npcOverlays.length = 0;
remainingNpcs.forEach((npc) => scope.npcOverlays.push(npc));
affectedIds.forEach((npcId) => {
delete scope.npcImages[npcId];
});
const targetEntries = buildNpcTargetEntries(operation, direction);
targetEntries.forEach((entry) => {
const nextIndex = Math.max(0, Math.min(scope.npcOverlays.length, entry.index));
scope.ensureNpcImageLoaded(entry.npc);
scope.npcOverlays.splice(nextIndex, 0, entry.npc);
});
if (typeof scope.rebuildWorldChunksForLocalBounds === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive() && touchedPositions.length > 0) {
const xs = touchedPositions.map((entry) => entry.x);
const ys = touchedPositions.map((entry) => entry.y);
scope.rebuildWorldChunksForLocalBounds({
minX: Math.min(...xs),
minY: Math.min(...ys),
maxX: Math.max(...xs),
maxY: Math.max(...ys),
});
}
}
function applyOperation(operation, direction, options) {
const config = options && typeof options === "object" ? options : {};
if (!operation || typeof operation !== "object") {
return false;
}
if (operation.type === "tile_cells") {
applyTileCellsOperation(operation, direction);
} else if (operation.type === "npc_entries") {
applyNpcEntriesOperation(operation, direction);
} else {
return false;
}
if (!config.deferRefresh) {
refreshUiAfterHistoryMutation();
}
return true;
}
function cloneHistoryState(state) {
if (!state || typeof state !== "object") {
return captureState();
}
return {
width: Math.max(1, Number(state.width) || 1),
height: Math.max(1, Number(state.height) || 1),
mapName: String(state.mapName || scope.mapId || ""),
backgroundColor: documentScope.normalizeMapBackgroundColor(state.backgroundColor),
backgroundTileId: documentScope.normalizeBackgroundTileId(state.backgroundTileId),
heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)),
layers: documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []),
heightLayers: documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []),
npcs: documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []),
editorUi: documentScope.cloneEditorUiState(state.editorUi || {}),
};
}
function ensureLayerForStateOperation(state, layerNumber) {
const normalizedLayer = Number(layerNumber) || 0;
let layerEntry = state.layers.find((layer) => Number(layer.layer) === normalizedLayer) || null;
if (layerEntry) {
return layerEntry;
}
layerEntry = {
layer: normalizedLayer,
name: undefined,
zIndex: 0,
rows: scope.normalizeRows([], normalizedLayer === 0 ? "." : " "),
instanceIds: [],
};
state.layers.push(layerEntry);
state.layers = state.layers
.slice()
.sort((left, right) => Number(left.layer) - Number(right.layer));
return state.layers.find((layer) => Number(layer.layer) === normalizedLayer) || layerEntry;
}
function setStoredTileCharAtInState(state, layerNumber, tileX, tileY, nextStoredChar) {
if (tileX < 0 || tileX >= state.width || tileY < 0 || tileY >= state.height) {
return false;
}
const normalizedLayer = Number(layerNumber) || 0;
const layerEntry = ensureLayerForStateOperation(state, normalizedLayer);
const fillChar = normalizedLayer === 0 ? "." : " ";
const rows = scope.normalizeRows(layerEntry.rows, fillChar);
const row = rows[tileY] || fillChar.repeat(state.width);
const safeChar = String(nextStoredChar || fillChar).charAt(0) || fillChar;
if ((row.charAt(tileX) || fillChar) === safeChar) {
return false;
}
rows[tileY] = row.slice(0, tileX) + safeChar + row.slice(tileX + 1);
layerEntry.rows = rows;
return true;
}
function applyTileCellsOperationToState(state, operation, direction) {
const nextState = cloneHistoryState(state);
const isRedo = direction !== "undo";
const nextBackgroundTileId = isRedo
? operation.afterBackgroundTileId
: operation.beforeBackgroundTileId;
if (nextBackgroundTileId !== undefined) {
nextState.backgroundTileId = documentScope.normalizeBackgroundTileId(nextBackgroundTileId);
}
const cells = Array.isArray(operation.cells) ? operation.cells : [];
cells.forEach((cell) => {
const resolvedCoord = resolveTileOperationCellCoord(cell, nextState.width, nextState.height);
const nextStoredChar = isRedo ? cell.afterStoredChar : cell.beforeStoredChar;
setStoredTileCharAtInState(nextState, cell.layer, resolvedCoord.x, resolvedCoord.y, nextStoredChar);
});
return nextState;
}
function applyNpcEntriesOperationToState(state, operation, direction) {
const nextState = cloneHistoryState(state);
const rawEntries = Array.isArray(operation.entries) ? operation.entries : [];
const affectedIds = new Set(
rawEntries.flatMap((entry) => {
const ids = [];
const beforeId = String(entry?.before?.id || "").trim();
const afterId = String(entry?.after?.id || "").trim();
if (beforeId) {
ids.push(beforeId);
}
if (afterId) {
ids.push(afterId);
}
return ids;
}),
);
if (affectedIds.size === 0) {
return nextState;
}
const useAfter = direction !== "undo";
const remainingNpcs = nextState.npcs.filter((npc) => !affectedIds.has(String(npc.id || "").trim()));
const targetEntries = rawEntries
.map((entry) => {
const snapshot = useAfter ? entry.after : entry.before;
const targetIndex = useAfter ? entry.afterIndex : entry.beforeIndex;
if (!snapshot || typeof snapshot !== "object") {
return null;
}
const cloned = documentScope.cloneNpcOverlays([cloneValue(snapshot)])[0];
if (!cloned) {
return null;
}
documentScope.syncNpcOverlayFromRecord(cloned);
return {
npc: cloned,
index: Math.max(0, Number(targetIndex) || 0),
};
})
.filter((entry) => entry !== null)
.sort((left, right) => left.index - right.index);
nextState.npcs = remainingNpcs;
targetEntries.forEach((entry) => {
const nextIndex = Math.max(0, Math.min(nextState.npcs.length, entry.index));
nextState.npcs.splice(nextIndex, 0, entry.npc);
});
return nextState;
}
function captureHistoryStateAtIndex(targetIndex) {
const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1));
const targetEntry = scope.historyEntries[normalizedTargetIndex] || null;
if (!targetEntry) {
return captureState();
}
if (targetEntry.state) {
return cloneHistoryState(targetEntry.state);
}
const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex);
if (snapshotIndex < 0) {
return captureState();
}
let nextState = cloneHistoryState(scope.historyEntries[snapshotIndex].state);
for (let index = snapshotIndex + 1; index <= normalizedTargetIndex; index += 1) {
const entry = scope.historyEntries[index];
if (!entry) {
continue;
}
if (entry.state) {
nextState = cloneHistoryState(entry.state);
continue;
}
if (!entry.operation || typeof entry.operation !== "object") {
continue;
}
if (entry.operation.type === "tile_cells") {
nextState = applyTileCellsOperationToState(nextState, entry.operation, "redo");
} else if (entry.operation.type === "npc_entries") {
nextState = applyNpcEntriesOperationToState(nextState, entry.operation, "redo");
}
}
return nextState;
}
function findNearestSnapshotIndex(targetIndex) {
for (let index = Math.max(0, Number(targetIndex) || 0); index >= 0; index -= 1) {
if (scope.historyEntries[index] && scope.historyEntries[index].state) {
return index;
}
}
return -1;
}
function restoreToHistoryIndex(targetIndex) {
const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1));
const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex);
if (snapshotIndex < 0) {
return false;
}
applyState(scope.historyEntries[snapshotIndex].state, { deferRefresh: true });
for (let index = snapshotIndex + 1; index <= normalizedTargetIndex; index += 1) {
const entry = scope.historyEntries[index];
if (!entry) {
continue;
}
if (entry.state) {
applyState(entry.state, { deferRefresh: true });
continue;
}
if (entry.operation) {
applyOperation(entry.operation, "redo", { deferRefresh: true });
}
}
refreshUiAfterHistoryMutation();
return true;
}
function getStateSignature(state) {
const layerSig = scope.cloneLayers(state.layers)
.sort((a, b) => a.layer - b.layer)
.map((layer) => ({
layer: layer.layer,
name: typeof layer.name === "string" ? layer.name : "",
rows: scope.normalizeRows(layer.rows, layer.layer === 0 ? "." : " "),
}));
const heightLayerSig = scope.cloneHeightLayers(state.heightLayers)
.sort((a, b) => String(a.id || "").localeCompare(String(b.id || "")))
.map((entry) => ({
id: String(entry.id || ""),
name: typeof entry.name === "string" ? entry.name : "",
z: Number(entry.z) || 1,
x: Number(entry.x) || 0,
y: Number(entry.y) || 0,
rows: Array.isArray(entry.rows) ? entry.rows.map((row) => String(row || "")) : [],
}));
const npcSig = scope.cloneNpcOverlays(state.npcs)
.sort((a, b) => a.id.localeCompare(b.id))
.map((entry) => ({
id: entry.id,
layer: Number(entry.layer) || 0,
name: entry.name,
spriteId: entry.spriteId,
x: entry.x,
y: entry.y,
}));
return JSON.stringify({
width: Number(state.width) || 1,
height: Number(state.height) || 1,
mapName: String(state.mapName || ""),
backgroundColor: scope.normalizeMapBackgroundColor(state.backgroundColor),
backgroundTileId: scope.normalizeBackgroundTileId(state.backgroundTileId),
heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)),
layerSig,
heightLayerSig,
npcSig,
worldChunkBackgrounds: state && state.worldChunkBackgrounds && typeof state.worldChunkBackgrounds === "object" && !Array.isArray(state.worldChunkBackgrounds)
? state.worldChunkBackgrounds
: {},
editorUi: scope.cloneEditorUiState(state.editorUi || {}),
});
}
function formatCellCoord(cell) {
return "(" + cell.x + "," + cell.y + ")";
}
function formatHistoryLabel(entry) {
const pairText = (entry.before || entry.after)
? (" (" + (entry.before || "?") + " -> " + (entry.after || "?") + ")")
: "";
return entry.label + pairText;
}
function renderHistoryPreview() {
const selectedEntry = scope.historyEntries[scope.historySelectionIndex] || null;
if (!selectedEntry) {
scope.historyPreviewEl.innerHTML = '<h4>Change Preview</h4><div class="history-preview-empty">Select a history entry to inspect it.</div>';
return;
}
const details = Array.isArray(selectedEntry.details) ? selectedEntry.details : [];
const detailHtml = details.length > 0
? "<ul>" + details.map((detail) => "<li>" + detail + "</li>").join("") + "</ul>"
: '<div class="history-preview-empty">No additional details recorded.</div>';
const currentText = scope.historySelectionIndex === scope.historyIndex ? "Current state" : "Selected step " + selectedEntry.seq;
scope.historyPreviewEl.innerHTML =
"<h4>" + currentText + "</h4>" +
'<div style="margin-bottom:6px;">' + formatHistoryLabel(selectedEntry) + "</div>" +
detailHtml +
'<button class="mini-btn" id="jumpHistoryBtn" type="button" style="margin-top:8px;">Restore To Selected</button>';
const nextJumpBtn = document.getElementById("jumpHistoryBtn");
nextJumpBtn.disabled = scope.isSaving || scope.historySelectionIndex === scope.historyIndex;
nextJumpBtn.addEventListener("click", () => {
if (scope.historySelectionIndex === scope.historyIndex) {
return;
}
scope.historyIndex = scope.historySelectionIndex;
restoreToHistoryIndex(scope.historyIndex);
refreshToolbarState();
scope.setStatus("Restored to history step " + scope.historyEntries[scope.historyIndex].seq + ".", false);
});
}
function renderHistoryList() {
scope.historyListEl.innerHTML = "";
if (scope.historyCurrentEl) {
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
scope.historyCurrentEl.innerHTML = currentEntry
? (
'<div class="history-current-label">Current State</div>' +
'<button type="button" class="history-row current-row">' +
"<span>" + String(currentEntry.seq) + ". " + formatHistoryLabel(currentEntry) + "</span>" +
'<span class="history-meta">' + new Date(currentEntry.createdAt).toLocaleTimeString() + "</span>" +
"</button>"
)
: '<div class="history-current-label">Current State</div><div class="history-current-empty">No history yet.</div>';
}
scope.historyEntries.forEach((entry, index) => {
if (index === scope.historyIndex) {
return;
}
const row = document.createElement("button");
row.type = "button";
row.className = "history-row" + (index === scope.historySelectionIndex ? " active" : "");
const timeText = new Date(entry.createdAt).toLocaleTimeString();
row.innerHTML =
"<span>" + String(entry.seq) + ". " + formatHistoryLabel(entry) + "</span>" +
'<span class="history-meta">' + timeText + "</span>";
row.addEventListener("click", () => {
if (index === scope.historySelectionIndex) {
return;
}
scope.historySelectionIndex = index;
renderHistoryList();
renderHistoryPreview();
});
scope.historyListEl.appendChild(row);
});
}
function refreshToolbarState(preserveCurrentStatus) {
const canUndo = scope.historyIndex > 0;
const canRedo = scope.historyIndex < scope.historyEntries.length - 1;
const currentHistoryId = scope.historyEntries[scope.historyIndex] ? scope.historyEntries[scope.historyIndex].id : 0;
const isDirtyFromSaved = currentHistoryId !== scope.lastSavedHistoryId;
scope.undoBtn.disabled = scope.isSaving || !canUndo;
scope.redoBtn.disabled = scope.isSaving || !canRedo;
scope.saveBtn.disabled = scope.isSaving || !isDirtyFromSaved;
renderHistoryList();
renderHistoryPreview();
if (preserveCurrentStatus) {
return;
}
if (scope.isSaving) {
scope.setStatus("Saving...", false);
} else if (canRedo) {
scope.setStatus("History branch active. New edits will replace future steps.", false);
} else if (isDirtyFromSaved) {
scope.setStatus("Unsaved history changes.", false);
} else {
scope.setStatus("All changes saved.", false);
}
}
function registerHistory(label, before, after, details, options) {
const config = options && typeof options === "object" ? options : {};
const operation = config.operation ? cloneValue(config.operation) : null;
if (operation && operation.type === "tile_cells" && (!Array.isArray(operation.cells) || operation.cells.length === 0)) {
return;
}
if (operation && operation.type === "npc_entries" && (!Array.isArray(operation.entries) || operation.entries.length === 0)) {
return;
}
const shouldStoreOperationOnly = Boolean(operation);
const nextState = shouldStoreOperationOnly ? null : (config.nextState || captureState());
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
const currentState = currentEntry && currentEntry.state
? currentEntry.state
: captureHistoryStateAtIndex(scope.historyIndex);
if (!config.skipStateCheck && nextState && currentState && getStateSignature(nextState) === getStateSignature(currentState)) {
return;
}
if (scope.historyIndex < scope.historyEntries.length - 1) {
scope.historyEntries = scope.historyEntries.slice(0, scope.historyIndex + 1);
}
let operationEntriesSinceSnapshot = 0;
if (shouldStoreOperationOnly) {
for (let index = scope.historyEntries.length - 1; index >= 0; index -= 1) {
const entry = scope.historyEntries[index];
if (!entry) {
continue;
}
if (entry.state) {
break;
}
if (entry.operation) {
operationEntriesSinceSnapshot += 1;
}
}
}
const checkpointState = shouldStoreOperationOnly && operationEntriesSinceSnapshot + 1 >= OPERATION_CHECKPOINT_INTERVAL
? captureState()
: null;
const entry = {
id: scope.nextHistoryId,
seq: scope.nextHistoryId,
createdAt: Date.now(),
label,
before,
after,
details: Array.isArray(details) ? details : [],
state: nextState || checkpointState,
operation,
};
scope.nextHistoryId += 1;
scope.historyEntries.push(entry);
scope.historyIndex = scope.historyEntries.length - 1;
scope.historySelectionIndex = scope.historyIndex;
if (scope.historyEntries.length > MAX_HISTORY_ENTRIES) {
const trimmedCount = scope.historyEntries.length - MAX_HISTORY_ENTRIES;
scope.historyEntries = scope.historyEntries.slice(trimmedCount);
scope.historyIndex = Math.max(0, scope.historyIndex - trimmedCount);
scope.historySelectionIndex = Math.max(0, scope.historySelectionIndex - trimmedCount);
}
schedulePersistHistoryState();
refreshToolbarState();
}
function undo() {
if (scope.historyIndex <= 0) {
return;
}
scope.historyIndex -= 1;
scope.historySelectionIndex = scope.historyIndex;
restoreToHistoryIndex(scope.historyIndex);
schedulePersistHistoryState();
refreshToolbarState();
scope.setStatus("Undo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false);
}
function redo() {
if (scope.historyIndex >= scope.historyEntries.length - 1) {
return;
}
scope.historyIndex += 1;
scope.historySelectionIndex = scope.historyIndex;
restoreToHistoryIndex(scope.historyIndex);
schedulePersistHistoryState();
refreshToolbarState();
scope.setStatus("Redo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false);
}
return {
persistHistoryState,
schedulePersistHistoryState,
restoreHistoryState,
applyHistorySnapshot,
captureState,
applyState,
applyOperation,
restoreToHistoryIndex,
getStateSignature,
formatCellCoord,
formatHistoryLabel,
renderHistoryPreview,
renderHistoryList,
refreshToolbarState,
registerHistory,
undo,
redo,
};
}

View file

@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
export function createHistoryStateStore() {
let entries = [];
let index = 0;
let selectionIndex = 0;
let nextId = 1;
let lastSavedId = 1;
return {
get entries() {
return entries;
},
set entries(value) {
entries = Array.isArray(value) ? value : [];
},
get index() {
return index;
},
set index(value) {
index = Number(value) || 0;
},
get selectionIndex() {
return selectionIndex;
},
set selectionIndex(value) {
selectionIndex = Number(value) || 0;
},
get nextId() {
return nextId;
},
set nextId(value) {
nextId = Math.max(1, Number(value) || 1);
},
get lastSavedId() {
return lastSavedId;
},
set lastSavedId(value) {
lastSavedId = Math.max(1, Number(value) || 1);
},
};
}

View file

@ -0,0 +1,369 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { mergeImagesPayloadWithSpritesPayload, mergeImagesPayloadWithTilesPayload } from "../editorCore";
const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~=";
function cloneValue(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function getRootKey(type) {
return type === "sprites" ? "sprites" : "tiles";
}
function getTypeLabel(type) {
return type === "sprites" ? "Sprites" : "Tiles";
}
function extractImportRecords(payload, rootKey) {
if (Array.isArray(payload)) {
return payload;
}
if (!payload || typeof payload !== "object") {
return [];
}
if (Array.isArray(payload[rootKey])) {
return payload[rootKey];
}
return [payload];
}
function normalizeOptionalImportText(value) {
const normalized = String(value || "").trim();
return normalized || "";
}
function normalizeImportRecord(record) {
if (!record || typeof record !== "object" || Array.isArray(record)) {
return null;
}
const rawRows = Array.isArray(record.rows) ? record.rows.map((row) => String(row || "")) : [];
const parsedWidth = Number(record.width);
const parsedHeight = Number(record.height);
const parsedPixelScale = Number(record.pixelScale);
const widthFromRows = rawRows.reduce((max, row) => Math.max(max, row.length), 0);
const width = Number.isFinite(parsedWidth) && parsedWidth > 0 ? Math.floor(parsedWidth) : widthFromRows;
const height = Number.isFinite(parsedHeight) && parsedHeight > 0 ? Math.floor(parsedHeight) : rawRows.length;
const pixelScale = Number.isFinite(parsedPixelScale) && parsedPixelScale > 0 ? Math.floor(parsedPixelScale) : 1;
if (width <= 0 || height <= 0 || rawRows.length === 0) {
return null;
}
return {
name: normalizeOptionalImportText(record.name) || normalizeOptionalImportText(record.id),
description: normalizeOptionalImportText(record.description),
width,
height,
pixelScale,
rows: Array.from({ length: height }, (_value, rowIndex) => {
const source = String(rawRows[rowIndex] || "");
return source.padEnd(width, ".").slice(0, width);
}),
};
}
function getImportSignature(record) {
return [
String(record.width || 0),
String(record.height || 0),
String(record.pixelScale || 1),
Array.isArray(record.rows) ? record.rows.join("\n") : "",
].join("|");
}
function createGeneratedId(scope, prefix) {
return prefix + "_" + String(scope.runtimeUniqueId() || "").replace(/^inst_/, "");
}
function createImportedSpriteRecord(scope, normalizedRecord) {
const id = createGeneratedId(scope, "sprite");
return {
id,
name: normalizedRecord.name || id,
width: normalizedRecord.width,
height: normalizedRecord.height,
pixelScale: normalizedRecord.pixelScale,
rows: normalizedRecord.rows.slice(),
};
}
function takeNextTileSymbol(usedSymbols) {
for (const symbol of TILE_SYMBOL_POOL) {
if (!usedSymbols.has(symbol)) {
usedSymbols.add(symbol);
return symbol;
}
}
throw new Error("No free tile symbols remain for imported tiles.");
}
function createImportedTileRecord(scope, normalizedRecord, usedSymbols) {
const id = createGeneratedId(scope, "tile");
return {
id,
symbol: takeNextTileSymbol(usedSymbols),
name: normalizedRecord.name || id,
description: normalizedRecord.description || "",
width: normalizedRecord.width,
height: normalizedRecord.height,
pixelScale: normalizedRecord.pixelScale,
rows: normalizedRecord.rows.slice(),
};
}
function normalizeExistingPayload(scope, type) {
const rootKey = getRootKey(type);
const source = scope.contentBundle[type] && typeof scope.contentBundle[type] === "object" && !Array.isArray(scope.contentBundle[type])
? scope.contentBundle[type]
: { schemaVersion: 1, [rootKey]: [] };
const records = Array.isArray(source[rootKey]) ? source[rootKey] : [];
return {
schemaVersion: typeof source.schemaVersion === "number" ? source.schemaVersion : 1,
[rootKey]: records.slice(),
};
}
export function createImportController(scope) {
const documentScope = scope.documentScope || scope;
const renderScope = scope.renderScope || scope;
const uiScope = scope.uiScope || scope;
const sessionScope = scope.sessionScope || scope;
let experimentalImportExpanded = false;
let isImporting = false;
let jsonImportModalOpen = false;
function refreshImportControls() {
if (scope.experimentalImportToggleBtn) {
scope.experimentalImportToggleBtn.classList.toggle("expanded", experimentalImportExpanded);
scope.experimentalImportToggleBtn.setAttribute("aria-expanded", experimentalImportExpanded ? "true" : "false");
}
if (scope.experimentalImportCheckEl) {
scope.experimentalImportCheckEl.textContent = experimentalImportExpanded ? "[x]" : "[ ]";
}
if (scope.experimentalImportBodyEl) {
scope.experimentalImportBodyEl.classList.toggle("hidden", !experimentalImportExpanded);
}
if (scope.importSpritesBtn) {
scope.importSpritesBtn.disabled = isImporting;
}
if (scope.importTilesBtn) {
scope.importTilesBtn.disabled = isImporting;
}
if (scope.importJsonBtn) {
scope.importJsonBtn.disabled = isImporting;
}
if (scope.importJsonConfirmBtn) {
scope.importJsonConfirmBtn.disabled = isImporting;
}
if (scope.importJsonCancelBtn) {
scope.importJsonCancelBtn.disabled = isImporting;
}
if (scope.importJsonModal) {
scope.importJsonModal.classList.toggle("hidden", !jsonImportModalOpen);
}
}
function toggleExperimentalImportPanel() {
experimentalImportExpanded = !experimentalImportExpanded;
refreshImportControls();
}
function openImportDialog(type) {
const inputEl = type === "sprites" ? scope.importSpritesInputEl : scope.importTilesInputEl;
if (!inputEl) {
return;
}
inputEl.value = "";
inputEl.click();
}
function openJsonImportModal() {
jsonImportModalOpen = true;
if (scope.importJsonTypeSelect) {
scope.importJsonTypeSelect.value = "sprites";
}
if (scope.importJsonTextarea) {
scope.importJsonTextarea.value = "";
}
refreshImportControls();
if (scope.importJsonTextarea && typeof scope.importJsonTextarea.focus === "function") {
window.setTimeout(() => {
try {
scope.importJsonTextarea.focus();
} catch {
// Ignore focus issues if the modal closes before the deferred focus runs.
}
}, 0);
}
}
function closeJsonImportModal() {
jsonImportModalOpen = false;
refreshImportControls();
}
async function importRecordsFromPayload(type, payload) {
const label = getTypeLabel(type);
const rootKey = getRootKey(type);
const importedEntries = extractImportRecords(payload, rootKey);
if (importedEntries.length === 0) {
scope.setStatus(label + " import failed: no compatible records found.", true);
return;
}
const existingPayload = normalizeExistingPayload(scope, type);
const existingRecords = existingPayload[rootKey];
const knownSignatures = new Set();
existingRecords.forEach((entry) => {
const normalized = normalizeImportRecord(entry);
if (!normalized) {
return;
}
knownSignatures.add(getImportSignature(normalized));
});
const pendingSignatures = new Set();
const nextRecords = [];
const usedTileSymbols = new Set(
type === "tiles"
? existingRecords.map((entry) => String(entry?.symbol || "").charAt(0)).filter(Boolean)
: [],
);
let duplicateCount = 0;
let invalidCount = 0;
importedEntries.forEach((entry) => {
const normalized = normalizeImportRecord(entry);
if (!normalized) {
invalidCount += 1;
return;
}
const signature = getImportSignature(normalized);
if (knownSignatures.has(signature) || pendingSignatures.has(signature)) {
duplicateCount += 1;
return;
}
pendingSignatures.add(signature);
nextRecords.push(
type === "sprites"
? createImportedSpriteRecord(scope, normalized)
: createImportedTileRecord(scope, normalized, usedTileSymbols),
);
});
if (nextRecords.length === 0) {
const summary = [
"No new " + label.toLowerCase() + " imported.",
duplicateCount > 0 ? (String(duplicateCount) + " duplicate" + (duplicateCount === 1 ? "" : "s") + " skipped.") : "",
invalidCount > 0 ? (String(invalidCount) + " invalid entr" + (invalidCount === 1 ? "y" : "ies") + " ignored.") : "",
].filter(Boolean).join(" ");
uiScope.setStatus(summary, false);
return;
}
const nextPayload = {
schemaVersion: existingPayload.schemaVersion,
[rootKey]: existingRecords.concat(nextRecords),
};
const existingImagesPayload = cloneValue(
documentScope.ensureDocumentContentPayload?.("images", { schemaVersion: 1, images: [] })
|| scope.contentBundle?.images
|| { schemaVersion: 1, images: [] },
) || { schemaVersion: 1, images: [] };
const nextImagesPayload = type === "sprites"
? mergeImagesPayloadWithSpritesPayload(existingImagesPayload, nextPayload)
: mergeImagesPayloadWithTilesPayload(existingImagesPayload, nextPayload);
isImporting = true;
refreshImportControls();
uiScope.setStatus("Importing " + label.toLowerCase() + "...", false);
try {
await documentScope.persistContentPayload("images", nextImagesPayload);
if (typeof documentScope.applyContentPayloadToRuntime === "function") {
documentScope.applyContentPayloadToRuntime("images", nextImagesPayload);
}
if (type === "tiles" && !sessionScope.activeBrushTileId && nextRecords[0]?.id) {
sessionScope.activeBrushTileId = nextRecords[0].id;
}
renderScope.draw();
const summary = [
"Imported " + nextRecords.length + " new " + label.toLowerCase() + ".",
duplicateCount > 0 ? (String(duplicateCount) + " duplicate" + (duplicateCount === 1 ? "" : "s") + " skipped.") : "",
invalidCount > 0 ? (String(invalidCount) + " invalid entr" + (invalidCount === 1 ? "y" : "ies") + " ignored.") : "",
].filter(Boolean).join(" ");
uiScope.setStatus(summary, false);
} catch (error) {
uiScope.setStatus(String(error), true);
} finally {
isImporting = false;
refreshImportControls();
}
}
async function importRecordsFromFile(type, file) {
const label = getTypeLabel(type);
if (!file) {
return;
}
let payload;
try {
payload = JSON.parse(await file.text());
} catch {
uiScope.setStatus(label + " import failed: invalid JSON.", true);
return;
}
await importRecordsFromPayload(type, payload);
}
async function handleImportSelection(type) {
const inputEl = type === "sprites" ? scope.importSpritesInputEl : scope.importTilesInputEl;
const file = inputEl?.files && inputEl.files[0] ? inputEl.files[0] : null;
if (!file) {
return;
}
try {
await importRecordsFromFile(type, file);
} catch (error) {
uiScope.setStatus(String(error), true);
} finally {
inputEl.value = "";
}
}
async function submitJsonImport() {
const type = String(scope.importJsonTypeSelect?.value || "sprites").trim() === "tiles" ? "tiles" : "sprites";
const rawText = String(scope.importJsonTextarea?.value || "").trim();
const label = getTypeLabel(type);
if (!rawText) {
uiScope.setStatus(label + " import failed: no JSON provided.", true);
return;
}
let payload;
try {
payload = JSON.parse(rawText);
} catch {
uiScope.setStatus(label + " import failed: invalid JSON.", true);
return;
}
await importRecordsFromPayload(type, payload);
if (!isImporting) {
closeJsonImportModal();
}
}
return {
refreshImportControls,
toggleExperimentalImportPanel,
openImportDialog,
handleImportSelection,
openJsonImportModal,
closeJsonImportModal,
submitJsonImport,
};
}

File diff suppressed because it is too large Load diff

121
src/mapEditorPopup/main.ts Normal file
View file

@ -0,0 +1,121 @@
import { getMapEditorPopupBodyMarkup, buildMapEditorPopupStyles } from "./dom";
import {
loadMapEditorPopupBootstrap,
loadStandaloneWorldEditorPopupBootstrap,
} from "./bootstrap";
import { startMapEditorPopup } from "./runtime";
import { applyMapEditorThemePreset, fetchEditorSettings, getDefaultEditorSettings } from "./themePresets";
const POPUP_STYLE_ID = "map-editor-popup-styles";
function ensurePopupStyles(): void {
let styleEl = document.getElementById(POPUP_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = POPUP_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = buildMapEditorPopupStyles();
}
function renderError(message: string): void {
document.title = "TES:VIII The Elder";
document.body.innerHTML = "";
document.body.style.margin = "0";
document.body.style.minHeight = "100vh";
document.body.style.display = "grid";
document.body.style.placeItems = "center";
document.body.style.background = "#0a1020";
document.body.style.color = "#d8e8ff";
document.body.style.fontFamily = "Segoe UI, Arial, sans-serif";
const panel = document.createElement("div");
panel.style.maxWidth = "460px";
panel.style.padding = "24px";
panel.style.border = "1px solid #2e426c";
panel.style.borderRadius = "10px";
panel.style.background = "#0e1a33";
panel.style.boxShadow = "0 12px 36px rgba(3, 8, 18, 0.45)";
const heading = document.createElement("h1");
heading.textContent = "World editor unavailable";
heading.style.margin = "0 0 8px";
heading.style.fontSize = "18px";
const text = document.createElement("p");
text.textContent = message;
text.style.margin = "0";
text.style.fontSize = "14px";
text.style.lineHeight = "1.5";
panel.appendChild(heading);
panel.appendChild(text);
document.body.appendChild(panel);
}
function renderLoading(message: string): void {
document.title = "TES:VIII The Elder";
document.body.innerHTML = "";
document.body.style.margin = "0";
document.body.style.minHeight = "100vh";
document.body.style.display = "grid";
document.body.style.placeItems = "center";
document.body.style.background = "#0a1020";
document.body.style.color = "#d8e8ff";
document.body.style.fontFamily = "Segoe UI, Arial, sans-serif";
const panel = document.createElement("div");
panel.style.maxWidth = "460px";
panel.style.padding = "24px";
panel.style.border = "1px solid #223557";
panel.style.borderRadius = "10px";
panel.style.background = "#0e1a33";
panel.style.boxShadow = "0 12px 36px rgba(3, 8, 18, 0.32)";
const heading = document.createElement("h1");
heading.textContent = "Loading world editor";
heading.style.margin = "0 0 8px";
heading.style.fontSize = "18px";
const text = document.createElement("p");
text.textContent = message;
text.style.margin = "0";
text.style.fontSize = "14px";
text.style.lineHeight = "1.5";
panel.appendChild(heading);
panel.appendChild(text);
document.body.appendChild(panel);
}
async function initMapEditorPopup(): Promise<void> {
ensurePopupStyles();
renderLoading("Preparing world data...");
const params = new URLSearchParams(window.location.search);
const token = params.get("token")?.trim() || "";
const requestedWorldId = params.get("worldId")?.trim() || params.get("mapId")?.trim() || "";
let bootstrap = loadMapEditorPopupBootstrap(token);
if (!bootstrap) {
try {
bootstrap = await loadStandaloneWorldEditorPopupBootstrap(requestedWorldId, window.location.origin);
} catch (error) {
renderError(String(error || "Failed to load the world editor."));
return;
}
}
if (!bootstrap) {
renderError("No world data was available for the editor.");
return;
}
const editorSettings = await fetchEditorSettings(bootstrap.apiBase).catch(() => getDefaultEditorSettings());
applyMapEditorThemePreset(editorSettings.mapEditor.themePreset);
document.body.removeAttribute("style");
document.body.innerHTML = getMapEditorPopupBodyMarkup();
document.title = "TES:VIII The Elder " + (bootstrap.mapName || bootstrap.mapId || "Untitled");
startMapEditorPopup(bootstrap, editorSettings);
}
void initMapEditorPopup();

View file

@ -0,0 +1,428 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import {
buildSpritesPayloadFromImagesPayload,
buildTilesPayloadFromImagesPayload,
mergeImagesPayloadWithSpritesPayload,
mergeImagesPayloadWithTilesPayload,
} from "../editorCore";
import { resizeRows } from "../components/mapEditorShared";
import { moveItemRelative } from "./reorderableListController";
function cloneValue(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return value == null ? value : JSON.parse(JSON.stringify(value));
}
export function createMapDocumentController(config) {
const {
mapId,
getMapId,
mapDocument,
popupSessionStore,
baseRows,
getBaseRows,
normalizeMapBackgroundColor,
onMapNameUpdated,
invalidateTileSurface,
} = config;
function resolveMapId() {
const resolved = typeof getMapId === "function" ? getMapId() : mapId;
return String(resolved || mapId || "").trim();
}
function resolveBaseRows() {
const resolved = typeof getBaseRows === "function" ? getBaseRows() : baseRows;
return Array.isArray(resolved) ? resolved : [];
}
function normalizeHeightBlurStep(value, fallback = 0.1) {
const normalized = Number(value);
if (!Number.isFinite(normalized)) {
return fallback;
}
return Math.max(0, Math.min(1, normalized));
}
function normalizeRows(rows, fillChar) {
return Array.from({ length: mapDocument.height }, (_, y) => {
const raw = String((rows && rows[y]) || "");
if (raw.length >= mapDocument.width) {
return raw.slice(0, mapDocument.width);
}
return raw + fillChar.repeat(Math.max(0, mapDocument.width - raw.length));
});
}
function getLayerByNumber(layerNumber) {
const normalizedLayer = Number(layerNumber) || 0;
return mapDocument.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || null;
}
function getDefaultEditableLayerNumber() {
const sorted = mapDocument.roomLayers
.map((layer) => Number(layer.layer) || 0)
.filter((layerNumber) => layerNumber > 0)
.sort((a, b) => a - b);
return sorted.length > 0 ? sorted[0] : 0;
}
function getLayerDefaultName(layerNumber) {
const normalizedLayer = Number(layerNumber) || 0;
return normalizedLayer === 0 ? "Background" : ("Layer " + Math.max(0, normalizedLayer - 1));
}
function getLayerDisplayName(layerOrNumber) {
const layer = typeof layerOrNumber === "object" && layerOrNumber
? layerOrNumber
: getLayerByNumber(layerOrNumber);
if (!layer) {
return getLayerDefaultName(layerOrNumber);
}
const customName = typeof layer.name === "string" ? layer.name.trim() : "";
return customName || getLayerDefaultName(layer.layer);
}
function isBackgroundLayer(layerNumber) {
return (Number(layerNumber) || 0) === 0;
}
function cloneHeightLayers(source) {
const seenIds = new Set();
return (Array.isArray(source) ? source : [])
.flatMap((entry, index) => {
const fallbackId = "height_" + String(index + 1);
const id = String(entry?.id || fallbackId).trim() || fallbackId;
if (seenIds.has(id)) {
return [];
}
seenIds.add(id);
return [{
id,
name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
z: index + 1,
x: Math.max(0, Number(entry?.x) || 0),
y: Math.max(0, Number(entry?.y) || 0),
rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
}];
});
}
function getHeightLayerById(heightLayerId) {
const normalizedId = String(heightLayerId || "").trim();
if (!normalizedId) {
return null;
}
return mapDocument.heightLayers.find((entry) => String(entry?.id || "").trim() === normalizedId) || null;
}
function getHeightLayerDisplayName(heightLayerOrId) {
const entry = typeof heightLayerOrId === "object" && heightLayerOrId
? heightLayerOrId
: getHeightLayerById(heightLayerOrId);
if (!entry) {
return "Height Layer";
}
return typeof entry.name === "string" && entry.name.trim()
? entry.name.trim()
: String(entry.id || "Height Layer");
}
function ensureBaseLayer() {
const baseLayerRows = resolveBaseRows();
if (!mapDocument.roomLayers.some((layer) => Number(layer.layer) === 0)) {
mapDocument.roomLayers.unshift({ layer: 0, name: undefined, rows: normalizeRows(baseLayerRows, "."), instanceIds: [] });
}
if (!mapDocument.roomLayers.some((layer) => Number(layer.layer) > 0)) {
mapDocument.roomLayers.push({ layer: 1, name: undefined, rows: normalizeRows([], " "), instanceIds: [] });
}
mapDocument.roomLayers = mapDocument.roomLayers
.map((layer) => ({
layer: Number(layer.layer),
name: typeof layer.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
rows: normalizeRows(layer.rows, Number(layer.layer) === 0 ? "." : " "),
instanceIds: Array.isArray(layer.instanceIds) ? layer.instanceIds : [],
}))
.sort((a, b) => a.layer - b.layer);
const baseIndex = mapDocument.roomLayers.findIndex((layer) => layer.layer === 0);
if (baseIndex >= 0) {
const candidate = mapDocument.roomLayers[baseIndex].rows.join("").replace(/\s/g, "");
if (!candidate) {
mapDocument.roomLayers[baseIndex].rows = normalizeRows(baseLayerRows, ".");
}
}
invalidateTileSurface();
popupSessionStore.syncLayerVisibility(mapDocument.roomLayers);
if ((Number(popupSessionStore.state.activeLayer) || 0) <= 0) {
popupSessionStore.state.activeLayer = getDefaultEditableLayerNumber();
}
}
function moveLayerToDepth(sourceLayerNumber, targetLayerNumber, position) {
const sourceLayer = Number(sourceLayerNumber) || 0;
const targetLayer = Number(targetLayerNumber) || 0;
if (sourceLayer <= 0 || targetLayer <= 0 || sourceLayer === targetLayer) {
return null;
}
const currentOrderedNonBaseLayers = mapDocument.roomLayers
.map((layer) => Number(layer.layer) || 0)
.filter((layerNumber) => layerNumber > 0)
.sort((a, b) => a - b);
const reorderedNonBaseLayers = moveItemRelative(
currentOrderedNonBaseLayers.map((layerNumber) => String(layerNumber)),
String(sourceLayer),
String(targetLayer),
position === "after" ? "after" : "before",
)
.map((layerNumber) => Number(layerNumber) || 0)
.filter((layerNumber) => layerNumber > 0);
if (reorderedNonBaseLayers.length !== currentOrderedNonBaseLayers.length) {
return null;
}
if (reorderedNonBaseLayers.every((layerNumber, index) => layerNumber === currentOrderedNonBaseLayers[index])) {
return null;
}
const layerNumberMap = { 0: 0 };
reorderedNonBaseLayers.forEach((oldLayerNumber, index) => {
layerNumberMap[String(oldLayerNumber)] = index + 1;
});
const previousVisibilityById = {};
mapDocument.roomLayers.forEach((layer) => {
const layerNumber = Number(layer.layer) || 0;
previousVisibilityById[String(layerNumber)] = popupSessionStore.isLayerVisible(layerNumber, mapDocument.roomLayers);
});
const previousActiveLayer = Number(popupSessionStore.state.activeLayer) || 0;
const previousSelectedTile = popupSessionStore.state.selectedTile && typeof popupSessionStore.state.selectedTile === "object"
? { ...popupSessionStore.state.selectedTile }
: null;
mapDocument.roomLayers = mapDocument.roomLayers
.map((layer) => ({
...layer,
layer: layerNumberMap[String(Number(layer.layer) || 0)] ?? (Number(layer.layer) || 0),
}))
.sort((a, b) => a.layer - b.layer);
mapDocument.npcOverlays.forEach((npc) => {
const nextLayer = layerNumberMap[String(Number(npc.layer) || 0)] ?? (Number(npc.layer) || 0);
npc.layer = nextLayer;
if (npc.record && typeof npc.record === "object" && !Array.isArray(npc.record)) {
npc.record.layer = nextLayer;
}
});
if (previousSelectedTile) {
popupSessionStore.state.selectedTile = {
...previousSelectedTile,
layer: layerNumberMap[String(Number(previousSelectedTile.layer) || 0)] ?? (Number(previousSelectedTile.layer) || 0),
};
}
popupSessionStore.state.activeLayer = layerNumberMap[String(previousActiveLayer)] ?? previousActiveLayer;
const nextVisibleLayersById = {};
Object.entries(previousVisibilityById).forEach(([oldLayerNumber, wasVisible]) => {
const nextLayerNumber = layerNumberMap[String(Number(oldLayerNumber) || 0)];
if (nextLayerNumber === undefined) {
return;
}
nextVisibleLayersById[String(nextLayerNumber)] = wasVisible !== false;
});
popupSessionStore.state.visibleLayersById = nextVisibleLayersById;
ensureBaseLayer();
popupSessionStore.syncLayerVisibility(mapDocument.roomLayers);
invalidateTileSurface();
return {
previousOrder: currentOrderedNonBaseLayers,
nextOrder: reorderedNonBaseLayers,
layerNumberMap,
sourceLayer,
targetLayer,
position: position === "after" ? "after" : "before",
};
}
function moveHeightLayerToDepth(sourceHeightLayerId, targetHeightLayerId, position) {
const sourceId = String(sourceHeightLayerId || "").trim();
const targetId = String(targetHeightLayerId || "").trim();
if (!sourceId || !targetId || sourceId === targetId) {
return null;
}
const currentOrderedIds = mapDocument.heightLayers
.map((entry) => String(entry?.id || "").trim())
.filter(Boolean);
if (!currentOrderedIds.includes(sourceId) || !currentOrderedIds.includes(targetId)) {
return null;
}
const reorderedIds = moveItemRelative(
currentOrderedIds,
sourceId,
targetId,
position === "after" ? "after" : "before",
).filter(Boolean);
if (reorderedIds.length !== currentOrderedIds.length) {
return null;
}
if (reorderedIds.every((entryId, index) => entryId === currentOrderedIds[index])) {
return null;
}
const entriesById = new Map(
mapDocument.heightLayers.map((entry) => [String(entry?.id || "").trim(), entry]),
);
mapDocument.heightLayers = cloneHeightLayers(
reorderedIds
.map((entryId) => entriesById.get(entryId) || null)
.filter((entry) => entry !== null),
);
return {
previousOrder: currentOrderedIds,
nextOrder: reorderedIds,
sourceId,
targetId,
position: position === "after" ? "after" : "before",
};
}
function applyMapInformationEdits(nextState) {
const currentMapId = resolveMapId();
const nextWidth = Math.max(1, Math.min(512, Number(nextState?.width) || mapDocument.width));
const nextHeight = Math.max(1, Math.min(512, Number(nextState?.height) || mapDocument.height));
const nextName = String(nextState?.name || mapDocument.mapName || currentMapId).trim() || currentMapId;
const nextBackgroundColor = normalizeMapBackgroundColor(nextState?.backgroundColor || mapDocument.backgroundColor);
const nextHeightBlurStep = normalizeHeightBlurStep(nextState?.heightBlurStep ?? nextState?.heightDetailStep, mapDocument.heightBlurStep);
const oldWidth = mapDocument.width;
const oldHeight = mapDocument.height;
const oldName = String(mapDocument.mapName || currentMapId || "").trim() || currentMapId;
const oldBackgroundColor = normalizeMapBackgroundColor(mapDocument.backgroundColor);
const oldHeightBlurStep = normalizeHeightBlurStep(mapDocument.heightBlurStep);
mapDocument.width = nextWidth;
mapDocument.height = nextHeight;
mapDocument.mapName = nextName;
mapDocument.backgroundColor = nextBackgroundColor;
mapDocument.heightBlurStep = nextHeightBlurStep;
mapDocument.mapInfoDraft = {
width: mapDocument.width,
height: mapDocument.height,
name: String(mapDocument.mapName || currentMapId || ""),
backgroundColor: normalizeMapBackgroundColor(mapDocument.backgroundColor),
heightBlurStep: normalizeHeightBlurStep(mapDocument.heightBlurStep),
};
mapDocument.roomLayers = mapDocument.roomLayers.map((layer) => ({
layer: layer.layer,
name: typeof layer.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
rows: resizeRows(layer.rows, mapDocument.width, mapDocument.height, layer.layer === 0 ? "." : " "),
instanceIds: Array.isArray(layer.instanceIds) ? layer.instanceIds : [],
}));
mapDocument.heightLayers = cloneHeightLayers(mapDocument.heightLayers)
.map((entry) => {
const clampedX = Math.max(0, Math.min(mapDocument.width - 1, Number(entry.x) || 0));
const clampedY = Math.max(0, Math.min(mapDocument.height - 1, Number(entry.y) || 0));
const rows = Array.isArray(entry.rows)
? entry.rows
.slice(0, Math.max(0, mapDocument.height - clampedY))
.map((row) => String(row || "").slice(0, Math.max(0, mapDocument.width - clampedX)))
: [];
return {
...entry,
x: clampedX,
y: clampedY,
rows,
};
});
mapDocument.npcOverlays.splice(0, mapDocument.npcOverlays.length, ...mapDocument.npcOverlays.filter((npc) => {
const x = Number(npc.x);
const y = Number(npc.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return false;
}
if (x < 0 || y < 0) {
return true;
}
return x >= 0 && x < mapDocument.width && y >= 0 && y < mapDocument.height;
}));
popupSessionStore.state.selectedNpcId = mapDocument.npcOverlays.some((npc) => npc.id === popupSessionStore.state.selectedNpcId)
? popupSessionStore.state.selectedNpcId
: (mapDocument.npcOverlays[0] ? String(mapDocument.npcOverlays[0].id || "") : "");
popupSessionStore.state.selectedTile = null;
ensureBaseLayer();
if (typeof onMapNameUpdated === "function") {
onMapNameUpdated();
}
return {
oldWidth,
oldHeight,
oldName,
oldBackgroundColor,
oldHeightBlurStep,
nextWidth: mapDocument.width,
nextHeight: mapDocument.height,
nextName: mapDocument.mapName,
nextBackgroundColor: mapDocument.backgroundColor,
nextHeightBlurStep: normalizeHeightBlurStep(mapDocument.heightBlurStep),
removedOutOfBoundsNpcs: true,
};
}
function ensureContentPayload(type, fallback) {
const normalizedType = String(type || "").trim();
if (normalizedType === "tiles") {
return buildTilesPayloadFromImagesPayload(mapDocument.contentBundle.images || { schemaVersion: 1, images: [] });
}
if (normalizedType === "sprites") {
return buildSpritesPayloadFromImagesPayload(mapDocument.contentBundle.images || { schemaVersion: 1, images: [] });
}
const existing = mapDocument.contentBundle[normalizedType];
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
return existing;
}
const nextPayload = cloneValue(fallback) || {};
mapDocument.contentBundle[normalizedType] = nextPayload;
return nextPayload;
}
function setContentPayload(type, payload) {
const normalizedType = String(type || "").trim();
if (normalizedType === "tiles") {
mapDocument.contentBundle.images = mergeImagesPayloadWithTilesPayload(
mapDocument.contentBundle.images || { schemaVersion: 1, images: [] },
payload,
);
return buildTilesPayloadFromImagesPayload(mapDocument.contentBundle.images);
}
if (normalizedType === "sprites") {
mapDocument.contentBundle.images = mergeImagesPayloadWithSpritesPayload(
mapDocument.contentBundle.images || { schemaVersion: 1, images: [] },
payload,
);
return buildSpritesPayloadFromImagesPayload(mapDocument.contentBundle.images);
}
mapDocument.contentBundle[normalizedType] = payload;
return mapDocument.contentBundle[normalizedType];
}
return {
normalizeRows,
getLayerByNumber,
getDefaultEditableLayerNumber,
getLayerDefaultName,
getLayerDisplayName,
isBackgroundLayer,
cloneHeightLayers,
getHeightLayerById,
getHeightLayerDisplayName,
ensureBaseLayer,
moveLayerToDepth,
moveHeightLayerToDepth,
applyMapInformationEdits,
ensureContentPayload,
setContentPayload,
};
}

View file

@ -0,0 +1,56 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
function cloneValue(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return value == null ? value : JSON.parse(JSON.stringify(value));
}
export function createMapDocumentStore(initialState) {
const state = {
width: Math.max(1, Number(initialState?.width) || 1),
height: Math.max(1, Number(initialState?.height) || 1),
mapName: String(initialState?.mapName || initialState?.mapId || "Untitled"),
backgroundColor: String(initialState?.backgroundColor || ""),
backgroundTileId: String(initialState?.backgroundTileId || ""),
heightBlurStep: Number.isFinite(Number(initialState?.heightBlurStep))
? Number(initialState.heightBlurStep)
: (Number.isFinite(Number(initialState?.heightDetailStep)) ? Number(initialState.heightDetailStep) : 0.1),
backgroundCellMode: String(initialState?.backgroundCellMode || "inherit"),
mapInfoDraft: cloneValue(initialState?.mapInfoDraft) || {},
roomLayers: cloneValue(initialState?.roomLayers) || [],
heightLayers: cloneValue(initialState?.heightLayers) || [],
npcOverlays: cloneValue(initialState?.npcOverlays) || [],
contentBundle: cloneValue(initialState?.contentBundle) || {},
};
function setMapName(value, fallbackMapId) {
state.mapName = String(value || fallbackMapId || "Untitled");
return state.mapName;
}
function setBackgroundTileId(value, normalizeBackgroundTileId) {
state.backgroundTileId = typeof normalizeBackgroundTileId === "function"
? normalizeBackgroundTileId(value)
: String(value || "").trim();
return state.backgroundTileId;
}
function setBackgroundCellMode(value, allowedModes) {
const allowed = Array.isArray(allowedModes) && allowedModes.length > 0
? allowedModes.map((entry) => String(entry || ""))
: ["tile", "hole", "inherit"];
const normalized = String(value || "");
state.backgroundCellMode = allowed.includes(normalized) ? normalized : "inherit";
return state.backgroundCellMode;
}
return {
state,
setMapName,
setBackgroundTileId,
setBackgroundCellMode,
};
}

View file

@ -0,0 +1,932 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { renderFolderedSelectorList } from "./folderedSelectorList";
import { normalizeEditorTags } from "./tagUtils";
import {
buildPickerMenuItems,
menuItem,
menuLabel,
menuSeparator,
openContextMenuAtAnchor,
openContextMenuAtPoint,
} from "./contextMenuSchema";
export function createNpcController(scope) {
const documentScope = scope.documentScope || scope;
const renderScope = scope.renderScope || scope;
const historyScope = scope.historyScope || scope;
const uiScope = scope.uiScope || scope;
const sessionScope = scope.sessionScope || scope;
const ENTITY_TYPE_META = {
friendly: { label: "Friendly", noun: "friendly entity" },
hostile: { label: "Hostile", noun: "hostile entity" },
prop: { label: "Props", noun: "prop" },
};
function normalizeEntityType(value, fallback = "friendly") {
const normalizedValue = String(value || "").trim().toLowerCase();
if (normalizedValue === "friendly" || normalizedValue === "friend" || normalizedValue === "friendo" || normalizedValue === "npc") {
return "friendly";
}
if (normalizedValue === "hostile" || normalizedValue === "enemy" || normalizedValue === "aggro" || normalizedValue === "monster") {
return "hostile";
}
if (normalizedValue === "prop" || normalizedValue === "props" || normalizedValue === "thing" || normalizedValue === "things" || normalizedValue === "object") {
return "prop";
}
return fallback;
}
function getEntityTypeLabel(value) {
return ENTITY_TYPE_META[normalizeEntityType(value)]?.label || ENTITY_TYPE_META.friendly.label;
}
function getEntityTypeMeta(value) {
return ENTITY_TYPE_META[normalizeEntityType(value)] || ENTITY_TYPE_META.friendly;
}
function getNpcEntityType(npc) {
const record = npc && npc.record && typeof npc.record === "object" && !Array.isArray(npc.record)
? npc.record
: {};
return normalizeEntityType(
record.entityType
|| record.entityCategory
|| record.kind
|| record.type
|| npc?.entityType,
"friendly",
);
}
function getVisibleNpcOverlays() {
return documentScope.npcOverlays.filter((npc) => documentScope.isLayerRendered(Number(npc.layer) || 0));
}
function getNpcCatalogRecords() {
const payload = documentScope.contentBundle.npc_templates;
const entries = payload && Array.isArray(payload.npcTemplates) ? payload.npcTemplates : [];
return entries
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((entry) => {
const spriteId = String(entry.spriteId || "").trim();
const spriteEntry = documentScope.spriteCatalog[spriteId] || null;
return {
id: String(entry.id || "").trim(),
name: String(entry.name || entry.id || "NPC").trim(),
title: String(entry.title || "").trim(),
entityType: normalizeEntityType(entry.entityType || entry.entityCategory || entry.kind || entry.type, "friendly"),
spriteId,
tags: normalizeEditorTags(entry.tags),
dataUrl: spriteEntry ? spriteEntry.dataUrl : null,
record: entry,
};
})
.filter((entry) => entry.id);
}
function setNpcCatalogEntityType(templateId, nextType) {
const normalizedTemplateId = String(templateId || "").trim();
if (!normalizedTemplateId) {
return false;
}
const normalizedType = normalizeEntityType(nextType, "friendly");
const payload = documentScope.contentBundle.npc_templates;
const entries = payload && Array.isArray(payload.npcTemplates) ? payload.npcTemplates : [];
const targetEntry = entries.find((entry) => String(entry?.id || "").trim() === normalizedTemplateId);
if (!targetEntry || typeof targetEntry !== "object" || Array.isArray(targetEntry)) {
uiScope.setStatus("Entity reclassification failed: catalog entry not found.", true);
return false;
}
const previousType = normalizeEntityType(targetEntry.entityType || targetEntry.entityCategory || targetEntry.kind || targetEntry.type, "friendly");
if (previousType === normalizedType) {
return false;
}
targetEntry.entityType = normalizedType;
historyScope.registerHistory("Catalog entity reclassified", getEntityTypeLabel(previousType), getEntityTypeLabel(normalizedType), [
"Catalog entity: " + String(targetEntry.name || targetEntry.id || normalizedTemplateId),
"Type: " + getEntityTypeLabel(previousType) + " -> " + getEntityTypeLabel(normalizedType),
]);
uiScope.renderInstancePalette();
uiScope.renderNpcList();
renderScope.draw();
uiScope.setStatus("Reclassified catalog entity " + (targetEntry.name || targetEntry.id || normalizedTemplateId) + " to " + getEntityTypeLabel(normalizedType) + ".", false);
return true;
}
function getDialogueCatalogRecords() {
const payload = documentScope.contentBundle.dialogues;
const entries = payload && Array.isArray(payload.dialogues) ? payload.dialogues : [];
return entries
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((entry) => ({
id: String(entry.id || "").trim(),
name: String(entry.name || entry.id || "Dialogue").trim(),
}))
.filter((entry) => entry.id);
}
function getFactionRecords() {
const payload = documentScope.contentBundle.factions;
const entries = payload && Array.isArray(payload.factions) ? payload.factions : [];
return entries
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((entry) => ({
id: String(entry.id || "").trim(),
name: String(entry.name || entry.id || "Faction").trim(),
}))
.filter((entry) => entry.id);
}
function getSpriteCatalogRecords() {
const payload = documentScope.ensureDocumentContentPayload?.("sprites", { schemaVersion: 1, sprites: [] }) || { schemaVersion: 1, sprites: [] };
const entries = payload && Array.isArray(payload.sprites) ? payload.sprites : [];
return entries
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.filter((entry) => String(entry.graphicRole || "sprite").trim().toLowerCase() !== "other")
.map((entry) => {
const id = String(entry.id || "").trim();
const name = String(entry.name || id || "Sprite").trim();
const sprite = documentScope.spriteCatalog[id] || null;
return {
id,
name,
dataUrl: sprite ? sprite.dataUrl : null,
};
})
.filter((entry) => entry.id);
}
function openPlacedEntityContextMenu(npc, event, options = {}) {
if (!npc || !event) {
return false;
}
const entityType = getNpcEntityType(npc);
const removeSource = String(options.removeSource || "instance-list");
const tooltipId = String(options.tooltipId || ("entity-context:" + String(npc.id || "")));
const buildItems = typeof options.buildItems === "function" ? options.buildItems : null;
const menuItems = [
menuLabel(npc.name || npc.id || "Entity"),
menuItem("<span>Edit</span>", () => {
selectNpc(npc);
scope.openEntityEditorWindow?.(npc.id);
uiScope.atTooltip.close();
}),
];
if (options.includeRemove !== false) {
menuItems.push(menuItem("<span>Remove</span>", () => {
removeNpcInstanceById(npc.id, removeSource);
uiScope.atTooltip.close();
}));
}
menuItems.push(...(buildItems?.({ npc, entityType }) || []), menuSeparator(), menuLabel("Entity Type"));
["friendly", "hostile", "prop"].forEach((type) => {
menuItems.push(menuItem(
"<span>" + uiScope.runtimeEscapeHtml(getEntityTypeLabel(type)) + "</span>",
() => {
applyNpcEditorChange(npc, (target) => {
target.record.entityType = type;
}, "Entity Type");
scope.activeEntityCategory = type;
uiScope.refreshEntityTypeTabs?.();
uiScope.renderInstancePalette();
uiScope.renderNpcList();
uiScope.atTooltip.close();
},
entityType === type ? "active" : "",
));
});
openContextMenuAtPoint(uiScope.atTooltip, event.clientX, event.clientY, menuItems, tooltipId);
if (options.status !== false) {
uiScope.setStatus("Opened entity context menu for " + (npc.name || npc.id) + ".", false);
}
return true;
}
function isNpcPlaced(npc) {
if (!npc) {
return false;
}
const x = Number(npc.x);
const y = Number(npc.y);
return Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0;
}
function applyNpcEditorChange(npc, mutator, statusLabel) {
const beforeIndex = documentScope.npcOverlays.findIndex((entry) => entry.id === npc.id);
const beforeSnapshot = documentScope.cloneNpcOverlays([npc])[0];
const beforeX = Math.floor(Number(npc?.x));
const beforeY = Math.floor(Number(npc?.y));
if (!npc.record || typeof npc.record !== "object" || Array.isArray(npc.record)) {
npc.record = {};
}
mutator(npc);
if (Number.isFinite(beforeX) && Number.isFinite(beforeY) && beforeX >= 0 && beforeY >= 0) {
npc.isPlacementSlot = false;
}
documentScope.syncNpcOverlayFromRecord(npc);
const afterX = Math.floor(Number(npc?.x));
const afterY = Math.floor(Number(npc?.y));
if (typeof scope.rebuildWorldChunksForLocalBounds === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
if (Number.isFinite(beforeX) && Number.isFinite(beforeY) && beforeX >= 0 && beforeY >= 0 && Number.isFinite(afterX) && Number.isFinite(afterY) && afterX >= 0 && afterY >= 0) {
scope.rebuildWorldChunksForLocalBounds({
minX: Math.min(beforeX, afterX),
minY: Math.min(beforeY, afterY),
maxX: Math.max(beforeX, afterX),
maxY: Math.max(beforeY, afterY),
});
} else if (Number.isFinite(afterX) && Number.isFinite(afterY) && afterX >= 0 && afterY >= 0) {
scope.rebuildWorldChunksForLocalBounds({ minX: afterX, minY: afterY, maxX: afterX, maxY: afterY });
} else if (Number.isFinite(beforeX) && Number.isFinite(beforeY) && beforeX >= 0 && beforeY >= 0) {
scope.rebuildWorldChunksForLocalBounds({ minX: beforeX, minY: beforeY, maxX: beforeX, maxY: beforeY });
}
}
ensureNpcImageLoaded(npc);
renderNpcList();
renderScope.draw();
const afterSnapshot = documentScope.cloneNpcOverlays([npc])[0];
uiScope.setStatus(statusLabel, false);
historyScope.registerHistory("NPC edited: " + (npc.name || npc.id), "record", "record", [
"NPC id: " + npc.id,
"Updated field: " + statusLabel,
], {
operation: {
type: "npc_entries",
entries: [{
before: beforeSnapshot,
after: afterSnapshot,
beforeIndex,
afterIndex: beforeIndex,
}],
},
});
}
function ensureNpcImageLoaded(npc) {
if (!npc) {
return;
}
if (!npc.dataUrl) {
delete sessionScope.npcImages[npc.id];
return;
}
const existing = sessionScope.npcImages[npc.id];
if (existing && existing.src === npc.dataUrl) {
return;
}
const img = new Image();
img.src = npc.dataUrl;
sessionScope.npcImages[npc.id] = img;
}
function getCachedImage(cacheKey, dataUrl) {
const normalizedKey = String(cacheKey || "");
if (!normalizedKey || !dataUrl) {
return null;
}
const existing = sessionScope.npcImages[normalizedKey];
if (existing && existing.src === dataUrl) {
return existing;
}
const next = new Image();
next.src = dataUrl;
sessionScope.npcImages[normalizedKey] = next;
return next;
}
function assignNpcToSlot(slotId, assignedTemplateId) {
const slot = documentScope.npcOverlays.find((npc) => npc.id === slotId);
if (!slot) {
return;
}
const beforeIndex = documentScope.npcOverlays.findIndex((entry) => entry.id === slot.id);
const beforeSnapshot = documentScope.cloneNpcOverlays([slot])[0];
const catalogEntry = getNpcCatalogRecords().find((entry) => entry.id === assignedTemplateId);
if (!catalogEntry) {
uiScope.setStatus("Entity assignment failed: catalog entry not found.", true);
return;
}
const nextRecord = JSON.parse(JSON.stringify(catalogEntry.record || {}));
nextRecord.id = String(slot.id || documentScope.runtimeUniqueId());
nextRecord.layer = Number(slot.layer) || 0;
nextRecord.position = { x: slot.x, y: slot.y };
nextRecord.name = String(nextRecord.name || catalogEntry.record?.name || "");
nextRecord.templateId = String(assignedTemplateId || "").trim();
nextRecord.entityType = normalizeEntityType(nextRecord.entityType || catalogEntry.entityType, "friendly");
nextRecord.faction = String(nextRecord.faction || catalogEntry.record?.faction || "");
nextRecord.spriteId = String(nextRecord.spriteId || catalogEntry.record?.spriteId || "");
nextRecord.dialogueId = String(nextRecord.dialogueId || catalogEntry.record?.defaultDialogueId || catalogEntry.record?.dialogueId || "");
nextRecord.description = String(nextRecord.description || catalogEntry.record?.description || "");
nextRecord.enabled = typeof nextRecord.enabled === "boolean" ? nextRecord.enabled : true;
slot.isPlacementSlot = false;
slot.record = nextRecord;
documentScope.syncNpcOverlayFromRecord(slot);
if (typeof scope.rebuildWorldChunksForLocalBounds === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
const tileX = Math.floor(Number(slot?.x));
const tileY = Math.floor(Number(slot?.y));
if (Number.isFinite(tileX) && Number.isFinite(tileY) && tileX >= 0 && tileY >= 0) {
scope.rebuildWorldChunksForLocalBounds({ minX: tileX, minY: tileY, maxX: tileX, maxY: tileY });
}
}
ensureNpcImageLoaded(slot);
sessionScope.selectedNpcId = slot.id;
sessionScope.spritePickerOpenNpcId = "";
renderNpcList();
uiScope.renderInstancePalette();
renderScope.draw();
const afterSnapshot = documentScope.cloneNpcOverlays([slot])[0];
historyScope.registerHistory("Catalog entity assigned: " + slot.name, "slot", assignedTemplateId, [
"Assigned entity: " + slot.name,
"Catalog source: " + assignedTemplateId,
"Position: (" + slot.x + "," + slot.y + ")",
], {
operation: {
type: "npc_entries",
entries: [{
before: beforeSnapshot,
after: afterSnapshot,
beforeIndex,
afterIndex: beforeIndex,
}],
},
});
}
function renderNpcList() {
if (uiScope.refreshInstanceSectionState) {
uiScope.refreshInstanceSectionState();
}
const activeEntityType = normalizeEntityType(scope.activeEntityCategory, "friendly");
const orderedNpcs = documentScope.npcOverlays
.slice()
.filter((npc) => getNpcEntityType(npc) === activeEntityType)
.sort((left, right) => String(left.name || left.id || "").localeCompare(String(right.name || right.id || "")));
const orderedNpcIds = orderedNpcs.map((npc) => String(npc.id || "").trim()).filter(Boolean);
if (sessionScope.selectedNpcId && !orderedNpcIds.includes(String(sessionScope.selectedNpcId || "").trim())) {
sessionScope.selectedNpcId = "";
sessionScope.spritePickerOpenNpcId = "";
}
renderFolderedSelectorList({
scope,
container: uiScope.npcListEl,
panelKey: "instances",
items: orderedNpcs,
getItemId: (npc) => npc.id,
emptyMessage: "No " + getEntityTypeLabel(activeEntityType).toLowerCase() + " entities placed yet.",
baseLabel: "Base Panel",
onMove: (dragging, dropTarget) => {
if (scope.movePanelNode("instances", orderedNpcIds, "Placed Entities", dragging, dropTarget)) {
renderNpcList();
}
},
onToggleFolder: (folderId) => {
if (scope.togglePanelFolder("instances", orderedNpcIds, folderId, "Placed Entities")) {
renderNpcList();
}
},
onRenameFolder: (folderId) => {
if (scope.renamePanelFolder("instances", orderedNpcIds, folderId, "Placed Entities")) {
renderNpcList();
}
},
onDeleteFolder: (folderId) => {
if (scope.deletePanelFolder("instances", orderedNpcIds, folderId, "Placed Entities")) {
renderNpcList();
}
},
renderItemRow: (npc) => {
const isSelected = npc.id === sessionScope.selectedNpcId;
const entityType = getNpcEntityType(npc);
const spriteMetaLabel = npc.spriteId ? npc.spriteId : "placeholder";
const positionMetaLabel = isNpcPlaced(npc) ? "(" + npc.x + "," + npc.y + ")" : "unplaced";
const showInlineEditor = false;
const row = document.createElement("div");
row.className = "history-row npc-row" + (isSelected ? " active" : "");
const header = document.createElement("div");
header.className = "npc-row-main";
const summaryButton = document.createElement("button");
summaryButton.type = "button";
summaryButton.className = "npc-row-header";
const thumb = npc.dataUrl
? '<img class="npc-thumb" alt="npc sprite" src="' + scope.runtimeEscapeHtml(npc.dataUrl) + '">'
: '<span class="npc-thumb-fallback">NPC</span>';
summaryButton.innerHTML =
thumb +
"<div><span>" + uiScope.runtimeEscapeHtml(npc.name) + "</span>" +
'<span class="history-meta"><span class="entity-type-badge">' + uiScope.runtimeEscapeHtml(getEntityTypeLabel(entityType)) + "</span> | " + uiScope.runtimeEscapeHtml(npc.id) + " | layer: " + uiScope.runtimeEscapeHtml(String(npc.layer || 0)) + " | sprite: " + uiScope.runtimeEscapeHtml(spriteMetaLabel) + " | pos: " + uiScope.runtimeEscapeHtml(positionMetaLabel) + "</span></div>";
summaryButton.addEventListener("click", () => {
selectNpc(npc);
});
summaryButton.addEventListener("contextmenu", (event) => {
event.preventDefault();
if (sessionScope.activeInstanceBrushId) {
sessionScope.activeInstanceBrushId = "";
uiScope.renderInstancePalette();
}
sessionScope.selectedNpcId = npc.id;
sessionScope.selectedTile = null;
documentScope.setLayerVisibility(Number(npc.layer) || 0, true);
uiScope.setSidebarTab("instances");
renderNpcList();
renderScope.draw();
openPlacedEntityContextMenu(npc, event, {
removeSource: "instance-list",
tooltipId: "instance-list-context:" + String(npc.id || ""),
});
});
header.appendChild(summaryButton);
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "npc-row-edit-btn";
editBtn.title = "Edit entity";
editBtn.setAttribute("aria-label", "Edit entity " + String(npc.name || npc.id || "entity"));
editBtn.appendChild(scope.uiIconEl("edit_note", "E", 14));
editBtn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
selectNpc(npc);
scope.openEntityEditorWindow?.(npc.id);
});
header.appendChild(editBtn);
row.appendChild(header);
if (isSelected && showInlineEditor) {
const editorPanel = document.createElement("div");
editorPanel.className = "npc-editor-panel";
const idRow = document.createElement("div");
idRow.className = "npc-editor-row";
idRow.innerHTML = "<label>Entity ID</label>";
const idInput = document.createElement("input");
idInput.type = "text";
idInput.value = String(npc.id || "");
idInput.readOnly = true;
idInput.style.opacity = "0.6";
idInput.style.cursor = "default";
idRow.appendChild(idInput);
editorPanel.appendChild(idRow);
const topToolbar = document.createElement("div");
topToolbar.className = "npc-top-toolbar";
const templateBtnTag = "template:" + npc.id;
const templateBtn = document.createElement("button");
templateBtn.type = "button";
templateBtn.className = "npc-icon-btn" + (scope.atTooltip.isOpenFor(templateBtnTag) ? " active" : "");
templateBtn.title = "Assign Catalog Entity";
templateBtn.appendChild(scope.uiIconEl("swap", "T", 16));
templateBtn.addEventListener("click", () => {
scope.spritePickerOpenNpcId = "";
const menuItems = [
menuItem("(Use no catalog source)", () => {
applyNpcEditorChange(npc, (target) => {
target.record.name = String(target.record.name || "");
target.record.templateId = "";
}, "Catalog Source");
scope.atTooltip.close();
}),
menuSeparator(),
];
menuItems.push(...buildPickerMenuItems(getNpcCatalogRecords(), {
getInnerHtml: (entry) => {
const thumbHtml = entry.dataUrl
? '<img src="' + scope.runtimeEscapeHtml(entry.dataUrl) + '" alt="">'
: '<span class="npc-thumb-fallback" style="font-size:9px;width:22px;height:22px;">T</span>';
return thumbHtml + "<span>" + scope.runtimeEscapeHtml(entry.name || entry.id) + "</span>";
},
onSelect: (entry) => {
assignNpcToSlot(npc.id, entry.id);
scope.atTooltip.close();
renderNpcList();
},
}));
openContextMenuAtAnchor(scope.atTooltip, templateBtn, menuItems, templateBtnTag);
renderNpcList();
});
topToolbar.appendChild(templateBtn);
const dialogueBtnTag = "dialogue:" + npc.id;
const dialogueBtn = document.createElement("button");
dialogueBtn.type = "button";
dialogueBtn.className = "npc-icon-btn" + (scope.atTooltip.isOpenFor(dialogueBtnTag) ? " active" : "");
dialogueBtn.title = "Assign Dialogue";
dialogueBtn.appendChild(scope.uiIconEl("chat_bubble", "D", 16));
dialogueBtn.addEventListener("click", () => {
scope.spritePickerOpenNpcId = "";
const menuItems = [
menuItem("(Use no dialogue)", () => {
applyNpcEditorChange(npc, (target) => { target.record.dialogueId = ""; }, "Dialogue");
scope.atTooltip.close();
}),
menuSeparator(),
];
menuItems.push(...buildPickerMenuItems(getDialogueCatalogRecords(), {
getInnerHtml: (entry) => "<span>" + scope.runtimeEscapeHtml(entry.name || entry.id) + "</span>",
onSelect: (entry) => {
applyNpcEditorChange(npc, (target) => { target.record.dialogueId = entry.id; }, "Dialogue");
scope.atTooltip.close();
},
getExtraClass: (entry) => (
String(npc.record.dialogueId || "") === entry.id ? "active" : ""
),
}));
openContextMenuAtAnchor(scope.atTooltip, dialogueBtn, menuItems, dialogueBtnTag);
renderNpcList();
});
topToolbar.appendChild(dialogueBtn);
editorPanel.appendChild(topToolbar);
const nameRow = document.createElement("div");
nameRow.className = "npc-editor-row";
nameRow.innerHTML = "<label>Name</label>";
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.value = String(npc.record.name || "");
nameInput.placeholder = "Default: entity id";
nameInput.addEventListener("change", () => {
applyNpcEditorChange(npc, (target) => {
target.record.name = String(nameInput.value || "");
}, "Name");
});
nameRow.appendChild(nameInput);
editorPanel.appendChild(nameRow);
const typeRow = document.createElement("div");
typeRow.className = "npc-editor-row";
typeRow.innerHTML = "<label>Type</label>";
const typeSelect = document.createElement("select");
["friendly", "hostile", "prop"].forEach((type) => {
const option = document.createElement("option");
option.value = type;
option.textContent = getEntityTypeLabel(type);
typeSelect.appendChild(option);
});
typeSelect.value = entityType;
typeSelect.addEventListener("change", () => {
const nextType = normalizeEntityType(typeSelect.value, entityType);
applyNpcEditorChange(npc, (target) => {
target.record.entityType = nextType;
}, "Entity Type");
scope.activeEntityCategory = nextType;
uiScope.refreshEntityTypeTabs?.();
uiScope.renderInstancePalette();
uiScope.renderNpcList();
});
typeRow.appendChild(typeSelect);
editorPanel.appendChild(typeRow);
const factionRow = document.createElement("div");
factionRow.className = "npc-editor-row";
factionRow.innerHTML = "<label>Faction</label>";
const factionSelect = document.createElement("select");
const factionPlaceholder = document.createElement("option");
factionPlaceholder.value = "";
factionPlaceholder.textContent = "(None)";
factionSelect.appendChild(factionPlaceholder);
getFactionRecords().forEach((entry) => {
const option = document.createElement("option");
option.value = entry.id;
option.textContent = entry.name !== entry.id ? entry.name + " (" + entry.id + ")" : entry.id;
factionSelect.appendChild(option);
});
factionSelect.value = String(npc.record.faction || "");
factionSelect.addEventListener("change", () => {
applyNpcEditorChange(npc, (target) => {
target.record.faction = String(factionSelect.value || "");
}, "Faction");
});
factionRow.appendChild(factionSelect);
editorPanel.appendChild(factionRow);
const layerRow = document.createElement("div");
layerRow.className = "npc-editor-row";
layerRow.innerHTML = "<label>Layer</label>";
const layerSelect = document.createElement("select");
scope.roomLayers
.slice()
.sort((a, b) => a.layer - b.layer)
.forEach((layerEntry) => {
const option = document.createElement("option");
option.value = String(layerEntry.layer);
option.textContent = scope.getLayerDisplayName(layerEntry);
layerSelect.appendChild(option);
});
layerSelect.value = String(Number(npc.layer || 0));
layerSelect.addEventListener("change", () => {
applyNpcEditorChange(npc, (target) => {
const nextLayer = Number(layerSelect.value);
target.layer = Number.isFinite(nextLayer) ? nextLayer : 0;
target.record.layer = target.layer;
}, "Layer");
});
layerRow.appendChild(layerSelect);
editorPanel.appendChild(layerRow);
const spriteRow = document.createElement("div");
spriteRow.className = "npc-editor-row";
spriteRow.innerHTML = "<label>Sprite</label>";
const spriteWrap = document.createElement("div");
spriteWrap.className = "sprite-dropdown-wrap";
const spriteBtn = document.createElement("button");
spriteBtn.type = "button";
spriteBtn.className = "sprite-dropdown-btn";
const currentSpriteId = String(npc.record.spriteId || "");
const spriteOptions = getSpriteCatalogRecords();
const currentSprite = spriteOptions.find((entry) => entry.id === currentSpriteId) || null;
const currentThumb = currentSprite && currentSprite.dataUrl
? '<img class="npc-thumb" alt="sprite" src="' + scope.runtimeEscapeHtml(currentSprite.dataUrl) + '">'
: '<span class="npc-thumb-fallback">Spr</span>';
const currentLabel = currentSpriteId || "placeholder";
spriteBtn.innerHTML =
'<span class="sprite-dropdown-current">' +
currentThumb +
"<span>" + scope.runtimeEscapeHtml(currentLabel) + "</span></span>" +
"<span>" + (scope.spritePickerOpenNpcId === npc.id ? "▲" : "▼") + "</span>";
spriteBtn.addEventListener("click", () => {
scope.spritePickerOpenNpcId = scope.spritePickerOpenNpcId === npc.id ? "" : npc.id;
renderNpcList();
});
spriteWrap.appendChild(spriteBtn);
if (scope.spritePickerOpenNpcId === npc.id) {
const menu = document.createElement("div");
menu.className = "sprite-dropdown-menu";
const templateBtn2 = document.createElement("button");
templateBtn2.type = "button";
templateBtn2.className = "sprite-option-btn" + (!currentSpriteId ? " active" : "");
templateBtn2.textContent = "placeholder";
templateBtn2.addEventListener("click", () => {
applyNpcEditorChange(npc, (target) => {
target.record.spriteId = "";
scope.spritePickerOpenNpcId = "";
}, "Sprite");
});
menu.appendChild(templateBtn2);
spriteOptions.forEach((entry) => {
const optionBtn = document.createElement("button");
optionBtn.type = "button";
optionBtn.className = "sprite-option-btn" + (entry.id === currentSpriteId ? " active" : "");
optionBtn.innerHTML =
(entry.dataUrl
? '<img class="npc-thumb" alt="sprite" src="' + scope.runtimeEscapeHtml(entry.dataUrl) + '">'
: '<span class="npc-thumb-fallback">Spr</span>') +
"<span>" + scope.runtimeEscapeHtml(entry.id + " - " + entry.name) + "</span>";
optionBtn.addEventListener("click", () => {
applyNpcEditorChange(npc, (target) => {
target.record.spriteId = entry.id;
scope.spritePickerOpenNpcId = "";
}, "Sprite");
});
menu.appendChild(optionBtn);
});
spriteWrap.appendChild(menu);
}
spriteRow.appendChild(spriteWrap);
editorPanel.appendChild(spriteRow);
const dialogueRow = document.createElement("div");
dialogueRow.className = "npc-editor-row";
dialogueRow.innerHTML = '<label>Dialogue</label><div class="muted">Use the D button above to change the dialogue.</div>';
editorPanel.appendChild(dialogueRow);
const descriptionRow = document.createElement("div");
descriptionRow.className = "npc-editor-row";
descriptionRow.innerHTML = "<label>Description</label>";
const descriptionInput = document.createElement("textarea");
descriptionInput.className = "npc-description-box";
descriptionInput.value = String(npc.record.description || "");
descriptionInput.placeholder = "Optional NPC notes or description";
descriptionInput.addEventListener("input", () => {
npc.record.description = String(descriptionInput.value || "");
npc.description = npc.record.description;
});
descriptionInput.addEventListener("change", () => {
applyNpcEditorChange(npc, (target) => {
target.record.description = String(descriptionInput.value || "");
}, "Description");
});
descriptionRow.appendChild(descriptionInput);
editorPanel.appendChild(descriptionRow);
row.appendChild(editorPanel);
}
return row;
},
});
}
function centerViewportOnNpc(npc) {
if (!npc || !isNpcPlaced(npc)) {
return;
}
const centerX = npc.x * scope.tileSize + scope.getScaledSize(npc.spriteWidth, scope.baseTileSize) / 2;
const centerY = npc.y * scope.tileSize + scope.getScaledSize(npc.spriteHeight, scope.baseTileSize) / 2;
const maxScrollLeft = Math.max(0, scope.viewport.scrollWidth - scope.viewport.clientWidth);
const maxScrollTop = Math.max(0, scope.viewport.scrollHeight - scope.viewport.clientHeight);
const nextLeft = Math.max(0, Math.min(maxScrollLeft, Math.floor(centerX - scope.viewport.clientWidth / 2)));
const nextTop = Math.max(0, Math.min(maxScrollTop, Math.floor(centerY - scope.viewport.clientHeight / 2)));
scope.viewport.scrollLeft = nextLeft;
scope.viewport.scrollTop = nextTop;
}
function selectNpc(npc, options = {}) {
const shouldCenterViewport = options.centerViewport !== false;
scope.selectedNpcId = npc.id;
scope.spritePickerOpenNpcId = "";
if (scope.activeInstanceBrushId) {
scope.activeInstanceBrushId = "";
scope.renderInstancePalette();
}
scope.selectedTile = null;
scope.setSidebarTab("instances");
scope.setLayerVisibility(Number(npc.layer) || 0, true);
renderNpcList();
scope.draw();
if (isNpcPlaced(npc)) {
if (shouldCenterViewport) {
centerViewportOnNpc(npc);
}
scope.setStatus("Selected entity " + (npc.name || npc.id) + ".", false);
} else {
scope.setStatus("Selected unplaced entity " + (npc.name || npc.id) + ". Click the canvas to place it.", false);
}
}
function removeNpcInstanceById(instanceId, sourceLabel) {
const index = scope.npcOverlays.findIndex((entry) => entry.id === instanceId);
if (index < 0) {
return false;
}
const beforeCount = scope.npcOverlays.length;
const npc = scope.npcOverlays[index];
const beforeSnapshot = scope.cloneNpcOverlays([npc])[0];
const tileX = Math.floor(Number(npc?.x));
const tileY = Math.floor(Number(npc?.y));
scope.npcOverlays.splice(index, 1);
if (typeof scope.rebuildWorldChunksForLocalBounds === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
if (Number.isFinite(tileX) && Number.isFinite(tileY) && tileX >= 0 && tileY >= 0) {
scope.rebuildWorldChunksForLocalBounds({ minX: tileX, minY: tileY, maxX: tileX, maxY: tileY });
}
}
delete scope.npcImages[instanceId];
if (scope.selectedNpcId === instanceId) {
scope.selectedNpcId = "";
}
renderNpcList();
scope.renderInstancePalette();
scope.draw();
scope.registerHistory("Entity removed", "entities:" + beforeCount, "entities:" + scope.npcOverlays.length, [
"Removed entity: " + (npc.name || npc.id),
"Position: (" + npc.x + "," + npc.y + ")",
"Source: " + sourceLabel,
], {
operation: {
type: "npc_entries",
entries: [{
before: beforeSnapshot,
after: null,
beforeIndex: index,
afterIndex: -1,
}],
},
});
scope.setStatus("Removed entity " + (npc.name || npc.id) + ".", false);
return true;
}
function findPlacedNpcByTemplateId(templateId) {
const normalizedId = String(templateId || "").trim();
if (!normalizedId) {
return null;
}
return scope.npcOverlays.find((entry) => {
const record = entry && entry.record && typeof entry.record === "object" && !Array.isArray(entry.record)
? entry.record
: {};
return String(record.templateId || "").trim() === normalizedId && Number(entry.x) >= 0 && Number(entry.y) >= 0;
}) || null;
}
function findOpenNpcSpawnTile() {
const occupied = new Set(scope.npcOverlays.map((npc) => npc.x + ":" + npc.y));
const preferredTiles = [];
if (scope.selectedTile && Number.isFinite(scope.selectedTile.x) && Number.isFinite(scope.selectedTile.y)) {
preferredTiles.push({ x: Number(scope.selectedTile.x), y: Number(scope.selectedTile.y) });
}
if (Number.isFinite(scope.hoverTileX) && Number.isFinite(scope.hoverTileY) && scope.hoverTileX >= 0 && scope.hoverTileY >= 0) {
preferredTiles.push({ x: Number(scope.hoverTileX), y: Number(scope.hoverTileY) });
}
for (const tile of preferredTiles) {
if (tile.x >= 0 && tile.x < scope.width && tile.y >= 0 && tile.y < scope.height) {
const key = tile.x + ":" + tile.y;
if (!occupied.has(key)) {
return { x: tile.x, y: tile.y };
}
}
}
const centerX = Math.max(0, Math.min(scope.width - 1, Math.floor(scope.width / 2)));
const centerY = Math.max(0, Math.min(scope.height - 1, Math.floor(scope.height / 2)));
if (!occupied.has(centerX + ":" + centerY)) {
return { x: centerX, y: centerY };
}
for (let radius = 1; radius < Math.max(scope.width, scope.height); radius += 1) {
for (let y = Math.max(0, centerY - radius); y <= Math.min(scope.height - 1, centerY + radius); y += 1) {
for (let x = Math.max(0, centerX - radius); x <= Math.min(scope.width - 1, centerX + radius); x += 1) {
const key = x + ":" + y;
if (!occupied.has(key)) {
return { x, y };
}
}
}
}
return { x: centerX, y: centerY };
}
function createNewNpc() {
scope.activeInstanceBrushId = "";
scope.spritePickerOpenNpcId = "";
const slotId = scope.runtimeUniqueId();
const spawnLayer = scope.getEditableLayerNumber();
const activeEntityType = normalizeEntityType(scope.activeEntityCategory, "friendly");
scope.setLayerVisibility(spawnLayer, true);
const overlay = {
id: slotId,
layer: spawnLayer,
name: "Unassigned " + getEntityTypeMeta(activeEntityType).label + " Entity",
spriteId: "",
isPlacementSlot: true,
x: -1,
y: -1,
dataUrl: null,
spriteWidth: 28,
spriteHeight: 28,
record: {
...JSON.parse(JSON.stringify(scope.defaultNpcTemplate || {})),
id: slotId,
layer: spawnLayer,
position: { x: -1, y: -1 },
name: "",
entityType: activeEntityType,
faction: "",
spriteId: "",
dialogueId: "",
description: "",
enabled: true,
},
};
scope.npcOverlays.push(overlay);
scope.selectedNpcId = slotId;
scope.selectedTile = null;
scope.setSidebarTab("instances");
scope.renderInstancePalette();
renderNpcList();
scope.draw();
const afterSnapshot = scope.cloneNpcOverlays([overlay])[0];
scope.registerHistory("Entity created", "none", "unplaced", [
"Created unassigned unique entity.",
"Entity id: " + slotId,
"Type: " + getEntityTypeLabel(activeEntityType),
"State: unplaced placeholder",
], {
operation: {
type: "npc_entries",
entries: [{
before: null,
after: afterSnapshot,
beforeIndex: -1,
afterIndex: scope.npcOverlays.length - 1,
}],
},
});
scope.setStatus("Created new unplaced " + getEntityTypeMeta(activeEntityType).noun + " " + slotId + ". Click the canvas to place it.", false);
}
return {
getVisibleNpcOverlays,
getNpcCatalogRecords,
getNpcEntityType,
normalizeEntityType,
getEntityTypeLabel,
setNpcCatalogEntityType,
getDialogueCatalogRecords,
getFactionRecords,
getSpriteCatalogRecords,
applyNpcEditorChange,
ensureNpcImageLoaded,
getCachedImage,
assignNpcToSlot,
renderNpcList,
openPlacedEntityContextMenu,
centerViewportOnNpc,
selectNpc,
removeNpcInstanceById,
findPlacedNpcByTemplateId,
findOpenNpcSpawnTile,
createNewNpc,
};
}

View file

@ -0,0 +1,248 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
export function createOverlayRenderer(scope, options) {
const tileImages = options?.tileImages || {};
const draw = typeof options?.draw === "function" ? options.draw : () => {};
const rectIntersects = typeof options?.rectIntersects === "function"
? options.rectIntersects
: () => true;
const drawSelectionReticle = typeof options?.drawSelectionReticle === "function"
? options.drawSelectionReticle
: () => {};
function isNpcPlaced(npc) {
if (!npc) {
return false;
}
const x = Number(npc.x);
const y = Number(npc.y);
return Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0;
}
function drawPlaceholderNpcMarker(drawX, drawY, destW, destH) {
const markerSize = Math.max(scope.tileSize * 0.58, Math.min(Math.min(destW || scope.tileSize, destH || scope.tileSize), scope.tileSize));
const radius = markerSize / 2;
const centerX = drawX + (destW || scope.tileSize) / 2;
const centerY = drawY + (destH || scope.tileSize) / 2;
const outlineRadius = radius + Math.max(2, scope.tileSize * 0.08);
const innerRadius = Math.max(3, radius * 0.72);
const highlightRadius = Math.max(2, radius * 0.24);
scope.ctx.save();
scope.ctx.fillStyle = "rgba(15, 24, 36, 0.92)";
scope.ctx.beginPath();
scope.ctx.arc(centerX, centerY, outlineRadius, 0, Math.PI * 2);
scope.ctx.fill();
scope.ctx.fillStyle = "rgba(255, 74, 182, 0.98)";
scope.ctx.beginPath();
scope.ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
scope.ctx.fill();
scope.ctx.fillStyle = "rgba(255, 222, 66, 0.98)";
scope.ctx.beginPath();
scope.ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2);
scope.ctx.fill();
scope.ctx.strokeStyle = "rgba(0, 245, 255, 0.98)";
scope.ctx.lineWidth = Math.max(1.5, scope.tileSize * 0.09);
scope.ctx.beginPath();
scope.ctx.arc(centerX, centerY, radius * 0.92, 0, Math.PI * 2);
scope.ctx.stroke();
scope.ctx.fillStyle = "rgba(0, 245, 255, 0.98)";
scope.ctx.beginPath();
scope.ctx.arc(centerX - radius * 0.28, centerY - radius * 0.28, highlightRadius, 0, Math.PI * 2);
scope.ctx.fill();
scope.ctx.restore();
}
function drawNpcSprite(npc, drawX, drawY) {
const img = npc.dataUrl ? scope.getCachedImage(npc.id, npc.dataUrl) : null;
const destW = scope.getScaledSize(npc.spriteWidth, scope.baseTileSize);
const destH = scope.getScaledSize(npc.spriteHeight, scope.baseTileSize);
const offsetX = drawX !== undefined ? drawX : npc.x * scope.tileSize;
const offsetY = drawY !== undefined ? drawY : npc.y * scope.tileSize;
if (img && img.complete && img.naturalWidth > 0) {
scope.ctx.drawImage(img, offsetX, offsetY, destW, destH);
} else if (img) {
img.onload = () => draw();
} else {
drawPlaceholderNpcMarker(offsetX, offsetY, destW, destH);
}
}
function drawSelectedNpcHighlight(npc, drawX, drawY) {
const offsetX = drawX !== undefined ? drawX : npc.x * scope.tileSize;
const offsetY = drawY !== undefined ? drawY : npc.y * scope.tileSize;
const destW = Math.max(scope.tileSize, scope.getScaledSize(npc.spriteWidth, scope.baseTileSize));
const destH = Math.max(scope.tileSize, scope.getScaledSize(npc.spriteHeight, scope.baseTileSize));
drawSelectionReticle(offsetX, offsetY, destW, destH);
}
function drawNpcUiOverlay(viewportRect) {
scope.ctx.globalCompositeOperation = "source-over";
const visibleNpcs = scope.getVisibleNpcOverlays();
visibleNpcs.forEach((npc) => {
const i = scope.npcOverlays.indexOf(npc);
if (scope.draggingNpc && scope.draggingNpc.index === i) {
const drawWidth = scope.getScaledSize(npc.spriteWidth, scope.baseTileSize);
const drawHeight = scope.getScaledSize(npc.spriteHeight, scope.baseTileSize);
if (!rectIntersects(viewportRect, scope.dragDrawX, scope.dragDrawY, drawWidth, drawHeight)) {
return;
}
const snapX = Math.max(0, Math.min(scope.width - 1, Math.floor((scope.dragDrawX + scope.draggingNpc.offsetX + scope.tileSize / 2) / scope.tileSize)));
const snapY = Math.max(0, Math.min(scope.height - 1, Math.floor((scope.dragDrawY + scope.draggingNpc.offsetY + scope.tileSize / 2) / scope.tileSize)));
scope.ctx.strokeStyle = "rgba(255,220,50,0.85)";
scope.ctx.lineWidth = 2;
scope.ctx.strokeRect(snapX * scope.tileSize + 1, snapY * scope.tileSize + 1, scope.tileSize - 2, scope.tileSize - 2);
scope.ctx.globalAlpha = 0.72;
drawNpcSprite(npc, scope.dragDrawX, scope.dragDrawY);
scope.ctx.globalAlpha = 1.0;
if (npc.id === scope.selectedNpcId) {
drawSelectedNpcHighlight(npc, scope.dragDrawX, scope.dragDrawY);
}
return;
}
if (npc.id !== scope.selectedNpcId) {
return;
}
if (!(npc.x >= 0 && npc.x < scope.width && npc.y >= 0 && npc.y < scope.height)) {
return;
}
const drawX = npc.x * scope.tileSize;
const drawY = npc.y * scope.tileSize;
const drawW = Math.max(scope.tileSize, scope.getScaledSize(npc.spriteWidth, scope.baseTileSize));
const drawH = Math.max(scope.tileSize, scope.getScaledSize(npc.spriteHeight, scope.baseTileSize));
if (!rectIntersects(viewportRect, drawX, drawY, drawW, drawH)) {
return;
}
drawSelectedNpcHighlight(npc);
});
}
function drawNpcHoverLabel() {
if (!scope.hoveredNpcId) {
return;
}
const hoveredNpc = scope.npcOverlays.find((entry) => entry.id === scope.hoveredNpcId) || null;
if (!hoveredNpc) {
return;
}
const label = (hoveredNpc.name || "NPC") + " [" + hoveredNpc.id + "]";
scope.ctx.save();
scope.ctx.font = "12px Segoe UI, Arial, sans-serif";
const textWidth = Math.ceil(scope.ctx.measureText(label).width);
const boxW = textWidth + 12;
const boxH = 22;
const margin = 10;
let boxX = scope.hoverCanvasX + 14;
let boxY = scope.hoverCanvasY - boxH - 10;
if (boxX + boxW > scope.canvas.width - margin) {
boxX = scope.canvas.width - boxW - margin;
}
if (boxY < margin) {
boxY = scope.hoverCanvasY + 14;
}
scope.ctx.fillStyle = "rgba(6, 14, 28, 0.92)";
scope.ctx.strokeStyle = "rgba(110, 160, 235, 0.9)";
scope.ctx.lineWidth = 1;
scope.ctx.beginPath();
scope.ctx.rect(boxX, boxY, boxW, boxH);
scope.ctx.fill();
scope.ctx.stroke();
scope.ctx.fillStyle = "#d9ebff";
scope.ctx.fillText(label, boxX + 6, boxY + 15);
scope.ctx.restore();
}
function drawGhostCursor() {
if (scope.hoverTileX < 0 || scope.hoverTileY < 0 || scope.hoverTileX >= scope.width || scope.hoverTileY >= scope.height) {
return;
}
const drawX = scope.hoverTileX * scope.tileSize;
const drawY = scope.hoverTileY * scope.tileSize;
if (scope.activeSidebarTab === "tiles" && scope.canvasToolMode !== "select" && scope.activeBrushTileId) {
const tile = scope.getTileEntryById(scope.activeBrushTileId);
const symbol = String(tile.symbol || "").charAt(0);
const img = symbol ? tileImages[symbol] : null;
scope.ctx.save();
scope.ctx.globalAlpha = 0.55;
if (img && img.complete && img.naturalWidth > 0) {
scope.ctx.drawImage(img, drawX, drawY, scope.tileSize, scope.tileSize);
} else {
scope.ctx.fillStyle = tile.color || scope.defaultTileColor;
scope.ctx.fillRect(drawX, drawY, scope.tileSize, scope.tileSize);
}
scope.ctx.globalAlpha = 1.0;
scope.ctx.strokeStyle = "rgba(255,255,255,0.7)";
scope.ctx.lineWidth = 1.5;
scope.ctx.strokeRect(drawX + 0.5, drawY + 0.5, scope.tileSize - 1, scope.tileSize - 1);
scope.ctx.restore();
return;
}
if (scope.activeSidebarTab === "instances" && scope.activeInstanceBrushId) {
const templateEntry = scope.getNpcCatalogRecords().find((entry) => entry.id === scope.activeInstanceBrushId);
const dataUrl = templateEntry ? templateEntry.dataUrl : null;
const spriteW = templateEntry
? scope.getScaledSize((scope.spriteCatalog[templateEntry.spriteId || ""] || {}).spriteWidth, scope.baseTileSize)
: scope.tileSize;
const spriteH = templateEntry
? scope.getScaledSize((scope.spriteCatalog[templateEntry.spriteId || ""] || {}).spriteHeight, scope.baseTileSize)
: scope.tileSize;
scope.ctx.save();
scope.ctx.globalAlpha = 0.55;
if (dataUrl) {
const img = scope.getCachedImage("__ghost__" + scope.activeInstanceBrushId, dataUrl);
if (img && !img.complete) {
img.onload = () => draw();
}
if (img.complete && img.naturalWidth > 0) {
scope.ctx.drawImage(img, drawX, drawY, spriteW, spriteH);
}
} else {
drawPlaceholderNpcMarker(drawX, drawY, spriteW, spriteH);
}
scope.ctx.globalAlpha = 1.0;
scope.ctx.strokeStyle = "rgba(100,220,100,0.8)";
scope.ctx.lineWidth = 1.5;
scope.ctx.strokeRect(drawX + 0.5, drawY + 0.5, scope.tileSize - 1, scope.tileSize - 1);
scope.ctx.restore();
return;
}
if (scope.activeSidebarTab !== "instances") {
return;
}
const selectedNpc = scope.npcOverlays.find((entry) => entry.id === scope.selectedNpcId) || null;
if (!selectedNpc || isNpcPlaced(selectedNpc)) {
return;
}
const spriteW = Math.max(1, scope.getScaledSize(selectedNpc.spriteWidth, scope.baseTileSize));
const spriteH = Math.max(1, scope.getScaledSize(selectedNpc.spriteHeight, scope.baseTileSize));
scope.ctx.save();
scope.ctx.globalAlpha = 0.55;
if (selectedNpc.dataUrl) {
const ghostImg = scope.getCachedImage("__ghost_selected__" + selectedNpc.id, selectedNpc.dataUrl);
if (ghostImg && !ghostImg.complete) {
ghostImg.onload = () => draw();
}
if (ghostImg && ghostImg.complete && ghostImg.naturalWidth > 0) {
scope.ctx.drawImage(ghostImg, drawX, drawY, spriteW, spriteH);
} else {
drawPlaceholderNpcMarker(drawX, drawY, spriteW, spriteH);
}
} else {
drawPlaceholderNpcMarker(drawX, drawY, spriteW, spriteH);
}
scope.ctx.globalAlpha = 1.0;
scope.ctx.strokeStyle = "rgba(95,168,255,0.92)";
scope.ctx.lineWidth = 1.5;
scope.ctx.strokeRect(drawX + 0.5, drawY + 0.5, scope.tileSize - 1, scope.tileSize - 1);
scope.ctx.restore();
}
return {
drawNpcUiOverlay,
drawNpcHoverLabel,
drawGhostCursor,
};
}

View file

@ -0,0 +1,289 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
const ITEM_NODE_PREFIX = "item:";
const FOLDER_NODE_PREFIX = "folder:";
function normalizeId(value) {
return String(value || "").trim();
}
function uniquePush(target, value, seen) {
if (!value || seen.has(value)) {
return;
}
seen.add(value);
target.push(value);
}
export function toItemNodeId(itemId) {
const normalizedId = normalizeId(itemId);
return normalizedId ? ITEM_NODE_PREFIX + normalizedId : "";
}
export function toFolderNodeId(folderId) {
const normalizedId = normalizeId(folderId);
return normalizedId ? FOLDER_NODE_PREFIX + normalizedId : "";
}
export function getItemIdFromNodeId(nodeId) {
const normalizedId = normalizeId(nodeId);
if (!normalizedId) {
return "";
}
if (normalizedId.startsWith(ITEM_NODE_PREFIX)) {
return normalizeId(normalizedId.slice(ITEM_NODE_PREFIX.length));
}
if (normalizedId.startsWith(FOLDER_NODE_PREFIX)) {
return "";
}
return normalizedId;
}
export function getFolderIdFromNodeId(nodeId) {
const normalizedId = normalizeId(nodeId);
if (!normalizedId) {
return "";
}
if (normalizedId.startsWith(FOLDER_NODE_PREFIX)) {
return normalizeId(normalizedId.slice(FOLDER_NODE_PREFIX.length));
}
return "";
}
function cloneFolder(folderId, sourceFolder) {
const rawFolder = sourceFolder && typeof sourceFolder === "object" && !Array.isArray(sourceFolder)
? sourceFolder
: {};
return {
id: folderId,
name: String(rawFolder.name || "New Folder").trim() || "New Folder",
collapsed: rawFolder.collapsed === true,
itemOrder: Array.isArray(rawFolder.itemOrder)
? rawFolder.itemOrder.map((entry) => normalizeId(entry)).filter(Boolean)
: (Array.isArray(rawFolder.items) ? rawFolder.items.map((entry) => normalizeId(entry)).filter(Boolean) : []),
};
}
export function normalizePanelFolderLayout(rawLayout, itemIds) {
const validItemIds = Array.isArray(itemIds)
? itemIds.map((entry) => normalizeId(entry)).filter(Boolean)
: [];
const validItemIdSet = new Set(validItemIds);
const rawState = rawLayout && typeof rawLayout === "object" && !Array.isArray(rawLayout)
? rawLayout
: {};
const rawFolders = rawState.folders && typeof rawState.folders === "object" && !Array.isArray(rawState.folders)
? rawState.folders
: {};
const folders = {};
const folderIds = [];
Object.entries(rawFolders).forEach(([rawFolderId, rawFolderValue]) => {
const folderId = normalizeId(rawFolderValue?.id || rawFolderId);
if (!folderId || folders[folderId]) {
return;
}
folders[folderId] = cloneFolder(folderId, rawFolderValue);
folderIds.push(folderId);
});
const assignedItemIds = new Set();
folderIds.forEach((folderId) => {
const nextItemOrder = [];
folders[folderId].itemOrder.forEach((itemId) => {
const normalizedId = normalizeId(itemId);
if (!validItemIdSet.has(normalizedId) || assignedItemIds.has(normalizedId)) {
return;
}
assignedItemIds.add(normalizedId);
nextItemOrder.push(normalizedId);
});
folders[folderId].itemOrder = nextItemOrder;
});
const rootOrder = [];
const seenRootNodes = new Set();
const rawRootOrder = Array.isArray(rawState.rootOrder)
? rawState.rootOrder
: (Array.isArray(rawState.order) ? rawState.order : []);
rawRootOrder.forEach((rawNodeId) => {
const folderId = getFolderIdFromNodeId(rawNodeId);
if (folderId) {
if (!folders[folderId]) {
return;
}
uniquePush(rootOrder, toFolderNodeId(folderId), seenRootNodes);
return;
}
const itemId = getItemIdFromNodeId(rawNodeId);
if (!itemId || !validItemIdSet.has(itemId) || assignedItemIds.has(itemId)) {
return;
}
assignedItemIds.add(itemId);
uniquePush(rootOrder, toItemNodeId(itemId), seenRootNodes);
});
folderIds.forEach((folderId) => {
uniquePush(rootOrder, toFolderNodeId(folderId), seenRootNodes);
});
validItemIds.forEach((itemId) => {
if (assignedItemIds.has(itemId)) {
return;
}
assignedItemIds.add(itemId);
uniquePush(rootOrder, toItemNodeId(itemId), seenRootNodes);
});
return {
rootOrder,
folders,
};
}
export function clonePanelFolderLayout(layout, itemIds) {
return normalizePanelFolderLayout(
JSON.parse(JSON.stringify(layout || {})),
Array.isArray(itemIds) ? itemIds.slice() : [],
);
}
export function createPanelFolderLayoutFolder(layout, itemIds, folderId, folderName) {
const nextLayout = clonePanelFolderLayout(layout, itemIds);
const normalizedFolderId = normalizeId(folderId);
if (!normalizedFolderId || nextLayout.folders[normalizedFolderId]) {
return nextLayout;
}
nextLayout.folders[normalizedFolderId] = {
id: normalizedFolderId,
name: String(folderName || "New Folder").trim() || "New Folder",
collapsed: false,
itemOrder: [],
};
nextLayout.rootOrder = [toFolderNodeId(normalizedFolderId)].concat(nextLayout.rootOrder.filter((nodeId) => nodeId !== toFolderNodeId(normalizedFolderId)));
return normalizePanelFolderLayout(nextLayout, itemIds);
}
export function renamePanelFolderLayoutFolder(layout, itemIds, folderId, nextName) {
const nextLayout = clonePanelFolderLayout(layout, itemIds);
const normalizedFolderId = normalizeId(folderId);
if (!normalizedFolderId || !nextLayout.folders[normalizedFolderId]) {
return nextLayout;
}
nextLayout.folders[normalizedFolderId].name = String(nextName || "").trim() || nextLayout.folders[normalizedFolderId].name || "New Folder";
return normalizePanelFolderLayout(nextLayout, itemIds);
}
export function togglePanelFolderLayoutFolder(layout, itemIds, folderId) {
const nextLayout = clonePanelFolderLayout(layout, itemIds);
const normalizedFolderId = normalizeId(folderId);
if (!normalizedFolderId || !nextLayout.folders[normalizedFolderId]) {
return nextLayout;
}
nextLayout.folders[normalizedFolderId].collapsed = !nextLayout.folders[normalizedFolderId].collapsed;
return normalizePanelFolderLayout(nextLayout, itemIds);
}
export function deletePanelFolderLayoutFolder(layout, itemIds, folderId) {
const nextLayout = clonePanelFolderLayout(layout, itemIds);
const normalizedFolderId = normalizeId(folderId);
const folder = nextLayout.folders[normalizedFolderId];
if (!normalizedFolderId || !folder) {
return nextLayout;
}
const folderNodeId = toFolderNodeId(normalizedFolderId);
const folderIndex = nextLayout.rootOrder.findIndex((nodeId) => nodeId === folderNodeId);
const nextRootOrder = nextLayout.rootOrder.filter((nodeId) => nodeId !== folderNodeId);
const movedItemNodeIds = folder.itemOrder.map((itemId) => toItemNodeId(itemId));
if (folderIndex >= 0) {
nextRootOrder.splice(folderIndex, 0, ...movedItemNodeIds);
} else {
nextRootOrder.push(...movedItemNodeIds);
}
nextLayout.rootOrder = nextRootOrder;
delete nextLayout.folders[normalizedFolderId];
return normalizePanelFolderLayout(nextLayout, itemIds);
}
function removeItemFromLayout(layout, itemId) {
const normalizedItemId = normalizeId(itemId);
layout.rootOrder = layout.rootOrder.filter((nodeId) => getItemIdFromNodeId(nodeId) !== normalizedItemId);
Object.values(layout.folders).forEach((folder) => {
folder.itemOrder = folder.itemOrder.filter((entry) => normalizeId(entry) !== normalizedItemId);
});
}
function removeFolderFromLayout(layout, folderId) {
const normalizedFolderId = normalizeId(folderId);
layout.rootOrder = layout.rootOrder.filter((nodeId) => getFolderIdFromNodeId(nodeId) !== normalizedFolderId);
}
function insertRelative(list, value, targetValue, position) {
const nextList = Array.isArray(list) ? list.slice() : [];
const targetIndex = nextList.findIndex((entry) => entry === targetValue);
if (targetIndex < 0) {
nextList.push(value);
return nextList;
}
const insertionIndex = position === "after" ? targetIndex + 1 : targetIndex;
nextList.splice(insertionIndex, 0, value);
return nextList;
}
export function movePanelFolderLayoutNode(layout, itemIds, dragging, dropTarget) {
const nextLayout = clonePanelFolderLayout(layout, itemIds);
const dragKind = normalizeId(dragging?.kind);
const dragId = normalizeId(dragging?.id);
const dropKind = normalizeId(dropTarget?.kind);
const dropId = normalizeId(dropTarget?.id);
const dropPosition = dropTarget?.position === "after" ? "after" : "before";
if (!dragKind || !dragId || !dropKind) {
return nextLayout;
}
if (dragKind === "folder") {
removeFolderFromLayout(nextLayout, dragId);
const folderNodeId = toFolderNodeId(dragId);
if (dropKind === "root") {
nextLayout.rootOrder.push(folderNodeId);
return normalizePanelFolderLayout(nextLayout, itemIds);
}
if (dropKind === "item" && !normalizeId(dropTarget?.parentFolderId)) {
nextLayout.rootOrder = insertRelative(nextLayout.rootOrder, folderNodeId, toItemNodeId(dropId), dropPosition);
return normalizePanelFolderLayout(nextLayout, itemIds);
}
if (dropKind === "folder") {
nextLayout.rootOrder = insertRelative(nextLayout.rootOrder, folderNodeId, toFolderNodeId(dropId), dropPosition);
return normalizePanelFolderLayout(nextLayout, itemIds);
}
nextLayout.rootOrder.push(folderNodeId);
return normalizePanelFolderLayout(nextLayout, itemIds);
}
if (dragKind === "item") {
removeItemFromLayout(nextLayout, dragId);
if (dropKind === "folder" && nextLayout.folders[dropId]) {
nextLayout.folders[dropId].itemOrder.push(dragId);
return normalizePanelFolderLayout(nextLayout, itemIds);
}
if (dropKind === "item") {
const parentFolderId = normalizeId(dropTarget?.parentFolderId);
if (parentFolderId && nextLayout.folders[parentFolderId]) {
nextLayout.folders[parentFolderId].itemOrder = insertRelative(
nextLayout.folders[parentFolderId].itemOrder,
dragId,
dropId,
dropPosition,
);
return normalizePanelFolderLayout(nextLayout, itemIds);
}
nextLayout.rootOrder = insertRelative(nextLayout.rootOrder, toItemNodeId(dragId), toItemNodeId(dropId), dropPosition);
return normalizePanelFolderLayout(nextLayout, itemIds);
}
nextLayout.rootOrder.push(toItemNodeId(dragId));
return normalizePanelFolderLayout(nextLayout, itemIds);
}
return nextLayout;
}

View file

@ -0,0 +1,151 @@
/* eslint-disable @typescript-eslint/ban-ts-comment, no-empty */
// @ts-nocheck
export function createPersistenceController(scope) {
const documentScope = scope.documentScope || scope;
const historyScope = scope.historyScope || scope;
const uiScope = scope.uiScope || scope;
async function readErrorResponse(response) {
try {
const text = await response.text();
const trimmed = String(text || "").trim();
return trimmed ? `: ${trimmed.slice(0, 240)}` : "";
} catch {
return "";
}
}
async function persistContentPayload(type, payload) {
const normalizedType = String(type || "").trim();
const response = await fetch(scope.apiBase + "/api/content/" + normalizedType, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const responseDetails = await readErrorResponse(response);
if (normalizedType === "images" && response.status === 404 && /Unknown content type/i.test(responseDetails)) {
throw new Error("Graphics save failed (404): API does not support images yet. Restart the server.");
}
throw new Error(getContentSaveLabel(normalizedType) + " save failed (" + response.status + ")" + responseDetails);
}
return response.json().catch(() => ({ ok: true }));
}
function getContentSaveLabel(type) {
const labels = {
npcs: "NPC",
images: "Graphics",
sprites: "Sprite",
tiles: "Tile",
};
return labels[String(type || "").trim()] || "Content";
}
async function persistWorldChunkBatchPayload(worldId, payload) {
const response = await fetch(
scope.apiBase + "/api/world/" + encodeURIComponent(String(worldId || "").trim()) + "/chunks/batch-save",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
throw new Error("World save failed (" + response.status + ")" + await readErrorResponse(response));
}
return response.json().catch(() => ({ ok: true }));
}
async function saveCurrentWorldState() {
const worldId = String(scope.worldId || scope.mapId || "").trim();
if (!worldId) {
uiScope.setStatus("World id missing. Cannot save.", true);
return false;
}
if (typeof documentScope.ensureWorldDocumentCurrent === "function") {
documentScope.ensureWorldDocumentCurrent();
}
if (typeof scope.syncCachedWorldHeightLayerMetadata === "function") {
scope.syncCachedWorldHeightLayerMetadata();
}
const dirtyChunkKeys = typeof scope.getDirtyWorldChunkKeys === "function"
? scope.getDirtyWorldChunkKeys()
: [];
if (dirtyChunkKeys.length > 0 && typeof scope.rebuildVisibleWorldChunksFromDocument === "function") {
scope.rebuildVisibleWorldChunksFromDocument(dirtyChunkKeys);
}
const chunksToSave = typeof scope.getDirtyWorldChunkPayloads === "function"
? scope.getDirtyWorldChunkPayloads()
: [];
historyScope.isSaving = true;
historyScope.refreshToolbarState();
let saveFailed = false;
try {
await persistWorldChunkBatchPayload(worldId, {
world: {
id: worldId,
name: String(scope.worldName || documentScope.mapName || worldId).trim() || worldId,
chunkWidth: Math.max(1, Number(scope.worldChunkWidth) || 32),
chunkHeight: Math.max(1, Number(scope.worldChunkHeight) || 32),
tileSize: Math.max(8, Number(documentScope.baseTileSize) || 32),
backgroundColor: documentScope.normalizeMapBackgroundColor(documentScope.backgroundColor),
defaultBackgroundTileId: documentScope.normalizeBackgroundTileId(documentScope.backgroundTileId),
heightBlurStep: Math.max(0, Math.min(1, Number(documentScope.heightBlurStep ?? documentScope.heightDetailStep) || 0.1)),
editorUi: documentScope.cloneEditorUiState(),
spawn: {
x: Math.floor(Number(scope.worldSpawnX) || 0),
y: Math.floor(Number(scope.worldSpawnY) || 0),
},
},
bookmarks: {
schemaVersion: 1,
worldId,
bookmarks: typeof scope.getWorldBookmarks === "function"
? scope.getWorldBookmarks().map((entry) => ({
id: String(entry?.id || "").trim(),
label: String(entry?.label || entry?.id || "").trim(),
x: Math.floor(Number(entry?.x) || 0),
y: Math.floor(Number(entry?.y) || 0),
})).filter((entry) => entry.id)
: [],
},
chunks: chunksToSave,
});
if (typeof scope.cacheStandaloneMapBootstrap === "function") {
scope.cacheStandaloneMapBootstrap(scope.mapId);
}
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage({ type: "map-editor-saved", mapId: worldId }, "*");
}
} catch {}
historyScope.lastSavedHistoryId = historyScope.historyEntries[historyScope.historyIndex]
? historyScope.historyEntries[historyScope.historyIndex].id
: historyScope.lastSavedHistoryId;
historyScope.persistHistoryState();
if (typeof scope.clearDirtyWorldChunks === "function" && chunksToSave.length > 0) {
scope.clearDirtyWorldChunks(chunksToSave.map((chunk) => `${Math.floor(Number(chunk?.chunkX) || 0)}:${Math.floor(Number(chunk?.chunkY) || 0)}`));
}
uiScope.setStatus(chunksToSave.length > 0 ? ("Saved world chunks: " + chunksToSave.length + ".") : "Saved world metadata.", false);
return true;
} catch (error) {
saveFailed = true;
uiScope.setStatus(String(error), true);
return false;
} finally {
historyScope.isSaving = false;
historyScope.refreshToolbarState(saveFailed);
}
}
async function saveCurrentState() {
return saveCurrentWorldState();
}
return {
persistContentPayload,
saveCurrentState,
};
}

View file

@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { refreshCanvasTexture } from "./pixiSurfaceHelpers";
export function buildChunkSignature(symbolAt, tileWidth, tileHeight, prefix) {
const parts = [String(prefix || "")];
for (let localY = 0; localY < tileHeight; localY += 1) {
let row = "";
for (let localX = 0; localX < tileWidth; localX += 1) {
row += String(symbolAt(localX, localY) || " ");
}
parts.push(row);
}
return parts.join("\n");
}
export function redrawChunkEntrySurface(entry, symbolAt, options) {
options.syncChunkEntryDimensions(entry);
const pixelTileSize = Math.max(1, Number(options.baseTileSize) || Number(options.tileSize) || 32);
const pixelWidth = Math.max(1, entry.tileWidth * pixelTileSize);
const pixelHeight = Math.max(1, entry.tileHeight * pixelTileSize);
entry.ctx.setTransform(1, 0, 0, 1, 0, 0);
entry.ctx.clearRect(0, 0, pixelWidth, pixelHeight);
entry.ctx.imageSmoothingEnabled = false;
let hasContent = false;
for (let localY = 0; localY < entry.tileHeight; localY += 1) {
for (let localX = 0; localX < entry.tileWidth; localX += 1) {
const symbol = symbolAt(localX, localY);
if (!symbol) {
continue;
}
const tileSurface = options.getTileSurface(symbol);
if (!tileSurface) {
continue;
}
const tileOpacity = typeof options.getTileOpacity === "function"
? Number(options.getTileOpacity(symbol))
: 1;
const normalizedTileOpacity = Number.isFinite(tileOpacity)
? Math.max(0, Math.min(1, tileOpacity))
: 1;
entry.ctx.globalAlpha = normalizedTileOpacity;
entry.ctx.drawImage(
tileSurface,
localX * pixelTileSize,
localY * pixelTileSize,
pixelTileSize,
pixelTileSize,
);
entry.ctx.globalAlpha = 1;
hasContent = true;
}
}
refreshCanvasTexture(entry.texture);
entry.lastRenderedAt = performance.now();
entry.sprite.visible = hasContent;
return hasContent;
}
export function rebuildChunkEntrySurface(entry, symbolAt, tileWidth, tileHeight, signaturePrefix, options) {
const safeWidth = Math.max(1, Number(tileWidth) || Number(options.chunkSize) || 32);
const safeHeight = Math.max(1, Number(tileHeight) || Number(options.chunkSize) || 32);
if (entry.tileWidth !== safeWidth || entry.tileHeight !== safeHeight) {
entry.tileWidth = safeWidth;
entry.tileHeight = safeHeight;
}
const signature = buildChunkSignature(symbolAt, safeWidth, safeHeight, signaturePrefix);
if (entry.signature === signature) {
return entry.sprite.visible;
}
entry.signature = signature;
return redrawChunkEntrySurface(entry, symbolAt, options);
}

View file

@ -0,0 +1,168 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { BlurFilter, Sprite, Texture } from "pixi.js";
import { getWorldBorderThickness, parseHexColor } from "./pixiSurfaceHelpers";
function createBorderSprite(x, y, width, height, tint, alpha) {
const border = new Sprite(Texture.WHITE);
border.x = x;
border.y = y;
border.width = width;
border.height = height;
border.tint = tint;
border.alpha = alpha;
return border;
}
export function rebuildHeightOverlay(state, scope, helpers) {
if (!helpers.isReady() || !state.heightOverlayRoot) {
return;
}
const overlayMode = scope.isEditingHeightLayer && scope.isEditingHeightLayer() ? "height" : "";
const activeEntry = scope.getActiveHeightLayer ? scope.getActiveHeightLayer() : null;
const activeId = String(activeEntry?.id || "").trim();
if (
!state.dirty
&& state.lastHeightLayersRef === scope.heightLayers
&& state.lastHeightOverlayMode === overlayMode
&& state.lastHeightActiveId === activeId
&& state.lastHeightTileSize === scope.tileSize
) {
return;
}
state.heightPatchEntries.forEach((entry) => {
helpers.destroyHeightPatchEntry(entry);
});
state.heightPatchEntries.clear();
state.heightOverlayRoot.removeChildren();
state.lastHeightLayersRef = scope.heightLayers;
state.lastHeightOverlayMode = overlayMode;
state.lastHeightActiveId = activeId;
state.lastHeightTileSize = scope.tileSize;
if (overlayMode !== "height" || !activeEntry) {
state.heightOverlayRoot.visible = false;
return;
}
state.heightOverlayRoot.visible = true;
const activeZ = Math.max(1, Number(activeEntry.z) || 1);
const visibleEntries = (Array.isArray(scope.heightLayers) ? scope.heightLayers : [])
.filter((entry) => Math.max(1, Number(entry?.z) || 1) === activeZ);
const borderThickness = getWorldBorderThickness(scope.tileSize);
visibleEntries.forEach((entry) => {
const entryId = String(entry?.id || "").trim();
if (!entryId) {
return;
}
const isActive = entryId === activeId;
const patchContainer = new helpers.Container();
patchContainer.label = `height_${entryId}`;
patchContainer.x = Math.max(0, Number(entry?.x) || 0);
patchContainer.y = Math.max(0, Number(entry?.y) || 0);
patchContainer.alpha = isActive ? 0.92 : 0.56;
patchContainer.roundPixels = true;
state.heightOverlayRoot.addChild(patchContainer);
const rows = Array.isArray(entry?.rows) ? entry.rows : [];
let patchWidth = 0;
rows.forEach((rawRow, localY) => {
const row = String(rawRow || "");
patchWidth = Math.max(patchWidth, row.length);
for (let localX = 0; localX < row.length; localX += 1) {
const symbol = String(row.charAt(localX) || " ").charAt(0) || " ";
if (symbol === " " || symbol === ".") {
continue;
}
const sprite = new Sprite(helpers.getTileTexture(symbol));
sprite.x = localX;
sprite.y = localY;
sprite.width = 1;
sprite.height = 1;
sprite.roundPixels = true;
patchContainer.addChild(sprite);
if (isActive) {
const shade = new Sprite(Texture.WHITE);
shade.x = localX;
shade.y = localY;
shade.width = 1;
shade.height = 1;
shade.tint = parseHexColor("#9198A8", 0x9198A8);
shade.alpha = 0.28;
shade.roundPixels = true;
patchContainer.addChild(shade);
}
}
});
if (patchWidth > 0 && rows.length > 0) {
const drawW = patchWidth;
const drawH = rows.length;
const tint = isActive ? parseHexColor("#FFEB8C", 0xFFEB8C) : parseHexColor("#6EA0EB", 0x6EA0EB);
const alpha = isActive ? 0.92 : 0.55;
patchContainer.addChild(createBorderSprite(0, 0, drawW, borderThickness, tint, alpha));
patchContainer.addChild(createBorderSprite(0, drawH - borderThickness, drawW, borderThickness, tint, alpha));
patchContainer.addChild(createBorderSprite(0, 0, borderThickness, drawH, tint, alpha));
patchContainer.addChild(createBorderSprite(drawW - borderThickness, 0, borderThickness, drawH, tint, alpha));
}
state.heightPatchEntries.set(entryId, {
id: entryId,
container: patchContainer,
});
});
}
export function resetSceneRoots(state, scope) {
if (!state.backgroundSprite || !state.baseWorldRoot) {
state.backgroundSprite = new Sprite(Texture.WHITE);
state.backgroundSprite.zIndex = -1;
state.backgroundSprite.roundPixels = true;
}
state.backgroundSprite.tint = parseHexColor(scope.normalizeMapBackgroundColor(scope.backgroundColor));
state.backgroundSprite.width = Math.max(1, Number(scope.width) || 1);
state.backgroundSprite.height = Math.max(1, Number(scope.height) || 1);
if (state.backgroundSprite.parent !== state.baseWorldRoot) {
state.baseWorldRoot.addChildAt(state.backgroundSprite, 0);
}
if (state.heightOverlayRoot?.parent !== state.worldContainer) {
state.worldContainer.addChild(state.heightOverlayRoot);
}
}
export function syncHeightFocusEffect(state, scope) {
if (!state.baseWorldRoot) {
return;
}
const activeHeightLayer = scope.getActiveHeightLayer ? scope.getActiveHeightLayer() : null;
const activeHeightZ = Math.max(0, Number(activeHeightLayer?.z) || 0);
const isHeightModeActive = !!(scope.isEditingHeightLayer && scope.isEditingHeightLayer()) && activeHeightZ > 0;
const blurStep = Math.max(
0,
Math.min(
1,
Number(scope.getEffectiveHeightBlurStep?.() ?? scope.heightBlurStep ?? scope.heightDetailStep) || 0.1,
),
);
const tileReferenceSize = Math.max(8, Number(scope.baseTileSize) || Number(scope.tileSize) || 32);
const nextBlurStrength = isHeightModeActive
? Math.min(8, activeHeightZ * blurStep * (tileReferenceSize / 4))
: 0;
if (!state.heightFocusBlurFilter) {
state.heightFocusBlurFilter = new BlurFilter({ strength: 0, quality: 2, kernelSize: 5 });
state.heightFocusBlurFilter.repeatEdgePixels = true;
}
if (isHeightModeActive) {
if (!state.heightFocusCacheEnabled || Math.abs(nextBlurStrength - state.heightFocusBlurStrength) > 0.001) {
state.heightFocusBlurFilter.strength = nextBlurStrength;
state.baseWorldRoot.filters = [state.heightFocusBlurFilter];
state.heightFocusCacheEnabled = true;
state.heightFocusBlurStrength = nextBlurStrength;
}
} else if (state.heightFocusCacheEnabled) {
state.baseWorldRoot.filters = [];
state.heightFocusCacheEnabled = false;
state.heightFocusBlurStrength = 0;
}
state.baseWorldRoot.alpha = isHeightModeActive
? Math.max(0.92, 1 - (activeHeightZ * 0.015))
: 1;
}

View file

@ -0,0 +1,145 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { rebuildChunkEntrySurface } from "./pixiChunkSurfaceHelpers";
function buildChunkSurfaceOptions(state, scope, helpers) {
return {
baseTileSize: scope.baseTileSize,
chunkSize: state.chunkSize,
getTileOpacity: helpers.getTileOpacity,
getTileSurface: helpers.getTileSurface,
syncChunkEntryDimensions: helpers.syncChunkEntryDimensions,
tileSize: scope.tileSize,
};
}
export function rebuildSceneFromWorldChunks(state, scope, helpers) {
const worldContext = helpers.getWorldChunkRenderContext();
if (!worldContext) {
return false;
}
helpers.resetSceneRoots();
const chunkSurfaceOptions = buildChunkSurfaceOptions(state, scope, helpers);
const desiredKeys = new Set();
const activeLayerNumbers = new Set();
worldContext.chunks.forEach((chunk) => {
const chunkX = Math.floor(Number(chunk?.chunkX) || 0);
const chunkY = Math.floor(Number(chunk?.chunkY) || 0);
const tileX = (chunkX - worldContext.originChunkX) * worldContext.chunkWidth;
const tileY = (chunkY - worldContext.originChunkY) * worldContext.chunkHeight;
const roomLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
roomLayers.forEach((layerObj) => {
const layerNumber = Number(layerObj?.layer) || 0;
activeLayerNumbers.add(layerNumber);
const layerRoot = helpers.getOrCreateLayerRoot(layerNumber);
layerRoot.visible = scope.isLayerRendered(layerNumber);
const chunkKey = helpers.buildLayerChunkKey(layerNumber, chunkX, chunkY);
desiredKeys.add(chunkKey);
let entry = state.chunkEntries.get(chunkKey) || null;
if (!entry) {
entry = helpers.createChunkEntry(layerNumber, chunkX, chunkY, {
tileX,
tileY,
tileWidth: worldContext.chunkWidth,
tileHeight: worldContext.chunkHeight,
});
}
if (entry.tileX !== tileX || entry.tileY !== tileY) {
entry.tileX = tileX;
entry.tileY = tileY;
entry.sprite.x = tileX;
entry.sprite.y = tileY;
}
if (entry.tileWidth !== worldContext.chunkWidth || entry.tileHeight !== worldContext.chunkHeight) {
entry.tileWidth = worldContext.chunkWidth;
entry.tileHeight = worldContext.chunkHeight;
}
const hasContent = rebuildChunkEntrySurface(
entry,
(localX, localY) => helpers.resolveWorldChunkLayerSymbol(chunk, layerObj, localX, localY),
worldContext.chunkWidth,
worldContext.chunkHeight,
String(chunk?.backgroundTileId || ""),
chunkSurfaceOptions,
);
if (!hasContent && layerNumber > 0) {
helpers.destroyChunkEntry(entry);
desiredKeys.delete(chunkKey);
}
});
});
Array.from(state.chunkEntries.entries()).forEach(([key, entry]) => {
if (!desiredKeys.has(key)) {
helpers.destroyChunkEntry(entry);
}
});
Array.from(state.layerRoots.entries()).forEach(([layerNumber, layerRoot]) => {
if (!activeLayerNumbers.has(layerNumber)) {
state.npcSpritesById.forEach((sprite, npcId) => {
if (sprite?.parent && sprite.parent === state.entityLayerRoots.get(layerNumber)) {
state.npcSpritesById.delete(npcId);
}
});
state.entityLayerRoots.delete(layerNumber);
layerRoot.removeFromParent();
layerRoot.destroy({ children: true });
state.layerRoots.delete(layerNumber);
}
});
return true;
}
export function rebuildSceneFromRoomLayers(state, scope, helpers) {
state.chunkEntries.forEach((entry) => {
helpers.destroyChunkEntry(entry);
});
state.chunkEntries.clear();
state.layerRoots.forEach((layerRoot) => {
layerRoot.removeFromParent();
layerRoot.destroy({ children: true });
});
state.layerRoots.clear();
state.entityLayerRoots.clear();
state.npcSpritesById.clear();
helpers.resetSceneRoots();
const chunkSurfaceOptions = buildChunkSurfaceOptions(state, scope, helpers);
const layers = Array.isArray(scope.roomLayers)
? [...scope.roomLayers].sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0))
: [];
layers.forEach((layerObj) => {
const layerNumber = Number(layerObj?.layer) || 0;
const layerRoot = helpers.getOrCreateLayerRoot(layerNumber);
layerRoot.visible = scope.isLayerRendered(layerNumber);
const chunkStartsX = Math.ceil(Math.max(1, Number(scope.width) || 1) / state.chunkSize);
const chunkStartsY = Math.ceil(Math.max(1, Number(scope.height) || 1) / state.chunkSize);
for (let chunkY = 0; chunkY < chunkStartsY; chunkY += 1) {
for (let chunkX = 0; chunkX < chunkStartsX; chunkX += 1) {
const baseTileX = chunkX * state.chunkSize;
const baseTileY = chunkY * state.chunkSize;
const tileWidth = Math.max(1, Math.min(state.chunkSize, Math.max(0, Number(scope.width) || 0) - baseTileX));
const tileHeight = Math.max(1, Math.min(state.chunkSize, Math.max(0, Number(scope.height) || 0) - baseTileY));
const chunkEntry = helpers.getOrCreateChunkEntry(layerNumber, chunkX, chunkY, {
tileX: baseTileX,
tileY: baseTileY,
tileWidth,
tileHeight,
});
const hasContent = rebuildChunkEntrySurface(
chunkEntry,
(localX, localY) => helpers.resolveStoredTileSymbol(layerObj, baseTileX + localX, baseTileY + localY),
tileWidth,
tileHeight,
String(layerNumber),
chunkSurfaceOptions,
);
if (!hasContent) {
helpers.destroyChunkEntry(chunkEntry);
}
}
}
});
return true;
}

View file

@ -0,0 +1,116 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { getSpritePalette, getSpriteRows, resolveUnifiedColorSymbol } from "../editorCore";
export function parseHexColor(value, fallback = 0x060A14) {
const raw = String(value || "").trim();
if (!/^#[0-9a-fA-F]{6}$/.test(raw)) {
return fallback;
}
return Number.parseInt(raw.slice(1), 16);
}
export function getWorldBorderThickness(tileSize) {
return Math.max(0.04, 1 / Math.max(1, Number(tileSize) || 1));
}
export function applyPixelArtTexture(texture) {
if (!texture?.source) {
return texture;
}
texture.source.scaleMode = "nearest";
return texture;
}
export function refreshCanvasTexture(texture) {
try {
texture?.source?.update?.();
texture?.update?.();
} catch {
// Ignore stale canvas texture refresh issues and let the next rebuild recover.
}
}
export function createSolidCanvas(width, height, color) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.floor(Number(width) || 1));
canvas.height = Math.max(1, Math.floor(Number(height) || 1));
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = String(color || "#000000");
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return canvas;
}
export function buildTileCanvasSurface(tileEntry, fallbackColor) {
const sourceRows = getSpriteRows(tileEntry);
const rows = Array.isArray(sourceRows) ? sourceRows.map((row) => String(row || "")) : [];
const width = Math.max(1, Number(tileEntry?.width) || rows.reduce((max, row) => Math.max(max, row.length), 0) || 1);
const height = Math.max(1, Number(tileEntry?.height) || rows.length || 1);
const pixelScale = Math.max(1, Number(tileEntry?.pixelScale) || 1);
const canvas = document.createElement("canvas");
canvas.width = width * pixelScale;
canvas.height = height * pixelScale;
const ctx = canvas.getContext("2d");
if (!ctx) {
return null;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < height; y += 1) {
const row = rows[y] || "";
for (let x = 0; x < width; x += 1) {
const symbol = String(row.charAt(x) || ".").charAt(0) || ".";
if (symbol === ".") {
continue;
}
ctx.fillStyle = resolveUnifiedColorSymbol(symbol, String(fallbackColor || "#00000000"));
ctx.fillRect(x * pixelScale, y * pixelScale, pixelScale, pixelScale);
}
}
return canvas;
}
export function buildSpriteCanvasSurface(spriteEntry) {
const sourceRows = getSpriteRows(spriteEntry);
const rows = Array.isArray(sourceRows) ? sourceRows.map((row) => String(row || "")) : [];
const width = Math.max(1, Number(spriteEntry?.width) || rows.reduce((max, row) => Math.max(max, row.length), 0) || 1);
const height = Math.max(1, Number(spriteEntry?.height) || rows.length || 1);
const pixelScale = Math.max(1, Number(spriteEntry?.pixelScale) || 1);
const palette = getSpritePalette(spriteEntry);
const canvas = document.createElement("canvas");
canvas.width = width * pixelScale;
canvas.height = height * pixelScale;
const ctx = canvas.getContext("2d");
if (!ctx) {
return null;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < height; y += 1) {
const row = rows[y] || "";
for (let x = 0; x < width; x += 1) {
const symbol = String(row.charAt(x) || ".").charAt(0) || ".";
if (symbol === ".") {
continue;
}
ctx.fillStyle = String(palette[symbol] || "#00000000");
ctx.fillRect(x * pixelScale, y * pixelScale, pixelScale, pixelScale);
}
}
return canvas;
}
export function destroyCachedTexture(cacheMap, key) {
const cachedTexture = cacheMap.get(key) || null;
if (!cachedTexture) {
return;
}
cacheMap.delete(key);
try {
cachedTexture.destroy(true);
} catch {
// Ignore stale texture cleanup issues and let Pixi recreate on demand.
}
}

View file

@ -0,0 +1,948 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { getSpriteRows } from "../editorCore";
import { Application, Container, Sprite, Texture } from "pixi.js";
import {
applyPixelArtTexture,
buildSpriteCanvasSurface,
buildTileCanvasSurface,
createSolidCanvas,
destroyCachedTexture,
} from "./pixiSurfaceHelpers";
import {
buildChunkSignature,
redrawChunkEntrySurface,
} from "./pixiChunkSurfaceHelpers";
import {
rebuildHeightOverlay,
resetSceneRoots,
syncHeightFocusEffect,
} from "./pixiHeightOverlayHelpers";
import {
rebuildSceneFromRoomLayers,
rebuildSceneFromWorldChunks,
} from "./pixiSceneRebuildHelpers";
function buildLayerChunkKey(layerNumber, chunkX, chunkY) {
return `${Number(layerNumber) || 0}:${Number(chunkX) || 0}:${Number(chunkY) || 0}`;
}
export function createPixiTileStageController(scope, options) {
const tileImages = options?.tileImages || {};
const recordMetric = typeof options?.recordMetric === "function" ? options.recordMetric : null;
const state = {
app: null,
hostEl: null,
viewportLayerEl: null,
worldContainer: null,
baseWorldRoot: null,
backgroundSprite: null,
heightOverlayRoot: null,
heightFocusBlurFilter: null,
heightFocusBlurStrength: 0,
heightFocusCacheResolution: 1,
heightFocusCacheEnabled: false,
heightFocusCacheDirty: true,
ready: false,
failed: false,
initPromise: null,
dirty: true,
chunkSize: 32,
tileSurfaceCache: new Map(),
textureCache: new Map(),
npcTextureCache: new Map(),
emptyNpcTexture: null,
layerRoots: new Map(),
entityLayerRoots: new Map(),
chunkEntries: new Map(),
npcSpritesById: new Map(),
heightPatchEntries: new Map(),
lastHeightLayersRef: null,
lastHeightOverlayMode: "",
lastHeightActiveId: "",
lastHeightTileSize: 0,
};
function getEmptyNpcTexture() {
if (state.emptyNpcTexture && !state.emptyNpcTexture.destroyed) {
return state.emptyNpcTexture;
}
const texture = Texture.from(createSolidCanvas(1, 1, "#00000000"), true);
applyPixelArtTexture(texture);
state.emptyNpcTexture = texture;
return texture;
}
function isReady() {
return state.ready && !!state.app && !!state.worldContainer;
}
function ensureHost() {
if (state.hostEl && state.hostEl.isConnected) {
return state.hostEl;
}
const viewportLayerEl = scope.pixiHost?.parentElement || scope.canvas?.parentElement || null;
state.viewportLayerEl = viewportLayerEl;
if (!viewportLayerEl) {
return null;
}
let hostEl = scope.pixiHost;
if (!hostEl) {
hostEl = document.createElement("div");
hostEl.id = "pixiHost";
hostEl.className = "pixi-host";
viewportLayerEl.insertBefore(hostEl, scope.canvas || null);
}
hostEl.setAttribute("aria-hidden", "true");
state.hostEl = hostEl;
return hostEl;
}
async function initialize() {
if (state.failed) {
return false;
}
if (state.initPromise) {
return state.initPromise;
}
state.initPromise = (async () => {
const hostEl = ensureHost();
if (!hostEl) {
state.failed = true;
return false;
}
const app = new Application();
await app.init({
width: Math.max(1, Number(scope.viewport?.clientWidth) || 1),
height: Math.max(1, Number(scope.viewport?.clientHeight) || 1),
preference: "webgl",
antialias: false,
backgroundAlpha: 0,
clearBeforeRender: true,
autoStart: false,
sharedTicker: false,
autoDensity: true,
resolution: Math.max(1, Number(window.devicePixelRatio) || 1),
roundPixels: true,
});
const pixiCanvas = app.canvas;
pixiCanvas.classList.add("pixi-stage-canvas");
pixiCanvas.style.position = "absolute";
pixiCanvas.style.inset = "0";
pixiCanvas.style.width = "100%";
pixiCanvas.style.height = "100%";
pixiCanvas.style.pointerEvents = "none";
pixiCanvas.style.imageRendering = "pixelated";
hostEl.replaceChildren(pixiCanvas);
const worldContainer = new Container();
worldContainer.sortableChildren = true;
worldContainer.roundPixels = true;
app.stage.sortableChildren = true;
app.stage.roundPixels = true;
app.stage.addChild(worldContainer);
const baseWorldRoot = new Container();
baseWorldRoot.label = "base_world_root";
baseWorldRoot.zIndex = 0;
baseWorldRoot.sortableChildren = true;
baseWorldRoot.roundPixels = true;
worldContainer.addChild(baseWorldRoot);
const backgroundSprite = new Sprite(Texture.WHITE);
backgroundSprite.zIndex = -1;
backgroundSprite.roundPixels = true;
baseWorldRoot.addChild(backgroundSprite);
const heightOverlayRoot = new Container();
heightOverlayRoot.label = "height_overlay_root";
heightOverlayRoot.zIndex = 100000;
worldContainer.addChild(heightOverlayRoot);
state.app = app;
state.worldContainer = worldContainer;
state.baseWorldRoot = baseWorldRoot;
state.backgroundSprite = backgroundSprite;
state.heightOverlayRoot = heightOverlayRoot;
state.ready = true;
state.dirty = true;
state.heightFocusCacheDirty = true;
return true;
})().catch((error) => {
state.failed = true;
console.error("Pixi tile stage failed to initialize.", error);
return false;
});
return state.initPromise;
}
function getTileTexture(symbol) {
const safeSymbol = String(symbol || "").charAt(0);
if (!safeSymbol) {
return Texture.WHITE;
}
if (state.textureCache.has(safeSymbol)) {
return state.textureCache.get(safeSymbol);
}
const texture = Texture.from(getTileSurface(safeSymbol), true);
applyPixelArtTexture(texture);
state.textureCache.set(safeSymbol, texture);
return texture;
}
function getTileSurface(symbol) {
const safeSymbol = String(symbol || "").charAt(0);
if (!safeSymbol) {
return createSolidCanvas(1, 1, "#00000000");
}
if (state.tileSurfaceCache.has(safeSymbol)) {
return state.tileSurfaceCache.get(safeSymbol);
}
const tileEntry = scope.getTileEntry(safeSymbol) || {};
const img = tileImages[safeSymbol];
let surface;
if (getSpriteRows(tileEntry).length > 0) {
surface = buildTileCanvasSurface(tileEntry, tileEntry.color || scope.defaultTileColor || "#7AA7FF");
} else if (img && img.complete && img.naturalWidth > 0) {
surface = img;
} else {
surface = createSolidCanvas(1, 1, String(tileEntry.color || scope.defaultTileColor || "#7AA7FF"));
}
state.tileSurfaceCache.set(safeSymbol, surface);
return surface;
}
function getTileOpacity(symbol) {
const safeSymbol = String(symbol || "").charAt(0);
if (!safeSymbol) {
return 1;
}
const tileEntry = scope.getTileEntry(safeSymbol) || null;
const opacity = Number(tileEntry?.opacity);
return Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1;
}
function invalidateTileTexture(symbol) {
const safeSymbol = String(symbol || "").charAt(0);
if (!safeSymbol) {
return false;
}
state.tileSurfaceCache.delete(safeSymbol);
destroyCachedTexture(state.textureCache, safeSymbol);
state.dirty = true;
return true;
}
function invalidateAllGraphicsCaches() {
state.tileSurfaceCache.clear();
state.textureCache.forEach((texture) => {
try {
texture?.destroy(true);
} catch {
// Ignore stale texture cleanup issues and let Pixi rebuild on demand.
}
});
state.textureCache.clear();
state.npcTextureCache.forEach((texture) => {
try {
texture?.destroy(true);
} catch {
// Ignore stale texture cleanup issues and let Pixi rebuild on demand.
}
});
state.npcTextureCache.clear();
state.chunkEntries.forEach((entry) => {
if (entry && typeof entry === "object") {
entry.signature = "";
}
});
state.heightFocusCacheDirty = true;
}
function getNpcTexture(npc) {
const cacheKey = String(npc?.dataUrl || npc?.spriteId || npc?.id || "").trim();
const spritePayload = scope.documentScope?.ensureDocumentContentPayload?.("sprites", { schemaVersion: 1, sprites: [] }) || { schemaVersion: 1, sprites: [] };
const spriteRecords = Array.isArray(spritePayload?.sprites)
? spritePayload.sprites
: [];
const spriteRecord = spriteRecords.find((entry) => String(entry?.id || "").trim() === String(npc?.spriteId || "").trim()) || null;
const spriteCanvas = spriteRecord ? buildSpriteCanvasSurface(spriteRecord) : null;
const hasSpriteDataUrl = !!(npc?.dataUrl && String(npc.dataUrl || "").trim());
if (!cacheKey || (!spriteCanvas && !hasSpriteDataUrl)) {
return getEmptyNpcTexture();
}
if (state.npcTextureCache.has(cacheKey)) {
const cachedTexture = state.npcTextureCache.get(cacheKey);
if (cachedTexture && !cachedTexture.destroyed) {
return cachedTexture;
}
state.npcTextureCache.delete(cacheKey);
}
const img = npc?.dataUrl && typeof scope.getCachedImage === "function"
? scope.getCachedImage(`__pixi_npc__:${cacheKey}`, String(npc.dataUrl || ""))
: null;
if (img && !img.complete) {
img.addEventListener("load", () => {
state.npcTextureCache.delete(cacheKey);
state.dirty = true;
scope.draw?.();
}, { once: true });
}
let texture;
try {
texture = spriteCanvas
? Texture.from(spriteCanvas, true)
: (img && img.complete && img.naturalWidth > 0
? Texture.from(img, true)
: (hasSpriteDataUrl ? Texture.from(String(npc.dataUrl || ""), true) : getEmptyNpcTexture()));
} catch {
return getEmptyNpcTexture();
}
if (!texture || texture.destroyed) {
return getEmptyNpcTexture();
}
applyPixelArtTexture(texture);
try {
texture?.source?.on?.("update", () => {
scope.draw?.();
});
} catch {
return getEmptyNpcTexture();
}
state.npcTextureCache.set(cacheKey, texture);
return texture;
}
function resolveStoredTileSymbol(layerObj, tileX, tileY) {
const layerNumber = Number(layerObj?.layer) || 0;
const fillChar = layerNumber === 0 ? "." : " ";
const rows = Array.isArray(layerObj?.rows) ? layerObj.rows : [];
const row = String(rows[tileY] || "");
let symbol = row.charAt(tileX) || fillChar;
if (layerNumber === 0 && symbol === "." && scope.backgroundTileId) {
symbol = scope.getBackgroundTileSymbol() || ".";
}
if (layerNumber === 0 && symbol === " ") {
return "";
}
if (layerNumber > 0 && symbol === " ") {
return "";
}
if (symbol === ".") {
return "";
}
return String(symbol || "").charAt(0);
}
function isWorldChunkModeActive() {
const visibleChunks = typeof scope.getVisibleWorldChunkPayloads === "function"
? scope.getVisibleWorldChunkPayloads()
: [];
return !!scope.isWorldModeActive?.() && visibleChunks.length > 0;
}
function getWorldChunkRenderContext() {
const visibleChunks = typeof scope.getVisibleWorldChunkPayloads === "function"
? scope.getVisibleWorldChunkPayloads()
: [];
if (!scope.isWorldModeActive?.() || visibleChunks.length <= 0) {
return null;
}
return {
chunks: visibleChunks,
chunkWidth: Math.max(1, Number(scope.worldChunkWidth) || state.chunkSize),
chunkHeight: Math.max(1, Number(scope.worldChunkHeight) || state.chunkSize),
originChunkX: Math.floor(Number(scope.worldOriginChunkX) || 0),
originChunkY: Math.floor(Number(scope.worldOriginChunkY) || 0),
tileOffsetX: Math.floor(Number(scope.worldTileOffsetX) || 0),
tileOffsetY: Math.floor(Number(scope.worldTileOffsetY) || 0),
};
}
function resolveWorldChunkLayerSymbol(chunk, layerObj, tileX, tileY) {
const layerNumber = Number(layerObj?.layer) || 0;
const fillChar = layerNumber === 0 ? "." : " ";
const rows = Array.isArray(layerObj?.rows) ? layerObj.rows : [];
const row = String(rows[tileY] || "");
let symbol = row.charAt(tileX) || fillChar;
const backgroundTileId = String(chunk?.backgroundTileId || scope.backgroundTileId || "").trim();
if (layerNumber === 0 && symbol === "." && backgroundTileId) {
const backgroundTileEntry = scope.getBackgroundTileEntry?.() || null;
const scopedBackgroundTileId = String(backgroundTileEntry?.id || "").trim();
if (scopedBackgroundTileId && scopedBackgroundTileId === backgroundTileId) {
symbol = scope.getBackgroundTileSymbol() || ".";
} else {
const fallbackEntry = scope.getTileEntryById?.(backgroundTileId) || null;
symbol = String(fallbackEntry?.symbol || ".").charAt(0) || ".";
}
}
if (layerNumber === 0 && symbol === " ") {
return "";
}
if (layerNumber > 0 && symbol === " ") {
return "";
}
if (symbol === ".") {
return "";
}
return String(symbol || "").charAt(0);
}
function getOrCreateLayerRoot(layerNumber) {
const safeLayerNumber = Number(layerNumber) || 0;
let layerRoot = state.layerRoots.get(safeLayerNumber);
if (layerRoot) {
if (layerRoot.parent !== state.baseWorldRoot) {
state.baseWorldRoot?.addChild(layerRoot);
}
return layerRoot;
}
layerRoot = new Container();
layerRoot.label = `layer_${safeLayerNumber}`;
layerRoot.zIndex = safeLayerNumber;
layerRoot.sortableChildren = true;
layerRoot.roundPixels = true;
state.baseWorldRoot?.addChild(layerRoot);
state.layerRoots.set(safeLayerNumber, layerRoot);
return layerRoot;
}
function getOrCreateEntityLayerRoot(layerNumber) {
const safeLayerNumber = Number(layerNumber) || 0;
let entityRoot = state.entityLayerRoots.get(safeLayerNumber);
if (entityRoot) {
if (entityRoot.parent !== getOrCreateLayerRoot(safeLayerNumber)) {
getOrCreateLayerRoot(safeLayerNumber).addChild(entityRoot);
}
return entityRoot;
}
entityRoot = new Container();
entityRoot.label = `layer_${safeLayerNumber}_entities`;
entityRoot.zIndex = 1;
entityRoot.sortableChildren = true;
entityRoot.roundPixels = true;
getOrCreateLayerRoot(safeLayerNumber).addChild(entityRoot);
state.entityLayerRoots.set(safeLayerNumber, entityRoot);
return entityRoot;
}
function getChunkPixelSize(tileWidth, tileHeight) {
const pixelTileSize = Math.max(1, Number(scope.baseTileSize) || Number(scope.tileSize) || 32);
return {
pixelTileSize,
pixelWidth: Math.max(1, Math.max(1, Number(tileWidth) || state.chunkSize) * pixelTileSize),
pixelHeight: Math.max(1, Math.max(1, Number(tileHeight) || state.chunkSize) * pixelTileSize),
};
}
function rebuildChunkEntryTexture(entry) {
const nextTexture = Texture.from(entry.canvas, true);
applyPixelArtTexture(nextTexture);
const previousTexture = entry.texture || null;
entry.texture = nextTexture;
if (entry.sprite) {
entry.sprite.texture = nextTexture;
}
if (previousTexture && previousTexture !== nextTexture) {
try {
previousTexture.destroy(true);
} catch {
// Ignore stale chunk texture replacement cleanup issues.
}
}
}
function syncChunkEntryDimensions(entry) {
const { pixelWidth, pixelHeight } = getChunkPixelSize(entry.tileWidth, entry.tileHeight);
if (entry.canvas.width !== pixelWidth || entry.canvas.height !== pixelHeight) {
entry.canvas.width = pixelWidth;
entry.canvas.height = pixelHeight;
entry.ctx.imageSmoothingEnabled = false;
rebuildChunkEntryTexture(entry);
}
entry.sprite.x = Number(entry.tileX) || 0;
entry.sprite.y = Number(entry.tileY) || 0;
entry.sprite.width = Math.max(1, Number(entry.tileWidth) || state.chunkSize);
entry.sprite.height = Math.max(1, Number(entry.tileHeight) || state.chunkSize);
entry.sprite.roundPixels = true;
}
function createChunkEntry(layerNumber, chunkX, chunkY, options) {
const layerRoot = getOrCreateLayerRoot(layerNumber);
const tileWidth = Math.max(1, Number(options?.tileWidth) || state.chunkSize);
const tileHeight = Math.max(1, Number(options?.tileHeight) || state.chunkSize);
const { pixelWidth, pixelHeight } = getChunkPixelSize(tileWidth, tileHeight);
const canvas = document.createElement("canvas");
canvas.width = pixelWidth;
canvas.height = pixelHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to create chunk surface canvas.");
}
ctx.imageSmoothingEnabled = false;
const texture = Texture.from(canvas, true);
applyPixelArtTexture(texture);
const chunkSprite = new Sprite(texture);
chunkSprite.label = `chunk_${layerNumber}_${chunkX}_${chunkY}`;
chunkSprite.zIndex = 0;
chunkSprite.roundPixels = true;
layerRoot.addChild(chunkSprite);
const entry = {
layerNumber,
chunkX,
chunkY,
tileX: Number(options?.tileX) || 0,
tileY: Number(options?.tileY) || 0,
tileWidth,
tileHeight,
signature: "",
canvas,
ctx,
texture,
sprite: chunkSprite,
lastRenderedAt: 0,
};
syncChunkEntryDimensions(entry);
state.chunkEntries.set(buildLayerChunkKey(layerNumber, chunkX, chunkY), entry);
return entry;
}
function getOrCreateChunkEntry(layerNumber, chunkX, chunkY, options) {
const key = buildLayerChunkKey(layerNumber, chunkX, chunkY);
return state.chunkEntries.get(key) || createChunkEntry(layerNumber, chunkX, chunkY, options);
}
function destroyChunkEntry(entry) {
if (!entry) {
return;
}
state.chunkEntries.delete(buildLayerChunkKey(entry.layerNumber, entry.chunkX, entry.chunkY));
entry.sprite?.removeFromParent();
entry.sprite?.destroy();
try {
entry.texture?.destroy(true);
} catch {
// Ignore stale chunk texture cleanup issues during removal.
}
if (entry.canvas) {
entry.canvas.width = 1;
entry.canvas.height = 1;
}
}
function syncChunkVisibility() {
if (!isReady()) {
return;
}
const viewportLeftTiles = (Number(scope.viewport?.scrollLeft) || 0) / Math.max(1, Number(scope.tileSize) || 1);
const viewportTopTiles = (Number(scope.viewport?.scrollTop) || 0) / Math.max(1, Number(scope.tileSize) || 1);
const viewportWidthTiles = (Number(scope.viewport?.clientWidth) || 0) / Math.max(1, Number(scope.tileSize) || 1);
const viewportHeightTiles = (Number(scope.viewport?.clientHeight) || 0) / Math.max(1, Number(scope.tileSize) || 1);
const minChunkX = Math.floor(viewportLeftTiles / state.chunkSize) - 1;
const maxChunkX = Math.ceil((viewportLeftTiles + viewportWidthTiles) / state.chunkSize) + 1;
const minChunkY = Math.floor(viewportTopTiles / state.chunkSize) - 1;
const maxChunkY = Math.ceil((viewportTopTiles + viewportHeightTiles) / state.chunkSize) + 1;
state.chunkEntries.forEach((entry) => {
const layerRoot = state.layerRoots.get(entry.layerNumber);
const entryLeft = Number(entry.tileX) || 0;
const entryTop = Number(entry.tileY) || 0;
const entryRight = entryLeft + Math.max(1, Number(entry.tileWidth) || state.chunkSize);
const entryBottom = entryTop + Math.max(1, Number(entry.tileHeight) || state.chunkSize);
const inViewport = entryRight > minChunkX * state.chunkSize
&& entryLeft < (maxChunkX + 1) * state.chunkSize
&& entryBottom > minChunkY * state.chunkSize
&& entryTop < (maxChunkY + 1) * state.chunkSize;
entry.sprite.visible = inViewport && !!layerRoot?.visible;
});
}
function destroyHeightPatchEntry(entry) {
if (!entry) {
return;
}
state.heightPatchEntries.delete(String(entry.id || "").trim());
entry.container.removeFromParent();
entry.container.destroy({ children: true });
}
function syncNpcLayer() {
if (!isReady()) {
return;
}
const visibleNpcSource = scope.getVisibleNpcOverlays?.();
const visibleNpcs = Array.isArray(visibleNpcSource) ? visibleNpcSource : [];
const visibleIds = new Set(visibleNpcs.map((npc) => String(npc?.id || "").trim()).filter(Boolean));
Array.from(state.npcSpritesById.keys()).forEach((id) => {
if (!visibleIds.has(id)) {
const sprite = state.npcSpritesById.get(id);
sprite?.removeFromParent();
sprite?.destroy();
state.npcSpritesById.delete(id);
}
});
visibleNpcs.forEach((npc) => {
const npcId = String(npc?.id || "").trim();
if (!npcId) {
return;
}
const layerNumber = Number(npc.layer) || 0;
const entityLayerRoot = getOrCreateEntityLayerRoot(layerNumber);
let sprite = state.npcSpritesById.get(npcId) || null;
if (sprite?.destroyed) {
state.npcSpritesById.delete(npcId);
sprite = null;
}
if (!sprite) {
sprite = new Sprite(getNpcTexture(npc));
sprite.label = `npc_${npcId}`;
sprite.zIndex = 1;
entityLayerRoot.addChild(sprite);
state.npcSpritesById.set(npcId, sprite);
} else {
if (sprite.parent !== entityLayerRoot) {
entityLayerRoot.addChild(sprite);
}
}
sprite.texture = getNpcTexture(npc);
sprite.x = Number(npc.x) || 0;
sprite.y = Number(npc.y) || 0;
sprite.width = Math.max(0.25, (Number(npc.spriteWidth) || Number(scope.baseTileSize) || 32) / Math.max(1, Number(scope.baseTileSize) || 32));
sprite.height = Math.max(0.25, (Number(npc.spriteHeight) || Number(scope.baseTileSize) || 32) / Math.max(1, Number(scope.baseTileSize) || 32));
sprite.zIndex = (Number(npc.y) || 0) + (sprite.height || 0);
sprite.alpha = Number.isFinite(Number(npc.opacity)) ? Math.max(0, Math.min(1, Number(npc.opacity))) : 1;
sprite.visible = true;
sprite.tint = 0xFFFFFF;
sprite.roundPixels = true;
const draggingNpc = scope.draggingNpc;
if (draggingNpc) {
const draggingOverlay = scope.npcOverlays[Number(draggingNpc.index) || 0];
if (draggingOverlay && String(draggingOverlay.id || "").trim() === npcId) {
sprite.visible = false;
}
}
});
}
function rebuildScene() {
if (!isReady()) {
return false;
}
const rebuildStartedAt = performance.now();
state.dirty = false;
if (isWorldChunkModeActive()) {
rebuildSceneFromWorldChunks(state, scope, {
buildLayerChunkKey,
createChunkEntry,
destroyChunkEntry,
getOrCreateLayerRoot,
getTileOpacity,
getWorldChunkRenderContext,
getTileSurface,
resetSceneRoots: () => resetSceneRoots(state, scope),
resolveWorldChunkLayerSymbol,
syncChunkEntryDimensions,
});
} else {
rebuildSceneFromRoomLayers(state, scope, {
destroyChunkEntry,
getOrCreateChunkEntry,
getOrCreateLayerRoot,
getTileOpacity,
getTileSurface,
resetSceneRoots: () => resetSceneRoots(state, scope),
resolveStoredTileSymbol,
syncChunkEntryDimensions,
});
}
syncChunkVisibility();
state.lastHeightLayersRef = null;
rebuildHeightOverlay(state, scope, {
Container,
destroyHeightPatchEntry,
getTileTexture,
isReady,
});
syncNpcLayer();
state.heightFocusCacheDirty = true;
syncHeightFocusEffect(state, scope);
if (recordMetric) {
recordMetric("tileSurfaceRefresh", performance.now() - rebuildStartedAt);
}
return true;
}
function syncRendererCanvasSize() {
if (!isReady()) {
return false;
}
const nextWidth = Math.max(1, Math.ceil(Number(scope.viewport?.clientWidth) || 0));
const nextHeight = Math.max(1, Math.ceil(Number(scope.viewport?.clientHeight) || 0));
if (state.app.renderer.width !== nextWidth || state.app.renderer.height !== nextHeight) {
state.app.renderer.resize(nextWidth, nextHeight);
}
return true;
}
function syncStageTransform() {
if (!isReady()) {
return false;
}
syncRendererCanvasSize();
state.worldContainer.scale.set(Math.max(1, Number(scope.tileSize) || 1));
state.worldContainer.position.set(
-Math.round(Number(scope.viewport?.scrollLeft) || 0),
-Math.round(Number(scope.viewport?.scrollTop) || 0),
);
state.layerRoots.forEach((layerRoot, layerNumber) => {
layerRoot.visible = scope.isLayerRendered(Number(layerNumber) || 0);
});
syncChunkVisibility();
rebuildHeightOverlay(state, scope, {
Container,
destroyHeightPatchEntry,
getTileTexture,
isReady,
});
syncNpcLayer();
syncHeightFocusEffect(state, scope);
return true;
}
function render(forceRebuild) {
if (!isReady()) {
return false;
}
if (forceRebuild || state.dirty) {
rebuildScene();
}
syncStageTransform();
state.app.render();
return true;
}
function patchTileAt(tileX, tileY) {
const normalizedTileX = Math.floor(Number(tileX) || 0);
const normalizedTileY = Math.floor(Number(tileY) || 0);
if (!isReady()) {
return false;
}
if (normalizedTileX < 0 || normalizedTileY < 0 || normalizedTileX >= scope.width || normalizedTileY >= scope.height) {
return false;
}
const patchStartedAt = performance.now();
const localChunkX = Math.floor(normalizedTileX / state.chunkSize);
const localChunkY = Math.floor(normalizedTileY / state.chunkSize);
let touched = false;
if (isWorldChunkModeActive()) {
const worldChunkX = Math.floor(Number(scope.worldOriginChunkX) || 0) + localChunkX;
const worldChunkY = Math.floor(Number(scope.worldOriginChunkY) || 0) + localChunkY;
const visibleChunks = Array.isArray(scope.getVisibleWorldChunkPayloads?.()) ? scope.getVisibleWorldChunkPayloads() : [];
const targetChunk = visibleChunks.find((entry) => (
Math.floor(Number(entry?.chunkX) || 0) === worldChunkX
&& Math.floor(Number(entry?.chunkY) || 0) === worldChunkY
)) || null;
if (!targetChunk) {
state.dirty = true;
} else {
const roomLayers = Array.isArray(targetChunk.roomLayers) ? targetChunk.roomLayers : [];
roomLayers.forEach((layerObj) => {
const layerNumber = Number(layerObj?.layer) || 0;
const chunkKey = buildLayerChunkKey(layerNumber, worldChunkX, worldChunkY);
let chunkEntry = state.chunkEntries.get(chunkKey) || null;
if (!chunkEntry) {
chunkEntry = getOrCreateChunkEntry(layerNumber, worldChunkX, worldChunkY, {
tileX: (worldChunkX - Math.floor(Number(scope.worldOriginChunkX) || 0)) * Math.max(1, Number(scope.worldChunkWidth) || state.chunkSize),
tileY: (worldChunkY - Math.floor(Number(scope.worldOriginChunkY) || 0)) * Math.max(1, Number(scope.worldChunkHeight) || state.chunkSize),
tileWidth: Math.max(1, Number(scope.worldChunkWidth) || state.chunkSize),
tileHeight: Math.max(1, Number(scope.worldChunkHeight) || state.chunkSize),
});
}
const hasContent = redrawChunkEntrySurface(
chunkEntry,
(cellX, cellY) => resolveWorldChunkLayerSymbol(targetChunk, layerObj, cellX, cellY),
{
baseTileSize: scope.baseTileSize,
getTileSurface,
syncChunkEntryDimensions,
tileSize: scope.tileSize,
},
);
chunkEntry.signature = buildChunkSignature(
(cellX, cellY) => resolveWorldChunkLayerSymbol(targetChunk, layerObj, cellX, cellY),
chunkEntry.tileWidth,
chunkEntry.tileHeight,
String(targetChunk?.backgroundTileId || ""),
);
if (!hasContent && layerNumber > 0) {
destroyChunkEntry(chunkEntry);
}
touched = true;
});
if (!roomLayers.some((layerObj) => Number(layerObj?.layer) === 0)) {
state.dirty = true;
}
}
if (touched || state.dirty) {
state.heightFocusCacheDirty = true;
syncStageTransform();
state.app.render();
if (recordMetric) {
recordMetric("tileSurfacePatch", performance.now() - patchStartedAt);
}
}
return touched || state.dirty;
}
(Array.isArray(scope.roomLayers) ? scope.roomLayers : []).forEach((layerObj) => {
const layerNumber = Number(layerObj?.layer) || 0;
const chunkEntryKey = buildLayerChunkKey(layerNumber, localChunkX, localChunkY);
let chunkEntry = state.chunkEntries.get(chunkEntryKey) || null;
if (!chunkEntry) {
const baseTileX = localChunkX * state.chunkSize;
const baseTileY = localChunkY * state.chunkSize;
chunkEntry = getOrCreateChunkEntry(layerNumber, localChunkX, localChunkY, {
tileX: baseTileX,
tileY: baseTileY,
tileWidth: Math.max(1, Math.min(state.chunkSize, Math.max(0, Number(scope.width) || 0) - baseTileX)),
tileHeight: Math.max(1, Math.min(state.chunkSize, Math.max(0, Number(scope.height) || 0) - baseTileY)),
});
}
const baseTileX = localChunkX * state.chunkSize;
const baseTileY = localChunkY * state.chunkSize;
const hasContent = redrawChunkEntrySurface(
chunkEntry,
(cellX, cellY) => resolveStoredTileSymbol(layerObj, baseTileX + cellX, baseTileY + cellY),
{
baseTileSize: scope.baseTileSize,
getTileSurface,
syncChunkEntryDimensions,
tileSize: scope.tileSize,
},
);
chunkEntry.signature = buildChunkSignature(
(cellX, cellY) => resolveStoredTileSymbol(layerObj, baseTileX + cellX, baseTileY + cellY),
chunkEntry.tileWidth,
chunkEntry.tileHeight,
String(layerNumber),
);
if (!hasContent) {
destroyChunkEntry(chunkEntry);
}
touched = true;
});
if (!touched) {
return false;
}
state.heightFocusCacheDirty = true;
syncStageTransform();
state.app.render();
if (recordMetric) {
recordMetric("tileSurfacePatch", performance.now() - patchStartedAt);
}
return true;
}
function invalidateAll(options) {
const config = options && typeof options === "object" ? options : {};
if (config.refreshTileImages === true || config.refreshGraphics === true) {
invalidateAllGraphicsCaches();
}
state.dirty = true;
}
function getDebugSnapshot() {
const visibleChunkCount = Array.from(state.chunkEntries.values()).reduce(
(total, entry) => total + (entry?.sprite?.visible ? 1 : 0),
0,
);
return {
ready: state.ready,
failed: state.failed,
dirty: state.dirty,
chunkSize: state.chunkSize,
chunkCount: state.chunkEntries.size,
visibleChunkCount,
layerRootCount: state.layerRoots.size,
textureCacheSize: state.textureCache.size,
npcTextureCacheSize: state.npcTextureCache.size,
npcSpriteCount: state.npcSpritesById.size,
heightPatchCount: state.heightPatchEntries.size,
rendererWidth: Number(state.app?.renderer?.width) || 0,
rendererHeight: Number(state.app?.renderer?.height) || 0,
rendererResolution: Number(state.app?.renderer?.resolution) || 0,
};
}
function destroy() {
state.heightPatchEntries.forEach((entry) => {
destroyHeightPatchEntry(entry);
});
state.heightPatchEntries.clear();
state.chunkEntries.forEach((entry) => {
destroyChunkEntry(entry);
});
state.chunkEntries.clear();
state.layerRoots.clear();
state.entityLayerRoots.clear();
state.npcSpritesById.forEach((sprite) => {
sprite?.removeFromParent();
sprite?.destroy();
});
state.npcSpritesById.clear();
state.textureCache.forEach((texture) => {
try {
texture?.destroy(true);
} catch {
// Ignore stale texture cleanup issues during shutdown.
}
});
state.textureCache.clear();
state.tileSurfaceCache.clear();
state.npcTextureCache.forEach((texture) => {
try {
texture?.destroy(true);
} catch {
// Ignore stale texture cleanup issues during shutdown.
}
});
state.npcTextureCache.clear();
if (state.emptyNpcTexture) {
try {
state.emptyNpcTexture.destroy(true);
} catch {
// Ignore stale texture cleanup issues during shutdown.
}
state.emptyNpcTexture = null;
}
if (state.app) {
state.app.destroy(true, { children: true });
}
if (state.hostEl) {
state.hostEl.replaceChildren();
}
state.app = null;
state.worldContainer = null;
state.baseWorldRoot = null;
state.backgroundSprite = null;
state.heightOverlayRoot = null;
state.heightFocusCacheResolution = 1;
state.heightFocusCacheEnabled = false;
state.heightFocusCacheDirty = true;
state.ready = false;
}
return {
initialize,
isReady,
render,
patchTileAt,
invalidateTileTexture,
invalidateAll,
getDebugSnapshot,
destroy,
};
}

View file

@ -0,0 +1,305 @@
import { createDebouncedCallback } from "./debounce";
const TOOL_WINDOW_LAYOUT_STORAGE_KEY = "content-editor-v2:map-editor:tool-windows:v1";
type ToolWindowMode = "inline" | "floating";
type ToolWindowState = {
mode?: ToolWindowMode;
visible?: boolean;
x?: number;
y?: number;
width?: number;
height?: number;
inlineHeight?: number;
order?: number;
};
type ToolWindowStateMap = Record<string, ToolWindowState>;
type PopupPanState = {
isPanning: boolean;
startX: number;
startY: number;
scrollLeft: number;
scrollTop: number;
};
type LayerLike = {
layer?: number;
};
type PopupSessionState = {
activeLayer: number;
viewingAllLayers: boolean;
visibleLayersById: Record<string, boolean>;
activeSidebarTab: string;
pan: PopupPanState;
draggingNpc: unknown;
pointerCandidate: unknown;
paintingStroke: unknown;
dragDrawX: number;
dragDrawY: number;
isSaving: boolean;
activeBrushTileId: string;
activeGraphicsTab: string;
activeGraphicsRecordId: string;
canvasToolMode: string;
activeInstanceBrushId: string;
activeEntityCategory: string;
hoverTileX: number;
hoverTileY: number;
selectedNpcId: string;
selectedTile: unknown;
spritePickerOpenNpcId: string;
hoveredNpcId: string;
hoverCanvasX: number;
hoverCanvasY: number;
templateSectionCollapsed: boolean;
placedSectionCollapsed: boolean;
drawLayerSectionCollapsed: boolean;
heightLayerSectionCollapsed: boolean;
organizedListDrag: unknown;
tileMutationBatchDepth: number;
hideTileGrid: boolean;
showChunkBounds: boolean;
zoomPreviewUntil: number;
scrollPreviewUntil: number;
toolWindows: ToolWindowStateMap;
};
type PersistedLayoutState = {
activeSidebarTab: string;
toolWindows: ToolWindowStateMap;
};
function normalizeToolWindowState(value: unknown): ToolWindowState {
const source = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const nextState: ToolWindowState = {};
if (source.mode === "inline" || source.mode === "floating") {
nextState.mode = source.mode;
}
if (typeof source.visible === "boolean") {
nextState.visible = source.visible;
}
["x", "y", "width", "height", "inlineHeight", "order"].forEach((key) => {
const numericValue = Number(source[key]);
if (Number.isFinite(numericValue)) {
nextState[key as keyof ToolWindowState] = Math.round(numericValue) as never;
}
});
return nextState;
}
function normalizeToolWindowStateMap(value: unknown): ToolWindowStateMap {
const source = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const nextMap: ToolWindowStateMap = {};
Object.entries(source).forEach(([key, entry]) => {
const normalizedKey = String(key || "").trim();
if (!normalizedKey) {
return;
}
nextMap[normalizedKey] = normalizeToolWindowState(entry);
});
return nextMap;
}
function captureToolWindowStateMap(value: unknown): ToolWindowStateMap {
const snapshot: ToolWindowStateMap = {};
Object.entries(normalizeToolWindowStateMap(value)).forEach(([key, entry]) => {
snapshot[key] = { ...entry };
});
return snapshot;
}
export function createPopupSessionStore(initialState: Partial<PopupSessionState> = {}) {
let lastPersistHostWindow = window;
let lastPersistStorageKey = TOOL_WINDOW_LAYOUT_STORAGE_KEY;
const state: PopupSessionState = {
activeLayer: 1,
viewingAllLayers: false,
visibleLayersById: {},
activeSidebarTab: "layers",
pan: { isPanning: false, startX: 0, startY: 0, scrollLeft: 0, scrollTop: 0 },
draggingNpc: null,
pointerCandidate: null,
paintingStroke: null,
dragDrawX: 0,
dragDrawY: 0,
isSaving: false,
activeBrushTileId: "",
activeGraphicsTab: "tiles",
activeGraphicsRecordId: "",
canvasToolMode: "paint",
activeInstanceBrushId: "",
activeEntityCategory: "friendly",
hoverTileX: -1,
hoverTileY: -1,
selectedNpcId: "",
selectedTile: null,
spritePickerOpenNpcId: "",
hoveredNpcId: "",
hoverCanvasX: 0,
hoverCanvasY: 0,
templateSectionCollapsed: false,
placedSectionCollapsed: false,
drawLayerSectionCollapsed: false,
heightLayerSectionCollapsed: false,
organizedListDrag: null,
tileMutationBatchDepth: 0,
hideTileGrid: false,
showChunkBounds: false,
zoomPreviewUntil: 0,
scrollPreviewUntil: 0,
toolWindows: {},
...initialState,
};
state.toolWindows = normalizeToolWindowStateMap(state.toolWindows);
function syncLayerVisibility(roomLayers: LayerLike[]) {
const nextVisibleLayersById: Record<string, boolean> = {};
roomLayers.forEach((layer) => {
const layerKey = String(Number(layer.layer) || 0);
nextVisibleLayersById[layerKey] = Object.prototype.hasOwnProperty.call(state.visibleLayersById, layerKey)
? state.visibleLayersById[layerKey] !== false
: true;
});
state.visibleLayersById = nextVisibleLayersById;
return state.visibleLayersById;
}
function setLayerVisibility(layerNumber: number, isVisible: boolean, roomLayers: LayerLike[]) {
syncLayerVisibility(roomLayers);
state.visibleLayersById[String(Number(layerNumber) || 0)] = isVisible !== false;
}
function isLayerVisible(layerNumber: number, roomLayers: LayerLike[]) {
syncLayerVisibility(roomLayers);
const layerKey = String(Number(layerNumber) || 0);
return !Object.prototype.hasOwnProperty.call(state.visibleLayersById, layerKey) || state.visibleLayersById[layerKey] !== false;
}
function beginTileMutationBatch() {
state.tileMutationBatchDepth += 1;
}
function endTileMutationBatch() {
if (state.tileMutationBatchDepth <= 0) {
state.tileMutationBatchDepth = 0;
return;
}
state.tileMutationBatchDepth -= 1;
}
function getToolWindowState(key: string) {
const normalizedKey = String(key || "").trim();
if (!normalizedKey || !state.toolWindows[normalizedKey]) {
return null;
}
return {
...state.toolWindows[normalizedKey],
};
}
function setToolWindowState(key: string, value: unknown) {
const normalizedKey = String(key || "").trim();
if (!normalizedKey) {
return null;
}
const mergedState: ToolWindowState = {
...(state.toolWindows[normalizedKey] || {}),
...normalizeToolWindowState(value),
};
state.toolWindows = {
...state.toolWindows,
[normalizedKey]: mergedState,
};
return {
...mergedState,
};
}
function capturePersistedLayoutState(): PersistedLayoutState {
return {
activeSidebarTab: String(state.activeSidebarTab || "").trim() || "layers",
toolWindows: captureToolWindowStateMap(state.toolWindows),
};
}
function restorePersistedLayout(hostWindow = window, storageKey = TOOL_WINDOW_LAYOUT_STORAGE_KEY) {
try {
const raw = hostWindow?.localStorage?.getItem(storageKey);
if (!raw) {
return capturePersistedLayoutState();
}
const parsed = JSON.parse(raw) as Partial<PersistedLayoutState>;
const activeSidebarTab = String(parsed?.activeSidebarTab || "").trim();
if (activeSidebarTab) {
state.activeSidebarTab = activeSidebarTab;
}
state.toolWindows = normalizeToolWindowStateMap(parsed?.toolWindows);
} catch {
return capturePersistedLayoutState();
}
return capturePersistedLayoutState();
}
function persistPersistedLayout(hostWindow = window, storageKey = TOOL_WINDOW_LAYOUT_STORAGE_KEY) {
lastPersistHostWindow = hostWindow || window;
lastPersistStorageKey = storageKey || TOOL_WINDOW_LAYOUT_STORAGE_KEY;
try {
hostWindow?.localStorage?.setItem(storageKey, JSON.stringify(capturePersistedLayoutState()));
return true;
} catch {
return false;
}
}
const persistPersistedLayoutDeferredInternal = createDebouncedCallback(() => {
persistPersistedLayout(lastPersistHostWindow, lastPersistStorageKey);
}, 140);
function persistPersistedLayoutDeferred(hostWindow = window, storageKey = TOOL_WINDOW_LAYOUT_STORAGE_KEY) {
lastPersistHostWindow = hostWindow || window;
lastPersistStorageKey = storageKey || TOOL_WINDOW_LAYOUT_STORAGE_KEY;
persistPersistedLayoutDeferredInternal();
return true;
}
function flushPersistedLayout(hostWindow = window, storageKey = TOOL_WINDOW_LAYOUT_STORAGE_KEY) {
lastPersistHostWindow = hostWindow || window;
lastPersistStorageKey = storageKey || TOOL_WINDOW_LAYOUT_STORAGE_KEY;
return persistPersistedLayoutDeferredInternal.flush() || persistPersistedLayout(lastPersistHostWindow, lastPersistStorageKey);
}
function clearPersistedLayout(hostWindow = window, storageKey = TOOL_WINDOW_LAYOUT_STORAGE_KEY) {
try {
hostWindow?.localStorage?.removeItem(storageKey);
state.toolWindows = {};
return true;
} catch {
return false;
}
}
return {
state,
syncLayerVisibility,
setLayerVisibility,
isLayerVisible,
beginTileMutationBatch,
endTileMutationBatch,
getToolWindowState,
setToolWindowState,
capturePersistedLayoutState,
restorePersistedLayout,
persistPersistedLayout,
persistPersistedLayoutDeferred,
flushPersistedLayout,
clearPersistedLayout,
};
}

View file

@ -0,0 +1,858 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { createOverlayRenderer } from "./overlayRenderer";
import { buildChunkFileName } from "../worldChunking";
export function createRenderController(scope) {
const documentScope = scope.documentScope || scope;
const uiScope = scope.uiScope || scope;
const sessionScope = scope.sessionScope || scope;
const tileImages = {};
let renderAssetsInitialized = false;
let pendingDrawFrame = 0;
let lastMetaMainText = "";
let lastMetaStatsText = "";
const renderDebugState = {
panelEl: null,
textEl: null,
lastText: "",
};
const invalidationState = {
lastFullReason: "startup",
fullCount: 0,
lastPatchReason: "",
patchCount: 0,
recent: [],
};
const profilerState = {
fps: 0,
fpsFrameCount: 0,
fpsWindowStartedAt: performance.now(),
metrics: {
draw: { last: 0, avg: 0, max: 0 },
tileStage: { last: 0, avg: 0, max: 0 },
tileSurfaceRefresh: { last: 0, avg: 0, max: 0 },
tileSurfacePatch: { last: 0, avg: 0, max: 0 },
overlay: { last: 0, avg: 0, max: 0 },
},
};
const overlayRenderer = createOverlayRenderer(scope, {
tileImages,
draw,
rectIntersects,
drawSelectionReticle,
});
let pixiTileStageController = null;
let pixiTileStageControllerPromise = null;
function isPixiTileStageActive() {
return !!pixiTileStageController?.isReady();
}
function ensurePixiTileStageController() {
if (pixiTileStageController) {
return Promise.resolve(pixiTileStageController);
}
if (pixiTileStageControllerPromise) {
return pixiTileStageControllerPromise;
}
pixiTileStageControllerPromise = import("./pixiTileStageController")
.then(({ createPixiTileStageController }) => {
pixiTileStageController = createPixiTileStageController(scope, {
tileImages,
recordMetric: recordProfileMetric,
});
return pixiTileStageController;
})
.catch((error) => {
pixiTileStageControllerPromise = null;
console.error("Failed to load the Pixi world renderer for the world editor.", error);
throw error;
});
return pixiTileStageControllerPromise;
}
function initializeRenderAssets() {
if (renderAssetsInitialized) {
refreshRendererDebugState();
updateMetaBar();
return;
}
renderAssetsInitialized = true;
refreshRendererDebugState();
preloadUiImages();
preloadNpcImages();
preloadTileImages();
void ensurePixiTileStageController()
.then((controller) => controller.initialize().then((ready) => ({ controller, ready })))
.then(({ controller, ready }) => {
if (ready) {
trackInvalidation("full", "renderer-ready");
controller.invalidateAll();
}
drawNow();
})
.catch(() => {
drawNow();
});
updateMetaBar();
}
function formatFixed2(value) {
return Number(value || 0).toFixed(2);
}
function recordProfileMetric(metricName, value) {
const bucket = profilerState.metrics[metricName];
const duration = Math.max(0, Number(value) || 0);
if (!bucket) {
return duration;
}
bucket.last = duration;
bucket.avg = bucket.avg > 0 ? ((bucket.avg * 0.82) + (duration * 0.18)) : duration;
bucket.max = Math.max(bucket.max || 0, duration);
return duration;
}
function measureProfileMetric(metricName, callback) {
const startedAt = performance.now();
const result = callback();
recordProfileMetric(metricName, performance.now() - startedAt);
return result;
}
function trackInvalidation(kind, reason) {
const normalizedKind = kind === "patch" ? "patch" : "full";
const normalizedReason = String(reason || "unspecified").trim() || "unspecified";
if (normalizedKind === "patch") {
invalidationState.lastPatchReason = normalizedReason;
invalidationState.patchCount += 1;
} else {
invalidationState.lastFullReason = normalizedReason;
invalidationState.fullCount += 1;
}
invalidationState.recent.unshift(normalizedKind + ":" + normalizedReason);
if (invalidationState.recent.length > 6) {
invalidationState.recent.length = 6;
}
}
function updateMeasuredFps(now) {
const currentNow = Number(now) || performance.now();
profilerState.fpsFrameCount += 1;
const elapsed = currentNow - profilerState.fpsWindowStartedAt;
if (elapsed < 240) {
return;
}
profilerState.fps = (profilerState.fpsFrameCount * 1000) / Math.max(1, elapsed);
profilerState.fpsFrameCount = 0;
profilerState.fpsWindowStartedAt = currentNow;
}
function getHeapUsageMb() {
const heapBytes = Number(performance?.memory?.usedJSHeapSize) || 0;
if (!heapBytes) {
return 0;
}
return heapBytes / (1024 * 1024);
}
function formatWorldCoordLabel(label, x, y) {
return label + " X: " + Math.round(Number(x) || 0) + " Y: " + Math.round(Number(y) || 0);
}
function toDisplayedWorldCoords(tileX, tileY) {
const safeTileX = Number(tileX);
const safeTileY = Number(tileY);
if (!Number.isFinite(safeTileX) || !Number.isFinite(safeTileY)) {
return null;
}
if (typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
return {
x: (Number(scope.worldTileOffsetX) || 0) + safeTileX,
y: (Number(scope.worldTileOffsetY) || 0) + safeTileY,
};
}
return {
x: safeTileX,
y: safeTileY,
};
}
function buildMetaMainText() {
const activeLayerLabel = sessionScope.viewingAllLayers ? "all" : documentScope.getLayerDisplayName(sessionScope.activeLayer);
const activeHeightLayer = documentScope.getActiveHeightLayer ? documentScope.getActiveHeightLayer() : null;
const drawTargetLabel = sessionScope.editingTargetKind === "height" && activeHeightLayer
? (documentScope.getHeightLayerDisplayName(activeHeightLayer) + " @ Z" + Math.max(1, Number(activeHeightLayer.z) || 1))
: documentScope.getLayerDisplayName(documentScope.getEditableLayerNumber());
const viewportCenter = typeof scope.getViewportCenterWorldTile === "function"
? scope.getViewportCenterWorldTile()
: null;
const viewportCoordLabel = viewportCenter
? (" | " + formatWorldCoordLabel("view", viewportCenter.worldTileX, viewportCenter.worldTileY))
: "";
const hoverCoords = toDisplayedWorldCoords(sessionScope.hoverTileX, sessionScope.hoverTileY);
const hoverCoordLabel = hoverCoords && sessionScope.hoverTileX >= 0 && sessionScope.hoverTileY >= 0
? (" | " + formatWorldCoordLabel("hover", hoverCoords.x, hoverCoords.y))
: "";
const preferredChunk = scope.isWorldModeActive?.() && typeof scope.getPreferredWorldChunkCoord === "function"
? scope.getPreferredWorldChunkCoord()
: null;
const chunkCoordLabel = preferredChunk
? (" | chunk " + Math.floor(Number(preferredChunk.chunkX) || 0) + "," + Math.floor(Number(preferredChunk.chunkY) || 0))
: "";
const selectedCoords = sessionScope.selectedTile
? toDisplayedWorldCoords(sessionScope.selectedTile.x, sessionScope.selectedTile.y)
: null;
return (
"World: " + documentScope.mapName + " | " + documentScope.width + "x" + documentScope.height + " | tile " + scope.tileSize +
"px | zoom " + scope.getZoomPercent() + "%" + viewportCoordLabel + hoverCoordLabel + chunkCoordLabel + " | active layer " + activeLayerLabel + " | draw target " + drawTargetLabel +
(selectedCoords ? (" | " + formatWorldCoordLabel("sel", selectedCoords.x, selectedCoords.y)) : "") +
(sessionScope.selectedNpcId ? " | selected npc " + sessionScope.selectedNpcId : "")
);
}
function buildMetaStatsText() {
const drawMetric = profilerState.metrics.draw;
const tileMetric = profilerState.metrics.tileStage;
const buildMetric = profilerState.metrics.tileSurfaceRefresh;
const overlayMetric = profilerState.metrics.overlay;
const heapUsageMb = getHeapUsageMb();
return (
"FPS: " + formatFixed2(profilerState.fps) +
" | Draw: " + formatFixed2(drawMetric.avg) + "ms" +
" | Tiles: " + formatFixed2(tileMetric.avg) + "ms" +
" | Build: " + formatFixed2(buildMetric.avg) + "ms" +
" | Overlay: " + formatFixed2(overlayMetric.avg) + "ms" +
(heapUsageMb > 0 ? (" | Heap: " + formatFixed2(heapUsageMb) + "MB") : "")
);
}
function updateMetaBar() {
const nextMainText = buildMetaMainText();
const nextStatsText = buildMetaStatsText();
if (uiScope.metaMainEl) {
if (nextMainText !== lastMetaMainText) {
uiScope.metaMainEl.textContent = nextMainText;
}
} else if (uiScope.metaEl && (nextMainText !== lastMetaMainText || nextStatsText !== lastMetaStatsText)) {
uiScope.metaEl.textContent = nextMainText + " | " + nextStatsText;
}
if (uiScope.metaStatsEl && nextStatsText !== lastMetaStatsText) {
uiScope.metaStatsEl.textContent = nextStatsText;
}
lastMetaMainText = nextMainText;
lastMetaStatsText = nextStatsText;
}
function ensureRenderDebugPanel() {
if (renderDebugState.panelEl || !scope.stageEl) {
return;
}
const panelEl = document.createElement("aside");
panelEl.setAttribute("aria-hidden", "true");
panelEl.style.position = "absolute";
panelEl.style.right = "12px";
panelEl.style.bottom = "12px";
panelEl.style.zIndex = "12";
panelEl.style.pointerEvents = "none";
panelEl.style.maxWidth = "320px";
panelEl.style.padding = "10px 12px";
panelEl.style.border = "1px solid rgba(110, 160, 235, 0.45)";
panelEl.style.borderRadius = "8px";
panelEl.style.background = "rgba(7, 12, 22, 0.84)";
panelEl.style.boxShadow = "0 10px 24px rgba(0, 0, 0, 0.24)";
panelEl.style.backdropFilter = "blur(4px)";
panelEl.style.color = "#d9ebff";
panelEl.style.font = "11px/1.45 Consolas, 'SFMono-Regular', Menlo, monospace";
const titleEl = document.createElement("div");
titleEl.textContent = "Renderer Debug";
titleEl.style.marginBottom = "6px";
titleEl.style.fontWeight = "700";
titleEl.style.letterSpacing = "0.03em";
titleEl.style.textTransform = "uppercase";
titleEl.style.color = "#9fd0ff";
const textEl = document.createElement("pre");
textEl.style.margin = "0";
textEl.style.whiteSpace = "pre-wrap";
textEl.style.wordBreak = "break-word";
panelEl.appendChild(titleEl);
panelEl.appendChild(textEl);
scope.stageEl.appendChild(panelEl);
renderDebugState.panelEl = panelEl;
renderDebugState.textEl = textEl;
}
function updateRenderDebugPanel(viewportRect, canvasWidth, canvasHeight) {
const renderDebugEnabled = scope.isRendererDebugEnabled?.() === true;
if (!renderDebugEnabled) {
if (renderDebugState.panelEl) {
renderDebugState.panelEl.style.display = "none";
}
return;
}
ensureRenderDebugPanel();
if (!renderDebugState.textEl) {
return;
}
if (renderDebugState.panelEl) {
renderDebugState.panelEl.style.display = "";
}
const pixiDebug = pixiTileStageController?.getDebugSnapshot?.() || null;
const lines = [
"mode: " + (isPixiTileStageActive() ? "pixi-world + canvas-overlay" : "loading"),
"canvas: " + canvasWidth + "x" + canvasHeight + " | dpr " + (Number(window.devicePixelRatio) || 1),
"viewport: " + viewportRect.width + "x" + viewportRect.height + " @ (" + viewportRect.left + "," + viewportRect.top + ")",
"draw avg: " + formatFixed2(profilerState.metrics.draw.avg) + "ms | tiles " + formatFixed2(profilerState.metrics.tileStage.avg) + "ms | overlay " + formatFixed2(profilerState.metrics.overlay.avg) + "ms",
"rebuild avg: " + formatFixed2(profilerState.metrics.tileSurfaceRefresh.avg) + "ms | patch " + formatFixed2(profilerState.metrics.tileSurfacePatch.avg) + "ms",
"invalidate: full " + invalidationState.fullCount + " (" + invalidationState.lastFullReason + ") | patch " + invalidationState.patchCount + (invalidationState.lastPatchReason ? (" (" + invalidationState.lastPatchReason + ")") : ""),
];
if (pixiDebug) {
lines.push(
"pixi: ready=" + (pixiDebug.ready ? "yes" : "no") + " dirty=" + (pixiDebug.dirty ? "yes" : "no") + " failed=" + (pixiDebug.failed ? "yes" : "no"),
"chunks: " + pixiDebug.visibleChunkCount + "/" + pixiDebug.chunkCount + " visible | size " + pixiDebug.chunkSize + " | layers " + pixiDebug.layerRootCount,
"textures: tiles " + pixiDebug.textureCacheSize + " | npc " + pixiDebug.npcTextureCacheSize + " | npc sprites " + pixiDebug.npcSpriteCount + " | height patches " + pixiDebug.heightPatchCount,
"renderer: " + pixiDebug.rendererWidth + "x" + pixiDebug.rendererHeight + " @ " + formatFixed2(pixiDebug.rendererResolution),
);
}
if (invalidationState.recent.length > 0) {
lines.push("recent: " + invalidationState.recent.join(" | "));
}
const nextText = lines.join("\n");
if (nextText === renderDebugState.lastText) {
return;
}
renderDebugState.textEl.textContent = nextText;
renderDebugState.lastText = nextText;
}
function refreshRendererDebugState() {
const renderDebugEnabled = scope.isRendererDebugEnabled?.() === true;
if (!renderDebugEnabled) {
if (renderDebugState.panelEl) {
renderDebugState.panelEl.style.display = "none";
}
if (renderDebugState.textEl) {
renderDebugState.textEl.textContent = "";
}
renderDebugState.lastText = "";
return;
}
ensureRenderDebugPanel();
if (renderDebugState.panelEl) {
renderDebugState.panelEl.style.display = "";
}
}
function preloadUiImages() {
try {
fetch(documentScope.apiBase + "/api/images")
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (!data || !Array.isArray(data.images)) return;
data.images.forEach((entry) => {
const slug = String(entry.name || "").replace(/\.[^.]+$/, "");
if (!slug) return;
const img = new Image();
img.src = scope.apiBase + entry.url;
img.onload = () => {
sessionScope.uiImageCache[slug] = img;
uiScope.renderNpcList();
};
});
})
.catch(() => {});
} catch {
// Ignore image-catalog fetch issues and keep fallback icon rendering.
}
}
function preloadNpcImages() {
documentScope.npcOverlays.forEach((npc) => {
if (npc.dataUrl) {
const img = new Image();
img.src = npc.dataUrl;
sessionScope.npcImages[npc.id] = img;
}
});
}
function preloadTileImages() {
Object.entries(documentScope.tileCatalog).forEach(([symbol, tile]) => {
const normalizedSymbol = String(symbol || "").charAt(0);
const nextDataUrl = tile && tile.dataUrl ? String(tile.dataUrl) : "";
const existing = normalizedSymbol ? tileImages[normalizedSymbol] : null;
if (!normalizedSymbol) {
return;
}
if (!nextDataUrl) {
if (existing) {
delete tileImages[normalizedSymbol];
pixiTileStageController?.invalidateTileTexture?.(normalizedSymbol);
}
return;
}
if (existing && existing.src === nextDataUrl) {
if (existing.complete && existing.naturalWidth > 0) {
return;
}
existing.onload = () => {
pixiTileStageController?.invalidateTileTexture?.(normalizedSymbol);
invalidateTileSurface("tile-image-loaded:" + normalizedSymbol);
draw();
};
return;
}
const img = new Image();
img.onload = () => {
pixiTileStageController?.invalidateTileTexture?.(normalizedSymbol);
invalidateTileSurface("tile-image-loaded:" + normalizedSymbol);
draw();
};
img.src = nextDataUrl;
tileImages[normalizedSymbol] = img;
});
}
function uiIconEl(slug, fallbackText, size) {
const img = sessionScope.uiImageCache[slug];
if (img && img.complete && img.naturalWidth > 0) {
const el = document.createElement("img");
el.src = img.src;
el.alt = fallbackText;
el.width = size || 16;
el.height = size || 16;
el.style.imageRendering = "pixelated";
el.style.pointerEvents = "none";
return el;
}
const el = document.createElement("span");
el.textContent = fallbackText;
return el;
}
function getInteractiveSurfaceRect() {
const candidates = [
scope.canvas,
scope.pixiHost,
scope.viewport,
];
for (const candidate of candidates) {
if (!candidate || typeof candidate.getBoundingClientRect !== "function") {
continue;
}
const rect = candidate.getBoundingClientRect();
if (rect && rect.width > 0 && rect.height > 0) {
return rect;
}
}
return scope.viewport.getBoundingClientRect();
}
function getCanvasPoint(event) {
const rect = getInteractiveSurfaceRect();
return {
x: (event.clientX - rect.left) + (Number(scope.viewport?.scrollLeft) || 0),
y: (event.clientY - rect.top) + (Number(scope.viewport?.scrollTop) || 0),
};
}
function findTopNpcAtCanvas(canvasX, canvasY) {
const visible = documentScope.getVisibleNpcOverlays().slice().reverse();
for (const npc of visible) {
const nx = npc.x * scope.tileSize;
const ny = npc.y * scope.tileSize;
const drawWidth = scope.getScaledSize(npc.spriteWidth, scope.baseTileSize);
const drawHeight = scope.getScaledSize(npc.spriteHeight, scope.baseTileSize);
if (canvasX >= nx && canvasX < nx + drawWidth && canvasY >= ny && canvasY < ny + drawHeight) {
return npc;
}
}
return null;
}
function drawSelectionReticle(drawX, drawY, drawW, drawH) {
const pad = Math.max(2, Math.min(8, scope.tileSize * 0.18));
const left = drawX - pad;
const top = drawY - pad;
const width = drawW + pad * 2;
const height = drawH + pad * 2;
const right = left + width;
const bottom = top + height;
const minDim = Math.max(8, Math.min(width, height));
const lineWidth = Math.max(1.25, Math.min(3, scope.tileSize * 0.08));
const outlineWidth = lineWidth + 2;
const cornerLen = Math.max(4, Math.min(minDim * 0.28, scope.tileSize * 0.46));
const markerLen = Math.max(4, Math.min(minDim * 0.24, scope.tileSize * 0.34));
const arrowSize = Math.max(2.5, Math.min(minDim * 0.16, scope.tileSize * 0.2));
const centerX = left + width / 2;
const centerY = top + height / 2;
const primaryColor = "rgba(72, 244, 226, 0.98)";
const accentColor = "rgba(255, 80, 96, 0.98)";
const outlineColor = "rgba(8, 16, 28, 0.96)";
const cornerSegments = [
[left, top + cornerLen, left, top],
[left, top, left + cornerLen, top],
[right - cornerLen, top, right, top],
[right, top, right, top + cornerLen],
[left, bottom - cornerLen, left, bottom],
[left, bottom, left + cornerLen, bottom],
[right - cornerLen, bottom, right, bottom],
[right, bottom - cornerLen, right, bottom],
];
const markerSegments = [
[centerX, top - markerLen, centerX, top - arrowSize],
[centerX, bottom + arrowSize, centerX, bottom + markerLen],
[left - markerLen, centerY, left - arrowSize, centerY],
[right + arrowSize, centerY, right + markerLen, centerY],
];
function strokeSegments(segments, strokeStyle, widthValue) {
scope.ctx.strokeStyle = strokeStyle;
scope.ctx.lineWidth = widthValue;
scope.ctx.beginPath();
segments.forEach(([x1, y1, x2, y2]) => {
scope.ctx.moveTo(x1, y1);
scope.ctx.lineTo(x2, y2);
});
scope.ctx.stroke();
}
function fillArrow(points, fillStyle, strokeStyle, widthValue) {
scope.ctx.fillStyle = fillStyle;
scope.ctx.strokeStyle = strokeStyle;
scope.ctx.lineWidth = widthValue;
scope.ctx.beginPath();
scope.ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i += 1) {
scope.ctx.lineTo(points[i][0], points[i][1]);
}
scope.ctx.closePath();
scope.ctx.fill();
scope.ctx.stroke();
}
scope.ctx.save();
scope.ctx.lineCap = "round";
scope.ctx.lineJoin = "round";
strokeSegments(cornerSegments, outlineColor, outlineWidth);
strokeSegments(cornerSegments, primaryColor, lineWidth);
strokeSegments(markerSegments, outlineColor, outlineWidth);
strokeSegments(markerSegments, accentColor, lineWidth);
fillArrow([
[centerX, top + 0.5],
[centerX - arrowSize, top - arrowSize * 1.15],
[centerX + arrowSize, top - arrowSize * 1.15],
], accentColor, outlineColor, Math.max(1, lineWidth * 0.9));
fillArrow([
[centerX, bottom - 0.5],
[centerX - arrowSize, bottom + arrowSize * 1.15],
[centerX + arrowSize, bottom + arrowSize * 1.15],
], accentColor, outlineColor, Math.max(1, lineWidth * 0.9));
fillArrow([
[left + 0.5, centerY],
[left - arrowSize * 1.15, centerY - arrowSize],
[left - arrowSize * 1.15, centerY + arrowSize],
], accentColor, outlineColor, Math.max(1, lineWidth * 0.9));
fillArrow([
[right - 0.5, centerY],
[right + arrowSize * 1.15, centerY - arrowSize],
[right + arrowSize * 1.15, centerY + arrowSize],
], accentColor, outlineColor, Math.max(1, lineWidth * 0.9));
scope.ctx.restore();
}
function getViewportRenderRect(canvasWidth, canvasHeight) {
const viewportWidth = Math.max(1, Math.ceil(Number(scope.viewport?.clientWidth) || canvasWidth));
const viewportHeight = Math.max(1, Math.ceil(Number(scope.viewport?.clientHeight) || canvasHeight));
const left = Math.max(0, Math.min(canvasWidth, Math.floor(Number(scope.viewport?.scrollLeft) || 0)));
const top = Math.max(0, Math.min(canvasHeight, Math.floor(Number(scope.viewport?.scrollTop) || 0)));
const right = Math.max(left + 1, Math.min(canvasWidth, Math.ceil((Number(scope.viewport?.scrollLeft) || 0) + viewportWidth)));
const bottom = Math.max(top + 1, Math.min(canvasHeight, Math.ceil((Number(scope.viewport?.scrollTop) || 0) + viewportHeight)));
return {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
};
}
function rectIntersects(rect, x, y, width, height) {
return x + width > rect.left && x < rect.right && y + height > rect.top && y < rect.bottom;
}
function invalidateTileSurface(reason, options) {
const config = options && typeof options === "object" ? options : {};
if (config.refreshTileImages === true) {
preloadTileImages();
}
trackInvalidation("full", reason);
pixiTileStageController?.invalidateAll(config);
}
function patchTileSurfaceCell(tileX, tileY, reason) {
const normalizedTileX = Number(tileX);
const normalizedTileY = Number(tileY);
if (!Number.isFinite(normalizedTileX) || !Number.isFinite(normalizedTileY)) {
return false;
}
if (normalizedTileX < 0 || normalizedTileY < 0 || normalizedTileX >= scope.width || normalizedTileY >= scope.height) {
return false;
}
if (!isPixiTileStageActive()) {
invalidateTileSurface(reason || "patch-fallback-full");
return false;
}
const patched = pixiTileStageController.patchTileAt(normalizedTileX, normalizedTileY);
if (patched) {
trackInvalidation("patch", reason || "cell-patch");
}
return patched;
}
function drawTileGridOverlay(viewportRect) {
if (scope.hideTileGrid || scope.tileSize < 12) {
return;
}
const startTileX = Math.max(0, Math.floor(viewportRect.left / scope.tileSize));
const endTileX = Math.min(scope.width, Math.ceil(viewportRect.right / scope.tileSize));
const startTileY = Math.max(0, Math.floor(viewportRect.top / scope.tileSize));
const endTileY = Math.min(scope.height, Math.ceil(viewportRect.bottom / scope.tileSize));
scope.ctx.save();
scope.ctx.strokeStyle = "rgba(0,0,0,0.23)";
scope.ctx.lineWidth = 0.5;
scope.ctx.beginPath();
for (let tileX = startTileX; tileX <= endTileX; tileX += 1) {
const drawX = Math.round((tileX * scope.tileSize) - viewportRect.left) + 0.5;
scope.ctx.moveTo(drawX, 0);
scope.ctx.lineTo(drawX, viewportRect.height);
}
for (let tileY = startTileY; tileY <= endTileY; tileY += 1) {
const drawY = Math.round((tileY * scope.tileSize) - viewportRect.top) + 0.5;
scope.ctx.moveTo(0, drawY);
scope.ctx.lineTo(viewportRect.width, drawY);
}
scope.ctx.stroke();
scope.ctx.restore();
}
function drawChunkBoundsOverlay(viewportRect) {
if (!scope.showChunkBounds || !scope.isWorldModeActive?.()) {
return;
}
const chunkWidth = Math.max(1, Number(scope.worldChunkWidth) || 0);
const chunkHeight = Math.max(1, Number(scope.worldChunkHeight) || 0);
if (!chunkWidth || !chunkHeight) {
return;
}
const startTileX = Math.max(0, Math.floor(viewportRect.left / scope.tileSize));
const endTileX = Math.min(scope.width, Math.ceil(viewportRect.right / scope.tileSize));
const startTileY = Math.max(0, Math.floor(viewportRect.top / scope.tileSize));
const endTileY = Math.min(scope.height, Math.ceil(viewportRect.bottom / scope.tileSize));
const firstBoundaryChunkX = Math.max(0, Math.floor(startTileX / chunkWidth));
const lastBoundaryChunkX = Math.ceil(endTileX / chunkWidth);
const firstBoundaryChunkY = Math.max(0, Math.floor(startTileY / chunkHeight));
const lastBoundaryChunkY = Math.ceil(endTileY / chunkHeight);
const originChunkX = Math.floor(Number(scope.worldOriginChunkX) || 0);
const originChunkY = Math.floor(Number(scope.worldOriginChunkY) || 0);
scope.ctx.save();
scope.ctx.strokeStyle = "rgba(255, 209, 102, 0.95)";
scope.ctx.lineWidth = Math.max(1, Math.min(2, scope.tileSize * 0.08));
scope.ctx.setLineDash([Math.max(4, Math.round(scope.tileSize * 0.24)), Math.max(3, Math.round(scope.tileSize * 0.16))]);
scope.ctx.beginPath();
for (let chunkX = firstBoundaryChunkX; chunkX <= lastBoundaryChunkX; chunkX += 1) {
const drawX = Math.round(((chunkX * chunkWidth) * scope.tileSize) - viewportRect.left) + 0.5;
scope.ctx.moveTo(drawX, 0);
scope.ctx.lineTo(drawX, viewportRect.height);
}
for (let chunkY = firstBoundaryChunkY; chunkY <= lastBoundaryChunkY; chunkY += 1) {
const drawY = Math.round(((chunkY * chunkHeight) * scope.tileSize) - viewportRect.top) + 0.5;
scope.ctx.moveTo(0, drawY);
scope.ctx.lineTo(viewportRect.width, drawY);
}
scope.ctx.stroke();
const fontSize = Math.max(10, Math.min(15, Math.round(scope.tileSize * 0.42)));
const labelPadX = Math.max(4, Math.round(scope.tileSize * 0.18));
const labelPadY = Math.max(4, Math.round(scope.tileSize * 0.16));
scope.ctx.setLineDash([]);
scope.ctx.font = `600 ${fontSize}px monospace`;
scope.ctx.textAlign = "left";
scope.ctx.textBaseline = "top";
for (let chunkY = firstBoundaryChunkY; chunkY < lastBoundaryChunkY; chunkY += 1) {
for (let chunkX = firstBoundaryChunkX; chunkX < lastBoundaryChunkX; chunkX += 1) {
const worldChunkX = originChunkX + chunkX;
const worldChunkY = originChunkY + chunkY;
const label = buildChunkFileName(worldChunkX, worldChunkY);
const drawLeft = Math.round(((chunkX * chunkWidth) * scope.tileSize) - viewportRect.left);
const drawTop = Math.round(((chunkY * chunkHeight) * scope.tileSize) - viewportRect.top);
const metrics = scope.ctx.measureText(label);
const textWidth = Math.ceil(metrics.width);
const labelHeight = fontSize + 4;
scope.ctx.fillStyle = "rgba(8, 16, 28, 0.78)";
scope.ctx.fillRect(
drawLeft + 2,
drawTop + 2,
textWidth + (labelPadX * 2),
labelHeight + (labelPadY * 2) - 2,
);
scope.ctx.fillStyle = "rgba(255, 238, 184, 0.98)";
scope.ctx.fillText(
label,
drawLeft + 2 + labelPadX,
drawTop + 2 + labelPadY,
);
}
}
scope.ctx.restore();
}
function drawSelectedChunkOverlay(viewportRect) {
if (!scope.showChunkBounds || !scope.isWorldModeActive?.() || typeof scope.getSelectedWorldChunkCoord !== "function") {
return;
}
const selectedChunk = scope.getSelectedWorldChunkCoord();
if (!selectedChunk) {
return;
}
const chunkWidth = Math.max(1, Number(scope.worldChunkWidth) || 0);
const chunkHeight = Math.max(1, Number(scope.worldChunkHeight) || 0);
const originChunkX = Math.floor(Number(scope.worldOriginChunkX) || 0);
const originChunkY = Math.floor(Number(scope.worldOriginChunkY) || 0);
const drawX = (((Math.floor(Number(selectedChunk.chunkX) || 0) - originChunkX) * chunkWidth) * scope.tileSize) - viewportRect.left;
const drawY = (((Math.floor(Number(selectedChunk.chunkY) || 0) - originChunkY) * chunkHeight) * scope.tileSize) - viewportRect.top;
const drawWidth = chunkWidth * scope.tileSize;
const drawHeight = chunkHeight * scope.tileSize;
if (!rectIntersects(viewportRect, drawX + viewportRect.left, drawY + viewportRect.top, drawWidth, drawHeight)) {
return;
}
scope.ctx.save();
scope.ctx.strokeStyle = "rgba(95, 195, 255, 0.98)";
scope.ctx.lineWidth = Math.max(2, Math.min(5, scope.tileSize * 0.16));
scope.ctx.setLineDash([]);
scope.ctx.strokeRect(
Math.round(drawX) + 0.5,
Math.round(drawY) + 0.5,
Math.max(0, Math.round(drawWidth) - 1),
Math.max(0, Math.round(drawHeight) - 1),
);
scope.ctx.restore();
}
function drawSelectedTileOverlay(viewportRect) {
if (
scope.selectedTile &&
scope.isLayerRendered(scope.selectedTile.layer) &&
rectIntersects(viewportRect, scope.selectedTile.x * scope.tileSize, scope.selectedTile.y * scope.tileSize, scope.tileSize, scope.tileSize)
) {
drawSelectionReticle(
(scope.selectedTile.x * scope.tileSize) - viewportRect.left,
(scope.selectedTile.y * scope.tileSize) - viewportRect.top,
scope.tileSize,
scope.tileSize,
);
}
}
function drawTiles(viewportRect) {
measureProfileMetric("tileStage", () => {
if (isPixiTileStageActive()) {
pixiTileStageController.render(false);
}
drawTileGridOverlay(viewportRect);
drawChunkBoundsOverlay(viewportRect);
drawSelectedChunkOverlay(viewportRect);
drawSelectedTileOverlay(viewportRect);
});
}
function drawRendererLoadingState(canvasWidth, canvasHeight) {
if (isPixiTileStageActive()) {
return;
}
scope.ctx.save();
scope.ctx.fillStyle = "rgba(7, 12, 22, 0.72)";
scope.ctx.fillRect(0, 0, canvasWidth, canvasHeight);
scope.ctx.fillStyle = "rgba(217, 235, 255, 0.94)";
scope.ctx.font = "600 14px Segoe UI, Arial, sans-serif";
scope.ctx.textAlign = "center";
scope.ctx.textBaseline = "middle";
scope.ctx.fillText("Loading world renderer...", canvasWidth / 2, canvasHeight / 2);
scope.ctx.restore();
}
function performDraw() {
const drawStartedAt = performance.now();
if (typeof scope.syncViewportDimensions === "function") {
scope.syncViewportDimensions();
}
const canvasWidth = Math.max(1, Math.ceil(Number(scope.viewport?.clientWidth) || 0));
const canvasHeight = Math.max(1, Math.ceil(Number(scope.viewport?.clientHeight) || 0));
if (scope.canvas.width !== canvasWidth || scope.canvas.height !== canvasHeight) {
scope.canvas.width = canvasWidth;
scope.canvas.height = canvasHeight;
}
const viewportRect = getViewportRenderRect(Math.max(1, scope.width * scope.tileSize), Math.max(1, scope.height * scope.tileSize));
scope.ctx.setTransform(1, 0, 0, 1, 0, 0);
scope.ctx.clearRect(0, 0, canvasWidth, canvasHeight);
if (!isPixiTileStageActive()) {
scope.ctx.fillStyle = scope.normalizeMapBackgroundColor(scope.backgroundColor);
scope.ctx.fillRect(0, 0, canvasWidth, canvasHeight);
}
drawTiles(viewportRect);
drawRendererLoadingState(canvasWidth, canvasHeight);
updateMetaBar();
measureProfileMetric("overlay", () => {
scope.ctx.save();
scope.ctx.setTransform(1, 0, 0, 1, -viewportRect.left, -viewportRect.top);
if (isPixiTileStageActive()) {
overlayRenderer.drawNpcUiOverlay(viewportRect);
}
overlayRenderer.drawGhostCursor();
scope.ctx.restore();
});
overlayRenderer.drawNpcHoverLabel();
updateRenderDebugPanel(viewportRect, canvasWidth, canvasHeight);
updateMeasuredFps(performance.now());
recordProfileMetric("draw", performance.now() - drawStartedAt);
}
function drawNow() {
if (pendingDrawFrame) {
window.cancelAnimationFrame(pendingDrawFrame);
pendingDrawFrame = 0;
}
performDraw();
}
function draw() {
if (pendingDrawFrame) {
return;
}
pendingDrawFrame = window.requestAnimationFrame(() => {
pendingDrawFrame = 0;
performDraw();
});
}
return {
initializeRenderAssets,
refreshRendererDebugState,
uiIconEl,
getCanvasPoint,
findTopNpcAtCanvas,
draw,
drawNow,
invalidateTileSurface,
patchTileSurfaceCell,
};
}

View file

@ -0,0 +1,165 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
export function moveItemRelative(items, sourceId, targetId, position) {
const sourceKey = String(sourceId || "").trim();
const targetKey = String(targetId || "").trim();
if (!sourceKey || !targetKey || sourceKey === targetKey) {
return Array.isArray(items) ? items.slice() : [];
}
const list = Array.isArray(items) ? items.slice() : [];
const sourceIndex = list.findIndex((entry) => String(entry) === sourceKey);
const targetIndex = list.findIndex((entry) => String(entry) === targetKey);
if (sourceIndex < 0 || targetIndex < 0) {
return list;
}
const [moved] = list.splice(sourceIndex, 1);
let insertionIndex = targetIndex;
if (sourceIndex < targetIndex) {
insertionIndex -= 1;
}
if (position === "after") {
insertionIndex += 1;
}
insertionIndex = Math.max(0, Math.min(list.length, insertionIndex));
list.splice(insertionIndex, 0, moved);
return list;
}
export function createReorderableListController(config) {
let draggingId = "";
let dropTargetId = "";
let dropPosition = "before";
function getItems() {
if (!config.container) {
return [];
}
return Array.from(config.container.querySelectorAll(config.itemSelector || "[data-reorder-item-id]"));
}
function resolveItemId(item) {
if (!item) {
return "";
}
if (typeof config.getItemId === "function") {
return String(config.getItemId(item) || "").trim();
}
return String(item.getAttribute("data-reorder-item-id") || "").trim();
}
function canDrag(itemId) {
return typeof config.canDragItem === "function" ? config.canDragItem(itemId) !== false : true;
}
function canDrop(itemId) {
return typeof config.canDropOnItem === "function" ? config.canDropOnItem(itemId) !== false : true;
}
function syncClasses() {
getItems().forEach((item) => {
const itemId = resolveItemId(item);
item.classList.toggle("reorder-dragging", !!draggingId && itemId === draggingId);
item.classList.toggle("reorder-drop-before", !!dropTargetId && itemId === dropTargetId && dropPosition === "before" && itemId !== draggingId);
item.classList.toggle("reorder-drop-after", !!dropTargetId && itemId === dropTargetId && dropPosition === "after" && itemId !== draggingId);
});
if (typeof config.onStateChange === "function") {
config.onStateChange({
draggingId,
dropTargetId,
dropPosition,
});
}
}
function clearState() {
draggingId = "";
dropTargetId = "";
dropPosition = "before";
syncClasses();
}
function handleDragStart(itemId, event) {
if (!canDrag(itemId)) {
event.preventDefault();
return;
}
draggingId = itemId;
dropTargetId = "";
dropPosition = "before";
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", itemId);
}
syncClasses();
}
function handleDragOver(itemId, item, event) {
if (!draggingId || draggingId === itemId || !canDrop(itemId)) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
const rect = item.getBoundingClientRect();
dropTargetId = itemId;
dropPosition = event.clientY < rect.top + (rect.height / 2) ? "before" : "after";
syncClasses();
}
function handleDrop(itemId, event) {
if (!draggingId || draggingId === itemId || !canDrop(itemId)) {
clearState();
return;
}
event.preventDefault();
const nextDraggingId = draggingId;
const nextDropPosition = dropPosition;
clearState();
if (typeof config.onMove === "function") {
config.onMove(nextDraggingId, itemId, nextDropPosition);
}
}
function bindItem(item) {
if (!item || item.dataset.reorderBound === "true") {
return;
}
const itemId = resolveItemId(item);
if (!itemId) {
return;
}
item.dataset.reorderBound = "true";
item.addEventListener("dragover", (event) => {
handleDragOver(itemId, item, event);
});
item.addEventListener("drop", (event) => {
handleDrop(itemId, event);
});
const handles = config.handleSelector ? Array.from(item.querySelectorAll(config.handleSelector)) : [item];
handles.forEach((handle) => {
handle.setAttribute("draggable", canDrag(itemId) ? "true" : "false");
handle.addEventListener("dragstart", (event) => {
handleDragStart(itemId, event);
});
handle.addEventListener("dragend", () => {
clearState();
});
});
}
function refresh() {
getItems().forEach((item) => bindItem(item));
syncClasses();
}
return {
clearState,
refresh,
};
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,347 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { copyTextWithClipboardFallback } from "./textTransferUtils";
import { clampFloatingWindowRect } from "./floatingWindowUtils";
const STATUS_LOG_WINDOW_KEY = "statusLog";
const DEFAULT_WIDTH = 540;
const DEFAULT_HEIGHT = 420;
const MIN_WIDTH = 360;
const MIN_HEIGHT = 240;
function clampWindowRect(layerRect, left, top, width, height) {
return clampFloatingWindowRect(layerRect, left, top, width, height, MIN_WIDTH, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT);
}
export function createStatusLogWindowController(scope) {
let initialized = false;
const uiScope = scope.uiScope || scope;
const sessionScope = scope.sessionScope || scope;
const persistedState = typeof sessionScope.getPersistedToolWindowState === "function"
? sessionScope.getPersistedToolWindowState(STATUS_LOG_WINDOW_KEY)
: null;
const state = {
visible: persistedState?.visible === true,
x: Number(persistedState?.x) || 96,
y: Number(persistedState?.y) || 72,
width: Number(persistedState?.width) || DEFAULT_WIDTH,
height: Number(persistedState?.height) || DEFAULT_HEIGHT,
shellEl: null,
titleEl: null,
metaEl: null,
listEl: null,
emptyEl: null,
copyBtnEl: null,
clearBtnEl: null,
resizeEl: null,
nextZIndex: 126,
};
function getLayerRect() {
return uiScope.toolWindowLayerEl?.getBoundingClientRect() || uiScope.editorBodyEl?.getBoundingClientRect() || {
left: 0,
top: 0,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
};
}
function persistState() {
if (typeof sessionScope.setPersistedToolWindowState === "function") {
sessionScope.setPersistedToolWindowState(STATUS_LOG_WINDOW_KEY, {
visible: state.visible === true,
mode: "floating",
x: state.x,
y: state.y,
width: state.width,
height: state.height,
order: 996,
});
}
scope.persistPopupSessionLayout?.();
sessionScope.persistPopupSessionLayout?.();
}
function focusWindow() {
if (!state.shellEl || state.visible !== true) {
return;
}
state.nextZIndex += 1;
state.shellEl.style.zIndex = String(state.nextZIndex);
state.shellEl.classList.add("is-focused");
}
function clearFocus() {
state.shellEl?.classList.remove("is-focused");
}
function applyWindowRect() {
if (!state.shellEl) {
return;
}
state.shellEl.style.left = Math.round(state.x) + "px";
state.shellEl.style.top = Math.round(state.y) + "px";
state.shellEl.style.width = Math.round(state.width) + "px";
state.shellEl.style.height = Math.round(state.height) + "px";
}
function buildExportText() {
const entries = scope.getEditorLogEntries?.() || [];
return entries.map((entry) => `[${entry.timestampLabel}] [${entry.level}] ${entry.message}`).join("\n");
}
async function copyLog() {
const exportText = buildExportText();
if (!exportText.trim()) {
scope.setStatus?.("Status log is empty.", false, { skipLogEntry: true });
return false;
}
return copyTextWithClipboardFallback(
exportText,
"Copy status log",
() => scope.setStatus?.("Copied status log to clipboard.", false, { skipLogEntry: true }),
() => scope.setStatus?.("Clipboard unavailable. Status log opened for manual copy.", false, { skipLogEntry: true }),
);
}
function refresh() {
const entries = scope.getEditorLogEntries?.() || [];
if (state.titleEl) {
state.titleEl.textContent = "Status Log";
}
if (state.metaEl) {
state.metaEl.textContent = entries.length === 1 ? "1 entry" : `${entries.length} entries`;
}
if (!state.listEl) {
return;
}
state.listEl.innerHTML = "";
if (entries.length <= 0) {
if (state.emptyEl) {
state.emptyEl.classList.remove("hidden");
}
return;
}
if (state.emptyEl) {
state.emptyEl.classList.add("hidden");
}
entries
.slice()
.reverse()
.forEach((entry) => {
const rowEl = document.createElement("div");
rowEl.className = "status-log-row";
rowEl.innerHTML =
'<div class="status-log-row-head">' +
'<span class="status-log-row-level status-log-row-level-' + String(entry.level || "information").toLowerCase() + '">' + String(entry.level || "Information") + '</span>' +
'<span class="status-log-row-time">' + String(entry.timestampLabel || "") + "</span>" +
"</div>" +
'<div class="status-log-row-message">' + scope.runtimeEscapeHtml(String(entry.message || "")) + "</div>";
state.listEl.appendChild(rowEl);
});
state.listEl.scrollTop = 0;
}
function ensureShell() {
if (state.shellEl && state.shellEl.isConnected) {
return state.shellEl;
}
const shellEl = document.createElement("div");
shellEl.className = "tool-popout-window status-log-window hidden";
const titlebarEl = document.createElement("div");
titlebarEl.className = "tool-popout-titlebar";
titlebarEl.innerHTML =
'<div class="tool-popout-title">Status Log</div>' +
'<div class="tool-popout-hint">Right-click the top-right status to reopen</div>' +
'<button class="tool-popout-close-btn" type="button" aria-label="Close status log">X</button>';
const bodyEl = document.createElement("div");
bodyEl.className = "tool-popout-body";
const cardEl = document.createElement("div");
cardEl.className = "status-log-card";
const headEl = document.createElement("div");
headEl.className = "status-log-head";
const titleEl = document.createElement("div");
titleEl.className = "status-log-title";
const metaEl = document.createElement("div");
metaEl.className = "status-log-meta";
headEl.appendChild(titleEl);
headEl.appendChild(metaEl);
const actionsEl = document.createElement("div");
actionsEl.className = "status-log-actions";
const copyBtnEl = document.createElement("button");
copyBtnEl.type = "button";
copyBtnEl.className = "mini-btn";
copyBtnEl.textContent = "Copy";
copyBtnEl.addEventListener("click", () => {
void copyLog();
});
const clearBtnEl = document.createElement("button");
clearBtnEl.type = "button";
clearBtnEl.className = "mini-btn danger";
clearBtnEl.textContent = "Clear";
clearBtnEl.addEventListener("click", () => {
scope.clearEditorLogEntries?.();
refresh();
scope.setStatus?.("Status log cleared.", false, { skipLogEntry: true });
});
actionsEl.appendChild(copyBtnEl);
actionsEl.appendChild(clearBtnEl);
const listEl = document.createElement("div");
listEl.className = "status-log-list";
const emptyEl = document.createElement("div");
emptyEl.className = "status-log-empty";
emptyEl.textContent = "No log entries yet.";
listEl.appendChild(emptyEl);
cardEl.appendChild(headEl);
cardEl.appendChild(actionsEl);
cardEl.appendChild(listEl);
bodyEl.appendChild(cardEl);
const resizeEl = document.createElement("div");
resizeEl.className = "tool-popout-resize";
const closeBtnEl = titlebarEl.querySelector(".tool-popout-close-btn");
shellEl.appendChild(titlebarEl);
shellEl.appendChild(bodyEl);
shellEl.appendChild(resizeEl);
shellEl.addEventListener("pointerdown", () => {
focusWindow();
});
titlebarEl.addEventListener("pointerdown", (event) => {
if (closeBtnEl && closeBtnEl.contains(event.target)) {
return;
}
if (event.button !== 0) {
return;
}
event.preventDefault();
focusWindow();
const layerRect = getLayerRect();
const originLeft = Number(state.x) || 0;
const originTop = Number(state.y) || 0;
const startX = event.clientX;
const startY = event.clientY;
const move = (moveEvent) => {
const nextRect = clampWindowRect(
layerRect,
originLeft + (moveEvent.clientX - startX),
originTop + (moveEvent.clientY - startY),
state.width,
state.height,
);
state.x = nextRect.left;
state.y = nextRect.top;
applyWindowRect();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
closeBtnEl?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
close();
});
resizeEl.addEventListener("pointerdown", (event) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
focusWindow();
const layerRect = getLayerRect();
const startX = event.clientX;
const startY = event.clientY;
const originWidth = Number(state.width) || DEFAULT_WIDTH;
const originHeight = Number(state.height) || DEFAULT_HEIGHT;
const move = (moveEvent) => {
const nextRect = clampWindowRect(
layerRect,
state.x,
state.y,
Math.max(MIN_WIDTH, originWidth + (moveEvent.clientX - startX)),
Math.max(MIN_HEIGHT, originHeight + (moveEvent.clientY - startY)),
);
state.width = nextRect.width;
state.height = nextRect.height;
applyWindowRect();
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
state.shellEl = shellEl;
state.titleEl = titleEl;
state.metaEl = metaEl;
state.listEl = listEl;
state.emptyEl = emptyEl;
state.copyBtnEl = copyBtnEl;
state.clearBtnEl = clearBtnEl;
state.resizeEl = resizeEl;
uiScope.toolWindowLayerEl?.appendChild(shellEl);
applyWindowRect();
shellEl.classList.toggle("hidden", state.visible !== true);
refresh();
return shellEl;
}
function open() {
ensureShell();
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
state.visible = true;
refresh();
state.shellEl?.classList.remove("hidden");
applyWindowRect();
focusWindow();
persistState();
return true;
}
function close() {
state.visible = false;
clearFocus();
state.shellEl?.classList.add("hidden");
persistState();
return true;
}
function initialize() {
if (initialized) {
return;
}
initialized = true;
ensureShell();
window.addEventListener("resize", () => {
const nextRect = clampWindowRect(getLayerRect(), state.x, state.y, state.width, state.height);
state.x = nextRect.left;
state.y = nextRect.top;
state.width = nextRect.width;
state.height = nextRect.height;
applyWindowRect();
persistState();
});
if (state.visible) {
open();
} else {
state.visible = false;
state.shellEl?.classList.add("hidden");
}
}
return {
initialize,
open,
close,
refresh,
isOpen: () => state.visible === true,
};
}

View file

@ -0,0 +1,58 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
export function normalizeEditorTagValue(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
export function normalizeEditorTags(value) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
return value
.map((entry) => normalizeEditorTagValue(entry))
.filter((entry) => {
if (!entry) {
return false;
}
const key = entry.toLocaleLowerCase();
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
})
.sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" }));
}
export function serializeEditorTags(value) {
return JSON.stringify(normalizeEditorTags(value));
}
export function parseImportedEditorTags(rawValue) {
const raw = String(rawValue || "").trim();
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return normalizeEditorTags(parsed);
}
if (typeof parsed === "string") {
return normalizeEditorTags(
parsed
.split(/\r?\n|[,;|]/g)
.map((entry) => String(entry || "").trim()),
);
}
} catch {
// Fall through to plain text parsing.
}
return normalizeEditorTags(
raw
.split(/\r?\n|[,;|]/g)
.map((entry) => String(entry || "").trim()),
);
}

View file

@ -0,0 +1,33 @@
export async function copyTextWithClipboardFallback(
text: unknown,
fallbackTitle: string,
onClipboardSuccess: () => void,
onFallbackSuccess: (clipboardAvailable: boolean) => void,
) {
const normalizedText = String(text || "");
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(normalizedText);
onClipboardSuccess();
return true;
}
window.prompt(fallbackTitle, normalizedText);
onFallbackSuccess(false);
return true;
} catch {
window.prompt(fallbackTitle, normalizedText);
onFallbackSuccess(true);
return true;
}
}
export function promptForImportText(promptLabel: string, defaultValue = "") {
return window.prompt(promptLabel, defaultValue);
}
export function confirmDiscardChanges(message: string, isDirty: boolean) {
if (!isDirty) {
return true;
}
return window.confirm(message);
}

View file

@ -0,0 +1,546 @@
import { normalizeEngineOverrideEntries } from "./engineOverrides";
export const DEFAULT_MAP_EDITOR_THEME_PRESET = "azure";
export const MAP_EDITOR_THEME_PRESETS = [
{
id: "azure",
label: "Azure",
swatch: ["#17325d", "#244c84", "#7fd1ff", "#10203c"],
vars: {
"--editor-shell-bg": "#0A1020",
"--editor-shell-fg": "#D8E8FF",
"--editor-menu-grad-1": "#152645",
"--editor-menu-grad-2": "#10203C",
"--editor-sidebar-bg": "#0E1A33",
"--editor-stage-bg": "#060A14",
"--editor-border": "#2E426C",
"--editor-border-strong": "#3C5E95",
"--editor-panel-bg": "#121F3B",
"--editor-panel-bg-alt": "#132B4F",
"--editor-panel-bg-elevated": "#10284B",
"--editor-panel-bg-hover": "#1A3F6D",
"--editor-control-bg": "#1A345E",
"--editor-control-bg-hover": "#214679",
"--editor-control-bg-active": "#1E4B82",
"--editor-control-border": "#3C5E95",
"--editor-control-fg": "#D6E7FF",
"--editor-muted": "#9FB8E5",
"--editor-muted-strong": "#CFE2FF",
"--editor-accent": "#64AAF8",
"--editor-accent-strong": "#8FD0FF",
"--editor-accent-soft": "#22466E",
"--editor-tool-armed": "#7EE8C6",
"--editor-tool-armed-soft": "#1A3C40",
"--editor-warn": "#FFD166",
"--editor-danger": "#3C1A1A",
"--editor-danger-border": "#7F4C4C",
"--editor-danger-hover": "#5A2323",
"--editor-preview-bg": "#0D1B34",
"--editor-drop-line": "#64AAF8",
"--editor-drop-shadow": "rgba(100, 170, 248, 0.3)",
"--editor-status-ok": "#B9CFEF",
"--editor-status-error": "#FF9E9E",
"--editor-tooltip-shadow": "rgba(0, 0, 20, 0.8)",
"--editor-tab-shadow": "rgba(3, 8, 18, 0.8)",
},
},
{
id: "verdant",
label: "Verdant",
swatch: ["#17372E", "#285B4A", "#7CE0AF", "#0E251E"],
vars: {
"--editor-shell-bg": "#081510",
"--editor-shell-fg": "#DDF7EA",
"--editor-menu-grad-1": "#16352B",
"--editor-menu-grad-2": "#0E251E",
"--editor-sidebar-bg": "#0D221B",
"--editor-stage-bg": "#06100D",
"--editor-border": "#305847",
"--editor-border-strong": "#46806A",
"--editor-panel-bg": "#122A22",
"--editor-panel-bg-alt": "#17362C",
"--editor-panel-bg-elevated": "#133127",
"--editor-panel-bg-hover": "#21503F",
"--editor-control-bg": "#1B4335",
"--editor-control-bg-hover": "#245844",
"--editor-control-bg-active": "#2D7258",
"--editor-control-border": "#46806A",
"--editor-control-fg": "#DDF7EA",
"--editor-muted": "#A5D2BE",
"--editor-muted-strong": "#D2F0E0",
"--editor-accent": "#70D8A6",
"--editor-accent-strong": "#8AE8BE",
"--editor-accent-soft": "#275845",
"--editor-tool-armed": "#8FD8FF",
"--editor-tool-armed-soft": "#173642",
"--editor-warn": "#F5D66D",
"--editor-danger": "#472123",
"--editor-danger-border": "#8B5559",
"--editor-danger-hover": "#633034",
"--editor-preview-bg": "#10271F",
"--editor-drop-line": "#70D8A6",
"--editor-drop-shadow": "rgba(112, 216, 166, 0.3)",
"--editor-status-ok": "#C0E9D5",
"--editor-status-error": "#FFADAD",
"--editor-tooltip-shadow": "rgba(0, 12, 8, 0.78)",
"--editor-tab-shadow": "rgba(2, 10, 8, 0.76)",
},
},
{
id: "ember",
label: "Ember",
swatch: ["#4F231C", "#87412F", "#FFB36C", "#24110F"],
vars: {
"--editor-shell-bg": "#160C0B",
"--editor-shell-fg": "#FFE8D9",
"--editor-menu-grad-1": "#432018",
"--editor-menu-grad-2": "#24110F",
"--editor-sidebar-bg": "#21110E",
"--editor-stage-bg": "#100706",
"--editor-border": "#6A3B33",
"--editor-border-strong": "#9A5A4D",
"--editor-panel-bg": "#311A16",
"--editor-panel-bg-alt": "#3F211B",
"--editor-panel-bg-elevated": "#341B17",
"--editor-panel-bg-hover": "#5A2E26",
"--editor-control-bg": "#4A261F",
"--editor-control-bg-hover": "#67352B",
"--editor-control-bg-active": "#8B4937",
"--editor-control-border": "#9A5A4D",
"--editor-control-fg": "#FFE8D9",
"--editor-muted": "#E2B6A2",
"--editor-muted-strong": "#FFE0CF",
"--editor-accent": "#FFB36C",
"--editor-accent-strong": "#FFD08E",
"--editor-accent-soft": "#684133",
"--editor-tool-armed": "#FF9D8A",
"--editor-tool-armed-soft": "#4A2824",
"--editor-warn": "#FFE17A",
"--editor-danger": "#512225",
"--editor-danger-border": "#A16063",
"--editor-danger-hover": "#6A2E32",
"--editor-preview-bg": "#281311",
"--editor-drop-line": "#FFB36C",
"--editor-drop-shadow": "rgba(255, 179, 108, 0.32)",
"--editor-status-ok": "#F6C8AF",
"--editor-status-error": "#FFB1A3",
"--editor-tooltip-shadow": "rgba(20, 6, 0, 0.76)",
"--editor-tab-shadow": "rgba(16, 6, 2, 0.76)",
},
},
{
id: "amethyst",
label: "Amethyst",
swatch: ["#342456", "#5A3B8A", "#D3A8FF", "#171125"],
vars: {
"--editor-shell-bg": "#0F0B19",
"--editor-shell-fg": "#F0E6FF",
"--editor-menu-grad-1": "#2A1F45",
"--editor-menu-grad-2": "#171125",
"--editor-sidebar-bg": "#17112A",
"--editor-stage-bg": "#0A0712",
"--editor-border": "#4E4474",
"--editor-border-strong": "#7662A9",
"--editor-panel-bg": "#211A39",
"--editor-panel-bg-alt": "#2A2149",
"--editor-panel-bg-elevated": "#241D41",
"--editor-panel-bg-hover": "#3B3066",
"--editor-control-bg": "#35295D",
"--editor-control-bg-hover": "#473678",
"--editor-control-bg-active": "#5B4594",
"--editor-control-border": "#7662A9",
"--editor-control-fg": "#F0E6FF",
"--editor-muted": "#C6B3E6",
"--editor-muted-strong": "#E5D9FF",
"--editor-accent": "#C38BFF",
"--editor-accent-strong": "#DDB5FF",
"--editor-accent-soft": "#493C72",
"--editor-tool-armed": "#8FE7FF",
"--editor-tool-armed-soft": "#22384C",
"--editor-warn": "#F7D37E",
"--editor-danger": "#4A213F",
"--editor-danger-border": "#935C8A",
"--editor-danger-hover": "#632D56",
"--editor-preview-bg": "#1A1430",
"--editor-drop-line": "#C38BFF",
"--editor-drop-shadow": "rgba(195, 139, 255, 0.32)",
"--editor-status-ok": "#D9C5FF",
"--editor-status-error": "#FFB6DE",
"--editor-tooltip-shadow": "rgba(10, 4, 24, 0.8)",
"--editor-tab-shadow": "rgba(10, 6, 20, 0.78)",
},
},
];
const themePresetIds = new Set(MAP_EDITOR_THEME_PRESETS.map((preset) => preset.id));
export function normalizeMapEditorThemePreset(value: unknown): string {
const normalized = String(value || "").trim().toLowerCase();
return themePresetIds.has(normalized) ? normalized : DEFAULT_MAP_EDITOR_THEME_PRESET;
}
export function getMapEditorThemePreset(value: unknown) {
const presetId = normalizeMapEditorThemePreset(value);
return MAP_EDITOR_THEME_PRESETS.find((preset) => preset.id === presetId) || MAP_EDITOR_THEME_PRESETS[0];
}
export function getMapEditorThemeLabel(value: unknown): string {
return getMapEditorThemePreset(value).label;
}
export function applyMapEditorThemePreset(value: unknown, targetDocument: Document = document): string {
const presetId = normalizeMapEditorThemePreset(value);
targetDocument.documentElement.setAttribute("data-editor-theme", presetId);
return presetId;
}
export function getDefaultEditorSettings() {
return {
schemaVersion: 1,
mapEditor: {
themePreset: DEFAULT_MAP_EDITOR_THEME_PRESET,
engineOverrides: [],
},
};
}
export function normalizeEditorSettings(value: unknown) {
const fallback = getDefaultEditorSettings();
const source = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const mapEditorSource = source.mapEditor && typeof source.mapEditor === "object" && !Array.isArray(source.mapEditor)
? source.mapEditor as Record<string, unknown>
: {};
return {
schemaVersion: typeof source.schemaVersion === "number" ? source.schemaVersion : fallback.schemaVersion,
mapEditor: {
themePreset: normalizeMapEditorThemePreset(mapEditorSource.themePreset),
engineOverrides: normalizeEngineOverrideEntries(mapEditorSource.engineOverrides),
},
};
}
async function readErrorResponse(response: Response): Promise<string> {
try {
const text = await response.text();
const trimmed = String(text || "").trim();
return trimmed ? `: ${trimmed.slice(0, 240)}` : "";
} catch {
return "";
}
}
export async function fetchEditorSettings(apiBase: string) {
const normalizedBase = String(apiBase || "").replace(/\/+$/, "");
try {
const response = await fetch(normalizedBase + "/api/editor-settings");
if (!response.ok) {
return getDefaultEditorSettings();
}
return normalizeEditorSettings(await response.json());
} catch {
return getDefaultEditorSettings();
}
}
export async function persistEditorSettings(apiBase: string, value: unknown) {
const normalizedBase = String(apiBase || "").replace(/\/+$/, "");
const payload = normalizeEditorSettings(value);
const response = await fetch(normalizedBase + "/api/editor-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error("Theme save failed (" + response.status + ")" + await readErrorResponse(response));
}
return normalizeEditorSettings(await response.json());
}
function varsToCss(vars: Record<string, string>): string {
return Object.entries(vars)
.map(([key, val]) => `${key}: ${val};`)
.join(" ");
}
export function buildMapEditorThemeOverrideCss(): string {
const rootCss = `:root { ${varsToCss(MAP_EDITOR_THEME_PRESETS[0].vars)} }`;
const presetCss = MAP_EDITOR_THEME_PRESETS
.map((preset) => `html[data-editor-theme="${preset.id}"] { ${varsToCss(preset.vars)} }`)
.join("\n");
return `
${rootCss}
${presetCss}
html, body {
background: var(--editor-shell-bg) !important;
color: var(--editor-shell-fg) !important;
}
.menu-bar {
border-bottom-color: var(--editor-border) !important;
background: linear-gradient(180deg, var(--editor-menu-grad-1) 0%, var(--editor-menu-grad-2) 100%) !important;
}
.menu-bar-right {
margin-left: auto;
display: grid;
grid-template-columns: auto 180px;
align-items: center;
gap: 10px;
min-width: 0;
}
.theme-preset-bar {
display: grid;
grid-template-columns: repeat(4, 40px);
align-items: center;
gap: 6px;
}
.theme-preset-btn {
width: 40px;
height: 40px;
padding: 0;
border: 1px solid var(--editor-control-border);
border-radius: 10px;
background: var(--editor-panel-bg);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
overflow: visible;
}
.theme-preset-btn:hover {
transform: translateY(-1px);
border-color: var(--editor-accent);
}
.theme-preset-btn.active {
border-color: var(--editor-accent);
box-shadow: 0 0 0 1px var(--editor-accent);
}
.theme-preset-btn:focus-visible {
outline: none;
border-color: var(--editor-accent);
box-shadow: 0 0 0 1px var(--editor-accent);
}
.theme-preset-swatch {
width: 24px;
height: 24px;
border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.18);
background:
linear-gradient(135deg, var(--theme-swatch-a) 0 50%, var(--theme-swatch-b) 50% 100%),
linear-gradient(315deg, var(--theme-swatch-c) 0 50%, var(--theme-swatch-d) 50% 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
transition: transform 120ms ease;
transform-origin: center;
}
.theme-preset-btn.active .theme-preset-swatch,
.theme-preset-btn:focus-visible .theme-preset-swatch {
transform: scale(1.18);
}
.save-status {
width: 180px;
min-width: 180px;
max-width: 180px;
font-size: 12px;
color: var(--editor-status-ok);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
cursor: context-menu;
}
.sidebar,
.sidebar-tabs,
.sidebar-tabs::before {
background: var(--editor-sidebar-bg) !important;
}
.sidebar,
.sidebar-tabs,
.history-preview,
.npc-editor-panel,
.npc-compact-menu,
.at-tooltip-panel,
.stage,
.meta {
border-color: var(--editor-border) !important;
}
.stage {
background: var(--editor-stage-bg) !important;
}
.menu-btn,
.menu-layer-select,
.layer-delete-btn,
.mini-btn,
.canvas-tool-btn,
.panel-square-btn,
.selector-drag-handle,
.layer-drag-handle,
.layer-visibility-btn,
.icon-action-btn,
.background-mode-btn,
.npc-icon-btn,
.folder-toggle-btn,
.sprite-dropdown-btn,
.sprite-option-btn,
.at-tooltip-item,
.paint-swatch-btn,
.selector-section-toggle,
.selector-section-label,
.map-manager select,
.map-manager input:not([type="color"]),
.npc-editor-row input,
.npc-editor-row select {
border-color: var(--editor-control-border) !important;
background: var(--editor-control-bg) !important;
color: var(--editor-control-fg) !important;
}
.menu-btn:hover,
.layer-delete-btn:hover,
.panel-square-btn:hover,
.canvas-tool-btn:hover,
.selector-drag-handle:hover,
.layer-drag-handle:hover,
.mini-btn:hover,
.background-mode-btn:hover,
.icon-action-btn:hover,
.sprite-option-btn:hover,
.at-tooltip-item:hover,
.at-tooltip-item.active,
.selector-drag-handle.dragging,
.selector-drag-handle:active {
background: var(--editor-control-bg-hover) !important;
}
.sidebar-tab-btn,
.layer-row,
.history-row,
.folder-row,
.folder-empty,
.folder-root-drop-zone,
.legend-item,
.history-preview,
.background-mode-preview,
.npc-thumb,
.npc-thumb-fallback,
.info-help-panel {
border-color: var(--editor-border) !important;
background: var(--editor-panel-bg) !important;
color: var(--editor-control-fg) !important;
}
.npc-editor-panel,
.npc-compact-menu,
.sprite-dropdown-menu {
background: var(--editor-panel-bg-elevated) !important;
}
.npc-editor-panel,
.npc-compact-menu,
.sprite-dropdown-menu,
.info-footer-bar {
border-color: var(--editor-border) !important;
}
.info-footer-bar {
background: linear-gradient(180deg, var(--editor-menu-grad-1) 0%, var(--editor-menu-grad-2) 100%) !important;
}
.info-footer-link {
color: var(--editor-accent-strong) !important;
}
.info-footer-link:hover,
.info-footer-link:focus-visible {
color: var(--editor-shell-fg) !important;
}
.sidebar-tab-btn,
.mini-btn,
.selector-section-toggle,
.selector-section-label {
background: var(--editor-panel-bg-alt) !important;
color: var(--editor-muted-strong) !important;
}
.sidebar-tab-btn.active,
.layer-row.active,
.npc-row.active,
.layer-visibility-btn.active,
.npc-icon-btn.active,
.sprite-option-btn.active,
.canvas-tool-btn.active {
border-color: var(--editor-accent) !important;
background: var(--editor-control-bg-active) !important;
color: var(--editor-shell-fg) !important;
}
.history-row.active,
.paint-swatch-btn.active {
border-color: var(--editor-warn) !important;
background: var(--editor-accent-soft) !important;
}
.layer-row.layer-add-row,
.folder-root-drop-active {
border-color: var(--editor-accent) !important;
background: var(--editor-accent-soft) !important;
}
.folder-row {
background: var(--editor-panel-bg-alt) !important;
border-color: var(--editor-control-border) !important;
}
.folder-empty,
.folder-root-drop-zone {
color: var(--editor-muted) !important;
}
.folder-children {
border-left-color: var(--editor-drop-shadow) !important;
}
.layer-row-wrap.reorder-drop-before::before,
.layer-row-wrap.reorder-drop-after::after,
.folder-drop-before::before,
.folder-drop-after::after {
background: var(--editor-drop-line) !important;
box-shadow: 0 0 0 1px var(--editor-drop-shadow) !important;
}
.folder-drop-inside {
box-shadow: inset 0 0 0 1px var(--editor-drop-line) !important;
}
.icon-action-btn.danger {
border-color: var(--editor-danger-border) !important;
background: var(--editor-danger) !important;
}
.icon-action-btn.danger:hover {
background: var(--editor-danger-hover) !important;
}
.background-mode-preview {
background: var(--editor-preview-bg) !important;
}
.background-mode-title,
.history-preview h4,
.meta-stats {
color: var(--editor-shell-fg) !important;
}
.menu-layer-label,
.field-row label,
.map-manager label,
.npc-editor-row label,
.background-mode-meta,
.history-meta,
.history-preview-empty,
.at-tooltip-label,
.legend,
.meta,
.selector-section-chevron,
.info-help-title,
.shortcut-plus {
color: var(--editor-muted) !important;
}
.shortcut-action,
.shortcut-mouse-label {
color: var(--editor-muted-strong) !important;
}
.sidebar h3 {
color: var(--editor-muted) !important;
}
.at-tooltip-panel {
background: var(--editor-panel-bg-elevated) !important;
box-shadow: 0 6px 28px var(--editor-tooltip-shadow) !important;
}
.sidebar-tabs {
box-shadow: 0 8px 14px var(--editor-tab-shadow) !important;
}
`;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,693 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
const PANEL_ORDER = [
"information",
"layers",
"tiles",
"instances",
"triggers",
"paths",
"transitions",
"history",
];
const PANEL_LABELS = {
information: "Settings",
layers: "Layers",
tiles: "Graphics",
instances: "Entities",
triggers: "Triggers",
paths: "Paths",
transitions: "Transitions",
history: "History",
};
const PANEL_DEFAULT_SIZES = {
information: { width: 340, height: 360 },
layers: { width: 340, height: 430 },
tiles: { width: 360, height: 540 },
instances: { width: 380, height: 520 },
triggers: { width: 320, height: 280 },
paths: { width: 320, height: 280 },
transitions: { width: 320, height: 280 },
history: { width: 380, height: 340 },
};
const PANEL_MIN_SIZES = {
default: { width: 260, height: 220 },
history: { width: 320, height: 300 },
};
const DEFAULT_VISIBLE_PANELS = new Set(["layers"]);
export function createToolWindowController(scope) {
let initialized = false;
const uiScope = scope.uiScope || scope;
const sessionScope = scope.sessionScope || scope;
const panelEntries = PANEL_ORDER
.map((key, index) => {
const persistedState = typeof sessionScope.getPersistedToolWindowState === "function"
? sessionScope.getPersistedToolWindowState(key)
: null;
return {
key,
label: PANEL_LABELS[key] || key,
buttonEl: uiScope[key + "TabBtn"],
panelEl: uiScope[key + "Panel"],
shellEl: null,
bodyEl: null,
titlebarEl: null,
resizeEl: null,
dockBtnEl: null,
mode: persistedState?.mode === "floating" ? "floating" : "inline",
visible: typeof persistedState?.visible === "boolean" ? persistedState.visible : DEFAULT_VISIBLE_PANELS.has(key),
x: Number(persistedState?.x) || 0,
y: Number(persistedState?.y) || 0,
width: Number(persistedState?.width) || 0,
height: Number(persistedState?.height) || 0,
inlineHeight: Number(persistedState?.inlineHeight) || 0,
order: Number.isFinite(Number(persistedState?.order)) ? Number(persistedState.order) : index,
};
})
.filter((entry) => entry.buttonEl && entry.panelEl);
panelEntries.sort((left, right) => left.order - right.order);
const entryByKey = new Map(panelEntries.map((entry) => [entry.key, entry]));
let nextZIndex = 60;
let suppressedClickKey = "";
function getEntry(key) {
return entryByKey.get(String(key || "").trim()) || null;
}
function getLayerRect() {
return uiScope.toolWindowLayerEl?.getBoundingClientRect() || uiScope.editorBodyEl?.getBoundingClientRect() || {
left: 0,
top: 0,
width: 0,
height: 0,
right: 0,
bottom: 0,
};
}
function getStageRect() {
return uiScope.stageEl?.getBoundingClientRect() || null;
}
function getDockRect() {
return uiScope.sidebarTabsEl?.getBoundingClientRect() || null;
}
function getSidebarBodyRect() {
return uiScope.sidebarPanelsHostEl?.getBoundingClientRect() || null;
}
function pointInsideRect(clientX, clientY, rect) {
if (!rect) {
return false;
}
return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
}
function pointInsideStage(clientX, clientY) {
return pointInsideRect(clientX, clientY, getStageRect());
}
function pointInsideDock(clientX, clientY) {
return pointInsideRect(clientX, clientY, getDockRect());
}
function pointInsideSidebarBody(clientX, clientY) {
return pointInsideRect(clientX, clientY, getSidebarBodyRect());
}
function setDockTargetActive(isActive) {
uiScope.sidebarTabsEl?.classList.toggle("dock-target", isActive === true);
}
function setSidebarBodyTargetActive(isActive) {
uiScope.sidebarPanelsHostEl?.classList.toggle("sidebar-drop-target", isActive === true);
}
function getMinSize(key) {
return PANEL_MIN_SIZES[key] || PANEL_MIN_SIZES.default;
}
function clampWindowRect(left, top, width, height) {
const layerRect = getLayerRect();
const clampedWidth = Math.max(180, Math.min(Math.max(180, width), Math.max(180, layerRect.width - 12)));
const clampedHeight = Math.max(140, Math.min(Math.max(140, height), Math.max(140, layerRect.height - 12)));
const maxLeft = Math.max(0, layerRect.width - clampedWidth);
const maxTop = Math.max(0, layerRect.height - clampedHeight);
return {
left: Math.max(0, Math.min(maxLeft, left)),
top: Math.max(0, Math.min(maxTop, top)),
width: clampedWidth,
height: clampedHeight,
};
}
function focusWindow(entry) {
if (!entry?.shellEl || entry.mode !== "floating" || entry.visible !== true) {
return;
}
nextZIndex += 1;
panelEntries.forEach((candidate) => {
candidate.shellEl?.classList.remove("is-focused");
});
entry.shellEl.style.zIndex = String(nextZIndex);
entry.shellEl.classList.add("is-focused");
}
function applyFloatingRect(entry) {
if (!entry?.shellEl || entry.mode !== "floating") {
return;
}
entry.shellEl.style.left = Math.round(entry.x) + "px";
entry.shellEl.style.top = Math.round(entry.y) + "px";
entry.shellEl.style.width = Math.round(entry.width) + "px";
entry.shellEl.style.height = Math.round(entry.height) + "px";
}
function getSidebarInsertBefore(clientY, excludeShell) {
if (!uiScope.sidebarPanelsHostEl) {
return null;
}
const children = Array.from(uiScope.sidebarPanelsHostEl.children).filter((child) => {
if (!(child instanceof HTMLElement) || child === excludeShell) {
return false;
}
const style = window.getComputedStyle(child);
return style.display !== "none";
});
return children.find((child) => {
const rect = child.getBoundingClientRect();
return clientY < rect.top + (rect.height / 2);
}) || null;
}
function attachShellToInline(entry, clientY) {
if (!entry?.shellEl || !uiScope.sidebarPanelsHostEl) {
return;
}
const beforeNode = Number.isFinite(Number(clientY)) ? getSidebarInsertBefore(Number(clientY), entry.shellEl) : null;
if (beforeNode) {
uiScope.sidebarPanelsHostEl.insertBefore(entry.shellEl, beforeNode);
} else {
uiScope.sidebarPanelsHostEl.appendChild(entry.shellEl);
}
}
function attachShellToFloating(entry) {
if (!entry?.shellEl || !uiScope.toolWindowLayerEl) {
return;
}
uiScope.toolWindowLayerEl.appendChild(entry.shellEl);
}
function clearDockHighlights() {
setDockTargetActive(false);
setSidebarBodyTargetActive(false);
}
function syncInlineOrderState() {
if (!uiScope.sidebarPanelsHostEl) {
return;
}
let nextOrder = 0;
Array.from(uiScope.sidebarPanelsHostEl.children).forEach((child) => {
if (!(child instanceof HTMLElement)) {
return;
}
const key = String(child.getAttribute("data-panel-key") || "").trim();
const entry = getEntry(key);
if (!entry || entry.mode !== "inline") {
return;
}
entry.order = nextOrder;
nextOrder += 1;
});
}
function persistPanelState() {
syncInlineOrderState();
if (typeof sessionScope.setPersistedToolWindowState === "function") {
panelEntries.forEach((entry) => {
sessionScope.setPersistedToolWindowState(entry.key, {
mode: entry.mode,
visible: entry.visible === true,
x: entry.x,
y: entry.y,
width: entry.width,
height: entry.height,
inlineHeight: entry.inlineHeight,
order: entry.order,
});
});
}
if (typeof scope.persistPopupSessionLayout === "function") {
scope.persistPopupSessionLayout();
} else if (typeof sessionScope.persistPopupSessionLayout === "function") {
sessionScope.persistPopupSessionLayout();
}
}
function updateShellPresentation(entry) {
if (!entry?.shellEl) {
return;
}
entry.shellEl.classList.toggle("tool-popout-window-inline", entry.mode === "inline");
entry.shellEl.classList.toggle("hidden", entry.visible !== true);
if (entry.titlebarEl) {
const hintEl = entry.titlebarEl.querySelector(".tool-popout-hint");
if (hintEl) {
hintEl.textContent = entry.mode === "floating" ? "Drag to dock" : "Drag into canvas";
}
}
if (entry.mode === "floating") {
applyFloatingRect(entry);
} else {
entry.shellEl.style.left = "";
entry.shellEl.style.top = "";
entry.shellEl.style.width = "";
entry.shellEl.style.height = Number(entry.inlineHeight) > 0 ? Math.round(entry.inlineHeight) + "px" : "";
entry.shellEl.style.zIndex = "";
entry.shellEl.classList.remove("is-focused");
}
}
function syncPanels() {
panelEntries.forEach((entry) => {
entry.buttonEl.classList.toggle("popped", entry.mode === "floating");
entry.buttonEl.classList.toggle("active", entry.visible === true);
entry.buttonEl.classList.toggle(
"tool-active-hidden",
scope.activeSidebarTab === entry.key && entry.visible !== true,
);
entry.buttonEl.setAttribute("aria-pressed", entry.visible === true ? "true" : "false");
entry.panelEl.classList.remove("hidden");
updateShellPresentation(entry);
});
}
function setActiveTool(key) {
if (typeof scope.setSidebarTab === "function") {
scope.setSidebarTab(key);
} else {
scope.activeSidebarTab = key;
syncPanels();
}
}
function ensureFloatingSize(entry, clientX, clientY) {
if (Number(entry.width) > 0 && Number(entry.height) > 0) {
return;
}
const defaultSize = PANEL_DEFAULT_SIZES[entry.key] || PANEL_DEFAULT_SIZES.tiles;
const minSize = getMinSize(entry.key);
const layerRect = getLayerRect();
const preferredWidth = Math.max(minSize.width, defaultSize.width);
const preferredHeight = Math.max(minSize.height, defaultSize.height);
const relativeX = Number(clientX) - layerRect.left - (preferredWidth / 2);
const relativeY = Number(clientY) - layerRect.top - 18;
const nextRect = clampWindowRect(
Number.isFinite(relativeX) ? relativeX : 350,
Number.isFinite(relativeY) ? relativeY : 90,
preferredWidth,
preferredHeight,
);
entry.x = nextRect.left;
entry.y = nextRect.top;
entry.width = nextRect.width;
entry.height = nextRect.height;
}
function moveEntryToInline(entry, options) {
if (!entry || !entry.shellEl) {
return false;
}
if (entry.mode === "floating") {
const measuredHeight = Math.round(entry.shellEl.getBoundingClientRect().height || 0);
if (measuredHeight > 0) {
entry.inlineHeight = Math.max(180, measuredHeight);
}
}
entry.mode = "inline";
entry.visible = true;
attachShellToInline(entry, options?.clientY);
updateShellPresentation(entry);
syncPanels();
persistPanelState();
return true;
}
function moveEntryToFloating(entry, options) {
if (!entry || !entry.shellEl || !uiScope.toolWindowLayerEl) {
return false;
}
entry.mode = "floating";
entry.visible = true;
ensureFloatingSize(entry, options?.clientX, options?.clientY);
if (Number.isFinite(Number(options?.clientX)) && Number.isFinite(Number(options?.clientY))) {
const layerRect = getLayerRect();
const nextRect = clampWindowRect(
Number(options.clientX) - layerRect.left - (entry.width / 2),
Number(options.clientY) - layerRect.top - 18,
entry.width,
entry.height,
);
entry.x = nextRect.left;
entry.y = nextRect.top;
entry.width = nextRect.width;
entry.height = nextRect.height;
}
attachShellToFloating(entry);
updateShellPresentation(entry);
syncPanels();
focusWindow(entry);
persistPanelState();
return true;
}
function toggleEntryVisibility(entry) {
if (!entry) {
return;
}
entry.visible = entry.visible !== true;
if (entry.visible && entry.mode === "floating") {
focusWindow(entry);
}
syncPanels();
persistPanelState();
}
function createShell(entry) {
const shellEl = document.createElement("div");
shellEl.className = "tool-popout-window";
shellEl.setAttribute("data-panel-key", entry.key);
const titlebarEl = document.createElement("div");
titlebarEl.className = "tool-popout-titlebar";
titlebarEl.innerHTML =
'<div class="tool-popout-title">' + entry.label + "</div>" +
'<div class="tool-popout-hint">Drag into canvas</div>' +
'<button type="button" class="tool-popout-dock-btn" title="Send back to dock" aria-label="Send back to dock">' +
'<span class="tool-popout-dock-icon">|&#8592;</span>' +
"</button>";
const bodyEl = document.createElement("div");
bodyEl.className = "tool-popout-body";
const resizeEl = document.createElement("div");
resizeEl.className = "tool-popout-resize";
const dockBtnEl = titlebarEl.querySelector(".tool-popout-dock-btn");
bodyEl.appendChild(entry.panelEl);
shellEl.appendChild(titlebarEl);
shellEl.appendChild(bodyEl);
shellEl.appendChild(resizeEl);
shellEl.addEventListener("pointerdown", () => {
setActiveTool(entry.key);
focusWindow(entry);
});
titlebarEl.addEventListener("pointerdown", (event) => {
if (dockBtnEl && dockBtnEl.contains(event.target)) {
return;
}
if (event.button !== 0) {
return;
}
event.preventDefault();
setActiveTool(entry.key);
const startX = event.clientX;
const startY = event.clientY;
const startMode = entry.mode;
const originLeft = Number(entry.x) || 0;
const originTop = Number(entry.y) || 0;
let dragArmed = false;
const move = (moveEvent) => {
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY);
if (!dragArmed && distance >= 6) {
dragArmed = true;
if (startMode === "inline") {
moveEntryToFloating(entry, { clientX: moveEvent.clientX, clientY: moveEvent.clientY });
}
}
if (!dragArmed) {
return;
}
if (entry.mode === "floating") {
const nextRect = clampWindowRect(
originLeft + (moveEvent.clientX - startX),
originTop + (moveEvent.clientY - startY),
entry.width,
entry.height,
);
entry.x = nextRect.left;
entry.y = nextRect.top;
applyFloatingRect(entry);
const overDock = pointInsideDock(moveEvent.clientX, moveEvent.clientY);
const overSidebar = !overDock && pointInsideSidebarBody(moveEvent.clientX, moveEvent.clientY);
setDockTargetActive(overDock);
setSidebarBodyTargetActive(overSidebar);
} else if (entry.mode === "inline") {
setSidebarBodyTargetActive(pointInsideSidebarBody(moveEvent.clientX, moveEvent.clientY));
}
};
const up = (upEvent) => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
if (!dragArmed) {
clearDockHighlights();
return;
}
if (entry.mode === "floating") {
if (pointInsideDock(upEvent.clientX, upEvent.clientY) || pointInsideSidebarBody(upEvent.clientX, upEvent.clientY)) {
moveEntryToInline(entry, { clientY: upEvent.clientY });
}
} else if (entry.mode === "inline") {
if (pointInsideStage(upEvent.clientX, upEvent.clientY)) {
moveEntryToFloating(entry, { clientX: upEvent.clientX, clientY: upEvent.clientY });
} else if (pointInsideSidebarBody(upEvent.clientX, upEvent.clientY)) {
attachShellToInline(entry, upEvent.clientY);
syncPanels();
}
}
clearDockHighlights();
persistPanelState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
dockBtnEl?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
setActiveTool(entry.key);
moveEntryToInline(entry, {});
});
resizeEl.addEventListener("pointerdown", (event) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
setActiveTool(entry.key);
if (entry.mode === "floating") {
focusWindow(entry);
}
const startX = event.clientX;
const startY = event.clientY;
const originWidth = Number(entry.width) || 0;
const originHeight = Number(entry.height) || 0;
const originInlineHeight = Number(entry.inlineHeight) > 0
? Number(entry.inlineHeight)
: Math.round(entry.shellEl.getBoundingClientRect().height);
const minSize = getMinSize(entry.key);
const move = (moveEvent) => {
if (entry.mode === "floating") {
const nextRect = clampWindowRect(
entry.x,
entry.y,
Math.max(minSize.width, originWidth + (moveEvent.clientX - startX)),
Math.max(minSize.height, originHeight + (moveEvent.clientY - startY)),
);
entry.width = nextRect.width;
entry.height = nextRect.height;
applyFloatingRect(entry);
return;
}
const sidebarBodyHeight = Math.round(uiScope.sidebarPanelsHostEl?.clientHeight || 0);
const maxInlineHeight = Math.max(180, sidebarBodyHeight || originInlineHeight || 180);
entry.inlineHeight = Math.max(
180,
Math.min(maxInlineHeight, originInlineHeight + (moveEvent.clientY - startY)),
);
updateShellPresentation(entry);
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
persistPanelState();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
entry.shellEl = shellEl;
entry.bodyEl = bodyEl;
entry.titlebarEl = titlebarEl;
entry.resizeEl = resizeEl;
entry.dockBtnEl = dockBtnEl;
return shellEl;
}
function handleTabButtonClick(key) {
const normalizedKey = String(key || "").trim();
if (!normalizedKey) {
return;
}
if (suppressedClickKey === normalizedKey) {
suppressedClickKey = "";
return;
}
const entry = getEntry(normalizedKey);
if (!entry) {
return;
}
setActiveTool(normalizedKey);
toggleEntryVisibility(entry);
}
function restoreAllWindows() {
panelEntries.forEach((entry, index) => {
entry.mode = "inline";
entry.visible = DEFAULT_VISIBLE_PANELS.has(entry.key) || scope.activeSidebarTab === entry.key;
entry.x = 0;
entry.y = 0;
entry.width = 0;
entry.height = 0;
entry.inlineHeight = 0;
entry.order = index;
attachShellToInline(entry);
});
syncPanels();
persistPanelState();
if (typeof scope.setStatus === "function") {
scope.setStatus("Restored the default tool window layout.", false);
}
}
function bindTabDrag(entry) {
if (!entry?.buttonEl) {
return;
}
entry.buttonEl.addEventListener("pointerdown", (event) => {
if (event.button !== 0) {
return;
}
const startX = event.clientX;
const startY = event.clientY;
let dragArmed = false;
entry.buttonEl.classList.add("drag-armed");
const move = (moveEvent) => {
if (dragArmed) {
return;
}
const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY);
if (distance >= 8) {
dragArmed = true;
}
};
const up = (upEvent) => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
entry.buttonEl.classList.remove("drag-armed");
if (!dragArmed) {
return;
}
suppressedClickKey = entry.key;
window.setTimeout(() => {
if (suppressedClickKey === entry.key) {
suppressedClickKey = "";
}
}, 0);
setActiveTool(entry.key);
if (pointInsideStage(upEvent.clientX, upEvent.clientY)) {
moveEntryToFloating(entry, { clientX: upEvent.clientX, clientY: upEvent.clientY });
return;
}
if (pointInsideSidebarBody(upEvent.clientX, upEvent.clientY)) {
moveEntryToInline(entry, { clientY: upEvent.clientY });
return;
}
toggleEntryVisibility(entry);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
}
function initialize() {
if (initialized) {
return;
}
initialized = true;
panelEntries.forEach((entry) => {
entry.panelEl.setAttribute("data-panel-key", entry.key);
entry.panelEl.classList.remove("hidden");
createShell(entry);
if (entry.mode === "floating") {
ensureFloatingSize(entry);
const nextRect = clampWindowRect(entry.x, entry.y, entry.width, entry.height);
entry.x = nextRect.left;
entry.y = nextRect.top;
entry.width = nextRect.width;
entry.height = nextRect.height;
attachShellToFloating(entry);
} else {
attachShellToInline(entry);
}
bindTabDrag(entry);
});
window.addEventListener("resize", () => {
panelEntries.forEach((entry) => {
if (!entry.shellEl) {
return;
}
if (entry.mode === "floating") {
const nextRect = clampWindowRect(entry.x, entry.y, entry.width, entry.height);
entry.x = nextRect.left;
entry.y = nextRect.top;
entry.width = nextRect.width;
entry.height = nextRect.height;
applyFloatingRect(entry);
return;
}
if (Number(entry.inlineHeight) > 0) {
const maxInlineHeight = Math.max(180, Math.round(uiScope.sidebarPanelsHostEl?.clientHeight || 0) || 180);
entry.inlineHeight = Math.min(Number(entry.inlineHeight), maxInlineHeight);
updateShellPresentation(entry);
}
});
persistPanelState();
});
syncPanels();
}
return {
initialize,
syncPanels,
handleTabButtonClick,
restoreAllWindows,
isPanelFloating: (key) => getEntry(key)?.mode === "floating",
};
}

View file

@ -0,0 +1,331 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
export function createAtTooltip() {
class AtTooltip {
constructor() {
this._panels = [];
this._mouseHandler = null;
this._keyHandler = null;
this._repositionHandler = null;
}
open(anchorEl, builderFn, tag) {
if (this.isOpenFor(tag)) {
this.close();
return;
}
this.close();
const panelEntry = this._createPanelEntry({
anchorEl,
builderFn,
mode: "anchor",
point: null,
rootTag: tag || null,
tag: tag || null,
parentIndex: -1,
});
this._panels = [panelEntry];
this._positionAll();
this._wireCloseHandlers();
this._focusPanelSoon(panelEntry.panel);
}
openAtPoint(clientX, clientY, builderFn, tag) {
if (this.isOpenFor(tag)) {
this.close();
return;
}
this.close();
const panelEntry = this._createPanelEntry({
anchorEl: null,
builderFn,
mode: "point",
point: {
x: Number(clientX) || 0,
y: Number(clientY) || 0,
},
rootTag: tag || null,
tag: tag || null,
parentIndex: -1,
});
this._panels = [panelEntry];
this._positionAll();
this._wireCloseHandlers();
this._focusPanelSoon(panelEntry.panel);
}
openChild(anchorEl, builderFn, tag) {
if (!anchorEl) {
return false;
}
const parentIndex = this._findOwningPanelIndex(anchorEl);
if (parentIndex < 0) {
return false;
}
const parentEntry = this._panels[parentIndex] || null;
if (!parentEntry) {
return false;
}
const normalizedTag = tag || null;
const existingChild = this._panels[parentIndex + 1] || null;
if (
existingChild
&& existingChild.parentIndex === parentIndex
&& existingChild.anchorEl === anchorEl
&& existingChild.tag === normalizedTag
) {
this._positionAll();
return true;
}
this._closeFromIndex(parentIndex + 1);
const panelEntry = this._createPanelEntry({
anchorEl,
builderFn,
mode: "submenu",
point: null,
rootTag: parentEntry.rootTag,
tag: normalizedTag,
parentIndex,
});
this._panels.push(panelEntry);
this._positionAll();
return true;
}
closeChildren(anchorEl) {
if (!anchorEl) {
return false;
}
const parentIndex = this._findOwningPanelIndex(anchorEl);
if (parentIndex < 0) {
return false;
}
this._closeFromIndex(parentIndex + 1);
return true;
}
_createPanelEntry(config) {
const panel = document.createElement("div");
panel.className = "at-tooltip-panel" + (config.mode === "submenu" ? " is-submenu" : "");
panel.tabIndex = -1;
panel.setAttribute("role", "menu");
config.builderFn(panel);
document.body.appendChild(panel);
return {
panel,
anchorEl: config.anchorEl || null,
point: config.point || null,
rootTag: config.rootTag || null,
tag: config.tag || null,
mode: config.mode || "anchor",
parentIndex: Number(config.parentIndex),
};
}
_focusPanelSoon(panel) {
setTimeout(() => {
if (this._panels.some((entry) => entry.panel === panel)) {
panel.focus();
}
}, 0);
}
_findOwningPanelIndex(element) {
return this._panels.findIndex((entry) => (
entry.panel === element
|| (entry.panel && typeof entry.panel.contains === "function" && entry.panel.contains(element))
));
}
_wireCloseHandlers() {
if (this._mouseHandler || this._keyHandler || this._repositionHandler) {
return;
}
this._mouseHandler = (event) => {
const target = event.target;
const clickedOpenPanel = this._panels.some((entry) => entry.panel?.contains?.(target));
const clickedAnchor = this._panels.some((entry) => (
entry.anchorEl
&& (target === entry.anchorEl || entry.anchorEl.contains?.(target))
));
if (!clickedOpenPanel && !clickedAnchor) {
this.close();
}
};
this._keyHandler = (event) => {
if (event.key === "Escape") {
this.close();
}
};
this._repositionHandler = () => {
this._positionAll();
};
setTimeout(() => {
document.addEventListener("mousedown", this._mouseHandler, { capture: true });
document.addEventListener("keydown", this._keyHandler);
document.addEventListener("scroll", this._repositionHandler, { capture: true, passive: true });
window.addEventListener("resize", this._repositionHandler);
}, 0);
}
_closeFromIndex(startIndex) {
while (this._panels.length > startIndex) {
const entry = this._panels.pop();
if (entry?.panel?.parentNode) {
entry.panel.parentNode.removeChild(entry.panel);
}
}
if (this._panels.length <= 0) {
this._teardownHandlers();
}
}
_teardownHandlers() {
if (this._mouseHandler) {
document.removeEventListener("mousedown", this._mouseHandler, { capture: true });
this._mouseHandler = null;
}
if (this._keyHandler) {
document.removeEventListener("keydown", this._keyHandler);
this._keyHandler = null;
}
if (this._repositionHandler) {
document.removeEventListener("scroll", this._repositionHandler, { capture: true });
window.removeEventListener("resize", this._repositionHandler);
this._repositionHandler = null;
}
}
_positionAll() {
this._panels.forEach((entry, index) => {
this._positionPanel(entry, index);
});
}
_positionPanel(entry) {
if (!entry?.panel) {
return;
}
const panelRect = entry.panel.getBoundingClientRect();
const panelW = Math.max(190, Math.ceil(panelRect.width) || 230);
const panelH = Math.max(32, Math.ceil(panelRect.height) || 32);
const vw = window.innerWidth;
const vh = window.innerHeight;
let preferredTop = 8;
let preferredLeft = 8;
if (entry.mode === "submenu" && entry.anchorEl) {
const anchorRect = entry.anchorEl.getBoundingClientRect();
preferredLeft = anchorRect.right + 6;
preferredTop = anchorRect.top - 6;
if (preferredLeft + panelW > vw - 8) {
preferredLeft = anchorRect.left - panelW - 6;
}
} else if (entry.anchorEl) {
const anchorRect = entry.anchorEl.getBoundingClientRect();
preferredLeft = anchorRect.left;
preferredTop = anchorRect.bottom + 6;
if (preferredTop + panelH > vh - 8) {
preferredTop = anchorRect.top - panelH - 6;
}
} else if (entry.point) {
preferredLeft = entry.point.x;
preferredTop = entry.point.y + 6;
if (preferredTop + panelH > vh - 8) {
preferredTop = entry.point.y - panelH - 6;
}
}
let left = preferredLeft;
let top = preferredTop;
if (left + panelW > vw - 8) {
left = Math.max(8, vw - panelW - 8);
}
if (top + panelH > vh - 8) {
top = Math.max(8, vh - panelH - 8);
}
if (top < 8) {
top = 8;
}
entry.panel.style.left = `${left}px`;
entry.panel.style.top = `${top}px`;
entry.panel.style.maxHeight = `${Math.max(120, vh - top - 8)}px`;
}
close() {
this._closeFromIndex(0);
this._panels = [];
this._teardownHandlers();
}
isOpenFor(tag) {
if (!tag) {
return false;
}
return this._panels.some((entry) => entry?.tag === tag || entry?.rootTag === tag);
}
makeItem(innerHtml, onClick, extraClass, options) {
const btn = document.createElement("button");
btn.type = "button";
const presentationClass = String(options?.presentation || "").trim().toLowerCase() === "icon"
? " at-tooltip-icon-item"
: "";
btn.className = "at-tooltip-item" + presentationClass + (extraClass ? ` ${extraClass}` : "");
btn.setAttribute("role", "menuitem");
btn.innerHTML = innerHtml;
if (options?.title) {
btn.title = String(options.title);
}
if (options?.ariaLabel) {
btn.setAttribute("aria-label", String(options.ariaLabel));
}
if (options && options.disabled) {
btn.disabled = true;
} else {
btn.addEventListener("click", () => {
onClick();
this.close();
});
}
return btn;
}
makeSubmenuItem(innerHtml, extraClass, options) {
const btn = document.createElement("button");
btn.type = "button";
const presentationClass = String(options?.presentation || "").trim().toLowerCase() === "icon"
? " at-tooltip-icon-item"
: "";
btn.className = `at-tooltip-item has-submenu${presentationClass}${extraClass ? ` ${extraClass}` : ""}`;
btn.setAttribute("role", "menuitem");
btn.innerHTML = `${String(innerHtml || "")}<span class="at-tooltip-submenu-arrow" aria-hidden="true"></span>`;
if (options?.title) {
btn.title = String(options.title);
}
if (options?.ariaLabel) {
btn.setAttribute("aria-label", String(options.ariaLabel));
}
if (options && options.disabled) {
btn.disabled = true;
}
return btn;
}
makeLabel(text) {
const el = document.createElement("div");
el.className = "at-tooltip-label";
el.textContent = String(text || "");
return el;
}
makeSeparator() {
const el = document.createElement("div");
el.className = "at-tooltip-separator";
return el;
}
}
return new AtTooltip();
}

View file

@ -0,0 +1,209 @@
export type PopupBounds = {
left: number;
top: number;
width: number;
height: number;
};
export const MAP_EDITOR_POPUP_WINDOW_NAME = "new-rpg-room-editor";
export const MAP_EDITOR_POPUP_BOUNDS_STORAGE_KEY = "content-editor-v2:map-editor-popup-bounds";
export const MAP_HEIGHT_VIEWER_WINDOW_NAME = "new-rpg-map-height-viewer";
export const MAP_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY = "content-editor-v2:map-height-viewer-bounds";
export function buildStandaloneMapEditorUrl(mapId: string, hostWindow: Window = window, options?: { worldId?: string }): string {
const popupUrl = new URL(`${import.meta.env.BASE_URL}map-editor-popup.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 buildStandaloneMapHeightViewerUrl(mapId: string, token = "", hostWindow: Window = window): string {
const popupUrl = new URL(`${import.meta.env.BASE_URL}map-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 getCenteredMapEditorPopupBounds(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 getCenteredMapHeightViewerBounds(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 readMapEditorPopupBounds(hostWindow: Window = window): PopupBounds {
try {
const raw = hostWindow.localStorage.getItem(MAP_EDITOR_POPUP_BOUNDS_STORAGE_KEY);
if (!raw) {
return getCenteredMapEditorPopupBounds(hostWindow);
}
const parsed = JSON.parse(raw) as Partial<PopupBounds>;
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 getCenteredMapEditorPopupBounds(hostWindow);
}
return { left, top, width, height };
} catch {
return getCenteredMapEditorPopupBounds(hostWindow);
}
}
export function readMapHeightViewerBounds(hostWindow: Window = window): PopupBounds {
try {
const raw = hostWindow.localStorage.getItem(MAP_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY);
if (!raw) {
return getCenteredMapHeightViewerBounds(hostWindow);
}
const parsed = JSON.parse(raw) as Partial<PopupBounds>;
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 getCenteredMapHeightViewerBounds(hostWindow);
}
return { left, top, width, height };
} catch {
return getCenteredMapHeightViewerBounds(hostWindow);
}
}
export function persistMapEditorPopupBounds(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(
MAP_EDITOR_POPUP_BOUNDS_STORAGE_KEY,
JSON.stringify({ left, top, width, height }),
);
} catch {
// Ignore storage and same-origin failures.
}
}
export function persistMapHeightViewerBounds(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(
MAP_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY,
JSON.stringify({ left, top, width, height }),
);
} catch {
// Ignore storage and same-origin failures.
}
}
export function openStandaloneMapEditorPopup(
mapId: string,
hostWindow: Window = window,
options?: { worldId?: string },
): Window | null {
const popupUrl = buildStandaloneMapEditorUrl(mapId, hostWindow, options);
const initialBounds = readMapEditorPopupBounds(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, MAP_EDITOR_POPUP_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 openStandaloneMapHeightViewer(mapId: string, token = "", hostWindow: Window = window): Window | null {
const popupUrl = buildStandaloneMapHeightViewerUrl(mapId, token, hostWindow);
const initialBounds = readMapHeightViewerBounds(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, MAP_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;
}

File diff suppressed because it is too large Load diff

705
src/mapHeightViewer/main.ts Normal file
View file

@ -0,0 +1,705 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import type { MapEditorPopupBootstrap } from "../mapEditorPopup/bootstrap";
import {
loadMapEditorPopupBootstrap,
loadStandaloneWorldEditorPopupBootstrap,
} from "../mapEditorPopup/bootstrap";
import { persistMapHeightViewerBounds } from "../mapEditorPopup/windowing";
import { createDebouncedCallback } from "../mapEditorPopup/debounce";
const VIEWER_STYLE_ID = "map-height-viewer-styles";
function ensureStyles(): void {
let styleEl = document.getElementById(VIEWER_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = VIEWER_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = `
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body {
margin: 0;
width: 100%;
height: 100%;
background: #07111f;
color: #d8e8ff;
font-family: Segoe UI, Arial, sans-serif;
}
.viewer-shell {
display: grid;
grid-template-rows: 52px 1fr;
width: 100vw;
height: 100vh;
}
.viewer-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid #274472;
background: linear-gradient(180deg, #152645 0%, #0d1b33 100%);
}
.viewer-title {
min-width: 0;
display: grid;
gap: 2px;
}
.viewer-title strong {
font-size: 14px;
color: #eef6ff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.viewer-title span {
font-size: 11px;
color: #9fb8e5;
}
.viewer-controls {
display: inline-flex;
align-items: center;
gap: 8px;
}
.viewer-btn {
height: 34px;
padding: 0 12px;
border: 1px solid #3c5e95;
border-radius: 8px;
background: #1a345e;
color: #d6e7ff;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.viewer-btn:hover {
background: #214679;
}
.viewer-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
background: #132643;
}
.viewer-height-pill {
min-width: 96px;
height: 34px;
padding: 0 12px;
border: 1px solid #3c5e95;
border-radius: 999px;
background: #10284b;
color: #d6e7ff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.viewer-hint {
font-size: 11px;
color: #9fb8e5;
white-space: nowrap;
}
.viewer-viewport {
position: relative;
min-width: 0;
min-height: 0;
overflow: auto;
background:
linear-gradient(180deg, rgba(20, 38, 69, 0.16), rgba(7, 17, 31, 0.04)),
#07111f;
}
.viewer-viewport-layer {
position: sticky;
top: 0;
left: 0;
width: 100%;
height: 0;
overflow: visible;
z-index: 1;
pointer-events: none;
}
.viewer-viewport-layer canvas {
display: block;
pointer-events: none;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.viewer-viewport-spacer {
position: relative;
z-index: 0;
pointer-events: none;
}
`;
}
function renderMessage(title: string, message: string): void {
document.body.innerHTML = "";
document.body.style.margin = "0";
document.body.style.minHeight = "100vh";
document.body.style.display = "grid";
document.body.style.placeItems = "center";
document.body.style.background = "#07111f";
document.body.style.color = "#d8e8ff";
document.body.style.fontFamily = "Segoe UI, Arial, sans-serif";
const panel = document.createElement("div");
panel.style.maxWidth = "460px";
panel.style.padding = "24px";
panel.style.border = "1px solid #2e426c";
panel.style.borderRadius = "10px";
panel.style.background = "#0e1a33";
panel.style.boxShadow = "0 12px 36px rgba(3, 8, 18, 0.45)";
const heading = document.createElement("h1");
heading.textContent = title;
heading.style.margin = "0 0 8px";
heading.style.fontSize = "18px";
const text = document.createElement("p");
text.textContent = message;
text.style.margin = "0";
text.style.fontSize = "14px";
text.style.lineHeight = "1.5";
panel.appendChild(heading);
panel.appendChild(text);
document.body.appendChild(panel);
}
function renderLoading(message: string): void {
renderMessage("Loading height viewer", message);
}
function renderError(message: string): void {
renderMessage("Height viewer unavailable", message);
}
function cloneValue<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function buildViewerMarkup(): string {
return `
<div class="viewer-shell">
<div class="viewer-bar">
<div class="viewer-title">
<strong id="viewerTitle">Height Viewer</strong>
<span id="viewerMeta">Previewing current world snapshot.</span>
</div>
<div class="viewer-controls">
<button class="viewer-btn" id="heightDownBtn" type="button">Height -</button>
<div class="viewer-height-pill" id="heightLabel">Height 0</div>
<button class="viewer-btn" id="heightUpBtn" type="button">Height +</button>
</div>
<div class="viewer-hint">Use Up / Down arrows to change height.</div>
</div>
<div class="viewer-viewport" id="viewerViewport">
<div class="viewer-viewport-layer">
<canvas id="viewerCanvas"></canvas>
</div>
<div class="viewer-viewport-spacer" id="viewerViewportSpacer" aria-hidden="true"></div>
</div>
</div>
`;
}
function startViewer(bootstrap: MapEditorPopupBootstrap): void {
document.body.removeAttribute("style");
document.body.innerHTML = buildViewerMarkup();
document.title = "Height Viewer - " + (bootstrap.mapName || bootstrap.mapId || "Untitled");
const titleEl = document.getElementById("viewerTitle");
const metaEl = document.getElementById("viewerMeta");
const heightLabelEl = document.getElementById("heightLabel");
const heightDownBtn = document.getElementById("heightDownBtn") as HTMLButtonElement | null;
const heightUpBtn = document.getElementById("heightUpBtn") as HTMLButtonElement | null;
const viewportEl = document.getElementById("viewerViewport") as HTMLDivElement | null;
const viewportSpacerEl = document.getElementById("viewerViewportSpacer") as HTMLDivElement | null;
const canvasEl = document.getElementById("viewerCanvas") as HTMLCanvasElement | null;
const ctx = canvasEl?.getContext("2d") || null;
if (!viewportEl || !viewportSpacerEl || !canvasEl || !ctx) {
renderError("The height viewer could not initialize its canvas.");
return;
}
const mapWidth = Math.max(1, Number(bootstrap.width) || 1);
const mapHeight = Math.max(1, Number(bootstrap.height) || 1);
const tileSize = Math.max(8, Number(bootstrap.tileSize) || 32);
const worldPixelWidth = mapWidth * tileSize;
const worldPixelHeight = mapHeight * tileSize;
const backgroundColor = /^#[0-9a-fA-F]{6}$/.test(String(bootstrap.backgroundColor || "").trim())
? String(bootstrap.backgroundColor).trim().toUpperCase()
: "#060A14";
const layers = Array.isArray(bootstrap.roomLayers)
? cloneValue(bootstrap.roomLayers).map((layer) => ({
layer: Number(layer.layer) || 0,
name: typeof layer.name === "string" ? layer.name.trim() : "",
rows: Array.isArray(layer.rows) ? layer.rows.map((row) => String(row || "")) : [],
})).sort((a, b) => a.layer - b.layer)
: [];
const heightLayers = Array.isArray(bootstrap.heightLayers)
? cloneValue(bootstrap.heightLayers).map((entry, index) => {
const rows = Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "").replace(/\./g, " ")) : [];
const width = rows.reduce((max, row) => Math.max(max, row.length), 0);
const height = rows.length;
const x = Math.max(0, Number(entry?.x) || 0);
const y = Math.max(0, Number(entry?.y) || 0);
return {
id: String(entry?.id || ("height_" + String(index + 1))).trim() || ("height_" + String(index + 1)),
name: typeof entry?.name === "string" ? entry.name.trim() : "",
z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
x,
y,
rows,
width,
height,
pixelX: x * tileSize,
pixelY: y * tileSize,
pixelWidth: width * tileSize,
pixelHeight: height * tileSize,
};
})
: [];
const heightLayersByZ = new Map<number, Array<Record<string, unknown>>>();
heightLayers.forEach((entry) => {
const z = Math.max(1, Number(entry.z) || 1);
if (!heightLayersByZ.has(z)) {
heightLayersByZ.set(z, []);
}
heightLayersByZ.get(z)?.push(entry);
});
const maxHeight = heightLayers.reduce((max, entry) => Math.max(max, Math.max(1, Number(entry?.z) || 1)), 0);
const tileCatalogBySymbol: Record<string, {
symbol: string;
color: string;
dataUrl: string | null;
}> = {};
Object.entries(bootstrap.tileColors || {}).forEach(([symbol, color]) => {
tileCatalogBySymbol[symbol] = {
symbol,
color: String(color || "#7AA7FF"),
dataUrl: null,
};
});
Object.values(bootstrap.tileCatalogById || {}).forEach((entry) => {
const symbol = String(entry?.symbol || "").charAt(0);
if (!symbol) {
return;
}
tileCatalogBySymbol[symbol] = {
symbol,
color: String(entry?.color || tileCatalogBySymbol[symbol]?.color || "#7AA7FF"),
dataUrl: entry?.dataUrl || null,
};
});
const backgroundTileId = String(bootstrap.backgroundTileId || "").trim();
const backgroundSymbol = backgroundTileId
? String(bootstrap.tileCatalogById?.[backgroundTileId]?.symbol || ".").charAt(0) || "."
: "";
const imageCache: Record<string, HTMLImageElement> = {};
const patchSurfaceCache = new Map<string, HTMLCanvasElement>();
const baseSurfaceCanvas = document.createElement("canvas");
const baseSurfaceCtx = baseSurfaceCanvas.getContext("2d");
const baseSurfaceState = {
dirty: true,
width: 0,
height: 0,
viewportLeft: -1,
viewportTop: -1,
tileSize: 0,
};
const state = {
currentHeight: 0,
pendingDrawFrame: 0,
};
const heightBlurStep = Math.max(0, Math.min(1, Number(bootstrap.heightBlurStep ?? bootstrap.heightDetailStep) || 0.1));
function clampViewerHeight(value: unknown): number {
return Math.max(0, Math.min(maxHeight, Number(value) || 0));
}
function getHeightBlurStrength(height: number): number {
const normalizedHeight = Math.max(0, Number(height) || 0);
return Math.min(8, normalizedHeight * heightBlurStep * (tileSize / 4));
}
function syncViewportDimensions(): void {
const nextCanvasWidth = Math.max(1, Math.ceil(Number(viewportEl.clientWidth) || 0));
const nextCanvasHeight = Math.max(1, Math.ceil(Number(viewportEl.clientHeight) || 0));
if (canvasEl.width !== nextCanvasWidth || canvasEl.height !== nextCanvasHeight) {
canvasEl.width = nextCanvasWidth;
canvasEl.height = nextCanvasHeight;
}
canvasEl.style.width = nextCanvasWidth + "px";
canvasEl.style.height = nextCanvasHeight + "px";
viewportSpacerEl.style.width = Math.max(nextCanvasWidth, worldPixelWidth) + "px";
viewportSpacerEl.style.height = Math.max(nextCanvasHeight, worldPixelHeight) + "px";
}
function getViewportRenderRect() {
const viewportWidth = Math.max(1, Math.ceil(Number(viewportEl.clientWidth) || 0));
const viewportHeight = Math.max(1, Math.ceil(Number(viewportEl.clientHeight) || 0));
const left = Math.max(0, Math.min(worldPixelWidth, Math.floor(Number(viewportEl.scrollLeft) || 0)));
const top = Math.max(0, Math.min(worldPixelHeight, Math.floor(Number(viewportEl.scrollTop) || 0)));
const right = Math.max(left + 1, Math.min(worldPixelWidth, Math.ceil((Number(viewportEl.scrollLeft) || 0) + viewportWidth)));
const bottom = Math.max(top + 1, Math.min(worldPixelHeight, Math.ceil((Number(viewportEl.scrollTop) || 0) + viewportHeight)));
return {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
};
}
function rectIntersects(rect, x, y, width, height): boolean {
return x + width > rect.left && x < rect.right && y + height > rect.top && y < rect.bottom;
}
function updateHeightLabel(): void {
if (heightLabelEl) {
heightLabelEl.textContent = "Height " + state.currentHeight;
}
if (heightDownBtn) {
heightDownBtn.disabled = state.currentHeight <= 0;
}
if (heightUpBtn) {
heightUpBtn.disabled = state.currentHeight >= maxHeight;
}
}
function invalidateBaseSurface(): void {
baseSurfaceState.dirty = true;
baseSurfaceState.viewportLeft = -1;
baseSurfaceState.viewportTop = -1;
baseSurfaceState.tileSize = 0;
}
function drawSymbolAtPixel(targetCtx: CanvasRenderingContext2D, symbol: string, drawX: number, drawY: number): void {
const tileEntry = tileCatalogBySymbol[symbol] || tileCatalogBySymbol["."] || { color: "#7AA7FF", dataUrl: null };
const img = getTileImage(symbol);
if (img && img.complete && img.naturalWidth > 0) {
targetCtx.drawImage(img, drawX, drawY, tileSize, tileSize);
return;
}
targetCtx.fillStyle = tileEntry.color || "#7AA7FF";
targetCtx.fillRect(drawX, drawY, tileSize, tileSize);
}
function getTileImage(symbol: string): HTMLImageElement | null {
const tileEntry = tileCatalogBySymbol[symbol];
if (!tileEntry?.dataUrl) {
return null;
}
if (imageCache[symbol]) {
return imageCache[symbol];
}
const img = new Image();
img.src = tileEntry.dataUrl;
img.onload = () => {
patchSurfaceCache.clear();
invalidateBaseSurface();
draw();
};
imageCache[symbol] = img;
return img;
}
function drawVisibleBaseTiles(targetCtx: CanvasRenderingContext2D, viewportRect): void {
const startTileX = Math.max(0, Math.floor(viewportRect.left / tileSize));
const endTileX = Math.min(mapWidth - 1, Math.ceil(viewportRect.right / tileSize));
const startTileY = Math.max(0, Math.floor(viewportRect.top / tileSize));
const endTileY = Math.min(mapHeight - 1, Math.ceil(viewportRect.bottom / tileSize));
targetCtx.save();
targetCtx.setTransform(1, 0, 0, 1, -viewportRect.left, -viewportRect.top);
targetCtx.imageSmoothingEnabled = false;
layers.forEach((layer) => {
const isBackgroundLayer = (Number(layer.layer) || 0) === 0;
const fillChar = isBackgroundLayer ? "." : " ";
const rows = Array.isArray(layer.rows) ? layer.rows : [];
for (let tileY = startTileY; tileY <= endTileY; tileY += 1) {
const row = String(rows[tileY] || "");
for (let tileX = startTileX; tileX <= endTileX; tileX += 1) {
let ch = row.charAt(tileX) || fillChar;
if (isBackgroundLayer && ch === " ") {
continue;
}
if (isBackgroundLayer && ch === "." && backgroundSymbol) {
ch = backgroundSymbol;
}
if (!isBackgroundLayer && ch === " ") {
continue;
}
if (ch === ".") {
continue;
}
drawSymbolAtPixel(targetCtx, ch, tileX * tileSize, tileY * tileSize);
}
}
});
targetCtx.restore();
}
function baseSurfaceNeedsRefresh(viewportRect, canvasWidth: number, canvasHeight: number): boolean {
if (baseSurfaceState.dirty || baseSurfaceState.width !== canvasWidth || baseSurfaceState.height !== canvasHeight) {
return true;
}
if (baseSurfaceState.viewportLeft !== viewportRect.left || baseSurfaceState.viewportTop !== viewportRect.top) {
return true;
}
if (baseSurfaceState.tileSize !== tileSize) {
return true;
}
return false;
}
function refreshBaseSurface(viewportRect, canvasWidth: number, canvasHeight: number): void {
if (!baseSurfaceCtx) {
return;
}
if (baseSurfaceCanvas.width !== canvasWidth || baseSurfaceCanvas.height !== canvasHeight) {
baseSurfaceCanvas.width = canvasWidth;
baseSurfaceCanvas.height = canvasHeight;
}
baseSurfaceCtx.setTransform(1, 0, 0, 1, 0, 0);
baseSurfaceCtx.clearRect(0, 0, canvasWidth, canvasHeight);
drawVisibleBaseTiles(baseSurfaceCtx, viewportRect);
baseSurfaceState.width = canvasWidth;
baseSurfaceState.height = canvasHeight;
baseSurfaceState.viewportLeft = viewportRect.left;
baseSurfaceState.viewportTop = viewportRect.top;
baseSurfaceState.tileSize = tileSize;
baseSurfaceState.dirty = false;
}
function getOrBuildPatchSurface(entry): HTMLCanvasElement | null {
if (!entry || entry.pixelWidth <= 0 || entry.pixelHeight <= 0) {
return null;
}
const entryId = String(entry.id || "").trim();
if (!entryId) {
return null;
}
const cached = patchSurfaceCache.get(entryId);
if (cached) {
return cached;
}
const surface = document.createElement("canvas");
surface.width = Math.max(1, Number(entry.pixelWidth) || 1);
surface.height = Math.max(1, Number(entry.pixelHeight) || 1);
const surfaceCtx = surface.getContext("2d");
if (!surfaceCtx) {
return null;
}
surfaceCtx.imageSmoothingEnabled = false;
const rows = Array.isArray(entry.rows) ? entry.rows : [];
rows.forEach((rawRow, localY) => {
const row = String(rawRow || "");
for (let localX = 0; localX < row.length; localX += 1) {
const symbol = String(row.charAt(localX) || " ").charAt(0) || " ";
if (symbol === " " || symbol === ".") {
continue;
}
drawSymbolAtPixel(surfaceCtx, symbol, localX * tileSize, localY * tileSize);
}
});
patchSurfaceCache.set(entryId, surface);
return surface;
}
function drawVisibleHeightPatches(viewportRect): void {
const visibleEntries = heightLayersByZ.get(state.currentHeight) || [];
if (visibleEntries.length <= 0) {
return;
}
ctx.save();
ctx.imageSmoothingEnabled = false;
visibleEntries.forEach((entry) => {
const patchPixelX = Math.max(0, Number(entry.pixelX) || 0);
const patchPixelY = Math.max(0, Number(entry.pixelY) || 0);
const patchPixelWidth = Math.max(0, Number(entry.pixelWidth) || 0);
const patchPixelHeight = Math.max(0, Number(entry.pixelHeight) || 0);
if (patchPixelWidth <= 0 || patchPixelHeight <= 0) {
return;
}
if (!rectIntersects(viewportRect, patchPixelX, patchPixelY, patchPixelWidth, patchPixelHeight)) {
return;
}
const surface = getOrBuildPatchSurface(entry);
if (!surface) {
return;
}
const cropLeft = Math.max(viewportRect.left, patchPixelX);
const cropTop = Math.max(viewportRect.top, patchPixelY);
const cropRight = Math.min(viewportRect.right, patchPixelX + patchPixelWidth);
const cropBottom = Math.min(viewportRect.bottom, patchPixelY + patchPixelHeight);
const sourceX = cropLeft - patchPixelX;
const sourceY = cropTop - patchPixelY;
const drawWidth = cropRight - cropLeft;
const drawHeight = cropBottom - cropTop;
if (drawWidth <= 0 || drawHeight <= 0) {
return;
}
ctx.drawImage(
surface,
sourceX,
sourceY,
drawWidth,
drawHeight,
cropLeft - viewportRect.left,
cropTop - viewportRect.top,
drawWidth,
drawHeight,
);
});
ctx.restore();
}
function performDraw(): void {
syncViewportDimensions();
const canvasWidth = Math.max(1, canvasEl.width || Math.ceil(Number(viewportEl.clientWidth) || 0));
const canvasHeight = Math.max(1, canvasEl.height || Math.ceil(Number(viewportEl.clientHeight) || 0));
const viewportRect = getViewportRenderRect();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
if (baseSurfaceNeedsRefresh(viewportRect, canvasWidth, canvasHeight)) {
refreshBaseSurface(viewportRect, canvasWidth, canvasHeight);
}
if (baseSurfaceCanvas.width > 0 && baseSurfaceCanvas.height > 0) {
ctx.save();
if (state.currentHeight > 0) {
const blurStrength = getHeightBlurStrength(state.currentHeight);
ctx.globalAlpha = Math.max(0.95, 1 - (state.currentHeight * 0.01));
ctx.filter = blurStrength > 0 ? `blur(${blurStrength}px)` : "none";
ctx.imageSmoothingEnabled = blurStrength > 0;
ctx.drawImage(baseSurfaceCanvas, 0, 0);
} else {
ctx.globalAlpha = 1;
ctx.filter = "none";
ctx.imageSmoothingEnabled = false;
ctx.drawImage(baseSurfaceCanvas, 0, 0);
}
ctx.restore();
if (state.currentHeight > 0) {
ctx.save();
ctx.fillStyle = "rgba(7, 12, 20, 0.06)";
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
ctx.restore();
}
}
drawVisibleHeightPatches(viewportRect);
updateHeightLabel();
}
function drawNow(): void {
if (state.pendingDrawFrame) {
window.cancelAnimationFrame(state.pendingDrawFrame);
state.pendingDrawFrame = 0;
}
performDraw();
}
function draw(): void {
if (state.pendingDrawFrame) {
return;
}
state.pendingDrawFrame = window.requestAnimationFrame(() => {
state.pendingDrawFrame = 0;
performDraw();
});
}
function setHeight(nextHeight: number): void {
const normalizedHeight = clampViewerHeight(nextHeight);
if (normalizedHeight === state.currentHeight) {
updateHeightLabel();
return;
}
state.currentHeight = normalizedHeight;
draw();
}
function changeHeight(delta: number): void {
setHeight(state.currentHeight + (Number(delta) || 0));
}
function handleWindowKeydown(event: KeyboardEvent): void {
if (event.defaultPrevented) {
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
changeHeight(1);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
changeHeight(-1);
}
}
titleEl.textContent = bootstrap.mapName || bootstrap.mapId || "Height Viewer";
metaEl.textContent = bootstrap.mapId + " | " + mapWidth + "x" + mapHeight + " | tile " + tileSize + "px | " + heightLayers.length + " height patch" + (heightLayers.length === 1 ? "" : "es");
const persistBounds = () => {
persistMapHeightViewerBounds(window);
};
const persistBoundsDeferred = createDebouncedCallback(() => {
persistBounds();
}, 160);
heightDownBtn?.addEventListener("click", () => changeHeight(-1));
heightUpBtn?.addEventListener("click", () => changeHeight(1));
window.addEventListener("keydown", handleWindowKeydown);
window.addEventListener("resize", () => {
invalidateBaseSurface();
draw();
persistBoundsDeferred();
});
viewportEl.addEventListener("scroll", () => {
draw();
}, { passive: true });
drawNow();
window.addEventListener("beforeunload", persistBounds);
}
async function initHeightViewer(): Promise<void> {
ensureStyles();
renderLoading("Preparing world snapshot...");
const params = new URLSearchParams(window.location.search);
const token = params.get("token")?.trim() || "";
const requestedWorldId = params.get("worldId")?.trim() || params.get("mapId")?.trim() || "";
let bootstrap = loadMapEditorPopupBootstrap(token);
if (!bootstrap) {
try {
bootstrap = await loadStandaloneWorldEditorPopupBootstrap(requestedWorldId, window.location.origin);
} catch (error) {
renderError(String(error || "Failed to load the height viewer."));
return;
}
}
if (!bootstrap) {
renderError("No world data was available for the height viewer.");
return;
}
startViewer(bootstrap);
}
void initHeightViewer();

View file

@ -0,0 +1,108 @@
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
type JsonObject = { [key: string]: JsonValue };
type ValidationRequest = {
requestId: number;
activeType: string;
rootKey: string;
parsedPayload: JsonObject | null;
records: JsonObject[];
};
type ValidationResponse = {
requestId: number;
issues: string[];
};
function isPlainObject(value: JsonValue | undefined): value is JsonObject {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function getDialogueNodes(record: JsonObject): JsonObject[] {
const raw = record.dialogueNodes;
if (!Array.isArray(raw)) {
return [];
}
return raw.filter((entry) => isPlainObject(entry));
}
function computeValidationIssues(input: Omit<ValidationRequest, "requestId">): string[] {
const { activeType, rootKey, parsedPayload, records } = input;
const issues: string[] = [];
if (!parsedPayload || !rootKey) {
return issues;
}
const rawList = parsedPayload[rootKey];
if (!Array.isArray(rawList)) {
issues.push(`Expected ${rootKey} to be an array.`);
return issues;
}
const seen = new Set<string | number>();
records.forEach((record, index) => {
if (activeType === "quests") {
const questId = Number(record.questId);
if (!Number.isInteger(questId) || questId < 1) {
issues.push(`Record ${index + 1}: questId must be an integer >= 1.`);
} else if (seen.has(questId)) {
issues.push(`Duplicate questId found: ${questId}.`);
} else {
seen.add(questId);
}
} else {
const id = String(record.id || "").trim();
if (!id) {
issues.push(`Record ${index + 1}: id is required.`);
} else if (seen.has(id)) {
issues.push(`Duplicate id found: ${id}.`);
} else {
seen.add(id);
}
}
const name = String(record.name || "").trim();
if (activeType !== "npcs" && !name) {
issues.push(`Record ${index + 1}: name is required.`);
}
if (activeType === "npcs") {
const templateId = String(record.templateId || "").trim();
const mapId = String(record.mapId || "").trim();
if (templateId) {
issues.push(`NPC instance ${record.id || index + 1}: templateId is no longer stored on disk.`);
}
if (mapId) {
issues.push(`NPC instance ${record.id || index + 1}: mapId is no longer stored on disk.`);
}
}
if (activeType === "dialogues") {
const npcNodes = getDialogueNodes(record);
const seenNodeIds = new Set<string>();
npcNodes.forEach((node, nodeIndex) => {
const nodeId = String(node.id || "").trim();
if (!nodeId) {
issues.push(`Dialogue ${record.id || index + 1} node ${nodeIndex + 1}: id is required.`);
return;
}
if (seenNodeIds.has(nodeId)) {
issues.push(`Dialogue ${record.id || index + 1}: duplicate node id ${nodeId}.`);
return;
}
seenNodeIds.add(nodeId);
});
}
});
return issues;
}
self.onmessage = (event: MessageEvent<ValidationRequest>) => {
const { requestId, activeType, rootKey, parsedPayload, records } = event.data;
const issues = computeValidationIssues({ activeType, rootKey, parsedPayload, records });
const response: ValidationResponse = { requestId, issues };
self.postMessage(response);
};

159
src/worldChunking.ts Normal file
View file

@ -0,0 +1,159 @@
import type { JsonObject } from "./editorCore";
export const WORLD_INDEX_SCHEMA_VERSION = 1;
export const WORLD_SCHEMA_VERSION = 1;
export const WORLD_CHUNK_SCHEMA_VERSION = 1;
export const WORLD_BOOKMARKS_SCHEMA_VERSION = 1;
export const DEFAULT_WORLD_CHUNK_SIZE = 32;
export const DEFAULT_WORLD_TILE_SIZE = 32;
export type WorldIndexEntry = {
id: string;
name: string;
worldDir: string;
};
export type WorldIndexPayload = {
schemaVersion: number;
worlds: WorldIndexEntry[];
};
export type WorldBookmark = {
id: string;
label: string;
x: number;
y: number;
};
export type WorldBookmarksPayload = {
schemaVersion: number;
worldId: string;
bookmarks: WorldBookmark[];
};
export type WorldDefinition = {
schemaVersion: number;
id: string;
name: string;
chunkWidth: number;
chunkHeight: number;
tileSize: number;
defaultBackgroundTileId: string;
spawn: { x: number; y: number };
editor?: {
defaultZoom?: number;
gridVisible?: boolean;
};
};
export type WorldChunkLayer = {
layer: number;
name?: string;
rows: string[];
instanceIds: string[];
};
export type WorldHeightPatch = {
id: string;
name?: string;
z: number;
x: number;
y: number;
rows: string[];
};
export type WorldChunkInstance = {
id: string;
templateId?: string;
layer: number;
x: number;
y: number;
record: JsonObject;
};
export type WorldChunk = {
schemaVersion: number;
worldId: string;
chunkX: number;
chunkY: number;
width: number;
height: number;
backgroundTileId: string;
roomLayers: WorldChunkLayer[];
heightLayers: WorldHeightPatch[];
instances: WorldChunkInstance[];
};
export function normalizeChunkDimension(value: unknown, fallback = DEFAULT_WORLD_CHUNK_SIZE): number {
return Math.max(1, Math.floor(Number(value) || fallback));
}
export function buildChunkKey(chunkX: number, chunkY: number): string {
return `${Math.floor(chunkX)}:${Math.floor(chunkY)}`;
}
export function buildChunkFileName(chunkX: number, chunkY: number): string {
return `${Math.floor(chunkX)}_${Math.floor(chunkY)}.json`;
}
export function worldToChunkCoord(worldCoord: number, chunkSize: number): number {
const safeChunkSize = Math.max(1, Math.floor(Number(chunkSize) || DEFAULT_WORLD_CHUNK_SIZE));
return Math.floor(Number(worldCoord) / safeChunkSize);
}
export function worldToLocalCoord(worldCoord: number, chunkSize: number): number {
const safeChunkSize = Math.max(1, Math.floor(Number(chunkSize) || DEFAULT_WORLD_CHUNK_SIZE));
const chunkCoord = worldToChunkCoord(worldCoord, safeChunkSize);
return Math.floor(Number(worldCoord) - (chunkCoord * safeChunkSize));
}
export function localToWorldCoord(chunkCoord: number, localCoord: number, chunkSize: number): number {
const safeChunkSize = Math.max(1, Math.floor(Number(chunkSize) || DEFAULT_WORLD_CHUNK_SIZE));
return (Math.floor(Number(chunkCoord) || 0) * safeChunkSize) + Math.floor(Number(localCoord) || 0);
}
export function resolveWorldChunkAddress(worldX: number, worldY: number, chunkWidth: number, chunkHeight: number) {
const safeChunkWidth = normalizeChunkDimension(chunkWidth);
const safeChunkHeight = normalizeChunkDimension(chunkHeight);
const chunkX = worldToChunkCoord(worldX, safeChunkWidth);
const chunkY = worldToChunkCoord(worldY, safeChunkHeight);
const localX = worldToLocalCoord(worldX, safeChunkWidth);
const localY = worldToLocalCoord(worldY, safeChunkHeight);
return {
chunkX,
chunkY,
localX,
localY,
chunkKey: buildChunkKey(chunkX, chunkY),
fileName: buildChunkFileName(chunkX, chunkY),
};
}
export function createEmptyChunk(worldId: string, chunkX: number, chunkY: number, backgroundTileId = "", chunkWidth = DEFAULT_WORLD_CHUNK_SIZE, chunkHeight = DEFAULT_WORLD_CHUNK_SIZE): WorldChunk {
const width = normalizeChunkDimension(chunkWidth);
const height = normalizeChunkDimension(chunkHeight);
return {
schemaVersion: WORLD_CHUNK_SCHEMA_VERSION,
worldId: String(worldId || "").trim(),
chunkX: Math.floor(Number(chunkX) || 0),
chunkY: Math.floor(Number(chunkY) || 0),
width,
height,
backgroundTileId: String(backgroundTileId || "").trim(),
roomLayers: [
{
layer: 0,
rows: Array.from({ length: height }, () => ".".repeat(width)),
instanceIds: [],
},
{
layer: 1,
rows: Array.from({ length: height }, () => " ".repeat(width)),
instanceIds: [],
},
],
heightLayers: [],
instances: [],
};
}