feat: 星域故事汇小游戏初始版本
This commit is contained in:
132
client/js/data/AudioManager.js
Normal file
132
client/js/data/AudioManager.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 音频管理器
|
||||
*/
|
||||
export default class AudioManager {
|
||||
constructor() {
|
||||
this.bgm = null;
|
||||
this.sfx = {};
|
||||
this.isMuted = false;
|
||||
this.bgmVolume = 0.5;
|
||||
this.sfxVolume = 0.8;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放背景音乐
|
||||
*/
|
||||
playBGM(src) {
|
||||
if (this.isMuted || !src) return;
|
||||
|
||||
// 停止当前BGM
|
||||
this.stopBGM();
|
||||
|
||||
// 创建新的音频实例
|
||||
this.bgm = wx.createInnerAudioContext();
|
||||
this.bgm.src = src;
|
||||
this.bgm.loop = true;
|
||||
this.bgm.volume = this.bgmVolume;
|
||||
this.bgm.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止背景音乐
|
||||
*/
|
||||
stopBGM() {
|
||||
if (this.bgm) {
|
||||
this.bgm.stop();
|
||||
this.bgm.destroy();
|
||||
this.bgm = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停背景音乐
|
||||
*/
|
||||
pauseBGM() {
|
||||
if (this.bgm) {
|
||||
this.bgm.pause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复背景音乐
|
||||
*/
|
||||
resumeBGM() {
|
||||
if (this.bgm && !this.isMuted) {
|
||||
this.bgm.play();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放音效
|
||||
*/
|
||||
playSFX(name, src) {
|
||||
if (this.isMuted || !src) return;
|
||||
|
||||
// 复用或创建音效实例
|
||||
if (!this.sfx[name]) {
|
||||
this.sfx[name] = wx.createInnerAudioContext();
|
||||
this.sfx[name].src = src;
|
||||
}
|
||||
|
||||
this.sfx[name].volume = this.sfxVolume;
|
||||
this.sfx[name].seek(0);
|
||||
this.sfx[name].play();
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放点击音效
|
||||
*/
|
||||
playClick() {
|
||||
// 可以配置点击音效
|
||||
// this.playSFX('click', 'audio/click.mp3');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置静音
|
||||
*/
|
||||
setMute(muted) {
|
||||
this.isMuted = muted;
|
||||
if (muted) {
|
||||
this.pauseBGM();
|
||||
} else {
|
||||
this.resumeBGM();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换静音状态
|
||||
*/
|
||||
toggleMute() {
|
||||
this.setMute(!this.isMuted);
|
||||
return this.isMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置BGM音量
|
||||
*/
|
||||
setBGMVolume(volume) {
|
||||
this.bgmVolume = Math.max(0, Math.min(1, volume));
|
||||
if (this.bgm) {
|
||||
this.bgm.volume = this.bgmVolume;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音效音量
|
||||
*/
|
||||
setSFXVolume(volume) {
|
||||
this.sfxVolume = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁所有音频
|
||||
*/
|
||||
destroy() {
|
||||
this.stopBGM();
|
||||
Object.values(this.sfx).forEach(audio => {
|
||||
audio.stop();
|
||||
audio.destroy();
|
||||
});
|
||||
this.sfx = {};
|
||||
}
|
||||
}
|
||||
147
client/js/data/StoryManager.js
Normal file
147
client/js/data/StoryManager.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 故事数据管理器
|
||||
*/
|
||||
import { get, post } from '../utils/http';
|
||||
|
||||
export default class StoryManager {
|
||||
constructor() {
|
||||
this.storyList = [];
|
||||
this.currentStory = null;
|
||||
this.currentNodeKey = 'start';
|
||||
this.categories = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载故事列表
|
||||
*/
|
||||
async loadStoryList(options = {}) {
|
||||
try {
|
||||
this.storyList = await get('/stories', options);
|
||||
return this.storyList;
|
||||
} catch (error) {
|
||||
console.error('加载故事列表失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载热门故事
|
||||
*/
|
||||
async loadHotStories(limit = 10) {
|
||||
try {
|
||||
return await get('/stories/hot', { limit });
|
||||
} catch (error) {
|
||||
console.error('加载热门故事失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载分类列表
|
||||
*/
|
||||
async loadCategories() {
|
||||
try {
|
||||
this.categories = await get('/stories/categories');
|
||||
return this.categories;
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载故事详情
|
||||
*/
|
||||
async loadStoryDetail(storyId) {
|
||||
try {
|
||||
this.currentStory = await get(`/stories/${storyId}`);
|
||||
this.currentNodeKey = 'start';
|
||||
|
||||
// 记录游玩次数
|
||||
await post(`/stories/${storyId}/play`);
|
||||
|
||||
return this.currentStory;
|
||||
} catch (error) {
|
||||
console.error('加载故事详情失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前节点
|
||||
*/
|
||||
getCurrentNode() {
|
||||
if (!this.currentStory || !this.currentStory.nodes) return null;
|
||||
return this.currentStory.nodes[this.currentNodeKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择选项,前进到下一个节点
|
||||
*/
|
||||
selectChoice(choiceIndex) {
|
||||
const currentNode = this.getCurrentNode();
|
||||
if (!currentNode || !currentNode.choices || !currentNode.choices[choiceIndex]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const choice = currentNode.choices[choiceIndex];
|
||||
this.currentNodeKey = choice.nextNodeKey;
|
||||
|
||||
return this.getCurrentNode();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前节点是否为结局
|
||||
*/
|
||||
isEnding() {
|
||||
const currentNode = this.getCurrentNode();
|
||||
return currentNode && currentNode.is_ending;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取结局信息
|
||||
*/
|
||||
getEndingInfo() {
|
||||
const currentNode = this.getCurrentNode();
|
||||
if (!currentNode || !currentNode.is_ending) return null;
|
||||
|
||||
return {
|
||||
name: currentNode.ending_name,
|
||||
score: currentNode.ending_score,
|
||||
type: currentNode.ending_type,
|
||||
content: currentNode.content
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置故事进度
|
||||
*/
|
||||
resetStory() {
|
||||
this.currentNodeKey = 'start';
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞故事
|
||||
*/
|
||||
async likeStory(like = true) {
|
||||
if (!this.currentStory) return;
|
||||
await post(`/stories/${this.currentStory.id}/like`, { like });
|
||||
}
|
||||
|
||||
/**
|
||||
* AI改写结局
|
||||
*/
|
||||
async rewriteEnding(storyId, ending, prompt) {
|
||||
try {
|
||||
const result = await post(`/stories/${storyId}/rewrite`, {
|
||||
ending_name: ending?.name,
|
||||
ending_content: ending?.content,
|
||||
prompt: prompt
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('AI改写失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
134
client/js/data/UserManager.js
Normal file
134
client/js/data/UserManager.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 用户数据管理器
|
||||
*/
|
||||
import { get, post } from '../utils/http';
|
||||
|
||||
export default class UserManager {
|
||||
constructor() {
|
||||
this.userId = null;
|
||||
this.openid = null;
|
||||
this.nickname = '';
|
||||
this.avatarUrl = '';
|
||||
this.isLoggedIn = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化用户
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
// 尝试从本地存储恢复用户信息
|
||||
const cached = wx.getStorageSync('userInfo');
|
||||
if (cached) {
|
||||
this.userId = cached.userId;
|
||||
this.openid = cached.openid;
|
||||
this.nickname = cached.nickname;
|
||||
this.avatarUrl = cached.avatarUrl;
|
||||
this.isLoggedIn = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取登录code
|
||||
const { code } = await this.wxLogin();
|
||||
|
||||
// 调用后端登录接口
|
||||
const result = await post('/user/login', { code });
|
||||
|
||||
this.userId = result.userId;
|
||||
this.openid = result.openid;
|
||||
this.nickname = result.nickname || '游客';
|
||||
this.avatarUrl = result.avatarUrl || '';
|
||||
this.isLoggedIn = true;
|
||||
|
||||
// 缓存用户信息
|
||||
wx.setStorageSync('userInfo', {
|
||||
userId: this.userId,
|
||||
openid: this.openid,
|
||||
nickname: this.nickname,
|
||||
avatarUrl: this.avatarUrl
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('用户初始化失败:', error);
|
||||
// 使用临时身份
|
||||
this.userId = 0;
|
||||
this.nickname = '游客';
|
||||
this.isLoggedIn = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信登录(带超时)
|
||||
*/
|
||||
wxLogin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('登录超时'));
|
||||
}, 3000);
|
||||
|
||||
wx.login({
|
||||
success: (res) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(res);
|
||||
},
|
||||
fail: (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户游玩进度
|
||||
*/
|
||||
async getProgress(storyId = null) {
|
||||
if (!this.isLoggedIn) return null;
|
||||
return await get('/user/progress', { userId: this.userId, storyId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户进度
|
||||
*/
|
||||
async saveProgress(storyId, currentNodeKey, isCompleted = false, endingReached = '') {
|
||||
if (!this.isLoggedIn) return;
|
||||
await post('/user/progress', {
|
||||
userId: this.userId,
|
||||
storyId,
|
||||
currentNodeKey,
|
||||
isCompleted,
|
||||
endingReached
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞故事
|
||||
*/
|
||||
async likeStory(storyId, isLiked) {
|
||||
if (!this.isLoggedIn) return;
|
||||
await post('/user/like', {
|
||||
userId: this.userId,
|
||||
storyId,
|
||||
isLiked
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏故事
|
||||
*/
|
||||
async collectStory(storyId, isCollected) {
|
||||
if (!this.isLoggedIn) return;
|
||||
await post('/user/collect', {
|
||||
userId: this.userId,
|
||||
storyId,
|
||||
isCollected
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏列表
|
||||
*/
|
||||
async getCollections() {
|
||||
if (!this.isLoggedIn) return [];
|
||||
return await get('/user/collections', { userId: this.userId });
|
||||
}
|
||||
}
|
||||
37
client/js/libs/weapp-adapter.js
Normal file
37
client/js/libs/weapp-adapter.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 微信小游戏适配器
|
||||
*/
|
||||
|
||||
// 获取系统信息
|
||||
const systemInfo = wx.getSystemInfoSync();
|
||||
const screenWidth = systemInfo.windowWidth;
|
||||
const screenHeight = systemInfo.windowHeight;
|
||||
const devicePixelRatio = systemInfo.pixelRatio;
|
||||
|
||||
// 创建主Canvas
|
||||
const canvas = wx.createCanvas();
|
||||
canvas.width = screenWidth * devicePixelRatio;
|
||||
canvas.height = screenHeight * devicePixelRatio;
|
||||
|
||||
// 设置全局变量
|
||||
GameGlobal.canvas = canvas;
|
||||
GameGlobal.screenWidth = screenWidth;
|
||||
GameGlobal.screenHeight = screenHeight;
|
||||
GameGlobal.devicePixelRatio = devicePixelRatio;
|
||||
|
||||
// Image适配
|
||||
GameGlobal.Image = function() {
|
||||
return wx.createImage();
|
||||
};
|
||||
|
||||
// Audio适配
|
||||
GameGlobal.Audio = function() {
|
||||
return wx.createInnerAudioContext();
|
||||
};
|
||||
|
||||
export default {
|
||||
canvas,
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
devicePixelRatio
|
||||
};
|
||||
183
client/js/main.js
Normal file
183
client/js/main.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 星域故事汇 - 主逻辑控制器
|
||||
*/
|
||||
import SceneManager from './scenes/SceneManager';
|
||||
import UserManager from './data/UserManager';
|
||||
import StoryManager from './data/StoryManager';
|
||||
import AudioManager from './data/AudioManager';
|
||||
|
||||
export default class Main {
|
||||
constructor() {
|
||||
// 获取画布和上下文
|
||||
this.canvas = GameGlobal.canvas;
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.screenWidth = GameGlobal.screenWidth;
|
||||
this.screenHeight = GameGlobal.screenHeight;
|
||||
this.dpr = GameGlobal.devicePixelRatio;
|
||||
|
||||
// 缩放上下文以适配设备像素比
|
||||
this.ctx.scale(this.dpr, this.dpr);
|
||||
|
||||
// 初始化管理器
|
||||
this.userManager = new UserManager();
|
||||
this.storyManager = new StoryManager();
|
||||
this.audioManager = new AudioManager();
|
||||
this.sceneManager = new SceneManager(this);
|
||||
|
||||
// 初始化游戏
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// 先启动游戏循环,确保能渲染加载界面
|
||||
this.bindEvents();
|
||||
this.loop();
|
||||
|
||||
try {
|
||||
// 显示加载界面
|
||||
this.showLoading('正在加载...');
|
||||
console.log('[Main] 开始初始化...');
|
||||
|
||||
// 用户初始化(失败不阻塞)
|
||||
console.log('[Main] 初始化用户...');
|
||||
await this.userManager.init().catch(e => {
|
||||
console.warn('[Main] 用户初始化失败,使用游客模式:', e);
|
||||
});
|
||||
console.log('[Main] 用户初始化完成');
|
||||
|
||||
// 加载故事列表
|
||||
console.log('[Main] 加载故事列表...');
|
||||
await this.storyManager.loadStoryList();
|
||||
console.log('[Main] 故事列表加载完成,共', this.storyManager.storyList.length, '个故事');
|
||||
|
||||
// 隐藏加载界面
|
||||
this.hideLoading();
|
||||
|
||||
// 进入首页
|
||||
this.sceneManager.switchScene('home');
|
||||
console.log('[Main] 初始化完成,进入首页');
|
||||
|
||||
// 设置分享
|
||||
this.setupShare();
|
||||
} catch (error) {
|
||||
console.error('[Main] 初始化失败:', error);
|
||||
this.hideLoading();
|
||||
this.showError('初始化失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示加载
|
||||
showLoading(text) {
|
||||
this.isLoading = true;
|
||||
this.loadingText = text;
|
||||
this.render();
|
||||
}
|
||||
|
||||
// 隐藏加载
|
||||
hideLoading() {
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
// 显示错误
|
||||
showError(text) {
|
||||
wx.showToast({
|
||||
title: text,
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
|
||||
// 绑定事件
|
||||
bindEvents() {
|
||||
// 触摸开始
|
||||
wx.onTouchStart((e) => {
|
||||
if (this.sceneManager.currentScene) {
|
||||
this.sceneManager.currentScene.onTouchStart(e);
|
||||
}
|
||||
});
|
||||
|
||||
// 触摸移动
|
||||
wx.onTouchMove((e) => {
|
||||
if (this.sceneManager.currentScene) {
|
||||
this.sceneManager.currentScene.onTouchMove(e);
|
||||
}
|
||||
});
|
||||
|
||||
// 触摸结束
|
||||
wx.onTouchEnd((e) => {
|
||||
if (this.sceneManager.currentScene) {
|
||||
this.sceneManager.currentScene.onTouchEnd(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 设置分享
|
||||
setupShare() {
|
||||
wx.showShareMenu({
|
||||
withShareTicket: true,
|
||||
menus: ['shareAppMessage', 'shareTimeline']
|
||||
});
|
||||
|
||||
wx.onShareAppMessage(() => {
|
||||
return {
|
||||
title: '星域故事汇 - 每个选择都是一个新世界',
|
||||
imageUrl: '',
|
||||
query: ''
|
||||
};
|
||||
});
|
||||
|
||||
wx.onShareTimeline(() => {
|
||||
return {
|
||||
title: '星域故事汇 - 沉浸式互动故事体验',
|
||||
query: ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 游戏循环
|
||||
loop() {
|
||||
this.update();
|
||||
this.render();
|
||||
requestAnimationFrame(() => this.loop());
|
||||
}
|
||||
|
||||
// 更新逻辑
|
||||
update() {
|
||||
if (this.sceneManager.currentScene) {
|
||||
this.sceneManager.currentScene.update();
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染
|
||||
render() {
|
||||
// 清屏
|
||||
this.ctx.clearRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
|
||||
// 绘制背景
|
||||
this.ctx.fillStyle = '#1a1a2e';
|
||||
this.ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
|
||||
// 渲染当前场景
|
||||
if (this.sceneManager.currentScene) {
|
||||
this.sceneManager.currentScene.render(this.ctx);
|
||||
}
|
||||
|
||||
// 渲染加载界面
|
||||
if (this.isLoading) {
|
||||
this.renderLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染加载界面
|
||||
renderLoading() {
|
||||
// 半透明遮罩
|
||||
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
this.ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
|
||||
// 加载文字
|
||||
this.ctx.fillStyle = '#ffffff';
|
||||
this.ctx.font = '18px sans-serif';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.fillText(this.loadingText || '加载中...', this.screenWidth / 2, this.screenHeight / 2);
|
||||
}
|
||||
}
|
||||
83
client/js/scenes/BaseScene.js
Normal file
83
client/js/scenes/BaseScene.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 场景基类
|
||||
*/
|
||||
export default class BaseScene {
|
||||
constructor(main, params = {}) {
|
||||
this.main = main;
|
||||
this.params = params;
|
||||
this.screenWidth = main.screenWidth;
|
||||
this.screenHeight = main.screenHeight;
|
||||
this.scrollVelocity = 0;
|
||||
}
|
||||
|
||||
// 初始化
|
||||
init() {}
|
||||
|
||||
// 更新逻辑
|
||||
update() {}
|
||||
|
||||
// 渲染
|
||||
render(ctx) {}
|
||||
|
||||
// 触摸开始
|
||||
onTouchStart(e) {}
|
||||
|
||||
// 触摸移动
|
||||
onTouchMove(e) {}
|
||||
|
||||
// 触摸结束
|
||||
onTouchEnd(e) {}
|
||||
|
||||
// 销毁
|
||||
destroy() {}
|
||||
|
||||
// 绘制圆角矩形
|
||||
roundRect(ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// 绘制多行文本
|
||||
wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
||||
const lines = [];
|
||||
let currentLine = '';
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
if (char === '\n') {
|
||||
lines.push(currentLine);
|
||||
currentLine = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
const testLine = currentLine + char;
|
||||
const metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width > maxWidth && currentLine.length > 0) {
|
||||
lines.push(currentLine);
|
||||
currentLine = char;
|
||||
} else {
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine.length > 0) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
ctx.fillText(line, x, y + index * lineHeight);
|
||||
});
|
||||
|
||||
return lines.length;
|
||||
}
|
||||
}
|
||||
284
client/js/scenes/ChapterScene.js
Normal file
284
client/js/scenes/ChapterScene.js
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 章节选择场景
|
||||
*/
|
||||
import BaseScene from './BaseScene';
|
||||
|
||||
export default class ChapterScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
super(main, params);
|
||||
this.storyId = params.storyId;
|
||||
this.story = null;
|
||||
this.nodeList = [];
|
||||
this.scrollY = 0;
|
||||
this.maxScrollY = 0;
|
||||
this.isDragging = false;
|
||||
this.lastTouchY = 0;
|
||||
this.hasMoved = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.story = this.main.storyManager.currentStory;
|
||||
if (!this.story || !this.story.nodes) {
|
||||
this.main.sceneManager.switchScene('home');
|
||||
return;
|
||||
}
|
||||
this.buildNodeList();
|
||||
this.calculateMaxScroll();
|
||||
}
|
||||
|
||||
buildNodeList() {
|
||||
const nodes = this.story.nodes;
|
||||
this.nodeList = [];
|
||||
|
||||
// 遍历所有节点
|
||||
Object.keys(nodes).forEach(key => {
|
||||
const node = nodes[key];
|
||||
this.nodeList.push({
|
||||
key: key,
|
||||
title: this.getNodeTitle(node, key),
|
||||
isEnding: node.is_ending,
|
||||
endingName: node.ending_name,
|
||||
endingType: node.ending_type,
|
||||
speaker: node.speaker,
|
||||
preview: this.getPreview(node.content)
|
||||
});
|
||||
});
|
||||
|
||||
// 按关键字排序,start在前,ending在后
|
||||
this.nodeList.sort((a, b) => {
|
||||
if (a.key === 'start') return -1;
|
||||
if (b.key === 'start') return 1;
|
||||
if (a.isEnding && !b.isEnding) return 1;
|
||||
if (!a.isEnding && b.isEnding) return -1;
|
||||
return a.key.localeCompare(b.key);
|
||||
});
|
||||
}
|
||||
|
||||
getNodeTitle(node, key) {
|
||||
if (key === 'start') return '故事开始';
|
||||
if (node.is_ending) return `结局:${node.ending_name || '未知'}`;
|
||||
if (node.speaker && node.speaker !== '旁白') return `${node.speaker}的对话`;
|
||||
return `章节 ${key}`;
|
||||
}
|
||||
|
||||
getPreview(content) {
|
||||
if (!content) return '';
|
||||
const text = content.replace(/\n/g, ' ').trim();
|
||||
return text.length > 40 ? text.substring(0, 40) + '...' : text;
|
||||
}
|
||||
|
||||
calculateMaxScroll() {
|
||||
const cardHeight = 85;
|
||||
const gap = 12;
|
||||
const headerHeight = 80;
|
||||
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight;
|
||||
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 20);
|
||||
}
|
||||
|
||||
update() {}
|
||||
|
||||
render(ctx) {
|
||||
this.renderBackground(ctx);
|
||||
this.renderHeader(ctx);
|
||||
this.renderNodeList(ctx);
|
||||
}
|
||||
|
||||
renderBackground(ctx) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
|
||||
gradient.addColorStop(0, '#0f0c29');
|
||||
gradient.addColorStop(0.5, '#302b63');
|
||||
gradient.addColorStop(1, '#24243e');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
}
|
||||
|
||||
renderHeader(ctx) {
|
||||
// 顶部遮罩
|
||||
const headerGradient = ctx.createLinearGradient(0, 0, 0, 80);
|
||||
headerGradient.addColorStop(0, 'rgba(15,12,41,1)');
|
||||
headerGradient.addColorStop(1, 'rgba(15,12,41,0)');
|
||||
ctx.fillStyle = headerGradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, 80);
|
||||
|
||||
// 返回按钮
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '16px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('‹ 返回', 15, 40);
|
||||
|
||||
// 标题
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = 'bold 17px sans-serif';
|
||||
ctx.fillText('选择章节', this.screenWidth / 2, 40);
|
||||
|
||||
// 故事名
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.font = '12px sans-serif';
|
||||
const title = this.story?.title || '';
|
||||
ctx.fillText(title.length > 15 ? title.substring(0, 15) + '...' : title, this.screenWidth / 2, 58);
|
||||
}
|
||||
|
||||
renderNodeList(ctx) {
|
||||
const padding = 15;
|
||||
const cardHeight = 85;
|
||||
const cardMargin = 12;
|
||||
const startY = 80;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, startY, this.screenWidth, this.screenHeight - startY);
|
||||
ctx.clip();
|
||||
|
||||
this.nodeList.forEach((node, index) => {
|
||||
const y = startY + index * (cardHeight + cardMargin) - this.scrollY;
|
||||
|
||||
if (y + cardHeight < startY || y > this.screenHeight) return;
|
||||
|
||||
// 卡片背景
|
||||
if (node.isEnding) {
|
||||
const endingGradient = ctx.createLinearGradient(padding, y, this.screenWidth - padding, y);
|
||||
const colors = this.getEndingColors(node.endingType);
|
||||
endingGradient.addColorStop(0, colors[0]);
|
||||
endingGradient.addColorStop(1, colors[1]);
|
||||
ctx.fillStyle = endingGradient;
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
}
|
||||
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12);
|
||||
ctx.fill();
|
||||
|
||||
// 节点标题
|
||||
ctx.fillStyle = node.isEnding ? '#ffffff' : '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(node.title, padding + 15, y + 28);
|
||||
|
||||
// 节点预览
|
||||
ctx.fillStyle = node.isEnding ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.5)';
|
||||
ctx.font = '12px sans-serif';
|
||||
const preview = this.truncateText(ctx, node.preview, this.screenWidth - padding * 2 - 30);
|
||||
ctx.fillText(preview, padding + 15, y + 52);
|
||||
|
||||
// 节点标识
|
||||
if (node.key === 'start') {
|
||||
this.renderTag(ctx, this.screenWidth - padding - 60, y + 18, '起点', '#4ecca3');
|
||||
} else if (node.isEnding) {
|
||||
this.renderTag(ctx, this.screenWidth - padding - 60, y + 18, '结局', '#ffd700');
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// 滚动条
|
||||
if (this.maxScrollY > 0) {
|
||||
const scrollBarHeight = 50;
|
||||
const scrollBarY = startY + (this.scrollY / this.maxScrollY) * (this.screenHeight - startY - scrollBarHeight - 20);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
||||
this.roundRect(ctx, this.screenWidth - 5, scrollBarY, 3, scrollBarHeight, 1.5);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
getEndingColors(type) {
|
||||
switch (type) {
|
||||
case 'good': return ['rgba(100,200,100,0.3)', 'rgba(50,150,50,0.2)'];
|
||||
case 'bad': return ['rgba(200,100,100,0.3)', 'rgba(150,50,50,0.2)'];
|
||||
case 'hidden': return ['rgba(255,215,0,0.3)', 'rgba(200,150,0,0.2)'];
|
||||
default: return ['rgba(150,150,255,0.3)', 'rgba(100,100,200,0.2)'];
|
||||
}
|
||||
}
|
||||
|
||||
renderTag(ctx, x, y, text, color) {
|
||||
ctx.font = '10px sans-serif';
|
||||
const width = ctx.measureText(text).width + 12;
|
||||
ctx.fillStyle = color + '40';
|
||||
this.roundRect(ctx, x, y, width, 18, 9);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(text, x + width / 2, y + 13);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
truncateText(ctx, text, maxWidth) {
|
||||
if (!text) return '';
|
||||
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||
let truncated = text;
|
||||
while (truncated.length > 0 && ctx.measureText(truncated + '...').width > maxWidth) {
|
||||
truncated = truncated.slice(0, -1);
|
||||
}
|
||||
return truncated + '...';
|
||||
}
|
||||
|
||||
roundRect(ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
onTouchStart(e) {
|
||||
const touch = e.touches[0];
|
||||
this.lastTouchY = touch.clientY;
|
||||
this.touchStartY = touch.clientY;
|
||||
this.touchStartX = touch.clientX;
|
||||
this.isDragging = true;
|
||||
this.hasMoved = false;
|
||||
}
|
||||
|
||||
onTouchMove(e) {
|
||||
if (!this.isDragging) return;
|
||||
const touch = e.touches[0];
|
||||
const deltaY = this.lastTouchY - touch.clientY;
|
||||
if (Math.abs(deltaY) > 3) {
|
||||
this.hasMoved = true;
|
||||
}
|
||||
this.scrollY += deltaY;
|
||||
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||
this.lastTouchY = touch.clientY;
|
||||
}
|
||||
|
||||
onTouchEnd(e) {
|
||||
this.isDragging = false;
|
||||
const touch = e.changedTouches[0];
|
||||
const x = touch.clientX;
|
||||
const y = touch.clientY;
|
||||
|
||||
if (this.hasMoved) return;
|
||||
|
||||
// 返回按钮
|
||||
if (y < 60 && x < 80) {
|
||||
this.main.sceneManager.switchScene('ending', {
|
||||
storyId: this.storyId,
|
||||
ending: this.main.storyManager.getEndingInfo()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击节点
|
||||
const padding = 15;
|
||||
const cardHeight = 85;
|
||||
const cardMargin = 12;
|
||||
const startY = 80;
|
||||
|
||||
for (let i = 0; i < this.nodeList.length; i++) {
|
||||
const cardY = startY + i * (cardHeight + cardMargin) - this.scrollY;
|
||||
if (y >= cardY && y <= cardY + cardHeight && x >= padding && x <= this.screenWidth - padding) {
|
||||
this.selectNode(this.nodeList[i].key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectNode(nodeKey) {
|
||||
this.main.storyManager.currentNodeKey = nodeKey;
|
||||
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
|
||||
}
|
||||
}
|
||||
745
client/js/scenes/EndingScene.js
Normal file
745
client/js/scenes/EndingScene.js
Normal file
@@ -0,0 +1,745 @@
|
||||
/**
|
||||
* 结局场景
|
||||
*/
|
||||
import BaseScene from './BaseScene';
|
||||
|
||||
export default class EndingScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
super(main, params);
|
||||
this.storyId = params.storyId;
|
||||
this.ending = params.ending;
|
||||
this.showButtons = false;
|
||||
this.fadeIn = 0;
|
||||
this.particles = [];
|
||||
this.isLiked = false;
|
||||
this.isCollected = false;
|
||||
// AI改写面板
|
||||
this.showRewritePanel = false;
|
||||
this.rewritePrompt = '';
|
||||
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢'];
|
||||
this.selectedTag = -1;
|
||||
this.initParticles();
|
||||
}
|
||||
|
||||
init() {
|
||||
setTimeout(() => {
|
||||
this.showButtons = true;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
initParticles() {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
this.particles.push({
|
||||
x: Math.random() * this.screenWidth,
|
||||
y: Math.random() * this.screenHeight,
|
||||
size: Math.random() * 3 + 1,
|
||||
speedY: Math.random() * 0.5 + 0.2,
|
||||
alpha: Math.random() * 0.5 + 0.3
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.fadeIn < 1) {
|
||||
this.fadeIn += 0.02;
|
||||
}
|
||||
this.particles.forEach(p => {
|
||||
p.y -= p.speedY;
|
||||
if (p.y < 0) {
|
||||
p.y = this.screenHeight;
|
||||
p.x = Math.random() * this.screenWidth;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render(ctx) {
|
||||
this.renderBackground(ctx);
|
||||
this.renderParticles(ctx);
|
||||
this.renderEndingContent(ctx);
|
||||
if (this.showButtons) {
|
||||
this.renderButtons(ctx);
|
||||
}
|
||||
// AI改写面板
|
||||
if (this.showRewritePanel) {
|
||||
this.renderRewritePanel(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
renderBackground(ctx) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
|
||||
switch (this.ending?.type) {
|
||||
case 'good':
|
||||
gradient.addColorStop(0, '#0f2027');
|
||||
gradient.addColorStop(0.5, '#203a43');
|
||||
gradient.addColorStop(1, '#2c5364');
|
||||
break;
|
||||
case 'bad':
|
||||
gradient.addColorStop(0, '#1a0a0a');
|
||||
gradient.addColorStop(0.5, '#3a1515');
|
||||
gradient.addColorStop(1, '#2d1f1f');
|
||||
break;
|
||||
case 'hidden':
|
||||
gradient.addColorStop(0, '#1a1a0a');
|
||||
gradient.addColorStop(0.5, '#3a3515');
|
||||
gradient.addColorStop(1, '#2d2d1f');
|
||||
break;
|
||||
default:
|
||||
gradient.addColorStop(0, '#0f0c29');
|
||||
gradient.addColorStop(0.5, '#302b63');
|
||||
gradient.addColorStop(1, '#24243e');
|
||||
}
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
}
|
||||
|
||||
renderParticles(ctx) {
|
||||
this.particles.forEach(p => {
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${p.alpha * this.fadeIn})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
renderEndingContent(ctx) {
|
||||
const centerX = this.screenWidth / 2;
|
||||
const alpha = this.fadeIn;
|
||||
const padding = 20;
|
||||
|
||||
// 结局卡片背景
|
||||
const cardY = 80;
|
||||
const cardHeight = 320;
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${0.05 * alpha})`;
|
||||
this.roundRect(ctx, padding, cardY, this.screenWidth - padding * 2, cardHeight, 20);
|
||||
ctx.fill();
|
||||
|
||||
// 装饰线
|
||||
const lineGradient = ctx.createLinearGradient(padding + 30, cardY + 20, this.screenWidth - padding - 30, cardY + 20);
|
||||
lineGradient.addColorStop(0, 'transparent');
|
||||
lineGradient.addColorStop(0.5, this.getEndingColorRgba(alpha * 0.5));
|
||||
lineGradient.addColorStop(1, 'transparent');
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding + 30, cardY + 20);
|
||||
ctx.lineTo(this.screenWidth - padding - 30, cardY + 20);
|
||||
ctx.stroke();
|
||||
|
||||
// 结局标签
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.6})`;
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('— 达成结局 —', centerX, cardY + 50);
|
||||
|
||||
// 结局名称(自动调整字号)
|
||||
const endingName = this.ending?.name || '未知结局';
|
||||
let fontSize = 24;
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
while (ctx.measureText(endingName).width > this.screenWidth - padding * 2 - 40 && fontSize > 14) {
|
||||
fontSize -= 2;
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
}
|
||||
ctx.fillStyle = this.getEndingColorRgba(alpha);
|
||||
ctx.fillText(endingName, centerX, cardY + 90);
|
||||
|
||||
// 结局类型标签
|
||||
const typeLabel = this.getTypeLabel();
|
||||
if (typeLabel) {
|
||||
ctx.font = '11px sans-serif';
|
||||
const labelWidth = ctx.measureText(typeLabel).width + 20;
|
||||
ctx.fillStyle = this.getEndingColorRgba(alpha * 0.3);
|
||||
this.roundRect(ctx, centerX - labelWidth / 2, cardY + 100, labelWidth, 22, 11);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.9})`;
|
||||
ctx.fillText(typeLabel, centerX, cardY + 115);
|
||||
}
|
||||
|
||||
// 评分
|
||||
if (this.ending?.score !== undefined) {
|
||||
this.renderScore(ctx, centerX, cardY + 155, alpha);
|
||||
}
|
||||
|
||||
// 结局描述(居中显示,限制在卡片内)
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.6})`;
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const content = this.ending?.content || '';
|
||||
const lastParagraph = content.split('\n').filter(p => p.trim()).pop() || '';
|
||||
const maxWidth = this.screenWidth - padding * 2 - 30;
|
||||
// 限制只显示2行,居中
|
||||
this.wrapTextCentered(ctx, lastParagraph, centerX, cardY + 250, maxWidth, 20, 2);
|
||||
}
|
||||
|
||||
getTypeLabel() {
|
||||
switch (this.ending?.type) {
|
||||
case 'good': return '✨ 完美结局';
|
||||
case 'bad': return '💔 悲伤结局';
|
||||
case 'hidden': return '🔮 隐藏结局';
|
||||
default: return '📖 普通结局';
|
||||
}
|
||||
}
|
||||
|
||||
truncateText(ctx, text, maxWidth) {
|
||||
if (!text) return '';
|
||||
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||
let truncated = text;
|
||||
while (truncated.length > 0 && ctx.measureText(truncated + '...').width > maxWidth) {
|
||||
truncated = truncated.slice(0, -1);
|
||||
}
|
||||
return truncated + '...';
|
||||
}
|
||||
|
||||
renderScore(ctx, x, y, alpha) {
|
||||
const score = this.ending?.score || 0;
|
||||
const stars = Math.ceil(score / 20);
|
||||
|
||||
// 星星
|
||||
const starSize = 22;
|
||||
const gap = 6;
|
||||
const totalWidth = 5 * starSize + 4 * gap;
|
||||
const startX = x - totalWidth / 2;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const filled = i < stars;
|
||||
ctx.fillStyle = filled ? `rgba(255, 215, 0, ${alpha})` : `rgba(100, 100, 100, ${alpha * 0.5})`;
|
||||
ctx.font = `${starSize}px sans-serif`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(filled ? '★' : '☆', startX + i * (starSize + gap), y);
|
||||
}
|
||||
|
||||
// 分数
|
||||
ctx.fillStyle = `rgba(255, 215, 0, ${alpha})`;
|
||||
ctx.font = 'bold 16px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`${score}分`, x, y + 28);
|
||||
}
|
||||
|
||||
getEndingColorRgba(alpha) {
|
||||
switch (this.ending?.type) {
|
||||
case 'good': return `rgba(100, 255, 150, ${alpha})`;
|
||||
case 'bad': return `rgba(255, 100, 100, ${alpha})`;
|
||||
case 'hidden': return `rgba(255, 215, 0, ${alpha})`;
|
||||
default: return `rgba(150, 150, 255, ${alpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
renderButtons(ctx) {
|
||||
const padding = 15;
|
||||
const buttonHeight = 38;
|
||||
const buttonMargin = 8;
|
||||
const startY = this.screenHeight - 220;
|
||||
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
|
||||
|
||||
// AI改写按钮(突出显示)
|
||||
this.renderGradientButton(ctx, padding, startY, this.screenWidth - padding * 2, buttonHeight, '✨ AI改写结局', ['#a855f7', '#ec4899']);
|
||||
|
||||
// 分享按钮
|
||||
const row2Y = startY + buttonHeight + buttonMargin;
|
||||
this.renderGradientButton(ctx, padding, row2Y, buttonWidth, buttonHeight, '分享结局', ['#ff6b6b', '#ffd700']);
|
||||
|
||||
// 章节选择按钮
|
||||
this.renderGradientButton(ctx, padding + buttonWidth + buttonMargin, row2Y, buttonWidth, buttonHeight, '章节选择', ['#667eea', '#764ba2']);
|
||||
|
||||
// 从头开始
|
||||
const row3Y = row2Y + buttonHeight + buttonMargin;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
this.roundRect(ctx, padding, row3Y, buttonWidth, buttonHeight, 19);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
this.roundRect(ctx, padding, row3Y, buttonWidth, buttonHeight, 19);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('从头开始', padding + buttonWidth / 2, row3Y + 24);
|
||||
|
||||
// 返回首页
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
this.roundRect(ctx, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight, 19);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||
this.roundRect(ctx, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight, 19);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText('返回首页', padding + buttonWidth + buttonMargin + buttonWidth / 2, row3Y + 24);
|
||||
|
||||
// 点赞和收藏
|
||||
const actionY = row3Y + buttonHeight + 18;
|
||||
const centerX = this.screenWidth / 2;
|
||||
|
||||
ctx.font = '20px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(this.isLiked ? '❤️' : '🤍', centerX - 40, actionY);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText('点赞', centerX - 40, actionY + 16);
|
||||
|
||||
ctx.font = '20px sans-serif';
|
||||
ctx.fillText(this.isCollected ? '⭐' : '☆', centerX + 40, actionY);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText('收藏', centerX + 40, actionY + 16);
|
||||
}
|
||||
|
||||
renderGradientButton(ctx, x, y, width, height, text, colors) {
|
||||
const gradient = ctx.createLinearGradient(x, y, x + width, y);
|
||||
gradient.addColorStop(0, colors[0]);
|
||||
gradient.addColorStop(1, colors[1]);
|
||||
ctx.fillStyle = gradient;
|
||||
this.roundRect(ctx, x, y, width, height, 22);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(text, x + width / 2, y + height / 2 + 5);
|
||||
}
|
||||
|
||||
renderRewritePanel(ctx) {
|
||||
const padding = 20;
|
||||
const panelWidth = this.screenWidth - padding * 2;
|
||||
const panelHeight = 380;
|
||||
const panelX = padding;
|
||||
const panelY = (this.screenHeight - panelHeight) / 2;
|
||||
|
||||
// 遮罩层
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
|
||||
// 面板背景渐变
|
||||
const panelGradient = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight);
|
||||
panelGradient.addColorStop(0, '#1a1a3e');
|
||||
panelGradient.addColorStop(1, '#0d0d1a');
|
||||
ctx.fillStyle = panelGradient;
|
||||
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
|
||||
ctx.fill();
|
||||
|
||||
// 面板边框渐变
|
||||
const borderGradient = ctx.createLinearGradient(panelX, panelY, panelX + panelWidth, panelY);
|
||||
borderGradient.addColorStop(0, '#a855f7');
|
||||
borderGradient.addColorStop(1, '#ec4899');
|
||||
ctx.strokeStyle = borderGradient;
|
||||
ctx.lineWidth = 2;
|
||||
this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20);
|
||||
ctx.stroke();
|
||||
|
||||
// 标题
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('✨ AI改写结局', this.screenWidth / 2, panelY + 35);
|
||||
|
||||
// 副标题
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillText('输入你想要的剧情走向,AI将为你重新创作', this.screenWidth / 2, panelY + 58);
|
||||
|
||||
// 分隔线
|
||||
const lineGradient = ctx.createLinearGradient(panelX + 20, panelY + 75, panelX + panelWidth - 20, panelY + 75);
|
||||
lineGradient.addColorStop(0, 'transparent');
|
||||
lineGradient.addColorStop(0.5, 'rgba(168,85,247,0.5)');
|
||||
lineGradient.addColorStop(1, 'transparent');
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(panelX + 20, panelY + 75);
|
||||
ctx.lineTo(panelX + panelWidth - 20, panelY + 75);
|
||||
ctx.stroke();
|
||||
|
||||
// 快捷标签标题
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('快捷选择:', panelX + 15, panelY + 105);
|
||||
|
||||
// 快捷标签
|
||||
const tagStartX = panelX + 15;
|
||||
const tagY = panelY + 120;
|
||||
const tagHeight = 32;
|
||||
const tagGap = 8;
|
||||
let currentX = tagStartX;
|
||||
let currentY = tagY;
|
||||
|
||||
this.tagRects = [];
|
||||
this.rewriteTags.forEach((tag, index) => {
|
||||
ctx.font = '12px sans-serif';
|
||||
const tagWidth = ctx.measureText(tag).width + 24;
|
||||
|
||||
// 换行
|
||||
if (currentX + tagWidth > panelX + panelWidth - 15) {
|
||||
currentX = tagStartX;
|
||||
currentY += tagHeight + tagGap;
|
||||
}
|
||||
|
||||
// 标签背景
|
||||
const isSelected = index === this.selectedTag;
|
||||
if (isSelected) {
|
||||
const tagGradient = ctx.createLinearGradient(currentX, currentY, currentX + tagWidth, currentY);
|
||||
tagGradient.addColorStop(0, '#a855f7');
|
||||
tagGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = tagGradient;
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
}
|
||||
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
|
||||
ctx.fill();
|
||||
|
||||
// 标签边框
|
||||
ctx.strokeStyle = isSelected ? 'transparent' : 'rgba(255,255,255,0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16);
|
||||
ctx.stroke();
|
||||
|
||||
// 标签文字
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(tag, currentX + tagWidth / 2, currentY + 21);
|
||||
|
||||
// 存储标签位置
|
||||
this.tagRects.push({ x: currentX, y: currentY, width: tagWidth, height: tagHeight, index });
|
||||
|
||||
currentX += tagWidth + tagGap;
|
||||
});
|
||||
|
||||
// 自定义输入提示
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('或自定义输入:', panelX + 15, panelY + 215);
|
||||
|
||||
// 输入框背景
|
||||
const inputY = panelY + 230;
|
||||
const inputHeight = 45;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12);
|
||||
ctx.stroke();
|
||||
|
||||
// 输入框文字或占位符
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
if (this.rewritePrompt) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText(this.rewritePrompt, panelX + 28, inputY + 28);
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.fillText('点击输入你的改写想法...', panelX + 28, inputY + 28);
|
||||
}
|
||||
|
||||
// 按钮
|
||||
const btnY = panelY + panelHeight - 70;
|
||||
const btnWidth = (panelWidth - 50) / 2;
|
||||
const btnHeight = 44;
|
||||
|
||||
// 取消按钮
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('取消', panelX + 15 + btnWidth / 2, btnY + 28);
|
||||
|
||||
// 确认按钮
|
||||
const confirmGradient = ctx.createLinearGradient(panelX + 35 + btnWidth, btnY, panelX + 35 + btnWidth * 2, btnY);
|
||||
confirmGradient.addColorStop(0, '#a855f7');
|
||||
confirmGradient.addColorStop(1, '#ec4899');
|
||||
ctx.fillStyle = confirmGradient;
|
||||
this.roundRect(ctx, panelX + 35 + btnWidth, btnY, btnWidth, btnHeight, 22);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText('✨ 开始改写', panelX + 35 + btnWidth + btnWidth / 2, btnY + 28);
|
||||
|
||||
// 存储按钮区域
|
||||
this.cancelBtnRect = { x: panelX + 15, y: btnY, width: btnWidth, height: btnHeight };
|
||||
this.confirmBtnRect = { x: panelX + 35 + btnWidth, y: btnY, width: btnWidth, height: btnHeight };
|
||||
this.inputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight };
|
||||
}
|
||||
|
||||
// 圆角矩形
|
||||
roundRect(ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// 文字换行
|
||||
wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
||||
if (!text) return;
|
||||
let line = '';
|
||||
let lineY = y;
|
||||
for (let char of text) {
|
||||
const testLine = line + char;
|
||||
if (ctx.measureText(testLine).width > maxWidth) {
|
||||
ctx.fillText(line, x, lineY);
|
||||
line = char;
|
||||
lineY += lineHeight;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
ctx.fillText(line, x, lineY);
|
||||
}
|
||||
|
||||
// 限制行数的文字换行
|
||||
wrapTextLimited(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||
if (!text) return;
|
||||
let line = '';
|
||||
let lineY = y;
|
||||
let lineCount = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const testLine = line + char;
|
||||
if (ctx.measureText(testLine).width > maxWidth) {
|
||||
lineCount++;
|
||||
if (lineCount >= maxLines) {
|
||||
// 最后一行加省略号
|
||||
while (line.length > 0 && ctx.measureText(line + '...').width > maxWidth) {
|
||||
line = line.slice(0, -1);
|
||||
}
|
||||
ctx.fillText(line + '...', x, lineY);
|
||||
return;
|
||||
}
|
||||
ctx.fillText(line, x, lineY);
|
||||
line = char;
|
||||
lineY += lineHeight;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
ctx.fillText(line, x, lineY);
|
||||
}
|
||||
|
||||
// 居中显示的限制行数文字换行
|
||||
wrapTextCentered(ctx, text, centerX, y, maxWidth, lineHeight, maxLines) {
|
||||
if (!text) return;
|
||||
// 先分行
|
||||
const lines = [];
|
||||
let line = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const testLine = line + char;
|
||||
if (ctx.measureText(testLine).width > maxWidth) {
|
||||
lines.push(line);
|
||||
line = char;
|
||||
if (lines.length >= maxLines) break;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
if (line && lines.length < maxLines) {
|
||||
lines.push(line);
|
||||
}
|
||||
// 如果超出行数,最后一行加省略号
|
||||
if (lines.length >= maxLines && line) {
|
||||
let lastLine = lines[maxLines - 1];
|
||||
while (lastLine.length > 0 && ctx.measureText(lastLine + '...').width > maxWidth) {
|
||||
lastLine = lastLine.slice(0, -1);
|
||||
}
|
||||
lines[maxLines - 1] = lastLine + '...';
|
||||
}
|
||||
// 居中绘制
|
||||
lines.slice(0, maxLines).forEach((l, i) => {
|
||||
ctx.fillText(l, centerX, y + i * lineHeight);
|
||||
});
|
||||
}
|
||||
|
||||
onTouchEnd(e) {
|
||||
const touch = e.changedTouches[0];
|
||||
const x = touch.clientX;
|
||||
const y = touch.clientY;
|
||||
|
||||
// 如果改写面板打开,优先处理
|
||||
if (this.showRewritePanel) {
|
||||
this.handleRewritePanelTouch(x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.showButtons) return;
|
||||
|
||||
const padding = 15;
|
||||
const buttonHeight = 38;
|
||||
const buttonMargin = 8;
|
||||
const startY = this.screenHeight - 220;
|
||||
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
|
||||
|
||||
// AI改写按钮
|
||||
if (this.isInRect(x, y, padding, startY, this.screenWidth - padding * 2, buttonHeight)) {
|
||||
this.handleAIRewrite();
|
||||
return;
|
||||
}
|
||||
|
||||
// 分享按钮
|
||||
const row2Y = startY + buttonHeight + buttonMargin;
|
||||
if (this.isInRect(x, y, padding, row2Y, buttonWidth, buttonHeight)) {
|
||||
this.handleShare();
|
||||
return;
|
||||
}
|
||||
|
||||
// 章节选择按钮
|
||||
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, row2Y, buttonWidth, buttonHeight)) {
|
||||
this.handleChapterSelect();
|
||||
return;
|
||||
}
|
||||
|
||||
// 从头开始
|
||||
const row3Y = row2Y + buttonHeight + buttonMargin;
|
||||
if (this.isInRect(x, y, padding, row3Y, buttonWidth, buttonHeight)) {
|
||||
this.handleReplay();
|
||||
return;
|
||||
}
|
||||
|
||||
// 返回首页
|
||||
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight)) {
|
||||
this.main.sceneManager.switchScene('home');
|
||||
return;
|
||||
}
|
||||
|
||||
// 点赞收藏
|
||||
const actionY = row3Y + buttonHeight + 18;
|
||||
const centerX = this.screenWidth / 2;
|
||||
if (this.isInRect(x, y, centerX - 70, actionY - 20, 60, 45)) {
|
||||
this.handleLike();
|
||||
return;
|
||||
}
|
||||
if (this.isInRect(x, y, centerX + 10, actionY - 20, 60, 45)) {
|
||||
this.handleCollect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isInRect(x, y, rx, ry, rw, rh) {
|
||||
return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh;
|
||||
}
|
||||
|
||||
handleShare() {
|
||||
wx.shareAppMessage({
|
||||
title: `我在《星域故事汇》达成了「${this.ending?.name}」结局!`,
|
||||
imageUrl: '',
|
||||
query: `storyId=${this.storyId}`
|
||||
});
|
||||
}
|
||||
|
||||
handleChapterSelect() {
|
||||
this.main.sceneManager.switchScene('chapter', { storyId: this.storyId });
|
||||
}
|
||||
|
||||
handleAIRewrite() {
|
||||
// 显示AI改写面板
|
||||
this.showRewritePanel = true;
|
||||
this.rewritePrompt = '';
|
||||
this.selectedTag = -1;
|
||||
}
|
||||
|
||||
handleRewritePanelTouch(x, y) {
|
||||
// 点击标签
|
||||
if (this.tagRects) {
|
||||
for (const tag of this.tagRects) {
|
||||
if (this.isInRect(x, y, tag.x, tag.y, tag.width, tag.height)) {
|
||||
this.selectedTag = tag.index;
|
||||
this.rewritePrompt = this.rewriteTags[tag.index];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 点击输入框
|
||||
if (this.inputRect && this.isInRect(x, y, this.inputRect.x, this.inputRect.y, this.inputRect.width, this.inputRect.height)) {
|
||||
this.showCustomInput();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 点击取消
|
||||
if (this.cancelBtnRect && this.isInRect(x, y, this.cancelBtnRect.x, this.cancelBtnRect.y, this.cancelBtnRect.width, this.cancelBtnRect.height)) {
|
||||
this.showRewritePanel = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 点击确认
|
||||
if (this.confirmBtnRect && this.isInRect(x, y, this.confirmBtnRect.x, this.confirmBtnRect.y, this.confirmBtnRect.width, this.confirmBtnRect.height)) {
|
||||
if (this.rewritePrompt) {
|
||||
this.showRewritePanel = false;
|
||||
this.callAIRewrite(this.rewritePrompt);
|
||||
} else {
|
||||
wx.showToast({ title: '请选择或输入改写内容', icon: 'none' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
showCustomInput() {
|
||||
wx.showModal({
|
||||
title: '输入改写想法',
|
||||
editable: true,
|
||||
placeholderText: '例如:让主角获得逆袭',
|
||||
content: this.rewritePrompt,
|
||||
success: (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
this.rewritePrompt = res.content;
|
||||
this.selectedTag = -1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async callAIRewrite(prompt) {
|
||||
wx.showLoading({ title: 'AI创作中...' });
|
||||
|
||||
try {
|
||||
const result = await this.main.storyManager.rewriteEnding(
|
||||
this.storyId,
|
||||
this.ending,
|
||||
prompt
|
||||
);
|
||||
|
||||
wx.hideLoading();
|
||||
|
||||
if (result && result.content) {
|
||||
// 跳转到故事场景播放新内容
|
||||
this.main.sceneManager.switchScene('story', {
|
||||
storyId: this.storyId,
|
||||
aiContent: result
|
||||
});
|
||||
} else {
|
||||
wx.showToast({ title: '改写失败,请重试', icon: 'none' });
|
||||
}
|
||||
} catch (error) {
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: '网络错误', icon: 'none' });
|
||||
}
|
||||
}
|
||||
|
||||
handleReplay() {
|
||||
this.main.storyManager.resetStory();
|
||||
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
|
||||
}
|
||||
|
||||
handleLike() {
|
||||
this.isLiked = !this.isLiked;
|
||||
this.main.userManager.likeStory(this.storyId, this.isLiked);
|
||||
this.main.storyManager.likeStory(this.isLiked);
|
||||
}
|
||||
|
||||
handleCollect() {
|
||||
this.isCollected = !this.isCollected;
|
||||
this.main.userManager.collectStory(this.storyId, this.isCollected);
|
||||
}
|
||||
}
|
||||
537
client/js/scenes/HomeScene.js
Normal file
537
client/js/scenes/HomeScene.js
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* 首页场景
|
||||
*/
|
||||
import BaseScene from './BaseScene';
|
||||
|
||||
export default class HomeScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
super(main, params);
|
||||
this.storyList = [];
|
||||
this.scrollY = 0;
|
||||
this.maxScrollY = 0;
|
||||
this.isDragging = false;
|
||||
this.lastTouchY = 0;
|
||||
this.scrollVelocity = 0;
|
||||
this.currentTab = 0; // 0: 首页, 1: 发现, 2: 我的
|
||||
this.categories = ['全部', '都市言情', '悬疑推理', '古风宫廷', '校园青春', '修仙玄幻', '穿越重生', '职场商战', '科幻未来', '恐怖惊悚', '搞笑轻喜'];
|
||||
this.selectedCategory = 0;
|
||||
// 分类横向滚动
|
||||
this.categoryScrollX = 0;
|
||||
this.maxCategoryScrollX = 0;
|
||||
this.isCategoryDragging = false;
|
||||
this.lastTouchX = 0;
|
||||
// 存储分类标签位置(用于点击判定)
|
||||
this.categoryRects = [];
|
||||
}
|
||||
|
||||
async init() {
|
||||
// 加载故事列表
|
||||
this.storyList = this.main.storyManager.storyList;
|
||||
this.calculateMaxScroll();
|
||||
}
|
||||
|
||||
// 获取过滤后的故事列表
|
||||
getFilteredStories() {
|
||||
if (this.selectedCategory === 0) {
|
||||
return this.storyList; // 全部
|
||||
}
|
||||
const categoryName = this.categories[this.selectedCategory];
|
||||
return this.storyList.filter(s => s.category === categoryName);
|
||||
}
|
||||
|
||||
calculateMaxScroll() {
|
||||
// 计算最大滚动距离
|
||||
const stories = this.getFilteredStories();
|
||||
const cardHeight = 120;
|
||||
const gap = 15;
|
||||
const startY = 150; // 故事列表起始位置
|
||||
const tabHeight = 65;
|
||||
|
||||
// 内容总高度
|
||||
const contentBottom = startY + stories.length * (cardHeight + gap);
|
||||
// 可视区域底部(减去底部Tab栏)
|
||||
const visibleBottom = this.screenHeight - tabHeight;
|
||||
|
||||
// 最大滚动距离 = 内容超出可视区域的部分
|
||||
this.maxScrollY = Math.max(0, contentBottom - visibleBottom);
|
||||
}
|
||||
|
||||
update() {
|
||||
// 滚动惯性
|
||||
if (!this.isDragging && Math.abs(this.scrollVelocity) > 0.5) {
|
||||
this.scrollY += this.scrollVelocity;
|
||||
this.scrollVelocity *= 0.95;
|
||||
|
||||
// 边界检查
|
||||
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||
}
|
||||
}
|
||||
|
||||
render(ctx) {
|
||||
// 绘制背景渐变(深紫蓝色调)
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
|
||||
gradient.addColorStop(0, '#0f0c29');
|
||||
gradient.addColorStop(0.5, '#302b63');
|
||||
gradient.addColorStop(1, '#24243e');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
|
||||
// 添加星空装饰点
|
||||
this.renderStars(ctx);
|
||||
|
||||
// 绘制顶部标题栏
|
||||
this.renderHeader(ctx);
|
||||
|
||||
// 绘制分类标签
|
||||
this.renderCategories(ctx);
|
||||
|
||||
// 绘制故事列表
|
||||
this.renderStoryList(ctx);
|
||||
|
||||
// 绘制底部Tab栏
|
||||
this.renderTabBar(ctx);
|
||||
}
|
||||
|
||||
renderStars(ctx) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
const stars = [[30, 80], [80, 30], [150, 60], [200, 25], [280, 70], [320, 40]];
|
||||
stars.forEach(([x, y]) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 1, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
renderHeader(ctx) {
|
||||
// 标题带渐变
|
||||
const titleGradient = ctx.createLinearGradient(20, 30, 200, 30);
|
||||
titleGradient.addColorStop(0, '#ffd700');
|
||||
titleGradient.addColorStop(1, '#ff6b6b');
|
||||
ctx.fillStyle = titleGradient;
|
||||
ctx.font = 'bold 26px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('星域故事汇', 20, 50);
|
||||
|
||||
// 副标题
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.fillText('每个选择,都是一个新世界', 20, 75);
|
||||
}
|
||||
|
||||
renderCategories(ctx) {
|
||||
const startY = 95;
|
||||
const tagHeight = 30;
|
||||
let x = 15 - this.categoryScrollX;
|
||||
const y = startY;
|
||||
|
||||
// 计算总宽度用于滚动限制
|
||||
ctx.font = '13px sans-serif';
|
||||
let totalWidth = 15;
|
||||
this.categories.forEach((cat) => {
|
||||
totalWidth += ctx.measureText(cat).width + 28 + 12;
|
||||
});
|
||||
this.maxCategoryScrollX = Math.max(0, totalWidth - this.screenWidth + 15);
|
||||
|
||||
// 清空并重新记录位置
|
||||
this.categoryRects = [];
|
||||
|
||||
this.categories.forEach((cat, index) => {
|
||||
const isSelected = index === this.selectedCategory;
|
||||
const textWidth = ctx.measureText(cat).width + 28;
|
||||
|
||||
// 记录每个标签的位置
|
||||
this.categoryRects.push({
|
||||
left: x + this.categoryScrollX,
|
||||
right: x + this.categoryScrollX + textWidth,
|
||||
index: index
|
||||
});
|
||||
|
||||
// 只渲染可见的标签
|
||||
if (x + textWidth > 0 && x < this.screenWidth) {
|
||||
if (isSelected) {
|
||||
// 选中态:渐变背景
|
||||
const tagGradient = ctx.createLinearGradient(x, y, x + textWidth, y);
|
||||
tagGradient.addColorStop(0, '#ff6b6b');
|
||||
tagGradient.addColorStop(1, '#ffd700');
|
||||
ctx.fillStyle = tagGradient;
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
}
|
||||
this.roundRect(ctx, x, y, textWidth, tagHeight, 15);
|
||||
ctx.fill();
|
||||
|
||||
// 未选中态加边框
|
||||
if (!isSelected) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
||||
ctx.lineWidth = 1;
|
||||
this.roundRect(ctx, x, y, textWidth, tagHeight, 15);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 标签文字
|
||||
ctx.fillStyle = isSelected ? '#ffffff' : 'rgba(255,255,255,0.7)';
|
||||
ctx.font = isSelected ? 'bold 13px sans-serif' : '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(cat, x + textWidth / 2, y + 20);
|
||||
}
|
||||
|
||||
x += textWidth + 12;
|
||||
});
|
||||
}
|
||||
|
||||
renderStoryList(ctx) {
|
||||
const startY = 150;
|
||||
const cardHeight = 120;
|
||||
const cardPadding = 15;
|
||||
const cardMargin = 15;
|
||||
|
||||
const stories = this.getFilteredStories();
|
||||
|
||||
if (stories.length === 0) {
|
||||
ctx.fillStyle = '#666666';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('暂无该分类的故事', this.screenWidth / 2, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置裁剪区域,防止卡片渲染到分类标签区域
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, startY, this.screenWidth, this.screenHeight - startY - 65);
|
||||
ctx.clip();
|
||||
|
||||
stories.forEach((story, index) => {
|
||||
const y = startY + index * (cardHeight + cardMargin) - this.scrollY;
|
||||
|
||||
// 只渲染可见区域的卡片
|
||||
if (y > startY - cardHeight && y < this.screenHeight - 65) {
|
||||
this.renderStoryCard(ctx, story, cardMargin, y, this.screenWidth - cardMargin * 2, cardHeight);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
renderStoryCard(ctx, story, x, y, width, height) {
|
||||
// 卡片背景 - 毛玻璃效果
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||
this.roundRect(ctx, x, y, width, height, 16);
|
||||
ctx.fill();
|
||||
|
||||
// 卡片高光边框
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
this.roundRect(ctx, x, y, width, height, 16);
|
||||
ctx.stroke();
|
||||
|
||||
// 封面区域 - 渐变色
|
||||
const coverWidth = 85;
|
||||
const coverHeight = height - 24;
|
||||
const coverGradient = ctx.createLinearGradient(x + 12, y + 12, x + 12 + coverWidth, y + 12 + coverHeight);
|
||||
const colors = this.getCategoryGradient(story.category);
|
||||
coverGradient.addColorStop(0, colors[0]);
|
||||
coverGradient.addColorStop(1, colors[1]);
|
||||
ctx.fillStyle = coverGradient;
|
||||
this.roundRect(ctx, x + 12, y + 12, coverWidth, coverHeight, 12);
|
||||
ctx.fill();
|
||||
|
||||
// 封面上的分类名
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(story.category || '故事', x + 12 + coverWidth / 2, y + 12 + coverHeight / 2 + 4);
|
||||
|
||||
// 故事标题
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 17px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
const titleX = x + 110;
|
||||
const maxTextWidth = width - 120; // 可用文字宽度
|
||||
const title = this.truncateText(ctx, story.title, maxTextWidth);
|
||||
ctx.fillText(title, titleX, y + 35);
|
||||
|
||||
// 故事简介
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.font = '12px sans-serif';
|
||||
const desc = story.description || '';
|
||||
const shortDesc = this.truncateText(ctx, desc, maxTextWidth);
|
||||
ctx.fillText(shortDesc, titleX, y + 58);
|
||||
|
||||
// 统计信息带图标
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '11px sans-serif';
|
||||
const playText = `▶ ${this.formatNumber(story.play_count)}`;
|
||||
const likeText = `♥ ${this.formatNumber(story.like_count)}`;
|
||||
ctx.fillText(playText, titleX, y + 90);
|
||||
ctx.fillText(likeText, titleX + 70, y + 90);
|
||||
|
||||
// 精选标签 - 更醒目
|
||||
if (story.is_featured) {
|
||||
const tagGradient = ctx.createLinearGradient(x + width - 55, y + 12, x + width - 10, y + 12);
|
||||
tagGradient.addColorStop(0, '#ff6b6b');
|
||||
tagGradient.addColorStop(1, '#ffd700');
|
||||
ctx.fillStyle = tagGradient;
|
||||
this.roundRect(ctx, x + width - 55, y + 12, 45, 22, 11);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('精选', x + width - 32, y + 27);
|
||||
}
|
||||
}
|
||||
|
||||
getCategoryGradient(category) {
|
||||
const gradients = {
|
||||
'都市言情': ['#ff758c', '#ff7eb3'],
|
||||
'悬疑推理': ['#667eea', '#764ba2'],
|
||||
'古风宫廷': ['#f093fb', '#f5576c'],
|
||||
'校园青春': ['#4facfe', '#00f2fe'],
|
||||
'修仙玄幻': ['#43e97b', '#38f9d7'],
|
||||
'穿越重生': ['#fa709a', '#fee140'],
|
||||
'职场商战': ['#30cfd0', '#330867'],
|
||||
'科幻未来': ['#a8edea', '#fed6e3'],
|
||||
'恐怖惊悚': ['#434343', '#000000'],
|
||||
'搞笑轻喜': ['#f6d365', '#fda085']
|
||||
};
|
||||
return gradients[category] || ['#667eea', '#764ba2'];
|
||||
}
|
||||
|
||||
renderTabBar(ctx) {
|
||||
const tabHeight = 65;
|
||||
const y = this.screenHeight - tabHeight;
|
||||
|
||||
// Tab栏背景 - 毛玻璃
|
||||
ctx.fillStyle = 'rgba(15, 12, 41, 0.95)';
|
||||
ctx.fillRect(0, y, this.screenWidth, tabHeight);
|
||||
|
||||
// 顶部高光线
|
||||
const lineGradient = ctx.createLinearGradient(0, y, this.screenWidth, y);
|
||||
lineGradient.addColorStop(0, 'rgba(255,107,107,0.3)');
|
||||
lineGradient.addColorStop(0.5, 'rgba(255,215,0,0.3)');
|
||||
lineGradient.addColorStop(1, 'rgba(255,107,107,0.3)');
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(this.screenWidth, y);
|
||||
ctx.stroke();
|
||||
|
||||
const tabs = [
|
||||
{ icon: '🏠', label: '首页' },
|
||||
{ icon: '🔍', label: '发现' },
|
||||
{ icon: '👤', label: '我的' }
|
||||
];
|
||||
|
||||
const tabWidth = this.screenWidth / tabs.length;
|
||||
|
||||
tabs.forEach((tab, index) => {
|
||||
const centerX = index * tabWidth + tabWidth / 2;
|
||||
const isActive = index === this.currentTab;
|
||||
|
||||
// 选中态指示器
|
||||
if (isActive) {
|
||||
const indicatorGradient = ctx.createLinearGradient(centerX - 20, y + 3, centerX + 20, y + 3);
|
||||
indicatorGradient.addColorStop(0, '#ff6b6b');
|
||||
indicatorGradient.addColorStop(1, '#ffd700');
|
||||
ctx.fillStyle = indicatorGradient;
|
||||
this.roundRect(ctx, centerX - 20, y + 2, 40, 3, 1.5);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 图标
|
||||
ctx.font = '22px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(tab.icon, centerX, y + 32);
|
||||
|
||||
// 标签文字
|
||||
ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.4)';
|
||||
ctx.font = isActive ? 'bold 11px sans-serif' : '11px sans-serif';
|
||||
ctx.fillText(tab.label, centerX, y + 52);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取分类颜色
|
||||
getCategoryColor(category) {
|
||||
const colors = {
|
||||
'都市言情': '#e94560',
|
||||
'悬疑推理': '#4a90d9',
|
||||
'古风宫廷': '#d4a574',
|
||||
'校园青春': '#7ed957',
|
||||
'修仙玄幻': '#9b59b6',
|
||||
'穿越重生': '#f39c12',
|
||||
'职场商战': '#3498db',
|
||||
'科幻未来': '#1abc9c',
|
||||
'恐怖惊悚': '#2c3e50',
|
||||
'搞笑轻喜': '#f1c40f'
|
||||
};
|
||||
return colors[category] || '#666666';
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
formatNumber(num) {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + 'w';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
// 截断文字以适应宽度
|
||||
truncateText(ctx, text, maxWidth) {
|
||||
if (!text) return '';
|
||||
if (ctx.measureText(text).width <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
let truncated = text;
|
||||
while (truncated.length > 0 && ctx.measureText(truncated + '...').width > maxWidth) {
|
||||
truncated = truncated.slice(0, -1);
|
||||
}
|
||||
return truncated + '...';
|
||||
}
|
||||
|
||||
// 绘制圆角矩形
|
||||
roundRect(ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
onTouchStart(e) {
|
||||
const touch = e.touches[0];
|
||||
this.lastTouchY = touch.clientY;
|
||||
this.lastTouchX = touch.clientX;
|
||||
this.touchStartX = touch.clientX;
|
||||
this.touchStartY = touch.clientY;
|
||||
this.hasMoved = false;
|
||||
|
||||
// 判断是否在分类区域(y: 90-140)
|
||||
if (touch.clientY >= 90 && touch.clientY <= 140) {
|
||||
this.isCategoryDragging = true;
|
||||
this.isDragging = false;
|
||||
} else {
|
||||
this.isCategoryDragging = false;
|
||||
this.isDragging = true;
|
||||
}
|
||||
this.scrollVelocity = 0;
|
||||
}
|
||||
|
||||
onTouchMove(e) {
|
||||
const touch = e.touches[0];
|
||||
|
||||
// 分类区域横向滑动
|
||||
if (this.isCategoryDragging) {
|
||||
const deltaX = this.lastTouchX - touch.clientX;
|
||||
if (Math.abs(deltaX) > 2) {
|
||||
this.hasMoved = true;
|
||||
}
|
||||
this.categoryScrollX += deltaX;
|
||||
this.categoryScrollX = Math.max(0, Math.min(this.categoryScrollX, this.maxCategoryScrollX));
|
||||
this.lastTouchX = touch.clientX;
|
||||
return;
|
||||
}
|
||||
|
||||
// 故事列表纵向滑动
|
||||
if (this.isDragging) {
|
||||
const deltaY = this.lastTouchY - touch.clientY;
|
||||
|
||||
if (Math.abs(touch.clientY - this.touchStartY) > 5) {
|
||||
this.hasMoved = true;
|
||||
}
|
||||
|
||||
this.scrollVelocity = deltaY;
|
||||
this.scrollY += deltaY;
|
||||
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||
this.lastTouchY = touch.clientY;
|
||||
}
|
||||
}
|
||||
|
||||
onTouchEnd(e) {
|
||||
this.isDragging = false;
|
||||
this.isCategoryDragging = false;
|
||||
|
||||
// 如果有滑动,不处理点击
|
||||
if (this.hasMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测点击
|
||||
const touch = e.changedTouches[0];
|
||||
const x = touch.clientX;
|
||||
const y = touch.clientY;
|
||||
|
||||
// 检测Tab栏点击
|
||||
if (y > this.screenHeight - 65) {
|
||||
const tabWidth = this.screenWidth / 3;
|
||||
const tabIndex = Math.floor(x / tabWidth);
|
||||
this.handleTabClick(tabIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测分类标签点击
|
||||
if (y >= 90 && y <= 140) {
|
||||
this.handleCategoryClick(x);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测故事卡片点击
|
||||
this.handleStoryClick(x, y);
|
||||
}
|
||||
|
||||
handleCategoryClick(x) {
|
||||
// 考虑横向滚动偏移
|
||||
const adjustedX = x + this.categoryScrollX;
|
||||
|
||||
// 使用保存的实际位置判断
|
||||
for (const rect of this.categoryRects) {
|
||||
if (adjustedX >= rect.left && adjustedX <= rect.right) {
|
||||
if (this.selectedCategory !== rect.index) {
|
||||
this.selectedCategory = rect.index;
|
||||
this.scrollY = 0;
|
||||
this.calculateMaxScroll();
|
||||
console.log('选中分类:', this.categories[rect.index]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTabClick(tabIndex) {
|
||||
if (tabIndex === this.currentTab) return;
|
||||
|
||||
this.currentTab = tabIndex;
|
||||
|
||||
if (tabIndex === 2) {
|
||||
// 切换到个人中心
|
||||
this.main.sceneManager.switchScene('profile');
|
||||
}
|
||||
}
|
||||
|
||||
handleStoryClick(x, y) {
|
||||
const startY = 150;
|
||||
const cardHeight = 120;
|
||||
const cardMargin = 15;
|
||||
|
||||
const stories = this.getFilteredStories();
|
||||
|
||||
// 计算点击的是哪个故事
|
||||
const adjustedY = y + this.scrollY;
|
||||
const index = Math.floor((adjustedY - startY) / (cardHeight + cardMargin));
|
||||
|
||||
if (index >= 0 && index < stories.length) {
|
||||
const story = stories[index];
|
||||
// 跳转到故事播放场景
|
||||
this.main.sceneManager.switchScene('story', { storyId: story.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
371
client/js/scenes/ProfileScene.js
Normal file
371
client/js/scenes/ProfileScene.js
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 个人中心场景
|
||||
*/
|
||||
import BaseScene from './BaseScene';
|
||||
|
||||
export default class ProfileScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
super(main, params);
|
||||
this.collections = [];
|
||||
this.progress = [];
|
||||
this.currentTab = 0; // 0: 收藏, 1: 历史
|
||||
this.scrollY = 0;
|
||||
this.maxScrollY = 0;
|
||||
this.isDragging = false;
|
||||
this.lastTouchY = 0;
|
||||
this.scrollVelocity = 0;
|
||||
this.hasMoved = false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
if (this.main.userManager.isLoggedIn) {
|
||||
this.collections = await this.main.userManager.getCollections() || [];
|
||||
this.progress = await this.main.userManager.getProgress() || [];
|
||||
}
|
||||
this.calculateMaxScroll();
|
||||
}
|
||||
|
||||
calculateMaxScroll() {
|
||||
const list = this.currentTab === 0 ? this.collections : this.progress;
|
||||
const cardHeight = 90;
|
||||
const gap = 12;
|
||||
const headerHeight = 300;
|
||||
const contentHeight = list.length * (cardHeight + gap) + headerHeight;
|
||||
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 20);
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.isDragging && Math.abs(this.scrollVelocity) > 0.5) {
|
||||
this.scrollY += this.scrollVelocity;
|
||||
this.scrollVelocity *= 0.95;
|
||||
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||
}
|
||||
}
|
||||
|
||||
render(ctx) {
|
||||
// 背景渐变
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, this.screenHeight);
|
||||
gradient.addColorStop(0, '#0f0c29');
|
||||
gradient.addColorStop(0.5, '#302b63');
|
||||
gradient.addColorStop(1, '#24243e');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
|
||||
// 顶部返回
|
||||
this.renderHeader(ctx);
|
||||
|
||||
// 用户信息卡片
|
||||
this.renderUserCard(ctx);
|
||||
|
||||
// Tab切换
|
||||
this.renderTabs(ctx);
|
||||
|
||||
// 列表内容
|
||||
this.renderList(ctx);
|
||||
}
|
||||
|
||||
renderHeader(ctx) {
|
||||
// 顶部渐变遮罩
|
||||
const headerGradient = ctx.createLinearGradient(0, 0, 0, 60);
|
||||
headerGradient.addColorStop(0, 'rgba(0,0,0,0.5)');
|
||||
headerGradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = headerGradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, 60);
|
||||
|
||||
// 返回按钮
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '16px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('‹ 返回', 15, 35);
|
||||
|
||||
// 标题
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = 'bold 17px sans-serif';
|
||||
ctx.fillText('个人中心', this.screenWidth / 2, 35);
|
||||
}
|
||||
|
||||
renderUserCard(ctx) {
|
||||
const cardY = 60;
|
||||
const cardHeight = 170;
|
||||
const centerX = this.screenWidth / 2;
|
||||
const user = this.main.userManager;
|
||||
|
||||
// 卡片背景
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||
this.roundRect(ctx, 15, cardY, this.screenWidth - 30, cardHeight, 16);
|
||||
ctx.fill();
|
||||
|
||||
// 头像
|
||||
const avatarSize = 55;
|
||||
const avatarY = cardY + 20;
|
||||
const avatarGradient = ctx.createLinearGradient(centerX - 30, avatarY, centerX + 30, avatarY + avatarSize);
|
||||
avatarGradient.addColorStop(0, '#ff6b6b');
|
||||
avatarGradient.addColorStop(1, '#ffd700');
|
||||
ctx.fillStyle = avatarGradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 头像文字
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 22px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(user.nickname ? user.nickname[0] : '游', centerX, avatarY + avatarSize / 2 + 8);
|
||||
|
||||
// 昵称
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 15px sans-serif';
|
||||
ctx.fillText(user.nickname || '游客用户', centerX, avatarY + avatarSize + 20);
|
||||
|
||||
// 分割线
|
||||
const lineY = avatarY + avatarSize + 35;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(30, lineY);
|
||||
ctx.lineTo(this.screenWidth - 30, lineY);
|
||||
ctx.stroke();
|
||||
|
||||
// 统计信息
|
||||
const statsY = lineY + 30;
|
||||
const statWidth = (this.screenWidth - 30) / 3;
|
||||
const statsData = [
|
||||
{ num: this.progress.length, label: '游玩' },
|
||||
{ num: this.collections.length, label: '收藏' },
|
||||
{ num: this.progress.filter(p => p.is_completed).length, label: '结局' }
|
||||
];
|
||||
|
||||
statsData.forEach((stat, i) => {
|
||||
const x = 15 + statWidth * i + statWidth / 2;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.fillText(stat.num.toString(), x, statsY);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.fillText(stat.label, x, statsY + 16);
|
||||
});
|
||||
}
|
||||
|
||||
renderTabs(ctx) {
|
||||
const tabY = 245;
|
||||
const tabWidth = (this.screenWidth - 30) / 2;
|
||||
const tabs = ['我的收藏', '游玩记录'];
|
||||
|
||||
tabs.forEach((tab, index) => {
|
||||
const x = 15 + index * tabWidth;
|
||||
const isActive = index === this.currentTab;
|
||||
const centerX = x + tabWidth / 2;
|
||||
|
||||
// Tab背景
|
||||
if (isActive) {
|
||||
const tabGradient = ctx.createLinearGradient(x, tabY, x + tabWidth, tabY);
|
||||
tabGradient.addColorStop(0, '#ff6b6b');
|
||||
tabGradient.addColorStop(1, '#ffd700');
|
||||
ctx.fillStyle = tabGradient;
|
||||
this.roundRect(ctx, x + 10, tabY, tabWidth - 20, 32, 16);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Tab文字
|
||||
ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.5)';
|
||||
ctx.font = isActive ? 'bold 14px sans-serif' : '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(tab, centerX, tabY + 21);
|
||||
});
|
||||
}
|
||||
|
||||
renderList(ctx) {
|
||||
const list = this.currentTab === 0 ? this.collections : this.progress;
|
||||
const startY = 295;
|
||||
const cardHeight = 90;
|
||||
const cardMargin = 12;
|
||||
const padding = 15;
|
||||
|
||||
// 裁剪区域
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, startY - 10, this.screenWidth, this.screenHeight - startY + 10);
|
||||
ctx.clip();
|
||||
|
||||
if (list.length === 0) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(
|
||||
this.currentTab === 0 ? '还没有收藏的故事' : '还没有游玩记录',
|
||||
this.screenWidth / 2,
|
||||
startY + 50
|
||||
);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
list.forEach((item, index) => {
|
||||
const y = startY + index * (cardHeight + cardMargin) - this.scrollY;
|
||||
if (y > -cardHeight && y < this.screenHeight) {
|
||||
this.renderListCard(ctx, item, padding, y, this.screenWidth - padding * 2, cardHeight, index);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
renderListCard(ctx, item, x, y, width, height, index) {
|
||||
// 卡片背景
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||
this.roundRect(ctx, x, y, width, height, 12);
|
||||
ctx.fill();
|
||||
|
||||
// 封面
|
||||
const coverSize = 65;
|
||||
const coverColors = [
|
||||
['#ff758c', '#ff7eb3'],
|
||||
['#667eea', '#764ba2'],
|
||||
['#4facfe', '#00f2fe'],
|
||||
['#43e97b', '#38f9d7'],
|
||||
['#fa709a', '#fee140']
|
||||
];
|
||||
const colors = coverColors[index % coverColors.length];
|
||||
const coverGradient = ctx.createLinearGradient(x + 12, y + 12, x + 12 + coverSize, y + 12 + coverSize);
|
||||
coverGradient.addColorStop(0, colors[0]);
|
||||
coverGradient.addColorStop(1, colors[1]);
|
||||
ctx.fillStyle = coverGradient;
|
||||
this.roundRect(ctx, x + 12, y + 12, coverSize, coverSize, 10);
|
||||
ctx.fill();
|
||||
|
||||
// 封面文字
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(item.category || '故事', x + 12 + coverSize / 2, y + 12 + coverSize / 2 + 4);
|
||||
|
||||
// 标题
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 15px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
const title = item.story_title || item.title || '未知故事';
|
||||
ctx.fillText(title.length > 12 ? title.substring(0, 12) + '...' : title, x + 90, y + 35);
|
||||
|
||||
// 状态
|
||||
ctx.font = '12px sans-serif';
|
||||
if (this.currentTab === 1 && item.is_completed) {
|
||||
ctx.fillStyle = '#4ecca3';
|
||||
ctx.fillText('✓ 已完成', x + 90, y + 58);
|
||||
} else if (this.currentTab === 1) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.fillText('进行中...', x + 90, y + 58);
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.fillText(item.category || '', x + 90, y + 58);
|
||||
}
|
||||
|
||||
// 继续按钮
|
||||
const btnGradient = ctx.createLinearGradient(x + width - 65, y + 30, x + width - 10, y + 30);
|
||||
btnGradient.addColorStop(0, '#ff6b6b');
|
||||
btnGradient.addColorStop(1, '#ffd700');
|
||||
ctx.fillStyle = btnGradient;
|
||||
this.roundRect(ctx, x + width - 65, y + 30, 52, 28, 14);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('继续', x + width - 39, y + 49);
|
||||
}
|
||||
|
||||
// 圆角矩形
|
||||
roundRect(ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
onTouchStart(e) {
|
||||
const touch = e.touches[0];
|
||||
this.lastTouchY = touch.clientY;
|
||||
this.touchStartY = touch.clientY;
|
||||
this.isDragging = true;
|
||||
this.scrollVelocity = 0;
|
||||
this.hasMoved = false;
|
||||
}
|
||||
|
||||
onTouchMove(e) {
|
||||
if (!this.isDragging) return;
|
||||
const touch = e.touches[0];
|
||||
const deltaY = this.lastTouchY - touch.clientY;
|
||||
|
||||
if (Math.abs(touch.clientY - this.touchStartY) > 5) {
|
||||
this.hasMoved = true;
|
||||
}
|
||||
|
||||
this.scrollVelocity = deltaY;
|
||||
this.scrollY += deltaY;
|
||||
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScrollY));
|
||||
this.lastTouchY = touch.clientY;
|
||||
}
|
||||
|
||||
onTouchEnd(e) {
|
||||
this.isDragging = false;
|
||||
|
||||
if (this.hasMoved) return;
|
||||
|
||||
const touch = e.changedTouches[0];
|
||||
const x = touch.clientX;
|
||||
const y = touch.clientY;
|
||||
|
||||
// 返回按钮
|
||||
if (y < 50 && x < 80) {
|
||||
this.main.sceneManager.switchScene('home');
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab切换
|
||||
if (y >= 240 && y <= 285) {
|
||||
const tabWidth = (this.screenWidth - 30) / 2;
|
||||
const newTab = x < 15 + tabWidth ? 0 : 1;
|
||||
if (newTab !== this.currentTab) {
|
||||
this.currentTab = newTab;
|
||||
this.scrollY = 0;
|
||||
this.calculateMaxScroll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 卡片点击
|
||||
this.handleCardClick(x, y);
|
||||
}
|
||||
|
||||
handleCardClick(x, y) {
|
||||
const list = this.currentTab === 0 ? this.collections : this.progress;
|
||||
const startY = 295;
|
||||
const cardHeight = 90;
|
||||
const cardMargin = 12;
|
||||
const padding = 15;
|
||||
|
||||
const adjustedY = y + this.scrollY;
|
||||
const index = Math.floor((adjustedY - startY) / (cardHeight + cardMargin));
|
||||
|
||||
if (index >= 0 && index < list.length) {
|
||||
const item = list[index];
|
||||
const storyId = item.story_id || item.id;
|
||||
const cardY = startY + index * (cardHeight + cardMargin) - this.scrollY;
|
||||
const buttonX = padding + (this.screenWidth - padding * 2) - 65;
|
||||
|
||||
if (x >= buttonX && x <= buttonX + 52 && y >= cardY + 30 && y <= cardY + 58) {
|
||||
this.main.sceneManager.switchScene('story', { storyId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
client/js/scenes/SceneManager.js
Normal file
56
client/js/scenes/SceneManager.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 场景管理器
|
||||
*/
|
||||
import HomeScene from './HomeScene';
|
||||
import StoryScene from './StoryScene';
|
||||
import EndingScene from './EndingScene';
|
||||
import ProfileScene from './ProfileScene';
|
||||
import ChapterScene from './ChapterScene';
|
||||
|
||||
export default class SceneManager {
|
||||
constructor(main) {
|
||||
this.main = main;
|
||||
this.currentScene = null;
|
||||
this.scenes = {
|
||||
home: HomeScene,
|
||||
story: StoryScene,
|
||||
ending: EndingScene,
|
||||
profile: ProfileScene,
|
||||
chapter: ChapterScene
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换场景
|
||||
*/
|
||||
switchScene(sceneName, params = {}) {
|
||||
const SceneClass = this.scenes[sceneName];
|
||||
if (!SceneClass) {
|
||||
console.error('场景不存在:', sceneName);
|
||||
return;
|
||||
}
|
||||
|
||||
// 销毁当前场景
|
||||
if (this.currentScene && this.currentScene.destroy) {
|
||||
this.currentScene.destroy();
|
||||
}
|
||||
|
||||
// 创建新场景
|
||||
this.currentScene = new SceneClass(this.main, params);
|
||||
|
||||
// 初始化场景
|
||||
if (this.currentScene.init) {
|
||||
this.currentScene.init();
|
||||
}
|
||||
|
||||
console.log('切换到场景:', sceneName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前场景名称
|
||||
*/
|
||||
getCurrentSceneName() {
|
||||
if (!this.currentScene) return null;
|
||||
return this.currentScene.constructor.name;
|
||||
}
|
||||
}
|
||||
636
client/js/scenes/StoryScene.js
Normal file
636
client/js/scenes/StoryScene.js
Normal file
@@ -0,0 +1,636 @@
|
||||
/**
|
||||
* 故事播放场景 - 视觉小说风格
|
||||
*/
|
||||
import BaseScene from './BaseScene';
|
||||
|
||||
export default class StoryScene extends BaseScene {
|
||||
constructor(main, params) {
|
||||
super(main, params);
|
||||
this.storyId = params.storyId;
|
||||
this.aiContent = params.aiContent || null; // AI改写内容
|
||||
this.story = null;
|
||||
this.currentNode = null;
|
||||
this.displayText = '';
|
||||
this.targetText = '';
|
||||
this.charIndex = 0;
|
||||
this.typewriterSpeed = 40;
|
||||
this.lastTypeTime = 0;
|
||||
this.isTyping = false;
|
||||
this.showChoices = false;
|
||||
this.waitingForClick = false;
|
||||
this.selectedChoice = -1;
|
||||
this.fadeAlpha = 0;
|
||||
this.isFading = false;
|
||||
// 滚动相关
|
||||
this.textScrollY = 0;
|
||||
this.maxScrollY = 0;
|
||||
this.isDragging = false;
|
||||
this.lastTouchY = 0;
|
||||
// 场景图相关
|
||||
this.sceneImage = null;
|
||||
this.sceneColors = this.generateSceneColors();
|
||||
}
|
||||
|
||||
// 根据场景生成氛围色
|
||||
generateSceneColors() {
|
||||
const themes = [
|
||||
{ bg1: '#1a0a2e', bg2: '#16213e', accent: '#ff6b9d' }, // 浪漫
|
||||
{ bg1: '#0d1b2a', bg2: '#1b263b', accent: '#778da9' }, // 悬疑
|
||||
{ bg1: '#2d132c', bg2: '#801336', accent: '#ffd700' }, // 古风
|
||||
{ bg1: '#1a1a2e', bg2: '#0f3460', accent: '#00fff5' }, // 科幻
|
||||
{ bg1: '#0b1215', bg2: '#1e3a3a', accent: '#4ecca3' }, // 校园
|
||||
];
|
||||
return themes[Math.floor(Math.random() * themes.length)];
|
||||
}
|
||||
|
||||
async init() {
|
||||
// 如果是AI改写内容,直接播放
|
||||
if (this.aiContent) {
|
||||
this.story = this.main.storyManager.currentStory;
|
||||
if (this.story) {
|
||||
this.setThemeByCategory(this.story.category);
|
||||
}
|
||||
this.currentNode = this.aiContent;
|
||||
this.startTypewriter(this.aiContent.content);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是重新开始(已有故事数据)
|
||||
const existingStory = this.main.storyManager.currentStory;
|
||||
if (existingStory && existingStory.id === this.storyId) {
|
||||
// 重新开始,使用已有数据
|
||||
this.story = existingStory;
|
||||
this.setThemeByCategory(this.story.category);
|
||||
this.currentNode = this.main.storyManager.getCurrentNode();
|
||||
if (this.currentNode) {
|
||||
this.startTypewriter(this.currentNode.content);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 首次加载故事
|
||||
this.main.showLoading('加载故事中...');
|
||||
|
||||
this.story = await this.main.storyManager.loadStoryDetail(this.storyId);
|
||||
|
||||
if (!this.story) {
|
||||
this.main.showError('故事加载失败');
|
||||
this.main.sceneManager.switchScene('home');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据故事分类设置氛围
|
||||
this.setThemeByCategory(this.story.category);
|
||||
|
||||
this.main.hideLoading();
|
||||
|
||||
this.currentNode = this.main.storyManager.getCurrentNode();
|
||||
if (this.currentNode) {
|
||||
this.startTypewriter(this.currentNode.content);
|
||||
}
|
||||
}
|
||||
|
||||
setThemeByCategory(category) {
|
||||
const themes = {
|
||||
'都市言情': { bg1: '#1a0a2e', bg2: '#2d1b4e', accent: '#ff6b9d' },
|
||||
'悬疑推理': { bg1: '#0d1b2a', bg2: '#1b263b', accent: '#778da9' },
|
||||
'古风宫廷': { bg1: '#2d132c', bg2: '#4a1942', accent: '#ffd700' },
|
||||
'校园青春': { bg1: '#0a2540', bg2: '#1e5162', accent: '#4ecca3' },
|
||||
'修仙玄幻': { bg1: '#0f0f2d', bg2: '#1a1a4e', accent: '#a855f7' },
|
||||
'穿越重生': { bg1: '#1a0a2e', bg2: '#3d1a5c', accent: '#f472b6' },
|
||||
'职场商战': { bg1: '#0c1929', bg2: '#1e3a5f', accent: '#60a5fa' },
|
||||
'科幻未来': { bg1: '#0a1628', bg2: '#162033', accent: '#00fff5' },
|
||||
'恐怖惊悚': { bg1: '#0a0a0a', bg2: '#1a1a1a', accent: '#ef4444' },
|
||||
'搞笑轻喜': { bg1: '#1a1a2e', bg2: '#2d2d52', accent: '#fbbf24' }
|
||||
};
|
||||
this.sceneColors = themes[category] || this.sceneColors;
|
||||
}
|
||||
|
||||
startTypewriter(text) {
|
||||
this.targetText = text || '';
|
||||
this.displayText = '';
|
||||
this.charIndex = 0;
|
||||
this.isTyping = true;
|
||||
this.showChoices = false;
|
||||
this.waitingForClick = false;
|
||||
this.lastTypeTime = Date.now();
|
||||
// 重置滚动
|
||||
this.textScrollY = 0;
|
||||
this.maxScrollY = 0;
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.isTyping && this.charIndex < this.targetText.length) {
|
||||
const now = Date.now();
|
||||
if (now - this.lastTypeTime >= this.typewriterSpeed) {
|
||||
this.displayText += this.targetText[this.charIndex];
|
||||
this.charIndex++;
|
||||
this.lastTypeTime = now;
|
||||
}
|
||||
} else if (this.isTyping && this.charIndex >= this.targetText.length) {
|
||||
this.isTyping = false;
|
||||
// 打字完成,等待用户点击再显示选项
|
||||
this.waitingForClick = true;
|
||||
}
|
||||
|
||||
if (this.isFading) {
|
||||
this.fadeAlpha = Math.min(1, this.fadeAlpha + 0.05);
|
||||
if (this.fadeAlpha >= 1) {
|
||||
this.isFading = false;
|
||||
this.fadeAlpha = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(ctx) {
|
||||
// 1. 绘制场景背景
|
||||
this.renderSceneBackground(ctx);
|
||||
|
||||
// 2. 绘制场景装饰
|
||||
this.renderSceneDecoration(ctx);
|
||||
|
||||
// 3. 绘制顶部UI
|
||||
this.renderHeader(ctx);
|
||||
|
||||
// 4. 绘制对话框
|
||||
this.renderDialogBox(ctx);
|
||||
|
||||
// 5. 绘制选项
|
||||
if (this.showChoices) {
|
||||
this.renderChoices(ctx);
|
||||
}
|
||||
|
||||
// 6. 淡入淡出
|
||||
if (this.fadeAlpha > 0) {
|
||||
ctx.fillStyle = `rgba(0, 0, 0, ${this.fadeAlpha})`;
|
||||
ctx.fillRect(0, 0, this.screenWidth, this.screenHeight);
|
||||
}
|
||||
}
|
||||
|
||||
renderSceneBackground(ctx) {
|
||||
// 场景区域(上方45%)
|
||||
const sceneHeight = this.screenHeight * 0.42;
|
||||
|
||||
// 渐变背景
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, sceneHeight);
|
||||
gradient.addColorStop(0, this.sceneColors.bg1);
|
||||
gradient.addColorStop(1, this.sceneColors.bg2);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
|
||||
|
||||
// 底部渐变过渡到对话框
|
||||
const fadeGradient = ctx.createLinearGradient(0, sceneHeight - 60, 0, sceneHeight);
|
||||
fadeGradient.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
fadeGradient.addColorStop(1, 'rgba(15,15,30,1)');
|
||||
ctx.fillStyle = fadeGradient;
|
||||
ctx.fillRect(0, sceneHeight - 60, this.screenWidth, 60);
|
||||
|
||||
// 对话框区域背景
|
||||
ctx.fillStyle = '#0f0f1e';
|
||||
ctx.fillRect(0, sceneHeight, this.screenWidth, this.screenHeight - sceneHeight);
|
||||
}
|
||||
|
||||
renderSceneDecoration(ctx) {
|
||||
const sceneHeight = this.screenHeight * 0.42;
|
||||
const centerX = this.screenWidth / 2;
|
||||
|
||||
// 场景氛围光效
|
||||
const glowGradient = ctx.createRadialGradient(centerX, sceneHeight * 0.5, 0, centerX, sceneHeight * 0.5, 200);
|
||||
glowGradient.addColorStop(0, this.sceneColors.accent + '30');
|
||||
glowGradient.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = glowGradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, sceneHeight);
|
||||
|
||||
// 装饰粒子
|
||||
ctx.fillStyle = this.sceneColors.accent + '40';
|
||||
const particles = [[50, 100], [120, 180], [200, 80], [280, 150], [320, 60], [80, 250], [250, 220]];
|
||||
particles.forEach(([x, y]) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// 场景提示文字(中央)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const sceneHint = this.getSceneHint();
|
||||
ctx.fillText(sceneHint, centerX, sceneHeight * 0.45);
|
||||
}
|
||||
|
||||
getSceneHint() {
|
||||
if (!this.currentNode) return '故事开始...';
|
||||
const speaker = this.currentNode.speaker;
|
||||
if (speaker && speaker !== '旁白') {
|
||||
return `【${speaker}】`;
|
||||
}
|
||||
return '— 旁白 —';
|
||||
}
|
||||
|
||||
renderHeader(ctx) {
|
||||
// 顶部渐变遮罩
|
||||
const headerGradient = ctx.createLinearGradient(0, 0, 0, 80);
|
||||
headerGradient.addColorStop(0, 'rgba(0,0,0,0.7)');
|
||||
headerGradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = headerGradient;
|
||||
ctx.fillRect(0, 0, this.screenWidth, 80);
|
||||
|
||||
// 返回按钮
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '16px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('‹ 返回', 15, 35);
|
||||
|
||||
// 故事标题
|
||||
if (this.story) {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = 'bold 15px sans-serif';
|
||||
ctx.fillStyle = this.sceneColors.accent;
|
||||
const title = this.story.title.length > 10 ? this.story.title.substring(0, 10) + '...' : this.story.title;
|
||||
ctx.fillText(title, this.screenWidth / 2, 35);
|
||||
}
|
||||
|
||||
// 进度指示
|
||||
ctx.textAlign = 'right';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
const progress = this.main.storyManager.getProgress ? this.main.storyManager.getProgress() : '';
|
||||
ctx.fillText(progress, this.screenWidth - 15, 35);
|
||||
}
|
||||
|
||||
renderDialogBox(ctx) {
|
||||
const boxY = this.screenHeight * 0.42;
|
||||
const boxHeight = this.screenHeight * 0.58;
|
||||
const padding = 20;
|
||||
|
||||
// 对话框背景
|
||||
ctx.fillStyle = 'rgba(20, 20, 40, 0.95)';
|
||||
ctx.fillRect(0, boxY, this.screenWidth, boxHeight);
|
||||
|
||||
// 顶部装饰线
|
||||
const lineGradient = ctx.createLinearGradient(0, boxY, this.screenWidth, boxY);
|
||||
lineGradient.addColorStop(0, 'transparent');
|
||||
lineGradient.addColorStop(0.5, this.sceneColors.accent);
|
||||
lineGradient.addColorStop(1, 'transparent');
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, boxY);
|
||||
ctx.lineTo(this.screenWidth, boxY);
|
||||
ctx.stroke();
|
||||
|
||||
// 如果显示选项,不显示对话内容
|
||||
if (this.showChoices) return;
|
||||
|
||||
// 角色名
|
||||
if (this.currentNode && this.currentNode.speaker && this.currentNode.speaker !== '旁白') {
|
||||
// 角色名背景
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
const nameWidth = ctx.measureText(this.currentNode.speaker).width + 30;
|
||||
ctx.fillStyle = this.sceneColors.accent;
|
||||
this.roundRect(ctx, padding, boxY + 15, nameWidth, 28, 14);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(this.currentNode.speaker, padding + 15, boxY + 34);
|
||||
}
|
||||
|
||||
// 对话内容
|
||||
const textY = boxY + 65;
|
||||
const lineHeight = 26;
|
||||
const maxWidth = this.screenWidth - padding * 2;
|
||||
const visibleHeight = boxHeight - 105; // 可见区域高度
|
||||
|
||||
ctx.font = '15px sans-serif';
|
||||
const allLines = this.getWrappedLines(ctx, this.displayText, maxWidth);
|
||||
const totalTextHeight = allLines.length * lineHeight;
|
||||
|
||||
// 计算最大滚动距离
|
||||
this.maxScrollY = Math.max(0, totalTextHeight - visibleHeight);
|
||||
|
||||
// 自动滚动到最新内容(打字时)
|
||||
if (this.isTyping) {
|
||||
this.textScrollY = this.maxScrollY;
|
||||
}
|
||||
|
||||
// 设置裁剪区域(从文字顶部开始)
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, textY - 18, this.screenWidth, visibleHeight + 18);
|
||||
ctx.clip();
|
||||
|
||||
// 绘制文字(带滚动偏移)
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.textAlign = 'left';
|
||||
allLines.forEach((line, i) => {
|
||||
const y = textY + i * lineHeight - this.textScrollY;
|
||||
ctx.fillText(line, padding, y);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// 滚动指示器(如果可以滚动)
|
||||
if (this.maxScrollY > 0) {
|
||||
const scrollBarHeight = 40;
|
||||
const scrollBarY = boxY + 55 + (this.textScrollY / this.maxScrollY) * (visibleHeight - scrollBarHeight);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
||||
this.roundRect(ctx, this.screenWidth - 6, scrollBarY, 3, scrollBarHeight, 1.5);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 打字机光标
|
||||
if (this.isTyping) {
|
||||
const cursorBlink = Math.floor(Date.now() / 500) % 2 === 0;
|
||||
if (cursorBlink) {
|
||||
ctx.fillStyle = this.sceneColors.accent;
|
||||
ctx.font = '15px sans-serif';
|
||||
ctx.fillText('▌', padding + this.getTextEndX(ctx, this.displayText, maxWidth), textY + this.getTextEndY(this.displayText, maxWidth, lineHeight));
|
||||
}
|
||||
}
|
||||
|
||||
// 继续提示
|
||||
if (!this.isTyping && !this.main.storyManager.isEnding()) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('点击继续 ▼', this.screenWidth / 2, this.screenHeight - 25);
|
||||
}
|
||||
}
|
||||
|
||||
getTextEndX(ctx, text, maxWidth) {
|
||||
const lines = this.getWrappedLines(ctx, text, maxWidth);
|
||||
if (lines.length === 0) return 0;
|
||||
return ctx.measureText(lines[lines.length - 1]).width;
|
||||
}
|
||||
|
||||
getTextEndY(text, maxWidth, lineHeight) {
|
||||
const lines = text.split('\n');
|
||||
return (lines.length - 1) * lineHeight;
|
||||
}
|
||||
|
||||
getWrappedLines(ctx, text, maxWidth) {
|
||||
const lines = [];
|
||||
const paragraphs = text.split('\n');
|
||||
paragraphs.forEach(para => {
|
||||
let line = '';
|
||||
for (let char of para) {
|
||||
const testLine = line + char;
|
||||
if (ctx.measureText(testLine).width > maxWidth) {
|
||||
lines.push(line);
|
||||
line = char;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
renderChoices(ctx) {
|
||||
if (!this.currentNode || !this.currentNode.choices) return;
|
||||
|
||||
const choices = this.currentNode.choices;
|
||||
const choiceHeight = 50;
|
||||
const choiceMargin = 10;
|
||||
const padding = 20;
|
||||
const startY = this.screenHeight * 0.42 + 30;
|
||||
|
||||
// 半透明遮罩
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.fillRect(0, this.screenHeight * 0.42, this.screenWidth, this.screenHeight * 0.58);
|
||||
|
||||
// 提示文字
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('请做出选择', this.screenWidth / 2, startY);
|
||||
|
||||
choices.forEach((choice, index) => {
|
||||
const y = startY + 25 + index * (choiceHeight + choiceMargin);
|
||||
const isSelected = index === this.selectedChoice;
|
||||
|
||||
// 选项背景
|
||||
if (isSelected) {
|
||||
const gradient = ctx.createLinearGradient(padding, y, this.screenWidth - padding, y);
|
||||
gradient.addColorStop(0, this.sceneColors.accent);
|
||||
gradient.addColorStop(1, this.sceneColors.accent + 'aa');
|
||||
ctx.fillStyle = gradient;
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.1)';
|
||||
}
|
||||
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, choiceHeight, 25);
|
||||
ctx.fill();
|
||||
|
||||
// 选项边框
|
||||
ctx.strokeStyle = isSelected ? this.sceneColors.accent : 'rgba(255,255,255,0.2)';
|
||||
ctx.lineWidth = 1.5;
|
||||
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, choiceHeight, 25);
|
||||
ctx.stroke();
|
||||
|
||||
// 选项文本
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(choice.text, this.screenWidth / 2, y + 30);
|
||||
|
||||
// 锁定图标
|
||||
if (choice.isLocked) {
|
||||
ctx.fillStyle = '#ffd700';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('🔒 看广告解锁', this.screenWidth - padding - 15, y + 30);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 文字换行
|
||||
wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
||||
if (!text) return;
|
||||
const lines = this.getWrappedLines(ctx, text, maxWidth);
|
||||
lines.forEach((line, i) => {
|
||||
ctx.fillText(line, x, y + i * lineHeight);
|
||||
});
|
||||
}
|
||||
|
||||
// 文字换行(限制行数)
|
||||
wrapTextWithLimit(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||
if (!text) return;
|
||||
let lines = this.getWrappedLines(ctx, text, maxWidth);
|
||||
|
||||
// 如果超出最大行数,只显示最后几行(滚动效果)
|
||||
if (lines.length > maxLines) {
|
||||
lines = lines.slice(lines.length - maxLines);
|
||||
}
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
ctx.fillText(line, x, y + i * lineHeight);
|
||||
});
|
||||
}
|
||||
|
||||
// 把行数组分成多页
|
||||
splitIntoPages(lines, linesPerPage) {
|
||||
const pages = [];
|
||||
for (let i = 0; i < lines.length; i += linesPerPage) {
|
||||
pages.push(lines.slice(i, i + linesPerPage));
|
||||
}
|
||||
return pages.length > 0 ? pages : [[]];
|
||||
}
|
||||
|
||||
onTouchStart(e) {
|
||||
const touch = e.touches[0];
|
||||
this.touchStartY = touch.clientY;
|
||||
this.touchStartX = touch.clientX;
|
||||
this.lastTouchY = touch.clientY;
|
||||
this.hasMoved = false;
|
||||
|
||||
// 判断是否在对话框区域
|
||||
const boxY = this.screenHeight * 0.42;
|
||||
if (touch.clientY > boxY) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
}
|
||||
|
||||
onTouchMove(e) {
|
||||
const touch = e.touches[0];
|
||||
|
||||
// 滑动对话框内容
|
||||
if (this.isDragging && this.maxScrollY > 0) {
|
||||
const deltaY = this.lastTouchY - touch.clientY;
|
||||
if (Math.abs(deltaY) > 2) {
|
||||
this.hasMoved = true;
|
||||
}
|
||||
this.textScrollY += deltaY;
|
||||
this.textScrollY = Math.max(0, Math.min(this.textScrollY, this.maxScrollY));
|
||||
this.lastTouchY = touch.clientY;
|
||||
}
|
||||
}
|
||||
|
||||
onTouchEnd(e) {
|
||||
this.isDragging = false;
|
||||
|
||||
const touch = e.changedTouches[0];
|
||||
const x = touch.clientX;
|
||||
const y = touch.clientY;
|
||||
|
||||
// 如果滑动过,不处理点击
|
||||
if (this.hasMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 返回按钮
|
||||
if (y < 60 && x < 80) {
|
||||
this.main.sceneManager.switchScene('home');
|
||||
return;
|
||||
}
|
||||
|
||||
// 加速打字
|
||||
if (this.isTyping) {
|
||||
this.displayText = this.targetText;
|
||||
this.charIndex = this.targetText.length;
|
||||
this.isTyping = false;
|
||||
this.waitingForClick = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待点击后显示选项或结局
|
||||
if (this.waitingForClick) {
|
||||
this.waitingForClick = false;
|
||||
|
||||
// 检查是否是结局
|
||||
if (this.main.storyManager.isEnding()) {
|
||||
this.main.sceneManager.switchScene('ending', {
|
||||
storyId: this.storyId,
|
||||
ending: this.main.storyManager.getEndingInfo()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示选项
|
||||
if (this.currentNode && this.currentNode.choices && this.currentNode.choices.length > 0) {
|
||||
this.showChoices = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 选项点击
|
||||
if (this.showChoices && this.currentNode && this.currentNode.choices) {
|
||||
const choices = this.currentNode.choices;
|
||||
const choiceHeight = 50;
|
||||
const choiceMargin = 10;
|
||||
const padding = 20;
|
||||
const startY = this.screenHeight * 0.42 + 55;
|
||||
|
||||
for (let i = 0; i < choices.length; i++) {
|
||||
const choiceY = startY + i * (choiceHeight + choiceMargin);
|
||||
if (y >= choiceY && y <= choiceY + choiceHeight && x >= padding && x <= this.screenWidth - padding) {
|
||||
this.handleChoiceSelect(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleChoiceSelect(index) {
|
||||
const choice = this.currentNode.choices[index];
|
||||
|
||||
if (choice.isLocked) {
|
||||
wx.showModal({
|
||||
title: '解锁剧情',
|
||||
content: '观看广告解锁隐藏剧情?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.unlockAndSelect(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectChoice(index);
|
||||
}
|
||||
|
||||
unlockAndSelect(index) {
|
||||
this.selectChoice(index);
|
||||
}
|
||||
|
||||
selectChoice(index) {
|
||||
this.isFading = true;
|
||||
this.fadeAlpha = 0;
|
||||
this.showChoices = false;
|
||||
|
||||
setTimeout(() => {
|
||||
this.currentNode = this.main.storyManager.selectChoice(index);
|
||||
if (this.currentNode) {
|
||||
this.startTypewriter(this.currentNode.content);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 圆角矩形
|
||||
roundRect(ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.main.userManager.isLoggedIn && this.story) {
|
||||
this.main.userManager.saveProgress(
|
||||
this.storyId,
|
||||
this.main.storyManager.currentNodeKey,
|
||||
this.main.storyManager.isEnding(),
|
||||
this.main.storyManager.isEnding() ? this.main.storyManager.getEndingInfo().name : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
client/js/utils/http.js
Normal file
55
client/js/utils/http.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 网络请求工具
|
||||
*/
|
||||
|
||||
// API基础地址(开发环境)
|
||||
const BASE_URL = 'http://localhost:3000/api';
|
||||
|
||||
/**
|
||||
* 发送HTTP请求
|
||||
*/
|
||||
export function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('请求超时'));
|
||||
}, 5000);
|
||||
|
||||
wx.request({
|
||||
url: BASE_URL + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.header
|
||||
},
|
||||
success(res) {
|
||||
clearTimeout(timeout);
|
||||
if (res.data.code === 0) {
|
||||
resolve(res.data.data);
|
||||
} else {
|
||||
reject(new Error(res.data.message || '请求失败'));
|
||||
}
|
||||
},
|
||||
fail(err) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
*/
|
||||
export function get(url, data) {
|
||||
return request({ url, method: 'GET', data });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
*/
|
||||
export function post(url, data) {
|
||||
return request({ url, method: 'POST', data });
|
||||
}
|
||||
|
||||
export default { request, get, post };
|
||||
Reference in New Issue
Block a user