diff --git a/packages/frontend/src/components/gff/AreaEditor.tsx b/packages/frontend/src/components/gff/AreaEditor.tsx new file mode 100644 index 0000000..e00d2d4 --- /dev/null +++ b/packages/frontend/src/components/gff/AreaEditor.tsx @@ -0,0 +1,197 @@ +import { useMemo } from "react"; +import { + GffEditor, + getFieldValue, + type FieldOverrideProps, +} from "./GffEditor"; + +interface AreaEditorProps { + repo: string; + filePath: string; + content: string; + onSave?: (content: string) => void; + onSwitchToRaw?: () => void; +} + +function intToHexColor(value: number): string { + const r = value & 0xff; + const g = (value >> 8) & 0xff; + const b = (value >> 16) & 0xff; + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; +} + +function hexColorToInt(hex: string): number { + const clean = hex.replace("#", ""); + const r = parseInt(clean.slice(0, 2), 16); + const g = parseInt(clean.slice(2, 4), 16); + const b = parseInt(clean.slice(4, 6), 16); + return r | (g << 8) | (b << 16); +} + +function FlagsOverride({ data, onChange }: FieldOverrideProps) { + const raw = getFieldValue(data, "Flags"); + const flags = typeof raw === "number" ? raw : 0; + + const bits = [ + { bit: 0, label: "Interior" }, + { bit: 1, label: "Underground" }, + { bit: 2, label: "Natural" }, + ]; + + return ( +
+ +
+ {bits.map(({ bit, label }) => { + const checked = (flags & (1 << bit)) !== 0; + return ( + + ); + })} +
+ + Raw value: {flags} + +
+ ); +} + +function ColorOverride({ field, value, onChange }: FieldOverrideProps) { + const num = typeof value === "number" ? value : 0; + const hex = intToHexColor(num); + + return ( +
+ +
+ onChange(field.label, hexColorToInt(e.target.value))} + className="h-8 w-10 cursor-pointer rounded border-0" + style={{ backgroundColor: "var(--forge-bg)" }} + /> + + {hex} + +
+
+ ); +} + +function DimensionsOverride({ data, onChange }: FieldOverrideProps) { + const width = getFieldValue(data, "Width"); + const height = getFieldValue(data, "Height"); + const tileset = getFieldValue(data, "Tileset"); + const w = typeof width === "number" ? width : 0; + const h = typeof height === "number" ? height : 0; + const ts = typeof tileset === "string" ? tileset : ""; + + return ( +
+
+
+ + onChange("Width", parseInt(e.target.value, 10))} + className="w-20 rounded border px-2 py-1.5 text-sm" + style={{ + backgroundColor: "var(--forge-bg)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }} + /> +
+ × +
+ + onChange("Height", parseInt(e.target.value, 10))} + className="w-20 rounded border px-2 py-1.5 text-sm" + style={{ + backgroundColor: "var(--forge-bg)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }} + /> +
+ tiles +
+
+ + onChange("Tileset", e.target.value)} + className="w-44 rounded border px-2 py-1.5 font-mono text-sm" + style={{ + backgroundColor: "var(--forge-bg)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }} + /> +
+
+ ); +} + +const COLOR_FIELDS = [ + "SunAmbientColor", "SunDiffuseColor", "SunFogColor", + "MoonAmbientColor", "MoonDiffuseColor", "MoonFogColor", +]; + +export function AreaEditor({ repo, filePath, content, onSave, onSwitchToRaw }: AreaEditorProps) { + const fieldOverrides = useMemo(() => { + const overrides = new Map React.ReactNode>(); + + overrides.set("Flags", (props) => ); + + overrides.set("Width", (props) => ); + overrides.set("Height", () => null); + overrides.set("Tileset", () => null); + + for (const cf of COLOR_FIELDS) { + overrides.set(cf, (props) => ); + } + + return overrides; + }, []); + + return ( + + ); +} diff --git a/packages/frontend/src/components/gff/CreatureEditor.tsx b/packages/frontend/src/components/gff/CreatureEditor.tsx new file mode 100644 index 0000000..99917ed --- /dev/null +++ b/packages/frontend/src/components/gff/CreatureEditor.tsx @@ -0,0 +1,185 @@ +import { useMemo } from "react"; +import { + GffEditor, + getFieldValue, + type FieldOverrideProps, +} from "./GffEditor"; + +interface CreatureEditorProps { + repo: string; + filePath: string; + content: string; + onSave?: (content: string) => void; + onSwitchToRaw?: () => void; +} + +function AbilityScoresOverride({ data, onChange }: FieldOverrideProps) { + const abilities = [ + ["Str", "Dex", "Con"], + ["Int", "Wis", "Cha"], + ]; + + const displayNames: Record = { + Str: "STR", Dex: "DEX", Con: "CON", + Int: "INT", Wis: "WIS", Cha: "CHA", + }; + + return ( +
+ +
+ {abilities.flat().map((ab) => { + const val = getFieldValue(data, ab); + const num = typeof val === "number" ? val : 0; + return ( +
+ + {displayNames[ab]} + + onChange(ab, parseInt(e.target.value, 10))} + className="mt-1 w-16 rounded border px-1 py-1 text-center text-lg font-semibold" + style={{ + backgroundColor: "var(--forge-surface)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }} + /> + + mod {num >= 10 ? "+" : ""}{Math.floor((num - 10) / 2)} + +
+ ); + })} +
+
+ ); +} + +function RaceGenderOverride({ data, onChange }: FieldOverrideProps) { + const race = getFieldValue(data, "Race"); + const gender = getFieldValue(data, "Gender"); + const raceNum = typeof race === "number" ? race : 0; + const genderNum = typeof gender === "number" ? gender : 0; + + return ( +
+
+ + onChange("Race", parseInt(e.target.value, 10))} + className="w-20 rounded border px-2 py-1.5 text-sm" + style={{ + backgroundColor: "var(--forge-bg)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }} + /> + + (racialtypes.2da) + +
+
+ + +
+
+ ); +} + +function ScriptsOverride({ data, onChange }: FieldOverrideProps) { + const scripts = [ + { label: "ScriptHeartbeat", display: "Heartbeat" }, + { label: "ScriptOnDamaged", display: "On Damaged" }, + { label: "ScriptDeath", display: "On Death" }, + { label: "ScriptSpawn", display: "On Spawn" }, + ]; + + return ( +
+ {scripts.map((s) => { + const val = getFieldValue(data, s.label); + const str = typeof val === "string" ? val : ""; + return ( +
+ + onChange(s.label, e.target.value)} + className="flex-1 rounded border px-2 py-1.5 font-mono text-sm" + style={{ + backgroundColor: "var(--forge-bg)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }} + placeholder="(none)" + /> +
+ ); + })} +
+ ); +} + +export function CreatureEditor({ repo, filePath, content, onSave, onSwitchToRaw }: CreatureEditorProps) { + const fieldOverrides = useMemo(() => { + const overrides = new Map React.ReactNode>(); + + overrides.set("Str", (props) => ); + overrides.set("Dex", () => null); + overrides.set("Con", () => null); + overrides.set("Int", () => null); + overrides.set("Wis", () => null); + overrides.set("Cha", () => null); + + overrides.set("Race", (props) => ); + overrides.set("Gender", () => null); + + overrides.set("ScriptHeartbeat", (props) => ); + overrides.set("ScriptOnDamaged", () => null); + overrides.set("ScriptDeath", () => null); + overrides.set("ScriptSpawn", () => null); + + return overrides; + }, []); + + return ( + + ); +} diff --git a/packages/frontend/src/components/gff/DialogEditor.tsx b/packages/frontend/src/components/gff/DialogEditor.tsx new file mode 100644 index 0000000..02d1620 --- /dev/null +++ b/packages/frontend/src/components/gff/DialogEditor.tsx @@ -0,0 +1,437 @@ +import { useState, useMemo, useCallback, useEffect } from "react"; +import { api } from "../../services/api"; +import { + type GffTypeSchema, + GffFieldType, + gffTypeFromPath, + getLocStringText, +} from "./GffEditor"; + +interface DialogEditorProps { + repo: string; + filePath: string; + content: string; + onSave?: (content: string) => void; + onSwitchToRaw?: () => void; +} + +interface DialogNode { + __struct_id?: number; + Text?: unknown; + Speaker?: unknown; + Animation?: unknown; + Sound?: unknown; + Script?: unknown; + Active?: unknown; + RepliesList?: unknown[]; + EntriesList?: unknown[]; + [key: string]: unknown; +} + +function getStringVal(node: DialogNode, key: string): string { + const v = node[key]; + if (v && typeof v === "object" && "value" in (v as Record)) { + return String((v as Record).value ?? ""); + } + return typeof v === "string" ? v : ""; +} + +function getTextVal(node: DialogNode): string { + return getLocStringText(node.Text); +} + +function NodeDetail({ node, type }: { node: DialogNode; type: "entry" | "reply" }) { + const text = getTextVal(node); + const speaker = getStringVal(node, "Speaker"); + const script = getStringVal(node, "Script"); + const active = getStringVal(node, "Active"); + const sound = getStringVal(node, "Sound"); + + return ( +
+
+ +

+ {text || (empty)} +

+
+
+ {type === "entry" && speaker && ( +
+ +

{speaker}

+
+ )} + {script && ( +
+ +

{script}

+
+ )} + {active && ( +
+ +

{active}

+
+ )} + {sound && ( +
+ +

{sound}

+
+ )} +
+
+ ); +} + +function DialogNodeItem({ + node, + index, + type, + entries, + replies, + depth, +}: { + node: DialogNode; + index: number; + type: "entry" | "reply"; + entries: DialogNode[]; + replies: DialogNode[]; + depth: number; +}) { + const [expanded, setExpanded] = useState(false); + const text = getTextVal(node); + const truncated = text.length > 60 ? text.slice(0, 60) + "..." : text; + + const childLinks = type === "entry" + ? (node.RepliesList as Array<{ Index?: unknown; value?: unknown }> | undefined) + : (node.EntriesList as Array<{ Index?: unknown; value?: unknown }> | undefined); + + const childType = type === "entry" ? "reply" : "entry"; + const childPool = childType === "entry" ? entries : replies; + + return ( +
0 ? 16 : 0 }}> + + + {expanded && ( +
+
+ +
+ + {childLinks && childLinks.length > 0 && depth < 4 && ( +
+ {childLinks.map((link, li) => { + const idx = typeof link === "object" && link !== null + ? (typeof link.Index === "number" ? link.Index : + typeof link.value === "number" ? link.value : + typeof (link as Record).Index === "object" + ? ((link as Record).Index as Record)?.value as number + : li) + : li; + const childIdx = typeof idx === "number" ? idx : li; + const child = childPool[childIdx]; + if (!child) return null; + return ( + + ); + })} +
+ )} +
+ )} +
+ ); +} + +export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }: DialogEditorProps) { + const [schema, setSchema] = useState(null); + const [data, setData] = useState>({}); + const [activeTab, setActiveTab] = useState<"tree" | "properties">("tree"); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const gffType = useMemo(() => gffTypeFromPath(filePath), [filePath]); + + useEffect(() => { + try { + setData(JSON.parse(content)); + } catch { + setError("Failed to parse dialog JSON"); + } + }, [content]); + + useEffect(() => { + if (!gffType) return; + api.editor.gffSchema(gffType).then(setSchema).catch(() => {}); + }, [gffType]); + + const entries = useMemo(() => { + const raw = data.EntryList; + if (raw && typeof raw === "object" && "value" in (raw as Record)) { + const v = (raw as Record).value; + return Array.isArray(v) ? (v as DialogNode[]) : []; + } + return Array.isArray(raw) ? (raw as DialogNode[]) : []; + }, [data]); + + const replies = useMemo(() => { + const raw = data.ReplyList; + if (raw && typeof raw === "object" && "value" in (raw as Record)) { + const v = (raw as Record).value; + return Array.isArray(v) ? (v as DialogNode[]) : []; + } + return Array.isArray(raw) ? (raw as DialogNode[]) : []; + }, [data]); + + const startingEntries = useMemo(() => { + const raw = data.StartingList; + if (raw && typeof raw === "object" && "value" in (raw as Record)) { + const v = (raw as Record).value; + return Array.isArray(v) ? v : []; + } + return Array.isArray(raw) ? raw : []; + }, [data]); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const newContent = JSON.stringify(data, null, 4) + "\n"; + await api.editor.writeFile(repo, filePath, newContent); + setDirty(false); + onSave?.(newContent); + } catch (err) { + setError(err instanceof Error ? err.message : "Save failed"); + } finally { + setSaving(false); + } + }, [data, repo, filePath, onSave]); + + const propertyFields = useMemo(() => { + if (!schema) return []; + return schema.fields.filter( + (f) => f.category === "Properties" && f.type !== GffFieldType.List, + ); + }, [schema]); + + return ( +
+ {/* Toolbar */} +
+
+ + Dialog Editor + + + {entries.length} entries, {replies.length} replies + + {dirty && ( + + (unsaved changes) + + )} +
+
+ {onSwitchToRaw && ( + + )} + +
+
+ + {/* Tabs */} +
+ {(["tree", "properties"] as const).map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {error && ( +

{error}

+ )} + + {activeTab === "tree" && ( +
+ {startingEntries.length > 0 ? ( + startingEntries.map((link, i) => { + const idx = typeof link === "object" && link !== null + ? ((link as Record).Index as number ?? + ((link as Record>).Index?.value as number) ?? + i) + : i; + const entry = entries[typeof idx === "number" ? idx : i]; + if (!entry) return null; + return ( + + ); + }) + ) : entries.length > 0 ? ( + + ) : ( +

+ No dialog entries found +

+ )} +
+ )} + + {activeTab === "properties" && ( +
+ {propertyFields.map((field) => { + const raw = data[field.label]; + const value = raw && typeof raw === "object" && "value" in (raw as Record) + ? (raw as Record).value + : raw; + + return ( +
+ + {field.type === GffFieldType.ResRef ? ( + { + setData((prev) => { + const existing = prev[field.label]; + const updated = { ...prev }; + if (existing && typeof existing === "object" && "value" in (existing as Record)) { + updated[field.label] = { ...(existing as Record), value: e.target.value }; + } else { + updated[field.label] = e.target.value; + } + setDirty(true); + return updated; + }); + }} + className="flex-1 rounded border px-2 py-1.5 font-mono text-sm" + style={{ + backgroundColor: "var(--forge-bg)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }} + /> + ) : ( + { + setData((prev) => { + const existing = prev[field.label]; + const updated = { ...prev }; + const num = parseInt(e.target.value, 10); + if (existing && typeof existing === "object" && "value" in (existing as Record)) { + updated[field.label] = { ...(existing as Record), value: num }; + } else { + updated[field.label] = num; + } + setDirty(true); + return updated; + }); + }} + className="w-32 rounded border px-2 py-1.5 text-sm" + style={{ + backgroundColor: "var(--forge-bg)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }} + /> + )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/packages/frontend/src/components/gff/ItemEditor.tsx b/packages/frontend/src/components/gff/ItemEditor.tsx new file mode 100644 index 0000000..665da74 --- /dev/null +++ b/packages/frontend/src/components/gff/ItemEditor.tsx @@ -0,0 +1,208 @@ +import { useMemo } from "react"; +import { + GffEditor, + GffFieldType, + getFieldValue, + getLocStringText, + type FieldOverrideProps, +} from "./GffEditor"; + +interface ItemEditorProps { + repo: string; + filePath: string; + content: string; + onSave?: (content: string) => void; + onSwitchToRaw?: () => void; +} + +function BaseItemOverride({ value, onChange, field }: FieldOverrideProps) { + const num = typeof value === "number" ? value : 0; + return ( +
+ + onChange(field.label, parseInt(e.target.value, 10))} + className="w-24 rounded border px-2 py-1.5 text-sm" + style={{ + backgroundColor: "var(--forge-bg)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }} + /> + + (baseitems.2da row) + +
+ ); +} + +function CompactNumbersOverride({ value, onChange, field, data }: FieldOverrideProps) { + const stackSize = typeof getFieldValue(data, "StackSize") === "number" + ? (getFieldValue(data, "StackSize") as number) : 0; + const cost = typeof getFieldValue(data, "Cost") === "number" + ? (getFieldValue(data, "Cost") as number) : 0; + const charges = typeof getFieldValue(data, "Charges") === "number" + ? (getFieldValue(data, "Charges") as number) : 0; + + if (field.label !== "StackSize") return null; + + return ( +
+ {[ + { label: "StackSize", display: "Stack", value: stackSize, max: 99 }, + { label: "Cost", display: "Cost (gp)", value: cost, max: 999999 }, + { label: "Charges", display: "Charges", value: charges, max: 255 }, + ].map((item) => ( +
+ + onChange(item.label, parseInt(e.target.value, 10))} + className="w-24 rounded border px-2 py-1.5 text-sm" + style={{ + backgroundColor: "var(--forge-bg)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", + }} + /> +
+ ))} +
+ ); +} + +function BooleanFlagsOverride({ data, onChange }: FieldOverrideProps) { + const flags = [ + { label: "Identified", display: "Identified" }, + { label: "Plot", display: "Plot" }, + { label: "Stolen", display: "Stolen" }, + { label: "Cursed", display: "Cursed" }, + ]; + + return ( +
+ {flags.map((flag) => { + const val = getFieldValue(data, flag.label); + const checked = typeof val === "number" ? val !== 0 : Boolean(val); + return ( + + ); + })} +
+ ); +} + +function PropertiesListOverride({ value }: FieldOverrideProps) { + const list = Array.isArray(value) ? value : []; + + return ( +
+
+ + Item Properties + +
+ +
+
+ {list.map((prop, i) => ( +
+ + {typeof prop === "object" && prop !== null + ? JSON.stringify(prop).slice(0, 80) + : String(prop)} + + +
+ ))} + {list.length === 0 && ( +

+ No item properties +

+ )} +
+ ); +} + +export function ItemEditor({ repo, filePath, content, onSave, onSwitchToRaw }: ItemEditorProps) { + const fieldOverrides = useMemo(() => { + const overrides = new Map React.ReactNode>(); + + overrides.set("BaseItem", (props) => ); + overrides.set("StackSize", (props) => ); + overrides.set("Cost", () => null); + overrides.set("Charges", () => null); + overrides.set("Identified", (props) => ); + overrides.set("Plot", () => null); + overrides.set("Stolen", () => null); + overrides.set("Cursed", () => null); + overrides.set("PropertiesList", (props) => ); + + return overrides; + }, []); + + const itemName = useMemo(() => { + try { + const data = JSON.parse(content); + const nameField = data.LocalizedName; + return getLocStringText(nameField) || "(unnamed item)"; + } catch { + return "(unnamed item)"; + } + }, [content]); + + const headerSlot = ( +
+

+ {itemName} +

+
+ ); + + return ( + + ); +} diff --git a/packages/frontend/src/pages/Editor.tsx b/packages/frontend/src/pages/Editor.tsx index 2af5a9c..cfa6565 100644 --- a/packages/frontend/src/pages/Editor.tsx +++ b/packages/frontend/src/pages/Editor.tsx @@ -2,13 +2,28 @@ import { useCallback, useMemo, useState } from "react"; import { MonacoEditor } from "../components/editor/MonacoEditor"; import { EditorTabs } from "../components/editor/EditorTabs"; import { GffEditor } from "../components/gff/GffEditor"; +import { ItemEditor } from "../components/gff/ItemEditor"; +import { CreatureEditor } from "../components/gff/CreatureEditor"; +import { AreaEditor } from "../components/gff/AreaEditor"; +import { DialogEditor } from "../components/gff/DialogEditor"; const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"]; +type GffEditorType = "uti" | "utc" | "are" | "dlg" | "generic" | null; + function isGffFile(tabKey: string): boolean { return GFF_EXTENSIONS.some((ext) => tabKey.endsWith(ext)); } +function getGffEditorType(tabKey: string): GffEditorType { + if (tabKey.endsWith(".uti.json")) return "uti"; + if (tabKey.endsWith(".utc.json")) return "utc"; + if (tabKey.endsWith(".are.json")) return "are"; + if (tabKey.endsWith(".dlg.json")) return "dlg"; + if (tabKey.endsWith(".utp.json") || tabKey.endsWith(".utm.json")) return "generic"; + return null; +} + function repoFromTabKey(tabKey: string): string { const idx = tabKey.indexOf(":"); return idx > 0 ? tabKey.slice(0, idx) : ""; @@ -99,16 +114,28 @@ export function Editor({ editorState }: EditorProps) { } if (isActiveGff && activeMode === "visual") { - return ( - - ); + const editorType = getGffEditorType(activeTab); + const commonProps = { + key: activeTab, + repo: activeRepo, + filePath: activeFilePath, + content: activeContent, + onSave: handleGffSave, + onSwitchToRaw: handleSwitchToRaw, + }; + + switch (editorType) { + case "uti": + return ; + case "utc": + return ; + case "are": + return ; + case "dlg": + return ; + default: + return ; + } } return (