dvc890 commited on
Commit
f48b984
Β·
verified Β·
1 Parent(s): df14092

Update pages/GameMountain.tsx

Browse files
Files changed (1) hide show
  1. 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="absolute -top-3 -right-3 bg-gradient-to-br from-amber-400 to-orange-500 text-white w-7 h-7 rounded-full flex items-center justify-center font-black border-2 border-white shadow-sm text-xs">
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 (Matches Peak) */}
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
- {/* Mathematically generated path */}
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-125 z-20' : 'scale-75 opacity-70 grayscale z-0'}
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 animate-bounce-slow' : 'bg-gray-200 border-gray-300'}
178
  `}>
179
- <Icon size={12} className={isUnlocked ? 'text-amber-600' : 'text-gray-500'} />
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
- ${team.score >= maxSteps ? 'animate-bounce' : 'hover:scale-110'}
195
- `} style={{ borderColor: team.color }}>
196
  <span className="text-2xl md:text-3xl">{team.avatar || 'πŸ§—'}</span>
197
 
198
- {team.score >= maxSteps && (
199
- <div className="absolute -right-4 -top-6 text-3xl drop-shadow-md origin-bottom-left animate-wave">🚩</div>
 
 
 
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
+