From 59f3e99d76bab3213614bfbe4c4d4d95317e91b0 Mon Sep 17 00:00:00 2001 From: marceld23 Date: Mon, 3 Aug 2026 20:31:08 +0200 Subject: [PATCH 1/2] feat(combat): enemy health bars, crosshair aiming + AutoAim world rule, ship-weapon enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Health bars (#692): pooled WorldBar primitive on ScreenLabelLayer + shared policy in EnemyHealthBars — every damageable entity (planet machines/bandits, creatures, space hostiles) shows a green→amber→red bar (companions cyan) while in combat (~6 s after a hull drop) or under the crosshair/fire lock. Lerped fill, distance fades matching the nameplate conventions, id cleanup on despawn. No protocol change (Hull/HullMax were already replicated). Client toggle ClientSettings.ShowEnemyHealthBars + ui.settings.show_enemy_health (en/de). Aiming (#693): on foot the crosshair entity (analytic ray-vs-sphere + voxel terrain march; entity meshes carry no colliders) always wins; auto-aim otherwise acquires only in a ~±35° forward cone (no more behind-the-back kills), melee sweeps ~±60°. New world rule GameRules.AutoAim (default ON; --auto-aim CLI, live admin row, SetWorldRulesIntent/ServerRules) — OFF means only a genuine crosshair hit (on foot) or boresight line (space, replacing the ±75° soft lock with ~±30°/ray) lands; misses trace into the terrain. Crosshair tints hostile-red, hit marker flashes on attributed hull drops. Intents carry the aim direction contractless-additively (zero = legacy client) and the server validates angle, ranged line-of-sight and the space firing arc with generous anti-cheat tolerances. Old saves keep AutoAim ON via the missing-field default — no rules lift needed. Ship weapons (#694): weapon_cooldown and weapon_energy are now enforced server-side (per-player cooldown committed only when the shot fires; lazily regenerating reactor-fed energy pool), and the client reads range/cooldown from the fitted module instead of hardcoding 45/0.45 — laser_cannon_2 finally gets its range 70. Tests: AimValidationTests (legacy zero-dir, cone reject, manual crosshair line, wall LOS block, space arc, server cooldown), CLI parse + live-edit coverage; fire-loop tests tick the cooldown. 1375+147 fast-tier green. closes #692 closes #693 closes #694 Co-Authored-By: Claude Fable 5 --- TODO.md | 32 ++- .../Scripts/ClientSettings.cs | 4 + .../Scripts/CraftingTechShipUI.cs | 14 ++ .../Scripts/CreatureView.cs | 10 + .../Scripts/EnemyHealthBars.cs | 92 +++++++ .../Scripts/EnemyHealthBars.cs.meta | 2 + .../Scripts/GameBootstrap.cs | 14 ++ .../BlocksBeyondTheStars/Scripts/HudUi.cs | 76 +++++- .../Scripts/PlayerController.cs | 228 +++++++++++++++--- .../Scripts/ScreenLabelLayer.cs | 105 ++++++++ .../BlocksBeyondTheStars/Scripts/SpaceView.cs | 117 +++++++-- .../Scripts/UiSettings.cs | 1 + .../Scripts/UiWorldOptions.cs | 3 + .../Scripts/WorldCreationOptions.cs | 7 +- .../Scripts/WorldEntities.cs | 7 + data/locales/de.json | 2 + data/locales/en.json | 2 + docs/user/USER_MANUAL.md | 17 +- .../NetworkClient.cs | 15 +- .../GameServer.cs | 12 +- .../GameServerEnemies.cs | 87 ++++++- .../GameServerSpaceCombat.cs | 137 ++++++++++- .../Messages.cs | 22 +- .../Configuration/GameRules.cs | 6 + .../Configuration/ServerConfig.cs | 4 + .../AimValidationTests.cs | 208 ++++++++++++++++ .../ServerConfigTests.cs | 12 + .../SpaceCombatTests.cs | 11 +- .../WorldOptionsTests.cs | 3 +- 29 files changed, 1181 insertions(+), 69 deletions(-) create mode 100644 client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs create mode 100644 client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs.meta create mode 100644 tests/BlocksBeyondTheStars.Tests/AimValidationTests.cs diff --git a/TODO.md b/TODO.md index 477acdf7..177693e4 100644 --- a/TODO.md +++ b/TODO.md @@ -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 @@ -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 %), diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/ClientSettings.cs b/client/Assets/BlocksBeyondTheStars/Scripts/ClientSettings.cs index 0e47e051..0b4c2c08 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/ClientSettings.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/ClientSettings.cs @@ -292,6 +292,10 @@ public sealed class ClientSettings /// always shows until the tutorial is finished or skipped; this mutes the optional coaching. public bool VegaHints = true; + /// 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. + 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. diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/CraftingTechShipUI.cs b/client/Assets/BlocksBeyondTheStars/Scripts/CraftingTechShipUI.cs index e3769961..b3ea12b7 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/CraftingTechShipUI.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/CraftingTechShipUI.cs @@ -1531,6 +1531,20 @@ void RuleRow(string label, string current, System.Action 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). diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/CreatureView.cs b/client/Assets/BlocksBeyondTheStars/Scripts/CreatureView.cs index 25d0e0e5..343c48e8 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/CreatureView.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/CreatureView.cs @@ -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); @@ -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) @@ -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); } } } diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs b/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs new file mode 100644 index 00000000..2dfd0cf9 --- /dev/null +++ b/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs @@ -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 +{ + /// + /// Floating health bars over damageable entities (#692) — planet enemies/bandits, creatures and + /// space hostiles all funnel through 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. + /// + 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 _lastHull = new Dictionary(); + private static readonly Dictionary _combatUntil = new Dictionary(); + private static readonly Dictionary _shownFrac = new Dictionary(); + + /// Feeds one entity's replicated state for this frame and draws its bar when the policy + /// says so. is the world-space point above the body; + /// marks the caller's own current target (space fire lock) on top of the on-foot crosshair aim. + 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); + } + + /// Drops an entity's bookkeeping when it despawns/dies, so ids never accumulate. + public static void Forget(string id) + { + _lastHull.Remove(id); + _combatUntil.Remove(id); + _shownFrac.Remove(id); + } + + /// The speeder-gauge colour convention: green while healthy, amber when bruised, red when + /// close to breaking — reads at a glance for kids. + 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); + } +} diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs.meta b/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs.meta new file mode 100644 index 00000000..770aff1c --- /dev/null +++ b/client/Assets/BlocksBeyondTheStars/Scripts/EnemyHealthBars.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 18be9bb63be44fe890e8d4112e2457b0 diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs b/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs index 660c723f..a8b045dd 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/GameBootstrap.cs @@ -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(); + // --- Crosshair enemy aiming (#693): published by PlayerController every frame --- + + /// 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. + public string AimedEnemyId { get; set; } + + /// Whether the active world's rules mandate auto-aim (defaults to ON before rules arrive). + public bool AutoAimOn => Rules == null || Rules.AutoAim; + + /// 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. + public string LastShotTargetId { get; set; } + public float LastShotTime { get; set; } = -999f; + /// Live procedural creatures near the player (fauna), with their species descriptor. public NetCreature[] Creatures { get; private set; } = System.Array.Empty(); diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/HudUi.cs b/client/Assets/BlocksBeyondTheStars/Scripts/HudUi.cs index 355670cd..9d80d9de 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/HudUi.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/HudUi.cs @@ -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; + /// Set while a scope draws its own reticle (see ); hides the HUD /// crosshair for as long as it is up. public static bool SuppressCrosshair; @@ -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; @@ -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(); vr.anchorMin = vr.anchorMax = new Vector2(0.5f, 0.5f); vr.sizeDelta = new Vector2(2, 18); - v.AddComponent().color = UiKit.Cyan; + _crossV = v.AddComponent(); _crossV.color = UiKit.Cyan; var hh = new GameObject("h", typeof(RectTransform)); hh.transform.SetParent(parent, false); var hr = hh.GetComponent(); hr.anchorMin = hr.anchorMax = new Vector2(0.5f, 0.5f); hr.sizeDelta = new Vector2(18, 2); - hh.AddComponent().color = UiKit.Cyan; + _crossH = hh.AddComponent(); _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(); + 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(); + 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().color = HitMarkerCol; + } + + _hitMarker.SetActive(false); + } + + /// Tints the reticle hostile-red while an enemy sits under it, and runs the hit-marker + /// flash timer. Called every frame from . + 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); + } + } + } + + /// Flashes the crosshair hit marker (#693) — called by the entity views when a hull drop is + /// attributable to the local player's latest shot. + public void ShowHitMarker() + { + if (_hitMarker == null) + { + return; + } + + _hitMarkerTimer = 0.25f; + _hitMarker.SetActive(true); } } } diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs b/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs index 45b63ab9..0242313c 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs @@ -421,6 +421,7 @@ private void Update() HandleDrillAudio(); UpdateGearPeriodically(); SendMovement(); + UpdateEnemyAim(); // Publish local pose for the HUD minimap/compass. if (Game != null) @@ -431,6 +432,12 @@ private void Update() } } + // Crosshair aiming (#693): auto-aim acquires inside a forward cone only (never behind the back); + // a melee swing sweeps a wider arc; with the AutoAim world rule OFF a ranged shot needs a genuine + // crosshair hit — nothing under the reticle means the shot misses. + private const float AutoAimCone = 0.819f; // cos ~35° + private const float MeleeCone = 0.5f; // cos ~60° + private void AttackNearestEnemy() { if (Game?.Network == null) @@ -439,8 +446,6 @@ private void AttackNearestEnemy() } PlayWeaponSound(); - string nearest = null; - Vector3 nearestPos = default; // Reach follows the equipped weapon: a ranged weapon must let you hit at its full range, not the bare // melee reach — otherwise a "gun" only ever fires point-blank (where the enemy's own bite already @@ -449,51 +454,50 @@ private void AttackNearestEnemy() float reach = heldTool != null && heldTool.Kind == BlocksBeyondTheStars.Shared.Definitions.ToolKind.Weapon ? Mathf.Max(6f, heldTool.Range) : 6f; - float bestSq = reach * reach; - foreach (var e in Game.PlanetEnemies) + var kind = HeldWeaponFx(); + bool melee = kind == WeaponFxKind.Melee; + + // The entity under the crosshair always wins — it is what the player is looking at. + string targetId = null; + Vector3 targetPos = default; + if (AimEnemy(reach, out var aimId, out var aimPos, out float terrainDist)) { - var ep = Game.ScenePos(e.X, e.Y, e.Z); // seam-aware (longitude wraps) - float d = (ep - transform.position).sqrMagnitude; - if (d < bestSq) - { - bestSq = d; - nearest = e.Id; - nearestPos = ep; - } + targetId = aimId; + targetPos = aimPos; } - - // Creatures (fauna) are attackable too — the server shares the hit path. - foreach (var c in Game.Creatures) + else if (melee || Game.AutoAimOn) { - var cp = Game.ScenePos(c.X, c.Y, c.Z); // seam-aware (longitude wraps) - float d = (cp - transform.position).sqrMagnitude; - if (d < bestSq) - { - bestSq = d; - nearest = c.Id; - nearestPos = cp; - } + // Auto-aim (and every melee swing): nearest target inside the forward cone. The old + // 360° nearest-anywhere selection is gone — no more kills behind your back. + targetId = BestConeTarget(reach, melee ? MeleeCone : AutoAimCone, out targetPos); } - if (nearest != null) + var ct = Camera != null ? Camera.transform : transform; + if (targetId != null) { - Game.Network.SendAttackEntity(nearest); + var f = ct.forward; + Game.Network.SendAttackEntity(targetId, + new BlocksBeyondTheStars.Shared.Primitives.Vector3f(f.x, f.y, f.z)); + Game.LastShotTargetId = targetId; + Game.LastShotTime = Time.time; } if (Weapons != null && Camera != null) { - var ct = Camera.transform; var from = ct.position + ct.forward * 0.4f - ct.up * 0.15f; var col = WeaponColor(); - var kind = HeldWeaponFx(); if (kind == WeaponFxKind.Melee) { // A melee slash sweeps whether or not it connects (whiff still reads). Weapons.MeleeArc(from, ct.forward, ct.up, col); } - else if (nearest != null) + else { - var target = nearestPos + Vector3.up * 0.4f; + // A hit flies to the body; a miss still leaves the muzzle and dies on the terrain + // (or at max range) — with manual aiming, "wide" has to read as wide. + var target = targetId != null + ? targetPos + Vector3.up * 0.4f + : ct.position + ct.forward * Mathf.Min(reach, terrainDist); if (kind == WeaponFxKind.Projectile) { Weapons.Projectile(from, target, col); // kinetic bolt that flies + bursts @@ -506,6 +510,172 @@ private void AttackNearestEnemy() } } + /// Finds the enemy/creature under the crosshair (#693): an analytic ray-vs-sphere sweep over + /// the replicated entities — their meshes deliberately carry no colliders — occluded by terrain via a + /// voxel march. Hitboxes are deliberately generous (kid-friendly forgiveness). Also reports how far + /// the ray flies before hitting terrain, for the miss tracer. + private bool AimEnemy(float maxRange, out string id, out Vector3 pos, out float terrainDist) + { + id = null; + pos = default; + terrainDist = maxRange; + if (Game == null || Camera == null) + { + return false; + } + + Vector3 o = Camera.transform.position; + Vector3 dir = Camera.transform.forward; + terrainDist = TerrainDistance(o, dir, maxRange); + + float best = terrainDist + 0.5f; // a body right at the wall still counts + foreach (var e in Game.PlanetEnemies) + { + var basePos = Game.ScenePos(e.X, e.Y, e.Z); // seam-aware (longitude wraps) + var center = basePos + Vector3.up * 0.9f; + float r = 1.1f * Mathf.Max(1f, e.Scale); + if (RayHitsSphere(o, dir, center, r, out float d) && d < best) + { + best = d; + id = e.Id; + pos = basePos; + } + } + + foreach (var c in Game.Creatures) + { + float size = Mathf.Clamp(c.Size, 0.4f, 8f); + var basePos = Game.ScenePos(c.X, c.Y, c.Z); + var center = basePos + Vector3.up * (0.6f * size); + float r = Mathf.Max(0.8f, 0.9f * size); + if (RayHitsSphere(o, dir, center, r, out float d) && d < best) + { + best = d; + id = c.Id; + pos = basePos; + } + } + + return id != null; + } + + /// Nearest attackable entity inside the camera-forward cone (auto-aim / melee sweep). + /// Point-blank targets (< 1.5 blocks) ignore the cone — something chewing on your boots is hittable + /// even while you look past it. + private string BestConeTarget(float reach, float cone, out Vector3 pos) + { + pos = default; + string bestId = null; + float bestSq = reach * reach; + Vector3 eye = Camera != null ? Camera.transform.position : transform.position; + Vector3 fwd = Camera != null ? Camera.transform.forward : transform.forward; + + void Consider(string cid, Vector3 p) + { + var to = p + Vector3.up * 0.9f - eye; + float d = to.sqrMagnitude; + if (d >= bestSq || d < 0.0001f) + { + return; + } + + if (d > 2.25f && Vector3.Dot(to.normalized, fwd) < cone) + { + return; + } + + bestSq = d; + bestId = cid; + pos = p; + } + + foreach (var e in Game.PlanetEnemies) + { + Consider(e.Id, Game.ScenePos(e.X, e.Y, e.Z)); + } + + // Creatures (fauna) are attackable too — the server shares the hit path. + foreach (var c in Game.Creatures) + { + Consider(c.Id, Game.ScenePos(c.X, c.Y, c.Z)); + } + + return bestId; + } + + /// Distance the aim ray travels before hitting solid terrain (voxel DDA like + /// , but with a caller-chosen range — weapon range exceeds block reach). + /// Fluids are passed through, matching the block-aim behaviour. + private float TerrainDistance(Vector3 o, Vector3 dir, float maxDist) + { + if (Game?.World == null) + { + return maxDist; + } + + int x = Mathf.FloorToInt(o.x), y = Mathf.FloorToInt(o.y), z = Mathf.FloorToInt(o.z); + int sx = dir.x >= 0 ? 1 : -1, sy = dir.y >= 0 ? 1 : -1, sz = dir.z >= 0 ? 1 : -1; + float invx = Mathf.Abs(dir.x) > 1e-6f ? 1f / Mathf.Abs(dir.x) : float.PositiveInfinity; + float invy = Mathf.Abs(dir.y) > 1e-6f ? 1f / Mathf.Abs(dir.y) : float.PositiveInfinity; + float invz = Mathf.Abs(dir.z) > 1e-6f ? 1f / Mathf.Abs(dir.z) : float.PositiveInfinity; + float tMaxX = float.IsInfinity(invx) ? float.PositiveInfinity : (dir.x > 0 ? (x + 1 - o.x) : (o.x - x)) * invx; + float tMaxY = float.IsInfinity(invy) ? float.PositiveInfinity : (dir.y > 0 ? (y + 1 - o.y) : (o.y - y)) * invy; + float tMaxZ = float.IsInfinity(invz) ? float.PositiveInfinity : (dir.z > 0 ? (z + 1 - o.z) : (o.z - z)) * invz; + + float t = 0f; + for (int i = 0; i < 160 && t <= maxDist; i++) + { + var id = Game.World.GetBlock(x, y, z); + if (!id.IsAir && !IsFluidBlock(id)) + { + return t; + } + + if (tMaxX <= tMaxY && tMaxX <= tMaxZ) { x += sx; t = tMaxX; tMaxX += invx; } + else if (tMaxY <= tMaxZ) { y += sy; t = tMaxY; tMaxY += invy; } + else { z += sz; t = tMaxZ; tMaxZ += invz; } + } + + return maxDist; + } + + /// Ray-vs-sphere with the hit reported at the centre plane — plenty for ordering targets + /// against each other and the terrain at gameplay scales. + private static bool RayHitsSphere(Vector3 o, Vector3 dir, Vector3 center, float radius, out float dist) + { + dist = 0f; + Vector3 to = center - o; + float along = Vector3.Dot(to, dir); + if (along < 0f) + { + return false; + } + + if (to.sqrMagnitude - along * along > radius * radius) + { + return false; + } + + dist = along; + return true; + } + + /// Publishes which enemy sits under the crosshair (every frame) — the HUD tints the reticle + /// and the health-bar layer keeps that entity's bar visible. + private void UpdateEnemyAim() + { + if (Game == null) + { + return; + } + + var heldTool = Game.Content?.GetItem(Game.ItemInSlot(Game.SelectedHotbarSlot))?.Tool; + float reach = heldTool != null && heldTool.Kind == BlocksBeyondTheStars.Shared.Definitions.ToolKind.Weapon + ? Mathf.Max(6f, heldTool.Range) + : 6f; + Game.AimedEnemyId = AimEnemy(reach, out var id, out _, out _) ? id : null; + } + private enum WeaponFxKind { Beam, Projectile, Melee } /// Classifies the held weapon's effect: kinetic guns fire a flying bolt, energy guns an diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/ScreenLabelLayer.cs b/client/Assets/BlocksBeyondTheStars/Scripts/ScreenLabelLayer.cs index 24149c45..03b2db05 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/ScreenLabelLayer.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/ScreenLabelLayer.cs @@ -25,6 +25,11 @@ public sealed class ScreenLabelLayer : MonoBehaviour private int _used; private int _frame = -1; + // Health/progress bars (#692) share the layer's projection + pooling lifecycle, so entity health + // bars and nameplates stay in the same visual system (and one distance-fade convention). + private readonly List _barPool = new List(); + private int _barUsed; + private sealed class Entry { public RectTransform Rt; @@ -32,6 +37,13 @@ private sealed class Entry public Text Shadow; } + private sealed class BarEntry + { + public RectTransform Rt; + public Image Track; + public Image Fill; + } + /// Lazily creates (or returns) the singleton layer. public static ScreenLabelLayer Instance { @@ -101,6 +113,52 @@ public void World(Camera cam, Vector3 world, string text, Color color, bool bold e.Rt.gameObject.SetActive(true); } + /// Pushes a horizontal bar (track + fill) anchored to a world position — the world-space + /// sibling of 's vitals. Same per-frame contract and distance fade as + /// ; callers re-push every frame from LateUpdate. + public void WorldBar(Camera cam, Vector3 world, float frac, Color color, + float width = 46f, float fadeStart = 0f, float fadeEnd = 0f) + { + if (cam == null) + { + return; + } + + var sp = cam.WorldToScreenPoint(world); + if (sp.z <= 0f) + { + return; // behind the camera + } + + float alpha = 1f; + if (fadeEnd > 0f) + { + float dist = Vector3.Distance(cam.transform.position, world); + if (dist >= fadeEnd) + { + return; + } + + if (dist > fadeStart && fadeEnd > fadeStart) + { + alpha = 1f - (dist - fadeStart) / (fadeEnd - fadeStart); + } + } + + BeginFrameIfNeeded(); + var e = AcquireBar(); + RectTransformUtility.ScreenPointToLocalPointInRectangle(_root, new Vector2(sp.x, sp.y), null, out var local); + e.Rt.anchoredPosition = local; + e.Rt.sizeDelta = new Vector2(width, 5f); + var track = new Color(0.03f, 0.07f, 0.13f, 0.9f * alpha); // the HUD vitals' dark track + e.Track.color = track; + var fill = color; + fill.a *= alpha; + e.Fill.color = fill; + e.Fill.fillAmount = Mathf.Clamp01(frac); + e.Rt.gameObject.SetActive(true); + } + private void BeginFrameIfNeeded() { if (_frame == Time.frameCount) @@ -110,6 +168,7 @@ private void BeginFrameIfNeeded() _frame = Time.frameCount; _used = 0; + _barUsed = 0; } private Entry Acquire() @@ -134,6 +193,43 @@ private Entry Acquire() return e; } + private BarEntry AcquireBar() + { + if (_barUsed < _barPool.Count) + { + return _barPool[_barUsed++]; + } + + var go = new GameObject("WorldBar", typeof(RectTransform)); + go.transform.SetParent(_root, false); + var rt = go.GetComponent(); + rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(0.5f, 0.5f); + rt.sizeDelta = new Vector2(46f, 5f); + + var track = go.AddComponent(); + track.sprite = UiKit.SolidSprite; + track.raycastTarget = false; + + var fillGo = new GameObject("Fill", typeof(RectTransform)); + fillGo.transform.SetParent(rt, false); + var fr = fillGo.GetComponent(); + fr.anchorMin = Vector2.zero; + fr.anchorMax = Vector2.one; + fr.offsetMin = new Vector2(0.5f, 0.5f); + fr.offsetMax = new Vector2(-0.5f, -0.5f); + var fill = fillGo.AddComponent(); + fill.sprite = UiKit.SolidSprite; + fill.type = Image.Type.Filled; + fill.fillMethod = Image.FillMethod.Horizontal; + fill.fillOrigin = (int)Image.OriginHorizontal.Left; + fill.raycastTarget = false; + + var e = new BarEntry { Rt = rt, Track = track, Fill = fill }; + _barPool.Add(e); + _barUsed++; + return e; + } + private static Text MakeText(RectTransform parent, Vector2 offset, Color color) { var go = new GameObject("T", typeof(RectTransform)); @@ -169,7 +265,16 @@ private IEnumerator FinalizeLoop() } } + for (int i = _barUsed; i < _barPool.Count; i++) + { + if (_barPool[i].Rt.gameObject.activeSelf) + { + _barPool[i].Rt.gameObject.SetActive(false); + } + } + _used = 0; // next producer call starts a fresh frame + _barUsed = 0; } } } diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs index a5eb9dd6..ccfc4e6d 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs @@ -294,8 +294,28 @@ private sealed class RemoteAvatar { public GameObject Root; public GameObject Sh private GameObject _systemsBarRoot; private int _builtSystemCount = -1; private const string FlightWeapon = "ship_laser_basic"; - private const float WeaponRange = 45f; // matches ship_laser_basic weapon_range - private const float FireRate = 0.45f; // seconds between shots + private const float WeaponRange = 45f; // fallback when the module data is unavailable + private const float FireRate = 0.45f; // fallback seconds between shots + + // #693: with the AutoAim world rule ON the laser acquires targets in a tight forward cone; OFF + // requires a genuine boresight line — the nose ray must pass through the target's body. + private const float SpaceAutoAimCone = 0.866f; // cos ~30° (was effectively ±75°) + + /// The fitted module's real range (#694 — the client used to hardcode 45, crippling the + /// laser_cannon_2's range advantage of 70). + private float WeaponRangeFor(string weaponKey) + { + var stats = Game.Content?.GetShipModule(weaponKey)?.Stats; + return stats != null && stats.TryGetValue("weapon_range", out var v) ? (float)v : WeaponRange; + } + + /// The fitted module's real fire cadence — the server enforces it now (#694), so firing + /// faster client-side would only get shots swallowed. + private float WeaponCooldownFor(string weaponKey) + { + var stats = Game.Content?.GetShipModule(weaponKey)?.Stats; + return stats != null && stats.TryGetValue("weapon_cooldown", out var v) ? (float)v : FireRate; + } private struct ShipSystem { public string Label; public string Kind; public string WeaponKey; } @@ -1228,11 +1248,11 @@ private void UpdateCruise() var sys = _systems[_selectedSystem]; if (sys.Kind == "laser") { - var target = BestFireTarget(); + var target = BestFireTarget(sys.WeaponKey); _fireTargetId = target?.Id; if (target != null && _fireCd <= 0f && InputMap.PrimaryHeld()) { - _fireCd = FireRate; + _fireCd = WeaponCooldownFor(sys.WeaponKey); FireAt(target, sys.WeaponKey); } } @@ -1543,9 +1563,10 @@ private void ActivateTractor(BlocksBeyondTheStars.Networking.Messages.NetCombatE ClientAudio.Instance?.Cue("scan_ping"); } - /// The best entity to fire on: the nearest asteroid/hostile within weapon range that's - /// roughly ahead of the ship (so you aim by pointing the nose at it). - private BlocksBeyondTheStars.Networking.Messages.NetCombatEntity BestFireTarget() + /// The best entity to fire on (#693). AutoAim rule ON: the nearest asteroid/hostile within + /// range inside a tight forward cone (point the nose at it). AutoAim OFF: only a target the nose ray + /// genuinely passes through counts — line it up or the trigger stays cold. + private BlocksBeyondTheStars.Networking.Messages.NetCombatEntity BestFireTarget(string weaponKey) { var space = Game.Space; if (space == null || _ship == null) @@ -1553,10 +1574,13 @@ private BlocksBeyondTheStars.Networking.Messages.NetCombatEntity BestFireTarget( return null; } + float range = WeaponRangeFor(weaponKey); Vector3 shipPos = _ship.transform.localPosition; Vector3 fwd = _ship.transform.localRotation * Vector3.forward; + bool autoAim = Game.AutoAimOn; BlocksBeyondTheStars.Networking.Messages.NetCombatEntity best = null; - float bestScore = 0.25f; // require at least this much forward alignment + float bestScore = 0f; // auto-aim: alignment/distance score + float bestDist = range; // boresight: nearest body the ray pierces foreach (var e in space.Entities) { if (e.Kind != "Asteroid" && e.Kind != "Drone" && e.Kind != "Ufo" && e.Kind != "Cruiser" && e.Kind != "BanditShip") @@ -1566,17 +1590,43 @@ private BlocksBeyondTheStars.Networking.Messages.NetCombatEntity BestFireTarget( Vector3 to = new Vector3(e.X, e.Y, e.Z) - shipPos; float dist = to.magnitude; - if (dist > WeaponRange || dist < 0.001f) + if (dist > range || dist < 0.001f) { continue; } - float align = Vector3.Dot(to / dist, fwd); // 1 = dead ahead - float score = align - (dist / WeaponRange) * 0.25f; // prefer aligned + close - if (score > bestScore) + float align = Vector3.Dot(to / dist, fwd); // 1 = dead ahead + if (autoAim) { - bestScore = score; - best = e; + if (align < SpaceAutoAimCone) + { + continue; + } + + float score = align - (dist / range) * 0.25f; // prefer aligned + close + if (score > bestScore) + { + bestScore = score; + best = e; + } + } + else + { + // Boresight: perpendicular distance of the nose ray from the body's centre must be + // inside its (generous) radius. Nearest pierced body wins. + float along = align * dist; + if (along <= 0f || along >= bestDist) + { + continue; + } + + float missSq = dist * dist - along * along; + float radius = 2.5f * Mathf.Max(1f, e.Scale); + if (missSq <= radius * radius) + { + bestDist = along; + best = e; + } } } @@ -1587,7 +1637,11 @@ private BlocksBeyondTheStars.Networking.Messages.NetCombatEntity BestFireTarget( /// reads amber; combat reads cyan. private void FireAt(BlocksBeyondTheStars.Networking.Messages.NetCombatEntity target, string weaponKey) { - Game.Network?.SendFireWeapon(weaponKey, target.Id); + Vector3 fwd = _ship.transform.localRotation * Vector3.forward; + Game.Network?.SendFireWeapon(weaponKey, target.Id, + new BlocksBeyondTheStars.Shared.Primitives.Vector3f(fwd.x, fwd.y, fwd.z)); + Game.LastShotTargetId = target.Id; + Game.LastShotTime = Time.time; bool mining = target.Kind == "Asteroid"; Color col = mining ? new Color(1f, 0.7f, 0.25f) : new Color(0.45f, 1f, 1f); @@ -3581,6 +3635,7 @@ private void SyncEntities() Destroy(_entities[id]); _entities.Remove(id); + EnemyHealthBars.Forget(id); } } } @@ -4186,6 +4241,38 @@ private void LateUpdate() if (_phase == Phase.Cruise) { DrawRemoteNameplates(); + DrawEntityHealthBars(); + } + } + + /// Floating health bars over space hostiles (#692): drones, UFOs, cruisers and bandit + /// ships — asteroids (mining targets) and stations are excluded. The shared policy in + /// shows a bar while an entity is in combat or is the current fire + /// lock, so a quiet field stays clean. Fade band matches the pilot nameplates. + private void DrawEntityHealthBars() + { + var space = Game.Space; + if (Camera == null || space == null) + { + return; + } + + foreach (var e in space.Entities) + { + if (e.Kind != "Drone" && e.Kind != "Ufo" && e.Kind != "Cruiser" && e.Kind != "BanditShip") + { + continue; + } + + if (!_entities.TryGetValue(e.Id, out var go) || go == null) + { + continue; + } + + float height = 2f * Mathf.Max(1f, e.Scale); + EnemyHealthBars.Push(Game, Camera, e.Id, go.transform.position + Vector3.up * height, + e.Hull, e.HullMax, friendly: false, fadeStart: 90f, fadeEnd: 140f, + targeted: e.Id == _fireTargetId); } } diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/UiSettings.cs b/client/Assets/BlocksBeyondTheStars/Scripts/UiSettings.cs index a2d8b9a9..b92d9ab9 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/UiSettings.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/UiSettings.cs @@ -164,6 +164,7 @@ private void Rebuild() Head(ref y, L("ui.settings.comfort")); Toggle(ref y, L("ui.settings.auto_stow"), S.AutoStowOnBoard, () => { S.AutoStowOnBoard = !S.AutoStowOnBoard; Rebuild(); }); + Toggle(ref y, L("ui.settings.show_enemy_health"), S.ShowEnemyHealthBars, () => { S.ShowEnemyHealthBars = !S.ShowEnemyHealthBars; Rebuild(); }); Toggle(ref y, L("ui.settings.show_session_time"), S.ShowSessionTime, () => { S.ShowSessionTime = !S.ShowSessionTime; Rebuild(); }); // Chat overlay: fade out on its own (default), stay up, or never show unprompted (#636). The // in-game toggle key does the same thing for one session without opening this menu. diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/UiWorldOptions.cs b/client/Assets/BlocksBeyondTheStars/Scripts/UiWorldOptions.cs index 567f6c09..d5dfe11e 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/UiWorldOptions.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/UiWorldOptions.cs @@ -101,6 +101,9 @@ void Row(bool leftCol, string label, string[] steps, System.Func get, Syste var onOff = new[] { shell.L("ui.toggle.off"), shell.L("ui.toggle.on") }; Row(true, shell.L("ui.worldopt.space_combat"), onOff, () => opt.SpaceCombat ? 1 : 0, v => opt.SpaceCombat = v == 1); Row(true, shell.L("ui.worldopt.keep_ship"), onOff, () => opt.KeepShip ? 1 : 0, v => opt.KeepShip = v == 1); + // Auto-aim (#693) intentionally has NO creation row: both columns already end flush with the + // footer. New worlds start with the server default (ON); the world admin flips it live in the + // in-game world-rules panel, and scripts can pass --auto-aim false at launch. // Right column: the generated world. UiKit.AddText(main.transform, rx, ry, 700f, 24f, shell.L("ui.worldopt.col_world"), 16, UiKit.Cyan, TextAnchor.MiddleLeft, FontStyle.Bold); diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/WorldCreationOptions.cs b/client/Assets/BlocksBeyondTheStars/Scripts/WorldCreationOptions.cs index fe626866..29023bbd 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/WorldCreationOptions.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/WorldCreationOptions.cs @@ -72,6 +72,10 @@ public sealed class WorldCreationOptions /// combat intact to base; OFF leaves it a wreck the owner must repair before flying again. public bool KeepShip = true; + /// Auto-aim (world rule #693, default ON = server default): ON lets weapons acquire targets + /// in a forward cone by themselves; OFF means only what is under the crosshair can be hit. + public bool AutoAim = true; + // Story (P8 world option): which story pack runs + how fast it unfolds. public int Story = 0; // 0 = Default pack, 1 = None (sandbox) public int StoryDensity = 1; // 0 Sparse · 1 Normal · 2 Dense @@ -106,7 +110,7 @@ public void CopyFrom(WorldCreationOptions other) Vaults = other.Vaults; Stations = other.Stations; Exotic = other.Exotic; UniverseSize = other.UniverseSize; StationTemplates = other.StationTemplates; SettlementTemplates = other.SettlementTemplates; Oxygen = other.Oxygen; Hunger = other.Hunger; Hazards = other.Hazards; DeathPenalty = other.DeathPenalty; - SpaceCombat = other.SpaceCombat; KeepShip = other.KeepShip; + SpaceCombat = other.SpaceCombat; KeepShip = other.KeepShip; AutoAim = other.AutoAim; Story = other.Story; StoryDensity = other.StoryDensity; StartPlanetType = other.StartPlanetType; PlanetTypes.Clear(); @@ -188,6 +192,7 @@ public string ToArgs() // defaults both to Off, so we emit the overrides whenever the toggle is on (= the panel default). if (SpaceCombat) { Arg("space-combat", "PvE"); Arg("ship-weapons", "NpcsOnly"); } if (!KeepShip) Arg("keep-ship", "false"); + if (!AutoAim) Arg("auto-aim", "false"); if (Story == 1) Arg("story", "none"); // sandbox (no story) if (StoryDensity != 1) Arg("story-density", StoryDensitySteps[StoryDensity]); diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs b/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs index 085d0640..2f93150c 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs @@ -70,6 +70,7 @@ private void Update() var seen = _seenScratch; seen.Clear(); + var cam = Camera.main; // for the floating health bars (#692) foreach (var e in Game.PlanetEnemies) { seen.Add(e.Id); @@ -158,6 +159,11 @@ private void Update() } en.PrevHull = e.Hull; + + // Floating health bar over machines + bandits (#692); also attributes hull drops to the + // local player's latest shot for the crosshair hit marker. Same fade band as NPC nameplates. + EnemyHealthBars.Push(Game, cam, e.Id, en.Root.transform.position + Vector3.up * 2.1f, + e.Hull, e.HullMax, friendly: false, fadeStart: 18f, fadeEnd: 28f); } // Remove enemies whose entity is gone (killed / out of range). @@ -177,6 +183,7 @@ private void Update() { Destroy(_enemies[id].Root); _enemies.Remove(id); + EnemyHealthBars.Forget(id); } } } diff --git a/data/locales/de.json b/data/locales/de.json index c39b48df..5f413298 100644 --- a/data/locales/de.json +++ b/data/locales/de.json @@ -1434,6 +1434,7 @@ "ui.settings.camera_motion": "Kamerabewegung (Wippen/Wackeln)", "ui.settings.comfort": "Komfort & Wohlbefinden", "ui.settings.auto_stow": "Beim Anbordgehen automatisch einlagern", + "ui.settings.show_enemy_health": "Lebensbalken über Gegnern", "ui.settings.show_session_time": "Spielzeit anzeigen", "ui.settings.chat_visibility": "Chat-Anzeige", "ui.settings.chat_visibility.auto": "Ausblenden", @@ -2015,6 +2016,7 @@ "ui.worldopt.bandits": "Banditen (Räuber & Lager)", "ui.worldopt.space_combat": "Raumkampf (Gegner bekämpfbar)", "ui.worldopt.keep_ship": "Schiff bei Zerstörung behalten", + "ui.worldopt.auto_aim": "Automatisches Zielen", "ui.worldopt.instant_travel": "Instant Travel", "ui.worldopt.flora": "Flora-Dichte", "ui.worldopt.ore": "Erzreichtum", diff --git a/data/locales/en.json b/data/locales/en.json index 83a49846..88623f6f 100644 --- a/data/locales/en.json +++ b/data/locales/en.json @@ -1433,6 +1433,7 @@ "ui.settings.camera_motion": "Camera motion (bob/shake)", "ui.settings.comfort": "Comfort & wellbeing", "ui.settings.auto_stow": "Auto-stow into cargo on boarding", + "ui.settings.show_enemy_health": "Enemy health bars", "ui.settings.show_session_time": "Show playtime", "ui.settings.chat_visibility": "Chat display", "ui.settings.chat_visibility.auto": "Fade out", @@ -2014,6 +2015,7 @@ "ui.worldopt.bandits": "Bandits (robbers & camps)", "ui.worldopt.space_combat": "Space combat (fightable enemies)", "ui.worldopt.keep_ship": "Keep ship when destroyed", + "ui.worldopt.auto_aim": "Auto-aim", "ui.worldopt.instant_travel": "Instant Travel", "ui.worldopt.flora": "Flora density", "ui.worldopt.ore": "Ore richness", diff --git a/docs/user/USER_MANUAL.md b/docs/user/USER_MANUAL.md index 377bd615..93d0f98e 100644 --- a/docs/user/USER_MANUAL.md +++ b/docs/user/USER_MANUAL.md @@ -65,7 +65,7 @@ Last updated: 2026-07-04. | **Right-click** | Place the selected hotbar block (or **use** the selected gadget, e.g. the terrain scanner) | | **Mouse wheel** | Cycle hotbar slot | | **1 – 9** | Select hotbar slot | -| **F** | Attack the nearest creature / swing the held tool | +| **F** | Attack with the held tool/weapon — hits what's **under your crosshair** (the reticle turns red over a target; with **auto-aim** on, the nearest enemy in front of you is acquired automatically) | | **R** | Repair the targeted wreck breach with the selected hotbar block (see §5 → Wrecks); with a **shaped block** selected: rotate its placement orientation (see §5 → Craftable block shapes) | | **L** | Toggle the suit headlamp (requires a `suit_lamp`) | | **G** | Loot the nearest container | @@ -310,6 +310,21 @@ separate unlock; admins can still disable it through server world rules. ### Space flight & combat - 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). +- **Aiming**: the ship laser acquires the best target roughly **ahead of the nose** (the centre dot lights up + cyan on lock). Weapon **range and fire rate come from the fitted module** — bigger cannons genuinely reach + further. + +### Aiming & enemy health bars +- Damaged enemies (and the one under your crosshair) show a small **health bar** that ramps + green → amber → red; tamed companions show a friendly cyan bar. Turn bars off under + **Settings → Comfort → "Enemy health bars"**. +- When one of **your** shots lands, the crosshair flashes a **hit marker**. +- **Auto-aim** is a **world rule** (default **on**): weapons pick a target in a forward cone by + themselves — kid- and gamepad-friendly. The world admin can turn it **off** in the in-game + **world rules** panel (or create the world with `--auto-aim false`): then only what is actually + **under the crosshair** (on foot) or **on the ship's boresight** (in space) can be hit — misses + really miss. Shots are server-validated either way, including line-of-sight (no shooting through + walls). ### Asteroid belts - In worlds created with belts (the default for new worlds), a system's landable asteroids orbit diff --git a/src/BlocksBeyondTheStars.Client.Core/NetworkClient.cs b/src/BlocksBeyondTheStars.Client.Core/NetworkClient.cs index e544bfc5..1a0702c8 100644 --- a/src/BlocksBeyondTheStars.Client.Core/NetworkClient.cs +++ b/src/BlocksBeyondTheStars.Client.Core/NetworkClient.cs @@ -194,7 +194,8 @@ public void Join(string playerName, string? password = null, string locale = "en /// World admin: live-edits the gameplay world options (empty fields = unchanged). public void SendSetWorldRules(string creatures = "", string planetEnemies = "", string spaceNpcs = "", string ufos = "", - string bandits = "", string instantTravel = "", string keepInventory = "", string keepShip = "", string hazards = "") + string bandits = "", string instantTravel = "", string keepInventory = "", string keepShip = "", string hazards = "", + string autoAim = "") => Send(new SetWorldRulesIntent { CreatureAbundance = creatures, @@ -206,6 +207,7 @@ public void SendSetWorldRules(string creatures = "", string planetEnemies = "", KeepInventoryOnDeath = keepInventory, KeepShipOnDeath = keepShip, EnvironmentalHazards = hazards, + AutoAim = autoAim, }); /// Hyperjump into a (possibly unvisited) star system, arriving in flight mode there. @@ -336,10 +338,15 @@ public void SendLeaveSpace(string destinationBodyId, int padIndex = -1) /// Asks the server for a body's fixed landing pads + their live occupancy (the pad chooser). public void SendRequestLandingPads(string bodyId) => Send(new RequestLandingPadsIntent { BodyId = bodyId }); - public void SendFireWeapon(string weaponKey, string targetEntityId) - => Send(new FireWeaponIntent { WeaponKey = weaponKey, TargetEntityId = targetEntityId }); + /// Fires a ship weapon at a space entity; the direction is the ship's nose at the moment of + /// firing so the server can enforce the firing arc (#693). Zero = legacy no-arc behaviour. + public void SendFireWeapon(string weaponKey, string targetEntityId, Vector3f dir = default) + => Send(new FireWeaponIntent { WeaponKey = weaponKey, TargetEntityId = targetEntityId, DirX = dir.X, DirY = dir.Y, DirZ = dir.Z }); - public void SendAttackEntity(string entityId) => Send(new AttackEntityIntent { EntityId = entityId }); + /// Attacks a planet enemy/creature; the direction is the camera ray of the shot so the + /// server can validate the claimed target against it (#693). Zero = legacy no-aim behaviour. + public void SendAttackEntity(string entityId, Vector3f dir = default) + => Send(new AttackEntityIntent { EntityId = entityId, DirX = dir.X, DirY = dir.Y, DirZ = dir.Z }); /// Asks the server to save the world + players to disk now (explicit save). public void SendSaveGame() => Send(new SaveGameIntent()); diff --git a/src/BlocksBeyondTheStars.GameServer/GameServer.cs b/src/BlocksBeyondTheStars.GameServer/GameServer.cs index 844cc29d..5da342d4 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServer.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServer.cs @@ -646,6 +646,9 @@ public void Travel(string playerId, string destinationBodyId) /// Test hook: toggle the Instant Travel world rule. public void SetInstantTravelForTest(bool on) => Rules.InstantTravel = on; + /// Test hook: flips the AutoAim world rule (#693) without a session/admin round-trip. + public void SetAutoAimForTest(bool on) => Rules.AutoAim = on; + /// Test hook for the travel-screen quick-travel path (gated by the Instant Travel rule). Returns /// whether the player ended up at the destination (i.e. the travel was allowed). public bool QuickTravelForTest(string playerId, string destinationBodyId) @@ -4562,6 +4565,7 @@ private void SendRules(PlayerSession session) AlienUfos = r.AlienUfos.ToString(), Bandits = r.Bandits.ToString(), InstantTravel = r.InstantTravel, + AutoAim = r.AutoAim, VoiceChatEnabled = _config.VoiceChatEnabled, }); } @@ -4613,6 +4617,11 @@ static void Apply(string value, System.Action set) Rules.EnvironmentalHazards = hz; } + if (!string.IsNullOrEmpty(intent.AutoAim)) + { + Rules.AutoAim = intent.AutoAim.Equals("On", System.StringComparison.OrdinalIgnoreCase); + } + _meta.RulesOverride = Rules.Clone(); // the world owns its rules — persist the edit _repo.SaveMetadata(_meta); @@ -4626,7 +4635,8 @@ static void Apply(string value, System.Action set) _log.Info($"World rules updated by '{session.State.Name}': creatures={Rules.CreatureAbundance}, " + $"planet={Rules.PlanetEnemies}, space={Rules.SpaceNpcEnemies}, ufos={Rules.AlienUfos}, " + - $"bandits={Rules.Bandits}, instantTravel={Rules.InstantTravel}, hazards={Rules.EnvironmentalHazards}."); + $"bandits={Rules.Bandits}, instantTravel={Rules.InstantTravel}, hazards={Rules.EnvironmentalHazards}, " + + $"autoAim={Rules.AutoAim}."); } /// Rearranges the player's personal inventory by swapping two slots (B58 — customising the quick-bar, diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs b/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs index 295a099e..2dcacb30 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs @@ -425,8 +425,10 @@ private void SpawnPlanetEnemyNear(Shared.State.PlayerState player, bool asDrone) private const float WreckCouplingRange = 64f; // bias spawns to a wreck within this of the player (P5) private const int ScanDroneHover = 4; // blocks the flying scan-drone floats above the surface (P4) - /// Player attacks a planet enemy or creature with the held tool/weapon. Server resolves the hit. - public void AttackEntity(string playerId, string entityId) + /// Player attacks a planet enemy or creature with the held tool/weapon. Server resolves the hit. + /// The optional aim direction is the client's camera ray at the moment of firing (#693); a zero vector + /// (older client) skips the aim validation. + public void AttackEntity(string playerId, string entityId, float dirX = 0f, float dirY = 0f, float dirZ = 0f) { var session = FindSessionByPlayerId(playerId); if (session is null) @@ -434,21 +436,22 @@ public void AttackEntity(string playerId, string entityId) return; } + var dir = new Vector3f(dirX, dirY, dirZ); if (_planetEnemies.FirstOrDefault(e => e.Id == entityId) is { } enemy) { - AttackCombatEntity(session, enemy, _planetEnemies, isCreature: false); + AttackCombatEntity(session, enemy, _planetEnemies, isCreature: false, dir); return; } if (_creatures.FirstOrDefault(e => e.Id == entityId) is { } creature) { - AttackCombatEntity(session, creature, _creatures, isCreature: true); + AttackCombatEntity(session, creature, _creatures, isCreature: true, dir); return; } if (_bandits.FirstOrDefault(e => e.Id == entityId) is { } bandit) { - AttackCombatEntity(session, bandit, _bandits, isCreature: false); + AttackCombatEntity(session, bandit, _bandits, isCreature: false, dir); return; } @@ -461,7 +464,7 @@ public void AttackEntity(string playerId, string entityId) private const double MeleeCooldown = 1.5; // melee weapons swing at most this often (B44) private readonly Dictionary _meleeReadyAt = new(); // playerId → uptime the next melee swing is allowed - private void AttackCombatEntity(PlayerSession session, CombatEntity target, List list, bool isCreature) + private void AttackCombatEntity(PlayerSession session, CombatEntity target, List list, bool isCreature, Vector3f aimDir = default) { var p = session.State; var tool = ActiveTool(p); @@ -495,6 +498,11 @@ private void AttackCombatEntity(PlayerSession session, CombatEntity target, List return; } + if (!ValidateAim(session, target, tool, isWeapon, aimDir)) + { + return; + } + // Energy weapons (laser/plasma) draw suit energy per shot. if (isWeapon && tool.EnergyPerUse > 0f) { @@ -566,13 +574,78 @@ private void AttackCombatEntity(PlayerSession session, CombatEntity target, List } } + /// Validates the client's claimed aim against the claimed target (#693). Anti-cheat guardrail, + /// not a precision hitbox: latency + interpolation mean the client's view lags the server's, so every + /// tolerance is generous — the client already did the precise crosshair test. A zero direction (older + /// client, or a melee swing) skips the angle checks entirely. With AutoAim ON the target only has to sit + /// in a wide forward cone; with AutoAim OFF the crosshair ray must actually pass near the target's body. + /// Ranged weapons additionally need a clear sightline — no shooting through walls. + private bool ValidateAim(PlayerSession session, CombatEntity target, ToolProperties tool, bool isWeapon, Vector3f aimDir) + { + float dirLenSq = aimDir.X * aimDir.X + aimDir.Y * aimDir.Y + aimDir.Z * aimDir.Z; + if (dirLenSq < 0.0001f) + { + return true; // no aim data (older client) — keep the legacy range-only behaviour + } + + bool ranged = isWeapon && tool.Range > EnemyAttackReach; + var p = session.State; + + // Ranged shots respect walls: the same voxel sightline that gates enemy bites (glass blocks it too). + if (ranged && !HasLineOfSight(p.Position, target.Position)) + { + Reject(session, "attack", "No clear line of fire."); + return false; + } + + const float eye = 1.5f; // matches HasLineOfSight/the client camera height + var dst = Unwrapped(p.Position, target.Position); + float tx = dst.X - p.Position.X; + float ty = (dst.Y + 0.9f) - (p.Position.Y + eye); // aim roughly at the body, not the feet + float tz = dst.Z - p.Position.Z; + float dist = (float)System.Math.Sqrt(tx * tx + ty * ty + tz * tz); + if (dist < 0.75f) + { + return true; // point-blank — any angle is honest + } + + float dirLen = (float)System.Math.Sqrt(dirLenSq); + float dot = (aimDir.X * tx + aimDir.Y * ty + aimDir.Z * tz) / (dirLen * dist); + + // Ray-precision only for ranged manual aiming; melee and auto-aim keep a wide forward cone. + if (!Rules.AutoAim && ranged) + { + // Perpendicular miss distance of the crosshair ray from the target's body centre, with a + // body-size + distance-scaled corridor (≈6° plus the body itself). + float along = System.Math.Max(0f, dot) * dist; + float missSq = dist * dist - along * along; + float scale = System.Math.Max(1f, System.Math.Max(target.Scale, target.SizeScale)); + float allowed = 1.5f * scale + 0.1f * dist; + if (dot <= 0f || missSq > allowed * allowed) + { + Reject(session, "attack", "Shot went wide."); + return false; + } + + return true; + } + + if (dot < 0.35f) // ~70° half-angle: forgiving even for a swirling melee fight, but never behind the back + { + Reject(session, "attack", "Target is not in front of you."); + return false; + } + + return true; + } + // Bandits ride the planet-enemy wire (same list message), so client targeting/health bars/defeat // handling work unchanged — the client tells them apart by the Kind string. private void BroadcastPlanetEnemies() => BroadcastToWorld(new PlanetEnemyList { Enemies = _planetEnemies.Concat(_bandits).Select(ToNet).ToArray() }); private void HandleAttackEntity(PlayerSession session, AttackEntityIntent intent) - => AttackEntity(session.State.PlayerId, intent.EntityId); + => AttackEntity(session.State.PlayerId, intent.EntityId, intent.DirX, intent.DirY, intent.DirZ); // ---------------- Test hooks ---------------- diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs index 47619ab4..95943f19 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServerSpaceCombat.cs @@ -245,7 +245,17 @@ public sealed partial class GameServer // weapon_class: 0 = mining tool (breaks asteroids, can't hit hostiles), 1 = combat weapon (hits // hostiles; breaks asteroids only where AsteroidDestruction allows weapons), 2 = dual laser (does both — // the starter ship laser, so one weapon mines AND fights). - private readonly record struct WeaponSpec(float Damage, float Range, double Cooldown, bool IsCombat, bool CanMine); + private readonly record struct WeaponSpec(float Damage, float Range, double Cooldown, float Energy, bool IsCombat, bool CanMine); + + // #694: ship weapons are rate-limited and energy-gated SERVER-side now (both stats existed in + // data/ship_modules.json but were never enforced — the only limit was the client's local fire timer). + private readonly Dictionary _shipWeaponReadyAt = new(); // "playerId|weaponKey" → uptime next shot is allowed + + // Ship energy is a lazily-regenerating pool fed by the reactor's energy_production: capacity = a few + // seconds of production, refill = production per second. With the stock reactor it never throttles + // legitimate fire (production far outpaces any weapon's draw) — it exists so weapon_energy is real + // and modded rapid-fire clients drain dry instead of firing forever. + private readonly Dictionary _shipEnergyByPlayer = new(); /// True while the player is flying in a space instance. public bool InSpace(string playerId) => _playerInstance.ContainsKey(playerId); @@ -738,7 +748,7 @@ private void SpawnGuardianGauntlet(SpaceInstance instance) // ---------------- Weapons ---------------- /// Fires a built ship weapon at a target entity. Server-authoritative: validates rules, range and resolves the hit. - public void FireWeapon(string playerId, string weaponKey, string targetId) + public void FireWeapon(string playerId, string weaponKey, string targetId, float dirX = 0f, float dirY = 0f, float dirZ = 0f) { var session = FindSessionByPlayerId(playerId); @@ -760,6 +770,16 @@ public void FireWeapon(string playerId, string weaponKey, string targetId) return; } + // #694: the module's fire rate is authoritative now (it was client-only before). A small slack + // absorbs network jitter so an honest client firing exactly on cadence never gets rejected. + // The cooldown is only COMMITTED once the shot actually fires (below) — a rejected shot + // (bad target/arc/rules) must not eat the cycle. + string cdKey = playerId + "|" + weaponKey; + if (weapon.Cooldown > 0.0 && _shipWeaponReadyAt.TryGetValue(cdKey, out var readyAt) && _uptime < readyAt) + { + return; // still cycling — swallow silently (no reject spam while the trigger is held) + } + var target = instance.Entities.FirstOrDefault(e => e.Id == targetId); if (target is null) { @@ -773,12 +793,29 @@ public void FireWeapon(string playerId, string weaponKey, string targetId) return; } + if (!ValidateSpaceAim(session, instance, target, dirX, dirY, dirZ)) + { + return; + } + if (!WeaponAllowedAgainst(weapon, target, out var reason)) { RejectSpace(session, reason); return; } + // #694: weapon_energy draws from the reactor-fed pool (was defined in the module data but unused). + if (!TryDrawShipEnergy(playerId, weapon.Energy)) + { + RejectSpace(session, "Not enough ship energy to fire."); + return; + } + + if (weapon.Cooldown > 0.0) + { + _shipWeaponReadyAt[cdKey] = _uptime + weapon.Cooldown * 0.95; + } + target.Hull -= weapon.Damage; // item 20 S3: a voxel ore asteroid carves down to match its hull as you shoot it (visible depletion). @@ -850,6 +887,55 @@ public void FireWeapon(string playerId, string weaponKey, string targetId) BroadcastSpaceState(instance); } + /// Validates the client's reported firing direction (the ship's nose) against the claimed + /// target (#693). Mirrors the on-foot ValidateAim: generous tolerances (latency, drifting + /// targets), zero direction = older client = skip. AutoAim ON needs the target roughly ahead; + /// AutoAim OFF needs a genuine boresight line — the nose ray must pass near the target's body. + private bool ValidateSpaceAim(PlayerSession? session, SpaceInstance instance, CombatEntity target, float dirX, float dirY, float dirZ) + { + float dirLenSq = dirX * dirX + dirY * dirY + dirZ * dirZ; + if (dirLenSq < 0.0001f) + { + return true; // no aim data (older client) — keep the legacy range-only behaviour + } + + float tx = target.Position.X - instance.ShipPosition.X; + float ty = target.Position.Y - instance.ShipPosition.Y; + float tz = target.Position.Z - instance.ShipPosition.Z; + float dist = (float)System.Math.Sqrt(tx * tx + ty * ty + tz * tz); + if (dist < 3f) + { + return true; // point-blank + } + + float dirLen = (float)System.Math.Sqrt(dirLenSq); + float dot = (dirX * tx + dirY * ty + dirZ * tz) / (dirLen * dist); + + if (!Rules.AutoAim) + { + // Boresight: perpendicular miss distance of the nose ray from the target's centre, allowing + // the body itself plus a distance-scaled corridor (space entities are big and drift fast). + float along = System.Math.Max(0f, dot) * dist; + float missSq = dist * dist - along * along; + float allowed = 2.5f * System.Math.Max(1f, target.Scale) + 0.1f * dist; + if (dot <= 0f || missSq > allowed * allowed) + { + RejectSpace(session, "Shot went wide — line up the target."); + return false; + } + + return true; + } + + if (dot < 0.5f) // ~60°: server-side guardrail above the client's ~±30° acquisition cone + { + RejectSpace(session, "Target is outside the firing arc."); + return false; + } + + return true; + } + private const int LargeAsteroidTier = 2; private const int AsteroidSplitCount = 2; @@ -968,11 +1054,56 @@ private bool TryGetWeapon(string moduleKey, out WeaponSpec spec) Damage: (float)def.Stats.GetValueOrDefault("weapon_damage", 10), Range: (float)def.Stats.GetValueOrDefault("weapon_range", 50), Cooldown: def.Stats.GetValueOrDefault("weapon_cooldown", 1.0), + Energy: (float)def.Stats.GetValueOrDefault("weapon_energy", 0), IsCombat: weaponClass >= 1, // combat weapons + dual lasers can hit hostiles CanMine: weaponClass == 0 || weaponClass == 2); // mining tools + dual lasers can break asteroids return true; } + /// Total reactor output of the current ship (energy per second) — feeds the weapon-energy pool. + private float ShipEnergyProduction() + { + float prod = 0f; + foreach (var key in _ship.Modules) + { + if (_content.GetShipModule(key) is { } m) + { + prod += (float)m.Stats.GetValueOrDefault("energy_production", 0); + } + } + + return prod; + } + + /// Tries to draw from the player's lazily-regenerating ship-energy + /// pool (#694). Capacity is ~3 s of reactor output; refill happens on access, so no per-tick work. + private bool TryDrawShipEnergy(string playerId, float amount) + { + if (amount <= 0f) + { + return true; + } + + float production = ShipEnergyProduction(); + if (production <= 0f) + { + return true; // no reactor data on this ship — never lock the trigger over a missing stat + } + + float capacity = System.Math.Max(amount, production * 3f); + var pool = _shipEnergyByPlayer.TryGetValue(playerId, out var state) + ? System.Math.Min(capacity, state.Energy + (float)((_uptime - state.Time) * production)) + : capacity; + if (pool < amount) + { + _shipEnergyByPlayer[playerId] = (_uptime, pool); + return false; + } + + _shipEnergyByPlayer[playerId] = (_uptime, pool - amount); + return true; + } + // ---------------- Ship flight (position in the instance) ---------------- private const float ShipCollisionRadius = 3f; @@ -1850,5 +1981,5 @@ private void HandleLeaveSpace(PlayerSession session, LeaveSpaceIntent intent) } private void HandleFireWeapon(PlayerSession session, FireWeaponIntent intent) - => FireWeapon(session.State.PlayerId, intent.WeaponKey, intent.TargetEntityId); + => FireWeapon(session.State.PlayerId, intent.WeaponKey, intent.TargetEntityId, intent.DirX, intent.DirY, intent.DirZ); } diff --git a/src/BlocksBeyondTheStars.Networking/Messages.cs b/src/BlocksBeyondTheStars.Networking/Messages.cs index 01bb26bf..14e1970f 100644 --- a/src/BlocksBeyondTheStars.Networking/Messages.cs +++ b/src/BlocksBeyondTheStars.Networking/Messages.cs @@ -250,17 +250,28 @@ public sealed class TravelIntent public int PadIndex { get; set; } = -1; } -/// Client fires a built ship weapon at a space entity. The server validates and resolves the hit. +/// Client fires a built ship weapon at a space entity. The server validates and resolves the hit. +/// Contractless-additive aim fields (#693): the ship's forward direction at the moment of firing, so the +/// server can enforce a firing arc. An all-zero direction (older client) skips the arc check. public sealed class FireWeaponIntent { public string WeaponKey { get; set; } = string.Empty; public string TargetEntityId { get; set; } = string.Empty; + public float DirX { get; set; } + public float DirY { get; set; } + public float DirZ { get; set; } } -/// Client attacks a planet enemy with the held tool/weapon. The server resolves the hit. +/// Client attacks a planet enemy with the held tool/weapon. The server resolves the hit. +/// Contractless-additive aim fields (#693): the camera-ray direction of the shot, so the server can +/// validate the claimed target against where the player was actually looking (angle + line of sight). +/// An all-zero direction (older client) skips those checks. public sealed class AttackEntityIntent { public string EntityId { get; set; } = string.Empty; + public float DirX { get; set; } + public float DirY { get; set; } + public float DirZ { get; set; } } /// Client eats/uses a consumable item (food heals, poison harms). The server applies it. @@ -867,6 +878,10 @@ public sealed class ServerRules /// false it is limited to bodies the player has already landed on (default). public bool InstantTravel { get; set; } + /// Auto-aim world option (#693): when true (default) weapons acquire targets in a forward cone + /// automatically; when false only the entity under the crosshair can be hit (manual aiming). + public bool AutoAim { get; set; } = true; + /// Whether the server accepts/relays live voice chat (opt-in; default off on dedicated servers). /// When false the client keeps voice capture disabled and shows voice comms as unavailable. public bool VoiceChatEnabled { get; set; } @@ -895,6 +910,9 @@ public sealed class SetWorldRulesIntent /// Environmental-hazards tier: "Off"/"Light"/"Normal"/"Hard" to set it, empty to leave /// unchanged (#670) — the live switch for the temperature survival hazard. public string EnvironmentalHazards { get; set; } = string.Empty; + + /// Auto-aim toggle (#693): "On"/"Off" to set it, empty to leave unchanged. + public string AutoAim { get; set; } = string.Empty; } // --- Missions --- diff --git a/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs b/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs index 0f9db79a..b02175c8 100644 --- a/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs +++ b/src/BlocksBeyondTheStars.Shared/Configuration/GameRules.cs @@ -197,6 +197,12 @@ public sealed class GameRules /// world admin. public bool InstantTravel { get; set; } + /// Auto-aim (world option, default ON — issue #693): when ON, weapons acquire the best target in + /// a forward cone automatically (kid/gamepad-friendly); when OFF the shot only hits what is actually under + /// the crosshair, and the server validates the shot's aim direction strictly. Live-editable by the world + /// admin. Old saves deserialize without the field and keep the ON default — no start-up lift needed. + public bool AutoAim { get; set; } = true; + /// Whether crafting consumes materials / needs stations (false in Creative). public bool CraftingCostsMaterials => GameMode != GameMode.Creative; diff --git a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs index d74a64f1..d97a505c 100644 --- a/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs +++ b/src/BlocksBeyondTheStars.Shared/Configuration/ServerConfig.cs @@ -499,6 +499,10 @@ public IReadOnlyList ApplyCommandLine(string[]? args) case "keep-ship": if (bool.TryParse(value, out var ks)) { Rules.KeepShipOnDeath = ks; applied.Add("keep-ship"); } break; + case "auto-aim": + // #693: manual aiming — weapons only hit what is under the crosshair when off. + if (bool.TryParse(value, out var aa)) { Rules.AutoAim = aa; applied.Add("auto-aim"); } + break; case "story": Rules.StoryId = value; applied.Add("story"); // pack id, "none" for sandbox, or "default"/empty break; diff --git a/tests/BlocksBeyondTheStars.Tests/AimValidationTests.cs b/tests/BlocksBeyondTheStars.Tests/AimValidationTests.cs new file mode 100644 index 00000000..cede2c8d --- /dev/null +++ b/tests/BlocksBeyondTheStars.Tests/AimValidationTests.cs @@ -0,0 +1,208 @@ +// 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.Linq; +using BlocksBeyondTheStars.GameServer; +using BlocksBeyondTheStars.Networking.Transport; +using BlocksBeyondTheStars.Persistence; +using BlocksBeyondTheStars.Shared.Configuration; +using BlocksBeyondTheStars.Shared.Content; +using BlocksBeyondTheStars.Shared.Geometry; +using BlocksBeyondTheStars.Shared.Primitives; +using BlocksBeyondTheStars.Shared.State; +using Xunit; +using SvGameServer = BlocksBeyondTheStars.GameServer.GameServer; + +namespace BlocksBeyondTheStars.Tests; + +/// +/// Server-side aim validation (#693): the client now reports the shot's aim direction, and the +/// server checks the claimed target against it — a forward cone with the AutoAim world rule ON, +/// a genuine crosshair/boresight line with it OFF, plus line-of-sight for ranged shots. A zero +/// direction (older client) keeps the legacy range-only behaviour. Also covers the ship-weapon +/// cooldown that #694 made authoritative. +/// +public sealed class AimValidationTests : IDisposable +{ + private readonly string _root; + private readonly GameContent _content; + + public AimValidationTests() + { + _root = Path.Combine(Path.GetTempPath(), "bbts_aim_" + Guid.NewGuid().ToString("N")); + _content = ContentLoader.LoadFromDirectory(TestPaths.DataDir()); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } + } + + private SvGameServer Started(out SqliteWorldRepository repo, Action? rules = null) + { + repo = new SqliteWorldRepository(new SaveGamePaths(_root, "aim")); + var st = new LoopbackServerTransport(new LoopbackLink()); + var config = new ServerConfig + { + WorldName = "aim", + Seed = 777, + StartPlanet = "jungle", + AutoSaveIntervalMinutes = 9999, + PlaceStarterShip = false, + }; + rules?.Invoke(config.Rules); + var server = new SvGameServer(config, _content, st, repo); + server.Start(); + return server; + } + + /// Player at y 300 (open sky — nothing ground-snaps while no ticks run) with a machine + /// 10 blocks along +X. The laser pistol is ranged + energy-gated with no swing cooldown, so + /// repeat shots need no time advancement. + private static void Arrange(SvGameServer server, out string enemyId) + { + var p = server.AddLocalPlayer("Gunner"); + p.State.AboardShip = false; + p.State.Position = new Vector3f(0f, 300f, 0f); + p.State.SuitEnergy = 100f; + p.State.Inventory.SetSlot(0, new ItemStack("laser_pistol", 1)); + p.State.SelectedHotbarSlot = 0; + server.SpawnPlanetEnemyAtForTest(new Vector3f(10f, 300f, 0f)); + enemyId = server.PlanetEnemies[^1].Id; + } + + [Fact] + public void LegacyShot_WithoutAimDirection_StillHits() + { + var server = Started(out var repo); + using (repo) + { + Arrange(server, out var enemyId); + var enemy = server.PlanetEnemies.First(e => e.Id == enemyId); + server.AttackEntity("Gunner", enemyId); // zero direction = pre-#693 client + Assert.True(enemy.Hull < enemy.HullMax, "a legacy client's shot must keep working"); + } + } + + [Fact] + public void AutoAim_RejectsATargetBehindTheBack() + { + var server = Started(out var repo); + using (repo) + { + Arrange(server, out var enemyId); + var enemy = server.PlanetEnemies.First(e => e.Id == enemyId); + + server.AttackEntity("Gunner", enemyId, dirX: -1f, dirY: 0f, dirZ: 0f); // looking AWAY + Assert.Equal(enemy.HullMax, enemy.Hull); + + server.AttackEntity("Gunner", enemyId, dirX: 1f, dirY: 0f, dirZ: 0f); // looking at it + Assert.True(enemy.Hull < enemy.HullMax); + } + } + + [Fact] + public void ManualAim_OnlyTheCrosshairLineHits() + { + var server = Started(out var repo); + using (repo) + { + server.SetAutoAimForTest(false); + Arrange(server, out var enemyId); + var enemy = server.PlanetEnemies.First(e => e.Id == enemyId); + + // 45° off the target: inside the old auto-aim cone, but not a crosshair hit — rejected. + server.AttackEntity("Gunner", enemyId, dirX: 0.707f, dirY: 0f, dirZ: 0.707f); + Assert.Equal(enemy.HullMax, enemy.Hull); + + // Dead on: the ray passes through the body — the shot lands. + server.AttackEntity("Gunner", enemyId, dirX: 1f, dirY: 0f, dirZ: 0f); + Assert.True(enemy.Hull < enemy.HullMax); + } + } + + [Fact] + public void RangedShot_WithAimData_IsBlockedByAWall() + { + var server = Started(out var repo); + using (repo) + { + Arrange(server, out var enemyId); + var enemy = server.PlanetEnemies.First(e => e.Id == enemyId); + + // A pillar squarely on the eye-line (sight runs at ~y 301.5 between the two). + var stone = _content.GetBlock("stone")!.NumericId; + server.World.SetBlock(new Vector3i(5, 300, 0), stone); + server.World.SetBlock(new Vector3i(5, 301, 0), stone); + server.World.SetBlock(new Vector3i(5, 302, 0), stone); + + server.AttackEntity("Gunner", enemyId, dirX: 1f, dirY: 0f, dirZ: 0f); + Assert.Equal(enemy.HullMax, enemy.Hull); // no shooting through walls + + // Clear the wall — the same shot lands. + server.World.SetBlock(new Vector3i(5, 300, 0), BlockId.Air); + server.World.SetBlock(new Vector3i(5, 301, 0), BlockId.Air); + server.World.SetBlock(new Vector3i(5, 302, 0), BlockId.Air); + server.AttackEntity("Gunner", enemyId, dirX: 1f, dirY: 0f, dirZ: 0f); + Assert.True(enemy.Hull < enemy.HullMax); + } + } + + [Fact] + public void ShipWeapon_HonoursFiringArc_WithAimData() + { + var server = Started(out var repo, r => + { + r.FreeSpaceFlight = true; + r.SpaceCombat = SpaceCombatMode.PvE; + r.SpaceNpcEnemies = AlienActivity.Rare; // 1 drone + r.ShipWeapons = ShipWeaponMode.NpcsOnly; + }); + using (repo) + { + server.AddLocalPlayer("Gunner"); + server.Ship.Modules.Add("ship_cannon_1"); + server.EnterSpace("Gunner"); + + var drone = server.SpaceEntitiesFor("Gunner").First(e => e.Kind == CombatEntityKind.Drone); + server.ShipMove("Gunner", drone.Position.X, drone.Position.Y, drone.Position.Z - 10f); + + // Nose pointing away from the drone: outside the arc — rejected, and the cooldown is NOT eaten. + server.FireWeapon("Gunner", "ship_cannon_1", drone.Id, dirX: 0f, dirY: 0f, dirZ: -1f); + Assert.Equal(drone.HullMax, drone.Hull); + + // Nose on the drone: the shot lands. + server.FireWeapon("Gunner", "ship_cannon_1", drone.Id, dirX: 0f, dirY: 0f, dirZ: 1f); + Assert.True(drone.Hull < drone.HullMax); + } + } + + [Fact] + public void ShipWeapon_CooldownIsServerEnforced() + { + var server = Started(out var repo, r => + { + r.FreeSpaceFlight = true; + r.SpaceCombat = SpaceCombatMode.PvE; + r.SpaceNpcEnemies = AlienActivity.Rare; // 1 drone, hull 40 + r.ShipWeapons = ShipWeaponMode.NpcsOnly; + }); + using (repo) + { + server.AddLocalPlayer("Gunner"); + server.Ship.Modules.Add("ship_cannon_1"); // 20 dmg, 1.0 s cooldown + server.EnterSpace("Gunner"); + + var drone = server.SpaceEntitiesFor("Gunner").First(e => e.Kind == CombatEntityKind.Drone); + server.ShipMove("Gunner", drone.Position.X, drone.Position.Y, drone.Position.Z); + + server.FireWeapon("Gunner", "ship_cannon_1", drone.Id); // 40 -> 20 + server.FireWeapon("Gunner", "ship_cannon_1", drone.Id); // still cycling — swallowed (#694) + Assert.Equal(drone.HullMax - 20f, drone.Hull); + + server.TickForTest(1.1); // cooldown cycles + server.FireWeapon("Gunner", "ship_cannon_1", drone.Id); // 20 -> 0: destroyed + Assert.DoesNotContain(server.SpaceEntitiesFor("Gunner"), e => e.Id == drone.Id); + } + } +} diff --git a/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs b/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs index 06e4c4ec..44cc307e 100644 --- a/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/ServerConfigTests.cs @@ -161,6 +161,18 @@ public void ApplyCommandLine_OverridesShipWeaponsAndKeepRules() Assert.Contains("keep-inventory", applied); } + [Fact] + public void ApplyCommandLine_OverridesAutoAim() + { + // #693: auto-aim is a world rule — ON by default, --auto-aim false mandates manual aiming. + Assert.True(new ServerConfig().Rules.AutoAim); + + var config = new ServerConfig(); + var applied = config.ApplyCommandLine(new[] { "--auto-aim", "false" }); + Assert.False(config.Rules.AutoAim); + Assert.Contains("auto-aim", applied); + } + [Fact] public void ApplyCommandLine_OverridesStructureTemplateOptions() { diff --git a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs index 5525c3c0..a93271d9 100644 --- a/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/SpaceCombatTests.cs @@ -622,11 +622,13 @@ public void AsteroidBreaker_BreaksAsteroidsDown_AndEventuallyYieldsLoot() server.EnterSpace("Pilot"); // Keep breaking the nearest asteroid: large -> medium -> small -> mineral drops. + // The tick lets the weapon's server-enforced cooldown cycle between shots (#694). for (int i = 0; i < 16 && pilot.State.Inventory.CountOf("iron_ore") == 0; i++) { var a = server.SpaceEntitiesFor("Pilot").FirstOrDefault(e => e.Kind == CombatEntityKind.Asteroid); if (a is null) break; server.FireWeapon("Pilot", "asteroid_breaker", a.Id); + server.TickForTest(2.0); } Assert.True(pilot.State.Inventory.CountOf("iron_ore") >= 5, "Breaking asteroids down should eventually drop ore."); @@ -659,8 +661,10 @@ public void Shoot_CarvesVoxelAsteroid_ThenDestroysIt() Assert.True(blocksAfter < blocksBefore, "shooting should carve voxel blocks off the asteroid"); // Keep firing until it's destroyed → its structure is gone and ore was banked. + // The tick lets the weapon's server-enforced cooldown cycle between shots (#694). for (int i = 0; i < 16 && server.SpaceEntitiesFor("Pilot").Any(e => e.Id == ast.Id); i++) { + server.TickForTest(2.0); server.FireWeapon("Pilot", "asteroid_breaker", ast.Id); } @@ -714,6 +718,7 @@ public void ShipCannon_DestroysDrone_WhenWeaponsAllowed() var drone = server.SpaceEntitiesFor("Pilot").First(e => e.Kind == CombatEntityKind.Drone); server.ShipMove("Pilot", drone.Position.X, drone.Position.Y, drone.Position.Z); // close to fire (range from ship) server.FireWeapon("Pilot", "ship_cannon_1", drone.Id); // 40 -> 20 + server.TickForTest(1.1); // let the cannon's server-enforced cooldown cycle (#694) server.FireWeapon("Pilot", "ship_cannon_1", drone.Id); // destroyed Assert.DoesNotContain(server.SpaceEntitiesFor("Pilot"), e => e.Id == drone.Id); @@ -1062,10 +1067,12 @@ public void TractorBeam_PullsSalvageDrops_IntoCargo() server.Ship.Modules.Add("tractor_beam"); // with a tractor, the smallest chunks float as salvage server.EnterSpace("Pilot"); - // Break asteroids down until a floating salvage drop appears. + // Break asteroids down until a floating salvage drop appears. The tick (BEFORE the shot, so a + // fresh drop is never passively swept up mid-loop) lets the server-enforced cooldown cycle (#694). CombatEntity? drop = null; for (int i = 0; i < 24 && drop == null; i++) { + server.TickForTest(2.0); var a = server.SpaceEntitiesFor("Pilot").FirstOrDefault(e => e.Kind == CombatEntityKind.Asteroid); if (a != null) { @@ -1102,9 +1109,11 @@ public void TractorBeam_AimedPull_CollectsDrop_BeyondPassiveRange() server.Ship.Modules.Add("tractor_beam"); server.EnterSpace("Pilot"); + // Tick BEFORE the shot (cooldown cycles, and a fresh drop is never passively swept mid-loop, #694). CombatEntity? drop = null; for (int i = 0; i < 24 && drop == null; i++) { + server.TickForTest(2.0); var a = server.SpaceEntitiesFor("Pilot").FirstOrDefault(e => e.Kind == CombatEntityKind.Asteroid); if (a != null) { diff --git a/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs b/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs index 0a4c8d4a..cb49f8d8 100644 --- a/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/WorldOptionsTests.cs @@ -229,7 +229,7 @@ public void CreatureAbundance_IsLiveEditable_ByTheWorldAdmin_AndPersists() server.Start(); JoinAndDrain(server, client, "Admin"); // the first player becomes the world admin - client.Send(NetCodec.Encode(new SetWorldRulesIntent { CreatureAbundance = "Off", PlanetEnemies = "Extreme" }), + client.Send(NetCodec.Encode(new SetWorldRulesIntent { CreatureAbundance = "Off", PlanetEnemies = "Extreme", AutoAim = "Off" }), DeliveryMode.ReliableOrdered); server.Tick(0.1); @@ -238,6 +238,7 @@ public void CreatureAbundance_IsLiveEditable_ByTheWorldAdmin_AndPersists() Assert.NotNull(meta?.RulesOverride); Assert.Equal(AlienActivity.Off, meta!.RulesOverride!.CreatureAbundance); Assert.Equal(AlienActivity.Extreme, meta.RulesOverride.PlanetEnemies); + Assert.False(meta.RulesOverride.AutoAim); // #693: the aim rule rides the same live-edit path } [Fact] From 4b8f214cccff77fdfcc7e5dfc23d35ba4ed12ed1 Mon Sep 17 00:00:00 2001 From: marceld23 Date: Mon, 3 Aug 2026 20:44:18 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(client):=20Unity=20compile=20=E2=80=94?= =?UTF-8?q?=20Vector3f=20lives=20in=20Shared.Geometry;=20out=20param=20not?= =?UTF-8?q?=20capturable=20in=20local=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dotnet test build didn't catch either (the shot-direction plumbing is Unity-client-only code): Vector3f was fully qualified with the wrong namespace in the two intent senders, and BestConeTarget's local Consider() captured the out parameter (CS1628) — now tracked in a local and assigned on return. Local Windows player build is green. Co-Authored-By: Claude Fable 5 --- .../BlocksBeyondTheStars/Scripts/PlayerController.cs | 7 ++++--- client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs b/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs index 0242313c..f7945c2f 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/PlayerController.cs @@ -477,7 +477,7 @@ private void AttackNearestEnemy() { var f = ct.forward; Game.Network.SendAttackEntity(targetId, - new BlocksBeyondTheStars.Shared.Primitives.Vector3f(f.x, f.y, f.z)); + new BlocksBeyondTheStars.Shared.Geometry.Vector3f(f.x, f.y, f.z)); Game.LastShotTargetId = targetId; Game.LastShotTime = Time.time; } @@ -564,8 +564,8 @@ private bool AimEnemy(float maxRange, out string id, out Vector3 pos, out float /// even while you look past it. private string BestConeTarget(float reach, float cone, out Vector3 pos) { - pos = default; string bestId = null; + Vector3 bestPos = default; // out params can't be captured by the local function below float bestSq = reach * reach; Vector3 eye = Camera != null ? Camera.transform.position : transform.position; Vector3 fwd = Camera != null ? Camera.transform.forward : transform.forward; @@ -586,7 +586,7 @@ void Consider(string cid, Vector3 p) bestSq = d; bestId = cid; - pos = p; + bestPos = p; } foreach (var e in Game.PlanetEnemies) @@ -600,6 +600,7 @@ void Consider(string cid, Vector3 p) Consider(c.Id, Game.ScenePos(c.X, c.Y, c.Z)); } + pos = bestPos; return bestId; } diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs index ccfc4e6d..0781181f 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/SpaceView.cs @@ -1639,7 +1639,7 @@ private void FireAt(BlocksBeyondTheStars.Networking.Messages.NetCombatEntity tar { Vector3 fwd = _ship.transform.localRotation * Vector3.forward; Game.Network?.SendFireWeapon(weaponKey, target.Id, - new BlocksBeyondTheStars.Shared.Primitives.Vector3f(fwd.x, fwd.y, fwd.z)); + new BlocksBeyondTheStars.Shared.Geometry.Vector3f(fwd.x, fwd.y, fwd.z)); Game.LastShotTargetId = target.Id; Game.LastShotTime = Time.time; bool mining = target.Kind == "Asteroid";