2026-03-03 16:57:49 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 章节选择场景
|
|
|
|
|
|
*/
|
|
|
|
|
|
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;
|
2026-03-06 13:16:54 +08:00
|
|
|
|
const bottomPadding = 50; // 底部留出空间
|
|
|
|
|
|
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight + bottomPadding;
|
|
|
|
|
|
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight);
|
|
|
|
|
|
console.log('[ChapterScene] nodeList长度:', this.nodeList.length, 'contentHeight:', contentHeight, 'screenHeight:', this.screenHeight, 'maxScrollY:', this.maxScrollY);
|
2026-03-03 16:57:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-06 13:16:54 +08:00
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
|
|
|
|
|
this.roundRect(ctx, this.screenWidth - 8, scrollBarY, 5, scrollBarHeight, 2.5);
|
2026-03-03 16:57:49 +08:00
|
|
|
|
ctx.fill();
|
2026-03-06 13:16:54 +08:00
|
|
|
|
|
|
|
|
|
|
// 如果还没滚动到底部,显示提示
|
|
|
|
|
|
if (this.scrollY < this.maxScrollY - 10) {
|
|
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
|
|
|
|
|
ctx.font = '12px sans-serif';
|
|
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
|
|
ctx.fillText('↑ 上滑查看更多 ↑', this.screenWidth / 2, this.screenHeight - 15);
|
|
|
|
|
|
}
|
2026-03-03 16:57:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|