first commit

This commit is contained in:
Matthieu
2025-08-11 21:32:22 +02:00
commit 43ee493f9e
31 changed files with 7519 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(npm run build:*)",
"Bash(npm run dev:*)",
"Bash(npm install:*)",
"Bash(npm uninstall:*)",
"Bash(rm:*)",
"Bash(npx tailwindcss init:*)"
],
"deny": []
}
}

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

27
README.md Normal file
View File

@@ -0,0 +1,27 @@
# Cobblemon NPC Creator
A comprehensive web application for creating and customizing NPCs for the Cobblemon Minecraft mod.
## Getting Started
1. Install dependencies: `npm install`
2. Start development server: `npm run dev`
3. Open browser to `http://localhost:5173`
## Features
- **NPC Configuration**: Basic settings, battle config, Pokemon parties
- **Dialogue Editor**: Visual dialogue tree editor with MoLang support
- **Import/Export**: JSON preview, validation, and file export
- **Real-time Validation**: Error checking and warnings
## Usage
1. Configure basic NPC settings
2. Set up battle configuration (optional)
3. Create Pokemon party (for trainers)
4. Configure interaction system
5. Create dialogue (if using dialogue interaction)
6. Export JSON files to your mod data folder
Built with React, TypeScript, Vite, and Tailwind CSS.

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4613
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "npc-config-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/forms": "^0.5.10",
"lucide-react": "^0.539.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.0",
"vite": "^7.1.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

192
src/App.tsx Normal file
View File

@@ -0,0 +1,192 @@
import React, { useState } from 'react';
import { Settings, MessageSquare, Sword, Users, Code, FileText, Upload } from 'lucide-react';
import type { NPCConfiguration, DialogueConfiguration } from './types/npc';
import { NPCBasicSettings } from './components/NPCBasicSettings';
import { NPCBattleConfiguration } from './components/NPCBattleConfiguration';
import { NPCPartyBuilder } from './components/NPCPartyBuilder';
import { NPCInteractionEditor } from './components/NPCInteractionEditor';
import { ConfigVariablesEditor } from './components/ConfigVariablesEditor';
import { DialogueEditor } from './components/DialogueEditor';
import { JSONPreview } from './components/JSONPreview';
import { ImportExport } from './components/ImportExport';
type Tab = 'basic' | 'battle' | 'party' | 'interaction' | 'variables' | 'dialogue' | 'preview' | 'import';
function App() {
const [activeTab, setActiveTab] = useState<Tab>('basic');
const [npcConfig, setNpcConfig] = useState<NPCConfiguration>({
hitbox: "player",
presets: [],
resourceIdentifier: "cobblemon:my_npc",
config: [],
names: ["My NPC"],
interaction: { type: "none" }
});
const [dialogueConfig, setDialogueConfig] = useState<DialogueConfiguration | null>(null);
// Handle dialogue creation when interaction type changes to dialogue
React.useEffect(() => {
if (npcConfig.interaction.type === 'dialogue' && !dialogueConfig) {
const newDialogue: DialogueConfiguration = {
speakers: {
npc: {
name: { type: 'expression', expression: 'q.npc.name' },
face: 'q.npc.face(false);'
},
player: {
name: { type: 'expression', expression: 'q.player.username' },
face: 'q.player.face();'
}
},
pages: [{
id: 'greeting',
speaker: 'npc',
lines: ['Hello there!'],
input: 'q.dialogue.close();'
}]
};
setDialogueConfig(newDialogue);
}
}, [npcConfig.interaction.type, dialogueConfig]);
const tabs = [
{ id: 'basic', name: 'Basic Settings', icon: Settings },
{ id: 'battle', name: 'Battle Config', icon: Sword },
{ id: 'party', name: 'Pokemon Party', icon: Users },
{ id: 'interaction', name: 'Interaction', icon: MessageSquare },
{ id: 'variables', name: 'Variables', icon: Code },
{ id: 'dialogue', name: 'Dialogue', icon: MessageSquare },
{ id: 'preview', name: 'JSON Preview', icon: FileText },
{ id: 'import', name: 'Import/Export', icon: Upload }
];
const renderActiveTab = () => {
switch (activeTab) {
case 'basic':
return <NPCBasicSettings config={npcConfig} onChange={setNpcConfig} />;
case 'battle':
return <NPCBattleConfiguration config={npcConfig} onChange={setNpcConfig} />;
case 'party':
return <NPCPartyBuilder config={npcConfig} onChange={setNpcConfig} />;
case 'interaction':
return <NPCInteractionEditor config={npcConfig} onChange={setNpcConfig} />;
case 'variables':
return <ConfigVariablesEditor config={npcConfig} onChange={setNpcConfig} />;
case 'dialogue':
return <DialogueEditor dialogue={dialogueConfig} onChange={setDialogueConfig} />;
case 'preview':
return <JSONPreview npcConfig={npcConfig} dialogueConfig={dialogueConfig} />;
case 'import':
return (
<ImportExport
npcConfig={npcConfig}
dialogueConfig={dialogueConfig}
onNPCConfigLoad={setNpcConfig}
onDialogueConfigLoad={setDialogueConfig}
/>
);
default:
return null;
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">
<div className="flex-shrink-0">
<h1 className="text-xl font-bold text-gray-900">Cobblemon NPC Creator</h1>
</div>
</div>
<div className="text-sm text-gray-500">
Create and customize NPCs for your Cobblemon mod
</div>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="lg:grid lg:grid-cols-12 lg:gap-8">
{/* Sidebar Navigation */}
<div className="lg:col-span-3">
<nav className="space-y-1">
{tabs.map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as Tab)}
className={`w-full group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? 'bg-indigo-100 text-indigo-700 border-r-2 border-indigo-500'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<Icon
className={`flex-shrink-0 -ml-1 mr-3 h-5 w-5 ${
isActive ? 'text-indigo-500' : 'text-gray-400 group-hover:text-gray-500'
}`}
/>
{tab.name}
</button>
);
})}
</nav>
{/* Quick Info Panel */}
<div className="mt-8 bg-white rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Current NPC</h3>
<dl className="space-y-2 text-xs">
<div>
<dt className="font-medium text-gray-700">Name</dt>
<dd className="text-gray-600">{npcConfig.names[0] || 'Unnamed'}</dd>
</div>
<div>
<dt className="font-medium text-gray-700">Type</dt>
<dd className="text-gray-600 capitalize">
{npcConfig.battleConfiguration?.canChallenge ? 'Trainer' : 'NPC'}
</dd>
</div>
<div>
<dt className="font-medium text-gray-700">Interaction</dt>
<dd className="text-gray-600 capitalize">{npcConfig.interaction.type}</dd>
</div>
<div>
<dt className="font-medium text-gray-700">Variables</dt>
<dd className="text-gray-600">{npcConfig.config.length}</dd>
</div>
</dl>
</div>
</div>
{/* Main Content */}
<div className="mt-8 lg:mt-0 lg:col-span-9">
<div className="bg-white shadow rounded-lg">
<div className="px-6 py-8">
{renderActiveTab()}
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 mt-12">
<div className="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<p className="text-center text-sm text-gray-500">
Built for the Cobblemon Minecraft mod - Create amazing NPCs for your world!
</p>
</div>
</footer>
</div>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,169 @@
import React from 'react';
import { Plus, Trash2 } from 'lucide-react';
import type { NPCConfiguration, MoLangConfigVariable } from '../types/npc';
interface ConfigVariablesEditorProps {
config: NPCConfiguration;
onChange: (config: NPCConfiguration) => void;
}
export const ConfigVariablesEditor: React.FC<ConfigVariablesEditorProps> = ({ config, onChange }) => {
const addVariable = () => {
const newVariable: MoLangConfigVariable = {
variableName: '',
displayName: '',
description: '',
type: 'TEXT',
defaultValue: ''
};
onChange({
...config,
config: [...config.config, newVariable]
});
};
const removeVariable = (index: number) => {
onChange({
...config,
config: config.config.filter((_, i) => i !== index)
});
};
const updateVariable = (index: number, field: keyof MoLangConfigVariable, value: any) => {
const newConfig = [...config.config];
newConfig[index] = { ...newConfig[index], [field]: value };
onChange({
...config,
config: newConfig
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Configuration Variables</h2>
<button
type="button"
onClick={addVariable}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
>
<Plus className="h-4 w-4 mr-1" />
Add Variable
</button>
</div>
<p className="text-sm text-gray-600">
Configuration variables allow for customizable NPC behavior through MoLang expressions.
These variables can be referenced in dialogues, scripts, and other configurations.
</p>
{config.config.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No configuration variables defined. Click "Add Variable" to create one.
</div>
) : (
<div className="space-y-4">
{config.config.map((variable, index) => (
<div key={index} className="border rounded-lg p-4 space-y-3">
<div className="flex justify-between items-center">
<h3 className="font-medium text-lg">Variable {index + 1}</h3>
<button
type="button"
onClick={() => removeVariable(index)}
className="p-1 text-red-600 hover:text-red-800"
title="Remove variable"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700">Variable Name</label>
<input
type="text"
value={variable.variableName}
onChange={(e) => updateVariable(index, 'variableName', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="challenge_cooldown"
/>
<p className="text-xs text-gray-500 mt-1">Internal identifier for the variable</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Display Name</label>
<input
type="text"
value={variable.displayName}
onChange={(e) => updateVariable(index, 'displayName', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Challenge Cooldown"
/>
<p className="text-xs text-gray-500 mt-1">Human-readable name shown in UI</p>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Description</label>
<textarea
value={variable.description}
onChange={(e) => updateVariable(index, 'description', e.target.value)}
rows={2}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="How long in ticks the NPC will be un-challengable from that player's last challenge."
/>
<p className="text-xs text-gray-500 mt-1">Detailed description of what this variable controls</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Type</label>
<select
value={variable.type}
onChange={(e) => updateVariable(index, 'type', e.target.value as MoLangConfigVariable['type'])}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="TEXT">Text</option>
<option value="NUMBER">Number</option>
<option value="BOOLEAN">Boolean</option>
</select>
<p className="text-xs text-gray-500 mt-1">Data type for the variable</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Default Value</label>
{variable.type === 'BOOLEAN' ? (
<select
value={variable.defaultValue.toString()}
onChange={(e) => updateVariable(index, 'defaultValue', e.target.value === 'true')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="false">False</option>
<option value="true">True</option>
</select>
) : variable.type === 'NUMBER' ? (
<input
type="number"
value={String(variable.defaultValue)}
onChange={(e) => updateVariable(index, 'defaultValue', e.target.value ? parseFloat(e.target.value) : 0)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="0"
/>
) : (
<input
type="text"
value={String(variable.defaultValue)}
onChange={(e) => updateVariable(index, 'defaultValue', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Default text value"
/>
)}
<p className="text-xs text-gray-500 mt-1">Initial value for the variable</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,414 @@
import React, { useState } from 'react';
import { Plus, Trash2, MessageCircle } from 'lucide-react';
import type { DialogueConfiguration, DialoguePage, DialogueSpeaker } from '../types/npc';
interface DialogueEditorProps {
dialogue: DialogueConfiguration | null;
onChange: (dialogue: DialogueConfiguration) => void;
}
export const DialogueEditor: React.FC<DialogueEditorProps> = ({ dialogue, onChange }) => {
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [speakerEditMode, setSpeakerEditMode] = useState(false);
const initializeDialogue = () => {
const newDialogue: DialogueConfiguration = {
speakers: {
npc: {
name: { type: 'expression', expression: 'q.npc.name' },
face: 'q.npc.face(false);'
},
player: {
name: { type: 'expression', expression: 'q.player.username' },
face: 'q.player.face();'
}
},
pages: [{
id: 'greeting',
speaker: 'npc',
lines: ['Hello there!'],
input: 'q.dialogue.close();'
}]
};
onChange(newDialogue);
setSelectedPageId('greeting');
};
if (!dialogue) {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Dialogue Editor</h2>
<div className="text-center py-8">
<MessageCircle className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">No dialogue configured</p>
<button
type="button"
onClick={initializeDialogue}
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700"
>
Create Dialogue
</button>
</div>
</div>
);
}
const addPage = () => {
const newPageId = `page_${Date.now()}`;
const newPage: DialoguePage = {
id: newPageId,
speaker: 'npc',
lines: ['New dialogue line'],
input: 'q.dialogue.close();'
};
onChange({
...dialogue,
pages: [...dialogue.pages, newPage]
});
setSelectedPageId(newPageId);
};
const updatePage = (pageId: string, updatedPage: DialoguePage) => {
onChange({
...dialogue,
pages: dialogue.pages.map(page =>
page.id === pageId ? updatedPage : page
)
});
};
const deletePage = (pageId: string) => {
onChange({
...dialogue,
pages: dialogue.pages.filter(page => page.id !== pageId)
});
if (selectedPageId === pageId) {
setSelectedPageId(dialogue.pages.length > 1 ? dialogue.pages[0].id : null);
}
};
const addSpeaker = () => {
const speakerName = `speaker_${Object.keys(dialogue.speakers).length + 1}`;
onChange({
...dialogue,
speakers: {
...dialogue.speakers,
[speakerName]: {
name: { type: 'expression', expression: `'${speakerName}'` },
face: ''
}
}
});
};
const updateSpeaker = (speakerId: string, speaker: DialogueSpeaker) => {
onChange({
...dialogue,
speakers: {
...dialogue.speakers,
[speakerId]: speaker
}
});
};
const deleteSpeaker = (speakerId: string) => {
const newSpeakers = { ...dialogue.speakers };
delete newSpeakers[speakerId];
onChange({
...dialogue,
speakers: newSpeakers
});
};
const selectedPage = dialogue.pages.find(page => page.id === selectedPageId);
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Dialogue Editor</h2>
{/* Global Dialogue Settings */}
<div className="border rounded-lg p-4 space-y-3">
<h3 className="font-medium">Global Settings</h3>
<div className="grid grid-cols-1 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700">Initialization Action</label>
<input
type="text"
value={dialogue.initializationAction || ''}
onChange={(e) => onChange({ ...dialogue, initializationAction: e.target.value || undefined })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="q.dialogue.close();"
/>
<p className="text-xs text-gray-500 mt-1">MoLang script executed when dialogue starts</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Escape Action</label>
<input
type="text"
value={dialogue.escapeAction || ''}
onChange={(e) => onChange({ ...dialogue, escapeAction: e.target.value || undefined })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="q.dialogue.close();"
/>
<p className="text-xs text-gray-500 mt-1">MoLang script executed when ESC is pressed</p>
</div>
</div>
</div>
{/* Speakers Management */}
<div className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium">Speakers</h3>
<div className="space-x-2">
<button
type="button"
onClick={() => setSpeakerEditMode(!speakerEditMode)}
className="text-sm text-indigo-600 hover:text-indigo-800"
>
{speakerEditMode ? 'Done' : 'Edit Speakers'}
</button>
<button
type="button"
onClick={addSpeaker}
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
>
<Plus className="h-3 w-3 mr-1" />
Add Speaker
</button>
</div>
</div>
{speakerEditMode ? (
<div className="space-y-3">
{Object.entries(dialogue.speakers).map(([speakerId, speaker]) => (
<div key={speakerId} className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<strong className="text-sm">{speakerId}</strong>
{!['npc', 'player'].includes(speakerId) && (
<button
type="button"
onClick={() => deleteSpeaker(speakerId)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-700">Name Expression</label>
<input
type="text"
value={speaker.name.expression || ''}
onChange={(e) => updateSpeaker(speakerId, {
...speaker,
name: { ...speaker.name, expression: e.target.value }
})}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="q.npc.name"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Face Expression</label>
<input
type="text"
value={speaker.face || ''}
onChange={(e) => updateSpeaker(speakerId, {
...speaker,
face: e.target.value
})}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="q.npc.face(false);"
/>
</div>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-wrap gap-2">
{Object.keys(dialogue.speakers).map(speakerId => (
<span key={speakerId} className="px-2 py-1 bg-gray-100 rounded-md text-sm">
{speakerId}
</span>
))}
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Pages List */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium">Pages</h3>
<button
type="button"
onClick={addPage}
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
>
<Plus className="h-3 w-3 mr-1" />
Add Page
</button>
</div>
<div className="space-y-1 max-h-96 overflow-y-auto">
{dialogue.pages.map(page => (
<div
key={page.id}
className={`p-2 rounded cursor-pointer border ${
selectedPageId === page.id
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
onClick={() => setSelectedPageId(page.id)}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">{page.id}</div>
<div className="text-xs text-gray-500">Speaker: {page.speaker}</div>
<div className="text-xs text-gray-600 truncate">
{Array.isArray(page.lines)
? (typeof page.lines[0] === 'string' ? page.lines[0] : page.lines[0]?.text || 'Expression')
: (typeof page.lines === 'string' ? page.lines : page.lines?.text || 'Expression')}
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
deletePage(page.id);
}}
className="text-red-600 hover:text-red-800 opacity-0 group-hover:opacity-100"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
))}
</div>
</div>
{/* Page Editor */}
<div className="lg:col-span-2">
{selectedPage ? (
<PageEditor
page={selectedPage}
speakers={dialogue.speakers}
onChange={(updatedPage) => updatePage(selectedPage.id, updatedPage)}
/>
) : (
<div className="text-center py-8 text-gray-500">
Select a page to edit
</div>
)}
</div>
</div>
</div>
);
};
interface PageEditorProps {
page: DialoguePage;
speakers: Record<string, DialogueSpeaker>;
onChange: (page: DialoguePage) => void;
}
const PageEditor: React.FC<PageEditorProps> = ({ page, speakers, onChange }) => {
const updatePage = (field: keyof DialoguePage, value: any) => {
onChange({ ...page, [field]: value });
};
const updateLine = (index: number, value: string) => {
const newLines = [...page.lines];
newLines[index] = value;
updatePage('lines', newLines);
};
const addLine = () => {
updatePage('lines', [...page.lines, '']);
};
const removeLine = (index: number) => {
updatePage('lines', page.lines.filter((_, i) => i !== index));
};
return (
<div className="space-y-4 border rounded-lg p-4">
<h3 className="font-medium text-lg">Edit Page: {page.id}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Page ID</label>
<input
type="text"
value={page.id}
onChange={(e) => updatePage('id', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Speaker</label>
<select
value={page.speaker}
onChange={(e) => updatePage('speaker', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
{Object.keys(speakers).map(speakerId => (
<option key={speakerId} value={speakerId}>{speakerId}</option>
))}
</select>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700">Lines</label>
<button
type="button"
onClick={addLine}
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
>
<Plus className="h-3 w-3 mr-1" />
Add Line
</button>
</div>
<div className="space-y-2">
{page.lines.map((line, index) => (
<div key={index} className="flex items-center space-x-2">
<input
type="text"
value={typeof line === 'string' ? line : line.expression || ''}
onChange={(e) => updateLine(index, e.target.value)}
className="flex-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="Dialogue text or MoLang expression"
/>
<button
type="button"
onClick={() => removeLine(index)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Input/Action</label>
<textarea
value={typeof page.input === 'string' ? page.input : JSON.stringify(page.input, null, 2)}
onChange={(e) => updatePage('input', e.target.value)}
rows={3}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm font-mono"
placeholder="q.dialogue.close(); or JSON for options"
/>
<p className="text-xs text-gray-500 mt-1">
MoLang script or JSON for dialogue options
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,316 @@
import React, { useRef } from 'react';
import { Upload, Download, FileText, AlertTriangle } from 'lucide-react';
import type { NPCConfiguration, DialogueConfiguration } from '../types/npc';
interface ImportExportProps {
npcConfig: NPCConfiguration;
dialogueConfig: DialogueConfiguration | null;
onNPCConfigLoad: (config: NPCConfiguration) => void;
onDialogueConfigLoad: (config: DialogueConfiguration) => void;
}
export const ImportExport: React.FC<ImportExportProps> = ({
npcConfig,
dialogueConfig,
onNPCConfigLoad,
onDialogueConfigLoad
}) => {
const npcFileInputRef = useRef<HTMLInputElement>(null);
const dialogueFileInputRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = React.useState<string | null>(null);
const [importSuccess, setImportSuccess] = React.useState<string | null>(null);
const handleFileRead = (
file: File,
onLoad: (config: any) => void,
configType: string
) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const config = JSON.parse(content);
// Basic validation
if (configType === 'npc') {
if (!config.resourceIdentifier || !config.names) {
throw new Error('Invalid NPC configuration: missing required fields');
}
} else if (configType === 'dialogue') {
if (!config.pages || !config.speakers) {
throw new Error('Invalid dialogue configuration: missing required fields');
}
}
onLoad(config);
setImportSuccess(`${configType} configuration loaded successfully!`);
setImportError(null);
setTimeout(() => setImportSuccess(null), 3000);
} catch (error) {
setImportError(`Error loading ${configType}: ${error instanceof Error ? error.message : 'Invalid JSON'}`);
setImportSuccess(null);
}
};
reader.readAsText(file);
};
const handleNPCFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFileRead(file, onNPCConfigLoad, 'npc');
}
};
const handleDialogueFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFileRead(file, onDialogueConfigLoad, 'dialogue');
}
};
const exportExample = () => {
const exampleNPC: NPCConfiguration = {
hitbox: "player",
presets: [],
resourceIdentifier: "mymod:example_npc",
config: [
{
variableName: "greeting_message",
displayName: "Greeting Message",
description: "The message displayed when first talking to the NPC",
type: "TEXT",
defaultValue: "Hello, trainer!"
}
],
isInvulnerable: true,
canDespawn: false,
names: ["Example NPC"],
interaction: {
type: "dialogue",
dialogue: "mymod:example_dialogue"
},
battleConfiguration: {
canChallenge: true
},
skill: 3,
party: {
type: "simple",
pokemon: [
"pikachu level=25 moves=thunderbolt,quick-attack",
"charmander level=24 moves=ember,scratch"
]
}
};
const exampleDialogue: DialogueConfiguration = {
speakers: {
npc: {
name: { type: "expression", expression: "q.npc.name" },
face: "q.npc.face(false);"
},
player: {
name: { type: "expression", expression: "q.player.username" },
face: "q.player.face();"
}
},
pages: [
{
id: "greeting",
speaker: "npc",
lines: ["Hello there, trainer! Would you like to battle?"],
input: {
type: "option",
vertical: true,
options: [
{
text: "Yes, let's battle!",
value: "accept",
action: ["q.npc.start_battle(q.player, 'single');"]
},
{
text: "Maybe later.",
value: "decline",
action: ["q.dialogue.close();"]
}
]
}
}
]
};
// Download both files
const downloadFile = (content: string, filename: string) => {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
downloadFile(JSON.stringify(exampleNPC, null, 2), 'example_npc.json');
downloadFile(JSON.stringify(exampleDialogue, null, 2), 'example_dialogue.json');
};
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Import & Export</h2>
{/* Status Messages */}
{importError && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-red-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Import Error</h3>
<p className="text-sm text-red-700">{importError}</p>
</div>
</div>
</div>
)}
{importSuccess && (
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<div className="flex">
<FileText className="h-5 w-5 text-green-400" />
<div className="ml-3">
<p className="text-sm font-medium text-green-800">{importSuccess}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Import Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Import Configurations</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Import NPC Configuration
</label>
<input
ref={npcFileInputRef}
type="file"
accept=".json"
onChange={handleNPCFileSelect}
className="hidden"
/>
<button
type="button"
onClick={() => npcFileInputRef.current?.click()}
className="w-full inline-flex items-center justify-center px-4 py-2 border border-gray-300 shadow-sm bg-white text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50"
>
<Upload className="h-4 w-4 mr-2" />
Choose NPC JSON File
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Import Dialogue Configuration
</label>
<input
ref={dialogueFileInputRef}
type="file"
accept=".json"
onChange={handleDialogueFileSelect}
className="hidden"
/>
<button
type="button"
onClick={() => dialogueFileInputRef.current?.click()}
className="w-full inline-flex items-center justify-center px-4 py-2 border border-gray-300 shadow-sm bg-white text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50"
>
<Upload className="h-4 w-4 mr-2" />
Choose Dialogue JSON File
</button>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-900 text-sm">Import Tips</h4>
<ul className="mt-2 text-xs text-blue-800 space-y-1">
<li> Files must be valid JSON format</li>
<li> NPC configs require resourceIdentifier and names</li>
<li> Dialogue configs require pages and speakers</li>
<li> Import will override current configuration</li>
</ul>
</div>
</div>
{/* Export/Examples Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Examples & Templates</h3>
<div className="space-y-3">
<button
type="button"
onClick={exportExample}
className="w-full inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<Download className="h-4 w-4 mr-2" />
Download Example Files
</button>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="font-medium text-gray-900 text-sm mb-2">Example Includes:</h4>
<ul className="text-xs text-gray-700 space-y-1">
<li> Complete NPC with battle configuration</li>
<li> Simple dialogue with options</li>
<li> Configuration variables example</li>
<li> Battle party setup</li>
<li> MoLang expressions</li>
</ul>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 className="font-medium text-yellow-800 text-sm">Quick Start</h4>
<p className="mt-1 text-xs text-yellow-700">
Download the example files to see a complete working NPC configuration.
You can then import and modify them to create your own NPCs.
</p>
</div>
</div>
</div>
{/* Current Configuration Summary */}
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-3">Current Configuration Summary</h3>
<div className="bg-gray-50 rounded-lg p-4">
<dl className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<dt className="font-medium text-gray-900">NPC Name</dt>
<dd className="text-gray-700">{npcConfig.names[0] || 'Unnamed NPC'}</dd>
</div>
<div>
<dt className="font-medium text-gray-900">Resource ID</dt>
<dd className="text-gray-700 font-mono text-xs">{npcConfig.resourceIdentifier || 'Not set'}</dd>
</div>
<div>
<dt className="font-medium text-gray-900">Interaction</dt>
<dd className="text-gray-700 capitalize">{npcConfig.interaction.type}</dd>
</div>
<div>
<dt className="font-medium text-gray-900">Can Battle</dt>
<dd className="text-gray-700">{npcConfig.battleConfiguration?.canChallenge ? 'Yes' : 'No'}</dd>
</div>
<div>
<dt className="font-medium text-gray-900">Config Variables</dt>
<dd className="text-gray-700">{npcConfig.config.length}</dd>
</div>
<div>
<dt className="font-medium text-gray-900">Has Dialogue</dt>
<dd className="text-gray-700">{dialogueConfig ? 'Yes' : 'No'}</dd>
</div>
</dl>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,192 @@
import React from 'react';
import { Download, Copy, Check } from 'lucide-react';
import type { NPCConfiguration, DialogueConfiguration } from '../types/npc';
import { ValidationPanel } from './ValidationPanel';
interface JSONPreviewProps {
npcConfig: NPCConfiguration;
dialogueConfig: DialogueConfiguration | null;
}
export const JSONPreview: React.FC<JSONPreviewProps> = ({ npcConfig, dialogueConfig }) => {
const [copiedNPC, setCopiedNPC] = React.useState(false);
const [copiedDialogue, setCopiedDialogue] = React.useState(false);
const cleanNPCConfig = (config: NPCConfiguration) => {
const cleaned = { ...config };
// Remove empty or undefined values
Object.keys(cleaned).forEach(key => {
const value = cleaned[key as keyof NPCConfiguration];
if (value === undefined || value === null ||
(typeof value === 'string' && value === '') ||
(Array.isArray(value) && value.length === 0)) {
delete cleaned[key as keyof NPCConfiguration];
}
});
// Remove empty battle configuration
if (cleaned.battleConfiguration && !cleaned.battleConfiguration.canChallenge) {
delete cleaned.battleConfiguration;
}
// Remove party if no battle configuration
if (!cleaned.battleConfiguration?.canChallenge) {
delete cleaned.party;
}
return cleaned;
};
const npcJson = JSON.stringify(cleanNPCConfig(npcConfig), null, 2);
const dialogueJson = dialogueConfig ? JSON.stringify(dialogueConfig, null, 2) : null;
const copyToClipboard = async (text: string, type: 'npc' | 'dialogue') => {
try {
await navigator.clipboard.writeText(text);
if (type === 'npc') {
setCopiedNPC(true);
setTimeout(() => setCopiedNPC(false), 2000);
} else {
setCopiedDialogue(true);
setTimeout(() => setCopiedDialogue(false), 2000);
}
} catch (err) {
console.error('Failed to copy to clipboard:', err);
}
};
const downloadJSON = (content: string, filename: string) => {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const generateFilenames = () => {
const baseName = npcConfig.resourceIdentifier
.split(':')[1] || 'npc';
return {
npc: `${baseName}.json`,
dialogue: `${baseName}-dialogue.json`
};
};
const filenames = generateFilenames();
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">JSON Preview & Export</h2>
{/* Validation Panel */}
<ValidationPanel npcConfig={npcConfig} dialogueConfig={dialogueConfig} />
{/* NPC Configuration */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">NPC Configuration</h3>
<div className="flex space-x-2">
<button
type="button"
onClick={() => copyToClipboard(npcJson, 'npc')}
className="inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
{copiedNPC ? (
<Check className="h-4 w-4 mr-1 text-green-600" />
) : (
<Copy className="h-4 w-4 mr-1" />
)}
Copy
</button>
<button
type="button"
onClick={() => downloadJSON(npcJson, filenames.npc)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<Download className="h-4 w-4 mr-1" />
Download
</button>
</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 overflow-auto max-h-96">
<pre className="text-sm text-gray-100 whitespace-pre-wrap">
{npcJson}
</pre>
</div>
<p className="text-sm text-gray-600">
Save as: <code className="bg-gray-100 px-1 rounded">{filenames.npc}</code> in your mod's data folder
</p>
</div>
{/* Dialogue Configuration */}
{dialogueConfig && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">Dialogue Configuration</h3>
<div className="flex space-x-2">
<button
type="button"
onClick={() => copyToClipboard(dialogueJson!, 'dialogue')}
className="inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
{copiedDialogue ? (
<Check className="h-4 w-4 mr-1 text-green-600" />
) : (
<Copy className="h-4 w-4 mr-1" />
)}
Copy
</button>
<button
type="button"
onClick={() => downloadJSON(dialogueJson!, filenames.dialogue)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<Download className="h-4 w-4 mr-1" />
Download
</button>
</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 overflow-auto max-h-96">
<pre className="text-sm text-gray-100 whitespace-pre-wrap">
{dialogueJson}
</pre>
</div>
<p className="text-sm text-gray-600">
Save as: <code className="bg-gray-100 px-1 rounded">{filenames.dialogue}</code> in your mod's dialogues folder
</p>
</div>
)}
{/* File Structure Guide */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2">File Structure Guide</h4>
<div className="text-sm text-blue-800 space-y-1">
<p>Place your files in the following locations within your mod:</p>
<ul className="list-disc list-inside ml-2 space-y-1">
<li>
<strong>NPC Config:</strong> <code>data/&lt;namespace&gt;/npcs/{filenames.npc}</code>
</li>
{dialogueConfig && (
<li>
<strong>Dialogue Config:</strong> <code>data/&lt;namespace&gt;/dialogues/{filenames.dialogue}</code>
</li>
)}
</ul>
<p className="mt-2 text-xs">
Replace <code>&lt;namespace&gt;</code> with your mod's namespace (e.g., "cobblemon", "mymod")
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,199 @@
import React from 'react';
import type { NPCConfiguration, NPCHitboxValue } from '../types/npc';
interface NPCBasicSettingsProps {
config: NPCConfiguration;
onChange: (config: NPCConfiguration) => void;
}
export const NPCBasicSettings: React.FC<NPCBasicSettingsProps> = ({ config, onChange }) => {
const handleChange = (field: keyof NPCConfiguration, value: any) => {
onChange({ ...config, [field]: value });
};
const handleHitboxChange = (hitbox: NPCHitboxValue) => {
handleChange('hitbox', hitbox);
};
const handleNamesChange = (names: string) => {
handleChange('names', names.split(',').map(name => name.trim()).filter(name => name));
};
const handleAspectsChange = (aspects: string) => {
handleChange('aspects', aspects ? aspects.split(',').map(aspect => aspect.trim()).filter(aspect => aspect) : undefined);
};
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Basic Settings</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Resource Identifier</label>
<input
type="text"
value={config.resourceIdentifier}
onChange={(e) => handleChange('resourceIdentifier', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="cobblemon:npc_name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Names (comma-separated)</label>
<input
type="text"
value={config.names.join(', ')}
onChange={(e) => handleNamesChange(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="NPC Name 1, NPC Name 2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Hitbox</label>
<div className="mt-1 space-y-2">
<label className="inline-flex items-center">
<input
type="radio"
checked={config.hitbox === "player"}
onChange={() => handleHitboxChange("player")}
className="form-radio"
/>
<span className="ml-2">Player (default)</span>
</label>
<div className="space-y-1">
<label className="inline-flex items-center">
<input
type="radio"
checked={typeof config.hitbox === "object"}
onChange={() => handleHitboxChange({ width: 0.6, height: 1.8 })}
className="form-radio"
/>
<span className="ml-2">Custom</span>
</label>
{typeof config.hitbox === "object" && (
<div className="ml-6 grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-600">Width</label>
<input
type="number"
step="0.1"
value={config.hitbox.width}
onChange={(e) => handleHitboxChange({
width: parseFloat(e.target.value),
height: typeof config.hitbox === "object" ? config.hitbox.height : 1.8
})}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
/>
</div>
<div>
<label className="block text-xs text-gray-600">Height</label>
<input
type="number"
step="0.1"
value={config.hitbox.height}
onChange={(e) => handleHitboxChange({
width: typeof config.hitbox === "object" ? config.hitbox.width : 0.6,
height: parseFloat(e.target.value)
})}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
/>
</div>
</div>
)}
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Model Scale</label>
<input
type="number"
step="0.01"
value={config.modelScale || 0.9375}
onChange={(e) => handleChange('modelScale', parseFloat(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Aspects (comma-separated)</label>
<input
type="text"
value={config.aspects?.join(', ') || ''}
onChange={(e) => handleAspectsChange(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="aspect1, aspect2"
/>
</div>
</div>
<div className="space-y-3">
<h3 className="text-lg font-medium text-gray-900">Behavior Settings</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={config.isInvulnerable || false}
onChange={(e) => handleChange('isInvulnerable', e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Invulnerable</span>
</label>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={!(config.canDespawn ?? true)}
onChange={(e) => handleChange('canDespawn', !e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Persistent</span>
</label>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={config.isMovable ?? true}
onChange={(e) => handleChange('isMovable', e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Movable</span>
</label>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={config.isLeashable || false}
onChange={(e) => handleChange('isLeashable', e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Leashable</span>
</label>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={config.allowProjectileHits ?? true}
onChange={(e) => handleChange('allowProjectileHits', e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Projectile Hits</span>
</label>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={config.hideNameTag || false}
onChange={(e) => handleChange('hideNameTag', e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Hide Name Tag</span>
</label>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,129 @@
import React from 'react';
import type { NPCConfiguration } from '../types/npc';
interface NPCBattleConfigurationProps {
config: NPCConfiguration;
onChange: (config: NPCConfiguration) => void;
}
export const NPCBattleConfiguration: React.FC<NPCBattleConfigurationProps> = ({ config, onChange }) => {
const handleChange = (field: keyof NPCConfiguration, value: any) => {
onChange({ ...config, [field]: value });
};
const handleBattleConfigChange = (field: string, value: any) => {
onChange({
...config,
battleConfiguration: {
canChallenge: config.battleConfiguration?.canChallenge ?? false,
...config.battleConfiguration,
[field]: value
}
});
};
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Battle Configuration</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={config.battleConfiguration?.canChallenge || false}
onChange={(e) => handleBattleConfigChange('canChallenge', e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Can Challenge</span>
</label>
<p className="text-xs text-gray-500 mt-1">Allow players to battle this NPC</p>
</div>
{config.battleConfiguration?.canChallenge && (
<>
<div>
<label className="block text-sm font-medium text-gray-700">Skill Level</label>
<select
value={config.skill || 1}
onChange={(e) => handleChange('skill', parseInt(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value={1}>1 - Beginner</option>
<option value={2}>2 - Novice</option>
<option value={3}>3 - Intermediate</option>
<option value={4}>4 - Advanced</option>
<option value={5}>5 - Expert</option>
</select>
<p className="text-xs text-gray-500 mt-1">AI difficulty level (1-5)</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Battle Theme</label>
<input
type="text"
value={config.battleTheme || ''}
onChange={(e) => handleChange('battleTheme', e.target.value || undefined)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="cobblemon:battle_music"
/>
<p className="text-xs text-gray-500 mt-1">Resource location for battle music</p>
</div>
<div className="space-y-2">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={config.autoHealParty || false}
onChange={(e) => handleChange('autoHealParty', e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Auto Heal Party</span>
</label>
<p className="text-xs text-gray-500">Heal Pokemon between battles</p>
</div>
<div className="space-y-2">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={config.randomizePartyOrder || false}
onChange={(e) => handleChange('randomizePartyOrder', e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Randomize Party Order</span>
</label>
<p className="text-xs text-gray-500">Randomize lead Pokemon</p>
</div>
<div className="space-y-2">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={config.battleConfiguration?.simultaneousBattles || false}
onChange={(e) => handleBattleConfigChange('simultaneousBattles', e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Simultaneous Battles</span>
</label>
<p className="text-xs text-gray-500">Allow multiple players to battle at once (deprecated)</p>
</div>
<div className="space-y-2">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={config.battleConfiguration?.healAfterwards ?? true}
onChange={(e) => handleBattleConfigChange('healAfterwards', e.target.checked)}
className="form-checkbox"
/>
<span className="ml-2">Heal Afterwards</span>
</label>
<p className="text-xs text-gray-500">Heal player's party after battle (deprecated)</p>
</div>
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,115 @@
import React from 'react';
import type { NPCConfiguration, NPCInteraction } from '../types/npc';
interface NPCInteractionEditorProps {
config: NPCConfiguration;
onChange: (config: NPCConfiguration) => void;
}
export const NPCInteractionEditor: React.FC<NPCInteractionEditorProps> = ({ config, onChange }) => {
const handleInteractionChange = (interaction: NPCInteraction) => {
onChange({ ...config, interaction });
};
const handleTypeChange = (type: NPCInteraction['type']) => {
switch (type) {
case 'dialogue':
handleInteractionChange({ type: 'dialogue', dialogue: '' });
break;
case 'script':
handleInteractionChange({ type: 'script', script: '' });
break;
case 'custom_script':
handleInteractionChange({ type: 'custom_script', script: '' });
break;
case 'none':
handleInteractionChange({ type: 'none' });
break;
}
};
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Interaction Configuration</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Interaction Type</label>
<div className="space-y-2">
{(['dialogue', 'script', 'custom_script', 'none'] as const).map((type) => (
<label key={type} className="inline-flex items-center mr-6">
<input
type="radio"
value={type}
checked={config.interaction.type === type}
onChange={(e) => handleTypeChange(e.target.value as NPCInteraction['type'])}
className="form-radio"
/>
<span className="ml-2">
{type === 'dialogue' && 'Dialogue'}
{type === 'script' && 'Predefined Script'}
{type === 'custom_script' && 'Custom Script'}
{type === 'none' && 'No Interaction'}
</span>
</label>
))}
</div>
</div>
{config.interaction.type === 'dialogue' && (
<div>
<label className="block text-sm font-medium text-gray-700">Dialogue Resource Location</label>
<input
type="text"
value={config.interaction.dialogue}
onChange={(e) => handleInteractionChange({ type: 'dialogue', dialogue: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="cobblemon:my_dialogue"
/>
<p className="text-xs text-gray-500 mt-1">
Reference to a dialogue file (e.g., "cobblemon:my_dialogue" refers to data/cobblemon/dialogues/my_dialogue.json)
</p>
</div>
)}
{config.interaction.type === 'script' && (
<div>
<label className="block text-sm font-medium text-gray-700">Script Resource Location</label>
<input
type="text"
value={config.interaction.script}
onChange={(e) => handleInteractionChange({ type: 'script', script: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="cobblemon:my_script"
/>
<p className="text-xs text-gray-500 mt-1">
Reference to a MoLang script file (e.g., "cobblemon:my_script" refers to data/cobblemon/molang/my_script.molang)
</p>
</div>
)}
{config.interaction.type === 'custom_script' && (
<div>
<label className="block text-sm font-medium text-gray-700">Custom MoLang Script</label>
<textarea
value={config.interaction.script}
onChange={(e) => handleInteractionChange({ type: 'custom_script', script: e.target.value })}
rows={4}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="q.player.send_message('Hello from custom script!');"
/>
<p className="text-xs text-gray-500 mt-1">
Direct MoLang script code to execute when player interacts with NPC
</p>
</div>
)}
{config.interaction.type === 'none' && (
<div className="bg-gray-50 p-4 rounded-md">
<p className="text-sm text-gray-600">
This NPC will not respond to player interactions. Useful for decorative NPCs.
</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,326 @@
import React, { useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import type { NPCConfiguration, NPCPartyProvider, SimplePartyProvider, PoolPartyProvider, PoolEntry } from '../types/npc';
interface NPCPartyBuilderProps {
config: NPCConfiguration;
onChange: (config: NPCConfiguration) => void;
}
export const NPCPartyBuilder: React.FC<NPCPartyBuilderProps> = ({ config, onChange }) => {
const [partyType, setPartyType] = useState<'simple' | 'pool' | 'script'>(config.party?.type || 'simple');
const handlePartyChange = (party: NPCPartyProvider) => {
onChange({ ...config, party });
};
const handlePartyTypeChange = (type: 'simple' | 'pool' | 'script') => {
setPartyType(type);
switch (type) {
case 'simple':
handlePartyChange({ type: 'simple', pokemon: [''] });
break;
case 'pool':
handlePartyChange({ type: 'pool', pool: [{ pokemon: '', weight: 1 }] });
break;
case 'script':
handlePartyChange({ type: 'script', script: '' });
break;
}
};
const renderSimpleParty = () => {
const party = config.party as SimplePartyProvider;
if (!party || !party.pokemon) {
return null;
}
const addPokemon = () => {
handlePartyChange({
...party,
pokemon: [...party.pokemon, '']
});
};
const removePokemon = (index: number) => {
handlePartyChange({
...party,
pokemon: party.pokemon.filter((_, i) => i !== index)
});
};
const updatePokemon = (index: number, value: string) => {
const newPokemon = [...party.pokemon];
newPokemon[index] = value;
handlePartyChange({
...party,
pokemon: newPokemon
});
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-medium">Pokemon List</h4>
<button
type="button"
onClick={addPokemon}
className="inline-flex items-center px-2 py-1 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
>
<Plus className="h-3 w-3 mr-1" />
Add Pokemon
</button>
</div>
{party.pokemon.map((pokemon, index) => (
<div key={index} className="flex items-center space-x-2">
<input
type="text"
value={pokemon}
onChange={(e) => updatePokemon(index, e.target.value)}
className="flex-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="pikachu level=50 moves=thunderbolt,quick-attack"
/>
<button
type="button"
onClick={() => removePokemon(index)}
className="p-1 text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
<div className="mt-4">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={party.isStatic || false}
onChange={(e) => handlePartyChange({ ...party, isStatic: e.target.checked })}
className="form-checkbox"
/>
<span className="ml-2 text-sm">Static Party</span>
</label>
<p className="text-xs text-gray-500 mt-1">If true, party won't change between battles</p>
</div>
</div>
);
};
const renderPoolParty = () => {
const party = config.party as PoolPartyProvider;
const addPoolEntry = () => {
handlePartyChange({
...party,
pool: [...party.pool, { pokemon: '', weight: 1 }]
});
};
const removePoolEntry = (index: number) => {
handlePartyChange({
...party,
pool: party.pool.filter((_, i) => i !== index)
});
};
const updatePoolEntry = (index: number, field: keyof PoolEntry, value: any) => {
const newPool = [...party.pool];
newPool[index] = { ...newPool[index], [field]: value };
handlePartyChange({
...party,
pool: newPool
});
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Min Pokemon</label>
<input
type="number"
min="1"
max="6"
value={party.minPokemon || 1}
onChange={(e) => handlePartyChange({ ...party, minPokemon: parseInt(e.target.value) })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Max Pokemon</label>
<input
type="number"
min="1"
max="6"
value={party.maxPokemon || 6}
onChange={(e) => handlePartyChange({ ...party, maxPokemon: parseInt(e.target.value) })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex items-center justify-between">
<h4 className="font-medium">Pool Entries</h4>
<button
type="button"
onClick={addPoolEntry}
className="inline-flex items-center px-2 py-1 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
>
<Plus className="h-3 w-3 mr-1" />
Add Entry
</button>
</div>
{party.pool.map((entry, index) => (
<div key={index} className="border rounded-lg p-3 space-y-2">
<div className="flex justify-between items-center">
<h5 className="font-medium text-sm">Entry {index + 1}</h5>
<button
type="button"
onClick={() => removePoolEntry(index)}
className="p-1 text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Pokemon</label>
<input
type="text"
value={entry.pokemon}
onChange={(e) => updatePoolEntry(index, 'pokemon', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="pikachu level=50"
/>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="block text-xs font-medium text-gray-700">Weight</label>
<input
type="number"
min="0"
step="0.1"
value={entry.weight || 1}
onChange={(e) => updatePoolEntry(index, 'weight', parseFloat(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Selectable Times</label>
<input
type="number"
min="0"
value={entry.selectableTimes || ''}
onChange={(e) => updatePoolEntry(index, 'selectableTimes', e.target.value ? parseInt(e.target.value) : undefined)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Level Variation</label>
<input
type="number"
min="0"
value={entry.levelVariation || ''}
onChange={(e) => updatePoolEntry(index, 'levelVariation', e.target.value ? parseInt(e.target.value) : undefined)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
/>
</div>
</div>
</div>
))}
<div className="grid grid-cols-2 gap-4">
<label className="inline-flex items-center">
<input
type="checkbox"
checked={party.isStatic || false}
onChange={(e) => handlePartyChange({ ...party, isStatic: e.target.checked })}
className="form-checkbox"
/>
<span className="ml-2 text-sm">Static</span>
</label>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={party.useFixedRandom || false}
onChange={(e) => handlePartyChange({ ...party, useFixedRandom: e.target.checked })}
className="form-checkbox"
/>
<span className="ml-2 text-sm">Fixed Random</span>
</label>
</div>
</div>
);
};
const renderScriptParty = () => {
const party = config.party as { type: 'script'; script: string; isStatic?: boolean };
return (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700">Script Resource Location</label>
<input
type="text"
value={party.script}
onChange={(e) => handlePartyChange({ ...party, script: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="cobblemon:party_script"
/>
</div>
<label className="inline-flex items-center">
<input
type="checkbox"
checked={party.isStatic || false}
onChange={(e) => handlePartyChange({ ...party, isStatic: e.target.checked })}
className="form-checkbox"
/>
<span className="ml-2 text-sm">Static Party</span>
</label>
</div>
);
};
if (!config.battleConfiguration?.canChallenge) {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Pokemon Party</h2>
<p className="text-gray-500 italic">Enable battle configuration to set up a party</p>
</div>
);
}
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Pokemon Party</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Party Type</label>
<div className="flex space-x-4">
{(['simple', 'pool', 'script'] as const).map((type) => (
<label key={type} className="inline-flex items-center">
<input
type="radio"
value={type}
checked={partyType === type}
onChange={(e) => handlePartyTypeChange(e.target.value as 'simple' | 'pool' | 'script')}
className="form-radio"
/>
<span className="ml-2 capitalize">{type}</span>
</label>
))}
</div>
</div>
{partyType === 'simple' && renderSimpleParty()}
{partyType === 'pool' && renderPoolParty()}
{partyType === 'script' && renderScriptParty()}
</div>
);
};

View File

@@ -0,0 +1,126 @@
import React from 'react';
import { AlertTriangle, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import type { NPCConfiguration, DialogueConfiguration } from '../types/npc';
import { validateNPCConfiguration, validateDialogueConfiguration, getValidationSummary } from '../utils/validation';
import type { ValidationError } from '../utils/validation';
interface ValidationPanelProps {
npcConfig: NPCConfiguration;
dialogueConfig: DialogueConfiguration | null;
}
export const ValidationPanel: React.FC<ValidationPanelProps> = ({ npcConfig, dialogueConfig }) => {
const npcErrors = validateNPCConfiguration(npcConfig);
const dialogueErrors = dialogueConfig ? validateDialogueConfiguration(dialogueConfig) : [];
const allErrors = [...npcErrors, ...dialogueErrors];
const summary = getValidationSummary(allErrors);
const ErrorItem: React.FC<{ error: ValidationError }> = ({ error }) => (
<div className={`flex items-start space-x-2 p-2 rounded ${
error.severity === 'error' ? 'bg-red-50 text-red-800' : 'bg-yellow-50 text-yellow-800'
}`}>
{error.severity === 'error' ? (
<XCircle className="h-4 w-4 mt-0.5 text-red-600 flex-shrink-0" />
) : (
<AlertTriangle className="h-4 w-4 mt-0.5 text-yellow-600 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{error.field}</p>
<p className="text-xs">{error.message}</p>
</div>
</div>
);
if (allErrors.length === 0) {
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-green-600" />
<div className="ml-3">
<h3 className="text-sm font-medium text-green-800">Configuration Valid</h3>
<p className="text-sm text-green-700">No validation errors found. Your NPC configuration is ready to export!</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Summary */}
<div className={`border rounded-lg p-4 ${
summary.hasErrors ? 'bg-red-50 border-red-200' : 'bg-yellow-50 border-yellow-200'
}`}>
<div className="flex items-center">
{summary.hasErrors ? (
<XCircle className="h-5 w-5 text-red-600" />
) : (
<AlertCircle className="h-5 w-5 text-yellow-600" />
)}
<div className="ml-3">
<h3 className={`text-sm font-medium ${
summary.hasErrors ? 'text-red-800' : 'text-yellow-800'
}`}>
Validation {summary.hasErrors ? 'Errors' : 'Warnings'} Found
</h3>
<p className={`text-sm ${
summary.hasErrors ? 'text-red-700' : 'text-yellow-700'
}`}>
{summary.errorCount > 0 && `${summary.errorCount} error${summary.errorCount > 1 ? 's' : ''}`}
{summary.errorCount > 0 && summary.warningCount > 0 && ', '}
{summary.warningCount > 0 && `${summary.warningCount} warning${summary.warningCount > 1 ? 's' : ''}`}
</p>
</div>
</div>
</div>
{/* Errors List */}
<div className="space-y-2">
{summary.errorCount > 0 && (
<div>
<h4 className="text-sm font-medium text-red-900 mb-2 flex items-center">
<XCircle className="h-4 w-4 mr-1" />
Errors ({summary.errorCount})
</h4>
<div className="space-y-1">
{allErrors
.filter(error => error.severity === 'error')
.map((error, index) => (
<ErrorItem key={`error-${index}`} error={error} />
))}
</div>
</div>
)}
{summary.warningCount > 0 && (
<div>
<h4 className="text-sm font-medium text-yellow-900 mb-2 flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" />
Warnings ({summary.warningCount})
</h4>
<div className="space-y-1">
{allErrors
.filter(error => error.severity === 'warning')
.map((error, index) => (
<ErrorItem key={`warning-${index}`} error={error} />
))}
</div>
</div>
)}
</div>
{/* Export Status */}
<div className={`border-t pt-4 ${
summary.hasErrors ? 'text-red-700' : 'text-yellow-700'
}`}>
<p className="text-sm">
{summary.hasErrors
? '⚠️ Fix all errors before exporting your configuration.'
: '✅ Configuration has warnings but can still be exported.'
}
</p>
</div>
</div>
);
};

3
src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

157
src/types/npc.ts Normal file
View File

@@ -0,0 +1,157 @@
export interface NPCHitbox {
width: number;
height: number;
}
export type NPCHitboxValue = "player" | NPCHitbox;
export interface MoLangConfigVariable {
variableName: string;
displayName: string;
description: string;
type: "NUMBER" | "TEXT" | "BOOLEAN";
defaultValue: string | number | boolean;
}
export interface NPCBattleConfiguration {
canChallenge: boolean;
simultaneousBattles?: boolean;
healAfterwards?: boolean;
}
export interface SimplePartyProvider {
type: "simple";
pokemon: string[];
isStatic?: boolean;
}
export interface PoolEntry {
pokemon: string;
weight?: number;
selectableTimes?: number;
npcLevels?: number[];
levelVariation?: number;
level?: number;
}
export interface PoolPartyProvider {
type: "pool";
minPokemon?: number;
maxPokemon?: number;
pool: PoolEntry[];
isStatic?: boolean;
useFixedRandom?: boolean;
}
export interface ScriptPartyProvider {
type: "script";
script: string;
isStatic?: boolean;
}
export type NPCPartyProvider = SimplePartyProvider | PoolPartyProvider | ScriptPartyProvider;
export interface DialogueInteraction {
type: "dialogue";
dialogue: string;
}
export interface ScriptInteraction {
type: "script";
script: string;
}
export interface CustomScriptInteraction {
type: "custom_script";
script: string;
}
export interface NoInteraction {
type: "none";
}
export type NPCInteraction = DialogueInteraction | ScriptInteraction | CustomScriptInteraction | NoInteraction;
export interface NPCConfiguration {
hitbox: NPCHitboxValue;
presets: string[];
resourceIdentifier: string;
config: MoLangConfigVariable[];
isInvulnerable?: boolean;
canDespawn?: boolean;
isMovable?: boolean;
isLeashable?: boolean;
allowProjectileHits?: boolean;
hideNameTag?: boolean;
names: string[];
aspects?: string[];
modelScale?: number;
interaction: NPCInteraction;
battleConfiguration?: NPCBattleConfiguration;
autoHealParty?: boolean;
randomizePartyOrder?: boolean;
skill?: number;
battleTheme?: string;
party?: NPCPartyProvider;
}
// Dialogue System Types
export interface DialogueText {
type?: "expression";
expression?: string;
text?: string;
}
export interface DialogueOption {
text: DialogueText | string;
value: string;
action: string | string[];
isSelectable?: string;
}
export interface DialogueOptionInput {
type: "option";
vertical?: boolean;
options: DialogueOption[];
}
export interface DialogueTextInput {
type: "text";
placeholder?: string;
maxLength?: number;
action: string | string[];
}
export interface DialogueAutoContinueInput {
type: "auto_continue";
delay: number;
action: string | string[];
}
export interface DialogueNoInput {
// No additional properties needed
}
export type DialogueInput = DialogueOptionInput | DialogueTextInput | DialogueAutoContinueInput | DialogueNoInput;
export interface DialogueSpeaker {
name: DialogueText;
face?: string;
}
export interface DialoguePage {
id: string;
speaker: string;
lines: (DialogueText | string)[];
input?: DialogueInput | string;
background?: string;
clientActions?: string[];
}
export interface DialogueConfiguration {
initializationAction?: string;
escapeAction?: string;
speakers: Record<string, DialogueSpeaker>;
pages: DialoguePage[];
background?: string;
}

293
src/utils/validation.ts Normal file
View File

@@ -0,0 +1,293 @@
import type { NPCConfiguration, DialogueConfiguration } from '../types/npc';
export interface ValidationError {
field: string;
message: string;
severity: 'error' | 'warning';
}
export const validateNPCConfiguration = (config: NPCConfiguration): ValidationError[] => {
const errors: ValidationError[] = [];
// Required fields
if (!config.resourceIdentifier) {
errors.push({
field: 'resourceIdentifier',
message: 'Resource identifier is required',
severity: 'error'
});
} else if (!/^[a-z_]+:[a-z_]+$/i.test(config.resourceIdentifier)) {
errors.push({
field: 'resourceIdentifier',
message: 'Resource identifier should follow the format "namespace:identifier" (e.g., "cobblemon:my_npc")',
severity: 'warning'
});
}
if (!config.names || config.names.length === 0) {
errors.push({
field: 'names',
message: 'At least one name is required',
severity: 'error'
});
} else if (config.names.some(name => !name.trim())) {
errors.push({
field: 'names',
message: 'Names cannot be empty',
severity: 'error'
});
}
// Hitbox validation
if (typeof config.hitbox === 'object') {
if (config.hitbox.width <= 0 || config.hitbox.height <= 0) {
errors.push({
field: 'hitbox',
message: 'Hitbox width and height must be positive numbers',
severity: 'error'
});
}
if (config.hitbox.width > 10 || config.hitbox.height > 10) {
errors.push({
field: 'hitbox',
message: 'Unusually large hitbox dimensions - are you sure this is correct?',
severity: 'warning'
});
}
}
// Battle configuration validation
if (config.battleConfiguration?.canChallenge) {
if (!config.party) {
errors.push({
field: 'party',
message: 'Battle NPCs must have a party configuration',
severity: 'error'
});
} else if (config.party.type === 'simple' && config.party.pokemon.length === 0) {
errors.push({
field: 'party',
message: 'Simple party must have at least one Pokemon',
severity: 'error'
});
} else if (config.party.type === 'pool' && config.party.pool.length === 0) {
errors.push({
field: 'party',
message: 'Pool party must have at least one pool entry',
severity: 'error'
});
} else if (config.party.type === 'script' && !config.party.script) {
errors.push({
field: 'party',
message: 'Script party must specify a script resource location',
severity: 'error'
});
}
if (config.skill && (config.skill < 1 || config.skill > 5)) {
errors.push({
field: 'skill',
message: 'Skill level must be between 1 and 5',
severity: 'error'
});
}
// Validate Pokemon strings in simple party
if (config.party?.type === 'simple') {
config.party.pokemon.forEach((pokemon, index) => {
if (!pokemon.trim()) {
errors.push({
field: `party.pokemon.${index}`,
message: `Pokemon entry ${index + 1} cannot be empty`,
severity: 'error'
});
}
});
}
}
// Interaction validation
if (config.interaction.type === 'dialogue' && !config.interaction.dialogue) {
errors.push({
field: 'interaction.dialogue',
message: 'Dialogue interaction must specify a dialogue resource location',
severity: 'error'
});
}
if (config.interaction.type === 'script' && !config.interaction.script) {
errors.push({
field: 'interaction.script',
message: 'Script interaction must specify a script resource location',
severity: 'error'
});
}
if (config.interaction.type === 'custom_script' && !config.interaction.script) {
errors.push({
field: 'interaction.script',
message: 'Custom script interaction must provide script code',
severity: 'error'
});
}
// Configuration variables validation
config.config.forEach((variable, index) => {
if (!variable.variableName) {
errors.push({
field: `config.${index}.variableName`,
message: `Variable ${index + 1} must have a name`,
severity: 'error'
});
} else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(variable.variableName)) {
errors.push({
field: `config.${index}.variableName`,
message: `Variable ${index + 1} name must be a valid identifier (letters, numbers, underscores only)`,
severity: 'error'
});
}
if (!variable.displayName) {
errors.push({
field: `config.${index}.displayName`,
message: `Variable ${index + 1} must have a display name`,
severity: 'error'
});
}
if (variable.type === 'NUMBER' && typeof variable.defaultValue !== 'number') {
errors.push({
field: `config.${index}.defaultValue`,
message: `Variable ${index + 1} has type NUMBER but default value is not a number`,
severity: 'error'
});
}
if (variable.type === 'BOOLEAN' && typeof variable.defaultValue !== 'boolean') {
errors.push({
field: `config.${index}.defaultValue`,
message: `Variable ${index + 1} has type BOOLEAN but default value is not a boolean`,
severity: 'error'
});
}
});
// Model scale validation
if (config.modelScale && (config.modelScale <= 0 || config.modelScale > 10)) {
errors.push({
field: 'modelScale',
message: 'Model scale should be a positive number, typically between 0.1 and 2.0',
severity: 'warning'
});
}
return errors;
};
export const validateDialogueConfiguration = (config: DialogueConfiguration): ValidationError[] => {
const errors: ValidationError[] = [];
// Speakers validation
if (!config.speakers || Object.keys(config.speakers).length === 0) {
errors.push({
field: 'speakers',
message: 'At least one speaker is required',
severity: 'error'
});
} else {
Object.entries(config.speakers).forEach(([speakerId, speaker]) => {
if (!speaker.name?.expression) {
errors.push({
field: `speakers.${speakerId}.name`,
message: `Speaker "${speakerId}" must have a name expression`,
severity: 'error'
});
}
});
}
// Pages validation
if (!config.pages || config.pages.length === 0) {
errors.push({
field: 'pages',
message: 'At least one dialogue page is required',
severity: 'error'
});
} else {
config.pages.forEach((page, index) => {
if (!page.id) {
errors.push({
field: `pages.${index}.id`,
message: `Page ${index + 1} must have an ID`,
severity: 'error'
});
}
if (!page.speaker) {
errors.push({
field: `pages.${index}.speaker`,
message: `Page ${index + 1} must specify a speaker`,
severity: 'error'
});
} else if (!config.speakers[page.speaker]) {
errors.push({
field: `pages.${index}.speaker`,
message: `Page ${index + 1} references undefined speaker "${page.speaker}"`,
severity: 'error'
});
}
if (!page.lines || page.lines.length === 0) {
errors.push({
field: `pages.${index}.lines`,
message: `Page ${index + 1} must have at least one line`,
severity: 'error'
});
} else {
page.lines.forEach((line, lineIndex) => {
if (typeof line === 'string' && !line.trim()) {
errors.push({
field: `pages.${index}.lines.${lineIndex}`,
message: `Page ${index + 1}, line ${lineIndex + 1} cannot be empty`,
severity: 'error'
});
}
});
}
if (!page.input) {
errors.push({
field: `pages.${index}.input`,
message: `Page ${index + 1} must specify input handling`,
severity: 'warning'
});
}
});
// Check for duplicate page IDs
const pageIds = config.pages.map(page => page.id).filter(id => id);
const duplicateIds = pageIds.filter((id, index) => pageIds.indexOf(id) !== index);
duplicateIds.forEach(id => {
errors.push({
field: 'pages',
message: `Duplicate page ID: "${id}"`,
severity: 'error'
});
});
}
return errors;
};
export const getValidationSummary = (errors: ValidationError[]) => {
const errorCount = errors.filter(e => e.severity === 'error').length;
const warningCount = errors.filter(e => e.severity === 'warning').length;
return {
hasErrors: errorCount > 0,
hasWarnings: warningCount > 0,
errorCount,
warningCount,
isValid: errorCount === 0
};
};

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

14
tailwind.config.js Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
],
}

27
tsconfig.app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})