feat: 星域故事汇小游戏初始版本
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
~$*
|
||||||
|
*.docx
|
||||||
|
.qoder/
|
||||||
|
project.private.config.json
|
||||||
130
README.md
Normal file
130
README.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# 星域故事汇
|
||||||
|
|
||||||
|
一款 AI 驱动的互动短剧微信小游戏,将超休闲游戏的极简操作与互动叙事的强代入感相结合。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ai_game/
|
||||||
|
├── client/ # 微信小游戏客户端
|
||||||
|
│ ├── js/
|
||||||
|
│ │ ├── data/ # 数据管理
|
||||||
|
│ │ │ └── StoryManager.js
|
||||||
|
│ │ ├── scenes/ # 场景模块
|
||||||
|
│ │ │ ├── HomeScene.js # 首页
|
||||||
|
│ │ │ ├── StoryScene.js # 故事播放
|
||||||
|
│ │ │ ├── EndingScene.js # 结局页
|
||||||
|
│ │ │ ├── ProfileScene.js # 个人中心
|
||||||
|
│ │ │ ├── ChapterScene.js # 章节选择
|
||||||
|
│ │ │ └── SceneManager.js # 场景管理
|
||||||
|
│ │ ├── utils/ # 工具类
|
||||||
|
│ │ └── main.js # 入口文件
|
||||||
|
│ ├── game.js # 游戏启动
|
||||||
|
│ └── game.json # 小游戏配置
|
||||||
|
│
|
||||||
|
├── server/ # Node.js 后端服务
|
||||||
|
│ ├── routes/ # API 路由
|
||||||
|
│ │ ├── story.js # 故事相关接口
|
||||||
|
│ │ └── user.js # 用户相关接口
|
||||||
|
│ ├── models/ # 数据模型
|
||||||
|
│ ├── config/ # 配置文件
|
||||||
|
│ ├── sql/ # 数据库脚本
|
||||||
|
│ └── app.js # 服务入口
|
||||||
|
│
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
**客户端**
|
||||||
|
- 原生微信小游戏(无引擎)
|
||||||
|
- Canvas 2D 渲染
|
||||||
|
- ES6+ 模块化
|
||||||
|
|
||||||
|
**服务端**
|
||||||
|
- Node.js + Express
|
||||||
|
- MySQL 数据库
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 启动后端服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm install
|
||||||
|
# 配置 .env 文件(参考 .env.example)
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 导入客户端
|
||||||
|
|
||||||
|
1. 打开微信开发者工具
|
||||||
|
2. 导入项目,选择 `client` 目录
|
||||||
|
3. 填入 AppID(测试号可使用测试 AppID)
|
||||||
|
4. 编译运行
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 故事游玩
|
||||||
|
- 沉浸式视觉小说风格界面
|
||||||
|
- 打字机效果文字展示
|
||||||
|
- 多分支选择剧情
|
||||||
|
- 多结局达成系统
|
||||||
|
|
||||||
|
### 章节选择
|
||||||
|
- 可选择任意已解锁节点重新游玩
|
||||||
|
- 体验不同分支剧情
|
||||||
|
|
||||||
|
### 个人中心
|
||||||
|
- 游玩记录
|
||||||
|
- 收藏管理
|
||||||
|
- 结局成就
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/stories` | GET | 获取故事列表 |
|
||||||
|
| `/api/stories/:id` | GET | 获取故事详情 |
|
||||||
|
| `/api/stories/categories` | GET | 获取分类列表 |
|
||||||
|
| `/api/stories/:id/play` | POST | 记录游玩 |
|
||||||
|
| `/api/stories/:id/like` | POST | 点赞故事 |
|
||||||
|
| `/api/user/login` | POST | 用户登录 |
|
||||||
|
| `/api/user/progress` | GET/POST | 游玩进度 |
|
||||||
|
| `/api/user/collections` | GET/POST | 收藏管理 |
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 服务端配置 (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3000
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_NAME=ai_game
|
||||||
|
```
|
||||||
|
|
||||||
|
### 客户端配置 (game.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deviceOrientation": "portrait",
|
||||||
|
"showStatusBar": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发计划
|
||||||
|
|
||||||
|
- [x] 核心游玩模块
|
||||||
|
- [x] 故事播放场景
|
||||||
|
- [x] 结局展示系统
|
||||||
|
- [x] 章节选择功能
|
||||||
|
- [ ] AI 改写功能
|
||||||
|
- [ ] AI 续写功能
|
||||||
|
- [ ] UGC 创作系统
|
||||||
|
- [ ] 社交分享优化
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
8
client/game.js
Normal file
8
client/game.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 星域故事汇 - 游戏入口
|
||||||
|
*/
|
||||||
|
import './js/libs/weapp-adapter';
|
||||||
|
import Main from './js/main';
|
||||||
|
|
||||||
|
// 创建游戏实例
|
||||||
|
new Main();
|
||||||
10
client/game.json
Normal file
10
client/game.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"deviceOrientation": "portrait",
|
||||||
|
"showStatusBar": false,
|
||||||
|
"networkTimeout": {
|
||||||
|
"request": 10000,
|
||||||
|
"connectSocket": 10000,
|
||||||
|
"uploadFile": 10000,
|
||||||
|
"downloadFile": 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
132
client/js/data/AudioManager.js
Normal file
132
client/js/data/AudioManager.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* 音频管理器
|
||||||
|
*/
|
||||||
|
export default class AudioManager {
|
||||||
|
constructor() {
|
||||||
|
this.bgm = null;
|
||||||
|
this.sfx = {};
|
||||||
|
this.isMuted = false;
|
||||||
|
this.bgmVolume = 0.5;
|
||||||
|
this.sfxVolume = 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放背景音乐
|
||||||
|
*/
|
||||||
|
playBGM(src) {
|
||||||
|
if (this.isMuted || !src) return;
|
||||||
|
|
||||||
|
// 停止当前BGM
|
||||||
|
this.stopBGM();
|
||||||
|
|
||||||
|
// 创建新的音频实例
|
||||||
|
this.bgm = wx.createInnerAudioContext();
|
||||||
|
this.bgm.src = src;
|
||||||
|
this.bgm.loop = true;
|
||||||
|
this.bgm.volume = this.bgmVolume;
|
||||||
|
this.bgm.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止背景音乐
|
||||||
|
*/
|
||||||
|
stopBGM() {
|
||||||
|
if (this.bgm) {
|
||||||
|
this.bgm.stop();
|
||||||
|
this.bgm.destroy();
|
||||||
|
this.bgm = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暂停背景音乐
|
||||||
|
*/
|
||||||
|
pauseBGM() {
|
||||||
|
if (this.bgm) {
|
||||||
|
this.bgm.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复背景音乐
|
||||||
|
*/
|
||||||
|
resumeBGM() {
|
||||||
|
if (this.bgm && !this.isMuted) {
|
||||||
|
this.bgm.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放音效
|
||||||
|
*/
|
||||||
|
playSFX(name, src) {
|
||||||
|
if (this.isMuted || !src) return;
|
||||||
|
|
||||||
|
// 复用或创建音效实例
|
||||||
|
if (!this.sfx[name]) {
|
||||||
|
this.sfx[name] = wx.createInnerAudioContext();
|
||||||
|
this.sfx[name].src = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sfx[name].volume = this.sfxVolume;
|
||||||
|
this.sfx[name].seek(0);
|
||||||
|
this.sfx[name].play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放点击音效
|
||||||
|
*/
|
||||||
|
playClick() {
|
||||||
|
// 可以配置点击音效
|
||||||
|
// this.playSFX('click', 'audio/click.mp3');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置静音
|
||||||
|
*/
|
||||||
|
setMute(muted) {
|
||||||
|
this.isMuted = muted;
|
||||||
|
if (muted) {
|
||||||
|
this.pauseBGM();
|
||||||
|
} else {
|
||||||
|
this.resumeBGM();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换静音状态
|
||||||
|
*/
|
||||||
|
toggleMute() {
|
||||||
|
this.setMute(!this.isMuted);
|
||||||
|
return this.isMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置BGM音量
|
||||||
|
*/
|
||||||
|
setBGMVolume(volume) {
|
||||||
|
this.bgmVolume = Math.max(0, Math.min(1, volume));
|
||||||
|
if (this.bgm) {
|
||||||
|
this.bgm.volume = this.bgmVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置音效音量
|
||||||
|
*/
|
||||||
|
setSFXVolume(volume) {
|
||||||
|
this.sfxVolume = Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁所有音频
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.stopBGM();
|
||||||
|
Object.values(this.sfx).forEach(audio => {
|
||||||
|
audio.stop();
|
||||||
|
audio.destroy();
|
||||||
|
});
|
||||||
|
this.sfx = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
147
client/js/data/StoryManager.js
Normal file
147
client/js/data/StoryManager.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 故事数据管理器
|
||||||
|
*/
|
||||||
|
import { get, post } from '../utils/http';
|
||||||
|
|
||||||
|
export default class StoryManager {
|
||||||
|
constructor() {
|
||||||
|
this.storyList = [];
|
||||||
|
this.currentStory = null;
|
||||||
|
this.currentNodeKey = 'start';
|
||||||
|
this.categories = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载故事列表
|
||||||
|
*/
|
||||||
|
async loadStoryList(options = {}) {
|
||||||
|
try {
|
||||||
|
this.storyList = await get('/stories', options);
|
||||||
|
return this.storyList;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载故事列表失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载热门故事
|
||||||
|
*/
|
||||||
|
async loadHotStories(limit = 10) {
|
||||||
|
try {
|
||||||
|
return await get('/stories/hot', { limit });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载热门故事失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载分类列表
|
||||||
|
*/
|
||||||
|
async loadCategories() {
|
||||||
|
try {
|
||||||
|
this.categories = await get('/stories/categories');
|
||||||
|
return this.categories;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载故事详情
|
||||||
|
*/
|
||||||
|
async loadStoryDetail(storyId) {
|
||||||
|
try {
|
||||||
|
this.currentStory = await get(`/stories/${storyId}`);
|
||||||
|
this.currentNodeKey = 'start';
|
||||||
|
|
||||||
|
// 记录游玩次数
|
||||||
|
await post(`/stories/${storyId}/play`);
|
||||||
|
|
||||||
|
return this.currentStory;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载故事详情失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前节点
|
||||||
|
*/
|
||||||
|
getCurrentNode() {
|
||||||
|
if (!this.currentStory || !this.currentStory.nodes) return null;
|
||||||
|
return this.currentStory.nodes[this.currentNodeKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择选项,前进到下一个节点
|
||||||
|
*/
|
||||||
|
selectChoice(choiceIndex) {
|
||||||
|
const currentNode = this.getCurrentNode();
|
||||||
|
if (!currentNode || !currentNode.choices || !currentNode.choices[choiceIndex]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = currentNode.choices[choiceIndex];
|
||||||
|
this.currentNodeKey = choice.nextNodeKey;
|
||||||
|
|
||||||
|
return this.getCurrentNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前节点是否为结局
|
||||||
|
*/
|
||||||
|
isEnding() {
|
||||||
|
const currentNode = this.getCurrentNode();
|
||||||
|
return currentNode && currentNode.is_ending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取结局信息
|
||||||
|
*/
|
||||||
|
getEndingInfo() {
|
||||||
|
const currentNode = this.getCurrentNode();
|
||||||
|
if (!currentNode || !currentNode.is_ending) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: currentNode.ending_name,
|
||||||
|
score: currentNode.ending_score,
|
||||||
|
type: currentNode.ending_type,
|
||||||
|
content: currentNode.content
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置故事进度
|
||||||
|
*/
|
||||||
|
resetStory() {
|
||||||
|
this.currentNodeKey = 'start';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞故事
|
||||||
|
*/
|
||||||
|
async likeStory(like = true) {
|
||||||
|
if (!this.currentStory) return;
|
||||||
|
await post(`/stories/${this.currentStory.id}/like`, { like });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI改写结局
|
||||||
|
*/
|
||||||
|
async rewriteEnding(storyId, ending, prompt) {
|
||||||
|
try {
|
||||||
|
const result = await post(`/stories/${storyId}/rewrite`, {
|
||||||
|
ending_name: ending?.name,
|
||||||
|
ending_content: ending?.content,
|
||||||
|
prompt: prompt
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI改写失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
client/js/data/UserManager.js
Normal file
134
client/js/data/UserManager.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 用户数据管理器
|
||||||
|
*/
|
||||||
|
import { get, post } from '../utils/http';
|
||||||
|
|
||||||
|
export default class UserManager {
|
||||||
|
constructor() {
|
||||||
|
this.userId = null;
|
||||||
|
this.openid = null;
|
||||||
|
this.nickname = '';
|
||||||
|
this.avatarUrl = '';
|
||||||
|
this.isLoggedIn = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化用户
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
// 尝试从本地存储恢复用户信息
|
||||||
|
const cached = wx.getStorageSync('userInfo');
|
||||||
|
if (cached) {
|
||||||
|
this.userId = cached.userId;
|
||||||
|
this.openid = cached.openid;
|
||||||
|
this.nickname = cached.nickname;
|
||||||
|
this.avatarUrl = cached.avatarUrl;
|
||||||
|
this.isLoggedIn = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取登录code
|
||||||
|
const { code } = await this.wxLogin();
|
||||||
|
|
||||||
|
// 调用后端登录接口
|
||||||
|
const result = await post('/user/login', { code });
|
||||||
|
|
||||||
|
this.userId = result.userId;
|
||||||
|
this.openid = result.openid;
|
||||||
|
this.nickname = result.nickname || '游客';
|
||||||
|
this.avatarUrl = result.avatarUrl || '';
|
||||||
|
this.isLoggedIn = true;
|
||||||
|
|
||||||
|
// 缓存用户信息
|
||||||
|
wx.setStorageSync('userInfo', {
|
||||||
|
userId: this.userId,
|
||||||
|
openid: this.openid,
|
||||||
|
nickname: this.nickname,
|
||||||
|
avatarUrl: this.avatarUrl
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('用户初始化失败:', error);
|
||||||
|
// 使用临时身份
|
||||||
|
this.userId = 0;
|
||||||
|
this.nickname = '游客';
|
||||||
|
this.isLoggedIn = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信登录(带超时)
|
||||||
|
*/
|
||||||
|
wxLogin() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error('登录超时'));
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
wx.login({
|
||||||
|
success: (res) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(res);
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户游玩进度
|
||||||
|
*/
|
||||||
|
async getProgress(storyId = null) {
|
||||||
|
if (!this.isLoggedIn) return null;
|
||||||
|
return await get('/user/progress', { userId: this.userId, storyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存用户进度
|
||||||
|
*/
|
||||||
|
async saveProgress(storyId, currentNodeKey, isCompleted = false, endingReached = '') {
|
||||||
|
if (!this.isLoggedIn) return;
|
||||||
|
await post('/user/progress', {
|
||||||
|
userId: this.userId,
|
||||||
|
storyId,
|
||||||
|
currentNodeKey,
|
||||||
|
isCompleted,
|
||||||
|
endingReached
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞故事
|
||||||
|
*/
|
||||||
|
async likeStory(storyId, isLiked) {
|
||||||
|
if (!this.isLoggedIn) return;
|
||||||
|
await post('/user/like', {
|
||||||
|
userId: this.userId,
|
||||||
|
storyId,
|
||||||
|
isLiked
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收藏故事
|
||||||
|
*/
|
||||||
|
async collectStory(storyId, isCollected) {
|
||||||
|
if (!this.isLoggedIn) return;
|
||||||
|
await post('/user/collect', {
|
||||||
|
userId: this.userId,
|
||||||
|
storyId,
|
||||||
|
isCollected
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取收藏列表
|
||||||
|
*/
|
||||||
|
async getCollections() {
|
||||||
|
if (!this.isLoggedIn) return [];
|
||||||
|
return await get('/user/collections', { userId: this.userId });
|
||||||
|
}
|
||||||
|
}
|
||||||
37
client/js/libs/weapp-adapter.js
Normal file
37
client/js/libs/weapp-adapter.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 微信小游戏适配器
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 获取系统信息
|
||||||
|
const systemInfo = wx.getSystemInfoSync();
|
||||||
|
const screenWidth = systemInfo.windowWidth;
|
||||||
|
const screenHeight = systemInfo.windowHeight;
|
||||||
|
const devicePixelRatio = systemInfo.pixelRatio;
|
||||||
|
|
||||||
|
// 创建主Canvas
|
||||||
|
const canvas = wx.createCanvas();
|
||||||
|
canvas.width = screenWidth * devicePixelRatio;
|
||||||
|
canvas.height = screenHeight * devicePixelRatio;
|
||||||
|
|
||||||
|
// 设置全局变量
|
||||||
|
GameGlobal.canvas = canvas;
|
||||||
|
GameGlobal.screenWidth = screenWidth;
|
||||||
|
GameGlobal.screenHeight = screenHeight;
|
||||||
|
GameGlobal.devicePixelRatio = devicePixelRatio;
|
||||||
|
|
||||||
|
// Image适配
|
||||||
|
GameGlobal.Image = function() {
|
||||||
|
return wx.createImage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audio适配
|
||||||
|
GameGlobal.Audio = function() {
|
||||||
|
return wx.createInnerAudioContext();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
canvas,
|
||||||
|
screenWidth,
|
||||||
|
screenHeight,
|
||||||
|
devicePixelRatio
|
||||||
|
};
|
||||||
183
client/js/main.js
Normal file
183
client/js/main.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* 星域故事汇 - 主逻辑控制器
|
||||||
|
*/
|
||||||
|
import SceneManager from './scenes/SceneManager';
|
||||||
|
import UserManager from './data/UserManager';
|
||||||
|
import StoryManager from './data/StoryManager';
|
||||||
|
import AudioManager from './data/AudioManager';
|
||||||
|
|
||||||
|
export default class Main {
|
||||||
|
constructor() {
|
||||||
|
// 获取画布和上下文
|
||||||
|
this.canvas = GameGlobal.canvas;
|
||||||
|
this.ctx = this.canvas.getContext('2d');
|
||||||
|
this.screenWidth = GameGlobal.screenWidth;
|
||||||
|
this.screenHeight = GameGlobal.screenHeight;
|
||||||
|
this.dpr = GameGlobal.devicePixelRatio;
|
||||||
|
|
||||||
|
// 缩放上下文以适配设备像素比
|
||||||
|
this.ctx.scale(this.dpr, this.dpr);
|
||||||
|
|
||||||
|
// 初始化管理器
|
||||||
|
this.userManager = new UserManager();
|
||||||
|
this.storyManager = new StoryManager();
|
||||||
|
this.audioManager = new AudioManager();
|
||||||
|
this.sceneManager = new SceneManager(this);
|
||||||
|
|
||||||
|
// 初始化游戏
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// 先启动游戏循环,确保能渲染加载界面
|
||||||
|
this.bindEvents();
|
||||||
|
this.loop();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 显示加载界面
|
||||||
|
this.showLoading('正在加载...');
|
||||||
|
console.log('[Main] 开始初始化...');
|
||||||
|
|
||||||
|
// 用户初始化(失败不阻塞)
|
||||||
|
console.log('[Main] 初始化用户...');
|
||||||
|
await this.userManager.init().catch(e => {
|
||||||
|
console.warn('[Main] 用户初始化失败,使用游客模式:', e);
|
||||||
|
});
|
||||||
|
console.log('[Main] 用户初始化完成');
|
||||||
|
|
||||||
|
// 加载故事列表
|
||||||
|
console.log('[Main] 加载故事列表...');
|
||||||
|
await this.storyManager.loadStoryList();
|
||||||
|
console.log('[Main] 故事列表加载完成,共', this.storyManager.storyList.length, '个故事');
|
||||||
|
|
||||||
|
// 隐藏加载界面
|
||||||
|
this.hideLoading();
|
||||||
|
|
||||||
|
// 进入首页
|
||||||
|
this.sceneManager.switchScene('home');
|
||||||
|
console.log('[Main] 初始化完成,进入首页');
|
||||||
|
|
||||||
|
// 设置分享
|
||||||
|
this.setupShare();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Main] 初始化失败:', error);
|
||||||
|
this.hideLoading();
|
||||||
|
this.showError('初始化失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示加载
|
||||||
|
showLoading(text) {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.loadingText = text;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏加载
|
||||||
|
hideLoading() {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示错误
|
||||||
|
showError(text) {
|
||||||
|
wx.showToast({
|
||||||
|
title: text,
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定事件
|
||||||
|
bindEvents() {
|
||||||
|
// 触摸开始
|
||||||
|
wx.onTouchStart((e) => {
|
||||||
|
if (this.sceneManager.currentScene) {
|
||||||
|
this.sceneManager.currentScene.onTouchStart(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触摸移动
|
||||||
|
wx.onTouchMove((e) => {
|
||||||
|
if (this.sceneManager.currentScene) {
|
||||||
|
this.sceneManager.currentScene.onTouchMove(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触摸结束
|
||||||
|
wx.onTouchEnd((e) => {
|
||||||
|
if (this.sceneManager.currentScene) {
|
||||||
|
this.sceneManager.currentScene.onTouchEnd(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置分享
|
||||||
|
setupShare() {
|
||||||
|
wx.showShareMenu({
|
||||||
|
withShareTicket: true,
|
||||||
|
menus: ['shareAppMessage', 'shareTimeline']
|
||||||
|
});
|
||||||
|
|
||||||
|
wx.onShareAppMessage(() => {
|
||||||
|
return {
|
||||||
|
title: '星域故事汇 - 每个选择都是一个新世界',
|
||||||
|
imageUrl: '',
|
||||||
|
query: ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
wx.onShareTimeline(() => {
|
||||||
|
return {
|
||||||
|
title: '星域故事汇 - 沉浸式互动故事体验',
|
||||||
|
query: ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 游戏循环
|
||||||
|
loop() {
|
||||||
|
this.update();
|
||||||
|
this.render();
|
||||||
|
requestAnimationFrame(() => this.loop());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新逻辑
|
||||||
|
update() {
|
||||||
|
if (this.sceneManager.currentScene) {
|
||||||
|
this.sceneManager.currentScene.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染
|
||||||
|
render() {
|
||||||
|
// 清屏
|
||||||
|
this.ctx.clearRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
|
|
||||||
|
// 绘制背景
|
||||||
|
this.ctx.fillStyle = '#1a1a2e';
|
||||||
|
this.ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
|
|
||||||
|
// 渲染当前场景
|
||||||
|
if (this.sceneManager.currentScene) {
|
||||||
|
this.sceneManager.currentScene.render(this.ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染加载界面
|
||||||
|
if (this.isLoading) {
|
||||||
|
this.renderLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染加载界面
|
||||||
|
renderLoading() {
|
||||||
|
// 半透明遮罩
|
||||||
|
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
this.ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
|
|
||||||
|
// 加载文字
|
||||||
|
this.ctx.fillStyle = '#ffffff';
|
||||||
|
this.ctx.font = '18px sans-serif';
|
||||||
|
this.ctx.textAlign = 'center';
|
||||||
|
this.ctx.fillText(this.loadingText || '加载中...', this.screenWidth / 2, this.screenHeight / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
client/js/scenes/BaseScene.js
Normal file
83
client/js/scenes/BaseScene.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* 场景基类
|
||||||
|
*/
|
||||||
|
export default class BaseScene {
|
||||||
|
constructor(main, params = {}) {
|
||||||
|
this.main = main;
|
||||||
|
this.params = params;
|
||||||
|
this.screenWidth = main.screenWidth;
|
||||||
|
this.screenHeight = main.screenHeight;
|
||||||
|
this.scrollVelocity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
// 更新逻辑
|
||||||
|
update() {}
|
||||||
|
|
||||||
|
// 渲染
|
||||||
|
render(ctx) {}
|
||||||
|
|
||||||
|
// 触摸开始
|
||||||
|
onTouchStart(e) {}
|
||||||
|
|
||||||
|
// 触摸移动
|
||||||
|
onTouchMove(e) {}
|
||||||
|
|
||||||
|
// 触摸结束
|
||||||
|
onTouchEnd(e) {}
|
||||||
|
|
||||||
|
// 销毁
|
||||||
|
destroy() {}
|
||||||
|
|
||||||
|
// 绘制圆角矩形
|
||||||
|
roundRect(ctx, x, y, width, height, radius) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制多行文本
|
||||||
|
wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
||||||
|
const lines = [];
|
||||||
|
let currentLine = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
if (char === '\n') {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testLine = currentLine + char;
|
||||||
|
const metrics = ctx.measureText(testLine);
|
||||||
|
|
||||||
|
if (metrics.width > maxWidth && currentLine.length > 0) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = char;
|
||||||
|
} else {
|
||||||
|
currentLine = testLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine.length > 0) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
ctx.fillText(line, x, y + index * lineHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
284
client/js/scenes/ChapterScene.js
Normal file
284
client/js/scenes/ChapterScene.js
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* 章节选择场景
|
||||||
|
*/
|
||||||
|
import BaseScene from './BaseScene';
|
||||||
|
|
||||||
|
export default class ChapterScene extends BaseScene {
|
||||||
|
constructor(main, params) {
|
||||||
|
super(main, params);
|
||||||
|
this.storyId = params.storyId;
|
||||||
|
this.story = null;
|
||||||
|
this.nodeList = [];
|
||||||
|
this.scrollY = 0;
|
||||||
|
this.maxScrollY = 0;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.lastTouchY = 0;
|
||||||
|
this.hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.story = this.main.storyManager.currentStory;
|
||||||
|
if (!this.story || !this.story.nodes) {
|
||||||
|
this.main.sceneManager.switchScene('home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.buildNodeList();
|
||||||
|
this.calculateMaxScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildNodeList() {
|
||||||
|
const nodes = this.story.nodes;
|
||||||
|
this.nodeList = [];
|
||||||
|
|
||||||
|
// 遍历所有节点
|
||||||
|
Object.keys(nodes).forEach(key => {
|
||||||
|
const node = nodes[key];
|
||||||
|
this.nodeList.push({
|
||||||
|
key: key,
|
||||||
|
title: this.getNodeTitle(node, key),
|
||||||
|
isEnding: node.is_ending,
|
||||||
|
endingName: node.ending_name,
|
||||||
|
endingType: node.ending_type,
|
||||||
|
speaker: node.speaker,
|
||||||
|
preview: this.getPreview(node.content)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按关键字排序,start在前,ending在后
|
||||||
|
this.nodeList.sort((a, b) => {
|
||||||
|
if (a.key === 'start') return -1;
|
||||||
|
if (b.key === 'start') return 1;
|
||||||
|
if (a.isEnding && !b.isEnding) return 1;
|
||||||
|
if (!a.isEnding && b.isEnding) return -1;
|
||||||
|
return a.key.localeCompare(b.key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeTitle(node, key) {
|
||||||
|
if (key === 'start') return '故事开始';
|
||||||
|
if (node.is_ending) return `结局:${node.ending_name || '未知'}`;
|
||||||
|
if (node.speaker && node.speaker !== '旁白') return `${node.speaker}的对话`;
|
||||||
|
return `章节 ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreview(content) {
|
||||||
|
if (!content) return '';
|
||||||
|
const text = content.replace(/\n/g, ' ').trim();
|
||||||
|
return text.length > 40 ? text.substring(0, 40) + '...' : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateMaxScroll() {
|
||||||
|
const cardHeight = 85;
|
||||||
|
const gap = 12;
|
||||||
|
const headerHeight = 80;
|
||||||
|
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight;
|
||||||
|
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {}
|
||||||
|
|
||||||
|
render(ctx) {
|
||||||
|
this.renderBackground(ctx);
|
||||||
|
this.renderHeader(ctx);
|
||||||
|
this.renderNodeList(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBackground(ctx) {
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
|
||||||
|
gradient.addColorStop(0, '#0f0c29');
|
||||||
|
gradient.addColorStop(0.5, '#302b63');
|
||||||
|
gradient.addColorStop(1, '#24243e');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader(ctx) {
|
||||||
|
// 顶部遮罩
|
||||||
|
const headerGradient = ctx.createLinearGradient(0, 0, 0, 80);
|
||||||
|
headerGradient.addColorStop(0, 'rgba(15,12,41,1)');
|
||||||
|
headerGradient.addColorStop(1, 'rgba(15,12,41,0)');
|
||||||
|
ctx.fillStyle = headerGradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, 80);
|
||||||
|
|
||||||
|
// 返回按钮
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '16px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('‹ 返回', 15, 40);
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.font = 'bold 17px sans-serif';
|
||||||
|
ctx.fillText('选择章节', this.screenWidth / 2, 40);
|
||||||
|
|
||||||
|
// 故事名
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
const title = this.story?.title || '';
|
||||||
|
ctx.fillText(title.length > 15 ? title.substring(0, 15) + '...' : title, this.screenWidth / 2, 58);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNodeList(ctx) {
|
||||||
|
const padding = 15;
|
||||||
|
const cardHeight = 85;
|
||||||
|
const cardMargin = 12;
|
||||||
|
const startY = 80;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(0, startY, this.screenWidth, this.screenHeight - startY);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
this.nodeList.forEach((node, index) => {
|
||||||
|
const y = startY + index * (cardHeight + cardMargin) - this.scrollY;
|
||||||
|
|
||||||
|
if (y + cardHeight < startY || y > this.screenHeight) return;
|
||||||
|
|
||||||
|
// 卡片背景
|
||||||
|
if (node.isEnding) {
|
||||||
|
const endingGradient = ctx.createLinearGradient(padding, y, this.screenWidth - padding, y);
|
||||||
|
const colors = this.getEndingColors(node.endingType);
|
||||||
|
endingGradient.addColorStop(0, colors[0]);
|
||||||
|
endingGradient.addColorStop(1, colors[1]);
|
||||||
|
ctx.fillStyle = endingGradient;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
}
|
||||||
|
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 节点标题
|
||||||
|
ctx.fillStyle = node.isEnding ? '#ffffff' : '#ffffff';
|
||||||
|
ctx.font = 'bold 14px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(node.title, padding + 15, y + 28);
|
||||||
|
|
||||||
|
// 节点预览
|
||||||
|
ctx.fillStyle = node.isEnding ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.5)';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
const preview = this.truncateText(ctx, node.preview, this.screenWidth - padding * 2 - 30);
|
||||||
|
ctx.fillText(preview, padding + 15, y + 52);
|
||||||
|
|
||||||
|
// 节点标识
|
||||||
|
if (node.key === 'start') {
|
||||||
|
this.renderTag(ctx, this.screenWidth - padding - 60, y + 18, '起点', '#4ecca3');
|
||||||
|
} else if (node.isEnding) {
|
||||||
|
this.renderTag(ctx, this.screenWidth - padding - 60, y + 18, '结局', '#ffd700');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// 滚动条
|
||||||
|
if (this.maxScrollY > 0) {
|
||||||
|
const scrollBarHeight = 50;
|
||||||
|
const scrollBarY = startY + (this.scrollY / this.maxScrollY) * (this.screenHeight - startY - scrollBarHeight - 20);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
||||||
|
this.roundRect(ctx, this.screenWidth - 5, scrollBarY, 3, scrollBarHeight, 1.5);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getEndingColors(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'good': return ['rgba(100,200,100,0.3)', 'rgba(50,150,50,0.2)'];
|
||||||
|
case 'bad': return ['rgba(200,100,100,0.3)', 'rgba(150,50,50,0.2)'];
|
||||||
|
case 'hidden': return ['rgba(255,215,0,0.3)', 'rgba(200,150,0,0.2)'];
|
||||||
|
default: return ['rgba(150,150,255,0.3)', 'rgba(100,100,200,0.2)'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTag(ctx, x, y, text, color) {
|
||||||
|
ctx.font = '10px sans-serif';
|
||||||
|
const width = ctx.measureText(text).width + 12;
|
||||||
|
ctx.fillStyle = color + '40';
|
||||||
|
this.roundRect(ctx, x, y, width, 18, 9);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(text, x + width / 2, y + 13);
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
truncateText(ctx, text, maxWidth) {
|
||||||
|
if (!text) return '';
|
||||||
|
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||||
|
let truncated = text;
|
||||||
|
while (truncated.length > 0 && ctx.measureText(truncated + '...').width > maxWidth) {
|
||||||
|
truncated = truncated.slice(0, -1);
|
||||||
|
}
|
||||||
|
return truncated + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
roundRect(ctx, x, y, width, height, radius) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchStart(e) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.lastTouchY = touch.clientY;
|
||||||
|
this.touchStartY = touch.clientY;
|
||||||
|
this.touchStartX = touch.clientX;
|
||||||
|
this.isDragging = true;
|
||||||
|
this.hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(e) {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const deltaY = this.lastTouchY - touch.clientY;
|
||||||
|
if (Math.abs(deltaY) > 3) {
|
||||||
|
this.hasMoved = true;
|
||||||
|
}
|
||||||
|
this.scrollY += deltaY;
|
||||||
|
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||||
|
this.lastTouchY = touch.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd(e) {
|
||||||
|
this.isDragging = false;
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const x = touch.clientX;
|
||||||
|
const y = touch.clientY;
|
||||||
|
|
||||||
|
if (this.hasMoved) return;
|
||||||
|
|
||||||
|
// 返回按钮
|
||||||
|
if (y < 60 && x < 80) {
|
||||||
|
this.main.sceneManager.switchScene('ending', {
|
||||||
|
storyId: this.storyId,
|
||||||
|
ending: this.main.storyManager.getEndingInfo()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击节点
|
||||||
|
const padding = 15;
|
||||||
|
const cardHeight = 85;
|
||||||
|
const cardMargin = 12;
|
||||||
|
const startY = 80;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.nodeList.length; i++) {
|
||||||
|
const cardY = startY + i * (cardHeight + cardMargin) - this.scrollY;
|
||||||
|
if (y >= cardY && y <= cardY + cardHeight && x >= padding && x <= this.screenWidth - padding) {
|
||||||
|
this.selectNode(this.nodeList[i].key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNode(nodeKey) {
|
||||||
|
this.main.storyManager.currentNodeKey = nodeKey;
|
||||||
|
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
|
||||||
|
}
|
||||||
|
}
|
||||||
745
client/js/scenes/EndingScene.js
Normal file
745
client/js/scenes/EndingScene.js
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
/**
|
||||||
|
* 结局场景
|
||||||
|
*/
|
||||||
|
import BaseScene from './BaseScene';
|
||||||
|
|
||||||
|
export default class EndingScene extends BaseScene {
|
||||||
|
constructor(main, params) {
|
||||||
|
super(main, params);
|
||||||
|
this.storyId = params.storyId;
|
||||||
|
this.ending = params.ending;
|
||||||
|
this.showButtons = false;
|
||||||
|
this.fadeIn = 0;
|
||||||
|
this.particles = [];
|
||||||
|
this.isLiked = false;
|
||||||
|
this.isCollected = false;
|
||||||
|
// AI改写面板
|
||||||
|
this.showRewritePanel = false;
|
||||||
|
this.rewritePrompt = '';
|
||||||
|
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢'];
|
||||||
|
this.selectedTag = -1;
|
||||||
|
this.initParticles();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.showButtons = true;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
initParticles() {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
this.particles.push({
|
||||||
|
x: Math.random() * this.screenWidth,
|
||||||
|
y: Math.random() * this.screenHeight,
|
||||||
|
size: Math.random() * 3 + 1,
|
||||||
|
speedY: Math.random() * 0.5 + 0.2,
|
||||||
|
alpha: Math.random() * 0.5 + 0.3
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (this.fadeIn < 1) {
|
||||||
|
this.fadeIn += 0.02;
|
||||||
|
}
|
||||||
|
this.particles.forEach(p => {
|
||||||
|
p.y -= p.speedY;
|
||||||
|
if (p.y < 0) {
|
||||||
|
p.y = this.screenHeight;
|
||||||
|
p.x = Math.random() * this.screenWidth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx) {
|
||||||
|
this.renderBackground(ctx);
|
||||||
|
this.renderParticles(ctx);
|
||||||
|
this.renderEndingContent(ctx);
|
||||||
|
if (this.showButtons) {
|
||||||
|
this.renderButtons(ctx);
|
||||||
|
}
|
||||||
|
// AI改写面板
|
||||||
|
if (this.showRewritePanel) {
|
||||||
|
this.renderRewritePanel(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBackground(ctx) {
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
|
||||||
|
switch (this.ending?.type) {
|
||||||
|
case 'good':
|
||||||
|
gradient.addColorStop(0, '#0f2027');
|
||||||
|
gradient.addColorStop(0.5, '#203a43');
|
||||||
|
gradient.addColorStop(1, '#2c5364');
|
||||||
|
break;
|
||||||
|
case 'bad':
|
||||||
|
gradient.addColorStop(0, '#1a0a0a');
|
||||||
|
gradient.addColorStop(0.5, '#3a1515');
|
||||||
|
gradient.addColorStop(1, '#2d1f1f');
|
||||||
|
break;
|
||||||
|
case 'hidden':
|
||||||
|
gradient.addColorStop(0, '#1a1a0a');
|
||||||
|
gradient.addColorStop(0.5, '#3a3515');
|
||||||
|
gradient.addColorStop(1, '#2d2d1f');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
gradient.addColorStop(0, '#0f0c29');
|
||||||
|
gradient.addColorStop(0.5, '#302b63');
|
||||||
|
gradient.addColorStop(1, '#24243e');
|
||||||
|
}
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderParticles(ctx) {
|
||||||
|
this.particles.forEach(p => {
|
||||||
|
ctx.fillStyle = `rgba(255, 255, 255, ${p.alpha * this.fadeIn})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEndingContent(ctx) {
|
||||||
|
const centerX = this.screenWidth / 2;
|
||||||
|
const alpha = this.fadeIn;
|
||||||
|
const padding = 20;
|
||||||
|
|
||||||
|
// 结局卡片背景
|
||||||
|
const cardY = 80;
|
||||||
|
const cardHeight = 320;
|
||||||
|
ctx.fillStyle = `rgba(255, 255, 255, ${0.05 * alpha})`;
|
||||||
|
this.roundRect(ctx, padding, cardY, this.screenWidth - padding * 2, cardHeight, 20);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 装饰线
|
||||||
|
const lineGradient = ctx.createLinearGradient(padding + 30, cardY + 20, this.screenWidth - padding - 30, cardY + 20);
|
||||||
|
lineGradient.addColorStop(0, 'transparent');
|
||||||
|
lineGradient.addColorStop(0.5, this.getEndingColorRgba(alpha * 0.5));
|
||||||
|
lineGradient.addColorStop(1, 'transparent');
|
||||||
|
ctx.strokeStyle = lineGradient;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding + 30, cardY + 20);
|
||||||
|
ctx.lineTo(this.screenWidth - padding - 30, cardY + 20);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 结局标签
|
||||||
|
ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.6})`;
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('— 达成结局 —', centerX, cardY + 50);
|
||||||
|
|
||||||
|
// 结局名称(自动调整字号)
|
||||||
|
const endingName = this.ending?.name || '未知结局';
|
||||||
|
let fontSize = 24;
|
||||||
|
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||||
|
while (ctx.measureText(endingName).width > this.screenWidth - padding * 2 - 40 && fontSize > 14) {
|
||||||
|
fontSize -= 2;
|
||||||
|
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||||
|
}
|
||||||
|
ctx.fillStyle = this.getEndingColorRgba(alpha);
|
||||||
|
ctx.fillText(endingName, centerX, cardY + 90);
|
||||||
|
|
||||||
|
// 结局类型标签
|
||||||
|
const typeLabel = this.getTypeLabel();
|
||||||
|
if (typeLabel) {
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
const labelWidth = ctx.measureText(typeLabel).width + 20;
|
||||||
|
ctx.fillStyle = this.getEndingColorRgba(alpha * 0.3);
|
||||||
|
this.roundRect(ctx, centerX - labelWidth / 2, cardY + 100, labelWidth, 22, 11);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.9})`;
|
||||||
|
ctx.fillText(typeLabel, centerX, cardY + 115);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 评分
|
||||||
|
if (this.ending?.score !== undefined) {
|
||||||
|
this.renderScore(ctx, centerX, cardY + 155, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结局描述(居中显示,限制在卡片内)
|
||||||
|
ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.6})`;
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const content = this.ending?.content || '';
|
||||||
|
const lastParagraph = content.split('\n').filter(p => p.trim()).pop() || '';
|
||||||
|
const maxWidth = this.screenWidth - padding * 2 - 30;
|
||||||
|
// 限制只显示2行,居中
|
||||||
|
this.wrapTextCentered(ctx, lastParagraph, centerX, cardY + 250, maxWidth, 20, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTypeLabel() {
|
||||||
|
switch (this.ending?.type) {
|
||||||
|
case 'good': return '✨ 完美结局';
|
||||||
|
case 'bad': return '💔 悲伤结局';
|
||||||
|
case 'hidden': return '🔮 隐藏结局';
|
||||||
|
default: return '📖 普通结局';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
truncateText(ctx, text, maxWidth) {
|
||||||
|
if (!text) return '';
|
||||||
|
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||||
|
let truncated = text;
|
||||||
|
while (truncated.length > 0 && ctx.measureText(truncated + '...').width > maxWidth) {
|
||||||
|
truncated = truncated.slice(0, -1);
|
||||||
|
}
|
||||||
|
return truncated + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderScore(ctx, x, y, alpha) {
|
||||||
|
const score = this.ending?.score || 0;
|
||||||
|
const stars = Math.ceil(score / 20);
|
||||||
|
|
||||||
|
// 星星
|
||||||
|
const starSize = 22;
|
||||||
|
const gap = 6;
|
||||||
|
const totalWidth = 5 * starSize + 4 * gap;
|
||||||
|
const startX = x - totalWidth / 2;
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const filled = i < stars;
|
||||||
|
ctx.fillStyle = filled ? `rgba(255, 215, 0, ${alpha})` : `rgba(100, 100, 100, ${alpha * 0.5})`;
|
||||||
|
ctx.font = `${starSize}px sans-serif`;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(filled ? '★' : '☆', startX + i * (starSize + gap), y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分数
|
||||||
|
ctx.fillStyle = `rgba(255, 215, 0, ${alpha})`;
|
||||||
|
ctx.font = 'bold 16px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(`${score}分`, x, y + 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEndingColorRgba(alpha) {
|
||||||
|
switch (this.ending?.type) {
|
||||||
|
case 'good': return `rgba(100, 255, 150, ${alpha})`;
|
||||||
|
case 'bad': return `rgba(255, 100, 100, ${alpha})`;
|
||||||
|
case 'hidden': return `rgba(255, 215, 0, ${alpha})`;
|
||||||
|
default: return `rgba(150, 150, 255, ${alpha})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderButtons(ctx) {
|
||||||
|
const padding = 15;
|
||||||
|
const buttonHeight = 38;
|
||||||
|
const buttonMargin = 8;
|
||||||
|
const startY = this.screenHeight - 220;
|
||||||
|
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
|
||||||
|
|
||||||
|
// AI改写按钮(突出显示)
|
||||||
|
this.renderGradientButton(ctx, padding, startY, this.screenWidth - padding * 2, buttonHeight, '✨ AI改写结局', ['#a855f7', '#ec4899']);
|
||||||
|
|
||||||
|
// 分享按钮
|
||||||
|
const row2Y = startY + buttonHeight + buttonMargin;
|
||||||
|
this.renderGradientButton(ctx, padding, row2Y, buttonWidth, buttonHeight, '分享结局', ['#ff6b6b', '#ffd700']);
|
||||||
|
|
||||||
|
// 章节选择按钮
|
||||||
|
this.renderGradientButton(ctx, padding + buttonWidth + buttonMargin, row2Y, buttonWidth, buttonHeight, '章节选择', ['#667eea', '#764ba2']);
|
||||||
|
|
||||||
|
// 从头开始
|
||||||
|
const row3Y = row2Y + buttonHeight + buttonMargin;
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
this.roundRect(ctx, padding, row3Y, buttonWidth, buttonHeight, 19);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
this.roundRect(ctx, padding, row3Y, buttonWidth, buttonHeight, 19);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('从头开始', padding + buttonWidth / 2, row3Y + 24);
|
||||||
|
|
||||||
|
// 返回首页
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
this.roundRect(ctx, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight, 19);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||||
|
this.roundRect(ctx, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight, 19);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillText('返回首页', padding + buttonWidth + buttonMargin + buttonWidth / 2, row3Y + 24);
|
||||||
|
|
||||||
|
// 点赞和收藏
|
||||||
|
const actionY = row3Y + buttonHeight + 18;
|
||||||
|
const centerX = this.screenWidth / 2;
|
||||||
|
|
||||||
|
ctx.font = '20px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(this.isLiked ? '❤️' : '🤍', centerX - 40, actionY);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||||
|
ctx.font = '10px sans-serif';
|
||||||
|
ctx.fillText('点赞', centerX - 40, actionY + 16);
|
||||||
|
|
||||||
|
ctx.font = '20px sans-serif';
|
||||||
|
ctx.fillText(this.isCollected ? '⭐' : '☆', centerX + 40, actionY);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||||
|
ctx.font = '10px sans-serif';
|
||||||
|
ctx.fillText('收藏', centerX + 40, actionY + 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGradientButton(ctx, x, y, width, height, text, colors) {
|
||||||
|
const gradient = ctx.createLinearGradient(x, y, x + width, y);
|
||||||
|
gradient.addColorStop(0, colors[0]);
|
||||||
|
gradient.addColorStop(1, colors[1]);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
this.roundRect(ctx, x, y, width, height, 22);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 14px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(text, x + width / 2, y + height / 2 + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRewritePanel(ctx) {
|
||||||
|
const padding = 20;
|
||||||
|
const panelWidth = this.screenWidth - padding * 2;
|
||||||
|
const panelHeight = 380;
|
||||||
|
const panelX = padding;
|
||||||
|
const panelY = (this.screenHeight - panelHeight) / 2;
|
||||||
|
|
||||||
|
// 遮罩层
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
|
|
||||||
|
// 面板背景渐变
|
||||||
|
const panelGradient = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight);
|
||||||
|
panelGradient.addColorStop(0, '#1a1a3e');
|
||||||
|
panelGradient.addColorStop(1, '#0d0d1a');
|
||||||
|
ctx.fillStyle = panelGradient;
|
||||||
|
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 面板边框渐变
|
||||||
|
const borderGradient = ctx.createLinearGradient(panelX, panelY, panelX + panelWidth, panelY);
|
||||||
|
borderGradient.addColorStop(0, '#a855f7');
|
||||||
|
borderGradient.addColorStop(1, '#ec4899');
|
||||||
|
ctx.strokeStyle = borderGradient;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 18px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('✨ AI改写结局', this.screenWidth / 2, panelY + 35);
|
||||||
|
|
||||||
|
// 副标题
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.fillText('输入你想要的剧情走向,AI将为你重新创作', this.screenWidth / 2, panelY + 58);
|
||||||
|
|
||||||
|
// 分隔线
|
||||||
|
const lineGradient = ctx.createLinearGradient(panelX + 20, panelY + 75, panelX + panelWidth - 20, panelY + 75);
|
||||||
|
lineGradient.addColorStop(0, 'transparent');
|
||||||
|
lineGradient.addColorStop(0.5, 'rgba(168,85,247,0.5)');
|
||||||
|
lineGradient.addColorStop(1, 'transparent');
|
||||||
|
ctx.strokeStyle = lineGradient;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(panelX + 20, panelY + 75);
|
||||||
|
ctx.lineTo(panelX + panelWidth - 20, panelY + 75);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 快捷标签标题
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('快捷选择:', panelX + 15, panelY + 105);
|
||||||
|
|
||||||
|
// 快捷标签
|
||||||
|
const tagStartX = panelX + 15;
|
||||||
|
const tagY = panelY + 120;
|
||||||
|
const tagHeight = 32;
|
||||||
|
const tagGap = 8;
|
||||||
|
let currentX = tagStartX;
|
||||||
|
let currentY = tagY;
|
||||||
|
|
||||||
|
this.tagRects = [];
|
||||||
|
this.rewriteTags.forEach((tag, index) => {
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
const tagWidth = ctx.measureText(tag).width + 24;
|
||||||
|
|
||||||
|
// 换行
|
||||||
|
if (currentX + tagWidth > panelX + panelWidth - 15) {
|
||||||
|
currentX = tagStartX;
|
||||||
|
currentY += tagHeight + tagGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签背景
|
||||||
|
const isSelected = index === this.selectedTag;
|
||||||
|
if (isSelected) {
|
||||||
|
const tagGradient = ctx.createLinearGradient(currentX, currentY, currentX + tagWidth, currentY);
|
||||||
|
tagGradient.addColorStop(0, '#a855f7');
|
||||||
|
tagGradient.addColorStop(1, '#ec4899');
|
||||||
|
ctx.fillStyle = tagGradient;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
}
|
||||||
|
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 标签边框
|
||||||
|
ctx.strokeStyle = isSelected ? 'transparent' : 'rgba(255,255,255,0.2)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 标签文字
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(tag, currentX + tagWidth / 2, currentY + 21);
|
||||||
|
|
||||||
|
// 存储标签位置
|
||||||
|
this.tagRects.push({ x: currentX, y: currentY, width: tagWidth, height: tagHeight, index });
|
||||||
|
|
||||||
|
currentX += tagWidth + tagGap;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自定义输入提示
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('或自定义输入:', panelX + 15, panelY + 215);
|
||||||
|
|
||||||
|
// 输入框背景
|
||||||
|
const inputY = panelY + 230;
|
||||||
|
const inputHeight = 45;
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 输入框文字或占位符
|
||||||
|
ctx.font = '14px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
if (this.rewritePrompt) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillText(this.rewritePrompt, panelX + 28, inputY + 28);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.fillText('点击输入你的改写想法...', panelX + 28, inputY + 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按钮
|
||||||
|
const btnY = panelY + panelHeight - 70;
|
||||||
|
const btnWidth = (panelWidth - 50) / 2;
|
||||||
|
const btnHeight = 44;
|
||||||
|
|
||||||
|
// 取消按钮
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '14px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('取消', panelX + 15 + btnWidth / 2, btnY + 28);
|
||||||
|
|
||||||
|
// 确认按钮
|
||||||
|
const confirmGradient = ctx.createLinearGradient(panelX + 35 + btnWidth, btnY, panelX + 35 + btnWidth * 2, btnY);
|
||||||
|
confirmGradient.addColorStop(0, '#a855f7');
|
||||||
|
confirmGradient.addColorStop(1, '#ec4899');
|
||||||
|
ctx.fillStyle = confirmGradient;
|
||||||
|
this.roundRect(ctx, panelX + 35 + btnWidth, btnY, btnWidth, btnHeight, 22);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 14px sans-serif';
|
||||||
|
ctx.fillText('✨ 开始改写', panelX + 35 + btnWidth + btnWidth / 2, btnY + 28);
|
||||||
|
|
||||||
|
// 存储按钮区域
|
||||||
|
this.cancelBtnRect = { x: panelX + 15, y: btnY, width: btnWidth, height: btnHeight };
|
||||||
|
this.confirmBtnRect = { x: panelX + 35 + btnWidth, y: btnY, width: btnWidth, height: btnHeight };
|
||||||
|
this.inputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 圆角矩形
|
||||||
|
roundRect(ctx, x, y, width, height, radius) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字换行
|
||||||
|
wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
||||||
|
if (!text) return;
|
||||||
|
let line = '';
|
||||||
|
let lineY = y;
|
||||||
|
for (let char of text) {
|
||||||
|
const testLine = line + char;
|
||||||
|
if (ctx.measureText(testLine).width > maxWidth) {
|
||||||
|
ctx.fillText(line, x, lineY);
|
||||||
|
line = char;
|
||||||
|
lineY += lineHeight;
|
||||||
|
} else {
|
||||||
|
line = testLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.fillText(line, x, lineY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制行数的文字换行
|
||||||
|
wrapTextLimited(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||||
|
if (!text) return;
|
||||||
|
let line = '';
|
||||||
|
let lineY = y;
|
||||||
|
let lineCount = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
const testLine = line + char;
|
||||||
|
if (ctx.measureText(testLine).width > maxWidth) {
|
||||||
|
lineCount++;
|
||||||
|
if (lineCount >= maxLines) {
|
||||||
|
// 最后一行加省略号
|
||||||
|
while (line.length > 0 && ctx.measureText(line + '...').width > maxWidth) {
|
||||||
|
line = line.slice(0, -1);
|
||||||
|
}
|
||||||
|
ctx.fillText(line + '...', x, lineY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.fillText(line, x, lineY);
|
||||||
|
line = char;
|
||||||
|
lineY += lineHeight;
|
||||||
|
} else {
|
||||||
|
line = testLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.fillText(line, x, lineY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 居中显示的限制行数文字换行
|
||||||
|
wrapTextCentered(ctx, text, centerX, y, maxWidth, lineHeight, maxLines) {
|
||||||
|
if (!text) return;
|
||||||
|
// 先分行
|
||||||
|
const lines = [];
|
||||||
|
let line = '';
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
const testLine = line + char;
|
||||||
|
if (ctx.measureText(testLine).width > maxWidth) {
|
||||||
|
lines.push(line);
|
||||||
|
line = char;
|
||||||
|
if (lines.length >= maxLines) break;
|
||||||
|
} else {
|
||||||
|
line = testLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line && lines.length < maxLines) {
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
// 如果超出行数,最后一行加省略号
|
||||||
|
if (lines.length >= maxLines && line) {
|
||||||
|
let lastLine = lines[maxLines - 1];
|
||||||
|
while (lastLine.length > 0 && ctx.measureText(lastLine + '...').width > maxWidth) {
|
||||||
|
lastLine = lastLine.slice(0, -1);
|
||||||
|
}
|
||||||
|
lines[maxLines - 1] = lastLine + '...';
|
||||||
|
}
|
||||||
|
// 居中绘制
|
||||||
|
lines.slice(0, maxLines).forEach((l, i) => {
|
||||||
|
ctx.fillText(l, centerX, y + i * lineHeight);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd(e) {
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const x = touch.clientX;
|
||||||
|
const y = touch.clientY;
|
||||||
|
|
||||||
|
// 如果改写面板打开,优先处理
|
||||||
|
if (this.showRewritePanel) {
|
||||||
|
this.handleRewritePanelTouch(x, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.showButtons) return;
|
||||||
|
|
||||||
|
const padding = 15;
|
||||||
|
const buttonHeight = 38;
|
||||||
|
const buttonMargin = 8;
|
||||||
|
const startY = this.screenHeight - 220;
|
||||||
|
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
|
||||||
|
|
||||||
|
// AI改写按钮
|
||||||
|
if (this.isInRect(x, y, padding, startY, this.screenWidth - padding * 2, buttonHeight)) {
|
||||||
|
this.handleAIRewrite();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分享按钮
|
||||||
|
const row2Y = startY + buttonHeight + buttonMargin;
|
||||||
|
if (this.isInRect(x, y, padding, row2Y, buttonWidth, buttonHeight)) {
|
||||||
|
this.handleShare();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 章节选择按钮
|
||||||
|
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, row2Y, buttonWidth, buttonHeight)) {
|
||||||
|
this.handleChapterSelect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从头开始
|
||||||
|
const row3Y = row2Y + buttonHeight + buttonMargin;
|
||||||
|
if (this.isInRect(x, y, padding, row3Y, buttonWidth, buttonHeight)) {
|
||||||
|
this.handleReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回首页
|
||||||
|
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight)) {
|
||||||
|
this.main.sceneManager.switchScene('home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点赞收藏
|
||||||
|
const actionY = row3Y + buttonHeight + 18;
|
||||||
|
const centerX = this.screenWidth / 2;
|
||||||
|
if (this.isInRect(x, y, centerX - 70, actionY - 20, 60, 45)) {
|
||||||
|
this.handleLike();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isInRect(x, y, centerX + 10, actionY - 20, 60, 45)) {
|
||||||
|
this.handleCollect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isInRect(x, y, rx, ry, rw, rh) {
|
||||||
|
return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleShare() {
|
||||||
|
wx.shareAppMessage({
|
||||||
|
title: `我在《星域故事汇》达成了「${this.ending?.name}」结局!`,
|
||||||
|
imageUrl: '',
|
||||||
|
query: `storyId=${this.storyId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChapterSelect() {
|
||||||
|
this.main.sceneManager.switchScene('chapter', { storyId: this.storyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAIRewrite() {
|
||||||
|
// 显示AI改写面板
|
||||||
|
this.showRewritePanel = true;
|
||||||
|
this.rewritePrompt = '';
|
||||||
|
this.selectedTag = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRewritePanelTouch(x, y) {
|
||||||
|
// 点击标签
|
||||||
|
if (this.tagRects) {
|
||||||
|
for (const tag of this.tagRects) {
|
||||||
|
if (this.isInRect(x, y, tag.x, tag.y, tag.width, tag.height)) {
|
||||||
|
this.selectedTag = tag.index;
|
||||||
|
this.rewritePrompt = this.rewriteTags[tag.index];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击输入框
|
||||||
|
if (this.inputRect && this.isInRect(x, y, this.inputRect.x, this.inputRect.y, this.inputRect.width, this.inputRect.height)) {
|
||||||
|
this.showCustomInput();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击取消
|
||||||
|
if (this.cancelBtnRect && this.isInRect(x, y, this.cancelBtnRect.x, this.cancelBtnRect.y, this.cancelBtnRect.width, this.cancelBtnRect.height)) {
|
||||||
|
this.showRewritePanel = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击确认
|
||||||
|
if (this.confirmBtnRect && this.isInRect(x, y, this.confirmBtnRect.x, this.confirmBtnRect.y, this.confirmBtnRect.width, this.confirmBtnRect.height)) {
|
||||||
|
if (this.rewritePrompt) {
|
||||||
|
this.showRewritePanel = false;
|
||||||
|
this.callAIRewrite(this.rewritePrompt);
|
||||||
|
} else {
|
||||||
|
wx.showToast({ title: '请选择或输入改写内容', icon: 'none' });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
showCustomInput() {
|
||||||
|
wx.showModal({
|
||||||
|
title: '输入改写想法',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '例如:让主角获得逆袭',
|
||||||
|
content: this.rewritePrompt,
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm && res.content) {
|
||||||
|
this.rewritePrompt = res.content;
|
||||||
|
this.selectedTag = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async callAIRewrite(prompt) {
|
||||||
|
wx.showLoading({ title: 'AI创作中...' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.main.storyManager.rewriteEnding(
|
||||||
|
this.storyId,
|
||||||
|
this.ending,
|
||||||
|
prompt
|
||||||
|
);
|
||||||
|
|
||||||
|
wx.hideLoading();
|
||||||
|
|
||||||
|
if (result && result.content) {
|
||||||
|
// 跳转到故事场景播放新内容
|
||||||
|
this.main.sceneManager.switchScene('story', {
|
||||||
|
storyId: this.storyId,
|
||||||
|
aiContent: result
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
wx.showToast({ title: '改写失败,请重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
wx.hideLoading();
|
||||||
|
wx.showToast({ title: '网络错误', icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReplay() {
|
||||||
|
this.main.storyManager.resetStory();
|
||||||
|
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLike() {
|
||||||
|
this.isLiked = !this.isLiked;
|
||||||
|
this.main.userManager.likeStory(this.storyId, this.isLiked);
|
||||||
|
this.main.storyManager.likeStory(this.isLiked);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCollect() {
|
||||||
|
this.isCollected = !this.isCollected;
|
||||||
|
this.main.userManager.collectStory(this.storyId, this.isCollected);
|
||||||
|
}
|
||||||
|
}
|
||||||
537
client/js/scenes/HomeScene.js
Normal file
537
client/js/scenes/HomeScene.js
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
/**
|
||||||
|
* 首页场景
|
||||||
|
*/
|
||||||
|
import BaseScene from './BaseScene';
|
||||||
|
|
||||||
|
export default class HomeScene extends BaseScene {
|
||||||
|
constructor(main, params) {
|
||||||
|
super(main, params);
|
||||||
|
this.storyList = [];
|
||||||
|
this.scrollY = 0;
|
||||||
|
this.maxScrollY = 0;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.lastTouchY = 0;
|
||||||
|
this.scrollVelocity = 0;
|
||||||
|
this.currentTab = 0; // 0: 首页, 1: 发现, 2: 我的
|
||||||
|
this.categories = ['全部', '都市言情', '悬疑推理', '古风宫廷', '校园青春', '修仙玄幻', '穿越重生', '职场商战', '科幻未来', '恐怖惊悚', '搞笑轻喜'];
|
||||||
|
this.selectedCategory = 0;
|
||||||
|
// 分类横向滚动
|
||||||
|
this.categoryScrollX = 0;
|
||||||
|
this.maxCategoryScrollX = 0;
|
||||||
|
this.isCategoryDragging = false;
|
||||||
|
this.lastTouchX = 0;
|
||||||
|
// 存储分类标签位置(用于点击判定)
|
||||||
|
this.categoryRects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// 加载故事列表
|
||||||
|
this.storyList = this.main.storyManager.storyList;
|
||||||
|
this.calculateMaxScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取过滤后的故事列表
|
||||||
|
getFilteredStories() {
|
||||||
|
if (this.selectedCategory === 0) {
|
||||||
|
return this.storyList; // 全部
|
||||||
|
}
|
||||||
|
const categoryName = this.categories[this.selectedCategory];
|
||||||
|
return this.storyList.filter(s => s.category === categoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateMaxScroll() {
|
||||||
|
// 计算最大滚动距离
|
||||||
|
const stories = this.getFilteredStories();
|
||||||
|
const cardHeight = 120;
|
||||||
|
const gap = 15;
|
||||||
|
const startY = 150; // 故事列表起始位置
|
||||||
|
const tabHeight = 65;
|
||||||
|
|
||||||
|
// 内容总高度
|
||||||
|
const contentBottom = startY + stories.length * (cardHeight + gap);
|
||||||
|
// 可视区域底部(减去底部Tab栏)
|
||||||
|
const visibleBottom = this.screenHeight - tabHeight;
|
||||||
|
|
||||||
|
// 最大滚动距离 = 内容超出可视区域的部分
|
||||||
|
this.maxScrollY = Math.max(0, contentBottom - visibleBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
// 滚动惯性
|
||||||
|
if (!this.isDragging && Math.abs(this.scrollVelocity) > 0.5) {
|
||||||
|
this.scrollY += this.scrollVelocity;
|
||||||
|
this.scrollVelocity *= 0.95;
|
||||||
|
|
||||||
|
// 边界检查
|
||||||
|
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx) {
|
||||||
|
// 绘制背景渐变(深紫蓝色调)
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
|
||||||
|
gradient.addColorStop(0, '#0f0c29');
|
||||||
|
gradient.addColorStop(0.5, '#302b63');
|
||||||
|
gradient.addColorStop(1, '#24243e');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
|
|
||||||
|
// 添加星空装饰点
|
||||||
|
this.renderStars(ctx);
|
||||||
|
|
||||||
|
// 绘制顶部标题栏
|
||||||
|
this.renderHeader(ctx);
|
||||||
|
|
||||||
|
// 绘制分类标签
|
||||||
|
this.renderCategories(ctx);
|
||||||
|
|
||||||
|
// 绘制故事列表
|
||||||
|
this.renderStoryList(ctx);
|
||||||
|
|
||||||
|
// 绘制底部Tab栏
|
||||||
|
this.renderTabBar(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStars(ctx) {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||||
|
const stars = [[30, 80], [80, 30], [150, 60], [200, 25], [280, 70], [320, 40]];
|
||||||
|
stars.forEach(([x, y]) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 1, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader(ctx) {
|
||||||
|
// 标题带渐变
|
||||||
|
const titleGradient = ctx.createLinearGradient(20, 30, 200, 30);
|
||||||
|
titleGradient.addColorStop(0, '#ffd700');
|
||||||
|
titleGradient.addColorStop(1, '#ff6b6b');
|
||||||
|
ctx.fillStyle = titleGradient;
|
||||||
|
ctx.font = 'bold 26px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('星域故事汇', 20, 50);
|
||||||
|
|
||||||
|
// 副标题
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.fillText('每个选择,都是一个新世界', 20, 75);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCategories(ctx) {
|
||||||
|
const startY = 95;
|
||||||
|
const tagHeight = 30;
|
||||||
|
let x = 15 - this.categoryScrollX;
|
||||||
|
const y = startY;
|
||||||
|
|
||||||
|
// 计算总宽度用于滚动限制
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
let totalWidth = 15;
|
||||||
|
this.categories.forEach((cat) => {
|
||||||
|
totalWidth += ctx.measureText(cat).width + 28 + 12;
|
||||||
|
});
|
||||||
|
this.maxCategoryScrollX = Math.max(0, totalWidth - this.screenWidth + 15);
|
||||||
|
|
||||||
|
// 清空并重新记录位置
|
||||||
|
this.categoryRects = [];
|
||||||
|
|
||||||
|
this.categories.forEach((cat, index) => {
|
||||||
|
const isSelected = index === this.selectedCategory;
|
||||||
|
const textWidth = ctx.measureText(cat).width + 28;
|
||||||
|
|
||||||
|
// 记录每个标签的位置
|
||||||
|
this.categoryRects.push({
|
||||||
|
left: x + this.categoryScrollX,
|
||||||
|
right: x + this.categoryScrollX + textWidth,
|
||||||
|
index: index
|
||||||
|
});
|
||||||
|
|
||||||
|
// 只渲染可见的标签
|
||||||
|
if (x + textWidth > 0 && x < this.screenWidth) {
|
||||||
|
if (isSelected) {
|
||||||
|
// 选中态:渐变背景
|
||||||
|
const tagGradient = ctx.createLinearGradient(x, y, x + textWidth, y);
|
||||||
|
tagGradient.addColorStop(0, '#ff6b6b');
|
||||||
|
tagGradient.addColorStop(1, '#ffd700');
|
||||||
|
ctx.fillStyle = tagGradient;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
}
|
||||||
|
this.roundRect(ctx, x, y, textWidth, tagHeight, 15);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 未选中态加边框
|
||||||
|
if (!isSelected) {
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
this.roundRect(ctx, x, y, textWidth, tagHeight, 15);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签文字
|
||||||
|
ctx.fillStyle = isSelected ? '#ffffff' : 'rgba(255,255,255,0.7)';
|
||||||
|
ctx.font = isSelected ? 'bold 13px sans-serif' : '13px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(cat, x + textWidth / 2, y + 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
x += textWidth + 12;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStoryList(ctx) {
|
||||||
|
const startY = 150;
|
||||||
|
const cardHeight = 120;
|
||||||
|
const cardPadding = 15;
|
||||||
|
const cardMargin = 15;
|
||||||
|
|
||||||
|
const stories = this.getFilteredStories();
|
||||||
|
|
||||||
|
if (stories.length === 0) {
|
||||||
|
ctx.fillStyle = '#666666';
|
||||||
|
ctx.font = '14px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('暂无该分类的故事', this.screenWidth / 2, 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置裁剪区域,防止卡片渲染到分类标签区域
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(0, startY, this.screenWidth, this.screenHeight - startY - 65);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
stories.forEach((story, index) => {
|
||||||
|
const y = startY + index * (cardHeight + cardMargin) - this.scrollY;
|
||||||
|
|
||||||
|
// 只渲染可见区域的卡片
|
||||||
|
if (y > startY - cardHeight && y < this.screenHeight - 65) {
|
||||||
|
this.renderStoryCard(ctx, story, cardMargin, y, this.screenWidth - cardMargin * 2, cardHeight);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStoryCard(ctx, story, x, y, width, height) {
|
||||||
|
// 卡片背景 - 毛玻璃效果
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||||
|
this.roundRect(ctx, x, y, width, height, 16);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 卡片高光边框
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
this.roundRect(ctx, x, y, width, height, 16);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 封面区域 - 渐变色
|
||||||
|
const coverWidth = 85;
|
||||||
|
const coverHeight = height - 24;
|
||||||
|
const coverGradient = ctx.createLinearGradient(x + 12, y + 12, x + 12 + coverWidth, y + 12 + coverHeight);
|
||||||
|
const colors = this.getCategoryGradient(story.category);
|
||||||
|
coverGradient.addColorStop(0, colors[0]);
|
||||||
|
coverGradient.addColorStop(1, colors[1]);
|
||||||
|
ctx.fillStyle = coverGradient;
|
||||||
|
this.roundRect(ctx, x + 12, y + 12, coverWidth, coverHeight, 12);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 封面上的分类名
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
||||||
|
ctx.font = 'bold 11px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(story.category || '故事', x + 12 + coverWidth / 2, y + 12 + coverHeight / 2 + 4);
|
||||||
|
|
||||||
|
// 故事标题
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 17px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
const titleX = x + 110;
|
||||||
|
const maxTextWidth = width - 120; // 可用文字宽度
|
||||||
|
const title = this.truncateText(ctx, story.title, maxTextWidth);
|
||||||
|
ctx.fillText(title, titleX, y + 35);
|
||||||
|
|
||||||
|
// 故事简介
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
const desc = story.description || '';
|
||||||
|
const shortDesc = this.truncateText(ctx, desc, maxTextWidth);
|
||||||
|
ctx.fillText(shortDesc, titleX, y + 58);
|
||||||
|
|
||||||
|
// 统计信息带图标
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
const playText = `▶ ${this.formatNumber(story.play_count)}`;
|
||||||
|
const likeText = `♥ ${this.formatNumber(story.like_count)}`;
|
||||||
|
ctx.fillText(playText, titleX, y + 90);
|
||||||
|
ctx.fillText(likeText, titleX + 70, y + 90);
|
||||||
|
|
||||||
|
// 精选标签 - 更醒目
|
||||||
|
if (story.is_featured) {
|
||||||
|
const tagGradient = ctx.createLinearGradient(x + width - 55, y + 12, x + width - 10, y + 12);
|
||||||
|
tagGradient.addColorStop(0, '#ff6b6b');
|
||||||
|
tagGradient.addColorStop(1, '#ffd700');
|
||||||
|
ctx.fillStyle = tagGradient;
|
||||||
|
this.roundRect(ctx, x + width - 55, y + 12, 45, 22, 11);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 10px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('精选', x + width - 32, y + 27);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryGradient(category) {
|
||||||
|
const gradients = {
|
||||||
|
'都市言情': ['#ff758c', '#ff7eb3'],
|
||||||
|
'悬疑推理': ['#667eea', '#764ba2'],
|
||||||
|
'古风宫廷': ['#f093fb', '#f5576c'],
|
||||||
|
'校园青春': ['#4facfe', '#00f2fe'],
|
||||||
|
'修仙玄幻': ['#43e97b', '#38f9d7'],
|
||||||
|
'穿越重生': ['#fa709a', '#fee140'],
|
||||||
|
'职场商战': ['#30cfd0', '#330867'],
|
||||||
|
'科幻未来': ['#a8edea', '#fed6e3'],
|
||||||
|
'恐怖惊悚': ['#434343', '#000000'],
|
||||||
|
'搞笑轻喜': ['#f6d365', '#fda085']
|
||||||
|
};
|
||||||
|
return gradients[category] || ['#667eea', '#764ba2'];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTabBar(ctx) {
|
||||||
|
const tabHeight = 65;
|
||||||
|
const y = this.screenHeight - tabHeight;
|
||||||
|
|
||||||
|
// Tab栏背景 - 毛玻璃
|
||||||
|
ctx.fillStyle = 'rgba(15, 12, 41, 0.95)';
|
||||||
|
ctx.fillRect(0, y, this.screenWidth, tabHeight);
|
||||||
|
|
||||||
|
// 顶部高光线
|
||||||
|
const lineGradient = ctx.createLinearGradient(0, y, this.screenWidth, y);
|
||||||
|
lineGradient.addColorStop(0, 'rgba(255,107,107,0.3)');
|
||||||
|
lineGradient.addColorStop(0.5, 'rgba(255,215,0,0.3)');
|
||||||
|
lineGradient.addColorStop(1, 'rgba(255,107,107,0.3)');
|
||||||
|
ctx.strokeStyle = lineGradient;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(this.screenWidth, y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ icon: '🏠', label: '首页' },
|
||||||
|
{ icon: '🔍', label: '发现' },
|
||||||
|
{ icon: '👤', label: '我的' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const tabWidth = this.screenWidth / tabs.length;
|
||||||
|
|
||||||
|
tabs.forEach((tab, index) => {
|
||||||
|
const centerX = index * tabWidth + tabWidth / 2;
|
||||||
|
const isActive = index === this.currentTab;
|
||||||
|
|
||||||
|
// 选中态指示器
|
||||||
|
if (isActive) {
|
||||||
|
const indicatorGradient = ctx.createLinearGradient(centerX - 20, y + 3, centerX + 20, y + 3);
|
||||||
|
indicatorGradient.addColorStop(0, '#ff6b6b');
|
||||||
|
indicatorGradient.addColorStop(1, '#ffd700');
|
||||||
|
ctx.fillStyle = indicatorGradient;
|
||||||
|
this.roundRect(ctx, centerX - 20, y + 2, 40, 3, 1.5);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图标
|
||||||
|
ctx.font = '22px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(tab.icon, centerX, y + 32);
|
||||||
|
|
||||||
|
// 标签文字
|
||||||
|
ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.font = isActive ? 'bold 11px sans-serif' : '11px sans-serif';
|
||||||
|
ctx.fillText(tab.label, centerX, y + 52);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分类颜色
|
||||||
|
getCategoryColor(category) {
|
||||||
|
const colors = {
|
||||||
|
'都市言情': '#e94560',
|
||||||
|
'悬疑推理': '#4a90d9',
|
||||||
|
'古风宫廷': '#d4a574',
|
||||||
|
'校园青春': '#7ed957',
|
||||||
|
'修仙玄幻': '#9b59b6',
|
||||||
|
'穿越重生': '#f39c12',
|
||||||
|
'职场商战': '#3498db',
|
||||||
|
'科幻未来': '#1abc9c',
|
||||||
|
'恐怖惊悚': '#2c3e50',
|
||||||
|
'搞笑轻喜': '#f1c40f'
|
||||||
|
};
|
||||||
|
return colors[category] || '#666666';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化数字
|
||||||
|
formatNumber(num) {
|
||||||
|
if (num >= 10000) {
|
||||||
|
return (num / 10000).toFixed(1) + 'w';
|
||||||
|
}
|
||||||
|
if (num >= 1000) {
|
||||||
|
return (num / 1000).toFixed(1) + 'k';
|
||||||
|
}
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截断文字以适应宽度
|
||||||
|
truncateText(ctx, text, maxWidth) {
|
||||||
|
if (!text) return '';
|
||||||
|
if (ctx.measureText(text).width <= maxWidth) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
let truncated = text;
|
||||||
|
while (truncated.length > 0 && ctx.measureText(truncated + '...').width > maxWidth) {
|
||||||
|
truncated = truncated.slice(0, -1);
|
||||||
|
}
|
||||||
|
return truncated + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制圆角矩形
|
||||||
|
roundRect(ctx, x, y, width, height, radius) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchStart(e) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.lastTouchY = touch.clientY;
|
||||||
|
this.lastTouchX = touch.clientX;
|
||||||
|
this.touchStartX = touch.clientX;
|
||||||
|
this.touchStartY = touch.clientY;
|
||||||
|
this.hasMoved = false;
|
||||||
|
|
||||||
|
// 判断是否在分类区域(y: 90-140)
|
||||||
|
if (touch.clientY >= 90 && touch.clientY <= 140) {
|
||||||
|
this.isCategoryDragging = true;
|
||||||
|
this.isDragging = false;
|
||||||
|
} else {
|
||||||
|
this.isCategoryDragging = false;
|
||||||
|
this.isDragging = true;
|
||||||
|
}
|
||||||
|
this.scrollVelocity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(e) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
|
||||||
|
// 分类区域横向滑动
|
||||||
|
if (this.isCategoryDragging) {
|
||||||
|
const deltaX = this.lastTouchX - touch.clientX;
|
||||||
|
if (Math.abs(deltaX) > 2) {
|
||||||
|
this.hasMoved = true;
|
||||||
|
}
|
||||||
|
this.categoryScrollX += deltaX;
|
||||||
|
this.categoryScrollX = Math.max(0, Math.min(this.categoryScrollX, this.maxCategoryScrollX));
|
||||||
|
this.lastTouchX = touch.clientX;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 故事列表纵向滑动
|
||||||
|
if (this.isDragging) {
|
||||||
|
const deltaY = this.lastTouchY - touch.clientY;
|
||||||
|
|
||||||
|
if (Math.abs(touch.clientY - this.touchStartY) > 5) {
|
||||||
|
this.hasMoved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollVelocity = deltaY;
|
||||||
|
this.scrollY += deltaY;
|
||||||
|
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||||
|
this.lastTouchY = touch.clientY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd(e) {
|
||||||
|
this.isDragging = false;
|
||||||
|
this.isCategoryDragging = false;
|
||||||
|
|
||||||
|
// 如果有滑动,不处理点击
|
||||||
|
if (this.hasMoved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测点击
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const x = touch.clientX;
|
||||||
|
const y = touch.clientY;
|
||||||
|
|
||||||
|
// 检测Tab栏点击
|
||||||
|
if (y > this.screenHeight - 65) {
|
||||||
|
const tabWidth = this.screenWidth / 3;
|
||||||
|
const tabIndex = Math.floor(x / tabWidth);
|
||||||
|
this.handleTabClick(tabIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测分类标签点击
|
||||||
|
if (y >= 90 && y <= 140) {
|
||||||
|
this.handleCategoryClick(x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测故事卡片点击
|
||||||
|
this.handleStoryClick(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCategoryClick(x) {
|
||||||
|
// 考虑横向滚动偏移
|
||||||
|
const adjustedX = x + this.categoryScrollX;
|
||||||
|
|
||||||
|
// 使用保存的实际位置判断
|
||||||
|
for (const rect of this.categoryRects) {
|
||||||
|
if (adjustedX >= rect.left && adjustedX <= rect.right) {
|
||||||
|
if (this.selectedCategory !== rect.index) {
|
||||||
|
this.selectedCategory = rect.index;
|
||||||
|
this.scrollY = 0;
|
||||||
|
this.calculateMaxScroll();
|
||||||
|
console.log('选中分类:', this.categories[rect.index]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTabClick(tabIndex) {
|
||||||
|
if (tabIndex === this.currentTab) return;
|
||||||
|
|
||||||
|
this.currentTab = tabIndex;
|
||||||
|
|
||||||
|
if (tabIndex === 2) {
|
||||||
|
// 切换到个人中心
|
||||||
|
this.main.sceneManager.switchScene('profile');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStoryClick(x, y) {
|
||||||
|
const startY = 150;
|
||||||
|
const cardHeight = 120;
|
||||||
|
const cardMargin = 15;
|
||||||
|
|
||||||
|
const stories = this.getFilteredStories();
|
||||||
|
|
||||||
|
// 计算点击的是哪个故事
|
||||||
|
const adjustedY = y + this.scrollY;
|
||||||
|
const index = Math.floor((adjustedY - startY) / (cardHeight + cardMargin));
|
||||||
|
|
||||||
|
if (index >= 0 && index < stories.length) {
|
||||||
|
const story = stories[index];
|
||||||
|
// 跳转到故事播放场景
|
||||||
|
this.main.sceneManager.switchScene('story', { storyId: story.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
371
client/js/scenes/ProfileScene.js
Normal file
371
client/js/scenes/ProfileScene.js
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* 个人中心场景
|
||||||
|
*/
|
||||||
|
import BaseScene from './BaseScene';
|
||||||
|
|
||||||
|
export default class ProfileScene extends BaseScene {
|
||||||
|
constructor(main, params) {
|
||||||
|
super(main, params);
|
||||||
|
this.collections = [];
|
||||||
|
this.progress = [];
|
||||||
|
this.currentTab = 0; // 0: 收藏, 1: 历史
|
||||||
|
this.scrollY = 0;
|
||||||
|
this.maxScrollY = 0;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.lastTouchY = 0;
|
||||||
|
this.scrollVelocity = 0;
|
||||||
|
this.hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
if (this.main.userManager.isLoggedIn) {
|
||||||
|
this.collections = await this.main.userManager.getCollections() || [];
|
||||||
|
this.progress = await this.main.userManager.getProgress() || [];
|
||||||
|
}
|
||||||
|
this.calculateMaxScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateMaxScroll() {
|
||||||
|
const list = this.currentTab === 0 ? this.collections : this.progress;
|
||||||
|
const cardHeight = 90;
|
||||||
|
const gap = 12;
|
||||||
|
const headerHeight = 300;
|
||||||
|
const contentHeight = list.length * (cardHeight + gap) + headerHeight;
|
||||||
|
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (!this.isDragging && Math.abs(this.scrollVelocity) > 0.5) {
|
||||||
|
this.scrollY += this.scrollVelocity;
|
||||||
|
this.scrollVelocity *= 0.95;
|
||||||
|
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx) {
|
||||||
|
// 背景渐变
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
|
||||||
|
gradient.addColorStop(0, '#0f0c29');
|
||||||
|
gradient.addColorStop(0.5, '#302b63');
|
||||||
|
gradient.addColorStop(1, '#24243e');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
|
|
||||||
|
// 顶部返回
|
||||||
|
this.renderHeader(ctx);
|
||||||
|
|
||||||
|
// 用户信息卡片
|
||||||
|
this.renderUserCard(ctx);
|
||||||
|
|
||||||
|
// Tab切换
|
||||||
|
this.renderTabs(ctx);
|
||||||
|
|
||||||
|
// 列表内容
|
||||||
|
this.renderList(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader(ctx) {
|
||||||
|
// 顶部渐变遮罩
|
||||||
|
const headerGradient = ctx.createLinearGradient(0, 0, 0, 60);
|
||||||
|
headerGradient.addColorStop(0, 'rgba(0,0,0,0.5)');
|
||||||
|
headerGradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
ctx.fillStyle = headerGradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, 60);
|
||||||
|
|
||||||
|
// 返回按钮
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '16px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('‹ 返回', 15, 35);
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.font = 'bold 17px sans-serif';
|
||||||
|
ctx.fillText('个人中心', this.screenWidth / 2, 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderUserCard(ctx) {
|
||||||
|
const cardY = 60;
|
||||||
|
const cardHeight = 170;
|
||||||
|
const centerX = this.screenWidth / 2;
|
||||||
|
const user = this.main.userManager;
|
||||||
|
|
||||||
|
// 卡片背景
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||||
|
this.roundRect(ctx, 15, cardY, this.screenWidth - 30, cardHeight, 16);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 头像
|
||||||
|
const avatarSize = 55;
|
||||||
|
const avatarY = cardY + 20;
|
||||||
|
const avatarGradient = ctx.createLinearGradient(centerX - 30, avatarY, centerX + 30, avatarY + avatarSize);
|
||||||
|
avatarGradient.addColorStop(0, '#ff6b6b');
|
||||||
|
avatarGradient.addColorStop(1, '#ffd700');
|
||||||
|
ctx.fillStyle = avatarGradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 头像文字
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 22px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(user.nickname ? user.nickname[0] : '游', centerX, avatarY + avatarSize / 2 + 8);
|
||||||
|
|
||||||
|
// 昵称
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 15px sans-serif';
|
||||||
|
ctx.fillText(user.nickname || '游客用户', centerX, avatarY + avatarSize + 20);
|
||||||
|
|
||||||
|
// 分割线
|
||||||
|
const lineY = avatarY + avatarSize + 35;
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(30, lineY);
|
||||||
|
ctx.lineTo(this.screenWidth - 30, lineY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
const statsY = lineY + 30;
|
||||||
|
const statWidth = (this.screenWidth - 30) / 3;
|
||||||
|
const statsData = [
|
||||||
|
{ num: this.progress.length, label: '游玩' },
|
||||||
|
{ num: this.collections.length, label: '收藏' },
|
||||||
|
{ num: this.progress.filter(p => p.is_completed).length, label: '结局' }
|
||||||
|
];
|
||||||
|
|
||||||
|
statsData.forEach((stat, i) => {
|
||||||
|
const x = 15 + statWidth * i + statWidth / 2;
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 18px sans-serif';
|
||||||
|
ctx.fillText(stat.num.toString(), x, statsY);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
ctx.fillText(stat.label, x, statsY + 16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTabs(ctx) {
|
||||||
|
const tabY = 245;
|
||||||
|
const tabWidth = (this.screenWidth - 30) / 2;
|
||||||
|
const tabs = ['我的收藏', '游玩记录'];
|
||||||
|
|
||||||
|
tabs.forEach((tab, index) => {
|
||||||
|
const x = 15 + index * tabWidth;
|
||||||
|
const isActive = index === this.currentTab;
|
||||||
|
const centerX = x + tabWidth / 2;
|
||||||
|
|
||||||
|
// Tab背景
|
||||||
|
if (isActive) {
|
||||||
|
const tabGradient = ctx.createLinearGradient(x, tabY, x + tabWidth, tabY);
|
||||||
|
tabGradient.addColorStop(0, '#ff6b6b');
|
||||||
|
tabGradient.addColorStop(1, '#ffd700');
|
||||||
|
ctx.fillStyle = tabGradient;
|
||||||
|
this.roundRect(ctx, x + 10, tabY, tabWidth - 20, 32, 16);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab文字
|
||||||
|
ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.5)';
|
||||||
|
ctx.font = isActive ? 'bold 14px sans-serif' : '14px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(tab, centerX, tabY + 21);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList(ctx) {
|
||||||
|
const list = this.currentTab === 0 ? this.collections : this.progress;
|
||||||
|
const startY = 295;
|
||||||
|
const cardHeight = 90;
|
||||||
|
const cardMargin = 12;
|
||||||
|
const padding = 15;
|
||||||
|
|
||||||
|
// 裁剪区域
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(0, startY - 10, this.screenWidth, this.screenHeight - startY + 10);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.font = '14px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(
|
||||||
|
this.currentTab === 0 ? '还没有收藏的故事' : '还没有游玩记录',
|
||||||
|
this.screenWidth / 2,
|
||||||
|
startY + 50
|
||||||
|
);
|
||||||
|
ctx.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.forEach((item, index) => {
|
||||||
|
const y = startY + index * (cardHeight + cardMargin) - this.scrollY;
|
||||||
|
if (y > -cardHeight && y < this.screenHeight) {
|
||||||
|
this.renderListCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardHeight, index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderListCard(ctx, item, x, y, width, height, index) {
|
||||||
|
// 卡片背景
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||||
|
this.roundRect(ctx, x, y, width, height, 12);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 封面
|
||||||
|
const coverSize = 65;
|
||||||
|
const coverColors = [
|
||||||
|
['#ff758c', '#ff7eb3'],
|
||||||
|
['#667eea', '#764ba2'],
|
||||||
|
['#4facfe', '#00f2fe'],
|
||||||
|
['#43e97b', '#38f9d7'],
|
||||||
|
['#fa709a', '#fee140']
|
||||||
|
];
|
||||||
|
const colors = coverColors[index % coverColors.length];
|
||||||
|
const coverGradient = ctx.createLinearGradient(x + 12, y + 12, x + 12 + coverSize, y + 12 + coverSize);
|
||||||
|
coverGradient.addColorStop(0, colors[0]);
|
||||||
|
coverGradient.addColorStop(1, colors[1]);
|
||||||
|
ctx.fillStyle = coverGradient;
|
||||||
|
this.roundRect(ctx, x + 12, y + 12, coverSize, coverSize, 10);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 封面文字
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
||||||
|
ctx.font = 'bold 10px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(item.category || '故事', x + 12 + coverSize / 2, y + 12 + coverSize / 2 + 4);
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 15px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
const title = item.story_title || item.title || '未知故事';
|
||||||
|
ctx.fillText(title.length > 12 ? title.substring(0, 12) + '...' : title, x + 90, y + 35);
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
if (this.currentTab === 1 && item.is_completed) {
|
||||||
|
ctx.fillStyle = '#4ecca3';
|
||||||
|
ctx.fillText('✓ 已完成', x + 90, y + 58);
|
||||||
|
} else if (this.currentTab === 1) {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||||
|
ctx.fillText('进行中...', x + 90, y + 58);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||||
|
ctx.fillText(item.category || '', x + 90, y + 58);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续按钮
|
||||||
|
const btnGradient = ctx.createLinearGradient(x + width - 65, y + 30, x + width - 10, y + 30);
|
||||||
|
btnGradient.addColorStop(0, '#ff6b6b');
|
||||||
|
btnGradient.addColorStop(1, '#ffd700');
|
||||||
|
ctx.fillStyle = btnGradient;
|
||||||
|
this.roundRect(ctx, x + width - 65, y + 30, 52, 28, 14);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = 'bold 12px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('继续', x + width - 39, y + 49);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 圆角矩形
|
||||||
|
roundRect(ctx, x, y, width, height, radius) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchStart(e) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.lastTouchY = touch.clientY;
|
||||||
|
this.touchStartY = touch.clientY;
|
||||||
|
this.isDragging = true;
|
||||||
|
this.scrollVelocity = 0;
|
||||||
|
this.hasMoved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(e) {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const deltaY = this.lastTouchY - touch.clientY;
|
||||||
|
|
||||||
|
if (Math.abs(touch.clientY - this.touchStartY) > 5) {
|
||||||
|
this.hasMoved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollVelocity = deltaY;
|
||||||
|
this.scrollY += deltaY;
|
||||||
|
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||||
|
this.lastTouchY = touch.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd(e) {
|
||||||
|
this.isDragging = false;
|
||||||
|
|
||||||
|
if (this.hasMoved) return;
|
||||||
|
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const x = touch.clientX;
|
||||||
|
const y = touch.clientY;
|
||||||
|
|
||||||
|
// 返回按钮
|
||||||
|
if (y < 50 && x < 80) {
|
||||||
|
this.main.sceneManager.switchScene('home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab切换
|
||||||
|
if (y >= 240 && y <= 285) {
|
||||||
|
const tabWidth = (this.screenWidth - 30) / 2;
|
||||||
|
const newTab = x < 15 + tabWidth ? 0 : 1;
|
||||||
|
if (newTab !== this.currentTab) {
|
||||||
|
this.currentTab = newTab;
|
||||||
|
this.scrollY = 0;
|
||||||
|
this.calculateMaxScroll();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡片点击
|
||||||
|
this.handleCardClick(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCardClick(x, y) {
|
||||||
|
const list = this.currentTab === 0 ? this.collections : this.progress;
|
||||||
|
const startY = 295;
|
||||||
|
const cardHeight = 90;
|
||||||
|
const cardMargin = 12;
|
||||||
|
const padding = 15;
|
||||||
|
|
||||||
|
const adjustedY = y + this.scrollY;
|
||||||
|
const index = Math.floor((adjustedY - startY) / (cardHeight + cardMargin));
|
||||||
|
|
||||||
|
if (index >= 0 && index < list.length) {
|
||||||
|
const item = list[index];
|
||||||
|
const storyId = item.story_id || item.id;
|
||||||
|
const cardY = startY + index * (cardHeight + cardMargin) - this.scrollY;
|
||||||
|
const buttonX = padding + (this.screenWidth - padding * 2) - 65;
|
||||||
|
|
||||||
|
if (x >= buttonX && x <= buttonX + 52 && y >= cardY + 30 && y <= cardY + 58) {
|
||||||
|
this.main.sceneManager.switchScene('story', { storyId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
client/js/scenes/SceneManager.js
Normal file
56
client/js/scenes/SceneManager.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 场景管理器
|
||||||
|
*/
|
||||||
|
import HomeScene from './HomeScene';
|
||||||
|
import StoryScene from './StoryScene';
|
||||||
|
import EndingScene from './EndingScene';
|
||||||
|
import ProfileScene from './ProfileScene';
|
||||||
|
import ChapterScene from './ChapterScene';
|
||||||
|
|
||||||
|
export default class SceneManager {
|
||||||
|
constructor(main) {
|
||||||
|
this.main = main;
|
||||||
|
this.currentScene = null;
|
||||||
|
this.scenes = {
|
||||||
|
home: HomeScene,
|
||||||
|
story: StoryScene,
|
||||||
|
ending: EndingScene,
|
||||||
|
profile: ProfileScene,
|
||||||
|
chapter: ChapterScene
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换场景
|
||||||
|
*/
|
||||||
|
switchScene(sceneName, params = {}) {
|
||||||
|
const SceneClass = this.scenes[sceneName];
|
||||||
|
if (!SceneClass) {
|
||||||
|
console.error('场景不存在:', sceneName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销毁当前场景
|
||||||
|
if (this.currentScene && this.currentScene.destroy) {
|
||||||
|
this.currentScene.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新场景
|
||||||
|
this.currentScene = new SceneClass(this.main, params);
|
||||||
|
|
||||||
|
// 初始化场景
|
||||||
|
if (this.currentScene.init) {
|
||||||
|
this.currentScene.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('切换到场景:', sceneName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前场景名称
|
||||||
|
*/
|
||||||
|
getCurrentSceneName() {
|
||||||
|
if (!this.currentScene) return null;
|
||||||
|
return this.currentScene.constructor.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
636
client/js/scenes/StoryScene.js
Normal file
636
client/js/scenes/StoryScene.js
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
/**
|
||||||
|
* 故事播放场景 - 视觉小说风格
|
||||||
|
*/
|
||||||
|
import BaseScene from './BaseScene';
|
||||||
|
|
||||||
|
export default class StoryScene extends BaseScene {
|
||||||
|
constructor(main, params) {
|
||||||
|
super(main, params);
|
||||||
|
this.storyId = params.storyId;
|
||||||
|
this.aiContent = params.aiContent || null; // AI改写内容
|
||||||
|
this.story = null;
|
||||||
|
this.currentNode = null;
|
||||||
|
this.displayText = '';
|
||||||
|
this.targetText = '';
|
||||||
|
this.charIndex = 0;
|
||||||
|
this.typewriterSpeed = 40;
|
||||||
|
this.lastTypeTime = 0;
|
||||||
|
this.isTyping = false;
|
||||||
|
this.showChoices = false;
|
||||||
|
this.waitingForClick = false;
|
||||||
|
this.selectedChoice = -1;
|
||||||
|
this.fadeAlpha = 0;
|
||||||
|
this.isFading = false;
|
||||||
|
// 滚动相关
|
||||||
|
this.textScrollY = 0;
|
||||||
|
this.maxScrollY = 0;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.lastTouchY = 0;
|
||||||
|
// 场景图相关
|
||||||
|
this.sceneImage = null;
|
||||||
|
this.sceneColors = this.generateSceneColors();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据场景生成氛围色
|
||||||
|
generateSceneColors() {
|
||||||
|
const themes = [
|
||||||
|
{ bg1: '#1a0a2e', bg2: '#16213e', accent: '#ff6b9d' }, // 浪漫
|
||||||
|
{ bg1: '#0d1b2a', bg2: '#1b263b', accent: '#778da9' }, // 悬疑
|
||||||
|
{ bg1: '#2d132c', bg2: '#801336', accent: '#ffd700' }, // 古风
|
||||||
|
{ bg1: '#1a1a2e', bg2: '#0f3460', accent: '#00fff5' }, // 科幻
|
||||||
|
{ bg1: '#0b1215', bg2: '#1e3a3a', accent: '#4ecca3' }, // 校园
|
||||||
|
];
|
||||||
|
return themes[Math.floor(Math.random() * themes.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// 如果是AI改写内容,直接播放
|
||||||
|
if (this.aiContent) {
|
||||||
|
this.story = this.main.storyManager.currentStory;
|
||||||
|
if (this.story) {
|
||||||
|
this.setThemeByCategory(this.story.category);
|
||||||
|
}
|
||||||
|
this.currentNode = this.aiContent;
|
||||||
|
this.startTypewriter(this.aiContent.content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是重新开始(已有故事数据)
|
||||||
|
const existingStory = this.main.storyManager.currentStory;
|
||||||
|
if (existingStory && existingStory.id === this.storyId) {
|
||||||
|
// 重新开始,使用已有数据
|
||||||
|
this.story = existingStory;
|
||||||
|
this.setThemeByCategory(this.story.category);
|
||||||
|
this.currentNode = this.main.storyManager.getCurrentNode();
|
||||||
|
if (this.currentNode) {
|
||||||
|
this.startTypewriter(this.currentNode.content);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次加载故事
|
||||||
|
this.main.showLoading('加载故事中...');
|
||||||
|
|
||||||
|
this.story = await this.main.storyManager.loadStoryDetail(this.storyId);
|
||||||
|
|
||||||
|
if (!this.story) {
|
||||||
|
this.main.showError('故事加载失败');
|
||||||
|
this.main.sceneManager.switchScene('home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据故事分类设置氛围
|
||||||
|
this.setThemeByCategory(this.story.category);
|
||||||
|
|
||||||
|
this.main.hideLoading();
|
||||||
|
|
||||||
|
this.currentNode = this.main.storyManager.getCurrentNode();
|
||||||
|
if (this.currentNode) {
|
||||||
|
this.startTypewriter(this.currentNode.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemeByCategory(category) {
|
||||||
|
const themes = {
|
||||||
|
'都市言情': { bg1: '#1a0a2e', bg2: '#2d1b4e', accent: '#ff6b9d' },
|
||||||
|
'悬疑推理': { bg1: '#0d1b2a', bg2: '#1b263b', accent: '#778da9' },
|
||||||
|
'古风宫廷': { bg1: '#2d132c', bg2: '#4a1942', accent: '#ffd700' },
|
||||||
|
'校园青春': { bg1: '#0a2540', bg2: '#1e5162', accent: '#4ecca3' },
|
||||||
|
'修仙玄幻': { bg1: '#0f0f2d', bg2: '#1a1a4e', accent: '#a855f7' },
|
||||||
|
'穿越重生': { bg1: '#1a0a2e', bg2: '#3d1a5c', accent: '#f472b6' },
|
||||||
|
'职场商战': { bg1: '#0c1929', bg2: '#1e3a5f', accent: '#60a5fa' },
|
||||||
|
'科幻未来': { bg1: '#0a1628', bg2: '#162033', accent: '#00fff5' },
|
||||||
|
'恐怖惊悚': { bg1: '#0a0a0a', bg2: '#1a1a1a', accent: '#ef4444' },
|
||||||
|
'搞笑轻喜': { bg1: '#1a1a2e', bg2: '#2d2d52', accent: '#fbbf24' }
|
||||||
|
};
|
||||||
|
this.sceneColors = themes[category] || this.sceneColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTypewriter(text) {
|
||||||
|
this.targetText = text || '';
|
||||||
|
this.displayText = '';
|
||||||
|
this.charIndex = 0;
|
||||||
|
this.isTyping = true;
|
||||||
|
this.showChoices = false;
|
||||||
|
this.waitingForClick = false;
|
||||||
|
this.lastTypeTime = Date.now();
|
||||||
|
// 重置滚动
|
||||||
|
this.textScrollY = 0;
|
||||||
|
this.maxScrollY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (this.isTyping && this.charIndex < this.targetText.length) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastTypeTime >= this.typewriterSpeed) {
|
||||||
|
this.displayText += this.targetText[this.charIndex];
|
||||||
|
this.charIndex++;
|
||||||
|
this.lastTypeTime = now;
|
||||||
|
}
|
||||||
|
} else if (this.isTyping && this.charIndex >= this.targetText.length) {
|
||||||
|
this.isTyping = false;
|
||||||
|
// 打字完成,等待用户点击再显示选项
|
||||||
|
this.waitingForClick = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isFading) {
|
||||||
|
this.fadeAlpha = Math.min(1, this.fadeAlpha + 0.05);
|
||||||
|
if (this.fadeAlpha >= 1) {
|
||||||
|
this.isFading = false;
|
||||||
|
this.fadeAlpha = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx) {
|
||||||
|
// 1. 绘制场景背景
|
||||||
|
this.renderSceneBackground(ctx);
|
||||||
|
|
||||||
|
// 2. 绘制场景装饰
|
||||||
|
this.renderSceneDecoration(ctx);
|
||||||
|
|
||||||
|
// 3. 绘制顶部UI
|
||||||
|
this.renderHeader(ctx);
|
||||||
|
|
||||||
|
// 4. 绘制对话框
|
||||||
|
this.renderDialogBox(ctx);
|
||||||
|
|
||||||
|
// 5. 绘制选项
|
||||||
|
if (this.showChoices) {
|
||||||
|
this.renderChoices(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 淡入淡出
|
||||||
|
if (this.fadeAlpha > 0) {
|
||||||
|
ctx.fillStyle = `rgba(0, 0, 0, ${this.fadeAlpha})`;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSceneBackground(ctx) {
|
||||||
|
// 场景区域(上方45%)
|
||||||
|
const sceneHeight = this.screenHeight * 0.42;
|
||||||
|
|
||||||
|
// 渐变背景
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, sceneHeight);
|
||||||
|
gradient.addColorStop(0, this.sceneColors.bg1);
|
||||||
|
gradient.addColorStop(1, this.sceneColors.bg2);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
|
||||||
|
|
||||||
|
// 底部渐变过渡到对话框
|
||||||
|
const fadeGradient = ctx.createLinearGradient(0, sceneHeight - 60, 0, sceneHeight);
|
||||||
|
fadeGradient.addColorStop(0, 'rgba(0,0,0,0)');
|
||||||
|
fadeGradient.addColorStop(1, 'rgba(15,15,30,1)');
|
||||||
|
ctx.fillStyle = fadeGradient;
|
||||||
|
ctx.fillRect(0, sceneHeight - 60, this.screenWidth, 60);
|
||||||
|
|
||||||
|
// 对话框区域背景
|
||||||
|
ctx.fillStyle = '#0f0f1e';
|
||||||
|
ctx.fillRect(0, sceneHeight, this.screenWidth, this.screenHeight - sceneHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSceneDecoration(ctx) {
|
||||||
|
const sceneHeight = this.screenHeight * 0.42;
|
||||||
|
const centerX = this.screenWidth / 2;
|
||||||
|
|
||||||
|
// 场景氛围光效
|
||||||
|
const glowGradient = ctx.createRadialGradient(centerX, sceneHeight * 0.5, 0, centerX, sceneHeight * 0.5, 200);
|
||||||
|
glowGradient.addColorStop(0, this.sceneColors.accent + '30');
|
||||||
|
glowGradient.addColorStop(1, 'transparent');
|
||||||
|
ctx.fillStyle = glowGradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
|
||||||
|
|
||||||
|
// 装饰粒子
|
||||||
|
ctx.fillStyle = this.sceneColors.accent + '40';
|
||||||
|
const particles = [[50, 100], [120, 180], [200, 80], [280, 150], [320, 60], [80, 250], [250, 220]];
|
||||||
|
particles.forEach(([x, y]) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 场景提示文字(中央)
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||||||
|
ctx.font = '14px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const sceneHint = this.getSceneHint();
|
||||||
|
ctx.fillText(sceneHint, centerX, sceneHeight * 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSceneHint() {
|
||||||
|
if (!this.currentNode) return '故事开始...';
|
||||||
|
const speaker = this.currentNode.speaker;
|
||||||
|
if (speaker && speaker !== '旁白') {
|
||||||
|
return `【${speaker}】`;
|
||||||
|
}
|
||||||
|
return '— 旁白 —';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader(ctx) {
|
||||||
|
// 顶部渐变遮罩
|
||||||
|
const headerGradient = ctx.createLinearGradient(0, 0, 0, 80);
|
||||||
|
headerGradient.addColorStop(0, 'rgba(0,0,0,0.7)');
|
||||||
|
headerGradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
ctx.fillStyle = headerGradient;
|
||||||
|
ctx.fillRect(0, 0, this.screenWidth, 80);
|
||||||
|
|
||||||
|
// 返回按钮
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '16px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('‹ 返回', 15, 35);
|
||||||
|
|
||||||
|
// 故事标题
|
||||||
|
if (this.story) {
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.font = 'bold 15px sans-serif';
|
||||||
|
ctx.fillStyle = this.sceneColors.accent;
|
||||||
|
const title = this.story.title.length > 10 ? this.story.title.substring(0, 10) + '...' : this.story.title;
|
||||||
|
ctx.fillText(title, this.screenWidth / 2, 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进度指示
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||||
|
const progress = this.main.storyManager.getProgress ? this.main.storyManager.getProgress() : '';
|
||||||
|
ctx.fillText(progress, this.screenWidth - 15, 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDialogBox(ctx) {
|
||||||
|
const boxY = this.screenHeight * 0.42;
|
||||||
|
const boxHeight = this.screenHeight * 0.58;
|
||||||
|
const padding = 20;
|
||||||
|
|
||||||
|
// 对话框背景
|
||||||
|
ctx.fillStyle = 'rgba(20, 20, 40, 0.95)';
|
||||||
|
ctx.fillRect(0, boxY, this.screenWidth, boxHeight);
|
||||||
|
|
||||||
|
// 顶部装饰线
|
||||||
|
const lineGradient = ctx.createLinearGradient(0, boxY, this.screenWidth, boxY);
|
||||||
|
lineGradient.addColorStop(0, 'transparent');
|
||||||
|
lineGradient.addColorStop(0.5, this.sceneColors.accent);
|
||||||
|
lineGradient.addColorStop(1, 'transparent');
|
||||||
|
ctx.strokeStyle = lineGradient;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, boxY);
|
||||||
|
ctx.lineTo(this.screenWidth, boxY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 如果显示选项,不显示对话内容
|
||||||
|
if (this.showChoices) return;
|
||||||
|
|
||||||
|
// 角色名
|
||||||
|
if (this.currentNode && this.currentNode.speaker && this.currentNode.speaker !== '旁白') {
|
||||||
|
// 角色名背景
|
||||||
|
ctx.font = 'bold 14px sans-serif';
|
||||||
|
const nameWidth = ctx.measureText(this.currentNode.speaker).width + 30;
|
||||||
|
ctx.fillStyle = this.sceneColors.accent;
|
||||||
|
this.roundRect(ctx, padding, boxY + 15, nameWidth, 28, 14);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(this.currentNode.speaker, padding + 15, boxY + 34);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话内容
|
||||||
|
const textY = boxY + 65;
|
||||||
|
const lineHeight = 26;
|
||||||
|
const maxWidth = this.screenWidth - padding * 2;
|
||||||
|
const visibleHeight = boxHeight - 105; // 可见区域高度
|
||||||
|
|
||||||
|
ctx.font = '15px sans-serif';
|
||||||
|
const allLines = this.getWrappedLines(ctx, this.displayText, maxWidth);
|
||||||
|
const totalTextHeight = allLines.length * lineHeight;
|
||||||
|
|
||||||
|
// 计算最大滚动距离
|
||||||
|
this.maxScrollY = Math.max(0, totalTextHeight - visibleHeight);
|
||||||
|
|
||||||
|
// 自动滚动到最新内容(打字时)
|
||||||
|
if (this.isTyping) {
|
||||||
|
this.textScrollY = this.maxScrollY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置裁剪区域(从文字顶部开始)
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(0, textY - 18, this.screenWidth, visibleHeight + 18);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
// 绘制文字(带滚动偏移)
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
allLines.forEach((line, i) => {
|
||||||
|
const y = textY + i * lineHeight - this.textScrollY;
|
||||||
|
ctx.fillText(line, padding, y);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// 滚动指示器(如果可以滚动)
|
||||||
|
if (this.maxScrollY > 0) {
|
||||||
|
const scrollBarHeight = 40;
|
||||||
|
const scrollBarY = boxY + 55 + (this.textScrollY / this.maxScrollY) * (visibleHeight - scrollBarHeight);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
||||||
|
this.roundRect(ctx, this.screenWidth - 6, scrollBarY, 3, scrollBarHeight, 1.5);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打字机光标
|
||||||
|
if (this.isTyping) {
|
||||||
|
const cursorBlink = Math.floor(Date.now() / 500) % 2 === 0;
|
||||||
|
if (cursorBlink) {
|
||||||
|
ctx.fillStyle = this.sceneColors.accent;
|
||||||
|
ctx.font = '15px sans-serif';
|
||||||
|
ctx.fillText('▌', padding + this.getTextEndX(ctx, this.displayText, maxWidth), textY + this.getTextEndY(this.displayText, maxWidth, lineHeight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续提示
|
||||||
|
if (!this.isTyping && !this.main.storyManager.isEnding()) {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('点击继续 ▼', this.screenWidth / 2, this.screenHeight - 25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTextEndX(ctx, text, maxWidth) {
|
||||||
|
const lines = this.getWrappedLines(ctx, text, maxWidth);
|
||||||
|
if (lines.length === 0) return 0;
|
||||||
|
return ctx.measureText(lines[lines.length - 1]).width;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTextEndY(text, maxWidth, lineHeight) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
return (lines.length - 1) * lineHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
getWrappedLines(ctx, text, maxWidth) {
|
||||||
|
const lines = [];
|
||||||
|
const paragraphs = text.split('\n');
|
||||||
|
paragraphs.forEach(para => {
|
||||||
|
let line = '';
|
||||||
|
for (let char of para) {
|
||||||
|
const testLine = line + char;
|
||||||
|
if (ctx.measureText(testLine).width > maxWidth) {
|
||||||
|
lines.push(line);
|
||||||
|
line = char;
|
||||||
|
} else {
|
||||||
|
line = testLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderChoices(ctx) {
|
||||||
|
if (!this.currentNode || !this.currentNode.choices) return;
|
||||||
|
|
||||||
|
const choices = this.currentNode.choices;
|
||||||
|
const choiceHeight = 50;
|
||||||
|
const choiceMargin = 10;
|
||||||
|
const padding = 20;
|
||||||
|
const startY = this.screenHeight * 0.42 + 30;
|
||||||
|
|
||||||
|
// 半透明遮罩
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||||
|
ctx.fillRect(0, this.screenHeight * 0.42, this.screenWidth, this.screenHeight * 0.58);
|
||||||
|
|
||||||
|
// 提示文字
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '14px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('请做出选择', this.screenWidth / 2, startY);
|
||||||
|
|
||||||
|
choices.forEach((choice, index) => {
|
||||||
|
const y = startY + 25 + index * (choiceHeight + choiceMargin);
|
||||||
|
const isSelected = index === this.selectedChoice;
|
||||||
|
|
||||||
|
// 选项背景
|
||||||
|
if (isSelected) {
|
||||||
|
const gradient = ctx.createLinearGradient(padding, y, this.screenWidth - padding, y);
|
||||||
|
gradient.addColorStop(0, this.sceneColors.accent);
|
||||||
|
gradient.addColorStop(1, this.sceneColors.accent + 'aa');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
}
|
||||||
|
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, choiceHeight, 25);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 选项边框
|
||||||
|
ctx.strokeStyle = isSelected ? this.sceneColors.accent : 'rgba(255,255,255,0.2)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, choiceHeight, 25);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 选项文本
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '14px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(choice.text, this.screenWidth / 2, y + 30);
|
||||||
|
|
||||||
|
// 锁定图标
|
||||||
|
if (choice.isLocked) {
|
||||||
|
ctx.fillStyle = '#ffd700';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText('🔒 看广告解锁', this.screenWidth - padding - 15, y + 30);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字换行
|
||||||
|
wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
||||||
|
if (!text) return;
|
||||||
|
const lines = this.getWrappedLines(ctx, text, maxWidth);
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
ctx.fillText(line, x, y + i * lineHeight);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字换行(限制行数)
|
||||||
|
wrapTextWithLimit(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||||
|
if (!text) return;
|
||||||
|
let lines = this.getWrappedLines(ctx, text, maxWidth);
|
||||||
|
|
||||||
|
// 如果超出最大行数,只显示最后几行(滚动效果)
|
||||||
|
if (lines.length > maxLines) {
|
||||||
|
lines = lines.slice(lines.length - maxLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
ctx.fillText(line, x, y + i * lineHeight);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把行数组分成多页
|
||||||
|
splitIntoPages(lines, linesPerPage) {
|
||||||
|
const pages = [];
|
||||||
|
for (let i = 0; i < lines.length; i += linesPerPage) {
|
||||||
|
pages.push(lines.slice(i, i + linesPerPage));
|
||||||
|
}
|
||||||
|
return pages.length > 0 ? pages : [[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchStart(e) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.touchStartY = touch.clientY;
|
||||||
|
this.touchStartX = touch.clientX;
|
||||||
|
this.lastTouchY = touch.clientY;
|
||||||
|
this.hasMoved = false;
|
||||||
|
|
||||||
|
// 判断是否在对话框区域
|
||||||
|
const boxY = this.screenHeight * 0.42;
|
||||||
|
if (touch.clientY > boxY) {
|
||||||
|
this.isDragging = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(e) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
|
||||||
|
// 滑动对话框内容
|
||||||
|
if (this.isDragging && this.maxScrollY > 0) {
|
||||||
|
const deltaY = this.lastTouchY - touch.clientY;
|
||||||
|
if (Math.abs(deltaY) > 2) {
|
||||||
|
this.hasMoved = true;
|
||||||
|
}
|
||||||
|
this.textScrollY += deltaY;
|
||||||
|
this.textScrollY = Math.max(0, Math.min(this.textScrollY, this.maxScrollY));
|
||||||
|
this.lastTouchY = touch.clientY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd(e) {
|
||||||
|
this.isDragging = false;
|
||||||
|
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const x = touch.clientX;
|
||||||
|
const y = touch.clientY;
|
||||||
|
|
||||||
|
// 如果滑动过,不处理点击
|
||||||
|
if (this.hasMoved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回按钮
|
||||||
|
if (y < 60 && x < 80) {
|
||||||
|
this.main.sceneManager.switchScene('home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加速打字
|
||||||
|
if (this.isTyping) {
|
||||||
|
this.displayText = this.targetText;
|
||||||
|
this.charIndex = this.targetText.length;
|
||||||
|
this.isTyping = false;
|
||||||
|
this.waitingForClick = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待点击后显示选项或结局
|
||||||
|
if (this.waitingForClick) {
|
||||||
|
this.waitingForClick = false;
|
||||||
|
|
||||||
|
// 检查是否是结局
|
||||||
|
if (this.main.storyManager.isEnding()) {
|
||||||
|
this.main.sceneManager.switchScene('ending', {
|
||||||
|
storyId: this.storyId,
|
||||||
|
ending: this.main.storyManager.getEndingInfo()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示选项
|
||||||
|
if (this.currentNode && this.currentNode.choices && this.currentNode.choices.length > 0) {
|
||||||
|
this.showChoices = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选项点击
|
||||||
|
if (this.showChoices && this.currentNode && this.currentNode.choices) {
|
||||||
|
const choices = this.currentNode.choices;
|
||||||
|
const choiceHeight = 50;
|
||||||
|
const choiceMargin = 10;
|
||||||
|
const padding = 20;
|
||||||
|
const startY = this.screenHeight * 0.42 + 55;
|
||||||
|
|
||||||
|
for (let i = 0; i < choices.length; i++) {
|
||||||
|
const choiceY = startY + i * (choiceHeight + choiceMargin);
|
||||||
|
if (y >= choiceY && y <= choiceY + choiceHeight && x >= padding && x <= this.screenWidth - padding) {
|
||||||
|
this.handleChoiceSelect(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChoiceSelect(index) {
|
||||||
|
const choice = this.currentNode.choices[index];
|
||||||
|
|
||||||
|
if (choice.isLocked) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '解锁剧情',
|
||||||
|
content: '观看广告解锁隐藏剧情?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
this.unlockAndSelect(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectChoice(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockAndSelect(index) {
|
||||||
|
this.selectChoice(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectChoice(index) {
|
||||||
|
this.isFading = true;
|
||||||
|
this.fadeAlpha = 0;
|
||||||
|
this.showChoices = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.currentNode = this.main.storyManager.selectChoice(index);
|
||||||
|
if (this.currentNode) {
|
||||||
|
this.startTypewriter(this.currentNode.content);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 圆角矩形
|
||||||
|
roundRect(ctx, x, y, width, height, radius) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.main.userManager.isLoggedIn && this.story) {
|
||||||
|
this.main.userManager.saveProgress(
|
||||||
|
this.storyId,
|
||||||
|
this.main.storyManager.currentNodeKey,
|
||||||
|
this.main.storyManager.isEnding(),
|
||||||
|
this.main.storyManager.isEnding() ? this.main.storyManager.getEndingInfo().name : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
client/js/utils/http.js
Normal file
55
client/js/utils/http.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 网络请求工具
|
||||||
|
*/
|
||||||
|
|
||||||
|
// API基础地址(开发环境)
|
||||||
|
const BASE_URL = 'http://localhost:3000/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送HTTP请求
|
||||||
|
*/
|
||||||
|
export function request(options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error('请求超时'));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
wx.request({
|
||||||
|
url: BASE_URL + options.url,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
data: options.data || {},
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.header
|
||||||
|
},
|
||||||
|
success(res) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (res.data.code === 0) {
|
||||||
|
resolve(res.data.data);
|
||||||
|
} else {
|
||||||
|
reject(new Error(res.data.message || '请求失败'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail(err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET请求
|
||||||
|
*/
|
||||||
|
export function get(url, data) {
|
||||||
|
return request({ url, method: 'GET', data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST请求
|
||||||
|
*/
|
||||||
|
export function post(url, data) {
|
||||||
|
return request({ url, method: 'POST', data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { request, get, post };
|
||||||
47
client/project.config.json
Normal file
47
client/project.config.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"appid": "wx772e2f0fbc498020",
|
||||||
|
"compileType": "game",
|
||||||
|
"projectname": "stardom-story",
|
||||||
|
"setting": {
|
||||||
|
"es6": true,
|
||||||
|
"enhance": true,
|
||||||
|
"postcss": true,
|
||||||
|
"minified": true,
|
||||||
|
"newFeature": true,
|
||||||
|
"coverView": true,
|
||||||
|
"nodeModules": false,
|
||||||
|
"autoAudits": false,
|
||||||
|
"showShadowRootInWxmlPanel": true,
|
||||||
|
"scopeDataCheck": false,
|
||||||
|
"checkInvalidKey": true,
|
||||||
|
"checkSiteMap": true,
|
||||||
|
"uploadWithSourceMap": true,
|
||||||
|
"compileHotReLoad": false,
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
},
|
||||||
|
"compileWorklet": false,
|
||||||
|
"uglifyFileName": false,
|
||||||
|
"packNpmManually": false,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"minifyWXSS": true,
|
||||||
|
"minifyWXML": true,
|
||||||
|
"localPlugins": false,
|
||||||
|
"disableUseStrict": false,
|
||||||
|
"useCompilerPlugins": false,
|
||||||
|
"condition": false,
|
||||||
|
"swc": false,
|
||||||
|
"disableSWC": true
|
||||||
|
},
|
||||||
|
"simulatorType": "wechat",
|
||||||
|
"simulatorPluginLibVersion": {},
|
||||||
|
"condition": {},
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [],
|
||||||
|
"include": []
|
||||||
|
},
|
||||||
|
"isGameTourist": false,
|
||||||
|
"editorSetting": {}
|
||||||
|
}
|
||||||
25
project.config.json
Normal file
25
project.config.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"setting": {
|
||||||
|
"es6": true,
|
||||||
|
"postcss": true,
|
||||||
|
"minified": true,
|
||||||
|
"uglifyFileName": false,
|
||||||
|
"enhance": true,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
},
|
||||||
|
"useCompilerPlugins": false
|
||||||
|
},
|
||||||
|
"compileType": "game",
|
||||||
|
"simulatorPluginLibVersion": {},
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [],
|
||||||
|
"include": []
|
||||||
|
},
|
||||||
|
"isGameTourist": false,
|
||||||
|
"appid": "wx772e2f0fbc498020",
|
||||||
|
"editorSetting": {}
|
||||||
|
}
|
||||||
13
server/.env.example
Normal file
13
server/.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 服务器配置
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# MySQL数据库配置
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_NAME=stardom_story
|
||||||
|
|
||||||
|
# 微信小游戏配置
|
||||||
|
WX_APPID=your_appid
|
||||||
|
WX_SECRET=your_secret
|
||||||
35
server/app.js
Normal file
35
server/app.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const storyRoutes = require('./routes/story');
|
||||||
|
const userRoutes = require('./routes/user');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// 路由
|
||||||
|
app.use('/api/stories', storyRoutes);
|
||||||
|
app.use('/api/user', userRoutes);
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', message: '星域故事汇服务运行中' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error(err.stack);
|
||||||
|
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`星域故事汇服务器运行在 http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
14
server/config/db.js
Normal file
14
server/config/db.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: process.env.DB_PORT || 3306,
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || 'stardom_story',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = pool;
|
||||||
157
server/models/story.js
Normal file
157
server/models/story.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
const pool = require('../config/db');
|
||||||
|
|
||||||
|
const StoryModel = {
|
||||||
|
// 获取故事列表
|
||||||
|
async getList(options = {}) {
|
||||||
|
const { category, featured, limit = 20, offset = 0 } = options;
|
||||||
|
let sql = `SELECT id, title, cover_url, description, category, play_count, like_count, is_featured
|
||||||
|
FROM stories WHERE status = 1`;
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
sql += ' AND category = ?';
|
||||||
|
params.push(category);
|
||||||
|
}
|
||||||
|
if (featured) {
|
||||||
|
sql += ' AND is_featured = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY is_featured DESC, play_count DESC LIMIT ? OFFSET ?';
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const [rows] = await pool.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取故事详情(含节点和选项)
|
||||||
|
async getDetail(storyId) {
|
||||||
|
// 获取故事基本信息
|
||||||
|
const [stories] = await pool.query(
|
||||||
|
'SELECT * FROM stories WHERE id = ? AND status = 1',
|
||||||
|
[storyId]
|
||||||
|
);
|
||||||
|
if (stories.length === 0) return null;
|
||||||
|
|
||||||
|
const story = stories[0];
|
||||||
|
|
||||||
|
// 获取所有节点
|
||||||
|
const [nodes] = await pool.query(
|
||||||
|
`SELECT id, node_key, content, speaker, background_image, character_image, bgm,
|
||||||
|
is_ending, ending_name, ending_score, ending_type
|
||||||
|
FROM story_nodes WHERE story_id = ? ORDER BY sort_order`,
|
||||||
|
[storyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取所有选项
|
||||||
|
const [choices] = await pool.query(
|
||||||
|
'SELECT id, node_id, text, next_node_key, is_locked FROM story_choices WHERE story_id = ? ORDER BY sort_order',
|
||||||
|
[storyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组装节点和选项
|
||||||
|
const nodesMap = {};
|
||||||
|
nodes.forEach(node => {
|
||||||
|
nodesMap[node.node_key] = {
|
||||||
|
...node,
|
||||||
|
choices: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
choices.forEach(choice => {
|
||||||
|
const node = nodes.find(n => n.id === choice.node_id);
|
||||||
|
if (node && nodesMap[node.node_key]) {
|
||||||
|
nodesMap[node.node_key].choices.push({
|
||||||
|
text: choice.text,
|
||||||
|
nextNodeKey: choice.next_node_key,
|
||||||
|
isLocked: choice.is_locked
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
story.nodes = nodesMap;
|
||||||
|
return story;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 增加游玩次数
|
||||||
|
async incrementPlayCount(storyId) {
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE stories SET play_count = play_count + 1 WHERE id = ?',
|
||||||
|
[storyId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 点赞/取消点赞
|
||||||
|
async toggleLike(storyId, increment) {
|
||||||
|
const delta = increment ? 1 : -1;
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE stories SET like_count = like_count + ? WHERE id = ?',
|
||||||
|
[delta, storyId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取热门故事
|
||||||
|
async getHotStories(limit = 10) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT id, title, cover_url, description, category, play_count, like_count
|
||||||
|
FROM stories WHERE status = 1 ORDER BY play_count DESC LIMIT ?`,
|
||||||
|
[limit]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取分类列表
|
||||||
|
async getCategories() {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT DISTINCT category FROM stories WHERE status = 1'
|
||||||
|
);
|
||||||
|
return rows.map(r => r.category);
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI改写结局
|
||||||
|
async aiRewriteEnding({ storyId, endingName, endingContent, prompt }) {
|
||||||
|
// 获取故事信息用于上下文
|
||||||
|
const [stories] = await pool.query(
|
||||||
|
'SELECT title, category, description FROM stories WHERE id = ?',
|
||||||
|
[storyId]
|
||||||
|
);
|
||||||
|
const story = stories[0];
|
||||||
|
|
||||||
|
// TODO: 接入真实AI服务(OpenAI/Claude/自建模型)
|
||||||
|
// 这里先返回模拟结果,后续替换为真实AI调用
|
||||||
|
const aiContent = await this.callAIService({
|
||||||
|
storyTitle: story?.title,
|
||||||
|
storyCategory: story?.category,
|
||||||
|
originalEnding: endingContent,
|
||||||
|
userPrompt: prompt
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: aiContent,
|
||||||
|
speaker: '旁白',
|
||||||
|
is_ending: true,
|
||||||
|
ending_name: `${endingName}(改写版)`,
|
||||||
|
ending_type: 'rewrite'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI服务调用(模拟/真实)
|
||||||
|
async callAIService({ storyTitle, storyCategory, originalEnding, userPrompt }) {
|
||||||
|
// 模拟AI生成内容
|
||||||
|
// 实际部署时替换为真实API调用
|
||||||
|
const templates = [
|
||||||
|
`根据你的愿望「${userPrompt}」,故事有了新的发展...\n\n`,
|
||||||
|
`命运的齿轮开始转动,${userPrompt}...\n\n`,
|
||||||
|
`在另一个平行世界里,${userPrompt}成为了现实...\n\n`
|
||||||
|
];
|
||||||
|
|
||||||
|
const template = templates[Math.floor(Math.random() * templates.length)];
|
||||||
|
const newContent = template +
|
||||||
|
`原本的结局被改写,新的故事在这里展开。\n\n` +
|
||||||
|
`【AI改写提示】这是基于「${userPrompt}」生成的新结局。\n` +
|
||||||
|
`实际部署时,这里将由AI大模型根据上下文生成更精彩的内容。`;
|
||||||
|
|
||||||
|
return newContent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = StoryModel;
|
||||||
134
server/models/user.js
Normal file
134
server/models/user.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
const pool = require('../config/db');
|
||||||
|
|
||||||
|
const UserModel = {
|
||||||
|
// 通过openid查找或创建用户
|
||||||
|
async findOrCreate(openid, userInfo = {}) {
|
||||||
|
const [existing] = await pool.query(
|
||||||
|
'SELECT * FROM users WHERE openid = ?',
|
||||||
|
[openid]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return existing[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新用户
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO users (openid, nickname, avatar_url, gender) VALUES (?, ?, ?, ?)',
|
||||||
|
[openid, userInfo.nickname || '', userInfo.avatarUrl || '', userInfo.gender || 0]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result.insertId,
|
||||||
|
openid,
|
||||||
|
nickname: userInfo.nickname || '',
|
||||||
|
avatar_url: userInfo.avatarUrl || '',
|
||||||
|
gender: userInfo.gender || 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
async updateProfile(userId, userInfo) {
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE users SET nickname = ?, avatar_url = ?, gender = ? WHERE id = ?',
|
||||||
|
[userInfo.nickname, userInfo.avatarUrl, userInfo.gender, userId]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户进度
|
||||||
|
async getProgress(userId, storyId = null) {
|
||||||
|
let sql = `SELECT up.*, s.title as story_title, s.cover_url
|
||||||
|
FROM user_progress up
|
||||||
|
JOIN stories s ON up.story_id = s.id
|
||||||
|
WHERE up.user_id = ?`;
|
||||||
|
const params = [userId];
|
||||||
|
|
||||||
|
if (storyId) {
|
||||||
|
sql += ' AND up.story_id = ?';
|
||||||
|
params.push(storyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY up.updated_at DESC';
|
||||||
|
const [rows] = await pool.query(sql, params);
|
||||||
|
return storyId ? rows[0] : rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 保存用户进度
|
||||||
|
async saveProgress(userId, storyId, data) {
|
||||||
|
const { currentNodeKey, isCompleted, endingReached } = data;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO user_progress (user_id, story_id, current_node_key, is_completed, ending_reached)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
current_node_key = VALUES(current_node_key),
|
||||||
|
is_completed = VALUES(is_completed),
|
||||||
|
ending_reached = VALUES(ending_reached),
|
||||||
|
play_count = play_count + 1`,
|
||||||
|
[userId, storyId, currentNodeKey, isCompleted || false, endingReached || '']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果完成了,记录结局
|
||||||
|
if (isCompleted && endingReached) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT IGNORE INTO user_endings (user_id, story_id, ending_name) VALUES (?, ?, ?)`,
|
||||||
|
[userId, storyId, endingReached]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新用户统计
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE users SET total_play_count = total_play_count + 1, total_endings = (SELECT COUNT(*) FROM user_endings WHERE user_id = ?) WHERE id = ?',
|
||||||
|
[userId, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 点赞/取消点赞
|
||||||
|
async toggleLike(userId, storyId, isLiked) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO user_progress (user_id, story_id, is_liked)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE is_liked = ?`,
|
||||||
|
[userId, storyId, isLiked, isLiked]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 收藏/取消收藏
|
||||||
|
async toggleCollect(userId, storyId, isCollected) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO user_progress (user_id, story_id, is_collected)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE is_collected = ?`,
|
||||||
|
[userId, storyId, isCollected, isCollected]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取收藏列表
|
||||||
|
async getCollections(userId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT s.id, s.title, s.cover_url, s.description, s.category, s.play_count, s.like_count
|
||||||
|
FROM user_progress up
|
||||||
|
JOIN stories s ON up.story_id = s.id
|
||||||
|
WHERE up.user_id = ? AND up.is_collected = 1
|
||||||
|
ORDER BY up.updated_at DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取用户解锁的结局
|
||||||
|
async getUnlockedEndings(userId, storyId = null) {
|
||||||
|
let sql = 'SELECT * FROM user_endings WHERE user_id = ?';
|
||||||
|
const params = [userId];
|
||||||
|
|
||||||
|
if (storyId) {
|
||||||
|
sql += ' AND story_id = ?';
|
||||||
|
params.push(storyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = UserModel;
|
||||||
1269
server/package-lock.json
generated
Normal file
1269
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
server/package.json
Normal file
20
server/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "stardom-story-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "星域故事汇小游戏后端服务",
|
||||||
|
"main": "app.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node app.js",
|
||||||
|
"dev": "nodemon app.js",
|
||||||
|
"init-db": "node sql/init.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"mysql2": "^3.6.5",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
110
server/routes/story.js
Normal file
110
server/routes/story.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const StoryModel = require('../models/story');
|
||||||
|
|
||||||
|
// 获取故事列表
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { category, featured, limit, offset } = req.query;
|
||||||
|
const stories = await StoryModel.getList({
|
||||||
|
category,
|
||||||
|
featured: featured === 'true',
|
||||||
|
limit: parseInt(limit) || 20,
|
||||||
|
offset: parseInt(offset) || 0
|
||||||
|
});
|
||||||
|
res.json({ code: 0, data: stories });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取故事列表失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '获取故事列表失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取热门故事
|
||||||
|
router.get('/hot', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit } = req.query;
|
||||||
|
const stories = await StoryModel.getHotStories(parseInt(limit) || 10);
|
||||||
|
res.json({ code: 0, data: stories });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取热门故事失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '获取热门故事失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取分类列表
|
||||||
|
router.get('/categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await StoryModel.getCategories();
|
||||||
|
res.json({ code: 0, data: categories });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取分类列表失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '获取分类列表失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取故事详情
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const storyId = parseInt(req.params.id);
|
||||||
|
const story = await StoryModel.getDetail(storyId);
|
||||||
|
if (!story) {
|
||||||
|
return res.status(404).json({ code: 404, message: '故事不存在' });
|
||||||
|
}
|
||||||
|
res.json({ code: 0, data: story });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取故事详情失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '获取故事详情失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录游玩
|
||||||
|
router.post('/:id/play', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const storyId = parseInt(req.params.id);
|
||||||
|
await StoryModel.incrementPlayCount(storyId);
|
||||||
|
res.json({ code: 0, message: '记录成功' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('记录游玩失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '记录游玩失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点赞
|
||||||
|
router.post('/:id/like', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const storyId = parseInt(req.params.id);
|
||||||
|
const { like } = req.body; // true: 点赞, false: 取消点赞
|
||||||
|
await StoryModel.toggleLike(storyId, like);
|
||||||
|
res.json({ code: 0, message: like ? '点赞成功' : '取消点赞成功' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('点赞操作失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '点赞操作失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// AI改写结局
|
||||||
|
router.post('/:id/rewrite', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const storyId = parseInt(req.params.id);
|
||||||
|
const { ending_name, ending_content, prompt } = req.body;
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
return res.status(400).json({ code: 400, message: '请输入改写指令' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用AI服务生成新内容
|
||||||
|
const result = await StoryModel.aiRewriteEnding({
|
||||||
|
storyId,
|
||||||
|
endingName: ending_name,
|
||||||
|
endingContent: ending_content,
|
||||||
|
prompt
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ code: 0, data: result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('AI改写失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: 'AI改写失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
124
server/routes/user.js
Normal file
124
server/routes/user.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const UserModel = require('../models/user');
|
||||||
|
|
||||||
|
// 模拟微信登录(实际环境需要调用微信API)
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { code, userInfo } = req.body;
|
||||||
|
|
||||||
|
// 实际项目中需要用code换取openid
|
||||||
|
// 这里为了开发测试,暂时用code作为openid
|
||||||
|
const openid = code || `test_user_${Date.now()}`;
|
||||||
|
|
||||||
|
const user = await UserModel.findOrCreate(openid, userInfo);
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
openid: user.openid,
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatarUrl: user.avatar_url
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('登录失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '登录失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
router.post('/profile', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId, nickname, avatarUrl, gender } = req.body;
|
||||||
|
await UserModel.updateProfile(userId, { nickname, avatarUrl, gender });
|
||||||
|
res.json({ code: 0, message: '更新成功' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('更新用户信息失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '更新用户信息失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取用户进度
|
||||||
|
router.get('/progress', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId, storyId } = req.query;
|
||||||
|
const progress = await UserModel.getProgress(
|
||||||
|
parseInt(userId),
|
||||||
|
storyId ? parseInt(storyId) : null
|
||||||
|
);
|
||||||
|
res.json({ code: 0, data: progress });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取进度失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '获取进度失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存用户进度
|
||||||
|
router.post('/progress', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId, storyId, currentNodeKey, isCompleted, endingReached } = req.body;
|
||||||
|
await UserModel.saveProgress(parseInt(userId), parseInt(storyId), {
|
||||||
|
currentNodeKey,
|
||||||
|
isCompleted,
|
||||||
|
endingReached
|
||||||
|
});
|
||||||
|
res.json({ code: 0, message: '保存成功' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存进度失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '保存进度失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点赞操作
|
||||||
|
router.post('/like', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId, storyId, isLiked } = req.body;
|
||||||
|
await UserModel.toggleLike(parseInt(userId), parseInt(storyId), isLiked);
|
||||||
|
res.json({ code: 0, message: isLiked ? '点赞成功' : '取消点赞' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('点赞操作失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '点赞操作失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 收藏操作
|
||||||
|
router.post('/collect', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId, storyId, isCollected } = req.body;
|
||||||
|
await UserModel.toggleCollect(parseInt(userId), parseInt(storyId), isCollected);
|
||||||
|
res.json({ code: 0, message: isCollected ? '收藏成功' : '取消收藏' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('收藏操作失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '收藏操作失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取收藏列表
|
||||||
|
router.get('/collections', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.query;
|
||||||
|
const collections = await UserModel.getCollections(parseInt(userId));
|
||||||
|
res.json({ code: 0, data: collections });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取收藏列表失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '获取收藏列表失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取已解锁结局
|
||||||
|
router.get('/endings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId, storyId } = req.query;
|
||||||
|
const endings = await UserModel.getUnlockedEndings(
|
||||||
|
parseInt(userId),
|
||||||
|
storyId ? parseInt(storyId) : null
|
||||||
|
);
|
||||||
|
res.json({ code: 0, data: endings });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取结局列表失败:', err);
|
||||||
|
res.status(500).json({ code: 500, message: '获取结局列表失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
69
server/sql/init.js
Normal file
69
server/sql/init.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 数据库初始化脚本
|
||||||
|
* 运行: npm run init-db
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
async function initDatabase() {
|
||||||
|
console.log('开始初始化数据库...');
|
||||||
|
|
||||||
|
// 先连接到MySQL(不指定数据库)
|
||||||
|
const connection = await mysql.createConnection({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: process.env.DB_PORT || 3306,
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
multipleStatements: true
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 读取并执行schema.sql
|
||||||
|
console.log('创建数据库表结构...');
|
||||||
|
const schemaSQL = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'schema.sql'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await connection.query(schemaSQL);
|
||||||
|
console.log('表结构创建成功!');
|
||||||
|
|
||||||
|
// 读取并执行种子数据
|
||||||
|
console.log('导入种子故事数据(第1部分)...');
|
||||||
|
const seedSQL1 = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'seed_stories_part1.sql'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await connection.query(seedSQL1);
|
||||||
|
console.log('种子数据第1部分导入成功!');
|
||||||
|
|
||||||
|
console.log('导入种子故事数据(第2部分)...');
|
||||||
|
const seedSQL2 = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'seed_stories_part2.sql'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await connection.query(seedSQL2);
|
||||||
|
console.log('种子数据第2部分导入成功!');
|
||||||
|
|
||||||
|
console.log('\n数据库初始化完成!');
|
||||||
|
console.log('共创建10个种子故事,包含66个剧情节点和多个结局分支。');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('数据库初始化失败:', error.message);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行初始化
|
||||||
|
initDatabase()
|
||||||
|
.then(() => {
|
||||||
|
console.log('\n可以启动服务器了: npm start');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('初始化过程中出现错误:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
107
server/sql/schema.sql
Normal file
107
server/sql/schema.sql
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
-- 星域故事汇数据库初始化脚本
|
||||||
|
-- 创建数据库
|
||||||
|
CREATE DATABASE IF NOT EXISTS stardom_story DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE stardom_story;
|
||||||
|
|
||||||
|
-- 故事主表
|
||||||
|
CREATE TABLE IF NOT EXISTS stories (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
title VARCHAR(100) NOT NULL COMMENT '故事标题',
|
||||||
|
cover_url VARCHAR(255) DEFAULT '' COMMENT '封面图URL',
|
||||||
|
description TEXT COMMENT '故事简介',
|
||||||
|
author_id INT DEFAULT 0 COMMENT '作者ID,0表示官方',
|
||||||
|
category VARCHAR(50) NOT NULL COMMENT '故事分类',
|
||||||
|
play_count INT DEFAULT 0 COMMENT '游玩次数',
|
||||||
|
like_count INT DEFAULT 0 COMMENT '点赞数',
|
||||||
|
is_featured BOOLEAN DEFAULT FALSE COMMENT '是否精选',
|
||||||
|
status TINYINT DEFAULT 1 COMMENT '状态:0下架 1上架',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_category (category),
|
||||||
|
INDEX idx_featured (is_featured),
|
||||||
|
INDEX idx_play_count (play_count)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事主表';
|
||||||
|
|
||||||
|
-- 故事节点表
|
||||||
|
CREATE TABLE IF NOT EXISTS story_nodes (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
story_id INT NOT NULL COMMENT '故事ID',
|
||||||
|
node_key VARCHAR(50) NOT NULL COMMENT '节点唯一标识',
|
||||||
|
content TEXT NOT NULL COMMENT '节点内容文本',
|
||||||
|
speaker VARCHAR(50) DEFAULT '' COMMENT '说话角色名',
|
||||||
|
background_image VARCHAR(255) DEFAULT '' COMMENT '背景图URL',
|
||||||
|
character_image VARCHAR(255) DEFAULT '' COMMENT '角色立绘URL',
|
||||||
|
bgm VARCHAR(255) DEFAULT '' COMMENT '背景音乐',
|
||||||
|
is_ending BOOLEAN DEFAULT FALSE COMMENT '是否为结局节点',
|
||||||
|
ending_name VARCHAR(100) DEFAULT '' COMMENT '结局名称',
|
||||||
|
ending_score INT DEFAULT 0 COMMENT '结局评分',
|
||||||
|
ending_type VARCHAR(20) DEFAULT '' COMMENT '结局类型:good/bad/normal/hidden',
|
||||||
|
sort_order INT DEFAULT 0 COMMENT '排序',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_story_id (story_id),
|
||||||
|
INDEX idx_node_key (story_id, node_key),
|
||||||
|
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事节点表';
|
||||||
|
|
||||||
|
-- 故事选项表
|
||||||
|
CREATE TABLE IF NOT EXISTS story_choices (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
node_id INT NOT NULL COMMENT '所属节点ID',
|
||||||
|
story_id INT NOT NULL COMMENT '故事ID(冗余,便于查询)',
|
||||||
|
text VARCHAR(200) NOT NULL COMMENT '选项文本',
|
||||||
|
next_node_key VARCHAR(50) NOT NULL COMMENT '下一个节点key',
|
||||||
|
sort_order INT DEFAULT 0 COMMENT '排序',
|
||||||
|
is_locked BOOLEAN DEFAULT FALSE COMMENT '是否锁定(需广告解锁)',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_node_id (node_id),
|
||||||
|
INDEX idx_story_id (story_id),
|
||||||
|
FOREIGN KEY (node_id) REFERENCES story_nodes(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事选项表';
|
||||||
|
|
||||||
|
-- 用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
openid VARCHAR(100) UNIQUE NOT NULL COMMENT '微信openid',
|
||||||
|
nickname VARCHAR(100) DEFAULT '' COMMENT '昵称',
|
||||||
|
avatar_url VARCHAR(255) DEFAULT '' COMMENT '头像URL',
|
||||||
|
gender TINYINT DEFAULT 0 COMMENT '性别:0未知 1男 2女',
|
||||||
|
total_play_count INT DEFAULT 0 COMMENT '总游玩次数',
|
||||||
|
total_endings INT DEFAULT 0 COMMENT '解锁结局数',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_openid (openid)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||||
|
|
||||||
|
-- 用户进度表
|
||||||
|
CREATE TABLE IF NOT EXISTS user_progress (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id INT NOT NULL COMMENT '用户ID',
|
||||||
|
story_id INT NOT NULL COMMENT '故事ID',
|
||||||
|
current_node_key VARCHAR(50) DEFAULT 'start' COMMENT '当前节点',
|
||||||
|
is_completed BOOLEAN DEFAULT FALSE COMMENT '是否完成',
|
||||||
|
ending_reached VARCHAR(100) DEFAULT '' COMMENT '达成的结局',
|
||||||
|
is_liked BOOLEAN DEFAULT FALSE COMMENT '是否点赞',
|
||||||
|
is_collected BOOLEAN DEFAULT FALSE COMMENT '是否收藏',
|
||||||
|
play_count INT DEFAULT 1 COMMENT '游玩次数',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_user_story (user_id, story_id),
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_story_id (story_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户进度表';
|
||||||
|
|
||||||
|
-- 用户结局收集表
|
||||||
|
CREATE TABLE IF NOT EXISTS user_endings (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
story_id INT NOT NULL,
|
||||||
|
ending_name VARCHAR(100) NOT NULL,
|
||||||
|
ending_score INT DEFAULT 0,
|
||||||
|
unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_user_ending (user_id, story_id, ending_name),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户结局收集表';
|
||||||
566
server/sql/schema_v2.sql
Normal file
566
server/sql/schema_v2.sql
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 星域故事汇 完整数据库结构 V2.0
|
||||||
|
-- 支持:AI改写/续写/创作、UGC、社交、计费
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS stardom_story DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
USE stardom_story;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 一、用户体系
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 用户主表
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
openid VARCHAR(100) UNIQUE NOT NULL COMMENT '微信openid',
|
||||||
|
unionid VARCHAR(100) DEFAULT '' COMMENT '微信unionid',
|
||||||
|
nickname VARCHAR(100) DEFAULT '' COMMENT '昵称',
|
||||||
|
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像',
|
||||||
|
gender TINYINT DEFAULT 0 COMMENT '0未知 1男 2女',
|
||||||
|
phone VARCHAR(20) DEFAULT '' COMMENT '手机号',
|
||||||
|
level INT DEFAULT 1 COMMENT '用户等级',
|
||||||
|
exp INT DEFAULT 0 COMMENT '经验值',
|
||||||
|
vip_type TINYINT DEFAULT 0 COMMENT '0普通 1月卡 2年卡 3永久',
|
||||||
|
vip_expire_at DATETIME DEFAULT NULL COMMENT 'VIP过期时间',
|
||||||
|
coin_balance INT DEFAULT 0 COMMENT '金币余额',
|
||||||
|
is_creator BOOLEAN DEFAULT FALSE COMMENT '是否认证创作者',
|
||||||
|
is_banned BOOLEAN DEFAULT FALSE COMMENT '是否封禁',
|
||||||
|
ban_reason VARCHAR(255) DEFAULT '',
|
||||||
|
last_login_at DATETIME DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_openid (openid),
|
||||||
|
INDEX idx_unionid (unionid),
|
||||||
|
INDEX idx_creator (is_creator)
|
||||||
|
) ENGINE=InnoDB COMMENT='用户主表';
|
||||||
|
|
||||||
|
-- 用户统计表(高频更新分离)
|
||||||
|
CREATE TABLE user_stats (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
total_play_count INT DEFAULT 0 COMMENT '总游玩次数',
|
||||||
|
total_endings INT DEFAULT 0 COMMENT '解锁结局数',
|
||||||
|
total_creations INT DEFAULT 0 COMMENT '创作数',
|
||||||
|
total_ai_uses INT DEFAULT 0 COMMENT 'AI使用次数',
|
||||||
|
followers_count INT DEFAULT 0 COMMENT '粉丝数',
|
||||||
|
following_count INT DEFAULT 0 COMMENT '关注数',
|
||||||
|
likes_received INT DEFAULT 0 COMMENT '获赞数',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB COMMENT='用户统计表';
|
||||||
|
|
||||||
|
-- 用户关注关系表
|
||||||
|
CREATE TABLE user_follows (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
follower_id BIGINT NOT NULL COMMENT '关注者',
|
||||||
|
following_id BIGINT NOT NULL COMMENT '被关注者',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_follow (follower_id, following_id),
|
||||||
|
INDEX idx_follower (follower_id),
|
||||||
|
INDEX idx_following (following_id)
|
||||||
|
) ENGINE=InnoDB COMMENT='用户关注关系';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 二、故事内容体系
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 故事主表(官方+UGC统一)
|
||||||
|
CREATE TABLE stories (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
author_id BIGINT DEFAULT 0 COMMENT '作者ID,0为官方',
|
||||||
|
title VARCHAR(100) NOT NULL COMMENT '标题',
|
||||||
|
cover_url VARCHAR(500) DEFAULT '' COMMENT '封面',
|
||||||
|
description TEXT COMMENT '简介',
|
||||||
|
category VARCHAR(50) NOT NULL COMMENT '分类',
|
||||||
|
tags VARCHAR(255) DEFAULT '' COMMENT '标签,逗号分隔',
|
||||||
|
word_count INT DEFAULT 0 COMMENT '字数',
|
||||||
|
node_count INT DEFAULT 0 COMMENT '节点数',
|
||||||
|
ending_count INT DEFAULT 0 COMMENT '结局数',
|
||||||
|
difficulty TINYINT DEFAULT 2 COMMENT '难度 1简单 2普通 3困难',
|
||||||
|
|
||||||
|
-- 来源标记
|
||||||
|
source_type ENUM('official','ugc','ai_generated','ai_assisted') DEFAULT 'official' COMMENT '来源类型',
|
||||||
|
base_story_id BIGINT DEFAULT NULL COMMENT '基于哪个故事改编',
|
||||||
|
ai_generation_id BIGINT DEFAULT NULL COMMENT '关联的AI生成记录',
|
||||||
|
|
||||||
|
-- 状态控制
|
||||||
|
status TINYINT DEFAULT 0 COMMENT '0草稿 1审核中 2已发布 3已下架 4已拒绝',
|
||||||
|
review_note VARCHAR(500) DEFAULT '' COMMENT '审核备注',
|
||||||
|
is_featured BOOLEAN DEFAULT FALSE COMMENT '是否精选',
|
||||||
|
is_hot BOOLEAN DEFAULT FALSE COMMENT '是否热门',
|
||||||
|
is_new BOOLEAN DEFAULT TRUE COMMENT '是否新作',
|
||||||
|
|
||||||
|
-- 付费设置
|
||||||
|
price_type TINYINT DEFAULT 0 COMMENT '0免费 1付费 2广告解锁',
|
||||||
|
price_coin INT DEFAULT 0 COMMENT '价格(金币)',
|
||||||
|
free_chapters INT DEFAULT 0 COMMENT '免费章节数',
|
||||||
|
|
||||||
|
-- 统计(读多写少可放主表)
|
||||||
|
play_count INT DEFAULT 0,
|
||||||
|
like_count INT DEFAULT 0,
|
||||||
|
collect_count INT DEFAULT 0,
|
||||||
|
comment_count INT DEFAULT 0,
|
||||||
|
share_count INT DEFAULT 0,
|
||||||
|
|
||||||
|
published_at DATETIME DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
INDEX idx_author (author_id),
|
||||||
|
INDEX idx_category (category),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_source (source_type),
|
||||||
|
INDEX idx_featured (is_featured),
|
||||||
|
INDEX idx_play (play_count),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='故事主表';
|
||||||
|
|
||||||
|
-- 故事节点表
|
||||||
|
CREATE TABLE story_nodes (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
story_id BIGINT NOT NULL,
|
||||||
|
node_key VARCHAR(50) NOT NULL COMMENT '节点标识',
|
||||||
|
parent_node_key VARCHAR(50) DEFAULT '' COMMENT '父节点(用于树形结构)',
|
||||||
|
chapter_num INT DEFAULT 1 COMMENT '章节号',
|
||||||
|
|
||||||
|
-- 内容
|
||||||
|
content TEXT NOT NULL COMMENT '文本内容',
|
||||||
|
speaker VARCHAR(50) DEFAULT '' COMMENT '说话角色',
|
||||||
|
emotion VARCHAR(30) DEFAULT '' COMMENT '情绪标签',
|
||||||
|
|
||||||
|
-- 媒体资源
|
||||||
|
background_image VARCHAR(500) DEFAULT '',
|
||||||
|
character_images JSON DEFAULT NULL COMMENT '角色立绘数组',
|
||||||
|
bgm VARCHAR(500) DEFAULT '',
|
||||||
|
sound_effect VARCHAR(500) DEFAULT '',
|
||||||
|
voice_url VARCHAR(500) DEFAULT '' COMMENT '语音URL',
|
||||||
|
|
||||||
|
-- 结局设置
|
||||||
|
is_ending BOOLEAN DEFAULT FALSE,
|
||||||
|
ending_name VARCHAR(100) DEFAULT '',
|
||||||
|
ending_type ENUM('good','bad','normal','hidden','secret') DEFAULT 'normal',
|
||||||
|
ending_score INT DEFAULT 0,
|
||||||
|
ending_cg VARCHAR(500) DEFAULT '' COMMENT '结局CG图',
|
||||||
|
|
||||||
|
-- AI标记
|
||||||
|
is_ai_generated BOOLEAN DEFAULT FALSE,
|
||||||
|
ai_generation_id BIGINT DEFAULT NULL,
|
||||||
|
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
INDEX idx_story (story_id),
|
||||||
|
INDEX idx_node_key (story_id, node_key),
|
||||||
|
INDEX idx_chapter (story_id, chapter_num),
|
||||||
|
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB COMMENT='故事节点表';
|
||||||
|
|
||||||
|
-- 故事选项表
|
||||||
|
CREATE TABLE story_choices (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
story_id BIGINT NOT NULL,
|
||||||
|
node_id BIGINT NOT NULL,
|
||||||
|
text VARCHAR(200) NOT NULL COMMENT '选项文本',
|
||||||
|
next_node_key VARCHAR(50) NOT NULL,
|
||||||
|
|
||||||
|
-- 条件解锁
|
||||||
|
condition_type ENUM('none','item','attr','ending','vip','ad') DEFAULT 'none',
|
||||||
|
condition_value VARCHAR(100) DEFAULT '' COMMENT '条件值JSON',
|
||||||
|
|
||||||
|
-- 效果
|
||||||
|
effect_type ENUM('none','attr','item','achievement') DEFAULT 'none',
|
||||||
|
effect_value VARCHAR(100) DEFAULT '' COMMENT '效果值JSON',
|
||||||
|
|
||||||
|
-- AI标记
|
||||||
|
is_ai_generated BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
INDEX idx_story (story_id),
|
||||||
|
INDEX idx_node (node_id),
|
||||||
|
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (node_id) REFERENCES story_nodes(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB COMMENT='故事选项表';
|
||||||
|
|
||||||
|
-- 故事版本表(支持版本回退)
|
||||||
|
CREATE TABLE story_versions (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
story_id BIGINT NOT NULL,
|
||||||
|
version_num INT NOT NULL COMMENT '版本号',
|
||||||
|
nodes_snapshot LONGTEXT NOT NULL COMMENT '节点快照JSON',
|
||||||
|
change_log VARCHAR(500) DEFAULT '' COMMENT '变更说明',
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_version (story_id, version_num),
|
||||||
|
INDEX idx_story (story_id)
|
||||||
|
) ENGINE=InnoDB COMMENT='故事版本表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 三、AI生成体系
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- AI生成记录表
|
||||||
|
CREATE TABLE ai_generations (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
story_id BIGINT DEFAULT NULL COMMENT '关联故事',
|
||||||
|
|
||||||
|
-- 生成类型
|
||||||
|
gen_type ENUM('rewrite_ending','rewrite_node','continue','create_outline','create_full','polish','translate') NOT NULL,
|
||||||
|
|
||||||
|
-- 输入
|
||||||
|
source_node_key VARCHAR(50) DEFAULT '',
|
||||||
|
source_content TEXT COMMENT '原内容',
|
||||||
|
user_prompt TEXT NOT NULL COMMENT '用户指令',
|
||||||
|
system_prompt TEXT COMMENT '系统提示词',
|
||||||
|
|
||||||
|
-- 输出
|
||||||
|
generated_content LONGTEXT COMMENT '生成内容',
|
||||||
|
generated_nodes JSON COMMENT '生成的节点结构',
|
||||||
|
|
||||||
|
-- 模型信息
|
||||||
|
model_provider VARCHAR(30) DEFAULT '' COMMENT 'openai/anthropic/local',
|
||||||
|
model_name VARCHAR(50) DEFAULT '',
|
||||||
|
temperature DECIMAL(3,2) DEFAULT 0.70,
|
||||||
|
|
||||||
|
-- 消耗统计
|
||||||
|
input_tokens INT DEFAULT 0,
|
||||||
|
output_tokens INT DEFAULT 0,
|
||||||
|
total_tokens INT DEFAULT 0,
|
||||||
|
latency_ms INT DEFAULT 0 COMMENT '响应耗时',
|
||||||
|
cost_cents INT DEFAULT 0 COMMENT '成本(分)',
|
||||||
|
|
||||||
|
-- 状态
|
||||||
|
status TINYINT DEFAULT 1 COMMENT '0失败 1成功 2处理中',
|
||||||
|
error_code VARCHAR(50) DEFAULT '',
|
||||||
|
error_msg TEXT,
|
||||||
|
|
||||||
|
-- 用户操作
|
||||||
|
is_accepted BOOLEAN DEFAULT NULL COMMENT '用户是否采纳',
|
||||||
|
is_edited BOOLEAN DEFAULT FALSE COMMENT '是否编辑后使用',
|
||||||
|
is_published BOOLEAN DEFAULT FALSE COMMENT '是否发布',
|
||||||
|
|
||||||
|
-- 评价
|
||||||
|
user_rating TINYINT DEFAULT NULL COMMENT '1-5星评价',
|
||||||
|
user_feedback TEXT COMMENT '用户反馈',
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_story (story_id),
|
||||||
|
INDEX idx_type (gen_type),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='AI生成记录表';
|
||||||
|
|
||||||
|
-- AI提示词模板表
|
||||||
|
CREATE TABLE ai_prompt_templates (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
gen_type VARCHAR(30) NOT NULL,
|
||||||
|
category VARCHAR(50) DEFAULT '' COMMENT '适用分类',
|
||||||
|
system_prompt TEXT NOT NULL,
|
||||||
|
user_prompt_template TEXT NOT NULL COMMENT '用户提示词模板',
|
||||||
|
variables JSON COMMENT '可用变量说明',
|
||||||
|
model_recommend VARCHAR(50) DEFAULT '',
|
||||||
|
temperature_recommend DECIMAL(3,2) DEFAULT 0.70,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_type (gen_type),
|
||||||
|
INDEX idx_active (is_active)
|
||||||
|
) ENGINE=InnoDB COMMENT='AI提示词模板';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 四、用户行为体系
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 用户游玩进度表
|
||||||
|
CREATE TABLE user_progress (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
story_id BIGINT NOT NULL,
|
||||||
|
current_node_key VARCHAR(50) DEFAULT 'start',
|
||||||
|
|
||||||
|
-- 状态
|
||||||
|
status TINYINT DEFAULT 0 COMMENT '0进行中 1已完成',
|
||||||
|
play_count INT DEFAULT 1,
|
||||||
|
play_duration INT DEFAULT 0 COMMENT '游玩时长(秒)',
|
||||||
|
|
||||||
|
-- 达成结局
|
||||||
|
endings_reached JSON COMMENT '达成的结局列表',
|
||||||
|
best_ending_score INT DEFAULT 0,
|
||||||
|
|
||||||
|
-- 存档数据
|
||||||
|
save_data JSON COMMENT '游戏存档(属性/道具等)',
|
||||||
|
choices_history JSON COMMENT '选择历史',
|
||||||
|
|
||||||
|
-- 互动
|
||||||
|
is_liked BOOLEAN DEFAULT FALSE,
|
||||||
|
is_collected BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
first_play_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_play_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
completed_at DATETIME DEFAULT NULL,
|
||||||
|
|
||||||
|
UNIQUE KEY uk_user_story (user_id, story_id),
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_story (story_id),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) ENGINE=InnoDB COMMENT='用户游玩进度';
|
||||||
|
|
||||||
|
-- 用户结局收集表
|
||||||
|
CREATE TABLE user_endings (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
story_id BIGINT NOT NULL,
|
||||||
|
node_id BIGINT NOT NULL,
|
||||||
|
ending_name VARCHAR(100) NOT NULL,
|
||||||
|
ending_type VARCHAR(20) DEFAULT 'normal',
|
||||||
|
ending_score INT DEFAULT 0,
|
||||||
|
choices_path JSON COMMENT '达成路径',
|
||||||
|
play_duration INT DEFAULT 0 COMMENT '本次游玩时长',
|
||||||
|
unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_user_ending (user_id, story_id, ending_name),
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_story (story_id)
|
||||||
|
) ENGINE=InnoDB COMMENT='用户结局收集';
|
||||||
|
|
||||||
|
-- 用户收藏表
|
||||||
|
CREATE TABLE user_collections (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
story_id BIGINT NOT NULL,
|
||||||
|
folder_name VARCHAR(50) DEFAULT '默认' COMMENT '收藏夹',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_user_story (user_id, story_id),
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_folder (user_id, folder_name)
|
||||||
|
) ENGINE=InnoDB COMMENT='用户收藏';
|
||||||
|
|
||||||
|
-- 评论表
|
||||||
|
CREATE TABLE comments (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
story_id BIGINT NOT NULL,
|
||||||
|
parent_id BIGINT DEFAULT NULL COMMENT '父评论ID',
|
||||||
|
reply_to_user_id BIGINT DEFAULT NULL COMMENT '回复谁',
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
like_count INT DEFAULT 0,
|
||||||
|
status TINYINT DEFAULT 1 COMMENT '0隐藏 1正常 2置顶',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_story (story_id),
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_parent (parent_id)
|
||||||
|
) ENGINE=InnoDB COMMENT='评论表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 五、经济体系
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 用户AI配额表
|
||||||
|
CREATE TABLE user_ai_quota (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
|
||||||
|
-- 每日免费额度
|
||||||
|
daily_free_total INT DEFAULT 5 COMMENT '每日免费总次数',
|
||||||
|
daily_free_used INT DEFAULT 0 COMMENT '今日已用',
|
||||||
|
daily_reset_date DATE COMMENT '重置日期',
|
||||||
|
|
||||||
|
-- 购买/赠送额度
|
||||||
|
purchased_quota INT DEFAULT 0 COMMENT '购买的次数',
|
||||||
|
gift_quota INT DEFAULT 0 COMMENT '赠送的次数',
|
||||||
|
|
||||||
|
-- VIP额度
|
||||||
|
vip_daily_bonus INT DEFAULT 0 COMMENT 'VIP每日额外次数',
|
||||||
|
|
||||||
|
-- 总使用统计
|
||||||
|
total_used INT DEFAULT 0,
|
||||||
|
total_tokens_used BIGINT DEFAULT 0,
|
||||||
|
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB COMMENT='用户AI配额';
|
||||||
|
|
||||||
|
-- 金币流水表
|
||||||
|
CREATE TABLE coin_transactions (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
amount INT NOT NULL COMMENT '正为收入负为支出',
|
||||||
|
balance_after INT NOT NULL COMMENT '交易后余额',
|
||||||
|
|
||||||
|
tx_type ENUM('recharge','purchase','reward','refund','gift','withdraw') NOT NULL,
|
||||||
|
ref_type VARCHAR(30) DEFAULT '' COMMENT '关联类型',
|
||||||
|
ref_id BIGINT DEFAULT NULL COMMENT '关联ID',
|
||||||
|
|
||||||
|
description VARCHAR(255) DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_type (tx_type),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='金币流水';
|
||||||
|
|
||||||
|
-- 订单表
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
order_no VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
|
||||||
|
product_type ENUM('coin','vip','story','ai_quota') NOT NULL,
|
||||||
|
product_id VARCHAR(50) DEFAULT '',
|
||||||
|
product_name VARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
|
amount_cents INT NOT NULL COMMENT '金额(分)',
|
||||||
|
pay_channel VARCHAR(30) DEFAULT '' COMMENT '支付渠道',
|
||||||
|
|
||||||
|
status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已退款',
|
||||||
|
paid_at DATETIME DEFAULT NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_order_no (order_no),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) ENGINE=InnoDB COMMENT='订单表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 六、运营与审核
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 内容审核表
|
||||||
|
CREATE TABLE content_reviews (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
content_type ENUM('story','comment','ai_generation','avatar','nickname') NOT NULL,
|
||||||
|
content_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL COMMENT '提交者',
|
||||||
|
|
||||||
|
content_snapshot TEXT COMMENT '内容快照',
|
||||||
|
|
||||||
|
-- 机审
|
||||||
|
auto_result TINYINT DEFAULT NULL COMMENT '0通过 1疑似 2违规',
|
||||||
|
auto_labels JSON COMMENT '机审标签',
|
||||||
|
auto_score DECIMAL(5,2) DEFAULT NULL,
|
||||||
|
|
||||||
|
-- 人审
|
||||||
|
review_status TINYINT DEFAULT 0 COMMENT '0待审 1通过 2拒绝',
|
||||||
|
reviewer_id BIGINT DEFAULT NULL,
|
||||||
|
review_note VARCHAR(500) DEFAULT '',
|
||||||
|
reviewed_at DATETIME DEFAULT NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_type_status (content_type, review_status),
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='内容审核表';
|
||||||
|
|
||||||
|
-- 举报表
|
||||||
|
CREATE TABLE reports (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
reporter_id BIGINT NOT NULL,
|
||||||
|
target_type ENUM('story','comment','user') NOT NULL,
|
||||||
|
target_id BIGINT NOT NULL,
|
||||||
|
reason_type VARCHAR(30) NOT NULL COMMENT '举报类型',
|
||||||
|
reason_detail TEXT,
|
||||||
|
|
||||||
|
status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理 2已忽略',
|
||||||
|
handler_id BIGINT DEFAULT NULL,
|
||||||
|
handle_result VARCHAR(255) DEFAULT '',
|
||||||
|
handled_at DATETIME DEFAULT NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_target (target_type, target_id),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) ENGINE=InnoDB COMMENT='举报表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 七、数据分析
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 行为埋点表(按天分区)
|
||||||
|
CREATE TABLE event_logs (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT DEFAULT NULL,
|
||||||
|
device_id VARCHAR(100) DEFAULT '',
|
||||||
|
session_id VARCHAR(64) DEFAULT '',
|
||||||
|
|
||||||
|
event_name VARCHAR(50) NOT NULL,
|
||||||
|
event_params JSON,
|
||||||
|
|
||||||
|
page_name VARCHAR(50) DEFAULT '',
|
||||||
|
ref_page VARCHAR(50) DEFAULT '',
|
||||||
|
|
||||||
|
platform VARCHAR(20) DEFAULT '' COMMENT 'weapp/h5/app',
|
||||||
|
app_version VARCHAR(20) DEFAULT '',
|
||||||
|
os VARCHAR(20) DEFAULT '',
|
||||||
|
device_model VARCHAR(50) DEFAULT '',
|
||||||
|
|
||||||
|
ip VARCHAR(50) DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
event_date DATE AS (DATE(created_at)) STORED,
|
||||||
|
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_event (event_name),
|
||||||
|
INDEX idx_date (event_date),
|
||||||
|
INDEX idx_session (session_id)
|
||||||
|
) ENGINE=InnoDB COMMENT='行为埋点表'
|
||||||
|
PARTITION BY RANGE (TO_DAYS(event_date)) (
|
||||||
|
PARTITION p_default VALUES LESS THAN MAXVALUE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AI调用统计日表
|
||||||
|
CREATE TABLE ai_daily_stats (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
stat_date DATE NOT NULL,
|
||||||
|
gen_type VARCHAR(30) NOT NULL,
|
||||||
|
model_name VARCHAR(50) DEFAULT '',
|
||||||
|
|
||||||
|
call_count INT DEFAULT 0,
|
||||||
|
success_count INT DEFAULT 0,
|
||||||
|
fail_count INT DEFAULT 0,
|
||||||
|
|
||||||
|
total_input_tokens BIGINT DEFAULT 0,
|
||||||
|
total_output_tokens BIGINT DEFAULT 0,
|
||||||
|
total_cost_cents INT DEFAULT 0,
|
||||||
|
|
||||||
|
avg_latency_ms INT DEFAULT 0,
|
||||||
|
p99_latency_ms INT DEFAULT 0,
|
||||||
|
|
||||||
|
unique_users INT DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE KEY uk_date_type (stat_date, gen_type, model_name),
|
||||||
|
INDEX idx_date (stat_date)
|
||||||
|
) ENGINE=InnoDB COMMENT='AI调用日统计';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 八、系统配置
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 分类配置表
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
icon VARCHAR(100) DEFAULT '',
|
||||||
|
color VARCHAR(20) DEFAULT '',
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
story_count INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB COMMENT='分类配置';
|
||||||
|
|
||||||
|
-- 系统配置表
|
||||||
|
CREATE TABLE system_configs (
|
||||||
|
config_key VARCHAR(100) PRIMARY KEY,
|
||||||
|
config_value TEXT NOT NULL,
|
||||||
|
description VARCHAR(255) DEFAULT '',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB COMMENT='系统配置';
|
||||||
|
|
||||||
|
-- 敏感词表
|
||||||
|
CREATE TABLE sensitive_words (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
word VARCHAR(50) NOT NULL,
|
||||||
|
category VARCHAR(30) DEFAULT 'general',
|
||||||
|
level TINYINT DEFAULT 1 COMMENT '1警告 2禁止',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
UNIQUE KEY uk_word (word),
|
||||||
|
INDEX idx_category (category)
|
||||||
|
) ENGINE=InnoDB COMMENT='敏感词表';
|
||||||
132
server/sql/seed_stories_part1.sql
Normal file
132
server/sql/seed_stories_part1.sql
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
-- 10个种子故事数据
|
||||||
|
USE stardom_story;
|
||||||
|
|
||||||
|
-- 1. 都市言情:《总裁的替身新娘》
|
||||||
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
|
(1, '总裁的替身新娘', '', '一场阴差阳错的婚礼,让平凡的你成为了霸道总裁的替身新娘。他冷漠、神秘,却在深夜对你展现出不一样的温柔...', '都市言情', 15680, 3420, TRUE);
|
||||||
|
|
||||||
|
INSERT INTO story_nodes (id, story_id, node_key, content, speaker, is_ending, ending_name, ending_score, ending_type, sort_order) VALUES
|
||||||
|
(1, 1, 'start', '婚礼当天,你穿着洁白的婚纱站在镜子前。这本该是你姐姐的婚礼,但她在昨晚逃婚了,留下一封信让你代替她嫁给陆氏集团的继承人——陆景深。\n\n"小姐,时间到了。"管家的声音从门外传来。\n\n你深吸一口气,推开了门。婚礼大厅里,那个男人背对着你站在红毯的尽头,高大的身影透着不可一世的气势。', '旁白', FALSE, '', 0, '', 1),
|
||||||
|
(2, 1, 'choice1_a', '你缓缓走向他,每一步都像踩在云端。当你站定在他身侧,他终于转过身来——剑眉星目,薄唇紧抿,眼神冰冷得像是能将人冻结。\n\n"你不是林诗韵。"他低沉的声音只有你能听见。\n\n你的心猛地一跳,他竟然认出来了!', '旁白', FALSE, '', 0, '', 2),
|
||||||
|
(3, 1, 'choice1_b', '你转身想逃,但刚迈出一步,一只有力的手就抓住了你的手腕。\n\n"想逃?"陆景深不知何时已经走到你身后,他的声音低沉而危险,"婚礼都准备好了,你觉得你能逃到哪里去?"\n\n他的眼神冰冷,却带着一丝玩味。', '旁白', FALSE, '', 0, '', 3),
|
||||||
|
(4, 1, 'choice2_a', '"是的,我是她的妹妹林诗语。"你决定坦白,"她...她逃婚了,我是被迫来的。"\n\n陆景深眯起眼睛,审视着你。良久,他嘴角勾起一抹意味不明的笑。\n\n"有趣。既然来了,那就继续演下去吧。"他握住你的手,转向宾客,"婚礼继续。"', '旁白', FALSE, '', 0, '', 4),
|
||||||
|
(5, 1, 'choice2_b', '"我不知道你在说什么。"你故作镇定地否认。\n\n陆景深冷笑一声:"你的耳垂没有耳洞,林诗韵左耳有三个。"他凑近你耳边,"别以为我什么都不知道。"\n\n你浑身一僵,原来从一开始,你就没骗过他。', '旁白', FALSE, '', 0, '', 5),
|
||||||
|
(6, 1, 'ending_good', '三个月后——\n\n"老婆,该吃药了。"陆景深端着一杯温水走进来,眼神里满是宠溺。\n\n你看着这个曾经冷漠如冰的男人,如今却甘愿为你放下所有骄傲。原来那场替身婚姻,竟成了你们爱情的开始。\n\n"陆景深,你什么时候开始喜欢我的?"\n\n他俯身在你额头落下一吻:"从你穿着婚纱走向我的那一刻。"\n\n【达成结局:真心换真情】', '旁白', TRUE, '真心换真情', 100, 'good', 6),
|
||||||
|
(7, 1, 'ending_normal', '一年后,合约婚姻到期。\n\n你站在陆家门口,手里提着行李。这一年,他对你不好不坏,你们像最熟悉的陌生人。\n\n"林诗语。"身后传来他的声音。\n\n你没有回头:"合约到期了,谢谢你这一年的照顾。"\n\n"...保重。"\n\n你笑了笑,迈步走向新的人生。有些人,注定只是生命中的过客。\n\n【达成结局:契约终结】', '旁白', TRUE, '契约终结', 60, 'normal', 7),
|
||||||
|
(8, 1, 'ending_bad', '你被陆家赶出了门。\n\n原来陆景深从始至终都在利用你,他需要一场婚姻来稳定公司股价,而你只是一颗棋子。\n\n"你以为我会爱上一个替身?"他的话像刀子一样扎进你的心。\n\n雨夜中,你孤独地走在街头,不知何去何从。\n\n【达成结局:棋子的悲哀】', '旁白', TRUE, '棋子的悲哀', 20, 'bad', 8);
|
||||||
|
|
||||||
|
INSERT INTO story_choices (node_id, story_id, text, next_node_key, sort_order) VALUES
|
||||||
|
(1, 1, '深吸一口气,走向红毯尽头', 'choice1_a', 1),
|
||||||
|
(1, 1, '转身想要逃离', 'choice1_b', 2),
|
||||||
|
(2, 1, '承认身份,坦白一切', 'choice2_a', 1),
|
||||||
|
(2, 1, '否认到底,继续伪装', 'choice2_b', 2),
|
||||||
|
(3, 1, '顺从地跟他完成婚礼', 'choice2_a', 1),
|
||||||
|
(3, 1, '挣扎反抗,坚持要走', 'ending_bad', 2),
|
||||||
|
(4, 1, '认真扮演妻子角色', 'ending_good', 1),
|
||||||
|
(4, 1, '保持距离,等合约结束', 'ending_normal', 2),
|
||||||
|
(5, 1, '向他道歉,请求原谅', 'ending_normal', 1),
|
||||||
|
(5, 1, '恼羞成怒,甩开他离去', 'ending_bad', 2);
|
||||||
|
|
||||||
|
-- 2. 悬疑推理:《密室中的第四个人》
|
||||||
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
|
(2, '密室中的第四个人', '', '暴风雪夜,你和三个陌生人被困在一座古老的山庄里。第二天早晨,其中一人死在了密室中。凶手,就在剩下的人当中...', '悬疑推理', 12350, 2890, TRUE);
|
||||||
|
|
||||||
|
INSERT INTO story_nodes (id, story_id, node_key, content, speaker, is_ending, ending_name, ending_score, ending_type, sort_order) VALUES
|
||||||
|
(9, 2, 'start', '暴风雪已经持续了整整一夜。\n\n你和另外三个人被困在这座偏僻的山庄里——戴眼镜的中年男人王教授、穿着名牌的富家女苏小姐、还有沉默寡言的青年李明。\n\n凌晨三点,一声尖叫划破夜空。\n\n你们冲向声音来源,发现王教授倒在书房的地板上,门从里面反锁,窗户紧闭。他已经没有了呼吸。\n\n一把沾血的匕首就在他身边,但诡异的是——密室里,不可能有第四个人进入。', '旁白', FALSE, '', 0, '', 1),
|
||||||
|
(10, 2, 'investigate_room', '你仔细检查了书房。\n\n发现几个关键线索:\n1. 王教授的手表停在2:47\n2. 窗户确实从内部锁死,没有破坏痕迹\n3. 书桌上有一杯已经凉透的咖啡,还剩半杯\n4. 地毯上有淡淡的香水味,不属于王教授\n\n"这是不可能的犯罪..."你喃喃自语。', '旁白', FALSE, '', 0, '', 2),
|
||||||
|
(11, 2, 'question_su', '你找到了苏小姐。\n\n"昨晚你在哪里?"你问道。\n\n她的眼眶微红:"我一直在房间里。王教授...他是个好人,我们曾经是师生关系。"她的声音微微颤抖。\n\n你注意到她身上有淡淡的香水味,和书房里的一模一样。', '旁白', FALSE, '', 0, '', 3),
|
||||||
|
(12, 2, 'question_li', '李明站在走廊尽头,表情阴沉。\n\n"你昨晚听到什么了吗?"你试探着问。\n\n"什么都没有。"他冷冷地回答,"我睡得很死。"\n\n但你注意到他的袖口有一小块暗红色的痕迹,像是没洗干净的血迹。', '旁白', FALSE, '', 0, '', 4),
|
||||||
|
(13, 2, 'accuse_su', '"凶手是你,苏小姐。"\n\n你指向她:"书房里的香水味就是证据。你和王教授的关系不只是师生,对吗?"\n\n苏小姐脸色惨白,突然崩溃大哭:"他...他要把我们的关系公开!我不能让他毁了我的婚约!但是...但是密室是怎么形成的,我真的不知道!"', '旁白', FALSE, '', 0, '', 5),
|
||||||
|
(14, 2, 'accuse_li', '"李明,血迹就是证据。"\n\n你直视他的眼睛。李明的表情终于出现了裂痕。\n\n"王教授...他十年前撞死了我妹妹,然后用钱摆平了一切!"他的声音充满恨意,"我等这个机会等了十年!"\n\n"但密室呢?你是怎么做到的?"\n\n他露出诡异的笑:"你难道没发现吗?这扇门的锁,是可以从外面用铁丝复位的老式锁..."', '旁白', FALSE, '', 0, '', 6),
|
||||||
|
(15, 2, 'ending_truth', '真相大白。\n\n李明利用老式门锁的漏洞制造了密室假象,而苏小姐的到来只是巧合——她确实去找过王教授,但那时他还活着。\n\n"你很聪明。"李明被铐上手铐前,看着你说,"但即使重来一次,我也会这么做。有些仇,不能不报。"\n\n暴风雪终于停了,警车的鸣笛声由远及近。\n\n【达成结局:真相猎人】', '旁白', TRUE, '真相猎人', 100, 'good', 7),
|
||||||
|
(16, 2, 'ending_wrong', '你指控了苏小姐。\n\n当警察到来后,真正的凶手李明趁乱逃走了。三天后,他在另一座城市落网,但已经又制造了一起"密室杀人案"。\n\n"如果你当时再仔细一点..."警探惋惜地说。\n\n你沉默了。错误的推理,让无辜的人背负嫌疑,也让真凶逍遥法外。\n\n【达成结局:错误的指控】', '旁白', TRUE, '错误的指控', 40, 'bad', 8);
|
||||||
|
|
||||||
|
INSERT INTO story_choices (node_id, story_id, text, next_node_key, sort_order) VALUES
|
||||||
|
(9, 2, '检查书房现场', 'investigate_room', 1),
|
||||||
|
(9, 2, '询问苏小姐', 'question_su', 2),
|
||||||
|
(9, 2, '询问李明', 'question_li', 3),
|
||||||
|
(10, 2, '香水味很关键,去问苏小姐', 'question_su', 1),
|
||||||
|
(10, 2, '继续调查其他人', 'question_li', 2),
|
||||||
|
(11, 2, '她就是凶手!', 'accuse_su', 1),
|
||||||
|
(11, 2, '先去问问李明', 'question_li', 2),
|
||||||
|
(12, 2, '血迹说明一切,他是凶手', 'accuse_li', 1),
|
||||||
|
(12, 2, '证据不足,先指控苏小姐', 'accuse_su', 2),
|
||||||
|
(13, 2, '她在撒谎,坚持指控', 'ending_wrong', 1),
|
||||||
|
(13, 2, '也许另有隐情,重新调查', 'question_li', 2),
|
||||||
|
(14, 2, '确认他就是凶手', 'ending_truth', 1);
|
||||||
|
|
||||||
|
-- 3. 古风宫廷:《凤临天下》
|
||||||
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
|
(3, '凤临天下', '', '你是将军府的嫡女,一道圣旨将你送入了深宫。后宫如战场,尔虞我诈步步惊心,你能否在这深宫中存活,甚至问鼎凤位?', '古风宫廷', 18920, 4560, TRUE);
|
||||||
|
|
||||||
|
INSERT INTO story_nodes (id, story_id, node_key, content, speaker, is_ending, ending_name, ending_score, ending_type, sort_order) VALUES
|
||||||
|
(17, 3, 'start', '永安十三年,春。\n\n一道圣旨,将你从将军府送入了这座金碧辉煌却暗藏杀机的皇宫。\n\n"林家嫡女林清婉,册封为婉嫔,赐居长春宫。"\n\n你跪在殿前,锦绣华服加身,却感受不到半分暖意。父亲战死沙场的消息传来不过三日,皇上便下了这道旨意。\n\n"婉嫔娘娘,请随奴婢来。"一个小太监恭敬地说。\n\n你站起身,望向那巍峨的宫门。前路未知,步步惊心。', '旁白', FALSE, '', 0, '', 1),
|
||||||
|
(18, 3, 'meet_emperor', '御花园中,你"偶遇"了当今圣上。\n\n他负手而立,龙袍加身,眉宇间带着天生的威严。\n\n"你就是林将军的女儿?"他的目光在你身上停留,"倒是比传闻中更出色。"\n\n你低眉顺目:"陛下谬赞。"\n\n"林将军为国捐躯,朕心甚痛。"他走近一步,"你可有什么心愿?"\n\n这是一个机会,也可能是一个陷阱。', '旁白', FALSE, '', 0, '', 2),
|
||||||
|
(19, 3, 'meet_consort', '你在宫道上遇到了皇后的仪仗。\n\n皇后端坐轿中,珠翠环绕,气度雍容。她掀开帘子,打量着你。\n\n"你就是新来的婉嫔?"她的声音不辨喜怒,"生得倒是标致。本宫听说你父亲是林大将军?可惜了..."\n\n"谢皇后娘娘关心。"你福身行礼。\n\n"长春宫偏僻,若有什么需要,尽管来坤宁宫找本宫。"她意味深长地笑了笑,便放下了帘子。', '旁白', FALSE, '', 0, '', 3),
|
||||||
|
(20, 3, 'choice_emperor', '"臣妾别无所求。"你温婉地回答,"只愿陛下龙体安康,江山永固。"\n\n皇帝眼中闪过一丝意外,随即笑了:"难得。宫中女子,大多急于求宠,你倒是不同。"\n\n他从袖中取出一块玉佩递给你:"这是朕的贴身之物,你且收着。"\n\n接过玉佩的那一刻,你感受到了远处无数道注视的目光,有嫉妒,有审视,更有杀意。', '旁白', FALSE, '', 0, '', 4),
|
||||||
|
(21, 3, 'choice_queen', '你主动去了坤宁宫。\n\n"皇后娘娘,臣妾初入宫闱,不懂规矩,还请娘娘多多指点。"你恭敬地跪下。\n\n皇后打量着你,似乎在评估你的价值。\n\n"你是个聪明人。"她终于开口,"在这宫里,聪明人才能活得久。本宫缺一个能用的人,你可愿意?"\n\n这是投靠,也是站队。', '旁白', FALSE, '', 0, '', 5),
|
||||||
|
(22, 3, 'ending_empress', '十年后——\n\n"皇后驾到!"\n\n你身着凤袍,步入太和殿。百官跪拜,三千佳丽俯首。\n\n从婉嫔到贵妃,从贵妃到皇后,这条路你走了整整十年。多少腥风血雨,多少尔虞我诈,都已成为过眼云烟。\n\n"皇后,今日是大喜之日。"皇帝握住你的手,眼中是多年来从未改变的深情。\n\n你微微一笑。凤临天下,母仪天下,这一切终于实现。\n\n【达成结局:凤临天下】', '旁白', TRUE, '凤临天下', 100, 'good', 6),
|
||||||
|
(23, 3, 'ending_concubine', '你选择了安稳度日。\n\n长春宫虽偏僻,却也清静。你不争不抢,在这后宫的惊涛骇浪中保全了自己。\n\n二十年后,你依旧是婉嫔,看着一代代新人入宫,又看着她们在争斗中陨落。\n\n"娘娘,您不后悔吗?"宫女问你。\n\n你望着天边的晚霞,轻轻摇头:"能活着,已是幸事。"\n\n【达成结局:深宫余生】', '旁白', TRUE, '深宫余生', 60, 'normal', 7),
|
||||||
|
(24, 3, 'ending_tragic', '你被卷入了一场宫变。\n\n无论是皇帝的宠爱还是皇后的拉拢,最终都成了你的催命符。\n\n"婉嫔通敌叛国,赐白绫。"\n\n你跪在冰冷的宫殿里,手中握着那块曾经的定情玉佩。原来从一开始,你就只是一颗棋子。\n\n"父亲..."你闭上了眼睛,"女儿来陪您了。"\n\n【达成结局:红颜薄命】', '旁白', TRUE, '红颜薄命', 20, 'bad', 8);
|
||||||
|
|
||||||
|
INSERT INTO story_choices (node_id, story_id, text, next_node_key, sort_order) VALUES
|
||||||
|
(17, 3, '在御花园偶遇皇上', 'meet_emperor', 1),
|
||||||
|
(17, 3, '在宫道上遇到皇后', 'meet_consort', 2),
|
||||||
|
(18, 3, '说只愿陛下安康', 'choice_emperor', 1),
|
||||||
|
(18, 3, '请求为父亲平反', 'ending_tragic', 2),
|
||||||
|
(19, 3, '主动去拜见皇后', 'choice_queen', 1),
|
||||||
|
(19, 3, '保持距离,安分守己', 'ending_concubine', 2),
|
||||||
|
(20, 3, '收下玉佩,争取圣宠', 'ending_empress', 1),
|
||||||
|
(20, 3, '婉拒御赐,保持低调', 'ending_concubine', 2),
|
||||||
|
(21, 3, '答应皇后,成为她的人', 'ending_empress', 1),
|
||||||
|
(21, 3, '婉拒,不想卷入纷争', 'ending_concubine', 2);
|
||||||
|
|
||||||
|
-- 4. 校园青春:《暗恋那件小事》
|
||||||
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
|
(4, '暗恋那件小事', '', '高三那年,你暗恋着班上最耀眼的那个人。毕业季来临,你是否有勇气说出那句"我喜欢你"?', '校园青春', 22450, 5280, TRUE);
|
||||||
|
|
||||||
|
INSERT INTO story_nodes (id, story_id, node_key, content, speaker, is_ending, ending_name, ending_score, ending_type, sort_order) VALUES
|
||||||
|
(25, 4, 'start', '高三下学期,距离高考还有三个月。\n\n教室里弥漫着紧张的气氛,而你的目光却总是不自觉地飘向窗边第三排的位置——那里坐着沈昼,全年级第一,校篮球队队长,无数女生心中的白月光。\n\n也是你暗恋了整整三年的人。\n\n"喂,又在看沈昼?"同桌用胳膊肘捅了捅你,"你什么时候才敢表白啊?"\n\n"别闹..."你赶紧低下头,心跳却已经漏了一拍。\n\n这时,沈昼突然回过头,目光与你相撞。你慌乱地移开视线,脸烧得发烫。', '旁白', FALSE, '', 0, '', 1),
|
||||||
|
(26, 4, 'library', '放学后,你在图书馆复习。\n\n"这里有人吗?"一个熟悉的声音在头顶响起。\n\n你抬起头,发现沈昼就站在你面前,手里拿着几本书。整个图书馆只剩你们两个人。\n\n"没...没有。"你的声音小得像蚊子叫。\n\n他在你对面坐下,专注地翻开书本。你完全看不进去任何东西,满脑子都是他的睫毛好长、他闻起来好香...\n\n突然,他抬起头:"你数学是不是不太好?我看你那道题写了很久。"', '旁白', FALSE, '', 0, '', 2),
|
||||||
|
(27, 4, 'basketball', '篮球赛上,你被同桌拉来当啦啦队。\n\n沈昼在场上奔跑跳跃,汗水浸湿了他的球衣。每一次投篮,都引来女生们的尖叫。\n\n比赛结束,他们班赢了。沈昼朝这边走来,你下意识想躲,却被同桌按住。\n\n"这个给你。"他递过来一瓶水,"刚才听到你喊加油了。"\n\n"我...我没有..."你语无伦次。\n\n他笑了,眼睛弯成了月牙:"骗人,我明明看到了。"', '旁白', FALSE, '', 0, '', 3),
|
||||||
|
(28, 4, 'rooftop', '毕业典礼后,你收到一张字条:\n\n"天台见。——沈昼"\n\n你的心跳快得像要爆炸。踏上天台的那一刻,夕阳正好洒满整个城市。沈昼背对着你,校服被风吹起。\n\n"你...找我有事吗?"你小心翼翼地开口。\n\n他转过身,认真地看着你:"有件事,我想了三年,今天必须告诉你。"', '旁白', FALSE, '', 0, '', 4),
|
||||||
|
(29, 4, 'confess_yes', '"我也...我也喜欢你!"你鼓起勇气,抢先说出了口。\n\n沈昼愣了一秒,然后露出了你从未见过的灿烂笑容。\n\n"笨蛋,我还没说完呢。"他走近,轻轻牵起你的手,"我喜欢你,从高一第一次看到你在图书馆睡着开始。"\n\n夕阳下,两个人的影子交叠在一起。\n\n"我们在一起吧。"\n\n【达成结局:双向奔赴】', '旁白', TRUE, '双向奔赴', 100, 'good', 5),
|
||||||
|
(30, 4, 'confess_no', '你没有说话,沉默地低下了头。\n\n"我知道你喜欢我。"沈昼说,"其实,我也..."\n\n"别说了。"你打断他,"我们...还是做普通同学吧。高考要紧。"\n\n你转身跑下了天台,泪水模糊了视线。你告诉自己,这是为了你们两个好。\n\n多年后,你总会想起那个夕阳下的天台,和那个没能说出口的"我喜欢你"。\n\n【达成结局:错过的心动】', '旁白', TRUE, '错过的心动', 40, 'normal', 6),
|
||||||
|
(31, 4, 'ending_friends', '高考结束后,你们考上了不同的大学。\n\n那三年的暗恋,最终还是没能说出口。你们成了普通朋友,偶尔在同学群里聊几句,仅此而已。\n\n十年后的同学聚会,你看到他带着妻子出现。他还是那么耀眼,而你只能在心里默默说一句:\n\n"我曾经很喜欢你。"\n\n【达成结局:藏在心底的秘密】', '旁白', TRUE, '藏在心底的秘密', 30, 'bad', 7);
|
||||||
|
|
||||||
|
INSERT INTO story_choices (node_id, story_id, text, next_node_key, sort_order) VALUES
|
||||||
|
(25, 4, '放学去图书馆学习', 'library', 1),
|
||||||
|
(25, 4, '去看篮球比赛', 'basketball', 2),
|
||||||
|
(26, 4, '请他教你数学', 'rooftop', 1),
|
||||||
|
(26, 4, '说不用,自己能行', 'ending_friends', 2),
|
||||||
|
(27, 4, '接过水,道谢', 'rooftop', 1),
|
||||||
|
(27, 4, '害羞跑开', 'ending_friends', 2),
|
||||||
|
(28, 4, '鼓起勇气说出喜欢', 'confess_yes', 1),
|
||||||
|
(28, 4, '沉默不语', 'confess_no', 2);
|
||||||
|
|
||||||
|
-- 5. 修仙玄幻:《废柴逆袭录》
|
||||||
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
|
(5, '废柴逆袭录', '', '身为青云宗的废柴弟子,你意外获得上古传承。从此开始了一段热血逆袭之路!', '修仙玄幻', 16780, 3890, TRUE);
|
||||||
|
|
||||||
|
INSERT INTO story_nodes (id, story_id, node_key, content, speaker, is_ending, ending_name, ending_score, ending_type, sort_order) VALUES
|
||||||
|
(32, 5, 'start', '青云宗,外门弟子居住的杂役院。\n\n你叫陈风,入宗三年,修为停滞在炼气一层,被同门嘲笑为"废柴"。今日是宗门大比,你照例被师兄们呼来喝去地搬运物资。\n\n"陈风,把这些灵石搬去演武场!动作快点!"\n\n你默默扛起沉重的箱子,路过一处山洞时,脚下突然踩空,整个人滚落洞中。\n\n黑暗中,一道金光直冲你的眉心——\n\n"小子,你的机缘到了。"一个苍老的声音在脑海中响起。', '旁白', FALSE, '', 0, '', 1),
|
||||||
|
(33, 5, 'inheritance', '你发现自己得到了一位上古强者的传承!\n\n"老夫是万年前的天元大帝,这部《混元诀》是我毕生心血。"那声音说道,"你资质平平,但心性不错。好好修炼,或可成就大道。"\n\n你感到一股浑厚的力量涌入体内,三年停滞的修为瞬间突破,炼气二层...三层...五层!\n\n"小子,收敛气息,别让人发现。"天元大帝提醒道。', '旁白', FALSE, '', 0, '', 2),
|
||||||
|
(34, 5, 'challenge', '宗门大比上,你的死对头——内门弟子赵天龙公然挑衅。\n\n"废柴陈风,敢不敢和我打一场?"他嚣张地笑着,"你要是赢了,我给你当一个月奴仆!"\n\n周围响起阵阵哄笑。所有人都在等着看你的笑话。\n\n天元大帝的声音响起:"小子,要不要教训教训他?以你现在的实力,轻松碾压。"', '旁白', FALSE, '', 0, '', 3),
|
||||||
|
(35, 5, 'show_power', '"我接受挑战!"\n\n你踏上擂台,赵天龙根本没把你放在眼里,漫不经心地出了一拳。\n\n你侧身躲过,反手一掌!\n\n轰!\n\n赵天龙直接飞出擂台,撞碎了三根石柱才停下。全场鸦雀无声。\n\n"这...这怎么可能!"赵天龙满脸不可置信。\n\n"还有谁?"你负手而立,气势如虹。', '旁白', FALSE, '', 0, '', 4),
|
||||||
|
(36, 5, 'hide_power', '你装作害怕的样子,转身就跑。\n\n"哈哈哈!看到没,就是个怂包!"赵天龙大笑。\n\n但天元大帝却赞许道:"不错,懂得韬光养晦。真正的强者,不需要逞一时之勇。等你筑基之后,再让他们看看什么叫脱胎换骨。"\n\n你暗暗握拳,总有一天,你会让所有人刮目相看。', '旁白', FALSE, '', 0, '', 5),
|
||||||
|
(37, 5, 'ending_immortal', '百年后——\n\n你已成为修真界的传奇,"天元真君"的名号响彻九州。\n\n青云宗早已被你踩在脚下,当年嘲笑你的人,如今只能仰望你的背影。\n\n"师尊,您当年为何愿意收我这个废柴为徒?"弟子问道。\n\n你望着云海,想起了那个山洞里改变命运的夜晚。\n\n"因为,成大事者,从来不在起点,而在终点。"\n\n【达成结局:问鼎仙途】', '旁白', TRUE, '问鼎仙途', 100, 'good', 6),
|
||||||
|
(38, 5, 'ending_mortal', '你选择放弃修炼,回归凡尘。\n\n天元大帝的传承太过沉重,你只想做一个普通人。\n\n多年后,你成为了一个小镇上的郎中,治病救人,娶妻生子。\n\n偶尔,你会抬头望向天空中划过的剑光,想起那段短暂的修仙岁月。\n\n"平凡,也是一种幸福吧。"你笑着给孙子讲起了那些关于仙人的故事。\n\n【达成结局:归于平凡】', '旁白', TRUE, '归于平凡', 50, 'normal', 7);
|
||||||
|
|
||||||
|
INSERT INTO story_choices (node_id, story_id, text, next_node_key, sort_order) VALUES
|
||||||
|
(32, 5, '接受传承', 'inheritance', 1),
|
||||||
|
(32, 5, '拒绝传承', 'ending_mortal', 2),
|
||||||
|
(33, 5, '参加宗门大比', 'challenge', 1),
|
||||||
|
(33, 5, '低调修炼不张扬', 'ending_immortal', 2),
|
||||||
|
(34, 5, '接受挑战,展示实力', 'show_power', 1),
|
||||||
|
(34, 5, '装怂跑路', 'hide_power', 2),
|
||||||
|
(35, 5, '继续高调挑战强者', 'ending_immortal', 1),
|
||||||
|
(35, 5, '见好就收,低调修炼', 'ending_immortal', 2),
|
||||||
|
(36, 5, '潜心修炼,等待时机', 'ending_immortal', 1),
|
||||||
|
(36, 5, '受不了嘲讽,主动退宗', 'ending_mortal', 2);
|
||||||
|
|
||||||
|
-- 6-10故事继续插入...
|
||||||
105
server/sql/seed_stories_part2.sql
Normal file
105
server/sql/seed_stories_part2.sql
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
-- 继续插入6-10号种子故事
|
||||||
|
USE stardom_story;
|
||||||
|
|
||||||
|
-- 6. 穿越重生:《回到高考前一天》
|
||||||
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
|
(6, '回到高考前一天', '', '车祸瞬间,你睁开眼发现自己回到了十年前——高考前一天。这一次,你能改变命运吗?', '穿越重生', 19870, 4230, FALSE);
|
||||||
|
|
||||||
|
INSERT INTO story_nodes (id, story_id, node_key, content, speaker, is_ending, ending_name, ending_score, ending_type, sort_order) VALUES
|
||||||
|
(39, 6, 'start', '刺眼的白光之后,你猛然睁开眼。\n\n熟悉的天花板,熟悉的旧风扇,熟悉的高中宿舍...床头的日历赫然写着:2016年6月6日。\n\n高考前一天。\n\n你愣住了。十年前的自己,因为一场失误与985失之交臂,从此走上了完全不同的人生轨迹。\n\n而现在...你回来了。\n\n手机震动,是妈妈的短信:"儿子,明天好好考,妈相信你!"\n\n你握紧手机,这一次,绝不能重蹈覆辙。', '旁白', FALSE, '', 0, '', 1),
|
||||||
|
(40, 6, 'choice_study', '你决定抓紧最后的时间复习。\n\n记忆中,理综有一道大题你当年没做出来,白白丢了15分。那道题,是关于电磁感应的...\n\n你翻开物理课本,十年后的你已经是一名工程师,这种题目早已烂熟于心。你把解题思路和关键公式重新梳理了一遍。\n\n"这次,一定能做出来。"\n\n室友探头问:"都考前一天了你还看书?不如出去放松放松?"', '旁白', FALSE, '', 0, '', 2),
|
||||||
|
(41, 6, 'choice_relax', '你决定出去走走,顺便见一些重要的人。\n\n十年后,你最后悔的不是高考成绩,而是失去的人和错过的机会。\n\n你想起了隔壁班的林小雨——她后来因为抑郁症退学了。那时候如果有人关心她一下,也许结局会不同。\n\n还有爷爷,高考后的那个暑假,他突发心梗去世了。你连最后一面都没见到。\n\n"我该先去见谁?"', '旁白', FALSE, '', 0, '', 3),
|
||||||
|
(42, 6, 'visit_girl', '你在校门口找到了林小雨。\n\n她正一个人坐在长椅上发呆,表情木然。你记得,原本的历史中,你从未注意过她。\n\n"林小雨?"你走过去,"明天就高考了,你还好吗?"\n\n她抬起头,眼眶微红:"我...我觉得我考不好。我爸妈说,考不上好大学就断绝关系..."\n\n你看着她,心里一阵酸楚。当年她就是因为压力太大才崩溃的。', '旁白', FALSE, '', 0, '', 4),
|
||||||
|
(43, 6, 'ending_perfect', '十年后——\n\n你坐在自己的公司里,看着窗外的车水马龙。\n\n高考那天,你超常发挥,考上了清华。但更重要的是,你改变了很多人的命运。\n\n林小雨后来成了心理医生,专门帮助有心理问题的青少年。你在她的朋友圈里看到她阳光灿烂的笑容。\n\n爷爷在你的提醒下去做了体检,及时发现了心脏问题,现在已经八十多岁,身体依然硬朗。\n\n"重来一次,真好。"\n\n【达成结局:完美逆转】', '旁白', TRUE, '完美逆转', 100, 'good', 5),
|
||||||
|
(44, 6, 'ending_changed', '你改变了一些事情,但代价是高考失利。\n\n为了开导林小雨,你熬夜陪她聊天,第二天考试时昏昏沉沉。你的分数比原本的历史还低了20分。\n\n但林小雨考上了大学,后来成了你最好的朋友。\n\n"后悔吗?"多年后她问你。\n\n你笑着摇头:"分数只是一时的,朋友是一辈子的。"\n\n【达成结局:无悔的选择】', '旁白', TRUE, '无悔的选择', 70, 'normal', 6);
|
||||||
|
|
||||||
|
INSERT INTO story_choices (node_id, story_id, text, next_node_key, sort_order) VALUES
|
||||||
|
(39, 6, '抓紧时间复习', 'choice_study', 1),
|
||||||
|
(39, 6, '出去见重要的人', 'choice_relax', 2),
|
||||||
|
(40, 6, '继续复习到深夜', 'ending_perfect', 1),
|
||||||
|
(40, 6, '出去见见朋友', 'choice_relax', 2),
|
||||||
|
(41, 6, '去找林小雨', 'visit_girl', 1),
|
||||||
|
(41, 6, '回去继续复习', 'ending_perfect', 2),
|
||||||
|
(42, 6, '陪她聊天开导她', 'ending_changed', 1),
|
||||||
|
(42, 6, '简单鼓励后回去复习', 'ending_perfect', 2);
|
||||||
|
|
||||||
|
-- 7. 职场商战:《逆风翻盘》
|
||||||
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
|
(7, '逆风翻盘', '', '你被陷害失去了一切,公司、名誉、爱人。三年后,你带着复仇之心重返商场...', '职场商战', 14560, 3120, FALSE);
|
||||||
|
|
||||||
|
INSERT INTO story_nodes (id, story_id, node_key, content, speaker, is_ending, ending_name, ending_score, ending_type, sort_order) VALUES
|
||||||
|
(45, 7, 'start', '三年前,你是年轻有为的创业者,星辰科技的创始人。\n\n直到那一天——你的合伙人兼好友周明背叛了你。他联合董事会,以莫须有的罪名将你踢出公司,还抢走了你的未婚妻。\n\n三年后的今天,你改头换面,以"陈墨"的身份重返商界。你的新公司"破晓资本"已经估值百亿。\n\n"周总,您的三点有人预约了见面。"\n\n你看着周明办公室的方向,嘴角勾起一抹冷笑。\n\n游戏,开始了。', '旁白', FALSE, '', 0, '', 1),
|
||||||
|
(46, 7, 'meeting', '会议室里,周明正襟危坐。他比三年前胖了不少,眼神里多了几分世故和贪婪。\n\n"陈总,久仰大名。"他殷勤地握住你的手,"破晓资本愿意投资我们,实在是星辰的荣幸。"\n\n他没认出你。三年的风霜和精心的改变,让你判若两人。\n\n"周总客气了。"你微微一笑,"不过投资之前,我想先了解一下贵公司的核心业务...以及一些历史问题。"\n\n周明的笑容僵了一秒:"历史问题?"', '旁白', FALSE, '', 0, '', 2),
|
||||||
|
(47, 7, 'expose', '你将一份文件扔在桌上。\n\n"周明,还记得三年前你是怎么踢走原创始人的吗?"你摘下眼镜,直视他的眼睛。\n\n周明瞳孔骤缩:"你...你是..."\n\n"对,我就是被你陷害的那个人。"你站起身,"这份文件是你当年伪造证据、贿赂董事的全部证据。明天,它会出现在所有媒体的头条上。"\n\n周明脸色惨白,双腿发软跪倒在地:"求你...给我一条活路..."', '旁白', FALSE, '', 0, '', 3),
|
||||||
|
(48, 7, 'ending_revenge', '周明入狱了,星辰科技被你收购。\n\n你坐在曾经属于自己的办公室里,看着窗外的夜景。复仇成功了,但你并没有想象中那么开心。\n\n"值得吗?"秘书问你。\n\n你沉默良久:"三年的仇恨终于放下了。但我也明白了,最好的复仇不是毁掉别人,而是让自己过得比他们好。"\n\n【达成结局:王者归来】', '旁白', TRUE, '王者归来', 100, 'good', 4),
|
||||||
|
(49, 7, 'ending_forgive', '你看着跪在地上的周明,最终收回了那份文件。\n\n"滚吧。"你冷冷地说,"这次放过你,是为了曾经的兄弟情。但如果你再敢做伤害别人的事,我不会再手软。"\n\n周明狼狈地逃走了。你没有毁掉他,而是选择了放手。\n\n三年后,你的公司成为行业龙头。而周明的公司因为经营不善倒闭了。\n\n"最好的复仇,是活得比他好。"你笑着说。\n\n【达成结局:格局之上】', '旁白', TRUE, '格局之上', 80, 'good', 5);
|
||||||
|
|
||||||
|
INSERT INTO story_choices (node_id, story_id, text, next_node_key, sort_order) VALUES
|
||||||
|
(45, 7, '主动约见周明', 'meeting', 1),
|
||||||
|
(45, 7, '先暗中收集更多证据', 'meeting', 2),
|
||||||
|
(46, 7, '直接摊牌,公布证据', 'expose', 1),
|
||||||
|
(46, 7, '继续伪装,慢慢渗透', 'expose', 2),
|
||||||
|
(47, 7, '彻底毁掉他', 'ending_revenge', 1),
|
||||||
|
(47, 7, '放他一马', 'ending_forgive', 2);
|
||||||
|
|
||||||
|
-- 8. 科幻未来:《2099最后一班地铁》
|
||||||
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
|
(8, '2099最后一班地铁', '', '2099年的最后一天,你登上了末班地铁。车厢里只有你和一个神秘的女孩。她说,这趟地铁会带你去任何你想去的地方...', '科幻未来', 11230, 2670, FALSE);
|
||||||
|
|
||||||
|
INSERT INTO story_nodes (id, story_id, node_key, content, speaker, is_ending, ending_name, ending_score, ending_type, sort_order) VALUES
|
||||||
|
(50, 8, 'start', '2099年12月31日,23:45。\n\n你独自站在空荡荡的地铁站台,霓虹灯闪烁着"末班车"的字样。这是人类在地球上的最后一年——明天,最后一批移民船将驶向火星。\n\n地铁门打开,车厢里空无一人,只有一个穿白裙的女孩坐在角落,看着窗外。\n\n"你好。"她转头看你,眼睛明亮得不像这个时代的人,"你是最后一个乘客了。"\n\n"这趟地铁...去哪?"你问。\n\n她微微一笑:"你想去哪,它就去哪。"', '旁白', FALSE, '', 0, '', 1),
|
||||||
|
(51, 8, 'talk_girl', '"你是谁?"你在她对面坐下。\n\n"我叫Zero。"她说,"我是这趟地铁的管理员。它已经运行了五百年,送走了无数乘客。"\n\n"五百年?那岂不是从2599年..."\n\n"不,是从1599年。"她平静地说,"这趟地铁可以穿越时间。每一个迷失的灵魂,都能在这里找到他想去的时代。"\n\n你愣住了。\n\n"所以..."她看着你,"你想去哪里?过去,还是未来?"', '旁白', FALSE, '', 0, '', 2),
|
||||||
|
(52, 8, 'choose_past', '"我想回到过去。"你说,"回到一切还没有毁灭的时候。"\n\n地铁开始加速,窗外的灯光变成了流动的光线。\n\n"你想回到哪一年?"Zero问。\n\n你想起了很多:2050年,地球最后一片森林消失的那天;2030年,你第一次失去亲人的那天;还是更早,人类还充满希望的21世纪初?\n\n"我想...回到还能改变什么的时候。"', '旁白', FALSE, '', 0, '', 3),
|
||||||
|
(53, 8, 'ending_past', '地铁停下了。\n\n车门打开,阳光照进来。你看到了蓝天白云,看到了绿色的树木,看到了街上熙熙攘攘的人群。\n\n"这是2024年。"Zero说,"人类还有机会的时候。"\n\n"我...真的能改变什么吗?"\n\n"一个人的力量很小,但蝴蝶效应是真实的。"她微微一笑,"去吧,做你觉得对的事。"\n\n你踏出车门,带着来自未来的记忆,开始了新的人生。\n\n【达成结局:时间旅人】', '旁白', TRUE, '时间旅人', 100, 'good', 4),
|
||||||
|
(54, 8, 'ending_future', '"我想去未来。"你说,"去看看人类最终会变成什么样。"\n\n地铁穿越星海,最终停在一个你从未见过的世界。\n\n这里的人类已经不再是人类——他们与机器融合,与宇宙合一,成为了新的生命形式。\n\n"欢迎来到永恒。"Zero说,"这是十万年后的未来。"\n\n你望着这个陌生而美丽的世界,第一次感受到了真正的希望。\n\n【达成结局:永恒之旅】', '旁白', TRUE, '永恒之旅', 90, 'good', 5);
|
||||||
|
|
||||||
|
INSERT INTO story_choices (node_id, story_id, text, next_node_key, sort_order) VALUES
|
||||||
|
(50, 8, '和女孩交谈', 'talk_girl', 1),
|
||||||
|
(50, 8, '保持沉默,看向窗外', 'talk_girl', 2),
|
||||||
|
(51, 8, '选择回到过去', 'choose_past', 1),
|
||||||
|
(51, 8, '选择去往未来', 'ending_future', 2),
|
||||||
|
(52, 8, '回到2024年', 'ending_past', 1),
|
||||||
|
(52, 8, '回到更早的时候', 'ending_past', 2);
|
||||||
|
|
||||||
|
-- 9. 恐怖惊悚:《第七夜》
|
||||||
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
|
(9, '第七夜', '', '你入住了一家偏僻的旅馆,老板娘警告你:千万不要在第七天午夜离开房间...', '恐怖惊悚', 13450, 2980, FALSE);
|
||||||
|
|
||||||
|
INSERT INTO story_nodes (id, story_id, node_key, content, speaker, is_ending, ending_name, ending_score, ending_type, sort_order) VALUES
|
||||||
|
(55, 9, 'start', '暴雨如注的夜晚,你的车在山路上抛锚了。\n\n你艰难地走到一家看起来很古老的旅馆。门口的老板娘用奇怪的眼神打量着你。\n\n"只剩最后一间房了。"她递过来一把生锈的钥匙,"307房间。记住,住满七天之前,千万不要在午夜离开房间。"\n\n"为什么?"你问。\n\n她没有回答,只是转身消失在走廊深处。\n\n你推开307的门,一股霉味扑面而来。墙上的日历停在了七年前...', '旁白', FALSE, '', 0, '', 1),
|
||||||
|
(56, 9, 'night_sound', '第三天午夜,你被一阵脚步声惊醒。\n\n沙沙...沙沙...\n\n脚步声在走廊里徘徊,时而远,时而近。然后,它停在了你的门口。\n\n咚,咚,咚。\n\n有人敲门。\n\n"有人在吗?"一个女孩的声音传来,"我迷路了...好冷...能让我进去吗?"\n\n你屏住呼吸。透过门缝,你隐约看到门外站着一个穿白裙的身影...', '旁白', FALSE, '', 0, '', 2),
|
||||||
|
(57, 9, 'open_door', '你打开了门。\n\n门外空无一人。走廊里漆黑一片,只有尽头的灯在忽明忽暗地闪烁。\n\n然后,你听到了背后传来的声音。\n\n"谢谢你...开门..."\n\n你猛然回头,看到床上躺着一个苍白的女孩,她的脸上挂着诡异的微笑。\n\n"终于...有人愿意陪我了..."', '旁白', FALSE, '', 0, '', 3),
|
||||||
|
(58, 9, 'not_open', '你没有开门,死死地盯着那道门。\n\n敲门声持续了整整一个小时,然后消失了。你一夜未眠。\n\n第七天终于到了。当第一缕阳光照进房间的那一刻,你几乎是冲出了旅馆。\n\n老板娘站在门口,表情复杂地看着你:"你是第一个活着离开的人。"\n\n"那个女孩...是什么?"\n\n"七年前,一个女孩在这里被杀了。从此每隔七天,她就会出现..."', '旁白', FALSE, '', 0, '', 4),
|
||||||
|
(59, 9, 'ending_death', '你感到一阵刺骨的寒冷。\n\n女孩的手穿过你的胸膛,你看到自己的身体变得透明...\n\n"不要怕...和我一起留在这里吧...永远..."\n\n第二天早晨,老板娘打开307的门,发现床上躺着你冰冷的尸体,脸上挂着恐惧的表情。\n\n而日历上,又多了一道血红的记号。\n\n【达成结局:永恒的旅客】', '旁白', TRUE, '永恒的旅客', 20, 'bad', 5),
|
||||||
|
(60, 9, 'ending_escape', '你跑出了旅馆,再也没有回头。\n\n后来你查到,那家旅馆在一年后被大火烧毁了。废墟中发现了数十具白骨——都是历年来失踪的旅客。\n\n那个女孩的诅咒,终于随着大火一起消散了。\n\n但每当午夜梦回,你依然会想起那个声音:\n\n"能让我进去吗?"\n\n【达成结局:劫后余生】', '旁白', TRUE, '劫后余生', 80, 'good', 6);
|
||||||
|
|
||||||
|
INSERT INTO story_choices (node_id, story_id, text, next_node_key, sort_order) VALUES
|
||||||
|
(55, 9, '躺下休息', 'night_sound', 1),
|
||||||
|
(55, 9, '检查一下房间', 'night_sound', 2),
|
||||||
|
(56, 9, '打开门看看', 'open_door', 1),
|
||||||
|
(56, 9, '不开门,假装没听到', 'not_open', 2),
|
||||||
|
(57, 9, '试图逃跑', 'ending_death', 1),
|
||||||
|
(57, 9, '放弃挣扎', 'ending_death', 2),
|
||||||
|
(58, 9, '赶紧离开这里', 'ending_escape', 1);
|
||||||
|
|
||||||
|
-- 10. 搞笑轻喜:《我的室友是只猫》
|
||||||
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
|
(10, '我的室友是只猫', '', '某天醒来,你发现你的室友变成了一只会说话的猫!更可怕的是,你们还得一起完成期末考试...', '搞笑轻喜', 20560, 5670, TRUE);
|
||||||
|
|
||||||
|
INSERT INTO story_nodes (id, story_id, node_key, content, speaker, is_ending, ending_name, ending_score, ending_type, sort_order) VALUES
|
||||||
|
(61, 10, 'start', '"喵~"\n\n你迷迷糊糊地睁开眼,发现一只橘色的大猫正趴在你脸上。\n\n"你干嘛呢!"你一把把它推开,"我室友的猫怎么跑我床上来了...等等,我室友好像没养猫?"\n\n"笨蛋,我就是你室友啊!"猫开口说话了。\n\n你愣了三秒,然后发出了杀猪般的尖叫。\n\n"吵死了!"猫翻了个白眼,"我也不知道怎么回事,早上起来就变成这样了。而且明天就是期末考试,你说怎么办!"', '旁白', FALSE, '', 0, '', 1),
|
||||||
|
(62, 10, 'panic', '"你...你...你怎么变成猫了!"你抓狂地来回走动。\n\n"我怎么知道!"猫跳上桌子,焦躁地甩尾巴,"可能是昨晚吃的那个外卖有问题?我就说那家新开的黑暗料理店不靠谱..."\n\n"所以你是吃了什么变成猫的?"\n\n"好像是...猫咪意面?"猫心虚地舔了舔爪子。\n\n你无语地看着它。你的室友,真的是个大冤种。', '旁白', FALSE, '', 0, '', 2),
|
||||||
|
(63, 10, 'exam_plan', '"算了,先想想怎么应对期末考试吧。"你叹了口气。\n\n"我有个主意!"猫兴奋地跳起来,"你把我藏在衣服里带进考场,我给你念答案!"\n\n"你一只猫能念什么答案啊!"\n\n"我虽然变成了猫,但脑子还是人类的脑子!高数我可是年级前十!"\n\n你思考了一下...这似乎是个好主意?但被抓到就完蛋了。', '旁白', FALSE, '', 0, '', 3),
|
||||||
|
(64, 10, 'bring_cat', '考试当天,你把室友(猫)藏在外套里。\n\n"《高等数学》考试现在开始!"\n\n你打开试卷,第一题就懵了。猫在你怀里小声说:"第一题选B...第二题是求导..."\n\n一切都很顺利,直到监考老师走过来。\n\n"这位同学,你衣服里怎么有声音?"', '旁白', FALSE, '', 0, '', 4),
|
||||||
|
(65, 10, 'ending_funny', '"喵~"猫突然跳出来,假装是一只普通的猫,在监考老师腿上蹭来蹭去。\n\n"哎呀,哪来的猫,好可爱~"监考老师完全被萌化了,忘记了刚才的疑问。\n\n猫冲你眨了眨眼。你憋着笑继续答题。\n\n期末考试,你拿了满分。\n\n三天后,室友恢复了人形。他决定:以后再也不吃黑暗料理了。\n\n【达成结局:作弊猫的胜利】', '旁白', TRUE, '作弊猫的胜利', 100, 'good', 5),
|
||||||
|
(66, 10, 'ending_caught', '"同学,你在作弊!"\n\n监考老师一把抓住了从你衣服里钻出来的猫。\n\n"等等,我没有!这只是...呃...我的情感支持猫!"\n\n"情感支持猫?会说话的情感支持猫?"\n\n全考场的人都看向你们。猫一脸生无可恋:"喵...我只是一只普通的猫...喵..."\n\n你被记了处分,室友(猫)被没收关了三天。\n\n【达成结局:全剧终,作弊必被抓】', '旁白', TRUE, '作弊的代价', 30, 'bad', 6);
|
||||||
|
|
||||||
|
INSERT INTO story_choices (node_id, story_id, text, next_node_key, sort_order) VALUES
|
||||||
|
(61, 10, '冷静下来问清楚', 'panic', 1),
|
||||||
|
(61, 10, '继续尖叫', 'panic', 2),
|
||||||
|
(62, 10, '想办法应对期末考试', 'exam_plan', 1),
|
||||||
|
(62, 10, '先带它去看兽医', 'exam_plan', 2),
|
||||||
|
(63, 10, '接受室友的主意', 'bring_cat', 1),
|
||||||
|
(63, 10, '老老实实自己考', 'ending_caught', 2),
|
||||||
|
(64, 10, '让猫装作普通猫', 'ending_funny', 1),
|
||||||
|
(64, 10, '慌张解释', 'ending_caught', 2);
|
||||||
73
小游戏需求.md
Normal file
73
小游戏需求.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
《星域故事汇》小游戏产品需求文档 (PRD)
|
||||||
|
版本: V1.0
|
||||||
|
1. 文档修订记录
|
||||||
|
版本 日期
|
||||||
|
V1.0 2026-2-26
|
||||||
|
2. 项目概述
|
||||||
|
2.1 产品背景
|
||||||
|
为发挥团队在AI内容生成与轻量级开发方面的核心优势,打造一款具有自传播能力、能实现稳定收益的自研小游戏。该游戏需符合“低开发难度、市场广阔、跨平台可玩性高”的核心战略要求,并成为连接团队未来“AI发文”业务与互动娱乐生态的起点。
|
||||||
|
2.2 产品定位
|
||||||
|
一款 “AI驱动、用户可轻度参与的互动短剧游戏” 。产品将超休闲游戏的极简操作与互动叙事游戏的强代入感相结合,通过AI降低故事创作门槛,让每个玩家都能快速消费或参与创作超短篇互动故事,并在社交平台分享自己的“故事结局”或“创作成果”。
|
||||||
|
2.3 项目目标
|
||||||
|
• 短期:用户平均次日留存率>35%,跑通内购与广告变现闭环,实现项目自负盈亏。
|
||||||
|
• 中期:构建活跃的UGC(用户生成内容)社区,优质用户创作故事占比超过30%,探索与主站“AI发文平台”的内容联动路径。
|
||||||
|
• 长期:成为轻量级互动故事的标准平台,孵化原生IP,验证“AI-互动内容-IP”的商业模式。
|
||||||
|
3. 用户画像与场景
|
||||||
|
3.1 核心用户画像
|
||||||
|
• 名称:小雅
|
||||||
|
• 年龄:18-30岁
|
||||||
|
• 身份:上班族/大学生,通勤、课间、睡前等碎片时间多。
|
||||||
|
• 特征:热衷刷短视频、看网文、玩轻量小游戏;有表达欲,喜欢在社交平台分享有趣内容;对新鲜、个性化内容有高接受度。
|
||||||
|
• 需求:在碎片时间获得快速、轻松、有参与感的娱乐;渴望独特体验并能以此作为社交货币。
|
||||||
|
3.2 典型使用场景
|
||||||
|
1. 通勤路上:小雅在地铁上打开微信,随手点开朋友分享的“我竟然在游戏里考上了状元!”故事链接,在5分钟内通过几次点击,体验了一个完整的逆袭故事,并生成了自己的结局海报分享到朋友圈。
|
||||||
|
2. 睡前放松:小雅刷完视频,打开《星域故事汇》小程序,玩了两则官方推送的悬疑短剧。她对某个结局不满意,使用“AI改写”功能,输入“我希望侦探是幕后黑手”,生成了一个全新的暗黑结局,并保存至个人作品集。
|
||||||
|
3. 灵感创作:小雅和男友吵架后灵感迸发,使用“AI速创”工具,输入关键词“冷战、穿越、追妻火葬场”,AI在1分钟内生成了一个包含3个关键选择的互动故事框架。她稍作编辑并发布,收到了几百个点赞和“玩同款”的请求。
|
||||||
|
4. 核心功能详述
|
||||||
|
4.1 模块一:核心游玩体验
|
||||||
|
• 功能描述:提供海量互动短剧。玩家通过点击屏幕上的选项推动剧情,每个故事有2-4个不同结局。
|
||||||
|
• 交互流程:
|
||||||
|
1. 进入游戏主界面,展示“今日精选”、“好友都在玩”、“热门创作”等故事流。
|
||||||
|
2. 点击一个故事卡片,进入全屏沉浸式阅读/播放界面。
|
||||||
|
3. 在剧情关键点,屏幕下方弹出2-4个选项(如“接受表白”/“转身离开”)。
|
||||||
|
4. 玩家点击选择,剧情立即向下发展,直至抵达结局。
|
||||||
|
5. 结局页面展示结局名称、评分,并提供 “生成分享图”、 “重玩选不同分支” 、 “点赞/收藏” 和 “关注作者” 按钮。
|
||||||
|
• 需求细化:
|
||||||
|
o 文本为主,辅以强情绪表达的角色立绘、背景图和音效,降低美术成本。
|
||||||
|
o 首次游戏提供明确的新手引导,5秒内让玩家理解“点击选择”的核心操作。
|
||||||
|
4.2 模块二:AI辅助创作(核心差异化功能)
|
||||||
|
• 功能描述:为玩家提供低门槛的故事创作工具,分为三个层级:
|
||||||
|
1. AI改写:在游玩任意官方或他人故事后,可对任意节点选项或结局使用。输入一句话指令(如“让主角暴富”),AI生成新的剧情走向。
|
||||||
|
2. AI续写:玩家可以自己写一个开头(至少100字),或选择一个系统提供的“开头灵感包”,由AI续写并提供后续的2-3个选项。
|
||||||
|
3. AI速创(高级功能):输入“题材、主角、核心冲突”等关键词(如:修仙、废柴、退婚),AI在1分钟内生成一个包含标题、简介、3个章节和多个选项的完整故事框架。创作者可在此基础上进行细节编辑。
|
||||||
|
• 需求细化:
|
||||||
|
o 初期为控制成本,可对“AI速创”功能设置每日免费次数,后续可通过任务或付费增加次数。
|
||||||
|
4.3 模块三:社交与传播体系
|
||||||
|
• 功能描述:设计激励用户在多平台分享的机制,实现低成本裂变。
|
||||||
|
• 核心设计:
|
||||||
|
1. 病毒式结局海报:每个结局生成时,自动合成一张精美的海报,包含结局名、一句高张力文案、游戏二维码。分享至朋友圈、抖音、小红书等平台。
|
||||||
|
2. “玩同款”挑战:热门UGC故事可被设置为“挑战”,其他用户可“玩同款”并尝试打出不同结局,数据会计入原故事排行榜。
|
||||||
|
3. 视频号/抖音集成:在微信小游戏端,支持将精彩剧情的连续选择过程录制成15秒短视频,一键分享至视频号,视频自带小程序回流链接。
|
||||||
|
• 需求细化:分享链接需带有用户ID和故事ID参数,以便追踪来源和进行裂变奖励。
|
||||||
|
• 4.4 模块四:商业化设计
|
||||||
|
采用 “混合变现” 模型,确保收益与用户体验平衡。
|
||||||
|
|
||||||
|
5. 非功能性需求
|
||||||
|
• 兼容性:必须完美兼容微信小游戏环境,并考虑未来一键发布至抖音、QQ小游戏等平台的技术可行性。
|
||||||
|
• 稳定性:核心游玩与创作接口可用性需大于99.9%。
|
||||||
|
• 数据:需埋点记录关键用户行为路径,包括故事完读率、分支选择率、分享率、广告展示与点击率,用于后续的AI模型训练和产品优化。
|
||||||
|
6. 项目路线图
|
||||||
|
• 第一阶段:开发核心游玩模块(含10个官方种子故事)、基础分享功能、激励视频接入。目标:验证核心玩法和留存率。
|
||||||
|
• 第二阶段:上线“AI改写”和“AI续写”功能,开放UGC发布与简单社区。目标:验证用户创作意愿和内容生态冷启动。
|
||||||
|
• 第三阶段:上线“AI速创”、内购商店、深化社交功能与跨平台分享优化。目标:提升变现效率与用户活跃度。
|
||||||
|
核心交互流程图
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
方案优势详解与对比分析
|
||||||
|
|
||||||
|
1. 风险最低:技术难度最低、市场接受度最高的交集区域。不需要挑战复杂的游戏引擎技术,可以专注于擅长的内容和 AI逻辑。
|
||||||
|
2. 护城河最清晰:市场上不缺互动故事游戏,但缺能持续、低成本生产海量优质故事,并让用户也参与创作的平台。我们的AI能力正是构建这条护城河的唯一砖石。
|
||||||
|
3. 增长路径最顺:其产品形态自带传播基因,能极大降低后续的买量成本和用户获取难度,非常适合资源有限的初创团队。
|
||||||
|
|
||||||
Reference in New Issue
Block a user