YALootBoxes/scripts/4_World/YALootBoxes_Manager.c
smthspec d28cebd5d6 Restructure: self-starting bootstrap + auto-provision all config files
Structure / module placement:
- Add scripts/5_Mission/YALootBoxes_MissionServer.c: a modded MissionServer
  that self-starts the manager in OnInit(). No init.c edit required anymore.
- config.cpp: bind 5_Mission to missionScriptModule (4_World stays on
  worldScriptModule). Core logic correctly stays in 4_World; only the
  mission-lifecycle bootstrap lives in 5_Mission.

Config create/read/write:
- LoadAll now writes populated defaults for ALL four JSON files when missing
  (previously only main_config.json was generated; box_types/loot_lists/
  spawn_locations were left empty, so a fresh install spawned nothing).
- Add default-data factories (YALB_MkReq/MkFail/MkLoot/MkLoc/MkBoxType) and
  DefaultBoxTypes/DefaultLootLists/DefaultSpawnLocations builders mirroring
  configfiles.readme. The mod is now self-provisioning on first run.

Docs:
- Prefix.cpp: refreshed folder-layout summary; dropped stale init.c /
  simulationModule notes.
- Manager.c: bootstrap comment now points to the 5_Mission self-start.
- README.md: add Install & first-run section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 23:44:43 +03:00

239 lines
No EOL
8.4 KiB
C

// ============================================================
// YALootBoxes_Manager.c
// Owns the lifecycle of all lootboxes: initial spawn, periodic
// despawn/respawn checks, and proximity "requires X" messages.
// Lives in: scripts/4_World/YALootBoxes_Manager.c
//
// Instantiated once from mission init (see init.c snippet at
// bottom of this file).
// ============================================================
class YALB_ActiveBoxEntry
{
YALootBoxes_Base box;
string locationName; // which spawn_locations.json entry it's using
};
class YALootBoxesManager
{
protected ref array<ref YALB_ActiveBoxEntry> m_ActiveBoxes;
protected ref array<string> m_FreeLocationNames; // locations currently unused (the live pool)
protected ref map<int, float> m_PlayerMessageCooldown; // playerId -> last message world time
void YALootBoxesManager()
{
m_ActiveBoxes = new array<ref YALB_ActiveBoxEntry>();
m_FreeLocationNames = new array<string>();
m_PlayerMessageCooldown = new map<int, float>();
}
void Init()
{
YALB_ConfigLoader.LoadAll();
if (!YALB_ConfigLoader.SpawnLocations || YALB_ConfigLoader.SpawnLocations.SpawnLocations.Count() == 0)
{
Log_Error("No spawn locations loaded. Manager will not spawn any lootboxes.");
return;
}
foreach (YALB_SpawnLocationDef loc : YALB_ConfigLoader.SpawnLocations.SpawnLocations)
{
m_FreeLocationNames.Insert(loc.name);
}
SpawnInitialBatch();
// Periodic lifecycle tick (despawn/respawn checks)
float intervalMs = YALB_ConfigLoader.MainConfig.GlobalCheckIntervalSeconds * 1000;
GetGame().GetCallQueue(CALL_CATEGORY_SYSTEM).CallLater(TickLifecycle, intervalMs, true);
// Proximity message tick - runs more frequently than the
// lifecycle check since it needs to feel responsive to players
GetGame().GetCallQueue(CALL_CATEGORY_SYSTEM).CallLater(TickProximityMessages, 1500, true);
Log_Info("Manager initialized. " + m_ActiveBoxes.Count() + " lootboxes spawned.");
}
protected void SpawnInitialBatch()
{
int target = YALB_ConfigLoader.MainConfig.MaxActiveLootboxes;
for (int i = 0; i < target; i++)
{
if (!SpawnOneAtRandomFreeLocation())
break; // ran out of free locations
}
}
// Picks a free location, picks the box type config matching that
// location's boxType, spawns the entity, marks location occupied.
protected bool SpawnOneAtRandomFreeLocation()
{
if (m_FreeLocationNames.Count() == 0) return false;
int idx = Math.RandomInt(0, m_FreeLocationNames.Count());
string locName = m_FreeLocationNames[idx];
YALB_SpawnLocationDef locDef = FindLocationDef(locName);
if (!locDef)
{
m_FreeLocationNames.Remove(idx);
return SpawnOneAtRandomFreeLocation(); // try again with a different one
}
YALB_BoxTypeDef typeDef = YALB_ConfigLoader.GetBoxTypeByType(locDef.boxType);
if (!typeDef)
{
Log_Warn("Spawn location '" + locName + "' references unknown boxType '" + locDef.boxType + "'. Skipping.");
m_FreeLocationNames.Remove(idx);
return SpawnOneAtRandomFreeLocation();
}
Object obj = GetGame().CreateObject(typeDef.className, locDef.GetPosition(), false, true, true);
YALootBoxes_Base box = YALootBoxes_Base.Cast(obj);
if (!box)
{
Log_Error("Failed to spawn class '" + typeDef.className + "' at '" + locName + "'.");
m_FreeLocationNames.Remove(idx);
return SpawnOneAtRandomFreeLocation();
}
box.SetPosition(locDef.GetPosition());
box.Init(locDef.boxType);
YALB_ActiveBoxEntry entry = new YALB_ActiveBoxEntry();
entry.box = box;
entry.locationName = locName;
m_ActiveBoxes.Insert(entry);
m_FreeLocationNames.Remove(idx);
Log_Info(string.Format("Spawned '%1' at location '%2' (%3).", typeDef.className, locName, locDef.GetPosition().ToString()));
return true;
}
protected YALB_SpawnLocationDef FindLocationDef(string name)
{
foreach (YALB_SpawnLocationDef d : YALB_ConfigLoader.SpawnLocations.SpawnLocations)
{
if (d.name == name) return d;
}
return null;
}
// Called every GlobalCheckIntervalSeconds. Removes expired boxes
// (whether still locked or already opened) and frees their location,
// then immediately tries to fill the gap at a NEW random location.
void TickLifecycle()
{
float now = GetGame().GetTime() / 1000.0;
array<ref YALB_ActiveBoxEntry> toRemove = new array<ref YALB_ActiveBoxEntry>();
foreach (YALB_ActiveBoxEntry entry : m_ActiveBoxes)
{
if (!entry.box)
{
toRemove.Insert(entry);
continue;
}
bool expired = entry.box.HasExpiredUnopened(now) || entry.box.HasExpiredAfterOpening(now);
if (expired)
{
toRemove.Insert(entry);
}
}
foreach (YALB_ActiveBoxEntry deadEntry : toRemove)
{
RemoveAndFreeLocation(deadEntry);
}
// Backfill up to MaxActiveLootboxes using newly-freed (and any
// previously unused) locations.
int deficit = YALB_ConfigLoader.MainConfig.MaxActiveLootboxes - m_ActiveBoxes.Count();
for (int i = 0; i < deficit; i++)
{
if (!SpawnOneAtRandomFreeLocation()) break;
}
Log_Debug(string.Format("Lifecycle tick: removed %1, active now %2.", toRemove.Count(), m_ActiveBoxes.Count()));
}
protected void RemoveAndFreeLocation(YALB_ActiveBoxEntry entry)
{
m_ActiveBoxes.RemoveItem(entry);
m_FreeLocationNames.Insert(entry.locationName);
if (entry.box)
{
GetGame().ObjectDelete(entry.box);
}
}
// Broadcasts "Requires: X, Y" to any player standing within range
// of a LOCKED box, regardless of what they're holding (per design:
// always show required items, even empty-handed).
void TickProximityMessages()
{
if (!YALB_ConfigLoader.MainConfig) return;
float range = YALB_ConfigLoader.MainConfig.ProximityMessageRangeMeters;
float now = GetGame().GetTime() / 1000.0;
array<Man> players = new array<Man>();
GetGame().GetWorld().GetPlayerList(players);
foreach (Man m : players)
{
PlayerBase player = PlayerBase.Cast(m);
if (!player || !player.GetIdentity()) continue;
YALootBoxes_Base nearest = FindNearestLockedBox(player.GetPosition(), range);
if (!nearest) continue;
// Light cooldown per player so we're not spamming every 1.5s
int playerId = player.GetIdentity().GetPlainId().HashCode();
float lastShown = 0;
m_PlayerMessageCooldown.Find(playerId, lastShown);
if (now - lastShown < 4.0) continue;
YALB_NotifyPlayer(player, nearest.GetRequiredItemsMessage());
m_PlayerMessageCooldown.Set(playerId, now);
Log_Verbose("Proximity message sent to '" + player.GetIdentity().GetName() + "' near box type '" + nearest.GetBoxTypeId() + "'.");
}
}
protected YALootBoxes_Base FindNearestLockedBox(vector pos, float maxRange)
{
YALootBoxes_Base closest = null;
float closestDist = maxRange;
foreach (YALB_ActiveBoxEntry entry : m_ActiveBoxes)
{
if (!entry.box || !entry.box.IsLocked()) continue;
float dist = vector.Distance(entry.box.GetPosition(), pos);
if (dist <= closestDist)
{
closest = entry.box;
closestDist = dist;
}
}
return closest;
}
};
// ============================================================
// BOOTSTRAP
// This manager is started automatically by the modded MissionServer
// in scripts/5_Mission/YALootBoxes_MissionServer.c (OnInit). No
// init.c edit is required.
//
// If you instead run a mission that does NOT go through MissionServer
// and want to start it manually, the equivalent init.c hook is:
//
// if (GetGame().IsServer())
// {
// g_YALootBoxesManager = new YALootBoxesManager();
// g_YALootBoxesManager.Init();
// }
// ============================================================