Spaces:
Sleeping
Sleeping
Update pages/GameMountain.tsx
Browse files- pages/GameMountain.tsx +73 -22
pages/GameMountain.tsx
CHANGED
|
@@ -20,23 +20,22 @@ const styles = `
|
|
| 20 |
0%, 100% { transform: translateY(0) scale(1); }
|
| 21 |
50% { transform: translateY(-10px) scale(1.1); }
|
| 22 |
}
|
| 23 |
-
@keyframes pop-in {
|
| 24 |
-
0% { transform: scale(0); opacity: 0; }
|
| 25 |
-
80% { transform: scale(1.2); opacity: 1; }
|
| 26 |
-
100% { transform: scale(1); opacity: 1; }
|
| 27 |
-
}
|
| 28 |
@keyframes wave {
|
| 29 |
0% { transform: rotate(0deg); }
|
| 30 |
25% { transform: rotate(-10deg); }
|
| 31 |
75% { transform: rotate(10deg); }
|
| 32 |
100% { transform: rotate(0deg); }
|
| 33 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
.animate-drift-slow { animation: drift 60s linear infinite; }
|
| 35 |
.animate-drift-medium { animation: drift 40s linear infinite; }
|
| 36 |
.animate-drift-fast { animation: drift 25s linear infinite; }
|
| 37 |
.bounce-effect { animation: bounce-avatar 0.5s ease-in-out; }
|
| 38 |
-
.pop-in { animation: pop-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; }
|
| 39 |
.animate-wave { animation: wave 2s infinite ease-in-out; }
|
|
|
|
| 40 |
`;
|
| 41 |
|
| 42 |
// --- Math Helper for Consistent Path ---
|
|
@@ -62,11 +61,6 @@ const generatePathString = () => {
|
|
| 62 |
for (let i = 0; i <= 100; i+=2) {
|
| 63 |
const p = i / 100;
|
| 64 |
const point = calculatePathPoint(p);
|
| 65 |
-
// SVG Coordinate mapping: ViewBox 0 0 200 300
|
| 66 |
-
// X: percent -> 0-200 range
|
| 67 |
-
// Y: percent -> map 0% (bottom) to 300 and 100% (top) to 0.
|
| 68 |
-
// Note: calculatePathPoint returns Y as "percent from bottom", CSS style.
|
| 69 |
-
// For SVG Y, 0 is top. So SVG_Y = 300 - (Y_percent/100 * 300)
|
| 70 |
|
| 71 |
const svgX = point.x * 2; // 0-100 -> 0-200
|
| 72 |
const svgY = 300 - (point.y * 3); // 0-100 -> 300-0
|
|
@@ -77,6 +71,16 @@ const generatePathString = () => {
|
|
| 77 |
return d;
|
| 78 |
};
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, isFullscreen }: {
|
| 81 |
team: GameTeam,
|
| 82 |
index: number,
|
|
@@ -88,6 +92,7 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 88 |
const percentage = Math.min(Math.max(team.score, 0), maxSteps) / maxSteps;
|
| 89 |
const currentPos = calculatePathPoint(percentage);
|
| 90 |
const pathString = generatePathString();
|
|
|
|
| 91 |
|
| 92 |
// Width classes: slightly wider in fullscreen
|
| 93 |
const widthClass = isFullscreen ? 'w-56 md:w-64' : 'w-40 md:w-48';
|
|
@@ -100,7 +105,7 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 100 |
<div className="relative bg-white/90 backdrop-blur-md px-3 py-1.5 rounded-xl shadow-md border-2 border-white text-center max-w-[90%]">
|
| 101 |
<h3 className="text-sm md:text-base font-black text-slate-800 truncate">{team.name}</h3>
|
| 102 |
{/* Score Badge */}
|
| 103 |
-
<div className=
|
| 104 |
{team.score}
|
| 105 |
</div>
|
| 106 |
</div>
|
|
@@ -127,14 +132,14 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 127 |
strokeLinejoin="round"
|
| 128 |
/>
|
| 129 |
|
| 130 |
-
{/* Snow Cap
|
| 131 |
<path
|
| 132 |
d="M100 20 L 115 60 Q 100 50, 85 60 Z"
|
| 133 |
fill="white"
|
| 134 |
opacity="0.9"
|
| 135 |
/>
|
| 136 |
|
| 137 |
-
{/*
|
| 138 |
<path
|
| 139 |
d={pathString}
|
| 140 |
fill="none"
|
|
@@ -168,17 +173,21 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 168 |
<div
|
| 169 |
key={i}
|
| 170 |
className={`absolute transition-all duration-500 flex flex-col items-center justify-center transform -translate-x-1/2 -translate-y-1/2
|
| 171 |
-
${isUnlocked ? 'scale-
|
| 172 |
`}
|
| 173 |
style={{ bottom: `${pos.y}%`, left: `${pos.x}%` }}
|
| 174 |
>
|
| 175 |
<div className={`
|
| 176 |
p-1.5 rounded-full shadow-md border relative
|
| 177 |
-
${isUnlocked ? 'bg-yellow-100 border-yellow-400
|
| 178 |
`}>
|
| 179 |
-
<Icon size={12} className={isUnlocked ? 'text-amber-600' : 'text-gray-
|
| 180 |
{isUnlocked && <div className="absolute inset-0 bg-yellow-400 rounded-full blur-sm opacity-50 animate-pulse"></div>}
|
| 181 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
</div>
|
| 183 |
);
|
| 184 |
})}
|
|
@@ -191,15 +200,17 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 191 |
>
|
| 192 |
<div className={`
|
| 193 |
w-12 h-12 md:w-14 md:h-14 bg-white rounded-full border-4 shadow-xl flex items-center justify-center transition-transform relative
|
| 194 |
-
${
|
| 195 |
-
`} style={{ borderColor: team.color }}>
|
| 196 |
<span className="text-2xl md:text-3xl">{team.avatar || 'π§'}</span>
|
| 197 |
|
| 198 |
-
{
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
| 200 |
)}
|
| 201 |
</div>
|
| 202 |
-
{/* Particle +1 Effect (Simulated via key based on score) */}
|
| 203 |
</div>
|
| 204 |
|
| 205 |
{/* Control Panel */}
|
|
@@ -213,6 +224,28 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 213 |
);
|
| 214 |
};
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
export const GameMountain: React.FC = () => {
|
| 217 |
const [session, setSession] = useState<GameSession | null>(null);
|
| 218 |
const [loading, setLoading] = useState(true);
|
|
@@ -222,6 +255,9 @@ export const GameMountain: React.FC = () => {
|
|
| 222 |
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 223 |
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
| 224 |
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
const currentUser = api.auth.getCurrentUser();
|
| 227 |
const isTeacher = currentUser?.role === 'TEACHER';
|
|
@@ -294,8 +330,19 @@ export const GameMountain: React.FC = () => {
|
|
| 294 |
if (t.id !== teamId) return t;
|
| 295 |
const newScore = Math.max(0, Math.min(session.maxSteps, t.score + delta));
|
| 296 |
|
|
|
|
| 297 |
if (delta > 0 && newScore > t.score) {
|
| 298 |
const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
if (reward) {
|
| 300 |
t.members.forEach(stuId => {
|
| 301 |
const stu = students.find(s => (s._id || s.id) == stuId);
|
|
@@ -365,6 +412,9 @@ export const GameMountain: React.FC = () => {
|
|
| 365 |
<div className="absolute top-24 left-1/4 text-slate-600/30 text-2xl animate-bounce-slow">ποΈ</div>
|
| 366 |
</div>
|
| 367 |
|
|
|
|
|
|
|
|
|
|
| 368 |
{/* Toolbar */}
|
| 369 |
<div className="absolute top-4 right-4 z-50 flex gap-2">
|
| 370 |
<button
|
|
@@ -582,3 +632,4 @@ export const GameMountain: React.FC = () => {
|
|
| 582 |
}
|
| 583 |
return GameContent;
|
| 584 |
};
|
|
|
|
|
|
| 20 |
0%, 100% { transform: translateY(0) scale(1); }
|
| 21 |
50% { transform: translateY(-10px) scale(1.1); }
|
| 22 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
@keyframes wave {
|
| 24 |
0% { transform: rotate(0deg); }
|
| 25 |
25% { transform: rotate(-10deg); }
|
| 26 |
75% { transform: rotate(10deg); }
|
| 27 |
100% { transform: rotate(0deg); }
|
| 28 |
}
|
| 29 |
+
@keyframes confetti-fall {
|
| 30 |
+
0% { transform: translateY(-10px) rotate(0deg); opacity: 1; }
|
| 31 |
+
100% { transform: translateY(60px) rotate(360deg); opacity: 0; }
|
| 32 |
+
}
|
| 33 |
.animate-drift-slow { animation: drift 60s linear infinite; }
|
| 34 |
.animate-drift-medium { animation: drift 40s linear infinite; }
|
| 35 |
.animate-drift-fast { animation: drift 25s linear infinite; }
|
| 36 |
.bounce-effect { animation: bounce-avatar 0.5s ease-in-out; }
|
|
|
|
| 37 |
.animate-wave { animation: wave 2s infinite ease-in-out; }
|
| 38 |
+
.animate-confetti { animation: confetti-fall 2s ease-out forwards; }
|
| 39 |
`;
|
| 40 |
|
| 41 |
// --- Math Helper for Consistent Path ---
|
|
|
|
| 61 |
for (let i = 0; i <= 100; i+=2) {
|
| 62 |
const p = i / 100;
|
| 63 |
const point = calculatePathPoint(p);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
const svgX = point.x * 2; // 0-100 -> 0-200
|
| 66 |
const svgY = 300 - (point.y * 3); // 0-100 -> 300-0
|
|
|
|
| 71 |
return d;
|
| 72 |
};
|
| 73 |
|
| 74 |
+
const CelebrationEffects = () => (
|
| 75 |
+
<div className="absolute -top-10 left-1/2 -translate-x-1/2 w-full h-full pointer-events-none z-50">
|
| 76 |
+
<div className="absolute top-0 left-1/4 text-2xl animate-confetti" style={{animationDelay: '0.1s'}}>π</div>
|
| 77 |
+
<div className="absolute top-2 right-1/4 text-2xl animate-confetti" style={{animationDelay: '0.3s'}}>β¨</div>
|
| 78 |
+
<div className="absolute top-0 left-1/2 text-2xl animate-confetti" style={{animationDelay: '0.5s'}}>π</div>
|
| 79 |
+
<div className="absolute -top-4 right-1/3 text-xl animate-confetti" style={{animationDelay: '0.2s'}}>π</div>
|
| 80 |
+
<div className="absolute -top-2 left-1/3 text-xl animate-confetti" style={{animationDelay: '0.4s'}}>π</div>
|
| 81 |
+
</div>
|
| 82 |
+
);
|
| 83 |
+
|
| 84 |
const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, isFullscreen }: {
|
| 85 |
team: GameTeam,
|
| 86 |
index: number,
|
|
|
|
| 92 |
const percentage = Math.min(Math.max(team.score, 0), maxSteps) / maxSteps;
|
| 93 |
const currentPos = calculatePathPoint(percentage);
|
| 94 |
const pathString = generatePathString();
|
| 95 |
+
const isFinished = team.score >= maxSteps;
|
| 96 |
|
| 97 |
// Width classes: slightly wider in fullscreen
|
| 98 |
const widthClass = isFullscreen ? 'w-56 md:w-64' : 'w-40 md:w-48';
|
|
|
|
| 105 |
<div className="relative bg-white/90 backdrop-blur-md px-3 py-1.5 rounded-xl shadow-md border-2 border-white text-center max-w-[90%]">
|
| 106 |
<h3 className="text-sm md:text-base font-black text-slate-800 truncate">{team.name}</h3>
|
| 107 |
{/* Score Badge */}
|
| 108 |
+
<div className={`absolute -top-3 -right-3 w-7 h-7 rounded-full flex items-center justify-center font-black border-2 border-white shadow-sm text-xs ${isFinished ? 'bg-yellow-400 text-yellow-900 animate-bounce' : 'bg-gradient-to-br from-amber-400 to-orange-500 text-white'}`}>
|
| 109 |
{team.score}
|
| 110 |
</div>
|
| 111 |
</div>
|
|
|
|
| 132 |
strokeLinejoin="round"
|
| 133 |
/>
|
| 134 |
|
| 135 |
+
{/* Snow Cap */}
|
| 136 |
<path
|
| 137 |
d="M100 20 L 115 60 Q 100 50, 85 60 Z"
|
| 138 |
fill="white"
|
| 139 |
opacity="0.9"
|
| 140 |
/>
|
| 141 |
|
| 142 |
+
{/* Path */}
|
| 143 |
<path
|
| 144 |
d={pathString}
|
| 145 |
fill="none"
|
|
|
|
| 173 |
<div
|
| 174 |
key={i}
|
| 175 |
className={`absolute transition-all duration-500 flex flex-col items-center justify-center transform -translate-x-1/2 -translate-y-1/2
|
| 176 |
+
${isUnlocked ? 'scale-110 z-20' : 'scale-90 opacity-80 z-0'}
|
| 177 |
`}
|
| 178 |
style={{ bottom: `${pos.y}%`, left: `${pos.x}%` }}
|
| 179 |
>
|
| 180 |
<div className={`
|
| 181 |
p-1.5 rounded-full shadow-md border relative
|
| 182 |
+
${isUnlocked ? 'bg-yellow-100 border-yellow-400' : 'bg-white border-gray-300'}
|
| 183 |
`}>
|
| 184 |
+
<Icon size={12} className={isUnlocked ? 'text-amber-600' : 'text-gray-400'} />
|
| 185 |
{isUnlocked && <div className="absolute inset-0 bg-yellow-400 rounded-full blur-sm opacity-50 animate-pulse"></div>}
|
| 186 |
</div>
|
| 187 |
+
{/* Reward Name Label - Now visible */}
|
| 188 |
+
<div className={`mt-1 px-1.5 py-0.5 rounded text-[10px] font-bold shadow-sm whitespace-nowrap border max-w-[80px] truncate transition-colors ${isUnlocked ? 'bg-yellow-50 text-yellow-700 border-yellow-200' : 'bg-white/90 text-gray-500 border-gray-200'}`}>
|
| 189 |
+
{reward.rewardName}
|
| 190 |
+
</div>
|
| 191 |
</div>
|
| 192 |
);
|
| 193 |
})}
|
|
|
|
| 200 |
>
|
| 201 |
<div className={`
|
| 202 |
w-12 h-12 md:w-14 md:h-14 bg-white rounded-full border-4 shadow-xl flex items-center justify-center transition-transform relative
|
| 203 |
+
${isFinished ? 'animate-bounce border-yellow-400' : 'hover:scale-110'}
|
| 204 |
+
`} style={{ borderColor: isFinished ? '#facc15' : team.color }}>
|
| 205 |
<span className="text-2xl md:text-3xl">{team.avatar || 'π§'}</span>
|
| 206 |
|
| 207 |
+
{isFinished && (
|
| 208 |
+
<>
|
| 209 |
+
<div className="absolute -right-4 -top-6 text-3xl drop-shadow-md origin-bottom-left animate-wave">π©</div>
|
| 210 |
+
<CelebrationEffects />
|
| 211 |
+
</>
|
| 212 |
)}
|
| 213 |
</div>
|
|
|
|
| 214 |
</div>
|
| 215 |
|
| 216 |
{/* Control Panel */}
|
|
|
|
| 224 |
);
|
| 225 |
};
|
| 226 |
|
| 227 |
+
// Toast Component
|
| 228 |
+
const GameToast = ({ title, message, type, onClose }: { title: string, message: string, type: 'success' | 'info', onClose: () => void }) => {
|
| 229 |
+
useEffect(() => {
|
| 230 |
+
const timer = setTimeout(onClose, 3000);
|
| 231 |
+
return () => clearTimeout(timer);
|
| 232 |
+
}, [onClose]);
|
| 233 |
+
|
| 234 |
+
return (
|
| 235 |
+
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] animate-in slide-in-from-top-5 fade-in duration-300">
|
| 236 |
+
<div className={`flex items-center gap-3 px-6 py-4 rounded-xl shadow-2xl border-2 backdrop-blur-md ${type === 'success' ? 'bg-yellow-50/90 border-yellow-400 text-yellow-800' : 'bg-white/90 border-blue-200 text-slate-800'}`}>
|
| 237 |
+
<div className={`p-2 rounded-full ${type === 'success' ? 'bg-yellow-400 text-white' : 'bg-blue-500 text-white'}`}>
|
| 238 |
+
{type === 'success' ? <Trophy size={24} /> : <Gift size={24} />}
|
| 239 |
+
</div>
|
| 240 |
+
<div>
|
| 241 |
+
<h4 className="font-black text-lg">{title}</h4>
|
| 242 |
+
<p className="text-sm opacity-90 font-medium">{message}</p>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
);
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
export const GameMountain: React.FC = () => {
|
| 250 |
const [session, setSession] = useState<GameSession | null>(null);
|
| 251 |
const [loading, setLoading] = useState(true);
|
|
|
|
| 255 |
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 256 |
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
| 257 |
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
| 258 |
+
|
| 259 |
+
// Toast State
|
| 260 |
+
const [toast, setToast] = useState<{title: string, message: string, type: 'success' | 'info'} | null>(null);
|
| 261 |
|
| 262 |
const currentUser = api.auth.getCurrentUser();
|
| 263 |
const isTeacher = currentUser?.role === 'TEACHER';
|
|
|
|
| 330 |
if (t.id !== teamId) return t;
|
| 331 |
const newScore = Math.max(0, Math.min(session.maxSteps, t.score + delta));
|
| 332 |
|
| 333 |
+
// Detect Reward Trigger
|
| 334 |
if (delta > 0 && newScore > t.score) {
|
| 335 |
const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
|
| 336 |
+
|
| 337 |
+
// Toast for Summit
|
| 338 |
+
if (newScore === session.maxSteps) {
|
| 339 |
+
setToast({ title: `π ε·
ε³°ζΆε»!`, message: `ζε [${t.name}] ζεη»ι‘ΆοΌ`, type: 'success' });
|
| 340 |
+
}
|
| 341 |
+
// Toast for Reward
|
| 342 |
+
else if (reward) {
|
| 343 |
+
setToast({ title: `π 触εε₯ε±!`, message: `[${t.name}] θ·εΎοΌ${reward.rewardName}`, type: 'info' });
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
if (reward) {
|
| 347 |
t.members.forEach(stuId => {
|
| 348 |
const stu = students.find(s => (s._id || s.id) == stuId);
|
|
|
|
| 412 |
<div className="absolute top-24 left-1/4 text-slate-600/30 text-2xl animate-bounce-slow">ποΈ</div>
|
| 413 |
</div>
|
| 414 |
|
| 415 |
+
{/* Toast Overlay */}
|
| 416 |
+
{toast && <GameToast title={toast.title} message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
| 417 |
+
|
| 418 |
{/* Toolbar */}
|
| 419 |
<div className="absolute top-4 right-4 z-50 flex gap-2">
|
| 420 |
<button
|
|
|
|
| 632 |
}
|
| 633 |
return GameContent;
|
| 634 |
};
|
| 635 |
+
|