在 Cocos Creator 项目中,对话系统是 RPG、冒险、视觉小说等类型游戏的核心功能之一。如何设计一个维护性高、可扩展、策划友好、支持存档的打字机(Typewriter)系统,是许多开发者面临的挑战。
该系统采用组件化 + 配置化 + JSON 数据驱动 + 事件解耦 + 状态机 + 存档集成的设计理念,代码清晰、参数集中、业务与逻辑分离,极大降低了后期维护和迭代成本。
1. 系统整体架构
系统由以下核心模块组成,每个模块职责单一,便于独立维护和测试:
| 模块名称 | 主要职责 | 设计优势 |
|---|---|---|
| TypewriterComponent | 核心打字逻辑(逐字符显示、计时、跳过) | 独立、可复用、事件驱动 |
| TypewriterConfig | 打字参数配置(速度、音效、标签等) | 数据与逻辑分离,支持 JSON 热更新 |
| DialogueEntry | 单条对话数据结构 | 支持富文本、头像、分支等 |
| DialogueManager | 对话序列管理、UI 更新、存档集成 | 业务层核心,JSON 驱动,解耦彻底 |
| SaveData | 存档数据结构 | 轻量、稳定,支持多槽位 |
推荐节点层级:
- DialoguePanel(根节点)
- Portrait(Sprite)------ 角色头像
- SpeakerName(Label)------ 说话者名字
- DialogueText(Label 或 RichText)------ 挂载 TypewriterComponent
- DialogueManager(脚本挂载)
2. TypewriterConfig(配置类)
tsx
// TypewriterConfig.ts
import { _decorator, CCString, CCFloat, CCBoolean, CCInteger } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('TypewriterConfig')
export class TypewriterConfig {
@property(CCFloat) public speed: number = 0.05;
@property(CCBoolean) public canSkip: boolean = true;
@property(CCBoolean) public autoNext: boolean = false;
@property(CCString) public typeSound: string = '';
@property(CCString) public completeSound: string = '';
@property(CCInteger) public defaultPauseMs: number = 300;
@property(CCBoolean) public enableRichParse: boolean = true;
constructor(data?: Partial<TypewriterConfig>) {
if (data) Object.assign(this, data);
}
public static fromJSON(json: any): TypewriterConfig {
return new TypewriterConfig(json);
}
public toJSON(): any { /* ... */ }
public clone(): TypewriterConfig { /* ... */ }
}
3. TypewriterComponent(核心打字组件)
核心逻辑封装在此组件中,支持 Label/RichText、自动开始、跳过、每字符/完成事件。
tsx
import { _decorator, Component, Label, RichText, CCString, CCFloat, CCBoolean, EventHandler } from 'cc';
const { ccclass, property, menu } = _decorator;
@ccclass('TypewriterComponent')
@menu('UI/TypewriterComponent') // 在组件菜单中一键添加
export class TypewriterComponent extends Component {
@property(Label)
public label: Label | null = null;
@property(RichText)
public richText: RichText | null = null;
@property(CCString)
public fullText: string = '';
@property(CCFloat)
public speed: number = 0.05; // 秒/字符
@property(CCBoolean)
public autoStart: boolean = true;
@property(CCBoolean)
public canSkip: boolean = true;
@property(EventHandler)
public onCharacterTyped: EventHandler = new EventHandler();
@property(EventHandler)
public onComplete: EventHandler = new EventHandler();
private _currentIndex: number = 0;
private _isTyping: boolean = false;
private _timer: number = 0;
private _currentText: string = '';
onLoad() {
if (!this.label && !this.richText) {
console.warn('【TypewriterComponent】必须绑定 Label 或 RichText 组件!');
}
}
start() {
if (this.autoStart && this.fullText) {
this.startTyping();
}
}
/** 开始打字(支持外部传入新文本) */
public startTyping(text?: string): void {
if (text !== undefined) this.fullText = text;
this._currentIndex = 0;
this._currentText = '';
this._isTyping = true;
this._timer = 0;
this.updateDisplay();
this.node.emit('typing-start');
}
/** 玩家点击跳过 */
public skipTyping(): void {
if (!this.canSkip || !this._isTyping) return;
this._currentIndex = this.fullText.length;
this._currentText = this.fullText;
this.updateDisplay();
this._isTyping = false;
this.node.emit('typing-complete');
EventHandler.emitEvents(this.onComplete);
}
update(dt: number) {
if (!this._isTyping) return;
this._timer += dt;
if (this._timer >= this.speed) {
this._timer -= this.speed; // 支持掉帧补偿
this.typeNextCharacter();
}
}
private typeNextCharacter(): void {
if (this._currentIndex < this.fullText.length) {
const nextChar = this.fullText[this._currentIndex];
this._currentText += nextChar;
this._currentIndex++;
this.updateDisplay();
// 每字符事件(可播放打字音效)
this.node.emit('character-typed', nextChar);
EventHandler.emitEvents(this.onCharacterTyped, nextChar);
} else {
this._isTyping = false;
this.node.emit('typing-complete');
EventHandler.emitEvents(this.onComplete);
}
}
private updateDisplay(): void {
if (this.label) this.label.string = this._currentText;
if (this.richText) this.richText.string = this._currentText; // 基础版支持标签
}
}
富文本高级扩展提示(维护性高):
若 fullText 包含 <color=#ff0000>红色文字</color>,基础 append 即可工作。若需更精确(不打断标签),可在 parseTokens 中将文本拆成"可见字符 + 标签"队列,逐个处理可见字符即可。
4. DialogueEntry(对话条目数据)
tsx
// DialogueEntry.ts
@ccclass('DialogueEntry')
export class DialogueEntry {
@property(CCString) public id: string = '';
@property(CCString) public speaker: string = '';
@property(CCString) public text: string = '';
@property(CCString) public portrait: string = '';
@property(TypewriterConfig) public config: TypewriterConfig = new TypewriterConfig();
@property(CCBoolean) public autoNext: boolean = false;
@property(CCString) public nextId: string = '';
public static fromJSON(json: any): DialogueEntry { /* ... */ }
}
5. DialogueManager(对话管理器 + 存档集成)
这是系统的业务核心,负责加载 JSON 对话表、切换对话、更新 UI、自动保存进度。
tsx
// DialogueManager.ts (关键存档部分已集成)
import { sys } from 'cc';
import { SaveData } from './SaveData';
@ccclass('DialogueManager')
export class DialogueManager extends Component {
// ... 属性绑定(typewriter、portraitSprite、speakerLabel 等)
private _dialogues: DialogueEntry[] = [];
private _currentIndex: number = -1;
// 加载对话 JSON
public loadDialoguesFromJSON(jsonPath: string = 'dialogues/main') { /* ... */ }
public startDialogue(index: number) { /* 更新 UI、应用 config、开始打字 */ }
public nextDialogue() { /* ... */ }
// ====================== 存档系统 ======================
private getSaveKey(slot: number = 1): string {
return `dialogue_save_${this.defaultDialogueGroup}_slot${slot}`;
}
public saveProgress(slot: number = 1): void {
if (this._currentIndex < 0) return;
const entry = this._dialogues[this._currentIndex];
const saveData = new SaveData(this.defaultDialogueGroup);
saveData.currentDialogueId = entry.id;
saveData.currentIndex = this._currentIndex;
sys.localStorage.setItem(this.getSaveKey(slot), JSON.stringify(saveData.toJSON()));
console.log(`存档成功:槽位 ${slot},对话ID ${entry.id}`);
}
public loadProgress(slot: number = 1): boolean {
const jsonStr = sys.localStorage.getItem(this.getSaveKey(slot));
if (!jsonStr) return false;
try {
const saveData = SaveData.fromJSON(JSON.parse(jsonStr));
let index = this._dialogues.findIndex(d => d.id === saveData.currentDialogueId);
if (index === -1) index = saveData.currentIndex;
if (index >= 0) {
this.startDialogue(index);
return true;
}
} catch (e) { console.error('读档失败', e); }
return false;
}
public deleteSave(slot: number = 1) { sys.localStorage.removeItem(this.getSaveKey(slot)); }
// 打字完成时自动保存
private _onTypingComplete() {
this.saveProgress(); // 关键:自动保存进度
// ... 处理 autoNext 等
}
}
SaveData.ts (轻量存档结构):
仅保存 dialogueGroup、currentDialogueId、currentIndex、timestamp 和 extraData,避免存储大量冗余文本。
6. JSON 数据驱动示例(dialogues/main.json)
json
[
{
"id": "d001",
"speaker": "村长",
"text": "勇者,你终于醒了![pause=500] 魔王又在作乱了!",
"portrait": "portraits/village-chief",
"config": { "speed": 0.04, "canSkip": true },
"autoNext": false
}
]
7. 使用与集成示例
在场景控制器中:
tsx
start() {
this.dialogueManager.loadDialoguesFromJSON('dialogues/main');
const loaded = this.dialogueManager.loadProgress(1); // 启动时读档
if (!loaded) {
// 从头开始
}
}
onClickContinue() {
this.dialogueManager.nextDialogue();
}
onClickSave() {
this.dialogueManager.saveProgress(1);
}