From cc0e39cccc5d7bf9b3084e6b2e9ec139fa24bc91 Mon Sep 17 00:00:00 2001 From: liangguodong Date: Tue, 3 Mar 2026 16:57:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=98=9F=E5=9F=9F=E6=95=85=E4=BA=8B?= =?UTF-8?q?=E6=B1=87=E5=B0=8F=E6=B8=B8=E6=88=8F=E5=88=9D=E5=A7=8B=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 + README.md | 130 +++ client/game.js | 8 + client/game.json | 10 + client/js/data/AudioManager.js | 132 +++ client/js/data/StoryManager.js | 147 ++++ client/js/data/UserManager.js | 134 +++ client/js/libs/weapp-adapter.js | 37 + client/js/main.js | 183 +++++ client/js/scenes/BaseScene.js | 83 ++ client/js/scenes/ChapterScene.js | 284 +++++++ client/js/scenes/EndingScene.js | 745 +++++++++++++++++ client/js/scenes/HomeScene.js | 537 ++++++++++++ client/js/scenes/ProfileScene.js | 371 +++++++++ client/js/scenes/SceneManager.js | 56 ++ client/js/scenes/StoryScene.js | 636 +++++++++++++++ client/js/utils/http.js | 55 ++ client/project.config.json | 47 ++ project.config.json | 25 + server/.env.example | 13 + server/app.js | 35 + server/config/db.js | 14 + server/models/story.js | 157 ++++ server/models/user.js | 134 +++ server/package-lock.json | 1269 +++++++++++++++++++++++++++++ server/package.json | 20 + server/routes/story.js | 110 +++ server/routes/user.js | 124 +++ server/sql/init.js | 69 ++ server/sql/schema.sql | 107 +++ server/sql/schema_v2.sql | 566 +++++++++++++ server/sql/seed_stories_part1.sql | 132 +++ server/sql/seed_stories_part2.sql | 105 +++ 小游戏需求.md | 73 ++ 34 files changed, 6556 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 client/game.js create mode 100644 client/game.json create mode 100644 client/js/data/AudioManager.js create mode 100644 client/js/data/StoryManager.js create mode 100644 client/js/data/UserManager.js create mode 100644 client/js/libs/weapp-adapter.js create mode 100644 client/js/main.js create mode 100644 client/js/scenes/BaseScene.js create mode 100644 client/js/scenes/ChapterScene.js create mode 100644 client/js/scenes/EndingScene.js create mode 100644 client/js/scenes/HomeScene.js create mode 100644 client/js/scenes/ProfileScene.js create mode 100644 client/js/scenes/SceneManager.js create mode 100644 client/js/scenes/StoryScene.js create mode 100644 client/js/utils/http.js create mode 100644 client/project.config.json create mode 100644 project.config.json create mode 100644 server/.env.example create mode 100644 server/app.js create mode 100644 server/config/db.js create mode 100644 server/models/story.js create mode 100644 server/models/user.js create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/routes/story.js create mode 100644 server/routes/user.js create mode 100644 server/sql/init.js create mode 100644 server/sql/schema.sql create mode 100644 server/sql/schema_v2.sql create mode 100644 server/sql/seed_stories_part1.sql create mode 100644 server/sql/seed_stories_part2.sql create mode 100644 小游戏需求.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a286fbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.env +*.log +.DS_Store +~$* +*.docx +.qoder/ +project.private.config.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..d305566 --- /dev/null +++ b/README.md @@ -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 diff --git a/client/game.js b/client/game.js new file mode 100644 index 0000000..acadc3f --- /dev/null +++ b/client/game.js @@ -0,0 +1,8 @@ +/** + * 星域故事汇 - 游戏入口 + */ +import './js/libs/weapp-adapter'; +import Main from './js/main'; + +// 创建游戏实例 +new Main(); diff --git a/client/game.json b/client/game.json new file mode 100644 index 0000000..45dbe19 --- /dev/null +++ b/client/game.json @@ -0,0 +1,10 @@ +{ + "deviceOrientation": "portrait", + "showStatusBar": false, + "networkTimeout": { + "request": 10000, + "connectSocket": 10000, + "uploadFile": 10000, + "downloadFile": 10000 + } +} diff --git a/client/js/data/AudioManager.js b/client/js/data/AudioManager.js new file mode 100644 index 0000000..a3eb728 --- /dev/null +++ b/client/js/data/AudioManager.js @@ -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 = {}; + } +} diff --git a/client/js/data/StoryManager.js b/client/js/data/StoryManager.js new file mode 100644 index 0000000..372e34f --- /dev/null +++ b/client/js/data/StoryManager.js @@ -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; + } + } +} diff --git a/client/js/data/UserManager.js b/client/js/data/UserManager.js new file mode 100644 index 0000000..f3da458 --- /dev/null +++ b/client/js/data/UserManager.js @@ -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 }); + } +} diff --git a/client/js/libs/weapp-adapter.js b/client/js/libs/weapp-adapter.js new file mode 100644 index 0000000..2fa4020 --- /dev/null +++ b/client/js/libs/weapp-adapter.js @@ -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 +}; diff --git a/client/js/main.js b/client/js/main.js new file mode 100644 index 0000000..ae8507f --- /dev/null +++ b/client/js/main.js @@ -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); + } +} diff --git a/client/js/scenes/BaseScene.js b/client/js/scenes/BaseScene.js new file mode 100644 index 0000000..f31f6b3 --- /dev/null +++ b/client/js/scenes/BaseScene.js @@ -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; + } +} diff --git a/client/js/scenes/ChapterScene.js b/client/js/scenes/ChapterScene.js new file mode 100644 index 0000000..1b15d80 --- /dev/null +++ b/client/js/scenes/ChapterScene.js @@ -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 }); + } +} diff --git a/client/js/scenes/EndingScene.js b/client/js/scenes/EndingScene.js new file mode 100644 index 0000000..2443807 --- /dev/null +++ b/client/js/scenes/EndingScene.js @@ -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); + } +} diff --git a/client/js/scenes/HomeScene.js b/client/js/scenes/HomeScene.js new file mode 100644 index 0000000..c787b5c --- /dev/null +++ b/client/js/scenes/HomeScene.js @@ -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 }); + } + } +} diff --git a/client/js/scenes/ProfileScene.js b/client/js/scenes/ProfileScene.js new file mode 100644 index 0000000..0b6f89c --- /dev/null +++ b/client/js/scenes/ProfileScene.js @@ -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 }); + } + } + } +} diff --git a/client/js/scenes/SceneManager.js b/client/js/scenes/SceneManager.js new file mode 100644 index 0000000..3905377 --- /dev/null +++ b/client/js/scenes/SceneManager.js @@ -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; + } +} diff --git a/client/js/scenes/StoryScene.js b/client/js/scenes/StoryScene.js new file mode 100644 index 0000000..e9249d6 --- /dev/null +++ b/client/js/scenes/StoryScene.js @@ -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 : '' + ); + } + } +} diff --git a/client/js/utils/http.js b/client/js/utils/http.js new file mode 100644 index 0000000..a1f9a02 --- /dev/null +++ b/client/js/utils/http.js @@ -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 }; diff --git a/client/project.config.json b/client/project.config.json new file mode 100644 index 0000000..8e2bf78 --- /dev/null +++ b/client/project.config.json @@ -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": {} +} \ No newline at end of file diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..05a71cb --- /dev/null +++ b/project.config.json @@ -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": {} +} \ No newline at end of file diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..1892790 --- /dev/null +++ b/server/.env.example @@ -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 diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..af1ca9f --- /dev/null +++ b/server/app.js @@ -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; diff --git a/server/config/db.js b/server/config/db.js new file mode 100644 index 0000000..9542f2b --- /dev/null +++ b/server/config/db.js @@ -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; diff --git a/server/models/story.js b/server/models/story.js new file mode 100644 index 0000000..8b5732e --- /dev/null +++ b/server/models/story.js @@ -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; diff --git a/server/models/user.js b/server/models/user.js new file mode 100644 index 0000000..d76d199 --- /dev/null +++ b/server/models/user.js @@ -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; diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..f017e7c --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1269 @@ +{ + "name": "stardom-story-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stardom-story-server", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "mysql2": "^3.6.5" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/mysql2": { + "version": "3.18.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.18.2.tgz", + "integrity": "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..a870f45 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/routes/story.js b/server/routes/story.js new file mode 100644 index 0000000..d98f9fd --- /dev/null +++ b/server/routes/story.js @@ -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; diff --git a/server/routes/user.js b/server/routes/user.js new file mode 100644 index 0000000..c73723e --- /dev/null +++ b/server/routes/user.js @@ -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; diff --git a/server/sql/init.js b/server/sql/init.js new file mode 100644 index 0000000..5537192 --- /dev/null +++ b/server/sql/init.js @@ -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); + }); diff --git a/server/sql/schema.sql b/server/sql/schema.sql new file mode 100644 index 0000000..eb6056a --- /dev/null +++ b/server/sql/schema.sql @@ -0,0 +1,107 @@ +-- 星域故事汇数据库初始化脚本 +-- 创建数据库 +CREATE DATABASE IF NOT EXISTS stardom_story DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE stardom_story; + +-- 故事主表 +CREATE TABLE IF NOT EXISTS stories ( + id INT PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(100) NOT NULL COMMENT '故事标题', + cover_url VARCHAR(255) DEFAULT '' COMMENT '封面图URL', + description TEXT COMMENT '故事简介', + author_id INT DEFAULT 0 COMMENT '作者ID,0表示官方', + category VARCHAR(50) NOT NULL COMMENT '故事分类', + play_count INT DEFAULT 0 COMMENT '游玩次数', + like_count INT DEFAULT 0 COMMENT '点赞数', + is_featured BOOLEAN DEFAULT FALSE COMMENT '是否精选', + status TINYINT DEFAULT 1 COMMENT '状态:0下架 1上架', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_category (category), + INDEX idx_featured (is_featured), + INDEX idx_play_count (play_count) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事主表'; + +-- 故事节点表 +CREATE TABLE IF NOT EXISTS story_nodes ( + id INT PRIMARY KEY AUTO_INCREMENT, + story_id INT NOT NULL COMMENT '故事ID', + node_key VARCHAR(50) NOT NULL COMMENT '节点唯一标识', + content TEXT NOT NULL COMMENT '节点内容文本', + speaker VARCHAR(50) DEFAULT '' COMMENT '说话角色名', + background_image VARCHAR(255) DEFAULT '' COMMENT '背景图URL', + character_image VARCHAR(255) DEFAULT '' COMMENT '角色立绘URL', + bgm VARCHAR(255) DEFAULT '' COMMENT '背景音乐', + is_ending BOOLEAN DEFAULT FALSE COMMENT '是否为结局节点', + ending_name VARCHAR(100) DEFAULT '' COMMENT '结局名称', + ending_score INT DEFAULT 0 COMMENT '结局评分', + ending_type VARCHAR(20) DEFAULT '' COMMENT '结局类型:good/bad/normal/hidden', + sort_order INT DEFAULT 0 COMMENT '排序', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_story_id (story_id), + INDEX idx_node_key (story_id, node_key), + FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事节点表'; + +-- 故事选项表 +CREATE TABLE IF NOT EXISTS story_choices ( + id INT PRIMARY KEY AUTO_INCREMENT, + node_id INT NOT NULL COMMENT '所属节点ID', + story_id INT NOT NULL COMMENT '故事ID(冗余,便于查询)', + text VARCHAR(200) NOT NULL COMMENT '选项文本', + next_node_key VARCHAR(50) NOT NULL COMMENT '下一个节点key', + sort_order INT DEFAULT 0 COMMENT '排序', + is_locked BOOLEAN DEFAULT FALSE COMMENT '是否锁定(需广告解锁)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_node_id (node_id), + INDEX idx_story_id (story_id), + FOREIGN KEY (node_id) REFERENCES story_nodes(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事选项表'; + +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id INT PRIMARY KEY AUTO_INCREMENT, + openid VARCHAR(100) UNIQUE NOT NULL COMMENT '微信openid', + nickname VARCHAR(100) DEFAULT '' COMMENT '昵称', + avatar_url VARCHAR(255) DEFAULT '' COMMENT '头像URL', + gender TINYINT DEFAULT 0 COMMENT '性别:0未知 1男 2女', + total_play_count INT DEFAULT 0 COMMENT '总游玩次数', + total_endings INT DEFAULT 0 COMMENT '解锁结局数', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_openid (openid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; + +-- 用户进度表 +CREATE TABLE IF NOT EXISTS user_progress ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL COMMENT '用户ID', + story_id INT NOT NULL COMMENT '故事ID', + current_node_key VARCHAR(50) DEFAULT 'start' COMMENT '当前节点', + is_completed BOOLEAN DEFAULT FALSE COMMENT '是否完成', + ending_reached VARCHAR(100) DEFAULT '' COMMENT '达成的结局', + is_liked BOOLEAN DEFAULT FALSE COMMENT '是否点赞', + is_collected BOOLEAN DEFAULT FALSE COMMENT '是否收藏', + play_count INT DEFAULT 1 COMMENT '游玩次数', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_story (user_id, story_id), + INDEX idx_user_id (user_id), + INDEX idx_story_id (story_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户进度表'; + +-- 用户结局收集表 +CREATE TABLE IF NOT EXISTS user_endings ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + story_id INT NOT NULL, + ending_name VARCHAR(100) NOT NULL, + ending_score INT DEFAULT 0, + unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_ending (user_id, story_id, ending_name), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户结局收集表'; diff --git a/server/sql/schema_v2.sql b/server/sql/schema_v2.sql new file mode 100644 index 0000000..f28b6c7 --- /dev/null +++ b/server/sql/schema_v2.sql @@ -0,0 +1,566 @@ +-- ============================================ +-- 星域故事汇 完整数据库结构 V2.0 +-- 支持:AI改写/续写/创作、UGC、社交、计费 +-- ============================================ + +CREATE DATABASE IF NOT EXISTS stardom_story DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE stardom_story; + +-- ============================================ +-- 一、用户体系 +-- ============================================ + +-- 用户主表 +CREATE TABLE users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + openid VARCHAR(100) UNIQUE NOT NULL COMMENT '微信openid', + unionid VARCHAR(100) DEFAULT '' COMMENT '微信unionid', + nickname VARCHAR(100) DEFAULT '' COMMENT '昵称', + avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像', + gender TINYINT DEFAULT 0 COMMENT '0未知 1男 2女', + phone VARCHAR(20) DEFAULT '' COMMENT '手机号', + level INT DEFAULT 1 COMMENT '用户等级', + exp INT DEFAULT 0 COMMENT '经验值', + vip_type TINYINT DEFAULT 0 COMMENT '0普通 1月卡 2年卡 3永久', + vip_expire_at DATETIME DEFAULT NULL COMMENT 'VIP过期时间', + coin_balance INT DEFAULT 0 COMMENT '金币余额', + is_creator BOOLEAN DEFAULT FALSE COMMENT '是否认证创作者', + is_banned BOOLEAN DEFAULT FALSE COMMENT '是否封禁', + ban_reason VARCHAR(255) DEFAULT '', + last_login_at DATETIME DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_openid (openid), + INDEX idx_unionid (unionid), + INDEX idx_creator (is_creator) +) ENGINE=InnoDB COMMENT='用户主表'; + +-- 用户统计表(高频更新分离) +CREATE TABLE user_stats ( + user_id BIGINT PRIMARY KEY, + total_play_count INT DEFAULT 0 COMMENT '总游玩次数', + total_endings INT DEFAULT 0 COMMENT '解锁结局数', + total_creations INT DEFAULT 0 COMMENT '创作数', + total_ai_uses INT DEFAULT 0 COMMENT 'AI使用次数', + followers_count INT DEFAULT 0 COMMENT '粉丝数', + following_count INT DEFAULT 0 COMMENT '关注数', + likes_received INT DEFAULT 0 COMMENT '获赞数', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB COMMENT='用户统计表'; + +-- 用户关注关系表 +CREATE TABLE user_follows ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + follower_id BIGINT NOT NULL COMMENT '关注者', + following_id BIGINT NOT NULL COMMENT '被关注者', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_follow (follower_id, following_id), + INDEX idx_follower (follower_id), + INDEX idx_following (following_id) +) ENGINE=InnoDB COMMENT='用户关注关系'; + +-- ============================================ +-- 二、故事内容体系 +-- ============================================ + +-- 故事主表(官方+UGC统一) +CREATE TABLE stories ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + author_id BIGINT DEFAULT 0 COMMENT '作者ID,0为官方', + title VARCHAR(100) NOT NULL COMMENT '标题', + cover_url VARCHAR(500) DEFAULT '' COMMENT '封面', + description TEXT COMMENT '简介', + category VARCHAR(50) NOT NULL COMMENT '分类', + tags VARCHAR(255) DEFAULT '' COMMENT '标签,逗号分隔', + word_count INT DEFAULT 0 COMMENT '字数', + node_count INT DEFAULT 0 COMMENT '节点数', + ending_count INT DEFAULT 0 COMMENT '结局数', + difficulty TINYINT DEFAULT 2 COMMENT '难度 1简单 2普通 3困难', + + -- 来源标记 + source_type ENUM('official','ugc','ai_generated','ai_assisted') DEFAULT 'official' COMMENT '来源类型', + base_story_id BIGINT DEFAULT NULL COMMENT '基于哪个故事改编', + ai_generation_id BIGINT DEFAULT NULL COMMENT '关联的AI生成记录', + + -- 状态控制 + status TINYINT DEFAULT 0 COMMENT '0草稿 1审核中 2已发布 3已下架 4已拒绝', + review_note VARCHAR(500) DEFAULT '' COMMENT '审核备注', + is_featured BOOLEAN DEFAULT FALSE COMMENT '是否精选', + is_hot BOOLEAN DEFAULT FALSE COMMENT '是否热门', + is_new BOOLEAN DEFAULT TRUE COMMENT '是否新作', + + -- 付费设置 + price_type TINYINT DEFAULT 0 COMMENT '0免费 1付费 2广告解锁', + price_coin INT DEFAULT 0 COMMENT '价格(金币)', + free_chapters INT DEFAULT 0 COMMENT '免费章节数', + + -- 统计(读多写少可放主表) + play_count INT DEFAULT 0, + like_count INT DEFAULT 0, + collect_count INT DEFAULT 0, + comment_count INT DEFAULT 0, + share_count INT DEFAULT 0, + + published_at DATETIME DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_author (author_id), + INDEX idx_category (category), + INDEX idx_status (status), + INDEX idx_source (source_type), + INDEX idx_featured (is_featured), + INDEX idx_play (play_count), + INDEX idx_created (created_at) +) ENGINE=InnoDB COMMENT='故事主表'; + +-- 故事节点表 +CREATE TABLE story_nodes ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + story_id BIGINT NOT NULL, + node_key VARCHAR(50) NOT NULL COMMENT '节点标识', + parent_node_key VARCHAR(50) DEFAULT '' COMMENT '父节点(用于树形结构)', + chapter_num INT DEFAULT 1 COMMENT '章节号', + + -- 内容 + content TEXT NOT NULL COMMENT '文本内容', + speaker VARCHAR(50) DEFAULT '' COMMENT '说话角色', + emotion VARCHAR(30) DEFAULT '' COMMENT '情绪标签', + + -- 媒体资源 + background_image VARCHAR(500) DEFAULT '', + character_images JSON DEFAULT NULL COMMENT '角色立绘数组', + bgm VARCHAR(500) DEFAULT '', + sound_effect VARCHAR(500) DEFAULT '', + voice_url VARCHAR(500) DEFAULT '' COMMENT '语音URL', + + -- 结局设置 + is_ending BOOLEAN DEFAULT FALSE, + ending_name VARCHAR(100) DEFAULT '', + ending_type ENUM('good','bad','normal','hidden','secret') DEFAULT 'normal', + ending_score INT DEFAULT 0, + ending_cg VARCHAR(500) DEFAULT '' COMMENT '结局CG图', + + -- AI标记 + is_ai_generated BOOLEAN DEFAULT FALSE, + ai_generation_id BIGINT DEFAULT NULL, + + sort_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_story (story_id), + INDEX idx_node_key (story_id, node_key), + INDEX idx_chapter (story_id, chapter_num), + FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE +) ENGINE=InnoDB COMMENT='故事节点表'; + +-- 故事选项表 +CREATE TABLE story_choices ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + story_id BIGINT NOT NULL, + node_id BIGINT NOT NULL, + text VARCHAR(200) NOT NULL COMMENT '选项文本', + next_node_key VARCHAR(50) NOT NULL, + + -- 条件解锁 + condition_type ENUM('none','item','attr','ending','vip','ad') DEFAULT 'none', + condition_value VARCHAR(100) DEFAULT '' COMMENT '条件值JSON', + + -- 效果 + effect_type ENUM('none','attr','item','achievement') DEFAULT 'none', + effect_value VARCHAR(100) DEFAULT '' COMMENT '效果值JSON', + + -- AI标记 + is_ai_generated BOOLEAN DEFAULT FALSE, + + sort_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_story (story_id), + INDEX idx_node (node_id), + FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE, + FOREIGN KEY (node_id) REFERENCES story_nodes(id) ON DELETE CASCADE +) ENGINE=InnoDB COMMENT='故事选项表'; + +-- 故事版本表(支持版本回退) +CREATE TABLE story_versions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + story_id BIGINT NOT NULL, + version_num INT NOT NULL COMMENT '版本号', + nodes_snapshot LONGTEXT NOT NULL COMMENT '节点快照JSON', + change_log VARCHAR(500) DEFAULT '' COMMENT '变更说明', + created_by BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_version (story_id, version_num), + INDEX idx_story (story_id) +) ENGINE=InnoDB COMMENT='故事版本表'; + +-- ============================================ +-- 三、AI生成体系 +-- ============================================ + +-- AI生成记录表 +CREATE TABLE ai_generations ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + story_id BIGINT DEFAULT NULL COMMENT '关联故事', + + -- 生成类型 + gen_type ENUM('rewrite_ending','rewrite_node','continue','create_outline','create_full','polish','translate') NOT NULL, + + -- 输入 + source_node_key VARCHAR(50) DEFAULT '', + source_content TEXT COMMENT '原内容', + user_prompt TEXT NOT NULL COMMENT '用户指令', + system_prompt TEXT COMMENT '系统提示词', + + -- 输出 + generated_content LONGTEXT COMMENT '生成内容', + generated_nodes JSON COMMENT '生成的节点结构', + + -- 模型信息 + model_provider VARCHAR(30) DEFAULT '' COMMENT 'openai/anthropic/local', + model_name VARCHAR(50) DEFAULT '', + temperature DECIMAL(3,2) DEFAULT 0.70, + + -- 消耗统计 + input_tokens INT DEFAULT 0, + output_tokens INT DEFAULT 0, + total_tokens INT DEFAULT 0, + latency_ms INT DEFAULT 0 COMMENT '响应耗时', + cost_cents INT DEFAULT 0 COMMENT '成本(分)', + + -- 状态 + status TINYINT DEFAULT 1 COMMENT '0失败 1成功 2处理中', + error_code VARCHAR(50) DEFAULT '', + error_msg TEXT, + + -- 用户操作 + is_accepted BOOLEAN DEFAULT NULL COMMENT '用户是否采纳', + is_edited BOOLEAN DEFAULT FALSE COMMENT '是否编辑后使用', + is_published BOOLEAN DEFAULT FALSE COMMENT '是否发布', + + -- 评价 + user_rating TINYINT DEFAULT NULL COMMENT '1-5星评价', + user_feedback TEXT COMMENT '用户反馈', + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user (user_id), + INDEX idx_story (story_id), + INDEX idx_type (gen_type), + INDEX idx_status (status), + INDEX idx_created (created_at) +) ENGINE=InnoDB COMMENT='AI生成记录表'; + +-- AI提示词模板表 +CREATE TABLE ai_prompt_templates ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + gen_type VARCHAR(30) NOT NULL, + category VARCHAR(50) DEFAULT '' COMMENT '适用分类', + system_prompt TEXT NOT NULL, + user_prompt_template TEXT NOT NULL COMMENT '用户提示词模板', + variables JSON COMMENT '可用变量说明', + model_recommend VARCHAR(50) DEFAULT '', + temperature_recommend DECIMAL(3,2) DEFAULT 0.70, + is_active BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_type (gen_type), + INDEX idx_active (is_active) +) ENGINE=InnoDB COMMENT='AI提示词模板'; + +-- ============================================ +-- 四、用户行为体系 +-- ============================================ + +-- 用户游玩进度表 +CREATE TABLE user_progress ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + story_id BIGINT NOT NULL, + current_node_key VARCHAR(50) DEFAULT 'start', + + -- 状态 + status TINYINT DEFAULT 0 COMMENT '0进行中 1已完成', + play_count INT DEFAULT 1, + play_duration INT DEFAULT 0 COMMENT '游玩时长(秒)', + + -- 达成结局 + endings_reached JSON COMMENT '达成的结局列表', + best_ending_score INT DEFAULT 0, + + -- 存档数据 + save_data JSON COMMENT '游戏存档(属性/道具等)', + choices_history JSON COMMENT '选择历史', + + -- 互动 + is_liked BOOLEAN DEFAULT FALSE, + is_collected BOOLEAN DEFAULT FALSE, + + first_play_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_play_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + completed_at DATETIME DEFAULT NULL, + + UNIQUE KEY uk_user_story (user_id, story_id), + INDEX idx_user (user_id), + INDEX idx_story (story_id), + INDEX idx_status (status) +) ENGINE=InnoDB COMMENT='用户游玩进度'; + +-- 用户结局收集表 +CREATE TABLE user_endings ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + story_id BIGINT NOT NULL, + node_id BIGINT NOT NULL, + ending_name VARCHAR(100) NOT NULL, + ending_type VARCHAR(20) DEFAULT 'normal', + ending_score INT DEFAULT 0, + choices_path JSON COMMENT '达成路径', + play_duration INT DEFAULT 0 COMMENT '本次游玩时长', + unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_ending (user_id, story_id, ending_name), + INDEX idx_user (user_id), + INDEX idx_story (story_id) +) ENGINE=InnoDB COMMENT='用户结局收集'; + +-- 用户收藏表 +CREATE TABLE user_collections ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + story_id BIGINT NOT NULL, + folder_name VARCHAR(50) DEFAULT '默认' COMMENT '收藏夹', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_story (user_id, story_id), + INDEX idx_user (user_id), + INDEX idx_folder (user_id, folder_name) +) ENGINE=InnoDB COMMENT='用户收藏'; + +-- 评论表 +CREATE TABLE comments ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + story_id BIGINT NOT NULL, + parent_id BIGINT DEFAULT NULL COMMENT '父评论ID', + reply_to_user_id BIGINT DEFAULT NULL COMMENT '回复谁', + content TEXT NOT NULL, + like_count INT DEFAULT 0, + status TINYINT DEFAULT 1 COMMENT '0隐藏 1正常 2置顶', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_story (story_id), + INDEX idx_user (user_id), + INDEX idx_parent (parent_id) +) ENGINE=InnoDB COMMENT='评论表'; + +-- ============================================ +-- 五、经济体系 +-- ============================================ + +-- 用户AI配额表 +CREATE TABLE user_ai_quota ( + user_id BIGINT PRIMARY KEY, + + -- 每日免费额度 + daily_free_total INT DEFAULT 5 COMMENT '每日免费总次数', + daily_free_used INT DEFAULT 0 COMMENT '今日已用', + daily_reset_date DATE COMMENT '重置日期', + + -- 购买/赠送额度 + purchased_quota INT DEFAULT 0 COMMENT '购买的次数', + gift_quota INT DEFAULT 0 COMMENT '赠送的次数', + + -- VIP额度 + vip_daily_bonus INT DEFAULT 0 COMMENT 'VIP每日额外次数', + + -- 总使用统计 + total_used INT DEFAULT 0, + total_tokens_used BIGINT DEFAULT 0, + + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB COMMENT='用户AI配额'; + +-- 金币流水表 +CREATE TABLE coin_transactions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + amount INT NOT NULL COMMENT '正为收入负为支出', + balance_after INT NOT NULL COMMENT '交易后余额', + + tx_type ENUM('recharge','purchase','reward','refund','gift','withdraw') NOT NULL, + ref_type VARCHAR(30) DEFAULT '' COMMENT '关联类型', + ref_id BIGINT DEFAULT NULL COMMENT '关联ID', + + description VARCHAR(255) DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user (user_id), + INDEX idx_type (tx_type), + INDEX idx_created (created_at) +) ENGINE=InnoDB COMMENT='金币流水'; + +-- 订单表 +CREATE TABLE orders ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + order_no VARCHAR(64) UNIQUE NOT NULL, + user_id BIGINT NOT NULL, + + product_type ENUM('coin','vip','story','ai_quota') NOT NULL, + product_id VARCHAR(50) DEFAULT '', + product_name VARCHAR(100) NOT NULL, + + amount_cents INT NOT NULL COMMENT '金额(分)', + pay_channel VARCHAR(30) DEFAULT '' COMMENT '支付渠道', + + status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已取消 3已退款', + paid_at DATETIME DEFAULT NULL, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user (user_id), + INDEX idx_order_no (order_no), + INDEX idx_status (status) +) ENGINE=InnoDB COMMENT='订单表'; + +-- ============================================ +-- 六、运营与审核 +-- ============================================ + +-- 内容审核表 +CREATE TABLE content_reviews ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + content_type ENUM('story','comment','ai_generation','avatar','nickname') NOT NULL, + content_id BIGINT NOT NULL, + user_id BIGINT NOT NULL COMMENT '提交者', + + content_snapshot TEXT COMMENT '内容快照', + + -- 机审 + auto_result TINYINT DEFAULT NULL COMMENT '0通过 1疑似 2违规', + auto_labels JSON COMMENT '机审标签', + auto_score DECIMAL(5,2) DEFAULT NULL, + + -- 人审 + review_status TINYINT DEFAULT 0 COMMENT '0待审 1通过 2拒绝', + reviewer_id BIGINT DEFAULT NULL, + review_note VARCHAR(500) DEFAULT '', + reviewed_at DATETIME DEFAULT NULL, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_type_status (content_type, review_status), + INDEX idx_user (user_id), + INDEX idx_created (created_at) +) ENGINE=InnoDB COMMENT='内容审核表'; + +-- 举报表 +CREATE TABLE reports ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + reporter_id BIGINT NOT NULL, + target_type ENUM('story','comment','user') NOT NULL, + target_id BIGINT NOT NULL, + reason_type VARCHAR(30) NOT NULL COMMENT '举报类型', + reason_detail TEXT, + + status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理 2已忽略', + handler_id BIGINT DEFAULT NULL, + handle_result VARCHAR(255) DEFAULT '', + handled_at DATETIME DEFAULT NULL, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_target (target_type, target_id), + INDEX idx_status (status) +) ENGINE=InnoDB COMMENT='举报表'; + +-- ============================================ +-- 七、数据分析 +-- ============================================ + +-- 行为埋点表(按天分区) +CREATE TABLE event_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT DEFAULT NULL, + device_id VARCHAR(100) DEFAULT '', + session_id VARCHAR(64) DEFAULT '', + + event_name VARCHAR(50) NOT NULL, + event_params JSON, + + page_name VARCHAR(50) DEFAULT '', + ref_page VARCHAR(50) DEFAULT '', + + platform VARCHAR(20) DEFAULT '' COMMENT 'weapp/h5/app', + app_version VARCHAR(20) DEFAULT '', + os VARCHAR(20) DEFAULT '', + device_model VARCHAR(50) DEFAULT '', + + ip VARCHAR(50) DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + event_date DATE AS (DATE(created_at)) STORED, + + INDEX idx_user (user_id), + INDEX idx_event (event_name), + INDEX idx_date (event_date), + INDEX idx_session (session_id) +) ENGINE=InnoDB COMMENT='行为埋点表' +PARTITION BY RANGE (TO_DAYS(event_date)) ( + PARTITION p_default VALUES LESS THAN MAXVALUE +); + +-- AI调用统计日表 +CREATE TABLE ai_daily_stats ( + id INT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL, + gen_type VARCHAR(30) NOT NULL, + model_name VARCHAR(50) DEFAULT '', + + call_count INT DEFAULT 0, + success_count INT DEFAULT 0, + fail_count INT DEFAULT 0, + + total_input_tokens BIGINT DEFAULT 0, + total_output_tokens BIGINT DEFAULT 0, + total_cost_cents INT DEFAULT 0, + + avg_latency_ms INT DEFAULT 0, + p99_latency_ms INT DEFAULT 0, + + unique_users INT DEFAULT 0, + + UNIQUE KEY uk_date_type (stat_date, gen_type, model_name), + INDEX idx_date (stat_date) +) ENGINE=InnoDB COMMENT='AI调用日统计'; + +-- ============================================ +-- 八、系统配置 +-- ============================================ + +-- 分类配置表 +CREATE TABLE categories ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + icon VARCHAR(100) DEFAULT '', + color VARCHAR(20) DEFAULT '', + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + story_count INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB COMMENT='分类配置'; + +-- 系统配置表 +CREATE TABLE system_configs ( + config_key VARCHAR(100) PRIMARY KEY, + config_value TEXT NOT NULL, + description VARCHAR(255) DEFAULT '', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB COMMENT='系统配置'; + +-- 敏感词表 +CREATE TABLE sensitive_words ( + id INT PRIMARY KEY AUTO_INCREMENT, + word VARCHAR(50) NOT NULL, + category VARCHAR(30) DEFAULT 'general', + level TINYINT DEFAULT 1 COMMENT '1警告 2禁止', + is_active BOOLEAN DEFAULT TRUE, + UNIQUE KEY uk_word (word), + INDEX idx_category (category) +) ENGINE=InnoDB COMMENT='敏感词表'; diff --git a/server/sql/seed_stories_part1.sql b/server/sql/seed_stories_part1.sql new file mode 100644 index 0000000..29a9e0f --- /dev/null +++ b/server/sql/seed_stories_part1.sql @@ -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故事继续插入... diff --git a/server/sql/seed_stories_part2.sql b/server/sql/seed_stories_part2.sql new file mode 100644 index 0000000..5f12a9c --- /dev/null +++ b/server/sql/seed_stories_part2.sql @@ -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); diff --git a/小游戏需求.md b/小游戏需求.md new file mode 100644 index 0000000..78ad705 --- /dev/null +++ b/小游戏需求.md @@ -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. 增长路径最顺:其产品形态自带传播基因,能极大降低后续的买量成本和用户获取难度,非常适合资源有限的初创团队。 +