mirror of
https://github.com/iTracerFacer/DCS_MissionDev.git
synced 2025-12-03 04:14:46 +00:00
added hover messages and message flow from crate cradle to pickup.
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
-- Moose_CTLD.lua
|
|
||||||
-- Pure-MOOSE, template-free CTLD-style logistics & troop transport
|
-- Pure-MOOSE, template-free CTLD-style logistics & troop transport
|
||||||
-- Drop-in script: no MIST, no mission editor templates required
|
-- Drop-in script: no MIST, no mission editor templates required
|
||||||
-- Dependencies: Moose.lua must be loaded before this script
|
-- Dependencies: Moose.lua must be loaded before this script
|
||||||
@@ -21,6 +20,50 @@ CTLD.__index = CTLD
|
|||||||
-- =========================
|
-- =========================
|
||||||
CTLD.Version = '0.1.0-alpha'
|
CTLD.Version = '0.1.0-alpha'
|
||||||
|
|
||||||
|
-- Immersive Hover Coach configuration (messages, thresholds, throttling)
|
||||||
|
-- All user-facing text lives here; logic only fills placeholders.
|
||||||
|
CTLD.HoverCoachConfig = {
|
||||||
|
enabled = true, -- master switch for hover coaching messages
|
||||||
|
coachOnByDefault = true, -- future per-player toggle; currently always on when enabled
|
||||||
|
|
||||||
|
thresholds = {
|
||||||
|
arrivalDist = 1000, -- m: start guidance “You’re close…”
|
||||||
|
closeDist = 100, -- m: reduce speed / set AGL guidance
|
||||||
|
precisionDist = 30, -- m: start precision hints
|
||||||
|
captureHoriz = 2, -- m: horizontal sweet spot radius
|
||||||
|
captureVert = 2, -- m: vertical sweet spot tolerance around AGL window
|
||||||
|
aglMin = 5, -- m: hover window min AGL
|
||||||
|
aglMax = 20, -- m: hover window max AGL
|
||||||
|
maxGS = 8/3.6, -- m/s: 8 km/h for precision, used for errors
|
||||||
|
captureGS = 4/3.6, -- m/s: 4 km/h capture requirement
|
||||||
|
maxVS = 1.5, -- m/s: absolute vertical speed during capture
|
||||||
|
driftResetDist = 35, -- m: if beyond, reset precision phase
|
||||||
|
stabilityHold = 1.8 -- s: hold steady before loading
|
||||||
|
},
|
||||||
|
|
||||||
|
throttle = {
|
||||||
|
coachUpdate = 1.5, -- s between hint updates in precision
|
||||||
|
generic = 3.0, -- s between non-coach messages
|
||||||
|
repeatSame = 6.0 -- s before repeating same message key
|
||||||
|
},
|
||||||
|
|
||||||
|
messages = {
|
||||||
|
-- Placeholders: {id}, {type}, {brg}, {rng}, {rng_u}, {gs}, {gs_u}, {agl}, {agl_u}, {hints}
|
||||||
|
spawned = "Crate’s live! {type} [{id}]. Bearing {brg}° range {rng} {rng_u}. Call for vectors if you need a hand.",
|
||||||
|
arrival = "You’re close—nice and easy. Hover at 5–20 meters.",
|
||||||
|
close = "Reduce speed below 15 km/h and set 5–20 m AGL.",
|
||||||
|
coach = "{hints} GS {gs} {gs_u}.",
|
||||||
|
tooFast = "Too fast for pickup: GS {gs} {gs_u}. Reduce below 8 km/h.",
|
||||||
|
tooHigh = "Too high: AGL {agl} {agl_u}. Target 5–20 m.",
|
||||||
|
tooLow = "Too low: AGL {agl} {agl_u}. Maintain at least 5 m.",
|
||||||
|
drift = "Outside pickup window. Re-center within 25 m.",
|
||||||
|
hold = "Oooh, right there! HOLD POSITION…",
|
||||||
|
loaded = "Crate is hooked! Nice flying!",
|
||||||
|
hoverLost = "Movement detected—recover hover to load.",
|
||||||
|
abort = "Hover lost. Reacquire within 25 m, GS < 8 km/h, AGL 5–20 m.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CTLD.Config = {
|
CTLD.Config = {
|
||||||
CoalitionSide = coalition.side.BLUE, -- default coalition this instance serves (menus created for this side)
|
CoalitionSide = coalition.side.BLUE, -- default coalition this instance serves (menus created for this side)
|
||||||
AllowedAircraft = { -- transport-capable unit type names (case-sensitive as in DCS DB)
|
AllowedAircraft = { -- transport-capable unit type names (case-sensitive as in DCS DB)
|
||||||
@@ -36,15 +79,17 @@ CTLD.Config = {
|
|||||||
MessageDuration = 15, -- seconds for on-screen messages
|
MessageDuration = 15, -- seconds for on-screen messages
|
||||||
Debug = false,
|
Debug = false,
|
||||||
PickupZoneSmokeColor = trigger.smokeColor.Green, -- default smoke color when spawning crates at pickup zones
|
PickupZoneSmokeColor = trigger.smokeColor.Green, -- default smoke color when spawning crates at pickup zones
|
||||||
|
RequirePickupZoneForCrateRequest = true, -- enforce that crate requests must be near a Supply (Pickup) Zone
|
||||||
|
PickupZoneMaxDistance = 10000, -- meters; nearest pickup zone must be within this distance to allow a request
|
||||||
BuildRequiresGroundCrates = true, -- if true, all required crates must be on the ground (won't count/consume carried crates)
|
BuildRequiresGroundCrates = true, -- if true, all required crates must be on the ground (won't count/consume carried crates)
|
||||||
|
|
||||||
-- Hover pickup configuration (Ciribob-style inspired)
|
-- Hover pickup configuration (Ciribob-style inspired)
|
||||||
HoverPickup = {
|
HoverPickup = {
|
||||||
Enabled = true, -- if true, auto-load the nearest crate when hovering close enough for a duration
|
Enabled = true, -- if true, auto-load the nearest crate when hovering close enough for a duration
|
||||||
Height = 3, -- meters AGL threshold for hover pickup
|
Height = 3, -- legacy: meters AGL threshold for hover pickup (superseded by HoverCoach thresholds when coach enabled)
|
||||||
Radius = 15, -- meters horizontal distance to crate to consider for pickup
|
Radius = 15, -- meters horizontal distance to crate to consider for pickup (used if precision thresholds not applicable)
|
||||||
AutoPickupDistance = 25, -- meters max search distance for candidate crates
|
AutoPickupDistance = 25, -- meters max search distance for candidate crates
|
||||||
Duration = 3, -- seconds of continuous hover before loading occurs
|
Duration = 3, -- seconds of continuous hover before loading occurs (steady time)
|
||||||
MaxCratesPerLoad = 6, -- maximum crates the aircraft can carry simultaneously
|
MaxCratesPerLoad = 6, -- maximum crates the aircraft can carry simultaneously
|
||||||
RequireLowSpeed = true, -- require near-stationary hover
|
RequireLowSpeed = true, -- require near-stationary hover
|
||||||
MaxSpeedMPS = 5 -- max allowed speed in m/s for hover pickup
|
MaxSpeedMPS = 5 -- max allowed speed in m/s for hover pickup
|
||||||
@@ -163,6 +208,7 @@ CTLD._troopsLoaded = {} -- [groupName] = { count, typeKey }
|
|||||||
CTLD._loadedCrates = {} -- [groupName] = { total=n, byKey = { key -> count } }
|
CTLD._loadedCrates = {} -- [groupName] = { total=n, byKey = { key -> count } }
|
||||||
CTLD._hoverState = {} -- [unitName] = { targetCrate=name, startTime=t }
|
CTLD._hoverState = {} -- [unitName] = { targetCrate=name, startTime=t }
|
||||||
CTLD._unitLast = {} -- [unitName] = { x, z, t }
|
CTLD._unitLast = {} -- [unitName] = { x, z, t }
|
||||||
|
CTLD._coachState = {} -- [unitName] = { lastKeyTimes = {key->time}, lastHint = "", phase = "", lastPhaseMsg = 0, target = crateName, holdStart = nil }
|
||||||
|
|
||||||
-- =========================
|
-- =========================
|
||||||
-- Utilities
|
-- Utilities
|
||||||
@@ -236,6 +282,105 @@ local function _vec3FromUnit(unit)
|
|||||||
return { x = p.x, y = p.y, z = p.z }
|
return { x = p.x, y = p.y, z = p.z }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Unit preference detection and unit-aware formatting
|
||||||
|
local function _getPlayerIsMetric(unit)
|
||||||
|
local ok, isMetric = pcall(function()
|
||||||
|
local pname = unit and unit.GetPlayerName and unit:GetPlayerName() or nil
|
||||||
|
if pname and type(SETTINGS) == 'table' and SETTINGS.Set then
|
||||||
|
local ps = SETTINGS:Set(pname)
|
||||||
|
if ps and ps.IsMetric then return ps:IsMetric() end
|
||||||
|
end
|
||||||
|
if _SETTINGS and _SETTINGS.IsMetric then return _SETTINGS:IsMetric() end
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
return (ok and isMetric) and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function _round(n, prec)
|
||||||
|
local m = 10^(prec or 0)
|
||||||
|
return math.floor(n * m + 0.5) / m
|
||||||
|
end
|
||||||
|
|
||||||
|
local function _fmtDistance(meters, isMetric)
|
||||||
|
if isMetric then
|
||||||
|
local v = math.max(0, _round(meters, 0))
|
||||||
|
return v, 'm'
|
||||||
|
else
|
||||||
|
local ft = meters * 3.28084
|
||||||
|
-- snap to 5 ft increments for readability
|
||||||
|
ft = math.max(0, math.floor((ft + 2.5) / 5) * 5)
|
||||||
|
return ft, 'ft'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function _fmtRange(meters, isMetric)
|
||||||
|
if isMetric then
|
||||||
|
local v = math.max(0, _round(meters, 0))
|
||||||
|
return v, 'm'
|
||||||
|
else
|
||||||
|
local nm = meters / 1852
|
||||||
|
return _round(nm, 1), 'NM'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function _fmtSpeed(mps, isMetric)
|
||||||
|
if isMetric then
|
||||||
|
return _round(mps, 1), 'm/s'
|
||||||
|
else
|
||||||
|
local fps = mps * 3.28084
|
||||||
|
return math.max(0, math.floor(fps + 0.5)), 'ft/s'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function _fmtAGL(meters, isMetric)
|
||||||
|
return _fmtDistance(meters, isMetric)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function _fmtTemplate(tpl, data)
|
||||||
|
if not tpl or tpl == '' then return '' end
|
||||||
|
return (tpl:gsub('{(%w+)}', function(k)
|
||||||
|
local v = data and data[k]
|
||||||
|
if v == nil then return '' end
|
||||||
|
return tostring(v)
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function _bearingDeg(from, to)
|
||||||
|
local dx = (to.x - from.x)
|
||||||
|
local dz = (to.z - from.z)
|
||||||
|
local ang = math.deg(math.atan2(dx, dz)) -- 0=N, +CW
|
||||||
|
if ang < 0 then ang = ang + 360 end
|
||||||
|
return math.floor(ang + 0.5)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function _projectToBodyFrame(dx, dz, hdg)
|
||||||
|
-- world (east=X=dx, north=Z=dz) to body frame (fwd/right)
|
||||||
|
local fwd = dx * math.sin(hdg) + dz * math.cos(hdg)
|
||||||
|
local right = dx * math.cos(hdg) - dz * math.sin(hdg)
|
||||||
|
return right, fwd
|
||||||
|
end
|
||||||
|
|
||||||
|
local function _coachSend(self, group, unitName, key, data, isCoach)
|
||||||
|
local cfg = CTLD.HoverCoachConfig
|
||||||
|
if not (cfg and cfg.enabled) then return end
|
||||||
|
local now = timer.getTime()
|
||||||
|
CTLD._coachState[unitName] = CTLD._coachState[unitName] or { lastKeyTimes = {} }
|
||||||
|
local st = CTLD._coachState[unitName]
|
||||||
|
local last = st.lastKeyTimes[key] or 0
|
||||||
|
local minGap = isCoach and (cfg.throttle.coachUpdate or 1.5) or (cfg.throttle.generic or 3.0)
|
||||||
|
local repeatGap = cfg.throttle.repeatSame or (minGap * 2)
|
||||||
|
if last > 0 and (now - last) < minGap then return end
|
||||||
|
-- prevent repeat spam of identical key too fast (only after first send)
|
||||||
|
if last > 0 and (now - last) < repeatGap then return end
|
||||||
|
local tpl = cfg.messages[key]
|
||||||
|
if not tpl then return end
|
||||||
|
local text = _fmtTemplate(tpl, data)
|
||||||
|
if text and text ~= '' then
|
||||||
|
_msgGroup(group, text)
|
||||||
|
st.lastKeyTimes[key] = now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Determine an approximate radius for a ZONE. Tries MOOSE radius, then trigger zone radius, then configured radius.
|
-- Determine an approximate radius for a ZONE. Tries MOOSE radius, then trigger zone radius, then configured radius.
|
||||||
function CTLD:_getZoneRadius(zone)
|
function CTLD:_getZoneRadius(zone)
|
||||||
if zone and zone.Radius then return zone.Radius end
|
if zone and zone.Radius then return zone.Radius end
|
||||||
@@ -443,7 +588,8 @@ function CTLD:RequestCrateForGroup(group, crateKey)
|
|||||||
if not unit or not unit:IsAlive() then return end
|
if not unit or not unit:IsAlive() then return end
|
||||||
local zone, dist = _nearestZonePoint(unit, self.Config.Zones.PickupZones)
|
local zone, dist = _nearestZonePoint(unit, self.Config.Zones.PickupZones)
|
||||||
local spawnPoint
|
local spawnPoint
|
||||||
if zone and dist < 10000 then
|
local maxd = (self.Config.PickupZoneMaxDistance or 10000)
|
||||||
|
if zone and dist <= maxd then
|
||||||
spawnPoint = zone:GetPointVec3()
|
spawnPoint = zone:GetPointVec3()
|
||||||
-- if pickup zone has smoke configured, mark it
|
-- if pickup zone has smoke configured, mark it
|
||||||
local zdef = self._ZoneDefs.PickupZones[zone:GetName()]
|
local zdef = self._ZoneDefs.PickupZones[zone:GetName()]
|
||||||
@@ -452,9 +598,15 @@ function CTLD:RequestCrateForGroup(group, crateKey)
|
|||||||
trigger.action.smoke({ x = spawnPoint.x, z = spawnPoint.z }, smokeColor)
|
trigger.action.smoke({ x = spawnPoint.x, z = spawnPoint.z }, smokeColor)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
-- fallback: spawn near aircraft current position (safe offset)
|
-- Either require a pickup zone proximity, or fallback to near-aircraft spawn (legacy behavior)
|
||||||
local p = unit:GetPointVec3()
|
if self.Config.RequirePickupZoneForCrateRequest then
|
||||||
spawnPoint = POINT_VEC3:New(p.x + 10, p.y, p.z + 10)
|
_msgGroup(group, string.format('You are not close enough to a Supply Zone to request resources. Move within %.1f km.', (maxd or 10000)/1000))
|
||||||
|
return
|
||||||
|
else
|
||||||
|
-- fallback: spawn near aircraft current position (safe offset)
|
||||||
|
local p = unit:GetPointVec3()
|
||||||
|
spawnPoint = POINT_VEC3:New(p.x + 10, p.y, p.z + 10)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
local cname = string.format('CTLD_CRATE_%s_%d', crateKey, math.random(100000,999999))
|
local cname = string.format('CTLD_CRATE_%s_%d', crateKey, math.random(100000,999999))
|
||||||
_spawnStaticCargo(self.Side, { x = spawnPoint.x, z = spawnPoint.z }, cat.dcsCargoType or 'uh1h_cargo', cname)
|
_spawnStaticCargo(self.Side, { x = spawnPoint.x, z = spawnPoint.z }, cat.dcsCargoType or 'uh1h_cargo', cname)
|
||||||
@@ -464,7 +616,24 @@ function CTLD:RequestCrateForGroup(group, crateKey)
|
|||||||
spawnTime = timer.getTime(),
|
spawnTime = timer.getTime(),
|
||||||
point = { x = spawnPoint.x, z = spawnPoint.z },
|
point = { x = spawnPoint.x, z = spawnPoint.z },
|
||||||
}
|
}
|
||||||
_msgGroup(group, string.format('Spawned crate %s at %s', crateKey, zone and zone:GetName() or 'current pos'))
|
-- Immersive spawn message with bearing/range per player units
|
||||||
|
do
|
||||||
|
local unitPos = unit:GetPointVec3()
|
||||||
|
local from = { x = unitPos.x, z = unitPos.z }
|
||||||
|
local to = { x = spawnPoint.x, z = spawnPoint.z }
|
||||||
|
local brg = _bearingDeg(from, to)
|
||||||
|
local isMetric = _getPlayerIsMetric(unit)
|
||||||
|
local rngMeters = math.sqrt(((to.x-from.x)^2)+((to.z-from.z)^2))
|
||||||
|
local rngV, rngU = _fmtRange(rngMeters, isMetric)
|
||||||
|
local data = {
|
||||||
|
id = cname,
|
||||||
|
type = tostring(crateKey),
|
||||||
|
brg = brg,
|
||||||
|
rng = rngV,
|
||||||
|
rng_u = rngU,
|
||||||
|
}
|
||||||
|
_coachSend(self, group, unit:GetName(), 'spawned', data, false)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function CTLD:GetNearbyCrates(point, radius)
|
function CTLD:GetNearbyCrates(point, radius)
|
||||||
@@ -665,8 +834,9 @@ end
|
|||||||
function CTLD:ScanHoverPickup()
|
function CTLD:ScanHoverPickup()
|
||||||
local hp = self.Config.HoverPickup or {}
|
local hp = self.Config.HoverPickup or {}
|
||||||
if not hp.Enabled then return end
|
if not hp.Enabled then return end
|
||||||
|
local coachCfg = CTLD.HoverCoachConfig or { enabled = false }
|
||||||
-- iterate all groups that have menus (active transports)
|
-- iterate all groups that have menus (active transports)
|
||||||
for gname,root in pairs(self.MenusByGroup or {}) do
|
for gname,_ in pairs(self.MenusByGroup or {}) do
|
||||||
local group = GROUP:FindByName(gname)
|
local group = GROUP:FindByName(gname)
|
||||||
if group and group:IsAlive() then
|
if group and group:IsAlive() then
|
||||||
local unit = group:GetUnit(1)
|
local unit = group:GetUnit(1)
|
||||||
@@ -674,66 +844,164 @@ function CTLD:ScanHoverPickup()
|
|||||||
-- Allowed type check
|
-- Allowed type check
|
||||||
local typ = _getUnitType(unit)
|
local typ = _getUnitType(unit)
|
||||||
if _isIn(self.Config.AllowedAircraft, typ) then
|
if _isIn(self.Config.AllowedAircraft, typ) then
|
||||||
local p3 = unit:GetPointVec3()
|
|
||||||
local agl = 0
|
|
||||||
if land and land.getHeight then
|
|
||||||
agl = math.max(0, p3.y - land.getHeight({ x = p3.x, y = p3.z }))
|
|
||||||
else
|
|
||||||
agl = p3.y -- fallback
|
|
||||||
end
|
|
||||||
-- speed estimate
|
|
||||||
local uname = unit:GetName()
|
local uname = unit:GetName()
|
||||||
local now = timer.getTime()
|
local now = timer.getTime()
|
||||||
|
local p3 = unit:GetPointVec3()
|
||||||
|
local ground = land and land.getHeight and land.getHeight({ x = p3.x, y = p3.z }) or 0
|
||||||
|
local agl = math.max(0, p3.y - ground)
|
||||||
|
-- speeds (ground/vertical)
|
||||||
local last = CTLD._unitLast[uname]
|
local last = CTLD._unitLast[uname]
|
||||||
local speed = 0
|
local gs, vs = 0, 0
|
||||||
if last and (now > (last.t or 0)) then
|
if last and (now > (last.t or 0)) then
|
||||||
local dx = (p3.x - last.x)
|
|
||||||
local dz = (p3.z - last.z)
|
|
||||||
local dt = now - last.t
|
local dt = now - last.t
|
||||||
if dt > 0 then speed = math.sqrt(dx*dx + dz*dz) / dt end
|
if dt > 0 then
|
||||||
|
local dx = (p3.x - last.x)
|
||||||
|
local dz = (p3.z - last.z)
|
||||||
|
gs = math.sqrt(dx*dx + dz*dz) / dt
|
||||||
|
if last.agl then vs = (agl - last.agl) / dt end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
CTLD._unitLast[uname] = { x = p3.x, z = p3.z, t = now }
|
CTLD._unitLast[uname] = { x = p3.x, z = p3.z, t = now, agl = agl }
|
||||||
|
|
||||||
local speedOK = (not hp.RequireLowSpeed) or (speed <= (hp.MaxSpeedMPS or 5))
|
-- find nearest crate within search distance
|
||||||
local heightOK = agl <= (hp.Height or 3)
|
local bestName, bestMeta, bestd
|
||||||
if speedOK and heightOK then
|
local maxd = hp.AutoPickupDistance or hp.Radius or 25
|
||||||
-- find nearest crate within AutoPickupDistance
|
for name,meta in pairs(CTLD._crates) do
|
||||||
local bestName, bestMeta, bestd
|
if meta.side == self.Side then
|
||||||
local maxd = hp.AutoPickupDistance or hp.Radius or 25
|
local dx = (meta.point.x - p3.x)
|
||||||
for name,meta in pairs(CTLD._crates) do
|
local dz = (meta.point.z - p3.z)
|
||||||
if meta.side == self.Side then
|
local d = math.sqrt(dx*dx + dz*dz)
|
||||||
local dx = (meta.point.x - p3.x)
|
if d <= maxd and ((not bestd) or d < bestd) then
|
||||||
local dz = (meta.point.z - p3.z)
|
bestName, bestMeta, bestd = name, meta, d
|
||||||
local d = math.sqrt(dx*dx + dz*dz)
|
|
||||||
if d <= maxd and ((not bestd) or d < bestd) then
|
|
||||||
bestName, bestMeta, bestd = name, meta, d
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if bestName and bestMeta and bestd <= (hp.Radius or maxd) then
|
end
|
||||||
-- check capacity
|
|
||||||
|
-- If coach is on, provide phased guidance
|
||||||
|
if coachCfg.enabled and bestName and bestMeta then
|
||||||
|
local isMetric = _getPlayerIsMetric(unit)
|
||||||
|
-- Arrival phase
|
||||||
|
if bestd <= (coachCfg.thresholds.arrivalDist or 1000) then
|
||||||
|
_coachSend(self, group, uname, 'arrival', {}, false)
|
||||||
|
end
|
||||||
|
-- Close-in
|
||||||
|
if bestd <= (coachCfg.thresholds.closeDist or 100) then
|
||||||
|
_coachSend(self, group, uname, 'close', {}, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Precision phase
|
||||||
|
if bestd <= (coachCfg.thresholds.precisionDist or 30) then
|
||||||
|
local hdg = unit:GetHeading() or 0
|
||||||
|
local dx = (bestMeta.point.x - p3.x)
|
||||||
|
local dz = (bestMeta.point.z - p3.z)
|
||||||
|
local right, fwd = _projectToBodyFrame(dx, dz, hdg)
|
||||||
|
|
||||||
|
-- Horizontal hint formatting
|
||||||
|
local function hintDir(val, posWord, negWord, toUnits)
|
||||||
|
local mag = math.abs(val)
|
||||||
|
local v, u = _fmtDistance(mag, isMetric)
|
||||||
|
if mag < 0.5 then return nil end
|
||||||
|
return string.format("%s %d %s", (val >= 0 and posWord or negWord), v, u)
|
||||||
|
end
|
||||||
|
local h = {}
|
||||||
|
local rHint = hintDir(right, 'Right', 'Left')
|
||||||
|
local fHint = hintDir(fwd, 'Forward', 'Back')
|
||||||
|
if rHint then table.insert(h, rHint) end
|
||||||
|
if fHint then table.insert(h, fHint) end
|
||||||
|
|
||||||
|
-- Vertical hint against AGL window
|
||||||
|
local vHint
|
||||||
|
local aglMin = coachCfg.thresholds.aglMin or 5
|
||||||
|
local aglMax = coachCfg.thresholds.aglMax or 20
|
||||||
|
if agl < aglMin then
|
||||||
|
local dv, du = _fmtAGL(aglMin - agl, isMetric)
|
||||||
|
vHint = string.format("Up %d %s", dv, du)
|
||||||
|
elseif agl > aglMax then
|
||||||
|
local dv, du = _fmtAGL(agl - aglMax, isMetric)
|
||||||
|
vHint = string.format("Down %d %s", dv, du)
|
||||||
|
end
|
||||||
|
if vHint then table.insert(h, vHint) end
|
||||||
|
|
||||||
|
local hints = table.concat(h, ", ")
|
||||||
|
local gsV, gsU = _fmtSpeed(gs, isMetric)
|
||||||
|
local data = { hints = (hints ~= '' and (hints..'.') or ''), gs = gsV, gs_u = gsU }
|
||||||
|
|
||||||
|
_coachSend(self, group, uname, 'coach', data, true)
|
||||||
|
|
||||||
|
-- Error prompts (dominant one)
|
||||||
|
local maxGS = coachCfg.thresholds.maxGS or (8/3.6)
|
||||||
|
local aglMinT = aglMin
|
||||||
|
local aglMaxT = aglMax
|
||||||
|
if gs > maxGS then
|
||||||
|
local v, u = _fmtSpeed(gs, isMetric)
|
||||||
|
_coachSend(self, group, uname, 'tooFast', { gs = v, gs_u = u }, false)
|
||||||
|
elseif agl > aglMaxT then
|
||||||
|
local v, u = _fmtAGL(agl, isMetric)
|
||||||
|
_coachSend(self, group, uname, 'tooHigh', { agl = v, agl_u = u }, false)
|
||||||
|
elseif agl < aglMinT then
|
||||||
|
local v, u = _fmtAGL(agl, isMetric)
|
||||||
|
_coachSend(self, group, uname, 'tooLow', { agl = v, agl_u = u }, false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Auto-load logic using capture thresholds (coach or legacy)
|
||||||
|
local speedOK, heightOK
|
||||||
|
if coachCfg.enabled then
|
||||||
|
local capGS = coachCfg.thresholds.captureGS or (4/3.6)
|
||||||
|
local aglMin = coachCfg.thresholds.aglMin or 5
|
||||||
|
local aglMax = coachCfg.thresholds.aglMax or 20
|
||||||
|
speedOK = gs <= capGS
|
||||||
|
heightOK = (agl >= aglMin and agl <= aglMax)
|
||||||
|
else
|
||||||
|
speedOK = (not hp.RequireLowSpeed) or (gs <= (hp.MaxSpeedMPS or 5))
|
||||||
|
heightOK = agl <= (hp.Height or 3)
|
||||||
|
end
|
||||||
|
|
||||||
|
if bestName and bestMeta and speedOK and heightOK then
|
||||||
|
local withinRadius
|
||||||
|
if coachCfg.enabled then
|
||||||
|
withinRadius = bestd <= (coachCfg.thresholds.captureHoriz or 2)
|
||||||
|
else
|
||||||
|
withinRadius = bestd <= (hp.Radius or hp.AutoPickupDistance or 25)
|
||||||
|
end
|
||||||
|
|
||||||
|
if withinRadius then
|
||||||
local carried = CTLD._loadedCrates[gname]
|
local carried = CTLD._loadedCrates[gname]
|
||||||
local total = carried and carried.total or 0
|
local total = carried and carried.total or 0
|
||||||
if total < (hp.MaxCratesPerLoad or 6) then
|
if total < (hp.MaxCratesPerLoad or 6) then
|
||||||
local hs = CTLD._hoverState[uname]
|
local hs = CTLD._hoverState[uname]
|
||||||
if not hs or hs.targetCrate ~= bestName then
|
if not hs or hs.targetCrate ~= bestName then
|
||||||
CTLD._hoverState[uname] = { targetCrate = bestName, startTime = now }
|
CTLD._hoverState[uname] = { targetCrate = bestName, startTime = now }
|
||||||
|
if coachCfg.enabled then _coachSend(self, group, uname, 'hold', {}, false) end
|
||||||
else
|
else
|
||||||
if (now - hs.startTime) >= (hp.Duration or 3) then
|
-- stability hold timer
|
||||||
|
local holdNeeded = coachCfg.enabled and (coachCfg.thresholds.stabilityHold or (hp.Duration or 3)) or (hp.Duration or 3)
|
||||||
|
if (now - hs.startTime) >= holdNeeded then
|
||||||
-- load it
|
-- load it
|
||||||
local obj = StaticObject.getByName(bestName)
|
local obj = StaticObject.getByName(bestName)
|
||||||
if obj then obj:destroy() end
|
if obj then obj:destroy() end
|
||||||
CTLD._crates[bestName] = nil
|
CTLD._crates[bestName] = nil
|
||||||
self:_addLoadedCrate(group, bestMeta.key)
|
self:_addLoadedCrate(group, bestMeta.key)
|
||||||
_msgGroup(group, string.format('Loaded %s crate', tostring(bestMeta.key)))
|
if coachCfg.enabled then
|
||||||
|
_coachSend(self, group, uname, 'loaded', {}, false)
|
||||||
|
else
|
||||||
|
_msgGroup(group, string.format('Loaded %s crate', tostring(bestMeta.key)))
|
||||||
|
end
|
||||||
CTLD._hoverState[uname] = nil
|
CTLD._hoverState[uname] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
-- lost precision window
|
||||||
|
if coachCfg.enabled then _coachSend(self, group, uname, 'hoverLost', {}, false) end
|
||||||
CTLD._hoverState[uname] = nil
|
CTLD._hoverState[uname] = nil
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
-- reset hover state when outside primary envelope
|
||||||
|
if CTLD._hoverState[uname] then
|
||||||
|
if coachCfg.enabled then _coachSend(self, group, uname, 'abort', {}, false) end
|
||||||
|
end
|
||||||
CTLD._hoverState[uname] = nil
|
CTLD._hoverState[uname] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user