Skip to content

14 · 自定义对话框模板 ​

引擎内置三个模板(bottom/center/fullscreen),但你可以创建完全自定义的对话框。本章讲解如何实现。

架构回顾 ​

灵泛引擎的对话框模板系统采用核心逻辑共享 + 容器控件自由的设计:

DialogEngine(纯逻辑)    ← 共享:打字机/内联标记/NVL/标签
    ↑ 委托
模板控件(独立 Control)  ← 自由:布局/外观/交互
  • DialogEngine——提取了打字机推进、内联标记解析、NVL 累积、{w}/{p}/{fast} 标签处理等纯逻辑
  • 模板控件——实现 IDialogBox 接口的独立 UserControl,自定义容器结构,委托 DialogEngine 处理核心逻辑

IDialogBox 接口 ​

csharp
public interface IDialogBox
{
    bool IsComplete { get; }              // 打字机是否已完成
    bool IsPausedByTag { get; }           // 是否被 {w}/{p} 暂停
    void SetText(string text, string? speaker = null);  // 设置文本
    void Advance(double deltaSeconds);    // 每帧推进打字机
    void SkipToEnd();                     // 跳到末尾
    void Hide();                          // 隐藏
    void ResetNvlState();                 // 重置 NVL 状态
    Control AsControl();                  // 获取 Avalonia 控件
}

DialogEngine 核心 ​

DialogEngine 提供:

方法说明
SetText(string)设置文本(ADV),重置打字机
SetNvlText(string) → int设置 NVL 文本,返回旧文本长度
Advance(double) → string?推进打字机,返回 raw 文本
SkipToEnd() → string跳到末尾,返回完整文本
ResumeFromPause()解除 {w}/{p} 暂停
ResetNvlState()重置 NVL 状态
ApplyInlineMarkup(Inlines, raw)静态方法:解析内联标记

创建自定义模板 ​

1. 实现 IDialogBox ​

csharp
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Media;
using LingFanEngine.Abstractions;
using LingFanEngine.Abstractions.Interfaces.Core;
using LingFanEngine.Views;

public class MyDialogBox : UserControl, IDialogBox
{
    private readonly DialogEngine _engine;
    private readonly TextBlock _contentText;
    private readonly Border _root;
    private readonly IStateContainer _state;

    public bool IsComplete => _engine.IsComplete;
    public bool IsPausedByTag => _engine.IsPausedByTag;

    public MyDialogBox(IStateContainer state)
    {
        _state = state;
        _engine = new DialogEngine(state);

        // 完全自定义容器结构
        _contentText = new TextBlock
        {
            FontSize = 20,
            TextWrapping = TextWrapping.Wrap,
            Foreground = Brushes.White,
            Margin = new Thickness(20)
        };

        _root = new Border
        {
            Background = new SolidColorBrush(Color.FromArgb(180, 20, 20, 30)),
            CornerRadius = new CornerRadius(8),
            Child = _contentText,
            IsVisible = false
        };
        Content = _root;

        // 点击交互
        _root.PointerPressed += (_, _) =>
        {
            if (!_root.IsVisible) return;
            if (IsPausedByTag && !IsComplete) { _engine.ResumeFromPause(); return; }
            if (!IsComplete) { SkipToEnd(); }
            else
            {
                _state.Set(StateKeys.Dialog.Complete, true);
                _state.Set(StateKeys.Dialog.WaitingSayComplete, true);
            }
        };
    }

    public void SetText(string text, string? speaker = null)
    {
        _engine.SetText(text);
        DialogEngine.ApplyInlineMarkup(_contentText.Inlines!, "");
        _root.IsVisible = true;
    }

    public void Advance(double deltaSeconds)
    {
        if (!_root.IsVisible || IsComplete) return;
        var raw = _engine.Advance(deltaSeconds);
        if (raw != null)
            DialogEngine.ApplyInlineMarkup(_contentText.Inlines!, raw);
    }

    public void SkipToEnd()
    {
        var full = _engine.SkipToEnd();
        DialogEngine.ApplyInlineMarkup(_contentText.Inlines!, full);
    }

    public void Hide() => _root.IsVisible = false;
    public void ResetNvlState() => _engine.ResetNvlState();
    public Control AsControl() => this;
}

2. 实现工厂 ​

csharp
public class MyDialogBoxFactory : IDialogBoxFactory
{
    public IDialogBox Create(IStateContainer state) => new MyDialogBox(state);
}

3. 注册模板 ​

csharp
public static IServiceCollection AddDialogTemplates(this IServiceCollection services)
{
    services.AddSingleton(sp =>
    {
        var registry = sp.GetRequiredService<IDialogTemplateRegistry>();

        registry.Register("bottom", new DefaultDialogBoxFactory());
        registry.Register("center", new CenterBubbleDialogBoxFactory());
        registry.Register("fullscreen", new FullScreenNvlDialogBoxFactory());
        registry.Register("my_style", new MyDialogBoxFactory());  // ← 你的模板

        registry.SetDefault("bottom");
        return registry;
    });
    return services;
}

4. 在 DSL 中使用 ​

dsl
say "这句用我的自定义模板。" speaker="旁白" template="my_style"

点击交互模式 ​

模板的 PointerPressed 处理三种情况:

csharp
_root.PointerPressed += (_, _) =>
{
    if (!_root.IsVisible) return;

    // 1. {w}/{p} 暂停后点击 → 继续打字机(不跳到末尾)
    if (IsPausedByTag && !IsComplete)
    {
        _engine.ResumeFromPause();
        return;
    }

    // 2. 打字机未完成 → 跳到末尾
    if (!IsComplete)
    {
        SkipToEnd();
    }
    // 3. 已完成 → 标记完成,触发下一句
    else
    {
        _state.Set(StateKeys.Dialog.Complete, true);
        _state.Set(StateKeys.Dialog.WaitingSayComplete, true);
    }
};

ResumeFromPause vs SkipToEnd

  • {w}/{p} 暂停后点击 → ResumeFromPause()(继续逐字)
  • 打字机进行中点击 → SkipToEnd()(跳到末尾)

不要混淆!

NVL 支持 ​

如果模板要支持 NVL 模式,在 SetText 中检测:

csharp
public void SetText(string text, string? speaker = null)
{
    var nvlActive = _state.Get<bool>(StateKeys.Nvl.Active);
    if (nvlActive)
    {
        var oldLen = _engine.SetNvlText(text);
        if (oldLen > 0)
            DialogEngine.ApplyInlineMarkup(_contentText.Inlines!, text[..oldLen]);
        else
            DialogEngine.ApplyInlineMarkup(_contentText.Inlines!, "");
    }
    else
    {
        _engine.SetText(text);
        DialogEngine.ApplyInlineMarkup(_contentText.Inlines!, "");
    }
    _root.IsVisible = true;
}

自定义自由度 ​

模板控件的结构完全自由,不受预设参数限制:

可自定义示例
容器结构Border/Grid/StackPanel 任意布局
容器位置底部/中央/顶部/全屏
容器外观背景色/圆角/边框/阴影/边距
侧脸图位置(左/右/无)、宽度
说话者位置(上/下/内联/隐藏)、字号
文本字号、颜色、对齐、换行
点击交互推进/跳过/完成的指针事件
背景图背景图加载(可选)

参考实现 ​

引擎内置三个模板的源码可供参考:

模板源码位置
bottomsrc/EngineCore/LingFanEngine/Views/DialogBox.cs
centersrc/Demo/LingFanEngine.Entry/UI/Dialogs/CenterBubbleDialogBox.cs
fullscreensrc/Demo/LingFanEngine.Entry/UI/Dialogs/FullScreenNvlDialogBox.cs

动手练习 ​

创建一个带说话者头像的右侧对话框:

csharp
public class RightSideDialogBox : UserControl, IDialogBox
{
    private readonly DialogEngine _engine;
    private readonly TextBlock _speakerText;
    private readonly TextBlock _contentText;
    private readonly Border _root;
    private readonly IStateContainer _state;

    public bool IsComplete => _engine.IsComplete;
    public bool IsPausedByTag => _engine.IsPausedByTag;

    public RightSideDialogBox(IStateContainer state)
    {
        _state = state;
        _engine = new DialogEngine(state);

        _speakerText = new TextBlock
        {
            FontSize = 16, FontWeight = FontWeight.Bold,
            Foreground = new SolidColorBrush(Color.Parse("#FFD700")),
            Margin = new Thickness(10, 5)
        };
        _contentText = new TextBlock
        {
            FontSize = 18, TextWrapping = TextWrapping.Wrap,
            Margin = new Thickness(10)
        };

        var stack = new StackPanel();
        stack.Children.Add(_speakerText);
        stack.Children.Add(_contentText);

        _root = new Border
        {
            Background = new SolidColorBrush(Color.FromArgb(200, 0, 0, 0)),
            CornerRadius = new CornerRadius(12, 0, 0, 12),
            Child = stack,
            IsVisible = false,
            HorizontalAlignment = HorizontalAlignment.Right,
            VerticalAlignment = VerticalAlignment.Bottom,
            MaxWidth = 500
        };
        Content = _root;

        _root.PointerPressed += (_, _) =>
        {
            if (!_root.IsVisible) return;
            if (IsPausedByTag && !IsComplete) { _engine.ResumeFromPause(); return; }
            if (!IsComplete) SkipToEnd();
            else { _state.Set(StateKeys.Dialog.Complete, true); _state.Set(StateKeys.Dialog.WaitingSayComplete, true); }
        };
    }

    public void SetText(string text, string? speaker = null)
    {
        _engine.SetText(text);
        _speakerText.Text = speaker ?? "";
        _speakerText.IsVisible = !string.IsNullOrEmpty(speaker);
        DialogEngine.ApplyInlineMarkup(_contentText.Inlines!, "");
        _root.IsVisible = true;
    }

    public void Advance(double deltaSeconds)
    {
        if (!_root.IsVisible || IsComplete) return;
        var raw = _engine.Advance(deltaSeconds);
        if (raw != null) DialogEngine.ApplyInlineMarkup(_contentText.Inlines!, raw);
    }

    public void SkipToEnd()
    {
        var full = _engine.SkipToEnd();
        DialogEngine.ApplyInlineMarkup(_contentText.Inlines!, full);
    }

    public void Hide() => _root.IsVisible = false;
    public void ResetNvlState() => _engine.ResetNvlState();
    public Control AsControl() => this;
}

下一步 ​

自定义模板掌握了!下一章学习加密与发布——把游戏安全地发布出去。

基于 .NET 10 + Avalonia 12 · Apache-2.0 License