Map Updates
This commit is contained in:
@@ -30,6 +30,12 @@ namespace ProjectM.Client
|
||||
/// <summary>EDITOR / execute_code hook: queue a turret placement at a specific cell.</summary>
|
||||
public static void PlaceTurret(int cellX, int cellZ) => PlaceStructure(StructureType.Turret, cellX, cellZ);
|
||||
|
||||
/// <summary>EDITOR / execute_code hook: queue a wall placement at a specific cell.</summary>
|
||||
public static void PlaceWall(int cellX, int cellZ) => PlaceStructure(StructureType.Wall, cellX, cellZ);
|
||||
|
||||
/// <summary>EDITOR / execute_code hook: queue a pylon placement at a specific cell.</summary>
|
||||
public static void PlacePylon(int cellX, int cellZ) => PlaceStructure(StructureType.Pylon, cellX, cellZ);
|
||||
|
||||
/// <summary>EDITOR / execute_code hook: queue an ability-damage upgrade.</summary>
|
||||
public static void UpgradeAbility() => s_PendingUpgrades++;
|
||||
#endif
|
||||
@@ -49,6 +55,10 @@ namespace ProjectM.Client
|
||||
{
|
||||
if (keyboard.bKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell))
|
||||
SendBuild(connection, StructureType.Turret, cell.x, cell.y);
|
||||
if (keyboard.vKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 wcell))
|
||||
SendBuild(connection, StructureType.Wall, wcell.x, wcell.y);
|
||||
if (keyboard.nKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 pcell))
|
||||
SendBuild(connection, StructureType.Pylon, pcell.x, pcell.y);
|
||||
if (keyboard.uKey.wasPressedThisFrame)
|
||||
SendUpgrade(connection);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-only WORLD JUICE for harvesting resource nodes + smashing Blight clutter. A managed presentation
|
||||
/// system (SystemBase, main thread, NO Burst) in <see cref="PresentationSystemGroup"/> that REACTS to
|
||||
/// replicated state — it never runs simulation. Each frame it edge-detects every node/clutter ghost's
|
||||
/// replicated <c>Remaining</c>: a decrease spawns a small tinted chip burst + soft SFX; a despawn (the server
|
||||
/// destroyed it — depletion or shatter) spawns a clear burst + SFX, and clutter adds a camera punch (the
|
||||
/// "carve through the frontier" smash). A PROXIMITY GATE suppresses the prune VFX unless the despawned
|
||||
/// entity's last position was near the local player, so the region-transit despawn storm at +1000 X stays
|
||||
/// silent off-camera (GhostRelevancy drops every expedition ghost at once when the player walks home).
|
||||
/// Procedural particles + procedural SFX (mirrors CombatFeedbackSystem; self-contained); knobs live in
|
||||
/// <see cref="WorldFeelConfig"/>. Never destroys a ghost — GhostDespawnSystem owns despawn; we only OBSERVE.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class WorldFeedbackSystem : SystemBase
|
||||
{
|
||||
struct Cache { public int Remaining; public float3 Pos; public bool IsClutter; public Color Tint; }
|
||||
|
||||
readonly Dictionary<Entity, Cache> _cache = new();
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
readonly List<Entity> _stale = new();
|
||||
|
||||
Transform _fxRoot;
|
||||
ParticleSystem _chipFx;
|
||||
ParticleSystem _clearFx;
|
||||
AudioClip _chipClip;
|
||||
AudioClip _clearClip;
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
_chipClip = MakeClip("harvest_chip", 900f, 1400f, 0.06f, 0.30f);
|
||||
_clearClip = MakeClip("clutter_clear", 420f, 90f, 0.22f, 0.50f);
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
if (_fxRoot != null) return;
|
||||
_fxRoot = new GameObject("~WorldFeedbackFX").transform;
|
||||
var mat = MakeParticleMaterial();
|
||||
_chipFx = MakeBurst("HarvestChips", mat, new Color(2.6f, 1.9f, 0.7f), 0.10f, 5f, 0.30f, 256);
|
||||
_clearFx = MakeBurst("ClutterClear", mat, new Color(3.0f, 1.1f, 0.25f), 0.16f, 7f, 0.45f, 512);
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (!WorldFeelConfig.Enabled) { _cache.Clear(); return; }
|
||||
|
||||
// Complete predicted/interpolation jobs writing these before the main-thread reads.
|
||||
EntityManager.CompleteDependencyBeforeRO<ResourceNode>();
|
||||
EntityManager.CompleteDependencyBeforeRO<BlightClutter>();
|
||||
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
||||
|
||||
// Local player position (for the proximity gate).
|
||||
bool haveLocal = false;
|
||||
float3 localPos = default;
|
||||
foreach (var xf in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
|
||||
{
|
||||
localPos = xf.ValueRO.Position;
|
||||
haveLocal = true;
|
||||
}
|
||||
|
||||
_seen.Clear();
|
||||
|
||||
// Resource nodes — chip on depletion.
|
||||
foreach (var (node, xf, e) in
|
||||
SystemAPI.Query<RefRO<ResourceNode>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
_seen.Add(e);
|
||||
Observe(e, node.ValueRO.Remaining, xf.ValueRO.Position, false, TintForResource(node.ValueRO.ResourceId));
|
||||
}
|
||||
|
||||
// Blight clutter — chip on damage.
|
||||
foreach (var (clutter, xf, e) in
|
||||
SystemAPI.Query<RefRO<BlightClutter>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
_seen.Add(e);
|
||||
Observe(e, clutter.ValueRO.Remaining, xf.ValueRO.Position, true, WorldFeelConfig.WildTint);
|
||||
}
|
||||
|
||||
// Prune: a despawn = the server destroyed it (node depleted / clutter shattered). Gate on proximity so
|
||||
// a region-transit despawn storm (every expedition ghost dropped at once, far at +1000 X) stays silent.
|
||||
if (_cache.Count != _seen.Count)
|
||||
{
|
||||
_stale.Clear();
|
||||
foreach (var kv in _cache)
|
||||
if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||
|
||||
float rangeSq = WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange;
|
||||
for (int i = 0; i < _stale.Count; i++)
|
||||
{
|
||||
var c = _cache[_stale[i]];
|
||||
if (haveLocal && math.distancesq(c.Pos, localPos) <= rangeSq)
|
||||
{
|
||||
EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount, c.Tint);
|
||||
PlayClip(_clearClip, (Vector3)c.Pos, WorldFeelConfig.ClearSfxVolume);
|
||||
if (c.IsClutter)
|
||||
{
|
||||
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick, 90f);
|
||||
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake);
|
||||
}
|
||||
}
|
||||
_cache.Remove(_stale[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint)
|
||||
{
|
||||
if (_cache.TryGetValue(e, out var prev) && remaining < prev.Remaining)
|
||||
{
|
||||
EmitTinted(_chipFx, (Vector3)pos + Vector3.up * 0.6f, WorldFeelConfig.ChipBurstCount, tint);
|
||||
PlayClip(_chipClip, (Vector3)pos, WorldFeelConfig.ChipSfxVolume);
|
||||
}
|
||||
_cache[e] = new Cache { Remaining = remaining, Pos = pos, IsClutter = isClutter, Tint = tint };
|
||||
}
|
||||
|
||||
static Color TintForResource(byte resourceId)
|
||||
{
|
||||
if (resourceId == ResourceId.Ore) return WorldFeelConfig.OreTint;
|
||||
if (resourceId == ResourceId.Biomass) return WorldFeelConfig.BiomassTint;
|
||||
return WorldFeelConfig.WildTint; // Aether + default
|
||||
}
|
||||
|
||||
// ---- procedural particles + SFX (mirrors CombatFeedbackSystem; self-contained) ----
|
||||
|
||||
static void EmitTinted(ParticleSystem ps, Vector3 pos, int count, Color tint)
|
||||
{
|
||||
if (ps == null) return;
|
||||
var main = ps.main;
|
||||
main.startColor = tint;
|
||||
ps.transform.position = pos;
|
||||
ps.Emit(count);
|
||||
}
|
||||
|
||||
static Material MakeParticleMaterial()
|
||||
{
|
||||
Shader sh = Shader.Find("Sprites/Default");
|
||||
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
|
||||
if (sh == null) sh = Shader.Find("Unlit/Color");
|
||||
return new Material(sh) { name = "WorldFeedbackParticle" };
|
||||
}
|
||||
|
||||
ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(_fxRoot, false);
|
||||
var ps = go.AddComponent<ParticleSystem>();
|
||||
|
||||
var main = ps.main;
|
||||
main.loop = false;
|
||||
main.playOnAwake = false;
|
||||
main.startLifetime = life;
|
||||
main.startSpeed = speed;
|
||||
main.startSize = size;
|
||||
main.startColor = color;
|
||||
main.maxParticles = max;
|
||||
main.gravityModifier = 0.25f;
|
||||
main.simulationSpace = ParticleSystemSimulationSpace.World;
|
||||
|
||||
var emission = ps.emission;
|
||||
emission.enabled = false; // manual Emit(count)
|
||||
|
||||
var shape = ps.shape;
|
||||
shape.enabled = true;
|
||||
shape.shapeType = ParticleSystemShapeType.Sphere;
|
||||
shape.radius = 0.10f;
|
||||
|
||||
var colOverLife = ps.colorOverLifetime;
|
||||
colOverLife.enabled = true;
|
||||
var grad = new Gradient();
|
||||
grad.SetKeys(
|
||||
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
|
||||
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) });
|
||||
colOverLife.color = new ParticleSystem.MinMaxGradient(grad);
|
||||
|
||||
var sizeOverLife = ps.sizeOverLifetime;
|
||||
sizeOverLife.enabled = true;
|
||||
sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, 0.2f));
|
||||
|
||||
var renderer = ps.GetComponent<ParticleSystemRenderer>();
|
||||
renderer.material = mat;
|
||||
renderer.renderMode = ParticleSystemRenderMode.Billboard;
|
||||
return ps;
|
||||
}
|
||||
|
||||
static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol)
|
||||
{
|
||||
const int rate = 44100;
|
||||
int len = Mathf.Max(16, (int)(dur * rate));
|
||||
var clip = AudioClip.Create(name, len, 1, rate, false);
|
||||
var data = new float[len];
|
||||
float phase = 0f;
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
float t = i / (float)len;
|
||||
float env = Mathf.Exp(-5f * t);
|
||||
float freq = Mathf.Lerp(f0, f1, t);
|
||||
phase += 2f * Mathf.PI * freq / rate;
|
||||
data[i] = Mathf.Sin(phase) * env * vol;
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
||||
{
|
||||
if (clip == null) return;
|
||||
AudioSource.PlayClipAtPoint(clip, pos, vol);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 470fdf29ccc332d42bb93e0b1e249dfb
|
||||
@@ -0,0 +1,61 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Live-tunable knobs for the client-only WORLD-FEEDBACK slice (harvesting nodes + smashing Blight clutter).
|
||||
/// A static bridge (mirrors <see cref="FeelConfig"/>) so values can be poked at runtime via MCP execute_code
|
||||
/// without a recompile (e.g. <c>ProjectM.Client.WorldFeelConfig.ClearFovKick = 1.2f;</c>). Read ONLY by
|
||||
/// <see cref="WorldFeedbackSystem"/> (managed, main-thread). NEVER read from a [BurstCompile] system
|
||||
/// (managed-static + Color-in-Burst hazards). <see cref="ResetDefaults"/> re-stamps on play-enter via
|
||||
/// [RuntimeInitializeOnLoadMethod] because statics survive fast-enter-playmode reloads (else a poked value
|
||||
/// leaks across play-enters).
|
||||
/// </summary>
|
||||
public static class WorldFeelConfig
|
||||
{
|
||||
/// <summary>Master gate for harvest/clear feedback.</summary>
|
||||
public static bool Enabled;
|
||||
|
||||
/// <summary>Particle burst when a node/clutter loses hit-points (a chip).</summary>
|
||||
public static int ChipBurstCount;
|
||||
/// <summary>Particle burst when a node depletes / clutter shatters (the clear).</summary>
|
||||
public static int ClearBurstCount;
|
||||
|
||||
/// <summary>Soft SFX volume on a chip.</summary>
|
||||
public static float ChipSfxVolume;
|
||||
/// <summary>SFX volume on a shatter / deplete.</summary>
|
||||
public static float ClearSfxVolume;
|
||||
|
||||
/// <summary>Camera FOV kick (deg) when clutter shatters near the player — the satisfying smash. 0 = off.</summary>
|
||||
public static float ClearFovKick;
|
||||
/// <summary>Camera shake when clutter shatters near the player.</summary>
|
||||
public static float ClearShake;
|
||||
|
||||
/// <summary>Only fire prune VFX when the despawned entity's last position is within this distance of the
|
||||
/// local player, so a region-transit despawn storm at +1000 X stays silent off-camera.</summary>
|
||||
public static float ProximityRange;
|
||||
|
||||
/// <summary>Tint for wild-Aether clutter shatter + Aether-node chips (HDR orange, pushes past bloom).</summary>
|
||||
public static Color WildTint;
|
||||
/// <summary>Tint for Ore-node chips (HDR amber).</summary>
|
||||
public static Color OreTint;
|
||||
/// <summary>Tint for Biomass-node chips (HDR sickly green).</summary>
|
||||
public static Color BiomassTint;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
public static void ResetDefaults()
|
||||
{
|
||||
Enabled = true;
|
||||
ChipBurstCount = 6;
|
||||
ClearBurstCount = 18;
|
||||
ChipSfxVolume = 0.30f;
|
||||
ClearSfxVolume = 0.55f;
|
||||
ClearFovKick = 0.8f;
|
||||
ClearShake = 0.12f;
|
||||
ProximityRange = 40f;
|
||||
WildTint = new Color(3.0f, 1.1f, 0.25f);
|
||||
OreTint = new Color(2.6f, 1.9f, 0.7f);
|
||||
BiomassTint = new Color(0.9f, 2.4f, 0.8f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d6f72b25a2a1d14e988b5999e029c5c
|
||||
Reference in New Issue
Block a user