feat: 星域故事汇小游戏初始版本

This commit is contained in:
2026-03-03 16:57:49 +08:00
commit cc0e39cccc
34 changed files with 6556 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
.env
*.log
.DS_Store
~$*
*.docx
.qoder/
project.private.config.json

130
README.md Normal file
View 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
View File

@@ -0,0 +1,8 @@
/**
* 星域故事汇 - 游戏入口
*/
import './js/libs/weapp-adapter';
import Main from './js/main';
// 创建游戏实例
new Main();

10
client/game.json Normal file
View File

@@ -0,0 +1,10 @@
{
"deviceOrientation": "portrait",
"showStatusBar": false,
"networkTimeout": {
"request": 10000,
"connectSocket": 10000,
"uploadFile": 10000,
"downloadFile": 10000
}
}

View 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 = {};
}
}

View 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;
}
}
}

View 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 });
}
}

View 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
View 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);
}
}

View 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;
}
}

View 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 });
}
}

View 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);
}
}

View 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 });
}
}
}

View 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 });
}
}
}
}

View 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;
}
}

View 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
View 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 };

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

20
server/package.json Normal file
View 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
View 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
View 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
View 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
View 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 '作者ID0表示官方',
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
View 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 '作者ID0为官方',
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='敏感词表';

View 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故事继续插入...

View 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
View 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. 增长路径最顺:其产品形态自带传播基因,能极大降低后续的买量成本和用户获取难度,非常适合资源有限的初创团队。