f851d8b8f2
Electron desktop application for Neverwinter Nights module development. Clone, edit, build, and run a complete Layonara NWNX server with only Docker required. - React 19 + Vite frontend with Monaco editor and NWScript LSP - Node.js + Express backend managing Docker sibling containers - Electron shell with Docker availability check and auto-setup - Builder image auto-builds on first use from bundled Dockerfile - Cross-platform: Windows (.exe), macOS (.dmg), Linux (.AppImage) - Gitea Actions CI for automated release builds
208 lines
6.9 KiB
TypeScript
208 lines
6.9 KiB
TypeScript
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<string, string> = {
|
|
Str: "STR", Dex: "DEX", Con: "CON",
|
|
Int: "INT", Wis: "WIS", Cha: "CHA",
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
|
Ability Scores
|
|
</label>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "0.75rem" }}>
|
|
{abilities.flat().map((ab) => {
|
|
const val = getFieldValue(data, ab);
|
|
const num = typeof val === "number" ? val : 0;
|
|
return (
|
|
<div
|
|
key={ab}
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
borderRadius: "0.25rem",
|
|
border: "1px solid var(--forge-border)",
|
|
padding: "0.5rem 0.75rem",
|
|
backgroundColor: "var(--forge-bg)",
|
|
}}
|
|
>
|
|
<span style={{ fontSize: "var(--text-xs)", fontWeight: 700, color: "var(--forge-text-secondary)" }}>
|
|
{displayNames[ab]}
|
|
</span>
|
|
<input
|
|
type="number"
|
|
value={num}
|
|
min={1}
|
|
max={99}
|
|
onChange={(e) => onChange(ab, parseInt(e.target.value, 10))}
|
|
style={{
|
|
marginTop: "0.25rem",
|
|
width: "4rem",
|
|
borderRadius: "0.25rem",
|
|
border: "1px solid var(--forge-border)",
|
|
padding: "0.25rem",
|
|
textAlign: "center",
|
|
fontSize: "var(--text-lg)",
|
|
fontWeight: 600,
|
|
backgroundColor: "var(--forge-surface)",
|
|
color: "var(--forge-text)",
|
|
}}
|
|
/>
|
|
<span style={{ marginTop: "0.125rem", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
|
mod {num >= 10 ? "+" : ""}{Math.floor((num - 10) / 2)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Race</label>
|
|
<input
|
|
type="number"
|
|
value={raceNum}
|
|
min={0}
|
|
onChange={(e) => onChange("Race", parseInt(e.target.value, 10))}
|
|
style={{
|
|
width: "5rem",
|
|
borderRadius: "0.25rem",
|
|
border: "1px solid var(--forge-border)",
|
|
padding: "0.375rem 0.5rem",
|
|
fontSize: "var(--text-sm)",
|
|
backgroundColor: "var(--forge-bg)",
|
|
color: "var(--forge-text)",
|
|
}}
|
|
/>
|
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
|
|
(racialtypes.2da)
|
|
</span>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
<label style={{ fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>Gender</label>
|
|
<select
|
|
value={genderNum}
|
|
onChange={(e) => onChange("Gender", parseInt(e.target.value, 10))}
|
|
style={{
|
|
borderRadius: "0.25rem",
|
|
border: "1px solid var(--forge-border)",
|
|
padding: "0.375rem 0.5rem",
|
|
fontSize: "var(--text-sm)",
|
|
backgroundColor: "var(--forge-bg)",
|
|
color: "var(--forge-text)",
|
|
}}
|
|
>
|
|
<option value={0}>Male</option>
|
|
<option value={1}>Female</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
{scripts.map((s) => {
|
|
const val = getFieldValue(data, s.label);
|
|
const str = typeof val === "string" ? val : "";
|
|
return (
|
|
<div key={s.label} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
<label style={{ width: "7rem", flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
|
|
{s.display}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={str}
|
|
maxLength={16}
|
|
onChange={(e) => onChange(s.label, e.target.value)}
|
|
style={{
|
|
flex: 1,
|
|
borderRadius: "0.25rem",
|
|
border: "1px solid var(--forge-border)",
|
|
padding: "0.375rem 0.5rem",
|
|
fontFamily: "var(--font-mono)",
|
|
fontSize: "var(--text-sm)",
|
|
backgroundColor: "var(--forge-bg)",
|
|
color: "var(--forge-text)",
|
|
}}
|
|
placeholder="(none)"
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CreatureEditor({ repo, filePath, content, onSave, onSwitchToRaw }: CreatureEditorProps) {
|
|
const fieldOverrides = useMemo(() => {
|
|
const overrides = new Map<string, (props: FieldOverrideProps) => React.ReactNode>();
|
|
|
|
overrides.set("Str", (props) => <AbilityScoresOverride {...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) => <RaceGenderOverride {...props} />);
|
|
overrides.set("Gender", () => null);
|
|
|
|
overrides.set("ScriptHeartbeat", (props) => <ScriptsOverride {...props} />);
|
|
overrides.set("ScriptOnDamaged", () => null);
|
|
overrides.set("ScriptDeath", () => null);
|
|
overrides.set("ScriptSpawn", () => null);
|
|
|
|
return overrides;
|
|
}, []);
|
|
|
|
return (
|
|
<GffEditor
|
|
repo={repo}
|
|
filePath={filePath}
|
|
content={content}
|
|
onSave={onSave}
|
|
onSwitchToRaw={onSwitchToRaw}
|
|
fieldOverrides={fieldOverrides}
|
|
/>
|
|
);
|
|
}
|