Initial import
This commit is contained in:
commit
ab891a315c
773 changed files with 257255 additions and 0 deletions
184
src/App.css
Normal file
184
src/App.css
Normal 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
1786
src/App.tsx
Normal file
File diff suppressed because it is too large
Load diff
295
src/components/ConfigSection.tsx
Normal file
295
src/components/ConfigSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/ContentSection.tsx
Normal file
70
src/components/ContentSection.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
91
src/components/EditorToolbar.tsx
Normal file
91
src/components/EditorToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
src/components/ItemQuestAdvancedPanels.tsx
Normal file
379
src/components/ItemQuestAdvancedPanels.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
382
src/components/NpcDialogueEditorPanel.tsx
Normal file
382
src/components/NpcDialogueEditorPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
src/components/NpcDialogueSimulationPanel.tsx
Normal file
113
src/components/NpcDialogueSimulationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/RawJsonSection.tsx
Normal file
30
src/components/RawJsonSection.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
396
src/components/RecordEditorBasePanel.tsx
Normal file
396
src/components/RecordEditorBasePanel.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
374
src/components/RecordListPanel.tsx
Normal file
374
src/components/RecordListPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/StatusFooter.tsx
Normal file
26
src/components/StatusFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/components/TopNavTabs.tsx
Normal file
88
src/components/TopNavTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/components/editorContexts.ts
Normal file
145
src/components/editorContexts.ts
Normal 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;
|
||||
359
src/components/mapEditorShared.ts
Normal file
359
src/components/mapEditorShared.ts
Normal 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 },
|
||||
};
|
||||
});
|
||||
}
|
||||
118
src/components/mapEditorSupport.tsx
Normal file
118
src/components/mapEditorSupport.tsx
Normal 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
1567
src/editorCore.ts
Normal file
File diff suppressed because it is too large
Load diff
1337
src/index.css
Normal file
1337
src/index.css
Normal file
File diff suppressed because it is too large
Load diff
10
src/main.tsx
Normal file
10
src/main.tsx
Normal 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>,
|
||||
)
|
||||
593
src/mapEditorPopup/bootstrap.ts
Normal file
593
src/mapEditorPopup/bootstrap.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
470
src/mapEditorPopup/changelogSplashWindowController.ts
Normal file
470
src/mapEditorPopup/changelogSplashWindowController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
294
src/mapEditorPopup/contextMenuSchema.ts
Normal file
294
src/mapEditorPopup/contextMenuSchema.ts
Normal 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;
|
||||
}
|
||||
47
src/mapEditorPopup/debounce.ts
Normal file
47
src/mapEditorPopup/debounce.ts
Normal 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
4722
src/mapEditorPopup/dom.ts
Normal file
File diff suppressed because it is too large
Load diff
90
src/mapEditorPopup/editorUiStore.ts
Normal file
90
src/mapEditorPopup/editorUiStore.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
518
src/mapEditorPopup/engineOverrideWindowController.ts
Normal file
518
src/mapEditorPopup/engineOverrideWindowController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
169
src/mapEditorPopup/engineOverrides.ts
Normal file
169
src/mapEditorPopup/engineOverrides.ts
Normal 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);
|
||||
}
|
||||
854
src/mapEditorPopup/entityEditorWindowController.ts
Normal file
854
src/mapEditorPopup/entityEditorWindowController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
src/mapEditorPopup/floatingWindowUtils.ts
Normal file
39
src/mapEditorPopup/floatingWindowUtils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
354
src/mapEditorPopup/folderedSelectorList.ts
Normal file
354
src/mapEditorPopup/folderedSelectorList.ts
Normal 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">↕</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 ? "▸" : "▾") + "</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 = "📁";
|
||||
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);
|
||||
}
|
||||
}
|
||||
145
src/mapEditorPopup/graphicsDocumentHelpers.ts
Normal file
145
src/mapEditorPopup/graphicsDocumentHelpers.ts
Normal 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) || [],
|
||||
});
|
||||
}
|
||||
852
src/mapEditorPopup/historyController.ts
Normal file
852
src/mapEditorPopup/historyController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
43
src/mapEditorPopup/historyStateStore.ts
Normal file
43
src/mapEditorPopup/historyStateStore.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
369
src/mapEditorPopup/importController.ts
Normal file
369
src/mapEditorPopup/importController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2272
src/mapEditorPopup/interactionController.ts
Normal file
2272
src/mapEditorPopup/interactionController.ts
Normal file
File diff suppressed because it is too large
Load diff
121
src/mapEditorPopup/main.ts
Normal file
121
src/mapEditorPopup/main.ts
Normal 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();
|
||||
428
src/mapEditorPopup/mapDocumentController.ts
Normal file
428
src/mapEditorPopup/mapDocumentController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
56
src/mapEditorPopup/mapDocumentStore.ts
Normal file
56
src/mapEditorPopup/mapDocumentStore.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
932
src/mapEditorPopup/npcController.ts
Normal file
932
src/mapEditorPopup/npcController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
248
src/mapEditorPopup/overlayRenderer.ts
Normal file
248
src/mapEditorPopup/overlayRenderer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
289
src/mapEditorPopup/panelFolders.ts
Normal file
289
src/mapEditorPopup/panelFolders.ts
Normal 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;
|
||||
}
|
||||
151
src/mapEditorPopup/persistenceController.ts
Normal file
151
src/mapEditorPopup/persistenceController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
74
src/mapEditorPopup/pixiChunkSurfaceHelpers.ts
Normal file
74
src/mapEditorPopup/pixiChunkSurfaceHelpers.ts
Normal 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);
|
||||
}
|
||||
168
src/mapEditorPopup/pixiHeightOverlayHelpers.ts
Normal file
168
src/mapEditorPopup/pixiHeightOverlayHelpers.ts
Normal 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;
|
||||
}
|
||||
145
src/mapEditorPopup/pixiSceneRebuildHelpers.ts
Normal file
145
src/mapEditorPopup/pixiSceneRebuildHelpers.ts
Normal 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;
|
||||
}
|
||||
116
src/mapEditorPopup/pixiSurfaceHelpers.ts
Normal file
116
src/mapEditorPopup/pixiSurfaceHelpers.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
948
src/mapEditorPopup/pixiTileStageController.ts
Normal file
948
src/mapEditorPopup/pixiTileStageController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
305
src/mapEditorPopup/popupSessionStore.ts
Normal file
305
src/mapEditorPopup/popupSessionStore.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
858
src/mapEditorPopup/renderController.ts
Normal file
858
src/mapEditorPopup/renderController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
165
src/mapEditorPopup/reorderableListController.ts
Normal file
165
src/mapEditorPopup/reorderableListController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
5492
src/mapEditorPopup/runtime.ts
Normal file
5492
src/mapEditorPopup/runtime.ts
Normal file
File diff suppressed because it is too large
Load diff
2735
src/mapEditorPopup/sidebarController.ts
Normal file
2735
src/mapEditorPopup/sidebarController.ts
Normal file
File diff suppressed because it is too large
Load diff
347
src/mapEditorPopup/statusLogWindowController.ts
Normal file
347
src/mapEditorPopup/statusLogWindowController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
58
src/mapEditorPopup/tagUtils.ts
Normal file
58
src/mapEditorPopup/tagUtils.ts
Normal 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()),
|
||||
);
|
||||
}
|
||||
33
src/mapEditorPopup/textTransferUtils.ts
Normal file
33
src/mapEditorPopup/textTransferUtils.ts
Normal 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);
|
||||
}
|
||||
546
src/mapEditorPopup/themePresets.ts
Normal file
546
src/mapEditorPopup/themePresets.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
4115
src/mapEditorPopup/tileArtEditorWindowController.ts
Normal file
4115
src/mapEditorPopup/tileArtEditorWindowController.ts
Normal file
File diff suppressed because it is too large
Load diff
693
src/mapEditorPopup/toolWindowController.ts
Normal file
693
src/mapEditorPopup/toolWindowController.ts
Normal 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">|←</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",
|
||||
};
|
||||
}
|
||||
331
src/mapEditorPopup/tooltip.ts
Normal file
331
src/mapEditorPopup/tooltip.ts
Normal 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();
|
||||
}
|
||||
209
src/mapEditorPopup/windowing.ts
Normal file
209
src/mapEditorPopup/windowing.ts
Normal 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;
|
||||
}
|
||||
1884
src/mapEditorPopup/worldOverviewWindowController.ts
Normal file
1884
src/mapEditorPopup/worldOverviewWindowController.ts
Normal file
File diff suppressed because it is too large
Load diff
705
src/mapHeightViewer/main.ts
Normal file
705
src/mapHeightViewer/main.ts
Normal 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();
|
||||
108
src/workers/validationWorker.ts
Normal file
108
src/workers/validationWorker.ts
Normal 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
159
src/worldChunking.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue