| 欢迎, 游客 |
您必须先 注册 才能在我们的网站上发帖.
|
| 论坛统计 |
» 会员总数: 8
» 新进会员: 柚子爱吃包子
» 主题总数: 30
» 帖子总数: 40
完整统计
|
| 在线用户 |
当前共有 3 位在线用户. » 0 会员 | 3 游客
|
| 最新主题 |
[插件] kook-connect
板块: 插件
最后发表: siwode
05-01-2026, 01:07 AM
» 回复: 0
» 浏览: 46
|
openmp/samp联机服务器插件开发 完全指南
板块: 教程
最后发表: 柚子爱吃包子
04-09-2026, 09:14 PM
» 回复: 2
» 浏览: 188
|
圣安地列斯联机二十年:SA:MP 与 open.m...
板块: 综合讨论
最后发表: siwode
03-26-2026, 12:40 PM
» 回复: 3
» 浏览: 216
|
[考古] openmp 常见问题解答 | 2020...
板块: 综合讨论
最后发表: 小鸟unsigned
03-24-2026, 04:55 PM
» 回复: 0
» 浏览: 44
|
[考古]MTA 团队与 open.mp 团队早期就...
板块: 综合讨论
最后发表: 小鸟unsigned
03-24-2026, 04:53 PM
» 回复: 0
» 浏览: 42
|
服务器开发 精选资源清单
板块: 发布
最后发表: 小鸟unsigned
03-22-2026, 12:24 AM
» 回复: 0
» 浏览: 137
|
[教程] 枚举器 enum 详细讲解 原文作者: ...
板块: 教程
最后发表: 小鸟unsigned
03-22-2026, 12:11 AM
» 回复: 0
» 浏览: 60
|
[教程] Pawn 中的压缩字符串 原文作者: E...
板块: 教程
最后发表: 小鸟unsigned
03-21-2026, 11:52 PM
» 回复: 0
» 浏览: 53
|
[修仙者篇] AMX 汇编文档
板块: 教程
最后发表: 小鸟unsigned
03-21-2026, 11:20 PM
» 回复: 1
» 浏览: 83
|
[wiki系列] openmp/samp 关键字:...
板块: 教程
最后发表: 小鸟unsigned
03-21-2026, 11:06 PM
» 回复: 0
» 浏览: 50
|
|
|
| [wiki系列] openmp/samp 关键字:初始值设定项 |
|
发布者: 小鸟unsigned - 03-21-2026, 11:05 PM - 板块: 教程
- 暂无回复
|
 |
[wiki系列] openmp/samp 关键字:初始值设定项
来自 SA-MP Wiki
目录
const
代码: new const
MY_CONSTANT[] = {1, 2, 3};
const 并不常用,但它用于声明一个无法被代码修改的变量。它的用途包括:让函数的数组参数编译得更高效,或者创建一个类似 #define 但却是数组的常量。const 是一个修饰符,必须与 new 或其他变量声明关键字搭配使用。如果你尝试修改 const 变量,编译器会报错。
enum
枚举(enum)是一个非常实用的系统,用于表示大量数据并快速修改常量。主要用途有三类:- 替代一大堆
#define 语句
- 用符号名称表示数组下标(其实本质相同,只是写法不同)
- 创建新的标签(tag)
最常见的用法是定义数组结构:
代码: enum E_MY_ARRAY
{
E_MY_ARRAY_MONEY,
E_MY_ARRAY_GUN
}
new
gPlayerData[MAX_PLAYERS][E_MY_ARRAY];
public OnPlayerConnect(playerid)
{
gPlayerData[playerid][E_MY_ARRAY_MONEY] = 0;
gPlayerData[playerid][E_MY_ARRAY_GUN] = 5;
}
这会为每个玩家创建一个包含两个槽位的数组。连接时,E_MY_ARRAY_MONEY 槽位设为 0,E_MY_ARRAY_GUN 槽位设为 5。
如果不用 enum,代码会变成这样:
代码: new
gPlayerData[MAX_PLAYERS][2];
public OnPlayerConnect(playerid)
{
gPlayerData[playerid][0] = 0;
gPlayerData[playerid][1] = 5;
}
可读性明显变差——下标 0 和 1 分别代表什么?而且扩展性差:如果要在中间插入一个新槽位,就得手动把所有 1 改成 2,非常容易出错。而使用 enum,只需添加一行:
代码: enum E_MY_ARRAY
{
E_MY_ARRAY_MONEY,
E_MY_ARRAY_AMMO,
E_MY_ARRAY_GUN
}
new
gPlayerData[MAX_PLAYERS][E_MY_ARRAY];
public OnPlayerConnect(playerid)
{
gPlayerData[playerid][E_MY_ARRAY_MONEY] = 0;
gPlayerData[playerid][E_MY_ARRAY_AMMO] = 100;
gPlayerData[playerid][E_MY_ARRAY_GUN] = 5;
}
重新编译后,所有索引自动更新。
enum 的完整格式与工作原理
代码: enum NAME (modifier)
{
NAME_ENTRY_1 = value,
NAME_ENTRY_2 = value,
...
NAME_ENTRY_N = value
}
大部分内容是隐含的。默认 modifier 为 (+= 1),表示每个值都是前一个值 + 1:
代码: enum E_EXAMPLE
{
E_EXAMPLE_0,
E_EXAMPLE_1,
E_EXAMPLE_2
}
结果:E_EXAMPLE_0 = 0,E_EXAMPLE_1 = 1,E_EXAMPLE_2 = 2,E_EXAMPLE = 3(枚举名本身等于最后一个值)。
修改 modifier 的例子:
代码: enum E_EXAMPLE (+= 5)
{
E_EXAMPLE_0,
E_EXAMPLE_1,
E_EXAMPLE_2
}
结果:0, 5, 10, 15。如果声明 new gEnumArray[E_EXAMPLE];,数组大小为 15,但只能通过枚举名访问 0、5、10(仍可使用普通数字访问)。
另一个例子(乘法):
代码: enum E_EXAMPLE (*= 2)
{
E_EXAMPLE_0,
E_EXAMPLE_1,
E_EXAMPLE_2
}
全部变为 0。解决方法是手动指定初始值:
代码: enum E_EXAMPLE (*= 2)
{
E_EXAMPLE_0 = 1,
E_EXAMPLE_1,
E_EXAMPLE_2
}
结果:1, 2, 4, 8。
提示:数组用途时强烈建议只用 += 1,其他 modifier 容易导致混乱。
enum 中可以包含数组
代码: enum E_EXAMPLE
{
E_EXAMPLE_0[10],
E_EXAMPLE_1,
E_EXAMPLE_2
}
结果:E_EXAMPLE_0 = 0,E_EXAMPLE_1 = 10,E_EXAMPLE_2 = 11,E_EXAMPLE = 12(而不是很多人以为的 0、1、2、3)。
enum 项可以带标签
代码: enum E_MY_ARRAY
{
E_MY_ARRAY_MONEY,
E_MY_ARRAY_AMMO,
Float:E_MY_ARRAY_HEALTH,
E_MY_ARRAY_GUN
}
new
gPlayerData[MAX_PLAYERS][E_MY_ARRAY];
public OnPlayerConnect(playerid)
{
gPlayerData[playerid][E_MY_ARRAY_MONEY] = 0;
gPlayerData[playerid][E_MY_ARRAY_AMMO] = 100;
gPlayerData[playerid][E_MY_ARRAY_GUN] = 5;
gPlayerData[playerid][E_MY_ARRAY_HEALTH] = 50.0; // 不会产生标签不匹配警告
}
enum 本身也可以作为标签使用(位标志)
代码: enum E_MY_TAG (<<= 1)
{
E_MY_TAG_NONE,
E_MY_TAG_VAL_1 = 1,
E_MY_TAG_VAL_2,
E_MY_TAG_VAL_3,
E_MY_TAG_VAL_4
}
new
E_MY_TAG:gMyTagVar = E_MY_TAG_VAL_2 | E_MY_TAG_VAL_3;
gMyTagVar 值为 6(4 | 2),并带有自定义标签。直接赋值 gMyTagVar = 7; 会产生标签不匹配警告,但可以用强制转换绕过:
代码: gMyTagVar = E_MY_TAG:7;
进阶用法(掩码 + 组合):
代码: enum E_MY_TAG (<<= 1)
{
E_MY_TAG_NONE,
E_MY_TAG_MASK = 0xFF,
E_MY_TAG_VAL_1 = 0x100,
E_MY_TAG_VAL_2,
E_MY_TAG_VAL_3,
E_MY_TAG_VAL_4
}
new
E_MY_TAG:gMyTagVar = E_MY_TAG_VAL_2 | E_MY_TAG_VAL_3 | (E_MY_TAG:7 & E_MY_TAG_MASK);
结果为 1543(0x0607)。
用无名 enum 替代 #define
传统写法:
代码: #define TEAM_NONE 0
#define TEAM_COP 1
#define TEAM_ROBBER 2
#define TEAM_CIV 3
#define TEAM_CLERK 4
#define TEAM_DRIVER 5
改用 enum 自动赋值:
代码: enum
{
TEAM_NONE,
TEAM_COP,
TEAM_ROBBER,
TEAM_CIV,
TEAM_CLERK,
TEAM_DRIVER
}
值完全相同,使用方式也一样。
更强大的位标志写法(推荐):
代码: enum (<<= 1)
{
TEAM_NONE,
TEAM_COP = 1,
TEAM_ROBBER,
TEAM_CIV,
TEAM_CLERK,
TEAM_DRIVER,
TEAM_ADMIN
}
现在 TEAM_COP = 1(二进制 00000001)、TEAM_ROBBER = 2(00000010)等。一个变量就能同时表示多个团队:
- 添加团队:
gPlayerTeam[playerid] |= TEAM_COP;
- 移除团队:
gPlayerTeam[playerid] &= ~TEAM_COP;
- 检查是否在团队:
if (gPlayerTeam[playerid] & TEAM_COP)
非常简洁且强大。
forward
forward 用于告诉编译器“这个函数稍后会定义”。所有 public 函数都必须 forward,也可以用于其他地方。
语法:
代码: forward MyPublicFunction(playerid, const string[]);
代码: forward MyPublicFunction(playerid, const string[]);
public MyPublicFunction(playerid, const string[])
{
}
除了 public 函数外,forward 还能解决一种罕见的“reparse”警告(当函数返回带标签的值如 Float 时,在声明前被调用):
有警告的写法:
代码: main()
{
new Float:myVar = MyFloatFunction();
}
Float:MyFloatFunction()
{
return 5.0;
}
解决方法 1(函数前置):
代码: Float:MyFloatFunction()
{
return 5.0;
}
main()
{
new Float:myVar = MyFloatFunction();
}
解决方法 2(forward):
代码: forward Float:MyFloatFunction();
main()
{
new Float:myVar = MyFloatFunction();
}
Float:MyFloatFunction()
{
return 5.0;
}
注意:forward 也要带返回值标签。
native
native 函数是由虚拟机(openmp(samp) 服务器或插件)提供的函数,而不是脚本自己定义的。你只能声明已存在于 openmp(samp) 或插件中的 native。
用途 1:让自定义函数出现在 qawno 右侧函数列表中(即使是假的):
代码: /*
native MyFunction(playerid);
*/
注释会被 qawno 识别加入列表,但编译器会忽略。
用途 2:重命名 / 重载函数:
代码: native my_print(const string[]) = print;
现在 print 函数在脚本中已不存在(但服务器内部仍存在)。你可以重新定义它:
代码: print(const string[])
{
my_print("Someone called print()");
my_print(string);
}
以后所有 print() 调用都会先执行你的代码,再调用原函数。
new
new 是变量声明的核心关键字之一。
创建变量并赋值为 5。未赋值时默认为 0:
代码: new myVar;
printf("%d", myVar); // 输出 0
作用域(scope)由大括号 {} 决定,声明在括号内的变量只能在该括号内使用。
代码: if (a == 1)
{
new myVar = 5;
printf("%d", myVar); // 可以
if (myVar == 1)
{
printf("%d", myVar); // 可以
}
}
// 这里无法使用 myVar,会报错
printf("%d", myVar);
全局变量(在函数外声明)从声明位置之后可在整个脚本中使用(包括 #include 的其他文件)。
static 与 new 的区别详见下方 static 部分。
operator
允许为自定义标签重载运算符。
示例(大端序转换):
代码: stock BigEndian:operator=(b)
{
return BigEndian:(((b >>> 24) & 0x000000FF) | ((b >>> 8) & 0x0000FF00) | ((b << 8) & 0x00FF0000) | ((b << 24) & 0xFF000000));
}
main()
{
new BigEndian:a = 7;
printf("%d", _:a); // 输出 117440512(因为小端序读取大端序数据)
}
可重载的运算符:
+, -, *, /, %, ++, --, ==, !=, <, >, <=, >=, !, =
你可以让运算符做任何事(不一定是原本功能):
代码: stock BigEndian:operator+(BigEndian:a, BigEndian:b)
{
return BigEndian:42;
}
a + b 永远返回 42。
public
public 让函数对虚拟机可见,允许 openmp(samp) 服务器从外部直接调用(而非仅在脚本内部调用)。也可用于变量(允许服务器读写),但 openmp(samp) 中极少使用。
public 函数的名称会存储在 .amx 文件中(普通函数只存地址),这也是反编译的难点之一。
通过名称调用 public 函数:
代码: forward MyPublicFunc();
main()
{
CallLocalFunction("MyPublicFunc", "");
}
public MyPublicFunc()
{
printf("Hello");
}
也可使用 @ 前缀:
代码: forward MyPublicFunc();
forward @MyOtherPublicFunc(var);
main()
{
CallLocalFunction("MyPublicFunc", "");
SetTimerEx("@MyOtherPublicFunc", 5000, 0, "i", 7);
}
public MyPublicFunc()
{
printf("Hello");
}
@MyOtherPublicFunc(var)
{
printf("%d", var);
}
所有 openmp(samp) 回调(如 OnPlayerConnect)都是 public,由服务器自动调用。
注意:public 函数也可以像普通函数一样直接调用(速度更快):
代码: forward MyPublicFunc();
main()
{
MyPublicFunc(); // 直接调用,比 CallLocalFunction 快
}
public MyPublicFunc()
{
printf("Hello");
}
static
static 全局变量类似 new,但作用域更小(仅限于声明所在的文件或 #section)。
对比 new 的跨文件可见性,static 仅限本文件:
代码: // file2.pwn 中无法访问 file1.pwn 里的 static 变量
局部 static:作用域与 new 相同(仅大括号内),但值在函数多次调用间保留(不像 new 每次重置)。
代码: MyFunc()
{
new i = 0;
printf("%d", i);
i++;
printf("%d", i);
}
调用 4 次输出:
改为 static 后:
初始化值仅在第一次调用时生效。
static 函数只能在声明所在文件中调用,适合“私有”函数。
stock
stock 用于声明可能不会被使用的变量或函数,避免产生“unused”警告。
代码: new stock
gMayBeUsedVar;
static stock
g_sMayBeUsedVar;
如果被使用则编译保留;如果未使用则完全剔除(不像 #pragma unused 只隐藏警告)。
最常见用途:编写库时。库作者无法预知用户会用哪些函数,使用 stock 可避免大量无用警告。
```pawn
stock Func1()
{
printf("Hello");
}
stock Func2()
{
printf("Hi");
}
#GTA# #圣安地列斯# #侠盗猎车手# #圣安地列斯联机# #samp# #gta联机# #gtasa联机# #openmp# #omp# #open.mp# #gtasa#
社区交流群: 673335567
论坛: https://open-mp.cn/
|
|
|
|
| [wiki系列] openmp/samp 控制结构 |
|
发布者: 小鸟unsigned - 03-21-2026, 11:04 PM - 板块: 教程
- 暂无回复
|
 |
[wiki系列] openmp/samp 控制结构
来自 SA-MP Wiki
目录
条件语句
if
if 语句会检查某个条件是否成立,如果成立则执行对应代码。
代码: new
a = 5;
if (a == 5)
{
print("a is 5");
}
if 后面的括号内是条件,你可以进行多种判断(详见运算符部分)。
条件中不仅可以是变量,也可以是函数:
代码: if (SomeFunction() == 5)
{
print("SomeFunction() is 5");
}
还可以组合多个条件:
代码: new
a = 5,
b = 3;
if (a == 5 && b != 3)
{
print("Won't be printed");
}
上面检查 a == 5 并且 b != 3,但 b 是 3,所以整个条件为假。
使用 ||(或):
代码: new
a = 5,
b = 3;
if (a == 5 || b != 3)
{
print("Will be printed");
}
只要有一个条件为真,整个 if 就成立(即使 b != 3 为假,因为 a == 5 为真)。
还可以链式比较(无需显式写多个 &&):
代码: new
idx = 3;
if (0 < idx < 5)
{
print("idx is greater than 0 and less than 5!");
}
运算符
条件判断中常用的运算符及其含义:
比较运算符
| 运算符 |
含义 |
示例 |
| == |
左边等于右边 |
if (Left == Right) |
| != |
左边不等于右边 |
if (Left != Right) |
| > |
左边大于右边 |
if (Left > Right) |
| >= |
左边大于等于右边 |
if (Left >= Right) |
| < |
左边小于右边 |
if (Left < Right) |
| <= |
左边小于等于右边 |
if (Left <= Right) |
逻辑运算符
| 运算符 |
含义 |
示例 |
| && |
并且(AND) |
if (Left && Right) |
|
|
|
或者(OR) |
if (Left || Right) |
| ! |
非(NOT) |
if (!Variable) |
|
都不(NOR) |
if (!(Left || Right)) |
|
不是都(NAND) |
if (!(Left && Right)) |
|
异或(XOR) |
if (!(Left && Right) && (Left || Right)) |
|
同或(NXOR) |
if ((Left && Right) || !(Left || Right)) |
括号
括号决定运算顺序:
代码: new
a = 3,
b = 3,
c = 1;
if (a == 5 && b == 3 || c == 1)
{
print("Will this be called?");
}
有两种可能的解释:
(a == 5 && b == 3) || c == 1
a == 5 && (b == 3 || c == 1)
第一种:先判断 a==5 && b==3(为假),再判断 c==1(为真),整体为真。
第二种:先判断 b==3 || c==1(为真),再判断 a==5(为假),整体为假。
强烈建议始终使用括号,既能明确逻辑,也能避免歧义。
else
else 在 if 条件不成立时执行:
代码: new
a = 5;
if (a == 3) // 假
{
print("Won't be called");
}
else
{
print("Will be called as the check failed");
}
else if
else if 用于在第一个 if 不成立时继续检查其他条件:
代码: new
a = 5;
if (a == 1)
{
print("Will be called if a is 1");
}
else if (a == 5)
{
print("Will be called if a is 5");
}
else
{
print("All other numbers");
}
你可以连续写多个 else if,但一个 if 组中只能有一个 else。
注意:else if 只在最初的 if 失败时才检查,且变量值以检查开始时的状态为准。
?:(三元运算符)
? 和 : 组合成三元运算符,可将 if-else 写在单行内:
代码: new
a,
b = 3;
if (b == 3)
{
a = 5;
}
else
{
a = 7;
}
可简化为:
代码: new
a,
b = 3;
a = (b == 3) ? 5 : 7;
(条件) ? 真值 : 假值
也可以嵌套(类似多个 else if):
代码: new
a,
b = 3;
a = (b == 1) ? 2 : (b == 2) ? 3 : (b == 3) ? 4 : 5;
循环
while ()
while 循环在条件成立时重复执行代码:
代码: new
a = 9;
while (a < 10)
{
// 循环内的代码
a++;
}
每次到达 } 时会回到条件重新判断。
for ()
for 是压缩版的 while 循环,包含三部分:初始化、条件、递增。
代码: for (new a = 9; a < 10; a++)
{
// 循环内的代码
}
常用遍历玩家示例:
代码: for (new i, a = GetMaxPlayers(); i < a; i++)
{
if (IsPlayerConnected(i))
{
// do something
}
}
三部分均可省略(留空):
代码: new a = 9;
for ( ; a < 10; )
{
a++;
}
do-while
do-while 先执行代码,再判断条件,因此至少执行一次:
代码: new
a = 10;
do
{
// 循环内的代码
a++;
}
while (a < 10); // 注意分号
if-goto
循环本质上就是 if + goto 的结构化形式(不推荐直接使用 goto):
代码: new
a = 9;
loop_start:
if (a < 10)
{
// 循环内的代码
a++;
goto loop_start;
}
OBOE(Off By One Error)
OBOE 是最常见的循环错误之一——循环多跑或少跑一次。
代码: new
a = 0,
b[10];
while (a <= sizeof (b))
{
b[a] = 0;
}
上面会尝试访问 b[10](越界),导致 OOB(Out Of Bounds)错误。
特别注意:do-while 永远至少执行一次,更容易出现 OBOE。
switch
switch
switch 是结构化的 if / else if / else,更清晰:
代码: new
a = 5;
switch (a)
{
case 1:
{
// 不会执行
}
case 2:
{
// 不会执行
}
case 5:
{
// 会执行
}
default:
{
// 不会执行
}
}
switch 只调用一次判断表达式(比连续 else if 更高效)。
case
case 支持单个值、列表或范围:
代码: case 1, 2, 3, 4: // 列表
case 1 .. 4: // 范围(推荐连续数字)
default
相当于 else,所有 case 都不匹配时执行。
单行语句
goto
goto 直接跳转到标签(不推荐使用,会破坏程序流程)。
break
break 立即跳出当前循环:
代码: for (new a = 0; a < 10; a++)
{
if (a == 5) break;
}
continue
continue 跳过本次循环剩余代码,直接进入下一次迭代:
代码: for (new a = 0; a < 3; a++)
{
if (a == 1) continue;
printf("a = %d", a);
}
输出:
注意:在某些循环(尤其是 while)中使用 continue 时要小心,可能导致无限循环。
return
return 立即结束当前函数,返回到调用处:
代码: main()
{
print("1");
MyFunction(1);
print("3");
}
MyFunction(num)
{
if (num == 1)
{
return; // 直接返回,不执行下面的 print
}
print("2");
}
输出:
也可以返回具体值:
注意:如果函数有时返回值,有时不返回值,必须在所有路径都显式 return 一个值。
#GTA# #圣安地列斯# #侠盗猎车手# #圣安地列斯联机# #samp# #gta联机# #gtasa联机# #openmp# #omp# #open.mp# #gtasa#
社区交流群: 673335567
论坛: https://open-mp.cn/
|
|
|
|
| [wiki系列] openmp/samp脚本基础 |
|
发布者: 小鸟unsigned - 03-21-2026, 10:34 PM - 板块: 教程
- 暂无回复
|
 |
[wiki系列] openmp/samp 脚本基础
来自 SAMP Wiki
目录
入门!
下面是一个可能最简单的脚本示例:
代码: #include <open.mp>
main()
{
print("Hello World!");
return 1;
}
我们将逐一讲解各个部分,先从第一行开始。
Include
这一行会把 qawno/includes/open.mp.inc 文件中的所有代码加载到你的脚本中,这样你就可以使用它提供的一切内容。其中它还包含了:
代码: #include <args>
#include <console>
#include <core>
#include <file>
#include <float>
#include <string>
#include <time>
#include <omp_core>
#include <omp_player>
#include <omp_actor>
#include <omp_checkpoint>
#include <omp_class>
#include <omp_database>
#include <omp_dialog>
#include <omp_gangzone>
#include <omp_http>
#include <omp_menu>
#include <omp_network>
#include <omp_object>
#include <omp_pickup>
#include <omp_textdraw>
#include <omp_variable>
#include <omp_vehicle>
#include <omp_textlabel>
#include <omp_npc>
这些文件包含了 open.mp(samp) 的所有核心功能,因此只需这一行,你就拥有了 open.mp(samp) 的全部函数(函数后面会详细说明)。
调用
接下来是函数调用的两部分。main() 是你自己编写代码的函数,由外部调用;print(string[]) 是代码在别处定义、由你调用的函数。目前这个脚本只会加载、打印一行字符串(即在服务器控制台输出 “Hello World!”,不带引号——这是所有脚本语言的传统),然后结束。
这条语句将值 1 返回给调用 main 的地方,告诉它执行结果(具体返回值在这里不重要,但在其他地方很重要)。
你现在已经写出了第一个(非常基础)的脚本。如果你在 qawno 中选择“文件 → 新建”,它会给你一个更大的模板,包含所有回调(见下文),包括 main(严格来说它不是回调,但行为类似)。
语句
print 和 return 行都以 ;(分号)结尾,分号表示一条语句的结束(语句是一组函数和运算符的组合,用于完成某项操作,类似自然语言中的句子)。大多数人把每条语句写在单独一行,但这不是必须的,只是为了更清晰。下面这种写法同样有效:
代码: main() {
print("Hello World!");
return 1;
}
{}(大括号)用于将一组语句组合在一起执行(类似自然语言中的段落)。如果你写成:
代码: main()
print("Hello World!");
return 1;
编译器会报错,因为 return 1; 没有被括起来,不属于 main 函数。大括号将多条语句组合成一条复合语句,而函数体只能包含一条语句。没有大括号时,print 和 return 是两条独立的语句,函数只能有一条,所以第二条语句就游离在函数之外,这是非法的。
不过,你也可以用逗号运算符 , 来扩展复合语句,但不推荐这样做(不是最佳编码习惯)。示例如下:
代码: main()
print("Hello World!"), return 1;
函数
函数是一段完成特定任务的代码,可以从其他地方调用。它还可以将执行结果返回给调用它的地方。
调用
代码: print("Hello World!");
如“入门”部分所述,这会调用 print 函数(定义在 open.mp.inc 中,所以需要 #include),并让它在服务器控制台显示 “Hello World!”。
函数由函数名(如 print)和参数列表(用 () 括起来)组成。参数列表向函数传递额外数据。如果没有参数,你就需要成千上万个函数:
代码: printa();
printaa();
printab();
...
函数可以有任意数量的参数(从 0 开始,至少支持 128 个):
代码: printf("Hello World!", 1, 2, 3, 4, 5, 6);
现在不用关心这个函数具体做什么,只需知道它有 7 个参数,用逗号分隔。
定义
除了调用已有函数,你也可以自己编写并调用函数:
代码: #include <open.mp>
main()
{
return MyFunction();
}
MyFunction()
{
print("Hello World!");
return 1;
}
这段代码功能与最初的示例完全相同,只是结构不同。当模式启动时 main() 被自动调用,它再调用自定义函数 MyFunction()。MyFunction() 在控制台打印消息,然后返回 1 给 main(),main() 再把这个值返回给服务器。
因为 return MyFunction(); 是一条完整语句,你也可以写成:
代码: #include <open.mp>
main() return MyFunction();
MyFunction()
{
print("Hello World!");
return 1;
}
但大多数人为了清晰起见还是分开写。你也可以完全不使用 MyFunction 的返回值:
代码: #include <open.mp>
main()
{
MyFunction();
return 1;
}
MyFunction()
{
print("Hello World!");
return 1;
}
参数
参数是一种特殊的变量,你不需要自己声明,它来自调用函数的地方:
代码: #include <open.mp>
main()
{
return MyFunction("Hello World!");
}
MyFunction(const string[])
{
print(string);
return 1;
}
这段代码功能相同,但现在我们把要显示的内容传给了 MyFunction()。调用时把字符串 “Hello World!” 传递给函数,存储在名为 string 的变量中([] 表示这是一个数组,后面会解释)。然后调用 print,把 string 的内容传给它(没有引号,所以它是一个变量)。
变量
变量本质上是内存中的一块空间,用于存储数据,可以随时读取和修改。变量由一个或多个 cell 组成,一个 cell 是 32 位(4 字节),默认有符号,可存储 -2147483648 到 2147483647(注意 -2147483648 在 PAWN 中定义不完善,显示时可能异常)。由多个 cell 组成的变量叫数组,字符串是一种特殊的数组,每个 cell 存放一个字符(打包字符串每个 cell 可存 4 个字符,但这里不讨论)。
声明
要创建一个新变量,必须先声明它:
这会创建一个名为 myVariable 的变量,初始值为 0。
赋值
声明变量并设置初始值为 7。现在打印它会显示 7。要打印非字符串变量,需要使用前面提到的 printf():
代码: new
myVariable = 7;
printf("%d", myVariable);
现在只需知道这会在服务器控制台输出变量的值(当前是 7)。
代码: new
myVariable = 7;
printf("%d", myVariable);
myVariable = 8;
printf("%d", myVariable);
这段代码会先打印 7,然后把变量改为 8,再打印新的值。
变量操作示例(更多运算符请参考其他章节):
代码: myVariable = myVariable + 4; // 等价于
myVariable += 4; // 增加 4
myVariable -= 4; // 减少 4
myVariable *= 4; // 乘以 4
myVariable /= 4; // 除以 4
数组
声明
数组是一种可以同时存储多个数据、动态访问的变量。数组大小在编译时就固定了,必须提前知道需要多少个槽位。最常见的例子是 MAX_PLAYERS 数组,每个玩家一个槽位,确保数据不会互相干扰。
这会创建一个有 5 个槽位的数组。不能这样做:
代码: new
myVariable = 5,
myArray[myVariable]; // 错误!
因为 PAWN 在编译时就分配内存,数组大小必须是常量。
访问
要给数组某个位置赋值,必须指定下标(index),下标可以是变量:
代码: new
myArray[5];
myArray[2] = 7;
数组现在的内容是:0, 0, 7, 0, 0
注意:下标从 0 开始计数,而不是 1。
如果你尝试访问 myArray[5](超出范围),可能会导致服务器崩溃。
下标可以是数字、变量,甚至是返回值的函数。
代码: new
myArray[5],
myIndex = 2;
myArray[myIndex] = 7;
数组中的元素可以像普通变量一样使用:
代码: myArray[2] = myArray[2] + 1;
myArray[2] += 1;
myArray[2]++;
示例
最常见的数组是 MAX_PLAYERS 数组(MAX_PLAYERS 是一个常量,默认 1000)。下面对比用普通变量和数组处理 4 个玩家数据(假设 MAX_PLAYERS 为 4):
传统写法(4 个变量):
代码: new
gPlayer0, gPlayer1, gPlayer2, gPlayer3;
SetPlayerValue(playerid, value)
{
switch (playerid)
{
case 0: gPlayer0 = value;
case 1: gPlayer1 = value;
case 2: gPlayer2 = value;
case 3: gPlayer3 = value;
}
}
数组写法(推荐):
代码: new
gPlayers[MAX_PLAYERS];
SetPlayerValue(playerid, value)
{
gPlayers[playerid] = value;
}
数组写法无论玩家数量多少,都只需要一行代码,清晰且高效。
字符串
基本用法
字符串是一种特殊的数组,用于存放多个字符组成文本。每个字符默认占用一个 cell。字符串以 NULL 终止(遇到 0 就结束,不是字符 '0')。
代码: new
myString[16] = "Hello World!";
这会创建一个可容纳 15 个字符的字符串,初始值为 “Hello World!”。内部实际存储为:
代码: 104 101 108 108 111 0 x x x x x x x x x x
(0 是终止符,后面的 x 无关紧要)
你可以像操作数组一样修改字符串:
代码: new
myString[16] = "Hello World!";
myString[1] = 97; // 97 是 'a'
结果变为 “hallo”。
更易读的写法:
单引号表示单个字符。
把某个位置设为终止符:
代码: myString[1] = '\0'; // 或 = 0;
字符串会变成 “h”。
转义字符
反斜杠 \ 是特殊字符,用于创建无法直接输入的字符:
代码: new
myString[4] = "\""; // 字符串内容就是一个双引号 "
常用转义序列:
| 转义符 |
含义 |
说明 |
\0 / EOS |
NULL 字符 |
字符串结束 |
\n |
换行 |
Linux 新行(Windows 也可用) |
\r |
回车 |
Windows 新行用 \r\n |
\\ |
反斜杠 |
实际输出 \ |
\' |
单引号 |
在单引号中用 '\' ' |
\" |
双引号 |
在字符串中用 \" |
\xNNN; |
十六进制字符 |
用十六进制值设置字符 |
\NNN; |
十进制字符 |
用十进制值设置字符 |
标签
标签是变量的额外信息,用于定义其用途和可使用范围。标签分为强标签(首字母大写)和弱标签。
示例:
Float 就是标签,表示这是一个浮点数(小数)。
代码: native SetGravity(Float:gravity);
这要求参数必须是 Float 类型:
代码: SetGravity(6.0);
new Float:fGrav = 5.0;
SetGravity(fGrav);
使用错误标签会产生 tag mismatch 警告:
代码: SetGravity(MyTag:7); // 错误
标签区分大小写。
你可以自定义标签:
代码: new myTag: variable = 0,
AppleTag: another = 1;
直接相加时需要用 _: 去除标签,否则会报标签不匹配。
作用域
作用域决定变量在哪里可用。主要有四种:局部、静态局部、全局、全局静态。变量必须先声明后使用。
局部(local)
在函数内用 new 声明:
代码: MyFunc()
{
new var1 = 4;
printf("%d", var1);
{
new var2 = 8;
printf("%d %d", var1, var2);
}
// var2 已失效
}
// var1 已失效
局部变量每次进入函数都会重置:
代码: for (new i = 0; i < 3; i++)
{
new j = 1;
printf("%d", j);
j++;
}
输出:
静态局部(static local)
与局部变量作用域相同,但值会在函数多次调用间保留:
代码: for (new i = 0; i < 3; i++)
{
static j = 1;
printf("%d", j);
j++;
}
输出:
全局(global)
在函数外声明,可在任何函数中使用,且永不重置:
代码: new
gMyVar = 4;
MyFunc()
{
printf("%d", gMyVar);
}
全局静态(global static)
与全局类似,但仅限声明所在文件使用:
File1.pwn
代码: static
gs_MyVar = 4;
MyFunc()
{
printf("%d", gs_MyVar);
}
#include "File2"
File2.pwn
代码: MyFunc2()
{
// gs_MyVar 在这里不存在
printf("%d", gs_MyVar); // 错误
}
static 也可用于函数,实现类似“私有”函数的效果。
#GTA# #圣安地列斯# #侠盗猎车手# #圣安地列斯联机# #samp# #gta联机# #gtasa联机# #openmp# #omp# #open.mp# #gtasa#
社区交流群: 673335567
论坛: https://open-mp.cn/
|
|
|
|
| SAMP 迁移到 openmp 完整指南 + pawn 编译报错全解析 |
|
发布者: 小鸟unsigned - 03-20-2026, 10:36 PM - 板块: 教程
- 暂无回复
|
 |
SAMP 迁移到 openmp 完整指南 + pawn 编译报错全解析
引用:本篇适合:- 已有 SA-MP 服务器,想迁移到 open.mp 的开发者
- 在编译时遇到错误不知道怎么处理的新手
第一部分:迁移步骤
第一步:下载 open.mp 服务端
前往 https://github.com/openmultiplayer/open.mp/releases 下载最新版本。
open.mp-win-x86.zip — Windows 服务器
open.mp-linux-x86.tar.gz — Linux 服务器
第二步:解压
目录结构
代码: omp-server/
├── components/ ← open.mp 原生组件(某些插件放这里而不是 plugins)
├── filterscripts/ ← 过滤脚本(辅助脚本)
├── gamemodes/ ← 游戏模式(主脚本 .amx 放这里)
├── models/ ← 自定义模型文件(.txd .dff)
├── plugins/ ← 旧版插件(.dll 或 .so)
├── qawno/ ← Pawn 编辑器 + include 头文件
│ └── include/ ← 把 .inc 头文件放这里
├── scriptfiles/ ← INI 文件等资料
├── bans.json ← 封禁列表
├── config.json ← 服务器配置(替代 SA-MP 的 server.cfg)
└── omp-server.exe ← 服务器主程序
第三步:放置脚本插件文件
- 把你的游戏模式
.pwn 源文件放入 gamemodes/ 文件夹
- 把所需的
.inc 头文件(如 sscanf2.inc、streamer.inc)放入 qawno/include/
- 把插件文件(如
streamer.dll)放入 plugins/
引用:YSI 用户:需要升级到 YSI-5.x,YSI-4 与 open.mp 不兼容。
引用:FCNPC 用户:FCNPC 插件已被官方 open.mp NPC 组件取代,转换代码即可使用。
引用:YSF 用户:open.mp 已内置了大多数 YSF 的原生函数,不再需要 YSF。
注意:有些插件需要放进 components/ 而不是 plugins/,具体查阅以下清单:
常见插件
| 插件 |
状态 |
备注 |
| Streamer |
✅ 兼容 |
放 plugins/,正常加载 |
| MySQL R41-4 |
✅ 兼容 |
放 plugins/,正常加载 |
| CrashDetect |
✅ 兼容 |
开发调试,放 plugins/ |
| Whirlpool |
✅ 兼容 |
放 plugins/ 但不建议使用 |
| PawnPlus |
✅ 兼容 |
放 plugins/,open.mp 对其有专项优化 |
| pawn-memory |
✅ 兼容 |
放 plugins/ |
| JIT |
✅ 兼容 |
代码稳定后可用来提升性能,放 plugins/ |
| Profiler |
✅ 兼容 |
性能分析工具,放 plugins/ |
| FileManager |
✅ 兼容 |
放 plugins/ |
| sscanf |
? 放 components/ |
使用 sscanf 2.13.8 |
| Pawn.CMD |
? 放 components/ |
使用 3.4.0-omp |
| Pawn.RakNet |
? 放 components/ |
使用 1.6.0-omp |
| sampvoice |
? 放 components/ |
使用 v3.1.5-omp |
| discord-connector |
? 放 components/ |
使用 v0.3.6-pre |
| rustext |
⚠️ 需升级 |
使用 v2.0.11-nomemhack |
| keylistener |
⚠️ 需升级 |
使用 1.1.2-pr |
| BCrypt |
✅ 兼容 |
samp-bcrypt,密码哈希推荐方案 |
| SHA256_PassHash |
? 有替代 |
open.mp 中已标记为废弃,改用 BCrypt |
| nativechecker |
? 有替代 |
open.mp 已内置 native 检查机制,不再需要 |
| YSF |
? 有替代 |
open.mp 已内置大多数 YSF 原生函数,不再需要 |
| SKY |
? 有替代 |
改用 Pawn.RakNet |
| 中文名插件 |
? 有替代 |
open.mp 有内置函数可实现兼容中文昵称 |
| FCNPC |
❌ 不兼容 |
open.mp 官方内置 NPC 组件,性能更好,不再需要它 |
- plugins/ ← 旧版(legacy)插件放这里,在 config.json 的 legacy_plugins 里加载
- components/ ← open.mp 原生组件放这里,服务器启动时自动加载,无需配置
以上资料来源个人经验和 open.mp/docs/server/Installation
第四步:修改 #include
打开你的 .pwn 文件,把第一行:
改为:
然后按 F5 编译。
第五步:配置 config.json
用记事本打开 config.json,按需修改:
加载主图:
代码: "main_scripts": [
"你的脚本文件名 1"
]
加载插件:
代码: "legacy_plugins": [
"mysql",
"sscanf",
"streamer"
]
加载过滤脚本:
代码: "side_scripts": [
"filterscripts/你的过滤脚本名"
]
设置 RCON 密码:
代码: "rcon": {
"allow_teleport": false,
"enable": false,
"password": "你的强密码"
}
引用:如何把旧的 server.cfg 转换为 config.json,参考官方文档:https://www.open.mp/docs/server/config.json
第六步:启动服务器
Windows: 双击 omp-server.exe
Linux:
代码: chmod +x omp-server
./omp-server
第二部分:SA-MP 到 open.mp 的变化
有以下几个变化
变更一:拼写统一为英式英语
open.mp 将所有函数名统一为英式拼写,Color 改为 Colour,例如:
代码: // SA-MP 写法(会报 warning)
TextDrawBoxColor(textid, 0xFF0000FF);
TextDrawColor(textid, 0xFFFFFFFF);
// open.mp 正确写法
TextDrawBoxColour(textid, 0xFF0000FF);
TextDrawColour(textid, 0xFFFFFFFF);
批量替换方法:在你的编辑器里按 Ctrl+H,把所有 .....Color 替换为 .....Colour。
如果不想逐个修改,可以在 include 顶部加上:
代码: #define MIXED_SPELLINGS
#include <open.mp>
变更二:GetPlayerPoolSize 等函数已移除
GetPlayerPoolSize()、GetVehiclePoolSize()、GetActorPoolSize() 这三个函数在 open.mp 中已被移除,因为它们本身就存在 bug(返回的是最高 ID 而不是数量,而且没有玩家时会返回错误数据)。
代码: // 错误:这些函数不存在了
for(new i = 0; i <= GetPlayerPoolSize(); i++) { }
// 正确:直接用 MAX_PLAYERS
for(new i = 0; i < MAX_PLAYERS; i++)
{
if(IsPlayerConnected(i)) { }
}
// 或者用 foreach(需要 YSI)
foreach(new i : Player) { }
同理:GetPlayerPoolSize() → 换成 MAX_PLAYERS
GetVehiclePoolSize() → 换成 MAX_VEHICLES
GetActorPoolSize() → 换成 MAX_ACTORS
变更三:死亡不再自动扣 100 元
SA-MP 中玩家死亡后会自动扣除 100 元(住院费)。open.mp 移除了这个机制,让脚本自己管理金钱。
如果你的脚本之前为了「修复」这个扣钱机制,在 OnPlayerDeath 或 OnPlayerSpawn 里手动加了 GivePlayerMoney(playerid, 100),现在应该删掉这行,否则玩家死亡后反而会多 100 元。
如果你的脚本确实依赖这个「死亡扣钱」功能,在 OnPlayerDeath 里手动加上:
代码: public OnPlayerDeath(playerid, killerid, WEAPON:reason)
{
GivePlayerMoney(playerid, -100); // 手动模拟死亡扣款
return 1;
}
变更四:HideMenuForPlayer 行为修正
SA-MP 中 HideMenuForPlayer 会忽略你传入的菜单 ID,直接关掉玩家当前打开的任意菜单。
open.mp 修正了这个行为,现在它只会关闭你指定的那个菜单。
代码: // SA-MP 的老写法(在 open.mp 里可能不按预期工作)
HideMenuForPlayer(gShopMenu, playerid);
// open.mp 正确写法:先获取玩家当前菜单再关闭
HideMenuForPlayer(GetPlayerMenu(playerid), playerid);
变更五:SetPlayerAttachedObject 不再跨模式保留
SA-MP 中玩家身上的附加物件(attached objects)在游戏模式重启后仍然保留。open.mp 修正了这个行为,重启后附加物件会消失。
如果你需要在模式重启后保留玩家的附加物件,需要在 OnPlayerConnect 里重新为他设置。
变更六:GameText 样式统一(不再渐变)
SA-MP 中有些 GameText 样式会闪烁、有些会忽略时间参数。open.mp 统一用 TextDraw 重新实现了这些样式,外观相同但不再渐变。如果你的玩家反馈 GameText 显示效果有变化,这是正常现象。
自动升级工具
open.mp 提供了一个自动升级工具,可以批量修复旧代码中的 tag 问题、const 问题和拼写问题:
- 工具已包含在
qawno/upgrader/ 文件夹中
- 在线版本:https://github.com/openmultiplayer/upgrade
建议先用工具跑一遍,再手动处理剩余的报错。
第三部分:编译错误与警告完全手册
编译器报的信息分两种:
- error(错误):必须修复,否则无法生成
.amx 文件
- warning(警告):可以运行,但代码存在潜在问题,建议修复
常见 Error(错误)
error 001: expected token
含义:期望某个符号但没找到,通常是缺少分号、括号不匹配。
代码: // 错误:缺少分号
new gold = 100
SetPlayerHealth(playerid, 100.0)
// 正确
new gold = 100;
SetPlayerHealth(playerid, 100.0);
代码: // 错误:括号不匹配
if(gold > 0
{
// ...
}
// 正确
if(gold > 0)
{
// ...
}
error 017: undefined symbol "xxx"
含义:用了一个没有定义过的变量、函数或常量。
最常见的三种原因:
1. 名字拼写错误
代码: // 错误
SenClientMessage(playerid, -1, "hello"); // 少了 d
new scroe = 0; // 字母顺序错了
// 正确
SendClientMessage(playerid, -1, "hello");
new score = 0;
2. SetTimerEx 的目标函数忘记 forward
代码: // 错误:没有 forward,运行时找不到函数
SetTimerEx("MyFunc", 1000, false, "i", playerid);
public MyFunc(playerid) { return 1; }
// 正确:使用前先 forward
forward MyFunc(playerid);
SetTimerEx("MyFunc", 1000, false, "i", playerid);
public MyFunc(playerid) { return 1; }
3. 变量在使用之前没有声明
代码: // 错误:先用再声明
gold += 100;
new gold = 0;
// 正确:先声明再用
new gold = 0;
gold += 100;
error 021: symbol already defined "xxx"
含义:同一个名字被定义了两次。
代码: // 错误:同名变量重复声明
new gold = 0;
new gold = 100; // 重复了
// 错误:同名函数重复定义
MyFunction() { return 1; }
MyFunction() { return 0; } // 重复了
也可能是 .inc 文件被 #include 了两次,导致里面的函数被重复引入。
解决方法:在每个 .inc 文件顶部加防重复引入守卫:
代码: #if defined _MY_INC
#endinput
#endif
#define _MY_INC
error 025: function heading differs from prototype
含义:forward 声明的参数和实际 public 函数的参数不一致,或者参数类型不匹配。
代码: // 错误:forward 和 public 参数不一致
forward MyFunc(playerid);
public MyFunc(playerid, value) { return 1; } // 多了一个参数
open.mp 特别常见的情况是回调参数的 tag 不匹配:
代码: // 错误(SA-MP 写法)
public OnPlayerDeath(playerid, killerid, reason)
// 正确(open.mp 要求 WEAPON: tag)
public OnPlayerDeath(playerid, killerid, WEAPON:reason)
代码: // 错误
public OnPlayerEditAttachedObject(playerid, response, index, modelid, boneid, ...)
// 正确(open.mp 要求 EDIT_RESPONSE: tag)
public OnPlayerEditAttachedObject(playerid, EDIT_RESPONSE:response, index, modelid, boneid, ...)
error 010: invalid function or declaration
含义:函数定义或声明的语法写错了。
代码: // 错误:函数名后面缺括号
MyFunction
{
return 1;
}
// 正确
MyFunction()
{
return 1;
}
error 029: invalid expression, assumed zero
含义:表达式写法不合法,通常是运算符用错或者判断写法有问题。
代码: // 错误:判断用了单等号(赋值)而不是双等号(比较)
if(gold = 100) { } // 这是赋值,不是比较
// 正确
if(gold == 100) { }
error 035: argument type mismatch (argument N)
含义:函数调用时,第 N 个参数的类型与函数期望的类型不符。
代码: // 错误:SetPlayerHealth 需要 Float,传了整数
SetPlayerHealth(playerid, 100);
// 正确:加小数点
SetPlayerHealth(playerid, 100.0);
代码: // 错误:传了字符串给需要整数的参数
SetPlayerSkin(playerid, "86");
// 正确
SetPlayerSkin(playerid, 86);
error 047: array sizes do not match
含义:把一个数组赋值给另一个数组,但两者大小不一样。
代码: new a[10];
new b[20];
a = b; // 错误:大小不同,不能直接赋值
// 正确:用 for 循环逐元素复制,或者用 memcpy
for(new i = 0; i < 10; i++) a[i] = b[i];
error 004: function not implemented
含义:只有 forward 声明,没有对应的 public 函数体。
代码: // 错误:只 forward 了,没有写函数体
forward MyFunc(playerid);
// 下面没有 public MyFunc ...
// 正确:forward 之后要有对应的实现
forward MyFunc(playerid);
public MyFunc(playerid)
{
return 1;
}
常见 Warning(警告)
warning 213: tag mismatch
含义:变量或参数的 tag(类型标签)不匹配。这是迁移到 open.mp 最常见的警告。
open.mp 为很多函数参数增加了 tag,让编译器能检查你传的值是否合理:
代码: // 警告:传了普通整数而不是 bool
TogglePlayerControllable(playerid, 1);
// 正确:用 true/false
TogglePlayerControllable(playerid, true);
代码: // 警告:传了数字而不是枚举值
TextDrawFont(textid, 1);
GivePlayerWeapon(playerid, 4, 1);
// 正确:用枚举常量
TextDrawFont(textid, TEXT_DRAW_FONT_1);
GivePlayerWeapon(playerid, WEAPON_KNIFE, 1);
如果暂时不想处理这些警告,可以在顶部加:
代码: #define NO_TAGS
#include <open.mp>
// 或者只关闭 213 号警告
#pragma warning disable 213
warning 234: function is deprecated
含义:你用的函数已经过时,open.mp 建议换用新的写法。
TextDrawColor → TextDrawColour
代码: TextDrawColor(textid, 0xFFFFFFFF); // 警告
TextDrawColour(textid, 0xFFFFFFFF); // 正确
GetPlayerPoolSize / GetVehiclePoolSize / GetActorPoolSize
代码: GetPlayerPoolSize() // 警告:已移除
MAX_PLAYERS // 正确
SHA256_PassHash(不安全的密码哈希)
代码: SHA256_PassHash(...); // 警告:SHA-256 不安全
// 改用 BCrypt:https://github.com/Sreyas-Sreelal/samp-bcrypt
warning 214 / 239: const 相关警告
含义:把字符串或数组传给没有 const 修饰的参数,或者反过来。
代码: // 警告:参数应该是 const
public MyFunction(string[])
// 正确:加上 const
public MyFunction(const string[])
warning 203: symbol is never used
含义:你声明了一个变量或函数,但从来没有使用过。
代码: // 警告:声明了但没用
new iUnusedVar = 0;
MyUnusedFunction()
{
return 1;
}
解决方法:删掉没用的变量/函数,或者用 stock 修饰函数告诉编译器「不用也不要警告」:
代码: stock MyMaybeUnusedFunction()
{
return 1;
}
warning 204: symbol is assigned a value that is never used
含义:给变量赋值了,但这个值从来没被读取。
代码: new iResult = SomeFunction(); // 警告:iResult 赋了值但后面没用到
通常意味着这行代码没有实际效果,检查是否逻辑有误。
warning 211: possibly unintended assignment
含义:在 if 条件里用了单等号 =,可能是误把赋值写成了比较。
代码: // 警告:这是赋值,不是比较,结果永远为 true
if(gold = 100) { }
// 正确:比较用双等号
if(gold == 100) { }
warning 219: local variable shadows a variable at a higher level
含义:在内层作用域声明了一个和外层同名的变量,内层的「遮住」了外层的,容易引发逻辑混乱。
代码: new gold = 100; // 外层
public OnPlayerConnect(playerid)
{
new gold = 50; // 警告:这个 gold 遮住了外层的 gold
// 在这个函数里,gold 指的是 50 那个
}
解决方法:给内层变量换个名字,或者直接用外层变量。
warning 225: unreachable code
含义:有一段代码永远不会被执行到,通常是 return 后面还有代码。
代码: MyFunction()
{
return 1;
SendClientMessage(playerid, -1, "这行永远不会执行"); // 警告
}
warning 209: function should return a value
含义:函数应该有返回值,但某个分支没有 return。
代码: // 警告:else 分支没有 return
MyFunction(iValue)
{
if(iValue > 0)
{
return 1;
}
// 如果 iValue <= 0,函数执行完没有 return
}
// 正确:所有分支都要有 return
MyFunction(iValue)
{
if(iValue > 0)
{
return 1;
}
return 0;
}
warning 202: number of arguments does not match definition
含义:调用函数时传入的参数数量和函数定义不一致。
代码: // 函数定义需要 2 个参数
MyFunction(playerid, value)
{
return 1;
}
// 警告:只传了 1 个参数
MyFunction(playerid);
// 正确:传够参数
MyFunction(playerid, 100);
运行时报错(服务器控制台)
这些不是编译错误,而是服务器运行时打印在控制台的提示。
Couldn't announce legacy network to open.mp list
代码: [Info] Couldn't announce legacy network to open.mp list.
[Info] [Server Error] Status: 406
含义:服务器无法被 open.mp 的服务器列表访问到。
原因:- 你在本地运行,没有公网 IP
- 防火墙阻止了对应端口
影响:不影响服务器正常运行,玩家仍然可以通过 IP 直连,只是不会显示在公开服务器列表里。
Insufficient specifiers given to format
代码: [Warning] Insufficient specifiers given to `format`: "?" < 1
含义:format 函数里,格式字符串中的占位符数量少于你传入的参数数量。
代码: new str[32];
new name[32] = "Tom";
format(str, sizeof(str), "Hello", name); // 警告:格式字符串里没有 %s,但传了 name
// 正确
format(str, sizeof(str), "Hello %s", name);
有用的资源
延伸阅读
如果您在运行服务器时仍有问题,请加入官方的 open.mp Discord 服务器:https://discord.gg/samp
在 #openmp-support 频道提问。
或者在我们的社区群求助: 673335567
#GTA# #圣安地列斯# #侠盗猎车手# #圣安地列斯联机# #samp# #gta联机# #gtasa联机# #openmp# #omp# #open.mp# #gtasa#
|
|
|
|
| 零基础开发openmp/SAMP服务器基础教程 - 第一章 |
|
发布者: 小鸟unsigned - 03-20-2026, 09:38 PM - 板块: 教程
- 暂无回复
|
 |
零基础开发openmp/SAMP服务器基础教程 - 第一章
写给完全没接触过编程的你
零基础开发openmp/SAMP服务器基础教程一共有三个篇章,此为第一篇,按顺序学习阅读即可
引用:这篇文章不会让你马上写出任何代码。
它只做一件事:让你在打开编辑器之前,脑子里先有一张地图。
有了这张地图,后面学什么都会快很多。
你可能正处于这样的处境
你玩过 SAMP 联机,看到别的服务器有各种有趣的玩法,心里想:这个我也想做。
然后你搜了一下教程,打开了一个 .pwn 文件,看到满屏的英文字母和括号符号,看不懂。
然后关掉了。
这不是你的问题。是大多数教程默认你已经有一些基础,直接跳过了最重要的第一步——建立认知。
这篇文章就是来补这一步的。
理解底层逻辑
引用:理解这些尤为重要,是一切的起点。本篇将优先使用新手能理解的大白话进行讲解。
计算机是一个非常听话但非常死板的工具,先忘掉代码,我们来聊聊计算机到底是什么。
比方说地图导航软件
你输入目的地,导航会告诉你:前方 200 米左转,然后直行 500 米,到达目的地。
导航给出的是一步一步的指令,你只需要跟着做。如果你在它说左转的时候右转了,它不会生气,也不会猜你的意思,它只会重新计算,继续给你下一条指令。
计算机就是这样的。
它只会严格地、一条接一条地执行你给它的指令。
它不会猜,不会发挥,不会应该是这个意思吧帮你圆场。你说什么,它做什么。你没说的,它绝对不做。
而编程,就是把你的想法翻译成计算机能看懂的指令。
这件事没有你想的那么神秘
很多人会觉得这是某种神秘的技术,像外星文字一样难以理解。
但你其实每天都在做类似的事情。
假设你要告诉一个什么都不懂的人,怎么帮你冲一杯咖啡,你会怎么说?
你不能说帮我冲杯咖啡,因为他不知道冲咖啡是什么意思。
你需要说:
代码: 1. 走到茶水间
2. 从柜子里取出咖啡粉
3. 打开水壶,确认里面有水
4. 按下水壶开关,等待水烧开
5. 取出一个杯子
6. 在杯子里放入一勺咖啡粉
7. 把烧开的水倒进杯子里
8. 搅拌均匀
你写的这份说明书,其实就是一段程序。
你是在把帮我冲咖啡这个模糊的想法,拆分成一步一步清晰的指令。
编程做的事情,和这个完全一样。
唯一的区别是:你的指令不是写给人看的,是写给计算机看的,所以要用计算机能懂的语法来写。
学编程,本质上是在学把事情说清楚,编程里真正难的不是记住语法,而是把你的想法说得足够清楚、足够准确,清楚到计算机能执行。想法是模糊的。你需要把它变得更具体,这就是代码背后的思维过程。语法是最后才出现的东西,在这之前,你要先把逻辑想清楚。
为什么代码要用奇怪的语法写?
为什么不能直接用中文写?直接告诉电脑给玩家 100 块钱不行吗?
因为计算机本身只认识 0 和 1,它不理解任何人类语言。
所以人们发明了编程语言——它是人和计算机之间的翻译官。
编程语言的语法比中文严格得多,但也简单得多。
中文有几万个汉字,有各种语气、歧义、修辞。编程语言通常只有几十个固定的关键词,每一个词只有一个意思,没有歧义。
比如在 Pawn 里:
代码: if → 如果
else → 否则
new → 新建一个变量(容器)
return → 返回一个结果,并结束
就这些,你能用的词总共就这么多。其他所有千变万化的功能,都是用这些基础词拼出来的。
所以,代码并不是在用一门你不认识的语言说话,它只是在用一套更严格的格式,表达你本来就会表达的逻辑。
不会就查,查了才会
open.mp 有完整的官方文档,里面列出了所有可用的函数和回调,以及每个函数怎么用、参数是什么意思。
地址:https://open.mp/docs
养成看文档的习惯,这是最重要的自学能力。当你查了文档、搜了搜索引擎,还是搞不定,这时候可以去问人
不好的问法:为什么我的代码不行?
好的问法:我想实现玩家加入时显示欢迎消息,我写了这段代码(贴上代码),编译没有报错,但玩家加入时没有收到消息,请问可能是什么原因?
把你的代码、你的预期、你观察到的现象都说清楚,别人才能帮你快速定位问题。你描述问题的清晰程度,决定了你能得到帮助的速度。
报错,是编译器在帮你
报错信息是编译器(帮你把代码翻译成计算机能运行的文件的工具)在帮你找问题。
报错信息通常会告诉你两件事:
就算是工作了十年的程序员,每天也在面对 bug(程序里的错误)。
但是pawn相对简单很多,熟悉了之后,有时都不需要实际测试都可以直接通过看代码预判出运行的效果。
进入正题
Pawn 是什么
Pawn 是一种为游戏服务器设计的轻量脚本语言,不是通用高级语言。
对于想学习编程的人来说,它是一个很好的起点——因为你写的代码可以立刻在游戏里看到效果,这种即时反馈会让你进步很快。
本质上,这是在用C 语言的思维写游戏逻辑。学会 Pawn 的人,以后转 C/C++、Rust 都很快。在国外有不少人通过 SA-MP 开发找到了工作,后来接触到了 C++、PHP、JavaScript 等更复杂的方向。
脚本文件是什么
你写的 Pawn 代码保存在一个 .pwn 文件里,这就是你的脚本源码。
写完后需要经过编译,把 .pwn 变成服务器能运行的 .amx 文件,服务器加载的是 .amx,不是 .pwn。
代码: 你写的代码 编译 服务器运行
myscript.pwn → myscript.amx → open.mp 加载执行
这个过程就像你写了一份中文食谱,翻译成机器能理解的语言,机器才能按步骤操作。
单线程
目前你只需要知道一件事:Pawn 服务器是单线程的。
单线程意味着:整个服务器只有一条流水线,所有逻辑、计算只能排着队一个一个处理,同一时间只能执行一条指令。
举个例子:假设你写了一段代码需要执行 1 秒钟,那在这 1 秒内,服务器上其他所有事情都会停下来等它跑完,包括玩家的移动同步、其他玩家的操作响应。
早期玩过 RPG 服的应该有印象——每到发薪日,服务器会卡顿一下。就是因为给所有玩家结算工资的代码在单线程里跑完之前,所有事件都被暂停了。
结论:写代码要注重性能,避免一段逻辑执行时间过长。单条简单的加减、判断代码几乎是瞬间完成的(微秒级别),这类代码完全不用担心。
理解 Tick(帧)
open.mp 默认每秒运行 30 个 tick(帧),也就是每秒打包、更新、同步 30 次数据。
如果网络不好(玩家或服务器任意一方),就可能出现某次 tick 的数据包没有成功传输,也就是俗称的丢包。
比如连续两秒没收到玩家的位置数据,第三秒突然收到了,就会出现我们常见的瞬移现象。网络好的情况下,每帧数据几乎全部到达,玩家动作就很平滑。
即使你什么逻辑都没写,open.mp 每个 tick 也会自动同步这些基础数据:
- 玩家位置、朝向
- 玩家动作状态
- 武器信息
- 血量和护甲
- 车辆位置、朝向
- 玩家按键状态
从玩家操作到游戏表现,完整流程
代码: 玩家操作
↓
客户端生成数据包,通过 UDP 发送到服务端
↓
服务端接收数据包,自动触发对应的回调函数
↓
回调内的 Pawn 代码执行,修改服务器内存里的数据
↓
Tick 结束,服务器把最新数据同步给所有客户端
↓
玩家在游戏里看到最终表现
不同的玩家操作,会触发不同的回调:
| 玩家操作 |
触发的回调 |
| 移动位置 |
OnPlayerUpdate |
| 开枪 |
OnPlayerWeaponShot |
| 按下按键 |
OnPlayerKeyStateChange |
| 操作对话框 |
OnDialogResponse |
| 死亡 |
OnPlayerDeath |
举个例子:友伤保护
当玩家开枪,触发 OnPlayerWeaponShot。在这个回调里,你可以判断被击中的是不是队友,如果是队友就让伤害为 0:
代码: public OnPlayerWeaponShot(playerid, weaponid, hittype, hitid, Float:fX, Float:fY, Float:fZ)
{
if(IsTeamMate(playerid, hitid))
{
return 0; // 返回 0 = 取消这次子弹伤害
}
return 1; // 返回 1 = 允许子弹正常造成伤害
}
再举个例子:对话框治疗
当玩家操作对话框,触发 OnDialogResponse。判断玩家选了哪个选项,然后执行对应逻辑:
代码: public OnDialogResponse(playerid, dialogid, response, listitem, inputtext[])
{
if(listitem == 2) // 第三个选项(listitem 从 0 开始,0=第一个,1=第二个,2=第三个)
{
SetPlayerHealth(playerid, 100.0); // 设置血量为 100
}
return 1;
}
引用:注意:listitem 从 0 开始计数,第一个选项是 0,第二个是 1,第三个是 2,以此类推。这是新手非常容易犯错的地方。
关于作弊
服务器收到的数据,默认是信任客户端发来的。作弊工具就是利用这一点,篡改客户端发出的数据包(比如把血量改成 10000、位置瞬移到某处),服务器收到后会直接把这些数据同步给其他玩家,其他人就会看到无敌血条、飞天瞬移。
GetPlayerMoney 获取的就是客户端发来的金钱数据。如果你没有用服务端自己的变量记录玩家金钱,就相当于完全信任了客户端,没有任何验证。
解决方法:重要的数据(血量、金钱、等级等)永远在服务端自己维护,不信任客户端发来的数值。
什么是回调(Callback)
回调是服务端自动触发的函数,触发时机是服务端提前定好的,你不需要手动调用它。
你能做的是:在这个函数里写你自己的逻辑。
所有服务端自带的回调都以 public On 开头,比如:
代码: public OnPlayerConnect(playerid)
{
// 当有玩家连入服务器时,这里的代码会自动执行
return 1;
}
public OnPlayerDeath(playerid, killerid, reason)
{
// 当有玩家死亡时,这里的代码会自动执行
return 1;
}
public 是什么意思
public 表示这个函数是公开的,可以被服务端直接调用。服务端自带的回调必须加 public,否则服务端找不到它,触发时就不会执行你写的代码。
return 1 是什么意思
函数执行完毕后,需要返回一个结果告诉服务端处理完了。
return 1 = 正常处理,允许默认行为继续
return 0 = 在某些回调里代表拦截/取消这次事件(比如上面的 OnPlayerWeaponShot 返回 0 取消伤害)
具体每个回调返回 0 和 1 各代表什么,查文档就知道。新手阶段统一写 return 1 不会出大问题。
每个回调都是一个独立的房间
回调的范围 = 花括号 {} 包着的所有内容。从 { 到 } 之间的所有代码都属于这个回调。
代码: public OnPlayerConnect(playerid)
{ ← 房间开始
// 这里的代码属于 OnPlayerConnect
// 玩家连入时执行
} ← 房间结束
什么是函数
函数是一个工具,你不需要知道它内部怎么实现的,只需要知道怎么用、给它传什么参数、它会做什么事。
就像微波炉:你不需要知道微波炉的电路原理,只需要把食物放进去、设好时间、按启动,它就帮你加热。
服务端自带大约 1000 个左右的函数,每个函数从名字上就能大致猜到它的作用:
代码: SetPlayerHealth(playerid, Float:health) // 设置玩家血量
SetPlayerSkin(playerid, skinid) // 修改玩家皮肤
SendClientMessage(playerid, color, msg) // 给玩家发送聊天消息
GivePlayerMoney(playerid, money) // 给玩家钱(负数是扣钱)
GetPlayerPos(playerid, Float:x, Float:y, Float:z) // 获取玩家坐标
括号里是参数,告诉函数对谁做做什么。
能不能自己创建函数
可以。当你发现一段代码要重复写很多次,就该把它打包成函数。
代码: // 没有函数:每次都要重复写
GivePlayerMoney(playerid, 500);
SetPlayerHealth(playerid, 100.0);
SendClientMessage(playerid, -1, "欢迎奖励已发放");
// 下一个地方又要写一遍...
GivePlayerMoney(playerid, 500);
SetPlayerHealth(playerid, 100.0);
SendClientMessage(playerid, -1, "欢迎奖励已发放");
代码: // 有了函数:打包一次,随时调用
GiveWelcomeReward(playerid)
{
GivePlayerMoney(playerid, 500);
SetPlayerHealth(playerid, 100.0);
SendClientMessage(playerid, -1, "欢迎奖励已发放");
}
// 需要用的地方只需要一行
GiveWelcomeReward(playerid);
什么时候该建函数? 一段逻辑在两个以上的地方用到,或者逻辑比较复杂、需要给它起个名字方便理解,就建函数。
能不能用别人写好的函数
可以,这就是安装插件和依赖库。
你不需要为了拧一颗螺丝自己炼钢做螺丝刀,直接去五金店买一把就好了。
别人做好的功能,打包成插件,你直接引入使用,不需要知道内部原理。
不过在用之前可以留意几点:- 它会不会消耗大量服务器性能?
- 作者是否持续维护?
- 社区是否有人在用、有没有已知的 bug?
去哪里找? Github 搜索,或者 open.mp 官方推荐列表:https://open.mp/docs/awesome
回调的执行顺序
回调不是按固定顺序排好的,而是由 tick 中收到了什么数据来决定。
玩家没有做某个操作,对应的回调就不会触发。
如果一个玩家在同一个 tick 内同时触发了多个操作(极罕见),服务器会把这些事件排成队列,在单线程里一个接一个处理。
但回调内部的代码,是严格从上到下一行一行执行的。
代码写在哪里
所有实际运行的代码,都必须写在某个回调的花括号里面。
写在外面的只能是:变量声明、函数定义、常量定义。
你自己写的函数,不会自动运行,必须在某个回调里主动调用它,才会被执行:
代码: // 这个函数不会自动运行
MyFunction(playerid)
{
SendClientMessage(playerid, -1, "你好");
}
// 必须在某个回调里调用它,才会执行
public OnPlayerConnect(playerid)
{
MyFunction(playerid); // ← 在这里调用,玩家连入时才会执行
return 1;
}
可以把服务器想象成一台自动售货机:投币(玩家操作)会触发里面固定的机械流程(服务端回调)。你想加功能,只能在原有流程的某个环节里接线(在回调里调用你自己写的函数),你的代码不会凭空自己运行。
决定你高度的关键因素
掌握了上面这些,你已经理解了 open.mp 服务端运行的核心原理。剩下的,本质上都是数学和逻辑问题。
但有一件事只能靠你自己做:查文档。
你能实现什么功能,取决于你知道多少回调和函数的存在。不需要把所有函数都背下来,但你需要通过不断查阅,建立起自己的工具库认知——知道有什么工具可以用,需要的时候去查怎么用。
官方文档:https://open.mp/docs
交流群:673335567
新手最常犯的错误是:遇到不会的功能,直接问别人这个怎么做,但别人告诉你思路了,你又不知道用哪个函数实现。
根本原因是对现有工具了解太少。多看文档,多看别人的开源脚本,这是没有捷径的积累过程。
你知道的越多,能做的就越多。
#GTA# #圣安地列斯# #侠盗猎车手# #圣安地列斯联机# #samp# #gta联机# #gtasa联机# #openmp# #omp# #open.mp# #gtasa#
社区交流群: 673335567
论坛: https://open-mp.cn/
|
|
|
|
| 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步骤的时候需要你创建一个密码,这个密码你在后面连接的时候需要用到
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. 关键字:变量声明修饰符
在 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 函数,所以你平时直接调用 SendClientMessage、CreateVehicle 这些,底层都是 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 ID |
new Text:tdLogo; |
PlayerText: |
玩家 TextDraw |
new 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-while 是 while 的变体,区别是先执行一次,再判断条件。
即使条件一开始就是假的,循环体至少也会执行一次。
代码: // 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 文件自己管理自己需要使用的回调,执行完自己的逻辑后再把控制权传给下一个模块,形成一条链。
原理
- 我先把
OnGameModeInit 重命名成 shop_OnGameModeInit(用 #define 宏替换)
- 然后我自己写一个新的
OnGameModeInit,在里面先执行我的逻辑,再调用 shop_OnGameModeInit
- 下一个
.inc(比如 npc.inc)接着同样操作,把我写的 OnGameModeInit 再重命名为 npc_OnGameModeInit,再写新的 OnGameModeInit...
- 最终形成一条调用链:
OnGameModeInit → npc_OnGameModeInit → shop_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 游戏实际应用为开端,让新手更快通过实际游戏表现获得反馈,以避免一开始就接触过多语法,建议阅读完本章节再开始第三章巩固进阶编程语言细节
目录
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_PLAYERS、SKIN_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 是 0 到 MAX_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 次以上的代码封装成函数。函数名需要清晰描述它做什么,例如 GiveGold、ShowShop、KickPlayer。
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层),开发过程基本一模一样
* * *
目录
- 环境搭建
- 创建项目
- 最小可运行组件
- 组件的生命周期
- 创建自己的native函数
- 事件处理
- 玩家数据扩展
- 如何扫描amx的public
- 完整示例
* * *
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):
- 按 Ctrl+Shift+P,输入 CMake: Select a Kit
- 选择 Visual Studio 生成工具 2026 Release x86,如果没有这个选项,选择[扫描工具包]即可搜索。
选择构建类型:
- 按 Ctrl+Shift+P,输入 CMake: Select Variant
- 开发时选 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 | 玩家聊天/命令 |
| PawnEventHandler | AMX 脚本加载/卸载 |
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)。要调用它,你需要:
- 找到编号 --- 用 amx_FindPublic 或在 onAmxLoad 时扫描
- 压入参数 --- 用 amx_Push(整数)或 amx_PushString(字符串)
- 执行 --- 用 amx_Exec
- 清理 --- 用 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
在您的代码中包含并开始使用该库:
介绍
如上所述,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 库多年后,我才受到启发制作了自己的库。如果您不需要我的库提供的额外功能,可以考虑使用他的库。
|
|
|
|
|