Initial import
This commit is contained in:
commit
ab891a315c
773 changed files with 257255 additions and 0 deletions
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue