diff --git a/TODO.md b/TODO.md index ec6072ee..ab24fc7b 100644 --- a/TODO.md +++ b/TODO.md @@ -7085,6 +7085,33 @@ is **pre-approved** (keys in `tools/ai-assets/.env`, run via `uv`). --- +## ✅ Done (2026-08-04): NPCs grounded, brighter and individual (#711) + +Three related NPC problems fixed in one branch (issue #711): + +- **Floating NPCs (regression from #492)** — settlement/camp markers sit centred in the air cell + above the floor (+0.5 from the cell-centre conversion); the settlement spawner's + `Max(Min.Y+1, markerY)` clamp never fired, so every settlement NPC hovered exactly half a block + over the floor. Now `Floor()`ed like the station-crew spawner (template upper-floor markers keep + their storey). Bandit-camp guards got the same `Floor()`. +- **Real-block grounding** — bandits + planet enemies snapped to the pure noise + `SurfaceHeight`, blind to stamped structures and player edits (guards floated over carved camp + floors). They now use the creatures' `GroundFeetYAt` probe (new `TryGroundFeetYAt` variant); + settlement NPCs re-ground each step (±2 blocks, doorsteps lift them, mined-out floors drop them) + with the home floor Y as the unloaded-chunk fallback — never the noise surface. +- **Uniform darkness** — the tintable avatar textures averaged ~100/255 grey and LitColor computes + `_Color * tex`, so every avatar rendered at ~40 % of its authored brightness; the NPC outfit + palette was dark on top. Textures are now mean-normalised to ~200/255 at load, the outfit + palettes were widened 3 → 6 tones per theme and lifted, and bandit/Guardian materials get the + standard `_Floor 0.62 / _Fill 0.3` (they ran at the 0.35/0 shader defaults — ~2× darker than + every other humanoid on the shadow side). +- **Identical clones** — per-NPC `Size` 0.92–1.08 (was hardcoded 1), independent trouser colour + (additive `NetNpc.LegsRgb`, 0 = old-server fallback), android chassis tone spread + only ~60 % of + researchers are robots now, and client-side deterministic per-NPC face jitter + hair (7 tones, + some bald) seeded from id+name via a stable hash (string `GetHashCode` is per-process randomised). +- **Tests** — settlement NPC feet must land on the floored marker Y (regression guard); stroll + leash assert switched to horizontal (the code leash is XZ-only) + a ±3 vertical bound. + ## ✅ Done (2026-08-04): worldgen perf pass — per-world wonder profile (#712) The #698–#709 wave made `SurfaceHeight` ~2.6× costlier (PR CI test job 6.5 → 17 min; same cost in @@ -7146,7 +7173,7 @@ like #576–#580; **continents are NEW WORLDS ONLY** via `WorldDescription.Terra escarpment storeys, caldera rim/floor, crater-trait distribution, chains, salt-ridge fraction, continents gating (flag/size/ocean-identity) + bimodality + basin sea, band compat (tier-0 == classic query), cenote sheerness, tunnel determinism + surface mouths, chunk smoke on all wonder - worlds. Status: **local branch `terrain-wonders` only — NO PR yet** (user decision). + worlds. Status: **merged to `main` via PR #710 (2026-08-04)**. ## ✅ Done (2026-08-03): beaches along seas and larger lakes (#679) diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/NpcView.cs b/client/Assets/BlocksBeyondTheStars/Scripts/NpcView.cs index c49d2f8b..9427d369 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/NpcView.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/NpcView.cs @@ -117,11 +117,18 @@ private void OnNpcs(NpcList m) go.transform.position = Game != null ? Game.ScenePos(nd.X, nd.Y, nd.Z) : new Vector3(nd.X, nd.Y, nd.Z); var avatar = go.AddComponent(); - Color skin = nd.IsRobot ? new Color(0.75f, 0.78f, 0.82f) : Rgb(nd.SkinRgb); + // The server owns the whole palette (#711): skin covers robot chassis tones too, and + // trousers are an independent colour (LegsRgb 0 = older server → derive from the outfit). + Color skin = nd.SkinRgb != 0 ? Rgb(nd.SkinRgb) : new Color(0.75f, 0.78f, 0.82f); Color outfit = Rgb(nd.OutfitRgb); - // Deliberately NO spacesuit: civilians stay bare-headed so suited players are - // recognisable at a glance (suit = player, bare face = NPC, face mask = bandit). - avatar.Build(skin, outfit, outfit * 0.85f, outfit * 0.7f); + Color legs = nd.LegsRgb != 0 ? Rgb(nd.LegsRgb) : outfit * 0.7f; + // Per-NPC variety (#711), deterministic from the NPC id + name so it survives rejoins: + // a face-feature jitter and a hair colour (some stay bald); androids keep the uniform + // manufactured look. Deliberately NO spacesuit: civilians stay bare-headed so suited + // players are recognisable at a glance (suit = player, bare face = NPC, mask = bandit). + int seed = unchecked((nd.Id * 486187739) ^ StableHash(nd.Name)); + Color? hair = !nd.IsRobot && (seed & 0x7) != 0 ? HairTones[(int)((uint)(seed >> 8) % (uint)HairTones.Length)] : (Color?)null; + avatar.Build(skin, outfit, outfit * 0.9f, legs, spacesuit: false, variantSeed: nd.IsRobot ? 0 : seed, hair: hair); avatar.SetVisible(true); if (nd.Size > 0f && !Mathf.Approximately(nd.Size, 1f)) @@ -228,5 +235,30 @@ private void OnDestroy() private static Color Rgb(uint rgb) => new Color(((rgb >> 16) & 0xFF) / 255f, ((rgb >> 8) & 0xFF) / 255f, (rgb & 0xFF) / 255f); + + /// Order-dependent string hash that is stable across sessions and machines — .NET string + /// hash codes are randomised per process, which would re-roll every NPC's hair each login. + private static int StableHash(string s) + { + int h = 17; + foreach (char c in s ?? string.Empty) + { + h = unchecked(h * 31 + c); + } + + return h; + } + + /// Civilian hair tones — muted human colours (no reds: red accents belong to the Guardians). + private static readonly Color[] HairTones = + { + new Color(0.10f, 0.09f, 0.08f), // black + new Color(0.28f, 0.19f, 0.12f), // dark brown + new Color(0.45f, 0.32f, 0.18f), // chestnut + new Color(0.62f, 0.48f, 0.25f), // dark blond + new Color(0.75f, 0.65f, 0.40f), // blond + new Color(0.55f, 0.55f, 0.55f), // grey + new Color(0.35f, 0.20f, 0.12f), // auburn + }; } } diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/PlayerAvatar.cs b/client/Assets/BlocksBeyondTheStars/Scripts/PlayerAvatar.cs index a1431ad4..69eb38d8 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/PlayerAvatar.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/PlayerAvatar.cs @@ -63,7 +63,7 @@ public sealed class PlayerAvatar : MonoBehaviour public void Build(ClientSettings s) => Build(s.SkinColor, s.TorsoColor, s.ArmColor, s.LegColor, spacesuit: true); - public void Build(Color skin, Color torso, Color arms, Color legs, bool spacesuit = false) + public void Build(Color skin, Color torso, Color arms, Color legs, bool spacesuit = false, int variantSeed = 0, Color? hair = null) { EnsureTextures(); _suit = spacesuit; @@ -74,6 +74,21 @@ public void Build(Color skin, Color torso, Color arms, Color legs, bool spacesui _arms = Lit(arms, _suitTex); _legs = Lit(legs, _suitTex); + // Per-NPC face variation (#711): tiny deterministic offsets from the seed so a settlement crowd + // isn't a row of identical clones. Seed 0 (players, previews) keeps the exact stock face. + float eyeDX = 0.11f, eyeY = 0.075f, browY = 0.175f, browW = 0.38f; + float mouthW = 0.20f, mouthY = -0.175f, pupilW = 0.085f; + if (variantSeed != 0) + { + eyeDX += Jit(variantSeed, 1) * 0.02f; + eyeY += Jit(variantSeed, 2) * 0.02f; + browY += Jit(variantSeed, 3) * 0.025f; + browW += Jit(variantSeed, 4) * 0.06f; + mouthW += Jit(variantSeed, 5) * 0.06f; + mouthY += Jit(variantSeed, 6) * 0.015f; + pupilW += Jit(variantSeed, 7) * 0.012f; + } + // Torso, tapered: pelvis → abdomen → wider chest. AddCube("Pelvis", transform, new Vector3(0f, 0.97f, 0f), new Vector3(0.46f, 0.22f, 0.28f), _legs); AddCube("Abdomen", transform, new Vector3(0f, 1.18f, 0f), new Vector3(0.46f, 0.26f, 0.30f), _torso); @@ -94,19 +109,29 @@ public void Build(Color skin, Color torso, Color arms, Color legs, bool spacesui // Eyes (whites + pupils + a brow + a mouth) so the face reads clearly — bigger/clearer (B20). var eyeWhite = Lit(new Color(0.96f, 0.97f, 1f), null); var pupil = Lit(new Color(0.04f, 0.04f, 0.07f), null); - var brow = Lit(new Color(0.18f, 0.14f, 0.11f), null); + var brow = hair is { } bc ? Lit(bc, null) : Lit(new Color(0.18f, 0.14f, 0.11f), null); var mouth = Lit(new Color(0.32f, 0.16f, 0.14f), null); // Default procedural features — collected so a custom pixel face (SetFace) can hide them. The // visor (above) is included so a drawn face fully replaces the stock look. // z values are head-LOCAL and must clear the head front (0.5); the +0.255 shift over the old // 0.235–0.275 lifts them just proud of the surface while preserving the relief (pupils ahead of the // whites, etc.). See the Visor note above. - _faceFeatures.Add(AddCube("EyeL", _head, new Vector3(-0.11f, 0.075f, 0.50f), new Vector3(0.17f, 0.13f, 0.05f), eyeWhite)); - _faceFeatures.Add(AddCube("EyeR", _head, new Vector3(0.11f, 0.075f, 0.50f), new Vector3(0.17f, 0.13f, 0.05f), eyeWhite)); - _faceFeatures.Add(AddCube("PupilL", _head, new Vector3(-0.11f, 0.06f, 0.53f), new Vector3(0.085f, 0.10f, 0.03f), pupil)); - _faceFeatures.Add(AddCube("PupilR", _head, new Vector3(0.11f, 0.06f, 0.53f), new Vector3(0.085f, 0.10f, 0.03f), pupil)); - _faceFeatures.Add(AddCube("Brow", _head, new Vector3(0f, 0.175f, 0.50f), new Vector3(0.38f, 0.05f, 0.045f), brow)); - _faceFeatures.Add(AddCube("Mouth", _head, new Vector3(0f, -0.175f, 0.49f), new Vector3(0.20f, 0.045f, 0.04f), mouth)); + _faceFeatures.Add(AddCube("EyeL", _head, new Vector3(-eyeDX, eyeY, 0.50f), new Vector3(0.17f, 0.13f, 0.05f), eyeWhite)); + _faceFeatures.Add(AddCube("EyeR", _head, new Vector3(eyeDX, eyeY, 0.50f), new Vector3(0.17f, 0.13f, 0.05f), eyeWhite)); + _faceFeatures.Add(AddCube("PupilL", _head, new Vector3(-eyeDX, eyeY - 0.015f, 0.53f), new Vector3(pupilW, 0.10f, 0.03f), pupil)); + _faceFeatures.Add(AddCube("PupilR", _head, new Vector3(eyeDX, eyeY - 0.015f, 0.53f), new Vector3(pupilW, 0.10f, 0.03f), pupil)); + _faceFeatures.Add(AddCube("Brow", _head, new Vector3(0f, browY, 0.50f), new Vector3(browW, 0.05f, 0.045f), brow)); + _faceFeatures.Add(AddCube("Mouth", _head, new Vector3(0f, mouthY, 0.49f), new Vector3(mouthW, 0.045f, 0.04f), mouth)); + + // Optional hair cap + back (civilian NPCs): head-LOCAL units — the head is a 0.46-scaled unit + // cube, so anything wrapping its ±0.5 surfaces needs a scale > 1 (see the face-feature note). + // NOT in _faceFeatures: a custom pixel face replaces the face, not the hair. + if (hair is { } hairCol) + { + var hairMat = Lit(hairCol, null); + AddCube("HairTop", _head, new Vector3(0f, 0.56f, -0.02f), new Vector3(1.08f, 0.14f, 1.06f), hairMat); + AddCube("HairBack", _head, new Vector3(0f, 0.18f, -0.53f), new Vector3(1.08f, 0.92f, 0.10f), hairMat); + } // Jointed arms (shoulder → elbow → hand) and legs (hip → knee → foot). _armL = AddArm("ArmLeft", -0.32f, out _elbowL, out _); @@ -573,16 +598,53 @@ private static Texture2D LoadTex(string key) return null; } + // Normalise the greyscale tint texture toward white (#711): the authored bytes average ~100/255, + // and LitColor computes _Color * tex — so every avatar surface rendered at ~40 % of its tint's + // perceptual brightness and whole outfits sank to near-black. Scaling the mean to ~200/255 keeps + // the pixel detail (weave, panels) but stops the texture eating the colour. + var data = (byte[])asset.bytes.Clone(); + long sum = 0; + for (int i = 0; i < data.Length; i += 4) + { + sum += data[i] + data[i + 1] + data[i + 2]; + } + + float mean = sum / (data.Length * 3f / 4f); + if (mean > 1f && mean < 200f) + { + float k = 200f / mean; + for (int i = 0; i < data.Length; i += 4) + { + data[i] = (byte)Mathf.Min(255f, data[i] * k); + data[i + 1] = (byte)Mathf.Min(255f, data[i + 1] * k); + data[i + 2] = (byte)Mathf.Min(255f, data[i + 2] * k); + } + } + var tex = new Texture2D(64, 64, TextureFormat.RGBA32, false) { wrapMode = TextureWrapMode.Repeat, filterMode = FilterMode.Point, }; - tex.LoadRawTextureData(asset.bytes); + tex.LoadRawTextureData(data); tex.Apply(); return tex; } + /// Deterministic jitter in [-1, 1] from a (seed, salt) pair — stable across sessions and + /// machines (unlike string hash codes), so an NPC keeps the same face every visit. + private static float Jit(int seed, int salt) + { + unchecked + { + uint h = (uint)(seed * 73856093) ^ (uint)(salt * 19349663); + h ^= h >> 13; + h *= 0x5bd1e995; + h ^= h >> 15; + return ((h & 0xFFFF) / 32767.5f) - 1f; + } + } + // Ambient floor + opposite-flank fill for avatar materials, same failure mode and values as // creatures (see CreatureBuilder): LitColor's single FIXED key light leaves camera-away / // backlit faces at the floor, and in Linear colour space the tinted suit textures then sink diff --git a/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs b/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs index 2f93150c..ea32fb35 100644 --- a/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs +++ b/client/Assets/BlocksBeyondTheStars/Scripts/WorldEntities.cs @@ -540,18 +540,37 @@ private static void EnsureBanditMaterials() var lit = Shader.Find("BlocksBeyondTheStars/LitColor") ?? Shader.Find("Unlit/Color"); var unlit = Shader.Find("Unlit/Color") ?? lit; - // Skin and cloth are TEMPLATES — every bandit gets its own tinted instance (see BuildBandit). - _banditSkinMat = new Material(lit) { color = ShaderColor.Srgb(new Color(0.78f, 0.6f, 0.45f)) }; - _banditClothMat = new Material(lit) { color = ShaderColor.Srgb(new Color(0.3f, 0.25f, 0.2f)) }; - _banditDarkMat = new Material(lit) { color = ShaderColor.Srgb(new Color(0.14f, 0.13f, 0.14f)) }; - _banditEyeMat = new Material(lit) { color = ShaderColor.Srgb(new Color(0.95f, 0.96f, 0.98f)) }; // eye whites — LIT, so they never glow - _banditEnergyMat = new Material(unlit) { color = ShaderColor.Srgb(BanditEnergyColor) }; // cold blue weapon glow (bloom picks it up) + // Skin and cloth are TEMPLATES — every bandit gets its own tinted instance (see BuildBandit), + // which copies the _Floor/_Fill lift along with the rest of the material. + _banditSkinMat = WithFill(new Material(lit) { color = ShaderColor.Srgb(new Color(0.78f, 0.6f, 0.45f)) }); + _banditClothMat = WithFill(new Material(lit) { color = ShaderColor.Srgb(new Color(0.3f, 0.25f, 0.2f)) }); + _banditDarkMat = WithFill(new Material(lit) { color = ShaderColor.Srgb(new Color(0.14f, 0.13f, 0.14f)) }); + _banditEyeMat = WithFill(new Material(lit) { color = ShaderColor.Srgb(new Color(0.95f, 0.96f, 0.98f)) }); // eye whites — LIT, so they never glow + _banditEnergyMat = new Material(unlit) { color = ShaderColor.Srgb(BanditEnergyColor) }; // cold blue weapon glow (bloom picks it up) } /// The bandits' weapon/tracer glow: cold blue human tech, kept clearly apart from the Guardian /// machines' red sensor glow (). private static readonly Color BanditEnergyColor = new(0.30f, 0.68f, 1f); + // LitColor's shader DEFAULTS are _Floor 0.35 / _Fill 0 — every other humanoid/creature material in + // the game runs at 0.62 / 0.3 (see PlayerAvatar.AvatarFloor); without this lift, bandits and + // Guardians rendered ~2× darker than everyone else on their shadow side (#711). + private const float EntityFloor = 0.62f; + private const float EntityFill = 0.3f; + + /// Applies the shared ambient floor + fill to a LitColor material (no-op on the Unlit fallback). + private static Material WithFill(Material m) + { + if (m.HasProperty("_Floor")) + { + m.SetFloat("_Floor", EntityFloor); + m.SetFloat("_Fill", EntityFill); + } + + return m; + } + private static Transform Pivot(Transform parent, Vector3 localPos) { var t = new GameObject("Pivot").transform; @@ -590,16 +609,16 @@ private static void EnsureMaterials() var lit = Shader.Find("BlocksBeyondTheStars/LitColor") ?? Shader.Find("Unlit/Color"); var unlit = Shader.Find("Unlit/Color") ?? lit; var plateTex = LoadTex("enemy_robot"); // optional metal-plating tile (flat dark if absent) - _hideMat = new Material(lit) { color = ShaderColor.Srgb(new Color(0.13f, 0.14f, 0.16f)) }; // dark plating - _hideDarkMat = new Material(lit) { color = ShaderColor.Srgb(new Color(0.08f, 0.085f, 0.10f)) }; // darker joints + _hideMat = WithFill(new Material(lit) { color = ShaderColor.Srgb(new Color(0.13f, 0.14f, 0.16f)) }); // dark plating + _hideDarkMat = WithFill(new Material(lit) { color = ShaderColor.Srgb(new Color(0.08f, 0.085f, 0.10f)) }); // darker joints if (plateTex != null) { _hideMat.mainTexture = plateTex; _hideDarkMat.mainTexture = plateTex; } - _clawMat = new Material(lit) { color = ShaderColor.Srgb(new Color(0.34f, 0.36f, 0.40f)) }; // metal trim / antennae / feet - _eyeMat = new Material(unlit) { color = ShaderColor.Srgb(new Color(1f, 0.18f, 0.14f)) }; // glowing red sensors (bloom picks it up) + _clawMat = WithFill(new Material(lit) { color = ShaderColor.Srgb(new Color(0.34f, 0.36f, 0.40f)) }); // metal trim / antennae / feet + _eyeMat = new Material(unlit) { color = ShaderColor.Srgb(new Color(1f, 0.18f, 0.14f)) }; // glowing red sensors (bloom picks it up) } private static Texture2D LoadTex(string key) diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerBandits.cs b/src/BlocksBeyondTheStars.GameServer/GameServerBandits.cs index f31906ff..ca6f1b2a 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServerBandits.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServerBandits.cs @@ -247,7 +247,8 @@ private void SpawnLoneBanditNear(Shared.State.PlayerState player) float dist = 35f + (float)(_banditRng.NextDouble() * 15.0); int ex = (int)System.Math.Round(player.Position.X + System.Math.Cos(ang) * dist); int ez = (int)System.Math.Round(player.Position.Z + System.Math.Sin(ang) * dist); - int ey = _generator.SurfaceHeight(_world.Planet, ex, ez) + 1; + // Real-block ground when the column is loaded (player builds/pits are honoured); noise surface otherwise. + int ey = GroundFeetYAt(ex, ez, _generator.SurfaceHeight(_world.Planet, ex, ez) + 1); bool gunner = _banditRng.NextDouble() < 0.4; var bandit = new CombatEntity @@ -292,7 +293,9 @@ private void SpawnBanditCampGuards(BanditCampInstance camp, System.Random rng) Hostile = true, Hull = BanditHull, HullMax = BanditHull, - Position = pos, + // Camp markers sit centred in the air cell above the floor (+0.5 from the cell-centre + // conversion); Floor() puts the guard's feet on the floor surface (#711). + Position = new Vector3f(pos.X, (float)System.Math.Floor(pos.Y), pos.Z), DamagePerSecond = gunner ? BanditGunDps : BanditMeleeDps, BanditPhase = BanditPhase.None, CampKey = camp.Key, @@ -480,8 +483,12 @@ private bool MoveBandit(CombatEntity bandit, List targets, double float nx = (float)WorldConstants.WrapX(res.Position.X, _world.Circumference); float nz = (float)WorldConstants.WrapZ(res.Position.Z, _world.Circumference); - int prevGround = _generator.SurfaceHeight(_world.Planet, (int)System.Math.Floor(bandit.Position.X), (int)System.Math.Floor(bandit.Position.Z)) + 1; - int groundY = _generator.SurfaceHeight(_world.Planet, (int)System.Math.Floor(nx), (int)System.Math.Floor(nz)) + 1; + // Ground from REAL blocks (like creatures, #650), not the pure noise surface — a camp carved into a + // hillside or terrain the player dug out would otherwise leave bandits walking on air (#711). The + // probe falls back to the generator surface only when the chunk isn't loaded. + int refY = (int)System.Math.Floor(bandit.Position.Y); + int prevGround = GroundFeetYAt((int)System.Math.Floor(bandit.Position.X), (int)System.Math.Floor(bandit.Position.Z), refY); + int groundY = GroundFeetYAt((int)System.Math.Floor(nx), (int)System.Math.Floor(nz), refY); if (System.Math.Abs(groundY - prevGround) > 3) { bandit.Loco.ModeTimer = 0f; // cliff in the way — pick a new heading next tick diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerCreatures.cs b/src/BlocksBeyondTheStars.GameServer/GameServerCreatures.cs index 9e577105..5189b238 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServerCreatures.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServerCreatures.cs @@ -922,21 +922,30 @@ private bool StepBlockedByTerrain(CreatureSpecies sp, Vector3f cur, Vector3f nex /// first at equal distance, so a creature under a player bridge keeps the ground instead of snapping /// onto the deck. This is what makes fauna honour player builds: dug pits, ramps, walls, floors. private int GroundFeetYAt(int x, int z, int refY) + => TryGroundFeetYAt(x, z, refY, out int feet) ? feet : _generator.SurfaceHeight(_world.Planet, x, z) + 1; + + /// Like but reports whether a REAL standable cell was found, so + /// callers that must never snap to the noise surface (settlement NPCs standing on stamped floors that + /// the generator knows nothing about) can keep their current Y when the column is unloaded/blocked. + private bool TryGroundFeetYAt(int x, int z, int refY, out int feetY) { for (int r = 0; r <= 6; r++) { if (StandableAt(x, refY - r, z)) { - return refY - r; + feetY = refY - r; + return true; } if (r > 0 && StandableAt(x, refY + r, z)) { - return refY + r; + feetY = refY + r; + return true; } } - return _generator.SurfaceHeight(_world.Planet, x, z) + 1; + feetY = refY; + return false; } /// Whether feet placed at stand on something real: solid (non-water) diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs b/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs index 2dcacb30..94f98f76 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServerEnemies.cs @@ -337,16 +337,20 @@ private bool MovePlanetEnemy(CombatEntity enemy, List targets, Ha float nx = (float)WorldConstants.WrapX(res.Position.X, _world.Circumference); float nz = (float)WorldConstants.WrapZ(res.Position.Z, _world.Circumference); - int prevGround = _generator.SurfaceHeight(_world.Planet, (int)System.Math.Floor(enemy.Position.X), (int)System.Math.Floor(enemy.Position.Z)) + 1; - int groundY = _generator.SurfaceHeight(_world.Planet, (int)System.Math.Floor(nx), (int)System.Math.Floor(nz)) + 1; + int hover = drone ? ScanDroneHover : 0; // scan-drones float above the ground + float bob = drone ? DroneBob * res.VertWave : 0f; // ...and hover-bob; robots stay grounded + // Ground from REAL blocks (like creatures, #650), not the pure noise surface, so machines honour + // stamped structures and player edits instead of walking on air over them (#711). The reference Y + // is the feet (a drone's hover offset removed); noise surface only when the chunk isn't loaded. + int refY = (int)System.Math.Floor(enemy.Position.Y) - hover; + int prevGround = GroundFeetYAt((int)System.Math.Floor(enemy.Position.X), (int)System.Math.Floor(enemy.Position.Z), refY); + int groundY = GroundFeetYAt((int)System.Math.Floor(nx), (int)System.Math.Floor(nz), refY); if (System.Math.Abs(groundY - prevGround) > 3) { enemy.Loco.ModeTimer = 0f; // cliff/spike in the way — pick a new direction next tick return false; } - int hover = drone ? ScanDroneHover : 0; // scan-drones float above the ground - float bob = drone ? DroneBob * res.VertWave : 0f; // ...and hover-bob; robots stay grounded var candidate = new Vector3f(nx, groundY + hover + bob, nz); if (EntityBlockedByShip(candidate) || BlockedByEnergyFence(enemy.Position, candidate)) { @@ -402,7 +406,8 @@ private void SpawnPlanetEnemyNear(Shared.State.PlayerState player, bool asDrone) } } - int ey = _generator.SurfaceHeight(_world.Planet, ex, ez) + 1; // stand on the ground, not in it + // Stand on the ground, not in it — real blocks when the column is loaded, noise surface otherwise. + int ey = GroundFeetYAt(ex, ez, _generator.SurfaceHeight(_world.Planet, ex, ez) + 1); if (asDrone) { ey += ScanDroneHover; // the flying scan-drone hovers above the surface diff --git a/src/BlocksBeyondTheStars.GameServer/GameServerNpcs.cs b/src/BlocksBeyondTheStars.GameServer/GameServerNpcs.cs index 30699d56..2c75da7f 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServerNpcs.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServerNpcs.cs @@ -59,6 +59,7 @@ internal sealed class ServerNpc public float Size; public uint SkinRgb; public uint OutfitRgb; + public uint LegsRgb; public bool IsRobot; public double WanderPhase; public LocomotionState Loco; // stop-and-go loiter/stroll state @@ -119,13 +120,14 @@ private void SpawnSettlementNpcs(System.Random rng) // Vendors each get their own profession (B55) so multiple vendors at one settlement sell different // goods; settlers/the quartermaster keep the settlement's own theme (its identity). string npcTheme = role == "vendor" ? VendorThemeFor(settlement.Name, vendorIndex++, settlementTheme) : settlementTheme; - bool robotic = npcTheme == "researchers"; // research staff are service androids - - // NPCs have no physics, so place their feet on top of the floor block. Procedural markers - // sit inside the ground-floor row (the clamp lifts them to Min.Y+1 as before); an authored - // TEMPLATE marker keeps its own Y (#480, was ST-8) — an upper-floor vendor is no longer - // teleported to the ground floor (possibly inside a wall). - var standing = new Vector3f(pos.X, System.Math.Max(settlement.Min.Y + 1f, pos.Y), pos.Z); + bool robotic = npcTheme == "researchers" && rng.Next(100) < 60; // most research staff are service androids — but not all (#711) + + // NPCs have no physics, so place their feet on top of the floor block. Markers sit centred + // in the air cell above the floor (+0.5 from the cell-centre conversion), so Floor() drops + // the feet onto the floor surface — same fix as station crews. The Max keeps an authored + // TEMPLATE marker's own storey (#480, was ST-8): an upper-floor vendor is not teleported to + // the ground floor, but no NPC hovers half a block over it either (#711). + var standing = new Vector3f(pos.X, (float)System.Math.Floor(System.Math.Max(settlement.Min.Y + 1f, pos.Y)), pos.Z); var npc = MakeNpc(role, npcTheme, robotic, standing, rng); npc.Settlement = settlement.Name; if (role == "quartermaster") @@ -146,14 +148,21 @@ private void SpawnSettlementNpcs(System.Random rng) private ServerNpc MakeNpc(string role, string theme, bool robotic, Vector3f home, System.Random rng) { uint[] skinTones = { 0xF2C9A0, 0xD9A066, 0x8D5524, 0xC68642, 0xFFDBAC }; + // Android chassis tones — a small spread so robots aren't one stamped grey either (#711). + uint[] chassisTones = { 0xBFC7CF, 0xD5DBE1, 0xA8B2BC, 0xC9CCB8 }; + // Six outfit tones per theme (was three), lifted out of the mud: the client multiplies these by the + // greyscale suit texture, so anything authored dark lands near black on screen (#711). uint[] outfitByTheme = theme switch { - "miners" => new uint[] { 0xB5651D, 0x808080, 0x5A4632 }, - "traders" => new uint[] { 0x2E5E8C, 0x6A4C93, 0xC9A227 }, - "researchers" => new uint[] { 0xECECEC, 0x4FA1C9, 0xBFD7EA }, - _ => new uint[] { 0x3F6B3F, 0x7A5C3C, 0x556B2F }, // settlers (default) + "miners" => new uint[] { 0xD97B29, 0xA8ADB5, 0x8A6A45, 0xE0B23C, 0x6E7B8A, 0xB5651D }, + "traders" => new uint[] { 0x3D7EBF, 0x8A63BF, 0xD9AE33, 0x2FA48E, 0xC24B5A, 0x2E5E8C }, + "researchers" => new uint[] { 0xECECEC, 0x5FB6E0, 0xBFD7EA, 0x9AD9C0, 0xC9C2E8, 0xE8D9A0 }, + _ => new uint[] { 0x5C9950, 0xA37B4F, 0x7C9950, 0xB3A05C, 0x6B8FA3, 0x9C6B3C }, // settlers (default) }; + // Trousers are picked independently of the top, so two NPCs sharing a jacket colour still differ. + uint[] legsTones = { 0x4A4E57, 0x5C5346, 0x3E4A5C, 0x6B5C4A, 0x777C85, 0x4E3D30 }; + string nameKey = role switch { "vendor" => "npc.role.vendor", @@ -174,9 +183,10 @@ private ServerNpc MakeNpc(string role, string theme, bool robotic, Vector3f home Home = home, Pos = home, Facing = (float)(rng.NextDouble() * System.Math.PI * 2), - Size = 1f, - SkinRgb = robotic ? 0xBFC7CFu : skinTones[rng.Next(skinTones.Length)], + Size = 0.92f + (float)rng.NextDouble() * 0.16f, // people vary a little (±8 %), not like fauna (#711) + SkinRgb = robotic ? chassisTones[rng.Next(chassisTones.Length)] : skinTones[rng.Next(skinTones.Length)], OutfitRgb = outfitByTheme[rng.Next(outfitByTheme.Length)], + LegsRgb = legsTones[rng.Next(legsTones.Length)], IsRobot = robotic, WanderPhase = rng.NextDouble() * System.Math.PI * 2, }; @@ -229,7 +239,18 @@ private void MoveNpcs(List targets, double dt) var res = LocomotionController.Step(npc.Loco, NpcProfile, npc.Pos, intent, target, moveDt, (uint)npc.Id); npc.Loco = res.State; - var next = new Vector3f(res.Position.X, npc.Home.Y, res.Position.Z); // keep the flat settlement floor Y + + // Follow the REAL floor instead of freezing Y at spawn forever (#711): when the block column is + // loaded and has a standable cell near the NPC, use it — so a doorstep lifts them and a mined-out + // floor drops them instead of leaving them hanging in mid-air. Capped at ±2 blocks per step (a + // strolling settler doesn't climb cliffs). When the column has no answer (chunk unloaded + // server-side, someone walled the cell in, or only a far-off cell) fall back to the home marker's + // floor Y — never the noise surface, which inside a stamped settlement can be metres off. + int gx = (int)System.Math.Floor(res.Position.X), gz = (int)System.Math.Floor(res.Position.Z); + int refY = (int)System.Math.Floor(npc.Pos.Y); + float nextY = TryGroundFeetYAt(gx, gz, refY, out int feet) && System.Math.Abs(feet - refY) <= 2 + ? feet : npc.Home.Y; + var next = new Vector3f(res.Position.X, nextY, res.Position.Z); // NPCs don't wander into the player's ship — or through their building's walls/doors. The world // check sweeps the whole step (not just the endpoint) so an NPC can't tunnel through a one-block @@ -356,6 +377,7 @@ private void SendNpcs(PlayerSession session) Size = n.Size, SkinRgb = n.SkinRgb, OutfitRgb = n.OutfitRgb, + LegsRgb = n.LegsRgb, IsRobot = n.IsRobot, }; } diff --git a/src/BlocksBeyondTheStars.Networking/Messages/NpcMessages.cs b/src/BlocksBeyondTheStars.Networking/Messages/NpcMessages.cs index 554e6c59..c757cf3c 100644 --- a/src/BlocksBeyondTheStars.Networking/Messages/NpcMessages.cs +++ b/src/BlocksBeyondTheStars.Networking/Messages/NpcMessages.cs @@ -30,10 +30,13 @@ public sealed class NetNpc /// Facing yaw in radians — the avatar turns toward a nearby player, else its stroll heading. public float Facing { get; set; } - /// Avatar build hints: humanoid scale, skin/outfit tint, and organic-vs-android body. + /// Avatar build hints: humanoid scale, skin/outfit/legs tint, and organic-vs-android body. + /// LegsRgb is additive (contractless MessagePack): 0 = unset, the client derives legs from the + /// outfit colour as before. public float Size { get; set; } public uint SkinRgb { get; set; } public uint OutfitRgb { get; set; } + public uint LegsRgb { get; set; } public bool IsRobot { get; set; } } diff --git a/tests/BlocksBeyondTheStars.Tests/SettlementNpcTests.cs b/tests/BlocksBeyondTheStars.Tests/SettlementNpcTests.cs index 59767504..57dde995 100644 --- a/tests/BlocksBeyondTheStars.Tests/SettlementNpcTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/SettlementNpcTests.cs @@ -85,13 +85,18 @@ public void InhabitedSettlement_SpawnsNpcs_AtItsMarkers() _ => throw new Xunit.Sdk.XunitException($"Unexpected NPC role '{npc.Role}'."), }; - // The NPC stands on a matching marker's column (its feet are grounded on the floor top, so - // only the horizontal position must match the marker). + // The NPC stands on a matching marker's column with its feet ON the floor surface: markers + // are centred in the air cell above the floor (+0.5), so the feet must be at the marker's + // floored Y — an NPC hovering half a block over the floor is the #711 regression. Assert.Contains( server.SettlementMarkers, m => m.Type == markerType && System.Math.Abs(m.Pos.X - npc.Home.X) < 0.001f - && System.Math.Abs(m.Pos.Z - npc.Home.Z) < 0.001f); + && System.Math.Abs(m.Pos.Z - npc.Home.Z) < 0.001f + && System.Math.Abs(System.Math.Floor(m.Pos.Y) - npc.Home.Y) < 0.001f); + + // Feet always land on a whole block surface — never centred inside a cell. + Assert.Equal(System.Math.Floor(npc.Home.Y), npc.Home.Y, 3); // Freshly spawned (no tick yet): the NPC sits at its home marker. Assert.True(npc.Pos.DistanceSquared(npc.Home) < 0.001f); @@ -157,8 +162,14 @@ public void Npcs_Stroll_ButStayNearHome_WhilePlayerPresent() foreach (var n in server.NpcSnapshots) { if (n.Pos.DistanceSquared(n.Home) > 0.01f) { anyMoved = true; } - Assert.True(n.Pos.DistanceSquared(n.Home) <= 16f, // leash ~1.6 → max ~3.6m - $"NPC '{n.Role}' wandered too far from home ({n.Pos.DistanceSquared(n.Home)})."); + + // The leash is HORIZONTAL (the code checks XZ only); Y follows the real floor now + // (#711) so a doorstep is allowed but the vertical drift stays tightly bounded. + float dx = n.Pos.X - n.Home.X, dz = n.Pos.Z - n.Home.Z; + Assert.True(dx * dx + dz * dz <= 16f, // leash ~1.6 → max ~3.6m overshoot + $"NPC '{n.Role}' wandered too far from home ({dx * dx + dz * dz})."); + Assert.True(System.Math.Abs(n.Pos.Y - n.Home.Y) <= 3f, + $"NPC '{n.Role}' drifted vertically ({n.Pos.Y} vs home {n.Home.Y})."); } }