diff --git a/CHANGELOG.md b/CHANGELOG.md index 1416a92c..420d81a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,25 @@ the richer, screenshot-laden versions live there. `(#123)` references the pull r ## [Unreleased] +### ✨ Real asteroid belts (#683) + +- **Asteroids now orbit in belts** (new worlds): all of a system's landable asteroids share 1–2 + orbit annuli — the outer belt just beyond the outermost planet, big systems sometimes a second + inner belt — instead of scattering randomly across planet orbits. Existing worlds keep their + layout untouched. +- **Belts are worth flying to**: every asteroid body carries a cluster of ship-laser-mineable rocks + at its position, and launching *from* an asteroid surrounds you with a dense 9-rock field instead + of the usual three. +- **The flight chart shows the belt as a belt**: one translucent band with its own label + ("Asteroid belt" / "Asteroidengürtel") instead of a smear of stacked orbit rings. + +### ✨ Planetary rings in more colours (#684) + +- Ring systems now roll a seeded **material family** — icy white stays the norm, but dusty tan, + rocky grey and a rare pale violet appear too — so two ringed planets under the same star no longer + wear the same colour. Applies everywhere a ring is drawn (orbit view, surface sky, horizon band), + including in existing worlds (same seed → same tilt and band pattern, just richer tints). + ### ✨ Star systems & planets got real names (#678) - **Several system-name registries** instead of one pattern: coined proper names ("Tharion"), diff --git a/TODO.md b/TODO.md index 590a168c..477acdf7 100644 --- a/TODO.md +++ b/TODO.md @@ -118,6 +118,32 @@ With #685's hardness parity the feel scales by material for free: ice pops in on titanium still wants the tier-2 drill. Hull tracking, laser carving and the client structure renderer were already size-agnostic — no client change. +### ★ Asteroid belts: shared orbit annuli + mineable rock density; seeded ring-colour families (#683, #684, 2026-08-03, branch feat/asteroid-belts) +Asteroids used to scatter uniformly over the whole system disc — regularly inside a planet's orbit +lane — and the ship-laser rocks were the same trio next to EVERY launch body, so a "belt" had no +mining pull. New worlds (flag `WorldDescription.AsteroidBelts`, default true on creation like +`SystemVariance`; `--belts off` escape hatch) now put all landable asteroids of a system on 1–2 +shared orbit annuli: the outer belt one orbit step beyond the outermost planet, big systems (5+ +planets, 4+ asteroids) sometimes a second inner belt in a ≥620-unit orbit gap. Members sit on evenly +spaced angular slots (Hash01 salt series 8xx, never the body rng — old saves regenerate +byte-identically with the flag off) so the flight-view clear-gap guarantee holds by construction. +Mining follows the geometry: every other asteroid body in the system carries a 4-rock mineable +cluster at its flight-view position (server + client agree via the shared +`SystemBodyLayout.FlightViewScale`; capped at 24), and launching FROM an asteroid spawns a dense +9-rock local field (per-instance respawn target; only rocks within 60 units of the launch point +count, or belt clusters would starve the replenish loop). The flight chart groups belt members into +ONE translucent band with a localized label (`ui.map.belt`, DE "Asteroidengürtel") instead of N +stacked orbit rings; legacy scattered worlds keep per-body rings. Star-centring zoom budget in +`SystemChartLayoutTests` raised 2.0→2.1 (measured ~2.01× with the outer belt). Also #684: planetary +ring COLOUR was near-constant ice-grey (±0.06 drift; the hue lerp target is the star, same for the +whole system) — `PlanetRings.TintFor` now rolls a seeded material family per ring (ice white 55 %, +dusty tan 20 %, rocky grey 15 %, pale violet 10 %), consistent across flight view, surface sky and +horizon band, retroactive-safe (same RingSeed → same geometry). Gotcha found on the way: a +never-launched ship's `CurrentLocationId` is the default planet TYPE ("varied"), not a body id — the +space-instance anchor now falls back to `_meta.ActiveLocationId` for the asteroid logic, while the +hostile-shading lookup deliberately keeps the legacy raw-id behaviour (fixing it would silently +raise fresh-start difficulty in pirate-space starts). + ### ★ EVA asteroid mining plays by the rules: drill tiers, hardness hits, no lost ore (#685, 2026-08-03, branch fix/eva-asteroid-mining-hardness) During a spacewalk any asteroid block popped with a SINGLE bare-hand click — no drill, no hardness, titanium included — while the same block on a planet takes a tier-2 drill and several timed hits. And diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/PlanetRings.cs b/client/Assets/BlocksBeyondTheStars/Scripts/PlanetRings.cs index e1f145b1..fe3d76ae 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/PlanetRings.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/PlanetRings.cs @@ -40,12 +40,21 @@ public static float AzimuthDegrees(int ringSeed) return (float)rng.NextDouble() * 360f; } - /// The ring's base colour: mostly pale ice-grey (real rings are water ice) pulled a - /// little toward the given body/star hue, with a small seeded warm/cool drift per planet. + /// The ring's base colour (#684): a seeded MATERIAL FAMILY per ring system — mostly + /// pale water-ice white (real rings are water ice, so it stays the norm), sometimes dusty tan + /// or bare rocky grey, rarely a pale exotic violet — pulled a little toward the given body/star + /// hue, with a small seeded warm/cool drift on top. Before the families every ring in a system + /// wore virtually the same ice-grey (the drift alone is ±0.06); now two ringed planets under + /// the same star read as different ring systems. Same RingSeed → same family in every view + /// (flight orbit, surface sky, horizon band). public static Color TintFor(int ringSeed, Color bodyHue) { var rng = new System.Random(ringSeed * 17 + 3); - var pale = new Color(0.93f, 0.90f, 0.85f); + double family = rng.NextDouble(); + var pale = family < 0.55 ? new Color(0.93f, 0.90f, 0.85f) // water ice — the common look + : family < 0.75 ? new Color(0.86f, 0.73f, 0.55f) // dusty tan + : family < 0.90 ? new Color(0.70f, 0.71f, 0.74f) // bare rocky grey + : new Color(0.82f, 0.73f, 0.93f); // rare pale violet var c = Color.Lerp(pale, bodyHue, 0.28f); float drift = ((float)rng.NextDouble() - 0.5f) * 0.12f; return new Color(Mathf.Clamp01(c.r + drift), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b - drift)); diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceMap.cs b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceMap.cs index 102b256d..8fd8d8e0 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceMap.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceMap.cs @@ -38,6 +38,8 @@ public sealed class SpaceMap : MonoBehaviour private const float OrbitAlpha = 0.34f; // faint enough to stay behind the markers it connects private const float MaxBodyDisc = 46f; // body discs are clamped to this diameter, however big the body private const float MarkerPad = MaxBodyDisc * 0.5f + 8f; // chart room a rim body's disc + backing needs + private const float BeltGroupGap = 26f; // flight units: asteroid orbit radii this close = one belt (#683) + private const float BeltBandPad = 8f; // chart units the belt band extends past its outermost member private static readonly Color WaypointCol = new Color(1f, 0.85f, 0.3f); private static readonly Color DiscCol = new Color(0.01f, 0.03f, 0.07f, 0.78f); // WorldMap's backing disc @@ -217,16 +219,62 @@ private void Build() Centered(_chart, Vector2.zero, new Vector2(24f, 24f), UiKit.DiscSprite, new Color(1f, 0.94f, 0.74f)); } + // Asteroid belts (#683): belt members share (nearly) one orbit radius, so per-member rings + // would stack into a smeared blob. Group the star-orbiting asteroid bodies by projected + // orbit radius; three or more within a belt-tight spread read as ONE belt — drawn as a + // single translucent band with one localized label — and their own rings are suppressed. + // Legacy scattered systems rarely group and simply keep their per-body rings. + var beltMembers = new HashSet(); + if (starCentred && landables != null) + { + var fields = new List<(string Id, float R)>(); + foreach (var b in landables) + { + var fnb = BodyFor(b.Id); + if (fnb != null && fnb.Kind == "AsteroidField" && string.IsNullOrEmpty(fnb.ParentId)) + { + fields.Add((b.Id, new Vector2(b.Pos.x - _centre.x, b.Pos.z - _centre.z).magnitude)); + } + } + + fields.Sort((a, c) => a.R.CompareTo(c.R)); + int start = 0; + for (int k = 1; k <= fields.Count; k++) + { + if (k < fields.Count && fields[k].R - fields[k - 1].R <= BeltGroupGap) + { + continue; // same annulus — keep extending the group + } + + if (k - start >= 3) + { + float rMin = fields[start].R * _scale, rMax = fields[k - 1].R * _scale; + float outer = rMax + BeltBandPad; + var bandCol = new Color(0.78f, 0.72f, 0.6f, 0.12f); + UiOrbitRing.Create(_chart, Vector2.zero, new Vector2(outer * 2f, outer * 2f), + bandCol, rMax - rMin + BeltBandPad * 2f); + Label(_chart, new Vector2(0f, outer + 12f), L("ui.map.belt")); + for (int m = start; m < k; m++) + { + beltMembers.Add(fields[m].Id); + } + } + + start = k; + } + } + // Orbit paths: one ring per body that circles the star — planets and the landable asteroid // bodies. Moons are deliberately left out: they are re-laddered onto clearance slots just // outside their parent's drawn radius, so their rings would collapse into the planet's disc // and turn the chart into noise. Each radius comes from the body's own projected position, so // the ring is guaranteed to pass through its marker whatever the layout passes did to it. + // Belt members are covered by their belt's band above instead of a ring each. if (starCentred && landables != null) { foreach (var b in landables) { - if (!OrbitsStar(BodyFor(b.Id))) + if (beltMembers.Contains(b.Id) || !OrbitsStar(BodyFor(b.Id))) { continue; } diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs index 6ebdd16c..a5eb9dd6 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs @@ -3785,8 +3785,10 @@ public bool TryResolveSpaceWaypoint(out Vector3 target, out float arriveSq) private float _landTargetSq; // squared distance to the land target (for station-vs-body priority) private float _nearStationSq; // squared distance to the near station private float _bounds = Bounds; // flight clamp, enlarged to span the resident system - private const float SystemViewScale = 0.16f; // system units → flight-view units (kept compact so - // neighbouring planets are a short cruise apart, not minutes) + private const float SystemViewScale = SystemBodyLayout.FlightViewScale; // system units → flight-view units + // (kept compact so neighbouring planets are a short cruise + // apart, not minutes; shared — the server parks belt rock + // clusters at the same transform, #683) private const float KeepOutMargin = 10f; // how far outside a body's surface the ship is held private const float LandBand = 40f; // land prompt shows within (body radius + margin + band) diff --git a/data/locales/de.json b/data/locales/de.json index 9736a444..c39b48df 100644 --- a/data/locales/de.json +++ b/data/locales/de.json @@ -662,6 +662,7 @@ "ui.map.kind_station": "Raumstation", "ui.map.kind_planet": "Planet", "ui.map.kind_moon": "Mond", + "ui.map.belt": "Asteroidengürtel", "ui.map.kind_asteroidfield": "Asteroidenfeld", "ui.map.kind_wreck": "Wrack", "ui.map.status_notgenerated": "Unerforscht", diff --git a/data/locales/en.json b/data/locales/en.json index 3c42c54d..83a49846 100644 --- a/data/locales/en.json +++ b/data/locales/en.json @@ -661,6 +661,7 @@ "ui.map.kind_station": "Space Station", "ui.map.kind_planet": "Planet", "ui.map.kind_moon": "Moon", + "ui.map.belt": "Asteroid belt", "ui.map.kind_asteroidfield": "Asteroid field", "ui.map.kind_wreck": "Wreck", "ui.map.status_notgenerated": "Uncharted", diff --git a/docs/developer/WORLD_GENERATION.md b/docs/developer/WORLD_GENERATION.md index 5924fea5..f25c75de 100644 --- a/docs/developer/WORLD_GENERATION.md +++ b/docs/developer/WORLD_GENERATION.md @@ -68,6 +68,20 @@ The data shapes are in [`Galaxy.cs`](../../src/BlocksBeyondTheStars.Shared/World 4000–16000 instead of 5000–12000). Bias 0 is bit-identical to the classic hash — that invariant protects every existing save's terrain. The client receives the bias via `NetBody.SizeBias` and must pass it to every `CircumferenceFor` call (orbit spheres, sky bodies, pad-map bakes). +- **Asteroid belts (#683, worlds created with `WorldDescription.AsteroidBelts`):** the system's + landable asteroids share 1–2 **orbit annuli** instead of scattering across the whole disc (which + regularly parked them inside a planet's orbit lane). The outer belt always sits one orbit step + beyond the outermost planet; big systems (5+ planets, 4+ asteroids) may roll a second, inner belt + into a ≥620-unit gap between two planet orbits. Members occupy evenly spaced angular slots with a + small wobble (radial jitter ±60), so the flight view's clear-gap guarantee holds by construction. + Geometry uses the `Hash01` **8xx salt series** and never the body rng. Like `SystemVariance`, the + flag **defaults to false** (old saves keep the legacy `DiscPoint` scatter byte-identically) and to + **true** on `ServerConfig`'s creation-time description; `--belts off` is the escape hatch + (mirrors `--variance`). At runtime every asteroid body also carries a **mineable rock cluster** at its flight-view + position (server: `AddBeltRockClusters`, shared transform `SystemBodyLayout.FlightViewScale`), + and launching *from* an asteroid spawns a dense 9-rock local field instead of the classic trio. + The flight chart draws grouped members as one translucent belt band (`ui.map.belt`) instead of + stacked per-body orbit rings. A `CelestialBody` stores only Id, Name, `Kind` (Planet/Moon/AsteroidField/SpaceStation/Wreck), a **`PlanetType` key**, and orbit data. The body's *content* is generated only when a player enters it. diff --git a/docs/user/USER_MANUAL.md b/docs/user/USER_MANUAL.md index 30377438..377bd615 100644 --- a/docs/user/USER_MANUAL.md +++ b/docs/user/USER_MANUAL.md @@ -311,6 +311,14 @@ separate unlock; admins can still disable it through server world rules. - Fly within local space instances; asteroids + NPC drones can damage hull/shield. Whether you can lose your ship is set by the world's **"Keep ship on death"** rule (see *Repairing your own ship* above). +### Asteroid belts +- In worlds created with belts (the default for new worlds), a system's landable asteroids orbit + together in **1–2 shared belts** — the flight chart (M) draws them as one translucent band with an + "Asteroid belt" label instead of separate orbit rings. +- Belts are the place to mine with the ship laser: **every asteroid body has a cluster of mineable + rocks floating around it**, and launching from an asteroid you landed on puts you inside a dense + local rock field (nine rocks instead of the usual three near planets). + ### Peaceful NPC trader ships - Space and busy systems feel alive with **civilian trader traffic**: merchant ships **warp in** at the system edge, **cruise** to a station to **dock**, or head into the inner system and **land on a planet/moon** if a diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs index 967441ac..47619ab4 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs @@ -171,6 +171,11 @@ public sealed class SpaceInstance /// Spreads successive respawned asteroids so they don't stack on one spot. public int AsteroidSpawnRotor { get; set; } + /// Rock count the LAUNCH-POINT field replenishes toward (#683 S1): the classic 3, or the + /// dense-field target when this instance is anchored at an asteroid body (the ship launched inside + /// the belt). Belt rock clusters parked at the OTHER asteroid bodies don't count toward it. + public int AsteroidFieldTarget { get; set; } = 3; + /// Voxel structures floating in this instance (item 20). S1: each present player's own ship, /// keyed by player id, seeded from its ship-editor design. Later stages add stations + voxel asteroids. public Dictionary Structures { get; } = new(); @@ -520,23 +525,34 @@ private SpaceInstance CreateSpaceInstance(string instanceId) { var instance = new SpaceInstance { Id = instanceId, Kind = "orbit" }; + string anchorId = instanceId.StartsWith("space:") ? instanceId.Substring("space:".Length) : instanceId; + // A never-launched ship still carries its creation placeholder (the default planet TYPE, not a + // body id) as its location, so resolve the true start body through the save's active location + // when the instance key doesn't name a real body. + var anchor = _galaxy?.FindBody(anchorId) ?? _galaxy?.FindBody(_meta.ActiveLocationId); + // Asteroids are always present as scenery + mining targets; breaking them is gated at fire - // time. They start large and split into smaller chunks when destroyed (§8.1). - int asteroids = 3; + // time. Launching from an asteroid body means the ship starts INSIDE the belt, so the local + // field is dense (#683 S1); anywhere else it stays the classic sparse trio. + int asteroids = anchor?.Kind == CelestialKind.AsteroidField ? DenseAsteroidFieldTarget : AsteroidFieldTarget; + instance.AsteroidFieldTarget = asteroids; for (int i = 0; i < asteroids; i++) { // B10: scatter them around the body (a golden-angle ring at varied radius/height) instead of a // tight line — but inside weapon range (asteroid_breaker reaches ~40) so they stay shootable. + // The dense field stacks extra layers in height rather than radius for the same reason. float ang = i * 2.39996f; - float rad = 18f + i * 8f; // 18 / 26 / 34 + float rad = 18f + (i % 3) * 8f; // 18 / 26 / 34 // item 20 S3: each asteroid is a voxel ore body (entity + structure) you can shoot AND EVA-mine. // #687: the ordinal seeds the family/size roll (0 = the pinned classic metallic rock). SpawnAsteroid(instance, - new Vector3f(rad * (float)System.Math.Cos(ang), (i - 1) * 9f, rad * (float)System.Math.Sin(ang)), + new Vector3f(rad * (float)System.Math.Cos(ang), ((i % 3) - 1) * 9f + (i / 3) * 14f, rad * (float)System.Math.Sin(ang)), ordinal: i, broadcast: false); } + AddBeltRockClusters(instance, anchor); // #683 S2: mineable rocks AT the system's asteroid bodies + AddStationContacts(instance); AddPersistedStations(instance); // item 20 S4: re-create player-built stations floating in this instance @@ -546,9 +562,8 @@ private SpaceInstance CreateSpaceInstance(string instanceId) if (combatEnabled && !_storyState.GuardianDefeated) { // The finale system runs its own scripted ELITE gauntlet (P6 Stage 1) instead of the ambient - // hostiles — strip the "space:" prefix to recover the anchor body id for the system check. - string bodyId = instanceId.StartsWith("space:") ? instanceId.Substring("space:".Length) : instanceId; - if (IsGuardianSystemLocation(bodyId)) + // hostiles — the anchor body id (the "space:" prefix already stripped above) keys the check. + if (IsGuardianSystemLocation(anchorId)) { SpawnGuardianGauntlet(instance); } @@ -559,7 +574,11 @@ private SpaceInstance CreateSpaceInstance(string instanceId) // hammered the ship the instant it launched (continuous damage → destroyed → respawn at base). // #547: the system archetype shades the ambient hostility — Desolate space is truly empty // (no drones, no UFO), a Pirate Haven runs one extra drone when NPC enemies are on at all. - var archetype = SystemArchetypeOf(_galaxy.FindBody(bodyId)?.SystemId); + // Deliberately keyed on the RAW anchor id (not the resolved start-body fallback above): + // a never-launched ship carries its type placeholder here, which never resolves — so the + // first launch has always been shaded Standard, and changing that would silently raise + // the fresh-start difficulty in pirate-space starts. + var archetype = SystemArchetypeOf(_galaxy?.FindBody(anchorId)?.SystemId); int drones = ActivityCount(Rules.SpaceNpcEnemies); if (archetype == SystemArchetype.Desolate) { @@ -607,6 +626,59 @@ private SpaceInstance CreateSpaceInstance(string instanceId) return instance; } + private const int DenseAsteroidFieldTarget = 9; // launch-field rocks when anchored at an asteroid (#683 S1) + private const int BeltClusterRocks = 4; // mineable rocks parked at each other asteroid body (#683 S2) + private const int BeltClusterCap = 24; // belt rocks per instance, total (broadcast/entity budget) + private const float BeltClusterMinRadius = 18f; // just outside an asteroid body's keep-out shell + + /// #683 S2: parks a small mineable rock cluster at the flight-view position of every OTHER + /// landable asteroid body in the resident system, so flying INTO the belt means flying through rocks + /// worth mining — not just past the sized, landable bodies. Positions replicate the client's layout + /// transform (star-map delta to the anchor × ); the + /// client's overlap-relax pass can nudge a BODY slightly off that spot in a legacy scattered layout, + /// but the cluster still reads as "the rocks around that asteroid". Deterministic per body id, so + /// re-entering the instance rebuilds the same field. + private void AddBeltRockClusters(SpaceInstance instance, CelestialBody? anchor) + { + if (anchor is null || _galaxy?.Systems.FirstOrDefault(s => s.Id == anchor.SystemId) is not { } system) + { + return; + } + + int spawned = 0; + foreach (var b in system.Bodies) + { + if (b.Kind != CelestialKind.AsteroidField || b.Id == anchor.Id || spawned >= BeltClusterCap) + { + continue; + } + + float cx = (b.SystemX - anchor.SystemX) * SystemBodyLayout.FlightViewScale; + float cz = (b.SystemZ - anchor.SystemZ) * SystemBodyLayout.FlightViewScale; + int h = 17; + foreach (char c in b.Id) + { + h = h * 31 + c; + } + + for (int r = 0; r < BeltClusterRocks && spawned < BeltClusterCap; r++, spawned++) + { + float ang = ((h & 0xff) / 255f) * 6.2831853f + r * 2.39996f; // per-body phase + golden spread + float rad = BeltClusterMinRadius + ((h >> (r * 3 + 8)) & 15); // 18..33 + SpawnAsteroid(instance, + new Vector3f( + cx + rad * (float)System.Math.Cos(ang), + ((r % 3) - 1) * 10f, + cz + rad * (float)System.Math.Sin(ang)), + // #687 family/size roll: belt rocks use their own ordinal series (well past any + // launch-field/respawn ordinal, never the pinned 0) — deterministic per entry + // because bodies iterate in stable galaxy order. + ordinal: 100 + spawned, + broadcast: false); + } + } + } + /// P6 Stage 1 — the Guardian system's elite gauntlet: the hardest space wave in the game, ringed /// around the dormant core. A heavy cruiser flanked by elite UFOs and a swarm of reinforced drones, all /// well beyond engage range so the approach stays opt-in. Reuses the normal ship-combat resolution + @@ -1181,13 +1253,18 @@ private void TickSpace(double dt) private const int AsteroidFieldTarget = 3; // large-equivalent asteroids the field tends toward private const double AsteroidRespawnInterval = 120.0; // seconds between replenishing spawns (B9: slower respawn) + private const float LaunchFieldRange = 60f; // rocks this close to the launch point ARE the local field /// Slowly refills a mined-out asteroid field back toward its target so it isn't barren for the - /// rest of the session (a fresh field is still generated on each space entry). + /// rest of the session (a fresh field is still generated on each space entry). Only the LAUNCH-POINT + /// field counts toward the target — the belt rock clusters parked at the system's other asteroid + /// bodies (#683 S2) sit far outside and must not satisfy it, or the + /// local field would never replenish in a belt-rich system. private void RespawnAsteroids(SpaceInstance instance, double dt) { - int count = instance.Entities.Count(e => e.Kind == CombatEntityKind.Asteroid); - if (count >= AsteroidFieldTarget) + int count = instance.Entities.Count(e => e.Kind == CombatEntityKind.Asteroid + && e.Position.X * e.Position.X + e.Position.Z * e.Position.Z <= LaunchFieldRange * LaunchFieldRange); + if (count >= instance.AsteroidFieldTarget) { instance.AsteroidRespawnTimer = 0; return; @@ -1207,8 +1284,9 @@ private void RespawnAsteroids(SpaceInstance instance, double dt) float rrad = 22f + (r % 3) * 6f; // 22 / 28 / 34 var pos = new Vector3f(rrad * (float)System.Math.Cos(rang), ((r % 5) - 2) * 8f, rrad * (float)System.Math.Sin(rang)); // item 20 S3: voxel ore body (sends its mesh + state). #687: respawn ordinals continue past the - // initial batch (3 + rotor) so replenished rocks roll fresh families/sizes deterministically. - SpawnAsteroid(instance, pos, ordinal: 3 + r, broadcast: true); + // initial batch (field target + rotor — the launch field may be the dense 9, #683 S1) so + // replenished rocks roll fresh families/sizes deterministically. + SpawnAsteroid(instance, pos, ordinal: instance.AsteroidFieldTarget + r, broadcast: true); } private const double SpottedCalloutCooldown = 15.0; // s between "hostile spotted you" warnings per instance diff --git a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs index 40a6b20d..d74a64f1 100644 --- a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs +++ b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs @@ -86,11 +86,13 @@ public sealed class ServerConfig /// Authoritative world rules (mode, PvP, hazards, death penalty, cheats, ...). public GameRules Rules { get; set; } = new(); - /// Universe description used when first creating the world. System variance (#546) is on - /// here — every NEWLY created world gets archetype-varied star systems — while the - /// property itself - /// defaults to false, so a loaded save whose metadata predates the feature stays byte-identical. - public BlocksBeyondTheStars.Shared.World.WorldDescription World { get; set; } = new() { SystemVariance = true }; + /// Universe description used when first creating the world. System variance (#546) and + /// asteroid belts (#683) are on here — every NEWLY created world gets archetype-varied star systems + /// whose asteroids share real belt annuli — while the + /// and + /// properties + /// default to false, so a loaded save whose metadata predates the features stays byte-identical. + public BlocksBeyondTheStars.Shared.World.WorldDescription World { get; set; } = new() { SystemVariance = true, AsteroidBelts = true }; /// Optional AI mission backend level (Off keeps the game fully AI-free). public AiLevel AiLevel { get; set; } = AiLevel.Off; @@ -582,6 +584,13 @@ public IReadOnlyList ApplyCommandLine(string[]? args) else if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase)) { World.SystemVariance = true; applied.Add("variance"); } else if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase)) { World.SystemVariance = false; applied.Add("variance"); } break; + case "belts": + // Asteroid-belt layout (#683). On for every new world by default; "off" restores the + // classic scattered-asteroid disc (same escape-hatch role as "variance"). + if (bool.TryParse(value, out var ab)) { World.AsteroidBelts = ab; applied.Add("belts"); } + else if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase)) { World.AsteroidBelts = true; applied.Add("belts"); } + else if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase)) { World.AsteroidBelts = false; applied.Add("belts"); } + break; case "danger": // Global hostility multiplier (#547) — scales space-ambush odds + bandit-camp presence. if (Enum.TryParse(value, ignoreCase: true, out var dg)) { World.Danger = dg; applied.Add("danger"); } diff --git a/src/BlocksBeyondTheStars.Shared/World/SystemBodyLayout.cs b/src/BlocksBeyondTheStars.Shared/World/SystemBodyLayout.cs index 8fd7ad23..e2c97be4 100644 --- a/src/BlocksBeyondTheStars.Shared/World/SystemBodyLayout.cs +++ b/src/BlocksBeyondTheStars.Shared/World/SystemBodyLayout.cs @@ -16,6 +16,12 @@ namespace BlocksBeyondTheStars.Shared.World; /// public static class SystemBodyLayout { + /// System (star-map) units → flight-view units. The client renders the resident system at + /// this scale; the server uses the SAME constant when it parks mineable rock clusters at the + /// flight-view positions of the system's asteroid bodies (#683) — one number, or the two sides + /// disagree about where "at that asteroid" is. + public const float FlightViewScale = 0.16f; + /// Smallest clear space between two bodies' surfaces, in flight-view units. Deliberately /// above the ship's keep-out margin (10) so there is always a gap the ship can fly through. public const float MinBodyGap = 14f; diff --git a/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs b/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs index 62d93c98..a7d5ce00 100644 --- a/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs +++ b/src/BlocksBeyondTheStars.Shared/World/WorldDescription.cs @@ -120,6 +120,14 @@ public sealed class WorldDescription /// default description carries true; a loaded save keeps whatever its metadata stored). public bool SystemVariance { get; set; } + /// Asteroid-belt layout (#683): when true, a system's landable asteroids share 1–2 orbit + /// annuli (real BELTS — big systems may roll a second, inner one) instead of scattering across the + /// whole disc. MUST default to false for the same reason as : the galaxy + /// is re-derived from the seed on every start, so flipping this on an existing save would move the + /// asteroid bodies players have visited or set waypoints to. New worlds switch it on at creation + /// (ServerConfig's default description carries true; a loaded save keeps whatever it stored). + public bool AsteroidBelts { get; set; } + // --- World options (creation-time; baked into the save's metadata — they shape worldgen) --- /// Flora/tree density factor applied on top of each world's seeded variation. diff --git a/src/BlocksBeyondTheStars.WorldGeneration/UniverseGenerator.cs b/src/BlocksBeyondTheStars.WorldGeneration/UniverseGenerator.cs index e9eb0f34..ecbf4a49 100644 --- a/src/BlocksBeyondTheStars.WorldGeneration/UniverseGenerator.cs +++ b/src/BlocksBeyondTheStars.WorldGeneration/UniverseGenerator.cs @@ -333,9 +333,17 @@ public Galaxy Generate() SystemArchetype.PirateHaven => rng.Range(3, 5), // cover for ambushes _ => 2 + (rng.NextDouble() < 0.5 ? 1 : 0), // Standard/Hub/Twin: 2 or 3, the legacy draw }; + // Belt layout (#683, worlds created with AsteroidBelts): the system's asteroids share 1–2 + // orbit annuli — a real belt — instead of scattering across the whole disc (which regularly + // parked them inside a planet's orbit lane). Geometry comes from its own Hash01 salts + // (the 8xx series belongs to belts), NEVER from rng, and the flag defaults off — so every + // pre-belt save keeps the legacy DiscPoint scatter byte-identically. + var beltRadii = _desc.AsteroidBelts ? BeltRadii(i, system, planets, asteroidCount) : null; for (int a = 0; a < asteroidCount; a++) { - var (ax, az) = DiscPoint(i, planets, 310 + a); + var (ax, az) = beltRadii is { Count: > 0 } + ? BeltPoint(i, a, asteroidCount, beltRadii) + : DiscPoint(i, planets, 310 + a); system.Bodies.Add(new CelestialBody { Id = $"{system.Id}-a{a}", @@ -486,6 +494,73 @@ public static void EnsureStartPlanetRings(CelestialBody start) start.RingSeed = 1 + (h & 0x7fffffff) % 999_999; } + // Belt geometry (#683). Radial jitter is kept well under half an orbit step so a belt member can + // never wander into a neighbouring lane; the angular slots guarantee the flight view's pair gap + // by construction (the separation pass would otherwise shove a member radially OFF the belt). + private const float BeltRadialJitter = 120f; // full jitter span (±60) around the belt radius + private const float MinInnerBeltGap = 620f; // an inner belt needs this much room between two planet orbits + + /// The system's 1–2 belt annulus radii (#683). The outer belt always exists, one orbit + /// step beyond the outermost planet (the main-belt/Kuiper-belt reading). Big systems (5+ planets, + /// enough asteroids for two rings) may roll a second, INNER belt — but only into a gap between two + /// adjacent planet orbits wide enough that a belt rock can never crowd a planet even when their + /// angles line up. + private List BeltRadii(int systemIndex, StarSystem system, int planets, int asteroidCount) + { + var orbitRadii = new List(); + foreach (var b in system.Bodies) + { + if (b.Kind == CelestialKind.Planet) + { + orbitRadii.Add(System.MathF.Sqrt(b.SystemX * b.SystemX + b.SystemZ * b.SystemZ)); + } + } + + orbitRadii.Sort(); + float outermost = orbitRadii.Count > 0 ? orbitRadii[orbitRadii.Count - 1] : BaseOrbit; + var radii = new List + { + outermost + OrbitStep + (Hash01(systemIndex, 800, 1) - 0.5f) * BeltRadialJitter, + }; + + if (planets >= 5 && asteroidCount >= 4 && Hash01(systemIndex, 800, 2) < 0.5f) + { + float bestGap = 0f, bestMid = 0f; + for (int k = 1; k < orbitRadii.Count; k++) + { + float gap = orbitRadii[k] - orbitRadii[k - 1]; + if (gap > bestGap) + { + bestGap = gap; + bestMid = (orbitRadii[k] + orbitRadii[k - 1]) * 0.5f; + } + } + + if (bestGap >= MinInnerBeltGap) + { + radii.Add(bestMid); + } + } + + return radii; + } + + /// A seeded point on one of the system's belt annuli (#683). Members go round-robin over + /// the belts; within one belt they occupy evenly spaced angular slots with a small seeded wobble + /// (≤¼ slot), so even the densest belt keeps at least half a slot of arc between neighbours — + /// comfortably above the flight view's required clear gap at any belt radius. + private (float X, float Z) BeltPoint(int systemIndex, int asteroidIndex, int asteroidCount, List radii) + { + int belt = asteroidIndex % radii.Count; + int slot = asteroidIndex / radii.Count; + int slots = (asteroidCount - belt + radii.Count - 1) / radii.Count; // members on THIS belt + float slotArc = Tau / System.Math.Max(1, slots); + float angle = Hash01(systemIndex, 810 + belt, 1) * Tau + + (slot + (Hash01(systemIndex, 820 + asteroidIndex, 1) - 0.5f) * 0.5f) * slotArc; + float radius = radii[belt] + (Hash01(systemIndex, 820 + asteroidIndex, 2) - 0.5f) * BeltRadialJitter; + return (radius * System.MathF.Cos(angle), radius * System.MathF.Sin(angle)); + } + /// A seeded point on the system disc (out to roughly the outermost planet's orbit). private (float X, float Z) DiscPoint(int systemIndex, int planets, int salt) { diff --git a/tests/BlocksBeyondTheStars.Client.Tests/SystemBodyLayoutTests.cs b/tests/BlocksBeyondTheStars.Client.Tests/SystemBodyLayoutTests.cs index a46b947c..672184d4 100644 --- a/tests/BlocksBeyondTheStars.Client.Tests/SystemBodyLayoutTests.cs +++ b/tests/BlocksBeyondTheStars.Client.Tests/SystemBodyLayoutTests.cs @@ -66,11 +66,13 @@ public void EveryBodyKeepsItsClearGap_AcrossRealGeneratedSystems() // Replay classic worlds AND archetype-varied ones (#546): the lone giant's 8-moon ladder and the // size-biased bodies must keep the same clear-gap guarantee as the uniform layout. - // Runs 1..40 use the classic description, 41..80 re-run the same 40 seeds with variance on. - for (long run = 1; run <= 80; run++) + // Runs 1..40 use the classic description, 41..80 re-run the same 40 seeds with variance on, and + // 81..120 add asteroid belts (#683) on top — belt members share an orbit annulus, so they are + // exactly the pairs the angular-slot spacing must keep apart. + for (long run = 1; run <= 120; run++) { - long seed = run <= 40 ? run : run - 40; - var desc = new WorldDescription { SystemVariance = run > 40 }; + long seed = ((run - 1) % 40) + 1; + var desc = new WorldDescription { SystemVariance = run > 40, AsteroidBelts = run > 80 }; var galaxy = new UniverseGenerator(seed, desc, content).Generate(); foreach (var system in galaxy.Systems) { diff --git a/tests/BlocksBeyondTheStars.Client.Tests/SystemChartLayoutTests.cs b/tests/BlocksBeyondTheStars.Client.Tests/SystemChartLayoutTests.cs index 7aba456d..887a0729 100644 --- a/tests/BlocksBeyondTheStars.Client.Tests/SystemChartLayoutTests.cs +++ b/tests/BlocksBeyondTheStars.Client.Tests/SystemChartLayoutTests.cs @@ -87,10 +87,12 @@ private static float Fit(List bodies, float centreX, float centreZ) private static IEnumerable<(StarSystem System, CelestialBody Home)> RealSystems(int runs = 30) { var content = ContentLoader.LoadFromDirectory(ClientTestPaths.DataDir()); - for (long run = 1; run <= runs * 2; run++) + for (long run = 1; run <= runs * 3; run++) { - long seed = run <= runs ? run : run - runs; - var desc = new WorldDescription { SystemVariance = run > runs }; // classic AND archetype-varied + long seed = ((run - 1) % runs) + 1; + // Classic, archetype-varied, and belt-layout (#683) sweeps: belt members share an orbit + // annulus, so they are the bodies most likely to stress the fit/ring invariants together. + var desc = new WorldDescription { SystemVariance = run > runs, AsteroidBelts = run > runs * 2 }; foreach (var system in new UniverseGenerator(seed, desc, content).Generate().Systems) { var home = system.Bodies.FirstOrDefault(b => b.Kind == CelestialKind.Planet); @@ -169,9 +171,10 @@ public void StarCentring_CostsAtMostABoundedZoomOut() // Centring on the star is what makes the orbit paths possible, and it is not free: where the // launch body sits INSIDE a far-flung asteroid's orbit, framing on the launch body is tighter // than framing on the star, so the chart zooms out. Measured worst case across real systems is - // ~1.72×, and the 12-unit minimum disc size keeps every marker legible through it. This bounds - // the cost so a future layout change can't quietly make the chart useless. - const float Budget = 2.0f; + // ~1.72× classic and ~2.01× with asteroid belts (#683 — the outer belt orbits one step beyond + // the outermost planet by design), and the 12-unit minimum disc size keeps every marker legible + // through it. This bounds the cost so a future layout change can't quietly make the chart useless. + const float Budget = 2.1f; float worst = 1f; string worstId = string.Empty; diff --git a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs index 6047eff6..5525c3c0 100644 --- a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs @@ -9,6 +9,7 @@ using BlocksBeyondTheStars.Shared.Configuration; using BlocksBeyondTheStars.Shared.Content; using BlocksBeyondTheStars.Shared.Geometry; +using BlocksBeyondTheStars.Shared.World; using Xunit; using SvGameServer = BlocksBeyondTheStars.GameServer.GameServer; @@ -66,11 +67,76 @@ public void EnterSpace_SpawnsAsteroids_ButNoDronesWhenCombatOff() Assert.True(server.InSpace("Pilot")); var entities = server.SpaceEntitiesFor("Pilot"); - Assert.Equal(3, entities.Count(e => e.Kind == CombatEntityKind.Asteroid)); + // The classic trio hugs the launch point; any further rocks belong to the belt clusters + // parked at the system's asteroid bodies (#683 S2), well outside the local field. + Assert.Equal(3, entities.Count(e => e.Kind == CombatEntityKind.Asteroid + && (e.Position.X * e.Position.X) + (e.Position.Z * e.Position.Z) <= 60f * 60f)); Assert.DoesNotContain(entities, e => e.Hostile); } } + [Fact] + public void EnterSpace_ParksMineableClustersAtTheSystemsAsteroidBodies() + { + // #683 S2: flying INTO the belt means flying through mineable rocks — every landable asteroid + // body in the resident system carries its own cluster at its flight-view position. + var server = NewServer("beltrocks", r => + { + r.FreeSpaceFlight = true; + r.SpaceCombat = SpaceCombatMode.Off; + }, out var repo); + using (repo) + { + server.AddLocalPlayer("Pilot"); + var system = server.Galaxy.Systems.First(s => s.Bodies.Any(b => b.Id == server.ActiveLocationId)); + var anchor = system.Bodies.First(b => b.Id == server.ActiveLocationId); + var fields = system.Bodies.Where(b => b.Kind == CelestialKind.AsteroidField).ToList(); + Assert.NotEmpty(fields); // the home system always rolls asteroid bodies (never Desolate) + + server.EnterSpace("Pilot"); + var rocks = server.SpaceEntitiesFor("Pilot") + .Where(e => e.Kind == CombatEntityKind.Asteroid).ToList(); + + foreach (var f in fields) + { + float cx = (f.SystemX - anchor.SystemX) * SystemBodyLayout.FlightViewScale; + float cz = (f.SystemZ - anchor.SystemZ) * SystemBodyLayout.FlightViewScale; + int near = rocks.Count(r => + { + float dx = r.Position.X - cx, dz = r.Position.Z - cz; + return (dx * dx) + (dz * dz) <= 40f * 40f; + }); + Assert.True(near >= 4, $"{f.Id}: only {near} mineable rocks parked at the belt body"); + } + } + } + + [Fact] + public void EnterSpace_AtAnAsteroidBody_SpawnsADenseLocalField() + { + // #683 S1: launching FROM an asteroid means the ship starts inside the belt — the local field + // is dense (9 rocks) instead of the classic trio. + var server = NewServer("densefield", r => + { + r.FreeSpaceFlight = true; + r.SpaceCombat = SpaceCombatMode.Off; + }, out var repo); + using (repo) + { + server.AddLocalPlayer("Pilot"); + server.Ship.Modules.Add("jump_generator"); + var rock = server.Galaxy.AllBodies().First(b => + b.Kind == CelestialKind.AsteroidField && !string.IsNullOrEmpty(b.PlanetType)); + server.Travel("Pilot", rock.Id); + Assert.Equal(rock.Id, server.ActiveLocationId); + + server.EnterSpace("Pilot"); + int local = server.SpaceEntitiesFor("Pilot").Count(e => e.Kind == CombatEntityKind.Asteroid + && (e.Position.X * e.Position.X) + (e.Position.Z * e.Position.Z) <= 60f * 60f); + Assert.True(local >= 9, $"launching inside the belt should surround the ship with rocks, got {local}"); + } + } + [Fact] public void EnterSpace_SpawnsDrones_WhenCombatAndNpcEnabled() { @@ -289,7 +355,10 @@ public void SpaceAsteroids_RollSeededFamiliesAndSizes() server.AddLocalPlayer("Pilot"); server.EnterSpace("Pilot"); return server.SpaceEntitiesFor("Pilot") - .Where(e => e.Kind == CombatEntityKind.Asteroid) + .Where(e => e.Kind == CombatEntityKind.Asteroid + // Launch field only — the belt rock clusters (#683 S2) park at the system's other + // asteroid bodies, far outside the local field this test pins. + && (e.Position.X * e.Position.X) + (e.Position.Z * e.Position.Z) <= 60f * 60f) .Select(e => (server.StructureBlockCountForTest(e.Id), server.StructureCellForTest(e.Id, 0, 0, 0))) .ToList(); } diff --git a/tests/BlocksBeyondTheStars.Tests/UniverseTests.cs b/tests/BlocksBeyondTheStars.Tests/UniverseTests.cs index cfcab8d0..dd58dcc7 100644 --- a/tests/BlocksBeyondTheStars.Tests/UniverseTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/UniverseTests.cs @@ -346,6 +346,93 @@ public void Rings_StartPlanetGuarantee_RingsOnlyWhatItShould() Assert.Equal(0, moon.RingSeed); } + // --- Asteroid belts (#683) --- + + private static WorldDescription BeltDesc(int systems = 120) => new() + { + StarSystemCount = systems, + SystemVariance = true, + AsteroidBelts = true, + PlanetsPerSystemMin = 2, + PlanetsPerSystemMax = 6, + SpaceStations = Frequency.Rare, + }; + + [Fact] + public void Belts_AsteroidsShareOneOrTwoAnnuli_ClearOfPlanetOrbits() + { + var galaxy = new UniverseGenerator(42, BeltDesc(), _content).Generate(); + int beltSystems = 0, twoBeltSystems = 0; + + foreach (var sys in galaxy.Systems) + { + var radii = sys.Bodies.Where(b => b.Kind == CelestialKind.AsteroidField) + .Select(b => MathF.Sqrt(b.SystemX * b.SystemX + b.SystemZ * b.SystemZ)) + .OrderBy(r => r).ToList(); + if (radii.Count == 0) + { + continue; + } + + beltSystems++; + + // Cluster the orbit radii: one belt's members span at most the full radial jitter (120), + // so a wider gap can only be the space between two DIFFERENT belts. + var belts = new List> { new() { radii[0] } }; + for (int k = 1; k < radii.Count; k++) + { + if (radii[k] - radii[k - 1] > 130f) + { + belts.Add(new List()); + } + + belts[^1].Add(radii[k]); + } + + Assert.InRange(belts.Count, 1, 2); + Assert.All(belts, belt => Assert.True(belt[^1] - belt[0] <= 130f, + $"{sys.Id}: belt spans {belt[^1] - belt[0]} — wider than the jitter allows")); + if (belts.Count == 2) + { + twoBeltSystems++; + } + + // The whole point (#683): no asteroid ever sits in a planet's orbit lane again. + var planetOrbits = sys.Bodies.Where(b => b.Kind == CelestialKind.Planet) + .Select(b => MathF.Sqrt(b.SystemX * b.SystemX + b.SystemZ * b.SystemZ)).ToList(); + foreach (var r in radii) + { + foreach (var p in planetOrbits) + { + Assert.True(System.Math.Abs(r - p) >= 200f, + $"{sys.Id}: asteroid at orbit {r} crowds a planet orbit at {p}"); + } + } + } + + Assert.True(beltSystems >= 80, $"only {beltSystems} systems carry asteroids — sweep too thin"); + Assert.True(twoBeltSystems >= 1, "no big system ever rolled a second belt across 120 systems"); + } + + [Fact] + public void Belts_LeavePlanetsAndMoonsExactlyWhereTheLegacyLayoutPutThem() + { + // The flag may only move ASTEROID bodies (and, transitively, the free-floating stations/wrecks + // that separate away from them). Planets and moons are placed before the asteroid pass and must + // stay byte-identical — they anchor already-visited worlds' sky and travel targets. + var flagOff = BeltDesc(); + flagOff.AsteroidBelts = false; + var off = new UniverseGenerator(42, flagOff, _content).Generate(); + var on = new UniverseGenerator(42, BeltDesc(), _content).Generate(); + + Assert.Equal(BodyKey(off), BodyKey(on)); // ids/kinds/types never depend on the flag + + static IEnumerable<(string, float, float)> Anchored(Galaxy g) => g.AllBodies() + .Where(b => b.Kind is CelestialKind.Planet or CelestialKind.Moon) + .Select(b => (b.Id, b.SystemX, b.SystemZ)); + Assert.Equal(Anchored(off), Anchored(on)); + } + // --- System archetype variance (#546/#549) --- private static WorldDescription VarianceDesc(int systems = 150) => new()