FiveM 狩猎许可证系统:许可、罚款与区域
为FiveM添加一个完整的狩猎许可证系统。许可证、保护区、罚款、护林员职位以及用于模拟真实野生动物法律的顶级脚本。
Agency Scripts
Agency Scripts 创始人兼首席开发者
作为角色扮演基础设施的许可证
执照和许可系统是任何严肃角色扮演服务器的基础设施。没有它,玩家从第一天起就能做任何事。执照将活动置于现实要求之后,创造进展、后果和角色扮演互动。驾照要求玩家通过考试才能合法驾驶。武器许可需背景调查和培训。狩猎许可限制谁能狩猎及地点。营业执照控制谁能经营商业。钓鱼许可、飞行执照、医疗执照、法律执照,,每种许可都创建一层监管,反映现实系统,生成市民、政府工作人员和执法者间的自然互动。警察拦截并检查执照状态,是执照系统使之成为可能的角色扮演时刻。本教程演示如何构建灵活、可扩展的执照框架,支持服务器所需的任何许可类型。
灵活的许可证类型配置
不要硬编码特定许可证类型,构建一个配置驱动系统,添加新许可证类型只需在配置表中新增条目。每种许可证定义包括唯一标识符、显示名称、描述、申请费用、是否需要考试、考试题目(如适用)、有效期及续期要求、可颁发的职位或角色,以及缺少许可证时的游戏限制。这样你的狩猎许可证、驾驶证、武器许可和营业执照都运行在同一引擎上。服务器发展需要新许可证类型如出租车牌照或建筑许可证时,只需添加配置条目,整个申请、颁发和执行流程自动生效,无需编写新代码。
-- shared/config.lua
Config = {}
Config.LicenseTypes = {
driving = {
label = 'Driving License',
description = 'Required to legally operate motor vehicles',
cost = 500,
requiresExam = true,
examType = 'practical',
validDays = 365,
issuedBy = {'dmv', 'police'},
restrictions = {'vehicle_operation'},
},
weapons = {
label = 'Weapons Permit',
description = 'Required to legally carry firearms',
cost = 2500,
requiresExam = true,
examType = 'written',
validDays = 180,
issuedBy = {'police'},
restrictions = {'weapon_carry'},
prerequisites = {'driving'},
},
hunting = {
label = 'Hunting License',
description = 'Required for legal hunting activities',
cost = 750,
requiresExam = true,
examType = 'written',
validDays = 90,
issuedBy = {'ranger', 'dmv'},
restrictions = {'hunting_activity'},
},
fishing = {
label = 'Fishing Permit',
description = 'Required for legal fishing activities',
cost = 200,
requiresExam = false,
validDays = 30,
issuedBy = {'dmv', 'ranger'},
restrictions = {'fishing_activity'},
},
business = {
label = 'Business License',
description = 'Required to operate a commercial business',
cost = 5000,
requiresExam = false,
validDays = 365,
issuedBy = {'government'},
restrictions = {'business_operation'},
},
pilot = {
label = 'Pilot License',
description = 'Required to operate aircraft',
cost = 10000,
requiresExam = true,
examType = 'practical',
validDays = 180,
issuedBy = {'faa'},
restrictions = {'aircraft_operation'},
prerequisites = {'driving'},
},
}
申请和审核流程
申请过程应感觉像真实的政府交互。玩家访问DMV地点、邮局或相关政府建筑,与NPC或自助终端互动开始申请。NUI展示可用的许可证类型、费用和要求。选择许可证并支付费用后,系统检查先决条件。如果许可证需要笔试,玩家进入包含多项选择题的测验界面,题目从可配置题库中抽取。随机题目顺序和答案位置以防止记忆作弊。要求最低通过分数,通常为70%至80%。对于驾驶考试等实操考试,生成车辆并创建玩家必须在限时内完成且遵守交通法规的检查点路线。驾驶教练NPC或担任DMV工作的真实玩家可监考考试,违规闯红灯、超速或撞车者将不及格。存储考试结果,合格后发放许可证,有效期从当前时间戳开始。
-- server/licenses.lua
function ApplyForLicense(playerId, licenseType)
local config = Config.LicenseTypes[licenseType]
if not config then return false, 'Invalid license type' end
local identifier = GetPlayerIdentifier(playerId, 0)
-- Check prerequisites
if config.prerequisites then
for _, prereq in ipairs(config.prerequisites) do
local has = HasValidLicense(identifier, prereq)
if not has then
return false, 'Missing prerequisite: ' ..
Config.LicenseTypes[prereq].label
end
end
end
-- Check if already has active license
local existing = MySQL.single.await([[
SELECT id, status, expires_at FROM licenses
WHERE identifier = ? AND license_type = ?
AND status IN ('active','suspended')
]], {identifier, licenseType})
if existing and existing.status == 'active' then
return false, 'You already hold this license'
end
-- Deduct application fee
local paid = exports['framework']:RemoveMoney(
playerId, config.cost, 'bank')
if not paid then
return false, 'Insufficient funds ($' .. config.cost .. ' required)'
end
-- Create pending application
local appId = MySQL.insert.await([[
INSERT INTO license_applications
(identifier, license_type, status, applied_at)
VALUES (?, ?, 'pending', NOW())
]], {identifier, licenseType})
if config.requiresExam then
return true, 'Application submitted. Please complete the exam.', appId
else
-- No exam required, issue directly
IssueLicense(identifier, licenseType)
return true, 'License issued successfully!'
end
end
function IssueLicense(identifier, licenseType)
local config = Config.LicenseTypes[licenseType]
local expiresAt = os.time() + (config.validDays * 86400)
MySQL.insert([[
INSERT INTO licenses
(identifier, license_type, status, issued_at, expires_at)
VALUES (?, ?, 'active', NOW(), FROM_UNIXTIME(?))
ON DUPLICATE KEY UPDATE
status = 'active', issued_at = NOW(),
expires_at = FROM_UNIXTIME(?)
]], {identifier, licenseType, expiresAt, expiresAt})
end
function HasValidLicense(identifier, licenseType)
local result = MySQL.single.await([[
SELECT id FROM licenses
WHERE identifier = ? AND license_type = ?
AND status = 'active' AND expires_at > NOW()
]], {identifier, licenseType})
return result ~= nil
end
exports('HasValidLicense', HasValidLicense)
执法集成
许可证系统的真正力量在于执法部门能在角色扮演互动中查询和修改许可证状态。警察需在交通拦截时检查公民的许可证状态,搜查时核实武器许可,并作为犯罪行为后果暂停或吊销许可证。构建警察MDT集成,显示公民持有的所有许可证及其状态、签发日期和到期日期。添加命令或MDT操作以理由和期限暂停许可证,永久吊销,以及暂停期后恢复。许可证被暂停时,持有人应收到说明暂停原因和期限的通知。将许可证检查集成到现有执法流程中。若玩家无有效许可证驾驶,警察系统可在交通拦截时自动标记。若有人无许可狩猎,护林员可开具罚单,附带罚款和潜在吊销许可证。
-- server/enforcement.lua
RegisterNetEvent('licenses:checkCitizen', function(targetId)
local src = source
-- Verify requesting player is law enforcement
local job = exports['framework']:GetPlayerJob(src)
if job ~= 'police' and job ~= 'ranger' and job ~= 'sheriff' then
return
end
local targetIdentifier = GetPlayerIdentifier(targetId, 0)
local licenses = MySQL.query.await([[
SELECT license_type, status, issued_at, expires_at,
suspended_reason, suspended_until
FROM licenses WHERE identifier = ?
]], {targetIdentifier})
TriggerClientEvent('licenses:showResults', src, licenses)
end)
RegisterNetEvent('licenses:suspend', function(targetIdentifier, licType, reason, days)
local src = source
local job = exports['framework']:GetPlayerJob(src)
if job ~= 'police' and job ~= 'judge' then return end
local suspendUntil = os.time() + (days * 86400)
MySQL.update([[
UPDATE licenses SET
status = 'suspended',
suspended_reason = ?,
suspended_until = FROM_UNIXTIME(?)
WHERE identifier = ? AND license_type = ?
]], {reason, suspendUntil, targetIdentifier, licType})
-- Notify the affected player if online
local targetPlayer = GetPlayerFromIdentifier(targetIdentifier)
if targetPlayer then
TriggerClientEvent('licenses:notify', targetPlayer,
'Your ' .. Config.LicenseTypes[licType].label ..
' has been suspended for ' .. days .. ' days. Reason: ' .. reason)
end
-- Log the action
MySQL.insert([[
INSERT INTO license_logs
(identifier, license_type, action, performed_by, reason)
VALUES (?, ?, 'suspend', ?, ?)
]], {targetIdentifier, licType, GetPlayerIdentifier(src, 0), reason})
end)
续订系统和过期处理
带有到期日期的许可证创造了经常性收入和定期的 NPC 互动,使世界保持生机。当许可证接近到期时,通过电话系统或另一个教程中介绍的邮件系统向持有者发送通知。给予几天游戏内的宽限期,在此期间许可证技术上已过期,但玩家不会立即受到惩罚,以便有时间续期。续期应比初次申请更简单,仅需支付费用且无需重新考试,除非许可证之前被暂停或吊销。实现一个服务器端定时任务,定期检查过期许可证并更新其状态。对于需要定期续期的许可证,如有效期较短的钓鱼许可证,考虑提供批量续期选项,玩家可以预付多期费用并享受小幅折扣。这奖励了有计划的忠实玩家,同时保持续期周期,使系统保持动态感。
跨脚本限制执行
牌照系统只有在其他脚本实际执行限制时才有意义。创建一个集中导出函数,任何脚本都可调用以检查玩家是否持有某种有效牌照。你的车辆脚本在允许发动引擎或玩家进入驾驶座前检查驾驶证。武器脚本在玩家装备枪械时检查武器许可证。狩猎脚本在允许采猎动物前检查狩猎证。钓鱼脚本在允许捕捞前检查钓鱼许可证。各脚本自行决定违规后果,有些仅阻止操作并通知,有些允许操作但标记玩家引起执法注意,触发自动通缉等级或警报。严格程度取决于服务器的RP理念。牌照系统提供数据层;执法脚本决定后果。此分离允许调整执法严格度而无需修改牌照系统本身。
数据库架构与管理工具
数据库架构需包含许可定义表(已在配置中)、有效许可、申请、考试结果和审计日志。审计日志对问责至关重要。每次许可发放、暂停、吊销和恢复都记录时间戳、执行警官标识和原因。管理员命令应允许工作人员直接发放任何许可以应对活动,赦免事件期间清除所有暂停,批量过期特定类型许可以重置系统,并查看任何玩家的完整许可历史。在服务器网页仪表盘或 MDT 中构建管理员面板,提供按类型分类的有效许可统计、待处理申请、近期暂停和即将过期数据。这些数据帮助理解玩家如何使用系统,以及是否需调整费用和有效期以保持活跃度。
-- SQL schema
CREATE TABLE IF NOT EXISTS licenses (
id INT AUTO_INCREMENT PRIMARY KEY,
identifier VARCHAR(64) NOT NULL,
license_type VARCHAR(32) NOT NULL,
status ENUM('active','suspended','revoked','expired')
DEFAULT 'active',
issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NULL,
suspended_reason VARCHAR(255) DEFAULT NULL,
suspended_until TIMESTAMP NULL,
UNIQUE KEY unique_license (identifier, license_type),
INDEX idx_status (status),
INDEX idx_expires (expires_at)
);
CREATE TABLE IF NOT EXISTS license_applications (
id INT AUTO_INCREMENT PRIMARY KEY,
identifier VARCHAR(64) NOT NULL,
license_type VARCHAR(32) NOT NULL,
status ENUM('pending','passed','failed','cancelled')
DEFAULT 'pending',
exam_score INT DEFAULT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL
);
CREATE TABLE IF NOT EXISTS license_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
identifier VARCHAR(64) NOT NULL,
license_type VARCHAR(32) NOT NULL,
action VARCHAR(32) NOT NULL,
performed_by VARCHAR(64),
reason VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_identifier (identifier)
);