欢迎, 游客
您必须先 注册 才能在我们的网站上发帖.

用户名
  

密码
  





搜索论坛



(高级搜索)

论坛统计
» 会员总数: 32
» 新进会员: PhilipSox
» 主题总数: 35
» 帖子总数: 51

完整统计

在线用户
当前共有 3 位在线用户.
» 0 会员 | 3 游客

最新主题
怎么在脚本中设置对外显示的服务器信息
板块: Pawn 脚本
最后发表: siwode
07-31-2026, 11:54 PM
» 回复: 1
» 浏览: 27
SA-MP 行人与角色路径系统
板块: 插件
最后发表: siwode
07-05-2026, 12:25 PM
» 回复: 0
» 浏览: 116
西部角色扮演 Wild-West-Roleplay
板块: 游戏模式
最后发表: NuanSen
06-23-2026, 06:07 PM
» 回复: 1
» 浏览: 124
PawnREST - HTTP/S 文件传输与 R...
板块: 插件
最后发表: siwode
06-11-2026, 09:59 PM
» 回复: 0
» 浏览: 94
网页版 TextDraws在线编辑
板块: 发布
最后发表: siwode
06-11-2026, 09:39 PM
» 回复: 0
» 浏览: 63
[插件] kook-connect
板块: 插件
最后发表: siwode
06-08-2026, 11:04 AM
» 回复: 4
» 浏览: 403
openmp/samp联机服务器插件开发 完全指南
板块: 教程
最后发表: 柚子爱吃包子
04-09-2026, 09:14 PM
» 回复: 2
» 浏览: 460
圣安地列斯联机二十年:SA:MP 与 open.m...
板块: 综合讨论
最后发表: siwode
03-26-2026, 12:40 PM
» 回复: 3
» 浏览: 557
[考古] openmp 常见问题解答 | 2020...
板块: 综合讨论
最后发表: 小鸟unsigned
03-24-2026, 04:55 PM
» 回复: 0
» 浏览: 136
[考古]MTA 团队与 open.mp 团队早期就...
板块: 综合讨论
最后发表: 小鸟unsigned
03-24-2026, 04:53 PM
» 回复: 0
» 浏览: 126

 
  samp/openmp MySQL 数据库教程
发布者: 小鸟unsigned - 03-20-2026, 08:55 PM - 板块: 教程 - 暂无回复

samp/openmp MySQL 数据库教程


数据保存:读、查、改、删





引用:这篇教程只教你用代码操作数据库里的数据
不涉及登录注册系统,只讲最核心的数据库操作原理和用法。



第一章:数据库?



数据库在哪?

你可以把数据库当成一个 Excel 文件,它就在你电脑硬盘上。

这个 Excel 文件里有很多张表格,每张表格存某一类型的数据,比如玩家数据 载具数据 房屋数据:

代码:
数据库(openmp)
├── players 表        ← 存玩家数据
│   ├── id | name | gold | level | skin
│   ├──  1 | Tom  | 5000 |    10 |  287
│   └──  2 | Jane | 2000 |     5 |   86

├── houses 表         ← 存房产数据
│   └── ...

└── vehicle 表        ← 存载具数据
    └── ...

为什么不用 .ini 文件或者 .txt 文件存?

可以,但MySQL数据库的优势:
  • 高并发处理能力:不会阻塞主线程,支持非阻塞/异步查询
  • 速度快:性能强劲,毫秒级返回结果,不需要历遍查找数据
  • 安全:数据安全可靠,也可以设置定时备份数据
  • 灵活:可以使用SQL语句执行各种复杂的数据库操作
  • 维护管理:可视化工具可直接管理、修改、备份数据



第二章:为什么要连接数据库?



数据库是一个独立运行的软件(MySQL),它和你的 open.mp 服务端是两个程序。

就像你用 QQ 发消息,你要先登录QQ连接腾讯的服务器,才能使用它发消息。

你的 Pawn 脚本要操作数据库,也需要先建立连接,告诉数据库:
  • 我是谁(用户名/密码)
  • 我要操作哪个数据库

建立连接之后,你才能往里面读数据、写数据。



第三章:安装准备



3.1 在本地安装 MySQL



下载 MariaDB Server (80多mb)

下载地址:https://mariadb.org/download/

安装过程有两点需要注意

  • 在user settings步骤的时候需要你创建一个密码,这个密码你在后面连接的时候需要用到
  • 确认TCP port是3306

MariaDB 安装包自带 HeidiSQL,安装完成之后你的桌面会有一个快捷方式:HeidiSQL,这是一个可视化数据库管理工具,不会操作的新手,网上也有非常多的视频教程更加直观,我就不多说了

3.2 下载 MySQL R41-4 插件



下载地址:https://github.com/pBlueG/SA-MP-MySQL/re.../tag/R41-4

解压后,把文件按下表复制到服务端对应位置:

文件 复制到
plugins/mysql.dll 服务端 plugins/ 目录
libmariadb.dll 服务端根目录(和 omp-server.exe 同级)
log-core.dll 服务端根目录
pawno/include/a_mysql.inc 服务端 qawno/include/ 目录

3.3 在 config.json 里加载插件



代码:
"pawn": {
    "legacy_plugins": [
        "mysql"
    ]
}

启动服务器,控制台看到这行说明插件加载成功:

代码:
Loading plugin: mysql
>> plugin.mysql: R41-4 successfully loaded.



第四章:创建数据库并连接数据库



插件装好了,接下来在脚本里建立连接,首先你要先使用 HeidiSQL 创建一个名为 openmp 的数据库。

创建好之后,在pawn写以下代码

代码:
#include <open.mp>
#include <a_mysql>    // 引入 MySQL 插件头文件

// 全局变量:保存连接句柄
// 句柄相当于钥匙,后续所有操作都要带上它
new MySQL:g_SQLHandle;

public OnGameModeInit()
{
    // mysql_connect("数据库地址", "用户名", "密码", "数据库名")
    g_SQLHandle = mysql_connect("127.0.0.1", "root", "password", "openmp");
    //                              ↑ 127.0.0.1 = 本机,本地固定写这个

    // 检查连接是否成功
    if(g_SQLHandle == MYSQL_INVALID_HANDLE || mysql_errno(g_SQLHandle) != 0)
    {
        print("[MySQL] 连接失败!请检查 MySQL 是否运行。");

        // 连不上数据库就关服,避免带着错误运行
        // 这也是很多新手拿到开源图打开就闪退的原因
        // 因为大部分服务器都是使用MySQL作为数据库
        // 你没有正确搭建数据库环境,下面这行代码会自动关闭服务器
        SendRconCommand("exit");  
        return 1;
    }

    print("[MySQL] 数据库连接成功!");
    return 1;
}



第五章:创建数据表



连接成功后,需要在数据库里建一张表来存玩家数据。

什么是 SQL?

SQL 是操作数据库专用的语言,每一条 SQL 语句就是给数据库下一道命令,其实你会发现SQL语句就像是说人话一样,教程的最后会展示一些实用便利的SQL语句示例。

代码:
public OnGameModeInit()
{
    mysql_query(g_SQLHandle,
        "CREATE TABLE IF NOT EXISTS `players` (\
        `id`    INT          NOT NULL AUTO_INCREMENT,\
        `name`  VARCHAR(24)  NOT NULL UNIQUE,\
        `gold`  INT          DEFAULT 0,\
        `level` INT          DEFAULT 1,\
        `skin`  INT          DEFAULT 86,\
        PRIMARY KEY (`id`))",
        false
    );

    // 说明:
    // CREATE TABLE = 创建表
    // IF NOT EXISTS = 如果表不存在才创建,已存在就跳过
    // id    - 整数,不能为空,自动递增(每插入一行自动+1,相当于每行的编号)
    // name  - 最多24字符的字符串,不能为空,且不能重复(UNIQUE)
    // gold  - 整数,不填时默认值是 0
    // level - 整数,不填时默认值是 1
    // skin  - 整数,不填时默认值是 86
    // PRIMARY KEY(id) - 设 id 为主键,数据库用它来精确定位某一行

    print("[MySQL] 数据表就绪!");
    return 1;
}

执行完这段代码后,你的数据库里就有了一张这样结构的空表:

代码:
players 表
┌────┬──────┬──────┬───────┬──────┐
│ id │ name │ gold │ level │ skin │
├────┼──────┼──────┼───────┼──────┤
│    │      │      │       │      │
└────┴──────┴──────┴───────┴──────┘

常用列类型速查



类型 说明 适用场景
INT 整数 金币、等级、皮肤 ID
VARCHAR(n) 最多 n 个字符的字符串 玩家名字、称号
FLOAT 小数 坐标、血量
TEXT 长文本(无长度限制) 个人简介、日志
TINYINT(1) 0 或 1,常用作布尔值 是否是管理员、是否封禁

常用列约束速查



约束 说明
NOT NULL 不允许为空,插入时必须填这一列
DEFAULT 值 不填时使用的默认值
UNIQUE 该列的值在整张表里不能重复
AUTO_INCREMENT 自动递增,配合 INT 主键使用
PRIMARY KEY 主键,每行的唯一标识,一张表只能有一个

我需要背这些语句吗?



不需要,没有人会背这些,包括写了很多年代码的开发者,你只需要大概知道每种语句是干什么用的,要用的时候回来翻一下,或者直接百度搜 MySQL 创建表格语法,几秒钟就找到了,又或者直接可视化工具输出具体的SQL语句



第六章:同步 vs 异步(非常重要)



在讲增删改查之前,必须先理解这个概念,否则你会做出卡服务器的代码。

同步查询(mysql_query)—— 阻塞查询



代码:
mysql_query(g_SQLHandle, "SELECT * FROM players");
// 同步查询会让服务器等待数据库返回结果,期间其他代码暂停
// OnGameModeInit 是服务器刚启动时执行的,此时没有任何玩家在线
// 有玩家在线时永远不要使用阻塞查询,除非你真的知道你在干什么

就像你在餐厅点餐,服务员站在那里死等厨房做完,期间不接待任何其他桌。

引用:但新手不必害怕这个阻塞查询,阻塞过程可能才千分之一秒,也可能因特殊软件异常或你使用的是远程数据库导致的网络波动会持续更久,但安全意识很重要,只要记住在必要时仅在服务器初始化和启动时同步,其他地方异步即可

异步查询(mysql_tquery)—— 非阻塞查询



代码:
mysql_tquery(g_SQLHandle, "SELECT * FROM players", "我的回调函数", "i", playerid);
// 把查询任务交给后台线程,然后立刻执行其他代码
// 等查询完成后,会自动调用 "我的回调函数"

就像你在餐厅点完餐,服务员给你一个号码牌,你的菜好了他会来找你,服务员继续招待其他桌。



第七章:INSERT — 插入(新增一行数据)



场景:玩家第一次进入服务器,在数据库里创建一条新的记录。

SQL 语句解释



代码:
INSERT INTO `players` (`name`, `gold`, `skin`) VALUES ('Tom', 100, 86)
  • INSERT INTO = 向某张表插入数据
  • players = 表名
  • (name, gold, skin) = 要填写哪几列信息
  • VALUES (...) = 信息对应的值

id 不需要填,因为它是 AUTO_INCREMENT,数据库会自动分配。

在 Pawn 里写



代码:
InsertPlayer(playerid)
{
    new name[MAX_PLAYER_NAME];
    GetPlayerName(playerid, name, sizeof(name));

    // 使用 mysql_format 防止玩家名字里有单引号导致 SQL 出错(SQL 注入防护)
    // %e 是转义
    new query[128];
    mysql_format(g_SQLHandle, query, sizeof(query),
        "INSERT INTO `players` (`name`, `gold`, `skin`) VALUES ('%e', 50, 86)",
        name
    );

    // 插入完成后调用 OnPlayerInserted,传入 playerid 告诉回调是哪个玩家
    mysql_tquery(g_SQLHandle, query, "OnPlayerInserted", "i", playerid);

    printf("[MySQL] 玩家 %s 数据正在创建...", name);
    return 1;
}

// mysql_tquery 完成后自动调用这里
// mysql 会把sql语句的返回结果传入此回调中
forward OnPlayerInserted(playerid);
public OnPlayerInserted(playerid)
{
    if(!IsPlayerConnected(playerid)) return 0;

    // cache_insert_id() 返回刚刚插入的那行的 id
    new insertID = cache_insert_id();

    if(insertID > 0)
        printf("[MySQL] 玩家 %d 已创建,数据库 ID:%d", playerid, insertID);

    return 1;
}



第八章:SELECT — 查询(读取数据)



场景:玩家进入服务器,从数据库里读取他的金币、等级、皮肤。

SQL 语句解释



代码:
SELECT `gold`, `level`, `skin` FROM `players` WHERE `name` = 'Tom'
  • SELECT = 我要查询
  • gold, level, skin = 查这几列(用 * 代表查所有列)
  • FROM players = 从 players 表里查
  • WHERE name = 'Tom' = 条件:只要名字是 Tom 的那行

在 Pawn 里写



代码:
// 存玩家数据的全局数组
new g_Gold[MAX_PLAYERS];
new g_Level[MAX_PLAYERS];
new g_Skin[MAX_PLAYERS];

LoadPlayerData(playerid)
{
    new name[MAX_PLAYER_NAME];
    GetPlayerName(playerid, name, sizeof(name));

    new query[128];
    mysql_format(g_SQLHandle, query, sizeof(query),
        "SELECT `gold`, `level`, `skin` FROM `players` WHERE `name` = '%e' LIMIT 1",
        name
    );
    // LIMIT 1 = 表示限制只查1行,名字是唯一的所以这样写更高效
    mysql_tquery(g_SQLHandle, query, "OnPlayerDataLoaded", "is", playerid, name);
    return 1;
}

forward OnPlayerDataLoaded(playerid, const name[]);
public OnPlayerDataLoaded(playerid, const name[])
{
    if(!IsPlayerConnected(playerid)) return 0;

    // 查询结果有几行
    if(cache_num_rows() == 0)
    {
        // 没找到这个玩家,说明他是新玩家,去插入一条记录
        InsertPlayer(playerid);
        return 1;
    }

    // 从结果的第 0 行(第一行)读取数据,填入全局数组
    // cache_get_value_name_int(行号, "列名", 存到哪个变量)
    cache_get_value_name_int(0, "gold",  g_Gold[playerid]);
    cache_get_value_name_int(0, "level", g_Level[playerid]);
    cache_get_value_name_int(0, "skin",  g_Skin[playerid]);

    printf("[MySQL] 玩家 %s 数据加载完毕:金币=%d 等级=%d 皮肤=%d", name, g_Gold[playerid], g_Level[playerid], g_Skin[playerid]);

    // 数据读好了,让玩家出生
    SpawnPlayer(playerid);
    return 1;
}

读取不同类型数据的函数



函数 用途
cache_get_value_name_int(行, "列", 变量) 读取整数
cache_get_value_name_float(行, "列", 变量) 读取浮点数
cache_get_value_name(行, "列", 变量, 长度) 读取字符串



第九章:UPDATE — 修改(更新数据)



场景:玩家下线时,把他当前的金币、等级、皮肤写回数据库。

SQL 语句解释



代码:
UPDATE `players` SET `gold` = 500, `level` = 3 WHERE `name` = 'Tom'
  • UPDATE players = 更新 players 表
  • SET gold = 500, level = 3 = 把这些列设置成新的值
  • WHERE name = 'Tom' = 只改名字是 Tom 的那行

引用:WHERE 非常重要:如果不写 WHERE,整张表所有行都会被修改!

在 Pawn 里写



代码:
SavePlayerData(playerid)
{
    new name[MAX_PLAYER_NAME];
    GetPlayerName(playerid, name, sizeof(name));

    new query[256];
    mysql_format(g_SQLHandle, query, sizeof(query),
        "UPDATE `players` SET `gold` = %d, `level` = %d, `skin` = %d WHERE `name` = '%e'",
        g_Gold[playerid],
        g_Level[playerid],
        g_Skin[playerid],
        name
    );

    // 保存不需要读取回调结果,传空字符串 "" 表示不需要回调
    mysql_tquery(g_SQLHandle, query, "");

    printf("[MySQL] 玩家 %s 数据已保存", name);
}

// 玩家下线时调用
public OnPlayerDisconnect(playerid, reason)
{
    SavePlayerData(playerid);
    return 1;
}



第十章:DELETE — 删除(删除一行数据)



场景:把这个玩家的数据从数据库里彻底删除。

SQL 语句解释



代码:
DELETE FROM `players` WHERE `name` = 'Tom'
  • DELETE FROM players = 从 players 表里删除
  • WHERE name = 'Tom' = 只删名字是 Tom 的那行

引用:同样注意 WHERE:没有 WHERE 条件,整张表的数据全删光!

在 Pawn 里写



代码:
DeletePlayerData(playerid)
{
    new name[MAX_PLAYER_NAME];
    GetPlayerName(playerid, name, sizeof(name));

    new query[128];
    mysql_format(g_SQLHandle, query, sizeof(query),
        "DELETE FROM `players` WHERE `name` = '%e'",
        name
    );
    mysql_tquery(g_SQLHandle, query, "OnPlayerDataDelete", "s", name);

    
    return 1;
}

forward OnPlayerDataDelete(const name[]);
public OnPlayerDataDelete(const name[])
{
    // 如果影响的行数大于0
    if(cache_affected_rows() > 0)
        printf("[MySQL] 玩家 %s 的数据已删除", name);

    return 1;
}



第十一章:查询多行数据



前面的 SELECT 只查了一个玩家(一行),有时候你需要查多行,比如「排行榜」。

场景:查询金币最多的前 5 名玩家。

SQL 语句解释



代码:
SELECT `name`, `gold` FROM `players` ORDER BY `gold` DESC LIMIT 5
  • ORDER BY gold DESC = 按 gold 列降序排列(DESC = 从大到小,ASC = 从小到大)
  • LIMIT 5 = 只取前 5 行

在 Pawn 里写



代码:
ShowTopPlayers(playerid)
{
    mysql_tquery(g_SQLHandle,
        "SELECT `name`, `gold` FROM `players` ORDER BY `gold` DESC LIMIT 5",
        "OnTopPlayerLoaded",
        "i", playerid
    );
    return 1;
}

forward OnTopPlayerLoaded(playerid);
public OnTopPlayerLoaded(playerid)
{
    if(!IsPlayerConnected(playerid)) return 0;

    new rows = cache_num_rows();   // 获取返回了几行
    if(rows == 0)
    {
        SendClientMessage(playerid, -1, "[排行榜] 暂无数据");
        return 1;
    }

    SendClientMessage(playerid, 0xFFDD44FF, "=== 金币排行榜 ===");

    new name[MAX_PLAYER_NAME];
    new gold;
    
    // 遍历每一行结果
    for(new i = 0; i < rows; i++)
    {
        cache_get_value_name(i, "name", name, sizeof(name));
        cache_get_value_name_int(i, "gold", gold);

        SendClientMessage(playerid, 0xFFFFFFFF, "#%d %s — %d 金币", i + 1, name, gold);
    }
    return 1;
}

第十二章:异步的安全防护措施



这一章讲的问题在实际中极罕见发生,但一旦发生就会造成数据写错玩家,值得了解并防范。

问题是什么?



异步查询的特点是:你发出查询,然后去做别的事,等数据库回来再处理结果。

这中间有一段时间差,通常只有1毫秒(千分之一秒)不到。正常情况下没有任何问题。但考虑这个极端场景:

代码:
1. 玩家 海绵宝宝 连入服务器(ID = 5)
2. 发出 SELECT 非阻塞查询,去数据库读 海绵宝宝 的数据
3. 查询还没回来(1毫秒的空档)
4. 玩家 海绵宝宝 突然断线了,此时ID 5 空出来了
5. 另一个玩家 派大星 立刻连入,空出来 ID 5 就分配给了派大星
6. 数据库查询回来了,把 海绵宝宝 的数据写进了 ID 5 的数组
7. 结果:派大星 拿到了 海绵宝宝 的数据

这个概率极其罕见——需要同一个玩家槽位在毫秒级内被复用,并且又刚好卡在发出查询 -> 查询结果返回来 的这个过程内

但对于认真的服务器来说,防一下是好习惯。

解决思路:给每个玩家槽位标一个「版本号」



每次玩家连入或断开,版本号 +1。

查询发出时记录当前版本号,查询回来时检查版本号是否还一致——不一致说明这个槽位已经换人了,直接丢弃结果。
代码:
// 全局:每个槽位的版本号
new g_PlayerRace[MAX_PLAYERS];

public OnPlayerConnect(playerid)
{
    g_PlayerRace[playerid]++;   // 玩家连入,版本号 +1
    LoadPlayerData(playerid);
    return 1;
}

public OnPlayerDisconnect(playerid, reason)
{
    SavePlayerData(playerid);
    g_PlayerRace[playerid]++;   // 玩家断开,版本号 +1
    return 1;
}

发出查询时,把当前版本号一起传进回调参数:
代码:
LoadPlayerData(playerid)
{
    new name[MAX_PLAYER_NAME];
    GetPlayerName(playerid, name, sizeof(name));

    new query[128];
    mysql_format(g_SQLHandle, query, sizeof(query),
        "SELECT `gold`,`level`,`skin` FROM `players` WHERE `name`='%e' LIMIT 1",
        name
    );

    // 把 race 一起传入回调,格式 "ii" = 两个整数
    mysql_tquery(g_SQLHandle, query, "OnPlayerDataLoaded", "ii", playerid, g_PlayerRace[playerid]);
    return 1;
}

回调里第一件事就是核对版本号:
代码:
forward OnPlayerDataLoaded(playerid, race);
public OnPlayerDataLoaded(playerid, race)
{
    // 核对版本号
    // 不一致说明这个 ID 已经换了别的玩家,直接丢弃
    if(race != g_PlayerRace[playerid]) return 0;

    // 版本号一致,正常处理
    if(cache_num_rows() == 0)
    {
        InsertPlayer(playerid);
        return 1;
    }

    cache_get_value_name_int(0, "gold",  g_Gold[playerid]);
    cache_get_value_name_int(0, "level", g_Level[playerid]);
    cache_get_value_name_int(0, "skin",  g_Skin[playerid]);

    SpawnPlayer(playerid);
    return 1;
}



这几行代码做了什么


代码:
玩家 海绵宝宝 连入(ID=5)  → g_PlayerRace[5] = 1,查询携带 race=1 发出
玩家 海绵宝宝 断线          → g_PlayerRace[5] = 2
玩家 派大星 连入(ID=5)    → g_PlayerRace[5] = 3
查询回来,race 1 ≠ g_PlayerRace[5] 3  → 丢弃,派大星不受影响

养成习惯,所有玩家异步回调都加上这个检查



附录:MySQL R41-4 完整函数列表



本教程只覆盖了最核心的几个函数。R41-4 还提供了更多功能,全部函数的说明和用法可以在官方 Wiki 查阅:

https://github.com/pBlueG/SA-MP-MySQL/wiki

遇到具体需求时,去 Wiki 查一下,通常几分钟就能找到你需要的。

附录:MySQL 实用便利的查询语句示例



直接在数据库里做加减(不需要先读再写)
代码:
UPDATE `players` SET `gold` = `gold` + 100 WHERE `name` = 'Tom'

玩家下线时保存数据,但不确定他的记录存不存在,与其先 SELECT 判断再决定 INSERT 还是 UPDATE,不如一句搞定
代码:
INSERT INTO `players` (`name`, `gold`, `level`)
VALUES ('Tom', 100, 1)
ON DUPLICATE KEY UPDATE `gold` = VALUES(`gold`), `level` = VALUES(`level`)

LIMIT + OFFSET(分页)排行榜翻页时很有用,OFFSET 是跳过多少行,LIMIT 是取多少行:
代码:
-- 第一页(第 1-10 名)
SELECT `name`, `gold` FROM `players` ORDER BY `gold` DESC LIMIT 10 OFFSET 0

-- 第二页(第 11-20 名)
SELECT `name`, `gold` FROM `players` ORDER BY `gold` DESC LIMIT 10 OFFSET 10

统计服务器总注册人数、某个帮派的成员数等:
代码:
SELECT COUNT(*) AS `total` FROM `players`
SELECT COUNT(*) AS `total` FROM `players` WHERE `faction` = 3

SUM / MAX / MIN / AVG(计算相关语句)
代码:
-- 所有玩家总金币
SELECT SUM(`gold`) AS `total_gold` FROM `players`

-- 最高等级
SELECT MAX(`level`) AS `max_level` FROM `players`

-- 平均金币
SELECT AVG(`gold`) AS `avg_gold` FROM `players`

模糊搜索,管理员搜索玩家名字时很实用
代码:
SELECT `name`, `level` FROM `players` WHERE `name` LIKE '%Tom%'
-- % 是通配符,匹配任意字符
-- 'Tom%'  = 以 Tom 开头
-- '%Tom'  = 以 Tom 结尾
-- '%Tom%' = 包含 Tom

这里就不一一列举了,总之功能很强大很便利,这是文本保存带来不了的巨大优势


  零基础开发openmp/SAMP服务器基础教程 - 第三章(pawn新手入门终篇)
发布者: 小鸟unsigned - 03-20-2026, 05:12 PM - 板块: 教程 - 暂无回复

零基础开发openmp/SAMP服务器基础教程 - 第三章(pawn新手入门终篇)



引用:本篇是新手教程第二章的基础巩固部分,覆盖新手教程第二章中未涉及或一笔带过的语言细节。 
建议在读完新手教程第二章、能跑起来第一个脚本之后,再来阅读本篇。

老实说,Pawn 和 openmp/samp 能做的事,远比这新手教程三章展示的多得多。更复杂的数据结构、更多的回调和函数、插件生态、性能优化……这些东西没有讲,也不可能全部讲完。剩下的路,要靠你自己走

方法其实很简单:想做一个功能,就去查它需要什么函数;看到别人的代码,就去读懂它在做什么。 日积月累,你脑子里的「工具库」会越来越大,能做的事情也会越来越多。遇到看不懂的,查文档、搜索、问社区,这是每一个开发者每天都在做的事,你也不例外。

官方文档:https://open.mp/docs 
社区交流群:673335567

后续仍然会更新更多教程,但不是pawn新手教程系列



目录



                                                                                                                                                                                                                                                                                   
#章节
1关键字:变量声明修饰符
2const — 不可修改的变量
3static — 静态变量
4forward — 函数前向声明
5native — 原生函数声明
6enum — 枚举
7Tag(标签)— 类型系统
8编译指令 #define
9编译指令 #include
10编译指令 #if / #else / #endif
11编译指令 #pragma
12运算符补充:sizeof
13运算符补充:三目运算符 ?:
14运算符补充:位运算
15控制结构补充:do-while
16控制结构补充:break 与 continue
17switch 进阶:范围与列表匹配
18字符串函数
19玩家指令(Commands)
20sscanf — 解析指令参数
21printf 与 print — 控制台输出
22代码组织:#include 拆分文件


1. 关键字:变量声明修饰符



在 Pawn 里,声明变量和函数时可以在前面加上修饰关键字,控制它们的可见性和行为。

主教程只用了最基础的 new,这里把所有修饰符都介绍清楚:

                                                                                                                               
关键字用于作用
new变量声明一个普通变量(最常用)
const变量声明后不能被修改
static变量/函数限制作用域
stock函数/变量未使用时不报警告
public函数可被服务端直接调用(回调)
forward函数前向声明,让编译器预先知道函数存在
native函数声明由 C/C++ 实现的内置函数


2. const — 不可修改的变量



const 声明的变量在初始化后不能再被修改,编译器会在你试图修改时报错。

代码:
const MAX_LEVEL = 50;
// MAX_LEVEL = 60;  ← 编译报错!不能修改 const 变量

const 与 #define 的区别



代码:
#define MAX_A  50      // 编译时文本替换,不占内存,不带类型
const  MAX_B = 50;    // 占内存,有类型,编译器会检查类型

在 Pawn 里,绝大多数情况下用 #define 更普遍,const 更常见于:

代码:
// 函数参数中的 const 数组:告诉编译器这个数组在函数内部不会被修改
stock MyFunction(const string[])
{
    print(string);  // 只读,不修改
}



3. static — 静态变量



static 有两种用法:

用法一:局部 static 变量(函数内)



普通局部变量每次调用函数时都会重置为 0,而 static 局部变量只初始化一次,之后保留上次的值

代码:
// 普通局部变量:每次调用都从 0 开始
CountNormal()
{
    new i = 0;
    i++;
    printf("普通:%d", i);  // 永远输出 1
}
// static 局部变量:值会在调用之间保留
CountStatic()
{
    static iCount = 0;  // 只在第一次调用时初始化
    iCount++;
    printf("静态:%d", iCount);  // 第1次=1, 第2次=2, 第3次=3...
}

用法二:全局 static 变量(文件私有)



在函数外部声明的 static 全局变量,只能在本文件内访问,其他文件无法访问,同时还可以避免相同名称的变量冲突:

代码:
// 只有本 .pwn 文件才能访问这个变量
static g_Value;

引用:新手建议:入门阶段不用刻意区分,static 局部变量是最常用到的,记住「值会保留」这个特点就够了。


4. forward — 函数前向声明



Pawn 编译器从上到下读取代码。如果函数 A 调用了函数 B,但 B 写在 A 的后面,编译器就会报错说找不到 B。

forward 告诉编译器:「这个函数存在,后面会定义它」。

什么时候必须用 forward?



1. SetTimerEx 的目标函数(最常见)

代码:
// 必须在使用前 forward 声明
forward MyTimerFunc(playerid);
// ... 某处调用 ...
SetTimerEx("MyTimerFunc", 3000, false, "i", playerid);
// 函数的实际执行逻辑
public MyTimerFunc(playerid)
{
    SendClientMessage(playerid, -1, "3秒到了!");
    return 1;
}

2. 相互调用的函数

代码:
forward FuncB();  // 先声明 B
FuncA()
{
    FuncB();  // 调用 B,编译器已经知道 B 存在了
}
FuncB()
{
    // ...
}

引用:规则:只要是 public 函数且通过字符串名称调用的(比如 Timer),都必须 forward


5. native — 原生函数声明



native 声明的函数是由服务端或插件用 C/C++ 实现的函数,不是在 Pawn 里定义的。

#include <open.mp> 已经帮你声明好了所有内置的 native 函数,所以你平时直接调用 SendClientMessageCreateVehicle 这些,底层都是 native。

你通常不需要自己写 native 声明,除非你在使用额外的插件,或者想给自定义函数加上 Pawno 的自动提示:

代码:
// 声明一个插件提供的函数(例如 sscanf 插件)
native sscanf(const str[], const format[], ...);
// 利用 native 重命名内置函数(高级用法)
native old_print = print;
// 现在 print 不可用,改为用 old_print 调用

引用:新手结论:不需要手写 native,知道它是「C/C++ 实现的函数」就够了。


6. enum — 枚举



枚举是定义一组有名字的整数常量的方式,让代码更有可读性。

基础用法:替代一组 #define



代码:
// 不用枚举的写法(容易混乱)
#define CLASS_PEASANT  0
#define CLASS_KNIGHT    1
#define CLASS_MERCHANT  2
#define CLASS_FISHER    3
// 用枚举(更整洁)
enum E_CLASS
{
    CLASS_PEASANT,    // 自动赋值 0
    CLASS_KNIGHT,    // 自动赋值 1
    CLASS_MERCHANT,  // 自动赋值 2
    CLASS_FISHER      // 自动赋值 3
}
new g_PlayerClass[MAX_PLAYERS];
public OnPlayerSpawn(playerid)
{
    g_PlayerClass[playerid] = CLASS_KNIGHT;
    return 1;
}

高级用法:用枚举定义结构化数组(最强用法)



这是 SA-MP/open.mp 脚本中最常见的枚举用途,可以把多种数据整合在一个二维数组里:

代码:
// 定义玩家数据的"字段"
enum E_PLAYER_DATA
{
    PLAYER_GOLD,        // 金币
    PLAYER_LEVEL,      // 等级
    PLAYER_SKIN,        // 皮肤 ID
    PLAYER_KILLS,      // 击杀数
    PLAYER_DEATHS      // 死亡数
}
// 声明二维数组,第二维用枚举大小
new g_PlayerData[MAX_PLAYERS][E_PLAYER_DATA];
// 使用:用字段名代替数字下标,可读性极强
public OnPlayerConnect(playerid)
{
    g_PlayerData[playerid][PLAYER_GOLD]  = 50;
    g_PlayerData[playerid][PLAYER_LEVEL]  = 1;
    g_PlayerData[playerid][PLAYER_SKIN]  = 86;
    g_PlayerData[playerid][PLAYER_KILLS]  = 0;
    g_PlayerData[playerid][PLAYER_DEATHS] = 0;
    return 1;
}
// 读取
public OnPlayerDeath(playerid, killerid, reason)
{
    g_PlayerData[playerid][PLAYER_DEATHS]++;
    if(killerid != INVALID_PLAYER_ID)
    {
        g_PlayerData[killerid][PLAYER_KILLS]++;
        g_PlayerData[killerid][PLAYER_GOLD] += 10;  // 击杀奖励 10 金
    }
    return 1;
}

引用:这种写法比单独维护 g_Gold[]g_Level[]g_Skin[] 多个数组更整洁,是进阶脚本的标准写法。


7. Tag(标签)— 类型系统



Pawn 是「弱类型」语言,所有变量本质上都是 32 位整数。 
Tag(标签) 是 Pawn 对类型的模拟,让编译器在你混用不同「类型」时发出警告。

常见的内置 Tag



                                                                                               
Tag含义示例
(无 tag)普通整数new iScore = 0;
Float:浮点数new Float:fX = 0.0;
bool:布尔值new bool:bAlive = true;
Text:TextDraw IDnew Text:tdLogo;
PlayerText:玩家 TextDrawnew PlayerText:tdHUD;

Tag Mismatch 警告



代码:
new Float:health;
new score;
score = health;  // 警告:tag mismatch!把 Float 赋给整数

修复方法:用类型转换强制去除 tag:

代码:
score = _:health;  // _: 去除 tag,变成普通整数再赋值(不推荐随意用)
score = floatround(health);  // 正确做法:用转换函数

bool 类型



代码:
new bool:g_IsAdmin[MAX_PLAYERS];
public OnPlayerConnect(playerid)
{
    g_IsAdmin[playerid] = false;  // 用 true/false,不用 0/1
    return 1;
}
// 判断
if(g_IsAdmin[playerid])
{
    SendClientMessage(playerid, -1, "你是管理员!");
}



8. 编译指令 #define



#define预处理指令,在代码编译前进行纯文本替换。

基础用法:定义常量



代码:
#define MAX_GOLD    999
#define SKIN_KNIGHT 287

带参数的宏(函数式宏)



代码:
// 定义一个带参数的宏,%0 是第一个参数,%1 是第二个,以此类推
#define IsValidPlayer(%0) ((%0) >= 0 && (%0) < MAX_PLAYERS && IsPlayerConnected(%0))
// 使用:
if(IsValidPlayer(playerid))
{
    SendClientMessage(playerid, -1, "有效的玩家");
}
// 实际展开为:
// if(((playerid) >= 0 && (playerid) < MAX_PLAYERS && IsPlayerConnected(playerid)))

主教程用过的按键宏



代码:
// 这两个就是带参数的宏
#define PRESSED(%0)  (((newkeys) & (%0)) && !((oldkeys) & (%0)))
#define RELEASED(%0) (!((newkeys) & (%0)) && ((oldkeys) & (%0)))

注意事项



代码:
// 宏是纯文本替换,不是函数,没有类型检查,要小心
#define DOUBLE(%0)  %0 * 2
new result = DOUBLE(3 + 4);
// 展开为:3 + 4 * 2 = 11,不是 14!
// 正确写法:
#define DOUBLE(%0)  ((%0) * 2)  // 用括号包裹,避免运算符优先级问题



9. 编译指令 #include



#include 把另一个文件的内容插入到当前文件,就像把那个文件的代码复制过来一样。

两种写法



代码:
#include <open.mp>      // 尖括号:在系统 include 目录(qawno/include/)查找
#include "myfile"      // 引号:在当前目录查找

常见的 include 文件



代码:
#include <open.mp>      // open.mp 全量头文件(必须)
// 常用插件头文件
#include <sscanf2>      // 字符串解析(指令参数处理)
#include <Pawn.CMD>      // 指令系统插件
#include <streamer>      // 动态对象流送插件

自定义 .inc 文件



当脚本变大时,可以把部分代码拆分到 .inc 文件里:

代码:
// shop.inc 文件里写商店相关的函数
// 在主脚本里引入
#include "shop"

注意事项



如果文件格式是.inc可以直接写文件名,如果是其他格式比如 .pwn, .dat, txt 则需要写文件格式

比如: #include "myfile.pwn"



10. 编译指令 #if / #else / #endif



#if编译时的条件判断,决定某段代码要不要被编译进去。 
注意:这和运行时的 if 不同,#if 在编译阶段就生效。

常见用法:区分 GameMode 和 FilterScript



代码:
// 根据是否定义了 FILTERSCRIPT,决定编译哪个初始化函数
#if defined FILTERSCRIPT
    public OnFilterScriptInit()
    {
        print("FilterScript 加载!");
        return 1;
    }
#else
    public OnGameModeInit()
    {
        print("GameMode 加载!");
        return 1;
    }
#endif

用 #if 开关调试模式



代码:
// 在脚本顶部定义,需要调试时就定义它,发布时删掉
#define DEBUG_MODE
// 在代码中用 #if 检查
#if defined DEBUG_MODE
    print("[DEBUG] 服务器已以调试模式启动");
#endif
// 也可以在函数里用(但 #if 在函数内部也是编译时判断)
// 示例中如果没有定义 DEBUG_MODE,则 #if - #endif 内的代码等于不存在,不会有任何开销
public OnPlayerConnect(playerid)
{
    #if defined DEBUG_MODE
        new name[MAX_PLAYER_NAME];
        GetPlayerName(playerid, name, sizeof(name));
        printf("[DEBUG] 玩家连入: %s (ID: %d)", name, playerid);
    #endif
    return 1;
}

defined 运算符



代码:
// defined 检查某个符号是否被 #define 过
#if defined MY_CONSTANT
    // MY_CONSTANT 存在时编译这段
#else
    // 不存在时编译这段
#endif



11. 编译指令 #pragma



#pragma 给编译器传递特殊指令,控制编译行为。

最常用的几个



代码:
// 关闭 tab 缩进警告
#pragma tabsize 0
// 关闭某个编号的警告
#pragma warning disable 200  // 关闭某个编号的警告
// 标记某个变量为「可能不会被用到,不要报警告」
new value;
#pragma unused value



12. 运算符补充:sizeof



sizeof 返回数组的元素数量(不是字节数),是 Pawn 特有的运算符。

代码:
new arr[10];
printf("%d", sizeof(arr));  // 输出 10
new name[MAX_PLAYER_NAME];
printf("%d", sizeof(name));  // 输出 MAX_PLAYER_NAME 的值(24)

为什么要用 sizeof?



避免硬编码数组大小,让代码更安全:

代码:
new message[128];
// 不好的写法:手动填 128,万一以后改了数组大小容易忘记改这里
format(message, 128, "你好,%s", name);
// 好的写法:用 sizeof 自动获取
format(message, sizeof(message), "你好,%s", name);
// 以后就算把 128 改成 256,这里也不需要动

在函数参数里传 sizeof



代码:
GetPlayerName(playerid, name, sizeof(name));
//                                ↑ 告诉函数数组有多大,防止越界



13. 运算符补充:三目运算符 ?:



三目运算符是 if/else 的简写,适合简单的赋值判断:

基本语法



代码:
// 格式:条件 ? 成立时的值 : 不成立时的值
new result = (mygold > 100) ? 1 : 0;
// 等同于:
// if(mygold > 100) result = 1;
// else            result = 0;

实际应用



代码:
// 根据玩家是否在线,获取名字
new string[6];
format(string, sizeof(string), "%s", IsPlayerConnected(targetid) ? "在线" : "离线");
// 根据职业决定皮肤
SetPlayerSkin(playerid, ((g_PlayerClass[playerid] == CLASS_KNIGHT) ? 287 : 86));
// 嵌套三目
new levelName[16];
format(levelName, sizeof(levelName), "%s",
    level >= 10 ? "高级" :
    level >= 5  ? "中级" : "新手"
);



14. 运算符补充:位运算



位运算直接操作数字的二进制位,在 SA-MP/open.mp 中主要用于按键检测标志位

基础位运算符



                                                                                                               
运算符名称说明
&按位与两位都是 1 才得 1
\|按位或任意一位是 1 就得 1
^按位异或两位不同才得 1
~按位非取反
<<左移相当于乘以 2 的 n 次方
>>右移相当于除以 2 的 n 次方

在按键检测中的应用



按键状态是一个整数,每一个「二进制位」代表一个按键是否被按下:

代码:
public OnPlayerKeyStateChange(playerid, KEY:newkeys, KEY:oldkeys)
{
    // & 运算:检查某个按键位是否为 1(按下状态)
    if(newkeys & KEY_YES)
    {
        // KEY_YES 位是 1,说明 Y 键被按着
    }
    // PRESSED 宏就是用位运算实现的:
    // PRESSED(KEY_YES) 展开为:
    // ((newkeys & KEY_YES) && !(oldkeys & KEY_YES))
    // 意思是:现在按着 AND 之前没按 = 刚刚按下
    return 1;
}

用位运算存储多个布尔标志



代码:
// 用一个整数存储多个开关状态(节省内存)
#define FLAG_ADMIN    (1 << 0)  // 二进制 0001
#define FLAG_VIP      (1 << 1)  // 二进制 0010
#define FLAG_MUTED    (1 << 2)  // 二进制 0100
#define FLAG_JAILED  (1 << 3)  // 二进制 1000
new g_Flags[MAX_PLAYERS];
// 设置标志(用 | 打开某位)
g_Flags[playerid] |= FLAG_ADMIN;
// 清除标志(用 & 和 ~ 关闭某位)
g_Flags[playerid] &= ~FLAG_MUTED;
// 检查标志(用 & 检测某位)
if(g_Flags[playerid] & FLAG_ADMIN)
{
    SendClientMessage(playerid, -1, "你是管理员");
}



15. 控制结构补充:do-while



do-whilewhile 的变体,区别是先执行一次,再判断条件。 
即使条件一开始就是假的,循环体至少也会执行一次。

代码:
// while:先判断,可能一次都不执行
while(false)
{
    print("永远不会执行");
}
// do-while:先执行,再判断,至少执行一次
do
{
    print("至少执行一次!");
}
while(false);  // 注意:while 后面要加分号

实际应用



代码:
// 寻找一个空的数据槽位(至少要进去看一次)
new slot = 0;
do
{
    if(g_SlotFree[slot])
    {
        break;  // 找到了,跳出
    }
    slot++;
}
while(slot < MAX_SLOTS);

引用:新手提示do-while 在脚本里不太常用,了解即可。


16. 控制结构补充:break 与 continue



这两个关键字用于控制循环的流程。

break — 立即跳出循环



代码:
// 找到第一个死亡的玩家后停止循环
for(new i = 0; i < MAX_PLAYERS; i++)
{
    if(!IsPlayerConnected(i)) continue;  // 跳过未连接的(见下)
    new Float:health;
    GetPlayerHealth(i, health);
    if(health <= 0.0)
    {
        printf("玩家 %d 已死亡", i);
        break;  // 找到了就不用继续循环了
    }
}

continue — 跳过本次,进入下一轮



代码:
// 给所有在线玩家发消息,跳过管理员
for(new i = 0; i < MAX_PLAYERS; i++)
{
    if(!IsPlayerConnected(i)) continue;  // 没连接,跳过
    if(g_Flags[i] & FLAG_ADMIN) continue; // 是管理员,跳过
    SendClientMessage(i, -1, "非管理员消息");
}

在 switch 中不需要 break



引用:Pawn 的 switch 每个 case独立的,不会穿透到下一个 case,这与 C 语言不同,不需要写 break
代码:
// Pawn:不会穿透,不需要 break
switch(value)
{
    case 1: print("value 是 1");  // 执行后直接跳出 switch
    case 2: print("value 是 2");
    case 3: print("value 是 3");
}



17. switch 进阶:范围与列表匹配



Pawn 的 switch 比 C 语言更强大,支持范围列表匹配。

列表匹配(多个值合并成一个 case)



代码:
new level = 3;
switch(level)
{
    case 1, 2, 3:      // 匹配 1 或 2 或 3
    {
        SendClientMessage(playerid, -1, "初级段位");
    }
    case 4, 5, 6:      // 匹配 4 或 5 或 6
    {
        SendClientMessage(playerid, -1, "中级段位");
    }
    case 7, 8, 9, 10:
    {
        SendClientMessage(playerid, -1, "高级段位");
    }
}

范围匹配(连续区间)



代码:
switch(level)
{
    case 1 .. 3:    // 匹配 1 到 3(含两端)
    {
        SendClientMessage(playerid, -1, "初级段位");
    }
    case 4 .. 6:    // 匹配 4 到 6
    {
        SendClientMessage(playerid, -1, "中级段位");
    }
    case 7 .. 10:
    {
        SendClientMessage(playerid, -1, "高级段位");
    }
    default:
    {
        SendClientMessage(playerid, -1, "段位超出范围");
    }
}



18. 字符串函数



Pawn 内置了很多处理字符串的函数,这里列出脚本开发中最常用的几个。

strlen — 获取字符串长度



代码:
new name[MAX_PLAYER_NAME];
GetPlayerName(playerid, name, sizeof(name));
new len = strlen(name);
printf("名字长度:%d", len);
// 常用:检查输入是否为空
if(strlen(inputtext) == 0)
{
    SendClientMessage(playerid, -1, "输入不能为空!");
    return 1;
}

strcmp — 比较两个字符串



代码:
// strcmp(str1, str2, ignorecase, length)
// 返回 0 = 相同,非 0 = 不同
new input[32] = "hello";
if(strcmp(input, "hello", true) == 0)  // true = 忽略大小写
{
    print("字符串匹配!");
}
// 常用简写(检查是否不同)
if(strcmp(input, "admin"))
{
    // 不相同时执行(非 0 = true)
}

strcat — 拼接字符串



代码:
new result[24] = "Hello, ";
strcat(result, "World!");
// result 现在是 "Hello, World!"

strfind — 查找子字符串



代码:
new string[] = "Welcome to the server!";
new pos = strfind(string, "server", true);  // 返回位置下标,找不到返回 -1
if(pos != -1)
{
    printf("找到了 server,位置:%d", pos);  // 输出 15
}

字符串与数字互转



代码:
// 字符串 → 整数
new string[] = "123";
new number = strval(string);  // number = 123
// 整数 → 字符串(用 format)
new output[16];
format(output, sizeof(output), "%d", number);



19. 玩家指令(Commands)



玩家在聊天框输入 /指令名 时触发 OnPlayerCommandText 回调。 

方法一:直接用 strcmp(基础做法)



代码:
new g_Gold[MAX_PLAYERS] = {100, ...};
public OnPlayerCommandText(playerid, cmdtext[])
{
    // cmdtext 是玩家输入的完整内容,例如 "/heal"
    if(strcmp(cmdtext, "/heal", true) == 0)
    {
        SetPlayerHealth(playerid, 100.0);
        SendClientMessage(playerid, -1, "血量已恢复!");
        return 1;  // ← 一定要 return 1,表示指令已处理
    }
    if(strcmp(cmdtext, "/gold", true) == 0)
    {
        SendClientMessage(playerid, -1, "你现在有 %d 枚金币。", g_Gold[playerid]);
        return 1;
    }
    // 没有匹配的指令:return 0 让服务器显示"未知指令"提示
    return 0;
}

方法二:用 Pawn.CMD 插件(推荐做法)



Pawn.CMD 是一个流行的指令处理插件,语法更简洁,性能更好:

自行下载安装: https://github.com/katursis/Pawn.CMD

代码:
#include <Pawn.CMD>  // 需要安装 Pawn.CMD 插件
// Pawn.CMD 的写法:CMD:指令名(playerid, params[])
CMD:heal(playerid, params[])
{
    SetPlayerHealth(playerid, 100.0);
    SendClientMessage(playerid, -1, "血量已恢复!");
    return 1;
}
CMD:gold(playerid, params[])
{
    SendClientMessage(playerid, -1, "你现在有 %d 枚金币。", g_Gold[playerid]);
    return 1;
}

指令权限检查



代码:
CMD:kick(playerid, params[])
{
    // 只有管理员才能用
    if(!(g_Flags[playerid] & FLAG_ADMIN))
    {
        SendClientMessage(playerid, -1, "你没有权限使用此指令!");
        return 1;
    }
    // ... 踢人逻辑 ...
    return 1;
}



20. sscanf — 解析指令参数



当指令带参数时(例如 /kick 5/give 3 100),需要从字符串里把参数提取出来。 
sscanf 插件是处理这类问题的标准工具。

自行下载安装: https://github.com/Y-Less/sscanf

基础用法



代码:
// sscanf(输入字符串, 格式, 变量1, 变量2, ...)
// 返回 0 = 解析成功,非 0 = 解析失败(参数不够或格式不对)
CMD:give(playerid, params[])
{
    new targetID, amount;
    // 格式 "ii" = 两个整数
    if(sscanf(params, "ii", targetID, amount))
    {
        SendClientMessage(playerid, -1, "用法:/give [玩家ID] [金币数]");
        return 1;
    }
    if(!IsPlayerConnected(targetID))
    {
        SendClientMessage(playerid, -1, "该玩家不在线!");
        return 1;
    }
    g_Gold[targetID] += amount;
    SendClientMessage(playerid, -1, "向玩家 %d 赠送了 %d 枚金币。", targetID, amount);
    SendClientMessage(targetID, -1, "管理员赠送了 %d 枚金币给你。", amount);
    return 1;
}
CMD:skin(playerid, params[])
{
    new skinID;
    if(sscanf(params, "i", skinID))
    {
        SendClientMessage(playerid, -1, "用法:/skin [皮肤ID]");
        return 1;
    }
    SetPlayerSkin(playerid, skinID);
    g_Skin[playerid] = skinID;
    SendClientMessage(playerid, -1, "皮肤已更换为 %d。", skinID);
    return 1;
}

sscanf 格式说明符



                                                                                   
格式符含义
i整数
f浮点数
s字符串(到空格结束)
s[32]最大 32 字符的字符串
u玩家名字或 ID(自动解析)
p<分隔符>设置分隔符,如 p, 用逗号分隔

sscanf 相关文档说明



如何使用: https://github.com/Y-Less/sscanf?tab=readme-ov-file#use
格式说明符: https://github.com/Y-Less/sscanf?tab=rea...specifiers



21. printf 与 print — 控制台输出



这两个函数把内容输出到服务器控制台窗口,玩家看不到,只能在服务器后台终端看到。 
常用于调试和记录日志。

print — 输出纯文字



代码:
print("服务器启动了!");
print("[DEBUG] 这是一条调试信息");

printf — 带格式化输出



代码:
new playerCounts = 0;
for(new i = 0; i < MAX_PLAYERS; i++)
    if(IsPlayerConnected(i)) playerCounts++;
printf("[服务器] 当前在线玩家:%d", playerCounts);
printf("[DEBUG] 玩家 %d 的金币:%d", playerid, g_Gold[playerid]);

格式符与 format 相同



代码:
printf("整数:%d,浮点:%.2f,字符串:%s", 42, 3.14, "hello");
// 输出:整数:42,浮点:3.14,字符串:hello



22. 代码组织:#include 拆分文件



当你的脚本越来越大,所有代码都写在一个 .pwn 文件里会很难维护。

功能模块拆分成多个 .inc 文件,再在主文件里引入。

比如



代码:
gamemodes/
├── main.pwn            ← 主文件(只负责引入和框架)
└── include/
    ├── shop.inc        ← 商店系统
    ├── npc.inc        ← NPC 系统
    ├── vehicle.inc    ← 载具系统
    └── commands.inc    ← 所有指令

主文件写法



代码:
// mian.pwn
#include <open.mp>
// 引入各功能模块
#include "include/shop.inc"
#include "include/npc.inc"
#include "include/vehicle.inc"
#include "include/commands.inc"
// 主文件只写框架回调
public OnGameModeInit()
{
    InitShop();      // 调用 shop.inc 里的初始化函数
    InitNPCs();      // 调用 npc.inc 里的初始化函数
    InitVehicles();  // 调用 vehicle.inc 里的初始化函数
    return 1;
}

shop.inc 示例



代码:
// 注意:这里不需要再 #include <open.mp>,主文件已经引入了
// 商店相关的变量、函数、指令都写在这里
InitShop()
{
    // 创建商店相关的 Pickup 等
    return 1;
}

进阶:ALS Hook 系统(让 .inc 自己挂载回调)



Pawn 不允许同一个回调定义两次,也就是说OnGameModeInit OnPlayerDeath 等等这些回调只能有一个

当模块越来越多,每次写新模块都要回去主文件里的各个回调中加入新模块的功能,容易忘记、切不容易维护管理。

让每个 .inc 文件自己管理自己需要使用的回调,执行完自己的逻辑后再把控制权传给下一个模块,形成一条链。

原理


  1. 我先把 OnGameModeInit 重命名shop_OnGameModeInit(用 #define 宏替换)
  2. 然后我自己写一个新的 OnGameModeInit,在里面先执行我的逻辑,再调用 shop_OnGameModeInit
  3. 下一个 .inc(比如 npc.inc)接着同样操作,把我写的 OnGameModeInit 再重命名为 npc_OnGameModeInit,再写新的 OnGameModeInit...
  4. 最终形成一条调用链:OnGameModeInitnpc_OnGameModeInitshop_OnGameModeInit → 主文件的 return 1

代码:
// shop.inc 文件内部
new g_ShopPickup;
InitShop()
{
    g_ShopPickup = CreatePickup(1242, 2, 100.0, 200.0, 10.0, -1);
    return 1;
}
public OnGameModeInit()
{
    InitShop();
    #if defined shop_OnGameModeInit
        return shop_OnGameModeInit();
    #else
        return 1;
    #endif
}
#if defined _ALS_OnGameModeInit
    #undef OnGameModeInit
#else
    #define _ALS_OnGameModeInit
#endif
#define OnGameModeInit shop_OnGameModeInit
#if defined shop_OnGameModeInit
    forward shop_OnGameModeInit();
#endif

有了 ALS 之后,主文件什么都不用写


只需要#include进去,干净利落

代码:
#include <open.mp>
#include "include/shop.inc"   
#include "include/npc.inc"   
#include "include/vehicle.inc"
public OnGameModeInit()
{
    return 1;
}


  零基础开发openmp/SAMP服务器基础教程 - 第二章
发布者: 小鸟unsigned - 03-20-2026, 01:22 PM - 板块: 教程 - 暂无回复

零基础开发openmp/SAMP服务器基础教程 - 第二章



引用:本教程面向完全零基础的新手,每一节只讲一个知识点,循序渐进。
引用:本章节先以快速了解 Pawn 语言和 openmp 游戏实际应用为开端,让新手更快通过实际游戏表现获得反馈,以避免一开始就接触过多语法,建议阅读完本章节再开始第三章巩固进阶编程语言细节


目录



                                                                                                                                                                                                                                                                                                                       
#章节
1open.mp 是什么
2开启服务器
3第一个脚本
4注释
5变量
6define
7字符串与 format
8运算符
9if / else 判断
10switch 多分支
11for 循环
12while 循环
13函数
14数组
15回调函数(Callback)
16SendClientMessage — 发消息
17GameText — 屏幕大字
18TextDraw — 屏幕 UI
19Dialog — 对话框
20Pickup — 拾取物
21Checkpoint — 检查点
22Vehicle — 载具
23Actor — NPC
24Timer — 计时器
25Keys — 按键检测


1. open.mp 是什么



open.mp(open multiplayer)是一个全新的 侠盗猎车手:圣安地列斯 的多人游戏服务端,完全向下兼容 SA-MP

你可以用 Pawn 脚本语言 来编写游戏服务器的逻辑,比如:
  • 玩家加入时显示欢迎消息
  • 在地图上放置 NPC、载具、拾取物
  • 制作商店、任务、角色扮演系统

Pawn 是一门类似 C 语言的脚本语言,语法简单,非常适合入门。

引用:open.mp vs SA-MP:两者语法完全一样。open.mp 是活跃维护的现代版本。


2. 开启服务器



第一步:下载 open.mp 服务端



前往官网 https://www.open.mp 下载最新版本,解压到任意文件夹。

解压后你会看到这样的目录结构:

代码:
components: open.mp 核心组件
filterscripts: 服务器脚本文件(辅助脚本)
gamemodes: 服务器游戏模式文件(主图)
models: 服务器自定义模型(纹理 .txd .dff)
plugins: 服务器插件文件(传统插件)
qawno: Pawn 编辑器程序及包含文件
scriptfiles: INI 配置文件及其他资源
bans.json: 封禁列表文件
config.json: 服务器配置文件
omp-server.exe: open.mp 服务器主程序

第二步:配置 config.json



打开 config.json,找到 main_scripts 这一行,改成你的主图名称 mygamemode

根据需求设置端口 服务器名称 需要添加的插件 辅助脚本 修改高强度的rcon密码

代码:
{
    "name": "我的服务器",
    "network": {
        "port": 7777
    },
    "max_bots": 0,
    "max_players": 100,
    "pawn": {
        "main_scripts": [
            "mygamemode 1"
        ]
    },
    "rcon": {
        "password": "changeme1"
    },
}

关于config.json更多详情可查阅 https://open.mp/docs/server/config.json 但几乎大部分设置保持默认即可

第三步:打开 qawno 编辑器



双击 qawno/qawno.exe,这就是你写代码和编译脚本的地方。

引用:提示:推荐使用 VSCode,代码补全更强大。入门阶段用 qawno 就够了。


3. 第一个脚本



创建脚本文件



在 qawno 中,点击菜单 File → New Blank,然后输入以下代码:

代码:
#include <open.mp>

main(){}

public OnGameModeInit()
{
    SetGameModeText("我的第一个open.mp服务器");
    AddPlayerClass(1, 2495.3547, -1688.2319, 13.6774, 351.1646, WEAPON_M4, 500, WEAPON_KNIFE, 1, WEAPON_COLORT45, 100);
    AddStaticVehicle(522, 2493.7583, -1683.6482, 12.9099, 270.8069, -1, -1);
    return 1;
}

保存并编译



点击 File → Save,保存为 mygamemode.pwn,保存到 gamemodes/ 目录下。

然后按 F5 编译。

编译成功后,在同目录会生成 mygamemode.amx 文件。

启动服务器



双击 omp-server.exe,就成功启动服务器了。

代码解释



                                                                                   
代码含义
#include <open.mp>引入 open.mp 的所有函数,必须写在第一行
public OnGameModeInit()服务器启动时自动执行的函数
SetGameModeText("...")设置服务器模式名称(显示在服务器列表)
AddPlayerClass(...)在职业选择中添加一个职业 用于让玩家可以以自己选择的皮肤出生
AddStaticVehicle(...)在游戏模式中添加静态车辆(模型会预加载给玩家)
return 1;函数结束,返回 1 表示成功
引用:规则:每条语句末尾必须有 分号 ;,花括号 {} 表示代码块的开始和结束。


4. 注释



注释是写给人看的说明文字,编译器会完全忽略它,不影响程序运行。

写注释的好处是以后回看代码时会轻松很多。

单行注释



// 开头,从这里到行尾都是注释:

代码:
// 这是一行注释,不会被执行
SetGameModeText("我的第一个open.mp服务器");  // 也可以写在代码后面

多行注释



/* ... */ 包裹,可以跨越多行:

代码:
/*
    这里是多行注释。
    可以写很多行。
    常用于说明。
*/
public OnGameModeInit()
{
    return 1;
}

引用:建议:新手可以给每个函数写一行注释说明它的用途,给复杂逻辑写注释说明思路。


5. 变量



变量是用来存储数据的容器,就像一个贴了标签的盒子。

声明变量



new 关键字声明变量:

代码:
new myScore;         // 声明一个整数变量,初始值默认是 0
new myScore = 10;    // 声明并赋初始值

变量类型



整数(默认类型)

代码:
new myAge = 25;
new myGold = 100;
new myScore = 0;

浮点数(小数)

浮点数需要在变量名前加 Float: 标签:

代码:
new Float:myHealth = 100.0;
new Float:mySpeed  = 3.14;
new Float:myX      = 1234.5;   // 坐标通常是浮点数

变量命名规范



变量名只能包含字母、数字、下划线,且不能以数字开头,推荐:g_ 前缀表示全局变量

代码:
new g_Score = 0;
new g_Speed = 0.0;
new g_Name[32];

引用:这种前缀命名法不是强制要求,但能让代码更易读,虽然不是主流实践,但比较适用于pawn开发。

全局变量 vs 局部变量



代码:
// 全局变量:写在所有函数外面,整个脚本都能访问,g_ 前缀表示全局(global)
new g_PlayerScore[MAX_PLAYERS];

public OnPlayerConnect(playerid)
{
    // 局部变量:写在函数内部,只在这个函数里有效,驼峰命名法‌(camelCase):首词小写,后续大写
    new defaultScore = 50;
    g_PlayerScore[playerid] = defaultScore;
    return 1;
}



6. define



它本质上是一个文本替换符号,而不是一个真正的变量或常量,从使用效果看,它表现得像一个常量(运行过程中不能改变其数值),在编译时直接替换,不占用内存

常量常用于替代魔法数字,使代码更具可读性和可维护性,在这篇教程中我们暂时称它为常量。

定义常量



代码:
#define SERVER_NAME     "我的第一个open.mp服务器"
#define MAX_GOLD        999
#define START_GOLD      50
#define PI              3.14159

如何使用



代码:
public OnGameModeInit()
{
    SetGameModeText(SERVER_NAME);   // 等同于 SetGameModeText("我的第一个open.mp服务器");
    return 1;
}

public OnPlayerConnect(playerid)
{
    g_Gold[playerid] = START_GOLD;  // 等同于 g_Gold[playerid] = 50;
    return 1;
}

引用:规范:全部大写,单词间用下划线分隔,例如 MAX_PLAYERSSKIN_NORMAL


7. 字符串与 format



字符串就是一段文字。在 Pawn 中,字符串用字符数组存储。

声明字符串



代码:
new g_Name[32];                 // 最多存 31 个字符(留一位给结束符)
new g_Msg[128];                 // 消息通常用 128 或 256
new g_Name[MAX_PLAYER_NAME];   // 用内置常量,等同于 g_Name[24]

获取玩家名字



代码:
new g_Name[MAX_PLAYER_NAME];
GetPlayerName(playerid, g_Name, sizeof(g_Name)); // sizeof(g_Name) 自动计算数组大小,不需要手动填数字

format — 格式化字符串



format 可以把变量的值拼进字符串里,类似其他语言的字符串模板:

代码:
new string[128];
new name[MAX_PLAYER_NAME];
new gold = 100;

GetPlayerName(playerid, name, sizeof(name));

format(string, sizeof(string), "你好,%s!你有 %d 枚金币。", name, gold);
// 结果:"你好,CJ!你有 100 枚金币。"

格式占位符



                                                                               
占位符含义示例
%s字符串"Hello, %s"
%d整数"金币:%d"
%f浮点数"血量:%.1f"
%i整数(同 %d"等级:%i"
引用:%.1f 表示保留 1 位小数,%.2f 保留 2 位,以此类推。


8. 运算符



算术运算符



代码:
new a = 10;
new b = 3;

new add  = a + b;   // 13  加
new sub  = a - b;   // 7   减
new mul  = a * b;   // 30  乘
new div  = a / b;   // 3   除(整数除法,舍去小数)
new mod  = a % b;   // 1   取余(10 除以 3 余 1)

快捷赋值



代码:
new gold = 100;
gold += 50;   // 等同于 gold = 100 + 50  →  150
gold -= 20;   // 等同于 gold = 100 - 20  →  80
gold *= 2;    // 等同于 gold = 100 * 2   →  200
gold /= 4;    // 等同于 gold = 100 / 4   →  25

自增 / 自减



代码:
new i = 0;
i++;    // i 变成 1,等同于 i = i + 1
i--;    // i 变成 0,等同于 i = i - 1

比较运算符(返回真/假)



                                                                                   
运算符含义
==等于
!=不等于
>大于
<小于
>=大于等于
<=小于等于
代码:
new gold = 100;

if(gold == 100)  { /* 如果gold等于100 则成立 */ }
if(gold > 50)    { /* 如果gold大于50 则成立 */ }
if(gold != 0)    { /* 如果gold不等于0 则成立 */ }

引用:常见错误:判断用 ==(两个等号),赋值用 =(一个等号)。写成 if(gold = 100) 是赋值,不是判断!

逻辑运算符



                                                               
运算符含义示例
&&且(两个都成立)gold > 0 && isAlive
\|\|或(任意一个成立)isAdmin \|\| isVIP
!非(取反)!IsPlayerConnected(i)


9. if / else 判断



if / else 根据条件决定执行哪段代码。

基础用法



代码:
new gold = 80;

// 如果金币大于100
if(gold >= 100)
{
    SendClientMessage(playerid, 0xFFFF00FF, "金币充足,可以购买!");
}
// 否则
else
{
    SendClientMessage(playerid, 0xFF4444FF, "金币不足!");
}

if / else if / else



代码:
new myLevel = 5;

if(myLevel >= 10)
{
    SendClientMessage(playerid, -1, "你是高级玩家!");
}
else if(myLevel >= 5)
{
    SendClientMessage(playerid, -1, "你是中级玩家。");
}
else
{
    SendClientMessage(playerid, -1, "你是新手。");
}



10. switch 多分支



当你需要根据一个变量的多个具体值做不同处理时,switch 比一堆 else if 更清晰。

基础用法



代码:
new g_Class = 2;

switch(g_Class)
{
    case 0:
    {
        SendClientMessage(playerid, -1, "你选择了:农民");
        SetPlayerSkin(playerid, 86);
    }
    case 1:
    {
        SendClientMessage(playerid, -1, "你选择了:骑士");
        SetPlayerSkin(playerid, 287);
    }
    case 2:
    {
        SendClientMessage(playerid, -1, "你选择了:商人");
        SetPlayerSkin(playerid, 19);
    }
    default:
    {
        // 以上都不匹配时
        SendClientMessage(playerid, -1, "未知职业");
    }
}

实际应用:处理对话框响应



代码:
public OnDialogResponse(playerid, dialogid, response, listitem, inputtext[])
{
    if(dialogid == DIALOG_SHOP)
    {
        if(!response) return 1;   // 点了取消,不处理,等同于if(response == false)

        switch(listitem)
        {
            case 0: SetPlayerSkin(playerid, 86);   // 选择了对话框的第一个选项
            case 1: SetPlayerSkin(playerid, 287);  // 选择了对话框的第二个选项
            case 2: SetPlayerSkin(playerid, 19);   // 选择了对话框的第三个选项
        }
    }
    return 1;
}

引用:switch 中每个 case 后面的值必须是整数或常量,不能是字符串或浮点数。


11. for 循环



当你需要重复执行某段代码时,用循环。

for 循环适合已知重复次数的场景。

基础结构



代码:
//   初始值; 条件; 每次循环后执行
for(new i = 0; i < 5; i++)
{
    // 这里的代码会执行 5 次(i = 0, 1, 2, 3, 4)
    print("循环中...(已循环%d次)", i + 1);
}

遍历所有在线玩家




代码:
// 给所有在线玩家发送消息
for(new i = 0; i < MAX_PLAYERS; i++)
{
    if(IsPlayerNPC(i) == false && inGame[i] == true)
    {
        SendClientMessage(i, 0xFFFF00FF, "[公告] 比赛开始!");
        GivePlayerWeapon(i, WEAPON_AK47, 1500);
        SetPlayerPos(i, 0.0, 0.0, 2.5);
        // 给所有玩家加金币
        g_Gold[i] += 10;
    }
}

引用:但通常历遍玩家我们会选择foreach库,更高效。 
注意i < MAX_PLAYERS 不能写成 i <= MAX_PLAYERS,否则会越界。 
MAX_PLAYERS 是最大玩家数,有效的 playerid 是 0MAX_PLAYERS - 1


12. while 循环



while 循环适合不知道要循环多少次、只知道循环条件的场景。

基础结构



代码:
new i = 0;
while(i < 5)
{
    print("执行中...");
    i++;     // 一定要记得让条件向终止靠近,否则会导致无限死循环!
}

while vs for



                                   
场景推荐
固定次数的重复(如遍历所有玩家)for
条件不满足就一直循环(如等待某个状态)while
引用:警告:循环体内一定要有让条件变化的语句,否则会进入死循环,导致服务器卡死。


13. 函数



函数是把一段代码打包起来命名,方便重复调用,避免重复写同样的代码。

定义函数



代码:
// 函数名(参数列表)
// {
//     代码内容
//     return 返回值;
// }

GiveGold(playerid, amount)
{
    g_Gold[playerid] += amount;
    SendClientMessage(playerid, 0xFFDD44FF, "你获得了金币!");
    return 1;
}

调用函数



代码:
public OnPlayerConnect(playerid)
{
    GiveGold(playerid, 50);    // 玩家连入时给 50 金币
    return 1;
}

带特定返回值的函数



代码:
// 检查玩家金币是否足够,返回 true(足够)或 false(不足)
bool:HasEnoughGold(playerid, cost)
{
    if(g_Gold[playerid] >= cost)
    {
        return true;   // 足够
    }
    return false;       // 不足
}

// 使用:
if(HasEnoughGold(playerid, 100) == true)
{
    SendClientMessage(playerid, -1, "可以购买!");
}

不需要带任何返回值的函数



代码:
void:ResetGold(playerid)
{
    g_Gold[playerid] = 0;
    SendClientMessage(playerid, 0xFFDD44FF, "你的金币被清空了");
}

stock 函数



在 Pawn 中,如果你定义了函数但某些情况下没用到,编译器会发出警告。 
加上 stock 关键字可以消除这个警告:

代码:
stock GetPlayerGold(playerid)
{
    return g_Gold[playerid];
}

引用:原则:把重复用到 3 次以上的代码封装成函数。函数名需要清晰描述它做什么,例如 GiveGoldShowShopKickPlayer


14. 数组



数组是一组同类型数据的集合,用一个名字访问多个值。

声明数组



代码:
new g_Scores[10];           // 存 10 个整数
new Float:g_Positions[3];   // 存 3 个浮点数(X, Y, Z)
new g_Name[MAX_PLAYER_NAME]; // 字符串本质也是字符数组

访问数组元素



数组下标从 0 开始:

代码:
new g_Scores[5];

g_Scores[0] = 100;   // 第 1 个元素
g_Scores[1] = 85;    // 第 2 个元素
g_Scores[4] = 60;    // 第 5 个(最后一个)
// g_Scores[5]  ← 越界!不能访问,会报错

最常见:每个玩家存一份数据



代码:
// 全局声明:每个玩家 ID 对应一个值
new g_Gold[MAX_PLAYERS];
new g_Level[MAX_PLAYERS];
new g_Skin[MAX_PLAYERS];

// 使用 playerid 作为下标访问对应玩家的数据
public OnPlayerConnect(playerid)
{
    g_Gold[playerid]  = 50;
    g_Level[playerid] = 1;
    g_Skin[playerid]  = 86;
    return 1;
}

二维数组



代码:
// 存 5 个检查点的坐标(每个坐标有 X、Y、Z 三个值)
new Float:g_CpPos[5][3] = {
    {100.0, 200.0, 10.0},   // 检查点 0
    {150.0, 220.0, 10.0},   // 检查点 1
    {200.0, 240.0, 10.0},   // 检查点 2
    {250.0, 260.0, 10.0},   // 检查点 3
    {300.0, 280.0, 10.0}    // 检查点 4
};

// 访问检查点 2 的 Y 坐标:
new Float:y = g_CpPos[2][1];   // 240.0



15. 回调函数(Callback)



回调函数是 open.mp 服务端自动调用的特殊函数。

你不需要手动调用它们,只需要在脚本里定义它们,当对应事件发生时,服务端会自动执行。

规律



所有回调函数都以 public On 开头:

代码:
public OnGameModeInit()      // 服务器启动时
public OnGameModeExit()      // 服务器关闭时
public OnPlayerConnect()     // 玩家进入服务器时
public OnPlayerDisconnect()  // 玩家离开服务器时
public OnPlayerSpawn()       // 玩家出生时
public OnPlayerDeath()       // 玩家死亡时
public OnPlayerText()        // 玩家发送聊天消息时

示例:完整的玩家连入/断开流程



代码:
public OnPlayerConnect(playerid)
{
    new name[MAX_PLAYER_NAME];
    GetPlayerName(playerid, name, sizeof(name));

    SendClientMessageToAll(0x88FF88FF, "[服务器] %s 加入了游戏", name);
    return 1;
}

public OnPlayerDisconnect(playerid, reason)
{
    // reason: 0=超时  1=主动退出  2=被踢
    new name[MAX_PLAYER_NAME];
    GetPlayerName(playerid, name, sizeof(name));

    SendClientMessageToAll(0xAAAAAAFF, "[服务器] %s 离开了游戏", name);
    return 1;
}

引用:返回值:大多数回调函数返回 1 表示正常处理。返回 0 在某些回调里有特殊含义(如阻止某些行为)


16. SendClientMessage — 发消息



SendClientMessage 是向玩家的聊天框发送消息,是最基础的通知手段。

引用:注意SendClientMessage 支持中文,不支持 emoji 表情符号。

语法



代码:
SendClientMessage(playerid, color, "消息内容");

颜色格式(RGBA)



颜色用 8 位十六进制数表示:0xRRGGBBAA

                                                           
参数含义
RR红色分量(00~FF)
GG绿色分量(00~FF)
BB蓝色分量(00~FF)
AA透明度(FF = 完全不透明)
代码:
SendClientMessage(playerid, 0xFFFFFFFF, "白色消息");
SendClientMessage(playerid, 0xFF4444FF, "红色消息");
SendClientMessage(playerid, 0x44FF44FF, "绿色消息");
SendClientMessage(playerid, 0xFFDD44FF, "金黄色消息");
SendClientMessage(playerid, -1,         "也是白色(-1 是快捷写法)");

颜色常量(推荐定义)



代码:
// 在脚本顶部定义常用颜色
#define COLOR_WHITE   0xFFFFFFFF
#define COLOR_RED     0xFF4444FF
#define COLOR_GREEN   0x44FF44FF
#define COLOR_YELLOW  0xFFDD44FF
#define COLOR_GRAY    0xAAAAAAFF

// 使用时更直观
SendClientMessage(playerid, COLOR_GREEN, "[商店] 购买成功!");
SendClientMessage(playerid, COLOR_RED,   "[商店] 金币不足!");

发给所有玩家



代码:
SendClientMessageToAll(COLOR_YELLOW, "[公告] 服务器将在 5 分钟后重启。");

在消息里嵌入变量



代码:
SendClientMessage(playerid, COLOR_GREEN, "[商店] 购买成功!剩余金币:%d 枚", g_Gold[playerid]);



17. GameText — 屏幕大字



GameText 在玩家屏幕显示文字,会自动消失。 
适合用于:重要事件提示、区域名称、倒计时。

引用:注意:GameText 不支持中文,只能写英文和符号。

语法



代码:
GameTextForPlayer(playerid, "文字内容", 持续毫秒, 样式);
GameTextForAll("文字内容", 持续毫秒, 样式);   // 对所有玩家显示

颜色标签



在文字中插入颜色标签改变颜色:

代码:
~w~  白色      ~r~  红色      ~g~  绿色
~b~  蓝色      ~y~  黄色      ~p~  紫色
~n~  换行      ~h~  加亮

样式速查



https://open.mp/docs/scripting/resources/gametextstyles

示例



代码:
// 进入区域时提示
GameTextForPlayer(playerid, "~y~Town Market", 3000, 3);

// 组合颜色与换行
GameTextForPlayer(playerid, "~g~Quest Complete!~n~~w~Reward: 100 Gold", 4000, 3);

// 全服公告
GameTextForAll("~r~Pirates~w~ are attacking!", 5000, 3);



18. TextDraw — 屏幕 UI



TextDraw 是固定显示在屏幕上的文字,不像 GameText 那样会消失。 
常用于:服务器 Logo、血量 HUD、分数显示。

详细资料



https://open.mp/docs/scripting/resources/TextDraws

创建一个右上角 Logo



第一步:声明变量

代码:
new Text:g_MyText;   // Text: 是 TextDraw 的类型标签

第二步:在 OnGameModeInit 里创建

代码:
public OnGameModeInit()
{
    // x, y坐标是基于640x480画布(与屏幕分辨率无关)
    g_MyText = TextDrawCreate(240.0, 580.0, "Welcome to my OPEN.MP server");
    return 1;
}

第三步:在 OnPlayerSpawn 里显示给玩家

代码:
public OnPlayerSpawn(playerid)
{
    TextDrawShowForPlayer(playerid, g_MyText);
    return 1;
}

玩家断开时隐藏

代码:
public OnPlayerDisconnect(playerid, reason)
{
    TextDrawHideForPlayer(playerid, g_MyText);
    return 1;
}

字体样式说明



https://open.mp/docs/scripting/functions/TextDrawFont



19. Dialog — 对话框



Dialog 是弹出在玩家屏幕上的交互窗口,是最核心的 UI 交互方式。

对话框类型



https://open.mp/docs/scripting/resources/dialogstyles

第一步:定义对话框 ID



代码:
// 每个对话框需要一个唯一 ID,用常量管理
#define DIALOG_NULL 0
#define DIALOG_LOGIN 1
#define DIALOG_WELCOME 2
#define DIALOG_WEAPONS 3

代码:
// 你也可以用枚举,他会自动分配ID,第一个是0 后面自动递增
enum {
    DIALOG_NULL,        // 0
    DIALOG_LOGIN,       // 1
    DIALOG_WELCOME,     // 2
    DIALOG_WEAPONS      // 3
}

第二步:显示对话框



代码:
// ShowPlayerDialog(玩家ID, 对话框ID, 样式, 标题, 内容, 按钮1, 按钮2)
// 对话框样式示例: DIALOG_STYLE_MSGBOX:
ShowPlayerDialog(playerid, DIALOG_WELCOME, DIALOG_STYLE_MSGBOX, "提示", "你已进入服务器", "关闭", "");

// 对话框样式示例: DIALOG_STYLE_INPUT:
ShowPlayerDialog(playerid, DIALOG_LOGIN, DIALOG_STYLE_INPUT, "登录", "请在下方输入密码登陆服务器:", "登录", "取消");

// 对话框样式示例: DIALOG_STYLE_LIST:
ShowPlayerDialog(playerid, DIALOG_WEAPONS, DIALOG_STYLE_LIST, "武器", "AK47\nM4\n狙击枪", "选择", "关闭");

// 对话框样式示例: DIALOG_STYLE_PASSWORD:
ShowPlayerDialog(playerid, DIALOG_LOGIN, DIALOG_STYLE_PASSWORD, "登录", "请在下方输入密码登陆服务器:", "登录", "取消");

// 对话框样式示例: DIALOG_STYLE_TABLIST:
ShowPlayerDialog(playerid, DIALOG_WEAPONS, DIALOG_STYLE_TABLIST, "武器商店", "沙鹰\t$5000\t100\n短管散弹枪\t$5000\t100\n手枪\t$1000\t50", "购买", "取消");

// 对话框样式示例: DIALOG_STYLE_TABLIST_HEADERS:
ShowPlayerDialog(playerid, DIALOG_WEAPONS, DIALOG_STYLE_TABLIST_HEADERS, "武器商店", "武器\t价格\t弹药数量\n沙鹰\t$5000\t100\n短管散弹枪\t$5000\t100\n手枪\t$1000\t50", "购买", "取消");

引用:\n 是换行符,在 DIALOG_STYLE_LIST 里每个 \n 分隔一个选项。

第三步:处理玩家的响应



代码:
public OnDialogResponse(playerid, dialogid, response, listitem, inputtext[])
{
    // response  = 1 点了左边按钮
    //           = 0 点了右边按钮或按了 ESC
    // listitem  = 玩家选的是第几行(从 0 开始)
    // inputtext = 玩家输入的文字

    if (dialogid == DIALOG_RULES)
    {
        if (response) // 如果玩家点击了左边的按钮
        {
            SendClientMessage(playerid, COLOR_GREEN, "你点击了左边的按钮!");
        }
        else // 否则(玩家点击了右边的按钮或按下ESC键)
        {
            SendClientMessage(playerid, COLOR_GREEN, "你点击了右边的按钮!");
        }
        return 1; // 返回停止后续代码的执行
    }
    return 0;
}



20. Pickup — 拾取物



Pickup 是放在地图上的可触发拾取物,玩家走触碰到之后会自动触发,不需要按键。 
常用于:金币、道具、任务触发点、商店入口。

创建 Pickup



代码:
new g_pickupGold;   // 保存 pickup 的 ID,响应时用来判断

public OnGameModeInit()
{
    // CreatePickup(模型ID, 类型, X, Y, Z, 虚拟世界)
    // 类型 2 = 拾取后消失,15 秒后重生
    g_pickupGold = CreatePickup(1274, 2, 2009.8658, 1220.0293, 10.8206, -1);
    //                          ↑模型 ↑类型   ↑坐标                     ↑-1=所有世界可见

    return 1;
}

常用模型 ID



                                                                       
模型 ID外观
1210公文包
1212金钱
1239信息
1240爱心
1273绿色房子
更多可查阅: https://open.mp/docs/scripting/resources/pickupids

响应拾取事件



代码:
public OnPlayerPickUpPickup(playerid, pickupid)
{
    if(pickupid == g_pickupGold)
    {
        g_Gold[playerid] += 20;
        SendClientMessage(playerid, COLOR_YELLOW, "捡到了金币!+20");
        GameTextForPlayer(playerid, "~y~+20 Gold", 1500, 5);
    }
    return 1;
}

Pickup 类型说明



                                               
类型值行为
0无特殊属性,不可被拾取
1永久存在 仅允许脚本控制
14仅限车辆拾取,触发检查点音效后消失
更多类型可查阅: https://open.mp/docs/scripting/resources/pickuptypes



21. Checkpoint — 检查点



Checkpoint 是地图上的红色发光圆柱区域,玩家走进去自动触发。 
常用于:任务目标点、区域进入触发、引导新玩家,每个玩家最多只能显示一个检查点。

当玩家进入检查点时,会调用 OnPlayerEnterCheckpoint 回调

设置检查点



代码:
// 每个玩家同时只能有一个活动的 Checkpoint
// SetPlayerCheckpoint(玩家ID, X, Y, Z, 检查点尺寸半径)
SetPlayerCheckpoint(playerid, 1982.6150, -220.6680, -0.2432, 3.0);

响应进入事件



代码:
public OnPlayerEnterCheckpoint(playerid)
{
    SendClientMessage(playerid, COLOR_GREEN, "到达目标地点!");

    // 触发后通常需要清除,避免反复触发
    DisablePlayerCheckpoint(playerid);

    // 在这里做你想做的事,比如给与玩家金钱
    GivePlayerMoney(playerid, 1000);

    return 1;
}

RaceCheckpoint — 比赛检查点



当玩家进入时,会调用 OnPlayerEnterRaceCheckPoint 回调

代码:
// SetPlayerRaceCheckpoint(玩家ID, 类型, 当前X,Y,Z, 下一目标X,Y,Z, 半径)
SetPlayerRaceCheckpoint(playerid, CP_TYPE_GROUND_NORMAL, 644.3091, 1767.0223, 4.9970, 650.6734, 1812.0367, 4.9970, 3.0);

public OnPlayerEnterRaceCheckpoint(playerid)
{
    DisablePlayerRaceCheckpoint(playerid);
    SendClientMessage(playerid, COLOR_GREEN, "到达!");
    return 1;
}



22. Vehicle — 载具



创建载具



代码:
// CreateVehicle(模型, X, Y, Z, 角度, 颜色1, 颜色2, 重生延迟秒)
new vehicleid = CreateVehicle(411, 100.0, 200.0, 10.0, 90.0, -1, -1, 60);
// -1 = 随机颜色,60 = 无人使用 60 秒后重生

让玩家进入载具



代码:
// PutPlayerInVehicle(玩家ID, 载具ID, 座位)
// 座位 0 = 驾驶座,1 = 副驾驶,2/3 = 后排
PutPlayerInVehicle(playerid, vehicleid, 0);

载具模型 ID



请查阅: https://open.mp/docs/scripting/resources/vehicleid

进出载具回调



代码:
public OnPlayerEnterVehicle(playerid, vehicleid, ispassenger)
{
    // ispassenger: 0 = 驾驶座,1 = 乘客
    if(!ispassenger)
    {
        SendClientMessage(playerid, COLOR_WHITE, "你正在进入载具的驾驶位。");
    }
    return 1;
}

public OnPlayerExitVehicle(playerid, vehicleid)
{
    SendClientMessage(playerid, COLOR_WHITE, "你正在离开载具");
    return 1;
}



23. Actor — NPC



Actor 是站在世界里的静态 NPC 角色,可以设置外观和动作动画,但不会移动,但功能有限

创建 Actor



代码:
new g_ActorSmith;   // 保存 actor ID

public OnGameModeInit()
{
    // CreateActor(皮肤ID, X, Y, Z, 朝向角度)
    g_ActorSmith = CreateActor(179, 316.1, -134.0, 999.6, 90.0);

    return 1;
}

给 Actor 播放动画



代码:
// ApplyActorAnimation(actorID, 动画库, 动画名, 速度, 循环, lockX, lockY, 冻结, 时长)
ApplyActorAnimation(g_ActorSmith, "DEALER", "shop_pay", 4.1, false, false, false, false, 0);

玩家靠近时按键交互



代码:
// 工具函数:计算玩家到 Actor 的距离
stock Float:DistToActor(playerid, actorid)
{
    new Float:px, Float:py, Float:pz;
    new Float:ax, Float:ay, Float:az;
    GetPlayerPos(playerid, px, py, pz);
    GetActorPos(actorid, ax, ay, az);
    return floatsqroot((px-ax)*(px-ax)+(py-ay)*(py-ay)+(pz-az)*(pz-az));
}

// 在按键回调里检测距离并触发对话
public OnPlayerKeyStateChange(playerid, KEY:newkeys, KEY:oldkeys)
{
    if(PRESSED(KEY_YES))
    {
        if(DistToActor(playerid, g_ActorSmith) < 3.0)
        {
            ShowPlayerDialog(playerid, DIALOG_SMITH, DIALOG_STYLE_MSGBOX,
                "[ 武器商人 ]",
                "欢迎!我能为你提供精良的武器。",
                "好的", ""
            );
        }
    }
    return 1;
}

服务器关闭时清理



代码:
public OnGameModeExit()
{
    DestroyActor(g_ActorSmith);
    return 1;
}



24. Timer — 计时器



Timer 用于延迟执行定期执行某个函数,是实现游戏事件、冷却时间、倒计时的基础工具。

创建计时器



代码:
// 一次性:2000 毫秒(2 秒)后执行,false = 不重复
SetTimer("MyFunction", 2000, false);

// 重复:每 5000 毫秒(5 秒)执行一次,true = 重复
new g_Timer = SetTimer("MyRepeatFunc", 5000, true);

// 停止计时器(需要提前保存它的 ID)
KillTimer(g_Timer);

带参数的计时器



代码:
// SetTimerEx("函数名", 间隔ms, 是否重复, "参数格式", 参数值...)
SetTimerEx("SendDelayedMsg", 3000, false, "i", playerid);

// 对应函数必须先用 forward 声明
forward SendDelayedMsg(playerid);
public SendDelayedMsg(playerid)
{
    if(!IsPlayerConnected(playerid)) return 0;
    GameTextForPlayer(playerid, "~w~Welcome to~n~~y~Island Town!", 3000, 3);
    return 1;
}

参数格式字符



                                               
格式符含义
i整数(int)
f浮点数(float)
s字符串

实际应用:每 60 秒播报随机事件



代码:
new g_EventTimer;

public OnGameModeInit()
{
    g_EventTimer = SetTimer("IslandEvent", 60000, true);
    return 1;
}

public OnGameModeExit()
{
    KillTimer(g_EventTimer);   // 服务器关闭时记得停止
    return 1;
}

forward IslandEvent();
public IslandEvent()
{
    switch(random(3))
    {
        case 0: SendClientMessageToAll(COLOR_ORANGE, "[小镇] 市集开放!来广场逛逛。");
        case 1: SendClientMessageToAll(COLOR_RED,    "[小镇] 海湾发现海盗船!");
        case 2: SendClientMessageToAll(COLOR_GREEN,  "[小镇] 今日好天气,渔获翻倍!");
    }
    return 1;
}

引用:注意:使用 SetTimerEx 的函数必须在使用前加 forward 声明,否则编译会报错或运行异常。


25. Keys — 按键检测



OnPlayerKeyStateChange 在玩家按下或松开按键时触发,用于实现交互操作。

官方文档: https://open.mp/docs/scripting/callbacks...tateChange

基础宏定义



代码:
// 在脚本顶部定义这两个宏,后面判断按键时更方便
#define PRESSED(%0)  (((newkeys) & (%0)) && !((oldkeys) & (%0)))
#define RELEASED(%0) (!((newkeys) & (%0)) && ((oldkeys) & (%0)))

基础用法



代码:
public OnPlayerKeyStateChange(playerid, KEY:newkeys, KEY:oldkeys)
{
    // PRESSED = 刚按下(本帧按着,上帧没按)
    if(PRESSED(KEY_FIRE))
    {
        SendClientMessage(playerid, COLOR_WHITE, "你按了鼠标左键");
    }

    // RELEASED = 刚松开
    if(RELEASED(KEY_FIRE))
    {
        SendClientMessage(playerid, COLOR_WHITE, "你松开了鼠标左键");
    }

    return 1;
}

按键常量



请查阅: https://open.mp/docs/scripting/resources/keys

完整交互示例:按 Y键 与 NPC 对话



代码:
#define PRESSED(%0) (((newkeys) & (%0)) && !((oldkeys) & (%0)))

public OnPlayerKeyStateChange(playerid, newkeys, oldkeys)
{
    if(PRESSED(KEY_YES))
    {
        // 检查玩家是否靠近铁匠 NPC
        if(DistToActor(playerid, g_ActorSmith) < 3.0)
        {
            ShowPlayerDialog(playerid, DIALOG_SMITH, DIALOG_STYLE_MSGBOX,
                "[ 武器商人 ]",
                "你好,旅人!需要武器吗?",
                "需要", "不了"
            );
        }
    }
    return 1;
}


  openmp/samp联机服务器插件开发 完全指南
发布者: 小鸟unsigned - 03-19-2026, 10:28 PM - 板块: 教程 - 回复 (2)

openmp/samp联机服务器插件开发 完全指南

引用:从零开始,用 C++ 为 openmp 服务器开发自定义组件。

本教程面向有 Pawn 脚本基础、想转 C++ 开发的 SAMP/openmp 服务器开发者。

本教程将教大家制作一个完整的查克拉系统简单插件实现

为什么标题包含samp但是确实openmp类的教程,因为很多人搜索sa-mp,但sa-mp已经彻底消逝了,有许多遗留问题且不再维护更新,openmp是最优解,你应该使用openmp,而且它向下兼容samp(我指的是pawn层),开发过程基本一模一样

* * *

目录

  1. 环境搭建
  2. 创建项目
  3. 最小可运行组件
  4. 组件的生命周期
  5. 创建自己的native函数
  6. 事件处理
  7. 玩家数据扩展
  8. 如何扫描amx的public
  9. 完整示例

* * *

1. 环境搭建

这里将演示轻量级环境搭建,你只需要安装三样东西和 VSCode

1.1 安装工具

Visual Studio Code(代码编辑器)

https://code.visualstudio.com/

Visual Studio Build Tools(不是完整的 Visual Studio)

这是微软的 C++ 编译器。你可以理解成pawn里的pawncc.exe,安装时只勾选「使用 C++ 的桌面开发」工作负载,其他全不选。

下载:https://visualstudio.microsoft.com/visual-cpp-build-tools/

CMake

构建系统,管理编译流程。

下载:https://cmake.org/download/

推荐下载 ZIP 版,解压到固定目录(如 D:\tools\cmake\),然后把 D:\tools\cmake\bin 加入系统 PATH 环境变量。干净利落,不想用了直接删文件夹。

如果选 Installer 版,安装时勾选 "Add CMake to the system PATH"。

Git

用于完整获取 open.mp SDK,以及后续各种开发所需的资源。

下载:https://git-scm.com

1.2 VSCode 扩展

打开 VSCode,安装以下扩展:
  • C/C++(Microsoft)--- 代码提示、语法高亮、调试
  • CMake Tools(Microsoft)--- CMake 项目集成,一键编译

1.3 验证安装

任意终端(比如 cmd)输入以下指令逐个验证是否安装配置成功:

代码:
cmake --version    # 应该显示版本号
git --version      # 应该显示版本号

* * *

2. 创建项目

2.1 初始化目录

选一个你喜欢的位置,创建项目:

代码:
新建文件夹 my-component
在根目录新建 src 的文件夹
在根目录新建 lib 的文件夹

2.2 添加 open.mp SDK 和相关依赖

在lib文件夹内点击鼠标右键,选择 Open Git Bash here 并执行以下命令

代码:
git clone --recursive https://github.com/openmultiplayer/open.mp-sdk sdk
git clone --recursive https://github.com/openmultiplayer/pawn-natives
git clone --recursive https://github.com/openmultiplayer/compiler pawn

等待获取完成,这会在lib文件夹内创建 sdk/ pawn/ pawn-natives/目录。

2.3 创建 CMakeLists.txt

在项目根目录创建 CMakeLists.txt 并复制以下内容粘贴进去:

代码:
cmake_minimum_required(VERSION 3.19)
project(my-component)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_subdirectory(lib/sdk)

add_library(${PROJECT_NAME} SHARED
    src/main.cpp
)

# 解决库的兼容性问题
add_definitions(
    -DHAVE_STDINT_H=1
    -DGLM_FORCE_SSE2=1
    -DGLM_FORCE_QUAT_DATA_WXYZ=1
    -DNOMINMAX=1
    -Dnssv_CONFIG_SELECT_STRING_VIEW=nssv_STRING_VIEW_NONSTD
    -Dspan_CONFIG_SELECT_SPAN=span_SPAN_NONSTD
    -DPAWN_CELL_SIZE=32
)

target_include_directories(${PROJECT_NAME} PRIVATE
    lib/
    lib/pawn/source
    lib/pawn/source/linux
    lib/pawn-natives
)

target_link_libraries(${PROJECT_NAME} PRIVATE
    OMP-SDK
)

2.4 项目结构

最终你的目录应该长这样:

代码:
my-component/
├── CMakeLists.txt      ← 构建配置
├── lib/
    ├── sdk/            ← open.mp SDK
    ├── pawn-natives/   ← 用于快速定义 PAWN 原生函数
    └── pawn/           ← 关联库
└── src/
    └── main.cpp        ← 你的代码

2.5 配置 VSCode

用 VSCode 打开 my-component 文件夹。CMake Tools 扩展会自动检测到 CMakeLists.txt。

选择编译器(Kit):

  1. 按 Ctrl+Shift+P,输入 CMake: Select a Kit
  2. 选择 Visual Studio 生成工具 2026 Release x86,如果没有这个选项,选择[扫描工具包]即可搜索。

选择构建类型:

  1. 按 Ctrl+Shift+P,输入 CMake: Select Variant
  2. 开发时选 Debug,发布时选 Release

引用:注意: 如果你使用的是 CMake 4.x,可能会遇到旧版依赖的兼容性警告。在项目根目录创建 .vscode/settings.json:

代码:
{
    "cmake.configureArgs": [
        "-DCMAKE_POLICY_VERSION_MINIMUM=3.10"
    ]
}

* * *

3. 最小可运行组件

创建 src/main.cpp,写入以下代码:

代码:
#include <sdk.hpp>

class MyComponent final : public IComponent
{
private:
    ICore* core_ = nullptr;

public:
    // 每个组件必须有一个全局唯一 ID
    // 去 https://open.mp/uid 生成一个替换下面的ID
    PROVIDE_UID(0x123456789ABCDEF0);

    // 组件名称,显示在服务端加载日志里
    StringView componentName() const override
    {
        return "MyComponent";
    }
    // 版本号
    SemanticVersion componentVersion() const override
    {
        return SemanticVersion(1, 0, 0, 0);
    }
    // 组件加载时调用
    void onLoad(ICore* c) override
    {
        core_ = c;
        core_->printLn("Hello World!");
    }

    // 其他组件初始化完成后调用
    void onInit(IComponentList* components) override {}

    // 所有组件和脚本都就绪后调用
    void onReady() override {}

    // 其他组件卸载时调用
    void onFree(IComponent* component) override {}

    // 组件自身被释放
    void free() override { delete this; }

    // GMX(换图)时调用
    void reset() override {}
};

// 组件入口点 —— 服务端通过这个宏发现并加载你的组件
COMPONENT_ENTRY_POINT()
{
    return new MyComponent();
}

编译:

按 F7(或 Ctrl+Shift+P → CMake: Build)。编译成功后,在 build/ 目录下会生成 my-component.dll。

测试:

把 .dll 复制到 open.mp 服务端的 components/ 目录,启动服务端,你应该能在控制台看到:

代码:
Loading component MyComponent.dll
        Successfully loaded component MyComponent (1.0.0.0) with UID 123456789abcdef0

Hello World!

恭喜,你的第一个组件跑起来了。

* * *

4. 组件的生命周期

组件的函数按固定顺序调用。理解这个顺序非常重要:

代码:
服务端启动
  │
  ├── onLoad(ICore*)           ← 拿到核心接口
  │
  ├── onInit(IComponentList*)  ← 查询其他组件(如 IPawnComponent)
  │
  ├── onAmxLoad(IPawnScript&)  ← Pawn 脚本加载,扫描 public 函数
  │
  ├── onReady()                ← 一切就绪,可以开始工作
  │
  │   ... 服务器运行中 ...
  │
  ├── onAmxUnload(IPawnScript&) ← Pawn 脚本卸载,清理相关数据
  │
  ├── onFree(IComponent*)      ← 组件卸载时
  │
  └── free()                   ← 释放自身

关键点:
  • onLoad 里只保存 ICore* 指针,不要访问其他组件(它们可能还没加载)
  • onInit 里通过 components->queryComponent<IPawnComponent>() 获取 Pawn 组件
  • onAmxLoad 是注册 native 函数和扫描 public 的地方

* * *

5. 创建自己的native函数

Native 函数让 Pawn 脚本能够调用你的 C++ 代码。

5.1 基本概念

在 Pawn 里写:

代码:
native MyAdd(a, b);

public OnGameModeInit()
{
    new a = 10, b = 20;
    new result = MyAdd(a, b);
    printf("%d + %d = %d", a, b, result);
    return 1;
}

在 C++ 里你需要实现函数并注册到 AMX。

5.2 使用 SCRIPT_API 宏(推荐)

open.mp 提供了 SCRIPT_API 宏,自动处理参数的类型转换:

代码:
#include <sdk.hpp>
#include <Server/Components/Pawn/pawn.hpp>
#include <Server/Components/Pawn/Impl/pawn_natives.hpp>
#include <Server/Components/Pawn/Impl/pawn_impl.hpp>

SCRIPT_API(MyAdd, int(int a, int b))
{
    return a + b;
}

SCRIPT_API 的参数含义: - 第一个参数:函数名,必须和 Pawn 里的 native 名称完全一致 - 第二个参数:返回类型(参数列表)

5.3 注册时机

在组件的 onAmxLoad 里调用 pawn_natives::AmxLoad,它会自动把所有 SCRIPT_API 定义的函数注册到 AMX:

代码:
void onAmxLoad(IPawnScript& script) override
{
    pawn_natives::AmxLoad(script.GetAMX());
}

5.4 完整的组件 + Native 示例

代码:
#include <sdk.hpp>
#include <Server/Components/Pawn/pawn.hpp>
#include <Server/Components/Pawn/Impl/pawn_natives.hpp>
#include <Server/Components/Pawn/Impl/pawn_impl.hpp>

class MyComponent final : public IComponent, public PawnEventHandler
{
private:
    ICore* core_ = nullptr;
    IPawnComponent* pawn_ = nullptr;

public:
    // 每个组件必须有一个全局唯一 ID
    // 去 https://open.mp/uid 生成一个替换下面的ID
    PROVIDE_UID(0x123456789ABCDEF0);

    StringView componentName() const override { return "MyComponent"; }
    SemanticVersion componentVersion() const override { return SemanticVersion(1, 0, 0, 0); }

    void onLoad(ICore* c) override
    {
        core_ = c;

        // 初始化 AMX 查找表
        setAmxLookups(core_); 
    }

    void onInit(IComponentList* components) override
    {
        pawn_ = components->queryComponent<IPawnComponent>();
        if (pawn_)
        {
            setAmxFunctions(pawn_->getAmxFunctions());
            setAmxLookups(components);
            pawn_->getEventDispatcher().addEventHandler(this);
        }
    }

    void onAmxLoad(IPawnScript& script) override
    {
        pawn_natives::AmxLoad(script.GetAMX());
    }

    void onAmxUnload(IPawnScript& script) override {}
    void onReady() override {}
    void onFree(IComponent* component) override {}
    void free() override { delete this; }
    void reset() override {}

    ~MyComponent()
    {
        if (pawn_) pawn_->getEventDispatcher().removeEventHandler(this);
    }
};

COMPONENT_ENTRY_POINT()
{
    return new MyComponent();
}

SCRIPT_API(MyAdd, int(int a, int b))
{
    return a + b;
}

重新编译插件并开启服务器,你就可以在控制台看到 10 + 20 = 30

* * *

6. 事件处理

open.mp 通过 EventHandler 接口分发事件。你的组件实现对应接口,注册到分发器,就能收到事件。

6.1 常见的 EventHandler

接口事件
PlayerConnectEventHandler玩家连接/断开
PlayerTextEventHandler玩家聊天/命令
PawnEventHandlerAMX 脚本加载/卸载

6.2 示例:监听玩家连接

代码:
class MyComponent final
    : public IComponent
    , public PawnEventHandler
    , public PlayerConnectEventHandler  // 实现连接事件接口
{
    // ... 省略其他成员 ...

    void onInit(IComponentList* components) override
    {
        // 注册 Pawn 事件
        pawn_ = components->queryComponent<IPawnComponent>();
        if (pawn_)
        {
            setAmxFunctions(pawn_->getAmxFunctions());
            setAmxLookups(components);
            pawn_->getEventDispatcher().addEventHandler(this);
        }

        // 注册玩家连接事件
        core_->getPlayers().getPlayerConnectDispatcher().addEventHandler(this);
    }
    // 析构函数里注销
    ~MyComponent()
    {
        if (pawn_)  pawn_->getEventDispatcher().removeEventHandler(this);
        if (core_) core_->getPlayers().getPlayerConnectDispatcher().removeEventHandler(this);
    }
    // 玩家连接时调用
    void onPlayerConnect(IPlayer& player) override
    {
        core_->printLn("玩家 %d 进入了服务器!", player.getID());
    }
};

* * *

7. 玩家数据扩展

在 Pawn 里,你可能习惯用全局数组存储玩家数据:

代码:
new pScore[MAX_PLAYERS];
new pLevel[MAX_PLAYERS];
new bool:pLoggedIn[MAX_PLAYERS];

在 open.mp 的 C++ 组件里,对应的机制是 IExtension。它把数据"挂"在玩家对象上。

7.1 定义数据结构

代码:
struct PlayerEnergy final : IExtension
{
    // 每个 Extension 需要一个全局唯一 ID
    // 去 https://open.mp/uid 生成
    PROVIDE_EXT_UID(0xABCDEF1234567890);

    // 你的自定义数据
    int energy = 100;
    int maxEnergy = 100;

    void freeExtension() override { delete this; }
    void reset() override
    {
        energy = 100;
        maxEnergy = 100;
    }
};

7.2 在玩家连接时挂载

代码:
void onPlayerConnect(IPlayer& player) override
{
    // 创建 PlayerEnergy 实例并挂到玩家身上
    // 第二个参数 true 表示玩家断开时自动调用 freeExtension
    player.addExtension(new PlayerEnergy(), true);
}

7.3 操作数据

代码:
auto* playerData = queryExtension<PlayerEnergy>(player);
if (playerData)
{
    playerData->energy += 100;
}

7.4 为什么不用 std::unordered_map<int, PlayerEnergy>?

你可以用,但 IExtension 有几个优势:
  • 生命周期自动管理:玩家断开时 freeExtension 自动调用,不会忘记清理
  • GMX 自动重置:换图时 reset 自动调用
  • 数据跟着对象走:不需要传 playerid 再去查 map,直接从 IPlayer& 取

* * *

8. 如何扫描amx的public

这是反方向的交互:你的 C++ 组件主动调用 Pawn 脚本里定义的 public 函数。

8.1 理解 AMX 虚拟机

Pawn 脚本编译后是 .amx 文件,由 AMX 虚拟机执行。每个 public 函数在 AMX 里有一个编号(index)。要调用它,你需要:

  1. 找到编号 --- 用 amx_FindPublic 或在 onAmxLoad 时扫描
  2. 压入参数 --- 用 amx_Push(整数)或 amx_PushString(字符串)
  3. 执行 --- 用 amx_Exec
  4. 清理 --- 用 amx_Release 释放字符串内存

8.2 扫描 Public 函数

假设 Pawn 里有:

代码:
forward OnPlayerEnergyChange(playerid, oldEnergy, newEnergy);
public OnPlayerEnergyChange(playerid, oldEnergy, newEnergy)
{
    printf("玩家 %d 的查克拉发生了变化: %d -> %d", playerid, oldEnergy, newEnergy);
}

在 onAmxLoad 时扫描它:

代码:
// 保存 AMX 指针和 public index
struct ScriptInfo
{
    AMX* amx = nullptr;
    int onEnergyChange = -1;  // -1 表示该脚本里没有这个函数
};

std::vector<ScriptInfo> scripts_;

void onAmxLoad(IPawnScript& script) override
{
    pawn_natives::AmxLoad(script.GetAMX());

    AMX* amx = script.GetAMX();
    ScriptInfo info;
    info.amx = amx;

    // 方法一:按名称精确查找
    amx_FindPublic(amx, "OnPlayerEnergyChange", &info.onEnergyChange);
    // 如果找到,info.onEnergyChange >= 0
    // 如果没找到,info.onEnergyChange 保持 -1

    scripts_.push_back(info);
}

8.3 调用 Public 函数

找到 index 后,就可以调用了。AMX 是栈式虚拟机,参数要倒序压入。

调用无参数函数:

代码:
// Pawn 侧
forward OnSomethingHappen();
public OnSomethingHappen()
{
    print("damn!");
}

代码:
// C++ 侧
void callOnSomethingHappen(AMX* amx, int index)
{
    cell returnValue;
    amx_Exec(amx, &returnValue, index);
    // returnValue 是 Pawn 函数的返回值
}

调用带整数参数的函数:

代码:
// Pawn 侧
forward OnPlayerEnergyChange(playerid, oldEnergy, newEnergy);
public OnPlayerEnergyChange(playerid, oldEnergy, newEnergy)
{
    printf("玩家 %d 的查克拉发生了变化: %d -> %d", playerid, oldEnergy, newEnergy);
    return 1;
}

代码:
// C++ 侧
void callOnPlayerEnergyChange(AMX* amx, int index, int playerid, int oldEnergy, int newEnergy)
{
    // Pawn: OnPlayerEnergyChange(playerid, oldEnergy, newEnergy)
    // 倒序压栈
    amx_Push(amx, newEnergy);
    amx_Push(amx, oldEnergy);
    amx_Push(amx, playerid);

    cell returnValue;
    amx_Exec(amx, &returnValue, index);
}

调用带字符串参数的函数:

代码:
// Pawn 侧
forward OnPlayerMessage(playerid, const message[]);
public OnPlayerMessage(playerid, const message[])
{
    printf("Player %d: %s", playerid, message);
    return 1;
}

代码:
// C++ 侧
void callMessage(AMX* amx, int index, int playerid, const char* message)
{
    cell addr_message;
    amx_PushString(amx, &addr_message, nullptr, message, 0, 0);
    amx_Push(amx, playerid);

    cell retval;
    amx_Exec(amx, &retval, index);

    // 必须释放 addr_message 不然会导致内存泄漏
    amx_Release(amx, addr_message);
}

8.4 AMX API 速查表

可查阅:https://open.mp/docs/tutorials/PluginDevelopmentGuide#amx-functions

函数用途
amx_NumPublics(amx, &count)获取 public 函数数量
amx_GetPublic(amx, index, name)按 index 获取函数名
amx_FindPublic(amx, name, &index)按名称查找 index
amx_Push(amx, value)压入整数参数
amx_PushString(amx, &addr, nullptr, str, 0, 0)压入字符串参数
amx_Exec(amx, &retval, index)执行 public 函数
amx_Release(amx, addr)释放 PushString 分配的内存

* * *

9. 完整示例

下面是一个完整的组件,包含上述所有概念。功能很简单:追踪玩家的"查克拉",提供 native 函数给 Pawn 操作,并在查克拉变化时回调 Pawn。

9.1 C++ 组件(src/main.cpp)

代码:
#include <sdk.hpp>
#include <Server/Components/Pawn/pawn.hpp>
#include <Server/Components/Pawn/Impl/pawn_natives.hpp>
#include <Server/Components/Pawn/Impl/pawn_impl.hpp>

#include <vector>
#include <string>

// 玩家数据扩展(查克拉)
struct PlayerEnergy final : IExtension
{
    // 每个组件必须有一个全局唯一 ID
    // 去 https://open.mp/uid 生成一个替换下面的ID
    PROVIDE_EXT_UID(0x1A2B3C4D5E6F7890);

    int energy = 100;
    int maxEnergy = 100;

    void freeExtension() override { delete this; }
    void reset() override
    {
        energy = 100;
        maxEnergy = 100;
    }
};

// 组件主类
class EnergyComponent final
    : public IComponent
    , public PawnEventHandler
    , public PlayerConnectEventHandler
{
private:
    ICore* core_ = nullptr;
    IPawnComponent* pawn_ = nullptr;

    // 保存 AMX 脚本中 OnPlayerEnergyChange 的 index
    struct ScriptInfo
    {
        AMX* amx = nullptr;
        int onEnergyChange = -1;
    };
    std::vector<ScriptInfo> scripts_;

public:
    PROVIDE_UID(0x9F8E7D6C5B4A3210);

    StringView componentName() const override { return "EnergySystem"; }
    SemanticVersion componentVersion() const override { return SemanticVersion(1, 0, 0, 0); }

    void onLoad(ICore* c) override
    {
        core_ = c;
        setAmxLookups(core_);
        core_->printLn("[查克拉插件] 查克拉系统已加载.");
    }

    void onInit(IComponentList* components) override
    {
        pawn_ = components->queryComponent<IPawnComponent>();
        if (pawn_)
        {
            setAmxFunctions(pawn_->getAmxFunctions());
            setAmxLookups(components);
            pawn_->getEventDispatcher().addEventHandler(this);
        }
        core_->getPlayers().getPlayerConnectDispatcher().addEventHandler(this);
    }

    // Pawn 事件
    void onAmxLoad(IPawnScript& script) override
    {
        pawn_natives::AmxLoad(script.GetAMX());

        // 扫描 Pawn 脚本中的回调函数
        AMX* amx = script.GetAMX();
        ScriptInfo info;
        info.amx = amx;
        amx_FindPublic(amx, "OnPlayerEnergyChange", &info.onEnergyChange);
        scripts_.push_back(info);
    }

    void onAmxUnload(IPawnScript& script) override
    {
        AMX* amx = script.GetAMX();
        scripts_.erase(
            std::remove_if(scripts_.begin(), scripts_.end(), [amx](const ScriptInfo& s) { return s.amx == amx; }), scripts_.end());
    }

    // 玩家事件
    void onPlayerConnect(IPlayer& player) override
    {
        player.addExtension(new PlayerEnergy(), true);
    }

    // C++ 调用 Pawn 回调
    void callOnPlayerEnergyChange(int playerid, int oldEnergy, int newEnergy)
    {
        for (auto& s : scripts_)
        {
            if (s.onEnergyChange < 0)
                continue;

            // Pawn: OnPlayerEnergyChange(playerid, oldEnergy, newEnergy)
            // 倒序压栈
            amx_Push(s.amx, newEnergy);
            amx_Push(s.amx, oldEnergy);
            amx_Push(s.amx, playerid);

            cell retval;
            amx_Exec(s.amx, &retval, s.onEnergyChange);
        }
    }

    // 生命周期
    void onReady() override {}
    void onFree(IComponent* component) override
    {
        if (component == pawn_)
        {
            pawn_ = nullptr;
            setAmxFunctions();
            setAmxLookups();
        }
    }
    void free() override { delete this; }
    void reset() override {}

    ~EnergyComponent()
    {
        if (pawn_) pawn_->getEventDispatcher().removeEventHandler(this);
        if (core_) core_->getPlayers().getPlayerConnectDispatcher().removeEventHandler(this);
    }
};

// 全局指针,给 SCRIPT_API 用
static EnergyComponent* gComponent = nullptr;

COMPONENT_ENTRY_POINT()
{
    gComponent = new EnergyComponent();
    return gComponent;
}

// Native 函数
// native GetPlayerEnergy(playerid);
SCRIPT_API(GetPlayerEnergy, int(IPlayer& player))
{
    auto* data = queryExtension<PlayerEnergy>(player);
    return data ? data->energy : 0;
}

// native SetPlayerEnergy(playerid, energy);
SCRIPT_API(SetPlayerEnergy, bool(IPlayer& player, int energy))
{
    auto* data = queryExtension<PlayerEnergy>(player);
    if (!data)
        return false;

    int oldEnergy = data->energy;
    data->energy = (energy > data->maxEnergy) ? data->maxEnergy : energy;

    // 触发 Pawn 回调
    if (data->energy != oldEnergy && gComponent)
        gComponent->callOnPlayerEnergyChange(player.getID(), oldEnergy, data->energy);

    return true;
}

// native GetPlayerMaxEnergy(playerid);
SCRIPT_API(GetPlayerMaxEnergy, int(IPlayer& player))
{
    auto* data = queryExtension<PlayerEnergy>(player);
    return data ? data->maxEnergy : 0;
}

// native SetPlayerMaxEnergy(playerid, maxEnergy);
SCRIPT_API(SetPlayerMaxEnergy, bool(IPlayer& player, int maxEnergy))
{
    auto* data = queryExtension<PlayerEnergy>(player);
    if (!data)
        return false;

    data->maxEnergy = maxEnergy;
    if (data->energy > data->maxEnergy)
        data->energy = data->maxEnergy;

    return true;
}

9.2 Pawn Include 文件(energy.inc)

代码:
// 函数声明
native GetPlayerEnergy(playerid);
native SetPlayerEnergy(playerid, energy);
native GetPlayerMaxEnergy(playerid);
native SetPlayerMaxEnergy(playerid, maxEnergy);

// 回调声明
forward OnPlayerEnergyChange(playerid, oldEnergy, newEnergy);

9.3 Pawn 测试脚本

代码:
#include <open.mp>
#include "energy.inc"

main() {}

public OnPlayerConnect(playerid)
{
    // 连接时默认 energy = 100
    new energy = GetPlayerEnergy(playerid);
    printf("[查克拉系统] Player %d 进入了服务器, 查克拉 = %d", playerid, energy);

    // 设置为 50,会触发 OnPlayerEnergyChange
    SetPlayerEnergy(playerid, 50);

    return 1;
}

// C++ 组件在查克拉变化时回调这个函数
public OnPlayerEnergyChange(playerid, oldEnergy, newEnergy)
{
    printf("[回调] 玩家 %d 的查克拉发生了变化: %d -> %d", playerid, oldEnergy, newEnergy);
    return 1;
}

9.4 数据流向图

代码:
玩家连接
  │
  ├─ open.mp 触发 onPlayerConnect
  │   └─ C++ 创建 PlayerEnergy, 挂到玩家对象上
  │
  ├─ Pawn OnPlayerConnect 被调用
  │   └─ 调用 native SetPlayerEnergy(playerid, 50)
  │        │
  │        ├─ SCRIPT_API 自动解析 playerid → IPlayer&
  │        ├─ queryExtension<PlayerEnergy>(player) 取出数据
  │        ├─ 修改 energy: 100 → 50
  │        └─ callOnPlayerEnergyChange(playerid, 100, 50)
  │             │
  │             ├─ amx_Push(amx, 50)         ← newEnergy
  │             ├─ amx_Push(amx, 100)        ← oldEnergy
  │             ├─ amx_Push(amx, playerid)
  │             └─ amx_Exec → 调用 Pawn 的 OnPlayerEnergyChange

* * *

附录:资源
  • c++学习网站: https://www.w3school.com.cn/cpp/cpp_reference.asp
  • open.mp SDK 仓库:https://github.com/openmultiplayer/open.mp-sdk
  • open.mp 组件模板示例:https://github.com/openmultiplayer/pawn-template
  • open.mp 完整组件模板示例:https://github.com/openmultiplayer/full-template
  • 使用组件实现的gamemode示例:https://github.com/openmultiplayer/rivershell-cpp
  • UID 生成器:https://open.mp/uid
  • open.mp 官方文档:https://www.open.mp/docs
  • amx相关函数说明文档:https://open.mp/docs/tutorials/PluginDevelopmentGuide#amx-functions

* * *


  [转发][库] tdialogs 异步处理对话框
发布者: 小鸟unsigned - 03-02-2026, 12:46 PM - 板块: - 回复 (1)

tdialogs

tdialogs 是一个库,它通过 PawnPlus 任务系统的强大功能,提供了多种函数来在 open.mp 服务器中异步处理对话框响应。

作者: TommyB123
GitHub地址: https://github.com/TommyB123/tdialogs

依赖项

本库需要以下依赖项。 * PawnPlus * sscanf

安装

只需安装到您的项目中:

代码:
sampctl install TommyB123/tdialogs

在您的代码中包含并开始使用该库:

代码:
#include <tdialogs>

介绍

如上所述,tdialogs 是一个基于 PawnPlus 的库,可用于在单个 PAWN 函数中异步处理对话框响应。这里有一个简单的示例。

代码:
MyCoolFunction()
{
    yield 1;
    new response[DIALOG_RESPONSE];
    await_arr(response) ShowAsyncDialog(playerid, DIALOG_STYLE_LIST, "酷炫对话框", "条目 1\n条目 2\n条目 3", "不错!", "返回");
    if(response[DIALOG_RESPONSE_RESPONSE])
    {
        SendClientMessage(playerid, -1, "您点击了第 %i 行,并收到了消息:%s",
            response[DIALOG_RESPONSE_LISTITEM], response[DIALOG_RESPONSE_INPUTTEXT]); // 值可能分别为 1 和 "条目 2"
    }
    else
    {
        SendClientMessage(playerid, -1, "您关闭了对话框");
    }
}

如果您熟悉所有可用的对话框处理库,您可能会想知道这个库与它们有何不同。简单的答案是,我包含了大量函数来快速从对话框响应中获取特定/单个值。您只需要显示一个快速的是/否确认对话框吗?没问题。使用 ShowAsyncConfirmationDialog。需要从游戏内商店购买用户输入的数量吗?ShowAsyncNumberInputDialog 可以满足您的要求。示例用法可以在这里找到。

从 1.1.0 版本开始,自动分页对话框也被包含在内,可以通过 AddPaginatedDialogRow 和 ShowAsyncPaginatedDialog 创建。完整的函数参数和示例如下。

对话框数据

tdialogs 还有一个虽小但实用的功能,用于在整个对话框流程中跟踪 ID 或其他数据。每个玩家在登录时都会获得一个动态的 PawnPlus 列表 分配给他们。这个列表可用于存储诸如房屋索引、车辆 ID 或其他与对话框内文本行匹配的数据。这样做是为了保留标识符,并避免进行不必要的循环查找或其他成本较高的方法来获取您已经找到的数据。

除了这个列表之外,还有一个专门结合其使用而设计的对话框函数。当您格式化了一个对话框并将相应的实体 ID(例如房屋 ID)推送到 DialogData 列表后,可以使用 ShowAsyncEntityIndexDialog 通过一行代码获取玩家点击的房屋 ID。您可以在下面的示例部分看到它的实际应用。

需要注意的是,在显示新对话框之前,必须使用 list_clear 清理该列表。否则,先前显示的对话框中的数据会混入后续的对话框显示中。

常量


 常量                                              默认值                               描述 
  • TDIALOG_DIALOG_ID_BEGIN           1234                                  该库为每种对话框类型(不要与对话框样式混淆)预留了 9 个对话框 ID TDIALOG_DIALOG_ID_BEGIN 是第一个将被使用的 ID,随后依次是接下来的 8 个数字。请注意您已有的任何对话框 ID 并相应地进行调整
  • PAGINATED_NEXT_TEXT                 "--> 下一页"                        当分页对话框提示您查看下一页时将显示的字符串。[/td][/tr]
  • PAGINATED_PREVIOUS_TEXT          "<-- 上一页"                        当您在分页对话框中可以返回上一页时将显示的字符串。[/td][/tr]


前提条件

建议您在包含 PawnPlus 之前添加 #define PP_SYNTAX_AWAIT。这允许您使用本库编写时所考虑的正确的 await 方法。

同时建议也添加 #define PP_SYNTAX_YIELD。这是 task_yield 的一个快速别名,用于在函数等待任务时将临时值让出给函数。例如,如果您在命令中没有 yield 1;,您很可能会在聊天中收到错误的未知命令错误。

函数

以下所有函数都有 PawnPlus 字符串变体。ShowAsyncDialog 对应 ShowAsyncDialog_s,以此类推。

代码:
ShowPlayerDialog_s(playerid, dialogid, DIALOG_STYLE:style, ConstAmxString:title, ConstAmxString:body, const button1[], const button2[])

ShowPlayerDialog 的简单 PawnPlus 字符串封装。

代码:
ShowAsyncDialog(playerid, DIALOG_STYLE:style, const title[], const body[], const button1[], const button2[] = "")

显示一个异步对话框,返回完整的对话框响应数组。

与 await_arr(response) ShowAsyncDialog(...) 一起使用。

代码:
ShowAsyncNumberInputDialog(playerid, const title[], const body[], const button1[], const button2[])

显示一个专门用于解析数字输入的异步对话框。

如果未接收到整数,对话框将简单地再次显示。

如果玩家关闭对话框/按下 ESC,则返回 cellmin。

与 new number = await ShowAsyncNumberInputDialog(...) 一起使用。

代码:
ShowAsyncFloatInputDialog(playerid, const title[], const body[], const button1[], const button2[])

显示一个专门用于解析数字输入的异步对话框。

如果未接收到浮点数,对话框将简单地再次显示。

如果玩家关闭对话框/按下 ESC,则返回 FLOAT_NAN。

与 new Float:number = Float:await ShowAsyncFloatInputDialog(...) 一起使用。

代码:
ShowAsyncStringInputDialog(playerid, const title[], const body[], const button1[], const button2[])

显示一个专门用于解析字符串输入的异步对话框。

如果接收到空字符串,对话框将简单地再次显示。

如果玩家关闭对话框/按下 ESC,则返回空字符串。使用 isnull 进行检查。

与 await_str(string) ShowAsyncStringInputDialog(...) 一起使用。

代码:
ShowAsyncPasswordDialog(playerid, const title[], const body[], const button1[], const button2[])

与 ShowAsyncStringInputDialog 相同,但使用密码对话框样式。

代码:
ShowAsyncListitemTextDialog(playerid, DIALOG_STYLE:style, const title[], const body[], const button1[], const button2[])

显示一个异步对话框,仅返回通过列表项传递的文本。

如果玩家关闭对话框/按下 ESC,则返回空字符串。使用 isnull 进行检查。

与 await_str(string) ShowAsyncListitemTextDialog(...) 一起使用。

代码:
ShowAsyncListitemIndexDialog(playerid, DIALOG_STYLE:style, const title[], const body[], const button1[], const button2[])

显示一个异步对话框,仅返回被点击的列表项的索引。

如果玩家关闭对话框/按下 ESC,则返回 -1。

与 new index = await ShowAsyncListitemIndexDialog(...) 一起使用。

代码:
ShowAsyncConfirmationDialog(playerid, const title[], const body[], const button1[], const button2[] = "")

显示一个异步对话框,仅将响应状态作为布尔值返回。

与 new bool:confirm = bool:await ShowAsyncConfirmationDialog(...) 一起使用。

代码:
ShowAsyncEntityIndexDialog(playerid, DIALOG_STYLE:style, const title[], const body[], const button1[], const button2[])

显示一个异步对话框,返回在玩家 DialogData 列表中位于 listitem 位置的值。在此处或下面的示例中阅读更多信息。

如果玩家关闭对话框/按下 ESC,则返回 -1。

与 new id = await ShowAsyncEntityIndexDialog(...) 一起使用。

代码:
ShowAsyncPaginatedDialog(playerid, DIALOG_STYLE:style, rows_per_page, const title[], const button1[], const button2[], const tablist_header_text[] = "")

显示一个异步对话框,根据提供的每页行数对提供的文本进行分页。返回完整的对话框响应。

此函数仅兼容 DIALOG_STYLE_TABLIST_HEADERS、DIALOG_STYLE_TABLIST 和 DIALOG_STYLE_LIST。

tablist_header_text 参数仅用于 DIALOG_STYLE_TABLIST_HEADERS,顾名思义,是对话框的标题头文本。

与 await_arr(response) ShowAsyncPaginatedDialog 一起使用。

代码:
AddPaginatedDialogRow(playerid, const text[], extraid = 0)

向分页对话框添加一行。extraid 参数可用于传递与您追加的文本行相关的额外 ID。

构建分页对话框时必需。

示例

这里有一些稍微深入一点的示例。

快速确认用户是否想出售他们的房子。

代码:
CMD:sellmyhouse(playerid)
{
    if(PlayerOwnsHouse(playerid))
    {
        task_yield(1);
        new bool:confirm = bool:await ShowAsyncConfirmationDialog(playerid, "出售您的房子?", "您确定要出售您的房子吗?", "是", "否");
        if(confirm)
        {
            SellPlayerHouse(playerid);
        }
    }
}

输入您想从商店购买多少鱼饵。

代码:
new baitamount = await ShowAsyncNumberInputDialog(playerid, "鱼饵数量", "在下面输入您想购买的鱼饵数量", "购买", "取消");
if(baitamount == cellmin) return false; // 当玩家取消对话框时返回 cellmin
if(baitamount <= 0 || baitamount > 10000)
{
    SendClientMessage(playerid, -1, "无效的鱼饵数量。");
    return false;
}
GivePlayerFishBait(playerid, baitamount);
return true;

通过使用 DialogData 列表从对话框中解析出房屋 ID。

代码:
ShowHouseTeleportDialog(playerid)
{
    new string[256], substring[64];
    list_clear(DialogData[playerid]); // 清空持久的 DialogData 列表,使其与我们即将显示的对话框中的行数匹配
    for(new i = 0; i < MAX_HOUSES; ++i)
    {
        if(HouseData[i][HouseOwner] == playerid)
        {
            // 将房屋名称追加到字符串,并将房屋索引添加到 DialogData 列表。
            // 这样做是为了使房屋名称行与任意的房屋 ID 保持一致。
            // 可能有 500 栋房子,但玩家只拥有其中少数(甚至一栋)。以这种方式存储
            // 房屋索引让我们能够快速获取正确的 ID,而无需进行额外的、不必要的查找。
            format(substring, sizeof(substring), "%s\n", HouseData[i][HouseAddress]);
            strcat(string, substring);
            list_add(DialogData[playerid], i);
        }
    }
    new houseid = await ShowAsyncEntityIndexDialog(playerid, DIALOG_STYLE_LIST, "传送到您的家", string, "传送!", "算了");
    if(houseid != -1) // 如果玩家取消对话框,返回 -1
    {
        SetPlayerPos(playerid, HouseData[houseid][HouseX], HouseData[houseid][HouseY], HouseData[houseid][HouseZ]);
    }
}

显示一个每页 10 行的分页对话框。

代码:
ShowOwnedVehiclesDialog(playerid)
{
    new string[128];
    list_clear(DialogData[playerid]);
    for(new i = 1; i <= MAX_VEHICLES; ++i)
    {
        if(PlayerOwnsVehicle(playerid, i))
        {
            format(string, sizeof(string), "%s\t%s\n", ReturnVehicleName(i), ReturnVehicleLocation(i));
            AddPaginatedDialogRow(playerid, string, i);
        }
    }
    new response[DIALOG_RESPONSE];
    await_arr(response) ShowAsyncPaginatedDialog(playerid, DIALOG_STYLE_TABLIST_HEADERS, 10, "您的车辆", "传送", "取消", "车辆\t位置");
    if(!response[DIALOG_RESPONSE_RESPONSE]) return false;
    new vehicleid = response[DIALOG_RESPONSE_EXTRAID], Float:x, Float:y, Float:z;
    GetVehiclePos(vehicleid, x, y, z);
    SetPlayerPos(playerid, x, y, z);
    SendClientMessage(playerid, -1, "您已传送到您的 %s", ReturnVehicleName(vehicleid));
}

致谢

TommyB123

特别感谢 Graber。在使用他的原创 async dialogs 库多年后,我才受到启发制作了自己的库。如果您不需要我的库提供的额外功能,可以考虑使用他的库。


  [转载][工具]DAWNO - PAWN 编辑器
发布者: 小鸟unsigned - 03-02-2026, 11:27 AM - 板块: 发布 - 暂无回复

# DAWNO - PAWN 编辑器

## 作者

由 deksdeveloper 开发。
更多信息,请访问 [GitHub 仓库](https://github.com/deksdeveloper/dawno)。

DAWNO 是一款现代化、注重性能的 PAWN 代码编辑器,专为 SA-MP(圣安地列斯多人游戏)和 open.mp 开发者设计。它基于 Next.js、Electron 和 Monaco 编辑器技术构建,是经典 Pawno 编辑器的现代替代品。

## 概述

传统 PAWN 编辑过程因工具过时而显得繁琐。DAWNO 弥补了这一不足,提供了一个专业环境,在保持针对圣安地列斯模组开发的轻量级特性的同时,兼具了 Visual Studio Code 等现代代码编辑器的功能和美学。

## 主要特点

- **现代化用户界面与体验**:采用 VS Code 风格的时尚界面,支持原生深色模式,带来高级质感。
- **高级 Monaco 编辑器**:高性能文本编辑,具备语法高亮、智能代码补全和平滑滚动功能。
- **自动化检测**:深度扫描(最多 4 级目录),自动识别服务器可执行文件(samp-server.exe, omp-server.exe)和配置文件(server.cfg, config.json)。
- **集成服务器管理器**:直接在编辑器中控制服务器,支持启动、停止、重启操作,并实时查看控制台日志。
- **配置编辑器**:提供专用的表格界面管理服务器设置,无需手动编辑文本文件。
- **编码支持**:全面的字符编码列表,包括对土耳其语(Windows-1254)等的完整支持,并支持即时切换编码。
- **Discord 富文本状态**:自动更新您的 Discord 状态,显示当前正在处理的项目和文件。
- **多语言支持**:内置国际化(i18n)功能,开箱即支持英语、土耳其语和德语。

## 国际化 (i18n)

DAWNO 通过基于 React Context 的自定义 i18n 解决方案支持多种语言。开发者可以通过在 `renderer/i18n/locales/` 中添加新的语言文件来轻松扩展语言支持。

当前支持的语言:
- 英语
- 土耳其语
- 德语

## 许可证

本项目采用 MIT 许可证。详情请参阅 LICENSE 文件。


  [转载]SA-MP/OpenMP反作弊系统 by Pevenaider
发布者: 小鸟unsigned - 02-28-2026, 04:08 PM - 板块: 脚本 - 暂无回复

一款基于SendClientCheck函数(类型0x5)的反作弊系统。该系统专为检测玩家修改行为而设计,可识别以下工具:S0beit, Sobfox.asi, Sobfox Launcher, CLEO 4, CLEO 5 with Ultimate ASI Loader, MoonLoader, SilentPatch, SampFuncs, modified VorbisFile.dll, UltraWH, Improved Deagle, StealthRemastered, Sensfix, SilentAim v8 和 FakeMobile

支持: open.mp | sa-mp

地址:https://github.com/Pevenaider/AntyCheat


  [库][分享转发] sa-mp/pawn 标准库包
发布者: 小鸟unsigned - 02-28-2026, 02:39 PM - 板块: - 暂无回复

samp-stdlib | pawn-stdlib

默认 sa-mp 服务器包含文件的更新版本。支持const校验、文档齐全且完整

下载地址: 

 - https://github.com/pawn-lang/pawn-stdlib

 - https://github.com/pawn-lang/samp-stdlib

San Andreas Multiplayer Pawn 标准库包 - 专为 sampctl 包管理系统 设计。

当前最新版本为 0.3.7-R2-2-1 | 0.3.DL-R1

master 分支头部目前不包含任何 RC 库。

为什么?

sampctl 工具内置的包管理系统基于 GitHub(类似于 Go 语言),因此将标准库也存储在 GitHub 上可以简化流程。这意味着不需要为标准库编写任何特殊代码,它可以像其他所有包一样只是一个普通包。

版本如何运作?

版本通过 Git 标签 表示

使用 sampctl 时始终指定一个版本。要依赖最新版本,只需在依赖项中使用 sampctl/samp-stdlib:0.3.7-R2-2-1。

发布候选版本(RC)期间会发生什么?

master 分支将包含 RC 库(如果存在活跃的 RC)。最近的 RC 是 0.3.8,但由于该版本已取消,此代码库不包含任何 0.3.8 RC 库。

版本标签与 sampctl 完全兼容

sampctl 简化了版本控制,此代码库的存在是为了帮助使用多个 SA:MP 服务器版本的 sampctl 用户。

版本起始于 0.3z(具体为 0.3z-R1)

0.3z 是 sampctl 支持的最早版本。大多数服务器使用最新版本,因此似乎没有必要归档每个 SA:MP 库版本。

某些标签指向同一提交

有些版本仅更改了服务器的内部机制,从未涉及库文件。在这些情况下,版本标签仍存在于该代码库中,但它们仅指向同一提交。


  SinglePlayer-Role Play 1.0.5
发布者: 小鸟unsigned - 02-28-2026, 02:16 PM - 板块: 游戏模式 - 暂无回复

SP-RP 1.0.5

SP-RP 下载地址

这是于2019年编写的SOLS游戏模式演变而来的游戏模式,后来它逐步发展为GTA-C,并在2024年末转变为现在的SP-RP。

这是一个由众多热情开发者为一个不知感恩的社区倾力打造的激情项目。这个社区多年来免费享受游戏,而开发者们却夜以继日地工作,投入大量资源,却换来社区成员试图泄露制作团队个人信息的回报。

我希望通过发布这个脚本,人们能够学习我们的一些技术、创新解决方案,并从我们的错误中吸取教训。它在底层不算特别出色,但在其巅峰时期,曾同时托管超过200名玩家。

在发布这个脚本时,我们共有2300次提交和8位GitHub合作者。感谢这些人让它变得如此出色。

致谢

主要贡献者:

  • DignitySAMP / PXL-DavyL:主要贡献者 / 项目负责人和创建者
  • Spooky/Sporky:贡献了从SOLS到GTAC的转变,包括数百项游戏体验优化
  • Rusu:整个项目期间的主要贡献者,专注于bug修复和游戏体验优化

次要贡献者:
  • Matz(篮球和卡车运输系统)
  • LorenC(台球脚本、扑克脚本等,全部由他为SFCNR编写)
  • LS-RP (DamianC):2011年之前的家具数组
  • PatrickGTR, Matz, DenNorske, Kubi 和 Patryk:开源了服务器使用的小型系统
  • Tramposo(次要游戏体验优化)
  • DTVSamp(次要游戏体验优化)
  • Shabo/plsegott(旧的物品道具系统,用于啤酒和食物,后来被重做,但脚本中仍有部分残留)

特别感谢:
  • TommyB123(更新SQL、YSI、主要优化、初始化sampctl)
  • DenNorske(帮助我们修复无法自行解决的问题,通常是隐藏的致命开关或无限循环,以及后来的DDoS保护调整)

概述

对于任何有经验的开发者来说,这是一个即插即用的脚本。它使用sampctl进行插件管理。我们提供了数据库结构以及运行所需的模型。不会提供如何运行它的支持。如果你知道自己在做什么,就不会有问题。我大约一个月前开始为代码添加文档,但许多功能仍然缺少关键说明。祝你好运解读它。

有一些小bug,但这个脚本已在生产环境中稳定运行超过5年。尽管如此,如果你想托管它,请投资一台专用服务器,至少配备3GB RAM和强大的CPU性能。车辆模块严重过时,会严重占用资源。车辆模块表现欠佳,但提交ce3fb3105057dfb25a027d9824b6c0f97950368d和69a4128b3ec070163073cd3381e16b1516db9b92包含了大幅提升性能的更改。目前,许多车辆原生函数仍使用for循环获取数据,这效率低下,因此在视为可接受之前,应将其转换为带有缓存机制的迭代器。

我会说脚本的80-85%是动态的,即数据保存在MySQL中。它支持UCP,你可以在这里找到配套的UCP:https://github.com/rzrusu/SPRP-UCP 由@rzrusu制作。我不推荐直接以此游戏模式作为基础,但经过一些完善,它几乎是任何中等规模角色扮演服务器的理想选择。

原始仓库保持私有状态。其中包含不宜公开的敏感数据提交。

功能

我们在2024年末正更新脚本至更现代版本。有些模块仍严重过时。我试图概述这里的内容,但要列举的东西实在太多。简而言之,这个脚本包含了你可能需要的一切,经过多年游戏体验优化的积累:

动态财产系统
  • 房屋和企业均属于财产范畴。
  • 每个企业在玩家使用/buy时都会生成脚本化的收入。
  • 全面的家具系统(仅限室内,玩家无需管理员即可升级室内装饰)。
  • 衣柜系统(保存你的皮肤,让你在房屋内轻松切换)。
  • 附近枪声警报系统(如果有人在房屋外开枪,你会收到通知)。
  • 房地产经纪系统:财产会随时间自动出售,并可在/realtor处集中查看。
  • 财产抢劫脚本,由PatrickGTR为开放CNR编写(包含EnEx模型和可抢劫的演员)。
  • 财产特定操作(/doorme, /doordo, /doorknock, /doorbell, /doorshout)。
  • 财产存储功能,用于存放枪支和毒品。
  • 众多小巧的游戏体验优化,如彩色财产名称等。
  • 大量管理员和用户命令,提供高度灵活性(管理员可修改企业的各方面设置)。
  • 丰富的购买类型,请查看/buy及其相关函数。

动态车辆系统
  • 支持静态车辆和玩家拥有的车辆。
  • 车辆改装系统,与改装店财产联动。涵盖游戏中所有可能的组件。
  • 车辆损坏保存,在停车后生效。
  • 服务器端喷漆系统,支持喷漆作业和所有单人模式颜色。
  • 基础燃料系统,包含动态燃料泵放置(对象)和可拥有的加油站。加油过程有专用界面。
  • 独特的单人模式风格车辆GUI,可在/settings中切换。
  • 汽车报废、转让和出售功能。
  • 扣押系统,专供LSPD使用。
  • 基础空中交通控制系统,用于飞机与LSPD通信。
  • 基础租车系统。
  • /rprf积分系统,用于工作、派系等,可修复和修理车辆。
  • 基础静态经销商系统,出售所有非过强车辆,价格与服务器经济匹配。
  • DMV系统,包含理论考试。
  • 服务器端颜色和重生系统(略有bug,此模块需重写)。
  • 车辆窗户脚本,可阻挡聊天,并与所有本地聊天同步。

动态派系系统
  • 全面成员管理(支持离线玩家)。
  • 派系层级决定玩家权限(3级最低,2级指挥,1级领导)。
  • 动态派系等级:无硬编码限制,使用/setrank id title。
  • 基础派系银行系统------功能正常,但未过多强调。
  • 派系皮肤半动态,但需重做。通过命令添加皮肤,并在派系重生点显示。
  • 军械库系统,专供LSPD,根据小队和层级获取枪支。
  • 通信系统(f聊天需管理员授权,2级以上可切换;派系寻呼机用于IC通信;LSPD无线电)。
  • 派系重生点,允许更改皮肤并在首次登录时重生于此。
  • LSPD小队系统。
  • 众多管理员和玩家命令,提供系统灵活性,无法一一列举。

小游戏
  • 台球脚本:由LorenC为SFCNR编写,移植至本游戏模式,仅作微调。
  • 篮球系统:由Matz编写的过时模块,模拟单人模式篮球游戏。虽旧且未优化,但功能正常。
  • 扑克脚本:曾正常运行,更改文本绘制处理后失效。可尝试修复,但不保证成功。从未投入时间修复。
  • /shakehands:包含游戏中大多数问候动作,并附带描述。

工作系统
  • 码头工人:乘坐叉车在3个点间往返。支付随机,连胜可增收至上限。这是入门级工作,便于起步,适合AFK。
  • 垃圾收集:进入垃圾车,在城市捡拾垃圾(地图标记),用LALT步行拾取并放入车中。每件支付。这是入门至中级工作,强度较高但可AFK。
  • 卡车运输:高级工作。需投资取货(从批发商),运至商店出售获利。长期收益最高。

伤害系统
  • 服务器端伤害计算,根据武器子弹、基础伤害(硬编码)、击中部位和距离。
  • 伤害模式和处决系统。
  • 电击枪、擒拿和豆袋弹系统,专供LSPD。
  • 与SKY插件同步的刀伤害。
  • 基础/damagelist显示伤害列表。
  • 手臂中枪:降低武器技能,屏幕抖动。
  • 腿部中枪:绊倒,无法冲刺或跳跃。
  • /surgery在医院治愈所有伤势。

健身房脚本
  • 受单人模式启发的健身房系统。
  • 可训练肌肉和耐力,消耗饥饿、口渴和能量。
  • 饥饿和口渴通过餐厅购买食物自动补充。
  • 能量随时间自动恢复。
  • 单人模式弹出界面,带进度条和绿/红指示(增/减)。
  • SPRINT+C 或 /gymstats 显示当前统计框。
  • 根据肌肉和耐力,提升近战伤害并降低所受近战伤害。
  • 最大化耐力和近战可获自定义战斗风格(管理员也可设置,早起捐赠包功能)。
  • 所有健身房位置硬编码。

武器系统
  • 服务器端武器,支持自定义枪名。
  • 服务器端弹药,影响伤害计算。易添加或修改。
  • 武器在/q后保存,必要时移除(医院、监狱、ajail、ban、伤害模式中/q等),可存于车辆或财产。
  • 动态经销商脚本:派系可分配"emmet",自动冷却与补充,如枪柜,派系成员优惠价。
  • Emmet箱子脚本:派系在经销商处获/emmettip,有几率生成强力武器箱。类似Fortnite箱子:在150随机位置生成,用/emmetcrate抢劫。警察可没收,其他玩家可偶遇抢夺。靠近时小地图显示蓝色骷髅。
  • 未用"visible.pwn":原型(有bug),用于可见武器附着。若启用,先调整附件限制。
  • 服务器端驾驶射击:动画中用H返回车内。
  • 滥用时,管理员用/setgunrights禁枪,阻止使用。
  • 服务器端ID和弹药,便于取消无效数据,杜绝武器作弊。

dp系统
  • dp1:购种子种植。随时间成长,多阶段,有生病几率。使用渐愈伤势。
  • dp2:同dp1。使用增近战伤害。
  • dp3:同上。使用获临时护甲,持续补充。
  • 效果时长依品种和用量。
  • 分装系统,将dp分至不同容器。从杂货店或餐厅购容器。

拆车场
  • 带车至此赚快钱,模拟报废损坏车辆,然后开车离开获奖励。
  • 可派系拥有,抽取小额分成。

偷车系统
  • 撬锁和热线,带GUI小游戏。
  • 受GTA-W旧系统启发,仿真现实。

音箱系统
  • 流官方电台或自定义链接。
  • 通过SAMP对象编辑器放置,显示标签。
  • 所有者退出后,15分钟内可认领。
  • 管理员可移除。
  • 使用proxdetector通知附近玩家无人认领或移除。
  • 多年调试,确保与车辆/财产电台自动同步优先。

反作弊系统
  • 全面反作弊,2024年重写用Raknet。
  • 可切换部分反作弊(2024 WIP,未完成)。
  • 支持暂停玩家检测。
  • 检测作弊包括:
    • AFK
    • 空中破墙
    • 机器人连接(假连接)
    • 反兔跳
    • 偷车检测
    • 车辆改装作弊
    • 车辆粒子作弊
    • 车辆喷漆作弊
    • 车辆摇摆作弊
    • 车辆恶搞作弊
    • 车辆传送检测
    • 对话框防护(防崩溃、伪造等)
    • 双连接检测(登录已连账户或共享IP新连接)
    • 简单封禁规避脚本(扫描封禁玩家子网,匹配警报管理员)
    • 假死亡检测
    • 生命作弊检测(WIP,非近战武器易误报)
    • 金钱作弊检测
    • 无换弹作弊(含滚轮规避检测)
    • 快速射击检测
    • 监视检测
    • 喷气背包检测
    • 传送作弊检测
    • 弹药作弊
    • 武器作弊

附件系统
  • 动态购买点,可链接财产,业主获收入。
  • 购对象附着玩家。
  • 保存玩具及最后编辑位置于/q。
  • 用SAMP附件编辑系统。
  • 基础但实用。
  • 辅助函数识别骨骼、槽位等(与其他模块同步)。

动画系统
  • 基础动画模块。
  • 分类动画,带选项,每文件单动画。
  • "粒子"动画,如/piss, /shit, /puke, /shakebottle带效果。
  • 反滥用/stopanim(检测用于规避近战眩晕)。
  • 感谢Emmet_, Reyo, niCe, BigBear 和 DamianC(借鉴部分LS-RP动画)。

管理员系统
  • 数百命令,覆盖一切。
  • 监视、监听等系统。
  • 其他系统额外命令。
  • 报告存储,管理员获提醒(/ar接受,/dr拒绝)。
  • 基础级别系统,带标题、/ahide等。
  • 功能繁多,无法尽述。

助手系统
  • 问题经/ask提交,助手用/ah接受、/answer回复。
  • 问题存储,助手获提醒。
  • 部分小命令可用,见/staffhelp。

贡献者系统
  • 邀请加入/admin聊天,有/无权限(无、初级、管理者=隐藏管理员)。

喷漆标签系统
  • 静态喷漆,仿单人模式。
  • 自定义文本喷漆,带颜色系统。
  • 管理员追踪并擦除。
  • LSPD也可擦除。

手机脚本
  • 可点击文本绘制手机系统。
  • 保存联系人,用名呼叫/短信。
  • 基于对话/文本或基本命令。
  • 自定义背景、铃声、颜色。

警察脚本
  • 支持/carsign。
  • 小队和军械库。
  • Spooky建模的精美LSPD室内。
  • /bk 和 /panic(小地图标记)。
  • 全刑法代码,与/charge /ticket同步。
  • 复杂罚单系统,自动过期需支付。
  • 自定义0.3DL警笛光效。
  • 自定义/siren,与光效同步。
  • 间谍脚本。
  • 钉刺脚本。
  • 隔音审讯室。
  • 枪架。
  • 无MDC,但有命令查询财产、角色和车辆信息。
  • 复杂对话/请求搜身系统。

杀手脚本
  • 当前未启用:拨666创建合同。
  • 杀手访问呼叫者,手动菜单输入数据防元游戏。
  • 接受后标记目标。
  • 标记下击杀目标,禁用角色24小时。

玩家账户系统
  • 游戏内注册。
  • 基础教程讲解要领。
  • UCP支持邮件提示。
  • 最多5角色槽。
  • 复杂重生系统:财产、派系、公共点或最后位置。
  • 添加角色属性(身高、体重、眼色、发色、体型等)=> /examine系统。
  • /settings自定义界面设置,按玩家。
  • 众多其他功能,无法尽列。

大门系统
  • 创建自定义门、大门、车库门。
  • 分配派系、财产或玩家。
  • 支持动画或自动开。
  • 可转为收费站,警察/policetolls。
  • 支持自动关闭。

通行点系统
  • 添加/pass点,用于后门或后窗。
  • 全自定义:范围、颜色、名称、使用权限。
  • 支持步行或车辆通行(依类型)。

GPS系统
  • 导航至兴趣点。
  • 至enex /buy点。
  • 至房屋或财产。
  • 总找最近实体并排序显示。

自定义皮肤系统
  • 无需重启加载:拖文件至正确文件夹。
  • 特定文件名/目录格式解析baseid和character id。
  • /wardrobe可用。
  • 设计用于UCP上传,但无需也可,手动放置文件即可。
  • 自定义错误处理、缓存、加载容器。

自定义姓名标签系统
  • 玩家切换名/姓。
  • Raknet驱动。
  • 同步计分板、标签列表、proxdetector。
  • 自定义sscanf处理兼容玩家ID、氏族标签、firstname_lastname。
  • 易配置:移除氏族选项即禁用切换。
  • 首次重生或/settings选择偏好。

新闻系统
  • /breaking突发新闻(管理员/abreaking)。
  • 直播邀请脱口秀(/broadcasts调入/出)。
  • 派系类型=新闻。
  • 为企业加/mic支持。

玩家日志系统
  • SA-MP最先进日志,由Spooky编写。
  • 游戏内浏览器,按类型排序。
  • 全脚本使用,存储关键数据。
  • 显示最后75条。
  • 支持全日志、在线/离线玩家日志(/plogs, /plog, /oplog)。
  • 管理员级别限制。

服务器配置
  • 动态存储信息:
  • 服务器年份时间。
  • 公共/管理员motd。
  • 登录音乐。
  • 管理员聊天十六进制色。
  • 其他次要设置。

可放置/可持有物品系统
  • 玩家动态放置或持有对象。
  • 适用于举办BBQ或事件:/spawn自行映射对象。
  • 酒吧中/hold装备啤酒瓶或玻璃。还有食物等RP辅助物品。
  • Spooky制作:最佳沉浸方式,玩家自置RP对象或持道具,提升截图和环境氛围。


  [教程] open.mp/sa-mp 服务器开发规范
发布者: 小鸟unsigned - 02-28-2026, 11:55 AM - 板块: 教程 - 暂无回复

open.mp/sa-mp 服务器开发规范



为了避免开发者在服务器开发过程中,系统功能逐渐坍塌。本规范的引入,并非为了增加开发负担,而是为了建立一套对抗系统熵增的秩序,共同维护一个默认的社区标准和规范。

本规范欢迎所有人提交 PR 或 Issue 进行改进。所有采纳的修改都将记入贡献者名单,共同维护 SA?MP/open.mp 中文开发社区的良好生态。规范的生命力在于持续迭代,而非一成不变。

文件区分



其实文件夹怎么分没有铁打的标准,每个项目情况都不一样,但大致的思路都是差不多的

先按“谁先用谁、谁依赖谁”排顺序,再按“这是游戏里的哪个系统”来分组,没必要一开始就搞得很复杂,先写着写着自然就知道哪里该拆了

这里仅提供示范思路:
代码:
/Server
  ├── gamemodes/
  │    └── main.pwn           // 主文件,负责 #include 模块
  │    └── locale/            // 如果你打算做多语言的话
  │    └── utils/             // 自定义封装好的通用函数、工具(和 libraries 差不多)
  │    └── core/              // 低层、必须先加载的核心系统(不依赖业务)
  │    │   ├── config/        // 配置相关 如 服务器名称 版本 规则 服务器设置
  │    │   ├── shared/        // 所有模块的“通用语言” 共享静态定义数据 用于校验 杜绝跨模块的变量污染
  │    │   └── database/      // 数据库(如:MySQL)
  │    ├── world/             // 游戏世界环境相关(地图、动态对象、时间等等)
  │    └── modules/           // 所有功能模块存放处
  │         ├── players/      // 玩家模块
  │         ├── vehicles/     // 车辆模块
  │         ├── houses/       // 房屋模块
  │         └── admin/        // 管理员模块
  │    └── libraries/         // 第三方库 (如 mysql, sscanf等)
  │    └── filterscripts/     // 可动态加载的独立功能(脚本玩法、工具之类的附加项内容)

模块间通信准则



模块之间严禁直接操作对方的 static 变量,必须通过 API

如果 vehicles 模块需要获取玩家的某个状态,必须调用 Player_GetSomeStatus(playerid),严禁直接读取 gs_PlayerData

命名规范


  • 函数:PascalCase(所有单词首字母大写), 前缀是模块名 (如: Player_Load(playerid))。
  • 全局变量:
  • 前缀 g_ 代表全局变量 (gobal) 比如: new g_Var;
  • 前缀 gc_ 代表全局变量 (gobal const) 比如: new const gc_Var;
  • 前缀 gs_ 代表全局静态变量(作用域仅在当前模块内) (gobal static) 比如: static gs_Var;
  • 前缀 gsc_ 表示全局静态常数变量 (gobal static const) 比如: static const gsc_Var;
  • 局部变量: 首单词小写, 后面单词首字母大写,或小写的缩写 (如: new number, vehicleIndex, id, pos)
  • 常量/宏:大写 SNAKE_CASE (如: #define MAX_VEHICLES 2000)。
  • 文件:全小写,分割使用 '-' (如: player-main.inc、player-impl.inc);主文件 main.pwn
  • 枚举与数据结构规范: 采用 EMODULENAMEDATA 格式(如 E_PLAYER_DATA),全大写,采用 E_ 开头
  • 枚举成员采用: 模块名字段名 全大写 SNAKECASE, 便于快速区分、选择、修改等等
代码:
// 房屋模块
enum E_HOUSE_DATA
{
    HOUSE_DBID,
    HOUSE_OWNER[MAX_PLAYER_NAME],
    Float:HOUSE_ENTRANCE_X,
    Float:HOUSE_ENTRANCE_Y,
    Float:HOUSE_ENTRANCE_Z,
    bool:HOUSE_IS_LOCKED,
    HOUSE_PRICE
}
static gs_HouseData[MAX_HOUSES][E_HOUSE_DATA];

// 载具模块
enum E_VEHICLE_DATA
{
    VEHICLE_DBID,
    VEHICLE_MODEL,
    Float:VEHICLE_HEALTH,
    VEHICLE_OWNER_ID
}
static gs_VehicleData[MAX_VEHICLES][E_VEHICLE_DATA];

缩写准则



只允许通用缩写

如:ID (Identity), Pos (Position), Rot (Rotation), Max/Min, Msg (Message), Cmd (Command) 等等

错误示例:pLv(到底是 Level 还是 Leave?)

禁止:将 Owner 缩写为 O, 将 Price 缩写为 Pr,禁止使用无意义缩写

包含守卫:



打开任何一个 .inc 文件,第一眼能看到“这个文件要放在哪里、依赖谁”,不用翻 main.pwn 的 include 列表猜顺序或者人工记忆顺序,长期维护更加直观清晰

尤其是相同层级的脚本,由于相互之间可能存在依赖关系,比如房屋模块需要使用到玩家模块的信息(金钱等等),包含守卫可以很好地理清关系,避免脚本运行时出现问题,在编译阶段完美规避问题的出现

注意注释保持简短,别写成大段文档

代码:
// 防止重复包含 替换 SCRIPT_NAME 即可
#if defined _INC_SCRIPT_NAME
    #endinput
#endif
#define _INC_SCRIPT_NAME

// 模块之间的依赖说明
#if !defined _INC_OTHER_SCRIPT_NAME
    #error 需要包含 other-script.inc.
#endif

// 环境约束 确保编译环境正确
#if !defined _INC_open_mp
    #error 需要包含 open.mp.inc.
#endif

// 依赖项的显式校验 快速定位缺失依赖
#tryinclude <Pawn.RakNet>
#if !defined PAWNRAKNET_INC_
    #error 需要使用 Pawn.RakNet 插件
#endif

// 插件版本与功能对齐
#tryinclude <streamer>
#if !defined Streamer_IncludeFileVersion
    #error cannot read from file: "streamer.inc"
#elseif Streamer_IncludeFileVersion != 0x296
    #error 不兼容的 streamer 插件版本, 请使用 2.9.6 版本.
#endif

// 零开销的默认配置 可在后续代码中随时关闭或开启调试
#if !defined SCR_DEBUG
    #define SCR_DEBUG false
#endif

// 或者这样
#if SCR_DEBUG
    #define DebugMessage(%1) printf("[Script Debug] " %1)
    #define ErrorMessage(%1) printf("[Script Debug-Error] " %1)
#else
    #define DebugMessage(%1);
    #define ErrorMessage(%1);
#endif

ALS 钩子标准



用传统 ALS 钩子扩展回调/函数

示例:回调钩子 (OnGameModeInit)
代码:
// 替换 SCR 即可
public OnGameModeInit()
{
    #if defined SCR_OnGameModeInit
        return SCR_OnGameModeInit();
    #else
        return 1;
    #endif
}
#if defined _ALS_OnGameModeInit
    #undef OnGameModeInit
#else
    #define _ALS_OnGameModeInit
#endif
#define OnGameModeInit SCR_OnGameModeInit
#if defined SCR_OnGameModeInit
    forward SCR_OnGameModeInit();
#endif

示例:stock 函数钩子 (SetPlayerScore)
代码:
// 替换 SCR 即可
stock bool:SCR_SetPlayerScore(playerid, score)
{
    if(SetPlayerScore(playerid, score))
    {
        // ......
        return true;
    }
    return false;
}
#if defined _ALS_SetPlayerScore
    #undef SetPlayerScore
#else
    #define _ALS_SetPlayerScore
#endif
#define SetPlayerScore SCR_SetPlayerScore

函数作用域规范



  1. static stock (模块私有函数)
  • 作用域仅在当前 .inc 文件中, 它只在内部使用
  • 命名以 下划线 开头,采用 _ModuleName_FunctionName 格式(如 _House_CheckDistance

  1. stock (模块公开接口)
  • 定义:作用域全服务器, 命名采用 ModuleName_FunctionName 格式

代码:
#define HOUSE_TAX_RATE 0.1

// 只有本模块能用,外部无法使用,也不会冲突
static stock _House_CalculateTax(houseid)
{
    return floatround(float(gs_HouseData[houseid][HOUSE_PRICE]) * HOUSE_TAX_RATE);
}

// 对外接口
stock bool:House_GetTotalCost(houseid, &cost)
{
    if(houseid < 0 || houseid >= MAX_HOUSES)
        return false;

    // 内部逻辑调用私有函数
    cost = gs_HouseData[houseid][HOUSE_PRICE] + _House_CalculateTax(houseid);
    return true;
}

注释标准



对于函数的说明注释使用 Doxygen 风格,直接关系到长期维护,至少包含 功能简述、参数、返回值、注意事项

代码:
/**
* 获取玩家当前拥有的房产数量。
* @param playerid 玩家ID
* @return 房产数量,失败返回 -1
*/
stock Player_GetPropertyCount(playerid);

标签规范



用 enum + 宏定义标签,确保类型安全。

示例:WEATHER
代码:
enum WEATHER:
{
    WEATHER_UNKNOWN = -1,
    WEATHER_EXTRASUNNY_LA = 0,
    WEATHER_SUNNY_LA,
    WEATHER_EXTRASUNNY_SMOG_LA,
    WEATHER_SUNNY_SMOG_LA,
    WEATHER_CLOUDY_LA,
    WEATHER_SUNNY_SF,
    ...,
    ...
}

错误处理标准



规范:返回值语义化

逻辑判断返回类型 bool:

成功返回 ID ( >= 0),失败返回 INVALID_..._ID (-1)

开源协议 (License)



本项目遵循 MIT License 协议。
你可以自由地使用、修改和分发本规范。