feat(traffic): implement autonomous City Traffic Engine and Living Urban Crowd Engine from CBT dump
This commit is contained in:
1 parent
f2a36fe188
commit
67bdbae24b
17 files changed
+18759
No files matched your search
@@ -5,6 +5,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Ananta.SDK\Ananta.SDK.csproj" />
|
||||
<ProjectReference Include="..\Ananta.Core\Ananta.Core.csproj" />
|
||||
<ProjectReference Include="..\Ananta.RpcTypes\Ananta.RpcTypes.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Ananta.SDK.Network;
|
||||
|
||||
namespace Ananta.Server.Gameplay.Crowd;
|
||||
|
||||
public interface IUrbanCrowdDriver
|
||||
{
|
||||
Task<(bool Ok, ulong[] EntityIds)> SpawnCrowdPedestriansAsync(
|
||||
TcpSession session, uint npcFormworkId, uint poiActionId, float x, float y, float z, float facing);
|
||||
|
||||
(bool HasPlayer, float X, float Y, float Z, float Yaw, uint RaidId) GetPlayerPosition(TcpSession session);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Ananta.SDK.Network;
|
||||
|
||||
namespace Ananta.Server.Gameplay.Crowd;
|
||||
|
||||
public sealed class InteriorsFile
|
||||
{
|
||||
[JsonPropertyName("metadata")]
|
||||
public InteriorMetadata Metadata { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("spatialGrid")]
|
||||
public Dictionary<string, List<int>> SpatialGrid { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("interiors")]
|
||||
public List<InteriorEntry> Interiors { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class InteriorMetadata
|
||||
{
|
||||
[JsonPropertyName("totalInteriors")]
|
||||
public int TotalInteriors { get; set; }
|
||||
|
||||
[JsonPropertyName("gridSize")]
|
||||
public float GridSize { get; set; } = 100f;
|
||||
}
|
||||
|
||||
public sealed class InteriorEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sector")]
|
||||
public string Sector { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("pos")]
|
||||
public List<float> Pos { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class PedestrianNetworkFile
|
||||
{
|
||||
[JsonPropertyName("metadata")]
|
||||
public PedestrianMetadata Metadata { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("spatialGrid")]
|
||||
public Dictionary<string, List<int>> SpatialGrid { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("lanes")]
|
||||
public List<PedestrianLaneEntry> Lanes { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class PedestrianMetadata
|
||||
{
|
||||
[JsonPropertyName("totalLanes")]
|
||||
public int TotalLanes { get; set; }
|
||||
|
||||
[JsonPropertyName("gridSize")]
|
||||
public float GridSize { get; set; } = 100f;
|
||||
}
|
||||
|
||||
public sealed class PedestrianLaneEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("pts")]
|
||||
public List<List<float>> Pts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("isCrosswalk")]
|
||||
public bool IsCrosswalk { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UrbanCrowdEngine
|
||||
{
|
||||
public static UrbanCrowdEngine Instance { get; } = new();
|
||||
|
||||
private IUrbanCrowdDriver? _driver;
|
||||
private Func<IEnumerable<TcpSession>>? _sessionProvider;
|
||||
|
||||
private InteriorsFile? _interiorsData;
|
||||
private PedestrianNetworkFile? _pedestrianData;
|
||||
|
||||
private readonly ConcurrentDictionary<int, DateTime> _populatedInteriors = new();
|
||||
private readonly ConcurrentDictionary<ulong, ActiveCrowdNpc> _activeCrowd = new();
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _crowdLoopTask;
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int TargetPedestrianDensity { get; set; } = 15;
|
||||
public bool PopulateShops { get; set; } = true;
|
||||
|
||||
// Verified civilian NPCs from Camp 2 with models
|
||||
private static readonly uint[] CitizenFormworkPool =
|
||||
[
|
||||
40120883, 40120884, 40120885, 40120886, 40120887,
|
||||
40120888, 40120889, 40120890, 40120891, 40120892,
|
||||
40120893, 40120894, 40120895, 40120896, 40120897
|
||||
];
|
||||
|
||||
// Common idle / chatter / phone POI action IDs
|
||||
private static readonly uint[] AmbientPoiActions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
private readonly Random _rng = new();
|
||||
|
||||
public sealed class ActiveCrowdNpc
|
||||
{
|
||||
public ulong EntityId { get; set; }
|
||||
public uint FormworkId { get; set; }
|
||||
public float X { get; set; }
|
||||
public float Y { get; set; }
|
||||
public float Z { get; set; }
|
||||
public DateTime SpawnedAt { get; set; }
|
||||
}
|
||||
|
||||
public void Initialize(string clientDataPath, IUrbanCrowdDriver driver, Func<IEnumerable<TcpSession>> sessionProvider)
|
||||
{
|
||||
_driver = driver;
|
||||
_sessionProvider = sessionProvider;
|
||||
|
||||
var interiorsPath = Path.Combine(clientDataPath, "World", "Interiors.json");
|
||||
var pedPath = Path.Combine(clientDataPath, "World", "PedestrianNetwork.json");
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(interiorsPath))
|
||||
{
|
||||
var json = File.ReadAllText(interiorsPath);
|
||||
_interiorsData = JsonSerializer.Deserialize<InteriorsFile>(json);
|
||||
Console.WriteLine($"[CROWD] Loaded {_interiorsData?.Interiors.Count ?? 0} shop interiors.");
|
||||
}
|
||||
|
||||
if (File.Exists(pedPath))
|
||||
{
|
||||
var json = File.ReadAllText(pedPath);
|
||||
_pedestrianData = JsonSerializer.Deserialize<PedestrianNetworkFile>(json);
|
||||
Console.WriteLine($"[CROWD] Loaded {_pedestrianData?.Lanes.Count ?? 0} pedestrian sidewalk/crosswalk lanes.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[CROWD] Error loading world crowd data: {ex.Message}");
|
||||
}
|
||||
|
||||
Start();
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_crowdLoopTask is not null && !_crowdLoopTask.IsCompleted)
|
||||
return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_crowdLoopTask = Task.Run(() => CrowdLoopAsync(_cts.Token));
|
||||
Console.WriteLine("[CROWD] Living Urban Crowd Engine started.");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
}
|
||||
|
||||
private async Task CrowdLoopAsync(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Enabled && _driver is not null && _sessionProvider is not null)
|
||||
{
|
||||
var sessions = _sessionProvider().ToList();
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
await TickSessionCrowdAsync(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[CROWD-ERROR] Tick error: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Run crowd checks every 2.5 seconds (crowd spawns do not require high frequency updates)
|
||||
await Task.Delay(2500, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TickSessionCrowdAsync(TcpSession session)
|
||||
{
|
||||
if (_driver is null)
|
||||
return;
|
||||
|
||||
var (hasPlayer, px, py, pz, pyaw, _) = _driver.GetPlayerPosition(session);
|
||||
if (!hasPlayer || !float.IsFinite(px) || !float.IsFinite(pz))
|
||||
return;
|
||||
|
||||
// 1. Populate nearby shops and cafes (within 60m)
|
||||
if (PopulateShops && _interiorsData is not null)
|
||||
{
|
||||
var grid = _interiorsData.SpatialGrid;
|
||||
var gridSize = _interiorsData.Metadata.GridSize;
|
||||
var centerCx = (int)MathF.Floor(px / gridSize);
|
||||
var centerCz = (int)MathF.Floor(pz / gridSize);
|
||||
|
||||
for (var cx = centerCx - 1; cx <= centerCx + 1; cx++)
|
||||
{
|
||||
for (var cz = centerCz - 1; cz <= centerCz + 1; cz++)
|
||||
{
|
||||
var ckey = $"{cx}_{cz}";
|
||||
if (grid.TryGetValue(ckey, out var interiorIndices))
|
||||
{
|
||||
foreach (var idx in interiorIndices)
|
||||
{
|
||||
if (idx >= 0 && idx < _interiorsData.Interiors.Count)
|
||||
{
|
||||
var shop = _interiorsData.Interiors[idx];
|
||||
if (shop.Pos.Count < 3) continue;
|
||||
|
||||
var sx = shop.Pos[0];
|
||||
var sy = shop.Pos[1];
|
||||
var sz = shop.Pos[2];
|
||||
|
||||
var dx = sx - px;
|
||||
var dz = sz - pz;
|
||||
var distSq = dx * dx + dz * dz;
|
||||
|
||||
if (distSq <= 60f * 60f && !_populatedInteriors.ContainsKey(shop.Id))
|
||||
{
|
||||
_populatedInteriors[shop.Id] = DateTime.UtcNow;
|
||||
|
||||
// Spawn 1 shopkeeper/patron inside
|
||||
var formworkId = CitizenFormworkPool[_rng.Next(CitizenFormworkPool.Length)];
|
||||
var poiAction = AmbientPoiActions[_rng.Next(AmbientPoiActions.Length)];
|
||||
var facing = _rng.Next(0, 360);
|
||||
|
||||
var (ok, ids) = await _driver.SpawnCrowdPedestriansAsync(
|
||||
session, formworkId, poiAction, sx, sy, sz, facing);
|
||||
|
||||
if (ok && ids.Length > 0)
|
||||
{
|
||||
_activeCrowd[ids[0]] = new ActiveCrowdNpc
|
||||
{
|
||||
EntityId = ids[0],
|
||||
FormworkId = formworkId,
|
||||
X = sx,
|
||||
Y = sy,
|
||||
Z = sz,
|
||||
SpawnedAt = DateTime.UtcNow
|
||||
};
|
||||
Console.WriteLine($"[CROWD] Populated shop '{shop.Name}' with citizen NPC {formworkId}!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sidewalk pedestrians
|
||||
if (_pedestrianData is not null && _activeCrowd.Count < TargetPedestrianDensity)
|
||||
{
|
||||
var pGrid = _pedestrianData.SpatialGrid;
|
||||
var pGridSize = _pedestrianData.Metadata.GridSize;
|
||||
var centerCx = (int)MathF.Floor(px / pGridSize);
|
||||
var centerCz = (int)MathF.Floor(pz / pGridSize);
|
||||
|
||||
var candidateWaypoints = new List<(float X, float Y, float Z)>();
|
||||
|
||||
for (var cx = centerCx - 1; cx <= centerCx + 1; cx++)
|
||||
{
|
||||
for (var cz = centerCz - 1; cz <= centerCz + 1; cz++)
|
||||
{
|
||||
var ckey = $"{cx}_{cz}";
|
||||
if (pGrid.TryGetValue(ckey, out var laneIds))
|
||||
{
|
||||
foreach (var laneId in laneIds)
|
||||
{
|
||||
if (laneId >= 0 && laneId < _pedestrianData.Lanes.Count)
|
||||
{
|
||||
var lane = _pedestrianData.Lanes[laneId];
|
||||
foreach (var pt in lane.Pts)
|
||||
{
|
||||
if (pt.Count < 3) continue;
|
||||
var ldx = pt[0] - px;
|
||||
var ldz = pt[2] - pz;
|
||||
var dSq = ldx * ldx + ldz * ldz;
|
||||
if (dSq >= 25f * 25f && dSq <= 80f * 80f)
|
||||
{
|
||||
candidateWaypoints.Add((pt[0], pt[1], pt[2]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateWaypoints.Count > 0)
|
||||
{
|
||||
var wp = candidateWaypoints[_rng.Next(candidateWaypoints.Count)];
|
||||
var formworkId = CitizenFormworkPool[_rng.Next(CitizenFormworkPool.Length)];
|
||||
var poiAction = AmbientPoiActions[_rng.Next(AmbientPoiActions.Length)];
|
||||
var facing = _rng.Next(0, 360);
|
||||
|
||||
var (ok, ids) = await _driver.SpawnCrowdPedestriansAsync(
|
||||
session, formworkId, poiAction, wp.X, wp.Y, wp.Z, facing);
|
||||
|
||||
if (ok && ids.Length > 0)
|
||||
{
|
||||
_activeCrowd[ids[0]] = new ActiveCrowdNpc
|
||||
{
|
||||
EntityId = ids[0],
|
||||
FormworkId = formworkId,
|
||||
X = wp.X,
|
||||
Y = wp.Y,
|
||||
Z = wp.Z,
|
||||
SpawnedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public CrowdStatus GetStatus() => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
TargetDensity = TargetPedestrianDensity,
|
||||
ActiveCrowdCount = _activeCrowd.Count,
|
||||
PopulatedShopsCount = _populatedInteriors.Count,
|
||||
TotalInteriors = _interiorsData?.Interiors.Count ?? 0,
|
||||
TotalPedestrianLanes = _pedestrianData?.Lanes.Count ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class CrowdStatus
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public int TargetDensity { get; set; }
|
||||
public int ActiveCrowdCount { get; set; }
|
||||
public int PopulatedShopsCount { get; set; }
|
||||
public int TotalInteriors { get; set; }
|
||||
public int TotalPedestrianLanes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Ananta.SDK.Network;
|
||||
|
||||
namespace Ananta.Server.Gameplay.Traffic;
|
||||
|
||||
public sealed class CityTrafficEngine
|
||||
{
|
||||
public static CityTrafficEngine Instance { get; } = new();
|
||||
|
||||
private ITrafficVehicleDriver? _driver;
|
||||
private Func<IEnumerable<TcpSession>>? _sessionProvider;
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, SimulatedVehicle> _simulatedVehicles = new();
|
||||
private RoadNetworkFile? _roadNetwork;
|
||||
private readonly Dictionary<int, RoadLaneData> _laneLookup = new();
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _simulationLoopTask;
|
||||
|
||||
// Traffic Configuration
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int TargetDensity { get; set; } = 20;
|
||||
public float SpeedScale { get; set; } = 1.0f;
|
||||
public float MinSpawnDistance { get; set; } = 50f;
|
||||
public float MaxSpawnDistance { get; set; } = 160f;
|
||||
public float DespawnDistance { get; set; } = 220f;
|
||||
|
||||
// Pool of verified vehicle configs with full models and multi-seat support
|
||||
private static readonly uint[] TrafficVehiclePool =
|
||||
[
|
||||
81001001, // Sunset Skywing (Sedan)
|
||||
81001002, // Korou RV6 (SUV)
|
||||
81001003, // Kazama Voyage (Sedan)
|
||||
81000007, // Sunset GT-X Specter (Sports Car)
|
||||
81000002, // Korou Transporter M (Truck)
|
||||
81000005, // Erebos Black Box (Van)
|
||||
];
|
||||
|
||||
private readonly Random _rng = new();
|
||||
|
||||
public sealed class SimulatedVehicle
|
||||
{
|
||||
public ulong EntityId { get; set; }
|
||||
public uint ConfigId { get; set; }
|
||||
public int CurrentLaneId { get; set; }
|
||||
public float DistanceOnLane { get; set; }
|
||||
public float Speed { get; set; }
|
||||
public float X { get; set; }
|
||||
public float Y { get; set; }
|
||||
public float Z { get; set; }
|
||||
public float Yaw { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public bool HijackedByPlayer { get; set; }
|
||||
}
|
||||
|
||||
public void Initialize(string clientDataPath, ITrafficVehicleDriver driver, Func<IEnumerable<TcpSession>> sessionProvider)
|
||||
{
|
||||
_driver = driver;
|
||||
_sessionProvider = sessionProvider;
|
||||
|
||||
var roadPath = Path.Combine(clientDataPath, "World", "RoadNetwork.json");
|
||||
if (!File.Exists(roadPath))
|
||||
{
|
||||
Console.WriteLine($"[TRAFFIC] RoadNetwork.json not found at: {roadPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"[TRAFFIC] Loading road network from: {roadPath}...");
|
||||
var json = File.ReadAllText(roadPath);
|
||||
_roadNetwork = JsonSerializer.Deserialize<RoadNetworkFile>(json);
|
||||
if (_roadNetwork is not null)
|
||||
{
|
||||
foreach (var lane in _roadNetwork.Lanes)
|
||||
{
|
||||
lane.Precompute();
|
||||
_laneLookup[lane.Id] = lane;
|
||||
}
|
||||
Console.WriteLine($"[TRAFFIC] Road network loaded: {_roadNetwork.Lanes.Count} lanes precomputed across {_roadNetwork.SpatialGrid.Count} spatial cells.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[TRAFFIC] Failed to load road network: {ex.Message}");
|
||||
}
|
||||
|
||||
Start();
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_simulationLoopTask is not null && !_simulationLoopTask.IsCompleted)
|
||||
return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_simulationLoopTask = Task.Run(() => SimulationLoopAsync(_cts.Token));
|
||||
Console.WriteLine("[TRAFFIC] Autonomous City Traffic Engine started.");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
}
|
||||
|
||||
private async Task SimulationLoopAsync(CancellationToken token)
|
||||
{
|
||||
const int tickIntervalMs = 100; // 10 ticks per second for smooth client replication
|
||||
const float dt = tickIntervalMs / 1000f;
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Enabled && _driver is not null && _sessionProvider is not null && _roadNetwork is not null)
|
||||
{
|
||||
var sessions = _sessionProvider().ToList();
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
await TickSessionTrafficAsync(session, dt);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Engine tick exception guard
|
||||
Console.WriteLine($"[TRAFFIC-ERROR] Loop exception: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(tickIntervalMs, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TickSessionTrafficAsync(TcpSession session, float dt)
|
||||
{
|
||||
if (_driver is null || _roadNetwork is null)
|
||||
return;
|
||||
|
||||
var (hasPlayer, px, py, pz, pyaw, raidId) = _driver.GetPlayerPosition(session);
|
||||
if (!hasPlayer || !float.IsFinite(px) || !float.IsFinite(pz))
|
||||
return;
|
||||
|
||||
// 1. Move and update existing simulated vehicles
|
||||
var vehicleEntries = _simulatedVehicles.Values.ToList();
|
||||
foreach (var v in vehicleEntries)
|
||||
{
|
||||
if (v.HijackedByPlayer)
|
||||
continue;
|
||||
|
||||
// Check if player has boarded or driven this vehicle
|
||||
if (_driver.IsVehiclePlayerControlled(v.EntityId))
|
||||
{
|
||||
v.HijackedByPlayer = true;
|
||||
_simulatedVehicles.TryRemove(v.EntityId, out _);
|
||||
Console.WriteLine($"[TRAFFIC] Player took control of traffic vehicle entity={v.EntityId}!");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Distance to player
|
||||
var dx = v.X - px;
|
||||
var dz = v.Z - pz;
|
||||
var distSq = dx * dx + dz * dz;
|
||||
|
||||
if (distSq > DespawnDistance * DespawnDistance)
|
||||
{
|
||||
// Despawn far away vehicle
|
||||
_simulatedVehicles.TryRemove(v.EntityId, out _);
|
||||
await _driver.DestroyTrafficVehicleAsync(session, v.EntityId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Advance vehicle along lane
|
||||
if (!_laneLookup.TryGetValue(v.CurrentLaneId, out var currentLane) || currentLane.TotalLength <= 0.1f)
|
||||
{
|
||||
_simulatedVehicles.TryRemove(v.EntityId, out _);
|
||||
await _driver.DestroyTrafficVehicleAsync(session, v.EntityId);
|
||||
continue;
|
||||
}
|
||||
|
||||
v.DistanceOnLane += v.Speed * SpeedScale * dt;
|
||||
|
||||
// Check if vehicle reached the end of the lane
|
||||
if (v.DistanceOnLane >= currentLane.TotalLength)
|
||||
{
|
||||
if (currentLane.Next.Count > 0)
|
||||
{
|
||||
// Pick next connected lane
|
||||
var nextLaneId = currentLane.Next[_rng.Next(currentLane.Next.Count)];
|
||||
if (_laneLookup.TryGetValue(nextLaneId, out var nextLane))
|
||||
{
|
||||
v.CurrentLaneId = nextLaneId;
|
||||
v.DistanceOnLane = 0f;
|
||||
v.Speed = nextLane.Speed > 0 ? nextLane.Speed : currentLane.Speed;
|
||||
currentLane = nextLane;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unresolved next lane -> despawn
|
||||
_simulatedVehicles.TryRemove(v.EntityId, out _);
|
||||
await _driver.DestroyTrafficVehicleAsync(session, v.EntityId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Dead end -> despawn
|
||||
_simulatedVehicles.TryRemove(v.EntityId, out _);
|
||||
await _driver.DestroyTrafficVehicleAsync(session, v.EntityId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate new spline position & velocity
|
||||
var (nx, ny, nz, nyaw, vx, vz) = currentLane.Evaluate(v.DistanceOnLane, v.Speed * SpeedScale);
|
||||
v.X = nx;
|
||||
v.Y = ny;
|
||||
v.Z = nz;
|
||||
v.Yaw = nyaw;
|
||||
|
||||
// Send smooth SyncVehicleMove to client
|
||||
await _driver.SendVehicleMoveAsync(session, v.EntityId, nx, ny, nz, nyaw, vx, vz);
|
||||
}
|
||||
|
||||
// 2. Spawn new vehicles if below target density
|
||||
var activeCount = _simulatedVehicles.Count;
|
||||
if (activeCount < TargetDensity)
|
||||
{
|
||||
await TrySpawnTrafficVehicleAroundPlayerAsync(session, px, py, pz, pyaw);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TrySpawnTrafficVehicleAroundPlayerAsync(
|
||||
TcpSession session, float px, float py, float pz, float pyaw)
|
||||
{
|
||||
if (_driver is null || _roadNetwork is null)
|
||||
return false;
|
||||
|
||||
// Query spatial cells around player
|
||||
var grid = _roadNetwork.SpatialGrid;
|
||||
var gridSize = _roadNetwork.Metadata.GridSize;
|
||||
var centerCx = (int)MathF.Floor(px / gridSize);
|
||||
var centerCz = (int)MathF.Floor(pz / gridSize);
|
||||
|
||||
var candidateLanes = new List<RoadLaneData>();
|
||||
var minSq = MinSpawnDistance * MinSpawnDistance;
|
||||
var maxSq = MaxSpawnDistance * MaxSpawnDistance;
|
||||
|
||||
// Search 3x3 surrounding cells (~300m range)
|
||||
for (var cx = centerCx - 2; cx <= centerCx + 2; cx++)
|
||||
{
|
||||
for (var cz = centerCz - 2; cz <= centerCz + 2; cz++)
|
||||
{
|
||||
var ckey = $"{cx}_{cz}";
|
||||
if (grid.TryGetValue(ckey, out var laneIds))
|
||||
{
|
||||
foreach (var lId in laneIds)
|
||||
{
|
||||
if (_laneLookup.TryGetValue(lId, out var lane) && lane.TotalLength > 10f)
|
||||
{
|
||||
var laneStart = lane.Pts[0];
|
||||
var ldx = laneStart[0] - px;
|
||||
var ldz = laneStart[2] - pz;
|
||||
var dSq = ldx * ldx + ldz * ldz;
|
||||
if (dSq >= minSq && dSq <= maxSq)
|
||||
{
|
||||
candidateLanes.Add(lane);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateLanes.Count == 0)
|
||||
return false;
|
||||
|
||||
// Pick a random lane from candidates
|
||||
var chosenLane = candidateLanes[_rng.Next(candidateLanes.Count)];
|
||||
|
||||
// Check if there is already a vehicle near the start of this lane
|
||||
foreach (var existing in _simulatedVehicles.Values)
|
||||
{
|
||||
if (existing.CurrentLaneId == chosenLane.Id && existing.DistanceOnLane < 20f)
|
||||
return false; // Lane entry occupied
|
||||
}
|
||||
|
||||
// Evaluate spawn coordinates at start of lane
|
||||
var (sx, sy, sz, syaw, _, _) = chosenLane.Evaluate(0f, 0f);
|
||||
|
||||
// Pick vehicle config
|
||||
var configId = TrafficVehiclePool[_rng.Next(TrafficVehiclePool.Length)];
|
||||
|
||||
var (ok, entityId) = await _driver.SpawnTrafficVehicleAsync(session, configId, sx, sy, sz, syaw);
|
||||
if (!ok || entityId == 0)
|
||||
return false;
|
||||
|
||||
var simVehicle = new SimulatedVehicle
|
||||
{
|
||||
EntityId = entityId,
|
||||
ConfigId = configId,
|
||||
CurrentLaneId = chosenLane.Id,
|
||||
DistanceOnLane = 0f,
|
||||
Speed = chosenLane.Speed > 0f ? chosenLane.Speed : 11.11f,
|
||||
X = sx,
|
||||
Y = sy,
|
||||
Z = sz,
|
||||
Yaw = syaw,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
HijackedByPlayer = false
|
||||
};
|
||||
|
||||
_simulatedVehicles[entityId] = simVehicle;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int> SpawnWaveAheadAsync(TcpSession session, int count = 5)
|
||||
{
|
||||
if (_driver is null || _roadNetwork is null)
|
||||
return 0;
|
||||
|
||||
var (hasPlayer, px, py, pz, pyaw, _) = _driver.GetPlayerPosition(session);
|
||||
if (!hasPlayer)
|
||||
return 0;
|
||||
|
||||
var spawned = 0;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (await TrySpawnTrafficVehicleAroundPlayerAsync(session, px, py, pz, pyaw))
|
||||
spawned++;
|
||||
}
|
||||
return spawned;
|
||||
}
|
||||
|
||||
public async Task ClearAllTrafficAsync(TcpSession session)
|
||||
{
|
||||
if (_driver is null)
|
||||
return;
|
||||
|
||||
var ids = _simulatedVehicles.Keys.ToList();
|
||||
_simulatedVehicles.Clear();
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
await _driver.DestroyTrafficVehicleAsync(session, id);
|
||||
}
|
||||
|
||||
Console.WriteLine($"[TRAFFIC] Cleared {ids.Count} traffic vehicles.");
|
||||
}
|
||||
|
||||
public TrafficStatus GetStatus() => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
TargetDensity = TargetDensity,
|
||||
SpeedScale = SpeedScale,
|
||||
ActiveVehicles = _simulatedVehicles.Count,
|
||||
TotalRoadLanes = _roadNetwork?.Lanes.Count ?? 0,
|
||||
TotalSpatialCells = _roadNetwork?.SpatialGrid.Count ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class TrafficStatus
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public int TargetDensity { get; set; }
|
||||
public float SpeedScale { get; set; }
|
||||
public int ActiveVehicles { get; set; }
|
||||
public int TotalRoadLanes { get; set; }
|
||||
public int TotalSpatialCells { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Ananta.SDK.Network;
|
||||
|
||||
namespace Ananta.Server.Gameplay.Traffic;
|
||||
|
||||
public interface ITrafficVehicleDriver
|
||||
{
|
||||
Task<(bool Ok, ulong EntityId)> SpawnTrafficVehicleAsync(
|
||||
TcpSession session, uint configId, float x, float y, float z, float yaw);
|
||||
|
||||
Task DestroyTrafficVehicleAsync(
|
||||
TcpSession session, ulong entityId);
|
||||
|
||||
Task SendVehicleMoveAsync(
|
||||
TcpSession session, ulong entityId, float x, float y, float z, float yaw, float vx, float vz);
|
||||
|
||||
bool IsVehiclePlayerControlled(ulong entityId);
|
||||
|
||||
(bool HasPlayer, float X, float Y, float Z, float Yaw, uint RaidId) GetPlayerPosition(TcpSession session);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Ananta.SDK.Network;
|
||||
using Ananta.Server.RpcTypes.Client4229938;
|
||||
|
||||
namespace Ananta.Server.Gameplay.Traffic;
|
||||
|
||||
public sealed class RoadNetworkFile
|
||||
{
|
||||
[JsonPropertyName("metadata")]
|
||||
public RoadMetadata Metadata { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("spatialGrid")]
|
||||
public Dictionary<string, List<int>> SpatialGrid { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("lanes")]
|
||||
public List<RoadLaneData> Lanes { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class RoadMetadata
|
||||
{
|
||||
[JsonPropertyName("totalLanes")]
|
||||
public int TotalLanes { get; set; }
|
||||
|
||||
[JsonPropertyName("gridSize")]
|
||||
public float GridSize { get; set; } = 100f;
|
||||
|
||||
[JsonPropertyName("totalGridCells")]
|
||||
public int TotalGridCells { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RoadLaneData
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("region")]
|
||||
public string Region { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public float Width { get; set; }
|
||||
|
||||
[JsonPropertyName("speed")]
|
||||
public float Speed { get; set; }
|
||||
|
||||
[JsonPropertyName("turn")]
|
||||
public string Turn { get; set; } = "Straight";
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("pts")]
|
||||
public List<List<float>> Pts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("tangents")]
|
||||
public List<List<float>> Tangents { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("next")]
|
||||
public List<int> Next { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("adj")]
|
||||
public List<int> Adj { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("bbox")]
|
||||
public List<float> Bbox { get; set; } = [];
|
||||
|
||||
// Pre-calculated runtime fields
|
||||
[JsonIgnore]
|
||||
public float TotalLength { get; private set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public float[] SegmentLengths { get; private set; } = [];
|
||||
|
||||
[JsonIgnore]
|
||||
public float[] CumulativeDistances { get; private set; } = [];
|
||||
|
||||
public void Precompute()
|
||||
{
|
||||
if (Pts.Count < 2)
|
||||
{
|
||||
TotalLength = 0f;
|
||||
SegmentLengths = [];
|
||||
CumulativeDistances = [0f];
|
||||
return;
|
||||
}
|
||||
|
||||
SegmentLengths = new float[Pts.Count - 1];
|
||||
CumulativeDistances = new float[Pts.Count];
|
||||
CumulativeDistances[0] = 0f;
|
||||
float total = 0f;
|
||||
|
||||
for (var i = 0; i < Pts.Count - 1; i++)
|
||||
{
|
||||
var p1 = Pts[i];
|
||||
var p2 = Pts[i + 1];
|
||||
var dx = p2[0] - p1[0];
|
||||
var dy = p2[1] - p1[1];
|
||||
var dz = p2[2] - p1[2];
|
||||
var segLen = MathF.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
SegmentLengths[i] = segLen;
|
||||
total += segLen;
|
||||
CumulativeDistances[i + 1] = total;
|
||||
}
|
||||
|
||||
TotalLength = total;
|
||||
}
|
||||
|
||||
public (float X, float Y, float Z, float Yaw, float Vx, float Vz) Evaluate(float distance, float speed)
|
||||
{
|
||||
if (Pts.Count == 0)
|
||||
return (0, 0, 0, 0, 0, 0);
|
||||
|
||||
if (Pts.Count == 1 || distance <= 0f)
|
||||
{
|
||||
var p = Pts[0];
|
||||
var yaw = 0f;
|
||||
if (Tangents.Count > 0 && Tangents[0].Count >= 3)
|
||||
yaw = MathF.Atan2(Tangents[0][0], Tangents[0][2]) * (180f / MathF.PI);
|
||||
var vx = MathF.Sin(yaw * (MathF.PI / 180f)) * speed;
|
||||
var vz = MathF.Cos(yaw * (MathF.PI / 180f)) * speed;
|
||||
return (p[0], p[1], p[2], yaw, vx, vz);
|
||||
}
|
||||
|
||||
if (distance >= TotalLength)
|
||||
{
|
||||
var p = Pts[^1];
|
||||
var yaw = 0f;
|
||||
if (Tangents.Count > 0 && Tangents[^1].Count >= 3)
|
||||
yaw = MathF.Atan2(Tangents[^1][0], Tangents[^1][2]) * (180f / MathF.PI);
|
||||
var vx = MathF.Sin(yaw * (MathF.PI / 180f)) * speed;
|
||||
var vz = MathF.Cos(yaw * (MathF.PI / 180f)) * speed;
|
||||
return (p[0], p[1], p[2], yaw, vx, vz);
|
||||
}
|
||||
|
||||
// Binary search or linear search for segment
|
||||
var segIdx = 0;
|
||||
for (var i = 0; i < SegmentLengths.Length; i++)
|
||||
{
|
||||
if (distance <= CumulativeDistances[i + 1])
|
||||
{
|
||||
segIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var pA = Pts[segIdx];
|
||||
var pB = Pts[segIdx + 1];
|
||||
var segStart = CumulativeDistances[segIdx];
|
||||
var segLen = SegmentLengths[segIdx];
|
||||
var t = segLen > 0.001f ? (distance - segStart) / segLen : 0f;
|
||||
t = Math.Clamp(t, 0f, 1f);
|
||||
|
||||
var x = pA[0] + (pB[0] - pA[0]) * t;
|
||||
var y = pA[1] + (pB[1] - pA[1]) * t;
|
||||
var z = pA[2] + (pB[2] - pA[2]) * t;
|
||||
|
||||
// Yaw from segment direction
|
||||
var dx = pB[0] - pA[0];
|
||||
var dz = pB[2] - pA[2];
|
||||
var yawDeg = MathF.Atan2(dx, dz) * (180f / MathF.PI);
|
||||
|
||||
var rad = yawDeg * (MathF.PI / 180f);
|
||||
var vX = MathF.Sin(rad) * speed;
|
||||
var vZ = MathF.Cos(rad) * speed;
|
||||
|
||||
return (x, y, z, yawDeg, vX, vZ);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user