LCUI 脚本语法
NOTE
以下内容取自 LOICollectionA 1.17.1 的 .lcui 脚本语言实现,对于后续版本可能会有所不同。
概述
.lcui 是 LOICollectionA 用于编写游戏内 GUI 的脚本语言。所有模块的内置界面(如 blacklist.lcui、statistics.lcui)以及 Menu/Shop 的 menu.lcui、shop.lcui 均使用该语言编写。
脚本的完整处理流程为:
ScriptLoader(导入解析与合并) -> Lexer(词法分析) -> Parser(语法分析) -> SemanticAnalyzer(语义分析) -> Compiler(字节码编译) -> Optimizer(优化) -> VM(执行)- 插件启动时或执行
/xxx reload命令后,GUIManager::load会读取.lcui文件,先解析import导入图(见「多文件导入」),再完成上述编译流程;编译产物除缓存在内存外,还会以.lcp字节码文件落盘(见「字节码产物」)。 - 玩家打开 GUI 时,
GUIManager::open(id, formId, type[, ctx])会针对该玩家执行缓存的脚本;脚本中new CustomForm(...)、new PaginatedForm(...)等创建的表单会注册到GUIManager,随后切换到formId对应的表单。
原生表单类的方法与生命周期详见 原生 UI(Native UI),Menu/Shop 专用表单与默认变量详见 LOICollectionAPI。
一个最小的脚本
let form = new CustomForm("example.main", {tr("example.gui.title")}) {
label({tr("example.gui.hello")}, new TextOptions());
button({tr("generic.gui.close")}, on: func () -> void {
form.close();
}, new ButtonOptions());
closeButton();
show();
};声明式 UI 块
创建表单时,可以在构造调用后紧跟一个 { ... } 块,按界面出现顺序声明控件。块内省略接收者的方法调用会自动作用于正在构建的表单。上面的最小脚本完全等价于手工展开的命令式写法:
let form = new CustomForm("example.main", {tr("example.gui.title")});
form.label({tr("example.gui.hello")}, new TextOptions());
form.button({tr("generic.gui.close")}, on: func () -> void {
form.close();
}, new ButtonOptions());
form.closeButton();
form.show();要点:
- 接收者:
变量 = new 表单类(参数) { ... }中,块内的裸调用(如label(...))会脱糖为变量.label(...)。接收者变量在块体执行前就已绑定,因此块内定义的 lambda(例如按钮回调)可以直接用名字引用正在构建的表单,如上例中的form.close()。 on:命名参数:在块内(且仅在块内)可以为button等带回调的方法使用on: 回调传参,参数位置与显式写法一致。- 控制流:块内允许
if等控制流语句,按实际执行顺序决定哪些控件被加入。例如内置的market_store.lcui中,审核按钮仅在管理员且开启审核时才会加入:
let mineForm = new CustomForm("market.store.mine", {tr("market.gui.title")}) {
/* ... 其他按钮 ... */
if (GUIManager::request("market.isAdmin", [])[0] && GUIManager::request("market.store.review.enabled", [])[0]) [
button({tr("market.gui.store.mine.audit")}, on: func () -> void {
navigateAudit.value = true;
mineForm.close();
}, auditOption);
]
closeButton();
};- 仅限表单类:声明式块只能用于
CustomForm、MessageBox、PaginatedForm、ScriptForm;对其他类使用(如new GlobalValue() { ... })会报编译错误。 - 普通函数回退:块内不属于表单方法的裸调用会按普通函数解析,因此导入的辅助函数(如
pagePrevious()、saveLabel())可以直接在块内调用。
组件(component)
component 把重复的表单控件组合抽象成可复用的片段。组件在使用点按编译期宏展开(卫生展开),参数按名绑定,运行期零开销;组件体内的裸方法调用继承使用点的当前表单隐式接收者:
component ConfirmBar(confirmText, onConfirm) {
button(confirmText, on: onConfirm, new ButtonOptions());
closeButton();
}
let form = new CustomForm("wallet.main", {tr("wallet.gui.title")}) {
label({tr("wallet.gui.label")}, new TextOptions());
ConfirmBar(saveLabel(), func () -> void {
form.close();
});
show();
};上面的写法在编译期等价于直接在块内写:
let form = new CustomForm("wallet.main", {tr("wallet.gui.title")}) {
label({tr("wallet.gui.label")}, new TextOptions());
button(saveLabel(), on: func () -> void {
form.close();
}, new ButtonOptions());
closeButton();
show();
};规则:
| 规则 | 说明 |
|---|---|
| 定义位置 | 组件只能定义在脚本顶层;嵌套在函数体或块内定义会报编译错误 |
| 使用位置 | 组件调用只能作为声明式 UI 块内的语句;在块外调用或作为表达式使用会报编译错误 |
| 参数绑定 | 调用参数个数必须与组件参数一致,按位置绑定到参数名 |
| 卫生展开 | 组件体内声明的局部变量会被自动重命名,不会捕获或污染使用点的外层同名变量;多个使用点的展开相互独立 |
| 禁止递归 | 组件(直接或间接)调用自身会报编译错误 |
| 块内能力 | 组件体内允许控制流与局部变量(块作用域),含义与手写在声明式块内完全一致 |
| 接收者继承 | 展开发生在使用点,因此组件体内的裸调用作用于使用点的表单接收者(上例中的 form) |
| 跨文件复用 | 组件定义属于顶层定义,可以放在被 import 的文件中(如 generic.lcui)供多个脚本共用 |
内置脚本的公共控件组合就定义在 generic.lcui 中:PageControls()(上一页/下一页/跳转三连按钮)、SectionHeader()(间隔 + 分隔线)、ConfirmBar(confirmText, onConfirm)(确认按钮 + 关闭按钮)。
多文件导入(import)
import 路径相对导入语句所在的文件解析(入口文件即其所在目录);std/ 标准库模块互相引用也因此稳定,无论入口脚本放在哪里。
脚本顶部可以使用 import 引入其他 .lcui 文件的顶层定义(class / func / using / component),实现跨文件复用。内置脚本的公共界面词汇表(分页按钮、保存/取消文案等)就抽取在 generic.lcui 中:
import "generic.lcui";
let quote = new PaginatedForm("market.quote", {tr("market.gui.title")}, GUIManager::value("market.quote.items"), 10) {
label({tr("market.gui.quote.list.label")}, new TextOptions());
PageControls();
closeButton();
show();
};也可以只导入部分定义。在路径后跟 { 名字, ... } 列出需要的符号,未列出的定义不会进入当前程序(引用会得到未定义错误):
import "generic.lcui" { PageControls, closeButton };规则:
| 规则 | 说明 |
|---|---|
| 路径解析 | import 路径相对于入口脚本所在目录解析 |
| 合并顺序 | 导入图按依赖优先(拓扑序)展开,每个文件的定义只合并一次;菱形导入(A、B 都导入 C)合法,C 只出现一次 |
| 选择性导入 | import "path" { a, b }; 只合并列出的定义;同一文件被多处导入时按所有导入点的符号并集合并;不带列表仍为全量导入(完全向后兼容) |
| 定义冲突 | 不同文件中同名 class / func / using / component 会报错,错误信息包含两处定义的文件与行号 |
| 仅限定义 | 被导入的文件只能包含 class / func / using / component / import,不能包含可执行语句(避免导入产生副作用);入口文件不受此限制 |
| 循环导入 | 循环导入(如 A → B → A)会报错,并给出完整环路径 |
| 文件缺失 | 导入的文件不存在或不可读时报错 |
注释、字面量与语句分隔
注释
支持行注释 // 与块注释 /* ... */:
// 这是行注释
/* 这是
块注释 */字面量
| 字面量 | 示例 | 说明 |
|---|---|---|
| 整数 | 42、-1 | 32 位有符号整数 |
| 浮点数 | 3.14、.5、5. | 支持省略小数点前的 0 |
| 字符串 | "hello"、'world' | 单引号或双引号包裹 |
| 布尔 | true、false | — |
| 空值 | None | 仅可用于 optional 上下文 |
| 数组 | [1, "a", true] | 可混合元素类型 |
字符串支持转义序列:\n(换行)、\t(制表符)、\r(回车)、\\(反斜杠)、\"(双引号)、\'(单引号)。未知转义(如 \x)保持字面量并产生编译期警告。若字符串中需要包含某种引号,也可以使用另一种引号包裹,例如 'say "hi"';字符串也允许跨行。
语句分隔
语句之间可以使用 ; 分隔,也可以使用换行分隔。同一行内存在多条语句时必须使用 ;,推荐每条语句都以 ; 结尾:
let a = 1; let b = 2; // 同一行需要 ;
let c = 3 // 换行可以省略 ;
let d = 4;变量与类型
变量声明(let)
所有变量绑定都必须用 let 显式声明,不再支持无关键字的直接赋值隐式创建变量:
let a = 1; // 可变变量,动态类型
let count: int = 1; // 可变变量,带类型标注let 声明的变量可随时重新赋值,类型仍可动态变化。const 不是语句级关键字,它只用于 trait / impl 中的关联常量(例如 const G = 9.8;)。该约束只作用于局部变量与全局变量的绑定,函数参数、类字段与 optional/variant 成员仍使用原有的结构化声明语法。
动态变量
未标注类型的 let 声明即为动态类型,可随时赋其他类型的值:
let a = 1;
a = "test"; // 合法,动态类型
let b = [1, 2, 3];类型标注
使用 let 变量名: 类型 = 值 声明带类型的变量,类型声明必须同时提供初始值:
let count: int = 1;
let name: string = "LOICollection";
let flag: bool = true;
let score: float = 1.5;类型标注会进行赋值检查,类型不匹配或声明后赋其他类型的值都会报错。
内置类型
| 类型 | 说明 |
|---|---|
int | 整数 |
float | 浮点数 |
string | 字符串 |
bool | 布尔值 |
void | 函数无返回值时使用 |
ClassName | 自定义类或原生类类型 |
optional<T> | 可选值,可能为空 |
variant<T1, T2, ...> | 联合值,只能保存声明中的某一种类型 |
未标注类型的函数参数、函数返回值与变量均为动态类型。None 是空值字面量而不是可写的类型名(optional 为空时,其 .type 返回 none)。
optional(可选值)
optional<T> 可以保存 T 类型的一个值,也可以是空值 None:
let value: optional<int> = 1;
let empty: optional<string> = None;
value.has_value; // true
value.value; // 1
empty.has_value; // false
empty.value; // 报错:optional 为空optional 支持三个特殊成员:
| 成员 | 说明 |
|---|---|
has_value | 是否有值(布尔) |
value | 当前值,为空时访问报错 |
type | 当前值的类型名;为空时为 none |
None 只能出现在 optional 上下文中(例如 optional 变量的初始值、optional 参数、optional 返回值),在其他位置使用会报错。空 optional 直接参与算术、比较等运算也会报错,建议先用 if (value) 或 has_value 判断,或使用空值安全操作符 ?? / ?. 提供默认值(见「空值安全操作符」)。
variant(联合值)
variant<T1, T2, ...> 只能保存声明列表中的某一种类型,赋值时会校验:
let data: variant<string, bool, int> = 1;
data.type; // "int"
data.value; // 1
data = "test"; // 合法
data = 1.5; // 报错:float 不在声明列表中variant 至少需要两个类型参数(如 variant<int> 不合法)。
类型别名 using
使用 using 可以为类型定义别名,仅允许出现在脚本顶层:
using Value = variant<string, bool, int>;
using Count = int;
let a: Value = 1;
let b: Count = 3;别名可以链式引用(using B = A;),但不能自引用,也不能重复定义。
数组
数组使用 [元素1, 元素2, ...] 创建,支持任意混合类型:
let items = [1, "a", true];
items[0]; // 1
items.length; // 3
items.push(2); // 追加元素,返回新长度
items[1] = "b"; // 修改已有元素(索引等于当前长度时同样会追加)数组索引从 0 开始,越界读取、负数索引会报错。数组的 ==/!= 比较的是引用(是否为同一个数组),而非内容。类的数组字段在每个实例中相互隔离(创建实例时会拷贝),修改一个实例的数组不会影响其他实例。
数组方法
数组自带以下方法(无需引入,直接以 数组.方法(...) 调用):
| 方法 | 签名 | 说明 |
|---|---|---|
push | arr.push(v) | 追加元素,返回新长度 |
pop | arr.pop() | 弹出并返回末元素;空数组报错 |
contains | arr.contains(v) | 是否包含相等的元素(== 语义) |
indexOf | arr.indexOf(v) | 首次出现的索引;找不到返回 -1 |
join | arr.join(sep) | 元素全部转字符串后用 sep 拼接 |
slice | arr.slice(start, end) | 半开区间切片,返回新数组;越界自动裁剪 |
sort | arr.sort([cmp]) | 原地排序;cmp 为 func(a, b) -> int,省略时要求元素两两可比较 |
let items = [3, 1, 2];
items.push(4); // items = [3, 1, 2, 4],返回 4
items.sort(); // items = [1, 2, 3, 4]
let names = items.join(","); // "1,2,3,4"
let words = ["b", "a"];
words.sort(func (a, b) -> int {
return if (a < b) [ -1 : 1 ];
});sort 无参数版本遇到不可比较的元素对(例如数字与字符串混合)会运行时报错;slice 的索引为负数时报错。
字符串方法
字符串同样以 字符串.方法(...) 调用:
| 方法 | 签名 | 说明 |
|---|---|---|
split | s.split(sep) | 按分隔符拆分为数组;sep 为空串时按字符拆分 |
contains | s.contains(sub) | 是否包含子串 |
startsWith / endsWith | s.startsWith(sub) | 前缀 / 后缀判断 |
indexOf | s.indexOf(sub) | 首次出现的字符下标;找不到返回 -1 |
toInt / toFloat | s.toInt() | 解析为 optional<int> / optional<float>,失败时为空 |
字符串也支持以 [] 取下标索引,返回指定位置的单个字符(类型为 string),越界或负索引会报错:
"hello"[0]; // "h"
"hello"[4]; // "o"
"hello"[-1]; // 报错:负索引"a,b,c".split(","); // ["a", "b", "c"]
"hello".startsWith("he"); // true
let value: optional<int> = "42".toInt();
value.has_value; // true
value.value; // 42
let bad: optional<int> = "abc".toInt();
bad.has_value; // falseMap
Map 是保持插入序的键值容器,键支持 int / float / string / bool(值相等即同键):
let m = new Map();
m.set("apple", 3);
m.set("banana", 5);
m.get("apple"); // 3
m.has("banana"); // true
m.remove("apple");
m.length; // 1
m.keys; // ["banana"],插入序
for (k in m.keys) [
println(k + " = " + m.get(k));
]get 在键不存在时返回 None,适合搭配空值安全操作符(见下文)。
运算符
算术运算符
| 运算符 | 说明 |
|---|---|
+ | 加法 / 字符串拼接 |
- | 减法 |
* | 乘法 |
/ | 除法(两个整数相除也会产生浮点数) |
% | 取模(仅支持整数) |
^ | 幂运算(右结合) |
+/-(一元) | 正号 / 负号 |
1 + 2 * 3; // 7
2 ^ 3 ^ 2; // 512(右结合)
10 / 3; // 3.33333...
10 % 3; // 1
"Hello " + "World"; // "Hello World"字符串与数值、布尔等类型使用 + 时都会转换为字符串拼接。% 只能用于整数,除数为零、整数溢出、取模非整数类型都会报错。
复合赋值与自增自减
= 可与算术运算符组合为复合赋值,左侧只求值一次(对 arr[i] += 1、obj.field += 1 尤为重要):
let a = 10;
a += 5; // 15
a -= 3; // 12
a *= 2; // 24
a /= 4; // 6
a %= 4; // 2
let arr = [1, 2, 3];
arr[1] += 10; // arr = [1, 12, 3]++ / -- 为自增 / 自减,前后缀语义一致,等价于 += 1 / -= 1:
let i = 0;
i++; // i = 1
++i; // i = 2
i--; // i = 1WARNING
++ / -- 作为表达式参与运算时返回的是更新后的新值(前缀语义),与 C/C++ 的后缀 i++ 返回旧值不同:let i = 5; let j = i++; 得到 j == 6 而不是 5。如需旧值,请先保存再自增。
历史版本中
++i会被解析为双重一元正号(值不变),引入前缀自增后语义改变;实际脚本中几乎不会出现这种写法,但属于已知的破坏性变更。
空值安全操作符
??(空值合并)在左侧为 None 或空 optional 时取右侧的值,否则取左侧;0、""、false 不会触发合并(与真值规则无关):
let name = value ?? "默认";
let count = 0 ?? 10; // 0:0 不是 None,不触发合并?.(安全访问)在左侧为 None 或空 optional 时使整个访问链短路,结果为 None,支持字段与索引两种形式:
let len = maybeArr?.length ?? 0;
let item = obj?.data?.[0];?? 的优先级高于赋值、低于比较运算,且不能与 && / || 无括号混用(直接报错,避免歧义)。x?.y 的结果可能为 None,赋给 T 类型标注变量会报错,需搭配 ?? 或使用 optional<T>。
错误传播 ?
在 optional 值后加 ? 表示「有值就取出,空值就从当前函数提前返回 None」。它把连续的空值检查压平成一行,作用范围是最近的函数边界(闭包内使用则返回到闭包的调用方):
func head(a) -> optional<int> {
if (a.length() == 0) [ return None; ]
return a[0];
}
func firstPlus(a) -> optional<int> {
return head(a)? + 1; // head(a) 为空时,firstPlus 直接返回 None
}
firstPlus([]) ?? -1; // -1
firstPlus([5]) ?? -1; // 6两侧约束在编译期检查:被 ? 修饰的表达式必须是 optional<T>,所在函数的返回类型也必须是 optional<...>。两个条件任一不满足都会报错,因此不会出现「空值被当成普通值继续参与运算」的情况。? 只处理 None,不处理 false / 0 / ""。
比较运算符
==、!=、>、<、>=、<= 可用于数值、字符串与布尔比较;数值的 int/float 可以直接比较。字符串比较规则大致参考 C++。类型不一致的 ==/!= 会报错(例如 1 == "1")。
对象、数组与函数支持 ==/!=,但比较的是引用:
let a = [1];
let b = [1];
a == b; // false,内容相同但不是同一个数组
let c = a;
a == c; // trueNone == None 为 true。
运算符重载
脚本类可以通过实现内置的运算符 trait 来重载运算,让自定义对象支持 +、-、==、一元负号等(Rust 风格:运算符对应一个 trait,trait 对应一个 op_xxx 方法)。内置运算符 trait 如下:
| 运算符 | trait | 方法 | 参数 |
|---|---|---|---|
+ | Add | op_add(rhs) | 1 |
- | Sub | op_sub(rhs) | 1 |
* | Mul | op_mul(rhs) | 1 |
/ | Div | op_div(rhs) | 1 |
% | Mod | op_mod(rhs) | 1 |
^ | Pow | op_pow(rhs) | 1 |
-(一元) | Neg | op_neg() | 0 |
== | Eq | op_eq(rhs) | 1 |
!= | Ne | op_ne(rhs) | 1 |
< | Lt | op_lt(rhs) | 1 |
<= | Le | op_le(rhs) | 1 |
> | Gt | op_gt(rhs) | 1 |
>= | Ge | op_ge(rhs) | 1 |
使用时对类 impl 对应的 trait 并实现 op_xxx 方法,运算符表达式即会调用该方法:
class Vec2 {
public:
x = 0;
y = 0;
}
impl Add for Vec2 {
func op_add(rhs: Vec2) -> Vec2 {
let r = new Vec2();
r.x = this.x + rhs.x;
r.y = this.y + rhs.y;
return r;
}
}
let a = new Vec2();
a.x = 3;
let b = new Vec2();
b.x = 4;
let c = a + b; // 调用 Vec2.op_add,c.x == 7比较类运算符返回 bool,可用于相等性判断:
impl Eq for Money {
func op_eq(rhs: Money) -> bool {
return this.cents == rhs.cents;
}
}
a == b; // 调用 Money.op_eq运算符重载规则
- 二元运算以左侧操作数为接收者(
this),调用左类实现的op_xxx;仅当左侧是脚本类且实现了对应 trait 时才会触发重载。 Neg(一元负号)无参数,以操作数为接收者。- 重载方法的返回类型即运算符表达式的结果类型,可用于类型推导与后续调用。
- 继承时沿基类链向上查找对应
op_xxx方法。 - 未实现对应 trait 的类保持内置行为不变;
int、float、string等基本类型的原生运算(含String的原生+拼接)不受影响。
逻辑运算符
&&、|| 支持短路求值:false && 任意表达式 不会执行右侧,true || 任意表达式 同样不会执行右侧。! 为逻辑非。
真值规则
条件判断使用以下真值规则:
| 值 | 真值 |
|---|---|
int | 非 0 为真 |
float | 绝对值大于 0 为真 |
string | ""、"false"、"FALSE"(不区分大小写)为假,其余非空字符串为真 |
bool | 本身 |
array | 非空为真 |
| 对象 / 函数 | 恒为真 |
None | 恒为假 |
流程控制
if / else
if 使用方括号 [ ... ] 作为分支块,: 分隔 else 分支,else 可以省略:
if (count > 0) [
mc::runCmd("say positive");
:
mc::runCmd("say not positive");
]
if (flag) [
mc::runCmd("say flag is true");
]if 本身也是表达式,分支中的最后一个值会作为结果返回,可以参与运算或赋值:
let result = if (count > 0) [ 10 : 20 ];
let total = if (flag) [ 1 : 2 ] + 5;while
let i = 0;
while (i < 5) [
i++;
]for
let s = 0;
for (let i = 0; i < 10; i++) [
if (i % 2 == 0) [ continue; ];
s += i;
]for (;;) 可以省略三个子句,配合 break 使用。
for-in 遍历
for-in 用于遍历可迭代对象,可同时获得下标与元素:
let items = ["a", "b", "c"];
for (item in items) [
println(item);
]
for (i, item in items) [
println(i + ": " + item);
]
for (c in "abc") [
println(c);
]
let m = new Map();
m.set("a", 1);
for (k in m) [
println(k + " = " + m.get(k));
]能否遍历由迭代协议决定,而不是编译器逐个类型特判:
| 可迭代对象 | 元素 | 下标 |
|---|---|---|
| 数组 | 元素值 | 位置(int) |
| 字符串 | 单个字符,类型为 string | 位置(int) |
提供了 length() 与 element(int) 的类 | element(i) 的返回值 | 位置(int) |
提供了 next() 返回 optional 的类(惰性迭代器) | next() 返回值的内层类型 | 轮次计数(int) |
a..b 区间 | 区间内的 int 值 | 位置(int) |
Map 内置了这一协议,for (k in m) 遍历其键(m.keys 仍然可用)。任何类——宿主注册的或脚本里 定义的——只要提供 length() 与 element(int),无需改动编译器即可被遍历:
class Squares {
public:
limit = 0;
func length() -> int { return this.limit; }
func element(i: int) -> int { return i * i; }
}
let s = new Squares();
s.limit = 4;
for (v in s) [
println(v); // 0 1 4 9
]协议方法沿继承链查找,extends 出来的子类自动获得父类的可迭代性。元素变量的类型取自 element(int) 的返回类型,因此写错类型会在编译期报错。
除了这种「先知长度」的索引协议,还可以实现惰性迭代器:只需提供一个无参的 next() 方法并返回 optional<T>——每轮调用一次,返回 None 即终止:
class Nats {
public:
i = 0;
func next() -> optional<int> {
this.i = this.i + 1;
return this.i;
}
}
let total = 0;
for (v in new Nats()) [ // 无限序列
if (v > 5) [ break; ] // 惰性迭代器配 break 是常规用法
total += v;
]惰性协议按需拉取:不预知元素个数,
next()返回None时循环结束,因此可以表达无限序列、分页数据等事先不知道长度的流。元素变量的类型取自
next()返回值optional<T>的内层类型T;for (i, v in ...)中i是轮次计数(从 0 开始),不是目标对象内部状态。一个类同时提供两套协议时(
length()/element(int)与next()),索引协议优先。next()同样沿继承链查找。continue会正常进入下一轮拉取。for (item in arr)遍历元素;for (i, item in arr)中i为下标、item为元素。字符串按 Unicode 字符遍历,不是按字节:
"你好".length()返回2,for (c in "你好")也迭代 2 次。indexOf返回字符下标,标准库substr(s, start, count)按字符截取,与length()一致。循环体中
continue不会跳过步进;每轮会重新读取长度,遍历期间追加的元素也会被遍历到。元素变量在每轮迭代中是独立副本:迭代内创建的 lambda 捕获的是当轮的值。
区间是语法级构造,遍历时不产生中间序列。
in仅在for (之后识别为关键字,其他位置仍可用作普通标识符。
区间
a..b 生成整数区间(仅 int),配合 for-in 使用。区间为半开 [a, b),端点在循环开始前求值一次:
for (i in 0..5) [
println(i); // 依次输出 0、1、2、3、4
]
for (i in 5..0) [ // 降序:5、4、3、2、1
println(i);
]5..5 为空区间(不执行循环体)。
break / continue
break 跳出当前循环,continue 跳过本次循环剩余部分;两者只能出现在循环内部,嵌套循环只作用于最近的一层。
panic(主动中止)
panic 立即中止整个脚本的执行,并携带一条错误信息。它是一个语句而不是函数调用,消息可以省略:
let config = GUIManager::value("shop.items");
if (config.length() == 0) [
panic("shop config is empty"); // 中止:后续语句不再执行
]
panic; // 也可以不带消息panic "msg";、panic("msg");与panic;三种写法都合法;消息是任意表达式,运行期求值。- 中止行为与运行时错误一致:错误信息带脚本级位置,VM 清理栈帧后退出,同一次运行中后续语句(包括外层函数的剩余部分)都不会再执行。
- 适合用于「配置缺失/前置条件不满足,继续执行没有意义」的场景;可恢复的分支请继续用
if/match。
match(模式匹配)
match (表达式) 按分支模式对目标值做结构化分发,语义参考 Rust 的 match。分支写作 模式 [ 主体 ],首个命中的分支生效,结果为该分支主体最后一个表达式的值:
let name = match (code) [
0 [ "zero" ]
1 [ "one" ]
n [ "many: " + n ]
];| 模式 | 示例 | 说明 |
|---|---|---|
| 字面量 | 1、-2、3.5、"text"、true、None | 按值相等匹配;None 匹配空的 optional;int / float 字面量可以互换匹配 |
| 类型 | Circle c | 按 instanceof 分发,c 在分支内绑定匹配到的对象 |
| 绑定 | n | 匹配任意值,n 在分支内可用 |
| 通配符 | _ | 匹配任意值,不绑定 |
| 带守卫 | n if (n > 10) | 模式命中后再做附加判断,守卫内可引用当前分支的绑定变量 |
match (shape) [
Circle c [ println("circle r=" + c.r); ]
Square s if (s.s > 5) [ println("big square"); ]
Square s [ println("square"); ]
_ [ println("unknown"); ]
]match是表达式,可用于let初始化、参与运算、作为函数return的值;分支主体内也可以return提前从外层函数返回。- 守卫只在模式命中后才求值,因此
Square s if (...)不会对非Square的值执行判断。 - 穷尽性是强制的:必须存在一个不带守卫的绑定或
_分支(bool目标也可以由true与false两个字面量分支覆盖),否则编译报错。 - 绑定变量只在自己的分支内可见,允许遮蔽外层同名变量;类型模式的类名在编译期校验,未知类会报错。
标准库(std)
assets/common/std/ 下的 .lcui 脚本即标准库:能拆出 C++ 的都拆成了脚本,原生层只保留 VM 值原语、超越函数与 ll/ui 相关类。入口脚本相对自己所在目录引用:
import "std/iter.lcui"; // 整个模块
import "std/array.lcui" { arrSum, arrZip }; // 只取需要的定义宿主从
assets/common/gui/等目录加载脚本时,路径形如import "../std/iter.lcui";。
| 模块 | 内容 |
|---|---|
iter.lcui | 惰性迭代器:range(a, b) / repeat(v) 种子,Iter 上的 map / filter / take / skip / takeWhile / stepBy / zip / chain 组合子,以及 collect / fold / sum / count / any / all / find / position / max / min / last 终结操作。映射与谓词是 Fn1 / Fn2 的子类(脚本世界的 Fn trait):class Square extends Fn1 { func call(x) { return x * x; } }。 |
array.lcui | 急切数组函数:arrMap / arrFilter / arrReverse / arrSum / arrProduct / arrAny / arrAll / arrFind / arrFindIndex / arrDedup / arrZip / arrLast / arrChunk。 |
string.lcui | upper / lower / capitalize / trim / trimStart / trimEnd / substr / slice / charAt / repeat / replace / padLeft / padRight / reverse(按 Unicode 字符;大小写映射 ASCII)。 |
math.lcui | abs / min / max / clamp / lerp(超越函数仍在原生 math:: 命名空间)。 |
result.lcui | Result 可恢复错误类型:Result::Ok(v) / Result::Err(e)、unwrap / unwrapOr / expect / errOr / map / mapErr / andThen。 |
示例——惰性流水线:
import "std/iter.lcui";
class Square extends Fn1 { func call(x) { return x * x; } }
class Big extends Fn1 { func call(x) { return x > 4; } }
let out = range(1, 10).map(new Square()).filter(new Big()).collect().join(",");
// "25,36,49,64,81"可选值语义:
let v = it.next();把原始值或None存进变量(动态目标不自动解包),if (v)、v.has_value、v.value与??/?.因此行为一致;类型标注为T的目标才会在赋值时立即解包。
函数
命名函数
使用 func 定义函数,参数与返回值可以标注类型,也可以省略(省略时为动态类型):
func add(a: int, b: int) -> int {
return a + b;
}
func pick(x) {
return x;
}
let result = add(1, 2);函数支持递归与相互递归调用,也支持按参数类型重载:
func id(x: int) -> int {
return x;
}
func id(x: string) -> string {
return x;
}
id(7); // 调用 int 版本
id("s"); // 调用 string 版本函数和类定义只能出现在脚本顶层,不能嵌套在函数或其他块中。return 只能出现在函数体内;没有显式 return 的函数返回空值。
匿名函数(Lambda)
使用 func (参数) -> 返回类型 { ... } 定义匿名函数,可以赋值给变量、作为参数传递,并捕获外部局部变量、参数与 this:
let double = func (x: int) -> int {
return x * 2;
};
form.button("Button", func () -> void {
GUIManager::callback("example.submit", [ double(21) ]);
}, new ButtonOptions());类与继承
定义类
class Animal {
public:
name = "unknown";
count: int = 0;
Animal(name) {
this.name = name;
}
func speak() -> string {
return "...";
}
}
let dog = new Animal("dog");
dog.name; // "dog"字段与方法
- 字段可以带默认值、类型标注,也可以两者都省略(动态类型)。
- 没有默认值的字段必须在构造函数中赋值。
- 构造函数的名称必须与类名一致,每个类最多一个构造函数;未定义构造函数时使用默认构造。
- 方法使用
func 方法名(参数) -> 返回类型 { ... }定义,支持重载。
访问控制
使用 public: 与 private: 声明访问段,默认(首个访问段之前)为公开:
class SecretBox {
private:
secret = 42;
public:
func getSecret() -> int {
return secret;
}
}
let box = new SecretBox();
box.getSecret(); // 42
box.secret; // 报错:私有字段私有成员不会被继承,子类中无法通过 this 或裸名称访问基类的私有成员。
静态成员
使用 static 声明静态字段或静态方法,通过 类名::成员 访问(实例成员用 .,静态成员用 :: 区分):
class Counter {
public:
static total = 0;
static func inc() -> int {
total += 1;
return total;
}
}
Counter::inc(); // 1
Counter::total; // 1静态字段在所有实例之间共享;静态方法中不能使用 this,实例对象也不能直接调用静态方法。静态成员支持继承与重写。
继承
使用 extends 继承基类:
class Dog extends Animal {
func speak() -> string {
return "Woof";
}
}
let dog = new Dog("dog");
dog instanceof Animal; // true相关关键字:
| 关键字 | 说明 |
|---|---|
extends | 声明基类 |
super(...) | 调用基类构造函数 |
super.方法(...) | 调用基类方法 |
instanceof | 判断对象是否为指定类(含派生类)的实例 |
this | 当前对象 |
构造规则:
- 子类构造函数若不显式调用
super(...),会隐式调用基类的无参构造(或默认构造)。 - 基类只有带参构造函数时,子类构造函数必须显式
super(...),否则报错。 - 类名先声明后声明均可,支持多层继承与多态;循环继承、重复类名、未知基类会报错。
对象的 ==/!= 同样为引用比较。
trait 与 impl
trait 声明一组方法契约,impl 为类提供实现,让"能力"可以脱离继承附加到类上:
trait Printable {
func describe() -> string;
}
class Card {
// ...
}
impl Printable for Card {
func describe() -> string {
return "card";
}
}impl Trait for Class为类实现 trait,必须实现 trait 声明的全部方法;trait 不存在、impl目标不是类、缺少必需方法都会报错。impl Class是固有实现(不绑定 trait),同样把方法合并进目标类。impl块内可以写static方法与const关联常量:lcuiimpl Card { const Kind = "card"; static func create() -> Card { return new Card(); } }impl的方法与常量会合并进目标类,调用方式与类自身方法完全一致(card.describe()),不存在额外的动态分发。与类中已存在的同名同参方法冲突、关联常量重名都会报错。
泛型
impl支持impl<T>形态。类型参数按位置分两段处理:- 前
min(impl参数数, 类参数数)个按位置映射到类的类型参数,名字不必相同——impl<U> ... for Box<T>里的U在合并时自动改写为T,方法签名里写U与类词表中的T是同一个变量。 - 多出来的参数(含非泛型类上的
impl<T>)作为方法级泛型变量注入每个合并进来的方法,用于表达「与该方法相关、但与类无关」的类型占位。 impl Box省略类型参数同样合法(<T>可写可不写)。
- 前
blanket impl(
impl<T> Trait for T,给所有类型统一实现)不支持,会报错。典型用途是可迭代协议:为类提供
length()与element(int)(写在类内或impl中均可),该类即可被for-in遍历,详见「for-in 遍历」。
示例:让自定义类可被遍历
trait Iterable {
func length() -> int;
func element(i: int) -> string;
}
class Deck {
cards = [];
}
impl Iterable for Deck {
func length() -> int {
return this.cards.length;
}
func element(i: int) -> string {
return this.cards[i];
}
}
let deck = new Deck();
for (i, card in deck) [
println(i + ": " + card);
]for-in 只检查目标是否提供 length() 与 element(int),不论它们定义在类体内还是 impl 块内,因此 Deck 无需继承任何基类即可被遍历。
常见错误
| 错误提示 | 原因 |
|---|---|
Cannot implement unknown trait 'X' | trait 未定义,或定义在 impl 之后 |
impl of trait 'X' for 'Y' is missing required method 'Z' | 未实现 trait 要求的全部方法 |
Method 'Z' is already defined in class 'Y' | 与类自身的同名同参方法冲突 |
impl target 'Y' is not a class | impl 的目标不是类 |
TIP
impl 在语义分析阶段就把方法与常量合并进目标类,因此不存在 trait 对象,也没有动态分发:能力是编译期附加的,调用开销与类自身方法完全一致。 需要运行时多态时用 extends 继承,需要给已有类横向附加能力时用 impl。
内置命名空间与宏
内置命名空间
| 命名空间 | 函数 | 说明 |
|---|---|---|
math | sqrt、pow、log、sin、cos、random | 数学函数(超越函数;abs/min/max 等可脚本化的已移入标准库) |
std | format(formatStr, array) | 使用数组中的值格式化字符串(fmt 风格) |
mc | runCmd(command) | 以当前玩家身份执行命令 |
GUIManager | value(id)、request(id, args)、callback(id, args)、open(id, formId, type[, ctx])、switchTo(id, type) | 与 GUI 数据交互 |
调用格式为 命名空间::函数(参数),例如:
mc::runCmd("say hello");
GUIManager::callback("example.submit", [ "data" ]);
let text = std::format({tr("example.gui.info")}, [ 1, "two" ]);
GUIManager::open与GUIManager::switchTo中的type为整数:1为 CustomForm,2为 MessageBox,3为 PaginatedForm,4为 ScriptForm。内置表单脚本从generic.lcui导入FORM_CUSTOM、FORM_MESSAGE、FORM_PAGINATED、FORM_SCRIPT来书写,避免散落的数字。
NOTE
GUIManager::open 跳转到其他脚本时,需要在 gui/permission.json 的 scripts.<id>.gui.navigations 中声明目标脚本 id,否则调用会被拒绝。打开自身表单无需声明。
宏 {...}
使用 {名称} 或 {名称(参数)} 调用宏,例如 {tr("language.gui.title")}。tr 返回当前玩家语言的翻译文本;{player}、{server_tps}、{score(名称)} 等默认变量也以宏形式使用,完整列表见 LOICollectionAPI。
let title = {tr("example.gui.title")};
let label = {player} + " -> " + {tr("example.gui.hello")};透传与阻断 $
$ 用于透传原始文本:$ 之后到 }(或 ;)之间的内容保持原样,不会参与解析:
let raw = $custom content};${变量} 形式可以阻断变量/宏替换,使内容保留字面形式,例如 {player_realname} + '->' + ${team} 中的 ${team} 会原样输出为 {team}。
原生类
脚本中可直接 new 以下原生类(概览):
| 类 | 用途 | 详细文档 |
|---|---|---|
GlobalValue | 全局值容器,字段 value | api.md |
CtxValue | 读取 GUIManager::open 传入的 ctx 数组,字段 value | 见下文 |
Map | 保持插入序的键值容器 | 见上文「Map」 |
ObservableString / ObservableNumber / ObservableBoolean / ObservableUIRawMessage | 可观察数据,用于控件双向绑定 | native-ui.md |
UIRawMessage | 构造富文本 / 翻译文本 | native-ui.md |
CustomForm / MessageBox / PaginatedForm | 原生表单 | native-ui.md |
TextOptions / ButtonOptions / TextFieldOptions / DropdownOptions / DropdownItem / SliderOptions / ToggleOptions / DividerOptions / SpacingOptions | 控件选项 | native-ui.md |
MenuData / MenuItemData / MenuControlData / MenuForm / MenuMessageBox | 菜单数据与表单 | api.md |
ShopData / ShopItemData / ShopForm | 商店数据与表单 | api.md |
ScoreRequirement | Score 需求,字段 objective、value | api.md |
CtxValue
当脚本通过 GUIManager::open(id, formId, type, ctx) 打开并传入 ctx 数组时,可以使用 new CtxValue(索引) 读取对应元素:
let detail = new CtxValue(0);
form.label(detail.value, new TextOptions());索引越界或未传入 ctx 时使用 CtxValue 会报错。
完整示例
综合使用变量、类型、函数、类、流程控制与原生表单:
class Item {
public:
name = "";
price: int = 0;
Item(name, price) {
this.name = name;
this.price = price;
}
func format() -> string {
return name + " (" + price + ")";
}
}
func makeItems() {
return [ new Item("Apple", 3), new Item("Banana", 5) ];
}
let items = makeItems();
let selected = new GlobalValue();
selected.value = "";
let form = new CustomForm("example.shop", {tr("example.shop.title")}) {
label({tr("example.shop.list")}, new TextOptions());
button(items[0].format(), on: func () -> void {
selected.value = items[0].name;
form.close();
}, new ButtonOptions());
button(items[1].format(), on: func () -> void {
selected.value = items[1].name;
form.close();
}, new ButtonOptions());
closeButton();
show();
};脚本权限:信任级与能力
脚本沙箱用单一门禁做敏感调用授权,完整字段见 数据文件 的 permission.json 一节。每个脚本先按信任级拿到一组能力(粗关),再按 scripts.<id> 下的 id 白名单做精确匹配(细关),两关都过才放行。
- 信任级在
trustLevels中定义为能力名数组,defaultTrust是缺省值,单个脚本可用trust覆盖; - 当前内置信任级:
user(仅GuiRead/GuiWrite)、admin与builtin(额外拥有ServerCommand与玩家数据 / 存档读写能力); - 旧有的
Network/Filesystem能力从未授予任何脚本,已从枚举中移除。
NOTE
粗关让未声明信任级的脚本只能碰 GUI:它即便在 defaultPolicy: "allow" 下也无法执行命令或读写玩家数据,默认最小权限。
执行预算与调用深度
为防止失控脚本卡死服务器,VM 对每次脚本执行(含表单回调这类脚本重入)计量燃料(fuel):每执行一条指令扣 1 点,同时墙钟、原生调用、对象与内存占用各有独立配额。任何一项耗尽都会立即终止执行并报错。
| 配额 | 默认值 | 允许范围 | 触发后的行为 |
|---|---|---|---|
| 指令数 | 1,000,000 条 | 10,000 ~ 100,000,000 | 终止执行并报错 Execution budget exhausted |
| 墙钟 | 1,000 ms | 10 ~ 30,000 ms | 终止执行并报错 Execution timeout |
| 调用深度 | 256 层调用帧 | 16 ~ 4,096 | 终止执行并报错 Call stack depth limit exceeded |
| 原生调用 | 100,000 次 | 1,000 ~ 10,000,000 | 终止执行并报错 Execution budget exhausted |
| 对象数 | 100,000 个 | 1,000 ~ 10,000,000 | 同上 |
| 数组元素 | 100,000 个 | 1,000 ~ 10,000,000 | 同上 |
| 单字符串 | 1 MiB | 4 KiB ~ 256 MiB | 终止执行并报错 String size budget exhausted |
| 累计内存 | 64 MiB | 1 MiB ~ 1 GiB | 终止执行并报错 Total byte budget exhausted |
普通界面脚本远达不到这些上限;忘写步进条件的死循环(如 while (true))或无终止条件的递归会被及时中止,而不是拖垮服务器。
按脚本调整预算
默认配额写在代码里,可按脚本在 gui/permission.json 中覆盖——根节点的 budget 是全局默认值,scripts.<id>.budget 只对该脚本生效,未写的字段沿用上层取值:
{
"defaultPolicy": "deny",
"budget": { "maxInstructions": 250000, "maxWallTimeMs": 750 },
"scripts": {
"market": {
"enabled": true,
"budget": { "maxInstructions": 4000000 },
"gui": { "values": [], "requests": [], "callbacks": [] }
}
}
}- 键名依次是
maxInstructions、maxWallTimeMs、maxFrames、maxNativeCalls、maxObjectCount、maxArrayElements、maxStringBytes、maxTotalBytes;只有maxWallTimeMs带Ms后缀(单位是毫秒),其余均为计数; - 超出允许范围的取值会被夹到边界:
0不会关闭沙箱,99999999ms 也变不成 27 小时; - 非数字或负数(如
"1000"、-5)一律忽略,回退到上层默认值; - 未知脚本 id 使用根节点
budget。
TIP
优先调大单个脚本的预算,而不是一上来就抬高全局默认值——配额的意义是圈住失控脚本,全局放宽等于给所有脚本一起松绑。
字节码优化
编译完成后、执行之前,字节码会经过一轮优化器(Optimizer)处理。优化只改变「怎么算」,不改变「算什么」——程序该报的错一个不会少,报错时的源码位置也保持不变。对脚本作者来说这一切是透明的,了解它有助于写出天然受益的脚本:
| 优化 | 例子 | 效果 |
|---|---|---|
| 常量折叠 | 1 + 2 * 3 | 编译期算成 PUSH_INT 7,运行期不再计算 |
| 变量值转发 | let x = 1; if (x == 1) [...] | 后续读取 x 直接替换为已知值,条件分支随之消除 |
| 纯函数折叠 | math::sqrt(9)、math::pow(2, 10) | 全常量参数的 math:: 纯函数在编译期求值;math::random 等非纯函数不折叠 |
| 超级指令 | let x = 1(DUP + STORE_VAR) | 高频指令对融合为单条 DUP_STORE / DUP_IS_NONE,一次分发完成两件事 |
| 跳转窥孔 | 条件跳转后紧跟同目标无条件跳转 | 双重跳转合并,中间不可达代码整体删除 |
| 死代码删除 | while (false) [...]、if (false) [...] | 不可达分支整体移除 |
优化器会反复迭代以上过程直到字节码不再变化(不动点),因此一条优化暴露出的新机会(如变量转发让条件变为常量)会在下一轮被继续吃掉。
运行期还有一层内联缓存:脚本反复调用同一个原生函数或原生方法时(例如循环里的 math::sqrt(...)、arr.push(...)),VM 会记住上一次的解析结果,命中时跳过签名匹配与注册表查找。这只影响速度不影响行为——缓存失效(如插件重载导致注册表变化)时自动回退到完整查找。
字节码产物
GUIManager::load 编译脚本后,会把字节码序列化到源文件旁边的 <脚本名>.lcp 文件(例如 market.lcui 对应 market.lcui.lcp):
- 再次加载时优先读取
.lcp产物,命中则跳过词法/语法/语义分析与编译,直接复用字节码; - 产物头记录脚本 id 与 ABI 指纹(所有已注册原生类、函数、宏的签名形状哈希);插件升级导致任一原生签名变化时,指纹不匹配会让旧产物自动失效并重新编译;
- 源文件在场时,产物还会记录入口文件与整个导入图中每个文件的 SHA-256,任一文件内容变化都会重新编译;
- 序列化数据附带校验和,损坏的产物会被丢弃并重新编译;
- 产物自包含:删除
.lcui后,只要 ABI 指纹匹配,脚本依然可以加载——这是闭源脚本的发布方式; - 调试信息单独存放在
.lcp.dbg,缺失时字节码照常执行,仅丢失行列号信息。
当前产物格式版本为 v2。
常见约束与陷阱
- 类、命名函数、
using与组件只能出现在脚本顶层,不能嵌套定义。 - 声明式 UI 块只能用于表单类(
CustomForm/MessageBox/PaginatedForm/ScriptForm);on:命名参数只在块内可用。 - 组件调用只能作为声明式 UI 块内的语句;组件递归(直接或间接)会报编译错误。
- 被导入的文件只能包含顶层定义(
class/func/using/component/import);不同文件中的同名定义会冲突报错。 - 每次脚本执行都受执行预算约束(默认 1,000,000 条指令 / 256 层调用帧),超限会终止执行;可按脚本在
permission.json的budget中调整,见执行预算与调用深度。 return只能在函数内使用;break/continue只能在循环内使用。- 类型声明(
let x: int = 0;)必须同时提供初始值;没有默认值的类字段必须在构造函数中赋值。 None只能用于optional上下文;空optional直接读取、算术或比较会报错。%仅支持整数;除零、整数溢出会报错。- 数组索引必须为
int,越界读取或负数索引会报错;索引等于长度时表示追加。 - 字符串未知转义(如
\x)保持字面量并产生警告;需要包含引号时也可使用另一种引号包裹。 ??只在None/ 空optional时触发,0、""、false不会;??与&&/||混用必须加括号。- 函数调用的参数最多支持 100 个,超出会解析失败。
- 不同模块注册的
GUIManager::value/request/callbackID 各有不同,具体以对应模块的.lcui文件为准。
脚本语言的常见错误及解决方案,请参见 常见错误含义。