From 41e7ca05f106364eb168b518554397588c887043 Mon Sep 17 00:00:00 2001 From: openclaw_frontend_developer Date: Wed, 11 Mar 2026 04:54:43 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Cocos=20Creator=E6=81=8B?= =?UTF-8?q?=E7=88=B1=E6=B8=B8=E6=88=8F=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoryManager: 剧情管理器 - DialogueBox: 对话框组件(带打字机效果) - CharacterView: 立绘组件 - ChoiceButton: 选项按钮 - AffectionSystem: 好感度系统 - chapter1.json: 示例剧情 --- README.md | 148 ++++++++++++++ assets/resources/story/chapter1.json | 249 ++++++++++++++++++++++++ assets/scripts/AffectionSystem.ts | 259 +++++++++++++++++++++++++ assets/scripts/CharacterView.ts | 183 +++++++++++++++++ assets/scripts/ChoiceButton.ts | 136 +++++++++++++ assets/scripts/DialogueBox.ts | 177 +++++++++++++++++ assets/scripts/StoryManager.ts | 280 +++++++++++++++++++++++++++ project.json | 28 +++ 8 files changed, 1460 insertions(+) create mode 100644 README.md create mode 100644 assets/resources/story/chapter1.json create mode 100644 assets/scripts/AffectionSystem.ts create mode 100644 assets/scripts/CharacterView.ts create mode 100644 assets/scripts/ChoiceButton.ts create mode 100644 assets/scripts/DialogueBox.ts create mode 100644 assets/scripts/StoryManager.ts create mode 100644 project.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..c88f3ef --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Cocos Dating Game + +基于 Cocos Creator 3.x 的恋爱养成游戏引擎。 + +## 功能特性 + +- 🎭 剧情系统 - 支持分支对话和选项 +- ❤️ 好感度系统 - 角色好感度等级 +- 🎬 立绘切换 - 多种表情和位置 +- 💾 存档系统 - 本地存储进度 + +## 项目结构 + +``` +assets/ +├── scripts/ +│ ├── StoryManager.ts # 剧情管理器 +│ ├── DialogueBox.ts # 对话框组件 +│ ├── CharacterView.ts # 立绘组件 +│ ├── ChoiceButton.ts # 选项按钮 +│ └── AffectionSystem.ts # 好感度系统 +├── resources/ +│ ├── backgrounds/ # 背景图 +│ ├── characters/ # 角色立绘 +│ │ └── {角色ID}/ +│ │ ├── normal.png +│ │ ├── happy.png +│ │ ├── angry.png +│ │ └── smile.png +│ └── story/ +│ └── chapter1.json # 剧情脚本 +└── scenes/ + └── MainScene.ts # 主场景 +``` + +## 快速开始 + +### 1. 安装 Cocos Creator + +下载并安装 [Cocos Creator 3.x](https://www.cocos.com/creator) + +### 2. 创建项目 + +1. 打开 Cocos Creator +2. 选择 "新建项目" -> "空白项目" +3. 将本项目 assets 目录下的文件复制到新项目中 + +### 3. 添加资源 + +在 `resources` 目录下添加: +- 背景图 (1920x1080) +- 角色立绘 (按角色ID和表情命名) +- 音效/音乐 (可选) + +### 4. 运行项目 + +点击 Cocos Creator 中的 "运行" 按钮 + +## 剧情脚本格式 + +参考 `chapter1.json`: + +```json +{ + "title": "第一章", + "scenes": [ + { + "id": "scene_1", + "background": "bg.jpg", + "characters": [ + { + "id": "角色ID", + "name": "显示名称", + "emotion": "normal", + "position": "center", + "visible": true + } + ], + "dialogue": [ + { + "speaker": "说话者", + "text": "对话内容", + "emotion": "happy" + } + ], + "choices": [ + { + "text": "选项文本", + "nextScene": "下一场景ID", + "affectionChange": { "角色ID": 5 } + } + ] + } + ] +} +``` + +## API 文档 + +### StoryManager + +```typescript +// 加载章节 +StoryManager.instance.loadChapter('chapter1'); + +// 跳转到指定场景 +StoryManager.instance.goToScene('scene_id'); + +// 重新开始 +StoryManager.instance.restart(); +``` + +### AffectionSystem + +```typescript +// 获取好感度 +const value = AffectionSystem.instance.getAffection('角色ID'); + +// 检查等级 +const isLover = AffectionSystem.instance.hasReachedLevel( + '角色ID', + AffectionLevel.LOVER +); + +// 保存存档 +const saveData = AffectionSystem.instance.save(); + +// 加载存档 +AffectionSystem.instance.load(saveData); +``` + +## 角色立绘规格 + +| 位置 | 尺寸 | +|------|------| +| left/right | 400x600 px | +| center | 500x700 px | + +表情文件命名:`{emotion}.png` +例如:`normal.png`, `happy.png`, `angry.png`, `sad.png`, `surprised.png`, `smile.png` + +## 许可证 + +MIT License + +## 贡献 + +欢迎提交 Issue 和 Pull Request! diff --git a/assets/resources/story/chapter1.json b/assets/resources/story/chapter1.json new file mode 100644 index 0000000..4e8ed9b --- /dev/null +++ b/assets/resources/story/chapter1.json @@ -0,0 +1,249 @@ +{ + "title": "第一章 - 相遇", + "scenes": [ + { + "id": "scene_intro", + "background": "bg_park.jpg", + "characters": [], + "dialogue": [ + { + "text": "春天的阳光洒在公园的长椅上..." + }, + { + "text": "今天的天气真好啊,适合出来散步。" + } + ], + "nextScene": "scene_meet_li" + }, + { + "id": "scene_meet_li", + "background": "bg_park.jpg", + "characters": [ + { + "id": "li_ze_yan", + "name": "李泽言", + "emotion": "normal", + "position": "center", + "visible": true + } + ], + "dialogue": [ + { + "speaker": "李泽言", + "text": "你好,请问你是?", + "emotion": "normal" + }, + { + "speaker": "你", + "text": "啊...你好,我叫..." + }, + { + "speaker": "李泽言", + "text": "看起来你很喜欢这里的风景。", + "emotion": "smile" + } + ], + "choices": [ + { + "text": "是的,这里很美。", + "nextScene": "scene_li_option1", + "affectionChange": { + "li_ze_yan": 5 + } + }, + { + "text": "我在等人。", + "nextScene": "scene_li_option2", + "affectionChange": { + "li_ze_yan": 0 + } + }, + { + "text": "你经常来这里吗?", + "nextScene": "scene_li_option3", + "affectionChange": { + "li_ze_yan": 3 + } + } + ] + }, + { + "id": "scene_li_option1", + "background": "bg_park.jpg", + "characters": [ + { + "id": "li_ze_yan", + "name": "李泽言", + "emotion": "happy", + "position": "center", + "visible": true + } + ], + "dialogue": [ + { + "speaker": "李泽言", + "text": "确实,这个季节的花开得很漂亮。", + "emotion": "happy" + }, + { + "speaker": "李泽言", + "text": "如果你感兴趣,我知道一个更美的去处。", + "emotion": "smile" + } + ], + "nextScene": "scene_meet_xu" + }, + { + "id": "scene_li_option2", + "background": "bg_park.jpg", + "characters": [ + { + "id": "li_ze_yan", + "name": "李泽言", + "emotion": "surprised", + "position": "center", + "visible": true + } + ], + "dialogue": [ + { + "speaker": "李泽言", + "text": "等人吗...那我不打扰了。", + "emotion": "surprised" + } + ], + "nextScene": "scene_meet_xu" + }, + { + "id": "scene_li_option3", + "background": "bg_park.jpg", + "characters": [ + { + "id": "li_ze_yan", + "name": "李泽言", + "emotion": "normal", + "position": "center", + "visible": true + } + ], + "dialogue": [ + { + "speaker": "李泽言", + "text": "偶尔会来。工作之余放松一下。", + "emotion": "normal" + } + ], + "nextScene": "scene_meet_xu" + }, + { + "id": "scene_meet_xu", + "background": "bg_cafe.jpg", + "characters": [ + { + "id": "li_ze_yan", + "name": "李泽言", + "emotion": "normal", + "position": "left", + "visible": true + }, + { + "id": "xu_mo", + "name": "许墨", + "emotion": "smile", + "position": "right", + "visible": true + } + ], + "dialogue": [ + { + "text": "(画面切换到一家咖啡馆)" + }, + { + "speaker": "许墨", + "text": "你好,看来我们又见面了。", + "emotion": "smile" + }, + { + "speaker": "你", + "text": "你是?" + }, + { + "speaker": "许墨", + "text": "我叫许墨。很高兴认识你。", + "emotion": "normal" + } + ], + "choices": [ + { + "text": "你好,许墨。", + "nextScene": "scene_xu_option1", + "affectionChange": { + "xu_mo": 5 + } + }, + { + "text": "我们之前见过吗?", + "nextScene": "scene_xu_option2", + "affectionChange": { + "xu_mo": 3 + } + } + ] + }, + { + "id": "scene_xu_option1", + "background": "bg_cafe.jpg", + "characters": [ + { + "id": "xu_mo", + "name": "许墨", + "emotion": "happy", + "position": "right", + "visible": true + } + ], + "dialogue": [ + { + "speaker": "许墨", + "text": "我也一样。这家咖啡不错,要一起吗?", + "emotion": "happy" + } + ], + "nextScene": "scene_ending" + }, + { + "id": "scene_xu_option2", + "background": "bg_cafe.jpg", + "characters": [ + { + "id": "xu_mo", + "name": "许墨", + "emotion": "smile", + "position": "right", + "visible": true + } + ], + "dialogue": [ + { + "speaker": "许墨", + "text": "也许在某个地方见过吧。缘分是很奇妙的东西。", + "emotion": "smile" + } + ], + "nextScene": "scene_ending" + }, + { + "id": "scene_ending", + "background": "bg_cafe.jpg", + "characters": [], + "dialogue": [ + { + "text": "(今天遇到了两个特别的人...)" + }, + { + "text": "(第一章完)" + } + ] + } + ] +} diff --git a/assets/scripts/AffectionSystem.ts b/assets/scripts/AffectionSystem.ts new file mode 100644 index 0000000..a4a08b2 --- /dev/null +++ b/assets/scripts/AffectionSystem.ts @@ -0,0 +1,259 @@ +import { _decorator, Component, Label, Node } from 'cc'; + +const { ccclass, property } = _decorator; + +// 好感度等级 +export enum AffectionLevel { + STRANGER = '陌生人', // 0-20 + ACQUAINTANCE = '认识的人', // 21-40 + FRIEND = '朋友', // 41-60 + CLOSE = '恋人未满', // 61-80 + LOVER = '恋人' // 81-100 +} + +// 角色好感度数据 +interface CharacterAffection { + value: number; + level: AffectionLevel; + events: string[]; // 触发过的事件 +} + +// 好感度变化事件 +interface AffectionEvent { + characterId: string; + change: number; + reason: string; + timestamp: number; +} + +@ccclass('AffectionSystem') +export class AffectionSystem extends Component { + @property(Node) + affectionDisplay: Node; + + // 角色好感度 + private affection: Map = new Map(); + + // 好感度变化历史 + private history: AffectionEvent[] = []; + + // 最大好感度 + private maxAffection: number = 100; + + onLoad() { + // 初始化角色好感度 + this.initCharacters(['li_ze_yan', 'xu_mo', 'zhou_qi_luo', 'bai_qi']); + } + + /** + * 初始化角色 + */ + initCharacters(characterIds: string[]) { + characterIds.forEach(id => { + this.affection.set(id, { + value: 0, + level: AffectionLevel.STRANGER, + events: [] + }); + }); + } + + /** + * 更改好感度 + */ + changeAffection(changes: { [key: string]: number }, reason: string = '选项') { + for (const [characterId, change] of Object.entries(changes)) { + const current = this.affection.get(characterId); + if (!current) { + console.warn(`角色不存在: ${characterId}`); + continue; + } + + // 计算新值 + const oldValue = current.value; + const newValue = Math.max(0, Math.min(this.maxAffection, current.value + change)); + + // 更新数据 + current.value = newValue; + current.level = this.calculateLevel(newValue); + + // 记录事件 + current.events.push(reason); + + // 记录历史 + this.history.push({ + characterId, + change, + reason, + timestamp: Date.now() + }); + + console.log(`好感度变化: ${characterId} ${oldValue} -> ${newValue} (${change > 0 ? '+' : ''}${change})`); + + // 显示提示 + this.showAffectionChange(characterId, change); + } + + // 更新显示 + this.updateDisplay(); + } + + /** + * 计算好感度等级 + */ + private calculateLevel(value: number): AffectionLevel { + if (value <= 20) return AffectionLevel.STRANGER; + if (value <= 40) return AffectionLevel.ACQUAINTANCE; + if (value <= 60) return AffectionLevel.FRIEND; + if (value <= 80) return AffectionLevel.CLOSE; + return AffectionLevel.LOVER; + } + + /** + * 获取角色好感度 + */ + getAffection(characterId: string): number { + const data = this.affection.get(characterId); + return data ? data.value : 0; + } + + /** + * 获取角色好感度等级 + */ + getAffectionLevel(characterId: string): AffectionLevel { + const data = this.affection.get(characterId); + return data ? data.level : AffectionLevel.STRANGER; + } + + /** + * 获取所有角色好感度 + */ + getAllAffection(): Map { + return this.affection; + } + + /** + * 检查是否达到特定等级 + */ + hasReachedLevel(characterId: string, level: AffectionLevel): boolean { + const currentLevel = this.getAffectionLevel(characterId); + const levelOrder = [ + AffectionLevel.STRANGER, + AffectionLevel.ACQUAINTANCE, + AffectionLevel.FRIEND, + AffectionLevel.CLOSE, + AffectionLevel.LOVER + ]; + + return levelOrder.indexOf(currentLevel) >= levelOrder.indexOf(level); + } + + /** + * 获取最高好感的角色 + */ + getHighestAffectionCharacter(): string { + let maxId = ''; + let maxValue = -1; + + this.affection.forEach((data, id) => { + if (data.value > maxValue) { + maxValue = data.value; + maxId = id; + } + }); + + return maxId; + } + + /** + * 重置好感度 + */ + reset() { + this.affection.forEach((data) => { + data.value = 0; + data.level = AffectionLevel.STRANGER; + data.events = []; + }); + this.history = []; + this.updateDisplay(); + } + + /** + * 保存存档 + */ + save(): string { + const saveData = { + affection: Object.fromEntries(this.affection), + history: this.history + }; + return JSON.stringify(saveData); + } + + /** + * 加载存档 + */ + load(jsonString: string) { + try { + const saveData = JSON.parse(jsonString); + + if (saveData.affection) { + this.affection = new Map(Object.entries(saveData.affection)); + } + + if (saveData.history) { + this.history = saveData.history; + } + + this.updateDisplay(); + } catch (e) { + console.error('加载存档失败:', e); + } + } + + /** + * 更新显示 + */ + private updateDisplay() { + if (!this.affectionDisplay) return; + + // 遍历显示节点并更新 + // 这里需要根据实际UI结构来实现 + // 示例:假设子节点名为角色ID + this.affectionDisplay.children.forEach(child => { + const characterId = child.name; + const data = this.affection.get(characterId); + + if (data) { + const label = child.getComponent(Label); + if (label) { + label.string = `${characterId}: ${data.value} (${data.level})`; + } + } + }); + } + + /** + * 显示好感度变化 + */ + private showAffectionChange(characterId: string, change: number) { + // 可以在这里添加飘字效果 + // 示例: + // this.showFloatingText(`${change > 0 ? '+' : ''}${change}`, characterId); + + console.log(`[${characterId}] 好感度 ${change > 0 ? '+' : ''}${change}`); + } + + /** + * 获取好感度历史 + */ + getHistory(): AffectionEvent[] { + return this.history; + } + + /** + * 获取特定角色的历史 + */ + getCharacterHistory(characterId: string): AffectionEvent[] { + return this.history.filter(e => e.characterId === characterId); + } +} diff --git a/assets/scripts/CharacterView.ts b/assets/scripts/CharacterView.ts new file mode 100644 index 0000000..0ea744c --- /dev/null +++ b/assets/scripts/CharacterView.ts @@ -0,0 +1,183 @@ +import { _decorator, Component, Node, Sprite, SpriteFrame, resources, tween, Vec3 } from 'cc'; + +const { ccclass, property } = _decorator; + +// 角色位置 +type CharacterPosition = 'left' | 'center' | 'right'; + +// 角色数据 +interface CharacterData { + id: string; + name: string; + emotion: string; + position: CharacterPosition; + visible: boolean; +} + +@ccclass('CharacterView') +export class CharacterView extends Component { + @property(Node) + leftCharacter: Node; + + @property(Node) + centerCharacter: Node; + + @property(Node) + rightCharacter: Node; + + // 角色资源缓存 + private characterAssets: Map> = new Map(); + + onLoad() { + // 初始化所有角色隐藏 + this.hideAllCharacters(); + } + + /** + * 显示角色 + */ + showCharacter(characterId: string, name: string, emotion: string, position: CharacterPosition) { + const node = this.getCharacterNode(position); + if (!node) return; + + // 加载角色立绘 + this.loadCharacterSprite(characterId, emotion, (spriteFrame) => { + if (spriteFrame) { + const sprite = node.getComponent(Sprite); + if (sprite) { + sprite.spriteFrame = spriteFrame; + } + + // 显示节点 + node.active = true; + + // 播放出场动画 + this.playEnterAnimation(node); + } + }); + } + + /** + * 隐藏角色 + */ + hideCharacter(characterId: string) { + // 遍历所有位置查找并隐藏 + [this.leftCharacter, this.centerCharacter, this.rightCharacter].forEach(node => { + if (node && node.active) { + node.active = false; + } + }); + } + + /** + * 隐藏所有角色 + */ + hideAllCharacters() { + [this.leftCharacter, this.centerCharacter, this.rightCharacter].forEach(node => { + if (node) { + node.active = false; + } + }); + } + + /** + * 切换角色表情 + */ + setEmotion(emotion: string) { + // 这个方法需要在具体场景中根据当前显示的角色来实现 + // 可以通过在场景中保存当前角色ID来调用 + console.log('切换表情:', emotion); + } + + /** + * 获取角色节点 + */ + private getCharacterNode(position: CharacterPosition): Node { + switch (position) { + case 'left': + return this.leftCharacter; + case 'center': + return this.centerCharacter; + case 'right': + return this.rightCharacter; + default: + return this.centerCharacter; + } + } + + /** + * 加载角色立绘 + */ + private loadCharacterSprite(characterId: string, emotion: string, callback: (spriteFrame: SpriteFrame) => void) { + const path = `characters/${characterId}/${emotion}`; + + // 检查缓存 + if (this.characterAssets.has(characterId)) { + const emotionMap = this.characterAssets.get(characterId); + if (emotionMap && emotionMap.has(emotion)) { + callback(emotionMap.get(emotion)); + return; + } + } + + // 加载新资源 + resources.load(path, SpriteFrame, (err, spriteFrame) => { + if (err) { + console.warn(`加载角色立绘失败: ${path}`, err); + callback(null); + return; + } + + // 缓存 + if (!this.characterAssets.has(characterId)) { + this.characterAssets.set(characterId, new Map()); + } + this.characterAssets.get(characterId).set(emotion, spriteFrame); + + callback(spriteFrame); + }); + } + + /** + * 播放出场动画 + */ + private playEnterAnimation(node: Node) { + // 从下方进入 + const originalPos = node.position.clone(); + node.setPosition(originalPos.x, originalPos.y - 200, originalPos.z); + + tween(node) + .to(0.3, { position: originalPos }, { easing: 'backOut' }) + .start(); + } + + /** + * 播放退场动画 + */ + playExitAnimation(node: Node, callback?: () => void) { + tween(node) + .to(0.3, { position: new Vec3(node.position.x, node.position.y - 200, node.position.z) }) + .call(() => { + node.active = false; + if (callback) callback(); + }) + .start(); + } + + /** + * 预加载角色资源 + */ + preloadCharacter(characterId: string, emotions: string[]) { + emotions.forEach(emotion => { + const path = `characters/${characterId}/${emotion}`; + resources.preload(path, SpriteFrame); + }); + } + + /** + * 清除缓存 + */ + clearCache() { + this.characterAssets.clear(); + } +} diff --git a/assets/scripts/ChoiceButton.ts b/assets/scripts/ChoiceButton.ts new file mode 100644 index 0000000..da9f896 --- /dev/null +++ b/assets/scripts/ChoiceButton.ts @@ -0,0 +1,136 @@ +import { _decorator, Component, Node, Label, Button, Sprite, tween, Vec3, Color } from 'cc'; + +const { ccclass, property } = _decorator; + +@ccclass('ChoiceButton') +export class ChoiceButton extends Component { + @property(Label) + textLabel: Label; + + @property(Button) + button: Button; + + @property(Sprite) + background: Sprite; + + private onClickCallback: () => void = null; + private isClicked: boolean = false; + + onLoad() { + // 确保有 Button 组件 + if (!this.button) { + this.button = this.getComponent(Button); + } + + if (this.button) { + this.button.node.on('click', this.onButtonClick, this); + } + + // 默认隐藏 + this.node.active = false; + } + + onDestroy() { + if (this.button) { + this.button.node.off('click', this.onButtonClick, this); + } + } + + /** + * 设置选项文本和回调 + */ + setup(text: string, onClick: () => void) { + if (this.textLabel) { + this.textLabel.string = text; + } + + this.onClickCallback = onClick; + this.isClicked = false; + this.node.active = true; + + // 重置状态 + this.setHoverState(false); + } + + /** + * 按钮点击处理 + */ + private onButtonClick() { + if (this.isClicked) return; + + this.isClicked = true; + + // 播放点击动画 + this.playClickAnimation(() => { + // 执行回调 + if (this.onClickCallback) { + this.onClickCallback(); + } + }); + } + + /** + * 播放点击动画 + */ + private playClickAnimation(callback: () => void) { + // 缩小动画 + tween(this.node) + .to(0.1, { scale: new Vec3(0.95, 0.95, 1) }) + .to(0.1, { scale: new Vec3(1, 1, 1) }) + .call(callback) + .start(); + } + + /** + * 设置悬停状态 + */ + setHoverState(isHover: boolean) { + if (!this.background) return; + + // 简单的悬停效果 + // 实际项目中可以根据需要调整颜色或缩放 + const targetScale = isHover ? 1.05 : 1; + + tween(this.node) + .to(0.1, { scale: new Vec3(targetScale, targetScale, 1) }) + .start(); + } + + /** + * 设置按钮可用状态 + */ + setEnabled(enabled: boolean) { + if (this.button) { + this.button.interactable = enabled; + } + + // 禁用时降低透明度 + const opacity = enabled ? 255 : 128; + if (this.background) { + this.background.color = new Color(255, 255, 255, opacity); + } + } + + /** + * 获取选项文本 + */ + getText(): string { + return this.textLabel ? this.textLabel.string : ''; + } + + /** + * 隐藏选项 + */ + hide() { + this.node.active = false; + this.isClicked = false; + } + + /** + * 显示选项 + */ + show() { + this.node.active = true; + this.isClicked = false; + } +} diff --git a/assets/scripts/DialogueBox.ts b/assets/scripts/DialogueBox.ts new file mode 100644 index 0000000..6345500 --- /dev/null +++ b/assets/scripts/DialogueBox.ts @@ -0,0 +1,177 @@ +import { _decorator, Component, Node, Label, Sprite, RichText, tween, Vec3 } from 'cc'; + +const { ccclass, property } = _decorator; + +@ccclass('DialogueBox') +export class DialogueBox extends Component { + @property(Label) + nameLabel: Label; + + @property(RichText) + textRichText: RichText; + + @property(Sprite) + nameBg: Sprite; + + @property(Node) + continueIndicator: Node; + + private currentText: string = ''; + private currentSpeaker: string = ''; + private typingInterval: number = null; + private onCompleteCallback: () => void = null; + private typingSpeed: number = 30; // 毫秒每个字符 + + onLoad() { + this.nameBg = this.nameLabel.node.parent.getComponent(Sprite); + } + + start() { + // 默认隐藏 + this.node.active = false; + } + + /** + * 显示对话 + */ + show(speaker: string | undefined, text: string, onComplete?: () => void) { + this.node.active = true; + this.currentText = text; + this.currentSpeaker = speaker || ''; + this.onCompleteCallback = onComplete || null; + + // 设置角色名 + if (this.currentSpeaker) { + this.nameLabel.string = this.currentSpeaker; + this.nameBg.node.active = true; + } else { + this.nameBg.node.active = false; + } + + // 隐藏继续提示 + if (this.continueIndicator) { + this.continueIndicator.active = false; + } + + // 开始打字机效果 + this.typewriterEffect(text); + } + + /** + * 打字机效果 + */ + private typewriterEffect(text: string) { + // 清除之前的定时器 + if (this.typingInterval) { + clearInterval(this.typingInterval); + } + + let index = 0; + this.textRichText.string = ''; + + this.typingInterval = window.setInterval(() => { + if (index < text.length) { + // 使用 RichText 来支持富文本 + this.textRichText.string = this.escapeXml(text.substring(0, index + 1)); + index++; + } else { + this.finishTyping(); + } + }, this.typingSpeed); + } + + /** + * 完成打字(立即显示完整文本) + */ + finishTyping() { + if (this.typingInterval) { + clearInterval(this.typingInterval); + this.typingInterval = null; + } + + this.textRichText.string = this.escapeXml(this.currentText); + + // 显示继续提示 + if (this.continueIndicator) { + this.continueIndicator.active = true; + this.playContinueIndicatorAnim(); + } + + // 回调 + if (this.onCompleteCallback) { + this.onCompleteCallback(); + } + } + + /** + * 播放继续提示动画 + */ + private playContinueIndicatorAnim() { + if (!this.continueIndicator) return; + + // 简单的上下浮动动画 + tween(this.continueIndicator) + .repeatForever( + tween() + .to(0.5, { position: new Vec3(0, 10, 0) }) + .to(0.5, { position: new Vec3(0, 0, 0) }) + ) + .start(); + } + + /** + * 隐藏对话框 + */ + hide() { + this.node.active = false; + if (this.typingInterval) { + clearInterval(this.typingInterval); + this.typingInterval = null; + } + } + + /** + * 重置 + */ + reset() { + this.nameLabel.string = ''; + this.textRichText.string = ''; + this.nameBg.node.active = false; + if (this.continueIndicator) { + this.continueIndicator.active = false; + } + } + + /** + * 转义 XML 特殊字符(用于 RichText) + */ + private escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * 设置打字速度 + */ + setTypingSpeed(speed: number) { + this.typingSpeed = speed; + } + + /** + * 获取当前说话者 + */ + getCurrentSpeaker(): string { + return this.currentSpeaker; + } + + /** + * 获取当前文本 + */ + getCurrentText(): string { + return this.currentText; + } +} diff --git a/assets/scripts/StoryManager.ts b/assets/scripts/StoryManager.ts new file mode 100644 index 0000000..09e26aa --- /dev/null +++ b/assets/scripts/StoryManager.ts @@ -0,0 +1,280 @@ +import { _decorator, Component, Node, resources, JsonAsset, Sprite, SpriteFrame, Label, Button, director } from 'cc'; +import { DialogueBox } from './DialogueBox'; +import { CharacterView } from './CharacterView'; +import { ChoiceButton } from './ChoiceButton'; +import { AffectionSystem } from './AffectionSystem'; + +const { ccclass, property } = _decorator; + +// 剧情数据结构 +interface StoryData { + title: string; + scenes: SceneData[]; +} + +interface SceneData { + id: string; + background?: string; + characters: CharacterShowData[]; + dialogue: DialogueData[]; + choices?: ChoiceData[]; + nextScene?: string; +} + +interface CharacterShowData { + id: string; + name: string; + emotion: string; + position: 'left' | 'center' | 'right'; + visible: boolean; +} + +interface DialogueData { + speaker?: string; + text: string; + emotion?: string; +} + +interface ChoiceData { + text: string; + nextScene: string; + affectionChange?: { [key: string]: number }; +} + +@ccclass('StoryManager') +export class StoryManager extends Component { + private static _instance: StoryManager = null; + public static get instance(): StoryManager { + return StoryManager._instance; + } + + @property(DialogueBox) + dialogueBox: DialogueBox; + + @property(CharacterView) + characterView: CharacterView; + + @property(Node) + choiceContainer: Node; + + @property(AffectionSystem) + affectionSystem: AffectionSystem; + + private storyData: StoryData = null; + private currentScene: SceneData = null; + private currentDialogueIndex: number = 0; + private isTyping: boolean = false; + + onLoad() { + StoryManager._instance = this; + } + + start() { + // 自动加载第一章 + this.loadChapter('chapter1'); + } + + /** + * 加载剧情章节 + */ + loadChapter(chapterId: string) { + resources.load(`story/${chapterId}`, JsonAsset, (err, jsonAsset) => { + if (err) { + console.error('加载剧情失败:', err); + return; + } + this.storyData = jsonAsset.json as StoryData; + console.log('剧情加载成功:', this.storyData.title); + + // 开始播放第一个场景 + if (this.storyData.scenes.length > 0) { + this.playScene(this.storyData.scenes[0].id); + } + }); + } + + /** + * 播放指定场景 + */ + playScene(sceneId: string) { + const scene = this.storyData.scenes.find((s) => s.id === sceneId); + if (!scene) { + console.error('未找到场景:', sceneId); + return; + } + + this.currentScene = scene; + this.currentDialogueIndex = 0; + + // 设置背景 + if (scene.background) { + this.loadBackground(scene.background); + } + + // 更新角色显示 + this.updateCharacters(scene.characters); + + // 隐藏选项 + this.hideChoices(); + + // 开始对话 + this.showNextDialogue(); + } + + /** + * 显示下一句对话 + */ + showNextDialogue() { + if (!this.currentScene) return; + + // 检查是否还有对话 + if (this.currentDialogueIndex >= this.currentScene.dialogue.length) { + this.onDialogueEnd(); + return; + } + + const dialogue = this.currentScene.dialogue[this.currentDialogueIndex]; + + // 更新对话框 + this.dialogueBox.show(dialogue.speaker, dialogue.text, () => { + this.isTyping = false; + }); + + // 更新立绘表情 + if (dialogue.emotion && this.characterView) { + this.characterView.setEmotion(dialogue.emotion); + } + + this.currentDialogueIndex++; + this.isTyping = true; + } + + /** + * 点击对话区域继续 + */ + onDialogueClicked() { + if (this.isTyping) { + // 打字时点击直接显示完整文本 + this.dialogueBox.finishTyping(); + this.isTyping = false; + } else { + // 显示下一句 + this.showNextDialogue(); + } + } + + /** + * 对话结束处理 + */ + private onDialogueEnd() { + if (!this.currentScene) return; + + // 检查是否有选项 + if (this.currentScene.choices && this.currentScene.choices.length > 0) { + this.showChoices(this.currentScene.choices); + } + // 检查是否有下一场景 + else if (this.currentScene.nextScene) { + this.playScene(this.currentScene.nextScene); + } + else { + console.log('剧情结束'); + } + } + + /** + * 显示选项 + */ + private showChoices(choices: ChoiceData[]) { + this.hideChoices(); + + choices.forEach((choice, index) => { + const choiceNode = this.choiceContainer.children[index]; + if (choiceNode) { + choiceNode.active = true; + const choiceBtn = choiceNode.getComponent(ChoiceButton); + if (choiceBtn) { + choiceBtn.setup(choice.text, () => { + this.onChoiceSelected(choice); + }); + } + } + }); + } + + /** + * 隐藏选项 + */ + private hideChoices() { + this.choiceContainer.children.forEach(child => { + child.active = false; + }); + } + + /** + * 选择选项 + */ + private onChoiceSelected(choice: ChoiceData) { + // 更新好感度 + if (choice.affectionChange) { + this.affectionSystem.changeAffection(choice.affectionChange); + } + + // 跳转到下一场景 + if (choice.nextScene) { + this.playScene(choice.nextScene); + } + } + + /** + * 加载背景图 + */ + private loadBackground(bgPath: string) { + resources.load(`backgrounds/${bgPath}`, SpriteFrame, (err, spriteFrame) => { + if (err) { + console.error('加载背景失败:', err); + return; + } + // 假设有一个背景节点 + const bgNode = this.node.getChildByName('Background'); + if (bgNode) { + const sprite = bgNode.getComponent(Sprite); + if (sprite) { + sprite.spriteFrame = spriteFrame; + } + } + }); + } + + /** + * 更新角色显示 + */ + private updateCharacters(characters: CharacterShowData[]) { + if (!this.characterView) return; + + characters.forEach(char => { + if (char.visible) { + this.characterView.showCharacter(char.id, char.name, char.emotion, char.position); + } else { + this.characterView.hideCharacter(char.id); + } + }); + } + + /** + * 跳转到指定场景(可外部调用) + */ + public goToScene(sceneId: string) { + this.playScene(sceneId); + } + + /** + * 重新开始 + */ + public restart() { + if (this.storyData && this.storyData.scenes.length > 0) { + this.affectionSystem.reset(); + this.playScene(this.storyData.scenes[0].id); + } + } +} diff --git a/project.json b/project.json new file mode 100644 index 0000000..7821be2 --- /dev/null +++ b/project.json @@ -0,0 +1,28 @@ +{ + "engine": "cocos-creator", + "version": "3.8.0", + "name": "cocos-dating-game", + "id": "cocos-dating-game", + "options": { + "appKey": "", + "companyName": "YourCompany", + "productName": "Cocos Dating Game", + "startScene": "db://assets/scenes/MainScene.scene" + }, + "paths": { + "misc": "resources/misc", + "font": "resources/font", + "audio": "resources/audio", + "particle": "resources/particle", + "prefab": "resources/prefab", + "scene": "assets/scenes" + }, + "scripts": {}, + "library": { + "base": "library" + }, + "native": { + "strip": true, + "compress": true + } +}