/** * 首页场景 - 支持UGC */ 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; // 底部Tab: 首页/发现/创作/我的 this.bottomTab = 0; // 内容源Tab: 推荐/关注/最新/排行 this.contentTab = 0; this.contentTabs = ['推荐', '关注', '最新', '排行']; // 分类标签 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() { let stories = this.storyList; // 按内容源筛选 if (this.contentTab === 1) { // 关注:暂时返回空(需要后端支持) stories = []; } else if (this.contentTab === 2) { // 最新:按时间排序 stories = [...stories].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); } else if (this.contentTab === 3) { // 排行:按播放量排序 stories = [...stories].sort((a, b) => b.play_count - a.play_count); } // 按分类筛选 if (this.selectedCategory > 0) { const categoryName = this.categories[this.selectedCategory]; stories = stories.filter(s => s.category === categoryName); } return stories; } calculateMaxScroll() { const stories = this.getFilteredStories(); const cardHeight = 130; const gap = 12; const startY = 175; const tabHeight = 60; const contentBottom = startY + stories.length * (cardHeight + gap); 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) { this.renderBackground(ctx); this.renderHeader(ctx); this.renderContentTabs(ctx); this.renderCategories(ctx); this.renderStoryList(ctx); this.renderBottomTabBar(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); // 星空点缀 ctx.fillStyle = 'rgba(255,255,255,0.3)'; [[30, 80], [80, 30], [150, 60], [200, 25], [280, 70], [320, 40]].forEach(([x, y]) => { ctx.beginPath(); ctx.arc(x, y, 1, 0, Math.PI * 2); ctx.fill(); }); } renderHeader(ctx) { // 标题 const titleGradient = ctx.createLinearGradient(15, 30, 180, 30); titleGradient.addColorStop(0, '#ffd700'); titleGradient.addColorStop(1, '#ff6b6b'); ctx.fillStyle = titleGradient; ctx.font = 'bold 22px sans-serif'; ctx.textAlign = 'left'; ctx.fillText('星域故事汇', 15, 42); // 搜索按钮 const searchX = this.screenWidth - 45; ctx.fillStyle = 'rgba(255,255,255,0.1)'; ctx.beginPath(); ctx.arc(searchX, 35, 18, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.font = '16px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('🔍', searchX, 41); this.searchBtnRect = { x: searchX - 18, y: 17, width: 36, height: 36 }; } renderContentTabs(ctx) { const tabY = 60; const tabWidth = (this.screenWidth - 30) / 4; const padding = 15; this.contentTabRects = []; this.contentTabs.forEach((tab, index) => { const x = padding + index * tabWidth; const isActive = index === this.contentTab; // 文字 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, x + tabWidth / 2, tabY + 12); // 下划线指示器 if (isActive) { const lineGradient = ctx.createLinearGradient(x + tabWidth / 2 - 12, tabY + 20, x + tabWidth / 2 + 12, tabY + 20); lineGradient.addColorStop(0, '#ff6b6b'); lineGradient.addColorStop(1, '#ffd700'); ctx.fillStyle = lineGradient; this.roundRect(ctx, x + tabWidth / 2 - 12, tabY + 18, 24, 3, 1.5); ctx.fill(); } this.contentTabRects.push({ x, y: tabY - 5, width: tabWidth, height: 30, index }); }); } renderCategories(ctx) { const startY = 95; const tagHeight = 28; let x = 15 - this.categoryScrollX; ctx.font = '12px sans-serif'; let totalWidth = 15; this.categories.forEach((cat) => { totalWidth += ctx.measureText(cat).width + 24 + 10; }); 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 + 24; this.categoryRects.push({ left: x + this.categoryScrollX, right: x + this.categoryScrollX + textWidth, index }); if (x + textWidth > 0 && x < this.screenWidth) { if (isSelected) { const tagGradient = ctx.createLinearGradient(x, startY, x + textWidth, startY); tagGradient.addColorStop(0, '#ff6b6b'); tagGradient.addColorStop(1, '#ffd700'); ctx.fillStyle = tagGradient; } else { ctx.fillStyle = 'rgba(255,255,255,0.08)'; } this.roundRect(ctx, x, startY, textWidth, tagHeight, 14); ctx.fill(); if (!isSelected) { ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; this.roundRect(ctx, x, startY, textWidth, tagHeight, 14); ctx.stroke(); } ctx.fillStyle = isSelected ? '#ffffff' : 'rgba(255,255,255,0.6)'; ctx.font = isSelected ? 'bold 12px sans-serif' : '12px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(cat, x + textWidth / 2, startY + 18); } x += textWidth + 10; }); } renderStoryList(ctx) { const startY = 140; const cardHeight = 130; const cardMargin = 12; const stories = this.getFilteredStories(); if (stories.length === 0) { ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; const emptyText = this.contentTab === 1 ? '关注作者后,这里会显示他们的故事' : '暂无故事'; ctx.fillText(emptyText, this.screenWidth / 2, 280); return; } ctx.save(); ctx.beginPath(); ctx.rect(0, startY, this.screenWidth, this.screenHeight - startY - 60); ctx.clip(); stories.forEach((story, index) => { const y = startY + index * (cardHeight + cardMargin) - this.scrollY; if (y > startY - cardHeight && y < this.screenHeight - 60) { this.renderStoryCard(ctx, story, 12, y, this.screenWidth - 24, 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, 14); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; this.roundRect(ctx, x, y, width, height, 14); ctx.stroke(); // 封面 const coverW = 80, coverH = height - 20; const coverGradient = ctx.createLinearGradient(x + 10, y + 10, x + 10 + coverW, y + 10 + coverH); const colors = this.getCategoryGradient(story.category); coverGradient.addColorStop(0, colors[0]); coverGradient.addColorStop(1, colors[1]); ctx.fillStyle = coverGradient; this.roundRect(ctx, x + 10, y + 10, coverW, coverH, 10); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.font = 'bold 10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(story.category || '故事', x + 10 + coverW / 2, y + 10 + coverH / 2 + 4); const textX = x + 100; const maxW = width - 115; // 故事标题 ctx.fillStyle = '#ffffff'; ctx.font = 'bold 15px sans-serif'; ctx.textAlign = 'left'; ctx.fillText(this.truncateText(ctx, story.title, maxW), textX, y + 28); // 作者信息 ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = '11px sans-serif'; const authorName = story.author_name || '官方'; ctx.fillText(`@${authorName}`, textX, y + 48); // 来源标签 const isOfficial = !story.source_type || story.source_type === 'official'; const tagText = isOfficial ? '官方' : (story.source_type === 'ai' ? 'AI' : 'UGC'); const tagColor = isOfficial ? '#ffd700' : (story.source_type === 'ai' ? '#a855f7' : '#4ade80'); const tagW = ctx.measureText(tagText).width + 12; const tagX = textX + ctx.measureText(`@${authorName}`).width + 8; ctx.fillStyle = tagColor + '22'; this.roundRect(ctx, tagX, y + 38, tagW, 16, 8); ctx.fill(); ctx.fillStyle = tagColor; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(tagText, tagX + tagW / 2, y + 49); // 简介 ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.font = '11px sans-serif'; ctx.textAlign = 'left'; ctx.fillText(this.truncateText(ctx, story.description || '', maxW), textX, y + 72); // 统计 ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '10px sans-serif'; ctx.fillText(`▶ ${this.formatNumber(story.play_count || 0)}`, textX, y + 100); ctx.fillText(`♥ ${this.formatNumber(story.like_count || 0)}`, textX + 55, y + 100); ctx.fillText(`💬 ${this.formatNumber(story.comment_count || 0)}`, textX + 105, y + 100); // 精选标签 if (story.is_featured) { const fGradient = ctx.createLinearGradient(x + width - 50, y + 10, x + width - 10, y + 10); fGradient.addColorStop(0, '#ff6b6b'); fGradient.addColorStop(1, '#ffd700'); ctx.fillStyle = fGradient; this.roundRect(ctx, x + width - 50, y + 10, 40, 20, 10); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('精选', x + width - 30, y + 24); } } renderBottomTabBar(ctx) { const tabH = 60; const y = this.screenHeight - tabH; ctx.fillStyle = 'rgba(15, 12, 41, 0.98)'; ctx.fillRect(0, y, this.screenWidth, tabH); const lineGradient = ctx.createLinearGradient(0, y, this.screenWidth, y); lineGradient.addColorStop(0, 'rgba(255,107,107,0.2)'); lineGradient.addColorStop(0.5, 'rgba(255,215,0,0.2)'); lineGradient.addColorStop(1, 'rgba(255,107,107,0.2)'); 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: '创作' }, { icon: '👤', label: '我的' } ]; const tabW = this.screenWidth / tabs.length; this.bottomTabRects = []; tabs.forEach((tab, index) => { const centerX = index * tabW + tabW / 2; const isActive = index === this.bottomTab; if (isActive) { const indGradient = ctx.createLinearGradient(centerX - 16, y + 2, centerX + 16, y + 2); indGradient.addColorStop(0, '#ff6b6b'); indGradient.addColorStop(1, '#ffd700'); ctx.fillStyle = indGradient; this.roundRect(ctx, centerX - 16, y + 2, 32, 3, 1.5); ctx.fill(); } ctx.font = index === 2 ? '24px sans-serif' : '20px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(tab.icon, centerX, y + 28); ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.4)'; ctx.font = isActive ? 'bold 10px sans-serif' : '10px sans-serif'; ctx.fillText(tab.label, centerX, y + 48); this.bottomTabRects.push({ x: index * tabW, y, width: tabW, height: tabH, index }); }); } 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']; } formatNumber(num) { if (!num) return '0'; 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 t = text; while (t.length > 0 && ctx.measureText(t + '...').width > maxWidth) t = t.slice(0, -1); return t + '...'; } roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, 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; if (touch.clientY >= 90 && touch.clientY <= 130) { 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 - 60) { const tabW = this.screenWidth / 4; const tabIndex = Math.floor(x / tabW); this.handleBottomTabClick(tabIndex); return; } // 内容源Tab点击 if (this.contentTabRects) { for (const rect of this.contentTabRects) { if (x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height) { if (this.contentTab !== rect.index) { this.contentTab = rect.index; this.scrollY = 0; this.calculateMaxScroll(); } return; } } } // 分类点击 if (y >= 90 && y <= 130) { 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(); } return; } } } handleBottomTabClick(tabIndex) { if (tabIndex === this.bottomTab && tabIndex === 0) return; if (tabIndex === 0) { this.bottomTab = 0; } else if (tabIndex === 1) { // 发现页(可跳转或在本页切换) this.bottomTab = 1; } else if (tabIndex === 2) { // 创作页 this.main.sceneManager.switchScene('aiCreate'); } else if (tabIndex === 3) { // 我的 this.main.sceneManager.switchScene('profile'); } } handleStoryClick(x, y) { const startY = 140; const cardHeight = 130; const cardMargin = 12; 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 }); } } }