added hover messages and message flow from crate cradle to pickup.

This commit is contained in:
iTracerFacer 2025-11-04 10:29:38 -06:00
parent 1338c4d9ac
commit d74226ee40

View File

@ -1,4 +1,3 @@
-- Moose_CTLD.lua
-- Pure-MOOSE, template-free CTLD-style logistics & troop transport
-- Drop-in script: no MIST, no mission editor templates required
-- Dependencies: Moose.lua must be loaded before this script
@ -21,6 +20,50 @@ CTLD.__index = CTLD
-- =========================
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 “Youre 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 = "Crates live! {type} [{id}]. Bearing {brg}° range {rng} {rng_u}. Call for vectors if you need a hand.",
arrival = "Youre close—nice and easy. Hover at 520 meters.",
close = "Reduce speed below 15 km/h and set 520 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 520 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 520 m.",
}
}
CTLD.Config = {
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)
@ -36,15 +79,17 @@ CTLD.Config = {
MessageDuration = 15, -- seconds for on-screen messages
Debug = false,
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)
-- Hover pickup configuration (Ciribob-style inspired)
HoverPickup = {
Enabled = true, -- if true, auto-load the nearest crate when hovering close enough for a duration
Height = 3, -- meters AGL threshold for hover pickup
Radius = 15, -- meters horizontal distance to crate to consider for 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 (used if precision thresholds not applicable)
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
RequireLowSpeed = true, -- require near-stationary hover
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._hoverState = {} -- [unitName] = { targetCrate=name, startTime=t }
CTLD._unitLast = {} -- [unitName] = { x, z, t }
CTLD._coachState = {} -- [unitName] = { lastKeyTimes = {key->time}, lastHint = "", phase = "", lastPhaseMsg = 0, target = crateName, holdStart = nil }
-- =========================
-- Utilities
@ -236,6 +282,105 @@ local function _vec3FromUnit(unit)
return { x = p.x, y = p.y, z = p.z }
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.
function CTLD:_getZoneRadius(zone)
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
local zone, dist = _nearestZonePoint(unit, self.Config.Zones.PickupZones)
local spawnPoint
if zone and dist < 10000 then
local maxd = (self.Config.PickupZoneMaxDistance or 10000)
if zone and dist <= maxd then
spawnPoint = zone:GetPointVec3()
-- if pickup zone has smoke configured, mark it
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)
end
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)
-- Either require a pickup zone proximity, or fallback to near-aircraft spawn (legacy behavior)
if self.Config.RequirePickupZoneForCrateRequest then
_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
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)
@ -464,7 +616,24 @@ function CTLD:RequestCrateForGroup(group, crateKey)
spawnTime = timer.getTime(),
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
function CTLD:GetNearbyCrates(point, radius)
@ -665,8 +834,9 @@ end
function CTLD:ScanHoverPickup()
local hp = self.Config.HoverPickup or {}
if not hp.Enabled then return end
local coachCfg = CTLD.HoverCoachConfig or { enabled = false }
-- 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)
if group and group:IsAlive() then
local unit = group:GetUnit(1)
@ -674,66 +844,164 @@ function CTLD:ScanHoverPickup()
-- Allowed type check
local typ = _getUnitType(unit)
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 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 speed = 0
local gs, vs = 0, 0
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
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
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))
local heightOK = agl <= (hp.Height or 3)
if speedOK and heightOK then
-- find nearest crate within AutoPickupDistance
local bestName, bestMeta, bestd
local maxd = hp.AutoPickupDistance or hp.Radius or 25
for name,meta in pairs(CTLD._crates) do
if meta.side == self.Side then
local dx = (meta.point.x - p3.x)
local dz = (meta.point.z - p3.z)
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
-- find nearest crate within search distance
local bestName, bestMeta, bestd
local maxd = hp.AutoPickupDistance or hp.Radius or 25
for name,meta in pairs(CTLD._crates) do
if meta.side == self.Side then
local dx = (meta.point.x - p3.x)
local dz = (meta.point.z - p3.z)
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
if bestName and bestMeta and bestd <= (hp.Radius or maxd) then
-- check capacity
end
-- 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 total = carried and carried.total or 0
if total < (hp.MaxCratesPerLoad or 6) then
local hs = CTLD._hoverState[uname]
if not hs or hs.targetCrate ~= bestName then
CTLD._hoverState[uname] = { targetCrate = bestName, startTime = now }
if coachCfg.enabled then _coachSend(self, group, uname, 'hold', {}, false) end
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
local obj = StaticObject.getByName(bestName)
if obj then obj:destroy() end
CTLD._crates[bestName] = nil
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
end
end
end
else
-- lost precision window
if coachCfg.enabled then _coachSend(self, group, uname, 'hoverLost', {}, false) end
CTLD._hoverState[uname] = nil
end
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
end
end