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

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

View File

@@ -0,0 +1,132 @@
/**
* 音频管理器
*/
export default class AudioManager {
constructor() {
this.bgm = null;
this.sfx = {};
this.isMuted = false;
this.bgmVolume = 0.5;
this.sfxVolume = 0.8;
}
/**
* 播放背景音乐
*/
playBGM(src) {
if (this.isMuted || !src) return;
// 停止当前BGM
this.stopBGM();
// 创建新的音频实例
this.bgm = wx.createInnerAudioContext();
this.bgm.src = src;
this.bgm.loop = true;
this.bgm.volume = this.bgmVolume;
this.bgm.play();
}
/**
* 停止背景音乐
*/
stopBGM() {
if (this.bgm) {
this.bgm.stop();
this.bgm.destroy();
this.bgm = null;
}
}
/**
* 暂停背景音乐
*/
pauseBGM() {
if (this.bgm) {
this.bgm.pause();
}
}
/**
* 恢复背景音乐
*/
resumeBGM() {
if (this.bgm && !this.isMuted) {
this.bgm.play();
}
}
/**
* 播放音效
*/
playSFX(name, src) {
if (this.isMuted || !src) return;
// 复用或创建音效实例
if (!this.sfx[name]) {
this.sfx[name] = wx.createInnerAudioContext();
this.sfx[name].src = src;
}
this.sfx[name].volume = this.sfxVolume;
this.sfx[name].seek(0);
this.sfx[name].play();
}
/**
* 播放点击音效
*/
playClick() {
// 可以配置点击音效
// this.playSFX('click', 'audio/click.mp3');
}
/**
* 设置静音
*/
setMute(muted) {
this.isMuted = muted;
if (muted) {
this.pauseBGM();
} else {
this.resumeBGM();
}
}
/**
* 切换静音状态
*/
toggleMute() {
this.setMute(!this.isMuted);
return this.isMuted;
}
/**
* 设置BGM音量
*/
setBGMVolume(volume) {
this.bgmVolume = Math.max(0, Math.min(1, volume));
if (this.bgm) {
this.bgm.volume = this.bgmVolume;
}
}
/**
* 设置音效音量
*/
setSFXVolume(volume) {
this.sfxVolume = Math.max(0, Math.min(1, volume));
}
/**
* 销毁所有音频
*/
destroy() {
this.stopBGM();
Object.values(this.sfx).forEach(audio => {
audio.stop();
audio.destroy();
});
this.sfx = {};
}
}

View File

@@ -0,0 +1,147 @@
/**
* 故事数据管理器
*/
import { get, post } from '../utils/http';
export default class StoryManager {
constructor() {
this.storyList = [];
this.currentStory = null;
this.currentNodeKey = 'start';
this.categories = [];
}
/**
* 加载故事列表
*/
async loadStoryList(options = {}) {
try {
this.storyList = await get('/stories', options);
return this.storyList;
} catch (error) {
console.error('加载故事列表失败:', error);
return [];
}
}
/**
* 加载热门故事
*/
async loadHotStories(limit = 10) {
try {
return await get('/stories/hot', { limit });
} catch (error) {
console.error('加载热门故事失败:', error);
return [];
}
}
/**
* 加载分类列表
*/
async loadCategories() {
try {
this.categories = await get('/stories/categories');
return this.categories;
} catch (error) {
console.error('加载分类失败:', error);
return [];
}
}
/**
* 加载故事详情
*/
async loadStoryDetail(storyId) {
try {
this.currentStory = await get(`/stories/${storyId}`);
this.currentNodeKey = 'start';
// 记录游玩次数
await post(`/stories/${storyId}/play`);
return this.currentStory;
} catch (error) {
console.error('加载故事详情失败:', error);
return null;
}
}
/**
* 获取当前节点
*/
getCurrentNode() {
if (!this.currentStory || !this.currentStory.nodes) return null;
return this.currentStory.nodes[this.currentNodeKey];
}
/**
* 选择选项,前进到下一个节点
*/
selectChoice(choiceIndex) {
const currentNode = this.getCurrentNode();
if (!currentNode || !currentNode.choices || !currentNode.choices[choiceIndex]) {
return null;
}
const choice = currentNode.choices[choiceIndex];
this.currentNodeKey = choice.nextNodeKey;
return this.getCurrentNode();
}
/**
* 检查当前节点是否为结局
*/
isEnding() {
const currentNode = this.getCurrentNode();
return currentNode && currentNode.is_ending;
}
/**
* 获取结局信息
*/
getEndingInfo() {
const currentNode = this.getCurrentNode();
if (!currentNode || !currentNode.is_ending) return null;
return {
name: currentNode.ending_name,
score: currentNode.ending_score,
type: currentNode.ending_type,
content: currentNode.content
};
}
/**
* 重置故事进度
*/
resetStory() {
this.currentNodeKey = 'start';
}
/**
* 点赞故事
*/
async likeStory(like = true) {
if (!this.currentStory) return;
await post(`/stories/${this.currentStory.id}/like`, { like });
}
/**
* AI改写结局
*/
async rewriteEnding(storyId, ending, prompt) {
try {
const result = await post(`/stories/${storyId}/rewrite`, {
ending_name: ending?.name,
ending_content: ending?.content,
prompt: prompt
});
return result;
} catch (error) {
console.error('AI改写失败:', error);
return null;
}
}
}

View File

@@ -0,0 +1,134 @@
/**
* 用户数据管理器
*/
import { get, post } from '../utils/http';
export default class UserManager {
constructor() {
this.userId = null;
this.openid = null;
this.nickname = '';
this.avatarUrl = '';
this.isLoggedIn = false;
}
/**
* 初始化用户
*/
async init() {
try {
// 尝试从本地存储恢复用户信息
const cached = wx.getStorageSync('userInfo');
if (cached) {
this.userId = cached.userId;
this.openid = cached.openid;
this.nickname = cached.nickname;
this.avatarUrl = cached.avatarUrl;
this.isLoggedIn = true;
return;
}
// 获取登录code
const { code } = await this.wxLogin();
// 调用后端登录接口
const result = await post('/user/login', { code });
this.userId = result.userId;
this.openid = result.openid;
this.nickname = result.nickname || '游客';
this.avatarUrl = result.avatarUrl || '';
this.isLoggedIn = true;
// 缓存用户信息
wx.setStorageSync('userInfo', {
userId: this.userId,
openid: this.openid,
nickname: this.nickname,
avatarUrl: this.avatarUrl
});
} catch (error) {
console.error('用户初始化失败:', error);
// 使用临时身份
this.userId = 0;
this.nickname = '游客';
this.isLoggedIn = false;
}
}
/**
* 微信登录(带超时)
*/
wxLogin() {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('登录超时'));
}, 3000);
wx.login({
success: (res) => {
clearTimeout(timeout);
resolve(res);
},
fail: (err) => {
clearTimeout(timeout);
reject(err);
}
});
});
}
/**
* 获取用户游玩进度
*/
async getProgress(storyId = null) {
if (!this.isLoggedIn) return null;
return await get('/user/progress', { userId: this.userId, storyId });
}
/**
* 保存用户进度
*/
async saveProgress(storyId, currentNodeKey, isCompleted = false, endingReached = '') {
if (!this.isLoggedIn) return;
await post('/user/progress', {
userId: this.userId,
storyId,
currentNodeKey,
isCompleted,
endingReached
});
}
/**
* 点赞故事
*/
async likeStory(storyId, isLiked) {
if (!this.isLoggedIn) return;
await post('/user/like', {
userId: this.userId,
storyId,
isLiked
});
}
/**
* 收藏故事
*/
async collectStory(storyId, isCollected) {
if (!this.isLoggedIn) return;
await post('/user/collect', {
userId: this.userId,
storyId,
isCollected
});
}
/**
* 获取收藏列表
*/
async getCollections() {
if (!this.isLoggedIn) return [];
return await get('/user/collections', { userId: this.userId });
}
}

View File

@@ -0,0 +1,37 @@
/**
* 微信小游戏适配器
*/
// 获取系统信息
const systemInfo = wx.getSystemInfoSync();
const screenWidth = systemInfo.windowWidth;
const screenHeight = systemInfo.windowHeight;
const devicePixelRatio = systemInfo.pixelRatio;
// 创建主Canvas
const canvas = wx.createCanvas();
canvas.width = screenWidth * devicePixelRatio;
canvas.height = screenHeight * devicePixelRatio;
// 设置全局变量
GameGlobal.canvas = canvas;
GameGlobal.screenWidth = screenWidth;
GameGlobal.screenHeight = screenHeight;
GameGlobal.devicePixelRatio = devicePixelRatio;
// Image适配
GameGlobal.Image = function() {
return wx.createImage();
};
// Audio适配
GameGlobal.Audio = function() {
return wx.createInnerAudioContext();
};
export default {
canvas,
screenWidth,
screenHeight,
devicePixelRatio
};

183
client/js/main.js Normal file
View File

@@ -0,0 +1,183 @@
/**
* 星域故事汇 - 主逻辑控制器
*/
import SceneManager from './scenes/SceneManager';
import UserManager from './data/UserManager';
import StoryManager from './data/StoryManager';
import AudioManager from './data/AudioManager';
export default class Main {
constructor() {
// 获取画布和上下文
this.canvas = GameGlobal.canvas;
this.ctx = this.canvas.getContext('2d');
this.screenWidth = GameGlobal.screenWidth;
this.screenHeight = GameGlobal.screenHeight;
this.dpr = GameGlobal.devicePixelRatio;
// 缩放上下文以适配设备像素比
this.ctx.scale(this.dpr, this.dpr);
// 初始化管理器
this.userManager = new UserManager();
this.storyManager = new StoryManager();
this.audioManager = new AudioManager();
this.sceneManager = new SceneManager(this);
// 初始化游戏
this.init();
}
async init() {
// 先启动游戏循环,确保能渲染加载界面
this.bindEvents();
this.loop();
try {
// 显示加载界面
this.showLoading('正在加载...');
console.log('[Main] 开始初始化...');
// 用户初始化(失败不阻塞)
console.log('[Main] 初始化用户...');
await this.userManager.init().catch(e => {
console.warn('[Main] 用户初始化失败,使用游客模式:', e);
});
console.log('[Main] 用户初始化完成');
// 加载故事列表
console.log('[Main] 加载故事列表...');
await this.storyManager.loadStoryList();
console.log('[Main] 故事列表加载完成,共', this.storyManager.storyList.length, '个故事');
// 隐藏加载界面
this.hideLoading();
// 进入首页
this.sceneManager.switchScene('home');
console.log('[Main] 初始化完成,进入首页');
// 设置分享
this.setupShare();
} catch (error) {
console.error('[Main] 初始化失败:', error);
this.hideLoading();
this.showError('初始化失败,请重试');
}
}
// 显示加载
showLoading(text) {
this.isLoading = true;
this.loadingText = text;
this.render();
}
// 隐藏加载
hideLoading() {
this.isLoading = false;
}
// 显示错误
showError(text) {
wx.showToast({
title: text,
icon: 'none',
duration: 2000
});
}
// 绑定事件
bindEvents() {
// 触摸开始
wx.onTouchStart((e) => {
if (this.sceneManager.currentScene) {
this.sceneManager.currentScene.onTouchStart(e);
}
});
// 触摸移动
wx.onTouchMove((e) => {
if (this.sceneManager.currentScene) {
this.sceneManager.currentScene.onTouchMove(e);
}
});
// 触摸结束
wx.onTouchEnd((e) => {
if (this.sceneManager.currentScene) {
this.sceneManager.currentScene.onTouchEnd(e);
}
});
}
// 设置分享
setupShare() {
wx.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
});
wx.onShareAppMessage(() => {
return {
title: '星域故事汇 - 每个选择都是一个新世界',
imageUrl: '',
query: ''
};
});
wx.onShareTimeline(() => {
return {
title: '星域故事汇 - 沉浸式互动故事体验',
query: ''
};
});
}
// 游戏循环
loop() {
this.update();
this.render();
requestAnimationFrame(() => this.loop());
}
// 更新逻辑
update() {
if (this.sceneManager.currentScene) {
this.sceneManager.currentScene.update();
}
}
// 渲染
render() {
// 清屏
this.ctx.clearRect(0, 0, this.screenWidth, this.screenHeight);
// 绘制背景
this.ctx.fillStyle = '#1a1a2e';
this.ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
// 渲染当前场景
if (this.sceneManager.currentScene) {
this.sceneManager.currentScene.render(this.ctx);
}
// 渲染加载界面
if (this.isLoading) {
this.renderLoading();
}
}
// 渲染加载界面
renderLoading() {
// 半透明遮罩
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
this.ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
// 加载文字
this.ctx.fillStyle = '#ffffff';
this.ctx.font = '18px sans-serif';
this.ctx.textAlign = 'center';
this.ctx.fillText(this.loadingText || '加载中...', this.screenWidth / 2, this.screenHeight / 2);
}
}

View File

@@ -0,0 +1,83 @@
/**
* 场景基类
*/
export default class BaseScene {
constructor(main, params = {}) {
this.main = main;
this.params = params;
this.screenWidth = main.screenWidth;
this.screenHeight = main.screenHeight;
this.scrollVelocity = 0;
}
// 初始化
init() {}
// 更新逻辑
update() {}
// 渲染
render(ctx) {}
// 触摸开始
onTouchStart(e) {}
// 触摸移动
onTouchMove(e) {}
// 触摸结束
onTouchEnd(e) {}
// 销毁
destroy() {}
// 绘制圆角矩形
roundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
// 绘制多行文本
wrapText(ctx, text, x, y, maxWidth, lineHeight) {
const lines = [];
let currentLine = '';
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === '\n') {
lines.push(currentLine);
currentLine = '';
continue;
}
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine.length > 0) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine.length > 0) {
lines.push(currentLine);
}
lines.forEach((line, index) => {
ctx.fillText(line, x, y + index * lineHeight);
});
return lines.length;
}
}

View File

@@ -0,0 +1,284 @@
/**
* 章节选择场景
*/
import BaseScene from './BaseScene';
export default class ChapterScene extends BaseScene {
constructor(main, params) {
super(main, params);
this.storyId = params.storyId;
this.story = null;
this.nodeList = [];
this.scrollY = 0;
this.maxScrollY = 0;
this.isDragging = false;
this.lastTouchY = 0;
this.hasMoved = false;
}
init() {
this.story = this.main.storyManager.currentStory;
if (!this.story || !this.story.nodes) {
this.main.sceneManager.switchScene('home');
return;
}
this.buildNodeList();
this.calculateMaxScroll();
}
buildNodeList() {
const nodes = this.story.nodes;
this.nodeList = [];
// 遍历所有节点
Object.keys(nodes).forEach(key => {
const node = nodes[key];
this.nodeList.push({
key: key,
title: this.getNodeTitle(node, key),
isEnding: node.is_ending,
endingName: node.ending_name,
endingType: node.ending_type,
speaker: node.speaker,
preview: this.getPreview(node.content)
});
});
// 按关键字排序start在前ending在后
this.nodeList.sort((a, b) => {
if (a.key === 'start') return -1;
if (b.key === 'start') return 1;
if (a.isEnding && !b.isEnding) return 1;
if (!a.isEnding && b.isEnding) return -1;
return a.key.localeCompare(b.key);
});
}
getNodeTitle(node, key) {
if (key === 'start') return '故事开始';
if (node.is_ending) return `结局:${node.ending_name || '未知'}`;
if (node.speaker && node.speaker !== '旁白') return `${node.speaker}的对话`;
return `章节 ${key}`;
}
getPreview(content) {
if (!content) return '';
const text = content.replace(/\n/g, ' ').trim();
return text.length > 40 ? text.substring(0, 40) + '...' : text;
}
calculateMaxScroll() {
const cardHeight = 85;
const gap = 12;
const headerHeight = 80;
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight;
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 20);
}
update() {}
render(ctx) {
this.renderBackground(ctx);
this.renderHeader(ctx);
this.renderNodeList(ctx);
}
renderBackground(ctx) {
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
gradient.addColorStop(0, '#0f0c29');
gradient.addColorStop(0.5, '#302b63');
gradient.addColorStop(1, '#24243e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
}
renderHeader(ctx) {
// 顶部遮罩
const headerGradient = ctx.createLinearGradient(0, 0, 0, 80);
headerGradient.addColorStop(0, 'rgba(15,12,41,1)');
headerGradient.addColorStop(1, 'rgba(15,12,41,0)');
ctx.fillStyle = headerGradient;
ctx.fillRect(0, 0, this.screenWidth, 80);
// 返回按钮
ctx.fillStyle = '#ffffff';
ctx.font = '16px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(' 返回', 15, 40);
// 标题
ctx.textAlign = 'center';
ctx.font = 'bold 17px sans-serif';
ctx.fillText('选择章节', this.screenWidth / 2, 40);
// 故事名
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '12px sans-serif';
const title = this.story?.title || '';
ctx.fillText(title.length > 15 ? title.substring(0, 15) + '...' : title, this.screenWidth / 2, 58);
}
renderNodeList(ctx) {
const padding = 15;
const cardHeight = 85;
const cardMargin = 12;
const startY = 80;
ctx.save();
ctx.beginPath();
ctx.rect(0, startY, this.screenWidth, this.screenHeight - startY);
ctx.clip();
this.nodeList.forEach((node, index) => {
const y = startY + index * (cardHeight + cardMargin) - this.scrollY;
if (y + cardHeight < startY || y > this.screenHeight) return;
// 卡片背景
if (node.isEnding) {
const endingGradient = ctx.createLinearGradient(padding, y, this.screenWidth - padding, y);
const colors = this.getEndingColors(node.endingType);
endingGradient.addColorStop(0, colors[0]);
endingGradient.addColorStop(1, colors[1]);
ctx.fillStyle = endingGradient;
} else {
ctx.fillStyle = 'rgba(255,255,255,0.08)';
}
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12);
ctx.fill();
// 节点标题
ctx.fillStyle = node.isEnding ? '#ffffff' : '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(node.title, padding + 15, y + 28);
// 节点预览
ctx.fillStyle = node.isEnding ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.5)';
ctx.font = '12px sans-serif';
const preview = this.truncateText(ctx, node.preview, this.screenWidth - padding * 2 - 30);
ctx.fillText(preview, padding + 15, y + 52);
// 节点标识
if (node.key === 'start') {
this.renderTag(ctx, this.screenWidth - padding - 60, y + 18, '起点', '#4ecca3');
} else if (node.isEnding) {
this.renderTag(ctx, this.screenWidth - padding - 60, y + 18, '结局', '#ffd700');
}
});
ctx.restore();
// 滚动条
if (this.maxScrollY > 0) {
const scrollBarHeight = 50;
const scrollBarY = startY + (this.scrollY / this.maxScrollY) * (this.screenHeight - startY - scrollBarHeight - 20);
ctx.fillStyle = 'rgba(255,255,255,0.2)';
this.roundRect(ctx, this.screenWidth - 5, scrollBarY, 3, scrollBarHeight, 1.5);
ctx.fill();
}
}
getEndingColors(type) {
switch (type) {
case 'good': return ['rgba(100,200,100,0.3)', 'rgba(50,150,50,0.2)'];
case 'bad': return ['rgba(200,100,100,0.3)', 'rgba(150,50,50,0.2)'];
case 'hidden': return ['rgba(255,215,0,0.3)', 'rgba(200,150,0,0.2)'];
default: return ['rgba(150,150,255,0.3)', 'rgba(100,100,200,0.2)'];
}
}
renderTag(ctx, x, y, text, color) {
ctx.font = '10px sans-serif';
const width = ctx.measureText(text).width + 12;
ctx.fillStyle = color + '40';
this.roundRect(ctx, x, y, width, 18, 9);
ctx.fill();
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.fillText(text, x + width / 2, y + 13);
ctx.textAlign = 'left';
}
truncateText(ctx, text, maxWidth) {
if (!text) return '';
if (ctx.measureText(text).width <= maxWidth) return text;
let truncated = text;
while (truncated.length > 0 && ctx.measureText(truncated + '...').width > maxWidth) {
truncated = truncated.slice(0, -1);
}
return truncated + '...';
}
roundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
onTouchStart(e) {
const touch = e.touches[0];
this.lastTouchY = touch.clientY;
this.touchStartY = touch.clientY;
this.touchStartX = touch.clientX;
this.isDragging = true;
this.hasMoved = false;
}
onTouchMove(e) {
if (!this.isDragging) return;
const touch = e.touches[0];
const deltaY = this.lastTouchY - touch.clientY;
if (Math.abs(deltaY) > 3) {
this.hasMoved = true;
}
this.scrollY += deltaY;
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
this.lastTouchY = touch.clientY;
}
onTouchEnd(e) {
this.isDragging = false;
const touch = e.changedTouches[0];
const x = touch.clientX;
const y = touch.clientY;
if (this.hasMoved) return;
// 返回按钮
if (y < 60 && x < 80) {
this.main.sceneManager.switchScene('ending', {
storyId: this.storyId,
ending: this.main.storyManager.getEndingInfo()
});
return;
}
// 点击节点
const padding = 15;
const cardHeight = 85;
const cardMargin = 12;
const startY = 80;
for (let i = 0; i < this.nodeList.length; i++) {
const cardY = startY + i * (cardHeight + cardMargin) - this.scrollY;
if (y >= cardY && y <= cardY + cardHeight && x >= padding && x <= this.screenWidth - padding) {
this.selectNode(this.nodeList[i].key);
return;
}
}
}
selectNode(nodeKey) {
this.main.storyManager.currentNodeKey = nodeKey;
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
}
}

View File

@@ -0,0 +1,745 @@
/**
* 结局场景
*/
import BaseScene from './BaseScene';
export default class EndingScene extends BaseScene {
constructor(main, params) {
super(main, params);
this.storyId = params.storyId;
this.ending = params.ending;
this.showButtons = false;
this.fadeIn = 0;
this.particles = [];
this.isLiked = false;
this.isCollected = false;
// AI改写面板
this.showRewritePanel = false;
this.rewritePrompt = '';
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢'];
this.selectedTag = -1;
this.initParticles();
}
init() {
setTimeout(() => {
this.showButtons = true;
}, 1500);
}
initParticles() {
for (let i = 0; i < 50; i++) {
this.particles.push({
x: Math.random() * this.screenWidth,
y: Math.random() * this.screenHeight,
size: Math.random() * 3 + 1,
speedY: Math.random() * 0.5 + 0.2,
alpha: Math.random() * 0.5 + 0.3
});
}
}
update() {
if (this.fadeIn < 1) {
this.fadeIn += 0.02;
}
this.particles.forEach(p => {
p.y -= p.speedY;
if (p.y < 0) {
p.y = this.screenHeight;
p.x = Math.random() * this.screenWidth;
}
});
}
render(ctx) {
this.renderBackground(ctx);
this.renderParticles(ctx);
this.renderEndingContent(ctx);
if (this.showButtons) {
this.renderButtons(ctx);
}
// AI改写面板
if (this.showRewritePanel) {
this.renderRewritePanel(ctx);
}
}
renderBackground(ctx) {
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
switch (this.ending?.type) {
case 'good':
gradient.addColorStop(0, '#0f2027');
gradient.addColorStop(0.5, '#203a43');
gradient.addColorStop(1, '#2c5364');
break;
case 'bad':
gradient.addColorStop(0, '#1a0a0a');
gradient.addColorStop(0.5, '#3a1515');
gradient.addColorStop(1, '#2d1f1f');
break;
case 'hidden':
gradient.addColorStop(0, '#1a1a0a');
gradient.addColorStop(0.5, '#3a3515');
gradient.addColorStop(1, '#2d2d1f');
break;
default:
gradient.addColorStop(0, '#0f0c29');
gradient.addColorStop(0.5, '#302b63');
gradient.addColorStop(1, '#24243e');
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
}
renderParticles(ctx) {
this.particles.forEach(p => {
ctx.fillStyle = `rgba(255, 255, 255, ${p.alpha * this.fadeIn})`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
}
renderEndingContent(ctx) {
const centerX = this.screenWidth / 2;
const alpha = this.fadeIn;
const padding = 20;
// 结局卡片背景
const cardY = 80;
const cardHeight = 320;
ctx.fillStyle = `rgba(255, 255, 255, ${0.05 * alpha})`;
this.roundRect(ctx, padding, cardY, this.screenWidth - padding * 2, cardHeight, 20);
ctx.fill();
// 装饰线
const lineGradient = ctx.createLinearGradient(padding + 30, cardY + 20, this.screenWidth - padding - 30, cardY + 20);
lineGradient.addColorStop(0, 'transparent');
lineGradient.addColorStop(0.5, this.getEndingColorRgba(alpha * 0.5));
lineGradient.addColorStop(1, 'transparent');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding + 30, cardY + 20);
ctx.lineTo(this.screenWidth - padding - 30, cardY + 20);
ctx.stroke();
// 结局标签
ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.6})`;
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('— 达成结局 —', centerX, cardY + 50);
// 结局名称(自动调整字号)
const endingName = this.ending?.name || '未知结局';
let fontSize = 24;
ctx.font = `bold ${fontSize}px sans-serif`;
while (ctx.measureText(endingName).width > this.screenWidth - padding * 2 - 40 && fontSize > 14) {
fontSize -= 2;
ctx.font = `bold ${fontSize}px sans-serif`;
}
ctx.fillStyle = this.getEndingColorRgba(alpha);
ctx.fillText(endingName, centerX, cardY + 90);
// 结局类型标签
const typeLabel = this.getTypeLabel();
if (typeLabel) {
ctx.font = '11px sans-serif';
const labelWidth = ctx.measureText(typeLabel).width + 20;
ctx.fillStyle = this.getEndingColorRgba(alpha * 0.3);
this.roundRect(ctx, centerX - labelWidth / 2, cardY + 100, labelWidth, 22, 11);
ctx.fill();
ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.9})`;
ctx.fillText(typeLabel, centerX, cardY + 115);
}
// 评分
if (this.ending?.score !== undefined) {
this.renderScore(ctx, centerX, cardY + 155, alpha);
}
// 结局描述(居中显示,限制在卡片内)
ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.6})`;
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
const content = this.ending?.content || '';
const lastParagraph = content.split('\n').filter(p => p.trim()).pop() || '';
const maxWidth = this.screenWidth - padding * 2 - 30;
// 限制只显示2行居中
this.wrapTextCentered(ctx, lastParagraph, centerX, cardY + 250, maxWidth, 20, 2);
}
getTypeLabel() {
switch (this.ending?.type) {
case 'good': return '✨ 完美结局';
case 'bad': return '💔 悲伤结局';
case 'hidden': return '🔮 隐藏结局';
default: return '📖 普通结局';
}
}
truncateText(ctx, text, maxWidth) {
if (!text) return '';
if (ctx.measureText(text).width <= maxWidth) return text;
let truncated = text;
while (truncated.length > 0 && ctx.measureText(truncated + '...').width > maxWidth) {
truncated = truncated.slice(0, -1);
}
return truncated + '...';
}
renderScore(ctx, x, y, alpha) {
const score = this.ending?.score || 0;
const stars = Math.ceil(score / 20);
// 星星
const starSize = 22;
const gap = 6;
const totalWidth = 5 * starSize + 4 * gap;
const startX = x - totalWidth / 2;
for (let i = 0; i < 5; i++) {
const filled = i < stars;
ctx.fillStyle = filled ? `rgba(255, 215, 0, ${alpha})` : `rgba(100, 100, 100, ${alpha * 0.5})`;
ctx.font = `${starSize}px sans-serif`;
ctx.textAlign = 'left';
ctx.fillText(filled ? '★' : '☆', startX + i * (starSize + gap), y);
}
// 分数
ctx.fillStyle = `rgba(255, 215, 0, ${alpha})`;
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${score}`, x, y + 28);
}
getEndingColorRgba(alpha) {
switch (this.ending?.type) {
case 'good': return `rgba(100, 255, 150, ${alpha})`;
case 'bad': return `rgba(255, 100, 100, ${alpha})`;
case 'hidden': return `rgba(255, 215, 0, ${alpha})`;
default: return `rgba(150, 150, 255, ${alpha})`;
}
}
renderButtons(ctx) {
const padding = 15;
const buttonHeight = 38;
const buttonMargin = 8;
const startY = this.screenHeight - 220;
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
// AI改写按钮突出显示
this.renderGradientButton(ctx, padding, startY, this.screenWidth - padding * 2, buttonHeight, '✨ AI改写结局', ['#a855f7', '#ec4899']);
// 分享按钮
const row2Y = startY + buttonHeight + buttonMargin;
this.renderGradientButton(ctx, padding, row2Y, buttonWidth, buttonHeight, '分享结局', ['#ff6b6b', '#ffd700']);
// 章节选择按钮
this.renderGradientButton(ctx, padding + buttonWidth + buttonMargin, row2Y, buttonWidth, buttonHeight, '章节选择', ['#667eea', '#764ba2']);
// 从头开始
const row3Y = row2Y + buttonHeight + buttonMargin;
ctx.fillStyle = 'rgba(255,255,255,0.1)';
this.roundRect(ctx, padding, row3Y, buttonWidth, buttonHeight, 19);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
this.roundRect(ctx, padding, row3Y, buttonWidth, buttonHeight, 19);
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('从头开始', padding + buttonWidth / 2, row3Y + 24);
// 返回首页
ctx.fillStyle = 'rgba(255,255,255,0.1)';
this.roundRect(ctx, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight, 19);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
this.roundRect(ctx, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight, 19);
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.fillText('返回首页', padding + buttonWidth + buttonMargin + buttonWidth / 2, row3Y + 24);
// 点赞和收藏
const actionY = row3Y + buttonHeight + 18;
const centerX = this.screenWidth / 2;
ctx.font = '20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(this.isLiked ? '❤️' : '🤍', centerX - 40, actionY);
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '10px sans-serif';
ctx.fillText('点赞', centerX - 40, actionY + 16);
ctx.font = '20px sans-serif';
ctx.fillText(this.isCollected ? '⭐' : '☆', centerX + 40, actionY);
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '10px sans-serif';
ctx.fillText('收藏', centerX + 40, actionY + 16);
}
renderGradientButton(ctx, x, y, width, height, text, colors) {
const gradient = ctx.createLinearGradient(x, y, x + width, y);
gradient.addColorStop(0, colors[0]);
gradient.addColorStop(1, colors[1]);
ctx.fillStyle = gradient;
this.roundRect(ctx, x, y, width, height, 22);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(text, x + width / 2, y + height / 2 + 5);
}
renderRewritePanel(ctx) {
const padding = 20;
const panelWidth = this.screenWidth - padding * 2;
const panelHeight = 380;
const panelX = padding;
const panelY = (this.screenHeight - panelHeight) / 2;
// 遮罩层
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
// 面板背景渐变
const panelGradient = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight);
panelGradient.addColorStop(0, '#1a1a3e');
panelGradient.addColorStop(1, '#0d0d1a');
ctx.fillStyle = panelGradient;
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
ctx.fill();
// 面板边框渐变
const borderGradient = ctx.createLinearGradient(panelX, panelY, panelX + panelWidth, panelY);
borderGradient.addColorStop(0, '#a855f7');
borderGradient.addColorStop(1, '#ec4899');
ctx.strokeStyle = borderGradient;
ctx.lineWidth = 2;
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
ctx.stroke();
// 标题
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('✨ AI改写结局', this.screenWidth / 2, panelY + 35);
// 副标题
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '12px sans-serif';
ctx.fillText('输入你想要的剧情走向AI将为你重新创作', this.screenWidth / 2, panelY + 58);
// 分隔线
const lineGradient = ctx.createLinearGradient(panelX + 20, panelY + 75, panelX + panelWidth - 20, panelY + 75);
lineGradient.addColorStop(0, 'transparent');
lineGradient.addColorStop(0.5, 'rgba(168,85,247,0.5)');
lineGradient.addColorStop(1, 'transparent');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(panelX + 20, panelY + 75);
ctx.lineTo(panelX + panelWidth - 20, panelY + 75);
ctx.stroke();
// 快捷标签标题
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('快捷选择:', panelX + 15, panelY + 105);
// 快捷标签
const tagStartX = panelX + 15;
const tagY = panelY + 120;
const tagHeight = 32;
const tagGap = 8;
let currentX = tagStartX;
let currentY = tagY;
this.tagRects = [];
this.rewriteTags.forEach((tag, index) => {
ctx.font = '12px sans-serif';
const tagWidth = ctx.measureText(tag).width + 24;
// 换行
if (currentX + tagWidth > panelX + panelWidth - 15) {
currentX = tagStartX;
currentY += tagHeight + tagGap;
}
// 标签背景
const isSelected = index === this.selectedTag;
if (isSelected) {
const tagGradient = ctx.createLinearGradient(currentX, currentY, currentX + tagWidth, currentY);
tagGradient.addColorStop(0, '#a855f7');
tagGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = tagGradient;
} else {
ctx.fillStyle = 'rgba(255,255,255,0.1)';
}
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
ctx.fill();
// 标签边框
ctx.strokeStyle = isSelected ? 'transparent' : 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
ctx.stroke();
// 标签文字
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.fillText(tag, currentX + tagWidth / 2, currentY + 21);
// 存储标签位置
this.tagRects.push({ x: currentX, y: currentY, width: tagWidth, height: tagHeight, index });
currentX += tagWidth + tagGap;
});
// 自定义输入提示
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('或自定义输入:', panelX + 15, panelY + 215);
// 输入框背景
const inputY = panelY + 230;
const inputHeight = 45;
ctx.fillStyle = 'rgba(255,255,255,0.08)';
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
ctx.stroke();
// 输入框文字或占位符
ctx.font = '14px sans-serif';
ctx.textAlign = 'left';
if (this.rewritePrompt) {
ctx.fillStyle = '#ffffff';
ctx.fillText(this.rewritePrompt, panelX + 28, inputY + 28);
} else {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.fillText('点击输入你的改写想法...', panelX + 28, inputY + 28);
}
// 按钮
const btnY = panelY + panelHeight - 70;
const btnWidth = (panelWidth - 50) / 2;
const btnHeight = 44;
// 取消按钮
ctx.fillStyle = 'rgba(255,255,255,0.1)';
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('取消', panelX + 15 + btnWidth / 2, btnY + 28);
// 确认按钮
const confirmGradient = ctx.createLinearGradient(panelX + 35 + btnWidth, btnY, panelX + 35 + btnWidth * 2, btnY);
confirmGradient.addColorStop(0, '#a855f7');
confirmGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = confirmGradient;
this.roundRect(ctx, panelX + 35 + btnWidth, btnY, btnWidth, btnHeight, 22);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.fillText('✨ 开始改写', panelX + 35 + btnWidth + btnWidth / 2, btnY + 28);
// 存储按钮区域
this.cancelBtnRect = { x: panelX + 15, y: btnY, width: btnWidth, height: btnHeight };
this.confirmBtnRect = { x: panelX + 35 + btnWidth, y: btnY, width: btnWidth, height: btnHeight };
this.inputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight };
}
// 圆角矩形
roundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
// 文字换行
wrapText(ctx, text, x, y, maxWidth, lineHeight) {
if (!text) return;
let line = '';
let lineY = y;
for (let char of text) {
const testLine = line + char;
if (ctx.measureText(testLine).width > maxWidth) {
ctx.fillText(line, x, lineY);
line = char;
lineY += lineHeight;
} else {
line = testLine;
}
}
ctx.fillText(line, x, lineY);
}
// 限制行数的文字换行
wrapTextLimited(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
if (!text) return;
let line = '';
let lineY = y;
let lineCount = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const testLine = line + char;
if (ctx.measureText(testLine).width > maxWidth) {
lineCount++;
if (lineCount >= maxLines) {
// 最后一行加省略号
while (line.length > 0 && ctx.measureText(line + '...').width > maxWidth) {
line = line.slice(0, -1);
}
ctx.fillText(line + '...', x, lineY);
return;
}
ctx.fillText(line, x, lineY);
line = char;
lineY += lineHeight;
} else {
line = testLine;
}
}
ctx.fillText(line, x, lineY);
}
// 居中显示的限制行数文字换行
wrapTextCentered(ctx, text, centerX, y, maxWidth, lineHeight, maxLines) {
if (!text) return;
// 先分行
const lines = [];
let line = '';
for (let i = 0; i < text.length; i++) {
const char = text[i];
const testLine = line + char;
if (ctx.measureText(testLine).width > maxWidth) {
lines.push(line);
line = char;
if (lines.length >= maxLines) break;
} else {
line = testLine;
}
}
if (line && lines.length < maxLines) {
lines.push(line);
}
// 如果超出行数,最后一行加省略号
if (lines.length >= maxLines && line) {
let lastLine = lines[maxLines - 1];
while (lastLine.length > 0 && ctx.measureText(lastLine + '...').width > maxWidth) {
lastLine = lastLine.slice(0, -1);
}
lines[maxLines - 1] = lastLine + '...';
}
// 居中绘制
lines.slice(0, maxLines).forEach((l, i) => {
ctx.fillText(l, centerX, y + i * lineHeight);
});
}
onTouchEnd(e) {
const touch = e.changedTouches[0];
const x = touch.clientX;
const y = touch.clientY;
// 如果改写面板打开,优先处理
if (this.showRewritePanel) {
this.handleRewritePanelTouch(x, y);
return;
}
if (!this.showButtons) return;
const padding = 15;
const buttonHeight = 38;
const buttonMargin = 8;
const startY = this.screenHeight - 220;
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
// AI改写按钮
if (this.isInRect(x, y, padding, startY, this.screenWidth - padding * 2, buttonHeight)) {
this.handleAIRewrite();
return;
}
// 分享按钮
const row2Y = startY + buttonHeight + buttonMargin;
if (this.isInRect(x, y, padding, row2Y, buttonWidth, buttonHeight)) {
this.handleShare();
return;
}
// 章节选择按钮
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, row2Y, buttonWidth, buttonHeight)) {
this.handleChapterSelect();
return;
}
// 从头开始
const row3Y = row2Y + buttonHeight + buttonMargin;
if (this.isInRect(x, y, padding, row3Y, buttonWidth, buttonHeight)) {
this.handleReplay();
return;
}
// 返回首页
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight)) {
this.main.sceneManager.switchScene('home');
return;
}
// 点赞收藏
const actionY = row3Y + buttonHeight + 18;
const centerX = this.screenWidth / 2;
if (this.isInRect(x, y, centerX - 70, actionY - 20, 60, 45)) {
this.handleLike();
return;
}
if (this.isInRect(x, y, centerX + 10, actionY - 20, 60, 45)) {
this.handleCollect();
return;
}
}
isInRect(x, y, rx, ry, rw, rh) {
return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
}
handleShare() {
wx.shareAppMessage({
title: `我在《星域故事汇》达成了「${this.ending?.name}」结局!`,
imageUrl: '',
query: `storyId=${this.storyId}`
});
}
handleChapterSelect() {
this.main.sceneManager.switchScene('chapter', { storyId: this.storyId });
}
handleAIRewrite() {
// 显示AI改写面板
this.showRewritePanel = true;
this.rewritePrompt = '';
this.selectedTag = -1;
}
handleRewritePanelTouch(x, y) {
// 点击标签
if (this.tagRects) {
for (const tag of this.tagRects) {
if (this.isInRect(x, y, tag.x, tag.y, tag.width, tag.height)) {
this.selectedTag = tag.index;
this.rewritePrompt = this.rewriteTags[tag.index];
return true;
}
}
}
// 点击输入框
if (this.inputRect && this.isInRect(x, y, this.inputRect.x, this.inputRect.y, this.inputRect.width, this.inputRect.height)) {
this.showCustomInput();
return true;
}
// 点击取消
if (this.cancelBtnRect && this.isInRect(x, y, this.cancelBtnRect.x, this.cancelBtnRect.y, this.cancelBtnRect.width, this.cancelBtnRect.height)) {
this.showRewritePanel = false;
return true;
}
// 点击确认
if (this.confirmBtnRect && this.isInRect(x, y, this.confirmBtnRect.x, this.confirmBtnRect.y, this.confirmBtnRect.width, this.confirmBtnRect.height)) {
if (this.rewritePrompt) {
this.showRewritePanel = false;
this.callAIRewrite(this.rewritePrompt);
} else {
wx.showToast({ title: '请选择或输入改写内容', icon: 'none' });
}
return true;
}
return false;
}
showCustomInput() {
wx.showModal({
title: '输入改写想法',
editable: true,
placeholderText: '例如:让主角获得逆袭',
content: this.rewritePrompt,
success: (res) => {
if (res.confirm && res.content) {
this.rewritePrompt = res.content;
this.selectedTag = -1;
}
}
});
}
async callAIRewrite(prompt) {
wx.showLoading({ title: 'AI创作中...' });
try {
const result = await this.main.storyManager.rewriteEnding(
this.storyId,
this.ending,
prompt
);
wx.hideLoading();
if (result && result.content) {
// 跳转到故事场景播放新内容
this.main.sceneManager.switchScene('story', {
storyId: this.storyId,
aiContent: result
});
} else {
wx.showToast({ title: '改写失败,请重试', icon: 'none' });
}
} catch (error) {
wx.hideLoading();
wx.showToast({ title: '网络错误', icon: 'none' });
}
}
handleReplay() {
this.main.storyManager.resetStory();
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
}
handleLike() {
this.isLiked = !this.isLiked;
this.main.userManager.likeStory(this.storyId, this.isLiked);
this.main.storyManager.likeStory(this.isLiked);
}
handleCollect() {
this.isCollected = !this.isCollected;
this.main.userManager.collectStory(this.storyId, this.isCollected);
}
}

View File

@@ -0,0 +1,537 @@
/**
* 首页场景
*/
import BaseScene from './BaseScene';
export default class HomeScene extends BaseScene {
constructor(main, params) {
super(main, params);
this.storyList = [];
this.scrollY = 0;
this.maxScrollY = 0;
this.isDragging = false;
this.lastTouchY = 0;
this.scrollVelocity = 0;
this.currentTab = 0; // 0: 首页, 1: 发现, 2: 我的
this.categories = ['全部', '都市言情', '悬疑推理', '古风宫廷', '校园青春', '修仙玄幻', '穿越重生', '职场商战', '科幻未来', '恐怖惊悚', '搞笑轻喜'];
this.selectedCategory = 0;
// 分类横向滚动
this.categoryScrollX = 0;
this.maxCategoryScrollX = 0;
this.isCategoryDragging = false;
this.lastTouchX = 0;
// 存储分类标签位置(用于点击判定)
this.categoryRects = [];
}
async init() {
// 加载故事列表
this.storyList = this.main.storyManager.storyList;
this.calculateMaxScroll();
}
// 获取过滤后的故事列表
getFilteredStories() {
if (this.selectedCategory === 0) {
return this.storyList; // 全部
}
const categoryName = this.categories[this.selectedCategory];
return this.storyList.filter(s => s.category === categoryName);
}
calculateMaxScroll() {
// 计算最大滚动距离
const stories = this.getFilteredStories();
const cardHeight = 120;
const gap = 15;
const startY = 150; // 故事列表起始位置
const tabHeight = 65;
// 内容总高度
const contentBottom = startY + stories.length * (cardHeight + gap);
// 可视区域底部减去底部Tab栏
const visibleBottom = this.screenHeight - tabHeight;
// 最大滚动距离 = 内容超出可视区域的部分
this.maxScrollY = Math.max(0, contentBottom - visibleBottom);
}
update() {
// 滚动惯性
if (!this.isDragging && Math.abs(this.scrollVelocity) > 0.5) {
this.scrollY += this.scrollVelocity;
this.scrollVelocity *= 0.95;
// 边界检查
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
}
}
render(ctx) {
// 绘制背景渐变(深紫蓝色调)
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
gradient.addColorStop(0, '#0f0c29');
gradient.addColorStop(0.5, '#302b63');
gradient.addColorStop(1, '#24243e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
// 添加星空装饰点
this.renderStars(ctx);
// 绘制顶部标题栏
this.renderHeader(ctx);
// 绘制分类标签
this.renderCategories(ctx);
// 绘制故事列表
this.renderStoryList(ctx);
// 绘制底部Tab栏
this.renderTabBar(ctx);
}
renderStars(ctx) {
ctx.fillStyle = 'rgba(255,255,255,0.3)';
const stars = [[30, 80], [80, 30], [150, 60], [200, 25], [280, 70], [320, 40]];
stars.forEach(([x, y]) => {
ctx.beginPath();
ctx.arc(x, y, 1, 0, Math.PI * 2);
ctx.fill();
});
}
renderHeader(ctx) {
// 标题带渐变
const titleGradient = ctx.createLinearGradient(20, 30, 200, 30);
titleGradient.addColorStop(0, '#ffd700');
titleGradient.addColorStop(1, '#ff6b6b');
ctx.fillStyle = titleGradient;
ctx.font = 'bold 26px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('星域故事汇', 20, 50);
// 副标题
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '13px sans-serif';
ctx.fillText('每个选择,都是一个新世界', 20, 75);
}
renderCategories(ctx) {
const startY = 95;
const tagHeight = 30;
let x = 15 - this.categoryScrollX;
const y = startY;
// 计算总宽度用于滚动限制
ctx.font = '13px sans-serif';
let totalWidth = 15;
this.categories.forEach((cat) => {
totalWidth += ctx.measureText(cat).width + 28 + 12;
});
this.maxCategoryScrollX = Math.max(0, totalWidth - this.screenWidth + 15);
// 清空并重新记录位置
this.categoryRects = [];
this.categories.forEach((cat, index) => {
const isSelected = index === this.selectedCategory;
const textWidth = ctx.measureText(cat).width + 28;
// 记录每个标签的位置
this.categoryRects.push({
left: x + this.categoryScrollX,
right: x + this.categoryScrollX + textWidth,
index: index
});
// 只渲染可见的标签
if (x + textWidth > 0 && x < this.screenWidth) {
if (isSelected) {
// 选中态:渐变背景
const tagGradient = ctx.createLinearGradient(x, y, x + textWidth, y);
tagGradient.addColorStop(0, '#ff6b6b');
tagGradient.addColorStop(1, '#ffd700');
ctx.fillStyle = tagGradient;
} else {
ctx.fillStyle = 'rgba(255,255,255,0.08)';
}
this.roundRect(ctx, x, y, textWidth, tagHeight, 15);
ctx.fill();
// 未选中态加边框
if (!isSelected) {
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
this.roundRect(ctx, x, y, textWidth, tagHeight, 15);
ctx.stroke();
}
// 标签文字
ctx.fillStyle = isSelected ? '#ffffff' : 'rgba(255,255,255,0.7)';
ctx.font = isSelected ? 'bold 13px sans-serif' : '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(cat, x + textWidth / 2, y + 20);
}
x += textWidth + 12;
});
}
renderStoryList(ctx) {
const startY = 150;
const cardHeight = 120;
const cardPadding = 15;
const cardMargin = 15;
const stories = this.getFilteredStories();
if (stories.length === 0) {
ctx.fillStyle = '#666666';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('暂无该分类的故事', this.screenWidth / 2, 250);
return;
}
// 设置裁剪区域,防止卡片渲染到分类标签区域
ctx.save();
ctx.beginPath();
ctx.rect(0, startY, this.screenWidth, this.screenHeight - startY - 65);
ctx.clip();
stories.forEach((story, index) => {
const y = startY + index * (cardHeight + cardMargin) - this.scrollY;
// 只渲染可见区域的卡片
if (y > startY - cardHeight && y < this.screenHeight - 65) {
this.renderStoryCard(ctx, story, cardMargin, y, this.screenWidth - cardMargin * 2, cardHeight);
}
});
ctx.restore();
}
renderStoryCard(ctx, story, x, y, width, height) {
// 卡片背景 - 毛玻璃效果
ctx.fillStyle = 'rgba(255,255,255,0.06)';
this.roundRect(ctx, x, y, width, height, 16);
ctx.fill();
// 卡片高光边框
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1;
this.roundRect(ctx, x, y, width, height, 16);
ctx.stroke();
// 封面区域 - 渐变色
const coverWidth = 85;
const coverHeight = height - 24;
const coverGradient = ctx.createLinearGradient(x + 12, y + 12, x + 12 + coverWidth, y + 12 + coverHeight);
const colors = this.getCategoryGradient(story.category);
coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient;
this.roundRect(ctx, x + 12, y + 12, coverWidth, coverHeight, 12);
ctx.fill();
// 封面上的分类名
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(story.category || '故事', x + 12 + coverWidth / 2, y + 12 + coverHeight / 2 + 4);
// 故事标题
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 17px sans-serif';
ctx.textAlign = 'left';
const titleX = x + 110;
const maxTextWidth = width - 120; // 可用文字宽度
const title = this.truncateText(ctx, story.title, maxTextWidth);
ctx.fillText(title, titleX, y + 35);
// 故事简介
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '12px sans-serif';
const desc = story.description || '';
const shortDesc = this.truncateText(ctx, desc, maxTextWidth);
ctx.fillText(shortDesc, titleX, y + 58);
// 统计信息带图标
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '11px sans-serif';
const playText = `${this.formatNumber(story.play_count)}`;
const likeText = `${this.formatNumber(story.like_count)}`;
ctx.fillText(playText, titleX, y + 90);
ctx.fillText(likeText, titleX + 70, y + 90);
// 精选标签 - 更醒目
if (story.is_featured) {
const tagGradient = ctx.createLinearGradient(x + width - 55, y + 12, x + width - 10, y + 12);
tagGradient.addColorStop(0, '#ff6b6b');
tagGradient.addColorStop(1, '#ffd700');
ctx.fillStyle = tagGradient;
this.roundRect(ctx, x + width - 55, y + 12, 45, 22, 11);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('精选', x + width - 32, y + 27);
}
}
getCategoryGradient(category) {
const gradients = {
'都市言情': ['#ff758c', '#ff7eb3'],
'悬疑推理': ['#667eea', '#764ba2'],
'古风宫廷': ['#f093fb', '#f5576c'],
'校园青春': ['#4facfe', '#00f2fe'],
'修仙玄幻': ['#43e97b', '#38f9d7'],
'穿越重生': ['#fa709a', '#fee140'],
'职场商战': ['#30cfd0', '#330867'],
'科幻未来': ['#a8edea', '#fed6e3'],
'恐怖惊悚': ['#434343', '#000000'],
'搞笑轻喜': ['#f6d365', '#fda085']
};
return gradients[category] || ['#667eea', '#764ba2'];
}
renderTabBar(ctx) {
const tabHeight = 65;
const y = this.screenHeight - tabHeight;
// Tab栏背景 - 毛玻璃
ctx.fillStyle = 'rgba(15, 12, 41, 0.95)';
ctx.fillRect(0, y, this.screenWidth, tabHeight);
// 顶部高光线
const lineGradient = ctx.createLinearGradient(0, y, this.screenWidth, y);
lineGradient.addColorStop(0, 'rgba(255,107,107,0.3)');
lineGradient.addColorStop(0.5, 'rgba(255,215,0,0.3)');
lineGradient.addColorStop(1, 'rgba(255,107,107,0.3)');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(this.screenWidth, y);
ctx.stroke();
const tabs = [
{ icon: '🏠', label: '首页' },
{ icon: '🔍', label: '发现' },
{ icon: '👤', label: '我的' }
];
const tabWidth = this.screenWidth / tabs.length;
tabs.forEach((tab, index) => {
const centerX = index * tabWidth + tabWidth / 2;
const isActive = index === this.currentTab;
// 选中态指示器
if (isActive) {
const indicatorGradient = ctx.createLinearGradient(centerX - 20, y + 3, centerX + 20, y + 3);
indicatorGradient.addColorStop(0, '#ff6b6b');
indicatorGradient.addColorStop(1, '#ffd700');
ctx.fillStyle = indicatorGradient;
this.roundRect(ctx, centerX - 20, y + 2, 40, 3, 1.5);
ctx.fill();
}
// 图标
ctx.font = '22px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(tab.icon, centerX, y + 32);
// 标签文字
ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.4)';
ctx.font = isActive ? 'bold 11px sans-serif' : '11px sans-serif';
ctx.fillText(tab.label, centerX, y + 52);
});
}
// 获取分类颜色
getCategoryColor(category) {
const colors = {
'都市言情': '#e94560',
'悬疑推理': '#4a90d9',
'古风宫廷': '#d4a574',
'校园青春': '#7ed957',
'修仙玄幻': '#9b59b6',
'穿越重生': '#f39c12',
'职场商战': '#3498db',
'科幻未来': '#1abc9c',
'恐怖惊悚': '#2c3e50',
'搞笑轻喜': '#f1c40f'
};
return colors[category] || '#666666';
}
// 格式化数字
formatNumber(num) {
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
}
// 截断文字以适应宽度
truncateText(ctx, text, maxWidth) {
if (!text) return '';
if (ctx.measureText(text).width <= maxWidth) {
return text;
}
let truncated = text;
while (truncated.length > 0 && ctx.measureText(truncated + '...').width > maxWidth) {
truncated = truncated.slice(0, -1);
}
return truncated + '...';
}
// 绘制圆角矩形
roundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
onTouchStart(e) {
const touch = e.touches[0];
this.lastTouchY = touch.clientY;
this.lastTouchX = touch.clientX;
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
this.hasMoved = false;
// 判断是否在分类区域y: 90-140
if (touch.clientY >= 90 && touch.clientY <= 140) {
this.isCategoryDragging = true;
this.isDragging = false;
} else {
this.isCategoryDragging = false;
this.isDragging = true;
}
this.scrollVelocity = 0;
}
onTouchMove(e) {
const touch = e.touches[0];
// 分类区域横向滑动
if (this.isCategoryDragging) {
const deltaX = this.lastTouchX - touch.clientX;
if (Math.abs(deltaX) > 2) {
this.hasMoved = true;
}
this.categoryScrollX += deltaX;
this.categoryScrollX = Math.max(0, Math.min(this.categoryScrollX, this.maxCategoryScrollX));
this.lastTouchX = touch.clientX;
return;
}
// 故事列表纵向滑动
if (this.isDragging) {
const deltaY = this.lastTouchY - touch.clientY;
if (Math.abs(touch.clientY - this.touchStartY) > 5) {
this.hasMoved = true;
}
this.scrollVelocity = deltaY;
this.scrollY += deltaY;
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
this.lastTouchY = touch.clientY;
}
}
onTouchEnd(e) {
this.isDragging = false;
this.isCategoryDragging = false;
// 如果有滑动,不处理点击
if (this.hasMoved) {
return;
}
// 检测点击
const touch = e.changedTouches[0];
const x = touch.clientX;
const y = touch.clientY;
// 检测Tab栏点击
if (y > this.screenHeight - 65) {
const tabWidth = this.screenWidth / 3;
const tabIndex = Math.floor(x / tabWidth);
this.handleTabClick(tabIndex);
return;
}
// 检测分类标签点击
if (y >= 90 && y <= 140) {
this.handleCategoryClick(x);
return;
}
// 检测故事卡片点击
this.handleStoryClick(x, y);
}
handleCategoryClick(x) {
// 考虑横向滚动偏移
const adjustedX = x + this.categoryScrollX;
// 使用保存的实际位置判断
for (const rect of this.categoryRects) {
if (adjustedX >= rect.left && adjustedX <= rect.right) {
if (this.selectedCategory !== rect.index) {
this.selectedCategory = rect.index;
this.scrollY = 0;
this.calculateMaxScroll();
console.log('选中分类:', this.categories[rect.index]);
}
return;
}
}
}
handleTabClick(tabIndex) {
if (tabIndex === this.currentTab) return;
this.currentTab = tabIndex;
if (tabIndex === 2) {
// 切换到个人中心
this.main.sceneManager.switchScene('profile');
}
}
handleStoryClick(x, y) {
const startY = 150;
const cardHeight = 120;
const cardMargin = 15;
const stories = this.getFilteredStories();
// 计算点击的是哪个故事
const adjustedY = y + this.scrollY;
const index = Math.floor((adjustedY - startY) / (cardHeight + cardMargin));
if (index >= 0 && index < stories.length) {
const story = stories[index];
// 跳转到故事播放场景
this.main.sceneManager.switchScene('story', { storyId: story.id });
}
}
}

View File

@@ -0,0 +1,371 @@
/**
* 个人中心场景
*/
import BaseScene from './BaseScene';
export default class ProfileScene extends BaseScene {
constructor(main, params) {
super(main, params);
this.collections = [];
this.progress = [];
this.currentTab = 0; // 0: 收藏, 1: 历史
this.scrollY = 0;
this.maxScrollY = 0;
this.isDragging = false;
this.lastTouchY = 0;
this.scrollVelocity = 0;
this.hasMoved = false;
}
async init() {
await this.loadData();
}
async loadData() {
if (this.main.userManager.isLoggedIn) {
this.collections = await this.main.userManager.getCollections() || [];
this.progress = await this.main.userManager.getProgress() || [];
}
this.calculateMaxScroll();
}
calculateMaxScroll() {
const list = this.currentTab === 0 ? this.collections : this.progress;
const cardHeight = 90;
const gap = 12;
const headerHeight = 300;
const contentHeight = list.length * (cardHeight + gap) + headerHeight;
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 20);
}
update() {
if (!this.isDragging && Math.abs(this.scrollVelocity) > 0.5) {
this.scrollY += this.scrollVelocity;
this.scrollVelocity *= 0.95;
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
}
}
render(ctx) {
// 背景渐变
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
gradient.addColorStop(0, '#0f0c29');
gradient.addColorStop(0.5, '#302b63');
gradient.addColorStop(1, '#24243e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
// 顶部返回
this.renderHeader(ctx);
// 用户信息卡片
this.renderUserCard(ctx);
// Tab切换
this.renderTabs(ctx);
// 列表内容
this.renderList(ctx);
}
renderHeader(ctx) {
// 顶部渐变遮罩
const headerGradient = ctx.createLinearGradient(0, 0, 0, 60);
headerGradient.addColorStop(0, 'rgba(0,0,0,0.5)');
headerGradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = headerGradient;
ctx.fillRect(0, 0, this.screenWidth, 60);
// 返回按钮
ctx.fillStyle = '#ffffff';
ctx.font = '16px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(' 返回', 15, 35);
// 标题
ctx.textAlign = 'center';
ctx.font = 'bold 17px sans-serif';
ctx.fillText('个人中心', this.screenWidth / 2, 35);
}
renderUserCard(ctx) {
const cardY = 60;
const cardHeight = 170;
const centerX = this.screenWidth / 2;
const user = this.main.userManager;
// 卡片背景
ctx.fillStyle = 'rgba(255,255,255,0.06)';
this.roundRect(ctx, 15, cardY, this.screenWidth - 30, cardHeight, 16);
ctx.fill();
// 头像
const avatarSize = 55;
const avatarY = cardY + 20;
const avatarGradient = ctx.createLinearGradient(centerX - 30, avatarY, centerX + 30, avatarY + avatarSize);
avatarGradient.addColorStop(0, '#ff6b6b');
avatarGradient.addColorStop(1, '#ffd700');
ctx.fillStyle = avatarGradient;
ctx.beginPath();
ctx.arc(centerX, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
ctx.fill();
// 头像文字
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 22px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(user.nickname ? user.nickname[0] : '游', centerX, avatarY + avatarSize / 2 + 8);
// 昵称
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 15px sans-serif';
ctx.fillText(user.nickname || '游客用户', centerX, avatarY + avatarSize + 20);
// 分割线
const lineY = avatarY + avatarSize + 35;
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(30, lineY);
ctx.lineTo(this.screenWidth - 30, lineY);
ctx.stroke();
// 统计信息
const statsY = lineY + 30;
const statWidth = (this.screenWidth - 30) / 3;
const statsData = [
{ num: this.progress.length, label: '游玩' },
{ num: this.collections.length, label: '收藏' },
{ num: this.progress.filter(p => p.is_completed).length, label: '结局' }
];
statsData.forEach((stat, i) => {
const x = 15 + statWidth * i + statWidth / 2;
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px sans-serif';
ctx.fillText(stat.num.toString(), x, statsY);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px sans-serif';
ctx.fillText(stat.label, x, statsY + 16);
});
}
renderTabs(ctx) {
const tabY = 245;
const tabWidth = (this.screenWidth - 30) / 2;
const tabs = ['我的收藏', '游玩记录'];
tabs.forEach((tab, index) => {
const x = 15 + index * tabWidth;
const isActive = index === this.currentTab;
const centerX = x + tabWidth / 2;
// Tab背景
if (isActive) {
const tabGradient = ctx.createLinearGradient(x, tabY, x + tabWidth, tabY);
tabGradient.addColorStop(0, '#ff6b6b');
tabGradient.addColorStop(1, '#ffd700');
ctx.fillStyle = tabGradient;
this.roundRect(ctx, x + 10, tabY, tabWidth - 20, 32, 16);
ctx.fill();
}
// Tab文字
ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.5)';
ctx.font = isActive ? 'bold 14px sans-serif' : '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(tab, centerX, tabY + 21);
});
}
renderList(ctx) {
const list = this.currentTab === 0 ? this.collections : this.progress;
const startY = 295;
const cardHeight = 90;
const cardMargin = 12;
const padding = 15;
// 裁剪区域
ctx.save();
ctx.beginPath();
ctx.rect(0, startY - 10, this.screenWidth, this.screenHeight - startY + 10);
ctx.clip();
if (list.length === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(
this.currentTab === 0 ? '还没有收藏的故事' : '还没有游玩记录',
this.screenWidth / 2,
startY + 50
);
ctx.restore();
return;
}
list.forEach((item, index) => {
const y = startY + index * (cardHeight + cardMargin) - this.scrollY;
if (y > -cardHeight && y < this.screenHeight) {
this.renderListCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardHeight, index);
}
});
ctx.restore();
}
renderListCard(ctx, item, x, y, width, height, index) {
// 卡片背景
ctx.fillStyle = 'rgba(255,255,255,0.06)';
this.roundRect(ctx, x, y, width, height, 12);
ctx.fill();
// 封面
const coverSize = 65;
const coverColors = [
['#ff758c', '#ff7eb3'],
['#667eea', '#764ba2'],
['#4facfe', '#00f2fe'],
['#43e97b', '#38f9d7'],
['#fa709a', '#fee140']
];
const colors = coverColors[index % coverColors.length];
const coverGradient = ctx.createLinearGradient(x + 12, y + 12, x + 12 + coverSize, y + 12 + coverSize);
coverGradient.addColorStop(0, colors[0]);
coverGradient.addColorStop(1, colors[1]);
ctx.fillStyle = coverGradient;
this.roundRect(ctx, x + 12, y + 12, coverSize, coverSize, 10);
ctx.fill();
// 封面文字
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(item.category || '故事', x + 12 + coverSize / 2, y + 12 + coverSize / 2 + 4);
// 标题
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 15px sans-serif';
ctx.textAlign = 'left';
const title = item.story_title || item.title || '未知故事';
ctx.fillText(title.length > 12 ? title.substring(0, 12) + '...' : title, x + 90, y + 35);
// 状态
ctx.font = '12px sans-serif';
if (this.currentTab === 1 && item.is_completed) {
ctx.fillStyle = '#4ecca3';
ctx.fillText('✓ 已完成', x + 90, y + 58);
} else if (this.currentTab === 1) {
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText('进行中...', x + 90, y + 58);
} else {
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText(item.category || '', x + 90, y + 58);
}
// 继续按钮
const btnGradient = ctx.createLinearGradient(x + width - 65, y + 30, x + width - 10, y + 30);
btnGradient.addColorStop(0, '#ff6b6b');
btnGradient.addColorStop(1, '#ffd700');
ctx.fillStyle = btnGradient;
this.roundRect(ctx, x + width - 65, y + 30, 52, 28, 14);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('继续', x + width - 39, y + 49);
}
// 圆角矩形
roundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
onTouchStart(e) {
const touch = e.touches[0];
this.lastTouchY = touch.clientY;
this.touchStartY = touch.clientY;
this.isDragging = true;
this.scrollVelocity = 0;
this.hasMoved = false;
}
onTouchMove(e) {
if (!this.isDragging) return;
const touch = e.touches[0];
const deltaY = this.lastTouchY - touch.clientY;
if (Math.abs(touch.clientY - this.touchStartY) > 5) {
this.hasMoved = true;
}
this.scrollVelocity = deltaY;
this.scrollY += deltaY;
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
this.lastTouchY = touch.clientY;
}
onTouchEnd(e) {
this.isDragging = false;
if (this.hasMoved) return;
const touch = e.changedTouches[0];
const x = touch.clientX;
const y = touch.clientY;
// 返回按钮
if (y < 50 && x < 80) {
this.main.sceneManager.switchScene('home');
return;
}
// Tab切换
if (y >= 240 && y <= 285) {
const tabWidth = (this.screenWidth - 30) / 2;
const newTab = x < 15 + tabWidth ? 0 : 1;
if (newTab !== this.currentTab) {
this.currentTab = newTab;
this.scrollY = 0;
this.calculateMaxScroll();
}
return;
}
// 卡片点击
this.handleCardClick(x, y);
}
handleCardClick(x, y) {
const list = this.currentTab === 0 ? this.collections : this.progress;
const startY = 295;
const cardHeight = 90;
const cardMargin = 12;
const padding = 15;
const adjustedY = y + this.scrollY;
const index = Math.floor((adjustedY - startY) / (cardHeight + cardMargin));
if (index >= 0 && index < list.length) {
const item = list[index];
const storyId = item.story_id || item.id;
const cardY = startY + index * (cardHeight + cardMargin) - this.scrollY;
const buttonX = padding + (this.screenWidth - padding * 2) - 65;
if (x >= buttonX && x <= buttonX + 52 && y >= cardY + 30 && y <= cardY + 58) {
this.main.sceneManager.switchScene('story', { storyId });
}
}
}
}

View File

@@ -0,0 +1,56 @@
/**
* 场景管理器
*/
import HomeScene from './HomeScene';
import StoryScene from './StoryScene';
import EndingScene from './EndingScene';
import ProfileScene from './ProfileScene';
import ChapterScene from './ChapterScene';
export default class SceneManager {
constructor(main) {
this.main = main;
this.currentScene = null;
this.scenes = {
home: HomeScene,
story: StoryScene,
ending: EndingScene,
profile: ProfileScene,
chapter: ChapterScene
};
}
/**
* 切换场景
*/
switchScene(sceneName, params = {}) {
const SceneClass = this.scenes[sceneName];
if (!SceneClass) {
console.error('场景不存在:', sceneName);
return;
}
// 销毁当前场景
if (this.currentScene && this.currentScene.destroy) {
this.currentScene.destroy();
}
// 创建新场景
this.currentScene = new SceneClass(this.main, params);
// 初始化场景
if (this.currentScene.init) {
this.currentScene.init();
}
console.log('切换到场景:', sceneName);
}
/**
* 获取当前场景名称
*/
getCurrentSceneName() {
if (!this.currentScene) return null;
return this.currentScene.constructor.name;
}
}

View File

@@ -0,0 +1,636 @@
/**
* 故事播放场景 - 视觉小说风格
*/
import BaseScene from './BaseScene';
export default class StoryScene extends BaseScene {
constructor(main, params) {
super(main, params);
this.storyId = params.storyId;
this.aiContent = params.aiContent || null; // AI改写内容
this.story = null;
this.currentNode = null;
this.displayText = '';
this.targetText = '';
this.charIndex = 0;
this.typewriterSpeed = 40;
this.lastTypeTime = 0;
this.isTyping = false;
this.showChoices = false;
this.waitingForClick = false;
this.selectedChoice = -1;
this.fadeAlpha = 0;
this.isFading = false;
// 滚动相关
this.textScrollY = 0;
this.maxScrollY = 0;
this.isDragging = false;
this.lastTouchY = 0;
// 场景图相关
this.sceneImage = null;
this.sceneColors = this.generateSceneColors();
}
// 根据场景生成氛围色
generateSceneColors() {
const themes = [
{ bg1: '#1a0a2e', bg2: '#16213e', accent: '#ff6b9d' }, // 浪漫
{ bg1: '#0d1b2a', bg2: '#1b263b', accent: '#778da9' }, // 悬疑
{ bg1: '#2d132c', bg2: '#801336', accent: '#ffd700' }, // 古风
{ bg1: '#1a1a2e', bg2: '#0f3460', accent: '#00fff5' }, // 科幻
{ bg1: '#0b1215', bg2: '#1e3a3a', accent: '#4ecca3' }, // 校园
];
return themes[Math.floor(Math.random() * themes.length)];
}
async init() {
// 如果是AI改写内容直接播放
if (this.aiContent) {
this.story = this.main.storyManager.currentStory;
if (this.story) {
this.setThemeByCategory(this.story.category);
}
this.currentNode = this.aiContent;
this.startTypewriter(this.aiContent.content);
return;
}
// 检查是否是重新开始(已有故事数据)
const existingStory = this.main.storyManager.currentStory;
if (existingStory && existingStory.id === this.storyId) {
// 重新开始,使用已有数据
this.story = existingStory;
this.setThemeByCategory(this.story.category);
this.currentNode = this.main.storyManager.getCurrentNode();
if (this.currentNode) {
this.startTypewriter(this.currentNode.content);
}
return;
}
// 首次加载故事
this.main.showLoading('加载故事中...');
this.story = await this.main.storyManager.loadStoryDetail(this.storyId);
if (!this.story) {
this.main.showError('故事加载失败');
this.main.sceneManager.switchScene('home');
return;
}
// 根据故事分类设置氛围
this.setThemeByCategory(this.story.category);
this.main.hideLoading();
this.currentNode = this.main.storyManager.getCurrentNode();
if (this.currentNode) {
this.startTypewriter(this.currentNode.content);
}
}
setThemeByCategory(category) {
const themes = {
'都市言情': { bg1: '#1a0a2e', bg2: '#2d1b4e', accent: '#ff6b9d' },
'悬疑推理': { bg1: '#0d1b2a', bg2: '#1b263b', accent: '#778da9' },
'古风宫廷': { bg1: '#2d132c', bg2: '#4a1942', accent: '#ffd700' },
'校园青春': { bg1: '#0a2540', bg2: '#1e5162', accent: '#4ecca3' },
'修仙玄幻': { bg1: '#0f0f2d', bg2: '#1a1a4e', accent: '#a855f7' },
'穿越重生': { bg1: '#1a0a2e', bg2: '#3d1a5c', accent: '#f472b6' },
'职场商战': { bg1: '#0c1929', bg2: '#1e3a5f', accent: '#60a5fa' },
'科幻未来': { bg1: '#0a1628', bg2: '#162033', accent: '#00fff5' },
'恐怖惊悚': { bg1: '#0a0a0a', bg2: '#1a1a1a', accent: '#ef4444' },
'搞笑轻喜': { bg1: '#1a1a2e', bg2: '#2d2d52', accent: '#fbbf24' }
};
this.sceneColors = themes[category] || this.sceneColors;
}
startTypewriter(text) {
this.targetText = text || '';
this.displayText = '';
this.charIndex = 0;
this.isTyping = true;
this.showChoices = false;
this.waitingForClick = false;
this.lastTypeTime = Date.now();
// 重置滚动
this.textScrollY = 0;
this.maxScrollY = 0;
}
update() {
if (this.isTyping && this.charIndex < this.targetText.length) {
const now = Date.now();
if (now - this.lastTypeTime >= this.typewriterSpeed) {
this.displayText += this.targetText[this.charIndex];
this.charIndex++;
this.lastTypeTime = now;
}
} else if (this.isTyping && this.charIndex >= this.targetText.length) {
this.isTyping = false;
// 打字完成,等待用户点击再显示选项
this.waitingForClick = true;
}
if (this.isFading) {
this.fadeAlpha = Math.min(1, this.fadeAlpha + 0.05);
if (this.fadeAlpha >= 1) {
this.isFading = false;
this.fadeAlpha = 0;
}
}
}
render(ctx) {
// 1. 绘制场景背景
this.renderSceneBackground(ctx);
// 2. 绘制场景装饰
this.renderSceneDecoration(ctx);
// 3. 绘制顶部UI
this.renderHeader(ctx);
// 4. 绘制对话框
this.renderDialogBox(ctx);
// 5. 绘制选项
if (this.showChoices) {
this.renderChoices(ctx);
}
// 6. 淡入淡出
if (this.fadeAlpha > 0) {
ctx.fillStyle = `rgba(0, 0, 0, ${this.fadeAlpha})`;
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
}
}
renderSceneBackground(ctx) {
// 场景区域上方45%
const sceneHeight = this.screenHeight * 0.42;
// 渐变背景
const gradient = ctx.createLinearGradient(0, 0, 0, sceneHeight);
gradient.addColorStop(0, this.sceneColors.bg1);
gradient.addColorStop(1, this.sceneColors.bg2);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
// 底部渐变过渡到对话框
const fadeGradient = ctx.createLinearGradient(0, sceneHeight - 60, 0, sceneHeight);
fadeGradient.addColorStop(0, 'rgba(0,0,0,0)');
fadeGradient.addColorStop(1, 'rgba(15,15,30,1)');
ctx.fillStyle = fadeGradient;
ctx.fillRect(0, sceneHeight - 60, this.screenWidth, 60);
// 对话框区域背景
ctx.fillStyle = '#0f0f1e';
ctx.fillRect(0, sceneHeight, this.screenWidth, this.screenHeight - sceneHeight);
}
renderSceneDecoration(ctx) {
const sceneHeight = this.screenHeight * 0.42;
const centerX = this.screenWidth / 2;
// 场景氛围光效
const glowGradient = ctx.createRadialGradient(centerX, sceneHeight * 0.5, 0, centerX, sceneHeight * 0.5, 200);
glowGradient.addColorStop(0, this.sceneColors.accent + '30');
glowGradient.addColorStop(1, 'transparent');
ctx.fillStyle = glowGradient;
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
// 装饰粒子
ctx.fillStyle = this.sceneColors.accent + '40';
const particles = [[50, 100], [120, 180], [200, 80], [280, 150], [320, 60], [80, 250], [250, 220]];
particles.forEach(([x, y]) => {
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
});
// 场景提示文字(中央)
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
const sceneHint = this.getSceneHint();
ctx.fillText(sceneHint, centerX, sceneHeight * 0.45);
}
getSceneHint() {
if (!this.currentNode) return '故事开始...';
const speaker = this.currentNode.speaker;
if (speaker && speaker !== '旁白') {
return `${speaker}`;
}
return '— 旁白 —';
}
renderHeader(ctx) {
// 顶部渐变遮罩
const headerGradient = ctx.createLinearGradient(0, 0, 0, 80);
headerGradient.addColorStop(0, 'rgba(0,0,0,0.7)');
headerGradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = headerGradient;
ctx.fillRect(0, 0, this.screenWidth, 80);
// 返回按钮
ctx.fillStyle = '#ffffff';
ctx.font = '16px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(' 返回', 15, 35);
// 故事标题
if (this.story) {
ctx.textAlign = 'center';
ctx.font = 'bold 15px sans-serif';
ctx.fillStyle = this.sceneColors.accent;
const title = this.story.title.length > 10 ? this.story.title.substring(0, 10) + '...' : this.story.title;
ctx.fillText(title, this.screenWidth / 2, 35);
}
// 进度指示
ctx.textAlign = 'right';
ctx.font = '12px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.5)';
const progress = this.main.storyManager.getProgress ? this.main.storyManager.getProgress() : '';
ctx.fillText(progress, this.screenWidth - 15, 35);
}
renderDialogBox(ctx) {
const boxY = this.screenHeight * 0.42;
const boxHeight = this.screenHeight * 0.58;
const padding = 20;
// 对话框背景
ctx.fillStyle = 'rgba(20, 20, 40, 0.95)';
ctx.fillRect(0, boxY, this.screenWidth, boxHeight);
// 顶部装饰线
const lineGradient = ctx.createLinearGradient(0, boxY, this.screenWidth, boxY);
lineGradient.addColorStop(0, 'transparent');
lineGradient.addColorStop(0.5, this.sceneColors.accent);
lineGradient.addColorStop(1, 'transparent');
ctx.strokeStyle = lineGradient;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, boxY);
ctx.lineTo(this.screenWidth, boxY);
ctx.stroke();
// 如果显示选项,不显示对话内容
if (this.showChoices) return;
// 角色名
if (this.currentNode && this.currentNode.speaker && this.currentNode.speaker !== '旁白') {
// 角色名背景
ctx.font = 'bold 14px sans-serif';
const nameWidth = ctx.measureText(this.currentNode.speaker).width + 30;
ctx.fillStyle = this.sceneColors.accent;
this.roundRect(ctx, padding, boxY + 15, nameWidth, 28, 14);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'left';
ctx.fillText(this.currentNode.speaker, padding + 15, boxY + 34);
}
// 对话内容
const textY = boxY + 65;
const lineHeight = 26;
const maxWidth = this.screenWidth - padding * 2;
const visibleHeight = boxHeight - 105; // 可见区域高度
ctx.font = '15px sans-serif';
const allLines = this.getWrappedLines(ctx, this.displayText, maxWidth);
const totalTextHeight = allLines.length * lineHeight;
// 计算最大滚动距离
this.maxScrollY = Math.max(0, totalTextHeight - visibleHeight);
// 自动滚动到最新内容(打字时)
if (this.isTyping) {
this.textScrollY = this.maxScrollY;
}
// 设置裁剪区域(从文字顶部开始)
ctx.save();
ctx.beginPath();
ctx.rect(0, textY - 18, this.screenWidth, visibleHeight + 18);
ctx.clip();
// 绘制文字(带滚动偏移)
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'left';
allLines.forEach((line, i) => {
const y = textY + i * lineHeight - this.textScrollY;
ctx.fillText(line, padding, y);
});
ctx.restore();
// 滚动指示器(如果可以滚动)
if (this.maxScrollY > 0) {
const scrollBarHeight = 40;
const scrollBarY = boxY + 55 + (this.textScrollY / this.maxScrollY) * (visibleHeight - scrollBarHeight);
ctx.fillStyle = 'rgba(255,255,255,0.2)';
this.roundRect(ctx, this.screenWidth - 6, scrollBarY, 3, scrollBarHeight, 1.5);
ctx.fill();
}
// 打字机光标
if (this.isTyping) {
const cursorBlink = Math.floor(Date.now() / 500) % 2 === 0;
if (cursorBlink) {
ctx.fillStyle = this.sceneColors.accent;
ctx.font = '15px sans-serif';
ctx.fillText('▌', padding + this.getTextEndX(ctx, this.displayText, maxWidth), textY + this.getTextEndY(this.displayText, maxWidth, lineHeight));
}
}
// 继续提示
if (!this.isTyping && !this.main.storyManager.isEnding()) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('点击继续 ▼', this.screenWidth / 2, this.screenHeight - 25);
}
}
getTextEndX(ctx, text, maxWidth) {
const lines = this.getWrappedLines(ctx, text, maxWidth);
if (lines.length === 0) return 0;
return ctx.measureText(lines[lines.length - 1]).width;
}
getTextEndY(text, maxWidth, lineHeight) {
const lines = text.split('\n');
return (lines.length - 1) * lineHeight;
}
getWrappedLines(ctx, text, maxWidth) {
const lines = [];
const paragraphs = text.split('\n');
paragraphs.forEach(para => {
let line = '';
for (let char of para) {
const testLine = line + char;
if (ctx.measureText(testLine).width > maxWidth) {
lines.push(line);
line = char;
} else {
line = testLine;
}
}
lines.push(line);
});
return lines;
}
renderChoices(ctx) {
if (!this.currentNode || !this.currentNode.choices) return;
const choices = this.currentNode.choices;
const choiceHeight = 50;
const choiceMargin = 10;
const padding = 20;
const startY = this.screenHeight * 0.42 + 30;
// 半透明遮罩
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, this.screenHeight * 0.42, this.screenWidth, this.screenHeight * 0.58);
// 提示文字
ctx.fillStyle = '#ffffff';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('请做出选择', this.screenWidth / 2, startY);
choices.forEach((choice, index) => {
const y = startY + 25 + index * (choiceHeight + choiceMargin);
const isSelected = index === this.selectedChoice;
// 选项背景
if (isSelected) {
const gradient = ctx.createLinearGradient(padding, y, this.screenWidth - padding, y);
gradient.addColorStop(0, this.sceneColors.accent);
gradient.addColorStop(1, this.sceneColors.accent + 'aa');
ctx.fillStyle = gradient;
} else {
ctx.fillStyle = 'rgba(255,255,255,0.1)';
}
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, choiceHeight, 25);
ctx.fill();
// 选项边框
ctx.strokeStyle = isSelected ? this.sceneColors.accent : 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1.5;
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, choiceHeight, 25);
ctx.stroke();
// 选项文本
ctx.fillStyle = '#ffffff';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(choice.text, this.screenWidth / 2, y + 30);
// 锁定图标
if (choice.isLocked) {
ctx.fillStyle = '#ffd700';
ctx.font = '12px sans-serif';
ctx.textAlign = 'right';
ctx.fillText('🔒 看广告解锁', this.screenWidth - padding - 15, y + 30);
}
});
}
// 文字换行
wrapText(ctx, text, x, y, maxWidth, lineHeight) {
if (!text) return;
const lines = this.getWrappedLines(ctx, text, maxWidth);
lines.forEach((line, i) => {
ctx.fillText(line, x, y + i * lineHeight);
});
}
// 文字换行(限制行数)
wrapTextWithLimit(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
if (!text) return;
let lines = this.getWrappedLines(ctx, text, maxWidth);
// 如果超出最大行数,只显示最后几行(滚动效果)
if (lines.length > maxLines) {
lines = lines.slice(lines.length - maxLines);
}
lines.forEach((line, i) => {
ctx.fillText(line, x, y + i * lineHeight);
});
}
// 把行数组分成多页
splitIntoPages(lines, linesPerPage) {
const pages = [];
for (let i = 0; i < lines.length; i += linesPerPage) {
pages.push(lines.slice(i, i + linesPerPage));
}
return pages.length > 0 ? pages : [[]];
}
onTouchStart(e) {
const touch = e.touches[0];
this.touchStartY = touch.clientY;
this.touchStartX = touch.clientX;
this.lastTouchY = touch.clientY;
this.hasMoved = false;
// 判断是否在对话框区域
const boxY = this.screenHeight * 0.42;
if (touch.clientY > boxY) {
this.isDragging = true;
}
}
onTouchMove(e) {
const touch = e.touches[0];
// 滑动对话框内容
if (this.isDragging && this.maxScrollY > 0) {
const deltaY = this.lastTouchY - touch.clientY;
if (Math.abs(deltaY) > 2) {
this.hasMoved = true;
}
this.textScrollY += deltaY;
this.textScrollY = Math.max(0, Math.min(this.textScrollY, this.maxScrollY));
this.lastTouchY = touch.clientY;
}
}
onTouchEnd(e) {
this.isDragging = false;
const touch = e.changedTouches[0];
const x = touch.clientX;
const y = touch.clientY;
// 如果滑动过,不处理点击
if (this.hasMoved) {
return;
}
// 返回按钮
if (y < 60 && x < 80) {
this.main.sceneManager.switchScene('home');
return;
}
// 加速打字
if (this.isTyping) {
this.displayText = this.targetText;
this.charIndex = this.targetText.length;
this.isTyping = false;
this.waitingForClick = true;
return;
}
// 等待点击后显示选项或结局
if (this.waitingForClick) {
this.waitingForClick = false;
// 检查是否是结局
if (this.main.storyManager.isEnding()) {
this.main.sceneManager.switchScene('ending', {
storyId: this.storyId,
ending: this.main.storyManager.getEndingInfo()
});
return;
}
// 显示选项
if (this.currentNode && this.currentNode.choices && this.currentNode.choices.length > 0) {
this.showChoices = true;
}
return;
}
// 选项点击
if (this.showChoices && this.currentNode && this.currentNode.choices) {
const choices = this.currentNode.choices;
const choiceHeight = 50;
const choiceMargin = 10;
const padding = 20;
const startY = this.screenHeight * 0.42 + 55;
for (let i = 0; i < choices.length; i++) {
const choiceY = startY + i * (choiceHeight + choiceMargin);
if (y >= choiceY && y <= choiceY + choiceHeight && x >= padding && x <= this.screenWidth - padding) {
this.handleChoiceSelect(i);
return;
}
}
}
}
handleChoiceSelect(index) {
const choice = this.currentNode.choices[index];
if (choice.isLocked) {
wx.showModal({
title: '解锁剧情',
content: '观看广告解锁隐藏剧情?',
success: (res) => {
if (res.confirm) {
this.unlockAndSelect(index);
}
}
});
return;
}
this.selectChoice(index);
}
unlockAndSelect(index) {
this.selectChoice(index);
}
selectChoice(index) {
this.isFading = true;
this.fadeAlpha = 0;
this.showChoices = false;
setTimeout(() => {
this.currentNode = this.main.storyManager.selectChoice(index);
if (this.currentNode) {
this.startTypewriter(this.currentNode.content);
}
}, 300);
}
// 圆角矩形
roundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
destroy() {
if (this.main.userManager.isLoggedIn && this.story) {
this.main.userManager.saveProgress(
this.storyId,
this.main.storyManager.currentNodeKey,
this.main.storyManager.isEnding(),
this.main.storyManager.isEnding() ? this.main.storyManager.getEndingInfo().name : ''
);
}
}
}

55
client/js/utils/http.js Normal file
View File

@@ -0,0 +1,55 @@
/**
* 网络请求工具
*/
// API基础地址开发环境
const BASE_URL = 'http://localhost:3000/api';
/**
* 发送HTTP请求
*/
export function request(options) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('请求超时'));
}, 5000);
wx.request({
url: BASE_URL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
...options.header
},
success(res) {
clearTimeout(timeout);
if (res.data.code === 0) {
resolve(res.data.data);
} else {
reject(new Error(res.data.message || '请求失败'));
}
},
fail(err) {
clearTimeout(timeout);
reject(err);
}
});
});
}
/**
* GET请求
*/
export function get(url, data) {
return request({ url, method: 'GET', data });
}
/**
* POST请求
*/
export function post(url, data) {
return request({ url, method: 'POST', data });
}
export default { request, get, post };