|
|
<!DOCTYPE html> |
|
|
<html lang="zh-TW"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
|
|
<title>代數遺跡:公因式的寶藏</title> |
|
|
|
|
|
|
|
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> |
|
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> |
|
|
|
|
|
|
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> |
|
|
|
|
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
|
|
|
<style> |
|
|
|
|
|
body { |
|
|
touch-action: none; |
|
|
-webkit-touch-callout: none; |
|
|
-webkit-user-select: none; |
|
|
user-select: none; |
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
|
|
background-color: #0f0f13; |
|
|
} |
|
|
|
|
|
|
|
|
.font-math { |
|
|
font-family: 'Georgia', 'Times New Roman', Times, serif; |
|
|
} |
|
|
|
|
|
|
|
|
.palace-bg { |
|
|
background-image: url('image_03b11b.jpg'); |
|
|
background-size: cover; |
|
|
background-position: center; |
|
|
background-repeat: no-repeat; |
|
|
position: fixed; |
|
|
inset: 0; |
|
|
z-index: -3; |
|
|
} |
|
|
|
|
|
.palace-overlay { |
|
|
background: |
|
|
radial-gradient(circle at 50% 50%, rgba(20, 10, 30, 0.4) 0%, rgba(10, 5, 15, 0.8) 80%), |
|
|
linear-gradient(to bottom, rgba(15, 15, 19, 0.3) 0%, rgba(26, 26, 46, 0.6) 100%); |
|
|
position: fixed; |
|
|
inset: 0; |
|
|
z-index: -2; |
|
|
} |
|
|
|
|
|
.palace-pillars { |
|
|
position: fixed; |
|
|
inset: 0; |
|
|
background-image: |
|
|
linear-gradient(90deg, rgba(0,0,0,0.3) 0%, transparent 10%, transparent 90%, rgba(0,0,0,0.3) 100%); |
|
|
z-index: -1; |
|
|
pointer-events: none; |
|
|
} |
|
|
|
|
|
|
|
|
@keyframes shake { |
|
|
0%, 100% { transform: translateX(0); } |
|
|
25% { transform: translateX(-5px); } |
|
|
75% { transform: translateX(5px); } |
|
|
} |
|
|
@keyframes coinFly { |
|
|
0% { transform: translate(-50%, -50%) scale(0) rotate(0deg); opacity: 1; } |
|
|
10% { transform: translate(-50%, -50%) scale(1) rotate(0deg); opacity: 1; } |
|
|
100% { transform: translate(calc(-50% + var(--tx)), calc(-50% + var(--ty))) scale(0.5) rotate(var(--rot)); opacity: 0; } |
|
|
} |
|
|
@keyframes pulse-glow { |
|
|
0%, 100% { opacity: 0.3; filter: blur(5px); } |
|
|
50% { opacity: 0.6; filter: blur(8px); } |
|
|
} |
|
|
@keyframes pop { |
|
|
0% { transform: scale(0.8); opacity: 0; } |
|
|
50% { transform: scale(1.2); opacity: 1; } |
|
|
100% { transform: scale(1); opacity: 1; } |
|
|
} |
|
|
@keyframes slideIn { |
|
|
from { transform: translateY(-100%); opacity: 0; } |
|
|
to { transform: translateY(0); opacity: 1; } |
|
|
} |
|
|
@keyframes fire { |
|
|
0% { text-shadow: 0 0 10px #ef4444, 0 0 20px #f59e0b; transform: scale(1); } |
|
|
50% { text-shadow: 0 0 20px #ef4444, 0 0 40px #f59e0b; transform: scale(1.1); } |
|
|
100% { text-shadow: 0 0 10px #ef4444, 0 0 20px #f59e0b; transform: scale(1); } |
|
|
} |
|
|
|
|
|
.animate-shake { animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both; } |
|
|
.animate-pulse-glow { animation: pulse-glow 3s infinite; } |
|
|
.animate-fade-in { animation: fadeIn 0.3s ease-out; } |
|
|
.animate-pop { animation: pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } |
|
|
.animate-slide-in { animation: slideIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); } |
|
|
.combo-fire { animation: fire 0.5s infinite alternate; } |
|
|
|
|
|
@keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } |
|
|
|
|
|
|
|
|
.scroll-paper-default { |
|
|
background-color: #e8dcb9; |
|
|
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100' height='100' filter='url(%23noise)' opacity='0.15'/%3E%3C/svg%3E"); |
|
|
color: #0f172a; border-color: #92400e; |
|
|
} |
|
|
.scroll-paper-royal { |
|
|
background-color: #f3e5f5; |
|
|
background-image: radial-gradient(#e1bee7 1px, transparent 1px); |
|
|
background-size: 20px 20px; |
|
|
color: #4a148c; border-color: #7b1fa2; |
|
|
} |
|
|
.scroll-paper-dark { |
|
|
background-color: #1e293b; color: #e2e8f0; border-color: #64748b; border: 2px solid #475569; |
|
|
} |
|
|
.scroll-paper-golden { |
|
|
background: linear-gradient(135deg, #fceabb 0%, #f8b500 100%); |
|
|
color: #5d4037; border-color: #ffd700; box-shadow: 0 0 15px rgba(253, 185, 49, 0.6); |
|
|
} |
|
|
.scroll-paper-matrix { |
|
|
background-color: #000; color: #0f0; border-color: #0f0; font-family: 'Courier New', monospace; |
|
|
box-shadow: 0 0 10px #0f0; |
|
|
} |
|
|
.scroll-paper-galaxy { |
|
|
background: linear-gradient(to bottom right, #0f2027, #203a43, #2c5364); |
|
|
color: #e0f7fa; border-color: #00d2ff; |
|
|
} |
|
|
|
|
|
|
|
|
.leaderboard-row:nth-child(1) .rank { color: #fbbf24; text-shadow: 0 0 5px rgba(251, 191, 36, 0.5); } |
|
|
.leaderboard-row:nth-child(2) .rank { color: #94a3b8; text-shadow: 0 0 5px rgba(148, 163, 184, 0.5); } |
|
|
.leaderboard-row:nth-child(3) .rank { color: #b45309; text-shadow: 0 0 5px rgba(180, 83, 9, 0.5); } |
|
|
|
|
|
|
|
|
::-webkit-scrollbar { width: 8px; } |
|
|
::-webkit-scrollbar-track { background: #1e293b; } |
|
|
::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; } |
|
|
::-webkit-scrollbar-thumb:hover { background: #64748b; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div id="root"></div> |
|
|
|
|
|
|
|
|
<script type="module"> |
|
|
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js"; |
|
|
import { getAuth, signInAnonymously, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js"; |
|
|
import { getFirestore, doc, setDoc, collection, onSnapshot } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js"; |
|
|
|
|
|
window.firebaseModules = { initializeApp, getAuth, signInAnonymously, onAuthStateChanged, getFirestore, doc, setDoc, collection, onSnapshot }; |
|
|
</script> |
|
|
|
|
|
<script type="text/babel"> |
|
|
const { useState, useEffect, useRef, useMemo } = React; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const YOUR_FIREBASE_CONFIG = { |
|
|
apiKey: "AIzaSyAcIQYst9gFRyE2nCqCwNgCL3LG2VkBXbM", |
|
|
authDomain: "factor-529c9.firebaseapp.com", |
|
|
projectId: "factor-529c9", |
|
|
storageBucket: "factor-529c9.firebasestorage.app", |
|
|
messagingSenderId: "575829635896", |
|
|
appId: "1:575829635896:web:a65b6edd9d163375b087cd" |
|
|
}; |
|
|
|
|
|
|
|
|
const Icons = { |
|
|
Home: () => <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>, |
|
|
ArrowRight: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>, |
|
|
Coins: () => <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/></svg>, |
|
|
Help: () => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>, |
|
|
Check: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>, |
|
|
Backspace: () => <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/><line x1="18" y1="9" x2="12" y2="15"/><line x1="12" y1="9" x2="18" y2="15"/></svg>, |
|
|
ShoppingBag: () => <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>, |
|
|
Lock: () => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>, |
|
|
Crown: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m5 18 4-9 4 8.8L17 8l-2 10H5Z"/><path d="M21 18h-4"/><path d="M9 18H5"/><circle cx="17" cy="8" r="1"/><circle cx="13" cy="17" r="1"/><circle cx="9" cy="9" r="1"/></svg>, |
|
|
Trophy: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>, |
|
|
Alert: () => <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>, |
|
|
Teacher: () => <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>, |
|
|
Flame: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.1.2-2.2.6-3.3.037.15.072.292.11.426.33 1.154 1.334 2.05 2.79 2.374Z"/></svg>, |
|
|
QrCode: () => <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/></svg>, |
|
|
Chart: () => <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>, |
|
|
List: () => <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg> |
|
|
}; |
|
|
|
|
|
|
|
|
let db = null; |
|
|
let auth = null; |
|
|
let isCustomConfig = false; |
|
|
let internalAppId = 'algebra-relics'; |
|
|
|
|
|
const initFirebase = () => { |
|
|
if (!window.firebaseModules) return; |
|
|
const { initializeApp, getAuth, getFirestore } = window.firebaseModules; |
|
|
|
|
|
try { |
|
|
let configToUse = null; |
|
|
if (YOUR_FIREBASE_CONFIG) { |
|
|
configToUse = YOUR_FIREBASE_CONFIG; |
|
|
isCustomConfig = true; |
|
|
console.log("Using custom Firebase config"); |
|
|
} |
|
|
else if (typeof __firebase_config !== 'undefined') { |
|
|
configToUse = JSON.parse(__firebase_config); |
|
|
internalAppId = typeof __app_id !== 'undefined' ? __app_id : 'algebra-relics'; |
|
|
console.log("Using environment Firebase config"); |
|
|
} |
|
|
|
|
|
if (configToUse) { |
|
|
const app = initializeApp(configToUse); |
|
|
auth = getAuth(app); |
|
|
db = getFirestore(app); |
|
|
console.log("Firebase initialized"); |
|
|
} else { |
|
|
console.log("No Firebase config found. Running in offline mode."); |
|
|
} |
|
|
} catch (e) { |
|
|
console.log("Firebase init error:", e); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const getLeaderboardCollection = (collectionFn) => { |
|
|
if (isCustomConfig) { |
|
|
return collectionFn(db, 'leaderboard'); |
|
|
} else { |
|
|
return collectionFn(db, 'artifacts', internalAppId, 'public', 'data', 'leaderboard'); |
|
|
} |
|
|
}; |
|
|
|
|
|
const getUserDoc = (docFn, uid) => { |
|
|
if (isCustomConfig) { |
|
|
return docFn(db, 'leaderboard', uid); |
|
|
} else { |
|
|
return docFn(db, 'artifacts', internalAppId, 'public', 'data', 'leaderboard', uid); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const SHOP_ITEMS = [ |
|
|
|
|
|
{ id: 'title_novice', type: 'title', name: '見習探險家', price: 0, desc: '初始稱號' }, |
|
|
{ id: 'title_hunter', type: 'title', name: '公因數獵人', price: 500, desc: '擅長發現共同點' }, |
|
|
{ id: 'title_solver', type: 'title', name: '解謎專家', price: 1000, desc: '沒有解不開的鎖' }, |
|
|
{ id: 'title_master', type: 'title', name: '多項式大師', price: 2000, desc: '精通代數奧秘' }, |
|
|
{ id: 'title_legend', type: 'title', name: '傳說中的學霸', price: 5000, desc: '萬人景仰的存在' }, |
|
|
{ id: 'title_god', type: 'title', name: '真理追尋者', price: 10000, desc: '超越凡人的智慧' }, |
|
|
|
|
|
|
|
|
{ id: 'scroll_default', type: 'scroll', name: '老舊羊皮紙', price: 0, desc: '經典的質感', styleClass: 'scroll-paper-default' }, |
|
|
{ id: 'scroll_royal', type: 'scroll', name: '皇家絲綢', price: 800, desc: '高貴的紫色風格', styleClass: 'scroll-paper-royal' }, |
|
|
{ id: 'scroll_golden', type: 'scroll', name: '黃金傳說', price: 2500, desc: '閃耀著財富的光芒', styleClass: 'scroll-paper-golden' }, |
|
|
{ id: 'scroll_dark', type: 'scroll', name: '暗黑石板', price: 1500, desc: '深淵中的智慧', styleClass: 'scroll-paper-dark' }, |
|
|
{ id: 'scroll_matrix', type: 'scroll', name: '駭客任務', price: 3000, desc: '解開母體的代碼', styleClass: 'scroll-paper-matrix' }, |
|
|
{ id: 'scroll_galaxy', type: 'scroll', name: '星辰大海', price: 5000, desc: '來自宇宙的知識', styleClass: 'scroll-paper-galaxy' }, |
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
const ALL_CATEGORIES = [ |
|
|
{ |
|
|
id: "basic", |
|
|
title: "基礎提公因式", |
|
|
desc: "萬丈高樓平地起,找出共同的寶藏。", |
|
|
color: "from-blue-500 to-cyan-500", |
|
|
problems: [ |
|
|
{ |
|
|
id: 101, q: "3b^2 + 2b", |
|
|
a: ["b(3b+2)", "(3b+2)b", "b(2+3b)", "(2+3b)b"], |
|
|
h: "提出公因式 b" |
|
|
}, |
|
|
{ |
|
|
id: 102, q: "5x^2 + 2x", |
|
|
a: ["x(5x+2)", "(5x+2)x", "x(2+5x)", "(2+5x)x"], |
|
|
h: "提出公因式 x" |
|
|
}, |
|
|
{ |
|
|
id: 103, q: "6y^2 - 2y", |
|
|
a: ["2y(3y-1)", "(3y-1)2y", "2y(-1+3y)", "(-1+3y)2y", "-2y(1-3y)", "(1-3y)(-2y)"], |
|
|
h: "係數 6 和 2 都有公因數 2" |
|
|
}, |
|
|
{ |
|
|
id: 104, q: "4x^2 - 8x", |
|
|
a: ["4x(x-2)", "x(4x-8)", "4(x^2-2x)", "2x(2x-4)", "2(2x^2-4x)", "4x(-2+x)", "(-2+x)4x", "(4x-8)x", "(x-2)4x", "-4x(2-x)"], |
|
|
h: "提出 4x" |
|
|
}, |
|
|
{ |
|
|
id: 105, q: "ax + bx", |
|
|
a: ["x(a+b)", "(a+b)x", "x(b+a)", "(b+a)x"], |
|
|
h: "最簡單的提公因式" |
|
|
}, |
|
|
{ |
|
|
id: 106, q: "3x^2 + 6x", |
|
|
a: ["3x(x+2)", "x(3x+6)", "3(x^2+2x)", "3x(2+x)", "(x+2)3x", "(3x+6)x", "(2+x)3x"], |
|
|
h: "提出 3x" |
|
|
} |
|
|
] |
|
|
}, |
|
|
{ |
|
|
id: "group", |
|
|
title: "分組提公因式", |
|
|
desc: "分進合擊!尋找隱藏的括號。", |
|
|
color: "from-emerald-500 to-teal-500", |
|
|
problems: [ |
|
|
{ |
|
|
id: 201, q: "2x(x-2) + 5(x-2)", |
|
|
a: ["(x-2)(2x+5)", "(2x+5)(x-2)", "(x-2)(5+2x)", "(5+2x)(x-2)", "(-x+2)(-2x-5)", "(-2x-5)(-x+2)", "-(2-x)(2x+5)", "-(2x+5)(2-x)"], |
|
|
h: "把 (x-2) 當作一個整體" |
|
|
}, |
|
|
{ |
|
|
id: 202, q: "x(3x-7) + 4(3x-7)", |
|
|
a: ["(3x-7)(x+4)", "(x+4)(3x-7)", "(3x-7)(4+x)", "(4+x)(3x-7)", "(7-3x)(-x-4)", "(-x-4)(7-3x)", "-(7-3x)(x+4)", "-(x+4)(7-3x)"], |
|
|
h: "提出 (3x-7)" |
|
|
}, |
|
|
{ |
|
|
id: 203, q: "a(x+y) + b(x+y)", |
|
|
a: ["(x+y)(a+b)", "(a+b)(x+y)", "(y+x)(a+b)", "(x+y)(b+a)", "(a+b)(y+x)", "(b+a)(x+y)", "(b+a)(y+x)"], |
|
|
h: "都有 (x+y)" |
|
|
}, |
|
|
{ |
|
|
id: 204, q: "x^2 + 2x + xy + 2y", |
|
|
a: ["(x+2)(x+y)", "(x+y)(x+2)", "(2+x)(y+x)", "(x+y)(2+x)", "(y+x)(x+2)", "(y+x)(2+x)", "(2+x)(x+y)"], |
|
|
h: "前兩項一組,後兩項一組" |
|
|
}, |
|
|
{ |
|
|
id: 205, q: "ac + ad + bc + bd", |
|
|
a: ["(c+d)(a+b)", "(a+b)(c+d)", "(d+c)(a+b)", "(c+d)(b+a)", "(a+b)(d+c)", "(b+a)(c+d)", "(b+a)(d+c)"], |
|
|
h: "分組:a(c+d) + b(c+d)" |
|
|
}, |
|
|
{ |
|
|
id: 206, q: "3x(2x-5) + (2x-5)(x+8)", |
|
|
a: ["(2x-5)(4x+8)", "4(2x-5)(x+2)", "(4x+8)(2x-5)", "(2x-5)(8+4x)", "4(2x-5)(2+x)", "4(x+2)(2x-5)", "4(2+x)(2x-5)", "(5-2x)(-4x-8)", "-(5-2x)(4x+8)"], |
|
|
h: "合併剩下的:3x + (x+8)" |
|
|
} |
|
|
] |
|
|
}, |
|
|
{ |
|
|
id: "sign", |
|
|
title: "變號提公因式", |
|
|
desc: "敵人的敵人就是朋友,善用負號。", |
|
|
color: "from-amber-500 to-orange-500", |
|
|
problems: [ |
|
|
{ |
|
|
id: 301, q: "x(2x-1) + (1-2x)", |
|
|
a: ["(2x-1)(x-1)", "(x-1)(2x-1)", "(1-2x)(1-x)", "(1-x)(1-2x)", "-(1-2x)(x-1)", "-(x-1)(1-2x)", "-(2x-1)(1-x)"], |
|
|
h: "(1-2x) 變號為 -(2x-1)" |
|
|
}, |
|
|
{ |
|
|
id: 302, q: "x(a-b) + y(b-a)", |
|
|
a: ["(a-b)(x-y)", "(x-y)(a-b)", "(b-a)(y-x)", "(y-x)(b-a)", "-(b-a)(x-y)", "-(x-y)(b-a)", "-(a-b)(y-x)"], |
|
|
h: "(b-a) 變號為 -(a-b)" |
|
|
}, |
|
|
{ |
|
|
id: 303, q: "x(2x-3) - 3(3-2x)", |
|
|
a: ["(2x-3)(x+3)", "(x+3)(2x-3)", "(3-2x)(-x-3)", "(-x-3)(3-2x)", "(3+x)(2x-3)", "(2x-3)(3+x)", "-(3-2x)(x+3)"], |
|
|
h: "減去負的變成加" |
|
|
}, |
|
|
{ |
|
|
id: 304, q: "(x-y)^2 + 2(y-x)", |
|
|
a: ["(x-y)(x-y-2)", "(x-y-2)(x-y)", "(y-x)(y-x+2)", "(y-x+2)(y-x)", "(x-y-2)(x-y)", "-(y-x)(x-y-2)", "(-x+y+2)(-x+y)"], |
|
|
h: "(y-x) 提出負號變成 -(x-y)" |
|
|
}, |
|
|
{ |
|
|
id: 305, q: "a(x-y) - b(y-x)", |
|
|
a: ["(x-y)(a+b)", "(a+b)(x-y)", "(y-x)(-a-b)", "(-a-b)(y-x)", "(x-y)(b+a)", "(b+a)(x-y)", "-(y-x)(a+b)"], |
|
|
h: "變號後提出 (x-y)" |
|
|
} |
|
|
] |
|
|
}, |
|
|
{ |
|
|
id: "diff", |
|
|
title: "平方差公式", |
|
|
desc: "一加一減,成雙成對。", |
|
|
color: "from-rose-500 to-pink-500", |
|
|
problems: [ |
|
|
{ |
|
|
id: 401, q: "x^2 - 49", |
|
|
a: ["(x+7)(x-7)", "(x-7)(x+7)", "(7+x)(x-7)", "(x-7)(7+x)", "(-x-7)(7-x)", "(7-x)(-x-7)", "-(7-x)(x+7)"], |
|
|
h: "7 的平方是 49" |
|
|
}, |
|
|
{ |
|
|
id: 402, q: "x^2 - 1", |
|
|
a: ["(x+1)(x-1)", "(x-1)(x+1)", "(1+x)(x-1)", "(x-1)(1+x)", "(-x-1)(1-x)", "(1-x)(-x-1)", "-(1-x)(x+1)"], |
|
|
h: "1 的平方還是 1" |
|
|
}, |
|
|
{ |
|
|
id: 403, q: "4x^2 - 9", |
|
|
a: ["(2x+3)(2x-3)", "(2x-3)(2x+3)", "(3+2x)(2x-3)", "(2x-3)(3+2x)", "(-2x-3)(3-2x)", "-(3-2x)(2x+3)"], |
|
|
h: "(2x)^2 - 3^2" |
|
|
}, |
|
|
{ |
|
|
id: 404, q: "25x^2 - 36y^2", |
|
|
a: ["(5x+6y)(5x-6y)", "(5x-6y)(5x+6y)", "(6y+5x)(5x-6y)", "(5x-6y)(6y+5x)", "-(6y-5x)(5x+6y)"], |
|
|
h: "(5x)^2 - (6y)^2" |
|
|
}, |
|
|
{ |
|
|
id: 405, q: "49 - (y-1)^2", |
|
|
a: [ |
|
|
"(8-y)(y+6)", "(y+6)(8-y)", |
|
|
"(6+y)(8-y)", "(8-y)(6+y)", |
|
|
"(-y+8)(y+6)", "(y+6)(-y+8)", |
|
|
"(-y+8)(6+y)", "(6+y)(-y+8)", |
|
|
"(-y-6)(y-8)", "(y-8)(-y-6)", |
|
|
"(-6-y)(y-8)", "(y-8)(-6-y)", |
|
|
"(-y-6)(-8+y)", "(-8+y)(-y-6)", |
|
|
"-(y-8)(y+6)", "-(y+6)(y-8)", |
|
|
"-(y-8)(6+y)", "-(6+y)(y-8)", |
|
|
"-(-8+y)(y+6)", "-(y+6)(-8+y)", |
|
|
"-(-8+y)(6+y)", "-(6+y)(-8+y)", |
|
|
"-(8-y)(-y-6)", "-(-y-6)(8-y)", |
|
|
"-(8-y)(-6-y)", "-(-6-y)(8-y)" |
|
|
], |
|
|
h: "A=7, B=(y-1)" |
|
|
} |
|
|
] |
|
|
}, |
|
|
{ |
|
|
id: "perfect", |
|
|
title: "完全平方公式", |
|
|
desc: "頭平方,尾平方,2倍頭尾在中央。", |
|
|
color: "from-violet-500 to-purple-500", |
|
|
problems: [ |
|
|
{ |
|
|
id: 501, q: "x^2 + 2x + 1", |
|
|
a: ["(x+1)^2", "(x+1)(x+1)", "(1+x)^2", "(1+x)(1+x)", "(-x-1)^2", "(-x-1)(-x-1)"], |
|
|
h: "1 是 1^2,中間是 2*x*1" |
|
|
}, |
|
|
{ |
|
|
id: 502, q: "x^2 + 8x + 16", |
|
|
a: ["(x+4)^2", "(x+4)(x+4)", "(4+x)^2", "(4+x)(4+x)", "(-x-4)^2"], |
|
|
h: "16 是 4^2" |
|
|
}, |
|
|
{ |
|
|
id: 503, q: "x^2 - 4x + 4", |
|
|
a: ["(x-2)^2", "(2-x)^2", "(x-2)(x-2)", "(2-x)(2-x)", "-(x-2)(2-x)"], |
|
|
h: "中間是負號" |
|
|
}, |
|
|
{ |
|
|
id: 504, q: "x^2 - 10x + 25", |
|
|
a: ["(x-5)^2", "(5-x)^2", "(x-5)(x-5)", "(5-x)(5-x)"], |
|
|
h: "25 是 5^2" |
|
|
}, |
|
|
{ |
|
|
id: 505, q: "16x^2 - 24x + 9", |
|
|
a: ["(4x-3)^2", "(3-4x)^2", "(4x-3)(4x-3)", "(3-4x)(3-4x)"], |
|
|
h: "(4x)^2 和 3^2" |
|
|
} |
|
|
] |
|
|
} |
|
|
]; |
|
|
|
|
|
const CHALLENGE_CATEGORY_TEMPLATE = { |
|
|
id: "challenge", |
|
|
title: "綜合試煉", |
|
|
desc: "最終的考驗,題目隨機出現!", |
|
|
color: "from-red-500 to-rose-600", |
|
|
problems: [] |
|
|
}; |
|
|
|
|
|
|
|
|
const getProblemDetails = (pid) => { |
|
|
for(const cat of ALL_CATEGORIES) { |
|
|
const prob = cat.problems.find(p => p.id === parseInt(pid)); |
|
|
if (prob) return { ...prob, category: cat.title }; |
|
|
} |
|
|
return { q: "未知題目", category: "未知", id: pid }; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const getStorageKey = (roomId) => { |
|
|
|
|
|
return roomId ? `factoring_save_room_${roomId}` : 'factoring_game_data_v5'; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const NameModal = ({ onSubmit }) => { |
|
|
const [name, setName] = useState(""); |
|
|
const [roomId, setRoomId] = useState(""); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const params = new URLSearchParams(window.location.search); |
|
|
const roomParam = params.get('room'); |
|
|
if (roomParam) { |
|
|
setRoomId(roomParam); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
return ( |
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in p-4"> |
|
|
<div className="bg-slate-900 p-8 rounded-2xl border-2 border-amber-500 shadow-2xl w-full max-w-md text-center relative overflow-hidden"> |
|
|
<div className="absolute inset-0 bg-gradient-to-b from-amber-900/20 to-transparent pointer-events-none"></div> |
|
|
<div className="relative z-10"> |
|
|
<div className="flex justify-center mb-4"> |
|
|
<div className="bg-amber-500/20 p-4 rounded-full"> |
|
|
<Icons.Crown /> |
|
|
</div> |
|
|
</div> |
|
|
<h2 className="text-2xl font-bold text-amber-100 mb-2">歡迎來到代數遺跡</h2> |
|
|
<p className="text-slate-400 mb-6 text-sm">請輸入你的代號與房間代碼(選填)。</p> |
|
|
|
|
|
<div className="text-left text-xs text-slate-400 mb-1 ml-1">冒險者代號</div> |
|
|
<input |
|
|
type="text" |
|
|
value={name} |
|
|
onChange={(e) => setName(e.target.value)} |
|
|
className="w-full bg-black/40 border border-slate-600 rounded-xl px-4 py-3 text-center text-slate-100 text-lg focus:border-amber-500 outline-none mb-4 placeholder-slate-600" |
|
|
placeholder="例如:數學小王子" |
|
|
autoFocus |
|
|
/> |
|
|
|
|
|
<div className="text-left text-xs text-slate-400 mb-1 ml-1">房間代碼 (選填,不填則進入公開區)</div> |
|
|
<input |
|
|
type="text" |
|
|
value={roomId} |
|
|
onChange={(e) => setRoomId(e.target.value)} |
|
|
className="w-full bg-black/40 border border-slate-600 rounded-xl px-4 py-3 text-center text-yellow-400 font-mono text-lg focus:border-amber-500 outline-none mb-6 placeholder-slate-700 tracking-widest uppercase" |
|
|
placeholder="EX: 123456" |
|
|
/> |
|
|
|
|
|
<button |
|
|
onClick={() => name.trim() && onSubmit(name.trim(), roomId.trim())} |
|
|
disabled={!name.trim()} |
|
|
className={`w-full py-3 rounded-xl font-bold text-lg transition-all ${ |
|
|
name.trim() |
|
|
? "bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-900/50" |
|
|
: "bg-slate-800 text-slate-500 cursor-not-allowed" |
|
|
}`} |
|
|
> |
|
|
開始冒險 |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const TeacherDashboard = ({ onClose, allData }) => { |
|
|
const [activeTab, setActiveTab] = useState('room'); |
|
|
const [roomCode, setRoomCode] = useState(""); |
|
|
const [manualRoomInput, setManualRoomInput] = useState(""); |
|
|
const [roomHistory, setRoomHistory] = useState([]); |
|
|
const [selectedStudent, setSelectedStudent] = useState(null); |
|
|
const [showLargeQr, setShowLargeQr] = useState(false); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const savedHistory = localStorage.getItem('teacher_room_history'); |
|
|
if (savedHistory) { |
|
|
try { |
|
|
setRoomHistory(JSON.parse(savedHistory)); |
|
|
} catch (e) { console.error(e); } |
|
|
} |
|
|
}, []); |
|
|
|
|
|
|
|
|
const switchRoom = (code) => { |
|
|
setRoomCode(code); |
|
|
setManualRoomInput(code); |
|
|
setActiveTab('room'); |
|
|
|
|
|
|
|
|
const newHistory = [code, ...roomHistory.filter(h => h !== code)].slice(0, 5); |
|
|
setRoomHistory(newHistory); |
|
|
localStorage.setItem('teacher_room_history', JSON.stringify(newHistory)); |
|
|
}; |
|
|
|
|
|
|
|
|
const generateRoom = () => { |
|
|
const code = Math.floor(100000 + Math.random() * 900000).toString(); |
|
|
switchRoom(code); |
|
|
}; |
|
|
|
|
|
|
|
|
const handleManualJoin = () => { |
|
|
if (manualRoomInput.trim()) { |
|
|
switchRoom(manualRoomInput.trim()); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const getQrUrl = (size = "150x150") => { |
|
|
const baseUrl = window.location.origin + window.location.pathname; |
|
|
const fullUrl = `${baseUrl}?room=${roomCode}`; |
|
|
return `https://api.qrserver.com/v1/create-qr-code/?size=${size}&data=${encodeURIComponent(fullUrl)}`; |
|
|
}; |
|
|
|
|
|
|
|
|
const fullRoomUrl = `${window.location.origin}${window.location.pathname}?room=${roomCode}`; |
|
|
|
|
|
|
|
|
const filteredData = useMemo(() => { |
|
|
if (!roomCode) return []; |
|
|
|
|
|
return allData.filter(u => u.roomId === roomCode); |
|
|
}, [allData, roomCode]); |
|
|
|
|
|
|
|
|
const analysis = useMemo(() => { |
|
|
const problemMistakes = {}; |
|
|
const categoryMistakes = {}; |
|
|
|
|
|
filteredData.forEach(student => { |
|
|
if (student.mistakes) { |
|
|
Object.entries(student.mistakes).forEach(([pid, count]) => { |
|
|
|
|
|
problemMistakes[pid] = (problemMistakes[pid] || 0) + count; |
|
|
|
|
|
|
|
|
const details = getProblemDetails(pid); |
|
|
categoryMistakes[details.category] = (categoryMistakes[details.category] || 0) + count; |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
const sortedProblems = Object.entries(problemMistakes) |
|
|
.map(([pid, count]) => ({ ...getProblemDetails(pid), count })) |
|
|
.sort((a, b) => b.count - a.count); |
|
|
|
|
|
const sortedCategories = Object.entries(categoryMistakes) |
|
|
.map(([cat, count]) => ({ cat, count })) |
|
|
.sort((a, b) => b.count - a.count); |
|
|
|
|
|
return { sortedProblems, sortedCategories }; |
|
|
}, [filteredData]); |
|
|
|
|
|
return ( |
|
|
<div className="min-h-screen flex flex-col p-4 md:p-6 relative bg-slate-900 text-slate-200 font-sans"> |
|
|
{/* Large QR Modal */} |
|
|
{showLargeQr && ( |
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-md p-4" onClick={() => setShowLargeQr(false)}> |
|
|
<div className="bg-white p-8 rounded-3xl animate-pop text-center max-w-lg w-full shadow-2xl relative" onClick={e => e.stopPropagation()}> |
|
|
<button onClick={() => setShowLargeQr(false)} className="absolute top-4 right-4 text-slate-400 hover:text-slate-600"> |
|
|
<Icons.Check className="rotate-45" /> |
|
|
</button> |
|
|
<h3 className="text-2xl font-bold text-slate-800 mb-2">加入房間</h3> |
|
|
<div className="text-6xl font-mono font-bold text-amber-600 tracking-widest mb-6 select-all">{roomCode}</div> |
|
|
<div className="bg-white p-2 rounded-xl inline-block border-4 border-slate-100 mb-4 hover:border-amber-200 transition-colors shadow-inner"> |
|
|
<a href={fullRoomUrl} target="_blank" rel="noopener noreferrer" title="點擊開啟連結"> |
|
|
<img src={getQrUrl("500x500")} alt="Large Room QR" className="w-full max-w-[400px] h-auto aspect-square object-contain cursor-pointer hover:opacity-95 transition-opacity" /> |
|
|
</a> |
|
|
</div> |
|
|
<p className="text-slate-500 font-bold">請學生掃描 QR Code 或輸入上方代碼</p> |
|
|
<div className="text-blue-500 text-sm mt-1 font-bold">(點擊 QR Code 可直接進入房間)</div> |
|
|
<div className="text-slate-400 text-xs mt-4">點擊背景關閉</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<div className="flex justify-between items-center mb-6"> |
|
|
<h2 className="text-2xl font-bold flex items-center gap-2 text-amber-400"><Icons.Teacher /> 教師管理後台</h2> |
|
|
<button onClick={onClose} className="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-lg">返回遊戲</button> |
|
|
</div> |
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 h-[calc(100vh-100px)]"> |
|
|
{/* Sidebar / Controls */} |
|
|
<div className="md:col-span-1 bg-slate-800 rounded-xl p-4 shadow-lg border border-slate-700 flex flex-col gap-4 overflow-y-auto"> |
|
|
|
|
|
{/* Room Control Panel */} |
|
|
<div className="bg-black/30 p-4 rounded-lg text-center"> |
|
|
<div className="text-xs text-slate-400 uppercase tracking-widest mb-2">目前房間代碼</div> |
|
|
{roomCode ? ( |
|
|
<> |
|
|
<div className="text-4xl font-mono font-bold text-yellow-400 tracking-widest mb-4 select-all">{roomCode}</div> |
|
|
<div |
|
|
className="bg-white p-2 rounded-lg inline-block mx-auto cursor-zoom-in hover:scale-105 transition-transform group relative" |
|
|
onClick={() => setShowLargeQr(true)} |
|
|
> |
|
|
<img src={getQrUrl("150x150")} alt="Room QR" className="w-32 h-32" /> |
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/20 transition-colors rounded-lg"> |
|
|
<span className="opacity-0 group-hover:opacity-100 bg-black/70 text-white text-xs px-2 py-1 rounded backdrop-blur-sm pointer-events-none">點擊放大</span> |
|
|
</div> |
|
|
</div> |
|
|
<div className="mt-2 text-xs text-slate-500">學生掃描後可自動填入代碼</div> |
|
|
</> |
|
|
) : ( |
|
|
<div className="text-slate-500 py-8">尚未選擇房間</div> |
|
|
)} |
|
|
<button onClick={generateRoom} className="w-full mt-4 bg-blue-600 hover:bg-blue-500 text-white py-2 rounded-lg font-bold text-sm shadow-lg mb-2"> |
|
|
建立新房間 |
|
|
</button> |
|
|
|
|
|
{/* Manual Input Area */} |
|
|
<div className="mt-4 pt-4 border-t border-slate-700"> |
|
|
<div className="text-xs text-slate-500 mb-1 text-left">監控現有房間</div> |
|
|
<div className="flex gap-2"> |
|
|
<input |
|
|
type="text" |
|
|
value={manualRoomInput} |
|
|
onChange={(e) => setManualRoomInput(e.target.value)} |
|
|
placeholder="輸入代碼" |
|
|
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1 text-white text-sm text-center" |
|
|
/> |
|
|
<button onClick={handleManualJoin} className="bg-slate-600 hover:bg-slate-500 text-white px-3 rounded text-sm">Go</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* History Area */} |
|
|
{roomHistory.length > 0 && ( |
|
|
<div className="mt-4 pt-4 border-t border-slate-700"> |
|
|
<div className="text-xs text-slate-500 mb-2 text-left">最近開啟</div> |
|
|
<div className="flex flex-wrap gap-2"> |
|
|
{roomHistory.map(code => ( |
|
|
<button |
|
|
key={code} |
|
|
onClick={() => switchRoom(code)} |
|
|
className={`text-xs px-2 py-1 rounded border ${code === roomCode ? 'bg-amber-900/50 border-amber-500 text-amber-200' : 'bg-slate-700 border-slate-600 text-slate-400 hover:text-white'}`} |
|
|
> |
|
|
{code} |
|
|
</button> |
|
|
))} |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
<div className="flex flex-col gap-2 mt-2"> |
|
|
<button onClick={() => {setActiveTab('list'); setSelectedStudent(null);}} className={`text-left px-4 py-3 rounded-lg flex items-center gap-3 transition-colors ${activeTab === 'list' ? 'bg-amber-600 text-white' : 'hover:bg-slate-700 text-slate-300'}`}> |
|
|
<Icons.List /> 學生列表 ({filteredData.length}) |
|
|
</button> |
|
|
<button onClick={() => setActiveTab('analysis')} className={`text-left px-4 py-3 rounded-lg flex items-center gap-3 transition-colors ${activeTab === 'analysis' ? 'bg-amber-600 text-white' : 'hover:bg-slate-700 text-slate-300'}`}> |
|
|
<Icons.Chart /> 錯題分析 |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Main Content Area */} |
|
|
<div className="md:col-span-3 bg-slate-800 rounded-xl shadow-lg border border-slate-700 overflow-hidden flex flex-col"> |
|
|
{!roomCode ? ( |
|
|
<div className="flex-1 flex flex-col items-center justify-center text-slate-500"> |
|
|
<Icons.Teacher /> |
|
|
<p className="mt-4">請先建立房間以開始收集數據</p> |
|
|
</div> |
|
|
) : activeTab === 'list' ? ( |
|
|
selectedStudent ? ( |
|
|
// Individual Student Detail View |
|
|
<div className="flex-1 overflow-y-auto p-6"> |
|
|
<button onClick={() => setSelectedStudent(null)} className="mb-4 text-sm text-blue-400 hover:text-blue-300 flex items-center gap-1">← 返回列表</button> |
|
|
<h3 className="text-2xl font-bold text-white mb-1">{selectedStudent.name} <span className="text-sm text-slate-400 font-normal">的詳細報告</span></h3> |
|
|
<div className="text-yellow-500 font-mono mb-6">Score: ${selectedStudent.score}</div> |
|
|
|
|
|
<h4 className="text-lg font-bold text-slate-300 mb-3 border-b border-slate-700 pb-2">錯題紀錄</h4> |
|
|
{selectedStudent.mistakes && Object.keys(selectedStudent.mistakes).length > 0 ? ( |
|
|
<div className="space-y-3"> |
|
|
{Object.entries(selectedStudent.mistakes).sort((a,b) => b[1] - a[1]).map(([pid, count]) => { |
|
|
const details = getProblemDetails(pid); |
|
|
return ( |
|
|
<div key={pid} className="bg-slate-700/50 p-3 rounded-lg flex justify-between items-center"> |
|
|
<div> |
|
|
<span className="text-xs bg-slate-600 px-2 py-0.5 rounded text-slate-300 mr-2">{details.category}</span> |
|
|
<span className="font-serif text-lg">{details.q}</span> |
|
|
</div> |
|
|
<div className="text-red-400 font-bold flex items-center gap-1"> |
|
|
<Icons.Alert /> {count} 次錯誤 |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
})} |
|
|
</div> |
|
|
) : ( |
|
|
<div className="text-slate-500 italic">該學生目前沒有錯題紀錄 (太神啦!)</div> |
|
|
)} |
|
|
</div> |
|
|
) : ( |
|
|
// Student List View |
|
|
<div className="flex-1 overflow-y-auto"> |
|
|
<table className="w-full text-left border-collapse"> |
|
|
<thead className="sticky top-0 bg-slate-900 z-10"> |
|
|
<tr className="text-slate-400 text-sm uppercase tracking-wider"> |
|
|
<th className="p-4 border-b border-slate-700">排名</th> |
|
|
<th className="p-4 border-b border-slate-700">姓名</th> |
|
|
<th className="p-4 border-b border-slate-700">分數</th> |
|
|
<th className="p-4 border-b border-slate-700">錯題總數</th> |
|
|
<th className="p-4 border-b border-slate-700">操作</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody className="divide-y divide-slate-700"> |
|
|
{filteredData.map((student, idx) => { |
|
|
const totalMistakes = student.mistakes ? Object.values(student.mistakes).reduce((a,b)=>a+b,0) : 0; |
|
|
return ( |
|
|
<tr key={idx} className="hover:bg-slate-700/30 transition-colors"> |
|
|
<td className="p-4 font-bold text-slate-500">#{idx + 1}</td> |
|
|
<td className="p-4 font-bold text-white">{student.name}</td> |
|
|
<td className="p-4 font-mono text-yellow-400">${student.score}</td> |
|
|
<td className="p-4 text-slate-300">{totalMistakes}</td> |
|
|
<td className="p-4"> |
|
|
<button onClick={() => setSelectedStudent(student)} className="text-xs bg-slate-700 hover:bg-blue-600 text-white px-3 py-1 rounded transition-colors"> |
|
|
查看詳情 |
|
|
</button> |
|
|
</td> |
|
|
</tr> |
|
|
); |
|
|
})} |
|
|
{filteredData.length === 0 && ( |
|
|
<tr><td colSpan="5" className="p-8 text-center text-slate-500">目前房間內沒有學生</td></tr> |
|
|
)} |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
) |
|
|
) : ( |
|
|
// Analysis View |
|
|
<div className="flex-1 overflow-y-auto p-6 grid grid-cols-1 lg:grid-cols-2 gap-8"> |
|
|
<div> |
|
|
<h3 className="text-xl font-bold text-red-400 mb-4 flex items-center gap-2"><Icons.Alert /> 錯誤率最高的題目</h3> |
|
|
<div className="space-y-3"> |
|
|
{analysis.sortedProblems.slice(0, 10).map((item, idx) => ( |
|
|
<div key={idx} className="bg-slate-700/50 p-3 rounded-lg border-l-4 border-red-500"> |
|
|
<div className="flex justify-between items-start mb-1"> |
|
|
<div className="font-serif text-xl text-white">{item.q}</div> |
|
|
<div className="bg-red-900/50 text-red-300 px-2 py-0.5 rounded text-xs font-bold">{item.count} 人次答錯</div> |
|
|
</div> |
|
|
<div className="text-xs text-slate-400">分類: {item.category}</div> |
|
|
<div className="text-xs text-slate-500 mt-1">提示: {item.h}</div> |
|
|
</div> |
|
|
))} |
|
|
{analysis.sortedProblems.length === 0 && <div className="text-slate-500">尚無數據</div>} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div> |
|
|
<h3 className="text-xl font-bold text-orange-400 mb-4 flex items-center gap-2"><Icons.Chart /> 最不熟練的題型</h3> |
|
|
<div className="space-y-4"> |
|
|
{analysis.sortedCategories.map((item, idx) => ( |
|
|
<div key={idx} className="relative pt-1"> |
|
|
<div className="flex mb-2 items-center justify-between"> |
|
|
<div> |
|
|
<span className="text-xs font-semibold inline-block py-1 px-2 uppercase rounded-full text-orange-600 bg-orange-200"> |
|
|
{item.cat} |
|
|
</span> |
|
|
</div> |
|
|
<div className="text-right"> |
|
|
<span className="text-xs font-semibold inline-block text-orange-200"> |
|
|
{item.count} 次錯誤 |
|
|
</span> |
|
|
</div> |
|
|
</div> |
|
|
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-slate-700"> |
|
|
<div style={{ width: `${(item.count / Math.max(1, analysis.sortedCategories[0].count)) * 100}%` }} className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-orange-500"></div> |
|
|
</div> |
|
|
</div> |
|
|
))} |
|
|
{analysis.sortedCategories.length === 0 && <div className="text-slate-500">尚無數據</div>} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const TeacherLoginModal = ({ onClose, onLogin }) => { |
|
|
const [password, setPassword] = useState(""); |
|
|
const [error, setError] = useState(false); |
|
|
|
|
|
const handleLogin = () => { |
|
|
if (password === "Ghjh") { |
|
|
onLogin(); |
|
|
} else { |
|
|
setError(true); |
|
|
setTimeout(() => setError(false), 500); |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md p-4 animate-fade-in"> |
|
|
<div className="bg-slate-800 p-6 rounded-2xl border border-slate-600 shadow-2xl w-full max-w-sm text-center relative"> |
|
|
<button onClick={onClose} className="absolute top-3 right-3 text-slate-400 hover:text-white"><Icons.Check className="rotate-45" /></button> |
|
|
<h2 className="text-xl font-bold text-slate-200 mb-4 flex justify-center items-center gap-2"><Icons.Teacher /> 教師登入</h2> |
|
|
<input |
|
|
type="password" |
|
|
value={password} |
|
|
onChange={(e) => setPassword(e.target.value)} |
|
|
className={`w-full bg-black/40 border ${error ? 'border-red-500' : 'border-slate-600'} rounded-lg px-4 py-2 text-center text-white mb-4 outline-none`} |
|
|
placeholder="請輸入密碼" |
|
|
autoFocus |
|
|
/> |
|
|
<button onClick={handleLogin} className="w-full bg-blue-600 hover:bg-blue-500 text-white py-2 rounded-lg font-bold">確認</button> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
const Toast = ({ message, type, show }) => { |
|
|
if (!show) return null; |
|
|
return ( |
|
|
<div className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-50 px-6 py-3 rounded-full shadow-2xl border-2 flex items-center gap-2 animate-slide-in ${ |
|
|
type === 'success' |
|
|
? 'bg-yellow-900/90 border-yellow-400 text-yellow-100' |
|
|
: 'bg-red-900/90 border-red-500 text-red-100' |
|
|
}`}> |
|
|
{type === 'success' ? <Icons.Crown /> : <Icons.Alert />} |
|
|
<span className="font-bold text-sm md:text-base whitespace-nowrap">{message}</span> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
const CoinBurst = () => { |
|
|
const coins = Array.from({ length: 20 }).map((_, i) => ({ |
|
|
id: i, |
|
|
tx: (Math.random() - 0.5) * 400, |
|
|
ty: (Math.random() - 1.5) * 300, |
|
|
rot: Math.random() * 720, |
|
|
scale: 0.5 + Math.random() * 0.5, |
|
|
delay: Math.random() * 0.2 |
|
|
})); |
|
|
return ( |
|
|
<div className="absolute top-1/2 left-1/2 w-0 h-0 pointer-events-none z-50"> |
|
|
{coins.map((c) => ( |
|
|
<div |
|
|
key={c.id} |
|
|
className="absolute w-8 h-8 bg-yellow-400 rounded-full border-2 border-yellow-600 flex items-center justify-center text-yellow-700 font-bold shadow-[0_0_10px_rgba(234,179,8,0.8)]" |
|
|
style={{ |
|
|
'--tx': `${c.tx}px`, |
|
|
'--ty': `${c.ty}px`, |
|
|
'--rot': `${c.rot}deg`, |
|
|
animation: `coinFly 1.5s cubic-bezier(0.1, 0.8, 0.2, 1) forwards ${c.delay}s` |
|
|
}} |
|
|
> |
|
|
$ |
|
|
</div> |
|
|
))} |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
const TreasureChest = ({ isOpen, shake, children, questionText, hint, scrollStyle, combo }) => { |
|
|
const [showHint, setShowHint] = useState(false); |
|
|
useEffect(() => { if(isOpen) setShowHint(false); }, [isOpen]); |
|
|
|
|
|
return ( |
|
|
<div className={`relative w-full max-w-[360px] mx-auto transition-all duration-300 ${shake ? 'animate-shake' : ''} ${isOpen ? 'scale-105' : ''}`}> |
|
|
{/* 題目卷軸 */} |
|
|
<div className={`absolute -top-36 left-1/2 transform -translate-x-1/2 w-[110%] z-20 transition-opacity duration-500 ${isOpen ? 'opacity-0 translate-y-[-20px]' : 'opacity-100'}`}> |
|
|
<div className={`p-4 rounded-sm relative border-y-4 shadow-xl text-center ${scrollStyle}`}> |
|
|
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-4 h-[110%] bg-neutral-800 rounded-l-lg shadow-lg flex flex-col justify-between py-1"></div> |
|
|
<div className="absolute -right-3 top-1/2 -translate-y-1/2 w-4 h-[110%] bg-neutral-800 rounded-r-lg shadow-lg flex flex-col justify-between py-1"></div> |
|
|
|
|
|
<div className="font-bold text-xs mb-1 tracking-widest uppercase opacity-70">Mission</div> |
|
|
<div className="text-3xl md:text-4xl font-serif font-bold py-2 drop-shadow-sm flex justify-center items-baseline"> |
|
|
{questionText} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Combo Indicator */} |
|
|
{combo > 1 && ( |
|
|
<div className="absolute -right-8 top-0 transform rotate-12 bg-red-600 text-white font-black text-lg px-3 py-1 rounded-lg shadow-lg border-2 border-yellow-400 combo-fire z-30 flex items-center gap-1"> |
|
|
<Icons.Flame /> x{combo} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2"> |
|
|
<button |
|
|
onClick={() => setShowHint(!showHint)} |
|
|
className="bg-blue-900/80 hover:bg-blue-800 text-blue-200 text-xs px-3 py-1 rounded-b-lg backdrop-blur flex items-center gap-1 transition-colors shadow-lg border border-t-0 border-blue-500/30" |
|
|
> |
|
|
<Icons.Help /> |
|
|
{showHint ? "隱藏提示" : "提示"} |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
{showHint && ( |
|
|
<div className="absolute -bottom-24 left-0 right-0 bg-blue-900/95 text-blue-100 text-sm p-3 rounded-xl border border-blue-400 shadow-2xl z-30 animate-fade-in"> |
|
|
<div className="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-blue-900 transform rotate-45 border-t border-l border-blue-400"></div> |
|
|
💡 {hint} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
{/* 寶箱 SVG */} |
|
|
<svg viewBox="0 0 400 320" className="w-full drop-shadow-2xl mt-12"> |
|
|
<defs> |
|
|
<linearGradient id="wood" x1="0" y1="0" x2="1" y2="1"> |
|
|
<stop offset="0%" stopColor="#3E2723" /><stop offset="50%" stopColor="#5D4037" /><stop offset="100%" stopColor="#3E2723" /> |
|
|
</linearGradient> |
|
|
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1"> |
|
|
<stop offset="0%" stopColor="#FDB931" /><stop offset="50%" stopColor="#FFD700" /><stop offset="100%" stopColor="#C49102" /> |
|
|
</linearGradient> |
|
|
<filter id="innerGlow"> |
|
|
<feGaussianBlur stdDeviation="10" result="blur"/> |
|
|
<feComposite in="SourceGraphic" in2="blur" operator="arithmetic" k2="1" k3="1"/> |
|
|
</filter> |
|
|
</defs> |
|
|
<path d="M 50 120 L 350 120 L 350 280 Q 350 310 320 310 L 80 310 Q 50 310 50 280 Z" fill="url(#wood)" stroke="#271c19" strokeWidth="3" /> |
|
|
<rect x="70" y="130" width="260" height="150" fill="#1a0f0a" /> |
|
|
{isOpen && ( |
|
|
<g> |
|
|
<circle cx="200" cy="200" r="80" fill="#FFD700" opacity="0.3" filter="url(#innerGlow)" className="animate-pulse-glow" /> |
|
|
<path d="M 100 290 Q 200 200 300 290" fill="#FFD700" opacity="0.8" /> |
|
|
</g> |
|
|
)} |
|
|
<g className="transition-transform duration-700 origin-[200px_120px]" style={{ transform: isOpen ? 'rotateX(-110deg)' : 'rotateX(0)' }}> |
|
|
<path d="M 40 120 Q 200 10 360 120 L 360 120 L 40 120 Z" fill="url(#wood)" stroke="#271c19" strokeWidth="3" /> |
|
|
<path d="M 40 120 L 360 120 L 360 280 L 40 280 Z" fill="url(#wood)" stroke="#271c19" strokeWidth="3" opacity={isOpen ? 0 : 1} /> |
|
|
<path d="M 190 120 L 190 180 L 210 180 L 210 120" fill="url(#gold)" opacity={isOpen ? 0 : 1} /> |
|
|
<circle cx="200" cy="180" r="15" fill="url(#gold)" stroke="#8a6e03" strokeWidth="2" opacity={isOpen ? 0 : 1} /> |
|
|
</g> |
|
|
<path d="M 50 120 L 80 120 L 80 310 M 350 120 L 320 120 L 320 310" stroke="url(#gold)" strokeWidth="6" fill="none" /> |
|
|
</svg> |
|
|
|
|
|
{/* 輸入框 */} |
|
|
<div className={`absolute top-[60%] left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[80%] transition-all duration-300 ${isOpen ? 'opacity-0 pointer-events-none scale-90' : 'opacity-100 scale-100'}`}> |
|
|
<div className="bg-black/70 p-2 rounded-lg border border-amber-600/50 shadow-inner backdrop-blur-sm flex gap-2 items-center"> |
|
|
{children} |
|
|
</div> |
|
|
</div> |
|
|
{isOpen && <CoinBurst />} |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
function FactoringGame() { |
|
|
|
|
|
const [gameState, setGameState] = useState('menu'); |
|
|
const [activeCategory, setActiveCategory] = useState(null); |
|
|
const [currentProblemIndex, setCurrentProblemIndex] = useState(0); |
|
|
|
|
|
|
|
|
const [inputAnswer, setInputAnswer] = useState(""); |
|
|
const [cursorPos, setCursorPos] = useState(0); |
|
|
|
|
|
|
|
|
const [shake, setShake] = useState(false); |
|
|
const [chestOpen, setChestOpen] = useState(false); |
|
|
const [combo, setCombo] = useState(0); |
|
|
|
|
|
|
|
|
const [userData, setUserData] = useState(() => { |
|
|
|
|
|
const params = new URLSearchParams(window.location.search); |
|
|
const urlRoomId = params.get('room') || ""; |
|
|
|
|
|
|
|
|
const storageKey = getStorageKey(urlRoomId); |
|
|
const saved = localStorage.getItem(storageKey); |
|
|
|
|
|
|
|
|
const defaultData = { |
|
|
score: 0, |
|
|
name: "", |
|
|
roomId: urlRoomId, |
|
|
mistakes: {}, |
|
|
unlockedItems: ['title_novice', 'scroll_default'], |
|
|
equippedTitle: 'title_novice', |
|
|
equippedScroll: 'scroll_default', |
|
|
completedLevels: [] |
|
|
}; |
|
|
|
|
|
if (saved) { |
|
|
try { |
|
|
const parsed = JSON.parse(saved); |
|
|
|
|
|
return { ...defaultData, ...parsed, roomId: urlRoomId }; |
|
|
} catch (e) { |
|
|
console.error("Load error", e); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
return defaultData; |
|
|
}); |
|
|
|
|
|
const [showNameModal, setShowNameModal] = useState(false); |
|
|
const [showTeacherLogin, setShowTeacherLogin] = useState(false); |
|
|
|
|
|
|
|
|
const [leaderboardData, setLeaderboardData] = useState([]); |
|
|
const [currentUser, setCurrentUser] = useState(null); |
|
|
const [notification, setNotification] = useState({ show: false, message: "", type: "" }); |
|
|
const previousRankRef = useRef(null); |
|
|
const lastOvertakenTimeRef = useRef(0); |
|
|
|
|
|
const inputRef = useRef(null); |
|
|
|
|
|
|
|
|
const OVERTAKE_MESSAGES = [ |
|
|
"小心!{name} 剛剛超越你了!", |
|
|
"警報!{name} 的分數超過你了!", |
|
|
"被 {name} 超車了!快追回來!", |
|
|
"{name} 正在大殺四方,你的排名下降了!", |
|
|
"注意!{name} 搶走了你的名次!" |
|
|
]; |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!userData.name || userData.name === "冒險者") { |
|
|
setShowNameModal(true); |
|
|
} |
|
|
}, []); |
|
|
|
|
|
|
|
|
|
|
|
const handleJoinRoom = (name, newRoomId) => { |
|
|
const targetRoomId = newRoomId.trim(); |
|
|
|
|
|
|
|
|
if (targetRoomId !== userData.roomId) { |
|
|
const newKey = getStorageKey(targetRoomId); |
|
|
const saved = localStorage.getItem(newKey); |
|
|
let newData; |
|
|
|
|
|
if (saved) { |
|
|
|
|
|
newData = JSON.parse(saved); |
|
|
|
|
|
newData.name = name; |
|
|
showNotification(`已切換至房間:${targetRoomId}`, "success"); |
|
|
} else { |
|
|
|
|
|
newData = { |
|
|
score: 0, |
|
|
name: name, |
|
|
roomId: targetRoomId, |
|
|
mistakes: {}, |
|
|
unlockedItems: ['title_novice', 'scroll_default'], |
|
|
equippedTitle: 'title_novice', |
|
|
equippedScroll: 'scroll_default', |
|
|
completedLevels: [] |
|
|
}; |
|
|
showNotification(`已加入新房間:${targetRoomId}`, "success"); |
|
|
} |
|
|
setUserData(newData); |
|
|
} else { |
|
|
|
|
|
setUserData(prev => ({ ...prev, name, roomId: targetRoomId })); |
|
|
} |
|
|
setShowNameModal(false); |
|
|
}; |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const key = getStorageKey(userData.roomId); |
|
|
localStorage.setItem(key, JSON.stringify(userData)); |
|
|
}, [userData]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
initFirebase(); |
|
|
if (!window.firebaseModules || !auth) return; |
|
|
|
|
|
const { signInAnonymously, onAuthStateChanged } = window.firebaseModules; |
|
|
const { collection, onSnapshot } = window.firebaseModules; |
|
|
|
|
|
signInAnonymously(auth).catch(console.error); |
|
|
onAuthStateChanged(auth, (user) => { if (user) setCurrentUser(user); }); |
|
|
|
|
|
|
|
|
const unsubscribe = onSnapshot(getLeaderboardCollection(collection), (snapshot) => { |
|
|
const lb = []; |
|
|
snapshot.forEach(doc => lb.push({ ...doc.data(), uid: doc.id })); |
|
|
lb.sort((a, b) => b.score - a.score); |
|
|
setLeaderboardData(lb); |
|
|
|
|
|
|
|
|
if (gameState !== 'teacher_dashboard' && currentUser) { |
|
|
|
|
|
const myRoomLb = userData.roomId ? lb.filter(u => u.roomId === userData.roomId) : lb; |
|
|
|
|
|
const myNewIndex = myRoomLb.findIndex(u => u.uid === currentUser.uid); |
|
|
const myNewRank = myNewIndex === -1 ? 999 : myNewIndex + 1; |
|
|
|
|
|
if (previousRankRef.current !== null && myNewRank > previousRankRef.current && Date.now() - lastOvertakenTimeRef.current > 60000) { |
|
|
const rival = myRoomLb[myNewIndex - 1]; |
|
|
const rivalName = rival ? rival.name : "神秘探險家"; |
|
|
const randomMsg = OVERTAKE_MESSAGES[Math.floor(Math.random() * OVERTAKE_MESSAGES.length)].replace("{name}", rivalName); |
|
|
showNotification(randomMsg, "error"); |
|
|
lastOvertakenTimeRef.current = Date.now(); |
|
|
} |
|
|
previousRankRef.current = myNewRank; |
|
|
} |
|
|
}, (error) => console.log("Sync error:", error)); |
|
|
|
|
|
return () => unsubscribe(); |
|
|
}, [currentUser, gameState, userData.roomId]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!currentUser || !db || !userData.name) return; |
|
|
const { doc, setDoc } = window.firebaseModules; |
|
|
const userRef = getUserDoc(doc, currentUser.uid); |
|
|
setDoc(userRef, { |
|
|
name: userData.name, |
|
|
score: userData.score, |
|
|
roomId: userData.roomId || "", |
|
|
mistakes: userData.mistakes || {}, |
|
|
title: SHOP_ITEMS.find(i => i.id === userData.equippedTitle)?.name || 'Novice', |
|
|
updatedAt: Date.now() |
|
|
}, { merge: true }).catch(e => console.log("Score sync failed:", e)); |
|
|
}, [userData, currentUser]); |
|
|
|
|
|
|
|
|
const showNotification = (msg, type) => { |
|
|
setNotification({ show: true, message: msg, type }); |
|
|
setTimeout(() => setNotification(prev => ({ ...prev, show: false })), 3000); |
|
|
}; |
|
|
|
|
|
const normalizeInput = (input) => input.replace(/\s/g, "").replace(/\^2/g, "^2").replace(/(/g, "(").replace(/)/g, ")"); |
|
|
|
|
|
const playSound = (type) => { |
|
|
try { |
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)(); |
|
|
const osc = ctx.createOscillator(); |
|
|
const gain = ctx.createGain(); |
|
|
osc.connect(gain); |
|
|
gain.connect(ctx.destination); |
|
|
if (type === 'success') { |
|
|
osc.frequency.setValueAtTime(523.25, ctx.currentTime); |
|
|
osc.frequency.exponentialRampToValueAtTime(1046.5, ctx.currentTime + 0.1); |
|
|
gain.gain.setValueAtTime(0.3, ctx.currentTime); |
|
|
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5); |
|
|
} else { |
|
|
osc.type = 'sawtooth'; |
|
|
osc.frequency.setValueAtTime(150, ctx.currentTime); |
|
|
osc.frequency.linearRampToValueAtTime(100, ctx.currentTime + 0.2); |
|
|
gain.gain.setValueAtTime(0.2, ctx.currentTime); |
|
|
gain.gain.linearRampToValueAtTime(0.01, ctx.currentTime + 0.2); |
|
|
} |
|
|
osc.start(); |
|
|
osc.stop(ctx.currentTime + (type === 'success' ? 0.5 : 0.2)); |
|
|
} catch (e) {} |
|
|
}; |
|
|
|
|
|
const handleCheckAnswer = () => { |
|
|
if (!inputAnswer || !activeCategory) return; |
|
|
const currentProblem = activeCategory.problems[currentProblemIndex]; |
|
|
const normalizedUser = normalizeInput(inputAnswer); |
|
|
const normalizedAnswers = currentProblem.a.map(normalizeInput); |
|
|
|
|
|
if (normalizedAnswers.includes(normalizedUser)) { |
|
|
|
|
|
setChestOpen(true); |
|
|
playSound('success'); |
|
|
|
|
|
const newCombo = combo + 1; |
|
|
setCombo(newCombo); |
|
|
const bonus = newCombo > 1 ? (newCombo - 1) * 10 : 0; |
|
|
const newScore = userData.score + 100 + bonus; |
|
|
|
|
|
|
|
|
const myRoomLb = userData.roomId ? leaderboardData.filter(u => u.roomId === userData.roomId) : leaderboardData; |
|
|
let potentialRank = 1; |
|
|
myRoomLb.forEach(u => { if (u.uid !== currentUser?.uid && u.score >= newScore) potentialRank++; }); |
|
|
|
|
|
if (previousRankRef.current !== null && potentialRank < previousRankRef.current) { |
|
|
showNotification(`太棒了!你的排名上升到第 ${potentialRank} 名!`, "success"); |
|
|
} |
|
|
|
|
|
setUserData(prev => ({ ...prev, score: newScore })); |
|
|
|
|
|
setTimeout(() => { |
|
|
if (currentProblemIndex < activeCategory.problems.length - 1) { |
|
|
setCurrentProblemIndex(p => p + 1); |
|
|
setInputAnswer(""); |
|
|
setCursorPos(0); |
|
|
setChestOpen(false); |
|
|
} else { |
|
|
setUserData(prev => ({ |
|
|
...prev, |
|
|
completedLevels: prev.completedLevels.includes(activeCategory.id) |
|
|
? prev.completedLevels |
|
|
: [...prev.completedLevels, activeCategory.id] |
|
|
})); |
|
|
setGameState('level_complete'); |
|
|
} |
|
|
}, 2000); |
|
|
} else { |
|
|
|
|
|
setShake(true); |
|
|
playSound('error'); |
|
|
setCombo(0); |
|
|
|
|
|
|
|
|
setUserData(prev => { |
|
|
const newMistakes = { ...prev.mistakes }; |
|
|
newMistakes[currentProblem.id] = (newMistakes[currentProblem.id] || 0) + 1; |
|
|
return { ...prev, mistakes: newMistakes }; |
|
|
}); |
|
|
|
|
|
setTimeout(() => setShake(false), 500); |
|
|
if (navigator.vibrate) navigator.vibrate(200); |
|
|
} |
|
|
}; |
|
|
|
|
|
const insertSymbol = (symbol) => { |
|
|
const input = inputRef.current; |
|
|
|
|
|
|
|
|
let start = cursorPos; |
|
|
let end = cursorPos; |
|
|
|
|
|
|
|
|
if (input && document.activeElement === input) { |
|
|
start = input.selectionStart; |
|
|
end = input.selectionEnd; |
|
|
} else if (input) { |
|
|
|
|
|
|
|
|
if (start > inputAnswer.length) start = inputAnswer.length; |
|
|
end = start; |
|
|
} |
|
|
|
|
|
const text = inputAnswer; |
|
|
const newText = text.substring(0, start) + symbol + text.substring(end); |
|
|
const newPos = start + symbol.length; |
|
|
|
|
|
setInputAnswer(newText); |
|
|
setCursorPos(newPos); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
if (inputRef.current) { |
|
|
inputRef.current.focus(); |
|
|
inputRef.current.setSelectionRange(newPos, newPos); |
|
|
} |
|
|
}, 0); |
|
|
}; |
|
|
|
|
|
const deleteSymbol = () => { |
|
|
const input = inputRef.current; |
|
|
|
|
|
let start = cursorPos; |
|
|
let end = cursorPos; |
|
|
|
|
|
if (input && document.activeElement === input) { |
|
|
start = input.selectionStart; |
|
|
end = input.selectionEnd; |
|
|
} |
|
|
|
|
|
const text = inputAnswer; |
|
|
let newText = text; |
|
|
let newPos = start; |
|
|
|
|
|
if (start !== end) { |
|
|
newText = text.substring(0, start) + text.substring(end); |
|
|
newPos = start; |
|
|
} else if (start > 0) { |
|
|
newText = text.substring(0, start - 1) + text.substring(end); |
|
|
newPos = start - 1; |
|
|
} |
|
|
|
|
|
setInputAnswer(newText); |
|
|
setCursorPos(newPos); |
|
|
|
|
|
setTimeout(() => { |
|
|
if (inputRef.current) { |
|
|
inputRef.current.focus(); |
|
|
inputRef.current.setSelectionRange(newPos, newPos); |
|
|
} |
|
|
}, 0); |
|
|
}; |
|
|
|
|
|
const handleSelect = (e) => { |
|
|
|
|
|
setCursorPos(e.target.selectionStart); |
|
|
}; |
|
|
|
|
|
const renderMath = (text) => <span className="font-math text-3xl md:text-4xl">{text.split(/(\^[0-9]+)/g).map((part, i) => part.startsWith("^") ? <sup key={i} className="text-xs font-bold relative -top-3 left-0.5" style={{verticalAlign: 'super'}}>{part.substring(1)}</sup> : part)}</span>; |
|
|
const getDynamicKeyboard = (question) => { const vars = new Set(); (question.match(/[a-z]/g) || []).forEach(m => vars.add(m)); if (vars.size === 0) vars.add('x'); return Array.from(vars).sort(); }; |
|
|
|
|
|
const handleBuy = (item) => { if (userData.score >= item.price) { setUserData(prev => ({ ...prev, score: prev.score - item.price, unlockedItems: [...prev.unlockedItems, item.id] })); } else { alert("金幣不足!"); } }; |
|
|
const handleEquip = (item) => { if (item.type === 'title') setUserData(prev => ({ ...prev, equippedTitle: item.id })); else if (item.type === 'scroll') setUserData(prev => ({ ...prev, equippedScroll: item.id })); }; |
|
|
const currentTitle = SHOP_ITEMS.find(i => i.id === userData.equippedTitle); |
|
|
const currentScroll = SHOP_ITEMS.find(i => i.id === userData.equippedScroll); |
|
|
|
|
|
const shuffleArray = (array) => { const newArr = [...array]; for (let i = newArr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [newArr[i], newArr[j]] = [newArr[j], newArr[i]]; } return newArr; }; |
|
|
const handleLevelSelect = (cat) => { |
|
|
if (cat.id === 'challenge') { |
|
|
let allProblems = []; ALL_CATEGORIES.forEach(c => { allProblems = [...allProblems, ...c.problems]; }); |
|
|
const shuffled = shuffleArray(allProblems); |
|
|
setActiveCategory({ ...cat, problems: shuffled }); |
|
|
} else { setActiveCategory(cat); } |
|
|
setCurrentProblemIndex(0); setInputAnswer(""); setCursorPos(0); setChestOpen(false); setCombo(0); setGameState('playing'); |
|
|
}; |
|
|
|
|
|
|
|
|
if (gameState === 'teacher_dashboard') { |
|
|
return <TeacherDashboard onClose={() => setGameState('menu')} allData={leaderboardData} />; |
|
|
} |
|
|
|
|
|
|
|
|
if (showNameModal) return <div className="relative min-h-screen"><div className="palace-bg"></div><div className="palace-overlay"></div><NameModal onSubmit={handleJoinRoom} /></div>; |
|
|
if (showTeacherLogin) return <TeacherLoginModal onClose={() => setShowTeacherLogin(false)} onLogin={() => { setShowTeacherLogin(false); setGameState('teacher_dashboard'); }} />; |
|
|
|
|
|
if (gameState === 'leaderboard') { |
|
|
const displayData = userData.roomId |
|
|
? leaderboardData.filter(u => u.roomId === userData.roomId) |
|
|
: leaderboardData; |
|
|
|
|
|
return ( |
|
|
<div className="min-h-screen flex flex-col p-4 relative overflow-hidden"> |
|
|
<div className="palace-bg"></div><div className="palace-overlay"></div><div className="palace-pillars"></div> |
|
|
<div className="flex items-center justify-between mb-6 z-10"> |
|
|
<button onClick={() => setGameState('menu')} className="bg-slate-800 hover:bg-slate-700 p-2 rounded-full text-slate-400 hover:text-white transition-all"><Icons.Home /></button> |
|
|
<h2 className="text-xl font-bold text-amber-100"> |
|
|
{userData.roomId ? `房間 ${userData.roomId} 排行榜` : "全球冒險家排行榜"} |
|
|
</h2> |
|
|
<div className="w-8"></div> |
|
|
</div> |
|
|
<div className="z-10 flex-1 overflow-y-auto pb-10 max-w-md mx-auto w-full"> |
|
|
<div className="bg-slate-900/80 rounded-2xl border border-slate-700 backdrop-blur-md overflow-hidden"> |
|
|
<div className="p-4 border-b border-slate-700 flex text-slate-400 text-xs uppercase tracking-widest"><div className="w-12 text-center">Rank</div><div className="flex-1">Name</div><div className="w-20 text-right">Bounty</div></div> |
|
|
{displayData.slice(0, 50).map((user, idx) => ( |
|
|
<div key={idx} className={`leaderboard-row flex items-center p-4 border-b border-slate-800/50 hover:bg-white/5 transition-colors ${user.uid === currentUser?.uid ? 'bg-amber-900/20' : ''}`}> |
|
|
<div className="w-12 text-center font-bold text-lg rank text-slate-500">{idx + 1}</div> |
|
|
<div className="flex-1"><div className="font-bold text-slate-200">{user.name || "Unknown"}</div><div className="text-[10px] text-slate-500">{user.title || "Novice"}</div></div> |
|
|
<div className="w-20 text-right font-mono text-yellow-500 font-bold">${user.score}</div> |
|
|
</div> |
|
|
))} |
|
|
{displayData.length === 0 && <div className="p-8 text-center text-slate-500">暫無數據</div>} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
if (gameState === 'shop') { |
|
|
return ( |
|
|
<div className="min-h-screen flex flex-col p-4 relative overflow-hidden"> |
|
|
<div className="palace-bg"></div><div className="palace-overlay"></div><div className="palace-pillars"></div> |
|
|
<div className="flex items-center justify-between mb-6 z-10"> |
|
|
<button onClick={() => setGameState('menu')} className="bg-slate-800 hover:bg-slate-700 p-2 rounded-full text-slate-400 hover:text-white transition-all"><Icons.Home /></button> |
|
|
<div className="flex items-center gap-2 text-yellow-500 font-bold bg-black/50 px-3 py-1 rounded-full backdrop-blur"><Icons.Coins /> {userData.score}</div> |
|
|
</div> |
|
|
<div className="z-10 flex-1 overflow-y-auto pb-10"> |
|
|
<h2 className="text-2xl font-bold text-amber-100 mb-4 text-center">遺跡商店</h2> |
|
|
<h3 className="text-slate-400 text-xs uppercase tracking-widest mb-2 ml-1">榮耀稱號</h3> |
|
|
<div className="grid gap-3 mb-6"> |
|
|
{SHOP_ITEMS.filter(i => i.type === 'title').map(item => { |
|
|
const isOwned = userData.unlockedItems.includes(item.id); const isEquipped = userData.equippedTitle === item.id; |
|
|
return <div key={item.id} className={`bg-slate-900/80 p-4 rounded-xl border ${isEquipped ? 'border-amber-500 bg-amber-900/20' : 'border-slate-700'} flex justify-between items-center backdrop-blur-sm`}><div><div className="text-slate-200 font-bold">{item.name}</div><div className="text-xs text-slate-500">{item.desc}</div></div>{isOwned ? <button onClick={() => handleEquip(item)} disabled={isEquipped} className={`px-3 py-1 rounded text-xs font-bold ${isEquipped ? 'bg-green-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}`}>{isEquipped ? '使用中' : '裝備'}</button> : <button onClick={() => handleBuy(item)} className="bg-amber-700 hover:bg-amber-600 text-amber-100 px-3 py-1 rounded text-xs font-bold flex items-center gap-1">${item.price} 購買</button>}</div> |
|
|
})} |
|
|
</div> |
|
|
<h3 className="text-slate-400 text-xs uppercase tracking-widest mb-2 ml-1">卷軸樣式</h3> |
|
|
<div className="grid gap-3 mb-8"> |
|
|
{SHOP_ITEMS.filter(i => i.type === 'scroll').map(item => { |
|
|
const isOwned = userData.unlockedItems.includes(item.id); const isEquipped = userData.equippedScroll === item.id; |
|
|
return <div key={item.id} className={`bg-slate-900/80 p-4 rounded-xl border ${isEquipped ? 'border-amber-500 bg-amber-900/20' : 'border-slate-700'} flex justify-between items-center backdrop-blur-sm`}><div><div className="text-slate-200 font-bold">{item.name}</div><div className="text-xs text-slate-500">{item.desc}</div></div>{isOwned ? <button onClick={() => handleEquip(item)} disabled={isEquipped} className={`px-3 py-1 rounded text-xs font-bold ${isEquipped ? 'bg-green-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}`}>{isEquipped ? '使用中' : '裝備'}</button> : <button onClick={() => handleBuy(item)} className="bg-amber-700 hover:bg-amber-600 text-amber-100 px-3 py-1 rounded text-xs font-bold flex items-center gap-1">${item.price} 購買</button>}</div> |
|
|
})} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
if (gameState === 'menu') { |
|
|
return ( |
|
|
<div className="min-h-screen flex flex-col items-center justify-center p-6 relative overflow-y-auto"> |
|
|
<div className="palace-bg"></div><div className="palace-overlay"></div><div className="palace-pillars"></div> |
|
|
<Toast message={notification.message} type={notification.type} show={notification.show} /> |
|
|
<button onClick={() => setShowTeacherLogin(true)} className="absolute top-4 left-4 text-slate-600 hover:text-slate-400 z-50"><Icons.Lock /></button> |
|
|
{userData.roomId && <div className="absolute top-4 right-4 text-slate-500 text-xs border border-slate-700 px-2 py-1 rounded bg-black/50">Room: {userData.roomId}</div>} |
|
|
<div className="z-10 w-full max-w-md text-center animate-fade-in"> |
|
|
<div className="mb-2 text-amber-400 text-xs tracking-[0.3em] uppercase opacity-80">Algebra Relics</div> |
|
|
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-200 to-yellow-500 mb-2 drop-shadow-lg tracking-wider">代數遺跡</h1> |
|
|
<div className="text-slate-400 text-sm mb-8 bg-slate-900/50 inline-block px-4 py-1 rounded-full border border-slate-700 backdrop-blur"> |
|
|
<span className="text-amber-500 font-bold mr-2">{userData.name}</span>{currentTitle ? currentTitle.name : '冒險者'} |
|
|
</div> |
|
|
<div className="grid gap-3 mb-8"> |
|
|
{[...ALL_CATEGORIES, CHALLENGE_CATEGORY_TEMPLATE].map((cat) => { |
|
|
const isCleared = userData.completedLevels.includes(cat.id); const isChallenge = cat.id === 'challenge'; |
|
|
return ( |
|
|
<button key={cat.id} onClick={() => handleLevelSelect(cat)} className={`relative overflow-hidden group p-4 rounded-xl border ${isChallenge ? 'border-red-500/50 bg-red-900/30' : (isCleared ? 'border-amber-500/50 bg-amber-900/40' : 'border-slate-700 bg-slate-900/80')} hover:bg-slate-800/90 transition-all duration-300 text-left shadow-lg hover:shadow-amber-900/20 hover:scale-[1.02] backdrop-blur-sm`}> |
|
|
<div className={`absolute inset-0 bg-gradient-to-r ${cat.color} opacity-0 group-hover:opacity-10 transition-opacity`}></div> |
|
|
<div className="flex justify-between items-center"> |
|
|
<div><h3 className={`text-lg font-bold transition-colors flex items-center gap-2 ${isCleared ? 'text-amber-400' : 'text-slate-200 group-hover:text-amber-400'}`}>{cat.title} {isCleared && <div className="animate-pop text-yellow-400"><Icons.Crown /></div>}</h3><p className="text-xs text-slate-500">{cat.desc}</p></div> |
|
|
<div className="flex items-center gap-2">{isCleared && <span className="text-[10px] font-bold text-amber-500 tracking-widest border border-amber-500 px-1 rounded">CLEARED</span>}<div className="bg-slate-800 p-2 rounded-full group-hover:bg-slate-700 text-slate-400 group-hover:text-white transition-colors"><Icons.ArrowRight /></div></div> |
|
|
</div> |
|
|
</button> |
|
|
); |
|
|
})} |
|
|
</div> |
|
|
<div className="flex justify-between gap-2 relative"> |
|
|
<div className="flex-1 flex justify-between items-center bg-black/60 p-3 rounded-xl border border-slate-800 backdrop-blur-md"> |
|
|
<div className="flex items-center gap-2 text-yellow-500 font-bold text-lg"><Icons.Coins /> <span>{userData.score}</span></div> |
|
|
<button onClick={() => setGameState('shop')} className="bg-amber-700 hover:bg-amber-600 text-amber-100 px-3 py-2 rounded-lg text-sm font-bold flex items-center gap-2 transition-colors"><Icons.ShoppingBag /> <span className="hidden sm:inline">商店</span></button> |
|
|
</div> |
|
|
<button onClick={() => setGameState('leaderboard')} className="bg-slate-800 hover:bg-slate-700 text-slate-200 px-4 py-2 rounded-xl border border-slate-700 flex items-center justify-center relative shadow-lg active:scale-95 transition-transform"><Icons.Trophy /></button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
if (gameState === 'playing' && activeCategory) { |
|
|
const problem = activeCategory.problems[currentProblemIndex]; |
|
|
const dynamicVars = getDynamicKeyboard(problem.q); |
|
|
return ( |
|
|
<div className="fixed inset-0 flex flex-col overflow-hidden"> |
|
|
<div className="palace-bg"></div><div className="palace-overlay"></div><div className="palace-pillars"></div> |
|
|
<Toast message={notification.message} type={notification.type} show={notification.show} /> |
|
|
<div className="h-16 bg-slate-900/90 backdrop-blur border-b border-slate-800 flex items-center justify-between px-4 z-50 shrink-0"> |
|
|
<button onClick={() => setGameState('menu')} className="bg-slate-800 hover:bg-slate-700 text-white px-4 py-2 rounded-lg font-bold shadow-md border border-slate-600 flex items-center gap-2 transition-all active:scale-95"><Icons.Home /> <span className="hidden sm:inline">返回地圖</span></button> |
|
|
<div className="flex items-center gap-4"> |
|
|
<div className="flex flex-col items-end"><span className="text-[10px] text-slate-500 uppercase tracking-wider">{activeCategory.title}</span><div className="flex items-center gap-1 text-amber-400 text-sm font-bold"><span>Stage {currentProblemIndex + 1}</span><span className="text-slate-600">/</span><span className="text-slate-500">{activeCategory.problems.length}</span></div></div> |
|
|
<div className="bg-slate-800 px-3 py-1.5 rounded-full border border-slate-700 flex items-center gap-2"><Icons.Coins /> <span className="text-yellow-500 font-mono font-bold">{userData.score}</span></div> |
|
|
</div> |
|
|
</div> |
|
|
<div className="flex-1 relative flex flex-col items-center justify-center pb-20"> |
|
|
<TreasureChest isOpen={chestOpen} shake={shake} questionText={renderMath(problem.q)} hint={problem.h} scrollStyle={currentScroll ? currentScroll.styleClass : 'scroll-paper-default'} combo={combo}> |
|
|
<input ref={inputRef} type="text" inputMode="none" value={inputAnswer} onChange={(e) => { setInputAnswer(e.target.value); setCursorPos(e.target.selectionStart); }} onSelect={handleSelect} onClick={handleSelect} onKeyUp={handleSelect} placeholder="輸入密碼" className={`flex-1 bg-transparent text-center text-xl font-mono font-bold tracking-widest focus:outline-none transition-colors placeholder-amber-800/30 ${shake ? "text-red-400" : "text-amber-300"}`} autoComplete="off" disabled={chestOpen} /> |
|
|
<button onClick={handleCheckAnswer} disabled={!inputAnswer || chestOpen} className={`px-4 py-1 rounded border transition-all active:scale-95 font-bold text-sm whitespace-nowrap shadow-lg ${chestOpen ? "bg-green-600 border-green-400 text-white opacity-0" : "bg-amber-700 border-amber-500 text-amber-100 hover:bg-amber-600"}`}>解鎖</button> |
|
|
</TreasureChest> |
|
|
</div> |
|
|
<div className={`absolute bottom-0 left-0 right-0 bg-[#16101a] border-t border-slate-800 p-2 z-50 transition-transform duration-500 ${chestOpen ? 'translate-y-full' : 'translate-y-0'}`}> |
|
|
<div className="max-w-2xl mx-auto"> |
|
|
<div className="flex gap-1 mb-1.5 justify-center">{[...dynamicVars, '+', '-', '(', ')', '^2'].map(sym => <button onMouseDown={(e) => e.preventDefault()} onTouchStart={(e) => e.preventDefault()} key={sym} onClick={() => insertSymbol(sym)} className="flex-1 h-10 md:h-12 bg-[#2d2033] hover:bg-[#3e2d45] active:bg-[#251a2b] text-amber-100 rounded border-b-2 border-[#1a101f] active:border-b-0 active:translate-y-[2px] transition-all font-mono text-lg md:text-xl font-bold shadow-sm">{sym === '^2' ? <span>^<sup className="text-xs font-bold relative -top-1">2</sup></span> : sym}</button>)}<button onMouseDown={(e) => e.preventDefault()} onTouchStart={(e) => e.preventDefault()} onClick={deleteSymbol} className="flex-none w-14 h-10 md:h-12 bg-slate-700 hover:bg-slate-600 text-slate-200 rounded border-b-2 border-slate-800 active:border-b-0 active:translate-y-[2px] flex items-center justify-center"><Icons.Backspace /></button><button onMouseDown={(e) => e.preventDefault()} onTouchStart={(e) => e.preventDefault()} onClick={() => { setInputAnswer(""); setCursorPos(0); inputRef.current?.focus(); }} className="flex-none w-14 h-10 md:h-12 bg-red-900/30 hover:bg-red-900/50 text-red-400 rounded border-b-2 border-red-900/50 active:border-b-0 active:translate-y-[2px] text-xs font-bold">清除</button></div> |
|
|
<div className="flex gap-1 justify-center">{['1','2','3','4','5','6','7','8','9','0'].map(num => <button onMouseDown={(e) => e.preventDefault()} onTouchStart={(e) => e.preventDefault()} key={num} onClick={() => insertSymbol(num)} className="flex-1 h-10 md:h-12 bg-[#1f1623] hover:bg-[#2d2033] active:bg-[#16101a] text-slate-400 hover:text-amber-200 rounded border-b-2 border-black active:border-b-0 active:translate-y-[2px] transition-all font-mono text-lg md:text-xl font-bold shadow-sm">{num}</button>)}</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
if (gameState === 'level_complete') { |
|
|
return ( |
|
|
<div className="min-h-screen flex flex-col items-center justify-center p-6 relative text-center"> |
|
|
<div className="palace-bg"></div><div className="palace-overlay"></div><div className="palace-pillars"></div> |
|
|
<div className="bg-slate-900/90 p-8 rounded-2xl border border-amber-500/50 shadow-2xl max-w-sm w-full animate-fade-in backdrop-blur-md"> |
|
|
<div className="flex justify-center mb-6"><div className="relative"><div className="absolute inset-0 bg-yellow-500 blur-xl opacity-30 animate-pulse"></div><Icons.Coins /></div></div> |
|
|
<h2 className="text-3xl font-bold text-white mb-2">關卡完成!</h2> |
|
|
<p className="text-slate-400 mb-6">你已經征服了 {activeCategory?.title}</p> |
|
|
<div className="bg-black/40 p-4 rounded-lg mb-8"><p className="text-sm text-slate-500 uppercase tracking-widest mb-1">Current Bounty</p><p className="text-4xl font-bold text-yellow-500 font-mono">${userData.score}</p></div> |
|
|
<div className="flex gap-3"><button onClick={() => setGameState('menu')} className="flex-1 bg-slate-700 hover:bg-slate-600 text-white py-3 rounded-lg font-bold transition-colors">返回地圖</button><button onClick={() => setGameState('shop')} className="flex-1 bg-amber-700 hover:bg-amber-600 text-white py-3 rounded-lg font-bold transition-colors">前往商店</button></div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
return null; |
|
|
} |
|
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root')); |
|
|
root.render(<FactoringGame />); |
|
|
</script> |
|
|
</body> |
|
|
</html> |