2026-03-03 16:57:49 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 故事播放场景 - 视觉小说风格
|
|
|
|
|
|
*/
|
|
|
|
|
|
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();
|
2026-03-06 13:16:54 +08:00
|
|
|
|
// AI改写相关
|
|
|
|
|
|
this.isAIRewriting = false;
|
2026-03-03 16:57:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据场景生成氛围色
|
|
|
|
|
|
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;
|
2026-03-06 13:16:54 +08:00
|
|
|
|
const title = this.story.title.length > 8 ? this.story.title.substring(0, 8) + '...' : this.story.title;
|
2026-03-03 16:57:49 +08:00
|
|
|
|
ctx.fillText(title, this.screenWidth / 2, 35);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 13:16:54 +08:00
|
|
|
|
// AI改写按钮(右上角)
|
|
|
|
|
|
const btnX = this.screenWidth - 70;
|
|
|
|
|
|
const btnY = 18;
|
|
|
|
|
|
const btnW = 55;
|
|
|
|
|
|
const btnH = 26;
|
|
|
|
|
|
|
|
|
|
|
|
// 按钮背景
|
|
|
|
|
|
if (this.isAIRewriting) {
|
|
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const gradient = ctx.createLinearGradient(btnX, btnY, btnX + btnW, btnY);
|
|
|
|
|
|
gradient.addColorStop(0, '#a855f7');
|
|
|
|
|
|
gradient.addColorStop(1, '#ec4899');
|
|
|
|
|
|
ctx.fillStyle = gradient;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.roundRect(ctx, btnX, btnY, btnW, btnH, 13);
|
|
|
|
|
|
ctx.fill();
|
|
|
|
|
|
|
|
|
|
|
|
// 按钮文字
|
|
|
|
|
|
ctx.fillStyle = '#ffffff';
|
|
|
|
|
|
ctx.font = '11px sans-serif';
|
|
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
|
|
ctx.fillText(this.isAIRewriting ? '生成中' : 'AI改写', btnX + btnW / 2, btnY + 17);
|
2026-03-03 16:57:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-06 13:16:54 +08:00
|
|
|
|
const visibleHeight = boxHeight - 90; // 增加可见区域高度
|
2026-03-03 16:57:49 +08:00
|
|
|
|
|
|
|
|
|
|
ctx.font = '15px sans-serif';
|
|
|
|
|
|
const allLines = this.getWrappedLines(ctx, this.displayText, maxWidth);
|
|
|
|
|
|
const totalTextHeight = allLines.length * lineHeight;
|
|
|
|
|
|
|
|
|
|
|
|
// 计算最大滚动距离
|
2026-03-06 13:16:54 +08:00
|
|
|
|
this.maxScrollY = Math.max(0, totalTextHeight - visibleHeight + 30);
|
2026-03-03 16:57:49 +08:00
|
|
|
|
|
|
|
|
|
|
// 自动滚动到最新内容(打字时)
|
|
|
|
|
|
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);
|
2026-03-06 13:16:54 +08:00
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
|
|
|
|
|
this.roundRect(ctx, this.screenWidth - 8, scrollBarY, 4, scrollBarHeight, 2);
|
2026-03-03 16:57:49 +08:00
|
|
|
|
ctx.fill();
|
2026-03-06 13:16:54 +08:00
|
|
|
|
|
|
|
|
|
|
// 如果还没滚动到底部,显示滚动提示
|
|
|
|
|
|
if (this.textScrollY < this.maxScrollY - 10 && !this.isTyping) {
|
|
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
|
|
|
|
|
ctx.font = '12px sans-serif';
|
|
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
|
|
ctx.fillText('↑ 上滑查看更多 ↑', this.screenWidth / 2, this.screenHeight - 25);
|
|
|
|
|
|
}
|
2026-03-03 16:57:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 打字机光标
|
|
|
|
|
|
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];
|
|
|
|
|
|
|
|
|
|
|
|
// 滑动对话框内容
|
2026-03-06 13:16:54 +08:00
|
|
|
|
if (this.isDragging) {
|
2026-03-03 16:57:49 +08:00
|
|
|
|
const deltaY = this.lastTouchY - touch.clientY;
|
|
|
|
|
|
if (Math.abs(deltaY) > 2) {
|
|
|
|
|
|
this.hasMoved = true;
|
|
|
|
|
|
}
|
2026-03-06 13:16:54 +08:00
|
|
|
|
if (this.maxScrollY > 0) {
|
|
|
|
|
|
this.textScrollY += deltaY;
|
|
|
|
|
|
this.textScrollY = Math.max(0, Math.min(this.textScrollY, this.maxScrollY));
|
|
|
|
|
|
}
|
2026-03-03 16:57:49 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 13:16:54 +08:00
|
|
|
|
// AI改写按钮点击
|
|
|
|
|
|
const btnX = this.screenWidth - 70;
|
|
|
|
|
|
const btnY = 18;
|
|
|
|
|
|
const btnW = 55;
|
|
|
|
|
|
const btnH = 26;
|
|
|
|
|
|
if (y >= btnY && y <= btnY + btnH && x >= btnX && x <= btnX + btnW) {
|
|
|
|
|
|
if (!this.isAIRewriting) {
|
|
|
|
|
|
this.showAIRewriteInput();
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 16:57:49 +08:00
|
|
|
|
// 加速打字
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-05 15:57:51 +08:00
|
|
|
|
// AI改写内容 - 直接跳转到新结局
|
|
|
|
|
|
if (this.aiContent && this.aiContent.is_ending) {
|
|
|
|
|
|
console.log('AI改写内容:', JSON.stringify(this.aiContent));
|
|
|
|
|
|
this.main.sceneManager.switchScene('ending', {
|
|
|
|
|
|
storyId: this.storyId,
|
|
|
|
|
|
ending: {
|
|
|
|
|
|
name: this.aiContent.ending_name,
|
|
|
|
|
|
type: this.aiContent.ending_type,
|
|
|
|
|
|
content: this.aiContent.content,
|
|
|
|
|
|
score: 100
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 16:57:49 +08:00
|
|
|
|
// 检查是否是结局
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 13:16:54 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 显示AI改写输入框
|
|
|
|
|
|
*/
|
|
|
|
|
|
showAIRewriteInput() {
|
|
|
|
|
|
wx.showModal({
|
|
|
|
|
|
title: 'AI改写剧情',
|
|
|
|
|
|
editable: true,
|
|
|
|
|
|
placeholderText: '输入你的改写指令,如"让主角暴富"',
|
|
|
|
|
|
success: (res) => {
|
|
|
|
|
|
if (res.confirm && res.content) {
|
|
|
|
|
|
this.doAIRewrite(res.content);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 执行AI改写
|
|
|
|
|
|
*/
|
|
|
|
|
|
async doAIRewrite(prompt) {
|
|
|
|
|
|
if (this.isAIRewriting) return;
|
|
|
|
|
|
|
|
|
|
|
|
this.isAIRewriting = true;
|
|
|
|
|
|
this.main.showLoading('AI正在改写剧情...');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const userId = this.main.userManager.userId || 0;
|
|
|
|
|
|
const newNode = await this.main.storyManager.rewriteBranch(
|
|
|
|
|
|
this.storyId,
|
|
|
|
|
|
prompt,
|
|
|
|
|
|
userId
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
this.main.hideLoading();
|
|
|
|
|
|
|
|
|
|
|
|
if (newNode) {
|
|
|
|
|
|
// 成功获取新分支,开始播放
|
|
|
|
|
|
this.currentNode = newNode;
|
|
|
|
|
|
this.startTypewriter(newNode.content);
|
|
|
|
|
|
wx.showToast({
|
|
|
|
|
|
title: '改写成功!',
|
|
|
|
|
|
icon: 'success',
|
|
|
|
|
|
duration: 1500
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// AI 失败,继续原故事
|
|
|
|
|
|
wx.showToast({
|
|
|
|
|
|
title: 'AI暂时不可用,继续原故事',
|
|
|
|
|
|
icon: 'none',
|
|
|
|
|
|
duration: 2000
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
this.main.hideLoading();
|
|
|
|
|
|
console.error('AI改写出错:', error);
|
|
|
|
|
|
wx.showToast({
|
|
|
|
|
|
title: '网络错误,请重试',
|
|
|
|
|
|
icon: 'none',
|
|
|
|
|
|
duration: 2000
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.isAIRewriting = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 16:57:49 +08:00
|
|
|
|
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 : ''
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|