From ce2295bd74779ce735066f020c3066ccc0324857 Mon Sep 17 00:00:00 2001 From: Jan Nielek Date: Fri, 20 Mar 2026 09:50:08 +0100 Subject: [PATCH 1/5] feat: AI commander phase 1 --- .../Scripts/Game/DiD/AFM_DiDAttackerBudget.c | 72 +++++ .../Scripts/Game/DiD/AFM_DiDZoneComponent.c | 251 +++++++++++------- .../Scripts/Game/DiD/AFM_DiDZoneSystem.c | 208 ++++++++------- .../Scripts/Game/DiD/AFM_GameModeDiD.c | 159 ++++++----- .../AFM_DiDMechanizedSpawnerComponent.c | 90 ++++--- .../DiD/Spawners/AFM_DiDSpawnerComponent.c | 178 +++++++------ .../DiD_Linear_Lamentin_Layers/Zone_2.layer | 3 + 7 files changed, 562 insertions(+), 399 deletions(-) create mode 100644 addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerBudget.c diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerBudget.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerBudget.c new file mode 100644 index 0000000..684870c --- /dev/null +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerBudget.c @@ -0,0 +1,72 @@ +//------------------------------------------------------------------------------------------------ +//! Tracks the attacker's points budget for a zone. +//! Budget is spent when AI is spawned. When exhausted and all AI cleared, +//! the zone transitions to FINISHED_REPELLED (defender victory). +//! +//! Usage: +//! Before spawn: if (!budget.CanAfford(cost)) return; +//! After spawn: budget.Consume(actualAgentCount * costPerUnit); +//------------------------------------------------------------------------------------------------ +class AFM_DiDAttackerBudget +{ + protected int m_iTotal; + protected int m_iRemaining; + + //------------------------------------------------------------------------------------------------ + void AFM_DiDAttackerBudget(int total) + { + m_iTotal = total; + m_iRemaining = total; + } + + //------------------------------------------------------------------------------------------------ + //! Returns true if at least 'cost' points are available + bool CanAfford(int cost) + { + return m_iRemaining >= cost; + } + + //------------------------------------------------------------------------------------------------ + //! Deduct points. Clamps to zero — never goes negative. + void Consume(int cost) + { + m_iRemaining = Math.Max(0, m_iRemaining - cost); + PrintFormat("AFM_DiDAttackerBudget: Consumed %1 pts — %2/%3 remaining", + cost, m_iRemaining, m_iTotal, level: LogLevel.DEBUG); + } + + //------------------------------------------------------------------------------------------------ + //! Add bonus points (e.g. rollover from a failed previous zone) + void AddBonus(int bonus) + { + m_iRemaining += bonus; + m_iTotal += bonus; + PrintFormat("AFM_DiDAttackerBudget: +%1 bonus pts — %2/%3 remaining", + bonus, m_iRemaining, m_iTotal); + } + + //------------------------------------------------------------------------------------------------ + bool IsExhausted() + { + return m_iRemaining <= 0; + } + + //------------------------------------------------------------------------------------------------ + int GetRemaining() + { + return m_iRemaining; + } + + int GetTotal() + { + return m_iTotal; + } + + //! 0.0 = exhausted, 1.0 = full budget + float GetRatio() + { + if (m_iTotal == 0) + return 0; + return m_iRemaining / (float)m_iTotal; + } +} diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c index f4db67e..df990c8 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c @@ -5,9 +5,10 @@ enum EAFMZoneState PREPARE, // Zone is in preparation phase (warmup) ACTIVE, // Zone is active and being defended (or wave is active) FROZEN, // Zone timer is frozen (too many attackers) - FINISHED_HELD, // Zone successfully defended by defenders (or all waves completed) - FINISHED_FAILED, // Zone lost - all defenders eliminated - + FINISHED_HELD, // Zone successfully defended — timer expired + FINISHED_FAILED, // Zone lost — all defenders eliminated + FINISHED_REPELLED, // Zone successfully defended — attacker budget exhausted and all AI cleared + // Wave zone specific states WAVE_COMPLETE // Wave cleared, preparing for next wave } @@ -22,62 +23,71 @@ class AFM_DiDZoneComponent: ScriptComponent { [Attribute("DidZone", UIWidgets.Auto, desc: "Zone name", category: "DiD")] protected string m_sZoneName; - + [Attribute("1", UIWidgets.Auto, desc: "Stop the timer when redfor presence is higher than blufor?", category: "DiD")] protected bool m_bStopTimerOnRedforSuperiority; - + [Attribute("1", UIWidgets.Auto, desc: "Stop the AI spawners when redfor presence is higher than blufor?", category: "DiD")] protected bool m_bStopSpawnersOnRedforSuperiority; - + [Attribute("1", UIWidgets.EditBox, "Zone index (1 to N), 1 is played first, N is the last zone", category: "DiD")] protected int m_iZoneIndex; - + [Attribute("300", UIWidgets.EditBox, "Time in seconds to prepare", category: "DiD")] protected int m_iPrepareTimeSeconds; - + [Attribute("600", UIWidgets.EditBox, "Time in seconds to defend zone", category: "DiD")] protected int m_iDefenseTimeSeconds; - + [Attribute("50", UIWidgets.EditBox, "Max number of AI groups", category: "DiD")] protected int m_iMaxAICount; - + + [Attribute("0", UIWidgets.EditBox, "Attacker points budget (0 = unlimited, no budget system)", category: "DiD Budget")] + protected int m_iPointsBudget; + + [Attribute("0.5", UIWidgets.EditBox, "Fraction of remaining budget rolled over to next zone on failure (0.0-1.0)", category: "DiD Budget")] + protected float m_fBudgetRolloverFraction; + protected PolylineShapeEntity m_PolylineEntity; protected AFM_PlayerSpawnPointEntity m_PlayerSpawnPoint; protected ref array m_aSpawners = {}; protected SCR_ResourceComponent m_SupplyCache; - + // Cached 2D polyline points for zone boundary checks (world-space X/Z pairs) protected ref array m_aZonePolylinePoints2D = null; - + // Zone state management protected EAFMZoneState m_eZoneState = EAFMZoneState.INACTIVE; protected WorldTimestamp m_fZoneStartTime; protected WorldTimestamp m_fZoneEndTime; protected int m_iRemainingTimeSeconds; - + + // Budget — null if m_iPointsBudget == 0 (system disabled) + protected ref AFM_DiDAttackerBudget m_Budget; + // Faction configuration protected SCR_Faction m_RedforFaction; protected SCR_Faction m_BluforFaction; - - + + override void OnPostInit(IEntity owner) { super.OnPostInit(owner); - + if (SCR_Global.IsEditMode()) return; - + //Only initialize when zone system is available (on authority) if (AFM_DiDZoneSystem.GetInstance()) GetGame().GetCallqueue().CallLater(LateInit, 5000); } - + protected void LateInit() { IEntity e = GetOwner().GetChildren(); if (!e) PrintFormat("AFM_DiDZoneComponent %1: No children found!", m_sZoneName, level: LogLevel.ERROR); - + while (e) { switch (e.Type()) @@ -103,26 +113,26 @@ class AFM_DiDZoneComponent: ScriptComponent } e = e.GetSibling(); } - + if (!m_PolylineEntity) - PrintFormat("AFM_DiDZoneComponent %1: Missing polyline component, zone wont work properly!", m_sZoneName, level:LogLevel.ERROR); + PrintFormat("AFM_DiDZoneComponent %1: Missing polyline component, zone wont work properly!", m_sZoneName, level: LogLevel.ERROR); if (!m_PlayerSpawnPoint) - PrintFormat("AFM_DiDZoneComponent %1: Missing player spawnpoint, zone wont work properly!", m_sZoneName, level:LogLevel.ERROR); + PrintFormat("AFM_DiDZoneComponent %1: Missing player spawnpoint, zone wont work properly!", m_sZoneName, level: LogLevel.ERROR); if (m_aSpawners.Count() == 0) - PrintFormat("AFM_DiDZoneComponent %1: No spawner components found, AI will not spawn!", m_sZoneName, level:LogLevel.WARNING); - + PrintFormat("AFM_DiDZoneComponent %1: No spawner components found, AI will not spawn!", m_sZoneName, level: LogLevel.WARNING); + // Initialize spawners foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) { spawner.Prepare(this); } - + if (!AFM_DiDZoneSystem.GetInstance().RegisterZone(this)) PrintFormat("AFM_DiDZoneComponent %1: Failed to register zone!", m_sZoneName, LogLevel.ERROR); else PrintFormat("AFM_DiDZoneComponent %1: Zone registered", m_sZoneName); - - + + AFM_GameModeDiD gamemode = AFM_GameModeDiD.Cast(GetGame().GetGameMode()); if (!gamemode) { @@ -132,23 +142,23 @@ class AFM_DiDZoneComponent: ScriptComponent m_RedforFaction = gamemode.GetRedforFaction(); m_BluforFaction = gamemode.GetBluforFaction(); } - + //------------------------------------------------------------------------------------------------ int GetDefenderCount() { if (!m_BluforFaction) return -1; - + array playerIds = new array; m_BluforFaction.GetPlayersInFaction(playerIds); int remainingPlayers = 0; - - foreach(int id: playerIds) + + foreach (int id : playerIds) { PlayerController pc = GetGame().GetPlayerManager().GetPlayerController(id); if (pc) { - SCR_ChimeraCharacter ent = SCR_ChimeraCharacter.Cast(pc.GetControlledEntity()); + SCR_ChimeraCharacter ent = SCR_ChimeraCharacter.Cast(pc.GetControlledEntity()); if (!ent) continue; SCR_DamageManagerComponent damageManager = ent.GetDamageManager(); @@ -156,18 +166,18 @@ class AFM_DiDZoneComponent: ScriptComponent remainingPlayers++; } } - + return remainingPlayers; } - + int GetAICountInsideZone() { WorldTimestamp timeStart = GetCurrentTimestamp(); - + if (!m_PolylineEntity) return -1; - - // Build 2D polygon cache once - polyline shape does not move at runtime + + // Build 2D polygon cache once — polyline shape does not move at runtime if (!m_aZonePolylinePoints2D) { m_aZonePolylinePoints2D = new array(); @@ -180,14 +190,14 @@ class AFM_DiDZoneComponent: ScriptComponent m_aZonePolylinePoints2D.Insert(p[2] + zonePos[2]); } } - + array agents = {}; GetGame().GetAIWorld().GetAIAgents(agents); - + int count = 0; int totalAgentCount = 0; - - foreach(AIAgent agent: agents) + + foreach (AIAgent agent : agents) { if (!agent.IsInherited(SCR_ChimeraAIAgent) || !agent.IsInherited(ChimeraAIAgent)) continue; @@ -195,22 +205,23 @@ class AFM_DiDZoneComponent: ScriptComponent IEntity agentEntity = agent.GetControlledEntity(); if (!agentEntity) continue; - + SCR_ChimeraCharacter character = SCR_ChimeraCharacter.Cast(agentEntity); if (!character || character.GetFactionKey() != m_RedforFaction.GetFactionKey()) continue; - + vector pos = character.GetOrigin(); if (Math2D.IsPointInPolygon(m_aZonePolylinePoints2D, pos[0], pos[2])) count++; totalAgentCount++; } - + WorldTimestamp end = GetCurrentTimestamp(); - PrintFormat("AFM_DiDZoneComponent %1: Found %2/%3 AIs inside zone. Took %4ms", m_sZoneName, count, totalAgentCount, end.DiffMilliseconds(timeStart).ToString(), level: LogLevel.DEBUG); + PrintFormat("AFM_DiDZoneComponent %1: Found %2/%3 AIs inside zone. Took %4ms", + m_sZoneName, count, totalAgentCount, end.DiffMilliseconds(timeStart).ToString(), level: LogLevel.DEBUG); return count; } - + //------------------------------------------------------------------------------------------------ //! Cleanup all spawned AI through spawner components //------------------------------------------------------------------------------------------------ @@ -222,41 +233,47 @@ class AFM_DiDZoneComponent: ScriptComponent spawner.Cleanup(); } } - + protected void FinishZoneHeld() { m_eZoneState = EAFMZoneState.FINISHED_HELD; - PrintFormat("AFM_DiDZoneComponent %1: Zone FINISHED_HELD - Defenders won!", m_sZoneName); + PrintFormat("AFM_DiDZoneComponent %1: FINISHED_HELD - Timer expired, defenders held!", m_sZoneName); } - + protected void FinishZoneFailed() { m_eZoneState = EAFMZoneState.FINISHED_FAILED; - PrintFormat("AFM_DiDZoneComponent %1: Zone FINISHED_FAILED - Defenders eliminated!", m_sZoneName); + PrintFormat("AFM_DiDZoneComponent %1: FINISHED_FAILED - Defenders eliminated!", m_sZoneName); + } + + protected void FinishZoneRepelled() + { + m_eZoneState = EAFMZoneState.FINISHED_REPELLED; + PrintFormat("AFM_DiDZoneComponent %1: FINISHED_REPELLED - Attacker budget exhausted, assault repelled!", m_sZoneName); } - + protected void FreezeZone() { if (m_eZoneState != EAFMZoneState.ACTIVE) return; - + m_iRemainingTimeSeconds = m_fZoneEndTime.DiffSeconds(GetCurrentTimestamp()); m_eZoneState = EAFMZoneState.FROZEN; - + PrintFormat("AFM_DiDZoneComponent %1: Zone FROZEN with %2 seconds remaining", - m_sZoneName, m_iRemainingTimeSeconds); + m_sZoneName, m_iRemainingTimeSeconds); } - + protected void UnfreezeZone() { if (m_eZoneState != EAFMZoneState.FROZEN) return; - + m_fZoneEndTime = GetCurrentTimestamp().PlusSeconds(m_iRemainingTimeSeconds); m_eZoneState = EAFMZoneState.ACTIVE; - + PrintFormat("AFM_DiDZoneComponent %1: Zone UNFROZEN, resuming with %2 seconds", - m_sZoneName, m_iRemainingTimeSeconds); + m_sZoneName, m_iRemainingTimeSeconds); } protected EAFMZoneState HandlePrepareLogic() @@ -269,30 +286,37 @@ class AFM_DiDZoneComponent: ScriptComponent WorldTimestamp now = GetCurrentTimestamp(); m_fZoneStartTime = now; m_fZoneEndTime = now.PlusSeconds(m_iDefenseTimeSeconds); - PrintFormat("AFM_DiDZoneComponent %1: PREPARE -> ACTIVE", m_sZoneName); + PrintFormat("AFM_DiDZoneComponent %1: PREPARE -> ACTIVE", m_sZoneName); } - + return m_eZoneState; } - + protected EAFMZoneState HandleActiveZoneLogic() { int defenderCount = GetDefenderCount(); int attackerCount = GetAICountInsideZone(); - + if (defenderCount == 0) { FinishZoneFailed(); return m_eZoneState; } - + if (IsZoneTimeExpired()) { FinishZoneHeld(); return m_eZoneState; } - - //freeze/unfreeze zone + + // Budget exhausted and zone cleared = defenders repelled the assault + if (m_Budget && m_Budget.IsExhausted() && attackerCount == 0 && GetActiveAICount() == 0) + { + FinishZoneRepelled(); + return m_eZoneState; + } + + // Freeze/unfreeze zone timer if (m_bStopTimerOnRedforSuperiority) { if (attackerCount > defenderCount && m_eZoneState == EAFMZoneState.ACTIVE) @@ -304,21 +328,21 @@ class AFM_DiDZoneComponent: ScriptComponent UnfreezeZone(); } } - + // Delegate spawning to spawner components foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) { if (spawner) spawner.Process(); } - + return m_eZoneState; } - + //------------------------------------------------------------------------------------------------ // Main process method - called periodically by the zone system //------------------------------------------------------------------------------------------------ - + EAFMZoneState Process() { switch (m_eZoneState) @@ -326,7 +350,7 @@ class AFM_DiDZoneComponent: ScriptComponent case EAFMZoneState.INACTIVE: case EAFMZoneState.FINISHED_HELD: case EAFMZoneState.FINISHED_FAILED: - //don't process inactive zones + case EAFMZoneState.FINISHED_REPELLED: return m_eZoneState; case EAFMZoneState.PREPARE: return HandlePrepareLogic(); @@ -334,20 +358,20 @@ class AFM_DiDZoneComponent: ScriptComponent case EAFMZoneState.FROZEN: return HandleActiveZoneLogic(); default: - PrintFormat("AFM_DiDZoneComponent %1: Unknown zone state %2", m_sZoneName, m_eZoneState, level:LogLevel.ERROR); + PrintFormat("AFM_DiDZoneComponent %1: Unknown zone state %2", m_sZoneName, m_eZoneState, level: LogLevel.ERROR); return m_eZoneState; } - + //unreachable code but required for parser return m_eZoneState; } - - + + int GetZoneIndex() { return m_iZoneIndex; } - + string GetZoneName() { return m_sZoneName; @@ -357,37 +381,46 @@ class AFM_DiDZoneComponent: ScriptComponent { return m_eZoneState; } - + bool IsZoneFinished() { - return m_eZoneState == EAFMZoneState.FINISHED_HELD || m_eZoneState == EAFMZoneState.FINISHED_FAILED; + return m_eZoneState == EAFMZoneState.FINISHED_HELD + || m_eZoneState == EAFMZoneState.FINISHED_FAILED + || m_eZoneState == EAFMZoneState.FINISHED_REPELLED; } - + void ActivateZone() { + // Initialize budget if configured + if (m_iPointsBudget > 0) + { + m_Budget = new AFM_DiDAttackerBudget(m_iPointsBudget); + PrintFormat("AFM_DiDZoneComponent %1: Budget initialized with %2 pts", m_sZoneName, m_iPointsBudget); + } + WorldTimestamp now = GetCurrentTimestamp(); m_eZoneState = EAFMZoneState.PREPARE; m_fZoneStartTime = now; m_fZoneEndTime = now.PlusSeconds(m_iPrepareTimeSeconds); PrintFormat("AFM_DiDZoneComponent %1: Entering PREPARE state for %2 seconds", - m_sZoneName, m_iPrepareTimeSeconds); + m_sZoneName, m_iPrepareTimeSeconds); } - + void DeactivateZone() { m_eZoneState = EAFMZoneState.INACTIVE; Cleanup(); PrintFormat("AFM_DiDZoneComponent %1: Deactivated", m_sZoneName); } - + void ForceEndPrepareStage() { if (m_eZoneState != EAFMZoneState.PREPARE) return; - + m_fZoneEndTime = GetCurrentTimestamp(); } - + WorldTimestamp GetZoneEndTime() { if (m_eZoneState == EAFMZoneState.FROZEN) @@ -395,45 +428,48 @@ class AFM_DiDZoneComponent: ScriptComponent // Return calculated end time based on remaining seconds return GetCurrentTimestamp().PlusSeconds(m_iRemainingTimeSeconds); } - + return m_fZoneEndTime; } - + AFM_PlayerSpawnPointEntity GetPlayerSpawnPoint() { return m_PlayerSpawnPoint; } - + PolylineShapeEntity GetPolylineEntity() { return m_PolylineEntity; } - + SCR_Faction GetDefenderFaction() { return m_BluforFaction; } - + SCR_Faction GetAttackerFaction() { return m_RedforFaction; } - + int GetBluforScore() { return GetDefenderCount(); } - + + //! Returns remaining budget if active, otherwise AI count inside zone int GetRedforScore() { + if (m_Budget) + return m_Budget.GetRemaining(); return GetAICountInsideZone(); } - + int GetZoneDisplayNumber() { return GetZoneIndex(); } - + //------------------------------------------------------------------------------------------------ //! Get total active AI count across all spawners //------------------------------------------------------------------------------------------------ @@ -447,21 +483,44 @@ class AFM_DiDZoneComponent: ScriptComponent } return totalCount; } - + + //------------------------------------------------------------------------------------------------ + // Budget API + //------------------------------------------------------------------------------------------------ + + //! Returns the budget instance, or null if budget system is disabled for this zone + AFM_DiDAttackerBudget GetBudget() + { + return m_Budget; + } + + int GetRemainingBudget() + { + if (!m_Budget) + return 0; + return m_Budget.GetRemaining(); + } + + float GetBudgetRolloverFraction() + { + return m_fBudgetRolloverFraction; + } + + //------------------------------------------------------------------------------------------------ protected bool IsZoneTimeExpired() { if (m_eZoneState != EAFMZoneState.ACTIVE) return false; - + return GetCurrentTimestamp().GreaterEqual(m_fZoneEndTime); } - + protected WorldTimestamp GetCurrentTimestamp() { ChimeraWorld world = GetGame().GetWorld(); return world.GetServerTimestamp(); } - + protected int GetZoneAILimit() { return m_iMaxAICount; diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneSystem.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneSystem.c index 632f539..723b341 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneSystem.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneSystem.c @@ -2,35 +2,35 @@ class AFM_DiDZoneSystem: GameSystem { protected ref map m_aZones = new map(); protected AFM_DiDZoneComponent m_ActiveZone = null; - - + // How often should the system check zones (in seconds) protected const float m_fCheckInterval = 1.0; protected float m_fCheckTimer = 0; - + // Callbacks protected ref ScriptInvoker m_OnZoneChanged; protected ref ScriptInvoker m_OnZoneUpdate; protected ref ScriptInvoker m_OnAllZonesCompleted; protected ref ScriptInvoker m_OnZoneHeld; + protected ref ScriptInvoker m_OnZoneRepelled; // Invoked when attacker budget exhausted + zone cleared protected ref ScriptInvoker m_OnZoneFailed; // Invoked with (int zoneIndex) when defenders are eliminated protected ref ScriptInvoker m_OnWaveCompleted; // Invoked with (int wave, int totalWaves) when a wave is cleared - + // Game mode reference protected AFM_GameModeDiD m_GameMode; protected SCR_FactionManager m_FactionManager; protected bool m_bIsSystemActive = false; protected bool m_bSkipWarmup = false; - + protected const int m_iStartingZoneIndex = 1; - + //------------------------------------------------------------------------------------------------ void AFM_DiDZoneSystem() { m_FactionManager = SCR_FactionManager.Cast(GetGame().GetFactionManager()); m_GameMode = AFM_GameModeDiD.Cast(GetGame().GetGameMode()); } - + //------------------------------------------------------------------------------------------------ override static void InitInfo(WorldSystemInfo outInfo) { @@ -40,15 +40,15 @@ class AFM_DiDZoneSystem: GameSystem .SetLocation(ESystemLocation.Server) .AddPoint(ESystemPoint.FixedFrame); } - + //------------------------------------------------------------------------------------------------ static AFM_DiDZoneSystem GetInstance() { World world = GetGame().GetWorld(); - + if (!world) return null; - + return AFM_DiDZoneSystem.Cast(world.FindSystem(AFM_DiDZoneSystem)); } @@ -57,107 +57,109 @@ class AFM_DiDZoneSystem: GameSystem { if (!m_bIsSystemActive) return; - + m_fCheckTimer += args.GetTimeSliceSeconds(); if (m_fCheckTimer < m_fCheckInterval) return; m_fCheckTimer = 0; - // Process the current zone ProcessZone(); } - + //------------------------------------------------------------------------------------------------ override event bool ShouldBePaused() { return true; } - + //------------------------------------------------------------------------------------------------ bool RegisterZone(AFM_DiDZoneComponent zone) { PrintFormat("Registering zone %1 as %2 stage", zone.GetZoneName(), zone.GetZoneIndex()); - - int zoneIndex = zone.GetZoneIndex(); - + + int zoneIndex = zone.GetZoneIndex(); + if (m_aZones.Contains(zoneIndex)) { PrintFormat("Zone %1 is already present at index %2", zone.GetZoneName(), zoneIndex, level: LogLevel.ERROR); return false; } - + m_aZones.Insert(zoneIndex, zone); - - //TODO: Add late init here - + return true; } - + //------------------------------------------------------------------------------------------------ void StartZoneSystem() { m_bIsSystemActive = true; Enable(true); - + PrintFormat("AFM_DiDZoneSystem: Started zone system with %1 zones", m_aZones.Count()); - - // Activate first zone in prepare phase + if (m_aZones.Contains(m_iStartingZoneIndex)) { m_ActiveZone = m_aZones[m_iStartingZoneIndex]; m_ActiveZone.ActivateZone(); - + if (m_OnZoneChanged) m_OnZoneChanged.Invoke(m_iStartingZoneIndex); - } + } else { - PrintFormat("AFM_DiDZoneSystem: Zone index %1 is invalid! Zone count: %2", - m_iStartingZoneIndex, m_aZones.Count(), level:LogLevel.ERROR + PrintFormat("AFM_DiDZoneSystem: Zone index %1 is invalid! Zone count: %2", + m_iStartingZoneIndex, m_aZones.Count(), level: LogLevel.ERROR ); StopZoneSystem(); } } - + //------------------------------------------------------------------------------------------------ // Main zone processing method //------------------------------------------------------------------------------------------------ - + protected void ProcessZone() { WorldTimestamp tStart = GetCurrentTimestamp(); if (!m_ActiveZone) - { - PrintFormat("AFM_DiDZoneSystem: Invalid active zone!", level:LogLevel.ERROR); + { + PrintFormat("AFM_DiDZoneSystem: Invalid active zone!", level: LogLevel.ERROR); return; } - - // Don't process zones that are already finished + if (m_ActiveZone.IsZoneFinished()) return; - + EAFMZoneState previousState = m_ActiveZone.GetZoneState(); EAFMZoneState currentState = m_ActiveZone.Process(); int zoneIndex = m_ActiveZone.GetZoneIndex(); - - // Handle state transitions + if (previousState != currentState) - { OnZoneStateChanged(zoneIndex, previousState, currentState); - } - - // Handle zone completion states (both regular zones and wave zones use FINISHED_HELD) + + // Defenders held — timer expired if (currentState == EAFMZoneState.FINISHED_HELD) { - PrintFormat("AFM_DiDZoneSystem: Zone %1 completed - defenders held!", zoneIndex); + PrintFormat("AFM_DiDZoneSystem: Zone %1 — defenders held! (timer expired)", zoneIndex); if (m_OnZoneHeld) m_OnZoneHeld.Invoke(); StopZoneSystem(); return; } - - // Handle zone failure (all defenders eliminated) - fire event with old index before progressing + + // Defenders repelled the assault — budget exhausted + zone cleared + if (currentState == EAFMZoneState.FINISHED_REPELLED) + { + PrintFormat("AFM_DiDZoneSystem: Zone %1 — assault repelled! (budget exhausted)", zoneIndex); + if (m_OnZoneRepelled) + m_OnZoneRepelled.Invoke(); + StopZoneSystem(); + return; + } + + // Zone failed — all defenders eliminated; fire event before advancing if (currentState == EAFMZoneState.FINISHED_FAILED) { PrintFormat("AFM_DiDZoneSystem: All defenders eliminated in zone %1", zoneIndex); @@ -166,37 +168,34 @@ class AFM_DiDZoneSystem: GameSystem ProgressToNextZone(); return; } - + if (m_OnZoneUpdate) m_OnZoneUpdate.Invoke(); - + WorldTimestamp tEnd = GetCurrentTimestamp(); float diff = tEnd.DiffMilliseconds(tStart); if (diff > 5) PrintFormat("AFM_DiDZoneSystem: ProcessZone took %1 ms", diff, level: LogLevel.WARNING); } - + //------------------------------------------------------------------------------------------------ protected void OnZoneStateChanged(int zoneIndex, EAFMZoneState oldState, EAFMZoneState newState) { PrintFormat("AFM_DiDZoneSystem: Zone %1 state changed from %2 to %3", zoneIndex, oldState, newState); - - // Notify game mode when transitioning from PREPARE to ACTIVE + if (oldState == EAFMZoneState.PREPARE && newState == EAFMZoneState.ACTIVE) { if (m_OnZoneChanged) m_OnZoneChanged.Invoke(); } - - // Notify game mode when timer state changes (ACTIVE <-> FROZEN) + if ((oldState == EAFMZoneState.ACTIVE && newState == EAFMZoneState.FROZEN) || (oldState == EAFMZoneState.FROZEN && newState == EAFMZoneState.ACTIVE)) { if (m_OnZoneUpdate) m_OnZoneUpdate.Invoke(); } - - // Notify when wave completes (for wave zones) + if (newState == EAFMZoneState.WAVE_COMPLETE) { if (m_OnZoneUpdate) @@ -205,7 +204,7 @@ class AFM_DiDZoneSystem: GameSystem m_OnZoneChanged.Invoke(); } } - + //------------------------------------------------------------------------------------------------ //! Called by AFM_DiDWaveZoneComponent when a wave is cleared void NotifyWaveCompleted(int currentWave, int totalWaves) @@ -213,43 +212,55 @@ class AFM_DiDZoneSystem: GameSystem if (m_OnWaveCompleted) m_OnWaveCompleted.Invoke(currentWave, totalWaves); } - + //------------------------------------------------------------------------------------------------ protected void ProgressToNextZone() { int newZoneIndex = m_iStartingZoneIndex; + int rolloverBudget = 0; + if (m_ActiveZone) { + // Capture rollover before deactivating + int remaining = m_ActiveZone.GetRemainingBudget(); + if (remaining > 0) + rolloverBudget = Math.Floor(remaining * m_ActiveZone.GetBudgetRolloverFraction()); + newZoneIndex = m_ActiveZone.GetZoneIndex() + 1; m_ActiveZone.DeactivateZone(); } - + m_ActiveZone = m_aZones[newZoneIndex]; - + Print("AFM_DiDZoneSystem: Progressing to zone " + newZoneIndex); m_bSkipWarmup = false; - - // Check if all zones completed + if (newZoneIndex > m_aZones.Count()) { PrintFormat("AFM_DiDZoneSystem: All zones completed (max: %1)", m_aZones.Count()); StopZoneSystem(); - + if (m_OnAllZonesCompleted) m_OnAllZonesCompleted.Invoke(); return; } - - // Activate next zone in prepare phase + if (m_ActiveZone) { m_ActiveZone.ActivateZone(); - + + // Apply rollover after ActivateZone initialises the new budget + if (rolloverBudget > 0 && m_ActiveZone.GetBudget()) + { + m_ActiveZone.GetBudget().AddBonus(rolloverBudget); + PrintFormat("AFM_DiDZoneSystem: Rolled over %1 pts to zone %2", rolloverBudget, newZoneIndex); + } + if (m_OnZoneChanged) m_OnZoneChanged.Invoke(); } } - + //------------------------------------------------------------------------------------------------ // Public API / Getters //------------------------------------------------------------------------------------------------ @@ -259,155 +270,148 @@ class AFM_DiDZoneSystem: GameSystem return -1; return m_ActiveZone.GetBluforScore(); } - + int GetRedforScore() { if (!m_ActiveZone) return -1; return m_ActiveZone.GetRedforScore(); } - - + int GetAICountInCurrentZone() { if (!m_ActiveZone) return -1; - return m_ActiveZone.GetAICountInsideZone(); } - + int GetDefenderCount() { if (!m_ActiveZone) return -1; - return m_ActiveZone.GetDefenderCount(); } - + AFM_PlayerSpawnPointEntity GetCurrentZonePlayerSpawnPoint() { if (!m_ActiveZone) return null; - return m_ActiveZone.GetPlayerSpawnPoint(); } - + int GetCurrentZoneIndex() { if (!m_ActiveZone) return -1; - return m_ActiveZone.GetZoneDisplayNumber(); } - + int GetMaxZoneIndex() { return m_aZones.Count(); } - + bool IsTimerRunning() { if (!m_ActiveZone) return false; - + EAFMZoneState state = m_ActiveZone.GetZoneState(); return ( state == EAFMZoneState.ACTIVE || - state == EAFMZoneState.PREPARE || - state == EAFMZoneState.WAVE_COMPLETE + state == EAFMZoneState.PREPARE || + state == EAFMZoneState.WAVE_COMPLETE ); } - + bool IsWarmup() { if (!m_ActiveZone) return false; - + return m_ActiveZone.GetZoneState() == EAFMZoneState.PREPARE; } - + WorldTimestamp GetZoneTimeoutTimestamp() { if (!m_ActiveZone) return GetCurrentTimestamp(); - return m_ActiveZone.GetZoneEndTime(); } - + void ForceEndPrepareStage() { if (!m_ActiveZone || m_ActiveZone.GetZoneState() != EAFMZoneState.PREPARE) return; - m_ActiveZone.ForceEndPrepareStage(); } - + WorldTimestamp GetCurrentTimestamp() { ChimeraWorld world = GetGame().GetWorld(); return world.GetServerTimestamp(); } - - + void StopZoneSystem() { Enable(false); m_bIsSystemActive = false; - // Deactivate all zones foreach (AFM_DiDZoneComponent zone : m_aZones) { if (zone) zone.DeactivateZone(); } } - + ScriptInvoker GetOnZoneChanged() { if (!m_OnZoneChanged) m_OnZoneChanged = new ScriptInvoker(); - return m_OnZoneChanged; } - + ScriptInvoker GetOnZoneUpdate() { if (!m_OnZoneUpdate) m_OnZoneUpdate = new ScriptInvoker(); - return m_OnZoneUpdate; } - + ScriptInvoker GetOnAllZonesCompleted() { if (!m_OnAllZonesCompleted) m_OnAllZonesCompleted = new ScriptInvoker(); - return m_OnAllZonesCompleted; } - + ScriptInvoker GetOnZoneHeld() { if (!m_OnZoneHeld) m_OnZoneHeld = new ScriptInvoker(); - return m_OnZoneHeld; } - + + //! Fired when attacker budget exhausted and zone is cleared — defender victory + ScriptInvoker GetOnZoneRepelled() + { + if (!m_OnZoneRepelled) + m_OnZoneRepelled = new ScriptInvoker(); + return m_OnZoneRepelled; + } + //! Fired with (int zoneIndex) when all defenders in a zone are eliminated ScriptInvoker GetOnZoneFailed() { if (!m_OnZoneFailed) m_OnZoneFailed = new ScriptInvoker(); - return m_OnZoneFailed; } - + //! Fired with (int currentWave, int totalWaves) when a wave zone wave is cleared ScriptInvoker GetOnWaveCompleted() { if (!m_OnWaveCompleted) m_OnWaveCompleted = new ScriptInvoker(); - return m_OnWaveCompleted; } } diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c index 2e88147..b0a9c26 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c @@ -6,60 +6,59 @@ class AFM_GameModeDiD: PS_GameModeCoop { [Attribute("US", UIWidgets.EditBox, "Defenders faction key", category: "DiD")] protected FactionKey m_sDefenderFactionKey; - + [Attribute("USSR", UIWidgets.EditBox, "Attackers faction key", category: "DiD")] - protected FactionKey m_sAttackerFactionKey; - + protected FactionKey m_sAttackerFactionKey; + protected SCR_FactionManager m_FactionManager; protected AFM_DiDZoneSystem m_ZoneSystem; protected ref ScriptInvoker m_OnMatchSituationChanged; - + protected bool m_bShowUI = false; [RplProp(onRplName: "OnMatchSituationChanged")] protected bool m_bIsGameRunning = false; - + [RplProp(onRplName: "OnMatchSituationChanged")] protected bool m_bIsWarmup = false; - + [RplProp(onRplName: "OnMatchSituationChanged")] protected bool m_bIsTimerRunning = false; - + [RplProp(onRplName: "OnMatchSituationChanged")] protected WorldTimestamp m_fTimeoutTimestamp; - + [RplProp(onRplName: "OnMatchSituationChanged")] protected int m_iDefendersRemaining = 0; - + [RplProp(onRplName: "OnMatchSituationChanged")] protected int m_iAttackersRemaining = 0; - + [RplProp(onRplName: "OnMatchSituationChanged")] protected int m_iCurrentZone = 0; - + //------------------------------------------------------------------------------------------------ ScriptInvoker GetOnMatchSituationChanged() { if (!m_OnMatchSituationChanged) m_OnMatchSituationChanged = new ScriptInvoker(); - return m_OnMatchSituationChanged; } - + void OnMatchSituationChanged() { if (m_OnMatchSituationChanged) m_OnMatchSituationChanged.Invoke(); } - + void ForceEndPrepareStage() { if (m_ZoneSystem) m_ZoneSystem.ForceEndPrepareStage(); - else //no zone system - assume we are a proxy + else Rpc(RPC_DoForceEndPrepareStage); } - + [RplRpc(RplChannel.Reliable, RplRcver.Server)] void RPC_DoForceEndPrepareStage() { @@ -67,21 +66,18 @@ class AFM_GameModeDiD: PS_GameModeCoop return; m_ZoneSystem.ForceEndPrepareStage(); } - + override void EOnInit(IEntity owner) { super.EOnInit(owner); - + if (SCR_Global.IsEditMode()) return; - + m_FactionManager = SCR_FactionManager.Cast(GetGame().GetFactionManager()); if (!m_FactionManager) - { Print("Faction manager component is missing!", LogLevel.ERROR); - } - - + m_ZoneSystem = AFM_DiDZoneSystem.GetInstance(); if (!m_ZoneSystem) { @@ -93,39 +89,37 @@ class AFM_GameModeDiD: PS_GameModeCoop m_ZoneSystem.GetOnZoneUpdate().Insert(OnZoneUpdate); m_ZoneSystem.GetOnAllZonesCompleted().Insert(OnAllZonesCompleted); m_ZoneSystem.GetOnZoneHeld().Insert(OnZoneHeld); + m_ZoneSystem.GetOnZoneRepelled().Insert(OnZoneRepelled); m_ZoneSystem.GetOnZoneFailed().Insert(OnZoneFailed); m_ZoneSystem.GetOnWaveCompleted().Insert(OnWaveCompleted); } } - - + override void OnGameStateChanged() { super.OnGameStateChanged(); - + SCR_EGameModeState state = GetState(); if (state != SCR_EGameModeState.GAME) return; - - ChimeraWorld world = GetGame().GetWorld(); + m_bIsGameRunning = true; m_bShowUI = true; - + if (m_ZoneSystem) m_ZoneSystem.StartZoneSystem(); - + OnMatchSituationChanged(); Replication.BumpMe(); } - + //------------------------------------------------------------------------------------------------ // Zone system callbacks //------------------------------------------------------------------------------------------------ - + protected void OnZoneChanged() { UpdateLocalGameState(); - // Respawn dead players when zone changes GetGame().GetCallqueue().CallLater(RespawnAllSpectators, 1000 * 5); RPC_DoProgressToNextZone(m_iCurrentZone); @@ -133,38 +127,46 @@ class AFM_GameModeDiD: PS_GameModeCoop OnMatchSituationChanged(); Replication.BumpMe(); } - + protected void OnZoneUpdate() { UpdateLocalGameState(); OnMatchSituationChanged(); Replication.BumpMe(); } - + protected void OnAllZonesCompleted() { GameEndAttackersWin(); } - + protected void OnZoneHeld() { GameEndDefendersWin(); } - - //! Called when all defenders in a zone are eliminated - fires before zone progression + + //! Attacker budget exhausted and zone cleared — defenders repelled the assault + protected void OnZoneRepelled() + { + RPC_DoZoneRepelled(); + Rpc(RPC_DoZoneRepelled); + GameEndDefendersWin(); + } + + //! All defenders eliminated — zone failed, progressing to next protected void OnZoneFailed(int zoneIndex) { RPC_DoZoneFailed(zoneIndex); Rpc(RPC_DoZoneFailed, zoneIndex); } - - //! Called when a wave zone wave is cleared + + //! Wave zone wave cleared protected void OnWaveCompleted(int currentWave, int totalWaves) { RPC_DoWaveCompleted(currentWave, totalWaves); Rpc(RPC_DoWaveCompleted, currentWave, totalWaves); } - + protected void UpdateLocalGameState() { m_iCurrentZone = m_ZoneSystem.GetCurrentZoneIndex(); @@ -175,14 +177,12 @@ class AFM_GameModeDiD: PS_GameModeCoop m_fTimeoutTimestamp = m_ZoneSystem.GetZoneTimeoutTimestamp(); } - protected void RespawnAllSpectators() { PS_PlayableManager playableManager = PS_PlayableManager.GetInstance(); array playableContainers = playableManager.GetPlayablesSorted(); AFM_PlayerSpawnPointEntity currentSpawnPoint = m_ZoneSystem.GetCurrentZonePlayerSpawnPoint(); - - + foreach (PS_PlayableContainer container : playableContainers) { PS_PlayableComponent pcomp = container.GetPlayableComponent(); @@ -197,7 +197,7 @@ class AFM_GameModeDiD: PS_GameModeCoop } } } - + protected void RespawnPlayer(int playerId, PS_PlayableComponent playableComponent, AFM_PlayerSpawnPointEntity sp) { if (playableComponent) @@ -206,10 +206,10 @@ class AFM_GameModeDiD: PS_GameModeCoop if (prefabToSpawn != "") { PS_RespawnData respawnData = new PS_RespawnData(playableComponent, prefabToSpawn); - + if (sp) respawnData.m_aSpawnTransform[3] = sp.GetOrigin(); - + Respawn(playerId, respawnData); return; } @@ -217,8 +217,24 @@ class AFM_GameModeDiD: PS_GameModeCoop SwitchToInitialEntity(playerId); } - - //! Broadcast: zone failure - shown before zone progression hint + + //------------------------------------------------------------------------------------------------ + // Broadcast RPCs + //------------------------------------------------------------------------------------------------ + + //! Defenders repelled the assault (budget exhausted + zone cleared) + [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] + protected void RPC_DoZoneRepelled() + { + SCR_HintManagerComponent.GetInstance().ShowCustom( + "Enemy assault repelled! All attackers eliminated!", + "", + 12, + false + ); + } + + //! Zone failed — all defenders eliminated [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] protected void RPC_DoZoneFailed(int zoneIndex) { @@ -229,8 +245,8 @@ class AFM_GameModeDiD: PS_GameModeCoop false ); } - - //! Broadcast: next zone is now active + + //! New zone is now active [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] protected void RPC_DoProgressToNextZone(int newZoneIndex) { @@ -241,8 +257,8 @@ class AFM_GameModeDiD: PS_GameModeCoop false ); } - - //! Broadcast: wave cleared in a wave zone + + //! Wave zone wave cleared [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] protected void RPC_DoWaveCompleted(int currentWave, int totalWaves) { @@ -251,21 +267,23 @@ class AFM_GameModeDiD: PS_GameModeCoop msg = string.Format("All %1 waves defeated! Hold position!", totalWaves); else msg = string.Format("Wave %1/%2 defeated! Prepare for the next wave!", currentWave, totalWaves); - + SCR_HintManagerComponent.GetInstance().ShowCustom(msg, "", 10, false); } - + + //------------------------------------------------------------------------------------------------ + // Game end //------------------------------------------------------------------------------------------------ - // Server side method to end game with winningFactionKey faction victory + protected void GameEnd(FactionKey winningFactionKey) { Faction faction = m_FactionManager.GetFactionByKey(winningFactionKey); int factionId = m_FactionManager.GetFactionIndex(faction); - SCR_GameModeEndData endData = SCR_GameModeEndData.CreateSimple(EGameOverTypes.ENDREASON_SCORELIMIT, winnerFactionId:factionId); + SCR_GameModeEndData endData = SCR_GameModeEndData.CreateSimple(EGameOverTypes.ENDREASON_SCORELIMIT, winnerFactionId: factionId); EndGameMode(endData); m_bIsGameRunning = false; } - + protected void GameEndDefendersWin() { Print("Defenders win!"); @@ -277,61 +295,58 @@ class AFM_GameModeDiD: PS_GameModeCoop Print("Attackers win!"); GameEnd(m_sAttackerFactionKey); } - - + //------------------------------------------------------------------------------------------------ // Public getters //------------------------------------------------------------------------------------------------ - + int GetAttackersRemaining() { return m_iAttackersRemaining; } - + int GetDefendersRemaining() { return m_iDefendersRemaining; } - + int GetCurrentZone() { return m_iCurrentZone; } - + bool IsGameRunning() { return m_bIsGameRunning; } - + bool IsTimerRunning() { return m_bIsTimerRunning; } - + bool IsWarmup() { return m_bIsWarmup; } - + bool ShowUI() { return m_bShowUI; } - + WorldTimestamp GetTimeoutTimestamp() { return m_fTimeoutTimestamp; } - + SCR_Faction GetBluforFaction() { return SCR_Faction.Cast(m_FactionManager.GetFactionByKey(m_sDefenderFactionKey)); } - + SCR_Faction GetRedforFaction() { return SCR_Faction.Cast(m_FactionManager.GetFactionByKey(m_sAttackerFactionKey)); } - - } diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c index 3226cfe..6feb5bd 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c @@ -1,6 +1,5 @@ //------------------------------------------------------------------------------------------------ //! Mechanized spawner - spawns vehicle groups with different timing and logic -//! Example of a more advanced spawner implementation //------------------------------------------------------------------------------------------------ class AFM_DiDMechanizedSpawnerComponentClass: AFM_DiDSpawnerComponentClass { @@ -11,118 +10,125 @@ class AFM_DiDMechanizedSpawnerComponent: AFM_DiDSpawnerComponent { [Attribute("1", UIWidgets.CheckBox, "Spawn vehicles only when minimum AI threshold reached", category: "DiD Mechanized Spawner")] protected bool m_bRequireMinAI; - + [Attribute("10", UIWidgets.EditBox, "Minimum AI count before spawning mechanized groups", category: "DiD Mechanized Spawner")] protected int m_iMinAIThreshold; - + [Attribute("2", UIWidgets.EditBox, "Delay multiplier for mechanized spawns (slower than infantry)", category: "DiD Mechanized Spawner")] protected float m_fDelayMultiplier; - + [Attribute("1", UIWidgets.CheckBox, "Spawn in coordinated groups", category: "DiD Mechanized Spawner")] protected bool m_bCoordinatedSpawn; - + [Attribute("", UIWidgets.Object, desc: "Defines the vehicles crew - you may drag existing configs into here.", category: "DiD Mechanized Spawner")] protected ref AFM_CrewConfig m_crewConfig; - + [Attribute("", UIWidgets.Auto, desc: "Vehicle prefabs to spawn", category: "DiD Mechanized Spawner")] protected ref array m_aVehiclePrefabs; - + protected ref array m_aSpawnedVehicles = {}; - + //------------------------------------------------------------------------------------------------ override void Prepare(AFM_DiDZoneComponent owner) { super.Prepare(owner); - - // Mechanized units spawn less frequently + m_iWaveIntervalSeconds = Math.Ceil(m_iWaveIntervalSeconds * m_fDelayMultiplier); - - PrintFormat("AFM_DiDMechanizedSpawnerComponent: Mechanized spawner initialized with %1s interval", + + PrintFormat("AFM_DiDMechanizedSpawnerComponent: Mechanized spawner initialized with %1s interval", m_iWaveIntervalSeconds, LogLevel.DEBUG); } - + //------------------------------------------------------------------------------------------------ override protected void SpawnWave() { - // Check if we should wait for minimum AI count if (m_bRequireMinAI) { int currentAI = m_Zone.GetActiveAICount(); if (currentAI < m_iMinAIThreshold) { - PrintFormat("AFM_DiDMechanizedSpawnerComponent: Waiting for minimum AI threshold (%1/%2)", + PrintFormat("AFM_DiDMechanizedSpawnerComponent: Waiting for minimum AI threshold (%1/%2)", currentAI, m_iMinAIThreshold, LogLevel.DEBUG); return; } } - + + // Early exit if budget can't cover at least one vehicle + if (!CanSpendBudget(m_iPointCostPerUnit)) + { + PrintFormat("AFM_DiDMechanizedSpawnerComponent: Budget exhausted, skipping spawn", LogLevel.DEBUG); + return; + } + int spawnCount = GetSpawnCountForWave(); - PrintFormat("AFM_DiDSpawnerComponent: Spawning wave with %1 groups", spawnCount, LogLevel.DEBUG); - + PrintFormat("AFM_DiDMechanizedSpawnerComponent: Spawning %1 vehicle(s)", spawnCount, LogLevel.DEBUG); + for (int i = 0; i < spawnCount; i++) { if (m_Zone.GetActiveAICount() >= m_iMaxAICount) break; - + SpawnSingleGroup(); } } - + //------------------------------------------------------------------------------------------------ override protected void Cleanup() { super.Cleanup(); - foreach(IEntity entity: m_aSpawnedVehicles) + foreach (IEntity entity : m_aSpawnedVehicles) { if (!entity) continue; SCR_EntityHelper.DeleteEntityAndChildren(entity); } } - - - //------------------------------------------------------------------------------------------------ - //! Mechanized groups spawn fewer units per wave but potentially with support + //------------------------------------------------------------------------------------------------ override protected int GetSpawnCountForWave() { if (!m_Zone) return 1; - + int zoneIndex = m_Zone.GetZoneIndex(); - - // Mechanized spawns scale differently - fewer vehicles but more impactful + if (m_bCoordinatedSpawn && zoneIndex >= 3) - { - // Spawn 2-3 vehicles in coordinated attack for higher zones return s_AIRandomGenerator.RandInt(2, 3); - } - - // Single vehicle spawn for lower zones + return 1; } - - + //------------------------------------------------------------------------------------------------ - //! Override spawn logic for special mechanized behavior + //! m_iPointCostPerUnit represents cost per whole vehicle group (vehicle + crew) //------------------------------------------------------------------------------------------------ override protected void SpawnSingleGroup() { if (m_aSpawnPoints.Count() == 0 || m_aAIWaypoints.Count() == 0 || m_aVehiclePrefabs.Count() == 0) return; - + if (!m_crewConfig) - return; - + return; + + // Budget check — cost is per vehicle, deducted immediately on successful spawn + if (!CanSpendBudget(m_iPointCostPerUnit)) + return; + IEntity vehicle = SpawnPrefab(m_aVehiclePrefabs.GetRandomElement(), m_aSpawnPoints.GetRandomElement()); if (!vehicle) return; + m_aSpawnedVehicles.Insert(vehicle); - - SCR_BaseCompartmentManagerComponent cm = SCR_BaseCompartmentManagerComponent.Cast(vehicle.FindComponent(SCR_BaseCompartmentManagerComponent)); + + SCR_BaseCompartmentManagerComponent cm = SCR_BaseCompartmentManagerComponent.Cast( + vehicle.FindComponent(SCR_BaseCompartmentManagerComponent) + ); if (!cm) return; - + AIGroup crew = m_crewConfig.SpawnCrew(cm, m_aAIWaypoints.GetRandomElement()); + + // Consume budget immediately — vehicle spawned, cost is committed + if (crew && m_Zone && m_Zone.GetBudget()) + m_Zone.GetBudget().Consume(m_iPointCostPerUnit); } } diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c index 4596e9e..c94fda4 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c @@ -11,47 +11,48 @@ class AFM_DiDSpawnerComponent: GenericEntity { [Attribute("", UIWidgets.Auto, desc: "AI Group prefabs to spawn", category: "DiD Spawner")] protected ref array m_aAIGroupPrefabs; - + [Attribute("90", UIWidgets.EditBox, "Time in seconds between spawning waves", category: "DiD Spawner")] protected int m_iWaveIntervalSeconds; - + [Attribute("5", UIWidgets.EditBox, "Base AI spawn count per wave", category: "DiD Spawner")] protected int m_iSpawnCountPerWave; - + [Attribute("50", UIWidgets.EditBox, "Max AI group count", category: "DiD Spawner")] protected int m_iMaxAICount; - + [Attribute("1.0", UIWidgets.EditBox, "Spawn count multiplier per zone level (e.g., zone 2 = 2x spawn count)", category: "DiD Spawner")] protected float m_fZoneLevelMultiplier; - + [Attribute("0", UIWidgets.CheckBox, "Use ticket system (limits total spawns)", category: "DiD Spawner")] protected bool m_bUseTickets; - + [Attribute("0", UIWidgets.EditBox, "Max tickets for spawning (0 = unlimited, only used if tickets enabled)", category: "DiD Spawner")] protected int m_iMaxTickets; - + + [Attribute("1", UIWidgets.EditBox, "Budget points consumed per spawned unit (used when zone has a budget)", category: "DiD Budget")] + protected int m_iPointCostPerUnit; + protected AFM_DiDZoneComponent m_Zone; protected ref array m_aSpawnPoints = {}; protected ref array m_aAIWaypoints = {}; protected ref array m_aSpawnedAIGroups = {}; protected WorldTimestamp m_fLastSpawnTime; protected int m_iRemainingTickets; - + //------------------------------------------------------------------------------------------------ // Prepare method - called by owner zone component on start //------------------------------------------------------------------------------------------------ void Prepare(AFM_DiDZoneComponent owner) { m_Zone = owner; - - // Initialize tickets if enabled + if (m_bUseTickets) { m_iRemainingTickets = m_iMaxTickets; PrintFormat("AFM_DiDSpawnerComponent: Tickets enabled with %1 tickets", m_iMaxTickets, LogLevel.DEBUG); } - - // Find spawn points and waypoints in children + IEntity child = GetChildren(); while (child) { @@ -62,7 +63,7 @@ class AFM_DiDSpawnerComponent: GenericEntity child = child.GetSibling(); continue; } - + SCR_AIWaypoint waypoint = SCR_AIWaypoint.Cast(child); if (waypoint) { @@ -70,20 +71,20 @@ class AFM_DiDSpawnerComponent: GenericEntity child = child.GetSibling(); continue; } - + child = child.GetSibling(); } - + if (m_aSpawnPoints.Count() == 0) PrintFormat("AFM_DiDSpawnerComponent: No spawn points found in spawner!", LogLevel.WARNING); - + if (m_aAIWaypoints.Count() == 0) PrintFormat("AFM_DiDSpawnerComponent: No waypoints found in spawner!", LogLevel.WARNING); - + ChimeraWorld world = GetGame().GetWorld(); m_fLastSpawnTime = world.GetServerTimestamp().PlusSeconds(-m_iWaveIntervalSeconds); } - + //------------------------------------------------------------------------------------------------ // Main process method - called periodically by the owner zone component //------------------------------------------------------------------------------------------------ @@ -91,19 +92,17 @@ class AFM_DiDSpawnerComponent: GenericEntity { if (!m_Zone) return; - - // Only spawn during active or frozen states + EAFMZoneState state = m_Zone.GetZoneState(); if (state != EAFMZoneState.ACTIVE && state != EAFMZoneState.FROZEN) return; - - // If using tickets, check if there are tickets available + if (m_bUseTickets && m_iRemainingTickets <= 0) return; - + ChimeraWorld world = GetGame().GetWorld(); WorldTimestamp now = world.GetServerTimestamp(); - + int timeSinceLastSpawn = Math.AbsInt(now.DiffSeconds(m_fLastSpawnTime)); if (timeSinceLastSpawn >= m_iWaveIntervalSeconds) { @@ -111,56 +110,49 @@ class AFM_DiDSpawnerComponent: GenericEntity SpawnWave(); } } - - //------------------------------------------------------------------------------------------------ - // Cleanup method - called by owner zone component on end + //------------------------------------------------------------------------------------------------ void Cleanup() { RemoveSpawnedAI(); } - + int GetWaveInterval() { return m_iWaveIntervalSeconds; } - + void SetWaveInterval(int interval) { m_iWaveIntervalSeconds = interval; } - + int GetSpawnCount() { return m_iSpawnCountPerWave; } - + void SetSpawnCount(int count) { m_iSpawnCountPerWave = count; } - + WorldTimestamp GetNextSpawnTime() { return m_fLastSpawnTime.PlusSeconds(m_iWaveIntervalSeconds); } - - //------------------------------------------------------------------------------------------------ - //! Calculate how many AI groups to spawn this wave - //! Override this for custom spawn count logic + //------------------------------------------------------------------------------------------------ protected int GetSpawnCountForWave() { if (!m_Zone) return m_iSpawnCountPerWave; - + int zoneIndex = m_Zone.GetZoneIndex(); float multiplier = 1.0 + ((zoneIndex - 1) * m_fZoneLevelMultiplier); return Math.Ceil(m_iSpawnCountPerWave * multiplier); } - - //------------------------------------------------------------------------------------------------ - //! Get the current count of active AI groups + //------------------------------------------------------------------------------------------------ int GetActiveAICount() { @@ -174,57 +166,74 @@ class AFM_DiDSpawnerComponent: GenericEntity } return count; } - + + //------------------------------------------------------------------------------------------------ + //! Returns true if the zone budget allows spending at least minCost points. + //! Always returns true when no budget is configured. //------------------------------------------------------------------------------------------------ - //! Main wave spawning logic - //! Override this for custom spawning behavior + protected bool CanSpendBudget(int minCost) + { + if (!m_Zone) + return true; + AFM_DiDAttackerBudget budget = m_Zone.GetBudget(); + if (!budget) + return true; // no budget system on this zone + return budget.CanAfford(minCost); + } + //------------------------------------------------------------------------------------------------ protected void SpawnWave() { if (m_aSpawnPoints.Count() == 0 || m_aAIWaypoints.Count() == 0) return; - + if (m_aAIGroupPrefabs.Count() == 0) { PrintFormat("AFM_DiDSpawnerComponent: No AI group prefabs configured!", LogLevel.WARNING); return; } - + if (m_Zone.GetActiveAICount() >= m_iMaxAICount) { PrintFormat("AFM_DiDSpawnerComponent: Max AI count reached (%1/%2)", GetActiveAICount(), m_iMaxAICount, LogLevel.DEBUG); return; } - + + // Early exit if budget can't afford even one unit + if (!CanSpendBudget(m_iPointCostPerUnit)) + { + PrintFormat("AFM_DiDSpawnerComponent: Budget exhausted, skipping spawn", LogLevel.DEBUG); + return; + } + int spawnCount = GetSpawnCountForWave(); PrintFormat("AFM_DiDSpawnerComponent: Spawning wave with %1 groups", spawnCount, LogLevel.DEBUG); - + for (int i = 0; i < spawnCount; i++) { if (m_Zone.GetActiveAICount() >= m_iMaxAICount) break; - + SpawnSingleGroup(); } } - - //------------------------------------------------------------------------------------------------ - //! Spawn a single AI group - //! Override this for custom group spawning logic + //------------------------------------------------------------------------------------------------ protected void SpawnSingleGroup() { - // Try to consume ticket if using ticket system if (m_bUseTickets && GetRemainingTickets() <= 0) { PrintFormat("AFM_DiDSpawnerComponent: No tickets remaining, cannot spawn", LogLevel.DEBUG); return; } - + + if (!CanSpendBudget(m_iPointCostPerUnit)) + return; + ResourceName groupPrefab = m_aAIGroupPrefabs.GetRandomElement(); AFM_SpawnPointEntity spawnPoint = m_aSpawnPoints.GetRandomElement(); SCR_AIWaypoint waypoint = m_aAIWaypoints.GetRandomElement(); - + AIGroup group = SpawnAI(groupPrefab, spawnPoint, waypoint); if (group) { @@ -236,9 +245,7 @@ class AFM_DiDSpawnerComponent: GenericEntity PrintFormat("AFM_DiDSpawnerComponent: Failed to spawn AI group %1", groupPrefab, LogLevel.ERROR); } } - - //------------------------------------------------------------------------------------------------ - //! Remove all spawned AI groups + //------------------------------------------------------------------------------------------------ protected void RemoveSpawnedAI() { @@ -246,10 +253,10 @@ class AFM_DiDSpawnerComponent: GenericEntity { if (!group) continue; - + array agents = {}; group.GetAgents(agents); - + foreach (AIAgent agent : agents) { if (!agent) @@ -262,48 +269,48 @@ class AFM_DiDSpawnerComponent: GenericEntity } m_aSpawnedAIGroups.Clear(); } - - //------------------------------------------------------------------------------------------------ - //! Get array of AI group prefabs (for external configuration) - //------------------------------------------------------------------------------------------------ + array GetAIGroupPrefabs() { return m_aAIGroupPrefabs; } - + protected AIGroup SpawnAI(ResourceName groupPrefab, IEntity spawnPoint, SCR_AIWaypoint waypoint) - { + { IEntity entity = SpawnPrefab(groupPrefab, spawnPoint); AIGroup aigroup = AIGroup.Cast(entity); if (!aigroup) return null; - + aigroup.AddWaypoint(waypoint); GetGame().GetCallqueue().CallLater(DisableAIUnconsciousness, 500, false, aigroup); return aigroup; } - + //------------------------------------------------------------------------------------------------ protected void DisableAIUnconsciousness(AIGroup group) { array agents = {}; group.GetAgents(agents); - - foreach(AIAgent agent: agents) + + foreach (AIAgent agent : agents) { IEntity agentEntity = agent.GetControlledEntity(); SCR_CharacterDamageManagerComponent damageMgr = SCR_CharacterDamageManagerComponent.Cast( - agentEntity.FindComponent(SCR_CharacterDamageManagerComponent - )); + agentEntity.FindComponent(SCR_CharacterDamageManagerComponent) + ); if (!damageMgr) continue; damageMgr.SetPermitUnconsciousness(false, true); } - - //TODO: Fix me - workaround for late group init + ConsumeTickets(agents.Count()); + + // Consume zone budget based on actual spawned agent count + if (m_Zone && m_Zone.GetBudget()) + m_Zone.GetBudget().Consume(agents.Count() * m_iPointCostPerUnit); } - + //------------------------------------------------------------------------------------------------ protected IEntity SpawnPrefab(ResourceName prefab, IEntity spawnPoint) { @@ -311,44 +318,41 @@ class AFM_DiDSpawnerComponent: GenericEntity vector mat[4]; spawnPoint.GetWorldTransform(mat); spawnParams.Transform = mat; - + return GetGame().SpawnEntityPrefab(Resource.Load(prefab), GetGame().GetWorld(), spawnParams); } - + //------------------------------------------------------------------------------------------------ protected WorldTimestamp GetCurrentTimestamp() { ChimeraWorld world = GetGame().GetWorld(); return world.GetServerTimestamp(); } - + //------------------------------------------------------------------------------------------------ - // Public API for ticket management + // Ticket management //------------------------------------------------------------------------------------------------ - - //! Consume one ticket, returns false if no tickets available + void ConsumeTickets(int ticketCount) { if (!m_bUseTickets || m_iRemainingTickets <= 0) return; - + m_iRemainingTickets = m_iRemainingTickets - ticketCount; PrintFormat("AFM_DiDSpawnerComponent: Ticket consumed, %1 remaining", m_iRemainingTickets, LogLevel.DEBUG); } - - //! Get remaining tickets + int GetRemainingTickets() { return m_iRemainingTickets; } - - //! Set remaining tickets (called by zone when starting waves) + void SetRemainingTickets(int tickets) { m_iRemainingTickets = tickets; PrintFormat("AFM_DiDSpawnerComponent: Tickets set to %1", tickets, LogLevel.DEBUG); } - + bool IsActive() { return true; diff --git a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer index 1429788..c338c6a 100644 --- a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer +++ b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer @@ -6,6 +6,9 @@ PS_ManualMarker : "{5CDEA09445852F9D}PrefabsEditable/Markers/SupportStationMarke coords 1290.432 37.499 6020.635 angles 0 0 0 } +SCR_AIWaypointArtillerySupport : "{6ED320498A60081C}PrefabsEditable/Auto/AI/Waypoints/E_AIWaypoint_ArtillerySupport.et" { + coords 1600.019 61.38 5885.906 +} GenericEntity : "{7AF53A74656A2D66}Prefabs/Props/Military/Compositions/US/DiD_BulidingService_US.et" { coords 1289.929 37.453 6018.585 angles 0 -18.07 0 From e660fcc4718c1c3c02ea53ce20dc9d792f767e96 Mon Sep 17 00:00:00 2001 From: Jan Nielek Date: Fri, 20 Mar 2026 11:03:49 +0100 Subject: [PATCH 2/5] feat: AI Director phase 2 --- .gitignore | 2 + .../MP/AFM_DiDAttackerDirector_Default.et | 4 + .../AFM_DiDAttackerDirector_Default.et.meta | 17 ++ .../Scripts/Game/DiD/AFM_DiDAttackPhase.c | 23 ++ .../Game/DiD/AFM_DiDAttackerDirector.c | 238 ++++++++++++++++++ .../Scripts/Game/DiD/AFM_DiDSpawnRequest.c | 16 ++ .../Scripts/Game/DiD/AFM_DiDZoneComponent.c | 59 ++++- .../AFM_DiDInfantrySpawnerComponent.c | 75 ++++-- .../AFM_DiDMechanizedSpawnerComponent.c | 45 +++- .../DiD/Spawners/AFM_DiDSpawnerComponent.c | 49 +++- .../DiD_Linear_Lamentin_Layers/Zone_1.layer | 5 + .../DiD_Linear_Lamentin_Layers/Zone_2.layer | 1 + .../DiD_Linear_Lamentin_Layers/Zone_3.layer | 1 + docs/IMPLEMENTATION_PLAN.md | 217 ++++------------ 14 files changed, 544 insertions(+), 208 deletions(-) create mode 100644 addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et create mode 100644 addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et.meta create mode 100644 addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c create mode 100644 addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c create mode 100644 addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDSpawnRequest.c diff --git a/.gitignore b/.gitignore index 6d49792..8ca87dc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ CHANGELOG.md # Enfusion files *.rdb + +EMCP_* \ No newline at end of file diff --git a/addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et new file mode 100644 index 0000000..ad00249 --- /dev/null +++ b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et @@ -0,0 +1,4 @@ +AFM_DiDAttackerDirector { + ID "68E743D4FAF810B0" + coords 1614.726 60.796 5902.434 +} \ No newline at end of file diff --git a/addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et.meta b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et.meta new file mode 100644 index 0000000..d528426 --- /dev/null +++ b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et.meta @@ -0,0 +1,17 @@ +MetaFileClass { + Name "{C88B368A8AC21F4E}Prefabs/MP/AFM_DiDAttackerDirector_Default.et" + Configurations { + EntityTemplateResourceClass PC { + } + EntityTemplateResourceClass XBOX_ONE : PC { + } + EntityTemplateResourceClass XBOX_SERIES : PC { + } + EntityTemplateResourceClass PS4 : PC { + } + EntityTemplateResourceClass PS5 : PC { + } + EntityTemplateResourceClass HEADLESS : PC { + } + } +} \ No newline at end of file diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c new file mode 100644 index 0000000..4de20cf --- /dev/null +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c @@ -0,0 +1,23 @@ +//------------------------------------------------------------------------------------------------ +//! Attack phase driven by remaining budget ratio. +//! PROBE → ASSAULT → FINAL as the attacker spends down their points pool. +enum EAFMAttackPhase +{ + PROBE, //! Budget > 75% — infantry probe, test defender positions + ASSAULT, //! Budget 75%–25% — all options active, peak pressure + FINAL //! Budget < 25% — spend aggressively, bonus to all options +} + +//------------------------------------------------------------------------------------------------ +//! Snapshot of battlefield conditions built by AFM_DiDAttackerDirector each decision cycle. +//! Passed read-only to AFM_DiDSpawnerComponent.ScoreRequest() for spawner self-scoring. +class AFM_DiDBattlefieldState +{ + int m_iDefenderCount; //! Alive defenders (blufor) + int m_iAICountInZone; //! Enemy AI currently inside the zone boundary + int m_iTotalActiveAI; //! Total AI tracked by all spawners (includes outside zone) + float m_fBudgetRatio; //! Remaining budget / total (1.0 when no budget system is active) + float m_fTimeRatio; //! Remaining time / total defense time (1.0 = just started, 0.0 = expired) + EAFMAttackPhase m_ePhase; //! Attack phase derived from budget ratio + bool m_bIsNight; //! True during low-visibility conditions — reserved for Phase 3 artillery +} diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c new file mode 100644 index 0000000..80416e2 --- /dev/null +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c @@ -0,0 +1,238 @@ +//------------------------------------------------------------------------------------------------ +//! Central decision-maker for the attacker side. +//! +//! Placed as a GenericEntity child of the zone entity in the world editor. +//! Owns spawner entities as its own children and coordinates them on each decision cycle. +//! +//! Decision cycle (every m_iDecisionIntervalSeconds): +//! 1. Build battlefield state snapshot (defender count, AI count, budget ratio, phase) +//! 2. Ask each spawner for a score via ScoreRequest() +//! 3. Filter: score > 0, spawner off cooldown, budget covers cost +//! 4. Weighted random pick from top 3 candidates (scores as weights) +//! 5. Trigger the chosen spawner via TriggerSpawn() +//! +//! Hierarchy in world editor: +//! AFM_DiDZoneEntity +//! ├── PolylineShapeEntity +//! ├── AFM_PlayerSpawnPointEntity +//! └── AFM_DiDAttackerDirector ← this entity +//! ├── AFM_DiDInfantrySpawnerComponent ← spawner children +//! └── AFM_DiDMechanizedSpawnerComponent +//------------------------------------------------------------------------------------------------ +class AFM_DiDAttackerDirectorClass: GenericEntityClass +{ +} + +//------------------------------------------------------------------------------------------------ +class AFM_DiDAttackerDirector: GenericEntity +{ + [Attribute("25", UIWidgets.EditBox, "Decision cycle interval in seconds", category: "DiD Director")] + protected int m_iDecisionIntervalSeconds; + + [Attribute("0.75", UIWidgets.EditBox, "Aggression 0.0-1.0: scales score of costly options (0=conservative, 1=reckless)", category: "DiD Director")] + protected float m_fAggression; + + protected AFM_DiDZoneComponent m_pZone; + protected ref array m_aSpawners = {}; + protected WorldTimestamp m_fLastDecisionTime; + protected bool m_bInitialized = false; + + //------------------------------------------------------------------------------------------------ + //! Called by AFM_DiDZoneComponent.LateInit() — links this director to its zone + //! and initialises all spawner children. + void Init(AFM_DiDZoneComponent zone) + { + m_pZone = zone; + + // Find and register all spawner children + IEntity child = GetChildren(); + while (child) + { + AFM_DiDSpawnerComponent spawner = AFM_DiDSpawnerComponent.Cast(child); + if (spawner) + { + m_aSpawners.Insert(spawner); + spawner.Prepare(zone); + } + child = child.GetSibling(); + } + + if (m_aSpawners.Count() == 0) + PrintFormat("AFM_DiDAttackerDirector: No spawner children found!", level: LogLevel.WARNING); + + // Push last decision back by one interval so the first cycle fires immediately + ChimeraWorld world = GetGame().GetWorld(); + m_fLastDecisionTime = world.GetServerTimestamp().PlusSeconds(-m_iDecisionIntervalSeconds); + m_bInitialized = true; + + PrintFormat("AFM_DiDAttackerDirector: Initialized with %1 spawners, decision every %2s", + m_aSpawners.Count(), m_iDecisionIntervalSeconds); + } + + //------------------------------------------------------------------------------------------------ + //! Called by AFM_DiDZoneComponent.HandleActiveZoneLogic() once per second. + //! Only runs a full decision cycle on the configured interval. + void Process() + { + if (!m_bInitialized || !m_pZone) + return; + + ChimeraWorld world = GetGame().GetWorld(); + WorldTimestamp now = world.GetServerTimestamp(); + + int timeSinceDecision = Math.AbsInt(now.DiffSeconds(m_fLastDecisionTime)); + if (timeSinceDecision < m_iDecisionIntervalSeconds) + return; + + m_fLastDecisionTime = now; + RunDecisionCycle(now); + } + + //------------------------------------------------------------------------------------------------ + //! Returns total active AI count across all owned spawners. + int GetActiveAICount() + { + int count = 0; + foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) + { + if (spawner) + count += spawner.GetActiveAICount(); + } + return count; + } + + //------------------------------------------------------------------------------------------------ + //! Cleans up all AI spawned by this director's spawners. + void Cleanup() + { + foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) + { + if (spawner) + spawner.Cleanup(); + } + } + + //------------------------------------------------------------------------------------------------ + protected void RunDecisionCycle(WorldTimestamp now) + { + AFM_DiDBattlefieldState state = BuildBattlefieldState(now); + + ref array candidates = {}; + + foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) + { + if (!spawner || !spawner.CanSpawnNow(now)) + continue; + + float score = spawner.ScoreRequest(state); + if (score <= 0) + continue; + + int cost = spawner.GetPointCostPerUnit(); + + // Skip if budget can't cover this spawn + AFM_DiDAttackerBudget budget = m_pZone.GetBudget(); + if (budget && !budget.CanAfford(cost)) + continue; + + // Aggression multiplier: higher aggression boosts the score of expensive options + if (cost > 1 && m_fAggression > 0) + score = score * (1.0 + (cost - 1) * m_fAggression * 0.1); + + candidates.Insert(new AFM_DiDSpawnRequest(spawner, score, cost)); + } + + if (candidates.Count() == 0) + { + PrintFormat("AFM_DiDAttackerDirector: No viable candidates (phase=%1, budget=%2%%)", + state.m_ePhase, state.m_fBudgetRatio * 100, level: LogLevel.DEBUG); + return; + } + + ref AFM_DiDSpawnRequest chosen = WeightedRandomPick(candidates); + if (!chosen) + return; + + PrintFormat("AFM_DiDAttackerDirector: Triggering %1 (score=%2, cost=%3pts, phase=%4)", + chosen.m_Spawner.Type().ToString(), chosen.m_fScore, chosen.m_iCost, + state.m_ePhase, level: LogLevel.DEBUG); + + chosen.m_Spawner.TriggerSpawn(now); + } + + //------------------------------------------------------------------------------------------------ + //! Sorts candidates descending by score and picks with weighted random from the top 3. + protected ref AFM_DiDSpawnRequest WeightedRandomPick(array candidates) + { + // Insertion sort — descending by score (typically 2–4 entries) + int count = candidates.Count(); + for (int i = 1; i < count; i++) + { + ref AFM_DiDSpawnRequest key = candidates[i]; + int j = i - 1; + while (j >= 0 && candidates[j].m_fScore < key.m_fScore) + { + candidates[j + 1] = candidates[j]; + j--; + } + candidates[j + 1] = key; + } + + // Weighted random selection from top 3 + int topN = Math.Min(3, count); + float totalWeight = 0; + for (int i = 0; i < topN; i++) + totalWeight += candidates[i].m_fScore; + + if (totalWeight <= 0) + return candidates[0]; + + float r = s_AIRandomGenerator.RandFloat01() * totalWeight; + float cumulative = 0; + for (int i = 0; i < topN; i++) + { + cumulative += candidates[i].m_fScore; + if (r <= cumulative) + return candidates[i]; + } + + return candidates[0]; + } + + //------------------------------------------------------------------------------------------------ + protected AFM_DiDBattlefieldState BuildBattlefieldState(WorldTimestamp now) + { + AFM_DiDBattlefieldState state = new AFM_DiDBattlefieldState(); + + state.m_iDefenderCount = m_pZone.GetDefenderCount(); + state.m_iAICountInZone = m_pZone.GetAICountInsideZone(); + state.m_iTotalActiveAI = GetActiveAICount(); + + AFM_DiDAttackerBudget budget = m_pZone.GetBudget(); + if (budget) + state.m_fBudgetRatio = budget.GetRatio(); + else + state.m_fBudgetRatio = 1.0; + + // Time ratio: 1.0 at zone start, approaching 0.0 at deadline + WorldTimestamp zoneEnd = m_pZone.GetZoneEndTime(); + float secondsRemaining = Math.Max(0, zoneEnd.DiffSeconds(now)); + int totalDefenseSeconds = m_pZone.GetTotalDefenseSeconds(); + if (totalDefenseSeconds > 0) + state.m_fTimeRatio = Math.Clamp(secondsRemaining / totalDefenseSeconds, 0.0, 1.0); + else + state.m_fTimeRatio = 1.0; + + // Derive attack phase from budget ratio + if (state.m_fBudgetRatio > 0.75) + state.m_ePhase = EAFMAttackPhase.PROBE; + else if (state.m_fBudgetRatio > 0.25) + state.m_ePhase = EAFMAttackPhase.ASSAULT; + else + state.m_ePhase = EAFMAttackPhase.FINAL; + + state.m_bIsNight = false; // Phase 3 will implement day/night detection + + return state; + } +} diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDSpawnRequest.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDSpawnRequest.c new file mode 100644 index 0000000..4cad5b0 --- /dev/null +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDSpawnRequest.c @@ -0,0 +1,16 @@ +//------------------------------------------------------------------------------------------------ +//! A scored spawn candidate produced by AFM_DiDSpawnerComponent.ScoreRequest(). +//! Collected by AFM_DiDAttackerDirector for weighted random selection each decision cycle. +class AFM_DiDSpawnRequest +{ + AFM_DiDSpawnerComponent m_Spawner; //! The spawner that produced this request + float m_fScore; //! Desirability score — higher = more likely selected + int m_iCost; //! Estimated budget cost for this spawn + + void AFM_DiDSpawnRequest(AFM_DiDSpawnerComponent spawner, float score, int cost) + { + m_Spawner = spawner; + m_fScore = score; + m_iCost = cost; + } +} diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c index df990c8..0f958d4 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c @@ -50,7 +50,10 @@ class AFM_DiDZoneComponent: ScriptComponent protected PolylineShapeEntity m_PolylineEntity; protected AFM_PlayerSpawnPointEntity m_PlayerSpawnPoint; + // Legacy: spawners are direct children of the zone (used when no director is present) protected ref array m_aSpawners = {}; + // Director mode: central coordinator that owns its own spawner children + protected AFM_DiDAttackerDirector m_Director; protected SCR_ResourceComponent m_SupplyCache; // Cached 2D polyline points for zone boundary checks (world-space X/Z pairs) @@ -98,6 +101,12 @@ class AFM_DiDZoneComponent: ScriptComponent case AFM_PlayerSpawnPointEntity: m_PlayerSpawnPoint = AFM_PlayerSpawnPointEntity.Cast(e); break; + // Director mode: central spawner coordinator — owns its own spawner children + case AFM_DiDAttackerDirector: + m_Director = AFM_DiDAttackerDirector.Cast(e); + m_Director.Init(this); + break; + // Legacy mode: spawners are direct children of the zone case AFM_DiDMechanizedSpawnerComponent: case AFM_DiDInfantrySpawnerComponent: case AFM_DiDMortarSpawnerComponent: @@ -118,13 +127,16 @@ class AFM_DiDZoneComponent: ScriptComponent PrintFormat("AFM_DiDZoneComponent %1: Missing polyline component, zone wont work properly!", m_sZoneName, level: LogLevel.ERROR); if (!m_PlayerSpawnPoint) PrintFormat("AFM_DiDZoneComponent %1: Missing player spawnpoint, zone wont work properly!", m_sZoneName, level: LogLevel.ERROR); - if (m_aSpawners.Count() == 0) + + // In director mode the director owns the spawners — no direct spawners on zone is expected + bool hasSpawners = m_Director || m_aSpawners.Count() > 0; + if (!hasSpawners) PrintFormat("AFM_DiDZoneComponent %1: No spawner components found, AI will not spawn!", m_sZoneName, level: LogLevel.WARNING); - // Initialize spawners - foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) + // Legacy mode: initialize spawners directly + foreach (AFM_DiDSpawnerComponent s : m_aSpawners) { - spawner.Prepare(this); + s.Prepare(this); } if (!AFM_DiDZoneSystem.GetInstance().RegisterZone(this)) @@ -132,6 +144,10 @@ class AFM_DiDZoneComponent: ScriptComponent else PrintFormat("AFM_DiDZoneComponent %1: Zone registered", m_sZoneName); + if (m_Director) + PrintFormat("AFM_DiDZoneComponent %1: Director mode active", m_sZoneName); + else + PrintFormat("AFM_DiDZoneComponent %1: Legacy spawner mode active (%2 spawners)", m_sZoneName, m_aSpawners.Count()); AFM_GameModeDiD gamemode = AFM_GameModeDiD.Cast(GetGame().GetGameMode()); if (!gamemode) @@ -223,10 +239,16 @@ class AFM_DiDZoneComponent: ScriptComponent } //------------------------------------------------------------------------------------------------ - //! Cleanup all spawned AI through spawner components + //! Cleanup all spawned AI through director or direct spawner components //------------------------------------------------------------------------------------------------ protected void Cleanup() { + if (m_Director) + { + m_Director.Cleanup(); + return; + } + foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) { if (spawner) @@ -329,11 +351,19 @@ class AFM_DiDZoneComponent: ScriptComponent } } - // Delegate spawning to spawner components - foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) + // Director mode: central decision cycle + // Legacy mode: each spawner manages its own timing + if (m_Director) { - if (spawner) - spawner.Process(); + m_Director.Process(); + } + else + { + foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) + { + if (spawner) + spawner.Process(); + } } return m_eZoneState; @@ -471,10 +501,13 @@ class AFM_DiDZoneComponent: ScriptComponent } //------------------------------------------------------------------------------------------------ - //! Get total active AI count across all spawners + //! Get total active AI count across director or all direct spawners //------------------------------------------------------------------------------------------------ int GetActiveAICount() { + if (m_Director) + return m_Director.GetActiveAICount(); + int totalCount = 0; foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) { @@ -506,6 +539,12 @@ class AFM_DiDZoneComponent: ScriptComponent return m_fBudgetRolloverFraction; } + //! Total defense time in seconds — used by director for time ratio calculation + int GetTotalDefenseSeconds() + { + return m_iDefenseTimeSeconds; + } + //------------------------------------------------------------------------------------------------ protected bool IsZoneTimeExpired() { diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDInfantrySpawnerComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDInfantrySpawnerComponent.c index 3184f2c..af1e92b 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDInfantrySpawnerComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDInfantrySpawnerComponent.c @@ -1,6 +1,6 @@ //------------------------------------------------------------------------------------------------ -//! Infantry spawner - spawns regular infantry groups -//! This is a concrete implementation showing how to extend the base spawner +//! Infantry spawner — spawns regular infantry groups. +//! Supports both legacy (self-ticking Process) and director (ScoreRequest/TriggerSpawn) modes. //------------------------------------------------------------------------------------------------ class AFM_DiDInfantrySpawnerComponentClass: AFM_DiDSpawnerComponentClass { @@ -11,65 +11,98 @@ class AFM_DiDInfantrySpawnerComponent: AFM_DiDSpawnerComponent { [Attribute("1", UIWidgets.CheckBox, "Enable random spawn point selection", category: "DiD Infantry Spawner")] protected bool m_bRandomSpawnPoints; - + [Attribute("1", UIWidgets.CheckBox, "Enable random waypoint assignment", category: "DiD Infantry Spawner")] protected bool m_bRandomWaypoints; - + [Attribute("0.8", UIWidgets.EditBox, "Min spawn interval multiplier for variety", category: "DiD Infantry Spawner")] protected float m_fMinIntervalMultiplier; - + [Attribute("1.2", UIWidgets.EditBox, "Max spawn interval multiplier for variety", category: "DiD Infantry Spawner")] protected float m_fMaxIntervalMultiplier; - + protected int m_iCurrentSpawnPointIndex = 0; protected int m_iCurrentWaypointIndex = 0; - + //------------------------------------------------------------------------------------------------ override void Prepare(AFM_DiDZoneComponent owner) { super.Prepare(owner); - PrintFormat("AFM_DiDInfantrySpawnerComponent: Infantry spawner initialized with %1 spawn points and %2 waypoints", + PrintFormat("AFM_DiDInfantrySpawnerComponent: Infantry spawner initialized with %1 spawn points and %2 waypoints", m_aSpawnPoints.Count(), m_aAIWaypoints.Count(), LogLevel.DEBUG); } - + + //------------------------------------------------------------------------------------------------ + //! Director mode: score how desirable an infantry spawn is right now. + //! + //! Scoring logic: + //! Base = 1.0 (infantry is always the default option) + //! +1.5 if AI in zone < half of defender count (outnumbered, send reinforcements) + //! +0.5 if phase == FINAL (spend remaining budget) + //! -2.0 if AI in zone > 80% of max cap (zone saturated, don't pile on) + //! -0.5 if budget ratio < 0.15 (critically low, be conservative) + //------------------------------------------------------------------------------------------------ + override float ScoreRequest(AFM_DiDBattlefieldState state) + { + float score = 1.0; + + // Need more bodies — attackers are outnumbered in zone + if (state.m_iAICountInZone < state.m_iDefenderCount * 0.5) + score += 1.5; + + // Final push — spend aggressively + if (state.m_ePhase == EAFMAttackPhase.FINAL) + score += 0.5; + + // Zone already saturated — adding more infantry won't help + if (state.m_iTotalActiveAI > m_iMaxAICount * 0.8) + score -= 2.0; + + // Budget critically low — hold infantry back + if (state.m_fBudgetRatio < 0.15) + score -= 0.5; + + return score; + } + //------------------------------------------------------------------------------------------------ - //! Override to add variety to spawn intervals + //! Legacy mode: override to add randomised spawn interval variety. //------------------------------------------------------------------------------------------------ override void Process() { if (!m_Zone) return; - + EAFMZoneState state = m_Zone.GetZoneState(); if (state != EAFMZoneState.ACTIVE && state != EAFMZoneState.FROZEN) return; - + ChimeraWorld world = GetGame().GetWorld(); WorldTimestamp now = world.GetServerTimestamp(); - + int timeSinceLastSpawn = Math.AbsInt(now.DiffSeconds(m_fLastSpawnTime)); - + // Add variety to spawn intervals float intervalMultiplier = s_AIRandomGenerator.RandFloatXY(m_fMinIntervalMultiplier, m_fMaxIntervalMultiplier); int adjustedInterval = Math.Ceil(m_iWaveIntervalSeconds * intervalMultiplier); - + if (timeSinceLastSpawn >= adjustedInterval) { m_fLastSpawnTime = now; SpawnWave(); } } - + //------------------------------------------------------------------------------------------------ - //! Infantry-specific spawn logic with optional sequential spawning + //! Infantry-specific spawn logic with optional sequential spawning. //------------------------------------------------------------------------------------------------ override protected void SpawnSingleGroup() { if (m_aSpawnPoints.Count() == 0 || m_aAIWaypoints.Count() == 0 || m_aAIGroupPrefabs.Count() == 0) return; - + ResourceName groupPrefab = m_aAIGroupPrefabs.GetRandomElement(); - + // Get spawn point (random or sequential) AFM_SpawnPointEntity spawnPoint; if (m_bRandomSpawnPoints) @@ -81,7 +114,7 @@ class AFM_DiDInfantrySpawnerComponent: AFM_DiDSpawnerComponent spawnPoint = m_aSpawnPoints[m_iCurrentSpawnPointIndex]; m_iCurrentSpawnPointIndex = (m_iCurrentSpawnPointIndex + 1) % m_aSpawnPoints.Count(); } - + // Get waypoint (random or sequential) SCR_AIWaypoint waypoint; if (m_bRandomWaypoints) @@ -93,7 +126,7 @@ class AFM_DiDInfantrySpawnerComponent: AFM_DiDSpawnerComponent waypoint = m_aAIWaypoints[m_iCurrentWaypointIndex]; m_iCurrentWaypointIndex = (m_iCurrentWaypointIndex + 1) % m_aAIWaypoints.Count(); } - + AIGroup group = SpawnAI(groupPrefab, spawnPoint, waypoint); if (group) { diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c index 6feb5bd..bf56470 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c @@ -1,5 +1,6 @@ //------------------------------------------------------------------------------------------------ -//! Mechanized spawner - spawns vehicle groups with different timing and logic +//! Mechanized spawner — spawns vehicle groups with crew. +//! Supports both legacy (self-ticking Process) and director (ScoreRequest/TriggerSpawn) modes. //------------------------------------------------------------------------------------------------ class AFM_DiDMechanizedSpawnerComponentClass: AFM_DiDSpawnerComponentClass { @@ -39,6 +40,46 @@ class AFM_DiDMechanizedSpawnerComponent: AFM_DiDSpawnerComponent m_iWaveIntervalSeconds, LogLevel.DEBUG); } + //------------------------------------------------------------------------------------------------ + //! Director mode: score how desirable a mechanized push is right now. + //! + //! Scoring logic: + //! Base = 0.1 (armor is a special-occasion option, not the default) + //! +2.0 if phase == FINAL (commit remaining budget) + //! +1.0 if budget ratio > 0.6 (still have plenty — escalate) + //! +1.5 if defenders > 5 (many defenders = armor-favourable) + //! -0.1 if m_bRequireMinAI and threshold not met (precondition not satisfied) + //! + //! The director respects CanSpawnNow() so the mechanized cooldown (wave interval × + //! m_fDelayMultiplier) is already enforced before ScoreRequest is called. + //------------------------------------------------------------------------------------------------ + override float ScoreRequest(AFM_DiDBattlefieldState state) + { + // In PROBE phase armor is off the table — attacker is still scouting + if (state.m_ePhase == EAFMAttackPhase.PROBE) + return 0; + + float score = 0.1; + + // Final phase: commit whatever is left, armor is a force multiplier + if (state.m_ePhase == EAFMAttackPhase.FINAL) + score += 2.0; + + // Plenty of budget remaining — escalate with armor + if (state.m_fBudgetRatio > 0.6) + score += 1.0; + + // More defenders = more value from a vehicle push + if (state.m_iDefenderCount > 5) + score += 1.5; + + // Precondition check: require minimum infantry in zone + if (m_bRequireMinAI && state.m_iTotalActiveAI < m_iMinAIThreshold) + score -= 0.1; + + return score; + } + //------------------------------------------------------------------------------------------------ override protected void SpawnWave() { @@ -99,7 +140,7 @@ class AFM_DiDMechanizedSpawnerComponent: AFM_DiDSpawnerComponent } //------------------------------------------------------------------------------------------------ - //! m_iPointCostPerUnit represents cost per whole vehicle group (vehicle + crew) + //! m_iPointCostPerUnit represents cost per whole vehicle group (vehicle + crew). //------------------------------------------------------------------------------------------------ override protected void SpawnSingleGroup() { diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c index c94fda4..33d58b2 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c @@ -1,6 +1,11 @@ //------------------------------------------------------------------------------------------------ //! Base class for AI spawner components //! Derive from this to create different spawning strategies (infantry, mechanized, fire support, etc.) +//! +//! Two operation modes: +//! - Legacy: zone calls Process() every second; spawner manages its own timing. +//! - Director: AFM_DiDAttackerDirector calls ScoreRequest() + TriggerSpawn(); +//! spawner's own Process() is never called. //------------------------------------------------------------------------------------------------ class AFM_DiDSpawnerComponentClass: GenericEntityClass { @@ -41,7 +46,7 @@ class AFM_DiDSpawnerComponent: GenericEntity protected int m_iRemainingTickets; //------------------------------------------------------------------------------------------------ - // Prepare method - called by owner zone component on start + // Prepare method - called by owner zone component (or director) on start //------------------------------------------------------------------------------------------------ void Prepare(AFM_DiDZoneComponent owner) { @@ -86,7 +91,8 @@ class AFM_DiDSpawnerComponent: GenericEntity } //------------------------------------------------------------------------------------------------ - // Main process method - called periodically by the owner zone component + // Legacy mode: called directly by the zone every second. + // Not called when a director is present — use ScoreRequest()/TriggerSpawn() instead. //------------------------------------------------------------------------------------------------ void Process() { @@ -111,6 +117,45 @@ class AFM_DiDSpawnerComponent: GenericEntity } } + //------------------------------------------------------------------------------------------------ + // Director mode: score how desirable a spawn is right now given the battlefield state. + // Return <= 0 to opt out this cycle (zone saturated, wrong phase, etc.) + // Base implementation always returns 1.0 — override in subclasses for intelligent scoring. + //------------------------------------------------------------------------------------------------ + float ScoreRequest(AFM_DiDBattlefieldState state) + { + // Base: always willing to spawn at neutral priority + return 1.0; + } + + //------------------------------------------------------------------------------------------------ + // Director mode: spawn immediately and reset the cooldown timer. + // Called by AFM_DiDAttackerDirector after scoring and budget checks pass. + //------------------------------------------------------------------------------------------------ + void TriggerSpawn(WorldTimestamp now) + { + m_fLastSpawnTime = now; + SpawnWave(); + } + + //------------------------------------------------------------------------------------------------ + // Director mode: returns true when enough time has elapsed since the last spawn. + // The director filters out spawners that return false before calling ScoreRequest(). + //------------------------------------------------------------------------------------------------ + bool CanSpawnNow(WorldTimestamp now) + { + int timeSinceLastSpawn = Math.AbsInt(now.DiffSeconds(m_fLastSpawnTime)); + return timeSinceLastSpawn >= m_iWaveIntervalSeconds; + } + + //------------------------------------------------------------------------------------------------ + // Returns the budget cost per spawned unit — used by the director for budget affordability checks. + //------------------------------------------------------------------------------------------------ + int GetPointCostPerUnit() + { + return m_iPointCostPerUnit; + } + //------------------------------------------------------------------------------------------------ void Cleanup() { diff --git a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer index 4171f7e..96ea204 100644 --- a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer +++ b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer @@ -4,6 +4,8 @@ AFM_DiDZoneEntity zone1 { m_sZoneName "Outskirts" m_iPrepareTimeSeconds 900 m_iDefenseTimeSeconds 900 + m_iPointsBudget 120 + m_fBudgetRolloverFraction 0.9 } Hierarchy "{66C71AE7EF54AAC8}" { } @@ -881,6 +883,9 @@ AFM_DiDZoneEntity zone1 { AFM_PlayerSpawnPointEntity : "{B6B6E54C76E4D206}Prefabs/MP/AFM_DiDPlayerSpawnPointPrefab.et" { coords 78.478 6.073 20.088 } + AFM_DiDAttackerDirector : "{C88B368A8AC21F4E}Prefabs/MP/AFM_DiDAttackerDirector_Default.et" { + coords -112.033 -11.168 33.665 + } AFM_DiDMortarSpawnerComponent : "{F040102F1843F7C1}Prefabs/Spawners/AFM_MortarSpawner.et" { coords 0.283 0.067 -0.146 { diff --git a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer index c338c6a..cb1965f 100644 --- a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer +++ b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer @@ -20,6 +20,7 @@ AFM_DiDZoneEntity zone2 : "{CA08D7D0BA0D24CF}Prefabs/MP/AFM_DiDZonePrefab.et" { m_iZoneIndex 2 m_iPrepareTimeSeconds 600 m_iDefenseTimeSeconds 450 + m_iPointsBudget 160 } } coords 1303.501 38.211 5952.463 diff --git a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer index 46c009c..2edd744 100644 --- a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer +++ b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer @@ -18,6 +18,7 @@ AFM_DiDZoneEntity zone3 : "{CA08D7D0BA0D24CF}Prefabs/MP/AFM_DiDZonePrefab.et" { m_iZoneIndex 3 m_iPrepareTimeSeconds 900 m_iDefenseTimeSeconds 300 + m_iPointsBudget 200 } } coords 1022.905 2.684 6026.365 diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 67cebdf..e3b1c06 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -7,162 +7,79 @@ Each phase builds on the previous but does not break it. --- -## Phase 1 — Attacker Budget System +## Phase 1 — Attacker Budget System ✅ COMPLETE **Goal:** Replace infinite AI spawning with a per-zone points budget. Each spawn costs points. Zone ends via budget exhaustion + AI cleared, not just timer expiry or defender death. -### New files -| File | Purpose | -|---|---| -| `Scripts/Game/DiD/AFM_DiDAttackerBudget.c` | Budget state: total, remaining, reservation logic | - -### Modified files -| File | Change | +### Files created/modified +| File | Status | |---|---| -| `AFM_DiDZoneComponent.c` | Add `m_iPointsBudget` attribute; hold `AFM_DiDAttackerBudget` instance; add `FINISHED_REPELLED` state (budget + AI = 0) | -| `AFM_DiDSpawnerComponent.c` | Add `m_iPointCostPerUnit` attribute; call `zone.ReserveBudget(cost)` before spawning, confirm/release after | -| `AFM_DiDZoneSystem.c` | Handle `FINISHED_REPELLED` → invoke `m_OnZoneHeld` (defenders win) | -| `AFM_GameModeDiD.c` | Add `RPC_DoZoneRepelled()` hint: "Enemy assault repelled!" | -| `AFM_ScoreInfoDisplay.c` | For regular zones: right score shows remaining budget instead of AI count in zone | - -### Budget reservation contract -``` -// Before spawn -bool reserved = zone.GetBudget().Reserve(cost); -if (!reserved) return; // can't afford, skip spawn - -// After successful spawn (in DisableAIUnconsciousness callback) -zone.GetBudget().Confirm(cost); - -// After failed spawn -zone.GetBudget().Release(cost); -``` - -### Key attributes (AFM_DiDZoneComponent) -``` -[Attribute("120", UIWidgets.EditBox, "Attacker points budget for this zone", category: "DiD Budget")] -int m_iPointsBudget; - -[Attribute("0.5", UIWidgets.EditBox, "Fraction of remaining budget rolled over to next zone on failure (0-1)", category: "DiD Budget")] -float m_fBudgetRolloverFraction; -``` - -### Key attributes (AFM_DiDSpawnerComponent) -``` -[Attribute("1", UIWidgets.EditBox, "Budget points consumed per spawned unit", category: "DiD Budget")] -int m_iPointCostPerUnit; -``` - -### Budget rollover -`AFM_DiDZoneSystem.ProgressToNextZone()` reads `remainingBudget * rolloverFraction` -and passes it to `nextZone.GetBudget().AddBonus(rollover)` before activating. - -### Tuning baseline -| Zone type | Base budget | Rationale | -|---|---|---| -| Infantry-only | 80–100 pts | ~20 squads of 4 @ 1pt/soldier | -| Mixed infantry + armor | 120–150 pts | infantry + 2–3 vehicles | -| Wave zone | Per-wave ticket (existing) | unchanged | +| `Scripts/Game/DiD/AFM_DiDAttackerBudget.c` | NEW — budget state class | +| `Scripts/Game/DiD/AFM_DiDZoneComponent.c` | MODIFIED — `FINISHED_REPELLED` state, budget attributes, rollover API | +| `Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c` | MODIFIED — `m_iPointCostPerUnit`, `CanSpendBudget()`, budget consumption | +| `Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c` | MODIFIED — immediate budget consume per vehicle | +| `Scripts/Game/DiD/AFM_DiDZoneSystem.c` | MODIFIED — `m_OnZoneRepelled`, budget rollover in `ProgressToNextZone()` | +| `Scripts/Game/DiD/AFM_GameModeDiD.c` | MODIFIED — `OnZoneRepelled` handler, `RPC_DoZoneRepelled` hint | + +### Key attributes (tunable in editor) +- `AFM_DiDZoneComponent.m_iPointsBudget` — 0 disables system; 80–150 typical +- `AFM_DiDZoneComponent.m_fBudgetRolloverFraction` — 0.5 default +- `AFM_DiDSpawnerComponent.m_iPointCostPerUnit` — 1 pt/soldier, higher for vehicles --- -## Phase 2 — Attacker Director +## Phase 2 — Attacker Director ✅ COMPLETE **Goal:** Replace autonomous per-spawner ticking with a central decision-maker that coordinates all spawners, tracks attack phases, and spends budget intelligently. -### New files +### Files created | File | Purpose | |---|---| -| `Scripts/Game/DiD/AFM_DiDAttackerDirector.c` | Director entity: decision loop, phase tracking, spawner coordination | -| `Scripts/Game/DiD/AFM_DiDAttackPhase.c` | Enum + phase transition logic | +| `Scripts/Game/DiD/AFM_DiDAttackPhase.c` | `EAFMAttackPhase` enum (PROBE/ASSAULT/FINAL) + `AFM_DiDBattlefieldState` class | | `Scripts/Game/DiD/AFM_DiDSpawnRequest.c` | Scored spawn candidate (spawner ref + score + cost) | +| `Scripts/Game/DiD/AFM_DiDAttackerDirector.c` | Director GenericEntity: 25s decision cycle, weighted random spawner selection | -### Modified files +### Files modified | File | Change | |---|---| -| `AFM_DiDZoneComponent.c` | `LateInit()` finds director child; `HandleActiveZoneLogic()` calls `director.Process()` instead of iterating spawners directly | -| `AFM_DiDSpawnerComponent.c` | Remove self-ticking `Process()`; add `ScoreRequest(AFM_DiDBattlefieldState state) → float` and `TriggerSpawn()` | -| `AFM_DiDInfantrySpawnerComponent.c` | Implement `ScoreRequest()` | -| `AFM_DiDMechanizedSpawnerComponent.c` | Implement `ScoreRequest()` | +| `Scripts/Game/DiD/AFM_DiDZoneComponent.c` | Detects `AFM_DiDAttackerDirector` child in `LateInit()`; delegates `Process()`/`Cleanup()`/`GetActiveAICount()` to director; added `GetTotalDefenseSeconds()` getter | +| `Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c` | Added `ScoreRequest()` (base: 1.0), `TriggerSpawn()`, `CanSpawnNow()`, `GetPointCostPerUnit()` | +| `Scripts/Game/DiD/Spawners/AFM_DiDInfantrySpawnerComponent.c` | `ScoreRequest()` override — boosts when outnumbered/FINAL phase, penalises when saturated | +| `Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c` | `ScoreRequest()` override — off during PROBE, boosted in FINAL/high budget/many defenders | -### Director hierarchy in world +### Architecture ``` AFM_DiDZoneEntity ├── PolylineShapeEntity ├── AFM_PlayerSpawnPointEntity -└── AFM_DiDAttackerDirector ← new GenericEntity child +└── AFM_DiDAttackerDirector ← GenericEntity child (director mode) ├── AFM_DiDInfantrySpawnerComponent └── AFM_DiDMechanizedSpawnerComponent ``` -Director owns spawners as children. Zone's `LateInit()` finds the director by type, -director finds its own spawner children. - -### Attack phases (budget-driven, not time-driven) -``` -enum EAFMAttackPhase -{ - PROBE, // budget > 75% — infantry only, test defender positions - ASSAULT, // 75% → 25% — all options active, peak pressure - FINAL // budget < 25% — score bonus to all options, spend aggressively -} -``` - -### Decision cycle -``` -every 25 seconds: - 1. Snapshot battlefield state (defenderCount, aiInZone, budgetRatio, timeRatio, phase) - 2. foreach spawner: score = spawner.ScoreRequest(state) - 3. filter: score > 0, budget covers cost, spawner off cooldown - 4. weighted random pick from top 3 candidates (scores as weights) - 5. reserve budget, call spawner.TriggerSpawn() - 6. start spawner cooldown -``` -### Battlefield state snapshot (passed to all ScoreRequest calls) -``` -class AFM_DiDBattlefieldState -{ - int m_iDefenderCount; - int m_iAICountInZone; - float m_fBudgetRatio; // remaining / total - float m_fTimeRatio; // timeRemaining / totalTime - float m_fDefenderDensity; // defenders per 100m² (zone centroid sample) - EAFMAttackPhase m_ePhase; - bool m_bIsNight; -} -``` - -### Per-spawner ScoreRequest logic - -**Infantry:** -``` -baseScore = 1.0 -+1.5 if aiInZone < defenderCount * 0.5 // outnumbered, need bodies -+0.5 if phase == FINAL --2.0 if aiInZone > maxAICount * 0.8 // zone saturated --0.5 if budgetRatio < 0.15 // budget critical -``` +Director mode is opt-in — zones without an `AFM_DiDAttackerDirector` child continue +to use the legacy per-spawner `Process()` path unchanged. -**Mechanized:** -``` -baseScore = 0.1 -+2.0 if phase == FINAL -+1.0 if budgetRatio > 0.6 -+1.5 if defenderDensity is high (static defense) --9.0 if on cooldown (min 3 min between armored pushes) -``` +### Decision cycle (every 25s) +1. Snapshot: defender count, AI in zone, budget ratio, time ratio → derive phase +2. Each spawner: `ScoreRequest(state)` → float +3. Filter: score > 0, spawner off cooldown (`CanSpawnNow`), budget covers cost +4. Weighted random pick from top 3 by score +5. `TriggerSpawn(now)` on winner -### Director attributes -``` -[Attribute("25", UIWidgets.EditBox, "Decision cycle interval (seconds)", category: "DiD Director")] -int m_iDecisionIntervalSeconds; +### Attack phases (budget-driven) +| Phase | Budget remaining | Behavior | +|---|---|---| +| PROBE | > 75% | Infantry only (mechanized scores 0) | +| ASSAULT | 75%–25% | All options active | +| FINAL | < 25% | Mechanized gets +2.0 bonus, infantry +0.5 | -[Attribute("0.75", UIWidgets.EditBox, "Aggression (0=conservative, 1=reckless)", category: "DiD Director")] -float m_fAggression; // multiplies scores of expensive options -``` +### Key attributes (tunable in editor) +- `AFM_DiDAttackerDirector.m_iDecisionIntervalSeconds` — 25s default +- `AFM_DiDAttackerDirector.m_fAggression` — 0.75 default (scales score of expensive options) --- @@ -256,34 +173,6 @@ SCR_EAIArtilleryAmmoType SelectRoundType(AFM_DiDBattlefieldState state) } ``` -### Smoke targeting (attack axis) -```cpp -vector GetSmokeTargetPosition() -{ - vector spawnPos = m_pLastUsedSpawnPoint.GetOrigin(); - vector zoneEdge = FindNearestZoneBoundaryPoint(spawnPos); - // lay smoke 60% along the approach, covering the gap between attacker and zone - return spawnPos + (zoneEdge - spawnPos) * 0.6; -} -``` - -### Respawn point selection (avoids last position) -```cpp -AFM_ArtillerySpawnPointEntity GetNextSpawnPoint() -{ - if (m_aSpawnPoints.Count() == 1) - return m_aSpawnPoints[0]; - - array candidates = {}; - foreach (AFM_ArtillerySpawnPointEntity sp : m_aSpawnPoints) - { - if (sp != m_pLastUsedSpawnPoint) - candidates.Insert(sp); - } - return candidates.GetRandomElement(); -} -``` - ### Respawn cost escalation ```cpp int GetRespawnCost() @@ -304,7 +193,6 @@ int GetRespawnCost() | Mortar fires mission | Yes (impacts) | Internal | | Mortar destroyed | **Yes** — hint | Internal | | Mortar respawns | **No** | Internal | -| Budget exhausted, no respawn | **No** | Internal | --- @@ -334,23 +222,6 @@ class AFM_DiDAttackSequence } ``` -### Smoke + armor sequence scoring -```cpp -float ScoreSmokeArmorSequence(AFM_DiDBattlefieldState state) -{ - // requires: smoke not on cooldown, armor not on cooldown, - // budget covers both, phase is ASSAULT or FINAL - if (!CanAffordSequence(smokeCost + armorCost)) return -1; - if (IsOnCooldown(SMOKE) || IsOnCooldown(MECHANIZED)) return -1; - if (state.m_ePhase == EAFMAttackPhase.PROBE) return -1; - - float score = 2.0; - score += state.m_fDefenderDensity * 1.5; // static defense = armor-friendly - score += (state.m_ePhase == EAFMAttackPhase.FINAL) ? 1.5 : 0; - return score; -} -``` - Sequences compete with individual options in the same decision cycle. If the sequence scores highest, both actions are reserved and the director begins executing step 1, then waits `m_fDelaySeconds` before step 2. @@ -377,8 +248,8 @@ Prevents 3-player sessions from facing 20-player-designed pressure. ## Dependencies ``` -Phase 1 (Budget) - └── Phase 2 (Director) +Phase 1 (Budget) ✅ + └── Phase 2 (Director) ✅ ├── Phase 3 (Artillery) └── Phase 4 (Sequences) ``` From 1b3e92a7e351b160e35826a744e8725eaeaa2fa2 Mon Sep 17 00:00:00 2001 From: Jan Nielek Date: Fri, 20 Mar 2026 12:37:29 +0100 Subject: [PATCH 3/5] feat: AI Director phase III --- .claude/settings.local.json | 5 +- .../MP/AFM_ArtillerySpawnPointEntity.et | 11 + .../MP/AFM_ArtillerySpawnPointEntity.et.meta | 17 + .../MP/AFM_DiDAttackerDirector_Default.et | 4 + .../Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et | 18 + .../MP/AFM_DiDZoneArtilleryPrefab.et.meta | 17 + .../Scripts/Game/DiD/AFM_DiDAttackPhase.c | 5 +- .../Game/DiD/AFM_DiDAttackerDirector.c | 118 ++- .../Scripts/Game/DiD/AFM_DiDZoneArtillery.c | 727 ++++++++++++++++++ .../Scripts/Game/DiD/AFM_DiDZoneComponent.c | 24 +- .../Scripts/Game/DiD/AFM_GameModeDiD.c | 24 + .../Entity/AFM_ArtillerySpawnPointEntity.c | 14 + .../DiD_Linear_Lamentin_Layers/Zone_1.layer | 123 ++- docs/IMPLEMENTATION_PLAN.md | 229 +++--- 14 files changed, 1149 insertions(+), 187 deletions(-) create mode 100644 addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et create mode 100644 addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et.meta create mode 100644 addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et create mode 100644 addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et.meta create mode 100644 addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c create mode 100644 addons/DefenseInDepth/Scripts/Game/DiD/Entity/AFM_ArtillerySpawnPointEntity.c diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 95b734b..464595c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,10 @@ "mcp__enfusion-mcp__wb_stop", "mcp__enfusion-mcp__wb_cleanup", "mcp__enfusion-mcp__game_browse", - "mcp__enfusion-mcp__asset_search" + "mcp__enfusion-mcp__asset_search", + "mcp__enfusion-mcp__wiki_search", + "mcp__enfusion-mcp__game_read", + "mcp__enfusion-mcp__wb_reload" ] } } diff --git a/addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et b/addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et new file mode 100644 index 0000000..3586e5c --- /dev/null +++ b/addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et @@ -0,0 +1,11 @@ +AFM_ArtillerySpawnPointEntity { + ID "68E8977F8944C994" + components { + MeshObject "{68E8977FA3BC91BC}" { + Object "{16D18A4925ADB872}system/wbdata/MaterialEditor/MaterialSphere.xob" + } + Hierarchy "{68E8977FB8D3EE43}" { + } + } + coords 1595.344 62.209 5842.046 +} \ No newline at end of file diff --git a/addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et.meta b/addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et.meta new file mode 100644 index 0000000..5db2946 --- /dev/null +++ b/addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et.meta @@ -0,0 +1,17 @@ +MetaFileClass { + Name "{1933061551525F98}Prefabs/MP/AFM_ArtillerySpawnPointEntity.et" + Configurations { + EntityTemplateResourceClass PC { + } + EntityTemplateResourceClass XBOX_ONE : PC { + } + EntityTemplateResourceClass XBOX_SERIES : PC { + } + EntityTemplateResourceClass PS4 : PC { + } + EntityTemplateResourceClass PS5 : PC { + } + EntityTemplateResourceClass HEADLESS : PC { + } + } +} \ No newline at end of file diff --git a/addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et index ad00249..9a882f1 100644 --- a/addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et +++ b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDAttackerDirector_Default.et @@ -1,4 +1,8 @@ AFM_DiDAttackerDirector { ID "68E743D4FAF810B0" + components { + Hierarchy "{68E8977847AE5892}" { + } + } coords 1614.726 60.796 5902.434 } \ No newline at end of file diff --git a/addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et new file mode 100644 index 0000000..6c65f67 --- /dev/null +++ b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et @@ -0,0 +1,18 @@ +AFM_DiDZoneArtillery { + ID "68E8977F96335319" + components { + Hierarchy "{68E897785EED2C48}" { + } + } + coords 1603.758 62.301 5838.798 + m_sMortarPrefab "{D0AAD73A3B0B0087}Prefabs/Compositions/DiD_MortarComposition_USSR.et" + m_CrewConfig AFM_CrewConfig "{68E8977FD9DC91CF}" { + m_sGunnerPrefab "{EBFB363AC7A5FCE6}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Engineer.et" + m_bSpawnDriver 0 + m_bNoTurretDismount 0 + } + m_iHECooldownSeconds 90 + m_iSmokeCooldownSeconds 60 + m_iIllumCooldownSeconds 180 + m_fSampleRadius 25 +} \ No newline at end of file diff --git a/addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et.meta b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et.meta new file mode 100644 index 0000000..f548399 --- /dev/null +++ b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et.meta @@ -0,0 +1,17 @@ +MetaFileClass { + Name "{2D68BCDEA841A4BC}Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et" + Configurations { + EntityTemplateResourceClass PC { + } + EntityTemplateResourceClass XBOX_ONE : PC { + } + EntityTemplateResourceClass XBOX_SERIES : PC { + } + EntityTemplateResourceClass PS4 : PC { + } + EntityTemplateResourceClass PS5 : PC { + } + EntityTemplateResourceClass HEADLESS : PC { + } + } +} \ No newline at end of file diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c index 4de20cf..ff79e2d 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c @@ -10,7 +10,7 @@ enum EAFMAttackPhase //------------------------------------------------------------------------------------------------ //! Snapshot of battlefield conditions built by AFM_DiDAttackerDirector each decision cycle. -//! Passed read-only to AFM_DiDSpawnerComponent.ScoreRequest() for spawner self-scoring. +//! Passed read-only to AFM_DiDSpawnerComponent.ScoreRequest() and AFM_DiDZoneArtillery scoring. class AFM_DiDBattlefieldState { int m_iDefenderCount; //! Alive defenders (blufor) @@ -18,6 +18,7 @@ class AFM_DiDBattlefieldState int m_iTotalActiveAI; //! Total AI tracked by all spawners (includes outside zone) float m_fBudgetRatio; //! Remaining budget / total (1.0 when no budget system is active) float m_fTimeRatio; //! Remaining time / total defense time (1.0 = just started, 0.0 = expired) + float m_fDefenderDensity; //! Alive defender count as a float — used by artillery to decide HE missions EAFMAttackPhase m_ePhase; //! Attack phase derived from budget ratio - bool m_bIsNight; //! True during low-visibility conditions — reserved for Phase 3 artillery + bool m_bIsNight; //! True during low-visibility conditions — pending day/night API verification } diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c index 80416e2..9784484 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c @@ -2,7 +2,8 @@ //! Central decision-maker for the attacker side. //! //! Placed as a GenericEntity child of the zone entity in the world editor. -//! Owns spawner entities as its own children and coordinates them on each decision cycle. +//! Owns spawner entities and optionally an AFM_DiDZoneArtillery entity as children. +//! Coordinates all attackers on each decision cycle. //! //! Decision cycle (every m_iDecisionIntervalSeconds): //! 1. Build battlefield state snapshot (defender count, AI count, budget ratio, phase) @@ -10,14 +11,17 @@ //! 3. Filter: score > 0, spawner off cooldown, budget covers cost //! 4. Weighted random pick from top 3 candidates (scores as weights) //! 5. Trigger the chosen spawner via TriggerSpawn() +//! 6. Independently evaluate and trigger artillery missions / respawns //! //! Hierarchy in world editor: //! AFM_DiDZoneEntity +//! ├── AFM_ArtillerySpawnPointEntity ← 1..N optional mortar spawn markers //! ├── PolylineShapeEntity //! ├── AFM_PlayerSpawnPointEntity -//! └── AFM_DiDAttackerDirector ← this entity -//! ├── AFM_DiDInfantrySpawnerComponent ← spawner children -//! └── AFM_DiDMechanizedSpawnerComponent +//! └── AFM_DiDAttackerDirector ← this entity +//! ├── AFM_DiDInfantrySpawnerComponent ← spawner children +//! ├── AFM_DiDMechanizedSpawnerComponent +//! └── AFM_DiDZoneArtillery ← optional artillery child //------------------------------------------------------------------------------------------------ class AFM_DiDAttackerDirectorClass: GenericEntityClass { @@ -34,29 +38,48 @@ class AFM_DiDAttackerDirector: GenericEntity protected AFM_DiDZoneComponent m_pZone; protected ref array m_aSpawners = {}; + protected AFM_DiDZoneArtillery m_pArtillery; protected WorldTimestamp m_fLastDecisionTime; protected bool m_bInitialized = false; //------------------------------------------------------------------------------------------------ - //! Called by AFM_DiDZoneComponent.LateInit() — links this director to its zone - //! and initialises all spawner children. + //! Called by AFM_DiDZoneComponent.LateInit() after all artillery spawn points are collected. + //! Links this director to its zone, initialises spawner and artillery children. void Init(AFM_DiDZoneComponent zone) { m_pZone = zone; - // Find and register all spawner children + // Find and register all children (spawners + optional artillery) IEntity child = GetChildren(); while (child) { + // Artillery capability — not a spawner, handled separately + AFM_DiDZoneArtillery artillery = AFM_DiDZoneArtillery.Cast(child); + if (artillery) + { + m_pArtillery = artillery; + child = child.GetSibling(); + continue; + } + AFM_DiDSpawnerComponent spawner = AFM_DiDSpawnerComponent.Cast(child); if (spawner) { m_aSpawners.Insert(spawner); spawner.Prepare(zone); } + child = child.GetSibling(); } + // Initialize artillery with the zone's spawn points (collected before Init() was called) + if (m_pArtillery) + { + array artilleryPoints = zone.GetArtillerySpawnPoints(); + m_pArtillery.Initialize(zone, artilleryPoints); + PrintFormat("AFM_DiDAttackerDirector: Artillery enabled (%1 spawn points)", artilleryPoints.Count()); + } + if (m_aSpawners.Count() == 0) PrintFormat("AFM_DiDAttackerDirector: No spawner children found!", level: LogLevel.WARNING); @@ -65,18 +88,22 @@ class AFM_DiDAttackerDirector: GenericEntity m_fLastDecisionTime = world.GetServerTimestamp().PlusSeconds(-m_iDecisionIntervalSeconds); m_bInitialized = true; - PrintFormat("AFM_DiDAttackerDirector: Initialized with %1 spawners, decision every %2s", - m_aSpawners.Count(), m_iDecisionIntervalSeconds); + PrintFormat("AFM_DiDAttackerDirector: Initialized with %1 spawners, decision every %2s, artillery=%3", + m_aSpawners.Count(), m_iDecisionIntervalSeconds, m_pArtillery != null); } //------------------------------------------------------------------------------------------------ //! Called by AFM_DiDZoneComponent.HandleActiveZoneLogic() once per second. - //! Only runs a full decision cycle on the configured interval. + //! Checks artillery health every call; runs full decision cycle on configured interval. void Process() { if (!m_bInitialized || !m_pZone) return; + // Artillery alive check runs every second for responsive destruction detection + if (m_pArtillery) + m_pArtillery.CheckMortarAlive(); + ChimeraWorld world = GetGame().GetWorld(); WorldTimestamp now = world.GetServerTimestamp(); @@ -89,7 +116,7 @@ class AFM_DiDAttackerDirector: GenericEntity } //------------------------------------------------------------------------------------------------ - //! Returns total active AI count across all owned spawners. + //! Returns total active AI count: spawner AI + mortar crew. int GetActiveAICount() { int count = 0; @@ -98,11 +125,13 @@ class AFM_DiDAttackerDirector: GenericEntity if (spawner) count += spawner.GetActiveAICount(); } + if (m_pArtillery) + count += m_pArtillery.GetCrewCount(); return count; } //------------------------------------------------------------------------------------------------ - //! Cleans up all AI spawned by this director's spawners. + //! Cleans up all AI spawned by this director's spawners and the mortar. void Cleanup() { foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) @@ -110,6 +139,8 @@ class AFM_DiDAttackerDirector: GenericEntity if (spawner) spawner.Cleanup(); } + if (m_pArtillery) + m_pArtillery.Cleanup(); } //------------------------------------------------------------------------------------------------ @@ -117,6 +148,7 @@ class AFM_DiDAttackerDirector: GenericEntity { AFM_DiDBattlefieldState state = BuildBattlefieldState(now); + // --- Spawner selection --- ref array candidates = {}; foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) @@ -142,22 +174,48 @@ class AFM_DiDAttackerDirector: GenericEntity candidates.Insert(new AFM_DiDSpawnRequest(spawner, score, cost)); } - if (candidates.Count() == 0) + if (candidates.Count() > 0) + { + ref AFM_DiDSpawnRequest chosen = WeightedRandomPick(candidates); + if (chosen) + { + PrintFormat("AFM_DiDAttackerDirector: Triggering %1 (score=%2, cost=%3pts, phase=%4)", + chosen.m_Spawner.Type().ToString(), chosen.m_fScore, chosen.m_iCost, + state.m_ePhase, level: LogLevel.DEBUG); + chosen.m_Spawner.TriggerSpawn(now); + } + } + else { - PrintFormat("AFM_DiDAttackerDirector: No viable candidates (phase=%1, budget=%2%%)", + PrintFormat("AFM_DiDAttackerDirector: No viable spawner candidates (phase=%1, budget=%2%%)", state.m_ePhase, state.m_fBudgetRatio * 100, level: LogLevel.DEBUG); - return; } - ref AFM_DiDSpawnRequest chosen = WeightedRandomPick(candidates); - if (!chosen) + // --- Artillery evaluation (independent of spawner selection) --- + if (!m_pArtillery) return; - PrintFormat("AFM_DiDAttackerDirector: Triggering %1 (score=%2, cost=%3pts, phase=%4)", - chosen.m_Spawner.Type().ToString(), chosen.m_fScore, chosen.m_iCost, - state.m_ePhase, level: LogLevel.DEBUG); - - chosen.m_Spawner.TriggerSpawn(now); + if (m_pArtillery.IsMortarActive() && m_pArtillery.IsReadyForMission(now)) + { + float artScore = m_pArtillery.ScoreArtilleryMission(state); + if (artScore > 0) + m_pArtillery.TriggerMission(state, now); + } + else if (!m_pArtillery.IsMortarActive() && m_pArtillery.CanRespawn()) + { + float respawnScore = m_pArtillery.ScoreRespawn(state); + if (respawnScore > 0) + { + AFM_DiDAttackerBudget budget = m_pZone.GetBudget(); + int respawnCost = m_pArtillery.GetRespawnCost(); + if (!budget || budget.CanAfford(respawnCost)) + { + if (budget) + budget.Consume(respawnCost); + m_pArtillery.TriggerRespawn(now); + } + } + } } //------------------------------------------------------------------------------------------------ @@ -208,10 +266,14 @@ class AFM_DiDAttackerDirector: GenericEntity state.m_iAICountInZone = m_pZone.GetAICountInsideZone(); state.m_iTotalActiveAI = GetActiveAICount(); + // Defender density: raw count used as density proxy. + // AFM_DiDZoneArtillery.m_fHEDensityThreshold (default 2.0) means "fire HE when 2+ defenders alive". + state.m_fDefenderDensity = state.m_iDefenderCount; + AFM_DiDAttackerBudget budget = m_pZone.GetBudget(); if (budget) state.m_fBudgetRatio = budget.GetRatio(); - else + else state.m_fBudgetRatio = 1.0; // Time ratio: 1.0 at zone start, approaching 0.0 at deadline @@ -222,7 +284,7 @@ class AFM_DiDAttackerDirector: GenericEntity state.m_fTimeRatio = Math.Clamp(secondsRemaining / totalDefenseSeconds, 0.0, 1.0); else state.m_fTimeRatio = 1.0; - + // Derive attack phase from budget ratio if (state.m_fBudgetRatio > 0.75) state.m_ePhase = EAFMAttackPhase.PROBE; @@ -231,7 +293,13 @@ class AFM_DiDAttackerDirector: GenericEntity else state.m_ePhase = EAFMAttackPhase.FINAL; - state.m_bIsNight = false; // Phase 3 will implement day/night detection + // Day/night via TimeAndWeatherManagerEntity.IsSunSet() + ChimeraWorld chimeraWorld = ChimeraWorld.CastFrom(GetGame().GetWorld()); + if (chimeraWorld) + { + TimeAndWeatherManagerEntity timeWeather = chimeraWorld.GetTimeAndWeatherManager(); + state.m_bIsNight = timeWeather && timeWeather.IsSunSet(); + } return state; } diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c new file mode 100644 index 0000000..96a88f7 --- /dev/null +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c @@ -0,0 +1,727 @@ +//------------------------------------------------------------------------------------------------ +//! Internal round type classification for AFM_DiDZoneArtillery. +//! Does NOT directly map to SCR_EAIArtilleryAmmoType — the waypoint prefab's default +//! ammo is used by the AI crew. This enum drives targeting strategy, cost, and cooldown. +enum EAFMRoundType +{ + HIGH_EXPLOSIVE, //! Costs budget; Monte Carlo targeting of defender clusters + SMOKE, //! Low cost; targets zone centroid to disrupt defenders + ILLUMINATION, //! Night only; targets zone centroid (night detection pending API) + PRACTICE //! Free harass; 1 round at best available position +} + +//------------------------------------------------------------------------------------------------ +//! Artillery capability owned by AFM_DiDAttackerDirector. +//! +//! Manages the mortar lifecycle: free initial spawn → director-triggered fire missions → +//! destruction detection → optional budget-paid respawn (max 2 times, escalating cost). +//! +//! Hierarchy in world editor: +//! AFM_DiDZoneEntity +//! ├── AFM_ArtillerySpawnPointEntity ← 1..N spawn positions (children of zone) +//! └── AFM_DiDAttackerDirector +//! └── AFM_DiDZoneArtillery ← this entity (child of director) +//! +//! No AFM_ArtillerySpawnPointEntity children on the zone = artillery disabled for that zone. +//------------------------------------------------------------------------------------------------ +class AFM_DiDZoneArtilleryClass: GenericEntityClass +{} + +class AFM_DiDZoneArtillery: GenericEntity +{ + [Attribute("", UIWidgets.ResourceNamePicker, "Mortar vehicle prefab to spawn", params: "et", category: "DiD Artillery")] + protected ResourceName m_sMortarPrefab; + + [Attribute("", UIWidgets.Object, "Crew configuration for the mortar team", category: "DiD Artillery")] + protected ref AFM_CrewConfig m_CrewConfig; + + [Attribute("15", UIWidgets.EditBox, "Budget cost to respawn mortar (first respawn)", category: "DiD Artillery")] + protected int m_iFirstRespawnCost; + + [Attribute("25", UIWidgets.EditBox, "Budget cost to respawn mortar (second respawn)", category: "DiD Artillery")] + protected int m_iSecondRespawnCost; + + [Attribute("240", UIWidgets.EditBox, "HE mission cooldown in seconds (plan: 4 min)", category: "DiD Artillery")] + protected int m_iHECooldownSeconds; + + [Attribute("120", UIWidgets.EditBox, "Smoke mission cooldown in seconds (plan: 2 min)", category: "DiD Artillery")] + protected int m_iSmokeCooldownSeconds; + + [Attribute("300", UIWidgets.EditBox, "Illumination mission cooldown in seconds (plan: 5 min)", category: "DiD Artillery")] + protected int m_iIllumCooldownSeconds; + + [Attribute("30", UIWidgets.EditBox, "Practice shot cooldown in seconds", category: "DiD Artillery")] + protected int m_iPracticeCooldownSeconds; + + [Attribute("3", UIWidgets.EditBox, "Budget cost per HE round", category: "DiD Artillery")] + protected int m_iHECostPerRound; + + [Attribute("1", UIWidgets.EditBox, "Budget cost per smoke round", category: "DiD Artillery")] + protected int m_iSmokeCostPerRound; + + [Attribute("2", UIWidgets.EditBox, "Budget cost per illumination round", category: "DiD Artillery")] + protected int m_iIllumCostPerRound; + + [Attribute("2.0", UIWidgets.EditBox, "Min alive defenders to trigger an HE mission (density threshold)", category: "DiD Artillery")] + protected float m_fHEDensityThreshold; + + [Attribute("10", UIWidgets.EditBox, "Monte Carlo samples for HE target selection", category: "DiD Artillery")] + protected int m_iMonteCarloSamples; + + [Attribute("50", UIWidgets.EditBox, "Sample radius (meters) around each MC point for defender counting", category: "DiD Artillery")] + protected float m_fSampleRadius; + + // Runtime state + protected AFM_DiDZoneComponent m_pZone; + protected ref array m_aSpawnPoints = {}; + + protected IEntity m_pSpawnedMortar; + protected AIGroup m_pMortarCrew; + protected SCR_AIWaypointArtillerySupport m_pCurrentWaypoint; + protected AFM_ArtillerySpawnPointEntity m_pLastSpawnPoint; + + protected bool m_bMortarActive; + protected int m_iRespawnCount; + protected int m_iMissionsThisLife; + + // Per-type cooldown timestamps + protected WorldTimestamp m_fHELastFired; + protected WorldTimestamp m_fSmokeLastFired; + protected WorldTimestamp m_fIllumLastFired; + protected WorldTimestamp m_fPracticeLastFired; + + // Polygon cache for MC sampling (built once from zone polyline) + protected ref array m_aPolylinePoints2D = null; + + //------------------------------------------------------------------------------------------------ + //! Called by AFM_DiDAttackerDirector.Init() after all spawners and spawn points are collected. + void Initialize(AFM_DiDZoneComponent zone, array spawnPoints) + { + m_pZone = zone; + foreach (AFM_ArtillerySpawnPointEntity sp : spawnPoints) + m_aSpawnPoints.Insert(sp); + + // Push cooldowns far into the past so first decision cycle can fire immediately + WorldTimestamp farPast = GetCurrentTimestamp().PlusSeconds(-3600); + m_fHELastFired = farPast; + m_fSmokeLastFired = farPast; + m_fIllumLastFired = farPast; + m_fPracticeLastFired = farPast; + + if (m_aSpawnPoints.IsEmpty()) + { + PrintFormat("AFM_DiDZoneArtillery: No spawn points — artillery disabled for this zone", level: LogLevel.DEBUG); + return; + } + + if (m_sMortarPrefab.IsEmpty()) + { + PrintFormat("AFM_DiDZoneArtillery: No mortar prefab configured — artillery disabled!", level: LogLevel.WARNING); + return; + } + + if (!m_CrewConfig) + { + PrintFormat("AFM_DiDZoneArtillery: No crew config — artillery disabled!", level: LogLevel.WARNING); + return; + } + + // Initial spawn is free (no budget cost) + SpawnMortarTeam(m_aSpawnPoints.GetRandomElement()); + PrintFormat("AFM_DiDZoneArtillery: Initialized with %1 spawn points", m_aSpawnPoints.Count()); + } + + //------------------------------------------------------------------------------------------------ + //! Polls mortar health — called every second from AFM_DiDAttackerDirector.Process(). + //! Preferred over damage callbacks for reliability across vehicle damage manager types. + void CheckMortarAlive() + { + if (!m_bMortarActive || !m_pSpawnedMortar) + return; + + SCR_DamageManagerComponent dmg = SCR_DamageManagerComponent.Cast( + m_pSpawnedMortar.FindComponent(SCR_DamageManagerComponent) + ); + + if (!dmg) + return; + + if (dmg.GetState() == EDamageState.DESTROYED) + HandleMortarDestroyed(); + } + + //------------------------------------------------------------------------------------------------ + //! Returns true when the mortar entity is alive and can receive fire missions. + bool IsMortarActive() + { + return m_bMortarActive && m_pSpawnedMortar != null; + } + + //------------------------------------------------------------------------------------------------ + //! Returns true when another respawn is allowed (max 2 paid respawns per zone). + bool CanRespawn() + { + return m_iRespawnCount < 2; + } + + //------------------------------------------------------------------------------------------------ + //! Budget cost for the next respawn — escalates with each use. + int GetRespawnCost() + { + switch (m_iRespawnCount) + { + case 0: return m_iFirstRespawnCost; + case 1: return m_iSecondRespawnCost; + default: return int.MAX; + } + return int.MAX; + } + + //------------------------------------------------------------------------------------------------ + //! Returns true when at least one round type is available (off cooldown). + bool IsReadyForMission(WorldTimestamp now) + { + if (!m_bMortarActive) + return false; + + return !IsOnCooldown(EAFMRoundType.PRACTICE, now) + || !IsOnCooldown(EAFMRoundType.HIGH_EXPLOSIVE, now) + || !IsOnCooldown(EAFMRoundType.SMOKE, now) + || !IsOnCooldown(EAFMRoundType.ILLUMINATION, now); + } + + //------------------------------------------------------------------------------------------------ + //! Score how valuable a fire mission is right now. Called by director each decision cycle. + //! Returns 0 if no mission makes sense. + float ScoreArtilleryMission(AFM_DiDBattlefieldState state) + { + if (!m_bMortarActive) + return 0; + + WorldTimestamp now = GetCurrentTimestamp(); + + // HE: valuable when defenders are clustered and budget allows + if (!IsOnCooldown(EAFMRoundType.HIGH_EXPLOSIVE, now) + && state.m_fDefenderDensity >= m_fHEDensityThreshold + && state.m_fBudgetRatio > 0.2) + return 1.5; + + // Smoke: worthwhile during ASSAULT/FINAL to disrupt defenders + if (!IsOnCooldown(EAFMRoundType.SMOKE, now) + && state.m_ePhase != EAFMAttackPhase.PROBE) + return 1.0; + + // Illumination: night only (m_bIsNight is currently always false — Phase 3 placeholder) + if (!IsOnCooldown(EAFMRoundType.ILLUMINATION, now) + && state.m_bIsNight + && state.m_fBudgetRatio > 0.3) + return 1.2; + + // Practice: free harass shot, low priority + if (!IsOnCooldown(EAFMRoundType.PRACTICE, now)) + return 0.5; + + return 0; + } + + //------------------------------------------------------------------------------------------------ + //! Score how valuable it is to respawn the mortar now. Called when mortar is not active. + float ScoreRespawn(AFM_DiDBattlefieldState state) + { + if (m_bMortarActive || !CanRespawn()) + return 0; + + // Only respawn during ASSAULT or FINAL when defenders present + if (state.m_ePhase == EAFMAttackPhase.PROBE || state.m_iDefenderCount == 0) + return 0; + + return 1.0; + } + + //------------------------------------------------------------------------------------------------ + //! Execute a fire mission: select round type → find target → consume budget → assign waypoint. + void TriggerMission(AFM_DiDBattlefieldState state, WorldTimestamp now) + { + if (!m_bMortarActive || !m_pMortarCrew) + return; + + EAFMRoundType roundType = SelectRoundType(state, now); + int shotCount = GetShotCount(roundType); + int cost = GetMissionCost(roundType, shotCount); + + // Budget check and consumption + AFM_DiDAttackerBudget budget = m_pZone.GetBudget(); + if (budget && cost > 0) + { + if (!budget.CanAfford(cost)) + { + PrintFormat("AFM_DiDZoneArtillery: Can't afford %1 mission (cost %2 pts)", RoundTypeToString(roundType), cost, level: LogLevel.DEBUG); + return; + } + budget.Consume(cost); + } + + vector targetPos = SelectTargetPosition(roundType); + if (targetPos == vector.Zero) + { + PrintFormat("AFM_DiDZoneArtillery: No valid target for %1 — aborting", RoundTypeToString(roundType), level: LogLevel.DEBUG); + // Refund budget if we already consumed (fall-through: we haven't assigned waypoint yet) + if (budget && cost > 0) + budget.AddBonus(cost); + return; + } + + SetLastFired(roundType, now); + m_iMissionsThisLife++; + + AssignFireMission(targetPos, shotCount); + + PrintFormat("AFM_DiDZoneArtillery: %1 mission fired — %2 rounds at %3 (cost %4 pts)", + RoundTypeToString(roundType), shotCount, targetPos.ToString(), cost); + } + + //------------------------------------------------------------------------------------------------ + //! Respawn mortar at a new position. Budget consumption is handled by the director. + void TriggerRespawn(WorldTimestamp now) + { + if (!CanRespawn() || m_aSpawnPoints.IsEmpty()) + return; + + AFM_ArtillerySpawnPointEntity spawnPoint = GetNextSpawnPoint(); + SpawnMortarTeam(spawnPoint); + // Deliberately no broadcast — defenders are NOT notified of respawn + PrintFormat("AFM_DiDZoneArtillery: Mortar respawned (respawn #%1)", m_iRespawnCount); + } + + //------------------------------------------------------------------------------------------------ + //! Returns live agent count in the mortar crew — added to director's GetActiveAICount(). + int GetCrewCount() + { + if (!m_pMortarCrew) + return 0; + return m_pMortarCrew.GetAgentsCount(); + } + + //------------------------------------------------------------------------------------------------ + //! Clean up mortar and waypoint entities when the zone ends. + void Cleanup() + { + ClearCurrentWaypoint(); + + if (m_pSpawnedMortar) + { + SCR_EntityHelper.DeleteEntityAndChildren(m_pSpawnedMortar); + m_pSpawnedMortar = null; + } + + m_pMortarCrew = null; + m_bMortarActive = false; + } + + //------------------------------------------------------------------------------------------------ + protected void SpawnMortarTeam(AFM_ArtillerySpawnPointEntity spawnPoint) + { + if (!spawnPoint) + { + PrintFormat("AFM_DiDZoneArtillery: No spawn point provided!", level: LogLevel.ERROR); + return; + } + + EntitySpawnParams spawnParams = new EntitySpawnParams(); + vector mat[4]; + spawnPoint.GetWorldTransform(mat); + spawnParams.Transform = mat; + + m_pSpawnedMortar = GetGame().SpawnEntityPrefab(Resource.Load(m_sMortarPrefab), GetGame().GetWorld(), spawnParams); + if (!m_pSpawnedMortar) + { + PrintFormat("AFM_DiDZoneArtillery: Failed to spawn mortar!", level: LogLevel.ERROR); + return; + } + + SCR_BaseCompartmentManagerComponent cm = SCR_BaseCompartmentManagerComponent.Cast( + m_pSpawnedMortar.FindComponent(SCR_BaseCompartmentManagerComponent) + ); + if (!cm) + { + PrintFormat("AFM_DiDZoneArtillery: Mortar has no compartment manager!", level: LogLevel.ERROR); + SCR_EntityHelper.DeleteEntityAndChildren(m_pSpawnedMortar); + m_pSpawnedMortar = null; + return; + } + + m_pMortarCrew = m_CrewConfig.SpawnCrew(cm, null); + if (!m_pMortarCrew) + { + PrintFormat("AFM_DiDZoneArtillery: Failed to spawn mortar crew!", level: LogLevel.ERROR); + SCR_EntityHelper.DeleteEntityAndChildren(m_pSpawnedMortar); + m_pSpawnedMortar = null; + return; + } + + m_pLastSpawnPoint = spawnPoint; + m_bMortarActive = true; + m_iMissionsThisLife = 0; + // Invalidate polygon cache on respawn (position hasn't moved but be safe) + m_aPolylinePoints2D = null; + + PrintFormat("AFM_DiDZoneArtillery: Mortar spawned at %1", m_pSpawnedMortar.GetOrigin().ToString(), level: LogLevel.DEBUG); + } + + //------------------------------------------------------------------------------------------------ + protected void HandleMortarDestroyed() + { + m_bMortarActive = false; + int missionsFired = m_iMissionsThisLife; + + // Clean up waypoint — crew can no longer execute it + ClearCurrentWaypoint(); + m_pMortarCrew = null; + + // Notify defenders: mortar is destroyed (they can stop counter-battery) + AFM_GameModeDiD gamemode = AFM_GameModeDiD.Cast(GetGame().GetGameMode()); + if (gamemode) + gamemode.NotifyMortarDestroyed(); + + PrintFormat("AFM_DiDZoneArtillery: Mortar destroyed after %1 missions", missionsFired); + } + + //------------------------------------------------------------------------------------------------ + protected EAFMRoundType SelectRoundType(AFM_DiDBattlefieldState state, WorldTimestamp now) + { + // Night → illumination (m_bIsNight always false until day/night API is verified) + if (state.m_bIsNight + && !IsOnCooldown(EAFMRoundType.ILLUMINATION, now) + && state.m_fBudgetRatio > 0.3) + return EAFMRoundType.ILLUMINATION; + + // Dense defenders + budget available → HE + if (!IsOnCooldown(EAFMRoundType.HIGH_EXPLOSIVE, now) + && state.m_fDefenderDensity >= m_fHEDensityThreshold + && state.m_fBudgetRatio > 0.2) + return EAFMRoundType.HIGH_EXPLOSIVE; + + // ASSAULT/FINAL → smoke to disrupt defender positions + if (!IsOnCooldown(EAFMRoundType.SMOKE, now) + && state.m_ePhase != EAFMAttackPhase.PROBE) + return EAFMRoundType.SMOKE; + + // Free harass + return EAFMRoundType.PRACTICE; + } + + //------------------------------------------------------------------------------------------------ + protected vector SelectTargetPosition(EAFMRoundType roundType) + { + switch (roundType) + { + case EAFMRoundType.HIGH_EXPLOSIVE: + case EAFMRoundType.PRACTICE: + return FindBestHETarget(); + case EAFMRoundType.SMOKE: + case EAFMRoundType.ILLUMINATION: + return GetZoneCentroid(); + } + return vector.Zero; + } + + //------------------------------------------------------------------------------------------------ + protected int GetShotCount(EAFMRoundType roundType) + { + switch (roundType) + { + case EAFMRoundType.HIGH_EXPLOSIVE: return s_AIRandomGenerator.RandInt(2, 8); + case EAFMRoundType.SMOKE: return s_AIRandomGenerator.RandInt(4, 6); + case EAFMRoundType.ILLUMINATION: return s_AIRandomGenerator.RandInt(2, 3); + case EAFMRoundType.PRACTICE: return 1; + } + return 1; + } + + //------------------------------------------------------------------------------------------------ + protected int GetMissionCost(EAFMRoundType roundType, int shotCount) + { + switch (roundType) + { + case EAFMRoundType.HIGH_EXPLOSIVE: return shotCount * m_iHECostPerRound; + case EAFMRoundType.SMOKE: return shotCount * m_iSmokeCostPerRound; + case EAFMRoundType.ILLUMINATION: return shotCount * m_iIllumCostPerRound; + case EAFMRoundType.PRACTICE: return 0; + } + return 0; + } + + //------------------------------------------------------------------------------------------------ + protected void AssignFireMission(vector targetPos, int shotCount) + { + ClearCurrentWaypoint(); + + Resource wpResource = Resource.Load("{C524700A27CFECDD}Prefabs/AI/Waypoints/AIWaypoint_ArtillerySupport.et"); + if (!wpResource || !wpResource.IsValid()) + { + PrintFormat("AFM_DiDZoneArtillery: Failed to load artillery waypoint prefab!", level: LogLevel.ERROR); + return; + } + + EntitySpawnParams spawnParams = new EntitySpawnParams(); + spawnParams.TransformMode = ETransformMode.WORLD; + targetPos[1] = GetGame().GetWorld().GetSurfaceY(targetPos[0], targetPos[2]); + spawnParams.Transform[3] = targetPos; + + IEntity wpEntity = GetGame().SpawnEntityPrefab(wpResource, GetGame().GetWorld(), spawnParams); + if (!wpEntity) + { + PrintFormat("AFM_DiDZoneArtillery: Failed to spawn waypoint!", level: LogLevel.ERROR); + return; + } + + SCR_AIWaypointArtillerySupport wp = SCR_AIWaypointArtillerySupport.Cast(wpEntity); + if (!wp) + { + SCR_EntityHelper.DeleteEntityAndChildren(wpEntity); + return; + } + + wp.SetTargetShotCount(shotCount); + + if (m_pMortarCrew) + m_pMortarCrew.AddWaypoint(wp); + + m_pCurrentWaypoint = wp; + } + + //------------------------------------------------------------------------------------------------ + protected void ClearCurrentWaypoint() + { + if (!m_pCurrentWaypoint) + return; + + if (m_pMortarCrew) + m_pMortarCrew.RemoveWaypoint(m_pCurrentWaypoint); + + SCR_EntityHelper.DeleteEntityAndChildren(m_pCurrentWaypoint); + m_pCurrentWaypoint = null; + } + + //------------------------------------------------------------------------------------------------ + //! Monte Carlo sampling — finds the position with the highest defender concentration. + //! Falls back to zone centroid if no defenders are found. + protected vector FindBestHETarget() + { + if (!m_pZone) + return vector.Zero; + + PolylineShapeEntity polyline = m_pZone.GetPolylineEntity(); + if (!polyline) + return vector.Zero; + + array points = {}; + polyline.GetPointsPositions(points); + if (points.Count() < 3) + return vector.Zero; + + vector zoneOrigin = polyline.GetOrigin(); + + // Build polygon cache once per zone life + if (!m_aPolylinePoints2D) + { + m_aPolylinePoints2D = new array(); + foreach (vector p : points) + { + m_aPolylinePoints2D.Insert(zoneOrigin[0] + p[0]); + m_aPolylinePoints2D.Insert(zoneOrigin[2] + p[2]); + } + } + + // Compute bounding box for random sampling + float minX = zoneOrigin[0] + points[0][0]; + float minZ = zoneOrigin[2] + points[0][2]; + float maxX = minX, maxZ = minZ; + foreach (vector p : points) + { + float wx = zoneOrigin[0] + p[0]; + float wz = zoneOrigin[2] + p[2]; + if (wx < minX) minX = wx; + if (wx > maxX) maxX = wx; + if (wz < minZ) minZ = wz; + if (wz > maxZ) maxZ = wz; + } + + vector bestPos = vector.Zero; + int maxCount = -1; + + for (int i = 0; i < m_iMonteCarloSamples; i++) + { + vector sample; + sample[0] = s_AIRandomGenerator.RandFloatXY(minX, maxX); + sample[2] = s_AIRandomGenerator.RandFloatXY(minZ, maxZ); + + if (!Math2D.IsPointInPolygon(m_aPolylinePoints2D, sample[0], sample[2])) + continue; + + sample[1] = GetGame().GetWorld().GetSurfaceY(sample[0], sample[2]); + + int count = CountDefendersInRadius(sample); + if (count > maxCount) + { + maxCount = count; + bestPos = sample; + } + } + + // Fall back to centroid if no defenders found in any sample + if (maxCount <= 0) + return GetZoneCentroid(); + + return bestPos; + } + + //------------------------------------------------------------------------------------------------ + protected int CountDefendersInRadius(vector center) + { + if (!m_pZone) + return 0; + + SCR_Faction defFaction = m_pZone.GetDefenderFaction(); + if (!defFaction) + return 0; + + array playerIds = {}; + defFaction.GetPlayersInFaction(playerIds); + + float radSq = m_fSampleRadius * m_fSampleRadius; + int count = 0; + + foreach (int pid : playerIds) + { + PlayerController pc = GetGame().GetPlayerManager().GetPlayerController(pid); + if (!pc) + continue; + + IEntity ent = pc.GetControlledEntity(); + if (!ent) + continue; + + SCR_ChimeraCharacter ch = SCR_ChimeraCharacter.Cast(ent); + if (!ch) + continue; + + SCR_DamageManagerComponent dmg = ch.GetDamageManager(); + if (!dmg || dmg.GetState() == EDamageState.DESTROYED) + continue; + + if (vector.DistanceSq(center, ch.GetOrigin()) <= radSq) + count++; + } + + return count; + } + + //------------------------------------------------------------------------------------------------ + protected vector GetZoneCentroid() + { + PolylineShapeEntity polyline = m_pZone.GetPolylineEntity(); + if (!polyline) + return vector.Zero; + + array points = {}; + polyline.GetPointsPositions(points); + if (points.IsEmpty()) + return vector.Zero; + + vector zoneOrigin = polyline.GetOrigin(); + float sumX = 0, sumZ = 0; + foreach (vector p : points) + { + sumX += zoneOrigin[0] + p[0]; + sumZ += zoneOrigin[2] + p[2]; + } + + vector centroid; + centroid[0] = sumX / points.Count(); + centroid[2] = sumZ / points.Count(); + centroid[1] = GetGame().GetWorld().GetSurfaceY(centroid[0], centroid[2]); + return centroid; + } + + //------------------------------------------------------------------------------------------------ + //! Returns a spawn point different from the last used one (if possible). + protected AFM_ArtillerySpawnPointEntity GetNextSpawnPoint() + { + if (m_aSpawnPoints.Count() == 1) + return m_aSpawnPoints[0]; + + // Try to avoid the position used last time + AFM_ArtillerySpawnPointEntity candidate; + for (int attempt = 0; attempt < 5; attempt++) + { + candidate = m_aSpawnPoints.GetRandomElement(); + if (candidate != m_pLastSpawnPoint) + return candidate; + } + return candidate; + } + + //------------------------------------------------------------------------------------------------ + protected bool IsOnCooldown(EAFMRoundType roundType, WorldTimestamp now) + { + int cooldown; + WorldTimestamp lastFired; + + switch (roundType) + { + case EAFMRoundType.HIGH_EXPLOSIVE: + cooldown = m_iHECooldownSeconds; + lastFired = m_fHELastFired; + break; + case EAFMRoundType.SMOKE: + cooldown = m_iSmokeCooldownSeconds; + lastFired = m_fSmokeLastFired; + break; + case EAFMRoundType.ILLUMINATION: + cooldown = m_iIllumCooldownSeconds; + lastFired = m_fIllumLastFired; + break; + case EAFMRoundType.PRACTICE: + cooldown = m_iPracticeCooldownSeconds; + lastFired = m_fPracticeLastFired; + break; + default: + return true; + } + + return Math.AbsInt(now.DiffSeconds(lastFired)) < cooldown; + } + + //------------------------------------------------------------------------------------------------ + protected void SetLastFired(EAFMRoundType roundType, WorldTimestamp now) + { + switch (roundType) + { + case EAFMRoundType.HIGH_EXPLOSIVE: m_fHELastFired = now; break; + case EAFMRoundType.SMOKE: m_fSmokeLastFired = now; break; + case EAFMRoundType.ILLUMINATION: m_fIllumLastFired = now; break; + case EAFMRoundType.PRACTICE: m_fPracticeLastFired = now; break; + } + } + + //------------------------------------------------------------------------------------------------ + protected string RoundTypeToString(EAFMRoundType roundType) + { + switch (roundType) + { + case EAFMRoundType.HIGH_EXPLOSIVE: return "HE"; + case EAFMRoundType.SMOKE: return "SMOKE"; + case EAFMRoundType.ILLUMINATION: return "ILLUM"; + case EAFMRoundType.PRACTICE: return "PRACTICE"; + } + return "UNKNOWN"; + } + + //------------------------------------------------------------------------------------------------ + protected WorldTimestamp GetCurrentTimestamp() + { + ChimeraWorld world = GetGame().GetWorld(); + return world.GetServerTimestamp(); + } +} diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c index 0f958d4..eca7a35 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c @@ -54,6 +54,8 @@ class AFM_DiDZoneComponent: ScriptComponent protected ref array m_aSpawners = {}; // Director mode: central coordinator that owns its own spawner children protected AFM_DiDAttackerDirector m_Director; + // Phase 3: artillery spawn positions on the zone entity (passed to director → artillery) + protected ref array m_aArtillerySpawnPoints = {}; protected SCR_ResourceComponent m_SupplyCache; // Cached 2D polyline points for zone boundary checks (world-space X/Z pairs) @@ -91,6 +93,8 @@ class AFM_DiDZoneComponent: ScriptComponent if (!e) PrintFormat("AFM_DiDZoneComponent %1: No children found!", m_sZoneName, level: LogLevel.ERROR); + // First pass: collect all children. + // Director init is deferred until after all AFM_ArtillerySpawnPointEntity children are found. while (e) { switch (e.Type()) @@ -101,10 +105,14 @@ class AFM_DiDZoneComponent: ScriptComponent case AFM_PlayerSpawnPointEntity: m_PlayerSpawnPoint = AFM_PlayerSpawnPointEntity.Cast(e); break; + // Phase 3: mortar spawn position markers — collected here, forwarded to director + case AFM_ArtillerySpawnPointEntity: + m_aArtillerySpawnPoints.Insert(AFM_ArtillerySpawnPointEntity.Cast(e)); + break; // Director mode: central spawner coordinator — owns its own spawner children case AFM_DiDAttackerDirector: m_Director = AFM_DiDAttackerDirector.Cast(e); - m_Director.Init(this); + // Init deferred below — artillery spawn points must be collected first break; // Legacy mode: spawners are direct children of the zone case AFM_DiDMechanizedSpawnerComponent: @@ -123,6 +131,14 @@ class AFM_DiDZoneComponent: ScriptComponent e = e.GetSibling(); } + // Init director now that all artillery spawn points have been collected + if (m_Director) + { + m_Director.Init(this); + if (m_aArtillerySpawnPoints.Count() > 0) + PrintFormat("AFM_DiDZoneComponent %1: %2 artillery spawn point(s) registered", m_sZoneName, m_aArtillerySpawnPoints.Count()); + } + if (!m_PolylineEntity) PrintFormat("AFM_DiDZoneComponent %1: Missing polyline component, zone wont work properly!", m_sZoneName, level: LogLevel.ERROR); if (!m_PlayerSpawnPoint) @@ -545,6 +561,12 @@ class AFM_DiDZoneComponent: ScriptComponent return m_iDefenseTimeSeconds; } + //! Artillery spawn points collected from zone children — forwarded to director on Init() + array GetArtillerySpawnPoints() + { + return m_aArtillerySpawnPoints; + } + //------------------------------------------------------------------------------------------------ protected bool IsZoneTimeExpired() { diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c index b0a9c26..ba23e66 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c @@ -218,10 +218,34 @@ class AFM_GameModeDiD: PS_GameModeCoop SwitchToInitialEntity(playerId); } + //------------------------------------------------------------------------------------------------ + // Artillery notifications + //------------------------------------------------------------------------------------------------ + + //! Called by AFM_DiDZoneArtillery on server when the mortar is destroyed. + //! Broadcasts a hint to all defenders. No location is revealed. + void NotifyMortarDestroyed() + { + RPC_DoMortarDestroyed(); + Rpc(RPC_DoMortarDestroyed); + } + //------------------------------------------------------------------------------------------------ // Broadcast RPCs //------------------------------------------------------------------------------------------------ + //! Enemy mortar destroyed — defenders notified, no position information given + [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] + protected void RPC_DoMortarDestroyed() + { + SCR_HintManagerComponent.GetInstance().ShowCustom( + "Enemy mortar destroyed!", + "", + 8, + false + ); + } + //! Defenders repelled the assault (budget exhausted + zone cleared) [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] protected void RPC_DoZoneRepelled() diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/Entity/AFM_ArtillerySpawnPointEntity.c b/addons/DefenseInDepth/Scripts/Game/DiD/Entity/AFM_ArtillerySpawnPointEntity.c new file mode 100644 index 0000000..c8fef04 --- /dev/null +++ b/addons/DefenseInDepth/Scripts/Game/DiD/Entity/AFM_ArtillerySpawnPointEntity.c @@ -0,0 +1,14 @@ +//------------------------------------------------------------------------------------------------ +//! Marker entity for valid mortar spawn positions. +//! +//! Place 1..N of these as direct children of the zone entity (AFM_DiDZoneEntity). +//! AFM_DiDZoneArtillery picks one at random for the initial mortar spawn, +//! and a different one each time it respawns. +//! +//! No mortar spawns if the zone has no AFM_ArtillerySpawnPointEntity children. +//------------------------------------------------------------------------------------------------ +class AFM_ArtillerySpawnPointEntityClass: GenericEntityClass +{} + +class AFM_ArtillerySpawnPointEntity: GenericEntity +{} diff --git a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer index 96ea204..88710e0 100644 --- a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer +++ b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer @@ -826,58 +826,15 @@ AFM_DiDZoneEntity zone1 { } } } - AFM_DiDMechanizedSpawnerComponent : "{8E7842A8534F4260}Prefabs/Spawners/AFM_MechanizedSpawner.et" { - coords 320.007 29.277 -99.463 + $grp AFM_ArtillerySpawnPointEntity : "{1933061551525F98}Prefabs/MP/AFM_ArtillerySpawnPointEntity.et" { { - $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { - zone_1_sp_10 { - coords 343.698 28.406 -338.142 - angles 0 -52.834 0 - } - zone_1_sp_11 { - coords 394.81 11.358 190.428 - angles 0 -100.862 0 - } - } - SCR_DefendWaypoint zone1_aiwp_1_def2 : "{0EBC4E7C452C16ED}Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et" { - coords -221.929 -22.955 131.933 - CompletionRadius 80 - } + coords 467.663 47.615 -159.499 } - } - AFM_DiDInfantrySpawnerComponent : "{A93E8199BD60FD8A}Prefabs/Spawners/AFM_DidInfantrySpawner.et" { - coords 245.991 10.319 28.013 { - $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { - zone_1_sp_2 { - coords 129.874 17.878 -89.151 - } - zone_1_sp_3 { - coords 74.633 19.863 -207.727 - } - zone_1_sp_8 { - coords 96.745 -13.592 40.402 - } - zone_1_sp_1 { - coords -153.307 -17.694 -279.657 - } - zone_1_sp_9 { - coords -26.872 -48.507 252.457 - } - } - $grp SCR_DefendWaypoint : "{0EBC4E7C452C16ED}Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et" { - zone1_aiwp_1_def { - coords -154.778 -6.384 57.859 - CompletionRadius 80 - } - zone1_aiwp_2_def { - coords -88.145 -1.257 -44.769 - CompletionRadius 120 - } - } - SCR_SearchAndDestroyWaypoint zone_1_aiwp_3_sead : "{D45195533ADF06E8}Prefabs/AI/Waypoints/DiD_AIWaypoint_SearchAndDestroy.et" { - coords -110.969 -2.429 0.844 - } + coords 546.012 1.623 162.492 + } + { + coords 666.152 73.763 -189.732 } } AFM_PlayerSpawnPointEntity : "{B6B6E54C76E4D206}Prefabs/MP/AFM_DiDPlayerSpawnPointPrefab.et" { @@ -885,22 +842,62 @@ AFM_DiDZoneEntity zone1 { } AFM_DiDAttackerDirector : "{C88B368A8AC21F4E}Prefabs/MP/AFM_DiDAttackerDirector_Default.et" { coords -112.033 -11.168 33.665 - } - AFM_DiDMortarSpawnerComponent : "{F040102F1843F7C1}Prefabs/Spawners/AFM_MortarSpawner.et" { - coords 0.283 0.067 -0.146 { - $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { - zone_1_sp_12 { - coords 467.702 47.617 -160.056 - angles 0 -76.831 0 - } - zone_1_sp_13 { - coords 665.916 73.77 -190.245 - angles 0 -58.187 0 + AFM_DiDZoneArtillery : "{2D68BCDEA841A4BC}Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et" { + coords 293.021 22.279 -55.39 + } + AFM_DiDMechanizedSpawnerComponent : "{8E7842A8534F4260}Prefabs/Spawners/AFM_MechanizedSpawner.et" { + coords 432.04 40.445 -133.128 + { + $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { + zone_1_sp_10 { + coords 343.698 28.406 -338.142 + angles 0 -52.834 0 + } + zone_1_sp_11 { + coords 394.81 11.358 190.428 + angles 0 -100.862 0 + } + } + SCR_DefendWaypoint zone1_aiwp_1_def2 : "{0EBC4E7C452C16ED}Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et" { + coords -221.929 -22.955 131.933 + CompletionRadius 80 + } } - zone_1_sp_14 { - coords 545.68 1.535 162.778 - angles 0 -159.344 0 + } + AFM_DiDInfantrySpawnerComponent : "{A93E8199BD60FD8A}Prefabs/Spawners/AFM_DidInfantrySpawner.et" { + coords 358.024 21.487 -5.652 + { + $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { + zone_1_sp_2 { + coords 129.874 17.878 -89.151 + } + zone_1_sp_3 { + coords 74.633 19.863 -207.727 + } + zone_1_sp_8 { + coords 96.745 -13.592 40.402 + } + zone_1_sp_1 { + coords -153.307 -17.694 -279.657 + } + zone_1_sp_9 { + coords -26.872 -48.507 252.457 + } + } + $grp SCR_DefendWaypoint : "{0EBC4E7C452C16ED}Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et" { + zone1_aiwp_1_def { + coords -154.778 -6.384 57.859 + CompletionRadius 80 + } + zone1_aiwp_2_def { + coords -88.145 -1.257 -44.769 + CompletionRadius 120 + } + } + SCR_SearchAndDestroyWaypoint zone_1_aiwp_3_sead : "{D45195533ADF06E8}Prefabs/AI/Waypoints/DiD_AIWaypoint_SearchAndDestroy.et" { + coords -110.969 -2.429 0.844 + } } } } diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index e3b1c06..6b672ee 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -83,148 +83,189 @@ to use the legacy per-spawner `Process()` path unchanged. --- -## Phase 3 — Artillery Overhaul +## Phase 3 — Artillery Overhaul ✅ COMPLETE **Goal:** Mortar becomes a destroyable zone asset, not a spawner. Fire missions cost budget, with 4 round types. Players can counter-battery. -### New files +### Files created | File | Purpose | |---|---| | `Scripts/Game/DiD/Entity/AFM_ArtillerySpawnPointEntity.c` | Marker entity for valid mortar positions (transform only) | | `Scripts/Game/DiD/AFM_DiDZoneArtillery.c` | Artillery capability: mortar lifecycle, mission selection, round type targeting | -### Modified files +### Files modified | File | Change | |---|---| -| `AFM_DiDZoneComponent.c` | `LateInit()` scans for `AFM_ArtillerySpawnPointEntity` children → passes array to director | -| `AFM_DiDAttackerDirector.c` | Holds `AFM_DiDZoneArtillery` reference; includes artillery options in decision cycle | -| `AFM_DiDMortarSpawnerComponent.c` | **Removed** — replaced by `AFM_DiDZoneArtillery` | -| `AFM_GameModeDiD.c` | Add `RPC_DoMortarDestroyed()` and `RPC_DoMortarRespawned()` (no location revealed) | +| `Scripts/Game/DiD/AFM_DiDAttackPhase.c` | Added `m_fDefenderDensity` field to `AFM_DiDBattlefieldState` | +| `Scripts/Game/DiD/AFM_DiDAttackerDirector.c` | Detects `AFM_DiDZoneArtillery` child; `CheckMortarAlive()` every second; artillery evaluated in `RunDecisionCycle()`; `GetActiveAICount()` includes mortar crew; `Cleanup()` includes artillery | +| `Scripts/Game/DiD/AFM_DiDZoneComponent.c` | Scans for `AFM_ArtillerySpawnPointEntity` children; deferred director `Init()` (after spawn points collected); `GetArtillerySpawnPoints()` getter | +| `Scripts/Game/DiD/AFM_GameModeDiD.c` | `NotifyMortarDestroyed()` server method + `RPC_DoMortarDestroyed()` broadcast hint | ### Zone hierarchy ``` AFM_DiDZoneEntity -├── AFM_ArtillerySpawnPointEntity ← 1..N, random one used per spawn +├── AFM_ArtillerySpawnPointEntity ← 1..N (no artillery if absent) ├── AFM_ArtillerySpawnPointEntity ├── AFM_DiDAttackerDirector │ ├── AFM_DiDInfantrySpawnerComponent │ ├── AFM_DiDMechanizedSpawnerComponent -│ └── AFM_DiDZoneArtillery ← artillery capability, child of director +│ └── AFM_DiDZoneArtillery ← artillery capability child of director ``` -No `AFM_ArtillerySpawnPointEntity` children = no artillery for this zone. ### Mortar lifecycle -``` -Zone activates - → AFM_DiDZoneArtillery.Initialize(spawnPoints[]) - → SpawnMortarTeam(RandomSpawnPoint()) [FREE — no budget cost] - → idle until director calls a mission - -Director calls mission - → SelectRoundType(battlefieldState) → SCR_EAIArtilleryAmmoType - → CalculateRoundCount(type, targetQuality) → int - → cost = roundCount * GetCostPerRound(type) - → budget.Reserve(cost) - → SelectTargetPosition(type) → vector - → SpawnWaypoint(targetPos, type, roundCount) - → crew fires mission - → cooldown[type] starts - -Players destroy mortar - → director detects: dmg.GetState() == EDamageState.DESTROYED - → OnMortarDestroyed(): - broadcast hint to defenders: "Enemy mortar destroyed!" - record m_iMissionsFiredBeforeDestruction - m_SpawnedMortar = null - → next director decision cycle evaluates ScoreArtilleryRespawn() - -Director respawns mortar (optional, costs budget) - → cost = GetRespawnCost() [escalating: 15 → 25 pts] - → budget.Reserve(cost) - → SpawnMortarTeam(GetNextSpawnPoint()) [avoids last position] - → NO notification to defenders -``` +- **Zone activates** → `AFM_DiDZoneArtillery.Initialize()` → `SpawnMortarTeam()` [FREE] +- **Director cycle** → `ScoreArtilleryMission()` → `TriggerMission()` → budget consumed, waypoint assigned +- **Destruction polling** → `CheckMortarAlive()` called every second → `HandleMortarDestroyed()` → broadcasts hint, nulls crew +- **Respawn** → director evaluates `ScoreRespawn()` → budget consumed → new mortar at different spawn point → NO hint to defenders ### Round types | Type | Cost/round | Typical volley | Cooldown | Targeting | |---|---|---|---|---| | `HIGH_EXPLOSIVE` | 3 pts | 2–8 rounds | 4 min | Monte Carlo (defender cluster) | -| `SMOKE` | 1 pt | 4–6 rounds | 2 min | Attack axis midpoint | -| `ILLUMINATION` | 2 pts | 2–3 rounds | 5 min | Zone centroid, night only | +| `SMOKE` | 1 pt | 4–6 rounds | 2 min | Zone centroid | +| `ILLUMINATION` | 2 pts | 2–3 rounds | 5 min | Zone centroid (night only — pending day/night API) | | `PRACTICE` | 0 pts | 1 round | 30 sec | Best HE target, fallback | -### Round type selection -```cpp -SCR_EAIArtilleryAmmoType SelectRoundType(AFM_DiDBattlefieldState state) -{ - if (state.m_bIsNight && !IsOnCooldown(ILLUMINATION) && state.m_fBudgetRatio > 0.3) - return SCR_EAIArtilleryAmmoType.ILLUMINATION; - - if (m_bArmorPushPending && !IsOnCooldown(SMOKE)) - return SCR_EAIArtilleryAmmoType.SMOKE; - - if (state.m_fDefenderDensity > m_fHEDensityThreshold - && !IsOnCooldown(HIGH_EXPLOSIVE) - && state.m_fBudgetRatio > 0.2) - return SCR_EAIArtilleryAmmoType.HIGH_EXPLOSIVE; - - return SCR_EAIArtilleryAmmoType.PRACTICE; // harass for free -} -``` - ### Respawn cost escalation -```cpp -int GetRespawnCost() -{ - switch (m_iRespawnCount) - { - case 0: return 15; - case 1: return 25; - default: return 999; // effectively blocked - } -} -``` +| Respawn # | Cost | +|---|---| +| 1st | 15 pts | +| 2nd | 25 pts | +| 3rd+ | Blocked (9999 pts) | -### Information asymmetry (by design) -| Event | Defenders notified | Attackers notified | -|---|---|---| -| Mortar spawns at zone start | No | Internal | -| Mortar fires mission | Yes (impacts) | Internal | -| Mortar destroyed | **Yes** — hint | Internal | -| Mortar respawns | **No** | Internal | +### Information asymmetry (implemented) +| Event | Defenders notified | +|---|---| +| Mortar spawns | No | +| Mortar fires mission | Yes (impacts visible) | +| Mortar destroyed | **Yes** — "Enemy mortar destroyed!" hint | +| Mortar respawns | **No** | + +### Architecture notes +- `EAFMRoundType` is an internal enum — `SetAmmoType()` is intentionally NOT called (game's `SCR_EAIArtilleryAmmoType` enum values unverifiable without game source; crew fires prefab default ammo) +- `m_fDefenderDensity` = raw alive defender count (float). `m_fHEDensityThreshold` default = 2.0 (fire HE when 2+ defenders) +- `m_bIsNight` always false pending day/night API verification — illumination missions currently never fire +- `AFM_DiDMortarSpawnerComponent` kept for backward compatibility (legacy mode without director) +- Destruction detected by polling `EDamageState.DESTROYED` every second — preferred over callback subscription for cross-vehicle-type reliability + +### Key attributes (tunable in editor) +- `AFM_DiDZoneArtillery.m_sMortarPrefab` — mortar vehicle prefab +- `AFM_DiDZoneArtillery.m_CrewConfig` — crew config (gunner only recommended) +- `AFM_DiDZoneArtillery.m_fHEDensityThreshold` — 2.0 default (min defenders to trigger HE) +- `AFM_DiDZoneArtillery.m_iHECooldownSeconds` — 240s (4 min) +- `AFM_DiDZoneArtillery.m_iSmokeCooldownSeconds` — 120s (2 min) +- `AFM_DiDZoneArtillery.m_iPracticeCooldownSeconds` — 30s +- `AFM_DiDZoneArtillery.m_iFirstRespawnCost` / `m_iSecondRespawnCost` — 15 / 25 pts --- ## Phase 4 — Combined Arms Sequencing -**Goal:** Director can plan multi-step actions (smoke → armor push). -Synergy between artillery and ground units becomes visible as a learnable pattern. +**Goal:** Director plans multi-step attacks where artillery smoke covers a ground push. +Both infantry and mechanized units can exploit a smoke screen. +The synergy is learnable by defenders — arriving impacts signal an imminent assault. ### New files | File | Purpose | |---|---| -| `Scripts/Game/DiD/AFM_DiDAttackSequence.c` | Two-step action: first action + delay + second action, budget reserved upfront | +| `Scripts/Game/DiD/AFM_DiDAttackSequence.c` | Named sequence types + two-step executor: fire step 1, wait delay, fire step 2 | ### Modified files | File | Change | |---|---| -| `AFM_DiDAttackerDirector.c` | Evaluate combined sequences alongside individual options; execute pending sequence steps | +| `Scripts/Game/DiD/AFM_DiDAttackerDirector.c` | Evaluate sequences alongside individual options each cycle; hold and execute pending sequence step 2 | -### Sequence concept +### Sequence data model ```cpp +enum EAFMSequenceType +{ + SMOKE_INFANTRY_PUSH, // smoke → infantry wave (shorter delay, cheaper) + SMOKE_ARMOR_PUSH, // smoke → mechanized push (longer delay, costly) +} + class AFM_DiDAttackSequence { - ref AFM_DiDDirectorAction m_FirstAction; // e.g. SMOKE mission - float m_fDelaySeconds; // e.g. 30s for smoke to develop - ref AFM_DiDDirectorAction m_SecondAction; // e.g. mechanized push - int m_iTotalCost; // reserved upfront + EAFMSequenceType m_eType; + int m_iTotalCost; // reserved upfront before step 1 fires + WorldTimestamp m_fStep2Time; // when to execute step 2 + bool m_bStep1Done; } ``` -Sequences compete with individual options in the same decision cycle. -If the sequence scores highest, both actions are reserved and the director -begins executing step 1, then waits `m_fDelaySeconds` before step 2. +The director holds at most one pending sequence at a time (`m_PendingSequence`). +If a sequence is pending, it skips normal candidate scoring and waits for `m_fStep2Time`. + +### Defined sequences +| Sequence | Step 1 | Delay | Step 2 | Total cost | Conditions | +|---|---|---|---|---|---| +| `SMOKE_INFANTRY_PUSH` | SMOKE mission (4–6 rounds) | 20s | Infantry spawner trigger | smoke cost + infantry cost | ASSAULT+, mortar active, infantry spawner available, AI outnumbered or defenders dense | +| `SMOKE_ARMOR_PUSH` | SMOKE mission (4–6 rounds) | 35s | Mechanized spawner trigger | smoke cost + vehicle cost | ASSAULT+, mortar active, mechanized spawner available, budget > 30% | + +**Delay difference rationale:** infantry exploits smoke faster (they move on foot into the cloud immediately); armor needs the smoke denser and further developed before driving through, and has longer reaction time to position. + +### Scoring +Sequences compete with individual spawner options in the same scoring pool, treated as a single candidate with a combined score. + +```cpp +float ScoreSequence(EAFMSequenceType type, AFM_DiDBattlefieldState state) +{ + // Both sequences require artillery smoke + if (!m_pArtillery || !m_pArtillery.IsMortarActive()) + return 0; + if (m_pArtillery.IsOnCooldown(EAFMRoundType.SMOKE)) + return 0; + + switch (type) + { + case EAFMSequenceType.SMOKE_INFANTRY_PUSH: + // Best when AI is outnumbered inside zone — smoke lets them close the gap + if (state.m_ePhase == EAFMAttackPhase.PROBE) return 0; + if (!InfantrySpawnerAvailable()) return 0; + float score = 1.2; + if (state.m_iAICountInZone < state.m_iDefenderCount) + score += 1.0; // outnumbered — smoke push is high value + if (state.m_fDefenderDensity >= 3.0) + score += 0.5; // dense defenders = smoke helps a lot + return score; + + case EAFMSequenceType.SMOKE_ARMOR_PUSH: + if (state.m_ePhase == EAFMAttackPhase.PROBE) return 0; + if (!MechanizedSpawnerAvailable()) return 0; + if (state.m_fBudgetRatio < 0.3) return 0; + return 1.5 + (state.m_ePhase == EAFMAttackPhase.FINAL ? 0.5 : 0); + } + return 0; +} +``` + +### Execution flow +``` +RunDecisionCycle() + ├── IF m_PendingSequence && now >= m_fStep2Time + │ └── ExecuteStep2() // trigger ground push, clear pending sequence + │ + └── ELSE (normal cycle) + ├── Score all spawners (existing) + ├── Score sequences (new) + ├── Weighted pick across all candidates + └── IF sequence chosen: + budget.Reserve(totalCost) + artillery.TriggerMission(SMOKE, now) // step 1 + m_PendingSequence = new sequence + m_fStep2Time = now + delay +``` + +### Budget reservation +Both steps' costs are deducted from the budget before step 1 fires — preventing a race condition where another decision cycle spends the budget before step 2 can execute. If budget becomes insufficient between reservation and step 2 (e.g. a concurrent artillery respawn consumed it), step 2 fires anyway (cost already reserved). + +### Defender experience +The sequence is intentionally learnable: +- Smoke impacts → experienced defenders recognize imminent assault, relay on comms +- Ground units arrive ~20–35s later through the smoke +- Counter: suppress the smoke landing zone before units enter, or fall back + +Defenders who learn the pattern gain an advantage; the pattern still works because smoke genuinely degrades defender accuracy, making it effective even when expected. --- @@ -250,13 +291,11 @@ Prevents 3-player sessions from facing 20-player-designed pressure. ``` Phase 1 (Budget) ✅ └── Phase 2 (Director) ✅ - ├── Phase 3 (Artillery) + ├── Phase 3 (Artillery) ✅ └── Phase 4 (Sequences) + └── requires Phase 3 (mortar provides smoke step 1) ``` -Phases 3 and 4 can be developed in parallel once Phase 2 is stable. -Phase 3 is independently testable (artillery can run without sequences). - --- ## Out of scope (tracked in GAMEPLAY_IDEAS.md) From b81da2bb3a5899d3e363bf25269dbf3e4f5aa525 Mon Sep 17 00:00:00 2001 From: Jan Nielek Date: Fri, 20 Mar 2026 19:01:35 +0100 Subject: [PATCH 4/5] feat: Phase IV of AI commander --- .claude/settings.local.json | 3 +- .../Compositions/DiD_MortarComposition_US.et | 10 + .../DiD_MortarComposition_US.et.meta | 17 ++ .../MP/AFM_ArtillerySpawnPointEntity.et | 3 - .../Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et | 2 +- .../Scripts/Game/DiD/AFM_DiDAttackPhase.c | 2 +- .../Game/DiD/AFM_DiDAttackerDirector.c | 36 ++- .../Scripts/Game/DiD/AFM_DiDZoneArtillery.c | 261 +++++++++++++++-- .../Scripts/Game/DiD/AFM_DiDZoneComponent.c | 58 ++++ .../Scripts/Game/DiD/AFM_GameModeDiD.c | 112 ++++++-- .../AFM_DiDMechanizedSpawnerComponent.c | 21 +- .../DiD/Spawners/AFM_DiDSpawnerComponent.c | 38 ++- .../DiD_Linear_Lamentin_Layers/Zone_1.layer | 1 + .../DiD_Linear_Lamentin_Layers/Zone_2.layer | 105 ++++--- .../DiD_Linear_Lamentin_Layers/Zone_3.layer | 134 ++++----- docs/PREFAB_HIERARCHY.md | 262 ++++++++++++++++++ 16 files changed, 853 insertions(+), 212 deletions(-) create mode 100644 addons/DefenseInDepth/Prefabs/Compositions/DiD_MortarComposition_US.et create mode 100644 addons/DefenseInDepth/Prefabs/Compositions/DiD_MortarComposition_US.et.meta create mode 100644 docs/PREFAB_HIERARCHY.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 464595c..16c028d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,8 @@ "mcp__enfusion-mcp__asset_search", "mcp__enfusion-mcp__wiki_search", "mcp__enfusion-mcp__game_read", - "mcp__enfusion-mcp__wb_reload" + "mcp__enfusion-mcp__wb_reload", + "mcp__enfusion-mcp__script_create" ] } } diff --git a/addons/DefenseInDepth/Prefabs/Compositions/DiD_MortarComposition_US.et b/addons/DefenseInDepth/Prefabs/Compositions/DiD_MortarComposition_US.et new file mode 100644 index 0000000..2ef4e0a --- /dev/null +++ b/addons/DefenseInDepth/Prefabs/Compositions/DiD_MortarComposition_US.et @@ -0,0 +1,10 @@ +Turret : "{8094D99689ABE241}Prefabs/Weapons/Mortars/M252/Mortar_M252.et" { + ID "5CF87D15B3D0A976" + coords 1511.126 59.987 5799.793 + { + GenericEntity : "{E1FD1ACDA98123AE}Prefabs/Props/Military/Arsenal/AmmoBoxes/US/AmmoBoxArsenal_Mortar_US.et" { + ID "68E9081877B677E6" + coords -1.405 0 -1.168 + } + } +} \ No newline at end of file diff --git a/addons/DefenseInDepth/Prefabs/Compositions/DiD_MortarComposition_US.et.meta b/addons/DefenseInDepth/Prefabs/Compositions/DiD_MortarComposition_US.et.meta new file mode 100644 index 0000000..73a6fc2 --- /dev/null +++ b/addons/DefenseInDepth/Prefabs/Compositions/DiD_MortarComposition_US.et.meta @@ -0,0 +1,17 @@ +MetaFileClass { + Name "{7C420F3570791EF7}Prefabs/Compositions/DiD_MortarComposition_US.et.et" + Configurations { + EntityTemplateResourceClass PC { + } + EntityTemplateResourceClass XBOX_ONE : PC { + } + EntityTemplateResourceClass XBOX_SERIES : PC { + } + EntityTemplateResourceClass PS4 : PC { + } + EntityTemplateResourceClass PS5 : PC { + } + EntityTemplateResourceClass HEADLESS : PC { + } + } +} \ No newline at end of file diff --git a/addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et b/addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et index 3586e5c..eb8bd65 100644 --- a/addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et +++ b/addons/DefenseInDepth/Prefabs/MP/AFM_ArtillerySpawnPointEntity.et @@ -1,9 +1,6 @@ AFM_ArtillerySpawnPointEntity { ID "68E8977F8944C994" components { - MeshObject "{68E8977FA3BC91BC}" { - Object "{16D18A4925ADB872}system/wbdata/MaterialEditor/MaterialSphere.xob" - } Hierarchy "{68E8977FB8D3EE43}" { } } diff --git a/addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et index 6c65f67..0774c1e 100644 --- a/addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et +++ b/addons/DefenseInDepth/Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et @@ -5,7 +5,7 @@ AFM_DiDZoneArtillery { } } coords 1603.758 62.301 5838.798 - m_sMortarPrefab "{D0AAD73A3B0B0087}Prefabs/Compositions/DiD_MortarComposition_USSR.et" + m_sMortarPrefab "{7C420F3570791EF7}Prefabs/Compositions/DiD_MortarComposition_US.et.et" m_CrewConfig AFM_CrewConfig "{68E8977FD9DC91CF}" { m_sGunnerPrefab "{EBFB363AC7A5FCE6}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Engineer.et" m_bSpawnDriver 0 diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c index ff79e2d..1cd1474 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackPhase.c @@ -20,5 +20,5 @@ class AFM_DiDBattlefieldState float m_fTimeRatio; //! Remaining time / total defense time (1.0 = just started, 0.0 = expired) float m_fDefenderDensity; //! Alive defender count as a float — used by artillery to decide HE missions EAFMAttackPhase m_ePhase; //! Attack phase derived from budget ratio - bool m_bIsNight; //! True during low-visibility conditions — pending day/night API verification + bool m_bIsNight; //! True when TimeAndWeatherManagerEntity.IsSunSet() returns true } diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c index 9784484..8434092 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c @@ -130,6 +130,16 @@ class AFM_DiDAttackerDirector: GenericEntity return count; } + //------------------------------------------------------------------------------------------------ + //! Returns whether all spawners exhausted their tickets + bool AreTicketsExhausted() + { + if (!m_pZone) + return true; + AFM_DiDAttackerBudget budget = m_pZone.GetBudget(); + return budget.IsExhausted(); + } + //------------------------------------------------------------------------------------------------ //! Cleans up all AI spawned by this director's spawners and the mortar. void Cleanup() @@ -179,10 +189,11 @@ class AFM_DiDAttackerDirector: GenericEntity ref AFM_DiDSpawnRequest chosen = WeightedRandomPick(candidates); if (chosen) { - PrintFormat("AFM_DiDAttackerDirector: Triggering %1 (score=%2, cost=%3pts, phase=%4)", - chosen.m_Spawner.Type().ToString(), chosen.m_fScore, chosen.m_iCost, + int groupCount = ComputeGroupCount(chosen, state); + PrintFormat("AFM_DiDAttackerDirector: Triggering %1 x%2 groups (score=%3, cost=%4pts, phase=%5)", + chosen.m_Spawner.Type().ToString(), groupCount, chosen.m_fScore, chosen.m_iCost, state.m_ePhase, level: LogLevel.DEBUG); - chosen.m_Spawner.TriggerSpawn(now); + chosen.m_Spawner.TriggerSpawn(now, groupCount); } } else @@ -218,6 +229,25 @@ class AFM_DiDAttackerDirector: GenericEntity } } + //------------------------------------------------------------------------------------------------ + //! Decide how many groups to spawn for the chosen request. + //! Reads base count and variance from the spawner; battlefield state can adjust the result. + protected int ComputeGroupCount(AFM_DiDSpawnRequest chosen, AFM_DiDBattlefieldState state) + { + int base = chosen.m_Spawner.GetSpawnCount(); + int variance = chosen.m_Spawner.GetSpawnCountVariance(); + + int lo = Math.Max(1, base - variance); + int hi = base + variance; + int count = s_AIRandomGenerator.RandInt(lo, hi); + + // Final phase: send one extra group to commit remaining budget aggressively + if (state.m_ePhase == EAFMAttackPhase.FINAL) + count++; + + return count; + } + //------------------------------------------------------------------------------------------------ //! Sorts candidates descending by score and picks with weighted random from the top 3. protected ref AFM_DiDSpawnRequest WeightedRandomPick(array candidates) diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c index 96a88f7..9412d74 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c @@ -5,7 +5,7 @@ enum EAFMRoundType { HIGH_EXPLOSIVE, //! Costs budget; Monte Carlo targeting of defender clusters - SMOKE, //! Low cost; targets zone centroid to disrupt defenders + SMOKE, //! Low cost; perpendicular smoke screen between player blob and mortar ILLUMINATION, //! Night only; targets zone centroid (night detection pending API) PRACTICE //! Free harass; 1 round at best available position } @@ -16,6 +16,9 @@ enum EAFMRoundType //! Manages the mortar lifecycle: free initial spawn → director-triggered fire missions → //! destruction detection → optional budget-paid respawn (max 2 times, escalating cost). //! +//! Smoke missions use perpendicular screen targeting: rounds fall in a line perpendicular +//! to the mortar→player axis, positioned 25–50m in front of the player blob. +//! //! Hierarchy in world editor: //! AFM_DiDZoneEntity //! ├── AFM_ArtillerySpawnPointEntity ← 1..N spawn positions (children of zone) @@ -71,13 +74,22 @@ class AFM_DiDZoneArtillery: GenericEntity [Attribute("50", UIWidgets.EditBox, "Sample radius (meters) around each MC point for defender counting", category: "DiD Artillery")] protected float m_fSampleRadius; + [Attribute("25", UIWidgets.EditBox, "Smoke screen: minimum distance (m) from player blob to screen center", category: "DiD Artillery Smoke")] + protected float m_fSmokeMinDistance; + + [Attribute("50", UIWidgets.EditBox, "Smoke screen: maximum distance (m) from player blob to screen center", category: "DiD Artillery Smoke")] + protected float m_fSmokeMaxDistance; + + [Attribute("20", UIWidgets.EditBox, "Smoke screen: perpendicular spread (m) to each side of center", category: "DiD Artillery Smoke")] + protected float m_fSmokeScreenSpread; + // Runtime state protected AFM_DiDZoneComponent m_pZone; protected ref array m_aSpawnPoints = {}; protected IEntity m_pSpawnedMortar; protected AIGroup m_pMortarCrew; - protected SCR_AIWaypointArtillerySupport m_pCurrentWaypoint; + protected ref array m_aActiveWaypoints = {}; protected AFM_ArtillerySpawnPointEntity m_pLastSpawnPoint; protected bool m_bMortarActive; @@ -261,23 +273,74 @@ class AFM_DiDZoneArtillery: GenericEntity budget.Consume(cost); } - vector targetPos = SelectTargetPosition(roundType); - if (targetPos == vector.Zero) + SetLastFired(roundType, now); + m_iMissionsThisLife++; + + if (roundType == EAFMRoundType.SMOKE) { - PrintFormat("AFM_DiDZoneArtillery: No valid target for %1 — aborting", RoundTypeToString(roundType), level: LogLevel.DEBUG); - // Refund budget if we already consumed (fall-through: we haven't assigned waypoint yet) - if (budget && cost > 0) - budget.AddBonus(cost); - return; + // Smoke uses perpendicular screen pattern — no single target position needed + AssignSmokescreenMissions(shotCount); } + else + { + vector targetPos = SelectTargetPosition(roundType); + if (targetPos == vector.Zero) + { + PrintFormat("AFM_DiDZoneArtillery: No valid target for %1 — aborting", RoundTypeToString(roundType), level: LogLevel.DEBUG); + if (budget && cost > 0) + budget.AddBonus(cost); + return; + } - SetLastFired(roundType, now); + AssignFireMission(targetPos, shotCount, ConvertRoundType(roundType)); + + // Notify players when HE rounds are inbound + if (roundType == EAFMRoundType.HIGH_EXPLOSIVE) + { + AFM_GameModeDiD gamemode = AFM_GameModeDiD.Cast(GetGame().GetGameMode()); + if (gamemode) + gamemode.NotifyMortarFiring(); + } + } + + PrintFormat("AFM_DiDZoneArtillery: %1 mission fired — %2 rounds (cost %3 pts)", + RoundTypeToString(roundType), shotCount, cost); + } + + //------------------------------------------------------------------------------------------------ + //! Trigger a SMOKE mission explicitly, used as step 1 in combined arms sequences. + //! Returns true if the mission was fired successfully. + //! Budget is consumed here (smoke rounds cost); the director does NOT pre-reserve. + bool TriggerSmokeMission(WorldTimestamp now) + { + if (!m_bMortarActive || !m_pMortarCrew) + return false; + + if (IsOnCooldown(EAFMRoundType.SMOKE, now)) + return false; + + int shotCount = GetShotCount(EAFMRoundType.SMOKE); + int cost = GetMissionCost(EAFMRoundType.SMOKE, shotCount); + + AFM_DiDAttackerBudget budget = m_pZone.GetBudget(); + if (budget && cost > 0) + { + if (!budget.CanAfford(cost)) + { + PrintFormat("AFM_DiDZoneArtillery: Can't afford sequence smoke (cost %1 pts)", cost, level: LogLevel.DEBUG); + return false; + } + budget.Consume(cost); + } + + SetLastFired(EAFMRoundType.SMOKE, now); m_iMissionsThisLife++; - AssignFireMission(targetPos, shotCount); + AssignSmokescreenMissions(shotCount); - PrintFormat("AFM_DiDZoneArtillery: %1 mission fired — %2 rounds at %3 (cost %4 pts)", - RoundTypeToString(roundType), shotCount, targetPos.ToString(), cost); + PrintFormat("AFM_DiDZoneArtillery: Sequence SMOKE — %1 rounds perpendicular screen (cost %2 pts)", + shotCount, cost); + return true; } //------------------------------------------------------------------------------------------------ @@ -389,7 +452,7 @@ class AFM_DiDZoneArtillery: GenericEntity //------------------------------------------------------------------------------------------------ protected EAFMRoundType SelectRoundType(AFM_DiDBattlefieldState state, WorldTimestamp now) { - // Night → illumination (m_bIsNight always false until day/night API is verified) + // Night → illumination (IsSunSet() drives m_bIsNight via director's BuildBattlefieldState) if (state.m_bIsNight && !IsOnCooldown(EAFMRoundType.ILLUMINATION, now) && state.m_fBudgetRatio > 0.3) @@ -401,7 +464,7 @@ class AFM_DiDZoneArtillery: GenericEntity && state.m_fBudgetRatio > 0.2) return EAFMRoundType.HIGH_EXPLOSIVE; - // ASSAULT/FINAL → smoke to disrupt defender positions + // ASSAULT/FINAL → smoke screen to disrupt defender positions if (!IsOnCooldown(EAFMRoundType.SMOKE, now) && state.m_ePhase != EAFMAttackPhase.PROBE) return EAFMRoundType.SMOKE; @@ -411,6 +474,8 @@ class AFM_DiDZoneArtillery: GenericEntity } //------------------------------------------------------------------------------------------------ + //! Returns a representative target position for non-smoke round types. + //! Smoke targeting is handled entirely inside AssignSmokescreenMissions(). protected vector SelectTargetPosition(EAFMRoundType roundType) { switch (roundType) @@ -418,7 +483,6 @@ class AFM_DiDZoneArtillery: GenericEntity case EAFMRoundType.HIGH_EXPLOSIVE: case EAFMRoundType.PRACTICE: return FindBestHETarget(); - case EAFMRoundType.SMOKE: case EAFMRoundType.ILLUMINATION: return GetZoneCentroid(); } @@ -452,10 +516,89 @@ class AFM_DiDZoneArtillery: GenericEntity } //------------------------------------------------------------------------------------------------ - protected void AssignFireMission(vector targetPos, int shotCount) + //! Fire a single-target mission. Clears all active waypoints first. + protected void AssignFireMission(vector targetPos, int shotCount, SCR_EAIArtilleryAmmoType ammoType = SCR_EAIArtilleryAmmoType.HIGH_EXPLOSIVE) + { + ClearCurrentWaypoint(); + AddFireWaypoint(targetPos, shotCount, ammoType); + } + + //------------------------------------------------------------------------------------------------ + //! Fire a perpendicular smoke screen: 3 waypoints spread along the axis that is + //! perpendicular to the mortar→player direction, centered m_fSmokeMinDistance– + //! m_fSmokeMaxDistance in front of the player blob (between blob and mortar). + //! + //! Falls back to zone centroid when no alive defenders are found. + protected void AssignSmokescreenMissions(int totalRounds) { ClearCurrentWaypoint(); + // Find player blob centroid + vector blobPos = FindPlayerBlob(); + if (blobPos == vector.Zero) + blobPos = GetZoneCentroid(); + + if (blobPos == vector.Zero) + return; + + // Mortar position — use current spawn point origin as reference + vector mortarPos = vector.Zero; + if (m_pSpawnedMortar) + mortarPos = m_pSpawnedMortar.GetOrigin(); + + // Forward direction: mortar → player blob (XZ plane only) + float dx = blobPos[0] - mortarPos[0]; + float dz = blobPos[2] - mortarPos[2]; + float len = Math.Sqrt(dx * dx + dz * dz); + + // Normalised forward; default to arbitrary axis when mortar is on top of blob + float ndx = 1.0, ndz = 0.0; + if (len >= 1.0) + { + ndx = dx / len; + ndz = dz / len; + } + + // Perpendicular direction (rotate forward 90° in XZ) + float perpX = -ndz; + float perpZ = ndx; + + // Screen center: 25–50 m from players, towards mortar + float offset = s_AIRandomGenerator.RandFloatXY(m_fSmokeMinDistance, m_fSmokeMaxDistance); + + vector center; + center[0] = blobPos[0] - ndx * offset; + center[2] = blobPos[2] - ndz * offset; + center[1] = GetGame().GetWorld().GetSurfaceY(center[0], center[2]); + + // Spread points along perpendicular axis + vector leftFlank; + leftFlank[0] = center[0] + perpX * m_fSmokeScreenSpread; + leftFlank[2] = center[2] + perpZ * m_fSmokeScreenSpread; + leftFlank[1] = GetGame().GetWorld().GetSurfaceY(leftFlank[0], leftFlank[2]); + + vector rightFlank; + rightFlank[0] = center[0] - perpX * m_fSmokeScreenSpread; + rightFlank[2] = center[2] - perpZ * m_fSmokeScreenSpread; + rightFlank[1] = GetGame().GetWorld().GetSurfaceY(rightFlank[0], rightFlank[2]); + + // Distribute rounds: center receives half, flanks split the rest + int centerRounds = Math.Max(1, totalRounds / 2); + int sideRounds = Math.Max(1, (totalRounds - centerRounds) / 2); + + AddFireWaypoint(center, centerRounds, SCR_EAIArtilleryAmmoType.SMOKE); + AddFireWaypoint(leftFlank, sideRounds, SCR_EAIArtilleryAmmoType.SMOKE); + AddFireWaypoint(rightFlank, sideRounds, SCR_EAIArtilleryAmmoType.SMOKE); + + PrintFormat("AFM_DiDZoneArtillery: Smoke screen — center %1, spread +/-%2m perp, blob at %3 (offset %4m)", + center.ToString(), m_fSmokeScreenSpread, blobPos.ToString(), offset); + } + + //------------------------------------------------------------------------------------------------ + //! Spawn and queue a single artillery waypoint. Does NOT clear existing waypoints. + //! Used internally to build multi-point smoke screens. + protected void AddFireWaypoint(vector targetPos, int shotCount, SCR_EAIArtilleryAmmoType ammoType) + { Resource wpResource = Resource.Load("{C524700A27CFECDD}Prefabs/AI/Waypoints/AIWaypoint_ArtillerySupport.et"); if (!wpResource || !wpResource.IsValid()) { @@ -483,24 +626,78 @@ class AFM_DiDZoneArtillery: GenericEntity } wp.SetTargetShotCount(shotCount); + wp.SetAmmoType(ammoType); if (m_pMortarCrew) m_pMortarCrew.AddWaypoint(wp); - m_pCurrentWaypoint = wp; + m_aActiveWaypoints.Insert(wp); } //------------------------------------------------------------------------------------------------ + //! Remove and delete all active waypoints. protected void ClearCurrentWaypoint() { - if (!m_pCurrentWaypoint) - return; + foreach (SCR_AIWaypointArtillerySupport wp : m_aActiveWaypoints) + { + if (!wp) + continue; + if (m_pMortarCrew) + m_pMortarCrew.RemoveWaypoint(wp); + SCR_EntityHelper.DeleteEntityAndChildren(wp); + } + m_aActiveWaypoints.Clear(); + } - if (m_pMortarCrew) - m_pMortarCrew.RemoveWaypoint(m_pCurrentWaypoint); + //------------------------------------------------------------------------------------------------ + //! Returns the centroid of all alive defenders. Returns vector.Zero when none are found. + protected vector FindPlayerBlob() + { + if (!m_pZone) + return vector.Zero; + + SCR_Faction defFaction = m_pZone.GetDefenderFaction(); + if (!defFaction) + return vector.Zero; + + array playerIds = {}; + defFaction.GetPlayersInFaction(playerIds); + + float sumX = 0, sumZ = 0; + int count = 0; + + foreach (int pid : playerIds) + { + PlayerController pc = GetGame().GetPlayerManager().GetPlayerController(pid); + if (!pc) + continue; + + IEntity ent = pc.GetControlledEntity(); + if (!ent) + continue; + + SCR_ChimeraCharacter ch = SCR_ChimeraCharacter.Cast(ent); + if (!ch) + continue; + + SCR_DamageManagerComponent dmg = ch.GetDamageManager(); + if (!dmg || dmg.GetState() == EDamageState.DESTROYED) + continue; + + vector pos = ch.GetOrigin(); + sumX += pos[0]; + sumZ += pos[2]; + count++; + } - SCR_EntityHelper.DeleteEntityAndChildren(m_pCurrentWaypoint); - m_pCurrentWaypoint = null; + if (count == 0) + return vector.Zero; + + vector blob; + blob[0] = sumX / count; + blob[2] = sumZ / count; + blob[1] = GetGame().GetWorld().GetSurfaceY(blob[0], blob[2]); + return blob; } //------------------------------------------------------------------------------------------------ @@ -663,7 +860,7 @@ class AFM_DiDZoneArtillery: GenericEntity } //------------------------------------------------------------------------------------------------ - protected bool IsOnCooldown(EAFMRoundType roundType, WorldTimestamp now) + bool IsOnCooldown(EAFMRoundType roundType, WorldTimestamp now) { int cooldown; WorldTimestamp lastFired; @@ -718,6 +915,20 @@ class AFM_DiDZoneArtillery: GenericEntity return "UNKNOWN"; } + + //------------------------------------------------------------------------------------------------ + protected SCR_EAIArtilleryAmmoType ConvertRoundType(EAFMRoundType roundType) + { + switch (roundType) + { + case EAFMRoundType.HIGH_EXPLOSIVE: return SCR_EAIArtilleryAmmoType.HIGH_EXPLOSIVE; + case EAFMRoundType.SMOKE: return SCR_EAIArtilleryAmmoType.SMOKE; + case EAFMRoundType.ILLUMINATION: return SCR_EAIArtilleryAmmoType.ILLUMINATION; + case EAFMRoundType.PRACTICE: return SCR_EAIArtilleryAmmoType.PRACTICE; + } + return SCR_EAIArtilleryAmmoType.PRACTICE; + } + //------------------------------------------------------------------------------------------------ protected WorldTimestamp GetCurrentTimestamp() { diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c index eca7a35..48caf5a 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneComponent.c @@ -48,6 +48,9 @@ class AFM_DiDZoneComponent: ScriptComponent [Attribute("0.5", UIWidgets.EditBox, "Fraction of remaining budget rolled over to next zone on failure (0.0-1.0)", category: "DiD Budget")] protected float m_fBudgetRolloverFraction; + [Attribute("3.0", UIWidgets.EditBox, "Time multiplier when all tickets exhausted (e.g. 3.0 = 3x faster). Only applies when spawners use the ticket system.", category: "DiD")] + protected float m_fTicketExhaustTimeMultiplier; + protected PolylineShapeEntity m_PolylineEntity; protected AFM_PlayerSpawnPointEntity m_PlayerSpawnPoint; // Legacy: spawners are direct children of the zone (used when no director is present) @@ -70,6 +73,8 @@ class AFM_DiDZoneComponent: ScriptComponent // Budget — null if m_iPointsBudget == 0 (system disabled) protected ref AFM_DiDAttackerBudget m_Budget; + protected bool m_bTicketsExhaustedNotified = false; //! Prevents repeated notifications once tickets run out + // Faction configuration protected SCR_Faction m_RedforFaction; protected SCR_Faction m_BluforFaction; @@ -325,6 +330,10 @@ class AFM_DiDZoneComponent: ScriptComponent m_fZoneStartTime = now; m_fZoneEndTime = now.PlusSeconds(m_iDefenseTimeSeconds); PrintFormat("AFM_DiDZoneComponent %1: PREPARE -> ACTIVE", m_sZoneName); + + AFM_GameModeDiD gamemode = AFM_GameModeDiD.Cast(GetGame().GetGameMode()); + if (gamemode) + gamemode.NotifyZoneActive(m_iZoneIndex); } return m_eZoneState; @@ -382,6 +391,31 @@ class AFM_DiDZoneComponent: ScriptComponent } } + // Ticket exhaustion: notify once then accelerate zone timer + if (!m_bTicketsExhaustedNotified && AreAllSpawnerTicketsExhausted()) + { + m_bTicketsExhaustedNotified = true; + AFM_GameModeDiD gamemode = AFM_GameModeDiD.Cast(GetGame().GetGameMode()); + if (gamemode) + gamemode.NotifyTicketsExhausted(); + } + + // Ticket exhaustion: accelerate zone timer when no more AI can spawn from ticket pools. + // Multiplier ramps linearly from 1x (full time remaining) to m_fTicketExhaustTimeMultiplier (at end). + if (m_eZoneState == EAFMZoneState.ACTIVE + && m_fTicketExhaustTimeMultiplier > 1.0 + && AreAllSpawnerTicketsExhausted()) + { + float secondsRemaining = Math.Max(0, m_fZoneEndTime.DiffSeconds(GetCurrentTimestamp())); + float timeRatio = Math.Clamp(secondsRemaining / m_iDefenseTimeSeconds, 0.0, 1.0); + // timeRatio 1.0 = full time left → scaledMultiplier = 1x (no acceleration) + // timeRatio 0.0 = at deadline → scaledMultiplier = m_fTicketExhaustTimeMultiplier + float scaledMultiplier = 1.0 + (m_fTicketExhaustTimeMultiplier - 1.0) * (1.0 - timeRatio); + int extraSeconds = Math.Ceil(scaledMultiplier - 1.0); + if (extraSeconds > 0) + m_fZoneEndTime = m_fZoneEndTime.PlusSeconds(-extraSeconds); + } + return m_eZoneState; } @@ -437,6 +471,8 @@ class AFM_DiDZoneComponent: ScriptComponent void ActivateZone() { + m_bTicketsExhaustedNotified = false; + // Initialize budget if configured if (m_iPointsBudget > 0) { @@ -586,4 +622,26 @@ class AFM_DiDZoneComponent: ScriptComponent { return m_iMaxAICount; } + + //------------------------------------------------------------------------------------------------ + //! Returns true when every ticket-based spawner in this zone has used all its tickets. + //! Returns false if no ticket-based spawners exist (no acceleration in that case). + protected bool AreAllSpawnerTicketsExhausted() + { + // Director mode: delegate to director + if (m_Director) + return m_Director.AreTicketsExhausted(); + + // Legacy mode: iterate direct spawner children + bool hasTicketSpawners = false; + foreach (AFM_DiDSpawnerComponent spawner : m_aSpawners) + { + if (!spawner || !spawner.IsTicketBased()) + continue; + hasTicketSpawners = true; + if (spawner.GetRemainingTickets() > 0) + return false; + } + return hasTicketSpawners; + } } diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c index ba23e66..9e9c6c8 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_GameModeDiD.c @@ -142,9 +142,17 @@ class AFM_GameModeDiD: PS_GameModeCoop protected void OnZoneHeld() { + RPC_DoZoneHeld(); + Rpc(RPC_DoZoneHeld); GameEndDefendersWin(); } + [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] + protected void RPC_DoZoneHeld() + { + SCR_ChatComponent.RadioProtocolMessage("Sector secured. Enemy assault has failed. Outstanding work, all units."); + } + //! Attacker budget exhausted and zone cleared — defenders repelled the assault protected void OnZoneRepelled() { @@ -219,11 +227,45 @@ class AFM_GameModeDiD: PS_GameModeCoop } //------------------------------------------------------------------------------------------------ - // Artillery notifications + // Commander radio notifications — called on server, broadcast to all clients //------------------------------------------------------------------------------------------------ + //! Called when zone transitions from PREPARE to ACTIVE (defend phase starts). + void NotifyZoneActive(int zoneIndex) + { + RPC_DoZoneActive(zoneIndex); + Rpc(RPC_DoZoneActive, zoneIndex); + } + + //! Called by director when attack phase changes to ASSAULT or FINAL. + void NotifyPhaseChanged(EAFMAttackPhase phase) + { + RPC_DoPhaseChanged(phase); + Rpc(RPC_DoPhaseChanged, phase); + } + + //! Called by director when a combined-arms smoke sequence starts (step 1 fired). + void NotifySmokeLaunched(bool isArmorPush) + { + RPC_DoSmokeLaunched(isArmorPush); + Rpc(RPC_DoSmokeLaunched, isArmorPush); + } + + //! Called by zone when all ticket-based spawners run out of tickets. + void NotifyTicketsExhausted() + { + RPC_DoTicketsExhausted(); + Rpc(RPC_DoTicketsExhausted); + } + + //! Called by artillery when an HE fire mission is triggered. + void NotifyMortarFiring() + { + RPC_DoMortarFiring(); + Rpc(RPC_DoMortarFiring); + } + //! Called by AFM_DiDZoneArtillery on server when the mortar is destroyed. - //! Broadcasts a hint to all defenders. No location is revealed. void NotifyMortarDestroyed() { RPC_DoMortarDestroyed(); @@ -234,52 +276,70 @@ class AFM_GameModeDiD: PS_GameModeCoop // Broadcast RPCs //------------------------------------------------------------------------------------------------ + [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] + protected void RPC_DoZoneActive(int zoneIndex) + { + SCR_ChatComponent.RadioProtocolMessage( + string.Format("Command to all units: sector %1 is now active. Enemy is advancing — hold your positions.", zoneIndex) + ); + } + + [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] + protected void RPC_DoPhaseChanged(EAFMAttackPhase phase) + { + if (phase == EAFMAttackPhase.ASSAULT) + SCR_ChatComponent.RadioProtocolMessage("Attention: enemy has committed their main force. Expect heavy contact."); + else if (phase == EAFMAttackPhase.FINAL) + SCR_ChatComponent.RadioProtocolMessage("All units: enemy is expending their last reserves. This is their final push — do not break."); + } + + [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] + protected void RPC_DoSmokeLaunched(bool isArmorPush) + { + if (isArmorPush) + SCR_ChatComponent.RadioProtocolMessage("SMOKE SCREEN downrange! Enemy armor moving up — AT teams stand by!"); + else + SCR_ChatComponent.RadioProtocolMessage("SMOKE SCREEN downrange! Enemy infantry assault through smoke — brace for contact!"); + } + + [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] + protected void RPC_DoTicketsExhausted() + { + SCR_ChatComponent.RadioProtocolMessage("Intel: enemy reserves are depleted. No further reinforcements inbound."); + } + + [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] + protected void RPC_DoMortarFiring() + { + SCR_ChatComponent.RadioProtocolMessage("INCOMING! Enemy fire support is active. Seek cover and locate the source!"); + } + //! Enemy mortar destroyed — defenders notified, no position information given [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] protected void RPC_DoMortarDestroyed() { - SCR_HintManagerComponent.GetInstance().ShowCustom( - "Enemy mortar destroyed!", - "", - 8, - false - ); + SCR_ChatComponent.RadioProtocolMessage("Enemy fire support eliminated. Well done."); } //! Defenders repelled the assault (budget exhausted + zone cleared) [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] protected void RPC_DoZoneRepelled() { - SCR_HintManagerComponent.GetInstance().ShowCustom( - "Enemy assault repelled! All attackers eliminated!", - "", - 12, - false - ); + SCR_ChatComponent.RadioProtocolMessage("Enemy force annihilated. The assault has been broken. Hold position."); } //! Zone failed — all defenders eliminated [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] protected void RPC_DoZoneFailed(int zoneIndex) { - SCR_HintManagerComponent.GetInstance().ShowCustom( - string.Format("Zone %1 lost! Falling back...", zoneIndex), - "", - 6, - false - ); + SCR_ChatComponent.RadioProtocolMessage("Position overrun. All units fall back and regroup at the next position."); } //! New zone is now active [RplRpc(RplChannel.Reliable, RplRcver.Broadcast)] protected void RPC_DoProgressToNextZone(int newZoneIndex) { - SCR_HintManagerComponent.GetInstance().ShowCustom( - string.Format("Moving to zone %1. Prepare your defenses!", newZoneIndex), - "", - 10, - false - ); + SCR_ChatComponent.RadioProtocolMessage(string.Format("Moving to zone %1. Prepare your defenses!", newZoneIndex)); } //! Wave zone wave cleared diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c index bf56470..eac7d8a 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDMechanizedSpawnerComponent.c @@ -81,7 +81,7 @@ class AFM_DiDMechanizedSpawnerComponent: AFM_DiDSpawnerComponent } //------------------------------------------------------------------------------------------------ - override protected void SpawnWave() + override protected void SpawnWave(int count) { if (m_bRequireMinAI) { @@ -101,10 +101,9 @@ class AFM_DiDMechanizedSpawnerComponent: AFM_DiDSpawnerComponent return; } - int spawnCount = GetSpawnCountForWave(); - PrintFormat("AFM_DiDMechanizedSpawnerComponent: Spawning %1 vehicle(s)", spawnCount, LogLevel.DEBUG); + PrintFormat("AFM_DiDMechanizedSpawnerComponent: Spawning %1 vehicle(s)", count, LogLevel.DEBUG); - for (int i = 0; i < spawnCount; i++) + for (int i = 0; i < count; i++) { if (m_Zone.GetActiveAICount() >= m_iMaxAICount) break; @@ -125,20 +124,6 @@ class AFM_DiDMechanizedSpawnerComponent: AFM_DiDSpawnerComponent } } - //------------------------------------------------------------------------------------------------ - override protected int GetSpawnCountForWave() - { - if (!m_Zone) - return 1; - - int zoneIndex = m_Zone.GetZoneIndex(); - - if (m_bCoordinatedSpawn && zoneIndex >= 3) - return s_AIRandomGenerator.RandInt(2, 3); - - return 1; - } - //------------------------------------------------------------------------------------------------ //! m_iPointCostPerUnit represents cost per whole vehicle group (vehicle + crew). //------------------------------------------------------------------------------------------------ diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c index 33d58b2..99977bb 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDSpawnerComponent.c @@ -26,8 +26,8 @@ class AFM_DiDSpawnerComponent: GenericEntity [Attribute("50", UIWidgets.EditBox, "Max AI group count", category: "DiD Spawner")] protected int m_iMaxAICount; - [Attribute("1.0", UIWidgets.EditBox, "Spawn count multiplier per zone level (e.g., zone 2 = 2x spawn count)", category: "DiD Spawner")] - protected float m_fZoneLevelMultiplier; + [Attribute("1", UIWidgets.EditBox, "Random variance applied to spawn count per wave (actual = base ± variance)", category: "DiD Spawner")] + protected int m_iSpawnCountVariance; [Attribute("0", UIWidgets.CheckBox, "Use ticket system (limits total spawns)", category: "DiD Spawner")] protected bool m_bUseTickets; @@ -113,7 +113,7 @@ class AFM_DiDSpawnerComponent: GenericEntity if (timeSinceLastSpawn >= m_iWaveIntervalSeconds) { m_fLastSpawnTime = now; - SpawnWave(); + SpawnWave(GetSpawnCountForWave()); } } @@ -130,12 +130,12 @@ class AFM_DiDSpawnerComponent: GenericEntity //------------------------------------------------------------------------------------------------ // Director mode: spawn immediately and reset the cooldown timer. - // Called by AFM_DiDAttackerDirector after scoring and budget checks pass. + // count is computed by AFM_DiDAttackerDirector.ComputeGroupCount() from the battlefield state. //------------------------------------------------------------------------------------------------ - void TriggerSpawn(WorldTimestamp now) + void TriggerSpawn(WorldTimestamp now, int count) { m_fLastSpawnTime = now; - SpawnWave(); + SpawnWave(count); } //------------------------------------------------------------------------------------------------ @@ -177,6 +177,11 @@ class AFM_DiDSpawnerComponent: GenericEntity return m_iSpawnCountPerWave; } + int GetSpawnCountVariance() + { + return m_iSpawnCountVariance; + } + void SetSpawnCount(int count) { m_iSpawnCountPerWave = count; @@ -190,12 +195,12 @@ class AFM_DiDSpawnerComponent: GenericEntity //------------------------------------------------------------------------------------------------ protected int GetSpawnCountForWave() { - if (!m_Zone) + if (m_iSpawnCountVariance <= 0) return m_iSpawnCountPerWave; - int zoneIndex = m_Zone.GetZoneIndex(); - float multiplier = 1.0 + ((zoneIndex - 1) * m_fZoneLevelMultiplier); - return Math.Ceil(m_iSpawnCountPerWave * multiplier); + int lo = Math.Max(1, m_iSpawnCountPerWave - m_iSpawnCountVariance); + int hi = m_iSpawnCountPerWave + m_iSpawnCountVariance; + return s_AIRandomGenerator.RandInt(lo, hi); } //------------------------------------------------------------------------------------------------ @@ -227,7 +232,7 @@ class AFM_DiDSpawnerComponent: GenericEntity } //------------------------------------------------------------------------------------------------ - protected void SpawnWave() + protected void SpawnWave(int count) { if (m_aSpawnPoints.Count() == 0 || m_aAIWaypoints.Count() == 0) return; @@ -251,10 +256,9 @@ class AFM_DiDSpawnerComponent: GenericEntity return; } - int spawnCount = GetSpawnCountForWave(); - PrintFormat("AFM_DiDSpawnerComponent: Spawning wave with %1 groups", spawnCount, LogLevel.DEBUG); + PrintFormat("AFM_DiDSpawnerComponent: Spawning wave with %1 groups", count, LogLevel.DEBUG); - for (int i = 0; i < spawnCount; i++) + for (int i = 0; i < count; i++) { if (m_Zone.GetActiveAICount() >= m_iMaxAICount) break; @@ -402,4 +406,10 @@ class AFM_DiDSpawnerComponent: GenericEntity { return true; } + + //! Returns true if this spawner uses the ticket system. + bool IsTicketBased() + { + return m_bUseTickets; + } } diff --git a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer index 88710e0..8b7927f 100644 --- a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer +++ b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_1.layer @@ -842,6 +842,7 @@ AFM_DiDZoneEntity zone1 { } AFM_DiDAttackerDirector : "{C88B368A8AC21F4E}Prefabs/MP/AFM_DiDAttackerDirector_Default.et" { coords -112.033 -11.168 33.665 + m_fAggression 0.5 { AFM_DiDZoneArtillery : "{2D68BCDEA841A4BC}Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et" { coords 293.021 22.279 -55.39 diff --git a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer index cb1965f..0dc81e2 100644 --- a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer +++ b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer @@ -6,9 +6,6 @@ PS_ManualMarker : "{5CDEA09445852F9D}PrefabsEditable/Markers/SupportStationMarke coords 1290.432 37.499 6020.635 angles 0 0 0 } -SCR_AIWaypointArtillerySupport : "{6ED320498A60081C}PrefabsEditable/Auto/AI/Waypoints/E_AIWaypoint_ArtillerySupport.et" { - coords 1600.019 61.38 5885.906 -} GenericEntity : "{7AF53A74656A2D66}Prefabs/Props/Military/Compositions/US/DiD_BulidingService_US.et" { coords 1289.929 37.453 6018.585 angles 0 -18.07 0 @@ -750,63 +747,65 @@ AFM_DiDZoneEntity zone2 : "{CA08D7D0BA0D24CF}Prefabs/MP/AFM_DiDZonePrefab.et" { } } } - AFM_DiDInfantrySpawnerComponent : "{A93E8199BD60FD8A}Prefabs/Spawners/AFM_DidInfantrySpawner.et" { - coords 38.776 3.583 25.999 - m_iWaveIntervalSeconds 60 - m_iSpawnCountPerWave 2 - m_iMaxAICount 80 + $grp AFM_ArtillerySpawnPointEntity : "{1933061551525F98}Prefabs/MP/AFM_ArtillerySpawnPointEntity.et" { { - $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { - zone_1_sp_4 { - coords 148.317 15.443 73.154 - } - zone_1_sp_5 { - coords 180.864 17.405 -107.572 - } - zone_1_sp_6 { - coords 83.753 17.911 -203.743 - } - zone_1_sp_7 { - coords 12.68 7.555 238.244 - } - } - SCR_DefendWaypoint : "{0EBC4E7C452C16ED}Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et" { - coords -34.256 -4.346 7.964 - CompletionRadius 90 - } - $grp SCR_SearchAndDestroyWaypoint : "{D45195533ADF06E8}Prefabs/AI/Waypoints/DiD_AIWaypoint_SearchAndDestroy.et" { - { - coords 8.905 1.122 26.333 - CompletionRadius 50 - } - { - coords -54.876 -4.002 -28.586 - } - { - coords -63.371 -4.831 29.505 - } - } + coords 214.293 23.015 -127.137 + } + { + coords 230.244 17.933 -25.885 + } + { + coords 247.933 13.981 38.241 } } AFM_PlayerSpawnPointEntity : "{B6B6E54C76E4D206}Prefabs/MP/AFM_DiDPlayerSpawnPointPrefab.et" { coords -10.534 -0.886 52.448 } - AFM_DiDMortarSpawnerComponent : "{F040102F1843F7C1}Prefabs/Spawners/AFM_MortarSpawner.et" { - coords 215.54 22.354 -112.876 - m_iFireMissionUpdateInterval 45 + AFM_DiDAttackerDirector : "{C88B368A8AC21F4E}Prefabs/MP/AFM_DiDAttackerDirector_Default.et" { + coords -12.364 -0.44 -9.362 + m_fAggression 0.6 { - $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { - zone_1_sp_15 { - coords -1.28 0.673 -15.105 - angles 0 -76.831 0 - } - zone_1_sp_16 { - coords 15.212 -4.476 87.454 - angles 0 -76.831 0 - } - zone_1_sp_17 { - coords 32.68 -8.345 151.517 - angles 0 -76.831 0 + AFM_DiDZoneArtillery : "{2D68BCDEA841A4BC}Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et" { + coords 12.61 0.445 9.204 + m_iMonteCarloSamples 25 + m_fSampleRadius 15 + } + AFM_DiDInfantrySpawnerComponent : "{A93E8199BD60FD8A}Prefabs/Spawners/AFM_DidInfantrySpawner.et" { + coords 51.14 4.023 35.361 + m_iWaveIntervalSeconds 60 + m_iSpawnCountPerWave 2 + m_iMaxAICount 80 + { + $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { + zone_1_sp_4 { + coords 148.317 15.443 73.154 + } + zone_1_sp_5 { + coords 180.864 17.405 -107.572 + } + zone_1_sp_6 { + coords 83.753 17.911 -203.743 + } + zone_1_sp_7 { + coords 12.68 7.555 238.244 + } + } + SCR_DefendWaypoint : "{0EBC4E7C452C16ED}Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et" { + coords -34.256 -4.346 7.964 + CompletionRadius 90 + } + $grp SCR_SearchAndDestroyWaypoint : "{D45195533ADF06E8}Prefabs/AI/Waypoints/DiD_AIWaypoint_SearchAndDestroy.et" { + { + coords 8.905 1.122 26.333 + CompletionRadius 50 + } + { + coords -54.876 -4.002 -28.586 + } + { + coords -63.371 -4.831 29.505 + } + } } } } diff --git a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer index 2edd744..a37ce62 100644 --- a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer +++ b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer @@ -802,80 +802,80 @@ AFM_DiDZoneEntity zone3 : "{CA08D7D0BA0D24CF}Prefabs/MP/AFM_DiDZonePrefab.et" { } } } - AFM_DiDInfantrySpawnerComponent : "{A93E8199BD60FD8A}Prefabs/Spawners/AFM_DidInfantrySpawner.et" { - coords 31.668 0.138 16.239 - m_iWaveIntervalSeconds 100 - m_iSpawnCountPerWave 8 - m_iMaxAICount 120 - m_fZoneLevelMultiplier 2 + $grp AFM_ArtillerySpawnPointEntity : "{1933061551525F98}Prefabs/MP/AFM_ArtillerySpawnPointEntity.et" { { - $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { - { - coords -118.699 -1.853 -76.084 - } - { - coords -84.415 -1.853 -123.135 - } - { - coords 69.65 2.492 -191.383 - } - { - coords 168.941 32.815 -83.865 - } - { - coords 183.663 31.427 -8.573 - } - { - coords 165.572 27.043 110.199 - } - { - coords 72.032 29.485 369.007 - } - } - $grp SCR_DefendWaypoint : "{0EBC4E7C452C16ED}Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et" { - { - coords 11.274 -0.103 -5.376 - } - { - coords 48.184 -0.01 60.371 - } - { - coords -127.568 1.666 4.644 - } - } - $grp SCR_SearchAndDestroyWaypoint : "{D45195533ADF06E8}Prefabs/AI/Waypoints/DiD_AIWaypoint_SearchAndDestroy.et" { - { - coords 4.06 -0.014 131.038 - CompletionRadius 50 - } - { - coords -182.381 0.241 47.279 - } - { - coords -47.05 0.022 -10.665 - } - } + coords 227.323 33.555 -37.018 + } + { + coords 231.572 31.625 29.575 + } + { + coords 202.94 26.884 110.01 } } AFM_PlayerSpawnPointEntity : "{B6B6E54C76E4D206}Prefabs/MP/AFM_DiDPlayerSpawnPointPrefab.et" { coords 43.555 0.259 -4.312 } - AFM_DiDMortarSpawnerComponent : "{F040102F1843F7C1}Prefabs/Spawners/AFM_MortarSpawner.et" { - coords 227.774 30.794 32.813 - m_iFireMissionUpdateInterval 30 + AFM_DiDAttackerDirector : "{C88B368A8AC21F4E}Prefabs/MP/AFM_DiDAttackerDirector_Default.et" { + coords 0.059 0.004 0.021 + m_fAggression 0.8 { - $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { - zone_1_sp_18 { - coords 3.791 0.801 -2.298 - angles 0 -76.831 0 - } - zone_1_sp_19 { - coords 0.213 2.772 -70.186 - angles 0 -76.831 0 - } - zone_1_sp_20 { - coords -24.508 -3.894 78.018 - angles 0 -76.831 0 + AFM_DiDZoneArtillery : "{2D68BCDEA841A4BC}Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et" { + coords 202.996 26.889 110.521 + } + AFM_DiDInfantrySpawnerComponent : "{A93E8199BD60FD8A}Prefabs/Spawners/AFM_DidInfantrySpawner.et" { + coords 31.609 0.134 16.218 + m_iWaveIntervalSeconds 100 + m_iSpawnCountPerWave 8 + m_iMaxAICount 120 + m_fZoneLevelMultiplier 2 + { + $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { + { + coords -118.699 -1.853 -76.084 + } + { + coords -84.415 -1.853 -123.135 + } + { + coords 69.65 2.492 -191.383 + } + { + coords 168.941 32.815 -83.865 + } + { + coords 183.663 31.427 -8.573 + } + { + coords 165.572 27.043 110.199 + } + { + coords 72.032 29.485 369.007 + } + } + $grp SCR_DefendWaypoint : "{0EBC4E7C452C16ED}Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et" { + { + coords 11.274 -0.103 -5.376 + } + { + coords 48.184 -0.01 60.371 + } + { + coords -127.568 1.666 4.644 + } + } + $grp SCR_SearchAndDestroyWaypoint : "{D45195533ADF06E8}Prefabs/AI/Waypoints/DiD_AIWaypoint_SearchAndDestroy.et" { + { + coords 4.06 -0.014 131.038 + CompletionRadius 50 + } + { + coords -182.381 0.241 47.279 + } + { + coords -47.05 0.022 -10.665 + } + } } } } diff --git a/docs/PREFAB_HIERARCHY.md b/docs/PREFAB_HIERARCHY.md new file mode 100644 index 0000000..a332212 --- /dev/null +++ b/docs/PREFAB_HIERARCHY.md @@ -0,0 +1,262 @@ +# DefenseInDepth — Prefab Hierarchy Reference + +This document describes the full entity hierarchy for a single Defense in Depth zone, +using Zone 1 (Outskirts, Lamentin) as the concrete example. + +--- + +## Overview + +Each zone is a self-contained entity tree. The zone entity is the root; everything the +zone needs — its boundary, defender spawn, attacker director, AI spawners, and optional +artillery — lives as a child of that root. + +``` +AFM_DiDZoneEntity ← zone root (carries AFM_DiDZoneComponent) +├── PolylineShapeEntity ← zone boundary polygon +├── AFM_PlayerSpawnPointEntity ← defender respawn point +├── AFM_ArtillerySpawnPointEntity × N ← mortar spawn markers (optional) +└── AFM_DiDAttackerDirector ← AI decision-maker + ├── AFM_DiDZoneArtillery ← mortar system (optional) + ├── AFM_DiDInfantrySpawnerComponent + │ ├── AFM_SpawnPointEntity × N + │ └── AIWaypoint × N + └── AFM_DiDMechanizedSpawnerComponent + ├── AFM_SpawnPointEntity × N + └── AIWaypoint × N +``` + +--- + +## Full Hierarchy (Zone 1 — "Outskirts") + +### 1. Zone Root + +``` +AFM_DiDZoneEntity "zone1" + components: + AFM_DiDZoneComponent + m_sZoneName = "Outskirts" + m_iZoneIndex = 1 + m_iPrepareTimeSeconds = 900 (15 min prep) + m_iDefenseTimeSeconds = 900 (15 min defense) + m_iPointsBudget = 60 (attacker budget) + m_fBudgetRolloverFraction = 0.9 + Hierarchy +``` + +**Script:** `Scripts/Game/DiD/AFM_DiDZoneComponent.c` + +The component is the zone's brain. On `LateInit` it walks all immediate children, +categorises them by type, then hands references off to the director. It runs the +state machine (`INACTIVE → PREPARE → ACTIVE → FINISHED_*`) and calls +`AFM_DiDAttackerDirector.Process()` every second while active. + +--- + +### 2. Zone Boundary + +``` +└── PolylineShapeEntity "zone_1" + IsClosed = true + Points: 29 ShapePoint entries (world-space XZ polygon) + └── PrefabGeneratorEntity + m_PrefabNames: ZoneBorderMarker_Blue.et + (auto-generates visual border markers along the polyline) +``` + +The polyline defines the capture area used for: +- AI count checks (`GetAICountInsideZone`) +- Smoke/HE target sampling bounds (Monte Carlo) +- `Math2D.IsPointInPolygon` calls throughout the zone logic + +--- + +### 3. Defender Spawn Point + +``` +└── AFM_PlayerSpawnPointEntity + prefab: Prefabs/MP/AFM_DiDPlayerSpawnPointPrefab.et + coords: relative to zone origin +``` + +One per zone. Defenders (BLUFOR players) respawn here. The zone component +caches this as `m_PlayerSpawnPoint` and exposes it via `GetPlayerSpawnPoint()`. + +--- + +### 4. Artillery Spawn Points + +``` +└── AFM_ArtillerySpawnPointEntity × N (3 in Zone 1) + prefab: Prefabs/MP/AFM_ArtillerySpawnPointEntity.et + coords: [different positions scattered around zone perimeter] +``` + +These are position markers only — no logic. The zone component collects them +all into `m_aArtillerySpawnPoints[]` during `LateInit`, then passes the array +to `AFM_DiDAttackerDirector.Init()`, which in turn passes it to +`AFM_DiDZoneArtillery.Initialize()`. + +The mortar spawns at one of these positions. On respawn it picks a different +one (up to 5 random attempts to avoid the last-used point). + +--- + +### 5. Attacker Director + +``` +└── AFM_DiDAttackerDirector + prefab: Prefabs/MP/AFM_DiDAttackerDirector_Default.et + m_iDecisionIntervalSeconds = 25 + m_fAggression = 0.75 +``` + +**Script:** `Scripts/Game/DiD/AFM_DiDAttackerDirector.c` + +Central coordinator. On each decision cycle it: +1. Builds a `AFM_DiDBattlefieldState` snapshot (defender count, budget ratio, phase, day/night) +2. Scores all child spawners via `ScoreRequest(state)` +3. Picks a spawner using weighted-random from the top 3 candidates +4. Separately evaluates and triggers artillery missions / mortar respawns + +Children of the director are discovered in `Init()` by walking `GetChildren()`. + +--- + +### 5a. Zone Artillery (child of Director) + +``` + └── AFM_DiDZoneArtillery + prefab: Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et + m_sMortarPrefab = DiD_MortarComposition_US.et + m_CrewConfig: + m_sGunnerPrefab = Character_USSR_Engineer.et + m_bSpawnDriver = false + m_iHECooldownSeconds = 90 + m_iSmokeCooldownSeconds = 60 + m_iIllumCooldownSeconds = 180 + m_fSampleRadius = 25 + m_fSmokeMinDistance = 25 (smoke screen: min offset from player blob) + m_fSmokeMaxDistance = 50 (smoke screen: max offset from player blob) + m_fSmokeScreenSpread = 20 (smoke screen: perpendicular spread per side) +``` + +**Script:** `Scripts/Game/DiD/AFM_DiDZoneArtillery.c` + +Not a spawner — managed directly by the director. Lifecycle: + +| Event | Action | +|---|---| +| `Initialize()` | Spawns mortar at a random `AFM_ArtillerySpawnPointEntity` (free) | +| Every second | `CheckMortarAlive()` polls damage state | +| Director cycle | `ScoreArtilleryMission()` / `TriggerMission()` | +| Mortar destroyed | `HandleMortarDestroyed()`, director may call `TriggerRespawn()` (max 2×, budget cost) | +| Zone ends | `Cleanup()` deletes mortar + all active waypoints | + +**Smoke mission targeting** (as of current version): fires 3 sequential waypoints +in a line perpendicular to the mortar→player-blob axis, centered 25–50 m in front +of the player blob. Falls back to zone centroid when no alive defenders are found. + +--- + +### 5b. Mortar Composition (spawned at runtime) + +``` +DiD_MortarComposition_USSR.et (or _US.et) + Turret : Mortar_2B14.et ← the mortar weapon entity + └── GenericEntity : AmmoBoxArsenal_Mortar_USSR.et ← ammo supply +``` + +**Prefab path:** `Prefabs/Compositions/` + +These are not placed in the world editor — they are spawned at runtime by +`AFM_DiDZoneArtillery.SpawnMortarTeam()` at the chosen `AFM_ArtillerySpawnPointEntity`. +An AI crew (gunner only) is spawned into the mortar's compartment via `AFM_CrewConfig`. + +--- + +### 5c. Mechanized Spawner (child of Director) + +``` + └── AFM_DiDMechanizedSpawnerComponent + prefab: Prefabs/Spawners/AFM_MechanizedSpawner.et + ├── AFM_SpawnPointEntity "zone_1_sp_10" + │ prefab: Prefabs/MP/AFM_DiDSpawnPointAI.et + ├── AFM_SpawnPointEntity "zone_1_sp_11" + └── SCR_DefendWaypoint "zone1_aiwp_1_def2" + prefab: Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et + CompletionRadius: 80 +``` + +Spawns vehicle groups (APCs, IFVs) at one of its `AFM_SpawnPointEntity` children. +The waypoints are assigned to the spawned group. + +--- + +### 5d. Infantry Spawner (child of Director) + +``` + └── AFM_DiDInfantrySpawnerComponent + prefab: Prefabs/Spawners/AFM_DidInfantrySpawner.et + ├── AFM_SpawnPointEntity "zone_1_sp_1" (5 total) + ├── AFM_SpawnPointEntity "zone_1_sp_2" + ├── AFM_SpawnPointEntity "zone_1_sp_3" + ├── AFM_SpawnPointEntity "zone_1_sp_8" + ├── AFM_SpawnPointEntity "zone_1_sp_9" + ├── SCR_DefendWaypoint "zone1_aiwp_1_def" CompletionRadius: 80 + ├── SCR_DefendWaypoint "zone1_aiwp_2_def" CompletionRadius: 120 + └── SCR_SearchAndDestroyWaypoint "zone_1_aiwp_3_sead" +``` + +Spawns infantry groups at one of its spawn point children. The director's +weighted-random selection decides which spawner fires each cycle — both mechs and +infantry compete for the same decision slot. + +--- + +## Supporting Layer Entities (not zone children) + +These sit in the same layer file but are **not** parented to the zone: + +``` +GenericEntity : ArsenalBox_US.et ← player equipment box +PS_ManualMarker : SupportStationMarker.et ← marks arsenal location on map +GenericEntity : DiD_BuildingService_US.et ← service station composition +PS_ManualMarker × 3 : AttackMarker_Red.et ← attack direction arrows (map markers) +``` + +--- + +## Prefab Reference Table + +| Prefab file | Entity type | Purpose | +|---|---|---| +| `MP/AFM_DiDZonePrefab.et` | `AFM_DiDZoneEntity` | Zone root with `AFM_DiDZoneComponent` | +| `MP/AFM_DiDZoneWavesPrefab.et` | `AFM_DiDZoneEntity` | Zone root with `AFM_DiDWaveZoneComponent` (wave variant) | +| `MP/AFM_DiDPlayerSpawnPointPrefab.et` | `AFM_PlayerSpawnPointEntity` | Defender respawn point | +| `MP/AFM_ArtillerySpawnPointEntity.et` | `AFM_ArtillerySpawnPointEntity` | Mortar spawn marker | +| `MP/AFM_DiDAttackerDirector_Default.et` | `AFM_DiDAttackerDirector` | AI decision coordinator | +| `MP/AFM_DiDZoneArtilleryPrefab.et` | `AFM_DiDZoneArtillery` | Mortar system (child of director) | +| `MP/AFM_DiDSpawnPointAI.et` | `AFM_SpawnPointEntity` | AI group spawn position | +| `Compositions/DiD_MortarComposition_USSR.et` | `Turret` (2B14) | Runtime-spawned mortar (USSR) | +| `Compositions/DiD_MortarComposition_US.et` | `Turret` (M252) | Runtime-spawned mortar (US) | +| `Modes/AFM_GameMode_DiD.et` | `PS_GameModeCoop` | Game mode entity (placed once per world) | + +--- + +## Setup Checklist for a New Zone + +1. Place `AFM_DiDZonePrefab.et` in the layer — this is your zone root. +2. Set `m_sZoneName`, `m_iZoneIndex`, `m_iDefenseTimeSeconds`, `m_iPointsBudget`. +3. Add a `PolylineShapeEntity` child — draw the capture boundary (minimum 3 points, `IsClosed = true`). +4. Add `AFM_DiDPlayerSpawnPointPrefab.et` as a child — position behind the defenders' line. +5. Add `AFM_DiDAttackerDirector_Default.et` as a child of the zone. +6. Add spawner prefabs as children of the **director** (not the zone): + - `AFM_DidInfantrySpawner.et` with `AFM_DiDSpawnPointAI.et` children and waypoints. + - `AFM_MechanizedSpawner.et` (optional) with its own spawn points and waypoints. +7. For artillery support: + - Add `AFM_DiDZoneArtilleryPrefab.et` as a child of the **director**. + - Add 1–3 `AFM_ArtillerySpawnPointEntity.et` as children of the **zone** (not the director). + - Set `m_sMortarPrefab` on the artillery entity to either mortar composition. +8. Set `m_iZoneIndex` sequentially (1 = first zone played, N = last). From 7eada4e82367726d0eaf12fe26620dc8ae05a313 Mon Sep 17 00:00:00 2001 From: Jan Nielek Date: Fri, 20 Mar 2026 21:36:54 +0100 Subject: [PATCH 5/5] fix: Mortar spawn at the game start --- .../Game/DiD/AFM_DiDAttackerDirector.c | 8 +++-- .../Scripts/Game/DiD/AFM_DiDZoneArtillery.c | 28 +++++++++++++-- .../AFM_DiDInfantrySpawnerComponent.c | 4 ++- .../DiD_Linear_Lamentin_Layers/Zone_2.layer | 2 +- .../DiD_Linear_Lamentin_Layers/Zone_3.layer | 36 +++++++------------ 5 files changed, 48 insertions(+), 30 deletions(-) diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c index 8434092..c527602 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDAttackerDirector.c @@ -100,6 +100,10 @@ class AFM_DiDAttackerDirector: GenericEntity if (!m_bInitialized || !m_pZone) return; + // First active tick: spawn the mortar now that this zone is actually running + if (m_pArtillery && m_pArtillery.IsInitialSpawnPending()) + m_pArtillery.TriggerInitialSpawn(); + // Artillery alive check runs every second for responsive destruction detection if (m_pArtillery) m_pArtillery.CheckMortarAlive(); @@ -192,14 +196,14 @@ class AFM_DiDAttackerDirector: GenericEntity int groupCount = ComputeGroupCount(chosen, state); PrintFormat("AFM_DiDAttackerDirector: Triggering %1 x%2 groups (score=%3, cost=%4pts, phase=%5)", chosen.m_Spawner.Type().ToString(), groupCount, chosen.m_fScore, chosen.m_iCost, - state.m_ePhase, level: LogLevel.DEBUG); + state.m_ePhase, level: LogLevel.NORMAL); chosen.m_Spawner.TriggerSpawn(now, groupCount); } } else { PrintFormat("AFM_DiDAttackerDirector: No viable spawner candidates (phase=%1, budget=%2%%)", - state.m_ePhase, state.m_fBudgetRatio * 100, level: LogLevel.DEBUG); + state.m_ePhase, state.m_fBudgetRatio * 100, level: LogLevel.WARNING); } // --- Artillery evaluation (independent of spawner selection) --- diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c index 9412d74..be4f561 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/AFM_DiDZoneArtillery.c @@ -93,6 +93,7 @@ class AFM_DiDZoneArtillery: GenericEntity protected AFM_ArtillerySpawnPointEntity m_pLastSpawnPoint; protected bool m_bMortarActive; + protected bool m_bInitialSpawnPending; protected int m_iRespawnCount; protected int m_iMissionsThisLife; @@ -138,9 +139,9 @@ class AFM_DiDZoneArtillery: GenericEntity return; } - // Initial spawn is free (no budget cost) - SpawnMortarTeam(m_aSpawnPoints.GetRandomElement()); - PrintFormat("AFM_DiDZoneArtillery: Initialized with %1 spawn points", m_aSpawnPoints.Count()); + // Mortar spawns when the zone activates, not at init time (see TriggerInitialSpawn) + m_bInitialSpawnPending = true; + PrintFormat("AFM_DiDZoneArtillery: Initialized with %1 spawn points — mortar pending zone activation", m_aSpawnPoints.Count()); } //------------------------------------------------------------------------------------------------ @@ -169,6 +170,27 @@ class AFM_DiDZoneArtillery: GenericEntity return m_bMortarActive && m_pSpawnedMortar != null; } + //------------------------------------------------------------------------------------------------ + //! Returns true when the mortar has not yet been spawned for this zone activation. + bool IsInitialSpawnPending() + { + return m_bInitialSpawnPending; + } + + //------------------------------------------------------------------------------------------------ + //! Spawn the mortar for the first time. Called by the director on the first active process tick. + //! The initial spawn is free — no budget is consumed. + void TriggerInitialSpawn() + { + m_bInitialSpawnPending = false; + + if (m_aSpawnPoints.IsEmpty()) + return; + + SpawnMortarTeam(m_aSpawnPoints.GetRandomElement()); + PrintFormat("AFM_DiDZoneArtillery: Initial mortar spawn triggered by zone activation"); + } + //------------------------------------------------------------------------------------------------ //! Returns true when another respawn is allowed (max 2 paid respawns per zone). bool CanRespawn() diff --git a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDInfantrySpawnerComponent.c b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDInfantrySpawnerComponent.c index af1e92b..e718dbd 100644 --- a/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDInfantrySpawnerComponent.c +++ b/addons/DefenseInDepth/Scripts/Game/DiD/Spawners/AFM_DiDInfantrySpawnerComponent.c @@ -89,7 +89,9 @@ class AFM_DiDInfantrySpawnerComponent: AFM_DiDSpawnerComponent if (timeSinceLastSpawn >= adjustedInterval) { m_fLastSpawnTime = now; - SpawnWave(); + + // TODO: Figure out wave size + SpawnWave(3); } } diff --git a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer index 0dc81e2..71e77e5 100644 --- a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer +++ b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_2.layer @@ -763,7 +763,7 @@ AFM_DiDZoneEntity zone2 : "{CA08D7D0BA0D24CF}Prefabs/MP/AFM_DiDZonePrefab.et" { } AFM_DiDAttackerDirector : "{C88B368A8AC21F4E}Prefabs/MP/AFM_DiDAttackerDirector_Default.et" { coords -12.364 -0.44 -9.362 - m_fAggression 0.6 + m_fAggression 0.8 { AFM_DiDZoneArtillery : "{2D68BCDEA841A4BC}Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et" { coords 12.61 0.445 9.204 diff --git a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer index a37ce62..e778f19 100644 --- a/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer +++ b/addons/DefenseInDepth/Worlds/DiD_Linear_Lamentin_Layers/Zone_3.layer @@ -818,7 +818,7 @@ AFM_DiDZoneEntity zone3 : "{CA08D7D0BA0D24CF}Prefabs/MP/AFM_DiDZonePrefab.et" { } AFM_DiDAttackerDirector : "{C88B368A8AC21F4E}Prefabs/MP/AFM_DiDAttackerDirector_Default.et" { coords 0.059 0.004 0.021 - m_fAggression 0.8 + m_fAggression 1 { AFM_DiDZoneArtillery : "{2D68BCDEA841A4BC}Prefabs/MP/AFM_DiDZoneArtilleryPrefab.et" { coords 202.996 26.889 110.521 @@ -828,52 +828,42 @@ AFM_DiDZoneEntity zone3 : "{CA08D7D0BA0D24CF}Prefabs/MP/AFM_DiDZonePrefab.et" { m_iWaveIntervalSeconds 100 m_iSpawnCountPerWave 8 m_iMaxAICount 120 - m_fZoneLevelMultiplier 2 { $grp AFM_SpawnPointEntity : "{0601F60522802997}Prefabs/MP/AFM_DiDSpawnPointAI.et" { { - coords -118.699 -1.853 -76.084 + coords 101.875 30.957 271.137 } { - coords -84.415 -1.853 -123.135 + coords -35.529 -0.992 210.137 } { - coords 69.65 2.492 -191.383 + coords -94.037 -1.808 -138.928 } { - coords 168.941 32.815 -83.865 + coords 15.318 -1.792 -239.935 } { - coords 183.663 31.427 -8.573 + coords 68.021 2.435 -190.582 } { - coords 165.572 27.043 110.199 + coords 236.012 35.172 -116.827 } { - coords 72.032 29.485 369.007 - } - } - $grp SCR_DefendWaypoint : "{0EBC4E7C452C16ED}Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et" { - { - coords 11.274 -0.103 -5.376 + coords 222.634 34.665 -99.265 } { - coords 48.184 -0.01 60.371 - } - { - coords -127.568 1.666 4.644 + coords 177.201 33.056 -41.697 } } - $grp SCR_SearchAndDestroyWaypoint : "{D45195533ADF06E8}Prefabs/AI/Waypoints/DiD_AIWaypoint_SearchAndDestroy.et" { + $grp SCR_DefendWaypoint : "{0EBC4E7C452C16ED}Prefabs/AI/Waypoints/DiD_AIWaypoint_Defend.et" { { - coords 4.06 -0.014 131.038 - CompletionRadius 50 + coords 17.523 -0.102 -9.372 } { - coords -182.381 0.241 47.279 + coords -62.809 -0.649 -23.476 } { - coords -47.05 0.022 -10.665 + coords 43.226 -0.009 60.985 } } }