feat: 星域故事汇小游戏初始版本
This commit is contained in:
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user