mirror of
https://github.com/FlightControl-Master/MOOSE.git
synced 2026-07-20 22:03:25 +00:00
1712 lines
73 KiB
Lua
1712 lines
73 KiB
Lua
--- **Functional** - Enhanced Warsaw Pact GCI.
|
||
--
|
||
-- ## Main Features:
|
||
--
|
||
-- * Guide AI and human pilots in Warsaw Pact Style. Single flight engagements.
|
||
-- * Advanced Tactics for Groups.
|
||
-- * Many additional events that the mission designer can hook into.
|
||
--
|
||
-- ===
|
||
--
|
||
-- ## Example Missions:
|
||
--
|
||
-- Demo missions can be found on [GitHub](https://github.com/FlightControl-Master/MOOSE_MISSIONS/).
|
||
--
|
||
-- ===
|
||
--
|
||
-- ### Author: **Applevangelist**
|
||
--
|
||
-- ===
|
||
-- @module Functional.RedGCI
|
||
-- @image Func_RedGCI.png
|
||
|
||
---
|
||
-- # RedGCI — Soviet GCI Doctrine & Player Guide
|
||
--
|
||
-- ## Philosophy: Централизованное управление (Centralized Control)
|
||
--
|
||
-- The fundamental difference between Soviet and NATO GCI is **who makes the tactical decision**.
|
||
--
|
||
-- In NATO doctrine, the GCI controller provides situational awareness — bearing, range, altitude, aspect — and the pilot decides how to prosecute the intercept. The pilot is an autonomous tactician. GCI is an advisor.
|
||
--
|
||
-- In Soviet doctrine, the GCI controller **directs**. The pilot executes. The controller selects the intercept geometry, assigns the heading, manages the radar, calls weapons free, and coordinates multi-ship tactics. The pilot's job is to fly the numbers and shoot when told. This is not a flaw — it is the system working as designed. Soviet fighter pilots were trained to be precise executors of GCI instructions, not independent tacticians. The ground radar network (PVO) was the brain; the aircraft was the weapon.
|
||
--
|
||
-- RedGCI models this philosophy faithfully.
|
||
--
|
||
-- ---
|
||
--
|
||
-- ## What to Expect as a Player
|
||
--
|
||
-- ### You will not be asked what you want to do.
|
||
--
|
||
-- There are no "recommend a vector" calls, no "at your discretion" callouts. The controller tells you your heading, your altitude, and your task. Your acknowledgement is assumed.
|
||
--
|
||
-- ### The controller manages your radar.
|
||
--
|
||
-- You do not decide when to turn your radar on. The GCI will tell you when to switch on (`локатор` / `Radar on`). Before that call, you fly cold and silent. This preserves your emissions discipline and prevents the target from getting an early RWR spike.
|
||
--
|
||
-- ### Weapons free is a controlled event.
|
||
--
|
||
-- You do not engage until the controller clears you (`цель разрешена` / `WEAPONS FREE`). The controller determines when geometry, range, and aspect are favorable. Shooting early breaks the coordinated intercept and may compromise your wingman's attack.
|
||
--
|
||
-- ### Radio calls are short and military.
|
||
--
|
||
-- Soviet GCI brevity is terse by design. Expect calls like:
|
||
--
|
||
-- - `"Сокол, курс 170, высота 4500."` — vector, altitude
|
||
-- - `"Сокол, цель, пара, истребитель. Локатор."` — picture call on commit: count, type, radar on
|
||
-- - `"Сокол, захват. Дальность 20. Цель разрешена."` — lock confirmed, range, weapons free
|
||
-- - `"Сокол, молодец. Домой."` — good kill, RTB
|
||
--
|
||
-- There are no "BOGEY DOPE" requests, no "BRAA" calls, no "DECLARE" queries. The controller has already done that work. You fly the vector.
|
||
--
|
||
-- ---
|
||
--
|
||
-- ## State Flow — What the GCI is Doing Behind the Scenes
|
||
--
|
||
-- RedGCI manages a state machine that progresses through six phases. Understanding these phases helps you anticipate what call is coming next.
|
||
--
|
||
-- ```
|
||
-- VECTOR → COMMIT → RADAR_CONTACT → VISUAL → MERGE → (SPLASH / ABORT / RTB)
|
||
-- ```
|
||
--
|
||
-- ### VECTOR
|
||
-- The controller has a track. You are being vectored onto an intercept geometry. Your radar is off. The controller is solving a collision course and updating your heading every tick. Altitude calls reflect the intercept geometry — you may be sent below the target (classic Soviet shoot-up doctrine for radar-limited types) or level/above (MiG-29/Su-27 lookdown geometry). Expect heading updates every 10–15 seconds.
|
||
--
|
||
-- **What you should do:** Fly the heading. Don't deviate. Don't turn your radar on yet. Speed is expected at 900kph TAS (depending on airframe)
|
||
--
|
||
-- ### COMMIT
|
||
-- Range has closed to approximately 30km. The controller calls the picture: count and type. Your radar comes on. You are now committed to the intercept — turning away is no longer the default option. The controller is building your radar geometry toward a lock.
|
||
--
|
||
-- **What you should do:** Activate your radar. Acquire the target. Do not fire yet.
|
||
--
|
||
-- ### RADAR_CONTACT
|
||
-- You have radar lock (or the AI has achieved it). The controller confirms lock and calls range. If geometry and range are favorable, weapons free follows immediately. If not — for example if aspect angle is unfavorable for a stern conversion — the controller holds fire and waits for better geometry.
|
||
--
|
||
-- **What you should do:** Maintain lock. Track the target. Wait for the weapons free call.
|
||
--
|
||
-- ### VISUAL
|
||
-- Range has closed to approximately 5km — visual conditions. Weapons free is automatic at this point. You are now in the merge envelope.
|
||
--
|
||
-- **What you should do:** Engage.
|
||
--
|
||
-- ### MERGE
|
||
-- Inside 2km. The GCI transitions to merge control: bearing to target, overshoot calls, separation instructions, reattack vectors. At this range the controller cannot see fine-grained geometry — merge calls are based on relative bearing and closure.
|
||
--
|
||
-- **What you should do:** Fight. Listen for overshoot, separation, and reattack calls.
|
||
--
|
||
-- ### SPLASH / ABORT / RTB
|
||
-- - `SPLASH` — kill confirmed, RTB
|
||
-- - `ABORT (THREAT)` — your RWR is spiked or a threat geometry has developed; break off immediately on the given heading
|
||
-- - `ABORT (BINGO)` — fuel state critical; break off and return
|
||
--
|
||
-- ---
|
||
--
|
||
-- ## Multi-Ship (2v2) Tactics (REDGCI2v2)
|
||
--
|
||
-- When two fighters are dispatched against a threat, the GCI selects a tactic automatically based on the tactical situation. The tactic is applied at COMMIT — until then, both fighters are vectored together toward the intercept midpoint.
|
||
--
|
||
-- | Tactic | Description |
|
||
-- |--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||
-- | **PINCER** | Classic bracket. F1 and F2 split left and right, attacking from opposite angles simultaneously. Forces the target to choose which threat to react to. |
|
||
-- | **HIGH-LOW** | Vertical split. One fighter attacks from below (radar up, clean sky background), one from above. Degrades the target's ability to acquire both simultaneously. |
|
||
-- | **STAGGER** | BVR timing offset. F1 fires first at long range, F2 follows 8–13km behind to engage a maneuvering or defending target. |
|
||
-- | **TRAIL** | Close trail. F1 is the shooter, F2 is support — ready to engage if F1 overshoots or is defeated. |
|
||
-- | **GIRAFFE** | *(Historical — Iraq/Iran War, Mirage F1 vs F-14A)* F1 attacks at normal altitude, binding the AWG-9 radar. F2 flies nap-of-earth |
|
||
-- | | (300–600m AGL) using ground clutter to degrade radar detection, then pulls up and fires from close range. |
|
||
--
|
||
-- During a tactic split, you may receive a heading that seems unusual — a large lateral offset or an unexpected altitude change. **Trust the vector.** The controller is positioning you for the tactic geometry. The merge point will bring you back onto the target.
|
||
--
|
||
-- ---
|
||
--
|
||
-- ## Dispatcher & CAP Flow (REDGCI_DISPATCHER)
|
||
--
|
||
-- When using the dispatcher layer, the full operational flow is:
|
||
--
|
||
-- ```
|
||
-- Spawn at homeplate
|
||
-- → Taxi and takeoff (template-controlled)
|
||
-- → Transit to CAP zone
|
||
-- → Orbit in assigned zone (radar cold, weapons safe)
|
||
-- ↓ INTEL detects threat cluster
|
||
-- → "Attention, radar contact. Pair, fighter, 45 kilometers." (all CAP fighters)
|
||
-- ↓ Dispatcher assigns pair
|
||
-- → "101 102, intercept. Pair, fighter." (dispatched pair)
|
||
-- → VECTOR → COMMIT → RADAR_CONTACT → VISUAL → MERGE → SPLASH
|
||
-- ↓ Engagement complete
|
||
-- → AI: RTB waypoint → land → despawn → respawn after delay
|
||
-- → Human: "101, mission complete. RTB, refuel and rearm."
|
||
-- ↓ After RespawnDelay
|
||
-- → New AI pair spawns into same CAP zone
|
||
-- ```
|
||
--
|
||
-- Human players are dispatched first when available. If a human and AI are both in the CAP pool, the human is always assigned to the next intercept. AI fills gaps. The dispatcher does not send a single fighter if a pair is available — pairing is always preferred.
|
||
--
|
||
-- ---
|
||
--
|
||
-- ## Key Differences from NATO GCI at a Glance
|
||
--
|
||
-- | | Soviet (RedGCI) | NATO |
|
||
-- |--------------------|--------------------------------------|------------------------------------|
|
||
-- | Tactical decision | Controller | Pilot |
|
||
-- | Radar management | Controller-commanded | Pilot-initiated |
|
||
-- | Weapons free | Controller-called | Pilot-discretion (after WF) |
|
||
-- | Heading calls | Prescriptive | Advisory |
|
||
-- | Brevity style | Terse, military, positional | Standardized (BRAA, DECLARE, etc.) |
|
||
-- | Multi-ship tactics | Centrally planned, applied at COMMIT | Mutually briefed, pilot-executed |
|
||
-- | Pilot autonomy | Low (by design) | High |
|
||
--
|
||
-- **The Soviet system is not inferior** — it is optimized for a different kind of pilot and a different operational context. Mass interception of large NATO strike packages over defended Soviet airspace demanded centralized, efficient, high-throughput GCI control. RedGCI brings that experience to DCS.
|
||
--
|
||
-- @field #REDGCI
|
||
REDGCI = {}
|
||
|
||
--- Class name
|
||
REDGCI.ClassName = "REDGCI"
|
||
|
||
--- Version
|
||
REDGCI.version = "2.0.0"
|
||
|
||
--- Waypoint lookahead: WP is clamped to this factor × speed × tick_interval (minimum 15 km)
|
||
-- @field #number WP_DISTANCE_FACTOR
|
||
REDGCI.WP_DISTANCE_FACTOR = 5.0
|
||
|
||
-- ─────────────────────────────────────────────────────────────
|
||
-- Localized messages — embedded (gci_messages.lua no longer needed)
|
||
-- ─────────────────────────────────────────────────────────────
|
||
|
||
--- @type REDGCI.Messages
|
||
-- @field #string en
|
||
-- @field #string de
|
||
-- @field #string ru
|
||
REDGCI.Messages = {
|
||
|
||
-- ── ENGLISH ──────────────────────────────────────────────
|
||
en = {
|
||
-- GCI → Pilot
|
||
VECTOR = "{CALLSIGN}, vector {HDG}, {ALT}.",
|
||
VECTOR_WITH_TTI = "{CALLSIGN}, vector {HDG}, {ALT}. Target in {TTI_M}.",
|
||
COMMIT_FIRST = "{CALLSIGN}, target, {COUNT}, {TYPE}. Radar on.",
|
||
COMMIT_NO_LOCK = "{CALLSIGN}, correction: bearing {ASPECT}, {RNG} kilometers. No lock?",
|
||
COMMIT_NUDGE = "{CALLSIGN}, {DIR_LR} ten degrees.",
|
||
RADAR_LOCK_WF = "{CALLSIGN}, lock confirmed. {RNG} kilometers. WEAPONS FREE.",
|
||
RADAR_LOCK_HOLD = "{CALLSIGN}, lock confirmed. {RNG} kilometers. Hold fire.",
|
||
RADAR_WF_NOW = "{CALLSIGN}, WEAPONS FREE.",
|
||
VISUAL_CONFIRM = "{CALLSIGN}, visual confirmed. WEAPONS FREE.",
|
||
NOTCH_ENTRY = "{CALLSIGN}, target maneuvering. Standby.",
|
||
NOTCH_UPDATE = "{CALLSIGN}, target {DIR_RL}, {RNG} kilometers.",
|
||
ABORT_BINGO = "{CALLSIGN}, BINGO. Break off. Course {HDG}.",
|
||
ABORT_THREAT = "{CALLSIGN}, THREAT. Break off. Course {HDG}.",
|
||
MERGE_ENTRY = "{CALLSIGN}, contact {DIR_RL}, {ASPECT} degrees. FIGHT.",
|
||
MERGE_OVERSHOOT = "{CALLSIGN}, overshoot. Break {DIR_LR}.",
|
||
MERGE_SEPARATION = "{CALLSIGN}, separate. Climb and reset.",
|
||
MERGE_REATTACK = "{CALLSIGN}, reattack. Target {DIR_RL}.",
|
||
MERGE_LOST = "{CALLSIGN}, blind. Heading {HDG}.",
|
||
MERGE_SPLASH = "{CALLSIGN}, good kill. RTB.",
|
||
RADAR_ON = "{CALLSIGN}, radar on.",
|
||
WEAPONS_FREE = "{CALLSIGN}, WEAPONS FREE.",
|
||
-- Pilot → GCI acknowledgements
|
||
ACK_VECTOR = "Copy, {HDG}.",
|
||
ACK_COMMIT = "Copy.",
|
||
ACK_WEAPONS_FREE = "Copy. Engaging.",
|
||
ACK_ABORT = "Copy. Breaking off.",
|
||
ACK_SPLASH = "Splash. RTB.",
|
||
},
|
||
|
||
-- ── GERMAN ───────────────────────────────────────────────
|
||
de = {
|
||
-- GCI → Pilot
|
||
VECTOR = "{CALLSIGN}, Kurs {HDG}, {ALT}.",
|
||
VECTOR_WITH_TTI = "{CALLSIGN}, Kurs {HDG}, {ALT}. Ziel in {TTI_M}.",
|
||
COMMIT_FIRST = "{CALLSIGN}, Ziel, {COUNT}, {TYPE}. Radar an.",
|
||
COMMIT_NO_LOCK = "{CALLSIGN}, Korrektur: Peilung {ASPECT}, {RNG} Kilometer. Kein Lock?",
|
||
COMMIT_NUDGE = "{CALLSIGN}, {DIR_LR} zehn Grad.",
|
||
RADAR_LOCK_WF = "{CALLSIGN}, Lock bestätigt. {RNG} Kilometer. Feuer frei.",
|
||
RADAR_LOCK_HOLD = "{CALLSIGN}, Lock bestätigt. {RNG} Kilometer. Warten.",
|
||
RADAR_WF_NOW = "{CALLSIGN}, Feuer frei.",
|
||
VISUAL_CONFIRM = "{CALLSIGN}, Sichtkontakt. Feuer frei.",
|
||
NOTCH_ENTRY = "{CALLSIGN}, Ziel manövriert. Warten.",
|
||
NOTCH_UPDATE = "{CALLSIGN}, Ziel {DIR_RL}, {RNG} Kilometer.",
|
||
ABORT_BINGO = "{CALLSIGN}, BINGO. Abbruch. Kurs {HDG}.",
|
||
ABORT_THREAT = "{CALLSIGN}, BEDROHUNG. Abbruch. Kurs {HDG}.",
|
||
MERGE_ENTRY = "{CALLSIGN}, Kontakt {DIR_RL}, {ASPECT} Grad. Angriff.",
|
||
MERGE_OVERSHOOT = "{CALLSIGN}, Überschuss. {DIR_LR} ausbrechen.",
|
||
MERGE_SEPARATION = "{CALLSIGN}, trennen. Steigen und neu ansetzen.",
|
||
MERGE_REATTACK = "{CALLSIGN}, neu angreifen. Ziel {DIR_RL}.",
|
||
MERGE_LOST = "{CALLSIGN}, BLIND. Kurs {HDG}.",
|
||
MERGE_SPLASH = "{CALLSIGN}, Treffer. Heimkurs.",
|
||
RADAR_ON = "{CALLSIGN}, Radar an.",
|
||
WEAPONS_FREE = "{CALLSIGN}, Feuer frei.",
|
||
-- Pilot → GCI acknowledgements
|
||
ACK_VECTOR = "Verstanden, Kurs {HDG}.",
|
||
ACK_COMMIT = "Verstanden.",
|
||
ACK_WEAPONS_FREE = "Verstanden. Greife an.",
|
||
ACK_ABORT = "Verstanden. Abbruch.",
|
||
ACK_SPLASH = "Treffer. Heimkurs.",
|
||
},
|
||
|
||
-- ── RUSSIAN ───────────────────────────────────────────────
|
||
ru = {
|
||
-- GCI → Pilot
|
||
VECTOR = "{CALLSIGN}, курс {HDG}, высота {ALT}.",
|
||
VECTOR_WITH_TTI = "{CALLSIGN}, курс {HDG}, высота {ALT}. До цели {TTI_M} минут.",
|
||
COMMIT_FIRST = "{CALLSIGN}, цель, {COUNT}, {TYPE}. Локатор.",
|
||
COMMIT_NO_LOCK = "{CALLSIGN}, поправка: азимут {ASPECT}, дальность {RNG}. Захват?",
|
||
COMMIT_NUDGE = "{CALLSIGN}, довернись {DIR_LR}.",
|
||
RADAR_LOCK_WF = "{CALLSIGN}, захват. Дальность {RNG}. Цель разрешена.",
|
||
RADAR_LOCK_HOLD = "{CALLSIGN}, захват. Дальность {RNG}. Жди.",
|
||
RADAR_WF_NOW = "{CALLSIGN}, цель разрешена.",
|
||
VISUAL_CONFIRM = "{CALLSIGN}, визуальный. Цель разрешена.",
|
||
NOTCH_ENTRY = "{CALLSIGN}, цель маневрирует. Жди.",
|
||
NOTCH_UPDATE = "{CALLSIGN}, цель {DIR_RL}, дальность {RNG}.",
|
||
ABORT_BINGO = "{CALLSIGN}, топливо. Прекрати. Курс {HDG}.",
|
||
ABORT_THREAT = "{CALLSIGN}, угроза. Прекрати. Курс {HDG}.",
|
||
MERGE_ENTRY = "{CALLSIGN}, контакт {DIR_RL}, {ASPECT} градусов. Бой.",
|
||
MERGE_OVERSHOOT = "{CALLSIGN}, перелёт. Разворот {DIR_LR}.",
|
||
MERGE_SEPARATION = "{CALLSIGN}, выход. Высота, повтори.",
|
||
MERGE_REATTACK = "{CALLSIGN}, повтори. Цель {DIR_RL}.",
|
||
MERGE_LOST = "{CALLSIGN}, потеря. Курс {HDG}.",
|
||
MERGE_SPLASH = "{CALLSIGN}, молодец. Домой.",
|
||
RADAR_ON = "{CALLSIGN}, локатор.",
|
||
WEAPONS_FREE = "{CALLSIGN}, цель разрешена.",
|
||
-- Pilot → GCI acknowledgements (kurz und militärisch)
|
||
ACK_VECTOR = "Понял, курс {HDG}.",
|
||
ACK_COMMIT = "Понял.",
|
||
ACK_WEAPONS_FREE = "Понял. Атакую.",
|
||
ACK_ABORT = "Понял. Прекращаю.",
|
||
ACK_SPLASH = "Цель поражена. Домой.",
|
||
},
|
||
}
|
||
|
||
--- @type REDGCI.DirTokens
|
||
-- @field #string en
|
||
-- @field #string de
|
||
-- @field #string ru
|
||
REDGCI.DirTokens = {
|
||
en = { left="left", right="right", ahead="ahead", behind="behind", low="low", high="high" },
|
||
de = { left="links", right="rechts", ahead="voraus", behind="hinten", low="tief", high="hoch" },
|
||
ru = { left="влево", right="вправо", ahead="впереди", behind="сзади", low="ниже", high="выше" },
|
||
}
|
||
|
||
--- ─────────────────────────────────────────────────────────────
|
||
-- Picture tokens — COUNT and TYPE
|
||
-- COUNT: 1=single, 2=pair, 3-4=group, 5+=big group
|
||
-- TYPE: RCS-based fighter/bomber/machines (machines = unknown/medium)
|
||
-- ─────────────────────────────────────────────────────────────
|
||
|
||
--- @type REDGCI.CountTokens
|
||
-- @field #string en
|
||
-- @field #string de
|
||
-- @field #string ru
|
||
REDGCI.CountTokens = {
|
||
en = { single="single", pair="pair", group="group", biggroup="big group" },
|
||
de = { single="einzeln", pair="Paar", group="Gruppe", biggroup="große Gruppe" },
|
||
ru = { single="одиночная", pair="пара", group="группа", biggroup="большая группа"},
|
||
}
|
||
|
||
--- @type REDGCI.TypeTokens
|
||
-- @field #string en
|
||
-- @field #string de
|
||
-- @field #string ru
|
||
REDGCI.TypeTokens = {
|
||
en = { fighter="fighter", bomber="bomber", machines="machines" },
|
||
de = { fighter="Jäger", bomber="Bomber", machines="Maschinen" },
|
||
ru = { fighter="истребитель", bomber="бомбардировщик", machines="машины" },
|
||
}
|
||
|
||
---
|
||
-- @field #number RCS_FIGHTER_MAX
|
||
-- @field #number RCS_BOMBER_MIN
|
||
-- RCS thresholds für automatische Typ-Erkennung aus INTEL
|
||
REDGCI.RCS_FIGHTER_MAX = 6.0 -- < 6 m² → Jäger
|
||
REDGCI.RCS_BOMBER_MIN = 20.0 -- > 20 m² → Bomber
|
||
-- dazwischen → Maschinen (unbekannt)
|
||
|
||
-- ─────────────────────────────────────────────────────────────
|
||
-- Constructor
|
||
-- ─────────────────────────────────────────────────────────────
|
||
|
||
--- Create a new REDGCI instance.
|
||
-- @param #REDGCI self
|
||
-- @param #string FighterGroupName DCS group name of the interceptor(s)
|
||
-- @param #string TargetGroupName DCS group name of the target(s)
|
||
-- @param #string Callsign Radio callsign string (e.g. "Сокол-1")
|
||
-- @param #number Coalition coalition.side.RED or coalition.side.BLUE
|
||
-- @return #REDGCI self
|
||
function REDGCI:New(FighterGroupName, TargetGroupName, Callsign, Coalition)
|
||
local self = BASE:Inherit(self, FSM:New()) --#REDGCI
|
||
|
||
-- Log prefix
|
||
self.lid = string.format("REDGCI (%s) | ", Callsign or "GCI")
|
||
|
||
-- ── Core identity ─────────────────────────────────────────
|
||
self.FighterGroupName = FighterGroupName or "Mig-29A"
|
||
self.TargetGroupName = TargetGroupName or "Target"
|
||
self.Callsign = Callsign or "Сокол 1"
|
||
self.Coalition = Coalition or coalition.side.RED
|
||
|
||
-- ── Defaults ─────────────────────────────────────────────
|
||
self.Locale = "ru"
|
||
self.TickInterval = 10.0
|
||
self.TxRepeatInterval = 30.0
|
||
self.SubtitleTime = 8
|
||
self.IsAIPlane = true
|
||
self.HomeBaseName = nil
|
||
self.HomeBase = nil -- Vec2 {x,y}
|
||
self.Debug = false
|
||
self.WFRange = 20000 -- metres: AI weapons-free range (C kernel wf=false workaround)
|
||
self.AltOffset = -700 -- metres relative to target altitude for intercept waypoint.
|
||
self.ContactLostTimeout = 3 -- ticks without contact before declaring target gone
|
||
self._contact_lost_ticks = 0 -- internal counter
|
||
-- Negative = below target (Shootup geometry, classic Soviet doctrine
|
||
-- for radar-limited types like MiG-21/early MiG-23).
|
||
-- Zero or positive = Lookdown/Shoot-Down geometry (MiG-29, Su-27).
|
||
-- Use SetAltOffset() to override.
|
||
self._target_count = 1
|
||
self._target_type = "FIGHTER"
|
||
|
||
-- ── SRS defaults (GCI controller voice) ──────────────────
|
||
self.SRSPath = nil
|
||
self.SRSFreq = 251
|
||
self.SRSMod = radio.modulation.AM
|
||
self.SRSCulture = "ru-RU"
|
||
self.SRSVoice = MSRS.Voices.Google.Standard.ru_RU_Standard_D
|
||
self.SRSPort = 5002
|
||
|
||
-- ── Pilot SRS defaults (separate voice for acknowledgements) ──
|
||
self.PilotCallsign = nil -- if nil, no pilot ACKs are transmitted
|
||
self.PilotSRSCulture = "ru-RU"
|
||
self.PilotSRSVoice = MSRS.Voices.Google.Standard.ru_RU_Standard_B
|
||
self._pilot_msrs = nil
|
||
self._pilot_queue = nil
|
||
|
||
-- ── Internal state (per instance) ─────────────────────────
|
||
self._pilot_flags = { radar=false, visual=false, threat=false }
|
||
self._prev_state = nil
|
||
self._prev_wf = false
|
||
self._prev_radar = false
|
||
self._last_tx = { text="", time=0 }
|
||
self._msrs = nil
|
||
self._srs_queue = nil
|
||
self._gettext = nil
|
||
self._wp_override = nil -- { x, z, y, ticks } set by REDGCI2v2 for tactic geometry
|
||
self._missilerangeflag = 2 -- 1, HALF_WAY_RMAX_NEZ = 2, TARGET_THREAT_EST = 3, RANDOM_RANGE = 4. Defaults to 2.
|
||
|
||
-- FSM state tracking for REDGCI_KERNEL (no C DLL needed)
|
||
self._ticks_in_state = 0
|
||
self._merge_phase = "ENTRY"
|
||
self._merge_phase_prev = "ENTRY"
|
||
self._merge_ticks = 0
|
||
self._merge_pass_count = 0
|
||
|
||
-- ── FSM transitions ───────────────────────────────────────
|
||
self:SetStartState("Stopped")
|
||
self:AddTransition("Stopped", "Start", "Running")
|
||
self:AddTransition("Running", "Status", "Running")
|
||
self:AddTransition("Running", "Stop", "Stopped")
|
||
|
||
self:I(self.lid .. "v" .. REDGCI.version .. " created. Fighter=" ..
|
||
self.FighterGroupName .. " Target=" .. self.TargetGroupName)
|
||
|
||
return self
|
||
end
|
||
|
||
-- ─────────────────────────────────────────────────────────────
|
||
-- User API — configuration (all return self for chaining)
|
||
-- ─────────────────────────────────────────────────────────────
|
||
|
||
--- Set the locale for radio messages.
|
||
-- @param #REDGCI self
|
||
-- @param #string Locale "en", "de", or "ru" (default "ru")
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetLocale(Locale)
|
||
self.Locale = Locale or "ru"
|
||
return self
|
||
end
|
||
|
||
--- Enable or disable AI waypoint and radar management.
|
||
-- @param #REDGCI self
|
||
-- @param #boolean IsAI true = push waypoints + control radar
|
||
-- @param #string HomeBaseName AIRBASE name for RTB (e.g. AIRBASE.Caucasus.Nalchik)
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetAIMode(IsAI, HomeBaseName)
|
||
self.IsAIPlane = IsAI ~= false
|
||
if HomeBaseName then
|
||
self.HomeBaseName = HomeBaseName
|
||
local ab = AIRBASE:FindByName(HomeBaseName)
|
||
if ab then
|
||
self.HomeBase = ab:GetVec2()
|
||
else
|
||
self:E(self.lid .. "SetAIMode: AIRBASE '" .. tostring(HomeBaseName) .. "' not found!")
|
||
end
|
||
end
|
||
return self
|
||
end
|
||
|
||
--- Configure SRS radio output.
|
||
-- @param #REDGCI self
|
||
-- @param #string Path Path to SRS (or nil to use MSRS default)
|
||
-- @param #number Frequency MHz, e.g. 251
|
||
-- @param #number Modulation radio.modulation.AM or FM (default AM)
|
||
-- @param #string Culture BCP-47 culture string, e.g. "ru-RU"
|
||
-- @param #string Voice MSRS voice constant
|
||
-- @param #number Port SRS port (default 5002)
|
||
-- @param #number Speed Speach speed, default 1.
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetSRS(Path, Frequency, Modulation, Culture, Voice, Port, Speed)
|
||
self.SRSPath = Path
|
||
self.SRSFreq = Frequency or self.SRSFreq
|
||
self.SRSMod = Modulation or self.SRSMod
|
||
self.SRSCulture = Culture or self.SRSCulture
|
||
self.SRSVoice = Voice or self.SRSVoice
|
||
self.SRSPort = Port or self.SRSPort
|
||
self.SRSSpeed = Speed or 1
|
||
self:I({F=Frequency,V=Voice})
|
||
return self
|
||
end
|
||
|
||
--- Set SRS Provider
|
||
--@param #REDGCI self
|
||
--@param #string Provider
|
||
function REDGCI:SetSRSProvider(Provider)
|
||
self:T(self.lid.."SetSRSProvider "..tostring(Provider))
|
||
self.SRSProvider = Provider or MSRS.Provider.GOOGLE
|
||
return self
|
||
end
|
||
|
||
--- Set SRS Voice Speaker for Hound/Piper
|
||
--@param #REDGCI self
|
||
--@param #number Speaker Speaker number, e.g. 11 for Speaker "318 (11)"
|
||
function REDGCI:SetSRSPiperSpeaker(Speaker)
|
||
self:T(self.lid.."SetSRSPiperSpeaker "..tostring(Speaker))
|
||
self.SRSSpeaker = Speaker
|
||
return self
|
||
end
|
||
|
||
--- Configure the pilot voice for radio acknowledgements.
|
||
-- The pilot uses the same frequency/modulation as the GCI controller but
|
||
-- a distinct voice so the two can be told apart on the radio.
|
||
-- Set PilotCallsign to nil (default) to disable pilot ACKs entirely.
|
||
-- @param #REDGCI self
|
||
-- @param #string PilotCallsign Pilot's callsign (e.g. "Сокол-1"), or nil to disable ACKs.
|
||
-- @param #string Culture BCP-47 culture string (default same as GCI)
|
||
-- @param #string Voice MSRS voice constant (default ru_RU_Standard_B)
|
||
-- @param #number Speaker (Optional) MSRS Speaker for Hound/Piper Voices, e.g. 11 for "318 (11)"
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetPilotSRS(PilotCallsign, Culture, Voice, Speaker)
|
||
self:T({CS=PilotCallsign,CU=Culture,VO=Voice,SP=Speaker})
|
||
self.PilotCallsign = PilotCallsign
|
||
self.PilotSRSCulture = Culture or self.SRSCulture
|
||
self.PilotSRSVoice = Voice or MSRS.Voices.Google.Standard.ru_RU_Standard_B
|
||
self.PilotSRSSpeaker = Speaker
|
||
return self
|
||
end
|
||
|
||
--- Set the target count manually (used when no INTEL source is attached).
|
||
-- Overridden automatically when INTEL is active.
|
||
-- @param #REDGCI self
|
||
-- @param #number Count Number of targets (1=single, 2=pair, 3-4=group, 5+=big group)
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetTargetCount(Count)
|
||
self._target_count = Count or 1
|
||
return self
|
||
end
|
||
|
||
--- Set the target type manually (used when no INTEL source is attached).
|
||
-- @param #REDGCI self
|
||
-- @param #string Type "FIGHTER", "BOMBER", or "MACHINES" (default)
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetTargetType(Type)
|
||
self._target_type = Type or "MACHINES"
|
||
return self
|
||
end
|
||
|
||
--- Set the GCI tick interval in seconds.
|
||
-- @param #REDGCI self
|
||
-- @param #number Seconds Default 10.0
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetTickInterval(Seconds)
|
||
self.TickInterval = Seconds or 10.0
|
||
return self
|
||
end
|
||
|
||
--- Set minimum seconds between identical transmissions.
|
||
-- @param #REDGCI self
|
||
-- @param #number Seconds Default 30.0
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetTxRepeatInterval(Seconds)
|
||
self.TxRepeatInterval = Seconds or 30.0
|
||
return self
|
||
end
|
||
|
||
--- Set the AI weapons-free range threshold in metres.
|
||
-- Weapons free is declared when the C kernel wf flag is true OR (AI mode AND
|
||
-- state is RADAR_CONTACT AND range <= WFRange). Set to 0 to disable the
|
||
-- Lua-side override and rely solely on the C kernel.
|
||
-- @param #REDGCI self
|
||
-- @param #number Meters Default 20000
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetWFRange(Meters)
|
||
self.WFRange = Meters or 20000
|
||
return self
|
||
end
|
||
|
||
--- Enable or disable debug logging.
|
||
-- @param #REDGCI self
|
||
-- @param #boolean OnOff true = verbose logging
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetDebug(OnOff)
|
||
self.Debug = OnOff ~= false
|
||
return self
|
||
end
|
||
|
||
--- Set the altitude offset applied to intercept waypoints relative to the target altitude.
|
||
-- Models the preferred attack geometry of the fighter type:
|
||
--
|
||
-- Negative offset (below target) = classic Soviet Shootup doctrine.
|
||
-- The fighter is vectored below the target so its radar looks up
|
||
-- against a clean sky background, avoiding ground clutter.
|
||
-- Appropriate for MiG-21, early MiG-23 (limited or no LDSD capability).
|
||
-- Typical value: -700 m (default).
|
||
--
|
||
-- Zero or positive offset (at or above target) = Lookdown/Shoot-Down.
|
||
-- The fighter has a modern pulse-Doppler radar capable of suppressing
|
||
-- ground clutter and shooting downward.
|
||
-- Appropriate for MiG-29 (N019), Su-27 (N001), MiG-31 (Zaslon).
|
||
-- Typical value: 0 (level) to +300 m (slight high perch).
|
||
--
|
||
-- The offset is only applied during VECTOR and COMMIT states.
|
||
-- In RADAR_CONTACT and beyond, exact geometry is driven by the C kernel.
|
||
-- @param #REDGCI self
|
||
-- @param #number Meters Altitude offset in metres (default -700).
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetAltOffset(Meters)
|
||
self.AltOffset = Meters or -700
|
||
return self
|
||
end
|
||
|
||
--- Set how many consecutive ticks without a target contact are tolerated
|
||
-- before the GCI declares the intercept over.
|
||
--
|
||
-- During a contact gap the GCI transmits NOTCH_ENTRY on the first missing
|
||
-- tick and then stays silent, holding the last known vector, until either
|
||
-- the contact is re-acquired (counter resets) or the timeout is reached
|
||
-- (MERGE_SPLASH + Stop).
|
||
--
|
||
-- One tick = TickInterval seconds (default 10 s), so the default of 3 ticks
|
||
-- means ~30 s of tolerance — enough to cover a typical notch manoeuvre or
|
||
-- brief Doppler blind spot without falsely declaring a kill.
|
||
-- @param #REDGCI self
|
||
-- @param #number Ticks Number of ticks (default 3).
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetContactLostTimeout(Ticks)
|
||
self.ContactLostTimeout = Ticks or 3
|
||
return self
|
||
end
|
||
|
||
--- Signal that the pilot has achieved radar lock.
|
||
-- Equivalent to pressing "Radar Lock" in the F10 menu.
|
||
-- @param #REDGCI self
|
||
-- @param #boolean OnOff
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetPilotRadarLock(OnOff)
|
||
self._pilot_flags.radar = OnOff ~= false
|
||
return self
|
||
end
|
||
|
||
--- Signal that the pilot has visual contact.
|
||
-- @param #REDGCI self
|
||
-- @param #boolean OnOff
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetPilotVisual(OnOff)
|
||
self._pilot_flags.visual = OnOff ~= false
|
||
return self
|
||
end
|
||
|
||
--- Set range on which AI will prefer to fire missiles.
|
||
-- MAX_RANGE = 0, NEZ_RANGE = 1, HALF_WAY_RMAX_NEZ = 2, TARGET_THREAT_EST = 3, RANDOM_RANGE = 4. Defaults to 2.
|
||
-- @param #REDGCI self
|
||
-- @param #number Flag The behavior to set.
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetMissileFiringFlag(Flag)
|
||
self._missilerangeflag = Flag or 2
|
||
return self
|
||
end
|
||
|
||
--- Signal that the pilot's RWR is active (threat warning).
|
||
-- @param #REDGCI self
|
||
-- @param #boolean OnOff
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetPilotThreat(OnOff)
|
||
self._pilot_flags.threat = OnOff ~= false
|
||
return self
|
||
end
|
||
|
||
--- Reset FSM state and pilot flags (e.g. after a splash or new intercept).
|
||
-- @param #REDGCI self
|
||
-- @return #REDGCI self
|
||
function REDGCI:Reset()
|
||
self._pilot_flags = { radar=false, visual=false, threat=false }
|
||
self._prev_state = nil
|
||
self._prev_wf = false
|
||
self._prev_radar = false
|
||
self._last_tx = { text="", time=0 }
|
||
self._contact_lost_ticks = 0
|
||
self._wp_override = nil -- { x, z, y, ticks } set by REDGCI2v2 for tactic geometry
|
||
RedGCI.reset(self.Callsign)
|
||
self:_SetRadar(false)
|
||
self:_SetWeaponsFree(false)
|
||
self:T(self.lid .. "Reset.")
|
||
return self
|
||
end
|
||
|
||
-- ─────────────────────────────────────────────────────────────
|
||
-- Internal helpers
|
||
-- ─────────────────────────────────────────────────────────────
|
||
|
||
--- [INTERNAL]
|
||
--- @param #REDGCI self
|
||
function REDGCI:_Log(msg)
|
||
if self.Debug then
|
||
env.info(self.lid .. msg)
|
||
end
|
||
end
|
||
|
||
--- [INTERNAL] Initialize TEXTANDSOUND localization from embedded Messages table.
|
||
-- @param #REDGCI self
|
||
function REDGCI:_InitLocalization()
|
||
self._gettext = TEXTANDSOUND:New("REDGCI_" .. self.Callsign, "en")
|
||
for locale, entries in pairs(REDGCI.Messages) do
|
||
local loc = string.lower(tostring(locale))
|
||
for id, text in pairs(entries) do
|
||
self._gettext:AddEntry(loc, tostring(id), text)
|
||
end
|
||
end
|
||
end
|
||
|
||
--- [INTERNAL] Initialize MSRS + queue.
|
||
-- @param #REDGCI self
|
||
function REDGCI:_InitSRS()
|
||
-- GCI controller voice
|
||
self._msrs = MSRS:New(self.SRSPath, self.SRSFreq, self.SRSMod) -- Sound.MSRS#MSRS
|
||
self._msrs:SetPort(self.SRSPort)
|
||
self._msrs:SetLabel("GCI")
|
||
self._msrs:SetCulture(self.SRSCulture)
|
||
self._msrs:SetVoice(self.SRSVoice)
|
||
self._msrs:SetCoalition(self.Coalition)
|
||
if self.SRSProvider then
|
||
self._msrs:SetProvider(self.SRSProvider)
|
||
end
|
||
if self.SRSSpeaker then
|
||
self._msrs:SetSpeakerPiper(self.SRSSpeaker)
|
||
end
|
||
self._srs_queue = MSRSQUEUE:New("REDGCI_" .. self.Callsign) -- Sound.MSRS#MSRSQUEUE
|
||
|
||
-- Pilot voice (only when a pilot callsign has been configured)
|
||
if self.PilotCallsign then
|
||
self._pilot_msrs = MSRS:New(self.SRSPath, self.SRSFreq, self.SRSMod)
|
||
self._pilot_msrs:SetPort(self.SRSPort)
|
||
self._pilot_msrs:SetLabel("PILOT")
|
||
self._pilot_msrs:SetCulture(self.PilotSRSCulture)
|
||
self._pilot_msrs:SetVoice(self.PilotSRSVoice)
|
||
self._pilot_msrs:SetCoalition(self.Coalition)
|
||
if self.SRSProvider then
|
||
self._pilot_msrs:SetProvider(self.SRSProvider)
|
||
end
|
||
if self.SRSSpeaker then
|
||
self._msrs:SetSpeakerPiper(self.PilotSRSSpeaker)
|
||
end
|
||
--self._pilot_queue = MSRSQUEUE:New("REDGCI_PILOT_" .. self.PilotCallsign)
|
||
self._pilot_queue = self._srs_queue
|
||
self:_Log("Pilot SRS ready: " .. self.PilotCallsign)
|
||
end
|
||
end
|
||
|
||
--- [INTERNAL] Get live unit data from a DCS group (returns first alive unit).
|
||
-- @param #REDGCI self
|
||
-- @param #string GroupName
|
||
-- @return #table { x, y, z, spd, vx, vy, vz, hdg, fuel } or nil
|
||
function REDGCI:_GetUnitData(GroupName)
|
||
if not GroupName then return end
|
||
local grp = Group.getByName(GroupName)
|
||
if not grp then return nil end
|
||
for _, u in ipairs(grp:getUnits()) do
|
||
if u and u:isExist() and u:isActive() then
|
||
local pos3 = u:getPosition()
|
||
local p = pos3.p
|
||
local fwd = pos3.x
|
||
local v = u:getVelocity()
|
||
return {
|
||
x = p.x,
|
||
y = p.y,
|
||
z = p.z,
|
||
spd = math.sqrt(v.x*v.x + v.z*v.z),
|
||
vx = v.x,
|
||
vy = v.y,
|
||
vz = v.z,
|
||
hdg = math.deg(math.atan2(fwd.z, fwd.x)) % 360,
|
||
fuel = u:getFuel(),
|
||
}
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
--- [INTERNAL] Parse a pipe-separated token string from the C kernel.
|
||
-- Input: "VECTOR|hdg=165|alt=4500|tti_m=8|wf=false"
|
||
-- Output: { key="VECTOR", hdg=165, alt=4500, tti_m=8, wf=false }
|
||
-- @param #REDGCI self
|
||
function REDGCI:_ParseTokens(TokenStr)
|
||
self:T("_ParseTokens "..TokenStr)
|
||
if not TokenStr or TokenStr == "" then return nil end
|
||
local parts = {}
|
||
for part in string.gmatch(TokenStr, "[^|]+") do
|
||
parts[#parts + 1] = part
|
||
end
|
||
local result = { key = parts[1] }
|
||
for i = 2, #parts do
|
||
local k, v = string.match(parts[i], "^([%w_]+)=(.+)$")
|
||
if k and v then
|
||
if v == "true" then result[k] = true
|
||
elseif v == "false" then result[k] = false
|
||
elseif tonumber(v) then result[k] = tonumber(v)
|
||
else result[k] = v
|
||
end
|
||
end
|
||
end
|
||
--UTILS.PrintTableToLog(result)
|
||
return result
|
||
end
|
||
|
||
--- [INTERNAL] Fill {PLACEHOLDER} tokens in a template string.
|
||
-- @param #REDGCI self
|
||
function REDGCI:_FillTemplate(Template, Vars)
|
||
return (string.gsub(Template, "{([%w_]+)}", function(key)
|
||
return tostring(Vars[key] or "")
|
||
end))
|
||
end
|
||
|
||
--- [INTERNAL] Resolve COUNT token to localized word.
|
||
-- @param #REDGCI self
|
||
-- @param #number Count
|
||
-- @return #string
|
||
function REDGCI:_CountToken(Count)
|
||
local t = REDGCI.CountTokens[self.Locale] or REDGCI.CountTokens["en"]
|
||
local n = Count or 1
|
||
if n == 1 then return t.single
|
||
elseif n == 2 then return t.pair
|
||
elseif n <= 4 then return t.group
|
||
else return t.biggroup
|
||
end
|
||
end
|
||
|
||
--- [INTERNAL] Resolve TYPE token to localized word.
|
||
-- @param #REDGCI self
|
||
-- @param #string TypeKey "FIGHTER", "BOMBER", "MACHINES"
|
||
-- @return #string
|
||
function REDGCI:_TypeToken(TypeKey)
|
||
local t = REDGCI.TypeTokens[self.Locale] or REDGCI.TypeTokens["en"]
|
||
local k = string.lower(TypeKey or "machines")
|
||
return t[k] or t.machines
|
||
end
|
||
|
||
--- [INTERNAL] Derive count and type from INTEL contact.
|
||
-- Updates self._target_count and self._target_type.
|
||
-- @param #REDGCI self
|
||
function REDGCI:_UpdatePictureFromIntel()
|
||
if not self.Intel or not self.Intel:Is("Running") then return end
|
||
local contacts = self.Intel:GetContactTable()
|
||
if not contacts then return end
|
||
|
||
local count = 0
|
||
local rcs_sum = 0.0
|
||
local rcs_n = 0
|
||
|
||
for _, contact in pairs(contacts) do
|
||
if contact.ctype == INTEL.Ctype.AIRCRAFT then
|
||
if not (self.IntelTargetFilter and
|
||
not string.find(contact.groupname or "", self.IntelTargetFilter, 1, true)) then
|
||
-- Anzahl Units in der Kontaktgruppe
|
||
local grp = GROUP:FindByName(contact.groupname)
|
||
if grp then
|
||
count = count + grp:CountAliveUnits()
|
||
else
|
||
count = count + 1
|
||
end
|
||
-- RCS aus contact wenn verfügbar
|
||
if contact.rcs then
|
||
rcs_sum = rcs_sum + contact.rcs
|
||
rcs_n = rcs_n + 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
if count > 0 then
|
||
self._target_count = count
|
||
end
|
||
|
||
-- Type aus durchschnittlichem RCS
|
||
if rcs_n > 0 then
|
||
local avg_rcs = rcs_sum / rcs_n
|
||
if avg_rcs < REDGCI.RCS_FIGHTER_MAX then self._target_type = "FIGHTER"
|
||
elseif avg_rcs > REDGCI.RCS_BOMBER_MIN then self._target_type = "BOMBER"
|
||
else self._target_type = "MACHINES"
|
||
end
|
||
end
|
||
end
|
||
|
||
--- [INTERNAL] Resolve a direction key to a localized word.
|
||
-- @param #REDGCI self
|
||
-- @param #string Key "left", "right", "ahead", "behind", "low", "high"
|
||
-- @return #string
|
||
function REDGCI:_DirToken(Key)
|
||
local t = REDGCI.DirTokens[self.Locale] or REDGCI.DirTokens["en"]
|
||
return t[Key] or Key
|
||
end
|
||
|
||
--- [INTERNAL] Derive "left"/"right" turn instruction from aspect angle.
|
||
-- @param #REDGCI self
|
||
function REDGCI:_DeriveDirLR(AspectAngle)
|
||
return (AspectAngle > 5.0) and "right" or "left"
|
||
end
|
||
|
||
--- [INTERNAL] Derive target position relative to fighter ("ahead"/"behind"/"left"/"right").
|
||
-- @param #REDGCI self
|
||
-- @param #table Fighter unit data
|
||
-- @param #table Target unit data
|
||
-- @return #string
|
||
function REDGCI:_DeriveDirRL(Fighter, Target)
|
||
local dx = Target.x - Fighter.x
|
||
local dz = Target.z - Fighter.z
|
||
local f_hdg = math.atan2(Fighter.vx, Fighter.vz)
|
||
local to_tgt = math.atan2(dx, dz)
|
||
local rel = math.deg(to_tgt - f_hdg) % 360
|
||
if rel < 45 or rel > 315 then return "ahead"
|
||
elseif rel < 135 then return "right"
|
||
elseif rel < 225 then return "behind"
|
||
else return "left"
|
||
end
|
||
end
|
||
|
||
--- [INTERNAL] Build and dispatch a radio transmission.
|
||
-- @param #REDGCI self
|
||
-- @param #string TokenStr Pipe-separated token string from C kernel
|
||
-- @param #string DirLR "left"/"right" for manoeuvre cues
|
||
-- @param #string DirRL "ahead"/"behind"/"left"/"right" for target position
|
||
-- @param #number Priority
|
||
function REDGCI:_Transmit(TokenStr, DirLR, DirRL, Priority)
|
||
local tok = self:_ParseTokens(TokenStr)
|
||
if not tok then
|
||
self:_Log("_Transmit: empty token string")
|
||
return
|
||
end
|
||
|
||
-- Look up template
|
||
local template = self._gettext:GetEntry(tok.key, self.Locale)
|
||
if not template then
|
||
self:_Log("_Transmit: no template for key=" .. tostring(tok.key) ..
|
||
" locale=" .. self.Locale)
|
||
return
|
||
end
|
||
|
||
tok.aspect = tok.aspect or tok.brg -- Merge nutzt brg statt aspect
|
||
|
||
-- Build variable table
|
||
local vars = {
|
||
CALLSIGN = string.gsub(self.Callsign, "-", " "),
|
||
HDG = tok.hdg and string.format("%03d", tok.hdg) or "",
|
||
ALT = tok.alt and tostring(math.floor(tok.alt)) or "",
|
||
RNG = tok.rng and tostring(math.floor(tok.rng)) or "",
|
||
TTI_M = tok.tti_m and tostring(tok.tti_m) or "",
|
||
TTI_S = tok.tti_s and tostring(tok.tti_s) or "",
|
||
ASPECT = tok.aspect and string.format("%03d", tok.aspect) or "",
|
||
DIR_LR = self:_DirToken(DirLR or "right"),
|
||
DIR_RL = self:_DirToken(DirRL or "ahead"),
|
||
BRG = tok.brg and string.format("%03d", tok.brg) or "",
|
||
COUNT = self:_CountToken(self._target_count),
|
||
TYPE = self:_TypeToken(self._target_type),
|
||
}
|
||
|
||
local text = self:_FillTemplate(template, vars)
|
||
|
||
-- Throttle: same text repeated within TxRepeatInterval → suppress
|
||
local now = timer.getTime()
|
||
if text == self._last_tx.text and
|
||
(now - self._last_tx.time) < self.TxRepeatInterval then
|
||
self:_Log("[SRS/THROTTLED/" .. tok.key .. "] " .. text)
|
||
return
|
||
end
|
||
self._last_tx.text = text
|
||
self._last_tx.time = now
|
||
|
||
self:_Log(string.format("[SRS/%s/%s] %s", self.Locale, tok.key, text))
|
||
|
||
local srstext = string.gsub(text,"%.",";")
|
||
|
||
if self._srs_queue and self._msrs then
|
||
local delay = tok.delay or 3.0
|
||
--MSRSQUEUE:NewTransmission(text, duration, msrs, tstart, interval, subgroups, subtitle, subduration, frequency, modulation, gender, culture, voice, volume, label,coordinate,speed,speaker,priority)
|
||
self._srs_queue:NewTransmission(
|
||
srstext, -- message text
|
||
nil, -- duration (auto)
|
||
self._msrs, -- MSRS instance
|
||
delay, -- start delay
|
||
2, -- interval
|
||
{GROUP:FindByName(RedGCI.FIGHTER_GROUP)}, -- Subgroups (Subtitle)
|
||
text, -- subtitle
|
||
self.SubtitleTime,-- subtitle duration
|
||
nil, nil, -- channel/mod (from msrs)
|
||
nil, nil, nil, -- gender/culture/voice (from msrs)
|
||
nil, -- volume
|
||
"GCI", -- label
|
||
nil, -- coordinate
|
||
self.SRSSpeed, -- speed
|
||
nil, -- speaker
|
||
Priority -- priority
|
||
)
|
||
else
|
||
-- Fallback: on-screen text
|
||
trigger.action.outText(text, self.SubtitleTime, false)
|
||
end
|
||
|
||
if self.Debug then
|
||
trigger.action.outText(text, self.SubtitleTime, false)
|
||
end
|
||
end
|
||
|
||
--- [INTERNAL] Dispatch a pilot acknowledgement transmission.
|
||
-- Uses the pilot's MSRS voice on the same frequency as GCI.
|
||
-- The ACK key is looked up in the Messages table under the pilot locale;
|
||
-- vars are filled identically to _Transmit so {HDG} etc. work.
|
||
-- The ACK fires after a short realistic reaction delay (GCI_delay + ~2s).
|
||
-- No-op when PilotCallsign is nil or pilot SRS is not initialised.
|
||
-- @param #REDGCI self
|
||
-- @param #string AckKey Message key, e.g. "ACK_VECTOR"
|
||
-- @param #table Vars Variable table (same format as _Transmit vars)
|
||
-- @param #number GciDelay Delay of the preceding GCI transmission (seconds)
|
||
function REDGCI:_TransmitPilot(AckKey, Vars, GciDelay)
|
||
if not self.IsAIPlane then return end
|
||
if not self.PilotCallsign then return end
|
||
if not self._pilot_queue or not self._pilot_msrs then return end
|
||
|
||
local template = self._gettext:GetEntry(AckKey, self.Locale)
|
||
if not template then
|
||
self:_Log("_TransmitPilot: no template for key=" .. AckKey)
|
||
return
|
||
end
|
||
|
||
-- Inject pilot callsign into vars
|
||
--local v = Vars or {}
|
||
--v.CALLSIGN = self.PilotCallsign
|
||
|
||
--local text = self:_FillTemplate(template, v)
|
||
local text = self.PilotCallsign
|
||
-- Pilot speaks ~2-3 s after GCI finishes (GCI delay + estimated GCI speech + reaction)
|
||
local pilot_delay = (GciDelay or 3.0)
|
||
|
||
self:_Log(string.format("[PILOT/%s/%s] %s", self.Locale, AckKey, text))
|
||
--(text, duration, msrs, tstart, interval, subgroups, subtitle, subduration, frequency, modulation, gender, culture, voice, volume, label,coordinate,speed,speaker)
|
||
self._pilot_queue:NewTransmission(
|
||
text,
|
||
nil, -- duration
|
||
self._pilot_msrs, --msrs
|
||
pilot_delay, --tstrat
|
||
1, --interval
|
||
nil, --{GROUP:FindByName(self.FighterGroupName)}, --subgroups
|
||
nil, --text, subtitle
|
||
nil, --self.SubtitleTime, subduration
|
||
nil, nil, --frequency, modulation,
|
||
nil, nil, self.PilotSRSVoice, --gender, culture, voice
|
||
nil, --volume
|
||
text -- label
|
||
)
|
||
|
||
if self.Debug then
|
||
trigger.action.outText("[PILOT] " .. text, self.SubtitleTime, false)
|
||
end
|
||
end
|
||
|
||
--- [INTERNAL] Push a waypoint
|
||
-- @param #REDGCI self
|
||
-- @param #number wx DCS x coord (North)
|
||
-- @param #number wz DCS z coord (East)
|
||
-- @param #number wy altitude MSL metres
|
||
-- @param #number SpeedMps airspeed in m/s
|
||
-- @param #boolean LandHome true = set waypoint type to LAND at HomeBase
|
||
function REDGCI:_PushWaypoint(wx, wz, wy, SpeedMps, LandHome)
|
||
if not self.IsAIPlane then return end
|
||
|
||
local grp = GROUP:FindByName(self.FighterGroupName)
|
||
if not grp then return end
|
||
|
||
-- Terrain floor + 300 m minimum clearance
|
||
local terrain_floor = land.getHeight({ x=wx, y=wz }) + 300
|
||
local safe_alt = math.max(wy, terrain_floor)
|
||
local kmph = UTILS.MpsToKmph(SpeedMps)
|
||
local speed_tas = UTILS.IasToTas(kmph,math.max(wy, safe_alt))
|
||
|
||
local tsk = grp:TaskAerobatics()
|
||
tsk = grp:TaskAerobaticsStraightFlight(tsk,1,math.max(wy, safe_alt),speed_tas,UseSmoke,StartImmediately,10)
|
||
|
||
local startpoint = grp:GetCoordinate()
|
||
local wp0 = startpoint:WaypointAir(
|
||
COORDINATE.WaypointAltType.BARO,
|
||
COORDINATE.WaypointType.TurningPoint,
|
||
COORDINATE.WaypointAction.FlyoverPoint,
|
||
speed_tas, true, nil, {}, "VECTOR")
|
||
|
||
local endpoint = COORDINATE:New(wx, safe_alt, wz)
|
||
local wp1
|
||
if LandHome then
|
||
grp:SetOptionLandingOverheadBreak()
|
||
grp:SetOptionLandingForcePair()
|
||
local ab = self.HomeBaseName and AIRBASE:FindByName(self.HomeBaseName) or nil
|
||
wp1 = endpoint:WaypointAir(
|
||
COORDINATE.WaypointAltType.BARO,
|
||
COORDINATE.WaypointType.Land,
|
||
COORDINATE.WaypointAction.Landing,
|
||
speed_tas, true, ab, {}, "HOME")
|
||
else
|
||
wp1 = endpoint:WaypointAir(
|
||
COORDINATE.WaypointAltType.BARO,
|
||
COORDINATE.WaypointType.TurningPoint,
|
||
COORDINATE.WaypointAction.FlyoverPoint,
|
||
speed_tas, true, nil, tsk, "VECTOR")
|
||
end
|
||
|
||
grp:Route({ wp0, wp1 }, 2)
|
||
self:_Log(string.format("WP → x=%.0f z=%.0f alt=%.0fm spd=%.0f kph TAS=%.0f kph",
|
||
wx, wz, safe_alt, kmph, speed_tas))
|
||
end
|
||
|
||
--- [INTERNAL] Resolve intercept target point, honouring any active tactic override.
|
||
-- When REDGCI2v2 has set _wp_override, that point is used instead of the
|
||
-- C-kernel intercept point for N ticks, then reverts to normal guidance.
|
||
-- @param #REDGCI self
|
||
-- @param #number ip_x Normal intercept x (DCS North, AltOffset already applied)
|
||
-- @param #number ip_z Normal intercept z (DCS East)
|
||
-- @param #number ip_y Normal intercept altitude
|
||
-- @return #number tx, tz, ty
|
||
function REDGCI:_ResolveTarget(ip_x, ip_z, ip_y)
|
||
local ov = self._wp_override
|
||
if ov and ov.ticks > 0 then
|
||
ov.ticks = ov.ticks - 1
|
||
self:_Log(string.format(
|
||
"Override active (%d ticks left) → x=%.0f z=%.0f y=%.0f",
|
||
ov.ticks + 1, ov.x, ov.z, ov.y))
|
||
if ov.ticks == 0 then
|
||
self._wp_override = nil
|
||
self:_Log("Override expired — resuming normal intercept")
|
||
end
|
||
return ov.x, ov.z, ov.y, ov.absolute_alt
|
||
end
|
||
return ip_x, ip_z, ip_y, false
|
||
end
|
||
|
||
--- [INTERNAL] Clamp an intercept point to max_dist from the fighter.
|
||
-- Prevents the AI from overshooting on a distant waypoint.
|
||
-- @param #REDGCI self
|
||
-- @param #table Fighter unit data
|
||
-- @param #number ip_x intercept x (DCS North)
|
||
-- @param #number ip_z intercept z (DCS East)
|
||
-- @param #number ip_y intercept altitude
|
||
-- @return #number, #number, #number clamped wx, wz, wy
|
||
function REDGCI:_ComputeRollingWaypoint(Fighter, ip_x, ip_z, ip_y)
|
||
local dx = ip_x - Fighter.x
|
||
local dz = ip_z - Fighter.z
|
||
local dist = math.sqrt(dx*dx + dz*dz)
|
||
|
||
local max_dist = math.max(
|
||
Fighter.spd * self.TickInterval * REDGCI.WP_DISTANCE_FACTOR,
|
||
15000) -- minimum 15 km lookahead
|
||
|
||
if dist <= max_dist or dist < 1 then
|
||
return ip_x, ip_z, ip_y
|
||
end
|
||
|
||
local nx = dx / dist
|
||
local nz = dz / dist
|
||
return Fighter.x + nx * max_dist,
|
||
Fighter.z + nz * max_dist,
|
||
ip_y
|
||
end
|
||
|
||
--- [INTERNAL] Toggle radar emission on the fighter group.
|
||
-- @param #REDGCI self
|
||
-- @param #boolean On
|
||
-- @param #number Delay
|
||
function REDGCI:_SetRadar(On,Delay)
|
||
if not self.IsAIPlane then return end
|
||
if Delay then
|
||
self:ScheduleOnce(Delay,REDGCI._SetRadar,self,On)
|
||
return
|
||
end
|
||
local grp = GROUP:FindByName(self.FighterGroupName)
|
||
if not grp then return end
|
||
if On == true then
|
||
grp:SetOptionRadarUsingForContinousSearch()
|
||
--grp:OptionROEWeaponFree()
|
||
--grp:OptionAlarmStateRed()
|
||
--grp:OptionAAAttackRange(1)
|
||
grp:OptionECM_DetectedLockByRadar()
|
||
grp:SetOptionJettisonEmptyTanks(true)
|
||
else
|
||
grp:SetOptionRadarUsingNever()
|
||
--grp:OptionROEHoldFire()
|
||
--grp:OptionAlarmStateAuto()
|
||
--grp:OptionAAAttackRange(3)
|
||
grp:OptionECM_Never()
|
||
end
|
||
self:_Log("Radar " .. (On and "ON" or "OFF"))
|
||
end
|
||
|
||
--- [INTERNAL] Toggle weapons free on the fighter group.
|
||
-- @param #REDGCI self
|
||
-- @param #boolean On
|
||
-- @param #number Delay Delay in seconds
|
||
function REDGCI:_SetWeaponsFree(On,Delay)
|
||
if not self.IsAIPlane then return end
|
||
if Delay then
|
||
self:ScheduleOnce(Delay,REDGCI._SetWeaponsFree,self,On)
|
||
return
|
||
end
|
||
-- Externer Gate-Check — REDGCI2v2 kann das überschreiben
|
||
if On and self._wf_gate and not self._wf_gate(self) then
|
||
self:_Log("WF geblockt — Gate nicht offen")
|
||
return
|
||
end
|
||
local grp = GROUP:FindByName(self.FighterGroupName)
|
||
if not grp then return end
|
||
if On == true then
|
||
--grp:SetOptionRadarUsingForContinousSearch()
|
||
grp:OptionROEWeaponFree()
|
||
grp:OptionAlarmStateRed()
|
||
grp:OptionAAAttackRange(self._missilerangeflag)
|
||
grp:OptionECM_DetectedLockByRadar()
|
||
grp:SetOptionJettisonEmptyTanks(true)
|
||
else
|
||
--grp:SetOptionRadarUsingNever()
|
||
grp:OptionROEHoldFire()
|
||
grp:OptionAlarmStateAuto()
|
||
grp:OptionAAAttackRange(3)
|
||
grp:OptionECM_Never()
|
||
end
|
||
self:_Log("Weapons " .. (On and "ON" or "OFF"))
|
||
end
|
||
|
||
--- [INTERNAL] Register F10 coalition menu entries.
|
||
-- @param #REDGCI self
|
||
function REDGCI:_SetupF10Menu()
|
||
local root = missionCommands.addSubMenuForCoalition(self.Coalition, "GCI")
|
||
|
||
missionCommands.addCommandForCoalition(self.Coalition, "Radar Lock", root,
|
||
function()
|
||
self._pilot_flags.radar = true
|
||
self._pilot_flags.visual = false
|
||
self:_Log("Pilot: Radar Lock")
|
||
end)
|
||
|
||
missionCommands.addCommandForCoalition(self.Coalition, "Visual Contact", root,
|
||
function()
|
||
self._pilot_flags.visual = true
|
||
self:_Log("Pilot: Visual Contact")
|
||
end)
|
||
|
||
missionCommands.addCommandForCoalition(self.Coalition, "Threat (RWR)", root,
|
||
function()
|
||
self._pilot_flags.threat = true
|
||
self:_Log("Pilot: Threat")
|
||
end)
|
||
|
||
missionCommands.addCommandForCoalition(self.Coalition, "Splash / Kill", root,
|
||
function()
|
||
self:_Transmit("MERGE_SPLASH|delay=1.5", nil, nil)
|
||
self:Reset()
|
||
self:_Log("Splash — Reset")
|
||
end)
|
||
|
||
missionCommands.addCommandForCoalition(self.Coalition, "Reset GCI", root,
|
||
function()
|
||
self:Reset()
|
||
self:_Log("GCI Reset via F10 menu")
|
||
end)
|
||
|
||
missionCommands.addCommandForCoalition(self.Coalition, "Toggle AI Mode", root,
|
||
function()
|
||
self.IsAIPlane = not self.IsAIPlane
|
||
local status = self.IsAIPlane and "ON" or "OFF"
|
||
trigger.action.outTextForCoalition(
|
||
self.Coalition, "[GCI] AI mode " .. status, 3)
|
||
self:_Log("AI mode: " .. status)
|
||
end)
|
||
end
|
||
|
||
---
|
||
-- ══════════════════════════════════════════════════════════════════
|
||
-- Part 2 — REDGCI Intel source integration
|
||
--
|
||
-- When an INTEL source is attached, REDGCI derives the intercept target
|
||
-- from the INTEL contact table instead of polling a fixed group name.
|
||
-- Selection criterion: highest threat-level aircraft contact.
|
||
-- Tie-break: closest to the fighter.
|
||
--
|
||
-- The INTEL contact's position/velocity are used directly, so the GCI
|
||
-- works even after the real unit is lost from DCS sensor view (INTEL
|
||
-- keeps a prediction window of up to 10 min for aircraft).
|
||
-- ══════════════════════════════════════════════════════════════════
|
||
|
||
--- Attach an INTEL object as the target source for this GCI instance.
|
||
-- When set, REDGCI picks the highest-threat aircraft contact from INTEL
|
||
-- on every tick instead of polling a fixed target group name.
|
||
-- The GCI continues to work with INTEL's predicted positions during
|
||
-- contact gaps (up to INTEL's configured forget window).
|
||
-- @param #REDGCI self
|
||
-- @param Ops.Intel#INTEL Intel INTEL instance (must be Started/Running).
|
||
-- @param #string Filter (optional) Only consider contacts whose group name
|
||
-- contains this substring (case-sensitive).
|
||
-- @return #REDGCI self
|
||
function REDGCI:SetIntelSource(Intel, Filter)
|
||
self.Intel = Intel
|
||
self.IntelTargetFilter = Filter
|
||
self:T(self.lid .. "Intel source set: " ..
|
||
(Intel and Intel.alias or "nil") ..
|
||
(Filter and (" filter='" .. Filter .. "'") or ""))
|
||
return self
|
||
end
|
||
|
||
--- [INTERNAL] Derive intercept target data from the attached INTEL.
|
||
-- Returns a unit-data table identical in structure to _GetUnitData(),
|
||
-- or nil when no suitable contact is available.
|
||
-- @param #REDGCI self
|
||
-- @param #table FighterData Fighter unit data (for proximity tie-break).
|
||
-- @return #table or nil
|
||
function REDGCI:_GetTargetFromIntel(FighterData)
|
||
if not self.Intel or not self.Intel:Is("Running") then return nil end
|
||
|
||
local contacts = self.Intel:GetContactTable()
|
||
if not contacts then return nil end
|
||
|
||
local best = nil
|
||
local bestScore = -math.huge
|
||
|
||
for _, contact in pairs(contacts) do --#INTEL.Contact
|
||
|
||
-- Aircraft contacts only
|
||
if contact.ctype == INTEL.Ctype.AIRCRAFT then
|
||
|
||
-- Optional name filter
|
||
if self.IntelTargetFilter and
|
||
not string.find(contact.groupname, self.IntelTargetFilter, 1, true) then
|
||
-- goto continue
|
||
end
|
||
|
||
if not contact.position then end --goto continue end
|
||
|
||
local pos = contact.position
|
||
local dx = pos.x - FighterData.x
|
||
local dz = pos.z - FighterData.z
|
||
local rng = math.sqrt(dx * dx + dz * dz)
|
||
|
||
-- Score: threat level primary; range as tie-break (closer = higher)
|
||
local score = (contact.threatlevel or 0) * 100000 - rng
|
||
|
||
if score > bestScore then
|
||
bestScore = score
|
||
best = contact
|
||
end
|
||
end
|
||
end
|
||
|
||
if not best then return nil end
|
||
|
||
local pos = best.position
|
||
local vel = best.velocity or { x = 0, y = 0, z = 0 }
|
||
local alt = best.altitude or (pos and pos.y) or 0
|
||
|
||
return {
|
||
x = pos.x,
|
||
y = alt,
|
||
z = pos.z,
|
||
spd = best.speed or 0,
|
||
vx = vel.x or 0,
|
||
vy = vel.y or 0,
|
||
vz = vel.z or 0,
|
||
hdg = best.heading or 0,
|
||
fuel = 1.0, -- not available from INTEL
|
||
}
|
||
end
|
||
|
||
-- ─────────────────────────────────────────────────────────────
|
||
-- FSM handlers
|
||
-- ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
-- ─────────────────────────────────────────────────────────────
|
||
-- FSM state machine (pure Lua, replaces RedGCI.fsmUpdate)
|
||
-- ─────────────────────────────────────────────────────────────
|
||
|
||
--- [INTERNAL] Compute next FSM state from range/aspect/pilot flags.
|
||
-- Mirrors gci_fsm_transition() from intercept_fsm.c.
|
||
-- @param #REDGCI self
|
||
-- @param #string current Current state string
|
||
-- @param #number range Distance to target (m)
|
||
-- @param #number aspect Aspect angle (deg)
|
||
-- @param #boolean has_radar Pilot has radar lock
|
||
-- @param #boolean has_visual Pilot has visual
|
||
-- @param #boolean threat RWR threat
|
||
-- @param #number fuel Fuel fraction 0-1
|
||
-- @return #string next state
|
||
function REDGCI:_FsmTransition(current, range, aspect, has_radar, has_visual, threat, fuel)
|
||
local C = REDGCI_KERNEL.C
|
||
|
||
-- Abort always wins
|
||
if (fuel or 1.0) < C.FUEL_BINGO then return "ABORT" end
|
||
if threat and range > C.RANGE_VISUAL then return "ABORT" end
|
||
|
||
if current == "VECTOR" then
|
||
if range <= C.RANGE_COMMIT then return "COMMIT" end
|
||
if aspect > C.ASPECT_NOTCH_MIN and
|
||
aspect < C.ASPECT_NOTCH_MAX then return "NOTCH" end
|
||
return "VECTOR"
|
||
|
||
elseif current == "COMMIT" then
|
||
if has_radar then return "RADAR_CONTACT" end
|
||
if range <= C.RANGE_VISUAL and not has_radar then return "VISUAL" end
|
||
return "COMMIT"
|
||
|
||
elseif current == "RADAR_CONTACT" then
|
||
if not has_radar then return "COMMIT" end
|
||
if has_visual or range <= C.RANGE_VISUAL then return "VISUAL" end
|
||
return "RADAR_CONTACT"
|
||
|
||
elseif current == "VISUAL" then
|
||
if range <= C.RANGE_MERGE then return "MERGE" end
|
||
return "VISUAL"
|
||
|
||
elseif current == "NOTCH" then
|
||
if aspect < C.ASPECT_NOTCH_MIN or
|
||
aspect > C.ASPECT_NOTCH_MAX then return "VECTOR" end
|
||
return "NOTCH"
|
||
|
||
else
|
||
-- MERGE / ABORT / RTB are terminal — handled elsewhere
|
||
return current
|
||
end
|
||
end
|
||
|
||
--- [INTERNAL] Called when Start event fires (Stopped → Running).
|
||
-- Initializes localization, SRS, context, F10 menu, and schedules first tick.
|
||
-- @param #REDGCI self
|
||
function REDGCI:onafterStart(From, Event, To)
|
||
self:T(self.lid .. "Starting...")
|
||
|
||
self:_InitLocalization()
|
||
self:_InitSRS()
|
||
self:_SetRadar(false)
|
||
self:_SetWeaponsFree(false)
|
||
|
||
RedGCI.getCtxId(self.Callsign)
|
||
if self.Debug == true and self.Callsign == "101" then
|
||
self:_SetupF10Menu()
|
||
end
|
||
|
||
local mode_str = self.IsAIPlane and " [AI]" or " [Human]"
|
||
trigger.action.outTextForCoalition(
|
||
self.Coalition,
|
||
"[GCI] Системы готовы. Жду цель." .. mode_str, 5)
|
||
|
||
self:T(self.lid .. "Ready. Fighter='" .. self.FighterGroupName ..
|
||
"' Target='" .. self.TargetGroupName ..
|
||
"' Mode=" .. (self.IsAIPlane and "AI" or "Human"))
|
||
|
||
-- Schedule first tick after short delay
|
||
self:__Status(-2)
|
||
end
|
||
|
||
--- [INTERNAL] Main tick — called every TickInterval seconds while Running.
|
||
-- @param #REDGCI self
|
||
function REDGCI:onafterStatus(From, Event, To)
|
||
local f = self:_GetUnitData(self.FighterGroupName)
|
||
local t = self.Intel and self:_GetTargetFromIntel(f)
|
||
or self:_GetUnitData(self.TargetGroupName)
|
||
|
||
if not f then
|
||
self:T(self.lid .. "Fighter '" .. self.FighterGroupName .. "' not found — stopping.")
|
||
self:Stop()
|
||
return
|
||
end
|
||
|
||
if not t then
|
||
self._contact_lost_ticks = self._contact_lost_ticks + 1
|
||
self:T(self.lid .. "Contact lost — tick " .. self._contact_lost_ticks ..
|
||
"/" .. self.ContactLostTimeout)
|
||
|
||
if self._contact_lost_ticks == 1 then
|
||
-- First missing tick: tell pilot to stand by, keep last heading
|
||
self:_Transmit("NOTCH_ENTRY|delay=1.5", nil, nil)
|
||
elseif self._contact_lost_ticks >= self.ContactLostTimeout then
|
||
-- Timeout expired: target is genuinely gone (destroyed or escaped)
|
||
self:T(self.lid .. "Contact timeout — declaring target gone.")
|
||
if self._prev_radar then
|
||
self:_SetRadar(false)
|
||
end
|
||
self:_Transmit("MERGE_SPLASH|delay=1.5", nil, nil)
|
||
if self.IsAIPlane and self.HomeBase then
|
||
self:_PushWaypoint(
|
||
self.HomeBase.x, self.HomeBase.y,
|
||
1000, f.spd * 0.8, true)
|
||
end
|
||
self:Stop()
|
||
return
|
||
end
|
||
-- Ticks 2…(timeout-1): stay silent, hold course
|
||
self:__Status(-self.TickInterval)
|
||
return
|
||
end
|
||
|
||
-- Contact (re-)acquired — reset counter
|
||
self._contact_lost_ticks = 0
|
||
|
||
-- Picture call aktualisieren (INTEL → count/type, sonst manuell gesetzt)
|
||
self:_UpdatePictureFromIntel()
|
||
|
||
-- ── 1. Intercept geometry (C kernel) ──────────────────────
|
||
local hdg, tti, mode, wf, range, aspect, ip_x, ip_z, ip_y = REDGCI_KERNEL.computeInterceptDCS(f, t)
|
||
--RedGCI.computeIntercept(f, t)
|
||
--UTILS.PrintTableToLog({hdg, tti, mode, wf, range, aspect, ip_x, ip_z, ip_y},indent,noprint,maxDepth,seen)
|
||
|
||
if self.Debug then
|
||
env.info(string.format(
|
||
self.lid .. "hdg=%d tti=%d mode=%s wf=%s range=%d aspect=%d ip_x=%d ip_z=%d ip_y=%d",
|
||
hdg, tti, mode, tostring(wf), range, aspect, ip_x, ip_z, ip_y))
|
||
end
|
||
|
||
if mode == "NONE" then
|
||
self:_Log("No intercept solution (fighter too slow?)")
|
||
self:__Status(-self.TickInterval)
|
||
return
|
||
end
|
||
|
||
-- ── 2. Closure rate ───────────────────────────────────────
|
||
local dx = t.x - f.x
|
||
local dz = t.z - f.z
|
||
local dist = math.sqrt(dx*dx + dz*dz)
|
||
local closure = 0
|
||
if dist > 1 then
|
||
closure = -((t.vx - f.vx) * dx/dist + (t.vz - f.vz) * dz/dist)
|
||
end
|
||
|
||
-- ── 3. AI radar lock at COMMIT range ─────────────────────
|
||
local ai_radar_lock = self.IsAIPlane and (range < 30000)
|
||
|
||
-- ── 4. FSM update (C kernel) ──────────────────────────────
|
||
local current_state = self._prev_state or "VECTOR"
|
||
local state = self:_FsmTransition(
|
||
current_state, range, aspect,
|
||
self._pilot_flags.radar or ai_radar_lock,
|
||
self._pilot_flags.visual,
|
||
self._pilot_flags.threat,
|
||
f.fuel)
|
||
|
||
-- Track ticks_in_state
|
||
if state ~= current_state then
|
||
self._ticks_in_state = 0
|
||
else
|
||
self._ticks_in_state = self._ticks_in_state + 1
|
||
end
|
||
|
||
-- ── 5. State-transition side effects ─────────────────────
|
||
if state ~= self._prev_state then
|
||
self:T(self.lid .. "State: " .. (self._prev_state or "START") .. " → " .. state)
|
||
|
||
if state == "COMMIT" then
|
||
self:_Transmit("RADAR_ON|delay=1.5", nil, nil,75)
|
||
self:_TransmitPilot("ACK_COMMIT", {}, 1.5)
|
||
elseif state == "ABORT" or state == "RTB" then
|
||
self:_SetRadar(false)
|
||
self._prev_radar = false
|
||
if self.HomeBase then
|
||
self:_PushWaypoint(
|
||
self.HomeBase.x, self.HomeBase.y,
|
||
1000, f.spd * 0.8, true)
|
||
end
|
||
-- Pilot ACK for abort includes RTB heading
|
||
local rtb_hdg = ""
|
||
if self.HomeBase then
|
||
local dx_h = self.HomeBase.x - f.x
|
||
local dz_h = self.HomeBase.y - f.z
|
||
rtb_hdg = string.format("%03d", math.floor(math.deg(math.atan2(dz_h, dx_h)) % 360))
|
||
end
|
||
self:_TransmitPilot("ACK_ABORT", { HDG = rtb_hdg }, 3.0)
|
||
end
|
||
end
|
||
|
||
-- ── 6. Waypoint + radar per state (every tick) ────────────
|
||
if state == "VECTOR" then
|
||
self:_SetRadar(false)
|
||
local cruise_spd = math.max(f.spd, 200)
|
||
local tx, tz, ty, abs_alt = self:_ResolveTarget(ip_x, ip_z, ip_y + self.AltOffset)
|
||
local final_ty = abs_alt and ty or (ty + self.AltOffset)
|
||
local wx, wz, wy = self:_ComputeRollingWaypoint(f, tx, tz, final_ty)
|
||
self:_PushWaypoint(wx, wz, wy, cruise_spd)
|
||
|
||
elseif state == "COMMIT" or state == "RADAR_CONTACT" then
|
||
if self._prev_radar == false then
|
||
self:_SetRadar(true,2)
|
||
self:_SetWeaponsFree(true,2)
|
||
self._prev_radar = true
|
||
end
|
||
local tx, tz, ty, abs_alt = self:_ResolveTarget(ip_x, ip_z, ip_y + self.AltOffset)
|
||
local final_ty = abs_alt and ty or (ty + self.AltOffset)
|
||
local wx, wz, wy = self:_ComputeRollingWaypoint(f, tx, tz, final_ty)
|
||
self:_PushWaypoint(wx, wz, wy, f.spd)
|
||
|
||
elseif state == "NOTCH" then
|
||
if self._prev_radar == true then
|
||
self:_SetRadar(false)
|
||
self._prev_radar = false
|
||
end
|
||
end
|
||
|
||
-- ── 7. Merge phase (Lua kernel) ───────────────────────────
|
||
if state == "MERGE" then
|
||
local f_hdg = math.atan2(f.vx, f.vz)
|
||
local to_tgt = math.atan2(t.x - f.x, t.z - f.z)
|
||
local rel_brg = math.deg(to_tgt - f_hdg) % 360
|
||
|
||
-- Build merge context
|
||
local merge_ctx = {
|
||
phase = self._merge_phase,
|
||
ticks_in_phase = self._merge_ticks,
|
||
range = range,
|
||
bearing_to_target = rel_brg,
|
||
closure_rate = closure,
|
||
altitude_delta = t.y - f.y,
|
||
pass_count = self._merge_pass_count,
|
||
radar_lost = false,
|
||
}
|
||
local merge_prev = { phase = self._merge_phase_prev }
|
||
|
||
-- Transition
|
||
local new_phase = REDGCI_KERNEL.mergeTransition(merge_ctx, merge_prev)
|
||
if new_phase ~= merge_ctx.phase then
|
||
self._merge_phase_prev = merge_ctx.phase
|
||
self._merge_phase = new_phase
|
||
self._merge_ticks = 0
|
||
if new_phase == "SEPARATION" then
|
||
self._merge_pass_count = self._merge_pass_count + 1
|
||
end
|
||
else
|
||
self._merge_ticks = self._merge_ticks + 1
|
||
end
|
||
merge_ctx.phase = self._merge_phase
|
||
merge_ctx.ticks_in_phase = self._merge_ticks
|
||
|
||
local tx_merge = REDGCI_KERNEL.buildMergeTransmission(merge_ctx, merge_prev)
|
||
|
||
if not tx_merge.silence then
|
||
local dir_lr = self:_DeriveDirLR(aspect)
|
||
local dir_rl = self:_DeriveDirRL(f, t)
|
||
self:_Transmit(tx_merge.token_str, dir_lr, dir_rl,tx_merge.priority)
|
||
end
|
||
|
||
self:_Log(string.format("[MERGE] range=%.0fm → %s",
|
||
range, tx_merge.silence and "SILENCE" or tx_merge.token_str))
|
||
|
||
self._prev_state = state
|
||
|
||
if not self._prev_radar or not self._prev_wf then
|
||
self:_SetRadar(true, 2)
|
||
self:_SetWeaponsFree(true, 2)
|
||
self._prev_radar = true
|
||
self._prev_wf = true
|
||
end
|
||
|
||
self:__Status(-self.TickInterval)
|
||
return
|
||
end
|
||
|
||
-- ── 8. Build and send transmission ────────────────────────
|
||
-- Effective WF: C kernel flag OR Lua-side override when AI has lock inside WEZ.
|
||
-- (C kernel currently always returns wf=false; remove override once fixed.)
|
||
local effective_wf = wf or (
|
||
self.IsAIPlane and
|
||
self.WFRange > 0 and
|
||
state == "RADAR_CONTACT" and
|
||
range <= self.WFRange)
|
||
|
||
-- ctx/prev for buildTransmission
|
||
local ctx = {
|
||
state = state,
|
||
range = range,
|
||
aspect_angle = aspect,
|
||
closure_rate = closure,
|
||
ticks_in_state = self._ticks_in_state,
|
||
fuel_fraction = f.fuel,
|
||
}
|
||
local prev_ctx = { state = self._prev_state or "VECTOR" }
|
||
|
||
-- sol: VECTOR reports ip_y (intercept geometry), others report t.y (real target alt)
|
||
local report_alt
|
||
if state == "VECTOR" then
|
||
local ov = self._wp_override
|
||
if ov and ov.ticks > 0 then
|
||
report_alt = ov.y -- taktische Override-Höhe direkt
|
||
else
|
||
report_alt = ip_y + self.AltOffset
|
||
end
|
||
else
|
||
report_alt = t.y
|
||
end
|
||
local sol = {
|
||
heading_deg = hdg,
|
||
time_to_intercept = tti,
|
||
target_alt = report_alt,
|
||
range = range,
|
||
aspect_angle = aspect,
|
||
weapons_free = effective_wf,
|
||
}
|
||
|
||
local tx = REDGCI_KERNEL.buildTransmission(ctx, prev_ctx, sol)
|
||
|
||
if not tx.silence then
|
||
local dir_lr = self:_DeriveDirLR(aspect)
|
||
local dir_rl = self:_DeriveDirRL(f, t)
|
||
self:_Transmit(tx.token_str, dir_lr, dir_rl,tx.priority)
|
||
end
|
||
|
||
-- Weapons free edge: fire once on first transition
|
||
if tx.weapons_free and not self._prev_wf then
|
||
if not self._prev_radar then
|
||
self:_Transmit("RADAR_ON|delay=1.5", nil, nil)
|
||
end
|
||
self:_Transmit("WEAPONS_FREE|delay=1.5", nil, nil,75)
|
||
self:_TransmitPilot("ACK_WEAPONS_FREE", {}, 1.0,100)
|
||
self:_SetRadar(true, 2)
|
||
self:_SetWeaponsFree(true, 2)
|
||
self._prev_radar = true
|
||
self._prev_wf = true
|
||
end
|
||
self._prev_wf = tx.weapons_free or false
|
||
|
||
self:_Log(string.format(
|
||
"[%s] HDG:%d TTI:%ds MODE:%d WF:%s RANGE:%.0fm ASPECT:%.1f° → %s",
|
||
state, math.floor(hdg), math.floor(tti or 0), mode,
|
||
tostring(tx.weapons_free), range, aspect,
|
||
tx.silence and "SILENCE" or tostring(tx.token_str)))
|
||
|
||
self._prev_state = state
|
||
self:__Status(-self.TickInterval)
|
||
end
|
||
|
||
--- [INTERNAL] Called when Stop event fires.
|
||
-- @param #REDGCI self
|
||
function REDGCI:onafterStop(From, Event, To)
|
||
self:T(self.lid .. "Stopped.")
|
||
end
|
||
|
||
-------------------------------------------------------------------------------
|
||
-- END of Class
|
||
-------------------------------------------------------------------------------
|