import { submitPrompt, getChallenges, startChallenge, getUserProgress, getPeerPrompts, resetProgress, toggleLike, subscribeToNotifications, markNotificationRead, getClassSize, updateUserMonster, getUser, subscribeToUserProgress } from "../services/classroom.js"; import { db } from "../services/firebase.js"; import { generateMonsterSVG, getNextMonster, MONSTER_STAGES, MONSTER_DEFS } from "../utils/monsterUtils.js"; import { doc, getDoc } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js"; // Cache challenges locally let cachedChallenges = []; let roomAiSettings = { active: false, key: null }; function renderTaskCard(c, userProgress) { const p = userProgress[c.id] || {}; const isCompleted = p.status === 'completed'; const isStarted = p.status === 'started'; // 1. Completed State: Collapsed with Trophy if (isCompleted) { return `
🏆

${c.title}

已通關
`; } // 2. Started or Not Started return `

${c.title}

任務說明

${c.description}

${!isStarted ? `
` : `
${roomAiSettings.active ? ` ` : ''}
`}
`; } export async function renderStudentView() { const nickname = localStorage.getItem('vibecoding_nickname') || 'Guest'; const roomCode = localStorage.getItem('vibecoding_room_code') || 'Unknown'; const userId = localStorage.getItem('vibecoding_user_id'); // Fetch challenges if empty if (cachedChallenges.length === 0) { try { cachedChallenges = await getChallenges(); } catch (e) { console.error("Failed to fetch challenges", e); throw new Error("無法讀取題目列表 (Error: " + e.message + ")"); } } // Fetch User Progress let userProgress = {}; if (userId) { try { userProgress = await getUserProgress(userId); } catch (e) { console.error("Failed to fetch progress", e); } } const levelGroups = { beginner: cachedChallenges.filter(c => c.level === 'beginner'), intermediate: cachedChallenges.filter(c => c.level === 'intermediate'), advanced: cachedChallenges.filter(c => c.level === 'advanced') }; const levelNames = { beginner: "初級 (Beginner)", intermediate: "中級 (Intermediate)", advanced: "高級 (Advanced)" }; // --- Monster Section Render Function --- // --- Monster Section Helper Functions --- // 1. Calculate Monster State (Separated logic) const calculateMonsterState = (currentUserProgress, classSize, userProfile) => { const totalLikes = Object.values(currentUserProgress).reduce((acc, p) => acc + (p.likes || 0), 0); // Count completions per level const counts = { 1: cachedChallenges.filter(c => c.level === 'beginner' && currentUserProgress[c.id]?.status === 'completed').length, 2: cachedChallenges.filter(c => c.level === 'intermediate' && currentUserProgress[c.id]?.status === 'completed').length, 3: cachedChallenges.filter(c => c.level === 'advanced' && currentUserProgress[c.id]?.status === 'completed').length }; const totalCompleted = counts[1] + counts[2] + counts[3]; let potentialStage = 0; if (counts[1] >= 5) potentialStage = 1; if (counts[2] >= 5 && potentialStage >= 1) potentialStage = 2; if (counts[3] >= 5 && potentialStage >= 2) potentialStage = 3; const actualStage = userProfile.monster_stage || 0; const actualMonsterId = userProfile.monster_id || 'Egg'; // Regression Logic Check if (actualStage > potentialStage) { // Regression Logic: Pass 'Egg' (or null) to enforce downgrade const targetId = potentialStage === 0 ? 'Egg' : null; // If going to 0, force Egg. Else keep null to let system decide? Or should we query? // Actually, if we devolve, we lose the specific form. So we should probably reset ID to something generic or clear it. // If we go to stage 0, 'Egg' is safe. // If we go to stage 1 (from 2), we might want to keep the stage 1 form (if we knew it). // But simplifying: just clear the ID so getNextMonster picks default for that stage. updateUserMonster(userId, potentialStage, targetId).then(() => { setTimeout(() => window.location.reload(), 500); }); // Return corrected state temporary return { ...calculateMonsterState(currentUserProgress, classSize, { ...userProfile, monster_stage: potentialStage, monster_id: targetId }), isRegressing: true }; } const canEvolve = potentialStage > actualStage; // Scale Logic const growthFactor = 0.08; const baseScale = 1.0; const currentScale = baseScale + (totalCompleted * growthFactor); // Get Monster Data let monster = getNextMonster(actualStage, 0, 0, actualMonsterId); if (actualMonsterId && actualMonsterId !== 'Egg') { const stored = MONSTER_DEFS.find(m => m.id === actualMonsterId); if (stored) monster = stored; } else { monster = getNextMonster(actualStage, totalLikes, classSize); } return { monster, currentScale, totalCompleted, totalLikes, canEvolve, actualStage, classSize, counts }; }; // 2. Render Stats HTML (Partial Update Target) const renderMonsterStats = (state) => { return `
${state.monster.name}
💖 ${state.totalLikes}
`; }; // 3. Render Full Monster Container (Initial or Full Update) // Now positioned stats to TOP const renderMonsterSection = (currentUserProgress, classSize, userProfile) => { const state = calculateMonsterState(currentUserProgress, classSize, userProfile); // Left sidebar position: Responsive // Mobile: Top Left (in header space) // Desktop: Fixed Left Sidebar return `
${renderMonsterStats(state)}
${generateMonsterSVG(state.monster)}
Lv.${1 + state.totalCompleted}
${state.canEvolve ? `
咦,小怪獸的樣子
正在發生變化...
` : ''}
`; }; // Inject Initial Monster UI const monsterContainerId = 'monster-ui-layer'; let monsterContainer = document.getElementById(monsterContainerId); if (!monsterContainer) { monsterContainer = document.createElement('div'); monsterContainer.id = monsterContainerId; document.body.appendChild(monsterContainer); } // Initial Render let classSize = 1; let userProfile = {}; try { classSize = await getClassSize(roomCode); userProfile = await getUser(userId) || {}; } catch (e) { console.error("Fetch stats error", e); } monsterContainer.innerHTML = renderMonsterSection(userProgress, classSize, userProfile); // Setup Real-time Subscription if (window.currentProgressUnsub) window.currentProgressUnsub(); window.currentProgressUnsub = subscribeToUserProgress(userId, (newProgressMap) => { // Merge updates (Actually newProgressMap is complete source of truth from firestore listener) const updatedProgress = newProgressMap; // 1. Update Monster (Visuals) const newState = calculateMonsterState(updatedProgress, classSize, userProfile); const fixedContainer = document.getElementById('monster-container-fixed'); const currentMonsterId = fixedContainer?.getAttribute('data-monster-id'); if (fixedContainer && String(currentMonsterId) === String(newState.monster.id)) { // Monster ID is same (no evolution/devolution). // Just update stats tooltip const statsContainer = document.getElementById('monster-stats-content'); if (statsContainer) { statsContainer.innerHTML = renderMonsterStats(newState); } // Check level up (totalCompleted) to update scale/animation if needed const oldTotal = parseInt(fixedContainer.querySelector('.bg-gray-900\\/90')?.textContent?.replace('Lv.', '') || '1') - 1; if (oldTotal !== newState.totalCompleted) { monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile); } } else { // Full Monster Render monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile); } // 2. Update Task List (Accordions) - Realtime "Reject" support const levelGroups = { beginner: cachedChallenges.filter(c => c.level === 'beginner'), intermediate: cachedChallenges.filter(c => c.level === 'intermediate'), advanced: cachedChallenges.filter(c => c.level === 'advanced') }; const levelNames = { beginner: "初級 (Beginner)", intermediate: "中級 (Intermediate)", advanced: "高級 (Advanced)" }; ['beginner', 'intermediate', 'advanced'].forEach(level => { const detailEl = document.getElementById(`details-group-${level}`); if (detailEl) { // renderLevelGroup checks existing DOM for 'open' state, so it persists const newHTML = renderLevelGroup(level, levelGroups[level], updatedProgress, levelNames); detailEl.outerHTML = newHTML; } }); }); // Accordion Layout return `
${nickname}
教室: ${roomCode}
${['beginner', 'intermediate', 'advanced'].map(level => { return renderLevelGroup(level, levelGroups[level] || [], userProgress, levelNames); }).join('')}
程式設計者:新竹縣精華國中 藍星宇老師
教育社群:萬物皆數
`; } export function setupStudentEvents() { // Start Level Logic window.startLevel = async (challengeId, link) => { // Open link window.open(link, '_blank'); // Call service to update status const roomCode = localStorage.getItem('vibecoding_room_code'); const userId = localStorage.getItem('vibecoding_user_id'); if (roomCode && userId) { try { await startChallenge(userId, roomCode, challengeId); // Reload view to show Input State // Ideally we should use state management, but checking URL hash or re-rendering works const app = document.querySelector('#app'); app.innerHTML = await renderStudentView(); // Re-attach events (recursion safety check needed? No, navigateTo does this usually, but here we manually re-render) // Or better: trigger a custom event or call navigateTo functionality? // Simple re-render is fine for now. } catch (e) { console.error("Start challenge failed", e); } } }; window.submitLevel = async (challengeId) => { const input = document.getElementById(`input-${challengeId}`); const errorMsg = document.getElementById(`error-${challengeId}`); const prompt = input.value; const roomCode = localStorage.getItem('vibecoding_room_code'); const userId = localStorage.getItem('vibecoding_user_id'); if (!participantDataCheck(roomCode, userId)) return; if (prompt.trim().length < 5) { errorMsg.classList.remove('hidden'); input.classList.add('border-red-500'); return; } errorMsg.classList.add('hidden'); input.classList.remove('border-red-500'); // Show loading state on button const container = input.parentElement; const btn = container.querySelector('button'); const originalText = btn.textContent; btn.textContent = "提交中..."; btn.disabled = true; try { await submitPrompt(userId, roomCode, challengeId, prompt); btn.textContent = "✓ 已通關"; btn.classList.add("bg-green-600"); // Fetch latest progress to ensure correct count const { getUserProgress } = await import("../services/classroom.js"); const newProgress = await getUserProgress(userId); // Re-render the level group to update count and all-clear status const challenge = cachedChallenges.find(c => c.id === challengeId); const level = challenge.level; const levelGroups = { beginner: cachedChallenges.filter(c => c.level === 'beginner'), intermediate: cachedChallenges.filter(c => c.level === 'intermediate'), advanced: cachedChallenges.filter(c => c.level === 'advanced') }; const levelNames = { beginner: "初級 (Beginner)", intermediate: "中級 (Intermediate)", advanced: "高級 (Advanced)" }; const newGroupHTML = renderLevelGroup(level, levelGroups[level], newProgress, levelNames); const detailEl = document.getElementById(`details-group-${level}`); if (detailEl) { detailEl.outerHTML = newGroupHTML; } else { const app = document.querySelector('#app'); app.innerHTML = await renderStudentView(); } } catch (error) { console.error(error); btn.textContent = originalText; btn.disabled = false; alert("提交失敗: " + error.message); } }; window.resetLevel = async (challengeId) => { if (!confirm("確定要重置這一題的進度嗎?(提示詞將會保留,但狀態會變回進行中)")) return; const roomCode = localStorage.getItem('vibecoding_room_code'); const userId = localStorage.getItem('vibecoding_user_id'); try { // Import and call resetProgress (Need to make sure it is imported or available globally? // Ideally import it. But setupStudentEvents is in module scope so imports are available. // Wait, import 'resetProgress' is not in the top import list yet. I need to add it.) // Let's assume I will update the import in the next step or use the global trick if needed. // But I should edit the import first. // For now, let's assume it is there. I will add it to the import list in a parallel or subsequent edit. // Checking imports above... I see 'getUserProgress' but not 'resetProgress'. I must update imports. // I'll do it in a separate edit step to be safe. // For now, just the logic: const { resetProgress } = await import("../services/classroom.js"); // Dynamic import to avoid changing top file lines again? // Or just rely on previous 'replace' having updated the file? // Actually, I should update the top import. await resetProgress(userId, roomCode, challengeId); const app = document.querySelector('#app'); app.innerHTML = await renderStudentView(); } catch (e) { console.error(e); alert("重置失敗"); } }; } function participantDataCheck(roomCode, userId) { if (!roomCode || !userId) { alert("連線資訊遺失,請重新登入"); window.location.reload(); return false; } return true; } window.logout = () => { if (!confirm("確定要登出嗎?")) return; localStorage.removeItem('vibecoding_user_id'); localStorage.removeItem('vibecoding_room_code'); localStorage.removeItem('vibecoding_nickname'); window.location.reload(); }; // Peer Learning Modal Logic function renderPeerModal() { // We need to re-fetch challenges for the dropdown? // They are cached in 'cachedChallenges' module variable let optionsHtml = ''; if (cachedChallenges.length > 0) { optionsHtml += cachedChallenges.map(c => `` ).join(''); } return ` `; } window.openPeerModal = () => { const existing = document.getElementById('peer-modal'); if (existing) existing.remove(); const div = document.createElement('div'); div.innerHTML = renderPeerModal(); document.body.appendChild(div.firstElementChild); document.getElementById('peer-modal').classList.remove('hidden'); }; window.closePeerModal = () => { document.getElementById('peer-modal').classList.add('hidden'); }; // Helper to render a level group (Accordion) function renderLevelGroup(level, tasks, userProgress, levelNames) { const detailsId = `details-group-${level}`; const existingDetails = document.getElementById(detailsId); let isOpenStr = ''; if (existingDetails) { if (existingDetails.hasAttribute('open')) isOpenStr = 'open'; } else { // Initial Load defaults if (level === 'beginner') isOpenStr = 'open'; } // Count completed const completedCount = tasks.filter(t => userProgress[t.id]?.status === 'completed').length; return `

${levelNames[level]}

${completedCount === tasks.length && tasks.length > 0 ? 'ALL CLEAR' : ''}
${completedCount} / ${tasks.length}
${tasks.length > 0 ? tasks.map(c => renderTaskCard(c, userProgress)).join('') : '
本區段尚無題目
'}
`; } window.loadPeerPrompts = async (challengeId) => { const container = document.getElementById('peer-prompts-container'); container.innerHTML = '
載入中...
'; const roomCode = localStorage.getItem('vibecoding_room_code'); const prompts = await getPeerPrompts(roomCode, challengeId); if (prompts.length === 0) { container.innerHTML = '
尚無同學提交此關卡或您無權限查看(需相同教室代碼)
'; return; } container.innerHTML = prompts.map(p => { const currentUserId = localStorage.getItem('vibecoding_user_id'); const isLiked = p.likedBy && p.likedBy.includes(currentUserId); return `
${p.nickname[0]}
${p.nickname} ${new Date(p.timestamp.seconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}

${p.prompt}

`}).join(''); // Attach challenge title for notification context window.currentPeerChallengeTitle = document.querySelector(`#peer-challenge-select option[value="${challengeId}"]`).text; }; // Like Handler window.handleLike = async (progressId, targetUserId) => { const userId = localStorage.getItem('vibecoding_user_id'); const nickname = localStorage.getItem('vibecoding_nickname'); const challengeTitle = window.currentPeerChallengeTitle || '挑戰'; // Optimistic UI update could go here, but for simplicity let's re-load or just fire and forget (the view won't update until reload currently) // To make it responsive, we should probably manually toggle the class on the button immediately. // For now, let's just call service and reload the list to see updated count. // Better UX: Find button and toggle 'processing' state? // Let's just reload the list for data consistency. const { toggleLike } = await import("../services/classroom.js"); await toggleLike(progressId, userId, nickname, targetUserId, challengeTitle); // Reload to refresh count const select = document.getElementById('peer-challenge-select'); if (select && select.value) { loadPeerPrompts(select.value); } }; window.triggerEvolution = async (currentStage, nextStage, likes, classSize, currentMonsterId) => { // 1. Hide Prompt const prompt = document.getElementById('evolution-prompt'); if (prompt) prompt.style.display = 'none'; try { const { getNextMonster, generateMonsterSVG, MONSTER_STAGES } = await import("../utils/monsterUtils.js"); const { updateUserMonster, getHigherStageCount } = await import("../services/classroom.js"); // Calculate Ranking Percentile const roomCode = localStorage.getItem('vibecoding_room_code'); let percentile = 1.0; // Query how many already at next stage (or same level but evolved) // Actually, we want to know how many have *completed* this stage essentially. // Since we trigger evolution AFTER completion, anyone AT or ABOVE nextStage has completed it. // So we count how many are >= nextStage. // Wait, if I am the first to evolve to stage 1, count >= 1 is 0. // So rank is (0 + 1) / classSize. try { const higherCount = await getHigherStageCount(roomCode, nextStage); percentile = higherCount / classSize; console.log(`Evolution Rank: ${higherCount}/${classSize} (Percentile: ${percentile.toFixed(2)})`); } catch (e) { console.error("Rank calc failed", e); } // Calculate Stage-Specific Likes // We need to fetch challenges to filter by level... // cachedChallenges is available in module scope // We need user progress... // We can't easily get it here without re-fetching or passing it in. // But we have 'likes' passed in, which is TOTAL likes. // Re-fetching progress is safer. const userId = localStorage.getItem('vibecoding_user_id'); const { getUserProgress } = await import("../services/classroom.js"); const progress = await getUserProgress(userId); let stageLikes = 0; // Map stage to challenge level // Stage 0 -> 1 requires Beginner (Level 1) likes // Stage 1 -> 2 requires Intermediate (Level 2) likes // Stage 2 -> 3 requires Advanced (Level 3) likes // Note: nextStage parameter is the TARGET stage. // If nextStage is 1 (Egg->Baby), we count Beginner likes. const targetLevelMap = { 1: 'beginner', 2: 'intermediate', 3: 'advanced' }; const targetLevel = targetLevelMap[nextStage]; if (targetLevel) { stageLikes = cachedChallenges .filter(c => c.level === targetLevel) .reduce((acc, c) => acc + (progress[c.id]?.likes || 0), 0); } console.log(`Stage ${nextStage} Likes: ${stageLikes} (Total: ${likes})`); // Calculate Next Monster with Lineage & Ranking // IMPORTANT: Ensure we pass currentMonsterId to enforce lineage! // We pass 'stageLikes' instead of total 'likes' now const nextMonster = getNextMonster(nextStage, stageLikes, classSize, currentMonsterId, percentile); console.log("Evolving from:", currentMonsterId, "to:", nextMonster.name); const container = document.querySelector('#monster-container-fixed .pixel-monster'); const containerWrapper = document.querySelector('#monster-container-fixed .pixel-art-container'); // Stop breathing animation container.style.animation = 'none'; // --- ANIMATION SEQUENCE --- let count = 0; const maxFlickers = 12; // Increased duration let speed = 300; const currentMonster = MONSTER_DEFS.find(m => m.id === currentMonsterId) || getNextMonster(currentStage, 0, 0, currentMonsterId); const svgCurrent = generateMonsterSVG(currentMonster); const svgNext = generateMonsterSVG(nextMonster); const setFrame = (svg, isSilhouette) => { container.innerHTML = svg; container.style.filter = isSilhouette ? 'brightness(0)' : 'none'; }; const playFlicker = () => { const isNext = count % 2 === 1; setFrame(isNext ? svgNext : svgCurrent, true); count++; if (count < maxFlickers) { speed *= 0.85; setTimeout(playFlicker, speed); } else { // Final Reveal setTimeout(() => { setFrame(svgNext, true); // Hold silhouette setTimeout(() => { // Reveal Color with Flash containerWrapper.style.transition = 'filter 0.8s ease-out'; containerWrapper.style.filter = 'drop-shadow(0 0 30px #ffffff) brightness(1.5)'; // Force SVG update to next monster for final state setFrame(svgNext, false); setTimeout(async () => { containerWrapper.style.filter = 'none'; // DB Update with Monster ID const userId = localStorage.getItem('vibecoding_user_id'); await updateUserMonster(userId, nextStage, nextMonster.id); // Soft refresh to prevent screen flicker const app = document.querySelector('#app'); if (app) app.innerHTML = await renderStudentView(); // Also need to ensure monster container is updated (handle in renderStudentView) }, 1200); }, 1000); }, 300); } }; playFlicker(); } catch (e) { console.error(e); alert("進化失敗: " + (e.message || "未知錯誤")); // Re-render instead of reload to keep state const app = document.querySelector('#app'); if (app) app.innerHTML = await renderStudentView(); } };