Add garage car variants and yard parking; record Workshop 0.0.142
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-11-06 11:32:33 +01:00
parent 9caed3f12d
commit 55c0ec775a
27 changed files with 6158 additions and 35 deletions
@@ -202,10 +202,10 @@ class ERR_AdminService
if (key == "telemetryEnabled") return "Default on. Sends mission results and player identities to the configured statistics service. Disabling clears unsent events immediately.";
if (key == "restartOnEmpty") return "On (1) restarts after the last disconnect; Off (0) preserves the round for reconnects. Default On. Applies immediately; normal round endings still apply.";
if (key == "emptyRestartHours") return "0 disables idle restarts; 1..168 real hours continuously empty. Default 5. Applies immediately, measured from mission start or the last disconnect. Connected players, including spectators, prevent idle restarts.";
if (key == "garageChancePercent") return "Garage selection chance (0-100%). Default 70%; 100 selects every suitable garage. Next round.";
if (key == "garageChancePercent") return "Parking-site selection chance (0-100%). Default 70%; applies to suitable garages and residential yards. Next round.";
if (key == "garageRadiusMetres") return "Spawn radius (250-1500 metres). Distance around players; default 1000 m. Next round.";
if (key == "garageSpawnIntervalSeconds") return "Time between spawns (5-60 seconds). Default 5 s. Increase to spread out spawning. Next round.";
if (key == "garageVehicleCount") return "0..24 unclaimed garage vehicles near players; default 16. Used vehicles are kept; distant unused vehicles retire. Optional bikes. Applies next round.";
if (key == "garageVehicleCount") return "0..24 unclaimed cars in garages and residential yards; default 16. Used vehicles stay. Optional Rally cars and bikes. Applies next round.";
if (key == "civilianTrafficCount") return "Set 0..6 civilian cars (default 3). Zero disables new traffic. Unclaimed cars retire only far from all players and out of sight; player-entered cars are preserved.";
if (key == "showAllSites") return "0 disabled (default), 1 enabled. Show prepared sites at their positions as question marks; recovered intel identifies their types. Applies immediately to everyone.";
if (key == "selfEpinephrine") return "0 disabled (default), 1 enabled. Unconscious players can press Space to use a carried epinephrine injector. Applies immediately; consumes the item and keeps its normal medical effect.";
@@ -1,4 +1,4 @@
// Nearby garages are discovered incrementally. Each callback performs one
// Nearby garages and residential yards are discovered incrementally. Each callback performs one
// survey, maintenance, planning, resource-load or native-spawn step.
class ERR_GarageVehicleRules
{
@@ -14,6 +14,26 @@ class ERR_GarageVehicleRules
&& low[1] >= -0.3 && high[1] <= 1.9
&& high[0] > low[0] && high[1] > low[1] && high[2] > low[2];
}
static bool Yard(string path)
{
path.ToLower();
return path.EndsWith("/house_village_e_1i01.et") || path.EndsWith("/house_village_e_1i01_beige.et")
|| path.EndsWith("/house_village_e_1i01_v2.et") || path.EndsWith("/house_village_e_1i01_beige_v2.et")
|| path.EndsWith("/house_village_e_1i02.et") || path.EndsWith("/house_village_e_1i02_white.et")
|| path.EndsWith("/house_village_e_1i03.et") || path.EndsWith("/house_village_e_1i03_yellow.et")
|| path.EndsWith("/house_village_e_1i06.et") || path.EndsWith("/house_village_e_1i06_brown.et")
|| path.EndsWith("/house_village_e_1i06_white.et");
}
static vector YardOffset(vector low, vector high, int side)
{
vector centre = (low + high) * 0.5; centre[1] = 0;
// Three metres from the wall leaves almost two metres beside the car.
if (side == 0) centre[0] = low[0] - 3;
else if (side == 1) centre[0] = high[0] + 3;
else if (side == 2) centre[2] = low[2] - 3;
else centre[2] = high[2] + 3;
return centre;
}
}
class ERR_GarageSite
{
@@ -21,6 +41,8 @@ class ERR_GarageSite
bool Consumed;
bool Selected;
float NextTry;
bool Yard;
int Side;
}
class ERR_ParkedVehicle
{
@@ -36,6 +58,7 @@ class ERR_GarageVehicles
protected ref array<ref ERR_ParkedVehicle> m_Parked = {};
protected ref map<string, bool> m_KnownCells = new map<string, bool>();
protected ref ERR_MotorbikeCatalog m_Bikes = new ERR_MotorbikeCatalog();
protected ref ERR_ParkedCarCatalog m_Cars = new ERR_ParkedCarCatalog();
protected ref array<ResourceName> m_LoadedNames = {};
protected ref array<ref Resource> m_LoadedResources = {};
protected ref ERR_GarageSite m_Pending;
@@ -61,6 +84,7 @@ class ERR_GarageVehicles
m_CleanupRadius = Math.Max(1400, m_SpawnRadius + 400);
if (m_Requested <= 0 || m_GarageChance <= 0) { Stop(); return; }
m_Bikes.Find();
m_Cars.Find();
GetGame().GetCallqueue().CallLater(Step, 250, true);
}
protected bool Observers(array<vector> positions, array<IEntity> actors)
@@ -124,9 +148,13 @@ class ERR_GarageVehicles
protected bool Remember(IEntity entity)
{
if (m_Sites.Count() >= 2048) return false;
if (!entity.GetPrefabData() || !ERR_GarageVehicleRules.Garage(entity.GetPrefabData().GetPrefabName())) return true;
if (!entity.GetPrefabData()) return true;
string prefab = entity.GetPrefabData().GetPrefabName();
bool yard = ERR_GarageVehicleRules.Yard(prefab);
if (!yard && !ERR_GarageVehicleRules.Garage(prefab)) return true;
foreach (ERR_GarageSite site : m_Sites) if (site.Garage == entity) return true;
ERR_GarageSite added = new ERR_GarageSite(); added.Garage = entity;
added.Yard = yard; added.Side = Math.RandomInt(0, 4);
added.Selected = Math.RandomInt(0, 100) < m_GarageChance;
m_Sites.Insert(added);
return true;
@@ -202,6 +230,22 @@ class ERR_GarageVehicles
foreach (ERR_ParkedVehicle parked : m_Parked) if (parked.Site == site) return true;
return false;
}
protected bool YardGround(vector position, vector transform[4])
{
SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld());
if (!world || !world.GetRoadNetworkManager()) return false;
BaseRoad road; float distance;
world.GetRoadNetworkManager().GetClosestRoad(position, road, distance, skipNavlinks: true);
if (road && (!ERR_ForceRules.Finite(distance) || distance < road.GetWidth() * 0.5 + 4)) return false;
float ground = SCR_TerrainHelper.GetTerrainY(position);
for (int x = -1; x <= 1; x++)
for (int z = -1; z <= 1; z++)
{
vector sample = position + transform[0] * (x * 1.1) + transform[2] * (z * 2.5);
if (!ERR_Actors.DryGround(sample) || Math.AbsFloat(SCR_TerrainHelper.GetTerrainY(sample) - ground) > 0.18) return false;
}
return true;
}
protected void Plan(array<vector> observers, array<IEntity> actors, float now)
{
if (observers.IsEmpty() || now < m_NextSpawn || m_Parked.Count() >= m_Requested || m_Parked.Count() + m_Claimed >= 128 || m_Sites.IsEmpty()) return;
@@ -211,20 +255,38 @@ class ERR_GarageVehicles
if (m_SiteCursor >= m_Sites.Count()) m_SiteCursor = 0;
ERR_GarageSite site = m_Sites[m_SiteCursor++];
if (!site.Selected || site.Consumed || !site.Garage || now < site.NextTry || Occupied(site)) continue;
vector position = site.Garage.CoordToParent("0 0 -0.5");
vector offset = "0 0 -0.5";
vector angles = site.Garage.GetYawPitchRoll();
if (site.Yard)
{
vector low, high; site.Garage.GetBounds(low, high);
if (!ERR_ForceRules.FiniteVector(low) || !ERR_ForceRules.FiniteVector(high) || (high - low).Length() > 40) continue;
int side = site.Side; site.Side = (site.Side + 1) % 4;
offset = ERR_GarageVehicleRules.YardOffset(low, high, side);
angles[1] = 0; angles[2] = 0;
if (side >= 2) angles[0] = angles[0] + 90;
}
vector position = site.Garage.CoordToParent(offset);
if (!Nearby(position, observers, m_SpawnRadius)) continue;
site.NextTry = now + 15000;
if (!ERR_CivilianTrafficRules.Far(position, observers, 35) || !ERR_Actors.Alive(site.Garage)) continue;
vector transform[4]; site.Garage.GetWorldTransform(transform);
TraceParam floor = new TraceParam(); floor.Start = position + "0 1 0"; floor.End = position - "0 1 0";
floor.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
float fraction = GetGame().GetWorld().TraceMove(floor, null);
if (!ERR_ForceRules.Finite(fraction) || fraction >= 1 || floor.TraceNorm[1] < 0.98) return;
position = vector.Lerp(floor.Start, floor.End, fraction) + "0 0.08 0";
vector angles = site.Garage.GetYawPitchRoll();
vector transform[4]; Math3D.AnglesToMatrix(angles, transform);
if (site.Yard)
{
position = ERR_Actors.Ground(position) + "0 0.08 0";
if (!YardGround(position, transform)) return;
}
else
{
TraceParam floor = new TraceParam(); floor.Start = position + "0 1 0"; floor.End = position - "0 1 0";
floor.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
float fraction = GetGame().GetWorld().TraceMove(floor, null);
if (!ERR_ForceRules.Finite(fraction) || fraction >= 1 || floor.TraceNorm[1] < 0.98) return;
position = vector.Lerp(floor.Start, floor.End, fraction) + "0 0.08 0";
}
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position) || !Clear(position, transform) || !Hidden(position, angles, actors)) return;
m_Pending = site; m_Position = position; m_Angles = angles;
m_Prefab = ERR_CivilianTraffic.CarPrefab(Math.RandomInt(0, 2));
m_Prefab = m_Cars.Prefabs[Math.RandomInt(0, m_Cars.Prefabs.Count())];
m_PendingBike = m_Spawned % 4 == 3 && m_BikeCandidate < m_Bikes.Prefabs.Count();
if (m_PendingBike) m_Prefab = m_Bikes.Prefabs[m_BikeCandidate];
m_Stage = 1; return;
@@ -246,6 +308,7 @@ class ERR_GarageVehicles
if (!site.Garage || site.Consumed || !ERR_Actors.Alive(site.Garage) || !Nearby(m_Position, observers, m_SpawnRadius)
|| !ERR_CivilianTrafficRules.Far(m_Position, observers, 35) || !Hidden(m_Position, m_Angles, actors)) return;
vector transform[4]; Math3D.AnglesToMatrix(m_Angles, transform);
if (site.Yard && !YardGround(m_Position, transform)) return;
if (!Clear(m_Position, transform)) return;
m_NextSpawn = Now() + m_SpawnInterval;
IEntity entity = ERR_Actors.Spawn(m_Prefab, m_Position, m_Angles);
@@ -0,0 +1,39 @@
// Discover only complete civilian cars. Rally is optional and must already be
// mounted; do not add a DLC dependency or bypass its normal access checks.
class ERR_ParkedCarCatalog
{
ref array<ResourceName> Prefabs = {};
protected bool m_Checked;
static bool Candidate(string path, bool rally)
{
path.ToLower();
if (!path.EndsWith(".et") || path.Contains("_base") || path.Contains("_fullkit")
|| path.Contains("_randomized") || path.Contains("_fia")) return false;
if (!path.Contains("/s105/s105_") && !path.Contains("/t601/t601_") && !path.Contains("/v2101/v2101_")) return false;
if (rally) return path.Contains("_rally_0");
return !path.Contains("_rally");
}
void Find()
{
if (m_Checked) return;
m_Checked = true;
Prefabs.Insert(ERR_CivilianTraffic.CarPrefab(0));
Prefabs.Insert(ERR_CivilianTraffic.CarPrefab(1));
SearchResourcesFilter filter = new SearchResourcesFilter();
filter.fileExtensions = {"et"}; filter.recursive = true;
filter.rootPath = "$" + GameProject.GetAddonID("58D0FB3206B6F859") + ":Prefabs/Vehicles/Wheeled";
ResourceDatabase.SearchResources(filter, AddRoad);
array<string> loaded = {}; GameProject.GetLoadedAddons(loaded);
if (!loaded.Contains("6566A2D70498DA6B")) return;
filter.rootPath = "$" + GameProject.GetAddonID("6566A2D70498DA6B") + ":Prefabs/Vehicles/Wheeled";
ResourceDatabase.SearchResources(filter, AddRally);
}
protected void AddRoad(ResourceName resource, string path = "")
{
if (Candidate(resource, false) && Prefabs.Count() < 64 && !Prefabs.Contains(resource)) Prefabs.Insert(resource);
}
protected void AddRally(ResourceName resource, string path = "")
{
if (Candidate(resource, true) && Prefabs.Count() < 64 && !Prefabs.Contains(resource)) Prefabs.Insert(resource);
}
}