dvc890 commited on
Commit
df14092
·
verified ·
1 Parent(s): 650d398

Update pages/GameMountain.tsx

Browse files
Files changed (1) hide show
  1. pages/GameMountain.tsx +118 -112
pages/GameMountain.tsx CHANGED
@@ -1,8 +1,9 @@
1
 
2
  import React, { useState, useEffect, useRef } from 'react';
 
3
  import { api } from '../services/api';
4
  import { GameSession, GameTeam, Student, GameRewardConfig, AchievementConfig } from '../types';
5
- import { Settings, Plus, Minus, Users, CheckSquare, Loader2, Trash2, X, Flag, Gift, Star, Trophy } from 'lucide-react';
6
 
7
  // --- CSS Animations for Clouds and Bounce ---
8
  const styles = `
@@ -24,64 +25,97 @@ const styles = `
24
  80% { transform: scale(1.2); opacity: 1; }
25
  100% { transform: scale(1); opacity: 1; }
26
  }
 
 
 
 
 
 
27
  .animate-drift-slow { animation: drift 60s linear infinite; }
28
  .animate-drift-medium { animation: drift 40s linear infinite; }
29
  .animate-drift-fast { animation: drift 25s linear infinite; }
30
  .bounce-effect { animation: bounce-avatar 0.5s ease-in-out; }
31
  .pop-in { animation: pop-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; }
 
32
  `;
33
 
34
- const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange }: {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  team: GameTeam,
36
  index: number,
37
  rewardsConfig: GameRewardConfig[],
38
  maxSteps: number,
39
- onScoreChange?: (id: string, delta: number) => void
 
40
  }) => {
41
  const percentage = Math.min(Math.max(team.score, 0), maxSteps) / maxSteps;
42
-
43
- // Calculate position along a sine wave path for more visual interest
44
- // x = center + amplitude * sin(height)
45
- // We want the path to wind slightly
46
- const getPathX = (pct: number) => {
47
- // 50% is center. Winding range +/- 15%
48
- return 50 + 10 * Math.sin(pct * Math.PI * 3);
49
- };
50
 
51
- const climberBottom = 8 + (percentage * 78); // Keep within mountain bounds (8% to 86%)
52
- const climberLeft = getPathX(percentage);
53
 
54
  return (
55
- <div className="relative flex flex-col items-center justify-end h-full w-48 md:w-64 mx-4 flex-shrink-0 select-none group perspective-1000">
56
 
57
  {/* Team Name Cloud Tag */}
58
- <div className="absolute top-[5%] z-20 transition-transform duration-300 hover:-translate-y-2 hover:scale-105 cursor-pointer">
59
- <div className="relative bg-white/90 backdrop-blur-md px-4 py-2 rounded-2xl shadow-lg border-2 border-white text-center min-w-[120px]">
60
- <h3 className="text-base font-black text-slate-800 truncate max-w-[140px]">{team.name}</h3>
61
  {/* Score Badge */}
62
- <div className="absolute -top-3 -right-3 bg-gradient-to-br from-amber-400 to-orange-500 text-white w-8 h-8 rounded-full flex items-center justify-center font-black border-2 border-white shadow-md text-sm">
63
  {team.score}
64
  </div>
65
  </div>
66
- {/* Triangle pointer */}
67
- <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-white/90 absolute left-1/2 -translate-x-1/2 -bottom-2"></div>
68
  </div>
69
 
70
  {/* Mountain SVG */}
71
- <div className="absolute bottom-0 left-0 w-full h-[85%] z-0 filter drop-shadow-xl transition-all duration-500 group-hover:drop-shadow-2xl">
72
  <svg viewBox="0 0 200 300" preserveAspectRatio="none" className="w-full h-full overflow-visible">
73
  <defs>
74
  <linearGradient id={`grad-${index}`} x1="0%" y1="0%" x2="100%" y2="0%">
75
- {/* Left side light, right side dark for 3D effect */}
76
  <stop offset="0%" stopColor={team.color} stopOpacity="0.8" />
77
  <stop offset="50%" stopColor={team.color} />
78
- <stop offset="50%" stopColor={team.color} stopOpacity="0.7"/> {/* Hard shadow line */}
79
  <stop offset="100%" stopColor={team.color} stopOpacity="0.5" />
80
  </linearGradient>
81
- <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
82
- <feGaussianBlur stdDeviation="2" result="blur" />
83
- <feComposite in="SourceGraphic" in2="blur" operator="over" />
84
- </filter>
85
  </defs>
86
 
87
  {/* Main Mountain Shape */}
@@ -93,47 +127,39 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange }:
93
  strokeLinejoin="round"
94
  />
95
 
96
- {/* Snow Cap */}
97
  <path
98
- d="M100 20 L 120 90 Q 110 80, 100 95 Q 90 80, 80 90 Z"
99
  fill="white"
100
  opacity="0.9"
101
  />
102
 
103
- {/* Winding Path (Dashed Line) */}
104
  <path
105
- d={`M ${getPathX(0)} 280
106
- Q ${getPathX(0.1)} 260, ${getPathX(0.2)} 240
107
- T ${getPathX(0.4)} 190
108
- T ${getPathX(0.6)} 140
109
- T ${getPathX(0.8)} 90
110
- T 100 20`}
111
  fill="none"
112
- stroke="rgba(255,255,255,0.4)"
113
- strokeWidth="3"
114
- strokeDasharray="5,5"
115
  strokeLinecap="round"
116
  />
117
 
118
- {/* Vegetation / Rocks at bottom */}
119
  <g transform="translate(0, 280)">
120
  <circle cx="30" cy="10" r="8" fill="#166534" opacity="0.8"/>
121
  <circle cx="45" cy="15" r="10" fill="#15803d" opacity="0.9"/>
122
  <circle cx="160" cy="12" r="9" fill="#166534" opacity="0.8"/>
123
- <path d="M140 20 L 150 0 L 160 20 Z" fill="#78716c"/>
124
  </g>
125
  </svg>
126
  </div>
127
 
128
- {/* Rewards Placed on Slope */}
129
- <div className="absolute bottom-0 w-full h-[85%] z-10 pointer-events-none">
130
  {rewardsConfig.map((reward, i) => {
131
  const rPct = reward.scoreThreshold / maxSteps;
132
  const isUnlocked = team.score >= reward.scoreThreshold;
133
- const rBottom = 8 + (rPct * 78);
134
- const rLeft = getPathX(rPct);
135
 
136
- // Icon selection
137
  let Icon = Gift;
138
  if (reward.rewardType === 'DRAW_COUNT') Icon = Star;
139
  if (reward.rewardType === 'ACHIEVEMENT') Icon = Trophy;
@@ -142,25 +168,16 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange }:
142
  <div
143
  key={i}
144
  className={`absolute transition-all duration-500 flex flex-col items-center justify-center transform -translate-x-1/2 -translate-y-1/2
145
- ${isUnlocked ? 'scale-110 z-20' : 'scale-90 opacity-60 grayscale z-0'}
146
  `}
147
- style={{ bottom: `${rBottom}%`, left: `${rLeft}%` }}
148
  >
149
  <div className={`
150
- p-2 rounded-full shadow-lg border-2 relative
151
  ${isUnlocked ? 'bg-yellow-100 border-yellow-400 animate-bounce-slow' : 'bg-gray-200 border-gray-300'}
152
  `}>
153
- <Icon size={16} className={isUnlocked ? 'text-amber-600' : 'text-gray-500'} />
154
- {/* Glow effect */}
155
- {isUnlocked && <div className="absolute inset-0 bg-yellow-400 rounded-full blur-md opacity-50 animate-pulse"></div>}
156
- </div>
157
-
158
- {/* Tooltip Label */}
159
- <div className={`
160
- mt-1 px-2 py-0.5 rounded text-[10px] font-bold whitespace-nowrap shadow-sm transition-opacity duration-300
161
- ${isUnlocked ? 'bg-white text-slate-700 opacity-100' : 'bg-gray-100 text-gray-400 opacity-0 group-hover:opacity-100'}
162
- `}>
163
- {reward.rewardName}
164
  </div>
165
  </div>
166
  );
@@ -169,40 +186,27 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange }:
169
 
170
  {/* Climber Avatar */}
171
  <div
172
- className="absolute z-30 transition-all duration-700 ease-in-out flex flex-col items-center w-16 h-16 -translate-x-1/2"
173
- style={{ bottom: `${climberBottom}%`, left: `${climberLeft}%` }}
174
  >
175
  <div className={`
176
- w-14 h-14 bg-white rounded-full border-4 shadow-xl flex items-center justify-center transform transition-transform relative
177
  ${team.score >= maxSteps ? 'animate-bounce' : 'hover:scale-110'}
178
  `} style={{ borderColor: team.color }}>
179
- <span className="text-3xl">{team.avatar || '🧗'}</span>
180
 
181
- {/* Flag when finished */}
182
  {team.score >= maxSteps && (
183
- <div className="absolute -right-6 -top-6 text-4xl drop-shadow-md origin-bottom-left animate-wave">🚩</div>
184
  )}
185
  </div>
 
186
  </div>
187
 
188
- {/* Control Panel (Only Visible on Hover or Always if Teacher) */}
189
  {onScoreChange && (
190
- <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3 opacity-90 transition-all z-40 bg-white/80 backdrop-blur px-3 py-1.5 rounded-full shadow-lg border border-white/50 hover:scale-105">
191
- <button
192
- onClick={() => onScoreChange(team.id, -1)}
193
- className="w-8 h-8 rounded-full bg-slate-100 text-slate-500 hover:bg-red-100 hover:text-red-600 flex items-center justify-center transition-colors"
194
- title="-1分"
195
- >
196
- <Minus size={16}/>
197
- </button>
198
- <span className="w-px h-4 bg-slate-300"></span>
199
- <button
200
- onClick={() => onScoreChange(team.id, 1)}
201
- className="w-10 h-10 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-600 hover:text-white flex items-center justify-center transition-colors shadow-sm"
202
- title="+1分"
203
- >
204
- <Plus size={20} strokeWidth={3}/>
205
- </button>
206
  </div>
207
  )}
208
  </div>
@@ -214,8 +218,8 @@ export const GameMountain: React.FC = () => {
214
  const [loading, setLoading] = useState(true);
215
  const [students, setStudents] = useState<Student[]>([]);
216
 
217
- // Settings State
218
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
 
219
  const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
220
  const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
221
 
@@ -223,15 +227,12 @@ export const GameMountain: React.FC = () => {
223
  const isTeacher = currentUser?.role === 'TEACHER';
224
  const isAdmin = currentUser?.role === 'ADMIN';
225
 
226
- useEffect(() => {
227
- loadData();
228
- }, []);
229
 
230
  const loadData = async () => {
231
  setLoading(true);
232
  try {
233
  if (!currentUser) return;
234
-
235
  const allStudents = await api.students.getAll();
236
  let targetClass = '';
237
  let filteredStudents: Student[] = [];
@@ -248,7 +249,6 @@ export const GameMountain: React.FC = () => {
248
  filteredStudents = allStudents;
249
  }
250
 
251
- // Sort students
252
  filteredStudents.sort((a, b) => {
253
  const seatA = parseInt(a.seatNo || '99999');
254
  const seatB = parseInt(b.seatNo || '99999');
@@ -262,7 +262,6 @@ export const GameMountain: React.FC = () => {
262
  if (sess) {
263
  setSession(sess);
264
  } else if (isTeacher && currentUser?.schoolId) {
265
- // Init Default Session
266
  const newSess: GameSession = {
267
  schoolId: currentUser.schoolId,
268
  className: targetClass,
@@ -288,7 +287,6 @@ export const GameMountain: React.FC = () => {
288
 
289
  const handleScoreChange = async (teamId: string, delta: number) => {
290
  if (!session || !isTeacher) return;
291
-
292
  const sysConfig = await api.config.getPublic();
293
  const currentSemester = sysConfig?.semester || '当前学期';
294
 
@@ -296,7 +294,6 @@ export const GameMountain: React.FC = () => {
296
  if (t.id !== teamId) return t;
297
  const newScore = Math.max(0, Math.min(session.maxSteps, t.score + delta));
298
 
299
- // Trigger reward logic
300
  if (delta > 0 && newScore > t.score) {
301
  const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
302
  if (reward) {
@@ -355,35 +352,39 @@ export const GameMountain: React.FC = () => {
355
  if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
356
  if (!session) return <div className="h-full flex items-center justify-center text-gray-400">暂无游戏会话,请联系班主任开启。</div>;
357
 
358
- return (
359
- <>
360
- <style>{styles}</style>
361
- <div className="h-full w-full flex flex-col relative overflow-hidden bg-gradient-to-b from-sky-300 via-sky-100 to-emerald-50">
362
 
363
- {/* Animated Background Elements */}
364
  <div className="absolute inset-0 pointer-events-none overflow-hidden">
365
- {/* Sun */}
366
  <div className="absolute top-10 right-20 w-24 h-24 bg-yellow-300 rounded-full blur-xl opacity-60 animate-pulse"></div>
367
-
368
- {/* Clouds */}
369
  <div className="absolute top-16 -left-20 text-white/60 text-9xl select-none animate-drift-slow opacity-80" style={{filter: 'blur(2px)'}}>☁️</div>
370
  <div className="absolute top-32 -left-40 text-white/40 text-8xl select-none animate-drift-medium opacity-60" style={{animationDelay: '5s'}}>☁️</div>
371
  <div className="absolute top-10 -left-10 text-white/50 text-[10rem] select-none animate-drift-fast opacity-40" style={{animationDelay: '15s'}}>☁️</div>
372
-
373
- {/* Birds */}
374
  <div className="absolute top-24 left-1/4 text-slate-600/30 text-2xl animate-bounce-slow">🕊️</div>
375
  </div>
376
 
377
- {isTeacher && (
378
- <div className="absolute top-4 right-4 z-50">
379
- <button onClick={() => setIsSettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-700 bg-white/90 backdrop-blur px-4 py-2 rounded-2xl border border-white/50 hover:bg-white shadow-md transition-all hover:scale-105 hover:shadow-lg active:scale-95">
380
- <Settings size={16} className="mr-2"/> 游戏设置
 
 
 
 
 
 
 
 
 
381
  </button>
382
- </div>
383
- )}
384
 
385
- <div className="flex-1 overflow-x-auto overflow-y-hidden relative custom-scrollbar z-10">
386
- <div className="h-full flex items-end min-w-max px-20 pb-4 gap-4 mx-auto">
 
387
  {session.teams.map((team, idx) => (
388
  <MountainStage
389
  key={team.id}
@@ -392,6 +393,7 @@ export const GameMountain: React.FC = () => {
392
  rewardsConfig={session.rewardsConfig}
393
  maxSteps={session.maxSteps}
394
  onScoreChange={isTeacher ? handleScoreChange : undefined}
 
395
  />
396
  ))}
397
  </div>
@@ -399,7 +401,7 @@ export const GameMountain: React.FC = () => {
399
 
400
  {/* SETTINGS MODAL */}
401
  {isSettingsOpen && (
402
- <div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
403
  <div className="bg-white rounded-2xl w-full max-w-5xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
404
  <div className="p-6 border-b border-gray-100 flex justify-between items-center shrink-0">
405
  <h3 className="text-xl font-bold text-gray-800 flex items-center"><Settings className="mr-2 text-blue-600"/> 游戏控制台</h3>
@@ -573,6 +575,10 @@ export const GameMountain: React.FC = () => {
573
  </div>
574
  )}
575
  </div>
576
- </>
577
  );
 
 
 
 
 
578
  };
 
1
 
2
  import React, { useState, useEffect, useRef } from 'react';
3
+ import { createPortal } from 'react-dom';
4
  import { api } from '../services/api';
5
  import { GameSession, GameTeam, Student, GameRewardConfig, AchievementConfig } from '../types';
6
+ import { Settings, Plus, Minus, Users, CheckSquare, Loader2, Trash2, X, Flag, Gift, Star, Trophy, Maximize, Minimize } from 'lucide-react';
7
 
8
  // --- CSS Animations for Clouds and Bounce ---
9
  const styles = `
 
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 ---
43
+ // Returns x (0-100%) and y (0-100%) based on progress (0-1)
44
+ // We map progress 0 -> bottom of path, 1 -> top of path
45
+ const calculatePathPoint = (progress: number) => {
46
+ // Y: Map 0-1 to roughly 10% - 90% of container height to stay on mountain
47
+ const y = 8 + (progress * 80);
48
+
49
+ // X: Center is 50%. Sine wave creates winding path.
50
+ // Amplitude tapers off slightly at the top to converge at peak
51
+ const amplitude = 15 * (1 - (progress * 0.3));
52
+ const frequency = 3; // Number of winds
53
+ const x = 50 + amplitude * Math.sin(progress * Math.PI * frequency);
54
+
55
+ return { x, y };
56
+ };
57
+
58
+ // Generate SVG Path String dynamically based on the math function
59
+ const generatePathString = () => {
60
+ let d = "";
61
+ // Sample points every 2%
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
73
+
74
+ if (i === 0) d += `M ${svgX} ${svgY}`;
75
+ else d += ` L ${svgX} ${svgY}`;
76
+ }
77
+ return d;
78
+ };
79
+
80
+ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, isFullscreen }: {
81
  team: GameTeam,
82
  index: number,
83
  rewardsConfig: GameRewardConfig[],
84
  maxSteps: number,
85
+ onScoreChange?: (id: string, delta: number) => void,
86
+ isFullscreen: boolean
87
  }) => {
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';
94
 
95
  return (
96
+ <div className={`relative flex flex-col items-center justify-end h-[95%] ${widthClass} mx-2 flex-shrink-0 select-none group perspective-1000 transition-all duration-300`}>
97
 
98
  {/* Team Name Cloud Tag */}
99
+ <div className="absolute top-[2%] z-20 transition-transform duration-300 hover:-translate-y-2 hover:scale-105 cursor-pointer w-full flex justify-center">
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>
 
 
107
  </div>
108
 
109
  {/* Mountain SVG */}
110
+ <div className="absolute bottom-0 left-0 w-full h-[90%] z-0 filter drop-shadow-lg transition-all duration-500 group-hover:drop-shadow-xl">
111
  <svg viewBox="0 0 200 300" preserveAspectRatio="none" className="w-full h-full overflow-visible">
112
  <defs>
113
  <linearGradient id={`grad-${index}`} x1="0%" y1="0%" x2="100%" y2="0%">
 
114
  <stop offset="0%" stopColor={team.color} stopOpacity="0.8" />
115
  <stop offset="50%" stopColor={team.color} />
116
+ <stop offset="50%" stopColor={team.color} stopOpacity="0.7"/>
117
  <stop offset="100%" stopColor={team.color} stopOpacity="0.5" />
118
  </linearGradient>
 
 
 
 
119
  </defs>
120
 
121
  {/* Main Mountain Shape */}
 
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"
141
+ stroke="rgba(255,255,255,0.5)"
142
+ strokeWidth="2"
143
+ strokeDasharray="4,4"
144
  strokeLinecap="round"
145
  />
146
 
147
+ {/* Vegetation */}
148
  <g transform="translate(0, 280)">
149
  <circle cx="30" cy="10" r="8" fill="#166534" opacity="0.8"/>
150
  <circle cx="45" cy="15" r="10" fill="#15803d" opacity="0.9"/>
151
  <circle cx="160" cy="12" r="9" fill="#166534" opacity="0.8"/>
 
152
  </g>
153
  </svg>
154
  </div>
155
 
156
+ {/* Rewards Placed on Path */}
157
+ <div className="absolute bottom-0 left-0 w-full h-[90%] z-10 pointer-events-none">
158
  {rewardsConfig.map((reward, i) => {
159
  const rPct = reward.scoreThreshold / maxSteps;
160
  const isUnlocked = team.score >= reward.scoreThreshold;
161
+ const pos = calculatePathPoint(rPct);
 
162
 
 
163
  let Icon = Gift;
164
  if (reward.rewardType === 'DRAW_COUNT') Icon = Star;
165
  if (reward.rewardType === 'ACHIEVEMENT') Icon = Trophy;
 
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
  );
 
186
 
187
  {/* Climber Avatar */}
188
  <div
189
+ className="absolute z-30 transition-all duration-700 ease-in-out flex flex-col items-center -translate-x-1/2 transform translate-y-1/2"
190
+ style={{ bottom: `${currentPos.y}%`, left: `${currentPos.x}%` }}
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 */}
206
  {onScoreChange && (
207
+ <div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-all z-40 bg-white/90 backdrop-blur px-2 py-1 rounded-full shadow-lg border border-gray-200 hover:scale-105">
208
+ <button onClick={() => onScoreChange(team.id, -1)} className="p-1 rounded-full bg-slate-100 text-slate-500 hover:bg-red-100 hover:text-red-600"><Minus size={14}/></button>
209
+ <button onClick={() => onScoreChange(team.id, 1)} className="p-1.5 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-600 hover:text-white shadow-sm"><Plus size={18} strokeWidth={3}/></button>
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  </div>
211
  )}
212
  </div>
 
218
  const [loading, setLoading] = useState(true);
219
  const [students, setStudents] = useState<Student[]>([]);
220
 
 
221
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
222
+ const [isFullscreen, setIsFullscreen] = useState(false);
223
  const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
224
  const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
225
 
 
227
  const isTeacher = currentUser?.role === 'TEACHER';
228
  const isAdmin = currentUser?.role === 'ADMIN';
229
 
230
+ useEffect(() => { loadData(); }, []);
 
 
231
 
232
  const loadData = async () => {
233
  setLoading(true);
234
  try {
235
  if (!currentUser) return;
 
236
  const allStudents = await api.students.getAll();
237
  let targetClass = '';
238
  let filteredStudents: Student[] = [];
 
249
  filteredStudents = allStudents;
250
  }
251
 
 
252
  filteredStudents.sort((a, b) => {
253
  const seatA = parseInt(a.seatNo || '99999');
254
  const seatB = parseInt(b.seatNo || '99999');
 
262
  if (sess) {
263
  setSession(sess);
264
  } else if (isTeacher && currentUser?.schoolId) {
 
265
  const newSess: GameSession = {
266
  schoolId: currentUser.schoolId,
267
  className: targetClass,
 
287
 
288
  const handleScoreChange = async (teamId: string, delta: number) => {
289
  if (!session || !isTeacher) return;
 
290
  const sysConfig = await api.config.getPublic();
291
  const currentSemester = sysConfig?.semester || '当前学期';
292
 
 
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) {
 
352
  if (loading) return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>;
353
  if (!session) return <div className="h-full flex items-center justify-center text-gray-400">暂无游戏会话,请联系班主任开启。</div>;
354
 
355
+ const GameContent = (
356
+ <div className={`${isFullscreen ? 'fixed inset-0 z-[9999] w-screen h-screen' : 'h-full w-full relative'} flex flex-col bg-gradient-to-b from-sky-300 via-sky-100 to-emerald-50 overflow-hidden`}>
357
+ <style>{styles}</style>
 
358
 
359
+ {/* Background Elements */}
360
  <div className="absolute inset-0 pointer-events-none overflow-hidden">
 
361
  <div className="absolute top-10 right-20 w-24 h-24 bg-yellow-300 rounded-full blur-xl opacity-60 animate-pulse"></div>
 
 
362
  <div className="absolute top-16 -left-20 text-white/60 text-9xl select-none animate-drift-slow opacity-80" style={{filter: 'blur(2px)'}}>☁️</div>
363
  <div className="absolute top-32 -left-40 text-white/40 text-8xl select-none animate-drift-medium opacity-60" style={{animationDelay: '5s'}}>☁️</div>
364
  <div className="absolute top-10 -left-10 text-white/50 text-[10rem] select-none animate-drift-fast opacity-40" style={{animationDelay: '15s'}}>☁️</div>
 
 
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
371
+ onClick={() => setIsFullscreen(!isFullscreen)}
372
+ className="p-2 bg-white/80 backdrop-blur rounded-full hover:bg-white shadow-sm border border-white/50 transition-colors"
373
+ title={isFullscreen ? "退出全屏" : "全屏"}
374
+ >
375
+ {isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
376
+ </button>
377
+
378
+ {isTeacher && (
379
+ <button onClick={() => setIsSettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-700 bg-white/90 backdrop-blur px-4 py-2 rounded-2xl border border-white/50 hover:bg-white shadow-md transition-all hover:scale-105 active:scale-95">
380
+ <Settings size={16} className="mr-2"/> 设置
381
  </button>
382
+ )}
383
+ </div>
384
 
385
+ {/* Scrollable Game Area */}
386
+ <div className="flex-1 overflow-x-auto overflow-y-hidden relative custom-scrollbar z-10 w-full min-w-0">
387
+ <div className="h-full flex items-end px-10 pb-4 gap-2 mx-auto w-max min-w-full justify-center">
388
  {session.teams.map((team, idx) => (
389
  <MountainStage
390
  key={team.id}
 
393
  rewardsConfig={session.rewardsConfig}
394
  maxSteps={session.maxSteps}
395
  onScoreChange={isTeacher ? handleScoreChange : undefined}
396
+ isFullscreen={isFullscreen}
397
  />
398
  ))}
399
  </div>
 
401
 
402
  {/* SETTINGS MODAL */}
403
  {isSettingsOpen && (
404
+ <div className="fixed inset-0 bg-black/60 z-[100000] flex items-center justify-center p-4 backdrop-blur-sm">
405
  <div className="bg-white rounded-2xl w-full max-w-5xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
406
  <div className="p-6 border-b border-gray-100 flex justify-between items-center shrink-0">
407
  <h3 className="text-xl font-bold text-gray-800 flex items-center"><Settings className="mr-2 text-blue-600"/> 游戏控制台</h3>
 
575
  </div>
576
  )}
577
  </div>
 
578
  );
579
+
580
+ if (isFullscreen) {
581
+ return createPortal(GameContent, document.body);
582
+ }
583
+ return GameContent;
584
  };