- 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>
371 lines
12 KiB
JavaScript
371 lines
12 KiB
JavaScript
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>
|
|
)
|
|
}
|