Spaces:
Running
Running
Upload 9 files
Browse files- src/services/classroom.js +7 -5
- src/utils/monsterUtils.js +39 -33
- src/views/StudentView.js +79 -79
src/services/classroom.js
CHANGED
|
@@ -459,15 +459,17 @@ export async function getClassSize(roomCode) {
|
|
| 459 |
}
|
| 460 |
|
| 461 |
/**
|
| 462 |
-
* Updates a student's monster stage
|
| 463 |
* @param {string} userId
|
| 464 |
* @param {number} newStage
|
|
|
|
| 465 |
*/
|
| 466 |
-
export async function
|
| 467 |
const userRef = doc(db, USERS_COLLECTION, userId);
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
|
|
|
| 471 |
}
|
| 472 |
|
| 473 |
/**
|
|
|
|
| 459 |
}
|
| 460 |
|
| 461 |
/**
|
| 462 |
+
* Updates a student's monster stage and specific form
|
| 463 |
* @param {string} userId
|
| 464 |
* @param {number} newStage
|
| 465 |
+
* @param {string} monsterId (Optional, for persisting specific form)
|
| 466 |
*/
|
| 467 |
+
export async function updateUserMonster(userId, newStage, monsterId = null) {
|
| 468 |
const userRef = doc(db, USERS_COLLECTION, userId);
|
| 469 |
+
const data = { monster_stage: newStage };
|
| 470 |
+
if (monsterId) data.monster_id = monsterId;
|
| 471 |
+
|
| 472 |
+
await updateDoc(userRef, data);
|
| 473 |
}
|
| 474 |
|
| 475 |
/**
|
src/utils/monsterUtils.js
CHANGED
|
@@ -397,36 +397,35 @@ export function generateMonsterSVG(monster) {
|
|
| 397 |
}
|
| 398 |
|
| 399 |
/**
|
| 400 |
-
* Determines the next monster form based on parameters
|
| 401 |
* @param {number} currentStage - Current evolutionary stage (0-3)
|
| 402 |
* @param {number} likes - Student's total likes
|
| 403 |
* @param {number} classSize - Active class size
|
| 404 |
-
* @param {string} [
|
| 405 |
* @returns {Object} Selected Monster Definition
|
| 406 |
*/
|
| 407 |
-
export function getNextMonster(currentStage, likes, classSize,
|
| 408 |
if (currentStage === 0) return MONSTER_DEFS.find(m => m.id === 'Egg');
|
| 409 |
|
| 410 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
if (currentStage === 1) {
|
| 412 |
-
// Simple
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
// Actually the prompt says:
|
| 417 |
-
// "Stage 1 (Basic): Unlocks after Beginner... Three basic families: Dust, Pup, Spirit"
|
| 418 |
-
// Let's pick based on (Likes % 3) for variety or just return one?
|
| 419 |
-
// Let's return Spirit if Likes are high, Dust if low?
|
| 420 |
-
|
| 421 |
-
// Simple distribution for now:
|
| 422 |
-
if (likes > 5) return MONSTER_DEFS.find(m => m.id === 'L1_A'); // Spirit
|
| 423 |
-
if (likes > 2) return MONSTER_DEFS.find(m => m.id === 'L1_B'); // Pup
|
| 424 |
-
return MONSTER_DEFS.find(m => m.id === 'L1_C'); // Dust
|
| 425 |
}
|
| 426 |
|
| 427 |
// Branching Logic for Stage 2 & 3
|
| 428 |
// A: High Likes ( > classSize / 2 )
|
| 429 |
-
// B:
|
| 430 |
// C: Low Likes ( <= 1 )
|
| 431 |
|
| 432 |
const ratio = classSize > 0 ? likes / classSize : 0;
|
|
@@ -440,25 +439,32 @@ export function getNextMonster(currentStage, likes, classSize, currentFam = null
|
|
| 440 |
if (classSize <= 2 && likes >= 1) tier = 'A'; // Boost for testing pairs
|
| 441 |
|
| 442 |
// Filter potential monsters for this Stage
|
| 443 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
|
| 445 |
// Filter by Tier (Suffix A/B/C)
|
| 446 |
-
// IDs are like L2_AA, L2_AB...
|
| 447 |
-
// Last char
|
| 448 |
-
// Yes: L3_AAA (High), L3_CAA (High? No wait, logic in readme says:
|
| 449 |
-
// Tier 1 (Low) -> C route.
|
| 450 |
-
// Tier 3 (High) -> A route.
|
| 451 |
-
|
| 452 |
const targetSuffix = tier;
|
| 453 |
|
| 454 |
-
|
| 455 |
-
// Implementation Plan didn't specify strict lineage tracking yet, but usually we want to.
|
| 456 |
-
|
| 457 |
-
// Let's filter candidates ending in targetSuffix
|
| 458 |
-
let match = candidates.find(m => m.id.endsWith(targetSuffix) && (!currentFam || m.fam === currentFam));
|
| 459 |
|
| 460 |
-
// If
|
| 461 |
-
|
|
|
|
| 462 |
|
| 463 |
-
return match || MONSTER_DEFS[0]; // Fallback
|
| 464 |
}
|
|
|
|
| 397 |
}
|
| 398 |
|
| 399 |
/**
|
| 400 |
+
* Determines the next monster form based on parameters and lineage
|
| 401 |
* @param {number} currentStage - Current evolutionary stage (0-3)
|
| 402 |
* @param {number} likes - Student's total likes
|
| 403 |
* @param {number} classSize - Active class size
|
| 404 |
+
* @param {string} [currentMonsterId] - ID of the current monster to determine lineage
|
| 405 |
* @returns {Object} Selected Monster Definition
|
| 406 |
*/
|
| 407 |
+
export function getNextMonster(currentStage, likes, classSize, currentMonsterId = null) {
|
| 408 |
if (currentStage === 0) return MONSTER_DEFS.find(m => m.id === 'Egg');
|
| 409 |
|
| 410 |
+
// Find current monster family if we have an ID
|
| 411 |
+
let currentFam = null;
|
| 412 |
+
if (currentMonsterId) {
|
| 413 |
+
const current = MONSTER_DEFS.find(m => m.id === currentMonsterId);
|
| 414 |
+
if (current) currentFam = current.fam; // e.g., 'Beast', 'Dust', 'Spirit'
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
// Stage 1: Basic Families (Origin -> Family)
|
| 418 |
+
// If coming from Egg (Stage 0), we pick a base family.
|
| 419 |
if (currentStage === 1) {
|
| 420 |
+
// Simple distribution:
|
| 421 |
+
if (likes >= 5) return MONSTER_DEFS.find(m => m.id === 'L1_A'); // Spirit (Blue)
|
| 422 |
+
if (likes >= 2) return MONSTER_DEFS.find(m => m.id === 'L1_B'); // Beast (Yellow)
|
| 423 |
+
return MONSTER_DEFS.find(m => m.id === 'L1_C'); // Dust (Red)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
}
|
| 425 |
|
| 426 |
// Branching Logic for Stage 2 & 3
|
| 427 |
// A: High Likes ( > classSize / 2 )
|
| 428 |
+
// B: Normal Likes ( > 1 )
|
| 429 |
// C: Low Likes ( <= 1 )
|
| 430 |
|
| 431 |
const ratio = classSize > 0 ? likes / classSize : 0;
|
|
|
|
| 439 |
if (classSize <= 2 && likes >= 1) tier = 'A'; // Boost for testing pairs
|
| 440 |
|
| 441 |
// Filter potential monsters for this Stage
|
| 442 |
+
let candidates = MONSTER_DEFS.filter(m => m.stage === currentStage);
|
| 443 |
+
|
| 444 |
+
// Enforce Lineage (Family)
|
| 445 |
+
if (currentFam && currentFam !== 'Origin') {
|
| 446 |
+
// Filter candidates that belong to this Family tree
|
| 447 |
+
// We need to know which families map to which.
|
| 448 |
+
// Stage 1 -> Stage 2 Mapping:
|
| 449 |
+
// Dust -> Dust (Trash/Slime/Tech)
|
| 450 |
+
// Beast -> Beast (Wolf/Cat/Mech)
|
| 451 |
+
// Spirit -> Spirit (Fire/Angel/Dragon)
|
| 452 |
+
|
| 453 |
+
// In MONSTER_DEFS, 'fam' for Stage 2 is 'Dust', 'Beast', 'Spirit'.
|
| 454 |
+
// So we just match strict equality.
|
| 455 |
+
candidates = candidates.filter(m => m.fam === currentFam || m.fam.includes(currentFam));
|
| 456 |
+
}
|
| 457 |
|
| 458 |
// Filter by Tier (Suffix A/B/C)
|
| 459 |
+
// IDs are like L2_AA, L2_AB...
|
| 460 |
+
// Last char is C(Low), B(Mid), A(High).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
const targetSuffix = tier;
|
| 462 |
|
| 463 |
+
let match = candidates.find(m => m.id.endsWith(targetSuffix));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
|
| 465 |
+
// Fallback: If specific tier not found in this family (shouldn't happen if definitions are complete),
|
| 466 |
+
// pick ANY from this family.
|
| 467 |
+
if (!match && candidates.length > 0) match = candidates[0];
|
| 468 |
|
| 469 |
+
return match || MONSTER_DEFS[0]; // Absolute Fallback
|
| 470 |
}
|
src/views/StudentView.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { submitPrompt, getChallenges, startChallenge, getUserProgress, getPeerPrompts, resetProgress, toggleLike, subscribeToNotifications, markNotificationRead, getClassSize,
|
| 2 |
import { generateMonsterSVG, getNextMonster, MONSTER_STAGES } from "../utils/monsterUtils.js";
|
| 3 |
|
| 4 |
|
|
@@ -149,58 +149,82 @@ export async function renderStudentView() {
|
|
| 149 |
if (counts[2] >= 5 && potentialStage >= 1) potentialStage = 2;
|
| 150 |
if (counts[3] >= 5 && potentialStage >= 2) potentialStage = 3;
|
| 151 |
|
| 152 |
-
// 2. Get Actual Stage
|
| 153 |
const actualStage = userProfile.monster_stage || 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
// 3. Display Logic
|
| 156 |
const canEvolve = potentialStage > actualStage;
|
| 157 |
|
| 158 |
-
//
|
| 159 |
-
// Grows with TOTAL completed tasks. e.g. 0.05 per task.
|
| 160 |
-
// Also resets effective growth if we evolve?
|
| 161 |
-
// User said: "Monster size should grow every time a question is answered correctly"
|
| 162 |
-
// And "Level also rise".
|
| 163 |
-
|
| 164 |
-
// Let's make base scale depend on tasks completed SINCE last evolution?
|
| 165 |
-
// Or just total tasks.
|
| 166 |
-
// If I evolve, I probably want to start small-ish again?
|
| 167 |
-
// But "Stage 2" monster should probably be bigger than "Stage 1 Egg".
|
| 168 |
-
// Let's use a simple global scalar:
|
| 169 |
const growthFactor = 0.08;
|
| 170 |
const baseScale = 1.0;
|
| 171 |
-
// Adjust for stage so high stage monsters aren't tiny initially?
|
| 172 |
-
// Actually, let's just make it grow linearly based on total questions.
|
| 173 |
-
// But if I evolve, does it shrink?
|
| 174 |
-
// User request: "If don't evolve... keep getting bigger"
|
| 175 |
-
// Implicitly, evolving might reset the 'extra' growth or change the base form.
|
| 176 |
-
// Let's just use Total Completed for scale.
|
| 177 |
const currentScale = baseScale + (totalCompleted * growthFactor);
|
| 178 |
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
return `
|
| 182 |
-
<div id="monster-container-fixed" class="fixed top-24 left-
|
| 183 |
-
<!--
|
| 184 |
-
<div class="pixel-art-container relative transform transition-transform duration-500 ease-out origin-center hover:scale-110"
|
|
|
|
|
|
|
| 185 |
<div class="pixel-monster w-28 h-28 drop-shadow-2xl filter" style="animation: breathe 3s infinite ease-in-out;">
|
| 186 |
${generateMonsterSVG(monster)}
|
| 187 |
</div>
|
| 188 |
|
| 189 |
-
<!-- Level Indicator
|
| 190 |
<div class="absolute -bottom-2 -right-2 bg-gray-900/90 text-xs text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-500/50 font-mono font-bold transform scale-75 origin-top-left whitespace-nowrap">
|
| 191 |
Lv.${1 + totalCompleted}
|
| 192 |
</div>
|
| 193 |
</div>
|
| 194 |
|
| 195 |
-
<!-- Evolution Prompt -->
|
| 196 |
<!-- Evolution Prompt -->
|
| 197 |
${canEvolve ? `
|
| 198 |
-
<div id="evolution-prompt" class="absolute top-full mt-
|
| 199 |
-
<
|
| 200 |
-
class="
|
| 201 |
-
|
| 202 |
-
<
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
</div>
|
| 205 |
` : ''}
|
| 206 |
|
|
@@ -220,6 +244,11 @@ export async function renderStudentView() {
|
|
| 220 |
0%, 100% { transform: translateY(0); filter: brightness(1); }
|
| 221 |
50% { transform: translateY(-3px); filter: brightness(1.1); }
|
| 222 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
</style>
|
| 224 |
`;
|
| 225 |
};
|
|
@@ -562,26 +591,18 @@ window.handleLike = async (progressId, targetUserId) => {
|
|
| 562 |
}
|
| 563 |
};
|
| 564 |
|
| 565 |
-
window.triggerEvolution = async (currentStage, nextStage, likes, classSize) => {
|
| 566 |
// 1. Hide Prompt
|
| 567 |
const prompt = document.getElementById('evolution-prompt');
|
| 568 |
if (prompt) prompt.style.display = 'none';
|
| 569 |
|
| 570 |
-
// 2. Prepare Animation Data
|
| 571 |
-
// We need Next Monster Data
|
| 572 |
-
// We can't easily import logic here if not exposed, but we exported getNextMonster.
|
| 573 |
-
// We need to re-import or use the one in scope if available.
|
| 574 |
-
// Fortunately setupStudentEvents is a module, but this function is on window.
|
| 575 |
-
// We need to pass data or use a helper.
|
| 576 |
-
// Ideally we should move getNextMonster to a global helper or fetch it.
|
| 577 |
-
// Let's use dynamic import to be safe and robust.
|
| 578 |
-
|
| 579 |
try {
|
| 580 |
const { getNextMonster, generateMonsterSVG, MONSTER_STAGES } = await import("../utils/monsterUtils.js");
|
| 581 |
-
const {
|
| 582 |
|
| 583 |
-
|
| 584 |
-
const
|
|
|
|
| 585 |
|
| 586 |
const container = document.querySelector('#monster-container-fixed .pixel-monster');
|
| 587 |
const containerWrapper = document.querySelector('#monster-container-fixed .pixel-art-container');
|
|
@@ -590,68 +611,47 @@ window.triggerEvolution = async (currentStage, nextStage, likes, classSize) => {
|
|
| 590 |
container.style.animation = 'none';
|
| 591 |
|
| 592 |
// --- ANIMATION SEQUENCE ---
|
| 593 |
-
// flicker count
|
| 594 |
let count = 0;
|
| 595 |
-
const maxFlickers =
|
| 596 |
-
let speed = 300;
|
| 597 |
|
| 598 |
const svgCurrent = generateMonsterSVG(currentMonster);
|
| 599 |
const svgNext = generateMonsterSVG(nextMonster);
|
| 600 |
|
| 601 |
-
// Helper to set Content and Style
|
| 602 |
const setFrame = (svg, isSilhouette) => {
|
| 603 |
container.innerHTML = svg;
|
| 604 |
-
container.style.filter = isSilhouette ? 'brightness(0)
|
| 605 |
-
if (isSilhouette) container.style.filter = 'brightness(0)';
|
| 606 |
};
|
| 607 |
|
| 608 |
const playFlicker = () => {
|
| 609 |
-
// Alternate
|
| 610 |
const isNext = count % 2 === 1;
|
| 611 |
setFrame(isNext ? svgNext : svgCurrent, true);
|
| 612 |
-
|
| 613 |
count++;
|
| 614 |
|
| 615 |
if (count < maxFlickers) {
|
| 616 |
-
|
| 617 |
-
speed *= 0.8;
|
| 618 |
setTimeout(playFlicker, speed);
|
| 619 |
} else {
|
| 620 |
// Final Reveal
|
| 621 |
setTimeout(() => {
|
| 622 |
-
//
|
| 623 |
-
setFrame(svgNext, true);
|
| 624 |
|
| 625 |
setTimeout(() => {
|
| 626 |
-
// Reveal Color with
|
| 627 |
-
containerWrapper.style.transition = 'filter 0.
|
| 628 |
-
containerWrapper.style.filter = 'drop-shadow(0 0
|
| 629 |
|
| 630 |
-
setFrame(svgNext, false);
|
| 631 |
|
| 632 |
setTimeout(async () => {
|
| 633 |
containerWrapper.style.filter = 'none';
|
| 634 |
-
// DB Update
|
| 635 |
const userId = localStorage.getItem('vibecoding_user_id');
|
| 636 |
-
await
|
| 637 |
-
// Reload
|
| 638 |
-
const app = document.querySelector('#app');
|
| 639 |
-
// We need to re-import renderStudentView? It's exported.
|
| 640 |
-
// But we are inside window function.
|
| 641 |
-
// Just generic reload for now or try to re-render if accessible.
|
| 642 |
-
// renderStudentView is not global.
|
| 643 |
-
// Let's reload page to be cleanest or rely on the subscribeToUserProgress which might flicker?
|
| 644 |
-
// subscribeToUserProgress listens to PROGRESS collection, not USERS collection (where monster scale is).
|
| 645 |
-
// So we MUST reload or manually fetch profile.
|
| 646 |
-
// Simple reload:
|
| 647 |
-
// window.location.reload();
|
| 648 |
-
// Or better: triggering the view re-render.
|
| 649 |
-
// Note: We don't have access to 'renderStudentView' function here easily unless we attached it to window.
|
| 650 |
-
// Let's reload to ensure clean state.
|
| 651 |
window.location.reload();
|
| 652 |
-
},
|
| 653 |
-
},
|
| 654 |
-
},
|
| 655 |
}
|
| 656 |
};
|
| 657 |
|
|
|
|
| 1 |
+
import { submitPrompt, getChallenges, startChallenge, getUserProgress, getPeerPrompts, resetProgress, toggleLike, subscribeToNotifications, markNotificationRead, getClassSize, updateUserMonster, getUser, subscribeToUserProgress } from "../services/classroom.js";
|
| 2 |
import { generateMonsterSVG, getNextMonster, MONSTER_STAGES } from "../utils/monsterUtils.js";
|
| 3 |
|
| 4 |
|
|
|
|
| 149 |
if (counts[2] >= 5 && potentialStage >= 1) potentialStage = 2;
|
| 150 |
if (counts[3] >= 5 && potentialStage >= 2) potentialStage = 3;
|
| 151 |
|
| 152 |
+
// 2. Get Actual Stage & ID
|
| 153 |
const actualStage = userProfile.monster_stage || 0;
|
| 154 |
+
const actualMonsterId = userProfile.monster_id || 'Egg';
|
| 155 |
+
|
| 156 |
+
// --- REGRESSION LOGIC (Auto-Devolve) ---
|
| 157 |
+
// If actual stage > potential stage, it means tasks were reset/rejected.
|
| 158 |
+
// We must devolve.
|
| 159 |
+
if (actualStage > potentialStage) {
|
| 160 |
+
// Devolve immediately (or show animation? Immediate for compliance)
|
| 161 |
+
// We need to call DB update. But we are in render function.
|
| 162 |
+
// Side-effect in render is bad, but necessary for self-correction.
|
| 163 |
+
// Let's debounce or check if we haven't already corrected.
|
| 164 |
+
// console.warn("Devolving from", actualStage, "to", potentialStage);
|
| 165 |
+
// Trigger update async
|
| 166 |
+
updateUserMonster(userId, potentialStage, null).then(() => {
|
| 167 |
+
// Reload to reflect
|
| 168 |
+
// window.location.reload(); // Might cause loop if not careful?
|
| 169 |
+
// If update succeeds, the subscription will fire?
|
| 170 |
+
// No, subscription listens to PROGRESS, not USER profile updates unless we subscribe to USER too.
|
| 171 |
+
// We currently verify profile on load.
|
| 172 |
+
// Let's just force a reload once to correct it.
|
| 173 |
+
/* setTimeout(() => window.location.reload(), 500); */
|
| 174 |
+
});
|
| 175 |
+
// For THIS render, show potential stage
|
| 176 |
+
// return renderMonsterSection(currentUserProgress, classSize, { ...userProfile, monster_stage: potentialStage });
|
| 177 |
+
}
|
| 178 |
|
| 179 |
// 3. Display Logic
|
| 180 |
const canEvolve = potentialStage > actualStage;
|
| 181 |
|
| 182 |
+
// Scale Logic
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
const growthFactor = 0.08;
|
| 184 |
const baseScale = 1.0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
const currentScale = baseScale + (totalCompleted * growthFactor);
|
| 186 |
|
| 187 |
+
// Get Monster Data (Preserve Lineage)
|
| 188 |
+
// If we have an actual ID, use it for display until evolution.
|
| 189 |
+
// If we are about to evolve, we preview next? No, current.
|
| 190 |
+
let monster = getNextMonster(actualStage, 0, 0, actualMonsterId); // Just lookup current by ID/Stage?
|
| 191 |
+
if (actualMonsterId && actualMonsterId !== 'Egg') {
|
| 192 |
+
// If we have a specific ID stored, try to use it directly
|
| 193 |
+
const stored = MONSTER_DEFS.find(m => m.id === actualMonsterId);
|
| 194 |
+
if (stored) monster = stored;
|
| 195 |
+
} else {
|
| 196 |
+
// Fallback for Egg or legacy data without ID
|
| 197 |
+
monster = getNextMonster(actualStage, totalLikes, classSize);
|
| 198 |
+
}
|
| 199 |
|
| 200 |
return `
|
| 201 |
+
<div id="monster-container-fixed" class="fixed top-24 left-1/2 -translate-x-1/2 z-50 flex flex-col items-center group pointer-events-none sm:pointer-events-auto w-40 h-40">
|
| 202 |
+
<!-- Walking Container -->
|
| 203 |
+
<div class="pixel-art-container relative transform transition-transform duration-500 ease-out origin-center hover:scale-110"
|
| 204 |
+
style="transform: scale(${currentScale}); animation: walk-float 6s ease-in-out infinite;">
|
| 205 |
+
|
| 206 |
<div class="pixel-monster w-28 h-28 drop-shadow-2xl filter" style="animation: breathe 3s infinite ease-in-out;">
|
| 207 |
${generateMonsterSVG(monster)}
|
| 208 |
</div>
|
| 209 |
|
| 210 |
+
<!-- Level Indicator -->
|
| 211 |
<div class="absolute -bottom-2 -right-2 bg-gray-900/90 text-xs text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-500/50 font-mono font-bold transform scale-75 origin-top-left whitespace-nowrap">
|
| 212 |
Lv.${1 + totalCompleted}
|
| 213 |
</div>
|
| 214 |
</div>
|
| 215 |
|
|
|
|
| 216 |
<!-- Evolution Prompt -->
|
| 217 |
${canEvolve ? `
|
| 218 |
+
<div id="evolution-prompt" class="absolute top-full mt-2 pointer-events-auto animate-bounce z-50">
|
| 219 |
+
<div class="flex flex-col items-center">
|
| 220 |
+
<div class="bg-gray-900/90 text-pink-200 text-xs py-2 px-3 rounded-xl border border-pink-500/30 shadow-lg text-center font-bold mb-1 backdrop-blur-sm whitespace-nowrap">
|
| 221 |
+
咦,小怪獸的樣子<br>正在發生變化...
|
| 222 |
+
</div>
|
| 223 |
+
<button onclick="window.triggerEvolution(${actualStage}, ${actualStage + 1}, ${totalLikes}, ${classSize}, '${monster.id}')"
|
| 224 |
+
class="bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white text-sm font-black py-2 px-6 rounded-full shadow-[0_0_15px_rgba(236,72,153,0.8)] border border-white/30 transition-all hover:scale-110 active:scale-95">
|
| 225 |
+
進化!
|
| 226 |
+
</button>
|
| 227 |
+
</div>
|
| 228 |
</div>
|
| 229 |
` : ''}
|
| 230 |
|
|
|
|
| 244 |
0%, 100% { transform: translateY(0); filter: brightness(1); }
|
| 245 |
50% { transform: translateY(-3px); filter: brightness(1.1); }
|
| 246 |
}
|
| 247 |
+
@keyframes walk-float {
|
| 248 |
+
0%, 100% { transform: translateX(0) scale(${currentScale}); }
|
| 249 |
+
25% { transform: translateX(-10px) rotate(-2deg) scale(${currentScale}); }
|
| 250 |
+
75% { transform: translateX(10px) rotate(2deg) scale(${currentScale}); }
|
| 251 |
+
}
|
| 252 |
</style>
|
| 253 |
`;
|
| 254 |
};
|
|
|
|
| 591 |
}
|
| 592 |
};
|
| 593 |
|
| 594 |
+
window.triggerEvolution = async (currentStage, nextStage, likes, classSize, currentMonsterId) => {
|
| 595 |
// 1. Hide Prompt
|
| 596 |
const prompt = document.getElementById('evolution-prompt');
|
| 597 |
if (prompt) prompt.style.display = 'none';
|
| 598 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
try {
|
| 600 |
const { getNextMonster, generateMonsterSVG, MONSTER_STAGES } = await import("../utils/monsterUtils.js");
|
| 601 |
+
const { updateUserMonster } = await import("../services/classroom.js");
|
| 602 |
|
| 603 |
+
// Calculate Next Monster with Lineage
|
| 604 |
+
const currentMonster = getNextMonster(currentStage, likes, classSize, currentMonsterId);
|
| 605 |
+
const nextMonster = getNextMonster(nextStage, likes, classSize, currentMonsterId);
|
| 606 |
|
| 607 |
const container = document.querySelector('#monster-container-fixed .pixel-monster');
|
| 608 |
const containerWrapper = document.querySelector('#monster-container-fixed .pixel-art-container');
|
|
|
|
| 611 |
container.style.animation = 'none';
|
| 612 |
|
| 613 |
// --- ANIMATION SEQUENCE ---
|
|
|
|
| 614 |
let count = 0;
|
| 615 |
+
const maxFlickers = 12; // Increased duration
|
| 616 |
+
let speed = 300;
|
| 617 |
|
| 618 |
const svgCurrent = generateMonsterSVG(currentMonster);
|
| 619 |
const svgNext = generateMonsterSVG(nextMonster);
|
| 620 |
|
|
|
|
| 621 |
const setFrame = (svg, isSilhouette) => {
|
| 622 |
container.innerHTML = svg;
|
| 623 |
+
container.style.filter = isSilhouette ? 'brightness(0)' : 'none';
|
|
|
|
| 624 |
};
|
| 625 |
|
| 626 |
const playFlicker = () => {
|
|
|
|
| 627 |
const isNext = count % 2 === 1;
|
| 628 |
setFrame(isNext ? svgNext : svgCurrent, true);
|
|
|
|
| 629 |
count++;
|
| 630 |
|
| 631 |
if (count < maxFlickers) {
|
| 632 |
+
speed *= 0.85;
|
|
|
|
| 633 |
setTimeout(playFlicker, speed);
|
| 634 |
} else {
|
| 635 |
// Final Reveal
|
| 636 |
setTimeout(() => {
|
| 637 |
+
setFrame(svgNext, true); // Hold silhouette
|
|
|
|
| 638 |
|
| 639 |
setTimeout(() => {
|
| 640 |
+
// Reveal Color with Flash
|
| 641 |
+
containerWrapper.style.transition = 'filter 0.8s ease-out';
|
| 642 |
+
containerWrapper.style.filter = 'drop-shadow(0 0 30px #ffffff) brightness(1.5)';
|
| 643 |
|
| 644 |
+
setFrame(svgNext, false);
|
| 645 |
|
| 646 |
setTimeout(async () => {
|
| 647 |
containerWrapper.style.filter = 'none';
|
| 648 |
+
// DB Update with Monster ID
|
| 649 |
const userId = localStorage.getItem('vibecoding_user_id');
|
| 650 |
+
await updateUserMonster(userId, nextStage, nextMonster.id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
window.location.reload();
|
| 652 |
+
}, 1200);
|
| 653 |
+
}, 1000);
|
| 654 |
+
}, 300);
|
| 655 |
}
|
| 656 |
};
|
| 657 |
|