Initial import

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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