Merge pull request #112 from EVEngine/codex/dialogue-procedural-generation
feat(dialogue): procedural dialogue generation v1 (.dnut C++ compiler + variables/conditions/pools)
This commit is contained in:
18 files changed
+2362
-10
No files matched your search
@@ -194,3 +194,31 @@ test/dialogue.cpp
|
||||
- [x] Dialogue 口型:`setLipSync*` 在打字阶段驱动 speaker 参数 / Image 图层 alpha
|
||||
- [ ] 正式 Cubism Core 渲染实现(需自带 SDK,放在用户插件中)
|
||||
- [ ] 音频波形口型(当前为打字机正弦包络,不依赖 audio)
|
||||
|
||||
## 程序化对话 v1:内容模型 / 变量 / 条件 / 台词池(2026-08-18)
|
||||
|
||||
在保持「剧情仍由 Squirrel 驱动」的前提下,为程序化生成补齐数据与运行时。
|
||||
|
||||
### 变量(双区)
|
||||
- `setVar(name, value, scope)`:value 支持 int / float / bool / string;scope 为 `"global" | "scene"`。
|
||||
- `getVarType / getVarInt / getVarFloat / getVarBool / getVarString / hasVar / clearVar / clearVars(scope)`(scope 也支持 `"all"`)。
|
||||
- scene 区在 `Dialogue::update` 内感知 `eve.Scene` 当前选中 host 名,切换时自动清空;global 区不动。
|
||||
|
||||
### 条件
|
||||
- 结构化表:`{ var, op, value }`(op:eq/ne/gt/ge/lt/le/has/missing)、`{ all=[...] }` / `{ any=[...] }` / `{ not=... }`、`{ script="name" }`。
|
||||
- `registerCondition(name, fn)` 注册脚本谓词,`fn(ctx)` 返回 bool,ctx = `{ vars, params, lineId }`。
|
||||
- `evalCondition(table)` 可在脚本里直接复用。
|
||||
|
||||
### 台词池
|
||||
- `loadPoolsFromTable(table)`:根表 `{ pools = { <poolId> = { noRepeat = N, lines = [ ... ] } } }`;追加注册,同名池整体替换。
|
||||
- 行字段:`id`(缺省 `poolId.序号`)、`speaker`(空=旁白)、`text`(字面量,支持 `{var}` 替换)或 `i18n`(翻译键,参数=变量+pick 参数)、`weight`(缺省 1,≤0 不参与随机)、`when`、`meta`(字符串表;`expression` / `motion` 播放时自动应用到绑定 Avatar,其余字段经 `getCurrentLineMeta` 暴露)、`tags`。
|
||||
- `pickLine(poolId, params)`:when 过滤 + 加权随机 + 每池最近 N 条去重(`noRepeat` 缺省 3,无候选时回退全池)。
|
||||
- `playLine(lineId, params)` / `playPool(poolId, params)`:解析文本 → `say/narrate` → 应用 meta。
|
||||
- `setRandomSeed(seed)` / `getRandomSeed()`:选择器内部 xorshift32,可复现。
|
||||
- `getLastPoolsError()` 返回首次错误;`clearPools / getPoolCount / getPoolId / hasPool` 用于池管理。
|
||||
|
||||
### .dnut 内容格式(增强 nut)
|
||||
- C++ 解析器 `src/modules/dialogue/DnutParser.cpp`:`dlg.loadPoolsFromDnut(source, path)` / `dlg.loadPoolsFromDnutFile(path)` 直接解析为池表并注册;错误经 `getLastPoolsError()` 读取(形如 `path:行: 信息`)。
|
||||
- 语法:`pool` 块、`when <条件> { ... }` 分组、`speaker: "文本"` / `- "文本"`(旁白)、属性 `weight / i18n / id / meta(...) / tags=[...]`(属性与台词同行)。
|
||||
- 条件语法糖 `mood == "happy" && hour >= 18` 编译为结构化 when 表;不引入第二套运行时。
|
||||
- 示例:`examples/dialogue/pools.dnut`;热重载走 `eve_asset_reload`(.dnut 变更时重新 runFile)。
|
||||
@@ -15,6 +15,7 @@ make run/linux-debug GAME=examples/dialogue
|
||||
- **空格 / 鼠标左键 / 触屏**:推进打字机或下一句
|
||||
- **1 / 2**:在选项阶段选择
|
||||
- **数字键 1 / 2**(非选项阶段):切换英文 / 中文文案(`eve.I18n` 翻译表热切换)
|
||||
- **3**:切换场景(scene 变量区随场景切换自动清空)
|
||||
|
||||
## 要点
|
||||
|
||||
@@ -22,3 +23,4 @@ make run/linux-debug GAME=examples/dialogue
|
||||
- **对话文案走 i18n 翻译表**——台词、角色名、选项、UI 提示都来自 `locales/en.json` / `locales/zh.json`,支持运行时热切换与热重载。
|
||||
- Image Avatar:`body` + `face` + `blush` 三层;表情用 `defineExpression("shy", "blush=1;face=1")`。
|
||||
- Live2D / VRoid 工厂在本例未加载真实模型;API 见 `docs/对话与Avatar模块设计.md`。
|
||||
- **程序化对话(.dnut)**——`pools.dnut` 由 C++ 解析器 `dlg.loadPoolsFromDnutFile` 解析注册;`mood` / `hour` 变量驱动加权选词,`meta` 自动切表情/动作,修改 `pools.dnut` 触发资产热重载。
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": { "alice": "Alice", "bob": "Bob" },
|
||||
"name": { "alice": "Alice", "bob": "Bob", "player": "Traveler" },
|
||||
"line": {
|
||||
"hello": "Hello! I'm {name}.",
|
||||
"intro": "I'm Bob. This dialogue script is written in Squirrel.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": { "alice": "Alice", "bob": "Bob" },
|
||||
"name": { "alice": "Alice", "bob": "Bob", "player": "旅行者" },
|
||||
"line": {
|
||||
"hello": "你好!我是 {name}。",
|
||||
"intro": "我是 Bob。这是用 Squirrel 写的对话脚本。",
|
||||
|
||||
@@ -21,6 +21,8 @@ if (!("vnDone" in getroottable())) vnDone <- false;
|
||||
if (!("waitingResume" in getroottable())) waitingResume <- false;
|
||||
if (!("prevKeys" in getroottable())) prevKeys <- {};
|
||||
if (!("uiReady" in getroottable())) uiReady <- false;
|
||||
if (!("curSceneName" in getroottable())) curSceneName <- "town";
|
||||
if (!("sceneReady" in getroottable())) sceneReady <- false;
|
||||
|
||||
function edgePressed(key, down) {
|
||||
local was = (key in prevKeys) ? prevKeys[key] : false;
|
||||
@@ -80,12 +82,13 @@ function tr(key, params = null) {
|
||||
|
||||
function scene_intro() {
|
||||
dlg.show("alice", "left");
|
||||
dlg.setExpression("alice", "happy");
|
||||
dlg.say("alice", tr("line.hello", { name = "Alice" }));
|
||||
if (!dlg.playPool("alice.greet", { name = tr("name.player") }))
|
||||
dlg.say("alice", tr("line.hello", { name = tr("name.player") }));
|
||||
yield "wait";
|
||||
|
||||
dlg.show("bob", "right");
|
||||
dlg.say("bob", tr("line.intro"));
|
||||
if (!dlg.playPool("bob.greet", { name = tr("name.player") }))
|
||||
dlg.say("bob", tr("line.intro"));
|
||||
yield "wait";
|
||||
|
||||
dlg.setExpression("alice", "shy");
|
||||
@@ -117,6 +120,28 @@ function scene_intro() {
|
||||
yield "wait";
|
||||
}
|
||||
|
||||
function buildScenes() {
|
||||
// 两个最小 Scene host:按键 3 切换,演示 scene 变量区随场景切换自动清空。
|
||||
scene.beginBuild();
|
||||
scene.beginNode("root", "Root");
|
||||
scene.end();
|
||||
scene.mountBuildAs("town");
|
||||
scene.beginBuild();
|
||||
scene.beginNode("root", "Root");
|
||||
scene.end();
|
||||
scene.mountBuildAs("tavern");
|
||||
scene.select("town");
|
||||
sceneReady = true;
|
||||
}
|
||||
|
||||
function eve_asset_reload(path) {
|
||||
// .dnut 属于内容资产:热重载时重新编译并注册台词池。
|
||||
if (path == "pools.dnut") {
|
||||
dlg.loadPoolsFromDnutFile("pools.dnut");
|
||||
print("dialogue pools reloaded: " + path + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
function resumeVn() {
|
||||
if (vnDone || vnGen == null) return;
|
||||
try {
|
||||
@@ -191,7 +216,10 @@ function refreshUI() {
|
||||
} else {
|
||||
ui.setText("c1", "");
|
||||
ui.setText("c2", "");
|
||||
ui.setText("hint", vnDone ? tr("hint.restart") : tr("hint.advance"));
|
||||
local sceneInfo = sceneReady
|
||||
? (" 场景:" + curSceneName + " met=" + dlg.getVarBool("met", false, "scene") + " [3]切场景")
|
||||
: "";
|
||||
ui.setText("hint", (vnDone ? tr("hint.restart") : tr("hint.advance")) + sceneInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +255,15 @@ function eve_init() {
|
||||
i18n.setLanguage("zh");
|
||||
i18n.setAutoReload(true);
|
||||
}
|
||||
// 程序化对话:.dnut 台词池 + 故事变量 + 脚本谓词。
|
||||
dlg.loadPoolsFromDnutFile("pools.dnut");
|
||||
dlg.setVar("name", tr("name.player"), "global");
|
||||
dlg.setVar("mood", "happy", "global");
|
||||
dlg.setVar("hour", 20, "global");
|
||||
dlg.registerCondition("isEvening", function(ctx) {
|
||||
return ctx.vars.hour >= 18;
|
||||
});
|
||||
buildScenes();
|
||||
if (aliceAv == null) aliceAv = makePortrait("happy", 0.35, 0.55, 0.85);
|
||||
if (bobAv == null) bobAv = makePortrait("neutral", 0.55, 0.40, 0.65);
|
||||
buildUI();
|
||||
@@ -250,6 +287,14 @@ function eve_update(dt) {
|
||||
dlg.reset();
|
||||
startScene();
|
||||
}
|
||||
// 3 = 切换场景:Dialogue 感知 Scene host 变化并自动清空 scene 变量区。
|
||||
if (keyPressed("3")) {
|
||||
curSceneName = (curSceneName == "town") ? "tavern" : "town";
|
||||
scene.select(curSceneName);
|
||||
dlg.setVar("met", true, "scene");
|
||||
dlg.reset();
|
||||
startScene();
|
||||
}
|
||||
dlg.update(dt);
|
||||
avatar.update(dt);
|
||||
dlg.syncStage(config.width.tofloat(), config.height.tofloat());
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// EVEngine Dialogue 台词池示例(.dnut)
|
||||
// 由 dlg.loadPoolsFromDnutFile("pools.dnut") 解析并注册到 eve.Dialogue。
|
||||
// 玩法:mood / hour 是故事变量,playPool 按条件加权选词;meta 自动切表情/动作。
|
||||
|
||||
pool alice.greet noRepeat=3 {
|
||||
when mood == "happy" && hour >= 18 {
|
||||
alice: "晚上好呀,{name}!今天过得怎么样?" weight=2 meta(expression="happy", motion="wave") tags=["greet","evening"]
|
||||
alice: "你来啦,我正想着你呢。" weight=1 meta(expression="happy") tags=["greet"]
|
||||
}
|
||||
when mood == "happy" {
|
||||
alice: "嗨,{name}!今天心情不错吧?" weight=2 meta(expression="happy") tags=["greet"]
|
||||
alice: "见到你真高兴!" weight=1 meta(expression="happy") tags=["greet"]
|
||||
}
|
||||
when mood == "shy" {
|
||||
alice: "那个……你、你好。" weight=1 meta(expression="shy") tags=["greet"]
|
||||
}
|
||||
alice: "……你好。" weight=1 tags=["greet"]
|
||||
}
|
||||
|
||||
pool bob.greet noRepeat=2 {
|
||||
when hour >= 18 {
|
||||
bob: "晚上好。今天过得怎么样?" weight=2 meta(expression="neutral") tags=["greet","evening"]
|
||||
}
|
||||
bob: "嘿,{name}。" weight=1 meta(expression="neutral") tags=["greet"]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,12 +3,81 @@
|
||||
#include "avatar/AvatarInstance.h"
|
||||
#include "common/Module.h"
|
||||
|
||||
#include <squirrel.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace ssq {
|
||||
class Object;
|
||||
}
|
||||
|
||||
namespace eve::dialogue {
|
||||
|
||||
/**
|
||||
* Generic JSON-like value tree used by the Squirrel bridge: dialogue pools and
|
||||
* conditions arrive as Squirrel tables and are converted into this structure
|
||||
* so the core loader/evaluator stays script-agnostic and unit-testable.
|
||||
*/
|
||||
struct DataValue {
|
||||
enum class Kind { Null, Int, Float, Bool, String, Array, Object };
|
||||
|
||||
Kind kind = Kind::Null;
|
||||
long long i = 0;
|
||||
double f = 0.0;
|
||||
bool b = false;
|
||||
std::string s;
|
||||
std::vector<DataValue> arr;
|
||||
std::vector<std::pair<std::string, DataValue>> obj;
|
||||
|
||||
static DataValue null() { return {}; }
|
||||
static DataValue integer(long long v) {
|
||||
DataValue d;
|
||||
d.kind = Kind::Int;
|
||||
d.i = v;
|
||||
return d;
|
||||
}
|
||||
static DataValue number(double v) {
|
||||
DataValue d;
|
||||
d.kind = Kind::Float;
|
||||
d.f = v;
|
||||
return d;
|
||||
}
|
||||
static DataValue boolean(bool v) {
|
||||
DataValue d;
|
||||
d.kind = Kind::Bool;
|
||||
d.b = v;
|
||||
return d;
|
||||
}
|
||||
static DataValue string(std::string v) {
|
||||
DataValue d;
|
||||
d.kind = Kind::String;
|
||||
d.s = std::move(v);
|
||||
return d;
|
||||
}
|
||||
static DataValue array(std::vector<DataValue> v) {
|
||||
DataValue d;
|
||||
d.kind = Kind::Array;
|
||||
d.arr = std::move(v);
|
||||
return d;
|
||||
}
|
||||
static DataValue object(std::vector<std::pair<std::string, DataValue>> v) {
|
||||
DataValue d;
|
||||
d.kind = Kind::Object;
|
||||
d.obj = std::move(v);
|
||||
return d;
|
||||
}
|
||||
|
||||
const DataValue *find(const std::string &key) const {
|
||||
for (const auto &kv : obj)
|
||||
if (kv.first == key) return &kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Visual-novel style dialogue stage.
|
||||
* Script: `dlg <- eve.Dialogue();`
|
||||
@@ -19,8 +88,8 @@ namespace eve::dialogue {
|
||||
class Dialogue : public Module {
|
||||
public:
|
||||
Module_REG(Dialogue);
|
||||
Dialogue() = default;
|
||||
~Dialogue() override = default;
|
||||
Dialogue();
|
||||
~Dialogue() override;
|
||||
|
||||
// ---- characters ----
|
||||
bool registerCharacter(const std::string &id, const std::string &displayName);
|
||||
@@ -80,6 +149,80 @@ public:
|
||||
bool selectChoice(int index);
|
||||
std::string getSelectedChoiceId() const { return selectedChoiceId_; }
|
||||
|
||||
// ---- variables (global / scene; scene auto-cleared on scene switch) ----
|
||||
struct VarValue {
|
||||
enum class Type { Int, Float, Bool, String };
|
||||
|
||||
Type type = Type::String;
|
||||
long long i = 0;
|
||||
double f = 0.0;
|
||||
bool b = false;
|
||||
std::string s;
|
||||
|
||||
std::string toString() const;
|
||||
std::string typeName() const;
|
||||
bool isNumeric() const { return type == Type::Int || type == Type::Float; }
|
||||
|
||||
static VarValue integer(long long v);
|
||||
static VarValue number(double v);
|
||||
static VarValue boolean(bool v);
|
||||
static VarValue string(std::string v);
|
||||
};
|
||||
|
||||
/** Squirrel-facing setter: value may be int/float/bool/string. */
|
||||
bool setVar(const std::string &name, ssq::Object value, const std::string &scope);
|
||||
/** C++ core setter (also used by unit tests). */
|
||||
bool setVarValue(const std::string &name, const VarValue &value, const std::string &scope);
|
||||
VarValue getVarValue(const std::string &name, const std::string &scope) const;
|
||||
std::string getVarType(const std::string &name, const std::string &scope) const;
|
||||
int getVarInt(const std::string &name, int defaultValue, const std::string &scope) const;
|
||||
float getVarFloat(const std::string &name, float defaultValue, const std::string &scope) const;
|
||||
bool getVarBool(const std::string &name, bool defaultValue, const std::string &scope) const;
|
||||
std::string getVarString(const std::string &name, const std::string &defaultValue,
|
||||
const std::string &scope) const;
|
||||
bool hasVar(const std::string &name, const std::string &scope) const;
|
||||
bool clearVar(const std::string &name, const std::string &scope);
|
||||
void clearVars(const std::string &scope);
|
||||
|
||||
// ---- conditions ----
|
||||
/** Register a Squirrel predicate: fn(ctx) -> bool, ctx = {vars, params, lineId}. */
|
||||
bool registerCondition(const std::string &name, ssq::Object fn);
|
||||
bool unregisterCondition(const std::string &name);
|
||||
bool evalCondition(ssq::Object table);
|
||||
bool evalConditionData(const DataValue &cond);
|
||||
|
||||
// ---- content pools ----
|
||||
int loadPoolsFromTable(ssq::Object table);
|
||||
int loadPoolsFromData(const DataValue &root);
|
||||
/** 解析 .dnut 源码并注册台词池;path 用于错误信息(可传 "")。 */
|
||||
int loadPoolsFromDnut(const std::string &source, const std::string &path);
|
||||
/** 读取 .dnut 文件并解析注册(经 eve.Filesystem)。 */
|
||||
int loadPoolsFromDnutFile(const std::string &path);
|
||||
void clearPools();
|
||||
int getPoolCount() const;
|
||||
std::string getPoolId(int index) const;
|
||||
bool hasPool(const std::string &id) const;
|
||||
std::string getLastPoolsError() const { return lastPoolsError_; }
|
||||
|
||||
// ---- rng (weighted selection determinism) ----
|
||||
/** Reseed the weighted picker; also resets per-pool no-repeat history. */
|
||||
void setRandomSeed(int seed);
|
||||
int getRandomSeed() const { return int(rngState_); }
|
||||
|
||||
// ---- generated-line selection / play ----
|
||||
std::string pickLine(const std::string &poolId, ssq::Object params);
|
||||
std::string pickLineWithParams(const std::string &poolId,
|
||||
const std::unordered_map<std::string, VarValue> ¶ms);
|
||||
bool playLine(const std::string &lineId, ssq::Object params);
|
||||
bool playLineWithParams(const std::string &lineId,
|
||||
const std::unordered_map<std::string, VarValue> ¶ms);
|
||||
bool playPool(const std::string &poolId, ssq::Object params);
|
||||
bool playPoolWithParams(const std::string &poolId,
|
||||
const std::unordered_map<std::string, VarValue> ¶ms);
|
||||
std::string getCurrentLineId() const { return currentLineId_; }
|
||||
std::string getCurrentLineMeta(const std::string &field) const;
|
||||
std::vector<std::string> getCurrentLineTags() const;
|
||||
|
||||
void update(float dt);
|
||||
void reset();
|
||||
|
||||
@@ -123,6 +266,78 @@ private:
|
||||
|
||||
void updateLipSync(float dt);
|
||||
void applyLipSyncToSpeaker();
|
||||
|
||||
// ---- procedural content (variables / conditions / pools) ----
|
||||
struct Condition {
|
||||
enum class Kind { Always, Cmp, All, Any, Not, Script };
|
||||
|
||||
Kind kind = Kind::Always;
|
||||
std::string var;
|
||||
std::string op;
|
||||
VarValue value;
|
||||
std::vector<Condition> children;
|
||||
std::string script;
|
||||
};
|
||||
|
||||
struct Line {
|
||||
std::string id;
|
||||
std::string speaker;
|
||||
std::string text;
|
||||
std::string i18nKey;
|
||||
double weight = 1.0;
|
||||
Condition when;
|
||||
std::unordered_map<std::string, std::string> meta;
|
||||
std::vector<std::string> tags;
|
||||
};
|
||||
|
||||
struct Pool {
|
||||
std::string id;
|
||||
int noRepeat = 3;
|
||||
std::vector<Line> lines;
|
||||
std::vector<size_t> recent;
|
||||
};
|
||||
|
||||
std::unordered_map<std::string, VarValue> *varsForScope(const std::string &scope);
|
||||
const std::unordered_map<std::string, VarValue> *varsForScope(const std::string &scope) const;
|
||||
std::unordered_map<std::string, VarValue> mergedVars(
|
||||
const std::unordered_map<std::string, VarValue> ¶ms) const;
|
||||
std::unordered_map<std::string, std::string> stringParams(
|
||||
const std::unordered_map<std::string, VarValue> &vars) const;
|
||||
bool parseCondition(const DataValue &v, Condition &out, std::string &error);
|
||||
bool parseLineData(const std::string &poolId, const DataValue &v, int index, Line &line,
|
||||
std::string &error);
|
||||
bool evalConditionInternal(const Condition &c,
|
||||
const std::unordered_map<std::string, VarValue> &merged,
|
||||
const std::unordered_map<std::string, VarValue> ¶ms,
|
||||
const std::string &lineId) const;
|
||||
bool evalScriptPredicate(const std::string &name,
|
||||
const std::unordered_map<std::string, VarValue> &merged,
|
||||
const std::unordered_map<std::string, VarValue> ¶ms,
|
||||
const std::string &lineId) const;
|
||||
bool compareEq(const VarValue &a, const VarValue &b) const;
|
||||
bool compareOrder(const std::string &op, const VarValue &a, const VarValue &b) const;
|
||||
std::string interpolate(const std::string &tpl,
|
||||
const std::unordered_map<std::string, VarValue> &vars) const;
|
||||
Pool *findPool(const std::string &id);
|
||||
const Pool *findPool(const std::string &id) const;
|
||||
Line *findLine(const std::string &id);
|
||||
const Line *findLine(const std::string &id) const;
|
||||
void applyLineMeta(const Line &line);
|
||||
void pollSceneChange();
|
||||
uint32_t nextRandom();
|
||||
double nextUnit();
|
||||
|
||||
std::unordered_map<std::string, VarValue> globalVars_;
|
||||
std::unordered_map<std::string, VarValue> sceneVars_;
|
||||
std::unordered_map<std::string, HSQOBJECT> predicates_;
|
||||
std::vector<Pool> pools_;
|
||||
std::string lastPoolsError_;
|
||||
uint32_t rngState_ = 0x9E3779B9u;
|
||||
std::string lastSceneName_;
|
||||
std::string currentLineId_;
|
||||
std::unordered_map<std::string, std::string> currentLineMeta_;
|
||||
std::vector<std::string> currentLineTags_;
|
||||
HSQUIRRELVM vm_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace eve::dialogue
|
||||
@@ -0,0 +1,475 @@
|
||||
#include "dialogue/DnutParser.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::dialogue {
|
||||
namespace {
|
||||
|
||||
enum class Tok { Ident, Str, Num, Punct, Eof };
|
||||
|
||||
struct Token {
|
||||
Tok kind = Tok::Eof;
|
||||
std::string value;
|
||||
int line = 1;
|
||||
};
|
||||
|
||||
std::string floatToString(double v) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "%g", v);
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string scalarToString(const DataValue &v) {
|
||||
switch (v.kind) {
|
||||
case DataValue::Kind::String:
|
||||
return v.s;
|
||||
case DataValue::Kind::Int:
|
||||
return std::to_string(v.i);
|
||||
case DataValue::Kind::Float:
|
||||
return floatToString(v.f);
|
||||
case DataValue::Kind::Bool:
|
||||
return v.b ? "true" : "false";
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
DataValue numValue(const std::string &raw) {
|
||||
if (raw.find_first_of(".eE") != std::string::npos)
|
||||
return DataValue::number(std::strtod(raw.c_str(), nullptr));
|
||||
return DataValue::integer(std::strtoll(raw.c_str(), nullptr, 10));
|
||||
}
|
||||
|
||||
DataValue negNumValue(const std::string &raw) {
|
||||
DataValue v = numValue(raw);
|
||||
if (v.kind == DataValue::Kind::Int) v.i = -v.i;
|
||||
else v.f = -v.f;
|
||||
return v;
|
||||
}
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
Parser(std::string source, std::string path) : source_(std::move(source)), path_(std::move(path)) {}
|
||||
|
||||
bool parse(DataValue &out, std::string &error) {
|
||||
try {
|
||||
tokenize();
|
||||
out = parsePools();
|
||||
return true;
|
||||
} catch (const ParseError &e) {
|
||||
error = e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
struct ParseError : std::runtime_error {
|
||||
explicit ParseError(const std::string &msg) : std::runtime_error(msg) {}
|
||||
};
|
||||
|
||||
std::string source_;
|
||||
std::string path_;
|
||||
std::vector<Token> toks_;
|
||||
size_t pos_ = 0;
|
||||
|
||||
const Token &cur() const { return toks_[pos_]; }
|
||||
Token adv() {
|
||||
Token t = toks_[pos_];
|
||||
if (pos_ + 1 < toks_.size()) ++pos_;
|
||||
return t;
|
||||
}
|
||||
bool isPunct(const std::string &p) const {
|
||||
return cur().kind == Tok::Punct && cur().value == p;
|
||||
}
|
||||
bool isIdent() const { return cur().kind == Tok::Ident; }
|
||||
|
||||
[[noreturn]] void fail(const std::string &msg) const {
|
||||
const std::string at = cur().kind == Tok::Eof ? "<结束>" : cur().value;
|
||||
throw ParseError(path_ + ":" + std::to_string(cur().line) + ": " + msg +
|
||||
"(实际是 '" + at + "')");
|
||||
}
|
||||
void expectPunct(const std::string &p) {
|
||||
if (!isPunct(p)) fail("期望 '" + p + "'");
|
||||
adv();
|
||||
}
|
||||
std::string expectIdent(const std::string &what) {
|
||||
if (!isIdent()) fail("期望标识符 " + what);
|
||||
return adv().value;
|
||||
}
|
||||
|
||||
void tokenize() {
|
||||
toks_.clear();
|
||||
const std::string &src = source_;
|
||||
const size_t n = src.size();
|
||||
size_t i = 0;
|
||||
int line = 1;
|
||||
const auto add = [&](Tok k, std::string v) {
|
||||
toks_.push_back(Token{k, std::move(v), line});
|
||||
};
|
||||
|
||||
while (i < n) {
|
||||
const char c = src[i];
|
||||
if (c == '\n') {
|
||||
++line;
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (c == ' ' || c == '\t' || c == '\r') {
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
if (c == '/' && i + 1 < n && src[i + 1] == '/') {
|
||||
while (i < n && src[i] != '\n') ++i;
|
||||
continue;
|
||||
}
|
||||
if (c == '/' && i + 1 < n && src[i + 1] == '*') {
|
||||
const int startLine = line;
|
||||
i += 2;
|
||||
while (i < n && !(src[i] == '*' && i + 1 < n && src[i + 1] == '/')) {
|
||||
if (src[i] == '\n') ++line;
|
||||
++i;
|
||||
}
|
||||
if (i >= n)
|
||||
throw ParseError(path_ + ":" + std::to_string(startLine) + ": 未闭合的块注释");
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c == '"' || c == '\'') {
|
||||
const char quote = c;
|
||||
const int startLine = line;
|
||||
++i;
|
||||
std::string s;
|
||||
while (i < n && src[i] != quote) {
|
||||
if (src[i] == '\\' && i + 1 < n) {
|
||||
++i;
|
||||
const char e = src[i];
|
||||
switch (e) {
|
||||
case 'n': s += '\n'; break;
|
||||
case 't': s += '\t'; break;
|
||||
case 'r': s += '\r'; break;
|
||||
case '"': s += '"'; break;
|
||||
case '\'': s += '\''; break;
|
||||
case '\\': s += '\\'; break;
|
||||
case '{': s += '{'; break;
|
||||
case '}': s += '}'; break;
|
||||
default: s += e; break;
|
||||
}
|
||||
++i;
|
||||
} else {
|
||||
s += src[i++];
|
||||
}
|
||||
}
|
||||
if (i >= n)
|
||||
throw ParseError(path_ + ":" + std::to_string(startLine) + ": 未闭合的字符串");
|
||||
++i;
|
||||
add(Tok::Str, std::move(s));
|
||||
continue;
|
||||
}
|
||||
if (std::isdigit(static_cast<unsigned char>(c)) ||
|
||||
(c == '.' && i + 1 < n && std::isdigit(static_cast<unsigned char>(src[i + 1])))) {
|
||||
const size_t start = i;
|
||||
while (i < n) {
|
||||
const char d = src[i];
|
||||
if (d >= '0' && d <= '9') {
|
||||
++i;
|
||||
} else if (d == '.') {
|
||||
++i;
|
||||
} else if ((d == 'e' || d == 'E') && i + 1 < n &&
|
||||
((src[i + 1] >= '0' && src[i + 1] <= '9') ||
|
||||
src[i + 1] == '+' || src[i + 1] == '-')) {
|
||||
i += 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
add(Tok::Num, src.substr(start, i - start));
|
||||
continue;
|
||||
}
|
||||
if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') {
|
||||
const size_t start = i;
|
||||
while (i < n) {
|
||||
const char d = src[i];
|
||||
if (std::isalnum(static_cast<unsigned char>(d)) || d == '_' || d == '.') ++i;
|
||||
else break;
|
||||
}
|
||||
add(Tok::Ident, src.substr(start, i - start));
|
||||
continue;
|
||||
}
|
||||
const std::string two = (i + 1 < n) ? src.substr(i, 2) : "";
|
||||
if (two == "==" || two == "!=" || two == ">=" || two == "<=" ||
|
||||
two == "&&" || two == "||") {
|
||||
add(Tok::Punct, two);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c == '{' || c == '}' || c == '(' || c == ')' || c == '[' || c == ']' ||
|
||||
c == ':' || c == ',' || c == '=' || c == '>' || c == '<' || c == '!' ||
|
||||
c == '-') {
|
||||
add(Tok::Punct, std::string(1, c));
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
throw ParseError(path_ + ":" + std::to_string(line) + ": 无法识别的字符 '" +
|
||||
std::string(1, c) + "'");
|
||||
}
|
||||
toks_.push_back(Token{Tok::Eof, "", line});
|
||||
}
|
||||
|
||||
DataValue parseLiteralValue() {
|
||||
const Token &t = cur();
|
||||
if (t.kind == Tok::Str) {
|
||||
adv();
|
||||
return DataValue::string(t.value);
|
||||
}
|
||||
if (t.kind == Tok::Num) {
|
||||
adv();
|
||||
return numValue(t.value);
|
||||
}
|
||||
if (t.kind == Tok::Ident) {
|
||||
if (t.value == "true") {
|
||||
adv();
|
||||
return DataValue::boolean(true);
|
||||
}
|
||||
if (t.value == "false") {
|
||||
adv();
|
||||
return DataValue::boolean(false);
|
||||
}
|
||||
fail("条件字面量只支持字符串/数字/true/false");
|
||||
}
|
||||
if (isPunct("-")) {
|
||||
adv();
|
||||
const Token &tt = cur();
|
||||
if (tt.kind == Tok::Num) {
|
||||
adv();
|
||||
return negNumValue(tt.value);
|
||||
}
|
||||
fail("'-' 后应跟数字");
|
||||
}
|
||||
fail("期望字面量");
|
||||
}
|
||||
|
||||
DataValue parseComparison() {
|
||||
if (!isIdent()) fail("条件左侧应为变量名");
|
||||
const std::string varName = adv().value;
|
||||
const Token &op = cur();
|
||||
if (op.kind != Tok::Punct ||
|
||||
(op.value != "==" && op.value != "!=" && op.value != ">" && op.value != "<" &&
|
||||
op.value != ">=" && op.value != "<="))
|
||||
fail("期望比较运算符(== != > < >= <=)");
|
||||
adv();
|
||||
DataValue value = parseLiteralValue();
|
||||
std::string mapped;
|
||||
if (op.value == "==") mapped = "eq";
|
||||
else if (op.value == "!=") mapped = "ne";
|
||||
else if (op.value == ">") mapped = "gt";
|
||||
else if (op.value == "<") mapped = "lt";
|
||||
else if (op.value == ">=") mapped = "ge";
|
||||
else mapped = "le";
|
||||
return DataValue::object({
|
||||
{"var", DataValue::string(varName)},
|
||||
{"op", DataValue::string(mapped)},
|
||||
{"value", std::move(value)},
|
||||
});
|
||||
}
|
||||
|
||||
DataValue parseNot() {
|
||||
if (isPunct("!")) {
|
||||
adv();
|
||||
return DataValue::object({{"not", parseNot()}});
|
||||
}
|
||||
if (isPunct("(")) {
|
||||
adv();
|
||||
DataValue e = parseOr();
|
||||
expectPunct(")");
|
||||
return e;
|
||||
}
|
||||
return parseComparison();
|
||||
}
|
||||
|
||||
DataValue parseAnd() {
|
||||
DataValue left = parseNot();
|
||||
while (isPunct("&&")) {
|
||||
adv();
|
||||
DataValue right = parseNot();
|
||||
bool appended = false;
|
||||
if (left.kind == DataValue::Kind::Object) {
|
||||
for (auto &kv : left.obj) {
|
||||
if (kv.first == "all" && kv.second.kind == DataValue::Kind::Array) {
|
||||
kv.second.arr.push_back(std::move(right));
|
||||
appended = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!appended) {
|
||||
left = DataValue::object(
|
||||
{{"all", DataValue::array({std::move(left), std::move(right)})}});
|
||||
}
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
DataValue parseOr() {
|
||||
DataValue left = parseAnd();
|
||||
while (isPunct("||")) {
|
||||
adv();
|
||||
DataValue right = parseAnd();
|
||||
bool appended = false;
|
||||
if (left.kind == DataValue::Kind::Object) {
|
||||
for (auto &kv : left.obj) {
|
||||
if (kv.first == "any" && kv.second.kind == DataValue::Kind::Array) {
|
||||
kv.second.arr.push_back(std::move(right));
|
||||
appended = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!appended) {
|
||||
left = DataValue::object(
|
||||
{{"any", DataValue::array({std::move(left), std::move(right)})}});
|
||||
}
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
void parseAttrs(const int lineNum, std::vector<std::pair<std::string, DataValue>> &out) {
|
||||
while (cur().line == lineNum && !isPunct("}") && cur().kind != Tok::Eof) {
|
||||
if (!isIdent()) fail("期望属性名");
|
||||
const std::string name = adv().value;
|
||||
if (name == "meta" && isPunct("(")) {
|
||||
adv();
|
||||
std::vector<std::pair<std::string, DataValue>> metaFields;
|
||||
while (!isPunct(")")) {
|
||||
if (!isIdent()) fail("meta 键应为标识符");
|
||||
const std::string k = adv().value;
|
||||
expectPunct("=");
|
||||
DataValue v = parseLiteralValue();
|
||||
if (v.kind != DataValue::Kind::String && v.kind != DataValue::Kind::Int &&
|
||||
v.kind != DataValue::Kind::Float && v.kind != DataValue::Kind::Bool)
|
||||
fail("meta 值只支持标量");
|
||||
metaFields.emplace_back(k, DataValue::string(scalarToString(v)));
|
||||
if (isPunct(",")) adv();
|
||||
else if (!isPunct(")")) fail("meta 内期望 ',' 或 ')'");
|
||||
}
|
||||
adv(); // )
|
||||
out.emplace_back("meta", DataValue::object(std::move(metaFields)));
|
||||
continue;
|
||||
}
|
||||
if (name == "tags") {
|
||||
expectPunct("=");
|
||||
if (!isPunct("[")) fail("tags 后应为 [");
|
||||
adv();
|
||||
std::vector<DataValue> arr;
|
||||
while (!isPunct("]")) {
|
||||
if (cur().kind != Tok::Str) fail("tags 元素应为字符串");
|
||||
arr.emplace_back(DataValue::string(adv().value));
|
||||
if (isPunct(",")) adv();
|
||||
else if (!isPunct("]")) fail("tags 内期望 ',' 或 ']'");
|
||||
}
|
||||
adv(); // ]
|
||||
out.emplace_back("tags", DataValue::array(std::move(arr)));
|
||||
continue;
|
||||
}
|
||||
expectPunct("=");
|
||||
DataValue v = parseLiteralValue();
|
||||
if (name == "weight" || name == "i18n" || name == "id") {
|
||||
out.emplace_back(name, std::move(v));
|
||||
} else {
|
||||
fail("未知属性 '" + name + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DataValue parseLine(const std::string &poolId, int idx, const DataValue *inheritWhen) {
|
||||
const int lineNum = cur().line;
|
||||
std::string speaker;
|
||||
if (isPunct("-")) {
|
||||
adv();
|
||||
} else if (isIdent()) {
|
||||
if (cur().value == "when") fail("when 分组不允许嵌套");
|
||||
speaker = adv().value;
|
||||
expectPunct(":");
|
||||
} else {
|
||||
fail("期望说话人或 '-'");
|
||||
}
|
||||
if (cur().kind != Tok::Str) fail("期望台词字符串");
|
||||
const std::string text = adv().value;
|
||||
|
||||
std::vector<std::pair<std::string, DataValue>> fields;
|
||||
fields.emplace_back("speaker", DataValue::string(speaker));
|
||||
fields.emplace_back("text", DataValue::string(text));
|
||||
if (inheritWhen) fields.emplace_back("when", *inheritWhen);
|
||||
std::vector<std::pair<std::string, DataValue>> attrs;
|
||||
parseAttrs(lineNum, attrs);
|
||||
bool hasId = false;
|
||||
for (auto &kv : attrs) {
|
||||
if (kv.first == "id") hasId = true;
|
||||
fields.push_back(std::move(kv));
|
||||
}
|
||||
if (!hasId) fields.emplace_back("id", DataValue::string(poolId + "." + std::to_string(idx)));
|
||||
return DataValue::object(std::move(fields));
|
||||
}
|
||||
|
||||
void parsePool(std::vector<std::pair<std::string, DataValue>> &pools) {
|
||||
expectIdent("pool");
|
||||
const std::string poolId = expectIdent("pool 名称");
|
||||
long long noRepeat = -1;
|
||||
while (isIdent() && cur().value == "noRepeat") {
|
||||
adv();
|
||||
expectPunct("=");
|
||||
const Token &t = cur();
|
||||
if (t.kind != Tok::Num) fail("noRepeat 应为数字");
|
||||
noRepeat = std::strtoll(adv().value.c_str(), nullptr, 10);
|
||||
}
|
||||
expectPunct("{");
|
||||
|
||||
std::vector<DataValue> lines;
|
||||
int idx = 1;
|
||||
while (!isPunct("}")) {
|
||||
if (cur().kind == Tok::Eof) fail("未闭合的 pool 块");
|
||||
if (isIdent() && cur().value == "pool") fail("pool 块不允许嵌套");
|
||||
if (isIdent() && cur().value == "when") {
|
||||
adv();
|
||||
DataValue cond = parseOr();
|
||||
expectPunct("{");
|
||||
while (!isPunct("}")) {
|
||||
if (cur().kind == Tok::Eof) fail("未闭合的 when 块");
|
||||
lines.push_back(parseLine(poolId, idx, &cond));
|
||||
++idx;
|
||||
}
|
||||
adv();
|
||||
continue;
|
||||
}
|
||||
lines.push_back(parseLine(poolId, idx, nullptr));
|
||||
++idx;
|
||||
}
|
||||
adv(); // }
|
||||
|
||||
std::vector<std::pair<std::string, DataValue>> poolFields;
|
||||
poolFields.emplace_back("lines", DataValue::array(std::move(lines)));
|
||||
if (noRepeat >= 0) poolFields.emplace_back("noRepeat", DataValue::integer(noRepeat));
|
||||
pools.emplace_back(poolId, DataValue::object(std::move(poolFields)));
|
||||
}
|
||||
|
||||
DataValue parsePools() {
|
||||
std::vector<std::pair<std::string, DataValue>> pools;
|
||||
while (cur().kind != Tok::Eof) parsePool(pools);
|
||||
return DataValue::object({{"pools", DataValue::object(std::move(pools))}});
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool parseDnut(const std::string &source, const std::string &path, DataValue &outRoot,
|
||||
std::string &error) {
|
||||
Parser parser(source, path);
|
||||
return parser.parse(outRoot, error);
|
||||
}
|
||||
|
||||
} // namespace eve::dialogue
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "dialogue/Dialogue.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace eve::dialogue {
|
||||
|
||||
/**
|
||||
* .dnut → pools 表的 C++ 解析器。
|
||||
*
|
||||
* 把可读的对话内容语法(pool / when 条件分组 / speaker: "文本" / 属性)编译为
|
||||
* Dialogue::loadPoolsFromData 所需的 DataValue 根表,不引入第二套运行时。
|
||||
* 失败时 error 形如 "path:行: 信息"。
|
||||
*/
|
||||
bool parseDnut(const std::string &source, const std::string &path, DataValue &outRoot,
|
||||
std::string &error);
|
||||
|
||||
} // namespace eve::dialogue
|
||||
@@ -848,6 +848,7 @@ scene::SceneHost *SceneLoader::mount(DecodedScene &d) {
|
||||
if (d.options.importCameras) importCameras(d.md->getScene(), d.cameras);
|
||||
importAnimations(d.md->getScene(), d.options, &d.skeleton, d.clips);
|
||||
}
|
||||
fillSceneBounds(host, d.slots);
|
||||
scene::TransformSystem::updateHost(host);
|
||||
scenes_[d.path] = Loaded{d.path, host, gfx, d.options, std::move(d.lights),
|
||||
std::move(d.cameras), d.skeleton, std::move(d.clips)};
|
||||
|
||||
@@ -10,4 +10,4 @@ extern const char* scene_director_content;
|
||||
extern const char* demo_content;
|
||||
extern const char* vertex_content;
|
||||
extern const char* fragment_content;
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,4 @@ const char* vertex_content = R"(@vertex_content@)";
|
||||
|
||||
const char* fragment_content = R"(@fragment_content@)";
|
||||
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ file(GLOB all_test_cpp
|
||||
i18n_script.cpp
|
||||
avatar.cpp
|
||||
dialogue.cpp
|
||||
dialogue_proc.cpp
|
||||
dialogue_proc_script.cpp
|
||||
procgen.cpp
|
||||
housegen.cpp
|
||||
housegen_render.cpp
|
||||
@@ -141,6 +143,7 @@ file(GLOB all_test_scripts
|
||||
filesystem.nut
|
||||
graphic.nut
|
||||
i18n.nut
|
||||
dialogue_proc_script.nut
|
||||
model.nut
|
||||
model2.nut
|
||||
mouse.nut
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
#include "zeroerr/assert.h"
|
||||
#include "zeroerr/unittest.h"
|
||||
|
||||
#include "avatar/Avatar.h"
|
||||
#include "dialogue/Dialogue.h"
|
||||
#include "i18n/I18n.h"
|
||||
#include "scene/NodeDesc.h"
|
||||
#include "scene/Scene.h"
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace eve::dialogue;
|
||||
using eve::avatar::Avatar;
|
||||
using eve::avatar::AvatarInstance;
|
||||
using eve::i18n::I18n;
|
||||
|
||||
namespace {
|
||||
|
||||
using Vars = std::unordered_map<std::string, Dialogue::VarValue>;
|
||||
|
||||
DataValue O(std::vector<std::pair<std::string, DataValue>> v) {
|
||||
return DataValue::object(std::move(v));
|
||||
}
|
||||
|
||||
DataValue A(std::vector<DataValue> v) { return DataValue::array(std::move(v)); }
|
||||
|
||||
DataValue S(const std::string &v) { return DataValue::string(v); }
|
||||
|
||||
DataValue I(long long v) { return DataValue::integer(v); }
|
||||
|
||||
DataValue F(double v) { return DataValue::number(v); }
|
||||
|
||||
DataValue B(bool v) { return DataValue::boolean(v); }
|
||||
|
||||
DataValue poolsRoot(DataValue pools) {
|
||||
return O({{"pools", std::move(pools)}});
|
||||
}
|
||||
|
||||
void resetDialogue(Dialogue *dlg) {
|
||||
dlg->reset();
|
||||
dlg->clearPools();
|
||||
dlg->clearVars("all");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("dialogueProc.varsScopes") {
|
||||
Dialogue *dlg = Dialogue::create();
|
||||
resetDialogue(dlg);
|
||||
|
||||
CHECK(dlg->setVarValue("hp", Dialogue::VarValue::integer(100), "global"));
|
||||
CHECK(dlg->setVarValue("mood", Dialogue::VarValue::string("happy"), "scene"));
|
||||
CHECK(dlg->setVarValue("ratio", Dialogue::VarValue::number(1.5), "global"));
|
||||
CHECK(dlg->setVarValue("flag", Dialogue::VarValue::boolean(true), "scene"));
|
||||
|
||||
CHECK(dlg->hasVar("hp", "global"));
|
||||
CHECK(!dlg->hasVar("hp", "scene"));
|
||||
CHECK_EQ(dlg->getVarType("mood", "scene"), std::string("string"));
|
||||
CHECK_EQ(dlg->getVarType("hp", "global"), std::string("int"));
|
||||
CHECK_EQ(dlg->getVarType("ratio", "global"), std::string("float"));
|
||||
CHECK_EQ(dlg->getVarType("flag", "scene"), std::string("bool"));
|
||||
CHECK_EQ(dlg->getVarType("missing", "global"), std::string(""));
|
||||
|
||||
CHECK_EQ(dlg->getVarInt("hp", -1, "global"), 100);
|
||||
CHECK_EQ(dlg->getVarInt("hp", -1, "scene"), -1);
|
||||
CHECK_EQ(dlg->getVarFloat("ratio", 0.f, "global"), 1.5f);
|
||||
CHECK(dlg->getVarBool("flag", false, "scene"));
|
||||
CHECK_EQ(dlg->getVarString("mood", "", "scene"), std::string("happy"));
|
||||
CHECK_EQ(dlg->getVarString("mood", "", "global"), std::string(""));
|
||||
|
||||
dlg->clearVars("scene");
|
||||
CHECK(!dlg->hasVar("mood", "scene"));
|
||||
CHECK(dlg->hasVar("hp", "global"));
|
||||
dlg->clearVars("global");
|
||||
CHECK(!dlg->hasVar("hp", "global"));
|
||||
|
||||
resetDialogue(dlg);
|
||||
}
|
||||
|
||||
TEST_CASE("dialogueProc.conditions") {
|
||||
Dialogue *dlg = Dialogue::create();
|
||||
resetDialogue(dlg);
|
||||
|
||||
dlg->setVarValue("mood", Dialogue::VarValue::string("happy"), "global");
|
||||
dlg->setVarValue("hour", Dialogue::VarValue::integer(20), "global");
|
||||
dlg->setVarValue("flag", Dialogue::VarValue::boolean(true), "global");
|
||||
|
||||
CHECK(dlg->evalConditionData(O({
|
||||
{"var", S("mood")}, {"op", S("eq")}, {"value", S("happy")},
|
||||
})));
|
||||
CHECK(!dlg->evalConditionData(O({
|
||||
{"var", S("mood")}, {"op", S("eq")}, {"value", S("sad")},
|
||||
})));
|
||||
CHECK(dlg->evalConditionData(O({
|
||||
{"var", S("hour")}, {"op", S("ge")}, {"value", I(18)},
|
||||
})));
|
||||
CHECK(!dlg->evalConditionData(O({
|
||||
{"var", S("hour")}, {"op", S("lt")}, {"value", I(18)},
|
||||
})));
|
||||
CHECK(dlg->evalConditionData(O({{"var", S("flag")}, {"op", S("eq")}, {"value", B(true)}})));
|
||||
CHECK(dlg->evalConditionData(O({{"var", S("flag")}, {"op", S("has")}})));
|
||||
CHECK(dlg->evalConditionData(O({{"var", S("nope")}, {"op", S("missing")}})));
|
||||
CHECK(!dlg->evalConditionData(O({{"var", S("nope")}, {"op", S("eq")}, {"value", S("x")}})));
|
||||
|
||||
CHECK(dlg->evalConditionData(O({
|
||||
{"all", A({
|
||||
O({{"var", S("mood")}, {"op", S("eq")}, {"value", S("happy")}}),
|
||||
O({{"var", S("hour")}, {"op", S("ge")}, {"value", I(18)}}),
|
||||
})},
|
||||
})));
|
||||
CHECK(dlg->evalConditionData(O({
|
||||
{"any", A({
|
||||
O({{"var", S("mood")}, {"op", S("eq")}, {"value", S("angry")}}),
|
||||
O({{"var", S("hour")}, {"op", S("ge")}, {"value", I(18)}}),
|
||||
})},
|
||||
})));
|
||||
CHECK(dlg->evalConditionData(O({
|
||||
{"not", O({{"var", S("flag")}, {"op", S("eq")}, {"value", B(false)}})},
|
||||
})));
|
||||
|
||||
// Script predicates need a VM; without one they evaluate false.
|
||||
CHECK(!dlg->evalConditionData(O({{"script", S("missingPred")}})));
|
||||
|
||||
resetDialogue(dlg);
|
||||
}
|
||||
|
||||
TEST_CASE("dialogueProc.poolLoading") {
|
||||
Dialogue *dlg = Dialogue::create();
|
||||
resetDialogue(dlg);
|
||||
|
||||
const DataValue root = poolsRoot(O({
|
||||
{"alice.greet", O({
|
||||
{"noRepeat", I(4)},
|
||||
{"lines", A({
|
||||
O({{"id", S("a1")}, {"speaker", S("alice")}, {"text", S("你好")}}),
|
||||
O({{"speaker", S("alice")}, {"i18n", S("line.hi")}, {"weight", I(2)},
|
||||
{"when", O({{"var", S("mood")}, {"op", S("eq")}, {"value", S("happy")}})},
|
||||
{"meta", O({{"expression", S("happy")}, {"voice", S("vo/a1.ogg")}})},
|
||||
{"tags", A({S("greet"), S("evening")})}}),
|
||||
})},
|
||||
})},
|
||||
}));
|
||||
|
||||
CHECK_EQ(dlg->loadPoolsFromData(root), 1);
|
||||
CHECK(dlg->hasPool("alice.greet"));
|
||||
CHECK_EQ(dlg->getPoolCount(), 1);
|
||||
CHECK_EQ(dlg->getPoolId(0), std::string("alice.greet"));
|
||||
// Missing id -> auto-generated "poolId.序号".
|
||||
CHECK(dlg->playLineWithParams("alice.greet.2", {}));
|
||||
CHECK(dlg->getLastPoolsError().empty());
|
||||
|
||||
// Same pool id replaces the previous definition (no duplication).
|
||||
CHECK_EQ(dlg->loadPoolsFromData(root), 1);
|
||||
CHECK_EQ(dlg->getPoolCount(), 1);
|
||||
|
||||
// Invalid root / missing pools object.
|
||||
CHECK_EQ(dlg->loadPoolsFromData(S("oops")), 0);
|
||||
CHECK(!dlg->getLastPoolsError().empty());
|
||||
resetDialogue(dlg);
|
||||
}
|
||||
|
||||
TEST_CASE("dialogueProc.pickLine") {
|
||||
Dialogue *dlg = Dialogue::create();
|
||||
resetDialogue(dlg);
|
||||
|
||||
const DataValue root = poolsRoot(O({
|
||||
{"pool.abc", O({
|
||||
{"lines", A({
|
||||
O({{"id", S("p1")}, {"speaker", S("a")}, {"text", S("one")}, {"weight", I(1)}}),
|
||||
O({{"id", S("p2")}, {"speaker", S("a")}, {"text", S("two")}, {"weight", I(1)}}),
|
||||
})},
|
||||
})},
|
||||
{"pool.filt", O({
|
||||
{"lines", A({
|
||||
O({{"id", S("f1")}, {"speaker", S("a")}, {"text", S("happy line")},
|
||||
{"when", O({{"var", S("mood")}, {"op", S("eq")}, {"value", S("happy")}})}}),
|
||||
O({{"id", S("f2")}, {"speaker", S("a")}, {"text", S("sad line")},
|
||||
{"when", O({{"var", S("mood")}, {"op", S("eq")}, {"value", S("sad")}})}}),
|
||||
})},
|
||||
})},
|
||||
{"pool.zero", O({
|
||||
{"lines", A({
|
||||
O({{"id", S("z0")}, {"speaker", S("a")}, {"text", S("zero")}, {"weight", I(0)}}),
|
||||
O({{"id", S("z1")}, {"speaker", S("a")}, {"text", S("one")}, {"weight", I(1)}}),
|
||||
})},
|
||||
})},
|
||||
}));
|
||||
CHECK_EQ(dlg->loadPoolsFromData(root), 3);
|
||||
|
||||
// Determinism: same seed reproduces the same pick sequence.
|
||||
dlg->setRandomSeed(42);
|
||||
const std::string first = dlg->pickLineWithParams("pool.abc", {});
|
||||
CHECK(!first.empty());
|
||||
dlg->setRandomSeed(42);
|
||||
const std::string second = dlg->pickLineWithParams("pool.abc", {});
|
||||
CHECK_EQ(second, first);
|
||||
|
||||
// Both lines appear across many picks (50/50 weights).
|
||||
bool sawP1 = false, sawP2 = false;
|
||||
dlg->setRandomSeed(7);
|
||||
for (int i = 0; i < 200; ++i) {
|
||||
const std::string id = dlg->pickLineWithParams("pool.abc", {});
|
||||
if (id == "p1") sawP1 = true;
|
||||
if (id == "p2") sawP2 = true;
|
||||
}
|
||||
CHECK(sawP1);
|
||||
CHECK(sawP2);
|
||||
|
||||
// Unknown pool -> "".
|
||||
CHECK_EQ(dlg->pickLineWithParams("nope", {}), std::string(""));
|
||||
|
||||
// when filtering by variables.
|
||||
dlg->setVarValue("mood", Dialogue::VarValue::string("happy"), "global");
|
||||
for (int i = 0; i < 10; ++i) CHECK_EQ(dlg->pickLineWithParams("pool.filt", {}), std::string("f1"));
|
||||
dlg->setVarValue("mood", Dialogue::VarValue::string("sad"), "global");
|
||||
for (int i = 0; i < 10; ++i) CHECK_EQ(dlg->pickLineWithParams("pool.filt", {}), std::string("f2"));
|
||||
dlg->clearVars("global");
|
||||
CHECK_EQ(dlg->pickLineWithParams("pool.filt", {}), std::string(""));
|
||||
|
||||
// weight <= 0 lines never win.
|
||||
dlg->setRandomSeed(3);
|
||||
for (int i = 0; i < 50; ++i)
|
||||
CHECK_EQ(dlg->pickLineWithParams("pool.zero", {}), std::string("z1"));
|
||||
|
||||
resetDialogue(dlg);
|
||||
}
|
||||
|
||||
TEST_CASE("dialogueProc.noRepeat") {
|
||||
Dialogue *dlg = Dialogue::create();
|
||||
resetDialogue(dlg);
|
||||
|
||||
// noRepeat=1 with two lines: consecutive picks must differ.
|
||||
const DataValue root = poolsRoot(O({
|
||||
{"pool.alt", O({
|
||||
{"noRepeat", I(1)},
|
||||
{"lines", A({
|
||||
O({{"id", S("n1")}, {"speaker", S("a")}, {"text", S("one")}}),
|
||||
O({{"id", S("n2")}, {"speaker", S("a")}, {"text", S("two")}}),
|
||||
})},
|
||||
})},
|
||||
// noRepeat=2 with two lines: third pick falls back to the full pool.
|
||||
{"pool.fb", O({
|
||||
{"noRepeat", I(2)},
|
||||
{"lines", A({
|
||||
O({{"id", S("m1")}, {"speaker", S("a")}, {"text", S("one")}}),
|
||||
O({{"id", S("m2")}, {"speaker", S("a")}, {"text", S("two")}}),
|
||||
})},
|
||||
})},
|
||||
}));
|
||||
CHECK_EQ(dlg->loadPoolsFromData(root), 2);
|
||||
|
||||
dlg->setRandomSeed(11);
|
||||
std::string prev = dlg->pickLineWithParams("pool.alt", {});
|
||||
for (int i = 1; i < 40; ++i) {
|
||||
const std::string id = dlg->pickLineWithParams("pool.alt", {});
|
||||
CHECK(id != prev);
|
||||
CHECK((id == "n1" || id == "n2"));
|
||||
prev = id;
|
||||
}
|
||||
|
||||
dlg->setRandomSeed(22);
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
const std::string id = dlg->pickLineWithParams("pool.fb", {});
|
||||
CHECK((id == "m1" || id == "m2"));
|
||||
}
|
||||
|
||||
resetDialogue(dlg);
|
||||
}
|
||||
|
||||
TEST_CASE("dialogueProc.playLineAndMeta") {
|
||||
Dialogue *dlg = Dialogue::create();
|
||||
resetDialogue(dlg);
|
||||
Avatar *avmod = Avatar::create();
|
||||
CHECK(dlg->registerCharacter("alice", "Alice"));
|
||||
AvatarInstance *alice = avmod->newImageAvatar();
|
||||
alice->addLayer("face", nullptr, 0);
|
||||
alice->defineExpression("happy", "face=1");
|
||||
CHECK(dlg->bindAvatar("alice", alice));
|
||||
|
||||
const DataValue root = poolsRoot(O({
|
||||
{"pool.play", O({
|
||||
{"lines", A({
|
||||
O({{"id", S("pl1")}, {"speaker", S("alice")}, {"text", S("你好,{name}!")},
|
||||
{"meta", O({{"expression", S("happy")}, {"motion", S("wave")},
|
||||
{"voice", S("vo/alice.ogg")}})},
|
||||
{"tags", A({S("greet"), S("night")})}}),
|
||||
O({{"id", S("pl2")}, {"speaker", S("")}, {"text", S("旁白")}}),
|
||||
O({{"id", S("pl3")}, {"speaker", S("alice")}, {"i18n", S("greet.key")}}),
|
||||
})},
|
||||
})},
|
||||
}));
|
||||
CHECK_EQ(dlg->loadPoolsFromData(root), 1);
|
||||
|
||||
// Literal text: params override global vars in {var} substitution.
|
||||
dlg->setVarValue("name", Dialogue::VarValue::string("Alice"), "global");
|
||||
Vars params;
|
||||
params["name"] = Dialogue::VarValue::string("Bob");
|
||||
CHECK(dlg->playLineWithParams("pl1", params));
|
||||
CHECK_EQ(dlg->getCurrentLineId(), std::string("pl1"));
|
||||
CHECK_EQ(dlg->getFullText(), std::string("你好,Bob!"));
|
||||
CHECK_EQ(dlg->getCurrentLineMeta("voice"), std::string("vo/alice.ogg"));
|
||||
CHECK_EQ(dlg->getCurrentLineMeta("expression"), std::string("happy"));
|
||||
CHECK_EQ(dlg->getCurrentLineMeta("missing"), std::string(""));
|
||||
CHECK_EQ(dlg->getCurrentLineTags().size(), size_t(2));
|
||||
CHECK_EQ(dlg->getCurrentLineTags()[0], std::string("greet"));
|
||||
// Expression was auto-applied to the bound avatar.
|
||||
CHECK_EQ(alice->getExpression(), std::string("happy"));
|
||||
CHECK_EQ(alice->getMotion(), std::string("wave"));
|
||||
|
||||
// Narration line (empty speaker).
|
||||
CHECK(dlg->playLineWithParams("pl2", {}));
|
||||
CHECK_EQ(dlg->getSpeakerId(), std::string(""));
|
||||
CHECK_EQ(dlg->getFullText(), std::string("旁白"));
|
||||
|
||||
// i18n key resolution through a real I18n instance.
|
||||
I18n *i18n = I18n::create();
|
||||
CHECK(i18n->loadFromJson("en", R"({"greet": { "key": "Hi, {name}!" } })"));
|
||||
CHECK(i18n->setLanguage("en"));
|
||||
dlg->setVarValue("name", Dialogue::VarValue::string("Carol"), "global");
|
||||
CHECK(dlg->playLineWithParams("pl3", {}));
|
||||
CHECK_EQ(dlg->getFullText(), std::string("Hi, Carol!"));
|
||||
|
||||
resetDialogue(dlg);
|
||||
dlg->bindAvatar("alice", nullptr); // 解除绑定,避免后续 reset 触碰已释放的 avatar
|
||||
alice->release();
|
||||
delete alice;
|
||||
}
|
||||
|
||||
TEST_CASE("dialogueProc.playPool") {
|
||||
Dialogue *dlg = Dialogue::create();
|
||||
resetDialogue(dlg);
|
||||
|
||||
const DataValue root = poolsRoot(O({
|
||||
{"pool.greet", O({
|
||||
{"lines", A({
|
||||
O({{"id", S("g1")}, {"speaker", S("npc")}, {"text", S("Hi {name}")}}),
|
||||
})},
|
||||
})},
|
||||
}));
|
||||
CHECK_EQ(dlg->loadPoolsFromData(root), 1);
|
||||
|
||||
Vars params;
|
||||
params["name"] = Dialogue::VarValue::string("Alex");
|
||||
CHECK(dlg->playPoolWithParams("pool.greet", params));
|
||||
CHECK_EQ(dlg->getCurrentLineId(), std::string("g1"));
|
||||
CHECK_EQ(dlg->getFullText(), std::string("Hi Alex"));
|
||||
CHECK(!dlg->playPoolWithParams("pool.gone", params));
|
||||
|
||||
resetDialogue(dlg);
|
||||
}
|
||||
|
||||
TEST_CASE("dialogueProc.dnutParser") {
|
||||
Dialogue *dlg = Dialogue::create();
|
||||
resetDialogue(dlg);
|
||||
|
||||
const std::string src =
|
||||
"pool alice.greet noRepeat=4 {\n"
|
||||
" when mood == \"happy\" && hour >= 18 {\n"
|
||||
" alice: \"晚上好,{name}!\" weight=2 meta(expression=\"happy\", motion=\"wave\") tags=[\"greet\",\"evening\"]\n"
|
||||
" alice: \"你来啦。\" weight=1\n"
|
||||
" }\n"
|
||||
" when mood == \"shy\" {\n"
|
||||
" alice: \"那个……你好。\" meta(expression=\"shy\")\n"
|
||||
" }\n"
|
||||
" - \"远处传来钟声。\"\n"
|
||||
"}\n";
|
||||
|
||||
CHECK_EQ(dlg->loadPoolsFromDnut(src, "pools.dnut"), 1);
|
||||
CHECK(dlg->hasPool("alice.greet"));
|
||||
CHECK(dlg->getLastPoolsError().empty());
|
||||
|
||||
dlg->setVarValue("mood", Dialogue::VarValue::string("happy"), "global");
|
||||
dlg->setVarValue("hour", Dialogue::VarValue::integer(20), "global");
|
||||
|
||||
// Line 1: compiled when = all(mood eq happy, hour ge 18); meta + tags.
|
||||
CHECK(dlg->playLineWithParams("alice.greet.1", {}));
|
||||
CHECK_EQ(dlg->getCurrentLineMeta("expression"), std::string("happy"));
|
||||
CHECK_EQ(dlg->getCurrentLineMeta("motion"), std::string("wave"));
|
||||
CHECK_EQ(dlg->getCurrentLineTags().size(), size_t(2));
|
||||
CHECK_EQ(dlg->getCurrentLineTags()[0], std::string("greet"));
|
||||
|
||||
// When filtering: hour < 18 -> only the narration line matches.
|
||||
dlg->setVarValue("hour", Dialogue::VarValue::integer(8), "global");
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
const std::string id = dlg->pickLineWithParams("alice.greet", {});
|
||||
CHECK_EQ(id, std::string("alice.greet.4"));
|
||||
}
|
||||
|
||||
// Narration line: empty speaker.
|
||||
CHECK(dlg->playLineWithParams("alice.greet.4", {}));
|
||||
CHECK_EQ(dlg->getSpeakerId(), std::string(""));
|
||||
CHECK_EQ(dlg->getFullText(), std::string("远处传来钟声。"));
|
||||
|
||||
// shy branch: line 3 命中;旁白行 4 无条件也合格,但 noRepeat 历史会先排除它。
|
||||
dlg->setVarValue("mood", Dialogue::VarValue::string("shy"), "global");
|
||||
dlg->setVarValue("hour", Dialogue::VarValue::integer(20), "global");
|
||||
const std::string firstShy = dlg->pickLineWithParams("alice.greet", {});
|
||||
CHECK_EQ(firstShy, std::string("alice.greet.3"));
|
||||
for (int i = 0; i < 9; ++i) {
|
||||
const std::string id = dlg->pickLineWithParams("alice.greet", {});
|
||||
CHECK((id == "alice.greet.3" || id == "alice.greet.4"));
|
||||
}
|
||||
|
||||
resetDialogue(dlg);
|
||||
}
|
||||
|
||||
TEST_CASE("dialogueProc.dnutAttrsAndErrors") {
|
||||
Dialogue *dlg = Dialogue::create();
|
||||
resetDialogue(dlg);
|
||||
|
||||
const std::string src =
|
||||
"pool x {\n"
|
||||
" alice: \"hi\" weight=3 i18n=\"line.hi\" id=\"custom\"\n"
|
||||
"}\n";
|
||||
CHECK_EQ(dlg->loadPoolsFromDnut(src, "a.dnut"), 1);
|
||||
CHECK(dlg->playLineWithParams("custom", {}));
|
||||
CHECK_EQ(dlg->getCurrentLineId(), std::string("custom"));
|
||||
// weight=3 参与随机(相对池内其它行权重更高);这里单行池直接命中。
|
||||
CHECK_EQ(dlg->pickLineWithParams("x", {}), std::string("custom"));
|
||||
|
||||
// 语法错误:行号与路径出现在 getLastPoolsError。
|
||||
CHECK_EQ(dlg->loadPoolsFromDnut("pool x {\n alice: hi\n}\n", "bad.dnut"), 0);
|
||||
CHECK(dlg->getLastPoolsError().find("bad.dnut:2") != std::string::npos);
|
||||
|
||||
// 未知属性。
|
||||
CHECK_EQ(dlg->loadPoolsFromDnut("pool x { alice: \"hi\" bogus=1 }\n", "bad2.dnut"), 0);
|
||||
CHECK(!dlg->getLastPoolsError().empty());
|
||||
|
||||
// 未闭合的 pool。
|
||||
CHECK_EQ(dlg->loadPoolsFromDnut("pool x {\n alice: \"hi\"\n", "bad3.dnut"), 0);
|
||||
CHECK(dlg->getLastPoolsError().find("bad3.dnut") != std::string::npos);
|
||||
|
||||
resetDialogue(dlg);
|
||||
}
|
||||
|
||||
TEST_CASE("dialogueProc.sceneVarsAutoClear") {
|
||||
Dialogue *dlg = Dialogue::create();
|
||||
resetDialogue(dlg);
|
||||
dlg->setVarValue("keep", Dialogue::VarValue::integer(1), "global");
|
||||
|
||||
eve::scene::Scene *scn = eve::scene::Scene::create();
|
||||
scn->mountAs("dialogueSceneA", eve::scene::node("root"));
|
||||
scn->mountAs("dialogueSceneB", eve::scene::node("root"));
|
||||
scn->select("dialogueSceneA");
|
||||
|
||||
dlg->update(0.016f); // record current scene name
|
||||
dlg->setVarValue("tmp", Dialogue::VarValue::integer(2), "scene");
|
||||
CHECK(dlg->hasVar("tmp", "scene"));
|
||||
|
||||
scn->select("dialogueSceneB");
|
||||
dlg->update(0.016f); // scene changed -> scene vars cleared
|
||||
CHECK(!dlg->hasVar("tmp", "scene"));
|
||||
CHECK(dlg->hasVar("keep", "global"));
|
||||
|
||||
resetDialogue(dlg);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ScriptTest.h"
|
||||
|
||||
extern const char* dialogue_proc_script_content;
|
||||
UnitSciptTest(DialogueProcScriptTest, dialogue_proc_script_content);
|
||||
|
||||
TEST_CASE_FIXTURE(DialogueProcScriptTest, "DialogueProcScriptTest.basicPools") {
|
||||
CHECK(vm.callFunc(vm.findFunc("basicPools"), vm).toBool());
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
function basicPools() {
|
||||
if (eve.Dialogue == null) return false;
|
||||
local dlg = eve.Dialogue();
|
||||
dlg.clearPools();
|
||||
dlg.clearVars("all");
|
||||
dlg.setRandomSeed(7);
|
||||
|
||||
dlg.setVar("mood", "happy", "scene");
|
||||
dlg.setVar("hour", 20, "global");
|
||||
dlg.setVar("ratio", 1.5, "global");
|
||||
dlg.setVar("flag", true, "global");
|
||||
|
||||
if (dlg.getVarType("mood", "scene") != "string") return false;
|
||||
if (dlg.getVarInt("hour", 0, "global") != 20) return false;
|
||||
if (dlg.getVarFloat("ratio", 0.0, "global") != 1.5) return false;
|
||||
if (!dlg.getVarBool("flag", false, "global")) return false;
|
||||
if (dlg.hasVar("mood", "global")) return false;
|
||||
|
||||
local n = dlg.loadPoolsFromTable({
|
||||
pools = {
|
||||
"alice.greet" : {
|
||||
noRepeat = 2,
|
||||
lines = [
|
||||
{ id = "a1", speaker = "alice", text = "你好,{name}!", weight = 2,
|
||||
when = { var = "mood", op = "eq", value = "happy" },
|
||||
meta = { expression = "happy", voice = "vo/a1.ogg" },
|
||||
tags = ["greet"] },
|
||||
{ id = "a2", speaker = "alice", text = "晚安。", weight = 1,
|
||||
when = { var = "mood", op = "eq", value = "sad" } },
|
||||
{ id = "a3", speaker = "", text = "(旁白)" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (n != 1) return false;
|
||||
if (!dlg.hasPool("alice.greet")) return false;
|
||||
if (dlg.getPoolCount() != 1) return false;
|
||||
|
||||
if (!dlg.evalCondition({ var = "mood", op = "eq", value = "happy" })) return false;
|
||||
if (dlg.evalCondition({ var = "mood", op = "eq", value = "sad" })) return false;
|
||||
if (!dlg.evalCondition({
|
||||
all = [ { var = "hour", op = "ge", value = 18 },
|
||||
{ var = "mood", op = "eq", value = "happy" } ],
|
||||
})) return false;
|
||||
|
||||
dlg.registerCondition("isNight", function(ctx) {
|
||||
return ctx.vars.hour >= 18;
|
||||
});
|
||||
if (!dlg.evalCondition({ script = "isNight" })) return false;
|
||||
dlg.unregisterCondition("isNight");
|
||||
if (dlg.evalCondition({ script = "isNight" })) return false;
|
||||
|
||||
local lineId = dlg.pickLine("alice.greet", { name = "Alice" });
|
||||
if (lineId == "") return false;
|
||||
if (!dlg.playLine(lineId, { name = "Bob" })) return false;
|
||||
if (dlg.getCurrentLineId() != lineId) return false;
|
||||
if (dlg.getFullText() != "你好,Bob!") return false;
|
||||
if (dlg.getCurrentLineMeta("voice") != "vo/a1.ogg") return false;
|
||||
local tags = dlg.getCurrentLineTags();
|
||||
if (tags.len() != 1 || tags[0] != "greet") return false;
|
||||
|
||||
if (!dlg.playPool("alice.greet", { name = "Carol" })) return false;
|
||||
if (dlg.getCurrentLineId() == "") return false;
|
||||
|
||||
// scene 区隔离:清 scene 不影响 global。
|
||||
dlg.clearVars("scene");
|
||||
if (dlg.hasVar("mood", "scene")) return false;
|
||||
if (!dlg.hasVar("hour", "global")) return false;
|
||||
|
||||
dlg.clearPools();
|
||||
return true;
|
||||
}
|
||||
@@ -7,3 +7,4 @@ const char* model_content = R"content(@model_content@)content";
|
||||
const char* model2_content = R"content(@model2_content@)content";
|
||||
const char* timer_content = R"content(@timer_content@)content";
|
||||
const char* i18n_content = R"content(@i18n_content@)content";
|
||||
const char* dialogue_proc_script_content = R"content(@dialogue_proc_script_content@)content";
|
||||
Reference in new issue
Block a user