Initial commit: Cocos Creator恋爱游戏引擎
- StoryManager: 剧情管理器 - DialogueBox: 对话框组件(带打字机效果) - CharacterView: 立绘组件 - ChoiceButton: 选项按钮 - AffectionSystem: 好感度系统 - chapter1.json: 示例剧情
This commit is contained in:
commit
41e7ca05f1
148
README.md
Normal file
148
README.md
Normal file
@ -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!
|
||||
249
assets/resources/story/chapter1.json
Normal file
249
assets/resources/story/chapter1.json
Normal file
@ -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": "(第一章完)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
259
assets/scripts/AffectionSystem.ts
Normal file
259
assets/scripts/AffectionSystem.ts
Normal file
@ -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<string, CharacterAffection> = 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<string, CharacterAffection> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
183
assets/scripts/CharacterView.ts
Normal file
183
assets/scripts/CharacterView.ts
Normal file
@ -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<string, Map<string, SpriteFrame>> = 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();
|
||||
}
|
||||
}
|
||||
136
assets/scripts/ChoiceButton.ts
Normal file
136
assets/scripts/ChoiceButton.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
177
assets/scripts/DialogueBox.ts
Normal file
177
assets/scripts/DialogueBox.ts
Normal file
@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置打字速度
|
||||
*/
|
||||
setTypingSpeed(speed: number) {
|
||||
this.typingSpeed = speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前说话者
|
||||
*/
|
||||
getCurrentSpeaker(): string {
|
||||
return this.currentSpeaker;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前文本
|
||||
*/
|
||||
getCurrentText(): string {
|
||||
return this.currentText;
|
||||
}
|
||||
}
|
||||
280
assets/scripts/StoryManager.ts
Normal file
280
assets/scripts/StoryManager.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
project.json
Normal file
28
project.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user