Add Factorio World Management feature to GSM
- Add gsm-frontend to repository (React + Vite + TailwindCSS) - New "Worlds" tab for Factorio server with: - List saved worlds with Start/Delete actions - Create new world with full map generation parameters - Preset selection (Default, Rich Resources, Rail World, etc.) - Save custom configurations as templates - Show which save will be loaded in Overview tab - Lock world management while server is running - Backend changes deployed to server separately 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
370
gsm-frontend/src/components/WorldGenForm.jsx
Normal file
370
gsm-frontend/src/components/WorldGenForm.jsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const SLIDER_VALUES = [0, 0.167, 0.5, 1, 2, 3]
|
||||
const SLIDER_LABELS = ['None', 'Very Low', 'Low', 'Normal', 'High', 'Very High']
|
||||
|
||||
function valueToSlider(value) {
|
||||
if (value === 0 || value === undefined) return 0
|
||||
if (value <= 0.167) return 1
|
||||
if (value <= 0.5) return 2
|
||||
if (value <= 1) return 3
|
||||
if (value <= 2) return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
function sliderToValue(index) {
|
||||
return SLIDER_VALUES[index]
|
||||
}
|
||||
|
||||
function ValueSelect({ value, onChange }) {
|
||||
const index = valueToSlider(value)
|
||||
return (
|
||||
<select
|
||||
value={index}
|
||||
onChange={(e) => onChange(sliderToValue(parseInt(e.target.value)))}
|
||||
className="select text-xs py-1 px-2"
|
||||
>
|
||||
{SLIDER_LABELS.map((label, i) => (
|
||||
<option key={i} value={i}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function ResourceRow({ label, settings, onChange }) {
|
||||
const freq = settings?.frequency ?? 1
|
||||
const size = settings?.size ?? 1
|
||||
const richness = settings?.richness ?? 1
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 items-center py-2 border-b border-neutral-800 last:border-0">
|
||||
<div className="text-neutral-300 text-sm">{label}</div>
|
||||
<ValueSelect value={freq} onChange={(v) => onChange({ ...settings, frequency: v })} />
|
||||
<ValueSelect value={size} onChange={(v) => onChange({ ...settings, size: v })} />
|
||||
<ValueSelect value={richness} onChange={(v) => onChange({ ...settings, richness: v })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SimpleSlider({ label, value, onChange }) {
|
||||
const sliderIndex = valueToSlider(value)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 py-2 border-b border-neutral-800 last:border-0">
|
||||
<div className="w-28 text-neutral-300 text-sm">{label}</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="5"
|
||||
value={sliderIndex}
|
||||
onChange={(e) => onChange(sliderToValue(parseInt(e.target.value)))}
|
||||
className="slider flex-1 max-w-48"
|
||||
/>
|
||||
<span className="text-neutral-500 text-xs w-16">{SLIDER_LABELS[sliderIndex]}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EvolutionSlider({ label, value, onChange }) {
|
||||
const displayValue = (value * 100).toFixed(0)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 py-2 border-b border-neutral-800 last:border-0">
|
||||
<div className="w-28 text-neutral-300 text-sm">{label}</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={value * 100}
|
||||
onChange={(e) => onChange(parseInt(e.target.value) / 100)}
|
||||
className="slider flex-1 max-w-48"
|
||||
/>
|
||||
<span className="text-neutral-500 text-xs w-16">{displayValue}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldGenForm({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
presets,
|
||||
templates,
|
||||
onLoadPreset,
|
||||
onLoadTemplate,
|
||||
onSaveTemplate
|
||||
}) {
|
||||
const [selectedPreset, setSelectedPreset] = useState('default')
|
||||
const [templateName, setTemplateName] = useState('')
|
||||
const [showSaveTemplate, setShowSaveTemplate] = useState(false)
|
||||
|
||||
const handlePresetChange = (presetName) => {
|
||||
setSelectedPreset(presetName)
|
||||
onLoadPreset(presetName)
|
||||
}
|
||||
|
||||
const handleSaveTemplate = () => {
|
||||
if (templateName.trim()) {
|
||||
onSaveTemplate(templateName.trim())
|
||||
setTemplateName('')
|
||||
setShowSaveTemplate(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAutoplace = (key, value) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
autoplace_controls: {
|
||||
...settings.autoplace_controls,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateCliff = (key, value) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
cliff_settings: {
|
||||
...settings.cliff_settings,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateProperty = (key, value) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
property_expression_names: {
|
||||
...settings.property_expression_names,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-h-[50vh] overflow-y-auto pr-2">
|
||||
{/* Preset & Template Selection */}
|
||||
<div className="card p-3">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-neutral-400 text-sm">Preset:</span>
|
||||
<select
|
||||
value={selectedPreset}
|
||||
onChange={(e) => handlePresetChange(e.target.value)}
|
||||
className="select"
|
||||
>
|
||||
{presets?.map(p => (
|
||||
<option key={p} value={p}>{p.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase())}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{templates?.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-neutral-400 text-sm">Template:</span>
|
||||
<select
|
||||
onChange={(e) => e.target.value && onLoadTemplate(parseInt(e.target.value))}
|
||||
className="select"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{templates.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowSaveTemplate(!showSaveTemplate)}
|
||||
className="btn btn-outline text-sm ml-auto"
|
||||
>
|
||||
Save as Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSaveTemplate && (
|
||||
<div className="flex gap-2 mt-3 pt-3 border-t border-neutral-700">
|
||||
<input
|
||||
type="text"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
placeholder="Template name..."
|
||||
className="input flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={!templateName.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSaveTemplate(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terrain Section */}
|
||||
<div className="card p-4">
|
||||
<h4 className="text-white font-medium text-sm mb-3">Terrain</h4>
|
||||
|
||||
<SimpleSlider
|
||||
label="Water"
|
||||
value={settings?.autoplace_controls?.water?.frequency ?? 1}
|
||||
onChange={(v) => updateAutoplace('water', { frequency: v, size: v })}
|
||||
/>
|
||||
<SimpleSlider
|
||||
label="Trees"
|
||||
value={settings?.autoplace_controls?.trees?.frequency ?? 1}
|
||||
onChange={(v) => updateAutoplace('trees', { frequency: v, size: v, richness: v })}
|
||||
/>
|
||||
<SimpleSlider
|
||||
label="Cliffs"
|
||||
value={settings?.cliff_settings?.richness ?? 1}
|
||||
onChange={(v) => updateCliff('richness', v)}
|
||||
/>
|
||||
<SimpleSlider
|
||||
label="Starting Area"
|
||||
value={settings?.starting_area ?? 1}
|
||||
onChange={(v) => onSettingsChange({ ...settings, starting_area: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resources Section */}
|
||||
<div className="card p-4">
|
||||
<h4 className="text-white font-medium text-sm mb-3">Resources</h4>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 text-neutral-500 text-xs mb-2 pb-2 border-b border-neutral-700">
|
||||
<div></div>
|
||||
<div className="text-center">Frequency</div>
|
||||
<div className="text-center">Size</div>
|
||||
<div className="text-center">Richness</div>
|
||||
</div>
|
||||
|
||||
<ResourceRow
|
||||
label="Iron Ore"
|
||||
settings={settings?.autoplace_controls?.['iron-ore']}
|
||||
onChange={(v) => updateAutoplace('iron-ore', v)}
|
||||
/>
|
||||
<ResourceRow
|
||||
label="Copper Ore"
|
||||
settings={settings?.autoplace_controls?.['copper-ore']}
|
||||
onChange={(v) => updateAutoplace('copper-ore', v)}
|
||||
/>
|
||||
<ResourceRow
|
||||
label="Coal"
|
||||
settings={settings?.autoplace_controls?.coal}
|
||||
onChange={(v) => updateAutoplace('coal', v)}
|
||||
/>
|
||||
<ResourceRow
|
||||
label="Stone"
|
||||
settings={settings?.autoplace_controls?.stone}
|
||||
onChange={(v) => updateAutoplace('stone', v)}
|
||||
/>
|
||||
<ResourceRow
|
||||
label="Uranium"
|
||||
settings={settings?.autoplace_controls?.['uranium-ore']}
|
||||
onChange={(v) => updateAutoplace('uranium-ore', v)}
|
||||
/>
|
||||
<ResourceRow
|
||||
label="Crude Oil"
|
||||
settings={settings?.autoplace_controls?.['crude-oil']}
|
||||
onChange={(v) => updateAutoplace('crude-oil', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enemies Section */}
|
||||
<div className="card p-4">
|
||||
<h4 className="text-white font-medium text-sm mb-3">Enemies</h4>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 text-neutral-500 text-xs mb-2 pb-2 border-b border-neutral-700">
|
||||
<div></div>
|
||||
<div className="text-center">Frequency</div>
|
||||
<div className="text-center">Size</div>
|
||||
<div className="text-center">Richness</div>
|
||||
</div>
|
||||
|
||||
<ResourceRow
|
||||
label="Biter Bases"
|
||||
settings={settings?.autoplace_controls?.['enemy-base']}
|
||||
onChange={(v) => updateAutoplace('enemy-base', v)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-neutral-700">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.peaceful_mode ?? false}
|
||||
onChange={(e) => onSettingsChange({ ...settings, peaceful_mode: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-neutral-600 bg-neutral-800 text-white focus:ring-neutral-500"
|
||||
/>
|
||||
<span className="text-neutral-300 text-sm">Peaceful Mode</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Evolution Section */}
|
||||
<div className="card p-4">
|
||||
<h4 className="text-white font-medium text-sm mb-3">Evolution</h4>
|
||||
|
||||
<EvolutionSlider
|
||||
label="Time Factor"
|
||||
value={settings?.property_expression_names?.['enemy-evolution-factor-by-time'] ?? 1}
|
||||
onChange={(v) => updateProperty('enemy-evolution-factor-by-time', v)}
|
||||
/>
|
||||
<EvolutionSlider
|
||||
label="Pollution Factor"
|
||||
value={settings?.property_expression_names?.['enemy-evolution-factor-by-pollution'] ?? 1}
|
||||
onChange={(v) => updateProperty('enemy-evolution-factor-by-pollution', v)}
|
||||
/>
|
||||
<EvolutionSlider
|
||||
label="Destroy Factor"
|
||||
value={settings?.property_expression_names?.['enemy-evolution-factor-by-killing-spawners'] ?? 1}
|
||||
onChange={(v) => updateProperty('enemy-evolution-factor-by-killing-spawners', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div className="card p-4">
|
||||
<h4 className="text-white font-medium text-sm mb-3">Advanced</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="form-label">Seed (empty = random)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.seed ?? ''}
|
||||
onChange={(e) => onSettingsChange({ ...settings, seed: e.target.value ? parseInt(e.target.value) : null })}
|
||||
placeholder="Random"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="form-label">Width (0=infinite)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings?.width ?? 0}
|
||||
onChange={(e) => onSettingsChange({ ...settings, width: parseInt(e.target.value) || 0 })}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Height (0=infinite)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings?.height ?? 0}
|
||||
onChange={(e) => onSettingsChange({ ...settings, height: parseInt(e.target.value) || 0 })}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user