// Creation wizard: 6 steps. Atmosphere → Surface → Moons → Rings → Star → Name.
// Each step renders a control surface; a pinned live 3D preview stays visible.
const { useState, useEffect, useRef, useMemo } = React;
const WIZ_STEPS = [
{ id: 'atmosphere', label: 'Atmosphere' },
{ id: 'surface', label: 'Surface' },
{ id: 'moons', label: 'Moons' },
{ id: 'rings', label: 'Rings' },
{ id: 'star', label: 'Star' },
{ id: 'seal', label: 'Seal' },
];
function defaultSpec() {
return {
atmosphere: [{sym:'N',pct:60},{sym:'O',pct:30},{sym:'H₂O',pct:10}],
surface: 'oceanic',
moons: [{name:'Moon I', size:0.2, tint:'#ccd8ff'}],
rings: { enabled: false, count: 1, tilt: 18, density: 0.5 },
rotationHours: 24,
orbitAU: 1.0,
star: 'G',
weather: 'mild',
life: 'microbial',
axial: 15,
name: '',
discoverer: '',
};
}
// ============ Step 1: Atmosphere ============
// Atmospheric chemistry constants used for inline science hints.
const GREENHOUSE_SET = new Set(['CO₂','CH4','H₂O','NH₃','O₃']);
const SHIELD_SET = new Set(['O₃']);
const LIGHT_GAS_SET = new Set(['H','He']); // escape easily from small/hot worlds
const BIOSIG_PAIR = ['O','CH4']; // disequilibrium suggests an active biosphere
function atmosphereAnalytics(atmosphere) {
const els = window.PLANETALIN_ELEMENTS;
const byS = new Map(els.map(e => [e.sym, e]));
const total = atmosphere.reduce((s,a) => s+a.pct, 0) || 0;
if (!total) return null;
// Mass-weighted mean molecular mass (u)
let mmm = 0, greenhouse = 0, lightGas = 0;
for (const a of atmosphere) {
const el = byS.get(a.sym);
if (!el) continue;
const frac = a.pct / total;
mmm += el.mass * frac;
if (GREENHOUSE_SET.has(a.sym)) greenhouse += frac;
if (LIGHT_GAS_SET.has(a.sym)) lightGas += frac;
}
const hasO = atmosphere.some(a => a.sym === BIOSIG_PAIR[0] && a.pct > 0);
const hasCH4 = atmosphere.some(a => a.sym === BIOSIG_PAIR[1] && a.pct > 0);
const hasO3 = atmosphere.some(a => a.sym === 'O₃' && a.pct > 0);
return {
mmm,
greenhouse, // 0..1 — fraction by volume of GHGs
lightGas, // 0..1 — fraction of easily-escaping light gases
biosignature: hasO && hasCH4,
uvShield: hasO3,
rayleighBlue: mmm > 20 && atmosphere.some(a => a.sym === 'N' || a.sym === 'O'),
};
}
function StepAtmosphere({ spec, setSpec }) {
const els = window.PLANETALIN_ELEMENTS;
const inAtmos = (sym) => spec.atmosphere.find(a => a.sym === sym);
const totalPct = spec.atmosphere.reduce((s,a) => s+a.pct, 0);
const sci = atmosphereAnalytics(spec.atmosphere);
function addElement(sym) {
if (inAtmos(sym) || spec.atmosphere.length >= 5) return;
const newAtm = [...spec.atmosphere, { sym, pct: 10 }];
setSpec({ ...spec, atmosphere: newAtm });
}
function removeElement(sym) {
setSpec({ ...spec, atmosphere: spec.atmosphere.filter(a => a.sym !== sym) });
}
function updatePct(sym, pct) {
setSpec({
...spec,
atmosphere: spec.atmosphere.map(a => a.sym === sym ? {...a, pct: Math.round(pct)} : a)
});
}
function normalize() {
if (totalPct === 0) return;
setSpec({ ...spec, atmosphere: window.PLANETALIN.normalizeAtmosphere(spec.atmosphere) });
}
return (
01 · Composition
What air will your world breathe?
Select up to 5 elements. Their signature colors blend into the atmosphere and drive the planet's palette.
{els.map(el => {
const sel = inAtmos(el.sym);
return (
);
})}
MIXTURE · {totalPct}%
{spec.atmosphere.length === 0 && (
No elements selected yet. Tap above.
)}
{spec.atmosphere.map(a => {
const el = els.find(e => e.sym === a.sym);
return (
{a.sym}
);
})}
{spec.atmosphere.map(a => {
const el = els.find(e => e.sym === a.sym);
return (
{el.sym}
{el.name}
{el.mass} u
{el.spectrum}
updatePct(a.sym, +e.target.value)} />
{a.pct}%
);
})}
{sci && (
ATMOSPHERIC ANALYSIS
Mean molecular mass
{sci.mmm.toFixed(1)} u
{sci.mmm < 10 ? 'Very light — escapes small worlds' : sci.mmm < 20 ? 'Light — retained only with enough gravity' : sci.mmm < 35 ? 'Earth-like weight' : 'Heavy — sinks, dense lower atmosphere'}
Greenhouse index
{Math.round(sci.greenhouse*100)}%
{sci.greenhouse < 0.1 ? 'Transparent to IR — runs cool' : sci.greenhouse < 0.35 ? 'Moderate warming' : sci.greenhouse < 0.7 ? 'Strong greenhouse — surface baked' : 'Runaway greenhouse (Venus-type)'}
Light-gas fraction
{Math.round(sci.lightGas*100)}%
{sci.lightGas > 0.3 ? 'H/He dominated — needs giant-world gravity to hold' : sci.lightGas > 0 ? 'Trace light gases may slowly escape' : 'Stable against Jeans escape'}
{sci.rayleighBlue && ☁︎ Rayleigh-blue sky}
{sci.uvShield && ☀︎ UV shield · ozone absorbs 200–300 nm}
{sci.greenhouse > 0.6 && ♨ Runaway greenhouse risk}
{sci.biosignature && ✦ O₂ + CH₄ disequilibrium — biosignature pair}
{sci.lightGas > 0.5 && ↑ Light gases escape — needs Jovian gravity}
)}
);
}
// ============ Step 2: Surface ============
function StepSurface({ spec, setSpec }) {
const surfaces = window.PLANETALIN_SURFACES;
return (
02 · Crust
What lies beneath the sky?
Choose the character of your world's surface, and the weather that moves across it.
{surfaces.map(s => (
))}
Weather
{['clear','mild','storms','auroras','inferno'].map(w => (
))}
Life indicators
{['none','microbial','complex','intelligent'].map(w => (
))}
Axial tilt {spec.axial}°
{/* Extended to 180° so retrograde worlds (Venus 177°, Uranus 98°) are expressible. */}
setSpec({...spec, axial:+e.target.value})} />
);
}
// ============ Step 3: Moons ============
function StepMoons({ spec, setSpec }) {
function addMoon() {
if (spec.moons.length >= 5) return;
const n = spec.moons.length;
const names = ['I','II','III','IV','V'];
setSpec({...spec, moons:[...spec.moons, { name:`Moon ${names[n]}`, size:0.15+Math.random()*0.15, tint:'#cccfe8' }]});
}
function removeMoon(i) {
setSpec({...spec, moons: spec.moons.filter((_,idx)=>idx!==i)});
}
function updateMoon(i, patch) {
setSpec({...spec, moons: spec.moons.map((m,idx)=> idx===i ? {...m,...patch} : m)});
}
return (
03 · Satellites
How many moons circle your world?
Up to five. Each can be tinted and sized independently.
{spec.moons.map((m,i) => (
))}
{spec.moons.length === 0 && (
A lonely world. No moons.
)}
);
}
// ============ Step 4: Rings ============
function StepRings({ spec, setSpec }) {
const r = spec.rings;
return (
04 · Rings
Will it wear a halo?
Dust and ice can orbit in bands around your planet. Not every world needs them.
{r.enabled && (
)}
);
}
// ============ Step 5: Star ============
function StepStar({ spec, setSpec }) {
const stars = window.PLANETALIN_STARS;
// Range goes to 40 AU so Neptune (30 AU) and outer bodies are expressible.
const ORBIT_MIN = 0.2, ORBIT_MAX = 40;
const hz = window.PLANETALIN.habitableZone(spec.star);
const regime = window.PLANETALIN.orbitRegime(spec.star, spec.orbitAU);
const tempK = window.PLANETALIN.equilibriumTempK(spec.star, spec.orbitAU);
const tempC = Math.round(tempK - 273.15);
// HZ band position on the orbit slider, in % of the slider range.
// Uses a log scale so tight inner orbits don't get crushed against the start.
const lo = Math.log(ORBIT_MIN), hi = Math.log(ORBIT_MAX);
const pctOf = au => Math.max(0, Math.min(100, ((Math.log(Math.max(ORBIT_MIN, au)) - lo) / (hi - lo)) * 100));
const hzLeft = pctOf(hz.inner);
const hzRight = pctOf(hz.outer);
return (
05 · Primary
Which sun does it orbit?
Stellar class shapes the colour of daylight. Orbital distance sets the season — and the habitable zone.
{stars.map(s => (
))}
Orbital distance {spec.orbitAU.toFixed(2)} AU
· {regime.label} · ~{tempC > -100 ? tempC : Math.round(tempK)+'K'}{tempC > -100 ? '°C' : ''}
setSpec({...spec, orbitAU:+e.target.value})} />
scorchedhabitable {hz.inner.toFixed(2)}–{hz.outer.toFixed(2)} AUfrozen
{regime.detail}. Luminosity ≈ {hz.luminosity >= 1 ? hz.luminosity.toFixed(1) : hz.luminosity.toFixed(2)} L☉.
);
}
// ============ Step 6: Seal ============
function StepSeal({ spec, setSpec, onSeal, sealState }) {
const [localName, setLocalName] = useState(spec.name);
const [localDisc, setLocalDisc] = useState(spec.discoverer);
const [cooldownMs, setCooldownMs] = useState(window.PLANETALIN.msUntilNextWindow());
useEffect(() => { setSpec({...spec, name: localName, discoverer: localDisc}); }, [localName, localDisc]);
useEffect(() => {
const iv = setInterval(() => setCooldownMs(window.PLANETALIN.msUntilNextWindow()), 1000);
return () => clearInterval(iv);
}, []);
const canCreate = cooldownMs === 0;
const hrs = Math.floor(cooldownMs/3600000);
const mins = Math.floor((cooldownMs%3600000)/60000);
const secs = Math.floor((cooldownMs%60000)/1000);
const cdLabel = hrs > 0 ? `${hrs}h ${String(mins).padStart(2,'0')}m` : `${mins}m ${String(secs).padStart(2,'0')}s`;
return (
06 · Seal
Name it. Sign it. Seal it.
Once sealed, the specification is permanent. You may fork it into a new world, but you may not alter this one.
Specification
- Atmosphere
- {spec.atmosphere.map(a=>`${a.sym} ${a.pct}%`).join(' · ')||'—'}
- Surface
- {spec.surface}
- Weather
- {spec.weather} · life: {spec.life}
- Moons
- {spec.moons.length ? spec.moons.map(m=>m.name).join(', ') : 'none'}
- Rings
- {spec.rings.enabled ? `${spec.rings.count} bands @ ${spec.rings.tilt}°` : 'none'}
- Star
- {spec.star}-type · {spec.orbitAU.toFixed(2)} AU
- Day
- {spec.rotationHours}h · tilt {spec.axial}°
{!canCreate && sealState === 'idle' && (
SKY CLOSED
You've already shaped a world in the current window. Your spec is held here — you can come back and seal it in {cdLabel}.
)}
);
}
// Global
window.PLANETALINWizard = { defaultSpec, StepAtmosphere, StepSurface, StepMoons, StepRings, StepStar, StepSeal, WIZ_STEPS };