/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ using System; using System.Collections; using System.Collections.Generic; using System.Linq; using GameCult.Caching; using UniRx; using CultMath; using static CultMath.math; using float2 = CultMath.float2; public class Zone { public Action Log; // Membership, in admission order. Admit and Remove are the only writers; everyone else reads this view, which has // no mutating member, so the death watch below follows membership by construction. private readonly ReactiveCollection _entities = new ReactiveCollection(); public MemberView Entities { get; } public sealed class MemberView : IReadOnlyReactiveCollection { private readonly ReactiveCollection _members; internal MemberView(ReactiveCollection members) => _members = members; public int Count => _members.Count; public Entity this[int index] => _members[index]; public int IndexOf(Entity entity) => _members.IndexOf(entity); public bool Contains(Entity entity) => _members.Contains(entity); public IEnumerator GetEnumerator() => _members.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public UniRx.IObservable> ObserveAdd() => _members.ObserveAdd(); public UniRx.IObservable ObserveCountChanged(bool notifyCurrentCount = false) => _members.ObserveCountChanged(notifyCurrentCount); public UniRx.IObservable> ObserveMove() => _members.ObserveMove(); public UniRx.IObservable> ObserveRemove() => _members.ObserveRemove(); public UniRx.IObservable> ObserveReplace() => _members.ObserveReplace(); public UniRx.IObservable ObserveReset() => _members.ObserveReset(); } // Laid mines, in lay order. Zone owns their existence: Lay is the one adder, the mine step the one remover, // and every removal is a blast. Runtime state, never saved. public ReactiveCollection Mines = new ReactiveCollection(); // Vented vapour clouds, in vent order. Zone owns their existence: Vent is the one adder, StepClouds the one // remover, and Obscuration the one reader that detection uses. Runtime state, never saved. public ReactiveCollection Clouds = new ReactiveCollection(); // Floating items (death drops, jettisoned cargo), in release order. Zone owns their existence: Release is the one // adder; StepFloatingItems (expiry) and TryPickUp are the only removers. TryHold, Free and Remove-of-the-holder // are the only writers of FloatingItem.Holder. Runtime state, never saved. public ReactiveCollection FloatingItems = new ReactiveCollection(); public Dictionary Planets = new Dictionary(); public Dictionary PlanetInstances = new Dictionary(); public Dictionary Orbits = new Dictionary(); public Dictionary AsteroidBelts = new Dictionary(); public PlanetSettings Settings; // Mining index (docs/aetheria-release-map.md, L3-L5): every targetable thing in the zone, fed by providers -- // the entities, and one provider per belt. The reticle, cycling and any range query over rocks go through it; // nothing else scans a belt. public readonly TargetingIndex Targets = new TargetingIndex(); private readonly List _suns = new List(); private HashSet _updatedOrbits = new HashSet(); private ItemManager _itemManager; private double _time; public List Agents = new List(); // Cut 2 (docs/mining-cut.md): chunk wear, keyed by chunk. Zone.Wear is the only writer; ChunkExists and // ChunkRadius are the only readers besides the pack/unpack round trip below. private Dictionary _wear = new Dictionary(); // Cut 3 (docs/fire-control-cut.md, 0b table): Zone owns the pending-shot collection and the ShotId // namespace; FireControl owns every transition a shot goes through. ShotCommitted publishes once a shot's // outcome is decided (R4's commit horizon); ShotResolved republishes the same, unchanged outcome at // arrival, right before the shot is removed. Presentations are the only readers of either. public List PendingShots = new List(); public Subject ShotCommitted = new Subject(); public Subject ShotResolved = new Subject(); // Contacts.Step publishes each hull-on-hull contact it resolves; presentations are the only readers. public Subject Contacted = new Subject(); private int _nextShotId; public int NextShotId() => ++_nextShotId; // The committed shot a presentation was handed the id of by OnFire. It carries what the simulation decided // -- the target it flies at (null for a round that carries none) and where it bursts -- so a presentation // reads that rather than the shooter's current selection. public bool TryGetShot(int shotId, out PendingShot shot) { foreach (var pending in PendingShots) if (pending.ShotId == shotId) { shot = pending; return true; } shot = default; return false; } private readonly List _departures = new List(); internal IReadOnlyList Departures => _departures; public float Time { get => (float) _time; } // Zone time at full precision, for the index's orbital keys, which must agree with ChunkPose's own. public double ExactTime => _time; public ZonePack Pack { get; } public GalaxyZone GalaxyZone { get; } public Galaxy Galaxy { get; } // Cut 6b (docs/fire-control-cut.md, 6.1, Soul finding 6): one zone, one stable identity -- a galaxy seed // reproduces it, and nothing else in the zone (an NPC joining, a shop opening, a wormhole exit) can // perturb it. FireControl.Commit builds its own local generator from this and the shot's own id instead // of touching a shared stream. public uint CombatSeed { get; } // The one source of floating item and mine ids: zone-scoped, from 1, never reused. Runtime state, not saved. private uint _lastBodyId; public FloatingBodyId NextBodyId() => new FloatingBodyId(++_lastBodyId); // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(CombatSeed, LootStream, n). private uint _deaths; // One death subscription per member entity: Admit creates it together with the membership, Remove disposes it // together with the membership, and nothing else touches either. private readonly Dictionary _deathWatches = new Dictionary(); public Zone(ItemManager itemManager, PlanetSettings settings, ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy) { Entities = new MemberView(_entities); _time = pack.Time; GalaxyZone = galaxyZone; Galaxy = galaxy; Pack = pack; _itemManager = itemManager; Settings = settings; CombatSeed = galaxyZone?.Name.StableHash() ?? 1337u; var cache = itemManager.ItemData; // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship, // in the simulation, not only in Unity's own loot-drop subscription. One subscription point covers // every join, whichever caller admits the entity (deserialization below, a jump, a spawned turret) -- // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. // The death watch lives exactly as long as the entity's membership in this zone: installed on admission, // disposed on removal (death, dock, jump), so an entity admitted again never holds a second one. // The one record of a removal for observers (ruling reveal-ends-on-perception): read by the next Sense pass. _entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz, remove.Value.Visibility))); Targets.Add(new EntityTargets()); foreach (var orbit in pack.Orbits) { Orbits.Add(orbit.Key, new Orbit(Settings, cache.Get(orbit))); } // Aetheria gravity (target invariant zone-valid-before-tick): every orbit is placed at the pack's time // when the zone is built, so each well, light and body pose read before the first Update is where the // body is. Velocity stays zero until the first Update differences two placed positions. foreach (var orbit in Orbits) GetOrbitPosition(orbit.Key); foreach (var body in pack.Planets) { var planet = cache.Get(body); Planets.Add(body.Key, planet); switch (planet) { case AsteroidBeltData belt: // Mining Cut 3 (Q16 B): a belt from before field kinds existed gets its kind here, once, by // the rule generation uses, and keeps it. FieldKinds.Ensure(body.Key, belt, cache); AsteroidBelts[body.Key] = new AsteroidBelt(belt); Targets.Add(new BeltTargets(this, body.Key, belt, settings, _time)); break; case SunData sun: var sunInstance = new Sun(settings, sun, Orbits[planet.Orbit.Key]); PlanetInstances.Add(body.Key, sunInstance); _suns.Add(sunInstance); break; case GasGiantData gas: PlanetInstances.Add(body.Key, new GasGiant(settings, gas, Orbits[planet.Orbit.Key])); break; default: PlanetInstances.Add(body.Key, new Planet(settings, planet, Orbits[planet.Orbit.Key])); break; } } // Cut 2 (docs/mining-cut.md): read after belts are built so a later ChunkExists/ChunkRadius call has // Planets/AsteroidBelts populated. A pack from before key 6 existed has ChunkWear == null (nullable // persistence rule); that reads as no wear rather than throwing. if (pack.ChunkWear != null) foreach (var wear in pack.ChunkWear) _wear[new ChunkId(wear.Field, wear.Index)] = new ChunkWear { Damage = wear.Damage, BrokenUntil = wear.BrokenUntil }; foreach (var entityPack in pack.Entities) { var entity = EntitySerializer.Unpack(_itemManager, this, entityPack); var ship = entity as Ship; Admit(entity, piloted: ship is {IsPlayerShip: false}); // Generation placement, not admission: a ship packed at the origin is scattered across the zone. if (ship is {IsPlayerShip: false} && lengthsq(ship.Position) < 1) ship.Position = _itemManager.Random.NextFloat3(float3(-pack.Radius * .5f), float3(pack.Radius * .5f)); } // TODO: Associate planets with stored entities for planetary colonies } // The one admission into this zone (docs/scenarios-cut.md, 2.2): the only writer of membership and the only // creator of agents. Construction, new-run staging (RunStart), warp arrival, undock and the console's spawned // turret all join through here. A piloted entity gets the zone's Minion; an unpiloted one gets no agent at all. // Only a ship can be piloted. The death watch is installed here, for every join, whichever caller admits it. // An entity that is already a member is refused: it would hold a second watch and a second agent. public void Admit(Entity entity, bool piloted) { if (_entities.Contains(entity)) throw new InvalidOperationException("entity: already a member of this zone"); _entities.Add(entity); _deathWatches.Add(entity, entity.Death.Subscribe(_ => { DropLoot(entity); Remove(entity); entity.Deactivate(); })); entity.Activate(); if (piloted) Agents.Add(CreateAgent((Ship) entity)); } // The one removal from this zone (death, dock, jump): membership and its death watch leave together. False, and // nothing changes, when the entity is not a member. The caller deactivates; Remove does not. public bool Remove(Entity entity) { if (!_entities.Remove(entity)) return false; _deathWatches.Remove(entity, out var watch); watch.Dispose(); foreach (var floating in FloatingItems) if (ReferenceEquals(floating.Holder, entity)) Free(floating.Id); return true; } private Agent CreateAgent(Ship ship) { var agent = new Minion(ship); var task = new PatrolOrbitsTask(); task.Circuit = Orbits.OrderBy(_ => _itemManager.Random.NextFloat()).Take(4).Select(x => x.Key).ToArray(); agent.Task = task; return agent; } public ZonePack PackZone() { return new ZonePack { Radius = Pack.Radius, Mass = Pack.Mass, Entities = _entities.Select(EntitySerializer.Pack).ToList(), Orbits = Orbits.Keys.Select(key => new CultRecordRef(key)).ToList(), Planets = Planets.Keys.Select(key => new CultRecordRef(key)).ToList(), Time = _time, BossSpawned = Pack.BossSpawned, // Cut 2 (docs/mining-cut.md): drop entries that carry no live information -- healed (no damage) and // not currently broken -- so a save never accumulates wear rows for chunks nobody has touched since // they last respawned. ChunkWear = _wear .Where(kv => !IsExpired(kv.Value)) .Select(kv => new ChunkWearPack { Field = kv.Key.Field, Index = kv.Key.Index, Damage = kv.Value.Damage, BrokenUntil = kv.Value.BrokenUntil }) .ToList() }; } private bool IsExpired(ChunkWear wear) => wear.Damage <= 0f && (!wear.BrokenUntil.HasValue || wear.BrokenUntil.Value <= _time); public void Update(float deltaTime) { _time += deltaTime; _updatedOrbits.Clear(); // Every previous position is taken before any orbit is placed: GetOrbitPosition places a parent as a // side effect of placing its child, so differencing inside one loop would depend on dictionary order. foreach (var orbit in Orbits) orbit.Value.PreviousPosition = orbit.Value.Position; foreach (var orbit in Orbits) { orbit.Value.Position = GetOrbitPosition(orbit.Key); orbit.Value.Velocity = (orbit.Value.Position - orbit.Value.PreviousPosition) / deltaTime; } foreach(var agent in Agents) agent.Update(deltaTime); var entities = Entities.ToArray(); foreach (var entity in entities) entity.PublishInbox(); foreach (var entity in entities) entity.Update(deltaTime); foreach (var entity in entities) entity.Sense(); _departures.Clear(); StepMines(deltaTime); StepClouds(deltaTime); StepFloatingItems(deltaTime); // Hull contact (cut sim-contact): after every entity has moved and the mines and clouds have taken // their toll, so only hulls still in the zone touch, and before shots resolve against the result. Contacts.Step(this); // Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that // firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and // resolves in this same call. FireControl.Step(this, deltaTime); } public void Lay(Mine mine) { mine.LaidAt = _time; Mines.Add(mine); } public void Vent(VapourCloud cloud) { cloud.Id = NextBodyId(); cloud.VentedAt = _time; Clouds.Add(cloud); } // The one adder of floating items: death drops and jettison both come here. The item drifts as a KinematicBody // launched at `velocity` and expires after FloatingItemLifetime. public FloatingItem Release(ItemInstance item, float2 position, float2 velocity) { var floating = new FloatingItem { Id = NextBodyId(), Item = item, Body = new KinematicBody { Position = position, Velocity = velocity }, ReleasedAt = _time, Lifetime = _itemManager.GameplaySettings.FloatingItemLifetime }; FloatingItems.Add(floating); return floating; } // The one commit of a pickup: the first of the taker's cargo bays that accepts the item stores it and the item // stops floating. Otherwise nothing changes. // An item held by another entity is refused: one holder per item holds at the commit as well. public bool TryPickUp(Entity taker, FloatingBodyId id) { var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); if (floating == null || (floating.Holder != null && !ReferenceEquals(floating.Holder, taker))) return false; if (!taker.CargoBays.Any(bay => bay.TryStore(floating.Item))) return false; FloatingItems.Remove(floating); return true; } // The tractor beam's claim on a floating item: false if it is absent or already held. The item stays where it is // until its pull begins; StepFloatingItems places it from then on. public bool TryHold(Entity holder, FloatingBodyId id) { var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); if (floating == null || floating.Holder != null) return false; floating.Holder = holder; floating.HeldFrom = floating.Body.Position; floating.HeldProgress = 0; return true; } // How far along its pull to the holder a held item is, 0..1. public void SetHeldProgress(FloatingBodyId id, float progress) { var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); if (floating?.Holder != null) floating.HeldProgress = saturate(progress); } // Lets a held item go: it floats on from where it is with the Velocity and Drift it had. public void Free(FloatingBodyId id) { var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); if (floating == null) return; floating.Holder = null; floating.HeldProgress = 0; } // Called from the death subscription, before the entity leaves the zone: one generator per death. Each equipped // item but the hull drops on a LootDropProbability draw; every cargo stack drops whole. Each drop leaves from the // dead entity's position at a random heading. Draw order: Equipment order (the probability draw, then the heading // of a dropped item), then each cargo bay's stacks in CargoBays order (a heading each). private void DropLoot(Entity dead) { var settings = _itemManager.GameplaySettings; var random = SimulationDice.For(CombatSeed, SimulationDice.LootStream, ++_deaths); var at = new float2(dead.Position.x, dead.Position.z); float2 Heading() { var angle = random.NextFloat() * 2f * PI; return float2(cos(angle), sin(angle)); } foreach (var equipped in dead.Equipment.ToArray()) { if (equipped == dead.EquippedHull || !(random.NextFloat() < settings.LootDropProbability)) continue; Release(equipped.EquippableItem, at, Heading() * settings.LootDropVelocity); } foreach (var bay in dead.CargoBays.ToArray()) foreach (var stack in bay.Cargo.Keys.ToArray()) Release(stack, at, Heading() * settings.LootDropVelocity); } // After StepClouds and before FireControl.Step: each floating item drifts as a KinematicBody on the zone's force // and is removed once its lifetime has run out. A held item is neither integrated nor expired: it is placed // between where it was claimed and its holder, now that the entities have moved. // The curve a held item rides from where it was claimed to its holder: the one owner, which the test scene's // stand-in sim and tendril call rather than spell again. public static float HeldPull(float progress) => smoothstep(0f, 1f, progress); private void StepFloatingItems(float deltaTime) { var settings = _itemManager.GameplaySettings; foreach (var floating in FloatingItems.ToArray()) { if (floating.Holder != null) { floating.Body.Position = lerp(floating.HeldFrom, floating.Holder.Position.xz, HeldPull(floating.HeldProgress)); continue; } floating.Body.Step(deltaTime, GetForce(floating.Body.Position), settings); if (floating.ReleasedAt + floating.Lifetime <= _time) FloatingItems.Remove(floating); } } // The fraction of a sight line's signal that survives the clouds between `a` and `b`: the product of // (1 - opacity now) over every cloud whose disc the segment touches, so 1f when none does. Derived from the // live clouds at each call and stored nowhere; Sensor.Gain is where it enters detection. public float Obscuration(float2 a, float2 b) { var through = 1f; for (var i = 0; i < Clouds.Count; i++) { var cloud = Clouds[i]; if (cloud.Touches(a, b)) through *= 1f - cloud.OpacityAt(_time); } return through; } // After StepMines: each cloud drifts as a KinematicBody on the zone's force, as a mine does, and is removed // once its lifetime has run out. Nothing else removes a cloud. private void StepClouds(float deltaTime) { var settings = _itemManager.GameplaySettings; foreach (var cloud in Clouds.ToArray()) { cloud.Body.Step(deltaTime, GetForce(cloud.Body.Position), settings); if (cloud.VentedAt + cloud.Lifetime <= _time) Clouds.Remove(cloud); } } // After the entity loop and before FireControl.Step, in lay order: each mine drifts as a KinematicBody, is // triggered by the first eligible hull its armed disc touches, and detonates through FireControl.Detonate at // its trigger plus fuse delay or at its lifetime, whatever its layer does (ruling mine-owner-leash). The // blast reads no faction; only the trigger does. private void StepMines(float deltaTime) { var settings = _itemManager.GameplaySettings; foreach (var mine in Mines.ToArray()) { mine.Body.Step(deltaTime, GetForce(mine.Body.Position), settings); if (mine.TriggeredAt == null && mine.Armed(_time) && Entities.Any(entity => mine.Triggers(entity) && FireControl.Touches(entity, mine.Body.Position, mine.BlastRadius))) mine.TriggeredAt = _time; var fused = mine.TriggeredAt + mine.FuseDelay <= _time; if (!fused && mine.LaidAt + mine.Lifetime > _time) continue; Mines.Remove(mine); FireControl.Detonate(this, mine.Body.Position, mine.BlastRadius, mine.Damage, mine.DamageType); } } // Determine orbital position recursively, caching parent positions to avoid repeated calculations public float2 GetOrbitPosition(CultRecordKey orbitID) { // Root orbit is fixed at origin if(!orbitID.IsSet()) return float2.zero; if (!Orbits.ContainsKey(orbitID)) { Log?.Invoke("Requested orbit is not part of this zone!"); return float2.zero; } if (!_updatedOrbits.Contains(orbitID)) { var orbit = Orbits[orbitID]; float2 pos = float2.zero; if (orbit.Period > .01f) { var phase = (float) frac(_time / orbit.Period); pos = OrbitData.Evaluate(frac(phase + orbit.Data.Phase)) * orbit.Data.Distance; if (float.IsNaN(pos.x)) { //_context.Log("Orbit position is NaN, something went very wrong!"); pos = float2.zero; } } var parentPosition = !Orbits[orbitID].Data.Parent.IsSet() ? Orbits[orbitID].Data.FixedPosition : GetOrbitPosition(orbit.Data.Parent.Key); Orbits[orbitID].Position = parentPosition + pos; _updatedOrbits.Add(orbitID); } return Orbits[orbitID].Position; } public float2 GetOrbitVelocity(CultRecordKey orbit) { if (Orbits.ContainsKey(orbit)) return Orbits[orbit].Velocity; return float2.zero; } // Cut 2 (docs/mining-cut.md): the index is in range for its field and the chunk is not broken at zone time. // Respawn is a comparison against zone time, never a per-tick loop or a write on read. public bool ChunkExists(ChunkId chunk) { if (!(Planets.TryGetValue(chunk.Field, out var body) && body is AsteroidBeltData beltData)) return false; if (chunk.Index < 0 || chunk.Index >= beltData.Asteroids.Length) return false; return !(_wear.TryGetValue(chunk, out var wear) && wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time); } // Cut 2: the size rule of AsteroidBelt.Size (Cut 1), now reading wear from its one owner (Zone) instead of // per-belt dictionaries. A broken chunk (BrokenUntil in the future) has no size; an unbroken, undamaged // chunk reads its authored size; a damaged one shrinks toward it. public float ChunkRadius(ChunkId chunk) { var belt = AsteroidBelts[chunk.Field]; if (!_wear.TryGetValue(chunk, out var wear)) return belt.UndamagedSize(chunk.Index, Settings); if (wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time) return 0f; if (wear.Damage <= 0f) return belt.UndamagedSize(chunk.Index, Settings); return belt.DamagedSize(chunk.Index, wear.Damage, Settings); } // Cut 2: the only writer of chunk wear. Returns whether this hit broke the chunk. The break threshold is // strictly greater-than, matching the deleted per-tick miner's own comparison against accumulated damage: // damage exactly equal to hitpoints does not yet break the chunk, only damage that exceeds them does. // A chunk that is currently broken (zone time < BrokenUntil) absorbs nothing: this hit did not break it // (it was already broken), and its wear stays zeroed until it respawns and starts fresh. public bool Wear(ChunkId chunk, float damage) { _wear.TryGetValue(chunk, out var wear); if (wear.BrokenUntil.HasValue && wear.BrokenUntil.Value > _time) return false; var beltData = (AsteroidBeltData) Planets[chunk.Field]; var size = beltData.Asteroids[chunk.Index].Size; var hitpoints = Settings.AsteroidHitpoints.Evaluate(size); var newDamage = wear.Damage + damage; if (newDamage > hitpoints) { var respawnTime = Settings.AsteroidRespawnTime.Evaluate(size); _wear[chunk] = new ChunkWear { Damage = 0f, BrokenUntil = _time + respawnTime }; return true; } _wear[chunk] = new ChunkWear { Damage = newDamage, BrokenUntil = null }; return false; } // Cut 1 (docs/mining-cut.md): a chunk's pose is a pure function of zone time, computed when asked. No stored // pose survives a tick; the sim and the renderer both read it fresh through here. public float4 ChunkPose(CultRecordKey belt, int index) { var beltData = Planets[belt] as AsteroidBeltData; var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); var size = ChunkRadius(new ChunkId(belt, index)); return AsteroidBelts[belt].Pose(index, _time, parentPosition, Settings, size); } // Mining Cut 3 (docs/mining-cut-refresh.md): how bright a chunk is -- Reflector's rule (cross-section × the light // on it), with the field kind's reflectivity per schematic cell times the chunk's area in cells. Computed when // asked; nothing about it is stored. A worn chunk shrinks and dims; a broken one, or one of a kindless belt, is dark. public float ChunkVisibility(ChunkId chunk) { if (!ChunkExists(chunk)) return 0f; var kind = _itemManager.ItemData.Get(((AsteroidBeltData) Planets[chunk.Field]).Kind); if (kind == null) return 0f; var cells = ChunkRadius(chunk) / _itemManager.GameplaySettings.SchematicCellSize; return kind.CrossSection * PI * cells * cells * GetLight(ChunkPose(chunk.Field, chunk.Index).xy); } public void EvaluateBelt(CultRecordKey belt, Span into) { var beltData = Planets[belt] as AsteroidBeltData; var parentPosition = GetOrbitPosition(Orbits[beltData.Orbit.Key].Data.Parent.Key); var beltInstance = AsteroidBelts[belt]; for (var i = 0; i < beltData.Asteroids.Length; i++) into[i] = beltInstance.Pose(i, _time, parentPosition, Settings, ChunkRadius(new ChunkId(belt, i))); } public SecurityLevel GetSecurityLevel(float2 pos) { if (GalaxyZone.Owner==null) return SecurityLevel.Open; var security = SecurityLevel.Open; foreach (var entity in Entities) { if (entity is OrbitalEntity orbitalEntity && orbitalEntity.SecurityRadius > 1 && entity.Faction == GalaxyZone.Owner) { if (orbitalEntity.SecurityLevel > security && length(orbitalEntity.Position.xz - pos) < orbitalEntity.SecurityRadius * Settings.SecureAreaRadiusMultiplier) security = orbitalEntity.SecurityLevel; } } return security; } // The potential of a body's well at the zone's edge, subtracted so every well is zero there: the one owner of the // reference. GetHeight sums with it and the renderer's lowering (GravityLowering) hands it to the shader unchanged. public float WellReference(Planet body) => GravityField.Potential(body.WellStrength, body.RocheLimit, Pack.Radius); public float GetHeight(float2 position) { var result = 0f; foreach (var body in PlanetInstances.Values) { var offset = position - body.Orbit.Position; result += GravityField.Potential(body.WellStrength, body.RocheLimit, length(offset)) - WellReference(body); } return result / Settings.HeightExaggeration; } public float GetLight(float2 position) { var light = 0f; foreach (var sun in _suns) light += SunLight(sun, length(position - sun.Orbit.Position)); return light; } // One sun's light at `distance` from it. It never grows with distance, so the light at the nearest point of a // region bounds the light anywhere in it (BeltTargets' visibility bound). public static float SunLight(Sun sun, float distance) => distance < sun.LightRadius ? PowerPulse(distance / sun.LightRadius, 8) : 0f; internal IReadOnlyList Suns() => _suns; internal float SchematicCellSize => _itemManager.GameplaySettings.SchematicCellSize; internal float FieldCrossSection(AsteroidBeltData belt) => _itemManager.ItemData.Get(belt.Kind)?.CrossSection ?? 0f; // The one source of gravitational acceleration (u/s^2): the sum of every body's well (GravityField). Ships, // floating items, clouds and mines all read it, and so does Flow. It is the gradient of the potential GetHeight // draws, so the two cannot disagree. public float2 GetForce(float2 position) { var force = float2.zero; foreach (var body in PlanetInstances.Values) force += GravityField.Acceleration(body.WellStrength, body.RocheLimit, position - body.Orbit.Position); return force; } // The global flow velocity at `position` in zone time, in u/s (FlowField is the law). The slope term reads the // downhill direction, which is the force. public float3 Flow(float3 position) => FlowField.At(Settings.Flow, position, FlowScroll, GetForce(position.xz)); // The flow's noise scroll at zone time (Zone.Flow evaluates at exactly this); lowered to the shaders' _FlowScroll. public float2 FlowScroll => FlowField.Scroll(Settings.Flow, _time); // The phase of a cycle of `period` seconds at zone time, for presenters that animate on the flow. public float CyclePhase(float period) => FlowField.Cycle(_time, period); public static float PowerPulse(float x, float exponent) { x *= 2; x = clamp(x, -1, 1); return pow((x + 1) * (1 - x), exponent); } // The surface normal of the drawn height (potential / K), derived from the force. Display only (ship tilt): the // sim never reads it as a force. public float3 GetNormal(float2 pos) { var slope = GetForce(pos) / Settings.HeightExaggeration; return normalize(new float3(slope.x, 1f, slope.y)); } } public class Planet { public Orbit Orbit; protected readonly PlanetSettings Settings; public BodyData BodyData; public float BodyRadius; // The well (GravityField): strength c by mass, and the Roche limit, which is both the closest a child may orbit and // the well's softened core radius R (one curve of mass, never per body). Both are authored PlanetSettings curves. public float WellStrength; public float RocheLimit; public Planet(PlanetSettings settings, BodyData data, Orbit orbit) { Settings = settings; Orbit = orbit; BodyData = data; CalculateProperties(); } public void CalculateProperties() { BodyRadius = Settings.BodyRadius.Evaluate(BodyData.Mass) * BodyData.BodyRadiusMultiplier; WellStrength = Settings.WellStrength.Evaluate(BodyData.Mass); RocheLimit = Settings.RocheLimit.Evaluate(BodyData.Mass); if (!(WellStrength >= 0f) || float.IsInfinity(WellStrength)) throw new InvalidOperationException("A body's well strength must be finite and not negative (PlanetSettings.WellStrength)."); if (WellStrength > 0 && !(RocheLimit > 0f && !float.IsInfinity(RocheLimit))) throw new InvalidOperationException("A body's Roche limit, which is its core radius, must be positive and finite (PlanetSettings.RocheLimit)."); } } public class GasGiant : Planet { public GasGiantData GasGiantData; public GasGiant(PlanetSettings settings, GasGiantData data, Orbit orbit) : base(settings, data, orbit) { GasGiantData = data; } } public class Sun : GasGiant { public SunData SunData; public float LightRadius; public Sun(PlanetSettings settings, SunData data, Orbit orbit) : base(settings, data, orbit) { SunData = data; CalculateProperties(); } public new void CalculateProperties() { base.CalculateProperties(); LightRadius = Settings.LightRadius.Evaluate(BodyData.Mass) * SunData.LightRadiusMultiplier; } } public class AsteroidBelt { public AsteroidBeltData Data; public float Radius { get; } public AsteroidBelt(AsteroidBeltData data) { Data = data; Radius = data.Asteroids.Max(a => a.Distance); } // Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used. // The only place a chunk pose is computed; no per-tick copy survives it. Cut 2: size is no longer read from // per-belt storage -- Zone owns wear now, so the caller (Zone.ChunkPose/EvaluateBelt) computes it through // Zone.ChunkRadius and passes it in. public float4 Pose(int index, double time, float2 parentPosition, PlanetSettings settings, float size) { var asteroid = Data.Asteroids[index]; var rot = (float) (time * asteroid.RotationSpeed % (PI * 2)); var pos = OrbitData.Evaluate((float) frac(time / settings.OrbitPeriod.Evaluate(asteroid.Distance) + asteroid.Phase)) * asteroid.Distance + parentPosition; return float4(pos.x, pos.y, rot, size); } // Cut 1's size rule (Zone.cs:259-267), split in Cut 2 into its undamaged and damaged halves now that wear // lives on Zone instead of here. Neither reads wear directly; the caller (Zone.ChunkRadius) decides which // applies and supplies the damage. public float UndamagedSize(int index, PlanetSettings settings) => settings.AsteroidSize.Evaluate(Data.Asteroids[index].Size); public float DamagedSize(int index, float damage, PlanetSettings settings) { var asteroidHitpoints = settings.AsteroidHitpoints.Evaluate(Data.Asteroids[index].Size); var remaining = (asteroidHitpoints - damage) / asteroidHitpoints; return settings.AsteroidSize.Evaluate(remaining * Data.Asteroids[index].Size); } } // Cut 2 (docs/mining-cut.md): a chunk is (field, index). "Field" is deliberate, not "belt" -- the operator's // ruling generalizes chunks to other kinds of debris field later; only AsteroidBelt/AsteroidBeltData, the one // live field kind, keep their existing names. public readonly struct ChunkId : IEquatable { public readonly CultRecordKey Field; public readonly int Index; public ChunkId(CultRecordKey field, int index) { Field = field; Index = index; } public bool Equals(ChunkId other) => Field.Equals(other.Field) && Index == other.Index; public override bool Equals(object obj) => obj is ChunkId other && Equals(other); public override int GetHashCode() => (Field.GetHashCode() * 397) ^ Index; } // Mining Cut 3 (docs/mining-cut-refresh.md, Q2 A): what an entity targets -- another entity, a chunk, or nothing. // One slot holds either kind (Entity.Target, written only by Entity.SetTarget). A chunk never equals an entity. // There is deliberately no == operator: a comparison against null or an entity must say which side it means. public readonly struct TargetRef : IEquatable { public readonly Entity Entity; public readonly ChunkId? Chunk; public TargetRef(Entity entity) { Entity = entity; Chunk = null; } public TargetRef(ChunkId chunk) { Entity = null; Chunk = chunk; } public static TargetRef None => default; public bool IsNone => Entity == null && !Chunk.HasValue; public static implicit operator TargetRef(Entity entity) => new TargetRef(entity); public static implicit operator TargetRef(ChunkId chunk) => new TargetRef(chunk); public bool Equals(TargetRef other) => ReferenceEquals(Entity, other.Entity) && Nullable.Equals(Chunk, other.Chunk); public override bool Equals(object obj) => obj is TargetRef other && Equals(other); public override int GetHashCode() => Chunk.HasValue ? Chunk.Value.GetHashCode() : Entity?.GetHashCode() ?? 0; } // Cut 2: wear on one chunk. BrokenUntil is the absolute zone time the chunk respawns at; null means the chunk // carries damage (or none) but is not currently broken. Respawn is read lazily by comparing to zone time -- // nothing decrements or clears this on a timer. public struct ChunkWear { public float Damage; public double? BrokenUntil; } public class Orbit { public OrbitData Data { get; } public float2 Velocity = float2.zero; public float2 Position = float2.zero; public float2 PreviousPosition = float2.zero; public float Period; public Orbit(PlanetSettings settings, OrbitData data) { Data = data; Period = settings.OrbitPeriod.Evaluate(data.Distance); } }