Skip to content
全文约 14670 字 预计阅读 53 分钟

LCUI 脚本语法 ​

NOTE

以下内容取自 LOICollectionA 1.17.1 的 .lcui 脚本语言实现,对于后续版本可能会有所不同。

概述 ​

.lcui 是 LOICollectionA 用于编写游戏内 GUI 的脚本语言。所有模块的内置界面(如 blacklist.lcui、statistics.lcui)以及 Menu/Shop 的 menu.lcui、shop.lcui 均使用该语言编写。

脚本的完整处理流程为:

text
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。

一个最小的脚本 ​

lcui
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 块 ​

创建表单时,可以在构造调用后紧跟一个 { ... } 块,按界面出现顺序声明控件。块内省略接收者的方法调用会自动作用于正在构建的表单。上面的最小脚本完全等价于手工展开的命令式写法:

lcui
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 中,审核按钮仅在管理员且开启审核时才会加入:
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 把重复的表单控件组合抽象成可复用的片段。组件在使用点按编译期宏展开(卫生展开),参数按名绑定,运行期零开销;组件体内的裸方法调用继承使用点的当前表单隐式接收者:

lcui
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();
};

上面的写法在编译期等价于直接在块内写:

lcui
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 中:

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();
};

也可以只导入部分定义。在路径后跟 { 名字, ... } 列出需要的符号,未列出的定义不会进入当前程序(引用会得到未定义错误):

lcui
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)会报错,并给出完整环路径
文件缺失导入的文件不存在或不可读时报错

注释、字面量与语句分隔 ​

注释 ​

支持行注释 // 与块注释 /* ... */:

lcui
// 这是行注释
/* 这是
   块注释 */

字面量 ​

字面量示例说明
整数42、-132 位有符号整数
浮点数3.14、.5、5.支持省略小数点前的 0
字符串"hello"、'world'单引号或双引号包裹
布尔true、false—
空值None仅可用于 optional 上下文
数组[1, "a", true]可混合元素类型

字符串支持转义序列:\n(换行)、\t(制表符)、\r(回车)、\\(反斜杠)、\"(双引号)、\'(单引号)。未知转义(如 \x)保持字面量并产生编译期警告。若字符串中需要包含某种引号,也可以使用另一种引号包裹,例如 'say "hi"';字符串也允许跨行。

语句分隔 ​

语句之间可以使用 ; 分隔,也可以使用换行分隔。同一行内存在多条语句时必须使用 ;,推荐每条语句都以 ; 结尾:

lcui
let a = 1; let b = 2;   // 同一行需要 ;

let c = 3           // 换行可以省略 ;
let d = 4;

变量与类型 ​

变量声明(let) ​

所有变量绑定都必须用 let 显式声明,不再支持无关键字的直接赋值隐式创建变量:

lcui
let a = 1;          // 可变变量,动态类型
let count: int = 1; // 可变变量,带类型标注

let 声明的变量可随时重新赋值,类型仍可动态变化。const 不是语句级关键字,它只用于 trait / impl 中的关联常量(例如 const G = 9.8;)。该约束只作用于局部变量与全局变量的绑定,函数参数、类字段与 optional/variant 成员仍使用原有的结构化声明语法。

动态变量 ​

未标注类型的 let 声明即为动态类型,可随时赋其他类型的值:

lcui
let a = 1;
a = "test";     // 合法,动态类型
let b = [1, 2, 3];

类型标注 ​

使用 let 变量名: 类型 = 值 声明带类型的变量,类型声明必须同时提供初始值:

lcui
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:

lcui
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, ...> 只能保存声明列表中的某一种类型,赋值时会校验:

lcui
let data: variant<string, bool, int> = 1;
data.type;      // "int"
data.value;     // 1
data = "test";  // 合法
data = 1.5;     // 报错:float 不在声明列表中

variant 至少需要两个类型参数(如 variant<int> 不合法)。

类型别名 using ​

使用 using 可以为类型定义别名,仅允许出现在脚本顶层:

lcui
using Value = variant<string, bool, int>;
using Count = int;

let a: Value = 1;
let b: Count = 3;

别名可以链式引用(using B = A;),但不能自引用,也不能重复定义。

数组 ​

数组使用 [元素1, 元素2, ...] 创建,支持任意混合类型:

lcui
let items = [1, "a", true];
items[0];       // 1
items.length;   // 3

items.push(2);  // 追加元素,返回新长度
items[1] = "b"; // 修改已有元素(索引等于当前长度时同样会追加)

数组索引从 0 开始,越界读取、负数索引会报错。数组的 ==/!= 比较的是引用(是否为同一个数组),而非内容。类的数组字段在每个实例中相互隔离(创建实例时会拷贝),修改一个实例的数组不会影响其他实例。

数组方法 ​

数组自带以下方法(无需引入,直接以 数组.方法(...) 调用):

方法签名说明
pusharr.push(v)追加元素,返回新长度
poparr.pop()弹出并返回末元素;空数组报错
containsarr.contains(v)是否包含相等的元素(== 语义)
indexOfarr.indexOf(v)首次出现的索引;找不到返回 -1
joinarr.join(sep)元素全部转字符串后用 sep 拼接
slicearr.slice(start, end)半开区间切片,返回新数组;越界自动裁剪
sortarr.sort([cmp])原地排序;cmp 为 func(a, b) -> int,省略时要求元素两两可比较
lcui
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 的索引为负数时报错。

字符串方法 ​

字符串同样以 字符串.方法(...) 调用:

方法签名说明
splits.split(sep)按分隔符拆分为数组;sep 为空串时按字符拆分
containss.contains(sub)是否包含子串
startsWith / endsWiths.startsWith(sub)前缀 / 后缀判断
indexOfs.indexOf(sub)首次出现的字符下标;找不到返回 -1
toInt / toFloats.toInt()解析为 optional<int> / optional<float>,失败时为空

字符串也支持以 [] 取下标索引,返回指定位置的单个字符(类型为 string),越界或负索引会报错:

lcui
"hello"[0];   // "h"
"hello"[4];   // "o"
"hello"[-1];  // 报错:负索引
lcui
"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;              // false

Map ​

Map 是保持插入序的键值容器,键支持 int / float / string / bool(值相等即同键):

lcui
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,适合搭配空值安全操作符(见下文)。

运算符 ​

算术运算符 ​

运算符说明
+加法 / 字符串拼接
-减法
*乘法
/除法(两个整数相除也会产生浮点数)
%取模(仅支持整数)
^幂运算(右结合)
+/-(一元)正号 / 负号
lcui
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 尤为重要):

lcui
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:

lcui
let i = 0;
i++;      // i = 1
++i;      // i = 2
i--;      // i = 1

WARNING

++ / -- 作为表达式参与运算时返回的是更新后的新值(前缀语义),与 C/C++ 的后缀 i++ 返回旧值不同:let i = 5; let j = i++; 得到 j == 6 而不是 5。如需旧值,请先保存再自增。

历史版本中 ++i 会被解析为双重一元正号(值不变),引入前缀自增后语义改变;实际脚本中几乎不会出现这种写法,但属于已知的破坏性变更。

空值安全操作符 ​

??(空值合并)在左侧为 None 或空 optional 时取右侧的值,否则取左侧;0、""、false 不会触发合并(与真值规则无关):

lcui
let name = value ?? "默认";
let count = 0 ?? 10;    // 0:0 不是 None,不触发合并

?.(安全访问)在左侧为 None 或空 optional 时使整个访问链短路,结果为 None,支持字段与索引两种形式:

lcui
let len = maybeArr?.length ?? 0;
let item = obj?.data?.[0];

?? 的优先级高于赋值、低于比较运算,且不能与 && / || 无括号混用(直接报错,避免歧义)。x?.y 的结果可能为 None,赋给 T 类型标注变量会报错,需搭配 ?? 或使用 optional<T>。

错误传播 ? ​

在 optional 值后加 ? 表示「有值就取出,空值就从当前函数提前返回 None」。它把连续的空值检查压平成一行,作用范围是最近的函数边界(闭包内使用则返回到闭包的调用方):

lcui
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")。

对象、数组与函数支持 ==/!=,但比较的是引用:

lcui
let a = [1];
let b = [1];
a == b;   // false,内容相同但不是同一个数组
let c = a;
a == c;   // true

None == None 为 true。

运算符重载 ​

脚本类可以通过实现内置的运算符 trait 来重载运算,让自定义对象支持 +、-、==、一元负号等(Rust 风格:运算符对应一个 trait,trait 对应一个 op_xxx 方法)。内置运算符 trait 如下:

运算符trait方法参数
+Addop_add(rhs)1
-Subop_sub(rhs)1
*Mulop_mul(rhs)1
/Divop_div(rhs)1
%Modop_mod(rhs)1
^Powop_pow(rhs)1
-(一元)Negop_neg()0
==Eqop_eq(rhs)1
!=Neop_ne(rhs)1
<Ltop_lt(rhs)1
<=Leop_le(rhs)1
>Gtop_gt(rhs)1
>=Geop_ge(rhs)1

使用时对类 impl 对应的 trait 并实现 op_xxx 方法,运算符表达式即会调用该方法:

lcui
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,可用于相等性判断:

lcui
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 可以省略:

lcui
if (count > 0) [
    mc::runCmd("say positive");
:
    mc::runCmd("say not positive");
]

if (flag) [
    mc::runCmd("say flag is true");
]

if 本身也是表达式,分支中的最后一个值会作为结果返回,可以参与运算或赋值:

lcui
let result = if (count > 0) [ 10 : 20 ];
let total = if (flag) [ 1 : 2 ] + 5;

while ​

lcui
let i = 0;
while (i < 5) [
    i++;
]

for ​

lcui
let s = 0;
for (let i = 0; i < 10; i++) [
    if (i % 2 == 0) [ continue; ];
    s += i;
]

for (;;) 可以省略三个子句,配合 break 使用。

for-in 遍历 ​

for-in 用于遍历可迭代对象,可同时获得下标与元素:

lcui
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),无需改动编译器即可被遍历:

lcui
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 即终止:

lcui
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),端点在循环开始前求值一次:

lcui
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 立即中止整个脚本的执行,并携带一条错误信息。它是一个语句而不是函数调用,消息可以省略:

lcui
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。分支写作 模式 [ 主体 ],首个命中的分支生效,结果为该分支主体最后一个表达式的值:

lcui
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)模式命中后再做附加判断,守卫内可引用当前分支的绑定变量
lcui
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 相关类。入口脚本相对自己所在目录引用:

lcui
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.lcuiupper / lower / capitalize / trim / trimStart / trimEnd / substr / slice / charAt / repeat / replace / padLeft / padRight / reverse(按 Unicode 字符;大小写映射 ASCII)。
math.lcuiabs / min / max / clamp / lerp(超越函数仍在原生 math:: 命名空间)。
result.lcuiResult 可恢复错误类型:Result::Ok(v) / Result::Err(e)、unwrap / unwrapOr / expect / errOr / map / mapErr / andThen。

示例——惰性流水线:

lcui
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 定义函数,参数与返回值可以标注类型,也可以省略(省略时为动态类型):

lcui
func add(a: int, b: int) -> int {
    return a + b;
}

func pick(x) {
    return x;
}

let result = add(1, 2);

函数支持递归与相互递归调用,也支持按参数类型重载:

lcui
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:

lcui
let double = func (x: int) -> int {
    return x * 2;
};

form.button("Button", func () -> void {
    GUIManager::callback("example.submit", [ double(21) ]);
}, new ButtonOptions());

类与继承 ​

定义类 ​

lcui
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: 声明访问段,默认(首个访问段之前)为公开:

lcui
class SecretBox {
private:
    secret = 42;
public:
    func getSecret() -> int {
        return secret;
    }
}

let box = new SecretBox();
box.getSecret();    // 42
box.secret;         // 报错:私有字段

私有成员不会被继承,子类中无法通过 this 或裸名称访问基类的私有成员。

静态成员 ​

使用 static 声明静态字段或静态方法,通过 类名::成员 访问(实例成员用 .,静态成员用 :: 区分):

lcui
class Counter {
public:
    static total = 0;

    static func inc() -> int {
        total += 1;
        return total;
    }
}

Counter::inc();  // 1
Counter::total;  // 1

静态字段在所有实例之间共享;静态方法中不能使用 this,实例对象也不能直接调用静态方法。静态成员支持继承与重写。

继承 ​

使用 extends 继承基类:

lcui
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 为类提供实现,让"能力"可以脱离继承附加到类上:

lcui
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 关联常量:

    lcui
    impl 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 遍历」。

示例:让自定义类可被遍历 ​

lcui
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 classimpl 的目标不是类

TIP

impl 在语义分析阶段就把方法与常量合并进目标类,因此不存在 trait 对象,也没有动态分发:能力是编译期附加的,调用开销与类自身方法完全一致。 需要运行时多态时用 extends 继承,需要给已有类横向附加能力时用 impl。

内置命名空间与宏 ​

内置命名空间 ​

命名空间函数说明
mathsqrt、pow、log、sin、cos、random数学函数(超越函数;abs/min/max 等可脚本化的已移入标准库)
stdformat(formatStr, array)使用数组中的值格式化字符串(fmt 风格)
mcrunCmd(command)以当前玩家身份执行命令
GUIManagervalue(id)、request(id, args)、callback(id, args)、open(id, formId, type[, ctx])、switchTo(id, type)与 GUI 数据交互

调用格式为 命名空间::函数(参数),例如:

lcui
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。

lcui
let title = {tr("example.gui.title")};
let label = {player} + " -> " + {tr("example.gui.hello")};

透传与阻断 $ ​

$ 用于透传原始文本:$ 之后到 }(或 ;)之间的内容保持原样,不会参与解析:

lcui
let raw = $custom content};

${变量} 形式可以阻断变量/宏替换,使内容保留字面形式,例如 {player_realname} + '->' + ${team} 中的 ${team} 会原样输出为 {team}。

原生类 ​

脚本中可直接 new 以下原生类(概览):

类用途详细文档
GlobalValue全局值容器,字段 valueapi.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
ScoreRequirementScore 需求,字段 objective、valueapi.md

CtxValue ​

当脚本通过 GUIManager::open(id, formId, type, ctx) 打开并传入 ctx 数组时,可以使用 new CtxValue(索引) 读取对应元素:

lcui
let detail = new CtxValue(0);
form.label(detail.value, new TextOptions());

索引越界或未传入 ctx 时使用 CtxValue 会报错。

完整示例 ​

综合使用变量、类型、函数、类、流程控制与原生表单:

lcui
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 ms10 ~ 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 MiB4 KiB ~ 256 MiB终止执行并报错 String size budget exhausted
累计内存64 MiB1 MiB ~ 1 GiB终止执行并报错 Total byte budget exhausted

普通界面脚本远达不到这些上限;忘写步进条件的死循环(如 while (true))或无终止条件的递归会被及时中止,而不是拖垮服务器。

按脚本调整预算 ​

默认配额写在代码里,可按脚本在 gui/permission.json 中覆盖——根节点的 budget 是全局默认值,scripts.<id>.budget 只对该脚本生效,未写的字段沿用上层取值:

json
{
    "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 不会关闭沙箱,99999999 ms 也变不成 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 / callback ID 各有不同,具体以对应模块的 .lcui 文件为准。

脚本语言的常见错误及解决方案,请参见 常见错误含义。

LOICollectionA · 开箱即用的 LeviLamina 多功能插件集