NarraLeaf

对话框

对话框的组件、头像解析顺序、动画、换行与文字自适应缩放规则

Dialog 渲染对话框:角色名牌、对话正文,以及可选的头像

用自定义组件替换内置对话框,通过 game.configure({ dialog }) 传入。在组件内组合 AvatarNametagTexts,或用 useDialog 读取当前台词

示例

  1. 导入组件
import { Avatar, Dialog, Nametag, Texts, useGame } from "narraleaf-react";
  1. 组合 ADV 布局
function GameDialog() {
    return (
        <Dialog className="bg-white">
            <div className="dialog-content flex items-start gap-4 w-full h-full">
                <Avatar />
                <div className="dialog-text-content min-w-0 flex-1">
                    <Nametag className="font-bold" />
                    <Texts className="text-lg" />
                </div>
            </div>
        </Dialog>
    );
}

Avatar 是可选的。内置对话框使用这套布局

  1. 在游戏中注册
function App() {
    const game = useGame();

    useEffect(() => {
        game.configure({
            dialog: GameDialog,
        });
    }, []);

    return /* ... */
}

组件

Dialog

渲染对话框容器。子节点通常是 AvatarNametagTexts,布局自定

  • children?: React.ReactNode - 对话框内容
  • ...props: HTMLMotionProps<"div"> - 传给内部的 motion.div,接受普通 div props 以及 initialanimateexittransitionlayout 等 Motion props 和 Motion 事件处理器

视觉 transform 写在 Dialog 上,播放器会把它们应用到内部的 motion 元素

Nametag

渲染说话人名字,需放在 Dialog 内。未传 childrenname 时显示当前说话人,未传 color 时使用当前角色颜色

  • character?: Character | null - 取名字的角色
  • entry?: NvlDialogEntry - 取名字的 NVL 条目
  • name?: React.ReactNode - 要显示的名字
  • color?: Color - 文字颜色
  • children?: React.ReactNode - 自定义名牌内容
  • ...props: Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "color">

Texts

渲染逐字显示的对话正文,需放在 Dialog 内。这些 props 设定默认外观,未设定的属性从 CSS 继承,SentenceWord 上的样式覆盖两者

  • children?: never
  • defaultColor?: Color - 句子和词都未设颜色时使用的文字颜色
  • fontSize?: React.CSSProperties["fontSize"]
  • fontWeight?: React.CSSProperties["fontWeight"]
  • fontWeightBold?: React.CSSProperties["fontWeight"] - 句子或词为粗体时的字重
  • fontFamily?: React.CSSProperties["fontFamily"]
  • writingMode?: TextWritingMode - 文本框的排版流向。vertical-rl 就是日文小说的经典设定:字列从上往下读、列向左推进。默认 "horizontal-tb"。自 0.26.0
  • textOrientation?: TextGlyphOrientation - 字形在竖列里怎么站。mixed 让 CJK 立着、拉丁字母躺着。横排时忽略。默认 "mixed"。自 0.26.0
  • tateChuYoko?: TateChuYoko - 縦中横:把短的拉丁或数字串横着立在字列里,而不是让它躺着。true 合并不超过两个字符的串,传数字则自定上限。横排时忽略。默认 true。自 0.26.0
  • autoFit?: boolean - 在逐字显示过程中缩小字号,使整行留在它所在的盒子内。默认 true。自 0.34.0
  • autoFitMinFontSize?: number - 缩放能设到的最小字号,单位 px。默认 12。自 0.34.0
  • ...props: React.HTMLAttributes<HTMLDivElement>

Avatar

渲染当前台词解析出的头像;该行没有头像时不渲染任何节点

  • ...props: React.ImgHTMLAttributes<HTMLImageElement> - 与默认图片样式合并

默认图片样式:

  • width / height96
  • objectFit"cover"
  • borderRadius6
  • flex"0 0 auto"

TextsPreview

用与 Texts 相同的逐字机渲染一行文本,但与正在进行的对白脱钩。凡是需要在台词之外展示一段对白样例的地方都用它:设置页的文本速度那一行、字体预览、样式选择器

它读取 cpsgameSpeed 偏好,并在两者变化时重新播放——这正是它能给速度滑块做实时预览的原因。在 GameProviders 之外,它退回到 Game.DefaultPreferenceGame.DefaultConfig,因此在没有游戏在跑的页面上同样能渲染

import { TextsPreview, Word } from "narraleaf-react";

<TextsPreview
    text={["敏捷的棕色狐狸", new Word("跃过", { color: "#f00" }), "了懒狗。"]}
    fontSize={18}
/>

内容来源给一个就够。words 优先于 texttext 优先于 sentence

  • text?: TextsPreviewInput - 静态内容:一个 StaticWord<string | Pausing>,或它们组成的数组
  • sentence?: Sentence - 要预览的 Sentence。其中的动态词不会被求值,而是被丢弃;预览已经跑过的内容请改用 words。句子自身的颜色、字体与粗体/斜体标志则照常生效
  • words?: Word<Pausing | string | TextEvent>[] - 已经求值完毕的词
  • useTypeEffect?: boolean - 逐字打出而不是整行直接显示。默认 true
  • loop?: TextsPreviewLoop - false 表示只播一次;{enabled?: boolean, delay?: number} 可设定两轮之间的间隔(毫秒)。默认 true
  • restartDelay?: number - loop 为布尔值时,下一轮开始前的间隔(毫秒)。loop 为带 delay 的对象时忽略。缺省取 GameConfig.autoForwardDefaultPause
  • cps?: number - 每秒字数。缺省取 cps 偏好
  • gameSpeed?: number - 速度倍率。缺省取 gameSpeed 偏好
  • pauseDuration?: number - 未自带时长的 Pause 停多久(毫秒)。缺省取 GameConfig.autoForwardDefaultPause
  • defaultColor?: ColorfontSizefontWeightfontWeightBoldfontFamily - 与 Texts 上的同名 props 一致
  • writingMode?: TextWritingModetextOrientation?: TextGlyphOrientationtateChuYoko?: TateChuYoko - 与 Texts 上的同名 props 一致。自 0.26.0
  • onCompleted?: () => void - 每次一行播完时调用,因此循环播放的预览每轮都会调用一次
  • ...props: Omit<React.HTMLAttributes<HTMLDivElement>, "children">

预览没有副作用:内容里的 TextEvent 会被跳过而不触发,因此预览一行永远不会换立绘、也不会放音效。这里同样没有 autoFit——预览不在对话框内,没有可供适配的盒子

动画

Dialog 传入 Motion props。内置对话框没有进入和退出动画

import { Dialog, Nametag, Texts } from "narraleaf-react";

function GameDialog() {
    return (
        <Dialog
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -20 }}
            transition={{ duration: 0.25, ease: "easeOut" }}
            layout
        >
            <Nametag />
            <Texts />
        </Dialog>
    );
}

播放器在这些时机播放动画:

  • 一句对话被另一句替换:对话框保持不动,不播放 exit,也不会因为正文或名字变化而重播进入动画
  • 对话之后是非对话内容:exit 与剧情推进同时进行
  • 上一次 exit 尚未结束又出现新对话:两段动画同时播放
  • 在对话框中显示的菜单 prompt:选项出现前采用同样的行为

正在退出的对话框忽略点击、跳过和自动播放

GameConfig.animationPropagate 作用于对话框的 AnimatePresence 边界。只有当自定义对话框内部嵌套了需要播放退出动画的 AnimatePresence 时,才设为 true

头像

Avatar 为当前说话的角色显示小头像。头像可以配置在角色上、绑定到角色的舞台立绘上,或单独某一行上

每一行按以下顺序解析头像:

  1. 旁白或无名角色:无头像
  2. 句子配置中的 avatar: false:无头像
  3. 句子级头像
  4. 绑定到该角色、最近显示且仍可见的立绘的头像
  5. 角色级头像
  6. 无头像

引擎不会把全身立绘裁切成头像。没有配置头像的角色不显示头像

角色级头像

台上台词与旁白使用同一张图

import { Character } from "narraleaf-react";

const alice = new Character("Alice", {
    avatar: "/assets/alice/avatar-default.png",
});

alice.say("画外音时也可以使用角色级头像。");

方法写法:

const alice = new Character("Alice")
    .setAvatar("/assets/alice/avatar-default.png");

alice.say("本句使用默认头像。");

舞台立绘

把舞台 Image 绑定到角色,由可见立绘决定头像

import { Character, Image } from "narraleaf-react";

const aliceBody = new Image({
    name: "alice-body",
    src: "/assets/alice/body-normal.png",
    opacity: 1,
});

const alice = new Character("Alice", {
    avatar: "/assets/alice/avatar-default.png",
    portraits: [
        {
            image: aliceBody,
            avatar: "/assets/alice/avatar-normal.png",
        },
    ],
});

aliceBody 在当前场景可见时使用立绘头像,否则使用角色级头像

const alice = new Character("Alice")
    .setAvatar("/assets/alice/avatar-default.png")
    .addPortrait(aliceBody, {
        avatar: "/assets/alice/avatar-normal.png",
    });

按表情区分头像

立绘头像可以是一个函数,入参为当前立绘、currentSrctagscharactersentencegameState,返回值为:

  • 图片 URL 或 StaticImageData
  • null,不显示头像;
  • undefined,继续按解析顺序进入下一步
const aliceBody = new Image({
    name: "alice-body",
    src: {
        groups: [
            ["normal", "happy", "angry"],
            ["school", "casual"],
        ],
        defaults: ["normal", "school"],
        resolve: (emotion, outfit) => `/assets/alice/body-${emotion}-${outfit}.png`,
    },
    opacity: 1,
});

const alice = new Character("Alice", {
    avatar: "/assets/alice/avatar-default.png",
    portraits: [
        {
            image: aliceBody,
            avatar: ({ tags }) => {
                const emotion = tags?.[0] ?? "normal";
                return `/assets/alice/avatar-${emotion}.png`;
            },
        },
    ],
});

单行覆盖

alice.say("仅本行隐藏头像。", {
    avatar: false,
});

alice.say("本行使用特殊切图。", {
    avatar: "/assets/alice/avatar-special.png",
});

alice.say("本行使用解析函数。", {
    avatar: ({ tags, currentSrc }) => {
        if (tags?.includes("angry")) {
            return "/assets/alice/avatar-angry-close.png";
        }
        return undefined;
    },
});

avatar: false 隐藏该行头像,其他值优先于立绘头像和角色级头像

多个立绘同时可见

同一角色有多个绑定立绘可见时,引擎按场景的显示顺序(从后到前)取最近显示的那一个。opacity 等效为 0 的显示对象会被忽略

依赖头像的布局

useAvatar() 返回 visiblesrccharacterportrait,可据此让网格或弹性布局在没有头像时改变。返回值结构与示例见 useAvatar

外观

容器样式写在 Dialog 上:

function GameDialog() {
    return (
        <Dialog
            style={{
                backgroundColor: "rgba(0, 0, 0, 0.5)",
                borderRadius: "10px",
                padding: "20px",
            }}
        >
            {/* ... */}
        </Dialog>
    );
}

名牌样式写在 Nametag 上:

function GameDialog() {
    return (
        <Dialog>
            <Nametag
                style={{
                    backgroundImage: "url('/path/to/image.png')",
                    backgroundSize: "cover",
                    width: "100%",
                    height: "100%",
                }}
            />
        </Dialog>
    );
}

断行

对话正文在横排与竖排下都按严格禁则排版。行首不会出现 、 。 」 ? !、小写假名与长音符,行尾不会出现 「 (。拉丁词不会被拆开,在任何宽度下都放不下的连续串(例如网址)会断开而不是溢出

严格禁则只在文档声明了语言时生效。在承载播放器的页面上设置 lang,取任意值均可;NarraLeaf Studio 构建出的外壳已经带有该属性

文本缩放

0.35.0 起可用

对白行在逐字显示的过程中缩小,以保持整行留在盒子内。起始字号为 fontSize 并在放得下时一直保持,因此短行按原字号显示。文本抵达盒子末端后,其后每多一个字都会重新测量,并按需要缩小到整行仍能容纳,最小到 autoFitMinFontSize。在最小字号下仍然溢出的行保持溢出

缩放默认开启。单行关闭用 autoFit={false},整个游戏关闭用 GameConfig.disableTextScaling

<Texts fontSize={24} autoFitMinFontSize={16} />

SentenceWord 上设定的字号随行一并缩放而不是被替换,相对大小关系在任何字号下都成立

盒子是容器的父元素。父元素自身没有高度时,行保持作者设定的字号

叠层

ADV 对话框自带一个叠层,它覆盖对话框并画在其上,且处于同一个缩放后的舞台内。凡是属于某一行、又放不进文本框的东西都放在那里:行内词的释义浮层、名字上的提示框。用 useDialogOverlay 取得它

叠层是内置 Dialog 组件的一部分,因此由 Dialog 组合而成的自定义对话框同样具备它。NVL 模式没有叠层

导出的类型

narraleaf-react 导出:

  • 组件 props:NametagPropsTextAppearancePropsTextsPropsRawTextsPropsEntryTextsPropsTextsPreviewPropsTextsPreviewInputTextsPreviewLoop
  • 头像来源:DialogAvatarSourceDialogAvatarResolverContextDialogAvatarResolverDialogAvatar
  • 立绘:CharacterPortraitConfigDialogAvatarResolution
  • React:AvataruseAvatar,以及类型 DialogAvatarContext

Character 提供 setAvataraddPortraitsetPortraits。参见 CharacterCharacterConfig,以及 SentenceUserConfigSentenceConfigavatar 字段

本页目录