Mining index: a general targeting index fed by providers; the linear chunk scan goes

Zone.Targets (TargetingIndex) answers Within and best-first Best over
ITargetProvider regions. EntityTargets offers the observer's VisibleEntities;
BeltTargets keys each belt's rocks into kinetic bands and bounds each segment's
annular sector for distance, bearing and visibility. Entity.VisibleChunksInReach
queries the index with DetectionReachPerVisibility, an upper bound from the
sensors' hull. Zone.ChunksNear and AsteroidBelt.InnerRadius are deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 15:02:51 +02:00
commit f803151681
11 changed files with 631 additions and 41 deletions

View file

@ -197,4 +197,13 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
var angle = acos(dot(Direction.xz, normalize(diff)));
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false);
}
// An upper bound on sensitivity x response over every bearing, so PassiveRate(v, p) <= v x this / distance
// (Entity.DetectionReachPerVisibility). The curve lies within its control points' hull.
public float PassiveGainBound()
{
var sensitivity = Evaluate(_data.Sensitivity);
var response = _data.SensitivityCurve.HullRange();
return max(sensitivity * response.x, sensitivity * response.y);
}
}

View file

@ -4,6 +4,7 @@ using MessagePack;
using Newtonsoft.Json;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class BezierCurve
@ -39,6 +40,31 @@ public class BezierCurve
}
}
// The least and greatest value Evaluate can return, as (min, max): each segment is a cubic Bezier whose control
// points are p0, p0 + m0/3, p1 - m1/3 and p1, and a Bezier curve lies within their hull. A stepped segment holds
// its left key; outside the keys Evaluate clamps to the end keys. A bound, not the curve's extremes.
public float2 HullRange()
{
var low = float.PositiveInfinity;
var high = float.NegativeInfinity;
void Include(float value)
{
low = min(low, value);
high = max(high, value);
}
foreach (var key in Keys) Include(key.y);
for (var i = 0; i + 1 < Keys.Length; i++)
{
var left = Keys[i];
var right = Keys[i + 1];
if (isinf(left.w) || isinf(right.z)) continue;
var dx = right.x - left.x;
Include(left.y + left.w * dx / 3);
Include(right.y - right.z * dx / 3);
}
return float2(low, high);
}
// Integrate area under curve between start and end time
public float IntegrateCurve(float startTime, float endTime, int steps)
{

View file

@ -372,16 +372,45 @@ public abstract class Entity
public bool ChunkVisible(ChunkId chunk) => ChunkInfo(chunk) > ItemManager.GameplaySettings.TargetDetectionInfoThreshold;
// Mining Cut 3 (Q13 A): the reach for picking a chunk -- the longest range among this entity's active weapons
// that can mine (Weapon.CanMine, Q12 A). Every visible chunk within it, written into `into` (cleared first).
// Cycling and any programmatic chunk pick read this, so there is one reach rule.
// that can mine (Weapon.CanMine, Q12 A). Every visible chunk within it, written into `into` (cleared first), by
// index then field. Cycling and any programmatic chunk pick read this, so there is one reach rule. The zone's
// targeting index proposes the chunks that could pass (no observer, so no entities); ChunkVisible decides.
public void VisibleChunksInReach(List<ChunkId> into)
{
var reach = 0f;
foreach (var weapon in _weapons)
if (weapon.CanMine && (weapon.Item == null || weapon.Item.Active.Value))
reach = max(reach, weapon.Range);
Zone.ChunksNear(Position.xz, reach, into);
into.RemoveAll(chunk => !ChunkVisible(chunk));
into.Clear();
Zone.Targets.Within(new TargetSearch(Position.xz, reach, reachPerVisibility: DetectionReachPerVisibility()), _targetCandidates);
foreach (var candidate in _targetCandidates)
if (candidate.Target.Chunk is ChunkId chunk && ChunkVisible(chunk))
into.Add(chunk);
into.Sort(ByIndexThenField);
}
private readonly List<TargetCandidate> _targetCandidates = new List<TargetCandidate>();
private static readonly Comparison<ChunkId> ByIndexThenField = (a, b) =>
a.Index != b.Index ? a.Index.CompareTo(b.Index) : a.Field.GetHashCode().CompareTo(b.Field.GetHashCode());
// The planar distance per unit of visibility beyond which no target can pass ChunkVisible's threshold: an upper
// bound, never a decision. ChunkInfo is saturate(sum_i v s_i c_i(angle) / d / (n k)), linear in v and falling as
// 1/d, and a Bezier curve lies within the hull of its control points, so each sensor's s_i c_i is bounded by
// Sensor.PassiveGainBound. Zero with no active sensor (nothing is visible); infinity when the threshold is not
// positive. Slack covers float rounding and only ever widens it.
public float DetectionReachPerVisibility()
{
var gain = 0f;
var sensors = 0;
foreach (var sensor in ActiveSensors())
{
gain += sensor.PassiveGainBound();
sensors++;
}
if (sensors == 0) return 0f;
var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold;
if (!(threshold > 0f)) return float.PositiveInfinity;
return max(gain, 0f) / (sensors * ItemManager.GameplaySettings.TargetInfoDecay * threshold) * 1.0001f;
}
// Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables.

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7c083d5830b0418cbf8e34158465d764
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,302 @@
/* 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.Generic;
using GameCult.Caching;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
// Mining index: one belt's rocks as targets. Derived from the belt's data, the zone's settings and zone time when the
// zone builds the belt, and never saved.
//
// Rocks are grouped into bands of neighbouring orbit distance. Within a band they are sorted by where they stood on
// their orbits (in turns, as AsteroidBelt.Pose measures them) at the band's key time. A rock's turn at a later time is
// its key plus elapsed / period, and a band's periods lie close together, so the rocks near a point lie in one arc of
// keys, widened by elapsed time times the band's rate spread. A band sheared past ShearLimit is re-sorted at the
// current time, at most RekeyBudget bands per search, so no single search pays for the whole belt; one that is not
// re-sorted is still searched correctly through its wider arc. Each band is split into segments of consecutive keys,
// and each segment's part of the arc is a region, bounded by the annular sector it can occupy.
public sealed class BeltTargets : ITargetProvider
{
private const int RocksPerBand = 256;
private const int RocksPerSegment = 32;
private const double ShearLimit = 1.0 / 64;
private const int RekeyBudget = 8;
private readonly Zone _zone;
private readonly CultRecordKey _field;
private readonly AsteroidBeltData _data;
private readonly double[] _periods;
private readonly Band[] _bands;
// No rock of the belt, whole or worn, is wider than this (AsteroidSize's bound over every argument).
private readonly float _largestRadius;
private sealed class Band
{
public float Inner, Outer; // the least and greatest orbit distance among the band's rocks
public double SlowestRate, FastestRate; // turns per second: 1 / period
public double KeyTime;
public int[] Rocks;
public double[] Keys; // each rock's turn at KeyTime, ascending, in [0, 1)
}
public BeltTargets(Zone zone, CultRecordKey field, AsteroidBeltData data, PlanetSettings settings, double time)
{
_zone = zone;
_field = field;
_data = data;
var size = settings.AsteroidSize;
_largestRadius = size.Exponent > 0 ? max(abs(size.Minimum), abs(size.Maximum)) : float.PositiveInfinity;
var count = data.Asteroids.Length;
_periods = new double[count];
var order = new int[count];
var distances = new float[count];
for (var i = 0; i < count; i++)
{
order[i] = i;
distances[i] = data.Asteroids[i].Distance;
// Pose divides by the float period the curve returns; the keys must use the same one.
_periods[i] = settings.OrbitPeriod.Evaluate(distances[i]);
}
Array.Sort(distances, order);
_bands = new Band[(count + RocksPerBand - 1) / RocksPerBand];
for (var b = 0; b < _bands.Length; b++)
{
var start = b * RocksPerBand;
var length = Math.Min(RocksPerBand, count - start);
var band = new Band
{
Inner = distances[start], Outer = distances[start + length - 1],
SlowestRate = double.PositiveInfinity, FastestRate = double.NegativeInfinity,
Rocks = new int[length], Keys = new double[length]
};
Array.Copy(order, start, band.Rocks, 0, length);
foreach (var rock in band.Rocks)
{
var rate = 1.0 / _periods[rock];
band.SlowestRate = Math.Min(band.SlowestRate, rate);
band.FastestRate = Math.Max(band.FastestRate, rate);
}
Rekey(band, time);
_bands[b] = band;
}
}
// Diagnostic only: how many bands have been re-sorted since the belt was built, so a test can pin the budget.
public long Rekeys { get; private set; }
private void Rekey(Band band, double time)
{
Rekeys++;
band.KeyTime = time;
for (var j = 0; j < band.Rocks.Length; j++)
band.Keys[j] = Turn(time / _periods[band.Rocks[j]] + _data.Asteroids[band.Rocks[j]].Phase);
Array.Sort(band.Keys, band.Rocks);
}
public void Regions(in TargetSearch search, List<TargetRegion> into)
{
if (_bands.Length == 0) return;
var time = _zone.ExactTime;
var centre = _zone.GetOrbitPosition(_zone.Orbits[_data.Orbit.Key].Data.Parent.Key);
var offset = search.Position - centre;
var distance = length(offset);
var turn = Turn(atan2(offset.y, offset.x) / (2 * PI_DBL));
// Slack covers float rounding in Pose and in these bounds; it only ever widens a region.
var slack = 1e-4f * (_bands[_bands.Length - 1].Outer + distance) + 1e-3f;
var reach = search.Reach + slack;
var half = distance <= reach ? 1.0 : Math.Asin(reach / distance) / (2 * PI_DBL) + 1e-6;
var budget = RekeyBudget;
// The first band whose outer edge reaches the search.
int lo = 0, hi = _bands.Length;
while (lo < hi)
{
var mid = (lo + hi) / 2;
if (_bands[mid].Outer < distance - reach) lo = mid + 1;
else hi = mid;
}
for (var b = lo; b < _bands.Length && _bands[b].Inner <= distance + reach; b++)
{
var band = _bands[b];
if (Math.Abs(time - band.KeyTime) * (band.FastestRate - band.SlowestRate) > ShearLimit && budget > 0)
{
Rekey(band, time);
budget--;
}
Drift(band, time, out var leastDrift, out var mostDrift);
var width = 2 * half + (mostDrift - leastDrift);
if (!(width < 1))
{
AddSegments(search, b, 0, band.Keys.Length, centre, distance, turn, slack, into);
continue;
}
// Keys whose rock can now lie within half a window of the search's own turn.
var from = Turn(turn - half - mostDrift);
var to = from + width;
AddSegments(search, b, LowerBound(band.Keys, from), UpperBound(band.Keys, Math.Min(to, 1.0)), centre, distance, turn, slack, into);
if (to > 1) AddSegments(search, b, 0, UpperBound(band.Keys, to - 1), centre, distance, turn, slack, into);
}
}
public void Candidates(in TargetSearch search, in TargetRegion region, List<TargetCandidate> into)
{
var band = _bands[region.Group];
for (var j = region.Start; j < region.End; j++)
{
var chunk = new ChunkId(_field, band.Rocks[j]);
if (_zone.ChunkExists(chunk)) into.Add(new TargetCandidate(chunk, _zone.ChunkPose(_field, chunk.Index).xy));
}
}
// The keys [start, end) of one band, one region per segment they cross, each bounded by the sector its rocks can
// occupy now: the band's distances, and the turns of its first and last key widened by the band's drift.
private void AddSegments(in TargetSearch search, int b, int start, int end, float2 centre, float distance, double turn, float slack, List<TargetRegion> into)
{
var band = _bands[b];
Drift(band, _zone.ExactTime, out var leastDrift, out var mostDrift);
var crossSection = _zone.FieldCrossSection(_data);
for (var s = start; s < end; s = (s / RocksPerSegment + 1) * RocksPerSegment)
{
var e = Math.Min(end, (s / RocksPerSegment + 1) * RocksPerSegment);
var sector = new Sector
{
Inner = band.Inner, Outer = band.Outer,
From = band.Keys[s] + leastDrift - 1e-6,
Width = band.Keys[e - 1] - band.Keys[s] + (mostDrift - leastDrift) + 2e-6
};
sector.Range(distance, turn, out var nearest, out var farthest);
nearest = max(0f, nearest - slack);
if (nearest > search.Reach) continue;
sector.Bearing(search.Position - centre, out var bearing, out var bearingHalf);
var light = 0f;
foreach (var sun in _zone.Suns())
{
var fromSun = sun.Orbit.Position - centre;
sector.Range(length(fromSun), Turn(atan2(fromSun.y, fromSun.x) / (2 * PI_DBL)), out var sunNearest, out _);
light += Zone.SunLight(sun, max(0f, sunNearest - slack));
}
var cells = _largestRadius / _zone.SchematicCellSize;
into.Add(new TargetRegion
{
Provider = this,
Group = b, Start = s, End = e,
Nearest = nearest,
Farthest = farthest + slack,
BearingCentre = bearing,
BearingHalfWidth = min((float) PI, bearingHalf + slack / max(distance, 1e-3f) + 1e-5f),
MaxVisibility = crossSection * PI * cells * cells * light * 1.0001f
});
}
}
private static void Drift(Band band, double time, out double least, out double most)
{
var elapsed = time - band.KeyTime;
least = Math.Min(elapsed * band.SlowestRate, elapsed * band.FastestRate);
most = Math.Max(elapsed * band.SlowestRate, elapsed * band.FastestRate);
}
private static double Turn(double turns) => turns - Math.Floor(turns);
private static int LowerBound(double[] keys, double value)
{
int lo = 0, hi = keys.Length;
while (lo < hi)
{
var mid = (lo + hi) / 2;
if (keys[mid] < value) lo = mid + 1;
else hi = mid;
}
return lo;
}
private static int UpperBound(double[] keys, double value)
{
int lo = 0, hi = keys.Length;
while (lo < hi)
{
var mid = (lo + hi) / 2;
if (keys[mid] <= value) lo = mid + 1;
else hi = mid;
}
return lo;
}
// An annular sector about the belt centre: distances [Inner, Outer], turns [From, From + Width] (a Width of 1 or
// more is the whole annulus).
private struct Sector
{
public float Inner, Outer;
public double From, Width;
// The nearest and farthest a point `distance` from the centre at `turn` can be from any point of the sector.
public void Range(float distance, double turn, out float nearest, out float farthest)
{
double offAngle, farAngle;
if (Width >= 1)
{
offAngle = 0;
farAngle = PI_DBL;
}
else
{
var into = Turn(turn - From);
offAngle = into <= Width ? 0 : Math.Min(into - Width, 1 - into) * 2 * PI_DBL;
// The sector point farthest in angle: the antipode if the sector holds it, else the farther end.
farAngle = Turn(turn + .5 - From) <= Width
? PI_DBL
: Math.Max(Separation(turn, From), Separation(turn, From + Width)) * 2 * PI_DBL;
}
if (offAngle == 0)
nearest = max(max(Inner - distance, distance - Outer), 0f);
else
{
// On the nearer edge ray: the closest point of the segment [Inner, Outer] along it.
var along = clamp(distance * (float) Math.Cos(offAngle), Inner, Outer);
nearest = Apart(distance, along, offAngle);
}
farthest = max(Apart(distance, Inner, farAngle), Apart(distance, Outer, farAngle));
}
// A circle that holds the sector, seen from `from` (relative to the belt centre): the bearing to its centre
// and the half-width of the bearings it covers (pi when `from` is inside it).
public void Bearing(float2 from, out float centre, out float halfWidth)
{
float2 middle;
float radius;
if (Width >= .5)
{
middle = float2.zero;
radius = Outer;
}
else
{
var middleTurn = (From + Width / 2) * 2 * PI_DBL;
var halfAngle = Width * PI_DBL;
var middleRadius = (Inner + Outer) / 2;
middle = middleRadius * float2((float) Math.Cos(middleTurn), (float) Math.Sin(middleTurn));
radius = max(Apart(middleRadius, Inner, halfAngle), Apart(middleRadius, Outer, halfAngle));
}
var toMiddle = middle - from;
var apart = length(toMiddle);
centre = atan2(toMiddle.y, toMiddle.x);
halfWidth = apart <= radius ? (float) PI : asin(radius / apart);
}
// How far apart two points are at distances a and b from the centre, `angle` radians apart about it.
private static float Apart(float a, float b, double angle) =>
(float) Math.Sqrt(Math.Max(0, (double) a * a + (double) b * b - 2.0 * a * b * Math.Cos(angle)));
// The angle in turns between two turns, in [0, .5].
private static double Separation(double a, double b)
{
var d = Turn(a - b);
return Math.Min(d, 1 - d);
}
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d33fc585810446f7b0a9319196c74633
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,39 @@
/* 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.Collections.Generic;
using static CultMath.math;
// Mining index: entities as targets. Perception stays Entity's: the only entities a search can be offered are those
// already in its observer's VisibleEntities, so the one region carries no visibility bound (they are already seen).
public sealed class EntityTargets : ITargetProvider
{
public void Regions(in TargetSearch search, List<TargetRegion> into)
{
var observer = search.Observer;
if (observer == null || observer.VisibleEntities.Count == 0) return;
var nearest = float.PositiveInfinity;
var farthest = 0f;
foreach (var entity in observer.VisibleEntities)
{
var distance = length(entity.Position.xz - search.Position);
nearest = min(nearest, distance);
farthest = max(farthest, distance);
}
into.Add(new TargetRegion
{
Provider = this,
Nearest = nearest,
Farthest = farthest,
BearingHalfWidth = PI,
MaxVisibility = float.PositiveInfinity
});
}
public void Candidates(in TargetSearch search, in TargetRegion region, List<TargetCandidate> into)
{
foreach (var entity in search.Observer.VisibleEntities)
into.Add(new TargetCandidate(entity, entity.Position.xz));
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1db47cd187504dbb97994e4b8c436ef1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,155 @@
/* 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.Collections.Generic;
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
// Mining index (docs/aetheria-release-map.md, L3-L5; operator 2026-10-01: "don't specialize the indexing too much
// towards asteroid belts"): one targeting index per zone (Zone.Targets), fed by providers. It owns which targetable
// things can lie in a region and an upper bound on how visible they can be. Each provider owns its own things'
// motion and bounds. Whether one target is actually visible stays with Entity (ChunkVisible, VisibleEntities): every
// answer here is a superset the caller filters exactly. Nothing in the index names a belt or a rock.
// One thing that can be targeted, with where it stands on the plane now.
public readonly struct TargetCandidate
{
public readonly TargetRef Target;
public readonly float2 Position;
public TargetCandidate(TargetRef target, float2 position)
{
Target = target;
Position = position;
}
}
// Who is searching, from where, how far, and how well they can see. A null Observer perceives no entities.
// ReachPerVisibility is Entity.DetectionReachPerVisibility: no target of visibility v farther than v times it can be
// detected. Infinity applies no visibility bound.
public readonly struct TargetSearch
{
public readonly float2 Position;
public readonly float Reach;
public readonly Entity Observer;
public readonly float ReachPerVisibility;
public TargetSearch(float2 position, float reach, Entity observer = null, float reachPerVisibility = float.PositiveInfinity)
{
Position = position;
Reach = reach;
Observer = observer;
ReachPerVisibility = reachPerVisibility;
}
}
// A group of a provider's things, with bounds that hold for every one of them at the time it was made: planar
// distance from the searcher in [Nearest, Farthest], bearing from the searcher (radians, as atan2 measures it)
// within BearingHalfWidth of BearingCentre (pi means any bearing), and visibility at most MaxVisibility.
// Group, Start and End are the provider's own: the index only hands the region back to it.
public struct TargetRegion
{
public ITargetProvider Provider;
public int Group, Start, End;
public float Nearest, Farthest;
public float BearingCentre, BearingHalfWidth;
public float MaxVisibility;
// Whether anything in this region can both lie within reach and be detected by the searcher.
public bool CanHold(in TargetSearch search) =>
!(Nearest > search.Reach) && !(Nearest > MaxVisibility * search.ReachPerVisibility);
}
public interface ITargetProvider
{
// Regions that together hold every one of this provider's things within the search's reach, added to `into`.
void Regions(in TargetSearch search, List<TargetRegion> into);
// The things of one region this provider made for this search, with their positions now, added to `into`.
void Candidates(in TargetSearch search, in TargetRegion region, List<TargetCandidate> into);
}
// A best-first question: the candidate with the least key wins. Bound must never exceed the key of any candidate the
// region holds (positive infinity skips the region). Key returns false for a candidate that is not eligible.
// Precedes breaks a tie between two equal keys.
public interface ITargetKey
{
float Bound(in TargetRegion region);
bool Key(in TargetCandidate candidate, out float key);
bool Precedes(in TargetCandidate a, in TargetCandidate b);
}
public sealed class TargetingIndex
{
private readonly List<ITargetProvider> _providers = new List<ITargetProvider>();
private readonly List<TargetRegion> _regions = new List<TargetRegion>();
private readonly List<TargetCandidate> _candidates = new List<TargetCandidate>();
private readonly List<(float bound, int region)> _order = new List<(float, int)>();
// Diagnostic only: how many candidates the index has posed and tested, ever. Tests pin a query's cost with it
// rather than with timing.
public long Examined { get; private set; }
public void Add(ITargetProvider provider) => _providers.Add(provider);
// Every candidate within the search's reach whose region can be detected, written into `into` (cleared first),
// in no particular order. Reach is exact; detection is only bounded, so the caller still tests it.
public void Within(in TargetSearch search, List<TargetCandidate> into)
{
into.Clear();
_regions.Clear();
foreach (var provider in _providers) provider.Regions(search, _regions);
foreach (var region in _regions)
{
if (!region.CanHold(search)) continue;
_candidates.Clear();
region.Provider.Candidates(search, region, _candidates);
foreach (var candidate in _candidates)
{
Examined++;
if (length(candidate.Position - search.Position) <= search.Reach) into.Add(candidate);
}
}
}
// The eligible candidate within reach with the least key, or false if there is none. Regions are opened in order
// of their bound, and the search stops at the first region whose bound is greater than the best key found:
// Hjaltason and Samet's best-first browsing over the providers' flat region sets.
public bool Best<TKey>(in TargetSearch search, TKey query, out TargetCandidate best) where TKey : ITargetKey
{
best = default;
var found = false;
var bestKey = float.PositiveInfinity;
_regions.Clear();
_order.Clear();
foreach (var provider in _providers) provider.Regions(search, _regions);
for (var i = 0; i < _regions.Count; i++)
{
if (!_regions[i].CanHold(search)) continue;
var bound = query.Bound(_regions[i]);
if (!float.IsPositiveInfinity(bound)) _order.Add((bound, i));
}
_order.Sort((a, b) => a.bound.CompareTo(b.bound));
foreach (var (bound, index) in _order)
{
if (found && bound > bestKey) break;
_candidates.Clear();
var region = _regions[index];
region.Provider.Candidates(search, region, _candidates);
foreach (var candidate in _candidates)
{
Examined++;
if (!(length(candidate.Position - search.Position) <= search.Reach) || !query.Key(candidate, out var key)) continue;
if (!found || key < bestKey || key == bestKey && query.Precedes(candidate, best))
{
best = candidate;
bestKey = key;
found = true;
}
}
}
return found;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9fef7cc648f2498ebd24e1d0ca61b5c3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -23,6 +23,12 @@ public class Zone
public Dictionary<CultRecordKey, AsteroidBelt> AsteroidBelts = new Dictionary<CultRecordKey, AsteroidBelt>();
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<Sun> _suns = new List<Sun>();
private HashSet<CultRecordKey> _updatedOrbits = new HashSet<CultRecordKey>();
private ItemManager _itemManager;
@ -62,6 +68,9 @@ public class Zone
{
get => (float) _time;
}
// Zone time at full precision, for the index's orbital keys, which must agree with ChunkPose's own.
internal double ExactTime => _time;
public ZonePack Pack { get; }
public GalaxyZone GalaxyZone { get; }
public Galaxy Galaxy { get; }
@ -89,6 +98,8 @@ public class Zone
// ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone.
Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); }));
Targets.Add(new EntityTargets());
foreach (var orbit in pack.Orbits)
{
Orbits.Add(orbit.Key, new Orbit(Settings, cache.Get(orbit)));
@ -105,9 +116,12 @@ public class Zone
// 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:
PlanetInstances.Add(body.Key, new Sun(settings, sun, Orbits[planet.Orbit.Key]));
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]));
@ -320,25 +334,6 @@ public class Zone
return kind.CrossSection * PI * cells * cells * GetLight(ChunkPose(chunk.Field, chunk.Index).xy);
}
// Mining Cut 3: the one chunk query -- every chunk that exists and whose centre lies within `range` of
// `position`, written into `into` (cleared first). Belts whose annulus cannot reach are skipped whole. It knows
// nothing about detection; callers filter by what an observer can see.
public void ChunksNear(float2 position, float range, List<ChunkId> into)
{
into.Clear();
foreach (var belt in AsteroidBelts)
{
var centre = GetOrbitPosition(Orbits[belt.Value.Data.Orbit.Key].Data.Parent.Key);
var fromCentre = length(position - centre);
if (fromCentre - range > belt.Value.Radius || fromCentre + range < belt.Value.InnerRadius) continue;
for (var i = 0; i < belt.Value.Data.Asteroids.Length; i++)
{
var chunk = new ChunkId(belt.Key, i);
if (ChunkExists(chunk) && length(ChunkPose(belt.Key, i).xy - position) <= range) into.Add(chunk);
}
}
}
public void EvaluateBelt(CultRecordKey belt, Span<float4> into)
{
var beltData = Planets[belt] as AsteroidBeltData;
@ -418,23 +413,20 @@ public class Zone
public float GetLight(float2 position)
{
var light = 0f;
foreach (var body in PlanetInstances.Values)
{
if (body is Sun sun)
{
var p = position - body.Orbit.Position;
var distSqr = lengthsq(p);
var lightRadius = sun.LightRadius;
if (distSqr < lightRadius * lightRadius)
{
light += PowerPulse(sqrt(distSqr) / lightRadius, 8);
}
}
}
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<Sun> Suns() => _suns;
internal float SchematicCellSize => _itemManager.GameplaySettings.SchematicCellSize;
internal float FieldCrossSection(AsteroidBeltData belt) => _itemManager.ItemData.Get(belt.Kind)?.CrossSection ?? 0f;
public float2 GetForce(float2 position)
{
var normal = GetNormal(position);
@ -537,14 +529,11 @@ public class AsteroidBelt
{
public AsteroidBeltData Data;
public float Radius { get; }
// Mining Cut 3: the belt's inner edge, so the chunk query can skip a belt whose annulus it cannot reach.
public float InnerRadius { get; }
public AsteroidBelt(AsteroidBeltData data)
{
Data = data;
Radius = data.Asteroids.Max(a => a.Distance);
InnerRadius = data.Asteroids.Min(a => a.Distance);
}
// Cut 1 (docs/mining-cut.md): the formula of Zone.cs:269-271 verbatim, `time` in double as `_time` was used.