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:
西风逍遥游 authored and GitHub committed 2026-08-19 00:08:35 +08:00
commit e95248dbe5
18 files changed
+2362 -10

No files matched your search

+28
View File
@@ -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)。
+2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"name": { "alice": "Alice", "bob": "Bob" },
"name": { "alice": "Alice", "bob": "Bob", "player": "旅行者" },
"line": {
"hello": "你好!我是 {name}。",
"intro": "我是 Bob。这是用 Squirrel 写的对话脚本。",
+49 -4
View File
@@ -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());
+25
View File
@@ -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
+217 -2
View File
@@ -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> &params);
bool playLine(const std::string &lineId, ssq::Object params);
bool playLineWithParams(const std::string &lineId,
const std::unordered_map<std::string, VarValue> &params);
bool playPool(const std::string &poolId, ssq::Object params);
bool playPoolWithParams(const std::string &poolId,
const std::unordered_map<std::string, VarValue> &params);
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> &params) 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> &params,
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> &params,
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
+475
View File
@@ -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
+19
View File
@@ -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
+1
View File
@@ -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)};
+1 -1
View File
@@ -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;
}
}
+1 -1
View File
@@ -17,4 +17,4 @@ const char* vertex_content = R"(@vertex_content@)";
const char* fragment_content = R"(@fragment_content@)";
}
}
+3
View File
@@ -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
+459
View File
@@ -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);
}
+8
View File
@@ -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());
}
+72
View File
@@ -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;
}
+1
View File
@@ -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";