Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ plans live under [docs/](docs/) (committed); the long-range direction is the str
keep it current when controls/features change. Last consolidated 2026-06-04.

**Build:** `scripts/build-client.ps1` (Windows) or `scripts/build-client.sh` (Linux) — publishes shared libs + bundled server + Unity player.
**Test:** `./scripts/run-tests.sh` — currently **1413 server + 154 client passing** (2026-08-03). Locale parity (en/de) is enforced by a test.
**Test:** `./scripts/run-tests.sh` — currently **1419 server + 154 client passing** (2026-08-03). Locale parity (en/de) is enforced by a test.
CI runs two tiers: PRs skip the tests marked `[Trait("Category", "Slow")]`; pushes to `main` and the release workflow run the full suite. CI builds/runs
tests in Release, and a per-test duration guardrail (`scripts/check-test-durations.py`, PRs only) fails the gate when a non-Slow test exceeds 120 s.
**Conventions:** English docs/comments; in-game text bilingual DE+EN; commit to `main` with the
Expand Down Expand Up @@ -102,6 +102,36 @@ Per-item detail lives in the dated work log below. **Since 2026-07 versions are

---

### ★ Enemy health bars + real aiming: crosshair shots, AutoAim world rule, ship-weapon enforcement (#692, #693, #694, 2026-08-03, branch feat/health-bars-and-aiming — LOCAL, no PR)
Combat never missed and never showed enemy health. **Health bars (#692):** every damageable entity
(machines/drones/bandits, creatures incl. titans, space drones/UFOs/cruisers/bandit ships) draws a
floating green→amber→red bar (companions: friendly cyan) while "in combat" (~6 s after a hull drop)
or under the crosshair/fire lock — `ScreenLabelLayer` grew a pooled `WorldBar` primitive beside the
nameplate pool, fed per-frame by `WorldEntities`/`CreatureView`/`SpaceView` through the shared policy
in the new `EnemyHealthBars` helper (lerped fill, id cleanup on despawn). No protocol change —
`Hull/HullMax` were already on the wire. Client toggle `ShowEnemyHealthBars` (comfort section,
`ui.settings.show_enemy_health`). **Aiming (#693):** on foot, `AttackNearestEnemy` no longer picks
the nearest target in a full 360° — the crosshair entity (analytic ray-vs-sphere over the replicated
entities + a voxel terrain march; entity meshes have no colliders) always wins, auto-aim otherwise
acquires only inside a ~±35° forward cone, and melee sweeps ~±60°. New world rule `GameRules.AutoAim`
(default ON; `--auto-aim`, live admin row in the tech menu, `SetWorldRulesIntent.AutoAim`): OFF means
only what's actually under the crosshair can be hit — misses fly a straight tracer into the terrain.
In space the ±75° soft-lock tightened to ~±30° (AutoAim ON) or a true boresight ray (OFF). The
crosshair tints hostile-red over a target and flashes a hit marker when your own shot lands (hull-drop
attribution via `LastShotTargetId`). Server-authoritative: `AttackEntityIntent`/`FireWeaponIntent`
carry the aim direction (contractless-additive — zero vector = old client = legacy behaviour), and the
server validates angle (generous anti-cheat tolerances), ranged line-of-sight (`HasLineOfSight` — no
shooting through walls) and a space firing arc. **Ship-weapon gaps (#694):** `weapon_cooldown` and
`weapon_energy` from `data/ship_modules.json` are now enforced server-side (per-player cooldown map;
lazily-regenerating reactor-fed energy pool — never throttles honest fire, drains modded spam), and
the client reads range/cooldown from the fitted module instead of hardcoding 45/0.45 s — the
`laser_cannon_2` finally uses its range 70. Existing worlds keep AutoAim ON automatically (missing
JSON field deserializes to the default — no rules lift needed). Tests: 6 new `AimValidationTests`
(legacy zero-dir, cone reject, manual crosshair line, wall block, space arc, server cooldown), CLI
parse + live-edit coverage; fire-loop tests tick the cooldown. 1375 server + 147 client green.
World-creation UI deliberately has NO AutoAim row (both columns are flush with the footer) — the
live admin row covers it.

### ★ Space rocks come in sizes and flavors: seeded mineral families, water ice included (#687, 2026-08-03, branch feat/mini-asteroid-variety)
Every mineable space rock was the same clone: a fixed r=2 sphere with a titanium core and an
iron/copper/stone shell. Rocks now roll a seeded FAMILY — stony (~40 %), metallic (~25 %),
Expand Down
4 changes: 4 additions & 0 deletions client/Assets/BlocksBeyondTheStars/Scripts/ClientSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,10 @@ public sealed class ClientSettings
/// always shows until the tutorial is finished or skipped; this mutes the optional coaching.</summary>
public bool VegaHints = true;

/// <summary>Show floating health bars over enemies and creatures in combat (#692) — planet surface
/// and space flight alike. Purely cosmetic (the values are replicated either way); off hides them.</summary>
public bool ShowEnemyHealthBars = true;

// Comfort / wellbeing (playtime). Purely client-side: the session timer counts real wall-clock from
// the moment you enter a world; the reminder is VEGA gently suggesting a break (a real-world nudge, not
// an in-fiction event). Both default on but unobtrusive.
Expand Down
14 changes: 14 additions & 0 deletions client/Assets/BlocksBeyondTheStars/Scripts/CraftingTechShipUI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1531,6 +1531,20 @@ void RuleRow(string label, string current, System.Action<string> send)
UiKit.AddText(keepShipBtn.transform, 560, 0, 200, 78, keepShip ? L("ui.toggle.on") : L("ui.toggle.off"), 22,
keepShip ? UiKit.Ok : UiKit.CyanDim, TextAnchor.MiddleLeft, FontStyle.Bold);
y += 96f;

// Auto-aim (world option, #693): when on (default) weapons acquire targets in a forward cone by
// themselves; when off only what is actually under the crosshair can be hit — for everyone in
// this world. The server enforces the admin gate and validates shots accordingly.
bool autoAim = rules?.AutoAim ?? true;
var autoAimBtn = UiKit.AddButton(_listContent, 0, y, 780, 78, string.Empty, () =>
{
Game?.Network?.SendSetWorldRules(autoAim: autoAim ? "Off" : "On");
Invoke(nameof(RebuildList), 0.35f);
});
UiKit.AddText(autoAimBtn.transform, 16, 0, 520, 78, L("ui.worldopt.auto_aim"), 24, UiKit.TextCol, TextAnchor.MiddleLeft, FontStyle.Bold);
UiKit.AddText(autoAimBtn.transform, 560, 0, 200, 78, autoAim ? L("ui.toggle.on") : L("ui.toggle.off"), 22,
autoAim ? UiKit.Ok : UiKit.CyanDim, TextAnchor.MiddleLeft, FontStyle.Bold);
y += 96f;
y += 16f;

// VEGA advisor hints on/off — mutes the ship AI's optional coaching (onboarding chip stays).
Expand Down
10 changes: 10 additions & 0 deletions client/Assets/BlocksBeyondTheStars/Scripts/CreatureView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ private void Update()

var seen = _seenScratch;
seen.Clear();
var cam = Camera.main; // for the floating health bars (#692)
foreach (var c in Game.Creatures)
{
seen.Add(c.Id);
Expand Down Expand Up @@ -216,6 +217,14 @@ private void Update()

entry.PrevHull = c.Hull;
entry.PrevHostile = c.Hostile;

// Floating health bar (#692): sits just above where a companion nameplate would hang, height
// scaled with the creature's size; companions read friendly cyan, wild fauna the health ramp.
float barHeight = 1.5f * Mathf.Clamp(c.Size, 0.4f, 8f) + 0.7f;
EnemyHealthBars.Push(Game, cam, c.Id,
entry.Root.transform.position + Vector3.up * barHeight,
c.Hull, c.HullMax, friendly: !string.IsNullOrEmpty(c.OwnerId),
fadeStart: 18f, fadeEnd: 28f);
}

if (_creatures.Count > seen.Count)
Expand All @@ -238,6 +247,7 @@ private void Update()
if (e.Zzz != null) Destroy(e.Zzz); // sleep label is under the game root too
Destroy(e.Root);
_creatures.Remove(id);
EnemyHealthBars.Forget(id);
}
}
}
Expand Down
92 changes: 92 additions & 0 deletions client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Blocks Beyond the Stars — Copyright (c) 2026 Justus Dütscher & Marcel Dütscher (JuMaVe Games)
// SPDX-License-Identifier: AGPL-3.0-or-later
// This file is part of Blocks Beyond the Stars. See LICENSE for the full AGPL-3.0 text.
using System.Collections.Generic;
using UnityEngine;

namespace BlocksBeyondTheStars.Client
{
/// <summary>
/// Floating health bars over damageable entities (#692) — planet enemies/bandits, creatures and
/// space hostiles all funnel through <see cref="Push"/> once per entity per frame. The helper owns
/// the shared policy so every caller behaves identically:
/// - a bar shows while the entity is "in combat" (took damage in the last few seconds) or is the
/// current crosshair/fire target — never permanently, so herds don't become a wall of gauges;
/// - the fill lerps toward the replicated value (snapshots arrive at 0.15–0.5 s cadence);
/// - colour ramps green→amber→red with remaining health (companions read friendly cyan instead);
/// - a hull drop attributable to the local player's latest shot flashes the HUD hit marker (#693).
/// The "Enemy health bars" client setting turns the bars off; hit attribution still runs.
/// </summary>
public static class EnemyHealthBars
{
private const float ShowSeconds = 6f; // how long a damaged entity keeps its bar
private const float LerpPerSecond = 1.2f; // fill fraction change per second toward the snapshot

private static readonly Dictionary<string, float> _lastHull = new Dictionary<string, float>();
private static readonly Dictionary<string, float> _combatUntil = new Dictionary<string, float>();
private static readonly Dictionary<string, float> _shownFrac = new Dictionary<string, float>();

/// <summary>Feeds one entity's replicated state for this frame and draws its bar when the policy
/// says so. <paramref name="anchor"/> is the world-space point above the body; <paramref name="targeted"/>
/// marks the caller's own current target (space fire lock) on top of the on-foot crosshair aim.</summary>
public static void Push(GameBootstrap game, Camera cam, string id, Vector3 anchor,
float hull, float hullMax, bool friendly,
float fadeStart, float fadeEnd, bool targeted = false)
{
if (game == null || cam == null || string.IsNullOrEmpty(id) || hullMax <= 1f)
{
return; // hullMax 1 = the sentinel stations/drops use — nothing worth a gauge
}

// Damage detection: a hull drop marks the entity "in combat" and, when our own latest shot
// went there, flashes the hit marker. Runs even with bars disabled so aiming feedback stays.
if (_lastHull.TryGetValue(id, out var prev) && hull < prev - 0.01f)
{
_combatUntil[id] = Time.time + ShowSeconds;
if (game.LastShotTargetId == id && Time.time - game.LastShotTime < 0.6f)
{
HudUi.Instance?.ShowHitMarker();
}
}

_lastHull[id] = hull;

if (game.Settings != null && !game.Settings.ShowEnemyHealthBars)
{
return;
}

bool aimed = targeted || game.AimedEnemyId == id;
bool inCombat = _combatUntil.TryGetValue(id, out var until) && Time.time < until;
if (!aimed && !inCombat)
{
_shownFrac.Remove(id); // next appearance snaps to the live value instead of lerping in
return;
}

float target = hullMax > 0f ? Mathf.Clamp01(hull / hullMax) : 0f;
float shown = _shownFrac.TryGetValue(id, out var s)
? Mathf.MoveTowards(s, target, Time.deltaTime * LerpPerSecond)
: target;
_shownFrac[id] = shown;

var col = friendly ? UiKit.Cyan : Ramp(shown);
ScreenLabelLayer.Instance.WorldBar(cam, anchor, shown, col, 46f, fadeStart, fadeEnd);
}

/// <summary>Drops an entity's bookkeeping when it despawns/dies, so ids never accumulate.</summary>
public static void Forget(string id)
{
_lastHull.Remove(id);
_combatUntil.Remove(id);
_shownFrac.Remove(id);
}

/// <summary>The speeder-gauge colour convention: green while healthy, amber when bruised, red when
/// close to breaking — reads at a glance for kids.</summary>
private static Color Ramp(float frac)
=> frac > 0.5f ? new Color(0.35f, 0.9f, 0.4f)
: frac > 0.25f ? new Color(1f, 0.75f, 0.25f)
: new Color(1f, 0.35f, 0.3f);
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,20 @@ public bool LandedShipCovers(int x, int y, int z)
public bool SpaceSkipLaunch { get; private set; } // entered space already airborne (helm) → no take-off anim
public NetCombatEntity[] PlanetEnemies { get; private set; } = System.Array.Empty<NetCombatEntity>();

// --- Crosshair enemy aiming (#693): published by PlayerController every frame ---

/// <summary>Id of the enemy/creature currently under the crosshair (null = none) — drives the
/// crosshair's hostile tint and keeps that entity's health bar always visible.</summary>
public string AimedEnemyId { get; set; }

/// <summary>Whether the active world's rules mandate auto-aim (defaults to ON before rules arrive).</summary>
public bool AutoAimOn => Rules == null || Rules.AutoAim;

/// <summary>The entity id of the local player's most recent shot + when it left — lets the entity
/// views attribute an observed hull drop to "my hit" and flash the crosshair hit marker.</summary>
public string LastShotTargetId { get; set; }
public float LastShotTime { get; set; } = -999f;

/// <summary>Live procedural creatures near the player (fauna), with their species descriptor.</summary>
public NetCreature[] Creatures { get; private set; } = System.Array.Empty<NetCreature>();

Expand Down
76 changes: 73 additions & 3 deletions client/Assets/BlocksBeyondTheStars/Scripts/HudUi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ public sealed class HudUi : MonoBehaviour
private Canvas _canvas;
private GameObject _crosshair, _locationPanel, _vitalsPanel, _shipRows;

// Crosshair state (#693): hostile tint while an enemy is under the reticle + the hit-marker flash.
private static readonly Color HostileAim = new Color(1f, 0.4f, 0.35f, 0.95f);
private static readonly Color HitMarkerCol = new Color(1f, 0.85f, 0.4f, 0.95f);
private Image _crossV, _crossH;
private GameObject _hitMarker;
private float _hitMarkerTimer;

/// <summary>Set while a scope draws its own reticle (see <see cref="BinocularOptic"/>); hides the HUD
/// crosshair for as long as it is up.</summary>
public static bool SuppressCrosshair;
Expand Down Expand Up @@ -131,6 +138,7 @@ private void LateUpdate()

if (show)
{
UpdateCrosshairState(Time.deltaTime); // per frame: aim tint must not lag the reticle
RefreshCompass(); // per frame: blips counter-rotate with the camera, throttling would judder

_refreshTimer -= Time.deltaTime;
Expand Down Expand Up @@ -1234,14 +1242,76 @@ private bool HoldingScanner()
private static Image Panel(Transform parent, float x, float y, float w, float h)
=> UiKit.AddPanel(parent, x, y, w, h, new Color(0.05f, 0.12f, 0.24f, 0.82f));

private static void MakeCrosshair(RectTransform parent)
private void MakeCrosshair(RectTransform parent)
{
var v = new GameObject("v", typeof(RectTransform)); v.transform.SetParent(parent, false);
var vr = v.GetComponent<RectTransform>(); vr.anchorMin = vr.anchorMax = new Vector2(0.5f, 0.5f); vr.sizeDelta = new Vector2(2, 18);
v.AddComponent<Image>().color = UiKit.Cyan;
_crossV = v.AddComponent<Image>(); _crossV.color = UiKit.Cyan;
var hh = new GameObject("h", typeof(RectTransform)); hh.transform.SetParent(parent, false);
var hr = hh.GetComponent<RectTransform>(); hr.anchorMin = hr.anchorMax = new Vector2(0.5f, 0.5f); hr.sizeDelta = new Vector2(18, 2);
hh.AddComponent<Image>().color = UiKit.Cyan;
_crossH = hh.AddComponent<Image>(); _crossH.color = UiKit.Cyan;

// Hit marker (#693): four diagonal ticks around the reticle, flashed briefly when one of the
// local player's shots visibly lands (the entity views attribute the hull drop and call
// ShowHitMarker). Inactive by default.
_hitMarker = new GameObject("hits", typeof(RectTransform));
_hitMarker.transform.SetParent(parent, false);
var hm = _hitMarker.GetComponent<RectTransform>();
hm.anchorMin = hm.anchorMax = new Vector2(0.5f, 0.5f);
hm.sizeDelta = Vector2.zero;
for (int i = 0; i < 4; i++)
{
var tick = new GameObject("t" + i, typeof(RectTransform));
tick.transform.SetParent(_hitMarker.transform, false);
var tr = tick.GetComponent<RectTransform>();
tr.anchorMin = tr.anchorMax = new Vector2(0.5f, 0.5f);
tr.sizeDelta = new Vector2(2.5f, 9f);
float ang = 45f + i * 90f;
tr.localRotation = Quaternion.Euler(0f, 0f, ang);
tr.anchoredPosition = Quaternion.Euler(0f, 0f, ang) * new Vector2(0f, 13f);
tick.AddComponent<Image>().color = HitMarkerCol;
}

_hitMarker.SetActive(false);
}

/// <summary>Tints the reticle hostile-red while an enemy sits under it, and runs the hit-marker
/// flash timer. Called every frame from <see cref="LateUpdate"/>.</summary>
private void UpdateCrosshairState(float dt)
{
if (_crossV == null)
{
return;
}

var col = Game.AimedEnemyId != null ? HostileAim : UiKit.Cyan;
if (_crossV.color != col)
{
_crossV.color = col;
_crossH.color = col;
}

if (_hitMarkerTimer > 0f)
{
_hitMarkerTimer -= dt;
if (_hitMarkerTimer <= 0f && _hitMarker != null)
{
_hitMarker.SetActive(false);
}
}
}

/// <summary>Flashes the crosshair hit marker (#693) — called by the entity views when a hull drop is
/// attributable to the local player's latest shot.</summary>
public void ShowHitMarker()
{
if (_hitMarker == null)
{
return;
}

_hitMarkerTimer = 0.25f;
_hitMarker.SetActive(true);
}
}
}
Loading