FiveM 无线电与调度系统:PMA、pma-voice 和 CADs
设置 FiveM 无线电、调度和 CAD 系统。pma-voice 频道、MDT 工具及适用于警察、EMS 和机械师团队的最佳脚本。
Agency Scripts
Agency Scripts 创始人兼首席开发者
无线电和调度系统架构
无线电和调度系统是 FiveM 角色扮演服务器紧急服务的通信骨干。它使警察、EMS 和消防部门通过结构化无线电频道协调响应,接收来自 911 呼叫和自动检测系统的优先调度警报,并通过 GPS 集成实时跟踪单位位置。架构包括三个互联系统:管理谁能在何频率通话的无线电频道框架,处理来电并将其路由至适当单位的调度队列,以及在共享地图上可视化单位位置的 GPS 跟踪层。与简单基于聊天的通信不同,合适的无线电系统增加了切换频率的沉浸感,仅在调频时听到无线电对话,并遵循模拟真实紧急服务的结构化通信协议。整个系统通过服务器端状态管理防止作弊,客户端 NUI 提供无线电界面和调度面板。
无线电频道系统
无线电频道组织通信,使不同部门和单位能够协调而无交叉干扰。每个频道有频率编号、基于职位的访问限制和当前连接玩家列表。系统支持多种频道类型:部门范围频道用于一般通信,战术频道用于特定行动如追捕或银行抢劫响应,跨部门频道让警察和EMS协调共享事件。玩家通过无线电物品或NUI界面加入频道,所有在该频道传输的语音或文本仅传达给调至相同频率的玩家。实现优先发言系统,调度操作员和指挥官可同时向所有频道广播紧急警报:
Config.RadioChannels = {
-- Police Department
{ frequency = 1, label = 'PD Main', jobs = {'police'}, type = 'department' },
{ frequency = 2, label = 'PD Patrol', jobs = {'police'}, type = 'department' },
{ frequency = 3, label = 'PD Tactical', jobs = {'police'}, type = 'tactical', maxUsers = 8 },
{ frequency = 4, label = 'PD Detectives', jobs = {'police'}, type = 'tactical', minRank = 3 },
-- EMS / Fire
{ frequency = 10, label = 'EMS Main', jobs = {'ambulance'}, type = 'department' },
{ frequency = 11, label = 'EMS Field', jobs = {'ambulance'}, type = 'department' },
{ frequency = 12, label = 'Fire Main', jobs = {'fire'}, type = 'department' },
-- Inter-department
{ frequency = 20, label = 'Emergency Joint', jobs = {'police','ambulance','fire'}, type = 'inter' },
{ frequency = 21, label = 'Command Channel', jobs = {'police','ambulance','fire'}, type = 'command', minRank = 5 },
-- Civilian (if radio item owned)
{ frequency = 50, label = 'Civilian Band', jobs = {}, type = 'open' },
}
-- Server-side channel state
local radioState = {} -- [frequency] = { players = {}, priority = false }
RegisterNetEvent('radio:server:joinChannel', function(frequency)
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return end
local channel = nil
for _, ch in ipairs(Config.RadioChannels) do
if ch.frequency == frequency then channel = ch break end
end
if not channel then return end
-- Check job access
if #channel.jobs > 0 then
local playerJob = Player.PlayerData.job.name
local hasAccess = false
for _, job in ipairs(channel.jobs) do
if job == playerJob then hasAccess = true break end
end
if not hasAccess then
TriggerClientEvent('QBCore:Notify', src, 'No access to this frequency', 'error')
return
end
end
-- Check rank requirement
if channel.minRank and Player.PlayerData.job.grade.level < channel.minRank then
TriggerClientEvent('QBCore:Notify', src, 'Insufficient rank for this channel', 'error')
return
end
-- Check max users for tactical channels
if channel.maxUsers and radioState[frequency] then
if #radioState[frequency].players >= channel.maxUsers then
TriggerClientEvent('QBCore:Notify', src, 'Channel full', 'error')
return
end
end
-- Leave current channel
LeaveCurrentChannel(src)
-- Join new channel
if not radioState[frequency] then
radioState[frequency] = { players = {}, priority = false }
end
table.insert(radioState[frequency].players, src)
TriggerClientEvent('radio:client:joined', src, frequency, channel.label)
TriggerClientEvent('QBCore:Notify', src, 'Tuned to ' .. channel.label .. ' (' .. frequency .. ')', 'success')
end)
10码系统
十码是标准化的无线电简码,增加紧急服务通信的真实性,并通过调度系统实现快速状态更新。不要仅将十码作为聊天宏实现,而应构建为功能性命令,更新警员在调度系统中的状态并触发自动响应。当警员呼叫时 10-80 (追捕进行中),系统应自动更新调度板上的状态,创建所有单位可见的追捕警报,并开始对警官车辆进行 GPS 跟踪。当他们呼叫 10-97 (抵达现场时),调度系统将其标记为正在响应的活动呼叫的现场人员。构建10码系统作为可配置表格,以便服务器所有者自定义代码以匹配其部门偏好的协议:
Config.TenCodes = {
['10-4'] = { label = 'Acknowledged', action = nil, status = nil },
['10-6'] = { label = 'Busy', action = nil, status = 'busy' },
['10-7'] = { label = 'Out of Service', action = 'setOffDuty', status = 'off_duty' },
['10-8'] = { label = 'In Service', action = 'setOnDuty', status = 'available' },
['10-11'] = { label = 'Traffic Stop', action = 'createTrafficStop', status = 'traffic_stop' },
['10-15'] = { label = 'Suspect in Custody', action = nil, status = 'transport' },
['10-20'] = { label = 'Location Request', action = 'shareLocation', status = nil },
['10-23'] = { label = 'Arrived at Scene', action = 'markOnScene', status = 'on_scene' },
['10-32'] = { label = 'Person with Weapon', action = 'createAlert', status = 'responding', priority = 'high' },
['10-41'] = { label = 'Beginning Tour of Duty', action = 'clockIn', status = 'available' },
['10-42'] = { label = 'Ending Tour of Duty', action = 'clockOut', status = 'off_duty' },
['10-71'] = { label = 'Shooting', action = 'createAlert', status = 'responding', priority = 'critical' },
['10-78'] = { label = 'Officer Needs Assistance', action = 'panicButton', status = 'emergency', priority = 'critical' },
['10-80'] = { label = 'Pursuit in Progress', action = 'startPursuit', status = 'pursuit', priority = 'high' },
['10-97'] = { label = 'Arriving on Scene', action = 'markOnScene', status = 'on_scene' },
['10-99'] = { label = 'Officer Down', action = 'officerDown', status = 'emergency', priority = 'critical' },
['code4'] = { label = 'No Further Assistance', action = 'clearScene', status = 'available' },
}
RegisterNetEvent('radio:server:tenCode', function(code)
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return end
local codeConfig = Config.TenCodes[code]
if not codeConfig then return end
-- Update officer status
if codeConfig.status then
UpdateUnitStatus(src, codeConfig.status)
end
-- Execute associated action
if codeConfig.action then
ExecuteCodeAction(codeConfig.action, src, codeConfig)
end
-- Broadcast to channel
local channel = GetPlayerChannel(src)
if channel then
local name = GetUnitCallsign(src) or Player.PlayerData.charinfo.firstname
BroadcastToChannel(channel, {
type = 'tencode',
code = code,
label = codeConfig.label,
unit = name,
priority = codeConfig.priority,
})
end
end)
调度呼叫队列
调度队列是处理所有紧急呼叫并将其路由到相应单位的中心枢纽。当平民拨打911、银行或商店的自动警报触发,或警察请求支援时,系统创建带有优先级、位置、描述和所需服务类型的调度条目。队列在调度NUI面板上显示所有活动呼叫,按优先级排序,显示呼叫时间、位置、状态及分配单位。专职调度操作员可手动将呼叫分配给特定单位,或警员可自行点击呼叫并标记为响应。实现自动呼叫升级,未接听呼叫在可配置超时后优先级提升,确保繁忙时段无紧急呼叫被忽视:
local dispatchQueue = {}
local callIdCounter = 0
function CreateDispatchCall(data)
callIdCounter = callIdCounter + 1
local call = {
id = callIdCounter,
type = data.type or 'general', -- police, ems, fire, general
priority = data.priority or 'standard', -- low, standard, urgent, critical
code = data.code or nil, -- e.g., '10-71', '211' (robbery)
title = data.title,
description = data.description or '',
coords = data.coords,
street = GetStreetName(data.coords),
blip = data.blip or nil,
caller = data.caller or 'Unknown',
callerId = data.callerId or nil,
timestamp = os.time(),
status = 'pending', -- pending, assigned, responding, on_scene, resolved
assignedUnits = {},
escalationTimer = nil,
metadata = data.metadata or {},
}
-- Set escalation timer
if call.priority ~= 'critical' then
call.escalationTimer = os.time() + Config.EscalationTimeout
end
table.insert(dispatchQueue, call)
-- Notify appropriate department
local targetJobs = GetJobsForCallType(call.type)
local players = QBCore.Functions.GetQBPlayers()
for _, player in pairs(players) do
local job = player.PlayerData.job
if job.onduty and TableContains(targetJobs, job.name) then
TriggerClientEvent('dispatch:client:newCall', player.PlayerData.source, call)
end
end
-- Play alert sound based on priority
if call.priority == 'critical' then
PlayDispatchAlert('critical', targetJobs)
end
return call.id
end
-- Automated dispatch triggers
AddEventHandler('banking:robberyStarted', function(bankId, coords)
CreateDispatchCall({
type = 'police',
priority = 'critical',
code = '211',
title = 'Bank Robbery in Progress',
description = 'Silent alarm triggered at ' .. GetBankLabel(bankId),
coords = coords,
blip = { sprite = 500, color = 1, scale = 1.5, flash = true },
})
end)
AddEventHandler('hospital:911call', function(callerId, coords, description, injuries)
CreateDispatchCall({
type = 'ems',
priority = injuries and injuries.maxSeverity >= 80 and 'critical' or 'urgent',
title = 'Medical Emergency',
description = description,
coords = coords,
callerId = callerId,
caller = GetPlayerName(callerId),
blip = { sprite = 153, color = 3, scale = 1.2 },
metadata = { injuries = injuries },
})
end)
GPS 追踪和单位地图
GPS 追踪提供实时可视化,显示所有活跃单位在地图上的位置,使调度员和指挥官能够做出明智的任务分配决策。每个值班警员的位置以可配置的间隔广播到调度系统,通常为每 5-10 秒一次,并显示在调度操作员和主管可访问的共享 NUI 地图面板上。地图显示按部门和状态颜色编码的单位标记,图标指示其是否可用、响应中、现场或追捕中。包含一个轨迹功能,跟踪单位过去几分钟的移动,使调度员在追捕过程中能看到行进方向。实现呼叫半径可视化,在活跃调度呼叫周围绘制圆圈,显示推荐响应区域。当调度员需要找到离新呼叫最近的可用单位时,系统可以计算所有可用单位的距离并建议最佳分配。保持 GPS 广播效率,仅在单位移动超过最小距离阈值时发送位置更新,以减少繁忙服务器上的网络流量。
警报系统和紧急按钮
警报系统处理需要所有单位立即关注的高优先级通知。最关键的警报是恐慌按钮,当警员呼叫时激活 10-78 或 10-99,向所有值班单位发送其GPS位置,带闪烁标记和独特警报声。紧急警报应覆盖正常调度队列优先级,并在每位警官屏幕上显著显示直到确认。实现不同警报等级触发升级响应:标准警报创建带通知声的调度呼叫,紧急警报闪烁调度面板并播放警告音,关键警报触发全屏通知带警笛声和自动GPS导航至警报地点。自动警报与其他服务器系统集成,如枪击检测区在公共区域开枪时触发警报,测速摄像头标记超速车辆,商店报警系统在抢劫开始时创建调度呼叫。每种警报类型有可配置冷却时间,防止同一区域重复触发造成垃圾信息。
调度操作员角色
调度操作员是一个专职角色,坐在工作站管理紧急呼叫流向现场单位。与只看到简化调度通知的警员不同,操作员拥有完整的调度控制台NUI,包括队列管理器、单位花名册、实时地图和通信工具。操作员可以通过拖动队列优先排序呼叫,基于距离和可用性分配特定单位,单位报告清除后标记呼叫为已解决,并在部门频率上广播消息。实现工作站系统,要求调度操作员必须坐在指定调度桌前才能访问完整控制台,防止其在现场操作调度系统。操作员角色应有独立的职位等级进阶,经验丰富的调度员可访问部门间协调工具和紧急广播功能。跟踪调度绩效指标,如平均响应时间、每班处理呼叫数和呼叫解决率,为操作员提供绩效反馈,并为服务器管理员提供人员配置数据。
语音无线电集成
对于使用pma-voice或mumble-voip等语音聊天解决方案的服务器,将无线电系统与近距离语音集成以创建逼真的无线电通信。当玩家通过按住快捷键在无线电频道上发射时,所有在同一频率上的玩家都应能听到其声音,无论物理距离如何,并应用无线电滤波效果以区分无线电通信和面对面交谈。实现静噪系统,玩家必须释放发射键后其他玩家才能发言,模拟真实无线电半双工行为。添加按键按下和释放的无线电音效,为开始和停止传输提供音频反馈。语音集成应遵守无线电系统定义的频道访问控制,未通过无线电NUI加入频率的玩家即使知道mumble频道ID也无法听或发声。无语音聊天服务器则回退为基于文本的无线电系统,频率上发送的消息以带有发信人呼号和频道标识的样式化聊天格式显示。
集成与扩展性
您的无线电和调度系统应提供简洁的 API,供其他资源创建调度呼叫、发送警报和查询单位状态,而无需了解内部实现。导出函数如 CreateDispatchCall, SendAlert, GetAvailableUnits,和 GetUnitStatus 以便服务器上的任何资源都能与调度系统集成。当银行抢劫开始时,银行资源调用调度导出以创建呼叫。当EMS接到911呼叫时,医院资源通过相同的调度系统路由。此集中式方法确保所有紧急通信通过单一系统进行,具有一致的优先级处理、单位跟踪和日志记录。在数据库中存储调度呼叫历史以供管理员审查,并生成总结呼叫量、响应时间和单位活动的班次报告。将调度系统连接到Discord webhook,使关键警报如警员倒地呼叫或银行抢劫显示在专用员工频道,即使管理员不在游戏中也能保持知情。