Action (the real "no action" cause): - DayZ does not auto-discover custom actions; ActionConstructor holds a hardcoded list and AddAction() silently drops anything missing from it. Added a modded ActionConstructor that registers YALB_ActionOpenLootbox, so the open action finally attaches to the box. - Switched the action target condition CCTObject -> CCTCursor (the look-at pattern vanilla uses for world-object interactions). Restart stacking: - Spawn boxes with CreateObjectEx + ECE_NOPERSISTENCY_WORLD so they are never saved to the world DB and don't survive restarts. - Added CleanupOrphanedBoxes(): on init, scan each spawn location and delete any leftover YALootBoxes_Base before spawning (clears boxes persisted before this change). Logging: - DebugLogLevel changed from a string to a numeric field (0=off 1=ERROR 2=WARN 3=INFO 4=DEBUG 5=VERBOSE), clamped at load. Removed the string parser. NOTE: existing main_config.json has a string value and must be regenerated. Config docs: - Each generated JSON now includes a "_comment" array describing every field and its range (ignored by the loader). README + configfiles.readme updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
278 lines
No EOL
10 KiB
C
278 lines
No EOL
10 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<string, float> m_PlayerMessageCooldown; // player UID -> last message world time
|
|
|
|
void YALootBoxesManager()
|
|
{
|
|
m_ActiveBoxes = new array<ref YALB_ActiveBoxEntry>();
|
|
m_FreeLocationNames = new array<string>();
|
|
m_PlayerMessageCooldown = new map<string, 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);
|
|
}
|
|
|
|
// Remove any lootboxes left over from a previous session (e.g. ones
|
|
// that persisted) BEFORE spawning, so they don't stack on restart.
|
|
CleanupOrphanedBoxes();
|
|
|
|
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.");
|
|
}
|
|
|
|
// Scans every configured spawn location and deletes any YALootBoxes_Base
|
|
// found there. Catches boxes that persisted from a previous session so a
|
|
// restart doesn't stack a fresh set on top of the old one. (New boxes are
|
|
// also spawned non-persistent below, so this is mostly a one-time cleanup
|
|
// for boxes created before that change.)
|
|
protected void CleanupOrphanedBoxes()
|
|
{
|
|
int removed = 0;
|
|
array<Object> nearby = new array<Object>();
|
|
array<CargoBase> cargos = new array<CargoBase>();
|
|
|
|
foreach (YALB_SpawnLocationDef loc : YALB_ConfigLoader.SpawnLocations.SpawnLocations)
|
|
{
|
|
nearby.Clear();
|
|
cargos.Clear();
|
|
GetGame().GetObjectsAtPosition3D(loc.GetPosition(), 5.0, nearby, cargos);
|
|
foreach (Object o : nearby)
|
|
{
|
|
YALootBoxes_Base box = YALootBoxes_Base.Cast(o);
|
|
if (box)
|
|
{
|
|
GetGame().ObjectDelete(box);
|
|
removed++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (removed > 0)
|
|
Log_Info("Removed " + removed + " leftover lootbox(es) from a previous session before spawning.");
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// ECE_NOPERSISTENCY_WORLD: never saved to the world DB, so boxes don't
|
|
// survive a restart and stack. ECE_SETUP gives full entity init (cargo
|
|
// etc.); ECE_CREATEPHYSICS gives collision/selection geometry.
|
|
Object obj = GetGame().CreateObjectEx(typeDef.className, locDef.GetPosition(),
|
|
ECE_SETUP | ECE_CREATEPHYSICS | ECE_NOPERSISTENCY_WORLD);
|
|
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
|
|
string playerId = player.GetIdentity().GetPlainId();
|
|
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();
|
|
// }
|
|
// ============================================================
|