diff --git a/docs/dev/对话与Avatar模块设计.md b/docs/dev/对话与Avatar模块设计.md index 71857613..db083bf8 100644 --- a/docs/dev/对话与Avatar模块设计.md +++ b/docs/dev/对话与Avatar模块设计.md @@ -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 = { = { 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)。 diff --git a/examples/dialogue/README.md b/examples/dialogue/README.md index 91366e21..bd7aae0d 100644 --- a/examples/dialogue/README.md +++ b/examples/dialogue/README.md @@ -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` 触发资产热重载。 diff --git a/examples/dialogue/locales/en.json b/examples/dialogue/locales/en.json index ed6e8134..c509971d 100644 --- a/examples/dialogue/locales/en.json +++ b/examples/dialogue/locales/en.json @@ -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.", diff --git a/examples/dialogue/locales/zh.json b/examples/dialogue/locales/zh.json index e45171ab..8a644df0 100644 --- a/examples/dialogue/locales/zh.json +++ b/examples/dialogue/locales/zh.json @@ -1,5 +1,5 @@ { - "name": { "alice": "Alice", "bob": "Bob" }, + "name": { "alice": "Alice", "bob": "Bob", "player": "旅行者" }, "line": { "hello": "你好!我是 {name}。", "intro": "我是 Bob。这是用 Squirrel 写的对话脚本。", diff --git a/examples/dialogue/main.nut b/examples/dialogue/main.nut index 91e47736..1e2d273c 100644 --- a/examples/dialogue/main.nut +++ b/examples/dialogue/main.nut @@ -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()); diff --git a/examples/dialogue/pools.dnut b/examples/dialogue/pools.dnut new file mode 100644 index 00000000..4b8cf156 --- /dev/null +++ b/examples/dialogue/pools.dnut @@ -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"] +} diff --git a/src/modules/dialogue/Dialogue.cpp b/src/modules/dialogue/Dialogue.cpp index 5a803f8e..76203ed2 100644 --- a/src/modules/dialogue/Dialogue.cpp +++ b/src/modules/dialogue/Dialogue.cpp @@ -1,14 +1,975 @@ #include "dialogue/Dialogue.h" +#include "dialogue/DnutParser.h" +#include "filesystem/FileData.h" +#include "filesystem/Filesystem.h" +#include "i18n/I18n.h" +#include "scene/Scene.h" +#include "scene/SceneHost.h" #include "common/utf8.h" +#include #include +#include +#include #include +#include namespace eve::dialogue { Module_IMPL(Dialogue, new Dialogue()); +namespace { + +std::string floatToString(double v) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%g", v); + return buf; +} + +bool squirrelToVarValueAt(HSQUIRRELVM vm, SQInteger idx, Dialogue::VarValue &out) { + switch (sq_gettype(vm, idx)) { + case OT_INTEGER: { + SQInteger v = 0; + if (SQ_FAILED(sq_getinteger(vm, idx, &v))) return false; + out = Dialogue::VarValue::integer(v); + return true; + } + case OT_FLOAT: { + SQFloat v = 0; + if (SQ_FAILED(sq_getfloat(vm, idx, &v))) return false; + out = Dialogue::VarValue::number(v); + return true; + } + case OT_BOOL: { + SQBool v = SQFalse; + if (SQ_FAILED(sq_getbool(vm, idx, &v))) return false; + out = Dialogue::VarValue::boolean(v != 0); + return true; + } + case OT_STRING: { + const SQChar *v = nullptr; + if (SQ_FAILED(sq_getstring(vm, idx, &v))) return false; + out = Dialogue::VarValue::string(v ? v : ""); + return true; + } + default: + return false; + } +} + +/** Convert the Squirrel value at stack index `idx` into a DataValue tree. */ +bool squirrelToDataValue(HSQUIRRELVM vm, SQInteger idx, DataValue &out) { + switch (sq_gettype(vm, idx)) { + case OT_NULL: + out = DataValue::null(); + return true; + case OT_INTEGER: { + SQInteger v = 0; + if (SQ_FAILED(sq_getinteger(vm, idx, &v))) return false; + out = DataValue::integer(v); + return true; + } + case OT_FLOAT: { + SQFloat v = 0; + if (SQ_FAILED(sq_getfloat(vm, idx, &v))) return false; + out = DataValue::number(v); + return true; + } + case OT_BOOL: { + SQBool v = SQFalse; + if (SQ_FAILED(sq_getbool(vm, idx, &v))) return false; + out = DataValue::boolean(v != 0); + return true; + } + case OT_STRING: { + const SQChar *s = nullptr; + if (SQ_FAILED(sq_getstring(vm, idx, &s))) return false; + out = DataValue::string(s ? s : ""); + return true; + } + case OT_ARRAY: { + const SQInteger size = sq_getsize(vm, idx); + const SQInteger absIdx = idx > 0 ? idx : sq_gettop(vm) + idx + 1; + std::vector items; + for (SQInteger i = 0; i < size; ++i) { + sq_pushinteger(vm, i); + if (SQ_FAILED(sq_get(vm, absIdx))) return false; + DataValue item; + const bool ok = squirrelToDataValue(vm, -1, item); + sq_pop(vm, 1); + if (!ok) return false; + items.push_back(std::move(item)); + } + out = DataValue::array(std::move(items)); + return true; + } + case OT_TABLE: { + std::vector> fields; + sq_pushnull(vm); // iterator + while (SQ_SUCCEEDED(sq_next(vm, -2))) { + std::string key; + if (sq_gettype(vm, -2) == OT_STRING) { + const SQChar *k = nullptr; + if (SQ_SUCCEEDED(sq_getstring(vm, -2, &k)) && k) key = k; + } + DataValue value; + const bool ok = !key.empty() && squirrelToDataValue(vm, -1, value); + sq_pop(vm, 2); + if (!ok) { + sq_pop(vm, 1); // iterator + return false; + } + fields.emplace_back(std::move(key), std::move(value)); + } + sq_pop(vm, 1); // iterator + out = DataValue::object(std::move(fields)); + return true; + } + default: + return false; + } +} + +bool objectToDataValue(HSQUIRRELVM vm, const ssq::Object &obj, DataValue &out) { + if (!vm) return false; + const SQInteger top = sq_gettop(vm); + sq_pushobject(vm, obj.getRaw()); + const bool ok = squirrelToDataValue(vm, -1, out); + sq_settop(vm, top); + return ok; +} + +bool objectToVarParams(HSQUIRRELVM vm, const ssq::Object &obj, + std::unordered_map &out) { + out.clear(); + if (!vm) return true; // no VM -> treat as empty params + const HSQOBJECT raw = obj.getRaw(); + if (raw._type != OT_TABLE) return true; // null / other -> empty params + const SQInteger top = sq_gettop(vm); + sq_pushobject(vm, raw); + sq_pushnull(vm); // iterator + while (SQ_SUCCEEDED(sq_next(vm, -2))) { + const SQChar *k = nullptr; + if (sq_gettype(vm, -2) == OT_STRING && SQ_SUCCEEDED(sq_getstring(vm, -2, &k)) && k) { + Dialogue::VarValue v; + if (squirrelToVarValueAt(vm, -1, v)) out[k] = std::move(v); + } + sq_pop(vm, 2); + } + sq_settop(vm, top); + return true; +} + +void pushVarValue(HSQUIRRELVM vm, const Dialogue::VarValue &v) { + switch (v.type) { + case Dialogue::VarValue::Type::Int: + sq_pushinteger(vm, SQInteger(v.i)); + break; + case Dialogue::VarValue::Type::Float: + sq_pushfloat(vm, SQFloat(v.f)); + break; + case Dialogue::VarValue::Type::Bool: + sq_pushbool(vm, v.b ? SQTrue : SQFalse); + break; + case Dialogue::VarValue::Type::String: + sq_pushstring(vm, v.s.c_str(), v.s.size()); + break; + } +} + +void pushVarTable(HSQUIRRELVM vm, const std::unordered_map &vars) { + sq_newtable(vm); + for (const auto &kv : vars) { + sq_pushstring(vm, kv.first.c_str(), kv.first.size()); + pushVarValue(vm, kv.second); + sq_newslot(vm, -3, SQFalse); + } +} + +} // namespace + +Dialogue::VarValue Dialogue::VarValue::integer(long long v) { + VarValue x; + x.type = Type::Int; + x.i = v; + return x; +} + +Dialogue::VarValue Dialogue::VarValue::number(double v) { + VarValue x; + x.type = Type::Float; + x.f = v; + return x; +} + +Dialogue::VarValue Dialogue::VarValue::boolean(bool v) { + VarValue x; + x.type = Type::Bool; + x.b = v; + return x; +} + +Dialogue::VarValue Dialogue::VarValue::string(std::string v) { + VarValue x; + x.type = Type::String; + x.s = std::move(v); + return x; +} + +std::string Dialogue::VarValue::typeName() const { + switch (type) { + case Type::Int: + return "int"; + case Type::Float: + return "float"; + case Type::Bool: + return "bool"; + case Type::String: + return "string"; + } + return "string"; +} + +std::string Dialogue::VarValue::toString() const { + switch (type) { + case Type::Int: + return std::to_string(i); + case Type::Float: + return floatToString(f); + case Type::Bool: + return b ? "true" : "false"; + case Type::String: + return s; + } + return {}; +} + +Dialogue::Dialogue() = default; + +Dialogue::~Dialogue() { + if (vm_) { + for (auto &kv : predicates_) sq_release(vm_, &kv.second); + predicates_.clear(); + } +} + +// --------------------------------------------------------------------------- +// Variables (global / scene) +// --------------------------------------------------------------------------- + +std::unordered_map *Dialogue::varsForScope( + const std::string &scope) { + if (scope == "global") return &globalVars_; + if (scope == "scene") return &sceneVars_; + return nullptr; +} + +const std::unordered_map *Dialogue::varsForScope( + const std::string &scope) const { + if (scope == "global") return &globalVars_; + if (scope == "scene") return &sceneVars_; + return nullptr; +} + +bool Dialogue::setVarValue(const std::string &name, const VarValue &value, + const std::string &scope) { + auto *m = varsForScope(scope); + if (!m || name.empty()) return false; + (*m)[name] = value; + return true; +} + +bool Dialogue::setVar(const std::string &name, ssq::Object value, const std::string &scope) { + if (!vm_) return false; + const SQInteger top = sq_gettop(vm_); + sq_pushobject(vm_, value.getRaw()); + VarValue v; + const bool ok = squirrelToVarValueAt(vm_, -1, v); + sq_settop(vm_, top); + if (!ok) return false; + return setVarValue(name, v, scope); +} + +Dialogue::VarValue Dialogue::getVarValue(const std::string &name, const std::string &scope) const { + const auto *m = varsForScope(scope); + if (!m) return VarValue::string(""); + const auto it = m->find(name); + return it == m->end() ? VarValue::string("") : it->second; +} + +std::string Dialogue::getVarType(const std::string &name, const std::string &scope) const { + return hasVar(name, scope) ? getVarValue(name, scope).typeName() : ""; +} + +int Dialogue::getVarInt(const std::string &name, int defaultValue, const std::string &scope) const { + const VarValue v = getVarValue(name, scope); + if (v.type == VarValue::Type::Int) return int(v.i); + if (v.type == VarValue::Type::Float) return int(v.f); + if (v.type == VarValue::Type::Bool) return v.b ? 1 : 0; + return defaultValue; +} + +float Dialogue::getVarFloat(const std::string &name, float defaultValue, + const std::string &scope) const { + const VarValue v = getVarValue(name, scope); + if (v.type == VarValue::Type::Int) return float(v.i); + if (v.type == VarValue::Type::Float) return float(v.f); + if (v.type == VarValue::Type::Bool) return v.b ? 1.f : 0.f; + return defaultValue; +} + +bool Dialogue::getVarBool(const std::string &name, bool defaultValue, + const std::string &scope) const { + const VarValue v = getVarValue(name, scope); + if (v.type == VarValue::Type::Int) return v.i != 0; + if (v.type == VarValue::Type::Float) return v.f != 0.0; + if (v.type == VarValue::Type::Bool) return v.b; + return defaultValue; +} + +std::string Dialogue::getVarString(const std::string &name, const std::string &defaultValue, + const std::string &scope) const { + const VarValue v = getVarValue(name, scope); + return v.type == VarValue::Type::String ? v.s : defaultValue; +} + +bool Dialogue::hasVar(const std::string &name, const std::string &scope) const { + const auto *m = varsForScope(scope); + return m && m->find(name) != m->end(); +} + +bool Dialogue::clearVar(const std::string &name, const std::string &scope) { + auto *m = varsForScope(scope); + return m && m->erase(name) > 0; +} + +void Dialogue::clearVars(const std::string &scope) { + if (scope == "global") { + globalVars_.clear(); + } else if (scope == "scene") { + sceneVars_.clear(); + } else if (scope == "all") { + globalVars_.clear(); + sceneVars_.clear(); + } +} + +// --------------------------------------------------------------------------- +// Conditions (structured tables + script predicates) +// --------------------------------------------------------------------------- + +bool Dialogue::registerCondition(const std::string &name, ssq::Object fn) { + if (!vm_ || name.empty()) return false; + HSQOBJECT raw = fn.getRaw(); + if (raw._type == OT_NULL) return unregisterCondition(name); + if (raw._type != OT_CLOSURE && raw._type != OT_NATIVECLOSURE) return false; + const auto it = predicates_.find(name); + if (it != predicates_.end()) { + sq_release(vm_, &it->second); + predicates_.erase(it); + } + sq_addref(vm_, &raw); + predicates_[name] = raw; + return true; +} + +bool Dialogue::unregisterCondition(const std::string &name) { + const auto it = predicates_.find(name); + if (it == predicates_.end()) return false; + if (vm_) sq_release(vm_, &it->second); + predicates_.erase(it); + return true; +} + +bool Dialogue::evalCondition(ssq::Object table) { + DataValue v; + if (!objectToDataValue(vm_, table, v)) return false; + return evalConditionData(v); +} + +bool Dialogue::parseCondition(const DataValue &v, Condition &out, std::string &error) { + if (v.kind == DataValue::Kind::Array) { + out.kind = Condition::Kind::All; + for (const auto &item : v.arr) { + Condition child; + if (!parseCondition(item, child, error)) return false; + out.children.push_back(std::move(child)); + } + return true; + } + if (v.kind != DataValue::Kind::Object) { + error = "condition must be a table"; + return false; + } + if (const DataValue *script = v.find("script")) { + if (script->kind != DataValue::Kind::String) { + error = "script condition name must be a string"; + return false; + } + out.kind = Condition::Kind::Script; + out.script = script->s; + return true; + } + if (const DataValue *all = v.find("all")) { + if (all->kind != DataValue::Kind::Array) { + error = "all must be an array"; + return false; + } + out.kind = Condition::Kind::All; + for (const auto &item : all->arr) { + Condition child; + if (!parseCondition(item, child, error)) return false; + out.children.push_back(std::move(child)); + } + return true; + } + if (const DataValue *any = v.find("any")) { + if (any->kind != DataValue::Kind::Array) { + error = "any must be an array"; + return false; + } + out.kind = Condition::Kind::Any; + for (const auto &item : any->arr) { + Condition child; + if (!parseCondition(item, child, error)) return false; + out.children.push_back(std::move(child)); + } + return true; + } + if (const DataValue *notc = v.find("not")) { + out.kind = Condition::Kind::Not; + out.children.emplace_back(); + return parseCondition(*notc, out.children.back(), error); + } + if (const DataValue *var = v.find("var")) { + const DataValue *op = v.find("op"); + if (var->kind != DataValue::Kind::String || !op || + op->kind != DataValue::Kind::String) { + error = "var/op condition requires string var and op"; + return false; + } + static const char *kOps[] = {"eq", "ne", "gt", "ge", "lt", "le", "has", "missing"}; + const std::string opStr = op->s; + if (std::find(std::begin(kOps), std::end(kOps), opStr) == std::end(kOps)) { + error = "unknown condition op: " + opStr; + return false; + } + out.kind = Condition::Kind::Cmp; + out.var = var->s; + out.op = opStr; + out.value = VarValue::string(""); + if (const DataValue *val = v.find("value")) { + switch (val->kind) { + case DataValue::Kind::Int: + out.value = VarValue::integer(val->i); + break; + case DataValue::Kind::Float: + out.value = VarValue::number(val->f); + break; + case DataValue::Kind::Bool: + out.value = VarValue::boolean(val->b); + break; + case DataValue::Kind::String: + out.value = VarValue::string(val->s); + break; + default: + break; + } + } + return true; + } + error = "unrecognized condition table"; + return false; +} + +bool Dialogue::compareEq(const VarValue &a, const VarValue &b) const { + if (a.isNumeric() && b.isNumeric()) { + const double x = a.type == VarValue::Type::Int ? double(a.i) : a.f; + const double y = b.type == VarValue::Type::Int ? double(b.i) : b.f; + return x == y; + } + if (a.type == VarValue::Type::Bool && b.type == VarValue::Type::Bool) return a.b == b.b; + if (a.type == VarValue::Type::String && b.type == VarValue::Type::String) + return a.s == b.s; + return false; +} + +bool Dialogue::compareOrder(const std::string &op, const VarValue &a, const VarValue &b) const { + if (!a.isNumeric() || !b.isNumeric()) return false; + const double x = a.type == VarValue::Type::Int ? double(a.i) : a.f; + const double y = b.type == VarValue::Type::Int ? double(b.i) : b.f; + if (op == "gt") return x > y; + if (op == "ge") return x >= y; + if (op == "lt") return x < y; + if (op == "le") return x <= y; + return false; +} + +bool Dialogue::evalConditionInternal(const Condition &c, + const std::unordered_map &merged, + const std::unordered_map ¶ms, + const std::string &lineId) const { + switch (c.kind) { + case Condition::Kind::Always: + return true; + case Condition::Kind::Cmp: { + const auto it = merged.find(c.var); + const bool present = it != merged.end(); + if (c.op == "has") return present; + if (c.op == "missing") return !present; + if (!present) return false; + if (c.op == "eq") return compareEq(it->second, c.value); + if (c.op == "ne") return !compareEq(it->second, c.value); + return compareOrder(c.op, it->second, c.value); + } + case Condition::Kind::All: + for (const auto &child : c.children) + if (!evalConditionInternal(child, merged, params, lineId)) return false; + return true; + case Condition::Kind::Any: + for (const auto &child : c.children) + if (evalConditionInternal(child, merged, params, lineId)) return true; + return false; + case Condition::Kind::Not: + return c.children.empty() || + !evalConditionInternal(c.children.front(), merged, params, lineId); + case Condition::Kind::Script: + return evalScriptPredicate(c.script, merged, params, lineId); + } + return false; +} + +bool Dialogue::evalConditionData(const DataValue &cond) { + Condition c; + std::string error; + if (!parseCondition(cond, c, error)) return false; + return evalConditionInternal(c, mergedVars({}), {}, ""); +} + +bool Dialogue::evalScriptPredicate(const std::string &name, + const std::unordered_map &merged, + const std::unordered_map ¶ms, + const std::string &lineId) const { + if (!vm_) return false; + const auto it = predicates_.find(name); + if (it == predicates_.end()) return false; + + const SQInteger top = sq_gettop(vm_); + sq_pushobject(vm_, it->second); // closure first; sq_call expects fn below its args + sq_newtable(vm_); // ctx + + sq_pushstring(vm_, "vars", -1); + pushVarTable(vm_, merged); + sq_newslot(vm_, -3, SQFalse); // fn at -4, key at -2, var table at -1 -> slot into ctx(-3) + + sq_pushstring(vm_, "params", -1); + pushVarTable(vm_, params); + sq_newslot(vm_, -3, SQFalse); + + sq_pushstring(vm_, "lineId", -1); + sq_pushstring(vm_, lineId.c_str(), lineId.size()); + sq_newslot(vm_, -3, SQFalse); + + if (SQ_FAILED(sq_call(vm_, 1, SQTrue, SQTrue))) { + sq_settop(vm_, top); + return false; + } + SQBool result = SQFalse; + if (SQ_FAILED(sq_getbool(vm_, -1, &result))) { + sq_settop(vm_, top); + return false; + } + sq_settop(vm_, top); + return result != 0; +} + +// --------------------------------------------------------------------------- +// Content pools +// --------------------------------------------------------------------------- + +int Dialogue::loadPoolsFromTable(ssq::Object table) { + DataValue root; + if (!objectToDataValue(vm_, table, root)) { + lastPoolsError_ = "pools root must be a table"; + return 0; + } + return loadPoolsFromData(root); +} + +bool Dialogue::parseLineData(const std::string &poolId, const DataValue &v, int index, Line &line, + std::string &error) { + if (v.kind != DataValue::Kind::Object) { + error = "line must be a table"; + return false; + } + if (const DataValue *id = v.find("id")) + if (id->kind == DataValue::Kind::String) line.id = id->s; + if (line.id.empty()) line.id = poolId + "." + std::to_string(index); + + if (const DataValue *sp = v.find("speaker")) + if (sp->kind == DataValue::Kind::String) line.speaker = sp->s; + if (const DataValue *tx = v.find("text")) + if (tx->kind == DataValue::Kind::String) line.text = tx->s; + if (const DataValue *ik = v.find("i18n")) + if (ik->kind == DataValue::Kind::String) line.i18nKey = ik->s; + if (line.text.empty() && line.i18nKey.empty()) { + error = "line '" + line.id + "': text or i18n required"; + return false; + } + + if (const DataValue *w = v.find("weight")) { + if (w->kind == DataValue::Kind::Int) line.weight = double(w->i); + else if (w->kind == DataValue::Kind::Float) line.weight = w->f; + else { + error = "line '" + line.id + "': weight must be a number"; + return false; + } + } + + if (const DataValue *when = v.find("when")) { + if (!parseCondition(*when, line.when, error)) { + error = "line '" + line.id + "': " + error; + return false; + } + } + + if (const DataValue *meta = v.find("meta")) { + if (meta->kind != DataValue::Kind::Object) { + error = "line '" + line.id + "': meta must be a table"; + return false; + } + for (const auto &mkv : meta->obj) { + std::string val; + switch (mkv.second.kind) { + case DataValue::Kind::String: + val = mkv.second.s; + break; + case DataValue::Kind::Int: + val = std::to_string(mkv.second.i); + break; + case DataValue::Kind::Float: + val = floatToString(mkv.second.f); + break; + case DataValue::Kind::Bool: + val = mkv.second.b ? "true" : "false"; + break; + default: + break; + } + line.meta[mkv.first] = val; + } + } + + if (const DataValue *tags = v.find("tags")) { + if (tags->kind != DataValue::Kind::Array) { + error = "line '" + line.id + "': tags must be an array"; + return false; + } + for (const auto &t : tags->arr) + if (t.kind == DataValue::Kind::String) line.tags.push_back(t.s); + } + return true; +} + +int Dialogue::loadPoolsFromData(const DataValue &root) { + lastPoolsError_.clear(); + if (root.kind != DataValue::Kind::Object) { + lastPoolsError_ = "pools root must be an object"; + return 0; + } + const DataValue *pools = root.find("pools"); + if (!pools || pools->kind != DataValue::Kind::Object) { + lastPoolsError_ = "missing pools object"; + return 0; + } + + int registered = 0; + for (const auto &kv : pools->obj) { + Pool pool; + pool.id = kv.first; + if (kv.second.kind != DataValue::Kind::Object) { + lastPoolsError_ = "pool '" + kv.first + "': expected object"; + continue; + } + if (const DataValue *nr = kv.second.find("noRepeat")) { + if (nr->kind == DataValue::Kind::Int) pool.noRepeat = int(nr->i); + else if (nr->kind == DataValue::Kind::Float) pool.noRepeat = int(nr->f); + if (pool.noRepeat < 0) pool.noRepeat = 0; + } + const DataValue *lines = kv.second.find("lines"); + if (!lines || lines->kind != DataValue::Kind::Array) { + lastPoolsError_ = "pool '" + kv.first + "': missing lines array"; + continue; + } + int lineIndex = 1; + for (const auto &lv : lines->arr) { + Line line; + std::string error; + if (!parseLineData(pool.id, lv, lineIndex, line, error)) { + if (lastPoolsError_.empty()) lastPoolsError_ = error; + continue; + } + pool.lines.push_back(std::move(line)); + ++lineIndex; + } + if (Pool *existing = findPool(pool.id)) *existing = std::move(pool); + else pools_.push_back(std::move(pool)); + ++registered; + } + return registered; +} + +int Dialogue::loadPoolsFromDnut(const std::string &source, const std::string &path) { + DataValue root; + std::string error; + if (!parseDnut(source, path.empty() ? "" : path, root, error)) { + lastPoolsError_ = error; + return 0; + } + return loadPoolsFromData(root); +} + +int Dialogue::loadPoolsFromDnutFile(const std::string &path) { + auto *fs = eve::ModuleManager::getInstance("Filesystem"); + if (!fs) fs = eve::filesystem::Filesystem::create(); + eve::filesystem::FileData *fd = nullptr; + try { + fd = fs->read(path); + } catch (...) { + delete fd; + lastPoolsError_ = path + ": 读取失败"; + return 0; + } + if (fd == nullptr || fd->getData() == nullptr || fd->getSize() == 0) { + delete fd; + lastPoolsError_ = path + ": 读取失败"; + return 0; + } + const std::string text(static_cast(fd->getData()), fd->getSize()); + delete fd; + return loadPoolsFromDnut(text, path); +} + +void Dialogue::clearPools() { pools_.clear(); } + +int Dialogue::getPoolCount() const { return int(pools_.size()); } + +std::string Dialogue::getPoolId(int index) const { + if (index < 0 || size_t(index) >= pools_.size()) return {}; + return pools_[size_t(index)].id; +} + +bool Dialogue::hasPool(const std::string &id) const { return findPool(id) != nullptr; } + +Dialogue::Pool *Dialogue::findPool(const std::string &id) { + for (auto &p : pools_) + if (p.id == id) return &p; + return nullptr; +} + +const Dialogue::Pool *Dialogue::findPool(const std::string &id) const { + for (const auto &p : pools_) + if (p.id == id) return &p; + return nullptr; +} + +Dialogue::Line *Dialogue::findLine(const std::string &id) { + for (auto &p : pools_) + for (auto &l : p.lines) + if (l.id == id) return &l; + return nullptr; +} + +const Dialogue::Line *Dialogue::findLine(const std::string &id) const { + for (const auto &p : pools_) + for (const auto &l : p.lines) + if (l.id == id) return &l; + return nullptr; +} + +// --------------------------------------------------------------------------- +// Merged variable context / interpolation +// --------------------------------------------------------------------------- + +std::unordered_map Dialogue::mergedVars( + const std::unordered_map ¶ms) const { + std::unordered_map m = globalVars_; + for (const auto &kv : sceneVars_) m[kv.first] = kv.second; + for (const auto &kv : params) m[kv.first] = kv.second; + return m; +} + +std::unordered_map Dialogue::stringParams( + const std::unordered_map &vars) const { + std::unordered_map out; + for (const auto &kv : vars) out[kv.first] = kv.second.toString(); + return out; +} + +std::string Dialogue::interpolate(const std::string &tpl, + const std::unordered_map &vars) const { + std::string out; + out.reserve(tpl.size()); + for (size_t i = 0; i < tpl.size();) { + if (tpl[i] == '{') { + const size_t close = tpl.find('}', i + 1); + if (close != std::string::npos) { + const std::string name = tpl.substr(i + 1, close - i - 1); + const auto it = vars.find(name); + if (it != vars.end()) { + out += it->second.toString(); + i = close + 1; + continue; + } + } + } + out += tpl[i++]; + } + return out; +} + +// --------------------------------------------------------------------------- +// RNG / weighted selection / play +// --------------------------------------------------------------------------- + +uint32_t Dialogue::nextRandom() { + uint32_t x = rngState_; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + rngState_ = x; + return x; +} + +double Dialogue::nextUnit() { return (nextRandom() & 0xFFFFFFu) / 16777216.0; } + +void Dialogue::setRandomSeed(int seed) { + rngState_ = seed ? uint32_t(seed) : 1u; + for (auto &p : pools_) p.recent.clear(); +} + +std::string Dialogue::pickLine(const std::string &poolId, ssq::Object params) { + std::unordered_map p; + objectToVarParams(vm_, params, p); + return pickLineWithParams(poolId, p); +} + +std::string Dialogue::pickLineWithParams( + const std::string &poolId, const std::unordered_map ¶ms) { + Pool *pool = findPool(poolId); + if (!pool) return ""; + const auto merged = mergedVars(params); + + auto collect = [&](std::vector &out, bool allowRecent) { + for (size_t i = 0; i < pool->lines.size(); ++i) { + const Line &line = pool->lines[i]; + if (line.weight <= 0.0) continue; + if (!evalConditionInternal(line.when, merged, params, line.id)) continue; + if (!allowRecent && pool->noRepeat > 0 && + std::find(pool->recent.begin(), pool->recent.end(), i) != pool->recent.end()) + continue; + out.push_back(i); + } + }; + + std::vector candidates; + collect(candidates, false); + if (candidates.empty()) collect(candidates, true); // noRepeat must not deadlock + if (candidates.empty()) return ""; + + double total = 0.0; + for (const size_t i : candidates) total += pool->lines[i].weight; + double roll = nextUnit() * total; + size_t picked = candidates.front(); + double acc = 0.0; + for (const size_t i : candidates) { + acc += pool->lines[i].weight; + if (roll < acc) { + picked = i; + break; + } + } + if (pool->noRepeat > 0) { + pool->recent.push_back(picked); + if (pool->recent.size() > size_t(pool->noRepeat)) pool->recent.erase(pool->recent.begin()); + } + return pool->lines[picked].id; +} + +bool Dialogue::playLine(const std::string &lineId, ssq::Object params) { + std::unordered_map p; + objectToVarParams(vm_, params, p); + return playLineWithParams(lineId, p); +} + +bool Dialogue::playLineWithParams(const std::string &lineId, + const std::unordered_map ¶ms) { + Line *line = findLine(lineId); + if (!line) return false; + const auto merged = mergedVars(params); + + std::string text; + if (!line->text.empty()) { + text = interpolate(line->text, merged); + } else { + auto *i18n = eve::ModuleManager::getInstance("I18n"); + text = i18n ? i18n->getWithParams(line->i18nKey, stringParams(merged)) : line->i18nKey; + } + + if (line->speaker.empty()) narrate(text); + else say(line->speaker, text); + + currentLineId_ = line->id; + currentLineMeta_ = line->meta; + currentLineTags_ = line->tags; + applyLineMeta(*line); + return true; +} + +bool Dialogue::playPool(const std::string &poolId, ssq::Object params) { + std::unordered_map p; + objectToVarParams(vm_, params, p); + return playPoolWithParams(poolId, p); +} + +bool Dialogue::playPoolWithParams(const std::string &poolId, + const std::unordered_map ¶ms) { + const std::string lineId = pickLineWithParams(poolId, params); + return !lineId.empty() && playLineWithParams(lineId, params); +} + +std::string Dialogue::getCurrentLineMeta(const std::string &field) const { + const auto it = currentLineMeta_.find(field); + return it == currentLineMeta_.end() ? std::string{} : it->second; +} + +std::vector Dialogue::getCurrentLineTags() const { return currentLineTags_; } + +void Dialogue::applyLineMeta(const Line &line) { + if (line.speaker.empty()) return; + const auto expr = line.meta.find("expression"); + const auto motion = line.meta.find("motion"); + if (expr == line.meta.end() && motion == line.meta.end()) return; + Character *c = findCharacter(line.speaker); + if (!c || !c->avatar) return; + if (expr != line.meta.end()) c->avatar->setExpression(expr->second); + if (motion != line.meta.end()) c->avatar->setMotion(motion->second); +} + +// --------------------------------------------------------------------------- +// Scene-scoped variables: clear when the selected Scene host changes +// --------------------------------------------------------------------------- + +void Dialogue::pollSceneChange() { + auto *scn = eve::ModuleManager::getInstance("Scene"); + if (!scn) return; + eve::scene::SceneHost *host = scn->current(); + const std::string name = host ? host->getName() : ""; + if (!name.empty() && name != lastSceneName_) sceneVars_.clear(); + if (!name.empty()) lastSceneName_ = name; +} + Dialogue::Character *Dialogue::findCharacter(const std::string &id) { for (auto &c : characters_) if (c.id == id) return &c; @@ -297,6 +1258,7 @@ void Dialogue::update(float dt) { } } updateLipSync(dt); + pollSceneChange(); } void Dialogue::reset() { @@ -308,6 +1270,9 @@ void Dialogue::reset() { selectedChoiceId_.clear(); lipSyncValue_ = 0.f; lipSyncTime_ = 0.f; + currentLineId_.clear(); + currentLineMeta_.clear(); + currentLineTags_.clear(); for (Character &c : characters_) { c.shown = false; if (c.avatar) c.avatar->setVisible(false); @@ -315,6 +1280,7 @@ void Dialogue::reset() { } void Dialogue::expose(ssq::Table &table) { + if (Dialogue *self = Dialogue::create()) self->vm_ = table.getHandle(); auto cls = table.addClass(name, Dialogue::create, false); expose(cls); } @@ -372,6 +1338,39 @@ void Dialogue::expose(ssq::Class &cls) { cls.addFunc("selectChoice", &Dialogue::selectChoice); cls.addFunc("getSelectedChoiceId", &Dialogue::getSelectedChoiceId); + cls.addFunc("setVar", &Dialogue::setVar); + cls.addFunc("getVarType", &Dialogue::getVarType); + cls.addFunc("getVarInt", &Dialogue::getVarInt); + cls.addFunc("getVarFloat", &Dialogue::getVarFloat); + cls.addFunc("getVarBool", &Dialogue::getVarBool); + cls.addFunc("getVarString", &Dialogue::getVarString); + cls.addFunc("hasVar", &Dialogue::hasVar); + cls.addFunc("clearVar", &Dialogue::clearVar); + cls.addFunc("clearVars", &Dialogue::clearVars); + + cls.addFunc("registerCondition", &Dialogue::registerCondition); + cls.addFunc("unregisterCondition", &Dialogue::unregisterCondition); + cls.addFunc("evalCondition", &Dialogue::evalCondition); + + cls.addFunc("loadPoolsFromTable", &Dialogue::loadPoolsFromTable); + cls.addFunc("loadPoolsFromDnut", &Dialogue::loadPoolsFromDnut); + cls.addFunc("loadPoolsFromDnutFile", &Dialogue::loadPoolsFromDnutFile); + cls.addFunc("clearPools", &Dialogue::clearPools); + cls.addFunc("getPoolCount", &Dialogue::getPoolCount); + cls.addFunc("getPoolId", &Dialogue::getPoolId); + cls.addFunc("hasPool", &Dialogue::hasPool); + cls.addFunc("getLastPoolsError", &Dialogue::getLastPoolsError); + + cls.addFunc("setRandomSeed", &Dialogue::setRandomSeed); + cls.addFunc("getRandomSeed", &Dialogue::getRandomSeed); + + cls.addFunc("pickLine", &Dialogue::pickLine); + cls.addFunc("playLine", &Dialogue::playLine); + cls.addFunc("playPool", &Dialogue::playPool); + cls.addFunc("getCurrentLineId", &Dialogue::getCurrentLineId); + cls.addFunc("getCurrentLineMeta", &Dialogue::getCurrentLineMeta); + cls.addFunc("getCurrentLineTags", &Dialogue::getCurrentLineTags); + cls.addFunc("update", &Dialogue::update); cls.addFunc("reset", &Dialogue::reset); } diff --git a/src/modules/dialogue/Dialogue.h b/src/modules/dialogue/Dialogue.h index 435289e2..a452a549 100644 --- a/src/modules/dialogue/Dialogue.h +++ b/src/modules/dialogue/Dialogue.h @@ -3,12 +3,81 @@ #include "avatar/AvatarInstance.h" #include "common/Module.h" +#include + +#include #include #include +#include #include +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 arr; + std::vector> 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 v) { + DataValue d; + d.kind = Kind::Array; + d.arr = std::move(v); + return d; + } + static DataValue object(std::vector> 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 ¶ms); + bool playLine(const std::string &lineId, ssq::Object params); + bool playLineWithParams(const std::string &lineId, + const std::unordered_map ¶ms); + bool playPool(const std::string &poolId, ssq::Object params); + bool playPoolWithParams(const std::string &poolId, + const std::unordered_map ¶ms); + std::string getCurrentLineId() const { return currentLineId_; } + std::string getCurrentLineMeta(const std::string &field) const; + std::vector 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 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 meta; + std::vector tags; + }; + + struct Pool { + std::string id; + int noRepeat = 3; + std::vector lines; + std::vector recent; + }; + + std::unordered_map *varsForScope(const std::string &scope); + const std::unordered_map *varsForScope(const std::string &scope) const; + std::unordered_map mergedVars( + const std::unordered_map ¶ms) const; + std::unordered_map stringParams( + const std::unordered_map &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 &merged, + const std::unordered_map ¶ms, + const std::string &lineId) const; + bool evalScriptPredicate(const std::string &name, + const std::unordered_map &merged, + const std::unordered_map ¶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 &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 globalVars_; + std::unordered_map sceneVars_; + std::unordered_map predicates_; + std::vector pools_; + std::string lastPoolsError_; + uint32_t rngState_ = 0x9E3779B9u; + std::string lastSceneName_; + std::string currentLineId_; + std::unordered_map currentLineMeta_; + std::vector currentLineTags_; + HSQUIRRELVM vm_ = nullptr; }; } // namespace eve::dialogue diff --git a/src/modules/dialogue/DnutParser.cpp b/src/modules/dialogue/DnutParser.cpp new file mode 100644 index 00000000..3b2580dc --- /dev/null +++ b/src/modules/dialogue/DnutParser.cpp @@ -0,0 +1,475 @@ +#include "dialogue/DnutParser.h" + +#include +#include +#include +#include +#include +#include +#include + +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 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(c)) || + (c == '.' && i + 1 < n && std::isdigit(static_cast(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(c)) || c == '_') { + const size_t start = i; + while (i < n) { + const char d = src[i]; + if (std::isalnum(static_cast(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> &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> 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 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> fields; + fields.emplace_back("speaker", DataValue::string(speaker)); + fields.emplace_back("text", DataValue::string(text)); + if (inheritWhen) fields.emplace_back("when", *inheritWhen); + std::vector> 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> &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 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> 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> 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 diff --git a/src/modules/dialogue/DnutParser.h b/src/modules/dialogue/DnutParser.h new file mode 100644 index 00000000..86035902 --- /dev/null +++ b/src/modules/dialogue/DnutParser.h @@ -0,0 +1,19 @@ +#pragma once + +#include "dialogue/Dialogue.h" + +#include + +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 diff --git a/src/modules/sceneloader/SceneLoader.cpp b/src/modules/sceneloader/SceneLoader.cpp index 29db8302..a206a475 100644 --- a/src/modules/sceneloader/SceneLoader.cpp +++ b/src/modules/sceneloader/SceneLoader.cpp @@ -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)}; diff --git a/src/scripts/scripts.h b/src/scripts/scripts.h index 2fc7944d..05337351 100644 --- a/src/scripts/scripts.h +++ b/src/scripts/scripts.h @@ -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; -} \ No newline at end of file +} diff --git a/src/scripts/template.cpp.in b/src/scripts/template.cpp.in index bf0a728f..cc1b5d17 100644 --- a/src/scripts/template.cpp.in +++ b/src/scripts/template.cpp.in @@ -17,4 +17,4 @@ const char* vertex_content = R"(@vertex_content@)"; const char* fragment_content = R"(@fragment_content@)"; -} \ No newline at end of file +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 823e021e..d60e70a9 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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 diff --git a/test/dialogue_proc.cpp b/test/dialogue_proc.cpp new file mode 100644 index 00000000..4659492f --- /dev/null +++ b/test/dialogue_proc.cpp @@ -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 +#include +#include +#include + +using namespace eve::dialogue; +using eve::avatar::Avatar; +using eve::avatar::AvatarInstance; +using eve::i18n::I18n; + +namespace { + +using Vars = std::unordered_map; + +DataValue O(std::vector> v) { + return DataValue::object(std::move(v)); +} + +DataValue A(std::vector 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); +} diff --git a/test/dialogue_proc_script.cpp b/test/dialogue_proc_script.cpp new file mode 100644 index 00000000..05b8d0e5 --- /dev/null +++ b/test/dialogue_proc_script.cpp @@ -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()); +} diff --git a/test/dialogue_proc_script.nut b/test/dialogue_proc_script.nut new file mode 100644 index 00000000..dd8ad918 --- /dev/null +++ b/test/dialogue_proc_script.nut @@ -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; +} diff --git a/test/template.cpp.in b/test/template.cpp.in index c4a2fe03..1eb56527 100644 --- a/test/template.cpp.in +++ b/test/template.cpp.in @@ -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";