Cocos Creator 3.x 高维护性打字机对话系统设计与实现
在 Cocos Creator 项目中对话系统是 RPG、冒险、视觉小说等类型游戏的核心功能之一。如何设计一个维护性高、可扩展、策划友好、支持存档的打字机Typewriter系统是许多开发者面临的挑战。该系统采用组件化 配置化 JSON 数据驱动 事件解耦 状态机 存档集成的设计理念代码清晰、参数集中、业务与逻辑分离极大降低了后期维护和迭代成本。1. 系统整体架构系统由以下核心模块组成每个模块职责单一便于独立维护和测试模块名称主要职责设计优势TypewriterComponent核心打字逻辑逐字符显示、计时、跳过独立、可复用、事件驱动TypewriterConfig打字参数配置速度、音效、标签等数据与逻辑分离支持 JSON 热更新DialogueEntry单条对话数据结构支持富文本、头像、分支等DialogueManager对话序列管理、UI 更新、存档集成业务层核心JSON 驱动解耦彻底SaveData存档数据结构轻量、稳定支持多槽位推荐节点层级DialoguePanel根节点PortraitSprite—— 角色头像SpeakerNameLabel—— 说话者名字DialogueTextLabel 或 RichText—— 挂载 TypewriterComponentDialogueManager脚本挂载2. TypewriterConfig配置类// 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?: PartialTypewriterConfig) { 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、自动开始、跳过、每字符/完成事件。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对话条目数据// 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、自动保存进度。// 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[{id:d001,speaker:村长,text:勇者你终于醒了[pause500] 魔王又在作乱了,portrait:portraits/village-chief,config:{speed:0.04,canSkip:true},autoNext:false}]7. 使用与集成示例在场景控制器中start() { this.dialogueManager.loadDialoguesFromJSON(dialogues/main); const loaded this.dialogueManager.loadProgress(1); // 启动时读档 if (!loaded) { // 从头开始 } } onClickContinue() { this.dialogueManager.nextDialogue(); } onClickSave() { this.dialogueManager.saveProgress(1); }