Lashtw commited on
Commit
945eb9e
·
verified ·
1 Parent(s): 98922ed

Upload 18 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/instructor_avatar.png filter=lfs diff=lfs merge=lfs -text
37
+ assets/photobg.png filter=lfs diff=lfs merge=lfs -text
38
+ assets/VIBE_Class_Photo_2026-01-27.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: VibeCodeingRenew
3
- emoji: 🌖
4
- colorFrom: pink
5
- colorTo: blue
6
  sdk: static
7
  pinned: false
8
  ---
 
1
  ---
2
+ title: Vibecodingex
3
+ emoji: 👁
4
+ colorFrom: indigo
5
+ colorTo: gray
6
  sdk: static
7
  pinned: false
8
  ---
assets/VIBE_Class_Photo_2026-01-27.png ADDED

Git LFS Details

  • SHA256: 32cadd4f1117546a680d2788996acc48d0e7e60ef2f44e67afc3063dc250eefd
  • Pointer size: 131 Bytes
  • Size of remote file: 489 kB
assets/eraser.svg ADDED
assets/instructor_avatar.png ADDED

Git LFS Details

  • SHA256: 8a338d67ef034c7716cc470af8f794ed7e06b028c8590341a4b1832f59b63b2c
  • Pointer size: 131 Bytes
  • Size of remote file: 123 kB
assets/photobg.png ADDED

Git LFS Details

  • SHA256: 531d4d67850fd5f22f3ef63848c5dd913e4b25210b7f331fc591a88fddc04dfb
  • Pointer size: 132 Bytes
  • Size of remote file: 7.08 MB
gitattributes ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/instructor_avatar.png filter=lfs diff=lfs merge=lfs -text
37
+ assets/photobg.png filter=lfs diff=lfs merge=lfs -text
38
+ assets/VIBE_Class_Photo_2026-01-27.png filter=lfs diff=lfs merge=lfs -text
index.html CHANGED
@@ -1,19 +1,34 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Vibecoding Workshop</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link
12
+ href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Noto+Sans+TC:wght@400;700&display=swap"
13
+ rel="stylesheet">
14
+ <style>
15
+ body {
16
+ font-family: 'Noto Sans TC', sans-serif;
17
+ background-color: #0f172a;
18
+ /* Slate 900 */
19
+ color: #e2e8f0;
20
+ /* Slate 200 */
21
+ }
22
+
23
+ .font-mono {
24
+ font-family: 'JetBrains Mono', monospace;
25
+ }
26
+ </style>
27
+ </head>
28
+
29
+ <body class="bg-slate-900 text-slate-200 antialiased selection:bg-cyan-500 selection:text-white">
30
+ <div id="app" class="max-w-7xl mx-auto"></div>
31
+ <script type="module" src="./src/main.js"></script>
32
+ </body>
33
+
34
+ </html>
monster_preview.html ADDED
@@ -0,0 +1,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>VIBECODING Monster Gallery - Final Check</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <style>
10
+ body {
11
+ background-color: #0F172A;
12
+ color: white;
13
+ font-family: 'Courier New', monospace;
14
+ }
15
+
16
+ @keyframes breathe {
17
+ 0% {
18
+ transform: scaleY(1) translateY(0);
19
+ }
20
+
21
+ 50% {
22
+ transform: scaleY(0.92) translateY(4px);
23
+ }
24
+
25
+ 100% {
26
+ transform: scaleY(1) translateY(0);
27
+ }
28
+ }
29
+
30
+ .pixel-art {
31
+ image-rendering: pixelated;
32
+ width: 144px;
33
+ height: 144px;
34
+ background: rgba(0, 0, 0, 0.3);
35
+ border-radius: 12px;
36
+ animation: breathe 2.5s infinite ease-in-out;
37
+ transform-origin: bottom center;
38
+ }
39
+
40
+ .pixel-art:hover {
41
+ animation-play-state: paused;
42
+ transform: scale(1.1);
43
+ z-index: 10;
44
+ box-shadow: 0 0 25px rgba(0, 0, 0, 0.6);
45
+ }
46
+
47
+ .card {
48
+ background: rgba(30, 41, 59, 0.5);
49
+ border: 1px solid rgba(148, 163, 184, 0.1);
50
+ border-radius: 16px;
51
+ padding: 15px;
52
+ text-align: center;
53
+ position: relative;
54
+ }
55
+
56
+ .grid-container {
57
+ display: grid;
58
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
59
+ gap: 24px;
60
+ padding: 20px;
61
+ }
62
+
63
+ h2 {
64
+ border-bottom: 2px solid #334155;
65
+ padding-bottom: 10px;
66
+ margin-top: 40px;
67
+ margin-bottom: 20px;
68
+ color: #60A5FA;
69
+ font-size: 1.5em;
70
+ font-weight: bold;
71
+ }
72
+
73
+ .badge {
74
+ display: inline-block;
75
+ padding: 2px 8px;
76
+ border-radius: 999px;
77
+ font-size: 0.7rem;
78
+ font-weight: bold;
79
+ margin-top: 5px;
80
+ background: #374151;
81
+ color: #9CA3AF;
82
+ }
83
+
84
+ .done-badge {
85
+ background: #059669;
86
+ color: #ccfbf1;
87
+ }
88
+
89
+ .new-badge {
90
+ background: #2563eb;
91
+ color: #dbeafe;
92
+ }
93
+
94
+ .fam-label {
95
+ position: absolute;
96
+ top: -10px;
97
+ left: 50%;
98
+ transform: translateX(-50%);
99
+ background: #1e293b;
100
+ color: #94a3b8;
101
+ font-size: 0.6rem;
102
+ padding: 2px 6px;
103
+ border-radius: 4px;
104
+ border: 1px solid #334155;
105
+ white-space: nowrap;
106
+ }
107
+ </style>
108
+ </head>
109
+
110
+ <body>
111
+ <div class="container mx-auto max-w-7xl p-8">
112
+ <h1
113
+ class="text-4xl font-extrabold mb-2 text-center text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-purple-500 to-pink-500">
114
+ 👾 VIBECODING 怪獸全圖鑑 👾</h1>
115
+ <p class="text-center text-slate-400 mb-12 font-mono">Restored & Updated: Wolf & Cat Redesigned</p>
116
+
117
+ <div id="gallery"></div>
118
+ </div>
119
+
120
+ <script>
121
+ const gallery = document.getElementById('gallery');
122
+ const W = 24; const H = 24; const C = W / 2;
123
+
124
+ function renderSVG(grid) {
125
+ let svg = '';
126
+ for (let y = 0; y < H; y++) {
127
+ for (let x = 0; x < W; x++) {
128
+ if (grid[y][x]) svg += `<rect x="${x}" y="${y}" width="1" height="1" fill="${grid[y][x]}"/>`;
129
+ }
130
+ }
131
+ return `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="w-full h-full drop-shadow-md">${svg}</svg>`;
132
+ }
133
+
134
+ const monsters = [
135
+ { id: 'Egg', name: '🥚 像素蛋', type: 'neutral', stage: 0, arch: 'egg', status: 'done', fam: 'Origin' },
136
+ // Stage 1
137
+ { id: 'L1_C', name: '🔴 灰塵球 Dust-Ball', type: 'glitch', stage: 1, arch: 'dust', status: 'done', fam: 'Dust' },
138
+ { id: 'L1_B', name: '🟡 像素狗 Pixel-Pup', type: 'animal', stage: 1, arch: 'beast_pup', status: 'done', fam: 'Beast' },
139
+ { id: 'L1_A', name: '🔵 光之靈 Lumina', type: 'holy', stage: 1, arch: 'spirit_orb', status: 'done', fam: 'Spirit' },
140
+ // Stage 2
141
+ { id: 'L2_CC', name: '🔴 垃圾怪 Trash-Mob', type: 'glitch', stage: 2, arch: 'dust_trash', status: 'done', fam: 'Dust' },
142
+ { id: 'L2_CB', name: '🟡 史萊姆 Slime-Box', type: 'slime', stage: 2, arch: 'dust_slime', status: 'done', fam: 'Dust' },
143
+ { id: 'L2_CA', name: '🔵 駭客蟲 Hacker-Bug', type: 'tech', stage: 2, arch: 'dust_tech', status: 'done', fam: 'Dust' },
144
+ { id: 'L2_BC', name: '🔴 廢鐵狼 Rusty-Wolf', type: 'grunge', stage: 2, arch: 'beast_wolf', status: 'done', fam: 'Beast' },
145
+ { id: 'L2_BB', name: '🟡 勇者貓 Hero-Cat', type: 'animal', stage: 2, arch: 'beast_cat', status: 'done', fam: 'Beast' },
146
+ { id: 'L2_BA', name: '🔵 機甲獅 Mecha-Lion', type: 'mech', stage: 2, arch: 'beast_mech', status: 'done', fam: 'Beast' },
147
+ { id: 'L2_AC', name: '🔴 幽靈火 Ghost-Fire', type: 'spirit', stage: 2, arch: 'spirit_fire', status: 'done', fam: 'Spirit' },
148
+ { id: 'L2_AB', name: '🟡 天使鳥 Angel-Bird', type: 'holy', stage: 2, arch: 'spirit_wing', status: 'done', fam: 'Spirit' },
149
+ { id: 'L2_AA', name: '🔵 星雲龍 Cosmos-Dragon', type: 'cosmic', stage: 2, arch: 'spirit_dragon', status: 'done', fam: 'Spirit' },
150
+ // Stage 3 - ESTABLISHED
151
+ { id: 'L3_CCC', name: '🗑️ 崩潰垃圾山', type: 'glitch', stage: 3, arch: 'pile_big', status: 'done', fam: 'Glitch/Trash' },
152
+ { id: 'L3_CCB', name: '📦 寶箱怪', type: 'mimic', stage: 3, arch: 'box_teeth', status: 'done', fam: 'Glitch/Trash' },
153
+ { id: 'L3_CCA', name: '🦠 病毒王', type: 'virus', stage: 3, arch: 'corona', status: 'done', fam: 'Glitch/Trash' },
154
+ { id: 'L3_CBC', name: '💧 汙泥怪', type: 'grunge', stage: 3, arch: 'fluid', status: 'done', fam: 'Slime' },
155
+ { id: 'L3_CBB', name: '🧊 果凍騎士', type: 'slime', stage: 3, arch: 'knight_slime', status: 'done', fam: 'Slime' },
156
+ { id: 'L3_CBA', name: '💎 鑽石魔像', type: 'crystal', stage: 3, arch: 'golem', status: 'done', fam: 'Slime' },
157
+ { id: 'L3_CAC', name: '🕷️ 錯誤蜘蛛', type: 'glitch', stage: 3, arch: 'spider', status: 'done', fam: 'Hacker' },
158
+ { id: 'L3_CAB', name: '👾 程式遊俠', type: 'tech', stage: 3, arch: 'ranger', status: 'done', fam: 'Hacker' },
159
+ { id: 'L3_CAA', name: '🧠 量子主腦', type: 'tech', stage: 3, arch: 'brain', status: 'done', fam: 'Hacker' },
160
+ { id: 'L3_BAC', name: '🚜 重裝推土機', type: 'mech', stage: 3, arch: 'tank', status: 'done', fam: 'Mech Lion' },
161
+ { id: 'L3_BAB', name: '✈️ 變形戰機', type: 'mech', stage: 3, arch: 'jet', status: 'done', fam: 'Mech Lion' },
162
+ { id: 'L3_BAA', name: '🤖 究極鋼彈', type: 'mech', stage: 3, arch: 'gundam', status: 'done', fam: 'Mech Lion' },
163
+ { id: 'L3_ACC', name: '💀 骷髏法師', type: 'undead', stage: 3, arch: 'mage_skull', status: 'done', fam: 'Ghost' },
164
+ { id: 'L3_ACB', name: '🕯️ 南瓜燈杰克', type: 'spirit', stage: 3, arch: 'pumpkin', status: 'done', fam: 'Ghost' },
165
+ { id: 'L3_ACA', name: '👻 虛空死神', type: 'void', stage: 3, arch: 'reaper', status: 'done', fam: 'Ghost' },
166
+
167
+ // Stage 3 - REDESIGN TARGETS (Wolf V2 & Cat)
168
+ { id: 'L3_BCC', name: '🧟 喪屍犬', type: 'undead', stage: 3, arch: 'wolf_zombie_v2', status: 'new', fam: 'Wolf' },
169
+ { id: 'L3_BCB', name: '🐕 警備犬', type: 'animal', stage: 3, arch: 'wolf_guard_v2', status: 'new', fam: 'Wolf' },
170
+ { id: 'L3_BCA', name: '🐺 合金戰狼', type: 'mech', stage: 3, arch: 'wolf_alloy_v2', status: 'new', fam: 'Wolf' },
171
+ { id: 'L3_BBC', name: '😼 流浪劍客', type: 'warrior', stage: 3, arch: 'ronin', status: 'new', fam: 'Cat' },
172
+ { id: 'L3_BBB', name: '👑 貓咪國王', type: 'royal', stage: 3, arch: 'king_cat', status: 'new', fam: 'Cat' },
173
+ { id: 'L3_BBA', name: '🦁 雷霆獅王', type: 'elemental', stage: 3, arch: 'lion_mane', status: 'new', fam: 'Cat' },
174
+ { id: 'L3_BBA', name: '🦁 雷霆獅王', type: 'elemental', stage: 3, arch: 'lion_mane', status: 'new', fam: 'Cat' },
175
+
176
+ // Stage 3 - NEW ADDITIONS (Angel & Cosmos)
177
+ { id: 'L3_ABC', name: '👁️ 座天使 Ophanim', type: 'holy', stage: 3, arch: 'biblical_angel', status: 'new', fam: 'Angel' },
178
+ { id: 'L3_ABB', name: '⚔️ 女武神 Valkyrie', type: 'warrior', stage: 3, arch: 'valkyrie', status: 'new', fam: 'Angel' },
179
+ { id: 'L3_ABA', name: '🔥 聖火鳳凰 Phoenix', type: 'divine', stage: 3, arch: 'phoenix', status: 'new', fam: 'Angel' },
180
+ { id: 'L3_AAC', name: '🛡️ 大地泰坦 Titan', type: 'cosmic', stage: 3, arch: 'terra_titan', status: 'new', fam: 'Cosmos' },
181
+ { id: 'L3_AAB', name: '🐋 星海鯨 Astro-Whale', type: 'cosmic', stage: 3, arch: 'cosmo_whale', status: 'new', fam: 'Cosmos' },
182
+ { id: 'L3_AAA', name: '🌌 創世龍 Genesis', type: 'god', stage: 3, arch: 'genesis_dragon', status: 'new', fam: 'Cosmos' },
183
+ ];
184
+
185
+ function mulberry32(a) {
186
+ return function () {
187
+ var t = a += 0x6D2B79F5;
188
+ t = Math.imul(t ^ (t >>> 15), t | 1);
189
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
190
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
191
+ }
192
+ }
193
+
194
+ const palettes = {
195
+ base: ['#94A3B8', '#64748B', '#475569', '#334155'],
196
+
197
+ // Standard
198
+ glitch: ['#18181b', '#27272a', '#3f3f46', '#ef4444'],
199
+ trash: ['#3f3f46', '#71717a', '#a1a1aa', '#ef4444'],
200
+ mimic: ['#854d0e', '#a16207', '#ca8a04', '#eab308'],
201
+ virus: ['#2e1065', '#581c87', '#7e22ce', '#bef264', '#84cc16'],
202
+ spider: ['#1e293b', '#0f172a', '#b91c1c', '#ef4444'],
203
+ slime: ['#059669', '#10b981', '#34d399', '#d1fae5'],
204
+ fluid: ['#3f3f46', '#52525b', '#4b5563', '#a1a1aa'],
205
+ knight: ['#0ea5e9', '#38bdf8', '#7dd3fc', '#ffffff'],
206
+ crypto: ['#0891b2', '#06b6d4', '#22d3ee', '#cffafe'],
207
+ tech: ['#22d3ee', '#06b6d4', '#0891b2', '#155e75'],
208
+ ranger: ['#3b82f6', '#2563eb', '#1d4ed8', '#fbbf24'],
209
+ brain: ['#d8b4fe', '#c084fc', '#a855f7', '#7e22ce', '#22d3ee'],
210
+ tank: ['#3f3f46', '#18181b', '#facc15', '#fbbf24'],
211
+ jet: ['#cbd5e1', '#94a3b8', '#ef4444', '#dc2626'],
212
+ gundam: ['#ffffff', '#2563eb', '#dc2626', '#facc15'],
213
+ mage: ['#4c1d95', '#a855f7', '#172554', '#fbbf24'],
214
+ pumpkin: ['#ea580c', '#c2410c', '#3f3f46', '#facc15'],
215
+ reaper: ['#000000', '#171717', '#7f1d1d', '#9ca3af'],
216
+
217
+ // Wolf V2
218
+ wolf: ['#c2410c', '#9a3412', '#7c2d12', '#ef4444'], // Rust/Browns
219
+ zombie: ['#44403c', '#78716c', '#991b1b', '#fecaca'],
220
+ guard: ['#172554', '#1e3a8a', '#fbbf24', '#f59e0b', '#3b82f6'],
221
+ alloy: ['#e2e8f0', '#94a3b8', '#0ea5e9', '#0284c7', '#38bdf8', '#ffffff'], // Added white/brights
222
+
223
+ // Cat V2
224
+ cat: ['#fb923c', '#ea580c', '#c2410c', '#fff7ed'],
225
+ ronin: ['#c2410c', '#9a3412', '#fde047', '#e2e8f0'],
226
+ king: ['#7e22ce', '#a855f7', '#fbbf24', '#ffffff'],
227
+ thunder: ['#facc15', '#eab308', '#fefce8', '#f59e0b', '#ffffff'],
228
+
229
+ // Base Fams
230
+ beast: ['#fbbf24', '#f59e0b', '#d97706', '#78350f'],
231
+ mech_lion: ['#60a5fa', '#3b82f6', '#1d4ed8', '#93c5fd'],
232
+ holy: ['#fef3c7', '#fde68a', '#f59e0b', '#ffffff'],
233
+ fire: ['#c084fc', '#a855f7', '#7e22ce', '#ffffff'],
234
+ space: ['#4f46e5', '#312e81', '#818cf8', '#f472b6', '#22d3ee'],
235
+
236
+ // Angel V3
237
+ biblical: ['#facc15', '#eab308', '#ffffff', '#ef4444', '#3b82f6'], // Gold + Eyes
238
+ valkyrie: ['#e2e8f0', '#94a3b8', '#facc15', '#38bdf8', '#fbbf24'], // Silver/Gold
239
+ seraphim: ['#dc2626', '#ef4444', '#fbbf24', '#facc15', '#ffffff'], // Fire/Gold
240
+
241
+ // Cosmos V3
242
+ meteor: ['#475569', '#334155', '#ea580c', '#f97316'], // Rock/Magma
243
+ whale: ['#1e1b4b', '#312e81', '#6366f1', '#c7d2fe', '#ffffff'], // Deep Space
244
+ genesis: ['#000000', '#171717', '#c084fc', '#22d3ee', '#f472b6', '#ffffff'] // Cosmic
245
+ };
246
+
247
+ function generateMonsterSVG(monster) {
248
+ let seed = 0;
249
+ for (let i = 0; i < monster.id.length; i++) seed += monster.id.charCodeAt(i);
250
+ const rng = mulberry32(seed);
251
+
252
+ let palette = palettes.base;
253
+ const t = monster.type;
254
+ const a = monster.arch;
255
+
256
+ // Palette Select
257
+ if (a.includes('wolf')) {
258
+ if (a.includes('zombie')) palette = palettes.zombie;
259
+ else if (a.includes('guard')) palette = palettes.guard;
260
+ else if (a.includes('alloy')) palette = palettes.alloy;
261
+ else palette = palettes.wolf;
262
+ }
263
+ else if (a === 'ronin') palette = palettes.ronin;
264
+ else if (a === 'king_cat') palette = palettes.king;
265
+ else if (a === 'lion_mane') palette = palettes.thunder;
266
+ else if (a === 'tank') palette = palettes.tank;
267
+ else if (a === 'jet') palette = palettes.jet;
268
+ else if (a === 'gundam') palette = palettes.gundam;
269
+ else if (a === 'mage_skull') palette = palettes.mage;
270
+ else if (a === 'pumpkin') palette = palettes.pumpkin;
271
+ else if (a === 'reaper') palette = palettes.reaper;
272
+
273
+ // Angel & Cosmos V3
274
+ else if (a === 'biblical_angel') palette = palettes.biblical;
275
+ else if (a === 'valkyrie') palette = palettes.valkyrie;
276
+ else if (a === 'phoenix') palette = palettes.seraphim; // Reuse Fire/Gold palette
277
+ else if (a === 'terra_titan') palette = palettes.meteor; // Reuse Rock palette
278
+ else if (a === 'cosmo_whale') palette = palettes.whale;
279
+ else if (a === 'genesis_dragon') palette = palettes.genesis;
280
+
281
+ // Beast Family
282
+ else if (a === 'beast_pup') palette = palettes.beast;
283
+ else if (a === 'beast_cat') palette = palettes.cat;
284
+ else if (a === 'beast_mech') palette = palettes.mech_lion;
285
+
286
+ // Spirit Family
287
+ else if (a === 'spirit_orb') palette = palettes.holy;
288
+ else if (a === 'spirit_fire') palette = palettes.fire;
289
+ else if (a === 'spirit_wing') palette = palettes.holy;
290
+ else if (a === 'spirit_dragon') palette = palettes.space;
291
+ // ... (Other palettes handled by fallbacks correctly as per previous logic)
292
+ // Safety fallbacks
293
+ if (a.includes('dust')) {
294
+ if (a === 'dust_trash' || a === 'pile_big' || a === 'box_teeth' || a === 'corona') palette = palettes.glitch; // Approx
295
+ if (a.includes('slime') || a === 'fluid' || a === 'knight_slime' || a === 'golem') palette = palettes.slime;
296
+ if (a.includes('tech') || a === 'spider' || a === 'ranger' || a === 'brain') palette = palettes.tech;
297
+ }
298
+ // Better-specific overrides
299
+ if (a === 'pile_big') palette = palettes.trash;
300
+ if (a === 'box_teeth') palette = palettes.mimic;
301
+ if (a === 'corona') palette = palettes.virus;
302
+ if (a === 'fluid') palette = palettes.fluid;
303
+ if (a === 'knight_slime') palette = palettes.knight;
304
+ if (a === 'golem') palette = palettes.crypto;
305
+ if (a === 'spider') palette = palettes.spider;
306
+ if (a === 'ranger') palette = palettes.ranger;
307
+ if (a === 'brain') palette = palettes.brain;
308
+
309
+ let grid = new Array(H).fill(0).map(() => new Array(W).fill(null));
310
+
311
+ const rect = (x, y, w, h, col) => {
312
+ for (let i = Math.max(0, Math.floor(x)); i < Math.min(W, x + w); i++)
313
+ for (let j = Math.max(0, Math.floor(y)); j < Math.min(H, y + h); j++)
314
+ grid[j][i] = col;
315
+ }
316
+ const circle = (cx, cy, r, col) => {
317
+ for (let y = 0; y < H; y++)
318
+ for (let x = 0; x < W; x++)
319
+ if (Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) < r) grid[y][x] = col;
320
+ }
321
+ const line = (x1, y1, x2, y2, col) => {
322
+ let dx = Math.abs(x2 - x1), sx = x1 < x2 ? 1 : -1;
323
+ let dy = -Math.abs(y2 - y1), sy = y1 < y2 ? 1 : -1;
324
+ let err = dx + dy, e2;
325
+ while (true) {
326
+ if (y1 >= 0 && y1 < H && x1 >= 0 && x1 < W) grid[y1][x1] = col;
327
+ if (x1 == x2 && y1 == y2) break;
328
+ const e2_val = 2 * err;
329
+ if (e2_val >= dy) { err += dy; x1 += sx; }
330
+ if (e2_val <= dx) { err += dx; y1 += sy; }
331
+ }
332
+ }
333
+
334
+ const mainCol = palette[Math.floor(rng() * (palette.length - 1))];
335
+ const secCol = palette[Math.floor(rng() * (palette.length - 2))] || palette[1];
336
+ const thirdCol = palette[palette.length - 1];
337
+
338
+ // --- STAGE 0-2 (Explicit Restoration) ---
339
+ if (a === 'egg') { circle(C, C, 7, mainCol); circle(C - 2, C - 3, 2, '#ffffff'); rect(C + 1, C + 2, 2, 2, secCol); rect(C - 4, C + 1, 2, 2, secCol); }
340
+ else if (a === 'dust') { circle(C, C, 5, '#1e293b'); for (let i = 0; i < 30; i++) { let angle = rng() * Math.PI * 2; let r = 4 + rng() * 4; rect(C + Math.cos(angle) * r, C + Math.sin(angle) * r, 1, 1, '#334155'); } rect(C - 4, C - 2, 3, 3, '#ffffff'); rect(C + 1, C - 2, 3, 3, '#ffffff'); rect(C - 3, C - 1, 1, 1, '#000000'); rect(C + 2, C - 1, 1, 1, '#000000'); }
341
+ else if (a === 'dust_trash') { circle(C, C, 6, mainCol); for (let i = 0; i < 40; i++) { let s = 7 + rng() * 3; rect(C + (rng() - 0.5) * s * 1.5, C + (rng() - 0.5) * s, 2, 2, i % 2 == 0 ? secCol : thirdCol); } rect(C - 3, C - 3, 3, 3, '#ffffff'); rect(C - 2, C - 2, 1, 1, '#000000'); rect(C + 1, C - 1, 2, 2, '#ffffff'); rect(C + 1, C - 1, 1, 1, '#000000'); }
342
+ else if (a === 'dust_slime') { for (let i = 0; i < 8; i++) circle(C + (rng() - 0.5) * 10, C + 3 + rng() * 3, 3, mainCol); rect(C - 3, C - 3, 2, 2, '#ffffff'); rect(C - 3, C - 1, 2, 2, '#000000'); rect(C + 1, C - 1, 2, 2, '#000000'); }
343
+ else if (a === 'dust_tech') { rect(C - 1, C - 9, 2, 4, secCol); rect(C - 5, C - 7, 10, 2, secCol); rect(C - 6, C + 2, 2, 4, mainCol); rect(C + 4, C + 2, 2, 4, mainCol); rect(C - 4, C - 1, 3, 2, '#a5f3fc'); rect(C + 1, C - 1, 3, 2, '#a5f3fc'); }
344
+ else if (a === 'beast_pup') { rect(C - 5, C - 1, 10, 6, mainCol); rect(C - 6, C - 5, 3, 4, secCol); rect(C + 3, C - 5, 3, 4, secCol); rect(C - 2, C + 1, 4, 3, secCol); rect(C - 1, C + 1, 2, 1, '#000000'); }
345
+ else if (a === 'beast_wolf') { rect(C - 6, C - 2, 12, 6, mainCol); rect(C - 7, C - 6, 5, 5, mainCol); for (let i = 0; i < 10; i++) rect(C - 7 + rng() * 14, C - 4 + rng() * 6, 1, 1, secCol); rect(C - 7, C - 8, 2, 3, secCol); rect(C - 4, C - 8, 2, 3, secCol); rect(C + 6, C, 4, 2, secCol); rect(C - 6, C - 5, 1, 1, '#ef4444'); }
346
+ else if (a === 'beast_cat') { rect(C - 6, C - 2, 12, 6, mainCol); rect(C - 7, C - 6, 5, 5, mainCol); rect(C - 7, C - 8, 2, 2, mainCol); rect(C - 4, C - 8, 2, 2, mainCol); rect(C - 6, C - 3, 1, 1, '#f9a8d4'); rect(C - 2, C - 3, 6, 6, '#ef4444'); }
347
+ else if (a === 'beast_mech') { rect(C - 6, C - 2, 12, 6, secCol); rect(C - 7, C - 6, 5, 5, mainCol); rect(C - 4, C - 8, 2, 6, thirdCol); rect(C - 6, C - 5, 3, 1, '#3b82f6'); }
348
+ else if (a === 'spirit_orb') { circle(C, C, 6, mainCol); for (let t = 0; t < Math.PI * 2; t += 0.4) { let r = 8; rect(C + Math.cos(t) * r, C + Math.sin(t) * r, 1, 1, secCol); } rect(C - 8, C - 2, 3, 4, '#ffffff'); rect(C + 5, C - 2, 3, 4, '#ffffff'); }
349
+ else if (a === 'spirit_fire') { circle(C, C + 1, 5, '#ffffff'); for (let i = 0; i < 40; i++) { let r = 4 + rng() * 6; let t = rng() * Math.PI * 2; let py = C + Math.sin(t) * r - (rng() * 6); let px = C + Math.cos(t) * r; rect(px, py, 2, 2, i % 3 == 0 ? thirdCol : secCol); } rect(C - 3, C - 1, 2, 3, '#000000'); rect(C + 1, C - 1, 2, 3, '#000000'); }
350
+ else if (a === 'spirit_wing') { circle(C, C, 5, mainCol); rect(C - 7, C + 2, 14, 2, secCol); rect(C - 11, C - 4, 2, 2, '#ffffff'); rect(C - 10, C - 5, 2, 4, '#ffffff'); rect(C - 8, C - 3, 4, 4, '#ffffff'); rect(C + 9, C - 4, 2, 2, '#ffffff'); rect(C + 8, C - 5, 2, 4, '#ffffff'); rect(C + 4, C - 3, 4, 4, '#ffffff'); rect(C - 4, C - 9, 8, 1, '#fbbf24'); rect(C - 5, C - 8, 1, 1, '#fbbf24'); rect(C + 4, C - 8, 1, 1, '#fbbf24'); rect(C - 1, C, 2, 2, '#fbbf24'); rect(C - 3, C - 2, 1, 1, '#3b82f6'); rect(C + 2, C - 2, 1, 1, '#3b82f6'); }
351
+ else if (a === 'spirit_dragon') { circle(C, C, 7, '#1e1b4b'); for (let i = 0; i < 20; i++) { let px = C + (rng() - 0.5) * 12; let py = C + (rng() - 0.5) * 10; rect(px, py, 1, 1, i % 3 == 0 ? '#f472b6' : (i % 2 == 0 ? '#22d3ee' : '#ffffff')); } rect(C - 9, C - 5, 4, 4, secCol); rect(C - 8, C - 9, 2, 4, thirdCol); rect(C + 6, C - 2, 6, 2, secCol); rect(C - 4, C - 2, 2, 1, '#22d3ee'); }
352
+
353
+ // --- STAGE 3 (Previously Done & APPROVED) ---
354
+ else if (a === 'pile_big') { circle(C, C, 8, '#000000'); for (let i = 0; i < 60; i++) rect(C + (rng() - 0.5) * 16, C + (rng() - 0.5) * 16, 2, 2, '#ef4444'); rect(C - 4, C - 2, 2, 2, '#ffffff'); rect(C - 3, C - 2, 1, 1, '#000000'); rect(C + 4, C, 2, 2, '#ffffff'); rect(C + 4, C, 1, 1, '#000000'); rect(C, C - 5, 3, 3, '#ffffff'); rect(C + 1, C - 5, 1, 1, '#000000'); }
355
+ else if (a === 'box_teeth') { rect(C - 8, C - 6, 16, 14, mainCol); rect(C - 8, C, 16, 2, '#000000'); rect(C - 1, C + 4, 2, 2, '#000000'); rect(C - 6, C + 2, 1, 2, '#ffffff'); rect(C - 2, C + 2, 1, 2, '#ffffff'); rect(C + 2, C + 2, 1, 2, '#ffffff'); rect(C + 5, C + 2, 1, 2, '#ffffff'); rect(C - 4, C - 3, 2, 1, '#ef4444'); rect(C + 2, C - 3, 2, 1, '#ef4444'); }
356
+ else if (a === 'corona') { circle(C, C, 6, mainCol); for (let t = 0; t < 6.28; t += 0.8) rect(C + Math.cos(t) * 10, C + Math.sin(t) * 10, 3, 3, secCol); rect(C - 4, C - 8, 8, 2, '#facc15'); rect(C - 4, C - 10, 2, 2, '#facc15'); rect(C + 2, C - 10, 2, 2, '#facc15'); rect(C - 3, C - 1, 2, 1, '#bef264'); rect(C + 1, C - 1, 2, 1, '#bef264'); rect(C - 2, C + 2, 4, 1, '#000000'); }
357
+ else if (a === 'fluid') { for (let i = 0; i < 4; i++) rect(C - 10 + i * 2, C + 4, 18 - i * 4, 4, mainCol); rect(C - 6, C, 2, 6, mainCol); rect(C + 4, C - 2, 3, 8, mainCol); rect(C - 5, C - 6, 3, 3, secCol); rect(C + 2, C - 5, 2, 2, secCol); rect(C - 2, C + 2, 4, 4, '#ffffff'); rect(C - 1, C + 2, 2, 2, '#000000'); }
358
+ else if (a === 'knight_slime') { rect(C - 3, C - 4, 6, 8, mainCol); rect(C - 4, C - 9, 8, 6, mainCol); rect(C - 2, C - 3, 4, 6, '#e0f2fe'); rect(C + 4, C, 8, 2, secCol); rect(C + 4, C - 2, 2, 6, secCol); rect(C - 7, C - 2, 4, 8, thirdCol); rect(C - 3, C - 7, 6, 1, '#000000'); }
359
+ else if (a === 'golem') { rect(C - 4, C - 4, 8, 8, mainCol); rect(C - 8, C - 8, 4, 4, secCol); rect(C + 4, C - 8, 4, 4, secCol); rect(C - 6, C + 4, 4, 6, secCol); rect(C + 2, C + 4, 4, 6, secCol); rect(C - 2, C - 10, 4, 4, thirdCol); rect(C - 3, C - 3, 2, 2, '#ffffff'); rect(C + 5, C - 7, 1, 1, '#ffffff'); rect(C - 1, C - 9, 2, 1, '#000000'); }
360
+ else if (a === 'spider') { rect(C - 4, C - 4, 8, 8, mainCol); rect(C - 5, C - 5, 10, 2, secCol); line(C - 4, C, C - 10, C - 4, secCol); line(C - 4, C + 2, C - 10, C + 4, secCol); line(C - 4, C + 4, C - 8, C + 8, secCol); line(C + 4, C, C + 10, C - 4, secCol); line(C + 4, C + 2, C + 10, C + 4, secCol); line(C + 4, C + 4, C + 8, C + 8, secCol); rect(C - 3, C - 2, 2, 2, '#ef4444'); rect(C + 1, C - 2, 2, 2, '#ef4444'); rect(C - 1, C + 1, 2, 2, '#ef4444'); }
361
+ else if (a === 'ranger') { rect(C - 4, C - 10, 8, 8, mainCol); rect(C - 3, C - 9, 2, 6, secCol); rect(C + 1, C - 9, 2, 6, secCol); rect(C - 1, C - 5, 2, 2, '#fbbf24'); rect(C - 4, C - 2, 8, 6, mainCol); rect(C + 4, C - 2, 6, 4, mainCol); rect(C + 8, C - 1, 2, 2, '#22d3ee'); rect(C - 4, C + 4, 3, 6, mainCol); rect(C + 1, C + 4, 3, 6, mainCol); }
362
+ else if (a === 'brain') { rect(C - 6, C - 6, 12, 12, '#a5f3fc'); rect(C - 6, C + 6, 12, 2, secCol); rect(C - 6, C - 8, 12, 2, secCol); rect(C - 4, C - 4, 8, 6, '#d8b4fe'); rect(C - 2, C - 2, 4, 1, '#ffffff'); line(C - 6, C, C - 10, C + 4, secCol); line(C + 6, C, C + 10, C + 4, secCol); }
363
+ else if (a === 'tank') {
364
+ rect(C - 8, C + 4, 16, 4, '#1e293b'); rect(C - 9, C + 5, 18, 2, '#000000');
365
+ rect(C - 6, C - 2, 12, 6, mainCol); rect(C - 4, C - 6, 8, 4, mainCol); rect(C, C - 5, 8, 2, '#3f3f46'); rect(C - 5, C, 10, 2, '#facc15');
366
+ rect(C - 3, C - 3, 2, 2, '#3f3f46'); rect(C - 1, C - 2, 2, 1, '#ef4444');
367
+ }
368
+ else if (a === 'jet') {
369
+ rect(C - 2, C - 10, 4, 6, '#ef4444'); rect(C - 4, C - 4, 8, 10, mainCol); rect(C - 10, C, 6, 8, secCol); rect(C + 4, C, 6, 8, secCol);
370
+ rect(C - 3, C - 6, 6, 2, '#38bdf8'); rect(C - 3, C + 6, 2, 4, '#ef4444'); rect(C + 1, C + 6, 2, 4, '#ef4444');
371
+ }
372
+ else if (a === 'gundam') {
373
+ rect(C - 4, C - 10, 8, 6, '#ffffff'); rect(C - 5, C - 8, 10, 2, '#ffffff'); rect(C - 1, C - 11, 2, 3, '#dc2626'); rect(C - 3, C - 9, 6, 1, '#facc15');
374
+ rect(C - 4, C - 4, 8, 8, '#2563eb'); rect(C - 2, C - 4, 4, 2, '#dc2626'); rect(C - 5, C - 4, 2, 4, '#ffffff'); rect(C + 3, C - 4, 2, 4, '#ffffff');
375
+ rect(C - 4, C + 4, 3, 6, '#ffffff'); rect(C + 1, C + 4, 3, 6, '#ffffff'); rect(C + 5, C, 2, 6, '#334155');
376
+ }
377
+ else if (a === 'mage_skull') {
378
+ rect(C - 4, C - 5, 8, 14, '#172554'); rect(C - 3, C - 9, 6, 5, '#e5e5e5'); rect(C - 2, C - 8, 1, 1, '#000000'); rect(C + 1, C - 8, 1, 1, '#000000');
379
+ rect(C - 6, C - 10, 12, 1, '#4c1d95'); rect(C - 3, C - 13, 6, 3, '#4c1d95'); rect(C + 5, C - 8, 1, 16, '#fbbf24'); circle(C + 5, C - 9, 2, '#a855f7');
380
+ }
381
+ else if (a === 'pumpkin') {
382
+ rect(C - 4, C - 2, 8, 10, '#3f3f46'); circle(C, C - 6, 6, '#ea580c'); rect(C - 2, C - 7, 1, 1, '#facc15'); rect(C + 1, C - 7, 1, 1, '#facc15');
383
+ rect(C - 2, C - 5, 4, 1, '#facc15'); rect(C, C - 13, 1, 2, '#166534'); rect(C - 6, C, 2, 2, '#ea580c'); rect(C + 4, C, 2, 2, '#ea580c');
384
+ }
385
+ else if (a === 'reaper') {
386
+ rect(C - 5, C - 8, 10, 16, '#000000'); rect(C - 3, C - 6, 6, 4, '#171717'); rect(C - 1, C - 5, 1, 1, '#ef4444'); rect(C, C - 5, 1, 1, '#ef4444');
387
+ rect(C + 6, C - 8, 1, 16, '#7f1d1d'); rect(C + 4, C - 10, 6, 2, '#9ca3af'); rect(C + 3, C - 10, 1, 6, '#9ca3af');
388
+ }
389
+
390
+ // --- REDESIGNED STAGE 3 (Wolf V2 & Cat V2 Targets) ---
391
+ else if (a === 'wolf_zombie_v2') {
392
+ rect(C - 6, C - 3, 13, 6, '#4b5563'); // Darker rot body
393
+ rect(C - 2, C - 2, 5, 4, '#991b1b'); // Ribcage
394
+ rect(C - 8, C - 7, 6, 6, '#4b5563'); // Head
395
+ rect(C - 6, C - 6, 2, 2, '#e7e5e4'); // Skull patch
396
+ rect(C - 5, C - 5, 1, 1, '#000000'); // Eye empty
397
+ rect(C - 7, C + 3, 2, 5, '#4b5563'); // Leg FL
398
+ rect(C + 4, C + 3, 2, 5, '#e7e5e4'); // Leg BR (Bone)
399
+ for (let i = 0; i < 4; i++) rect(C + (rng() - 0.5) * 14, C - 6 + (rng() - 0.5) * 8, 1, 1, '#bef264');
400
+ }
401
+ else if (a === 'wolf_guard_v2') {
402
+ rect(C - 7, C - 3, 14, 7, '#1e3a8a'); // Body Armor
403
+ rect(C - 3, C - 4, 6, 6, '#fbbf24'); // Heavy Plating
404
+ rect(C - 9, C - 8, 7, 7, '#172554'); // Helmet
405
+ rect(C - 9, C - 6, 7, 1, '#60a5fa'); // Visor
406
+ rect(C - 3, C - 6, 3, 3, '#1e40af'); // Pad
407
+ rect(C - 7, C + 4, 3, 5, '#3b82f6');
408
+ rect(C + 5, C + 4, 3, 5, '#3b82f6');
409
+ }
410
+ else if (a === 'wolf_alloy_v2') {
411
+ rect(C - 7, C - 3, 14, 5, '#e2e8f0'); // Silver Body
412
+ rect(C - 2, C - 8, 4, 6, '#94a3b8'); // Booster base
413
+ rect(C - 3, C - 9, 2, 4, '#0ea5e9'); // Thruster
414
+ rect(C - 9, C - 6, 7, 5, '#e2e8f0'); // Head
415
+ rect(C - 6, C - 6, 4, 1, '#0ea5e9'); // Visor
416
+ line(C + 7, C, C + 11, C - 4, '#0ea5e9'); // Saber tail
417
+ rect(C - 7, C + 5, 2, 3, '#94a3b8');
418
+ rect(C + 6, C + 5, 2, 3, '#94a3b8');
419
+ }
420
+ else if (a === 'ronin') {
421
+ rect(C - 3, C - 4, 6, 8, '#c2410c'); // Orange Gi
422
+ rect(C - 4, C - 10, 8, 4, '#fde047'); // Hat
423
+ rect(C - 3, C + 4, 2, 4, '#3f3f46'); // Hakama
424
+ rect(C + 1, C + 4, 2, 4, '#3f3f46');
425
+ line(C + 4, C - 4, C + 8, C - 8, '#e5e5e5'); // Blade handle
426
+ rect(C - 4, C - 3, 2, 2, '#ef4444'); // Scarf
427
+ }
428
+ else if (a === 'king_cat') {
429
+ rect(C - 4, C - 3, 8, 8, '#7e22ce'); // Robe
430
+ rect(C - 2, C - 3, 4, 8, '#ffffff'); // Fur
431
+ rect(C - 4, C - 10, 8, 3, '#fbbf24'); // Crown
432
+ rect(C - 1, C - 10, 2, 2, '#ef4444');
433
+ line(C + 6, C, C + 6, C + 6, '#fbbf24'); // Scepter
434
+ circle(C + 6, C - 1, 2, '#ef4444');
435
+ }
436
+ else if (a === 'lion_mane') {
437
+ rect(C - 4, C - 3, 8, 8, '#ffffff');
438
+ for (let t = 0; t < 6.3; t += 0.6) rect(C + Math.cos(t) * 11, C + Math.sin(t) * 11 - 5, 2, 2, '#facc15'); // Mane
439
+ rect(C - 2, C - 6, 4, 4, '#fff7ed'); // Face
440
+ rect(C - 1, C - 5, 2, 1, '#3b82f6'); // Eyes
441
+ line(C - 8, C, C - 4, C + 4, '#facc15'); // Lightning
442
+ line(C + 8, C, C + 4, C - 4, '#facc15');
443
+ }
444
+
445
+ // --- ANGEL FAMILY V3 ---
446
+ else if (a === 'biblical_angel') {
447
+ // Ophanim: Interlocking rings
448
+ for (let t = 0; t < 6.3; t += 0.1) {
449
+ let r = 9;
450
+ let x1 = C + Math.cos(t) * r; let y1 = C + Math.sin(t) * r * 0.4;
451
+ rect(x1, y1, 1, 1, mainCol); // Ring 1
452
+ let x2 = C + Math.cos(t) * r * 0.4; let y2 = C + Math.sin(t) * r;
453
+ rect(x2, y2, 1, 1, secCol); // Ring 2
454
+ }
455
+ circle(C, C, 4, '#ffffff'); // Core
456
+ // Eyes on rings
457
+ for (let i = 0; i < 8; i++) {
458
+ rect(C + Math.cos(i * 0.8) * 9, C + Math.sin(i * 0.8) * 3.5, 2, 2, '#ffffff');
459
+ rect(C + Math.cos(i * 0.8) * 9, C + Math.sin(i * 0.8) * 3.5, 1, 1, '#3b82f6');
460
+ }
461
+ }
462
+ else if (a === 'valkyrie') {
463
+ rect(C - 3, C - 5, 6, 8, '#e2e8f0'); // Armor Body
464
+ rect(C - 3, C - 9, 6, 4, '#facc15'); // Helm
465
+ rect(C - 4, C + 3, 3, 5, '#e2e8f0'); // Leg L
466
+ rect(C + 1, C + 3, 3, 5, '#e2e8f0'); // Leg R
467
+ rect(C - 8, C - 6, 4, 10, '#ffffff'); // Wing L
468
+ rect(C + 5, C - 6, 4, 10, '#ffffff'); // Wing R
469
+ line(C + 4, C + 8, C + 10, C - 8, '#fbbf24'); // Spear
470
+ rect(C + 9, C - 9, 2, 4, '#38bdf8'); // Spear Tip
471
+ }
472
+ else if (a === 'phoenix') {
473
+ // Body
474
+ rect(C - 2, C - 4, 4, 8, mainCol);
475
+ // Head
476
+ rect(C - 2, C - 8, 4, 4, mainCol);
477
+ rect(C, C - 7, 1, 1, '#fbbf24'); // Eye
478
+ rect(C - 3, C - 6, 1, 2, '#fbbf24'); // Beak
479
+
480
+ // Wings (Spread Y shape)
481
+ for (let i = 0; i < 8; i++) {
482
+ rect(C - 2 - i, C - 4 - i, 1, 2, '#ef4444');
483
+ rect(C + 1 + i, C - 4 - i, 1, 2, '#ef4444');
484
+ rect(C - 2 - i, C - 2 - i, 1, 4, secCol); // Feathers
485
+ rect(C + 1 + i, C - 2 - i, 1, 4, secCol);
486
+ }
487
+
488
+ // Tail
489
+ rect(C - 1, C + 4, 2, 6, '#fbbf24');
490
+ rect(C - 3, C + 6, 2, 4, '#ef4444');
491
+ rect(C + 1, C + 6, 2, 4, '#ef4444');
492
+ }
493
+
494
+ // --- COSMOS FAMILY V3 ---
495
+ else if (a === 'terra_titan') {
496
+ // Heavy Mech/Golem body
497
+ rect(C - 6, C - 6, 12, 10, mainCol); // Chest
498
+ rect(C - 8, C - 8, 4, 6, secCol); // Shoulder L
499
+ rect(C + 4, C - 8, 4, 6, secCol); // Shoulder R
500
+
501
+ // Head
502
+ rect(C - 3, C - 9, 6, 4, mainCol);
503
+ rect(C - 2, C - 8, 4, 1, '#f97316'); // Visor
504
+
505
+ // Limbs
506
+ rect(C - 8, C - 2, 3, 8, mainCol); // Arm L
507
+ rect(C + 5, C - 2, 3, 8, mainCol); // Arm R
508
+ rect(C - 6, C + 4, 4, 6, secCol); // Leg L
509
+ rect(C + 2, C + 4, 4, 6, secCol); // Leg R
510
+
511
+ // Core
512
+ circle(C, C - 2, 3, '#ea580c');
513
+ rect(C - 1, C - 3, 2, 2, '#f97316');
514
+ }
515
+
516
+ else if (a === 'cosmo_whale') {
517
+ // Massive Body (Sperm whale shape)
518
+ rect(C - 10, C - 6, 14, 10, mainCol); // Big Head/Front
519
+ rect(C + 4, C - 4, 6, 6, mainCol); // Mid
520
+ rect(C + 10, C - 2, 4, 3, mainCol); // Tail fuse
521
+
522
+ // Underbelly
523
+ rect(C - 10, C + 2, 12, 2, '#c7d2fe');
524
+
525
+ // Tail Fluke
526
+ rect(C + 12, C - 5, 2, 8, mainCol);
527
+ rect(C + 11, C - 6, 1, 2, mainCol);
528
+ rect(C + 11, C + 4, 1, 2, mainCol);
529
+
530
+ // Eye
531
+ rect(C - 5, C, 2, 1, '#ffffff');
532
+
533
+ // Blowhole Spray
534
+ for (let k = 0; k < 5; k++) rect(C - 6 + (rng() - 0.5) * 4, C - 10 + (rng() - 0.5) * 4, 1, 1, '#22d3ee');
535
+
536
+ // Stars/Spots
537
+ rect(C - 8, C - 3, 1, 1, '#ffffff');
538
+ rect(C - 2, C - 4, 1, 1, '#ffffff');
539
+ rect(C + 6, C - 1, 1, 1, '#ffffff');
540
+ }
541
+ else if (a === 'genesis_dragon') {
542
+ // Sinuous body
543
+ let px = C, py = C;
544
+ for (let i = 0; i < 30; i++) {
545
+ rect(px, py, 3, 3, i % 2 == 0 ? '#000000' : secCol);
546
+ px += Math.cos(i * 0.4) * 2;
547
+ py += Math.sin(i * 0.4) * 2;
548
+ }
549
+ circle(C, C, 5, '#c084fc'); // Void Core
550
+ // Cosmic Particles
551
+ for (let k = 0; k < 20; k++) {
552
+ rect(C + (rng() - 0.5) * 20, C + (rng() - 0.5) * 20, 1, 1, k % 3 == 0 ? '#22d3ee' : '#f472b6');
553
+ }
554
+ }
555
+
556
+ return renderSVG(grid);
557
+ }
558
+
559
+ const stages = [0, 1, 2, 3];
560
+ stages.forEach(stage => {
561
+ const section = document.createElement('div');
562
+ section.innerHTML = `<h2>Stage ${stage}</h2><div class="grid-container" id="stage-${stage}"></div>`;
563
+ gallery.appendChild(section);
564
+
565
+ const container = section.querySelector('.grid-container');
566
+
567
+ monsters.filter(m => m.stage === stage && (m.status === 'done' || m.status === 'new')).sort((a, b) => {
568
+ const famOrder = { 'Origin': 0, 'Dust': 1, 'Beast': 2, 'Spirit': 3, 'Glitch/Trash': 4, 'Slime': 5, 'Hacker': 6, 'Wolf': 7, 'Cat': 8, 'Mech Lion': 9, 'Ghost': 10, 'Angel': 11, 'Cosmos': 12 };
569
+ return (famOrder[a.fam] || 99) - (famOrder[b.fam] || 99);
570
+ }).forEach(m => {
571
+ const card = document.createElement('div');
572
+ card.className = 'card';
573
+ let badgeClass = m.status === 'new' ? 'new-badge' : 'done-badge';
574
+ card.innerHTML = `
575
+ <div class="fam-label">${m.fam || 'Base'}</div>
576
+ <div class="pixel-art mx-auto bg-slate-800 p-2">${generateMonsterSVG(m)}</div>
577
+ <div class="mt-4 font-bold text-sm text-slate-200" style="min-height: 48px; display: flex; align-items: center; justify-content: center;">${m.name.replace(' ', '<br>')}</div>
578
+ <span class="badge ${badgeClass}">${m.status.toUpperCase()}</span>
579
+ `;
580
+ container.appendChild(card);
581
+ });
582
+ });
583
+ </script>
584
+ </body>
585
+
586
+ </html>
src/main.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { renderLandingView, setupLandingEvents } from './views/LandingView.js';
2
+ import { renderInstructorView, setupInstructorEvents } from './views/InstructorView.js';
3
+ import { renderStudentView, setupStudentEvents } from './views/StudentView.js';
4
+ import { renderAdminView, setupAdminEvents } from './views/AdminView.js';
5
+
6
+ const app = document.querySelector('#app');
7
+
8
+ function navigateTo(view) {
9
+ // Update hash maybe? For now simple switch
10
+ switch (view) {
11
+ case 'landing':
12
+ app.innerHTML = renderLandingView();
13
+ setupLandingEvents(navigateTo);
14
+ break;
15
+ case 'instructor':
16
+ app.innerHTML = '載入中...';
17
+ // Async render because Instructor view fetches challenges for column headers
18
+ renderInstructorView().then(html => {
19
+ app.innerHTML = html;
20
+ setupInstructorEvents();
21
+ });
22
+ break;
23
+ case 'student':
24
+ app.innerHTML = '載入中...';
25
+ // Async render because Student view fetches challenges
26
+ renderStudentView().then(html => {
27
+ app.innerHTML = html;
28
+ setupStudentEvents();
29
+ }).catch(err => {
30
+ console.error("Student View Load Error:", err);
31
+ app.innerHTML = `<div class="p-10 text-center text-red-500">
32
+ <h2 class="text-xl font-bold mb-2">載入失敗</h2>
33
+ <p class="mb-4">${err.message}</p>
34
+ <button onclick="window.location.reload()" class="bg-gray-700 text-white px-4 py-2 rounded">重新整理</button>
35
+ </div>`;
36
+ });
37
+ break;
38
+ case 'admin':
39
+ app.innerHTML = renderAdminView();
40
+ setupAdminEvents();
41
+ break;
42
+ default:
43
+ app.innerHTML = renderLandingView();
44
+ setupLandingEvents(navigateTo);
45
+ }
46
+ }
47
+
48
+ // Route Handler
49
+ function handleRoute() {
50
+ const hash = window.location.hash.slice(1);
51
+ if (hash === 'admin') {
52
+ navigateTo('admin');
53
+ return;
54
+ }
55
+ if (hash === 'instructor') {
56
+ navigateTo('instructor');
57
+ return;
58
+ }
59
+
60
+
61
+
62
+ const roomCode = localStorage.getItem('vibecoding_room_code');
63
+ const userId = localStorage.getItem('vibecoding_user_id'); // Changed key to match new logic
64
+
65
+ if (roomCode && userId && !hash) {
66
+ navigateTo('student');
67
+ } else {
68
+ navigateTo('landing');
69
+ }
70
+ }
71
+
72
+ // Listen to hash changes
73
+ window.addEventListener('hashchange', handleRoute);
74
+
75
+ // Initial Load
76
+ handleRoute();
src/services/auth.js ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { auth, db } from "./firebase.js";
2
+ import {
3
+ signInWithEmailAndPassword,
4
+ createUserWithEmailAndPassword,
5
+ signOut,
6
+ sendPasswordResetEmail
7
+ } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
8
+ import {
9
+ doc,
10
+ getDoc,
11
+ setDoc,
12
+ updateDoc,
13
+ collection,
14
+ getDocs,
15
+ deleteDoc,
16
+ serverTimestamp,
17
+ query
18
+ } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
19
+
20
+ const INSTRUCTORS_COLLECTION = "instructors";
21
+ const SUPER_ADMIN_EMAIL = "t92206@gmail.com";
22
+
23
+ /**
24
+ * Sign in with Email/Password
25
+ */
26
+ export async function loginWithEmail(email, password) {
27
+ try {
28
+ const result = await signInWithEmailAndPassword(auth, email, password);
29
+ return result.user;
30
+ } catch (error) {
31
+ console.error("Login Error:", error);
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Register with Email/Password
38
+ * (Used for new instructors to create their auth account matching their whitelisted email)
39
+ */
40
+ export async function registerWithEmail(email, password) {
41
+ try {
42
+ const result = await createUserWithEmailAndPassword(auth, email, password);
43
+ return result.user;
44
+ } catch (error) {
45
+ console.error("Register Error:", error);
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Send Password Reset Email
52
+ */
53
+ export async function resetPassword(email) {
54
+ try {
55
+ await sendPasswordResetEmail(auth, email);
56
+ } catch (error) {
57
+ console.error("Reset Password Error:", error);
58
+ throw error;
59
+ }
60
+ }
61
+
62
+
63
+
64
+ /**
65
+ * Sign out
66
+ */
67
+ export async function signOutUser() {
68
+ try {
69
+ await signOut(auth);
70
+ } catch (error) {
71
+ console.error("Sign Out Error:", error);
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Check if user is an instructor and get permissions
78
+ * Bootstraps the Super Admin if not exists
79
+ * @param {object} user - Firebase User object
80
+ * @returns {Promise<object|null>} Instructor data or null if not authorized
81
+ */
82
+ export async function checkInstructorPermission(user) {
83
+ if (!user || !user.email) return null;
84
+
85
+ const email = user.email;
86
+ const instructorRef = doc(db, INSTRUCTORS_COLLECTION, email);
87
+ let snap;
88
+
89
+ try {
90
+ console.log(`[Permission Check] Checking whitelist for email: '${email}'`);
91
+ snap = await getDoc(instructorRef);
92
+ console.log(`[Permission Check] Result for '${email}': exists=${snap.exists()}`);
93
+ } catch (error) {
94
+ console.warn(`[Permission Check] Failed for '${email}'. Error:`, error);
95
+ console.warn("Instructor Permission Check Failed (likely not whitelisted):", error.code);
96
+ // If permission denied, it means they are not allowed to read the doc => likely not in whitelist
97
+ return null;
98
+ }
99
+
100
+ // Bootstrap Super Admin
101
+ if (email === SUPER_ADMIN_EMAIL) {
102
+ const adminData = {
103
+ name: user.displayName || "Super Admin",
104
+ email: email,
105
+ role: 'admin',
106
+ permissions: ['create_room', 'add_question', 'manage_instructors'],
107
+ lastLogin: serverTimestamp()
108
+ };
109
+
110
+ try {
111
+ if (!snap.exists()) {
112
+ await setDoc(instructorRef, {
113
+ ...adminData,
114
+ createdAt: serverTimestamp()
115
+ });
116
+ } else {
117
+ // Ensure admin always has full permissions
118
+ await updateDoc(instructorRef, {
119
+ role: 'admin',
120
+ permissions: ['create_room', 'add_question', 'manage_instructors'],
121
+ lastLogin: serverTimestamp()
122
+ });
123
+ }
124
+ } catch (e) {
125
+ console.warn("Admin bootstrap failed (likely permission issues), but allowing login as admin.", e);
126
+ // We continue because we return adminData anyway, effectively granting admin rights in UI.
127
+ }
128
+ return adminData;
129
+ }
130
+
131
+ if (snap.exists()) {
132
+ const data = snap.data();
133
+ try {
134
+ await updateDoc(instructorRef, { lastLogin: serverTimestamp() });
135
+ } catch (e) {
136
+ console.warn("Failed to update lastLogin (likely permission), proceeding anyway.", e);
137
+ }
138
+ return data;
139
+ }
140
+
141
+ return null; // Not an instructor
142
+ }
143
+
144
+ /**
145
+ * Get all instructors (Admin Only)
146
+ */
147
+ export async function getInstructors() {
148
+ const q = query(collection(db, INSTRUCTORS_COLLECTION));
149
+ const snapshot = await getDocs(q);
150
+ return snapshot.docs.map(doc => doc.data());
151
+ }
152
+
153
+ /**
154
+ * Add new instructor (Admin Only)
155
+ */
156
+ export async function addInstructor(email, name, permissions) {
157
+ const safeEmail = email.trim(); // Ensure no leading/trailing spaces
158
+ const instructorRef = doc(db, INSTRUCTORS_COLLECTION, safeEmail);
159
+ await setDoc(instructorRef, {
160
+ email: safeEmail,
161
+ name,
162
+ role: 'instructor',
163
+ permissions,
164
+ createdAt: serverTimestamp()
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Update instructor (Admin Only)
170
+ */
171
+ export async function updateInstructor(email, data) {
172
+ const instructorRef = doc(db, INSTRUCTORS_COLLECTION, email);
173
+ await updateDoc(instructorRef, data);
174
+ }
175
+
176
+ /**
177
+ * Remove instructor (Admin Only)
178
+ */
179
+ export async function removeInstructor(email) {
180
+ if (email === SUPER_ADMIN_EMAIL) throw new Error("Cannot remove Super Admin");
181
+ await deleteDoc(doc(db, INSTRUCTORS_COLLECTION, email));
182
+ }
src/services/classroom.js ADDED
@@ -0,0 +1,569 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db } from "./firebase.js";
2
+ import {
3
+ collection,
4
+ doc,
5
+ setDoc,
6
+ getDoc,
7
+ addDoc,
8
+ onSnapshot,
9
+ serverTimestamp,
10
+ query,
11
+ where,
12
+ getDocs,
13
+ orderBy,
14
+ updateDoc,
15
+ getCountFromServer,
16
+ deleteDoc
17
+ } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
18
+
19
+ // Collection references
20
+ const ROOMS_COLLECTION = "classrooms";
21
+ const USERS_COLLECTION = "users";
22
+ const PROGRESS_COLLECTION = "progress";
23
+ const CHALLENGES_COLLECTION = "challenges";
24
+
25
+ /**
26
+ * Creates a new classroom room
27
+ * @param {string} promotedCode - Optional custom code
28
+ * @param {string} hostName - Optional host name
29
+ * @returns {Promise<string>} The room code
30
+ */
31
+ export async function createRoom(promotedCode = null, hostName = 'Unknown') {
32
+ const roomCode = promotedCode || Math.floor(1000 + Math.random() * 9000).toString();
33
+ const roomRef = doc(db, ROOMS_COLLECTION, roomCode);
34
+
35
+ await setDoc(roomRef, {
36
+ createdAt: serverTimestamp(),
37
+ active: true,
38
+ host: hostName
39
+ });
40
+
41
+ return roomCode;
42
+ }
43
+
44
+ /**
45
+ * Cleans up rooms older than 14 days (inactive)
46
+ */
47
+ export async function cleanupOldRooms() {
48
+ try {
49
+ const fourteenDaysAgo = new Date();
50
+ fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
51
+
52
+ const roomsRef = collection(db, ROOMS_COLLECTION);
53
+ const q = query(roomsRef, where("createdAt", "<", fourteenDaysAgo));
54
+
55
+ const snapshot = await getDocs(q);
56
+ if (snapshot.empty) return;
57
+
58
+ const deletePromises = snapshot.docs.map(doc => deleteDoc(doc.ref));
59
+ await Promise.all(deletePromises);
60
+ console.log(`Cleaned up ${snapshot.size} old rooms.`);
61
+ } catch (e) {
62
+ console.error("Cleanup failed:", e);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Verifies instructor password against Firestore (Auto-seeds if missing)
68
+ * @param {string} inputPassword
69
+ * @returns {Promise<boolean>}
70
+ */
71
+ export async function verifyInstructorPassword(inputPassword) {
72
+ const settingsRef = doc(db, "settings", "instructor_auth");
73
+ const snap = await getDoc(settingsRef);
74
+
75
+ if (!snap.exists()) {
76
+ // Auto-seed default password for migration
77
+ await setDoc(settingsRef, { password: "88300" });
78
+ return inputPassword === "88300";
79
+ }
80
+
81
+ return snap.data().password === inputPassword;
82
+ }
83
+
84
+ /**
85
+ * Joins a room with Dual-Role Auth / Session Persistence logic
86
+ * @param {string} roomCode
87
+ * @param {string} nickname
88
+ * @returns {Promise<string>} The student ID (userId)
89
+ */
90
+ export async function joinRoom(roomCode, nickname) {
91
+ // 1. Verify Room Exists
92
+ const roomRef = doc(db, ROOMS_COLLECTION, roomCode);
93
+ const roomSnap = await getDoc(roomRef);
94
+
95
+ if (!roomSnap.exists()) {
96
+ throw new Error("教室代碼不存在");
97
+ }
98
+
99
+ // 2. Check if user already exists in this room (Cross-device sync)
100
+ const usersRef = collection(db, USERS_COLLECTION);
101
+ const q = query(
102
+ usersRef,
103
+ where("current_room", "==", roomCode),
104
+ where("nickname", "==", nickname)
105
+ );
106
+
107
+ const querySnapshot = await getDocs(q);
108
+
109
+ if (!querySnapshot.empty) {
110
+ // User exists, return existing ID
111
+ const userDoc = querySnapshot.docs[0];
112
+ // Update last active
113
+ await updateDoc(userDoc.ref, { last_active: serverTimestamp() });
114
+ return userDoc.id;
115
+ }
116
+
117
+ // 3. Create new user if not exists
118
+ const newUserRef = await addDoc(usersRef, {
119
+ nickname,
120
+ current_room: roomCode,
121
+ role: 'student',
122
+ joinedAt: serverTimestamp(),
123
+ last_active: serverTimestamp()
124
+ });
125
+
126
+ return newUserRef.id;
127
+ }
128
+
129
+ /**
130
+ * Submits a prompt for a specific level
131
+ * @param {string} userId
132
+ * @param {string} roomCode
133
+ * @param {string} challengeId
134
+ * @param {string} prompt
135
+ */
136
+ export async function submitPrompt(userId, roomCode, challengeId, prompt) {
137
+ const text = prompt.trim();
138
+ if (!text) return;
139
+
140
+ // Check if submission already exists to update it, or add new
141
+ // For simplicity, we can just use a composite ID or query first.
142
+ // Let's use addDoc for history, or setDoc with custom ID for latest state.
143
+ // Requirement says "progress/{docId}", let's query first to see if we should update.
144
+
145
+ const progressRef = collection(db, PROGRESS_COLLECTION);
146
+ const q = query(
147
+ progressRef,
148
+ where("userId", "==", userId),
149
+ where("challengeId", "==", challengeId)
150
+ );
151
+ const snapshot = await getDocs(q);
152
+
153
+ if (!snapshot.empty) {
154
+ // Update existing
155
+ const docRef = snapshot.docs[0].ref;
156
+ await updateDoc(docRef, {
157
+ status: "completed",
158
+ submission_prompt: text,
159
+ roomCode: roomCode, // Ensure roomCode is updated for legacy docs
160
+ timestamp: serverTimestamp()
161
+ });
162
+ } else {
163
+ // Create new
164
+ await addDoc(progressRef, {
165
+ userId,
166
+ roomCode, // Added for easier querying by instructor
167
+ challengeId,
168
+ status: "completed",
169
+ submission_prompt: text,
170
+ timestamp: serverTimestamp()
171
+ });
172
+ }
173
+
174
+ // Update user last active
175
+ const userRef = doc(db, USERS_COLLECTION, userId);
176
+ await updateDoc(userRef, { last_active: serverTimestamp() });
177
+ }
178
+
179
+ /**
180
+ * Records that a user has started a challenge
181
+ * @param {string} userId
182
+ * @param {string} roomCode
183
+ * @param {string} challengeId
184
+ */
185
+ export async function startChallenge(userId, roomCode, challengeId) {
186
+ const progressRef = collection(db, PROGRESS_COLLECTION);
187
+ const q = query(
188
+ progressRef,
189
+ where("userId", "==", userId),
190
+ where("challengeId", "==", challengeId)
191
+ );
192
+ const snapshot = await getDocs(q);
193
+
194
+ if (!snapshot.empty) {
195
+ // Already exists (maybe started or completed)
196
+ const docData = snapshot.docs[0].data();
197
+ if (docData.status === 'completed') return; // Don't overwrite completed status
198
+
199
+ // If already started, maybe just update timestamp? Or do nothing.
200
+ // Let's update timestamp to reflect "last worked on"
201
+ await updateDoc(snapshot.docs[0].ref, {
202
+ status: 'started',
203
+ roomCode: String(roomCode), // Ensure roomCode is updated
204
+ timestamp: serverTimestamp()
205
+ });
206
+ } else {
207
+ // Create new progress entry with 'started' status
208
+ await addDoc(progressRef, {
209
+ userId,
210
+ roomCode: String(roomCode),
211
+ challengeId,
212
+ status: 'started',
213
+ startedAt: serverTimestamp(), // Keep original start time if we want to track duration
214
+ timestamp: serverTimestamp() // Last update time
215
+ });
216
+ }
217
+
218
+ // Update user last active
219
+ const userRef = doc(db, USERS_COLLECTION, userId);
220
+ await updateDoc(userRef, { last_active: serverTimestamp() });
221
+ }
222
+
223
+ /**
224
+ * Subscribes to room users and their progress
225
+ * @param {string} roomCode
226
+ * @param {Function} callback (studentsWithProgress) => void
227
+ * @returns {Function} Unsubscribe function
228
+ */
229
+ export function subscribeToRoom(roomCode, callback) {
230
+ // Listen to users in the room
231
+ const usersQuery = query(collection(db, USERS_COLLECTION), where("current_room", "==", roomCode));
232
+
233
+ // We also need progress. Real-time listening to TWO collections and joining them
234
+ // is complex in NoSQL.
235
+ // Strategy: Listen to Users. When Users change, fetch all progress for this room (or listen to it).
236
+ // Simpler efficient approach for dashboard:
237
+ // Listen to Progress independent of users? No, we need user list.
238
+
239
+ // Let's listen to Users, and inside, listen to Progress for this room.
240
+
241
+ let unsubscribeProgress = () => { };
242
+
243
+ const unsubscribeUsers = onSnapshot(usersQuery, (userSnap) => {
244
+ const users = [];
245
+ userSnap.forEach((doc) => {
246
+ users.push({ id: doc.id, ...doc.data() });
247
+ });
248
+
249
+ // Now listen to progress for this room
250
+ const progressQuery = query(collection(db, PROGRESS_COLLECTION), where("roomCode", "==", roomCode));
251
+
252
+ unsubscribeProgress(); // Detach previous listener if any
253
+
254
+ unsubscribeProgress = onSnapshot(progressQuery, (progressSnap) => {
255
+ const progressMap = {}; // { userId: { challengeId: { status, prompt } } }
256
+
257
+ progressSnap.forEach(doc => {
258
+ const data = doc.data();
259
+ if (!progressMap[data.userId]) progressMap[data.userId] = {};
260
+ progressMap[data.userId][data.challengeId] = {
261
+ status: data.status,
262
+ prompt: data.submission_prompt,
263
+ timestamp: data.timestamp,
264
+ likes: data.likes || 0 // Include likes
265
+ };
266
+ });
267
+
268
+ // Merge back to users
269
+ const combinedData = users.map(user => ({
270
+ ...user,
271
+ progress: progressMap[user.id] || {}
272
+ }));
273
+
274
+ callback(combinedData);
275
+ });
276
+ });
277
+
278
+ return () => {
279
+ unsubscribeUsers();
280
+ unsubscribeProgress();
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Fetches prompts for Peer Learning
286
+ */
287
+ export async function getPeerPrompts(roomCode, challengeId) {
288
+ const progressRef = collection(db, PROGRESS_COLLECTION);
289
+
290
+ // Query completions (filtered by challenge, status)
291
+ const q = query(
292
+ progressRef,
293
+ where("challengeId", "==", challengeId),
294
+ where("status", "==", "completed")
295
+ );
296
+
297
+ try {
298
+ const snapshot = await getDocs(q);
299
+ const entries = [];
300
+
301
+ for (const docSnapshot of snapshot.docs) {
302
+ const data = docSnapshot.data();
303
+
304
+ // Fetch nickname & user room info
305
+ // We fetch user first to verify room if needed
306
+ const userSnap = await getDoc(doc(db, USERS_COLLECTION, data.userId));
307
+ if (!userSnap.exists()) continue;
308
+
309
+ const userData = userSnap.data();
310
+
311
+ // Room Code Check
312
+ // 1. Check direct reference in progress (Fastest)
313
+ // 2. Fallback to user's current room (Legacy support)
314
+ const targetRoom = data.roomCode || userData.current_room;
315
+
316
+ if (String(targetRoom) !== String(roomCode)) {
317
+ continue;
318
+ }
319
+
320
+ entries.push({
321
+ id: docSnapshot.id,
322
+ userId: data.userId,
323
+ nickname: userData.nickname,
324
+ prompt: data.submission_prompt,
325
+ timestamp: data.timestamp,
326
+ likes: data.likes || 0,
327
+ likedBy: data.likedBy || []
328
+ });
329
+ }
330
+ return entries;
331
+
332
+ } catch (e) {
333
+ console.error("Error fetching peer prompts:", e);
334
+ return [];
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Resets a user's progress for a specific challenge (sets status to 'started')
340
+ * @param {string} userId
341
+ * @param {string} roomCode
342
+ * @param {string} challengeId
343
+ */
344
+ export async function resetProgress(userId, roomCode, challengeId) {
345
+ const progressRef = collection(db, PROGRESS_COLLECTION);
346
+ const q = query(
347
+ progressRef,
348
+ where("userId", "==", userId),
349
+ where("challengeId", "==", challengeId)
350
+ );
351
+ const snapshot = await getDocs(q);
352
+
353
+ if (!snapshot.empty) {
354
+ // Reset status to 'started' so they can submit again
355
+ await updateDoc(snapshot.docs[0].ref, {
356
+ status: 'started',
357
+ timestamp: serverTimestamp()
358
+ });
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Validates and gets progress for a specific user
364
+ * @param {string} userId
365
+ * @returns {Promise<Object>} Map of challengeId -> { status, prompt, timestamp }
366
+ */
367
+ export async function getUserProgress(userId) {
368
+ const progressRef = collection(db, PROGRESS_COLLECTION);
369
+ const q = query(progressRef, where("userId", "==", userId));
370
+ const snapshot = await getDocs(q);
371
+
372
+ const progressMap = {};
373
+ snapshot.forEach(doc => {
374
+ const data = doc.data();
375
+ progressMap[data.challengeId] = data;
376
+ });
377
+ return progressMap;
378
+ }
379
+
380
+ // --- Admin / Challenge Services ---
381
+
382
+ export async function getChallenges() {
383
+ const q = query(collection(db, CHALLENGES_COLLECTION), orderBy("order", "asc"));
384
+ const snapshot = await getDocs(q);
385
+ return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
386
+ }
387
+
388
+ export async function createChallenge(data) {
389
+ // data: { level, title, description, link, order }
390
+ await addDoc(collection(db, CHALLENGES_COLLECTION), data);
391
+ }
392
+
393
+ export async function updateChallenge(id, data) {
394
+ await updateDoc(doc(db, CHALLENGES_COLLECTION, id), data);
395
+ }
396
+
397
+ export async function deleteChallenge(id) {
398
+ await deleteDoc(doc(db, CHALLENGES_COLLECTION, id));
399
+ }
400
+
401
+ // --- Social Features ---
402
+
403
+ const NOTIFICATIONS_COLLECTION = "notifications";
404
+
405
+ /**
406
+ * Toggles like on a progress submission
407
+ */
408
+ export async function toggleLike(progressId, currentUserId, currentNickname, targetUserId, challengeTitle) {
409
+ const progressRef = doc(db, PROGRESS_COLLECTION, progressId);
410
+ const progressSnap = await getDoc(progressRef);
411
+
412
+ if (!progressSnap.exists()) return;
413
+
414
+ const data = progressSnap.data();
415
+ const likedBy = data.likedBy || [];
416
+ const isLiked = likedBy.includes(currentUserId);
417
+
418
+ if (isLiked) {
419
+ // Unlike
420
+ await updateDoc(progressRef, {
421
+ likes: (data.likes || 1) - 1,
422
+ likedBy: likedBy.filter(id => id !== currentUserId)
423
+ });
424
+ } else {
425
+ // Like
426
+ await updateDoc(progressRef, {
427
+ likes: (data.likes || 0) + 1,
428
+ likedBy: [...likedBy, currentUserId]
429
+ });
430
+
431
+ // Send Notification if not liking self
432
+ if (targetUserId !== currentUserId) {
433
+ await addDoc(collection(db, NOTIFICATIONS_COLLECTION), {
434
+ recipientId: targetUserId,
435
+ senderNickname: currentNickname,
436
+ challengeTitle: challengeTitle,
437
+ type: 'like',
438
+ timestamp: serverTimestamp(),
439
+ read: false
440
+ });
441
+ }
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Listen to notifications for a user
447
+ */
448
+ export function subscribeToNotifications(userId, callback) {
449
+ const q = query(
450
+ collection(db, NOTIFICATIONS_COLLECTION),
451
+ where("recipientId", "==", userId),
452
+ where("read", "==", false),
453
+ orderBy("timestamp", "desc")
454
+ );
455
+
456
+ return onSnapshot(q, (snapshot) => {
457
+ const notifications = [];
458
+ snapshot.forEach(doc => {
459
+ notifications.push({ id: doc.id, ...doc.data() });
460
+ });
461
+ callback(notifications);
462
+ });
463
+ }
464
+
465
+ /**
466
+ * Mark notification as read
467
+ */
468
+ export async function markNotificationRead(notificationId) {
469
+ await updateDoc(doc(db, NOTIFICATIONS_COLLECTION, notificationId), {
470
+ read: true
471
+ });
472
+ }
473
+
474
+ /**
475
+ * Gets the number of users in a room
476
+ * @param {string} roomCode
477
+ * @returns {Promise<number>}
478
+ */
479
+ /**
480
+ * Gets the number of users in a room
481
+ * @param {string} roomCode
482
+ * @returns {Promise<number>}
483
+ */
484
+ export async function getClassSize(roomCode) {
485
+ const q = query(
486
+ collection(db, USERS_COLLECTION),
487
+ where("current_room", "==", roomCode)
488
+ );
489
+ const snapshot = await getCountFromServer(q);
490
+ return snapshot.data().count;
491
+ }
492
+
493
+ /**
494
+ * Gets the number of students who have reached a higher or equal stage
495
+ * Used for determining percentile ranking
496
+ * @param {string} roomCode
497
+ * @param {number} targetStage
498
+ * @returns {Promise<number>}
499
+ */
500
+ export async function getHigherStageCount(roomCode, targetStage) {
501
+ const q = query(
502
+ collection(db, USERS_COLLECTION),
503
+ where("current_room", "==", roomCode),
504
+ where("monster_stage", ">=", targetStage)
505
+ );
506
+ const snapshot = await getCountFromServer(q);
507
+ return snapshot.data().count;
508
+ }
509
+
510
+ /**
511
+ * Updates a student's monster stage and specific form
512
+ * @param {string} userId
513
+ * @param {number} newStage
514
+ * @param {string} monsterId (Optional, for persisting specific form)
515
+ */
516
+ export async function updateUserMonster(userId, newStage, monsterId = undefined) {
517
+ const userRef = doc(db, USERS_COLLECTION, userId);
518
+ const data = { monster_stage: newStage };
519
+ // If monsterId is explicitly provided (including null/empty string), update it.
520
+ // If undefined, leave as is.
521
+ // We want to allow clearing it (null) or setting 'Egg'.
522
+ if (monsterId !== undefined) {
523
+ data.monster_id = monsterId;
524
+ }
525
+
526
+ await updateDoc(userRef, data);
527
+ }
528
+
529
+ /**
530
+ * Gets user profile data
531
+ * @param {string} userId
532
+ * @returns {Promise<Object>}
533
+ */
534
+ export async function getUser(userId) {
535
+ const userRef = doc(db, USERS_COLLECTION, userId);
536
+ const snap = await getDoc(userRef);
537
+ return snap.exists() ? snap.data() : null;
538
+ }
539
+
540
+ /**
541
+ * Removes a user from the classroom (Kick)
542
+ * @param {string} userId
543
+ */
544
+ /**
545
+ * Subscribes to a single user's progress for real-time updates
546
+ * @param {string} userId
547
+ * @param {Function} callback (progressMap) => void
548
+ * @returns {Function} unsubscribe
549
+ */
550
+ export function subscribeToUserProgress(userId, callback) {
551
+ const q = query(
552
+ collection(db, PROGRESS_COLLECTION),
553
+ where("userId", "==", userId)
554
+ );
555
+
556
+ return onSnapshot(q, (snapshot) => {
557
+ const progressMap = {};
558
+ snapshot.forEach(doc => {
559
+ const data = doc.data();
560
+ progressMap[data.challengeId] = data;
561
+ });
562
+ callback(progressMap);
563
+ });
564
+ }
565
+
566
+ export async function removeUser(userId) {
567
+ if (!userId) return;
568
+ await deleteDoc(doc(db, USERS_COLLECTION, userId));
569
+ }
src/services/firebase.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js";
2
+ import { getFirestore } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
3
+ import { getAuth, GoogleAuthProvider } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
4
+
5
+ const firebaseConfig = {
6
+ apiKey: "AIzaSyDq2a-yE6pbaeNf6KzkTcogh9oi-6nQbKk",
7
+ authDomain: "vibecodingex.firebaseapp.com",
8
+ projectId: "vibecodingex",
9
+ storageBucket: "vibecodingex.firebasestorage.app",
10
+ messagingSenderId: "831513336128",
11
+ appId: "1:831513336128:web:e31d2654fcfa19fb2642a5"
12
+ };
13
+
14
+ const app = initializeApp(firebaseConfig);
15
+ const db = getFirestore(app);
16
+ const auth = getAuth(app);
17
+ const googleProvider = new GoogleAuthProvider();
18
+
19
+ export { db, auth, googleProvider };
src/utils/monsterUtils.js ADDED
@@ -0,0 +1,532 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Logic for generating Pixel Art Monsters
3
+ * Adapted from monster_preview.html
4
+ */
5
+
6
+ export const MONSTER_STAGES = {
7
+ EGG: 0,
8
+ BASIC: 1,
9
+ EVOLVED: 2,
10
+ FINAL: 3
11
+ };
12
+
13
+ // Monster Definitions
14
+ export const MONSTER_DEFS = [
15
+ { id: 'Egg', name: '🥚 像素蛋', type: 'neutral', stage: 0, arch: 'egg', fam: 'Origin' },
16
+ // Stage 1
17
+ { id: 'L1_C', name: '🔴 灰塵球 Dust-Ball', type: 'glitch', stage: 1, arch: 'dust', fam: 'Dust' },
18
+ { id: 'L1_B', name: '🟡 像素狗 Pixel-Pup', type: 'animal', stage: 1, arch: 'beast_pup', fam: 'Beast' },
19
+ { id: 'L1_A', name: '🔵 光之靈 Lumina', type: 'holy', stage: 1, arch: 'spirit_orb', fam: 'Spirit' },
20
+ // Stage 2
21
+ { id: 'L2_CC', name: '🔴 垃圾怪 Trash-Mob', type: 'glitch', stage: 2, arch: 'dust_trash', fam: 'Dust' },
22
+ { id: 'L2_CB', name: '🟡 史萊姆 Slime-Box', type: 'slime', stage: 2, arch: 'dust_slime', fam: 'Dust' },
23
+ { id: 'L2_CA', name: '🔵 駭客蟲 Hacker-Bug', type: 'tech', stage: 2, arch: 'dust_tech', fam: 'Dust' },
24
+ { id: 'L2_BC', name: '🔴 廢鐵狼 Rusty-Wolf', type: 'grunge', stage: 2, arch: 'beast_wolf', fam: 'Beast' },
25
+ { id: 'L2_BB', name: '🟡 勇者貓 Hero-Cat', type: 'animal', stage: 2, arch: 'beast_cat', fam: 'Beast' },
26
+ { id: 'L2_BA', name: '🔵 機甲獅 Mecha-Lion', type: 'mech', stage: 2, arch: 'beast_mech', fam: 'Beast' },
27
+ { id: 'L2_AC', name: '🔴 幽靈火 Ghost-Fire', type: 'spirit', stage: 2, arch: 'spirit_fire', fam: 'Spirit' },
28
+ { id: 'L2_AB', name: '🟡 天使鳥 Angel-Bird', type: 'holy', stage: 2, arch: 'spirit_wing', fam: 'Spirit' },
29
+ { id: 'L2_AA', name: '🔵 星雲龍 Cosmos-Dragon', type: 'cosmic', stage: 2, arch: 'spirit_dragon', fam: 'Spirit' },
30
+ // Stage 3
31
+ { id: 'L3_CCC', name: '🗑️ 崩潰垃圾山', type: 'glitch', stage: 3, arch: 'pile_big', fam: 'Glitch/Trash' },
32
+ { id: 'L3_CCB', name: '📦 寶箱怪', type: 'mimic', stage: 3, arch: 'box_teeth', fam: 'Glitch/Trash' },
33
+ { id: 'L3_CCA', name: '🦠 病毒王', type: 'virus', stage: 3, arch: 'corona', fam: 'Glitch/Trash' },
34
+ { id: 'L3_CBC', name: '💧 汙泥怪', type: 'grunge', stage: 3, arch: 'fluid', fam: 'Slime' },
35
+ { id: 'L3_CBB', name: '🧊 果凍騎士', type: 'slime', stage: 3, arch: 'knight_slime', fam: 'Slime' },
36
+ { id: 'L3_CBA', name: '💎 鑽石魔像', type: 'crystal', stage: 3, arch: 'golem', fam: 'Slime' },
37
+ { id: 'L3_CAC', name: '🕷️ 錯誤蜘蛛', type: 'glitch', stage: 3, arch: 'spider', fam: 'Hacker' },
38
+ { id: 'L3_CAB', name: '👾 程式遊俠', type: 'tech', stage: 3, arch: 'ranger', fam: 'Hacker' },
39
+ { id: 'L3_CAA', name: '🧠 量子主腦', type: 'tech', stage: 3, arch: 'brain', fam: 'Hacker' },
40
+ { id: 'L3_BAC', name: '🚜 重裝推土機', type: 'mech', stage: 3, arch: 'tank', fam: 'Mech Lion' },
41
+ { id: 'L3_BAB', name: '✈️ 變形戰機', type: 'mech', stage: 3, arch: 'jet', fam: 'Mech Lion' },
42
+ { id: 'L3_BAA', name: '🤖 究極鋼彈', type: 'mech', stage: 3, arch: 'gundam', fam: 'Mech Lion' },
43
+ { id: 'L3_ACC', name: '💀 骷髏法師', type: 'undead', stage: 3, arch: 'mage_skull', fam: 'Ghost' },
44
+ { id: 'L3_ACB', name: '🕯️ 南瓜燈杰克', type: 'spirit', stage: 3, arch: 'pumpkin', fam: 'Ghost' },
45
+ { id: 'L3_ACA', name: '👻 虛空死神', type: 'void', stage: 3, arch: 'reaper', fam: 'Ghost' },
46
+ // Stage 3 - REDESIGN TARGETS (Wolf V2 & Cat)
47
+ { id: 'L3_BCC', name: '🧟 喪屍犬', type: 'undead', stage: 3, arch: 'wolf_zombie_v2', fam: 'Wolf' },
48
+ { id: 'L3_BCB', name: '🐕 警備犬', type: 'animal', stage: 3, arch: 'wolf_guard_v2', fam: 'Wolf' },
49
+ { id: 'L3_BCA', name: '🐺 合金戰狼', type: 'mech', stage: 3, arch: 'wolf_alloy_v2', fam: 'Wolf' },
50
+ { id: 'L3_BBC', name: '😼 流浪劍客', type: 'warrior', stage: 3, arch: 'ronin', fam: 'Cat' },
51
+ { id: 'L3_BBB', name: '👑 貓咪國王', type: 'royal', stage: 3, arch: 'king_cat', fam: 'Cat' },
52
+ { id: 'L3_BBA', name: '🦁 雷霆獅王', type: 'elemental', stage: 3, arch: 'lion_mane', fam: 'Cat' },
53
+ // Stage 3 - NEW ADDITIONS (Angel & Cosmos)
54
+ { id: 'L3_ABC', name: '👁️ 座天使 Ophanim', type: 'holy', stage: 3, arch: 'biblical_angel', fam: 'Angel' },
55
+ { id: 'L3_ABB', name: '⚔️ 女武神 Valkyrie', type: 'warrior', stage: 3, arch: 'valkyrie', fam: 'Angel' },
56
+ { id: 'L3_ABA', name: '🔥 聖火鳳凰 Phoenix', type: 'divine', stage: 3, arch: 'phoenix', fam: 'Angel' },
57
+ { id: 'L3_AAC', name: '🛡️ 大地泰坦 Titan', type: 'cosmic', stage: 3, arch: 'terra_titan', fam: 'Cosmos' },
58
+ { id: 'L3_AAB', name: '🐋 星海鯨 Astro-Whale', type: 'cosmic', stage: 3, arch: 'cosmo_whale', fam: 'Cosmos' },
59
+ { id: 'L3_AAA', name: '🌌 創世龍 Genesis', type: 'god', stage: 3, arch: 'genesis_dragon', fam: 'Cosmos' },
60
+ ];
61
+
62
+ const palettes = {
63
+ base: ['#94A3B8', '#64748B', '#475569', '#334155'],
64
+
65
+ // Standard
66
+ glitch: ['#18181b', '#27272a', '#3f3f46', '#ef4444'],
67
+ trash: ['#3f3f46', '#71717a', '#a1a1aa', '#ef4444'],
68
+ mimic: ['#854d0e', '#a16207', '#ca8a04', '#eab308'],
69
+ virus: ['#2e1065', '#581c87', '#7e22ce', '#bef264', '#84cc16'],
70
+ spider: ['#1e293b', '#0f172a', '#b91c1c', '#ef4444'],
71
+ slime: ['#059669', '#10b981', '#34d399', '#d1fae5'],
72
+ fluid: ['#3f3f46', '#52525b', '#4b5563', '#a1a1aa'],
73
+ knight: ['#0ea5e9', '#38bdf8', '#7dd3fc', '#ffffff'],
74
+ crypto: ['#0891b2', '#06b6d4', '#22d3ee', '#cffafe'],
75
+ tech: ['#22d3ee', '#06b6d4', '#0891b2', '#155e75'],
76
+ ranger: ['#3b82f6', '#2563eb', '#1d4ed8', '#fbbf24'],
77
+ brain: ['#d8b4fe', '#c084fc', '#a855f7', '#7e22ce', '#22d3ee'],
78
+ tank: ['#3f3f46', '#18181b', '#facc15', '#fbbf24'],
79
+ jet: ['#cbd5e1', '#94a3b8', '#ef4444', '#dc2626'],
80
+ gundam: ['#ffffff', '#2563eb', '#dc2626', '#facc15'],
81
+ mage: ['#4c1d95', '#a855f7', '#172554', '#fbbf24'],
82
+ pumpkin: ['#ea580c', '#c2410c', '#3f3f46', '#facc15'],
83
+ reaper: ['#000000', '#171717', '#7f1d1d', '#9ca3af'],
84
+
85
+ // Wolf V2
86
+ wolf: ['#c2410c', '#9a3412', '#7c2d12', '#ef4444'], // Rust/Browns
87
+ zombie: ['#44403c', '#78716c', '#991b1b', '#fecaca'],
88
+ guard: ['#172554', '#1e3a8a', '#fbbf24', '#f59e0b', '#3b82f6'],
89
+ alloy: ['#e2e8f0', '#94a3b8', '#0ea5e9', '#0284c7', '#38bdf8', '#ffffff'], // Added white/brights
90
+
91
+ // Cat V2
92
+ cat: ['#fb923c', '#ea580c', '#c2410c', '#fff7ed'],
93
+ ronin: ['#c2410c', '#9a3412', '#fde047', '#e2e8f0'],
94
+ king: ['#7e22ce', '#a855f7', '#fbbf24', '#ffffff'],
95
+ thunder: ['#facc15', '#eab308', '#fefce8', '#f59e0b', '#ffffff'],
96
+
97
+ // Base Fams
98
+ beast: ['#fbbf24', '#f59e0b', '#d97706', '#78350f'],
99
+ mech_lion: ['#60a5fa', '#3b82f6', '#1d4ed8', '#93c5fd'],
100
+ holy: ['#fef3c7', '#fde68a', '#f59e0b', '#ffffff'],
101
+ fire: ['#c084fc', '#a855f7', '#7e22ce', '#ffffff'],
102
+ space: ['#4f46e5', '#312e81', '#818cf8', '#f472b6', '#22d3ee'],
103
+
104
+ // Angel V3
105
+ biblical: ['#facc15', '#eab308', '#ffffff', '#ef4444', '#3b82f6'], // Gold + Eyes
106
+ valkyrie: ['#e2e8f0', '#94a3b8', '#facc15', '#38bdf8', '#fbbf24'], // Silver/Gold
107
+ seraphim: ['#dc2626', '#ef4444', '#fbbf24', '#facc15', '#ffffff'], // Fire/Gold
108
+
109
+ // Cosmos V3
110
+ meteor: ['#475569', '#334155', '#ea580c', '#f97316'], // Rock/Magma
111
+ whale: ['#1e1b4b', '#312e81', '#6366f1', '#c7d2fe', '#ffffff'], // Deep Space
112
+ genesis: ['#000000', '#171717', '#c084fc', '#22d3ee', '#f472b6', '#ffffff'] // Cosmic
113
+ };
114
+
115
+ function mulberry32(a) {
116
+ return function () {
117
+ var t = a += 0x6D2B79F5;
118
+ t = Math.imul(t ^ (t >>> 15), t | 1);
119
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
120
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Generates an SVG string of the monster
126
+ * @param {Object} monster The monster object (from MONSTER_DEFS)
127
+ * @returns {string} SVG string
128
+ */
129
+ export function generateMonsterSVG(monster) {
130
+ // If no monster provided, return empty
131
+ if (!monster) return '';
132
+
133
+ const W = 24; const H = 24; const C = W / 2;
134
+
135
+ let seed = 0;
136
+ // Use monster ID as seed source
137
+ for (let i = 0; i < monster.id.length; i++) seed += monster.id.charCodeAt(i);
138
+ const rng = mulberry32(seed);
139
+
140
+ let palette = palettes.base;
141
+ const t = monster.type;
142
+ const a = monster.arch;
143
+
144
+ // Palette Select (Simplified Logic from original)
145
+ if (a.includes('wolf')) {
146
+ if (a.includes('zombie')) palette = palettes.zombie;
147
+ else if (a.includes('guard')) palette = palettes.guard;
148
+ else if (a.includes('alloy')) palette = palettes.alloy;
149
+ else palette = palettes.wolf;
150
+ }
151
+ else if (a === 'ronin') palette = palettes.ronin;
152
+ else if (a === 'king_cat') palette = palettes.king;
153
+ else if (a === 'lion_mane') palette = palettes.thunder;
154
+ else if (a === 'tank') palette = palettes.tank;
155
+ else if (a === 'jet') palette = palettes.jet;
156
+ else if (a === 'gundam') palette = palettes.gundam;
157
+ else if (a === 'mage_skull') palette = palettes.mage;
158
+ else if (a === 'pumpkin') palette = palettes.pumpkin;
159
+ else if (a === 'reaper') palette = palettes.reaper;
160
+ else if (a === 'biblical_angel') palette = palettes.biblical;
161
+ else if (a === 'valkyrie') palette = palettes.valkyrie;
162
+ else if (a === 'phoenix') palette = palettes.seraphim;
163
+ else if (a === 'terra_titan') palette = palettes.meteor;
164
+ else if (a === 'cosmo_whale') palette = palettes.whale;
165
+ else if (a === 'genesis_dragon') palette = palettes.genesis;
166
+ else if (a === 'beast_pup') palette = palettes.beast;
167
+ else if (a === 'beast_cat') palette = palettes.cat;
168
+ else if (a === 'beast_mech') palette = palettes.mech_lion;
169
+ else if (a === 'spirit_orb') palette = palettes.holy;
170
+ else if (a === 'spirit_fire') palette = palettes.fire;
171
+ else if (a === 'spirit_wing') palette = palettes.holy;
172
+ else if (a === 'spirit_dragon') palette = palettes.space;
173
+
174
+ // Safety Fallbacks
175
+ else if (a.includes('dust')) {
176
+ if (a === 'dust_trash' || a === 'pile_big' || a === 'box_teeth' || a === 'corona') palette = palettes.glitch;
177
+ else if (a.includes('slime') || a === 'fluid' || a === 'knight_slime' || a === 'golem') palette = palettes.slime;
178
+ else if (a.includes('tech') || a === 'spider' || a === 'ranger' || a === 'brain') palette = palettes.tech;
179
+ else palette = palettes.trash; // Generic
180
+ }
181
+ // Specific Overrides
182
+ if (a === 'pile_big') palette = palettes.trash;
183
+ if (a === 'box_teeth') palette = palettes.mimic;
184
+ if (a === 'corona') palette = palettes.virus;
185
+ if (a === 'fluid') palette = palettes.fluid;
186
+ if (a === 'knight_slime') palette = palettes.knight;
187
+ if (a === 'golem') palette = palettes.crypto;
188
+ if (a === 'spider') palette = palettes.spider;
189
+ if (a === 'ranger') palette = palettes.ranger;
190
+ if (a === 'brain') palette = palettes.brain;
191
+
192
+ let grid = new Array(H).fill(0).map(() => new Array(W).fill(null));
193
+
194
+ const rect = (x, y, w, h, col) => {
195
+ for (let i = Math.max(0, Math.floor(x)); i < Math.min(W, x + w); i++)
196
+ for (let j = Math.max(0, Math.floor(y)); j < Math.min(H, y + h); j++)
197
+ grid[j][i] = col;
198
+ }
199
+ const circle = (cx, cy, r, col) => {
200
+ for (let y = 0; y < H; y++)
201
+ for (let x = 0; x < W; x++)
202
+ if (Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) < r) grid[y][x] = col;
203
+ }
204
+ const line = (x1, y1, x2, y2, col) => {
205
+ let dx = Math.abs(x2 - x1), sx = x1 < x2 ? 1 : -1;
206
+ let dy = -Math.abs(y2 - y1), sy = y1 < y2 ? 1 : -1;
207
+ let err = dx + dy, e2;
208
+ while (true) {
209
+ if (y1 >= 0 && y1 < H && x1 >= 0 && x1 < W) grid[y1][x1] = col;
210
+ if (x1 == x2 && y1 == y2) break;
211
+ const e2_val = 2 * err;
212
+ if (e2_val >= dy) { err += dy; x1 += sx; }
213
+ if (e2_val <= dx) { err += dx; y1 += sy; }
214
+ }
215
+ }
216
+
217
+ const mainCol = palette[Math.floor(rng() * (palette.length - 1))];
218
+ const secCol = palette[Math.floor(rng() * (palette.length - 2))] || palette[1];
219
+ const thirdCol = palette[palette.length - 1];
220
+
221
+ // --- DRAWING LOGIC ---
222
+ if (a === 'egg') { circle(C, C, 7, mainCol); circle(C - 2, C - 3, 2, '#ffffff'); rect(C + 1, C + 2, 2, 2, secCol); rect(C - 4, C + 1, 2, 2, secCol); }
223
+ else if (a === 'dust') { circle(C, C, 5, '#1e293b'); for (let i = 0; i < 30; i++) { let angle = rng() * Math.PI * 2; let r = 4 + rng() * 4; rect(C + Math.cos(angle) * r, C + Math.sin(angle) * r, 1, 1, '#334155'); } rect(C - 4, C - 2, 3, 3, '#ffffff'); rect(C + 1, C - 2, 3, 3, '#ffffff'); rect(C - 3, C - 1, 1, 1, '#000000'); rect(C + 2, C - 1, 1, 1, '#000000'); }
224
+ else if (a === 'dust_trash') { circle(C, C, 6, mainCol); for (let i = 0; i < 40; i++) { let s = 7 + rng() * 3; rect(C + (rng() - 0.5) * s * 1.5, C + (rng() - 0.5) * s, 2, 2, i % 2 == 0 ? secCol : thirdCol); } rect(C - 3, C - 3, 3, 3, '#ffffff'); rect(C - 2, C - 2, 1, 1, '#000000'); rect(C + 1, C - 1, 2, 2, '#ffffff'); rect(C + 1, C - 1, 1, 1, '#000000'); }
225
+ else if (a === 'dust_slime') { for (let i = 0; i < 8; i++) circle(C + (rng() - 0.5) * 10, C + 3 + rng() * 3, 3, mainCol); rect(C - 3, C - 3, 2, 2, '#ffffff'); rect(C - 3, C - 1, 2, 2, '#000000'); rect(C + 1, C - 1, 2, 2, '#000000'); }
226
+ else if (a === 'dust_tech') { rect(C - 1, C - 9, 2, 4, secCol); rect(C - 5, C - 7, 10, 2, secCol); rect(C - 6, C + 2, 2, 4, mainCol); rect(C + 4, C + 2, 2, 4, mainCol); rect(C - 4, C - 1, 3, 2, '#a5f3fc'); rect(C + 1, C - 1, 3, 2, '#a5f3fc'); }
227
+ else if (a === 'beast_pup') { rect(C - 5, C - 1, 10, 6, mainCol); rect(C - 6, C - 5, 3, 4, secCol); rect(C + 3, C - 5, 3, 4, secCol); rect(C - 2, C + 1, 4, 3, secCol); rect(C - 1, C + 1, 2, 1, '#000000'); }
228
+ else if (a === 'beast_wolf') { rect(C - 6, C - 2, 12, 6, mainCol); rect(C - 7, C - 6, 5, 5, mainCol); for (let i = 0; i < 10; i++) rect(C - 7 + rng() * 14, C - 4 + rng() * 6, 1, 1, secCol); rect(C - 7, C - 8, 2, 3, secCol); rect(C - 4, C - 8, 2, 3, secCol); rect(C + 6, C, 4, 2, secCol); rect(C - 6, C - 5, 1, 1, '#ef4444'); }
229
+ else if (a === 'beast_cat') { rect(C - 6, C - 2, 12, 6, mainCol); rect(C - 7, C - 6, 5, 5, mainCol); rect(C - 7, C - 8, 2, 2, mainCol); rect(C - 4, C - 8, 2, 2, mainCol); rect(C - 6, C - 3, 1, 1, '#f9a8d4'); rect(C - 2, C - 3, 6, 6, '#ef4444'); }
230
+ else if (a === 'beast_mech') { rect(C - 6, C - 2, 12, 6, secCol); rect(C - 7, C - 6, 5, 5, mainCol); rect(C - 4, C - 8, 2, 6, thirdCol); rect(C - 6, C - 5, 3, 1, '#3b82f6'); }
231
+ else if (a === 'spirit_orb') { circle(C, C, 6, mainCol); for (let t = 0; t < Math.PI * 2; t += 0.4) { let r = 8; rect(C + Math.cos(t) * r, C + Math.sin(t) * r, 1, 1, secCol); } rect(C - 8, C - 2, 3, 4, '#ffffff'); rect(C + 5, C - 2, 3, 4, '#ffffff'); }
232
+ else if (a === 'spirit_fire') { circle(C, C + 1, 5, '#ffffff'); for (let i = 0; i < 40; i++) { let r = 4 + rng() * 6; let t = rng() * Math.PI * 2; let py = C + Math.sin(t) * r - (rng() * 6); let px = C + Math.cos(t) * r; rect(px, py, 2, 2, i % 3 == 0 ? thirdCol : secCol); } rect(C - 3, C - 1, 2, 3, '#000000'); rect(C + 1, C - 1, 2, 3, '#000000'); }
233
+ else if (a === 'spirit_wing') { circle(C, C, 5, mainCol); rect(C - 7, C + 2, 14, 2, secCol); rect(C - 11, C - 4, 2, 2, '#ffffff'); rect(C - 10, C - 5, 2, 4, '#ffffff'); rect(C - 8, C - 3, 4, 4, '#ffffff'); rect(C + 9, C - 4, 2, 2, '#ffffff'); rect(C + 8, C - 5, 2, 4, '#ffffff'); rect(C + 4, C - 3, 4, 4, '#ffffff'); rect(C - 4, C - 9, 8, 1, '#fbbf24'); rect(C - 5, C - 8, 1, 1, '#fbbf24'); rect(C + 4, C - 8, 1, 1, '#fbbf24'); rect(C - 1, C, 2, 2, '#fbbf24'); rect(C - 3, C - 2, 1, 1, '#3b82f6'); rect(C + 2, C - 2, 1, 1, '#3b82f6'); }
234
+ else if (a === 'spirit_dragon') { circle(C, C, 7, '#1e1b4b'); for (let i = 0; i < 20; i++) { let px = C + (rng() - 0.5) * 12; let py = C + (rng() - 0.5) * 10; rect(px, py, 1, 1, i % 3 == 0 ? '#f472b6' : (i % 2 == 0 ? '#22d3ee' : '#ffffff')); } rect(C - 9, C - 5, 4, 4, secCol); rect(C - 8, C - 9, 2, 4, thirdCol); rect(C + 6, C - 2, 6, 2, secCol); rect(C - 4, C - 2, 2, 1, '#22d3ee'); }
235
+
236
+ // --- STAGE 3 ---
237
+ else if (a === 'pile_big') { circle(C, C, 8, '#000000'); for (let i = 0; i < 60; i++) rect(C + (rng() - 0.5) * 16, C + (rng() - 0.5) * 16, 2, 2, '#ef4444'); rect(C - 4, C - 2, 2, 2, '#ffffff'); rect(C - 3, C - 2, 1, 1, '#000000'); rect(C + 4, C, 2, 2, '#ffffff'); rect(C + 4, C, 1, 1, '#000000'); rect(C, C - 5, 3, 3, '#ffffff'); rect(C + 1, C - 5, 1, 1, '#000000'); }
238
+ else if (a === 'box_teeth') { rect(C - 8, C - 6, 16, 14, mainCol); rect(C - 8, C, 16, 2, '#000000'); rect(C - 1, C + 4, 2, 2, '#000000'); rect(C - 6, C + 2, 1, 2, '#ffffff'); rect(C - 2, C + 2, 1, 2, '#ffffff'); rect(C + 2, C + 2, 1, 2, '#ffffff'); rect(C + 5, C + 2, 1, 2, '#ffffff'); rect(C - 4, C - 3, 2, 1, '#ef4444'); rect(C + 2, C - 3, 2, 1, '#ef4444'); }
239
+ else if (a === 'corona') { circle(C, C, 6, mainCol); for (let t = 0; t < 6.28; t += 0.8) rect(C + Math.cos(t) * 10, C + Math.sin(t) * 10, 3, 3, secCol); rect(C - 4, C - 8, 8, 2, '#facc15'); rect(C - 4, C - 10, 2, 2, '#facc15'); rect(C + 2, C - 10, 2, 2, '#facc15'); rect(C - 3, C - 1, 2, 1, '#bef264'); rect(C + 1, C - 1, 2, 1, '#bef264'); rect(C - 2, C + 2, 4, 1, '#000000'); }
240
+ else if (a === 'fluid') { for (let i = 0; i < 4; i++) rect(C - 10 + i * 2, C + 4, 18 - i * 4, 4, mainCol); rect(C - 6, C, 2, 6, mainCol); rect(C + 4, C - 2, 3, 8, mainCol); rect(C - 5, C - 6, 3, 3, secCol); rect(C + 2, C - 5, 2, 2, secCol); rect(C - 2, C + 2, 4, 4, '#ffffff'); rect(C - 1, C + 2, 2, 2, '#000000'); }
241
+ else if (a === 'knight_slime') { rect(C - 3, C - 4, 6, 8, mainCol); rect(C - 4, C - 9, 8, 6, mainCol); rect(C - 2, C - 3, 4, 6, '#e0f2fe'); rect(C + 4, C, 8, 2, secCol); rect(C + 4, C - 2, 2, 6, secCol); rect(C - 7, C - 2, 4, 8, thirdCol); rect(C - 3, C - 7, 6, 1, '#000000'); }
242
+ else if (a === 'golem') { rect(C - 4, C - 4, 8, 8, mainCol); rect(C - 8, C - 8, 4, 4, secCol); rect(C + 4, C - 8, 4, 4, secCol); rect(C - 6, C + 4, 4, 6, secCol); rect(C + 2, C + 4, 4, 6, secCol); rect(C - 2, C - 10, 4, 4, thirdCol); rect(C - 3, C - 3, 2, 2, '#ffffff'); rect(C + 5, C - 7, 1, 1, '#ffffff'); rect(C - 1, C - 9, 2, 1, '#000000'); }
243
+ else if (a === 'spider') { rect(C - 4, C - 4, 8, 8, mainCol); rect(C - 5, C - 5, 10, 2, secCol); line(C - 4, C, C - 10, C - 4, secCol); line(C - 4, C + 2, C - 10, C + 4, secCol); line(C - 4, C + 4, C - 8, C + 8, secCol); line(C + 4, C, C + 10, C - 4, secCol); line(C + 4, C + 2, C + 10, C + 4, secCol); line(C + 4, C + 4, C + 8, C + 8, secCol); rect(C - 3, C - 2, 2, 2, '#ef4444'); rect(C + 1, C - 2, 2, 2, '#ef4444'); rect(C - 1, C + 1, 2, 2, '#ef4444'); }
244
+ else if (a === 'ranger') { rect(C - 4, C - 10, 8, 8, mainCol); rect(C - 3, C - 9, 2, 6, secCol); rect(C + 1, C - 9, 2, 6, secCol); rect(C - 1, C - 5, 2, 2, '#fbbf24'); rect(C - 4, C - 2, 8, 6, mainCol); rect(C + 4, C - 2, 6, 4, mainCol); rect(C + 8, C - 1, 2, 2, '#22d3ee'); rect(C - 4, C + 4, 3, 6, mainCol); rect(C + 1, C + 4, 3, 6, mainCol); }
245
+ else if (a === 'brain') { rect(C - 6, C - 6, 12, 12, '#a5f3fc'); rect(C - 6, C + 6, 12, 2, secCol); rect(C - 6, C - 8, 12, 2, secCol); rect(C - 4, C - 4, 8, 6, '#d8b4fe'); rect(C - 2, C - 2, 4, 1, '#ffffff'); line(C - 6, C, C - 10, C + 4, secCol); line(C + 6, C, C + 10, C + 4, secCol); }
246
+ else if (a === 'tank') {
247
+ rect(C - 8, C + 4, 16, 4, '#1e293b'); rect(C - 9, C + 5, 18, 2, '#000000');
248
+ rect(C - 6, C - 2, 12, 6, mainCol); rect(C - 4, C - 6, 8, 4, mainCol); rect(C, C - 5, 8, 2, '#3f3f46'); rect(C - 5, C, 10, 2, '#facc15');
249
+ rect(C - 3, C - 3, 2, 2, '#3f3f46'); rect(C - 1, C - 2, 2, 1, '#ef4444');
250
+ }
251
+ else if (a === 'jet') {
252
+ rect(C - 2, C - 10, 4, 6, '#ef4444'); rect(C - 4, C - 4, 8, 10, mainCol); rect(C - 10, C, 6, 8, secCol); rect(C + 4, C, 6, 8, secCol);
253
+ rect(C - 3, C - 6, 6, 2, '#38bdf8'); rect(C - 3, C + 6, 2, 4, '#ef4444'); rect(C + 1, C + 6, 2, 4, '#ef4444');
254
+ }
255
+ else if (a === 'gundam') {
256
+ rect(C - 4, C - 10, 8, 6, '#ffffff'); rect(C - 5, C - 8, 10, 2, '#ffffff'); rect(C - 1, C - 11, 2, 3, '#dc2626'); rect(C - 3, C - 9, 6, 1, '#facc15');
257
+ rect(C - 4, C - 4, 8, 8, '#2563eb'); rect(C - 2, C - 4, 4, 2, '#dc2626'); rect(C - 5, C - 4, 2, 4, '#ffffff'); rect(C + 3, C - 4, 2, 4, '#ffffff');
258
+ rect(C - 4, C + 4, 3, 6, '#ffffff'); rect(C + 1, C + 4, 3, 6, '#ffffff'); rect(C + 5, C, 2, 6, '#334155');
259
+ }
260
+ else if (a === 'mage_skull') {
261
+ rect(C - 4, C - 5, 8, 14, '#172554'); rect(C - 3, C - 9, 6, 5, '#e5e5e5'); rect(C - 2, C - 8, 1, 1, '#000000'); rect(C + 1, C - 8, 1, 1, '#000000');
262
+ rect(C - 6, C - 10, 12, 1, '#4c1d95'); rect(C - 3, C - 13, 6, 3, '#4c1d95'); rect(C + 5, C - 8, 1, 16, '#fbbf24'); circle(C + 5, C - 9, 2, '#a855f7');
263
+ }
264
+ else if (a === 'pumpkin') {
265
+ rect(C - 4, C - 2, 8, 10, '#3f3f46'); circle(C, C - 6, 6, '#ea580c'); rect(C - 2, C - 7, 1, 1, '#facc15'); rect(C + 1, C - 7, 1, 1, '#facc15');
266
+ rect(C - 2, C - 5, 4, 1, '#facc15'); rect(C, C - 13, 1, 2, '#166534'); rect(C - 6, C, 2, 2, '#ea580c'); rect(C + 4, C, 2, 2, '#ea580c');
267
+ }
268
+ else if (a === 'reaper') {
269
+ rect(C - 5, C - 8, 10, 16, '#000000'); rect(C - 3, C - 6, 6, 4, '#171717'); rect(C - 1, C - 5, 1, 1, '#ef4444'); rect(C, C - 5, 1, 1, '#ef4444');
270
+ rect(C + 6, C - 8, 1, 16, '#7f1d1d'); rect(C + 4, C - 10, 6, 2, '#9ca3af'); rect(C + 3, C - 10, 1, 6, '#9ca3af');
271
+ }
272
+
273
+ // --- REDESIGNED STAGE 3 (Wolf V2 & Cat V2 Targets) ---
274
+ else if (a === 'wolf_zombie_v2') {
275
+ rect(C - 6, C - 3, 13, 6, '#4b5563'); // Darker rot body
276
+ rect(C - 2, C - 2, 5, 4, '#991b1b'); // Ribcage
277
+ rect(C - 8, C - 7, 6, 6, '#4b5563'); // Head
278
+ rect(C - 6, C - 6, 2, 2, '#e7e5e4'); // Skull patch
279
+ rect(C - 5, C - 5, 1, 1, '#000000'); // Eye empty
280
+ rect(C - 7, C + 3, 2, 5, '#4b5563'); // Leg FL
281
+ rect(C + 4, C + 3, 2, 5, '#e7e5e4'); // Leg BR (Bone)
282
+ for (let i = 0; i < 4; i++) rect(C + (rng() - 0.5) * 14, C - 6 + (rng() - 0.5) * 8, 1, 1, '#bef264');
283
+ }
284
+ else if (a === 'wolf_guard_v2') {
285
+ rect(C - 7, C - 3, 14, 7, '#1e3a8a'); // Body Armor
286
+ rect(C - 3, C - 4, 6, 6, '#fbbf24'); // Heavy Plating
287
+ rect(C - 9, C - 8, 7, 7, '#172554'); // Helmet
288
+ rect(C - 9, C - 6, 7, 1, '#60a5fa'); // Visor
289
+ rect(C - 3, C - 6, 3, 3, '#1e40af'); // Pad
290
+ rect(C - 7, C + 4, 3, 5, '#3b82f6');
291
+ rect(C + 5, C + 4, 3, 5, '#3b82f6');
292
+ }
293
+ else if (a === 'wolf_alloy_v2') {
294
+ rect(C - 7, C - 3, 14, 5, '#e2e8f0'); // Silver Body
295
+ rect(C - 2, C - 8, 4, 6, '#94a3b8'); // Booster base
296
+ rect(C - 3, C - 9, 2, 4, '#0ea5e9'); // Thruster
297
+ rect(C - 9, C - 6, 7, 5, '#e2e8f0'); // Head
298
+ rect(C - 6, C - 6, 4, 1, '#0ea5e9'); // Visor
299
+ line(C + 7, C, C + 11, C - 4, '#0ea5e9'); // Saber tail
300
+ rect(C - 7, C + 5, 2, 3, '#94a3b8');
301
+ rect(C + 6, C + 5, 2, 3, '#94a3b8');
302
+ }
303
+ else if (a === 'ronin') {
304
+ rect(C - 3, C - 4, 6, 8, '#c2410c'); // Orange Gi
305
+ rect(C - 4, C - 10, 8, 4, '#fde047'); // Hat
306
+ rect(C - 3, C + 4, 2, 4, '#3f3f46'); // Hakama
307
+ rect(C + 1, C + 4, 2, 4, '#3f3f46');
308
+ line(C + 4, C - 4, C + 8, C - 8, '#e5e5e5'); // Blade handle
309
+ rect(C - 4, C - 3, 2, 2, '#ef4444'); // Scarf
310
+ }
311
+ else if (a === 'king_cat') {
312
+ rect(C - 4, C - 3, 8, 8, '#7e22ce'); // Robe
313
+ rect(C - 2, C - 3, 4, 8, '#ffffff'); // Fur
314
+ rect(C - 4, C - 10, 8, 3, '#fbbf24'); // Crown
315
+ rect(C - 1, C - 10, 2, 2, '#ef4444');
316
+ line(C + 6, C, C + 6, C + 6, '#fbbf24'); // Scepter
317
+ circle(C + 6, C - 1, 2, '#ef4444');
318
+ }
319
+ else if (a === 'lion_mane') {
320
+ rect(C - 4, C - 3, 8, 8, '#ffffff');
321
+ for (let t = 0; t < 6.3; t += 0.6) rect(C + Math.cos(t) * 11, C + Math.sin(t) * 11 - 5, 2, 2, '#facc15'); // Mane
322
+ rect(C - 2, C - 6, 4, 4, '#fff7ed'); // Face
323
+ rect(C - 1, C - 5, 2, 1, '#3b82f6'); // Eyes
324
+ line(C - 8, C, C - 4, C + 4, '#facc15'); // Lightning
325
+ line(C + 8, C, C + 4, C - 4, '#facc15');
326
+ }
327
+
328
+ // --- ANGEL FAMILY V3 ---
329
+ else if (a === 'biblical_angel') {
330
+ for (let t = 0; t < 6.3; t += 0.1) {
331
+ let r = 9;
332
+ let x1 = C + Math.cos(t) * r; let y1 = C + Math.sin(t) * r * 0.4;
333
+ rect(x1, y1, 1, 1, mainCol);
334
+ let x2 = C + Math.cos(t) * r * 0.4; let y2 = C + Math.sin(t) * r;
335
+ rect(x2, y2, 1, 1, secCol);
336
+ }
337
+ circle(C, C, 4, '#ffffff');
338
+ for (let i = 0; i < 8; i++) {
339
+ rect(C + Math.cos(i * 0.8) * 9, C + Math.sin(i * 0.8) * 3.5, 2, 2, '#ffffff');
340
+ rect(C + Math.cos(i * 0.8) * 9, C + Math.sin(i * 0.8) * 3.5, 1, 1, '#3b82f6');
341
+ }
342
+ }
343
+ else if (a === 'valkyrie') {
344
+ rect(C - 3, C - 5, 6, 8, '#e2e8f0'); rect(C - 3, C - 9, 6, 4, '#facc15');
345
+ rect(C - 4, C + 3, 3, 5, '#e2e8f0'); rect(C + 1, C + 3, 3, 5, '#e2e8f0');
346
+ rect(C - 8, C - 6, 4, 10, '#ffffff'); rect(C + 5, C - 6, 4, 10, '#ffffff');
347
+ line(C + 4, C + 8, C + 10, C - 8, '#fbbf24'); rect(C + 9, C - 9, 2, 4, '#38bdf8');
348
+ }
349
+ else if (a === 'phoenix') {
350
+ rect(C - 2, C - 4, 4, 8, mainCol); rect(C - 2, C - 8, 4, 4, mainCol);
351
+ rect(C, C - 7, 1, 1, '#fbbf24'); rect(C - 3, C - 6, 1, 2, '#fbbf24');
352
+ for (let i = 0; i < 8; i++) {
353
+ rect(C - 2 - i, C - 4 - i, 1, 2, '#ef4444');
354
+ rect(C + 1 + i, C - 4 - i, 1, 2, '#ef4444');
355
+ rect(C - 2 - i, C - 2 - i, 1, 4, secCol);
356
+ rect(C + 1 + i, C - 2 - i, 1, 4, secCol);
357
+ }
358
+ rect(C - 1, C + 4, 2, 6, '#fbbf24'); rect(C - 3, C + 6, 2, 4, '#ef4444'); rect(C + 1, C + 6, 2, 4, '#ef4444');
359
+ }
360
+
361
+ // --- COSMOS FAMILY V3 ---
362
+ else if (a === 'terra_titan') {
363
+ rect(C - 6, C - 6, 12, 10, mainCol); rect(C - 8, C - 8, 4, 6, secCol); rect(C + 4, C - 8, 4, 6, secCol);
364
+ rect(C - 3, C - 9, 6, 4, mainCol); rect(C - 2, C - 8, 4, 1, '#f97316');
365
+ rect(C - 8, C - 2, 3, 8, mainCol); rect(C + 5, C - 2, 3, 8, mainCol);
366
+ rect(C - 6, C + 4, 4, 6, secCol); rect(C + 2, C + 4, 4, 6, secCol);
367
+ circle(C, C - 2, 3, '#ea580c'); rect(C - 1, C - 3, 2, 2, '#f97316');
368
+ }
369
+ else if (a === 'cosmo_whale') {
370
+ rect(C - 10, C - 6, 14, 10, mainCol); rect(C + 4, C - 4, 6, 6, mainCol); rect(C + 10, C - 2, 4, 3, mainCol);
371
+ rect(C - 10, C + 2, 12, 2, '#c7d2fe'); rect(C + 12, C - 5, 2, 8, mainCol);
372
+ rect(C + 11, C - 6, 1, 2, mainCol); rect(C + 11, C + 4, 1, 2, mainCol);
373
+ rect(C - 5, C, 2, 1, '#ffffff');
374
+ for (let k = 0; k < 5; k++) rect(C - 6 + (rng() - 0.5) * 4, C - 10 + (rng() - 0.5) * 4, 1, 1, '#22d3ee');
375
+ rect(C - 8, C - 3, 1, 1, '#ffffff'); rect(C - 2, C - 4, 1, 1, '#ffffff'); rect(C + 6, C - 1, 1, 1, '#ffffff');
376
+ }
377
+ else if (a === 'genesis_dragon') {
378
+ let px = C, py = C;
379
+ for (let i = 0; i < 30; i++) {
380
+ rect(px, py, 3, 3, i % 2 == 0 ? '#000000' : secCol);
381
+ px += Math.cos(i * 0.4) * 2;
382
+ py += Math.sin(i * 0.4) * 2;
383
+ }
384
+ circle(C, C, 5, '#c084fc');
385
+ for (let k = 0; k < 20; k++) {
386
+ rect(C + (rng() - 0.5) * 20, C + (rng() - 0.5) * 20, 1, 1, k % 3 == 0 ? '#22d3ee' : '#f472b6');
387
+ }
388
+ }
389
+
390
+ let svg = '';
391
+ for (let y = 0; y < H; y++) {
392
+ for (let x = 0; x < W; x++) {
393
+ if (grid[y][x]) svg += `<rect x="${x}" y="${y}" width="1" height="1" fill="${grid[y][x]}"/>`;
394
+ }
395
+ }
396
+ return `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="w-full h-full drop-shadow-md shape-rendering-crispEdges">${svg}</svg>`;
397
+ }
398
+
399
+ /**
400
+ * Determines the next monster form based on parameters and lineage
401
+ * @param {number} currentStage - Current evolutionary stage (0-3)
402
+ * @param {number} likes - Student's total likes
403
+ * @param {number} classSize - Active class size
404
+ * @param {string} [currentMonsterId] - ID of the current monster to determine lineage
405
+ * @returns {Object} Selected Monster Definition
406
+ */
407
+ /**
408
+ * Determines the next monster form based on parameters and lineage
409
+ * @param {number} currentStage - Current evolutionary stage (0-3)
410
+ * @param {number} stageLikes - Likes earned IN THIS STAGE
411
+ * @param {number} classSize - Active class size
412
+ * @param {string} [currentMonsterId] - ID of the current monster to determine lineage
413
+ * @param {number} [percentile] - Speed ranking percentile (0.0 - 1.0, lower is faster). Default 1.0
414
+ * @returns {Object} Selected Monster Definition
415
+ */
416
+ export function getNextMonster(currentStage, stageLikes, classSize, currentMonsterId = null, percentile = 1.0) {
417
+ if (currentStage === 0) return MONSTER_DEFS.find(m => m.id === 'Egg');
418
+
419
+ // Find current monster family if we have an ID
420
+ let currentFam = null;
421
+ if (currentMonsterId) {
422
+ const current = MONSTER_DEFS.find(m => m.id === currentMonsterId);
423
+ if (current) currentFam = current.fam; // e.g., 'Beast', 'Dust', 'Spirit'
424
+ }
425
+
426
+ // Stage 1: Basic Families (Origin -> Family)
427
+ // If coming from Egg (Stage 0), we pick a base family based on performance.
428
+ if (currentStage === 1) {
429
+ // Simple distribution for Stage 1 (Egg -> Base)
430
+ // We can use the same Score Logic or keep simple likes.
431
+ // Let's use the Score Logic to be consistent with design.
432
+
433
+ let score = 0;
434
+ // Speed Score
435
+ if (percentile <= 0.3) score += 5; // Top 30%
436
+ else if (percentile <= 0.6) score += 3; // Top 60%
437
+ else score += 1; // Rest
438
+
439
+ // Like Score
440
+ if (stageLikes >= 2) score += 2;
441
+ else if (stageLikes > 0) score += 1;
442
+
443
+ if (score >= 5) return MONSTER_DEFS.find(m => m.id === 'L1_A'); // High (Spirit)
444
+ if (score >= 3) return MONSTER_DEFS.find(m => m.id === 'L1_B'); // Mid (Beast)
445
+ return MONSTER_DEFS.find(m => m.id === 'L1_C'); // Low (Dust)
446
+ }
447
+
448
+ // Branching Logic for Stage 2 & 3
449
+ // Score Calculation
450
+ let score = 0;
451
+
452
+ // 1. Speed Score
453
+ if (percentile <= 0.3) score += 5; // Top 30%
454
+ else if (percentile <= 0.6) score += 3; // Top 60%
455
+ else score += 1; // Rest
456
+
457
+ // 2. Like Score
458
+ if (stageLikes >= 2) score += 2; // High Likes
459
+ else if (stageLikes > 0) score += 1; // Any Likes
460
+
461
+ // Tier Logic
462
+ let tier = 'C';
463
+ if (score >= 5) tier = 'A'; // High Tier
464
+ else if (score >= 3) tier = 'B'; // Mid Tier
465
+
466
+ // Safety check for small classes (Boost if alone)
467
+ if (classSize <= 2) {
468
+ if (stageLikes >= 1) tier = 'A';
469
+ else tier = 'B';
470
+ }
471
+
472
+ // Filter potential monsters for this Stage
473
+ let candidates = MONSTER_DEFS.filter(m => m.stage === currentStage);
474
+
475
+ // 1. Strict Lineage by ID Pattern (L{stage}_{path})
476
+ // e.g. L1_A -> L2_A... / L2_AB -> L3_AB...
477
+ // This allows exact branching control as per design doc.
478
+ const isStandardId = currentMonsterId?.match(/^L\d+_[ABC]+$/);
479
+
480
+ if (isStandardId) {
481
+ // Extract path suffix (e.g. "A" from "L1_A", "AB" from "L2_AB")
482
+ const currentPath = currentMonsterId.split('_')[1];
483
+ // Filter candidates: Must start with L{targetStage}_{currentPath}
484
+ // currentStage IS the target stage (e.g. 3), so we look for L3_...
485
+ const strictMatches = candidates.filter(m => m.id.startsWith(`L${currentStage}_${currentPath}`));
486
+
487
+ if (strictMatches.length > 0) {
488
+ candidates = strictMatches;
489
+ }
490
+ }
491
+ // 2. Fallback: Family Tree Logic (for custom IDs or Egg)
492
+ else if (currentFam && currentFam !== 'Origin') {
493
+ // Define Family Tree Mapping
494
+ const FAMILY_TREE = {
495
+ // Stage 1 Root: Low Tier (Red Path)
496
+ 'Dust': ['Dust', 'Glitch', 'Trash', 'Slime', 'Tech', 'Hacker', 'Virus'],
497
+ // Stage 1 Root: Mid Tier (Yellow Path)
498
+ 'Beast': ['Beast', 'Wolf', 'Cat', 'Mech', 'Mech Lion', 'Animal', 'Grunge', 'Royal', 'Warrior', 'Undead', 'Elemental'],
499
+ // Stage 1 Root: High Tier (Blue Path)
500
+ 'Spirit': ['Spirit', 'Ghost', 'Holy', 'Cosmos', 'Angel', 'Divin', 'Void', 'Undead']
501
+ };
502
+
503
+ // Find which root family the current family belongs to
504
+ let rootFamily = null;
505
+ for (const [root, children] of Object.entries(FAMILY_TREE)) {
506
+ if (root === currentFam || children.includes(currentFam) || children.some(c => currentFam.includes(c))) {
507
+ rootFamily = root;
508
+ break;
509
+ }
510
+ }
511
+
512
+ if (rootFamily) {
513
+ const allowedFamilies = FAMILY_TREE[rootFamily];
514
+ candidates = candidates.filter(m => {
515
+ return allowedFamilies.some(fam => m.fam.includes(fam) || fam.includes(m.fam));
516
+ });
517
+ }
518
+ }
519
+
520
+ // Filter by Tier (Suffix A/B/C)
521
+ // IDs are like L2_AA, L2_AB...
522
+ // Last char is C(Low), B(Mid), A(High).
523
+ const targetSuffix = tier;
524
+
525
+ let match = candidates.find(m => m.id.endsWith(targetSuffix));
526
+
527
+ // Fallback: If specific tier not found in this family (shouldn't happen if definitions are complete),
528
+ // pick ANY from this family.
529
+ if (!match && candidates.length > 0) match = candidates[0];
530
+
531
+ return match || MONSTER_DEFS[0]; // Absolute Fallback
532
+ }
src/views/AdminView.js ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getChallenges, createChallenge, updateChallenge, deleteChallenge } from "../services/classroom.js";
2
+ import { checkInstructorPermission } from "../services/auth.js";
3
+ import { auth } from "../services/firebase.js";
4
+
5
+ export function renderAdminView() {
6
+ return `
7
+ <div class="min-h-screen p-6 pb-20">
8
+ <header class="flex justify-between items-center mb-10 bg-gray-800 bg-opacity-50 p-4 rounded-xl border border-gray-700 backdrop-blur-sm">
9
+ <div class="flex items-center space-x-4">
10
+ <button id="back-instructor-btn" class="bg-gray-700 hover:bg-gray-600 text-white p-2 rounded-lg transition-all">
11
+ ← 回講師端
12
+ </button>
13
+ <h1 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 to-orange-600">
14
+ 後台管理系統 Admin Panel
15
+ </h1>
16
+ </div>
17
+ <button id="add-challenge-btn" class="bg-green-600 hover:bg-green-500 text-white font-bold py-2 px-6 rounded-lg transition-all shadow-lg">
18
+ + 新增題目
19
+ </button>
20
+ </header>
21
+
22
+ <div id="challenges-list" class="space-y-4">
23
+ <!-- Questions loaded here -->
24
+ <div class="text-center text-gray-500 py-10">載入中...</div>
25
+ </div>
26
+
27
+ <!-- Edit/Add Modal -->
28
+ <div id="challenge-modal" class="fixed inset-0 bg-black bg-opacity-80 backdrop-blur-sm hidden flex items-center justify-center z-50 p-4">
29
+ <div class="bg-gray-800 rounded-xl w-full max-w-2xl border border-gray-700 shadow-2xl overflow-y-auto max-h-[90vh]">
30
+ <div class="p-6 border-b border-gray-700">
31
+ <h3 id="modal-title" class="text-xl font-bold text-white">編輯題目</h3>
32
+ </div>
33
+ <div class="p-6 space-y-4">
34
+ <input type="hidden" id="edit-id">
35
+ <div>
36
+ <label class="block text-gray-400 mb-1">標題 (Title)</label>
37
+ <input type="text" id="edit-title" class="w-full bg-gray-900 border border-gray-600 rounded p-2 text-white">
38
+ </div>
39
+ <div>
40
+ <label class="block text-gray-400 mb-1">難度 (Level)</label>
41
+ <select id="edit-level" class="w-full bg-gray-900 border border-gray-600 rounded p-2 text-white">
42
+ <option value="beginner">初級 (Beginner)</option>
43
+ <option value="intermediate">中級 (Intermediate)</option>
44
+ <option value="advanced">高級 (Advanced)</option>
45
+ </select>
46
+ </div>
47
+ <div>
48
+ <label class="block text-gray-400 mb-1">描述 (Description)</label>
49
+ <textarea id="edit-desc" rows="3" class="w-full bg-gray-900 border border-gray-600 rounded p-2 text-white"></textarea>
50
+ </div>
51
+ <div>
52
+ <label class="block text-gray-400 mb-1">連結 (GeminiCanvas Link/Code)</label>
53
+ <input type="text" id="edit-link" class="w-full bg-gray-900 border border-gray-600 rounded p-2 text-white">
54
+ </div>
55
+ <div>
56
+ <label class="block text-gray-400 mb-1">排序 (Order)</label>
57
+ <input type="number" id="edit-order" class="w-full bg-gray-900 border border-gray-600 rounded p-2 text-white" value="1">
58
+ </div>
59
+ </div>
60
+ <div class="p-6 border-t border-gray-700 flex justify-end space-x-3">
61
+ <button onclick="closeChallengeModal()" class="px-4 py-2 text-gray-400 hover:text-white">取消</button>
62
+ <button id="save-challenge-btn" class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded">儲存</button>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ `;
68
+ }
69
+
70
+ export function setupAdminEvents() {
71
+ // Permission Check
72
+ const user = auth.currentUser;
73
+ if (!user) {
74
+ alert("請先登入");
75
+ window.location.hash = ''; // Back to Landing
76
+ return;
77
+ }
78
+
79
+ checkInstructorPermission(user).then(inst => {
80
+ if (!inst || !inst.permissions?.includes('add_question')) {
81
+ alert("您沒有權限管理題目");
82
+ window.location.hash = 'instructor';
83
+ return;
84
+ }
85
+ });
86
+
87
+ loadChallenges();
88
+
89
+ document.getElementById('back-instructor-btn').addEventListener('click', () => {
90
+ const referer = localStorage.getItem('vibecoding_admin_referer');
91
+ if (referer === 'instructor') {
92
+ window.location.hash = 'instructor';
93
+ } else {
94
+ window.location.hash = ''; // Main landing
95
+ }
96
+ localStorage.removeItem('vibecoding_admin_referer');
97
+ });
98
+
99
+ document.getElementById('add-challenge-btn').addEventListener('click', () => {
100
+ openModal();
101
+ });
102
+
103
+ document.getElementById('save-challenge-btn').addEventListener('click', async () => {
104
+ const id = document.getElementById('edit-id').value;
105
+ const data = {
106
+ title: document.getElementById('edit-title').value,
107
+ level: document.getElementById('edit-level').value,
108
+ description: document.getElementById('edit-desc').value,
109
+ link: document.getElementById('edit-link').value,
110
+ order: parseInt(document.getElementById('edit-order').value) || 0
111
+ };
112
+
113
+ if (id) {
114
+ await updateChallenge(id, data);
115
+ } else {
116
+ await createChallenge(data);
117
+ }
118
+
119
+ closeChallengeModal();
120
+ loadChallenges();
121
+ });
122
+ }
123
+
124
+ async function loadChallenges() {
125
+ const list = document.getElementById('challenges-list');
126
+ const challenges = await getChallenges();
127
+
128
+ const levels = ['beginner', 'intermediate', 'advanced'];
129
+ const levelNames = {
130
+ beginner: "初級 (Beginner)",
131
+ intermediate: "中級 (Intermediate)",
132
+ advanced: "高級 (Advanced)"
133
+ };
134
+
135
+ // Group challenges
136
+ const groups = { beginner: [], intermediate: [], advanced: [] };
137
+ challenges.forEach(c => {
138
+ if (groups[c.level]) groups[c.level].push(c);
139
+ else groups.beginner.push(c); // Fallback
140
+ });
141
+
142
+ list.innerHTML = levels.map(level => {
143
+ const groupItems = groups[level] || [];
144
+ const isOpen = level === 'beginner' ? 'open' : ''; // Open first by default
145
+
146
+ const itemsHtml = groupItems.map(c => `
147
+ <div class="bg-gray-800 p-4 rounded-lg border border-gray-700 flex justify-between items-center group hover:border-gray-500 transition-colors mb-2">
148
+ <div class="flex items-center space-x-3">
149
+ <span class="text-gray-500 font-mono text-sm">#${c.order}</span>
150
+ <div>
151
+ <span class="font-bold text-white text-lg">${c.title}</span>
152
+ <p class="text-gray-400 text-sm mt-1 truncate max-w-md">${c.description}</p>
153
+ </div>
154
+ </div>
155
+ <div class="flex space-x-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
156
+ <button onclick="window.editChallenge('${c.id}')" class="bg-cyan-600/20 text-cyan-400 px-3 py-1 rounded hover:bg-cyan-600 hover:text-white transition-colors">編輯</button>
157
+ <button onclick="window.deleteChallenge('${c.id}')" class="bg-red-600/20 text-red-400 px-3 py-1 rounded hover:bg-red-600 hover:text-white transition-colors">刪除</button>
158
+ </div>
159
+ </div>
160
+ `).join('');
161
+
162
+ return `
163
+ <details class="group border border-gray-700 rounded-xl bg-gray-800/30 overflow-hidden mb-4" ${isOpen}>
164
+ <summary class="flex items-center justify-between p-4 cursor-pointer bg-gray-800 hover:bg-gray-700 transition-colors select-none">
165
+ <div class="flex items-center space-x-3">
166
+ <h3 class="text-xl font-bold text-cyan-400">${levelNames[level]}</h3>
167
+ <span class="text-xs text-gray-400 bg-gray-900 px-2 py-1 rounded-full">${groupItems.length} 題</span>
168
+ </div>
169
+ <div class="flex items-center space-x-4">
170
+ <button onclick="event.preventDefault(); window.openModal(null, '${level}')" class="bg-green-600 hover:bg-green-500 text-white text-xs font-bold py-1 px-3 rounded transition-colors shadow-lg">
171
+ + 新增至此區
172
+ </button>
173
+ <svg class="w-5 h-5 text-gray-400 transform group-open:rotate-180 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
174
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
175
+ </svg>
176
+ </div>
177
+ </summary>
178
+ <div class="p-4 pt-4 border-t border-gray-700/50">
179
+ ${itemsHtml || '<div class="text-gray-500 text-center italic">尚無題目,請新增</div>'}
180
+ </div>
181
+ </details>
182
+ `;
183
+ }).join('');
184
+
185
+ // Expose helpers globally for onclick
186
+ window.editChallenge = (id) => {
187
+ const c = challenges.find(x => x.id === id);
188
+ if (c) openModal(c);
189
+ };
190
+ window.deleteChallenge = async (id) => {
191
+ if (confirm('確定刪除?')) {
192
+ await deleteChallenge(id);
193
+ loadChallenges();
194
+ }
195
+ };
196
+ }
197
+
198
+ // Global expose for the "Add to Section" button
199
+ window.openModal = function (challenge = null, defaultLevel = 'beginner') {
200
+ const modal = document.getElementById('challenge-modal');
201
+ const title = document.getElementById('modal-title');
202
+
203
+ // Reset or Fill
204
+ document.getElementById('edit-id').value = challenge ? challenge.id : '';
205
+ document.getElementById('edit-title').value = challenge ? challenge.title : '';
206
+ // Use challenge level if editing, otherwise use defaultLevel passed from section button
207
+ document.getElementById('edit-level').value = challenge ? challenge.level : defaultLevel;
208
+ document.getElementById('edit-desc').value = challenge ? challenge.description : '';
209
+ document.getElementById('edit-link').value = challenge ? challenge.link : '';
210
+ document.getElementById('edit-order').value = challenge ? challenge.order : '1';
211
+
212
+ title.textContent = challenge ? '編輯題目' : '新增題目';
213
+ modal.classList.remove('hidden');
214
+ }
215
+
216
+ window.closeChallengeModal = () => {
217
+ document.getElementById('challenge-modal').classList.add('hidden');
218
+ };
src/views/InstructorView.js ADDED
The diff for this file is too large to render. See raw diff
 
src/views/LandingView.js ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createRoom, joinRoom } from "../services/classroom.js";
2
+ import { generateMonsterSVG, MONSTER_DEFS } from "../utils/monsterUtils.js";
3
+
4
+ export function renderLandingView() {
5
+ // Select Decor Monsters
6
+ // Left: Genesis Dragon (L3_AAA), Right: Gundam (L3_BAA) - or fallbacks
7
+ const mLeft = MONSTER_DEFS.find(m => m.id === 'L3_AAA') || MONSTER_DEFS.find(m => m.stage === 3);
8
+ const mRight = MONSTER_DEFS.find(m => m.id === 'L3_BAA') || MONSTER_DEFS.find(m => m.stage === 3);
9
+
10
+ const svgLeft = generateMonsterSVG(mLeft);
11
+ const svgRight = generateMonsterSVG(mRight);
12
+
13
+ return `
14
+ <div class="min-h-screen flex flex-col items-center justify-center p-4 relative overflow-hidden">
15
+
16
+ <!-- Decor Monsters (Desktop Only) -->
17
+ <div class="absolute bottom-10 left-10 w-48 h-48 hidden lg:block pointer-events-none"
18
+ style="animation: float 6s ease-in-out infinite;">
19
+ <div class="w-full h-full drop-shadow-[0_0_15px_rgba(34,211,238,0.5)]">
20
+ ${svgLeft}
21
+ </div>
22
+ </div>
23
+ <div class="absolute bottom-10 right-10 w-48 h-48 hidden lg:block pointer-events-none"
24
+ style="animation: float 8s ease-in-out infinite reverse;">
25
+ <div class="w-full h-full drop-shadow-[0_0_15px_rgba(59,130,246,0.5)]">
26
+ ${svgRight}
27
+ </div>
28
+ </div>
29
+
30
+ <div class="max-w-md w-full bg-gray-600 bg-opacity-20 backdrop-blur-lg rounded-xl shadow-2xl p-8 border border-gray-700 z-10">
31
+ <h1 class="text-3xl sm:text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-500 mb-8 text-center tracking-tighter whitespace-nowrap">
32
+ VIBECODING-怪獸成長營
33
+ </h1>
34
+
35
+ <!-- Student Join Form -->
36
+ <div id="student-form" class="space-y-6">
37
+ <div>
38
+ <label class="block text-gray-400 text-sm font-bold mb-2">教室代碼 (Room Code)</label>
39
+ <input type="text" id="room-code-input" class="w-full bg-gray-800 text-white border border-gray-600 rounded-lg py-3 px-4 focus:outline-none focus:border-cyan-500 transition-colors" placeholder="1234">
40
+ </div>
41
+ <div>
42
+ <label class="block text-gray-400 text-sm font-bold mb-2">您的暱稱 (Nickname)</label>
43
+ <input type="text" id="nickname-input" class="w-full bg-gray-800 text-white border border-gray-600 rounded-lg py-3 px-4 focus:outline-none focus:border-purple-500 transition-colors" placeholder="小明">
44
+ </div>
45
+ <button id="join-btn" class="w-full bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white font-bold py-3 px-4 rounded-lg transform transition hover:scale-105 active:scale-95 shadow-lg shadow-cyan-500/30">
46
+ 進入教室
47
+ </button>
48
+ </div>
49
+
50
+ <!-- Instructor Toggle -->
51
+ <div class="mt-8 pt-6 border-t border-gray-700 text-center">
52
+ <button id="instructor-mode-btn" class="text-gray-500 text-sm hover:text-cyan-400 transition-colors">
53
+ 我是講師 (Instructor Mode)
54
+ </button>
55
+ </div>
56
+ </div>
57
+
58
+ <style>
59
+ @keyframes float {
60
+ 0%, 100% { transform: translateY(0); }
61
+ 50% { transform: translateY(-20px); }
62
+ }
63
+ </style>
64
+ </div>
65
+ `;
66
+ }
67
+
68
+ export function setupLandingEvents(navigateTo) {
69
+ const joinBtn = document.getElementById('join-btn');
70
+ const instructorBtn = document.getElementById('instructor-mode-btn');
71
+
72
+ joinBtn.addEventListener('click', async () => {
73
+ const roomCode = document.getElementById('room-code-input').value.trim();
74
+ const nickname = document.getElementById('nickname-input').value.trim();
75
+
76
+ if (!roomCode || !nickname) {
77
+ alert('請輸入教室代碼和暱稱');
78
+ return;
79
+ }
80
+
81
+ try {
82
+ joinBtn.textContent = '加入中...';
83
+ joinBtn.disabled = true;
84
+
85
+ const studentId = await joinRoom(roomCode, nickname);
86
+
87
+ // Save Session
88
+ localStorage.setItem('vibecoding_user_id', studentId);
89
+ localStorage.setItem('vibecoding_room_code', roomCode);
90
+ localStorage.setItem('vibecoding_nickname', nickname);
91
+
92
+ navigateTo('student');
93
+ } catch (error) {
94
+ alert('加入失敗: ' + error.message);
95
+ joinBtn.textContent = '進入教室';
96
+ joinBtn.disabled = false;
97
+ }
98
+ });
99
+
100
+ instructorBtn.addEventListener('click', () => {
101
+ // Clear any previous admin referer to ensure clean state
102
+ localStorage.removeItem('vibecoding_admin_referer');
103
+ navigateTo('instructor');
104
+ });
105
+ }
src/views/StudentView.js ADDED
@@ -0,0 +1,826 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { submitPrompt, getChallenges, startChallenge, getUserProgress, getPeerPrompts, resetProgress, toggleLike, subscribeToNotifications, markNotificationRead, getClassSize, updateUserMonster, getUser, subscribeToUserProgress } from "../services/classroom.js";
2
+ import { generateMonsterSVG, getNextMonster, MONSTER_STAGES, MONSTER_DEFS } from "../utils/monsterUtils.js";
3
+
4
+
5
+ // Cache challenges locally
6
+ let cachedChallenges = [];
7
+
8
+ function renderTaskCard(c, userProgress) {
9
+ const p = userProgress[c.id] || {};
10
+ const isCompleted = p.status === 'completed';
11
+ const isStarted = p.status === 'started';
12
+
13
+ // 1. Completed State: Collapsed with Trophy
14
+ if (isCompleted) {
15
+ return `
16
+ <div id="card-${c.id}" class="bg-gray-800/50 border border-green-500/30 rounded-xl p-4 flex items-center justify-between group hover:bg-gray-800 transition-all">
17
+ <div class="flex items-center space-x-3">
18
+ <div class="text-2xl">🏆</div>
19
+ <h3 class="font-bold text-gray-300 group-hover:text-white transition-colors">${c.title}</h3>
20
+ </div>
21
+ <div class="flex items-center space-x-3">
22
+ <button onclick="window.resetLevel('${c.id}')" class="text-xs bg-red-900/50 hover:bg-red-700 text-red-300 border border-red-800 px-2 py-1 rounded transition-colors" title="重置進度 (Reset)">
23
+ ↺ 重置
24
+ </button>
25
+ <span class="text-xs text-green-400 font-mono bg-green-900/30 px-2 py-1 rounded">已通關</span>
26
+ <button onclick="document.getElementById('detail-${c.id}').classList.toggle('hidden')" class="text-gray-500 hover:text-white">
27
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
28
+ </button>
29
+ </div>
30
+ </div>
31
+ <!-- Hidden detail for reference -->
32
+ <div id="detail-${c.id}" class="hidden mt-2 p-4 bg-gray-900/50 rounded-xl border border-gray-700 text-sm text-gray-400">
33
+ <p class="mb-2">您的提示詞:</p>
34
+ <div class="font-mono bg-black p-2 rounded text-gray-300 border border-gray-800">${p.submission_prompt}</div>
35
+ </div>
36
+ `;
37
+ }
38
+
39
+ // 2. Started or Not Started
40
+ return `
41
+ <div id="card-${c.id}" class="group relative bg-gray-800 bg-opacity-50 border ${isStarted ? 'border-cyan-500/50 shadow-[0_0_15px_rgba(6,182,212,0.1)]' : 'border-gray-700'} rounded-2xl overflow-hidden hover:border-cyan-500/50 transition-all duration-300 flex flex-col">
42
+ <div class="absolute top-0 left-0 w-1 h-full ${isStarted ? 'bg-cyan-500' : 'bg-gray-600'} group-hover:bg-cyan-400 transition-colors"></div>
43
+
44
+ <div class="p-6 pl-8 flex-1 flex flex-col">
45
+ <div class="flex flex-col sm:flex-row justify-between items-start mb-4 gap-4">
46
+ <div>
47
+ <h2 class="text-xl font-bold text-white mb-1">${c.title}</h2>
48
+ <div class="bg-blue-900/30 border border-blue-500/30 p-4 rounded-xl my-3 shadow-[inset_0_0_10px_rgba(59,130,246,0.1)]">
49
+ <div class="flex items-start space-x-2 text-cyan-300 mb-1">
50
+ <svg class="w-5 h-5 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
51
+ <span class="font-bold text-sm tracking-wider uppercase">任務說明</span>
52
+ </div>
53
+ <p class="text-gray-200 text-base font-medium whitespace-pre-line leading-relaxed pl-7">${c.description}</p>
54
+ </div>
55
+ </div>
56
+ </div>
57
+
58
+ ${!isStarted ? `
59
+ <!-- Not Started State -->
60
+ <div class="mt-4 flex justify-center">
61
+ <button onclick="window.startLevel('${c.id}', '${c.link}')"
62
+ class="w-full sm:w-auto bg-gray-700 hover:bg-cyan-600 hover:text-white text-gray-200 font-bold py-3 px-6 rounded-xl transition-all flex items-center justify-center space-x-2 shadow-lg">
63
+ <span>🚀 開始任務 (Start Task)</span>
64
+ </button>
65
+ </div>
66
+ ` : `
67
+ <!-- Started State: Input Area -->
68
+ <div class="mt-4 pt-4 border-t border-gray-700/50">
69
+ <div class="flex justify-between items-center mb-2">
70
+ <label class="block text-xs uppercase tracking-wider text-cyan-400 animate-pulse">任務進行中</label>
71
+ <a href="${c.link}" target="_blank" class="text-xs text-gray-500 hover:text-white flex items-center space-x-1">
72
+ <span>再次開啟 GeminCanvas</span>
73
+ <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
74
+ </a>
75
+ </div>
76
+
77
+ <div class="flex flex-col space-y-2">
78
+ <textarea id="input-${c.id}" rows="2"
79
+ class="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none transition-colors text-sm"
80
+ placeholder="貼上您的修復提示詞..."></textarea>
81
+
82
+ <div id="error-${c.id}" class="text-red-500 text-xs hidden">提示詞太短囉,請多寫一點細節!</div>
83
+
84
+ <button onclick="window.submitLevel('${c.id}')"
85
+ class="w-full bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white px-6 py-2 rounded-lg font-bold transition-transform active:scale-95 shadow-lg shadow-cyan-900/50">
86
+ 提交解答
87
+ </button>
88
+ </div>
89
+ </div>
90
+ `}
91
+ </div>
92
+ </div>
93
+ `;
94
+ }
95
+
96
+ export async function renderStudentView() {
97
+ const nickname = localStorage.getItem('vibecoding_nickname') || 'Guest';
98
+ const roomCode = localStorage.getItem('vibecoding_room_code') || 'Unknown';
99
+ const userId = localStorage.getItem('vibecoding_user_id');
100
+
101
+ // Fetch challenges if empty
102
+ if (cachedChallenges.length === 0) {
103
+ try {
104
+ cachedChallenges = await getChallenges();
105
+ } catch (e) {
106
+ console.error("Failed to fetch challenges", e);
107
+ throw new Error("無法讀取題目列表 (Error: " + e.message + ")");
108
+ }
109
+ }
110
+
111
+ // Fetch User Progress
112
+ let userProgress = {};
113
+ if (userId) {
114
+ try {
115
+ userProgress = await getUserProgress(userId);
116
+ } catch (e) {
117
+ console.error("Failed to fetch progress", e);
118
+ }
119
+ }
120
+
121
+ const levelGroups = {
122
+ beginner: cachedChallenges.filter(c => c.level === 'beginner'),
123
+ intermediate: cachedChallenges.filter(c => c.level === 'intermediate'),
124
+ advanced: cachedChallenges.filter(c => c.level === 'advanced')
125
+ };
126
+
127
+ const levelNames = {
128
+ beginner: "初級 (Beginner)",
129
+ intermediate: "中級 (Intermediate)",
130
+ advanced: "高級 (Advanced)"
131
+ };
132
+
133
+ // --- Monster Section Render Function ---
134
+ // --- Monster Section Helper Functions ---
135
+
136
+ // 1. Calculate Monster State (Separated logic)
137
+ const calculateMonsterState = (currentUserProgress, classSize, userProfile) => {
138
+ const totalLikes = Object.values(currentUserProgress).reduce((acc, p) => acc + (p.likes || 0), 0);
139
+
140
+ // Count completions per level
141
+ const counts = {
142
+ 1: cachedChallenges.filter(c => c.level === 'beginner' && currentUserProgress[c.id]?.status === 'completed').length,
143
+ 2: cachedChallenges.filter(c => c.level === 'intermediate' && currentUserProgress[c.id]?.status === 'completed').length,
144
+ 3: cachedChallenges.filter(c => c.level === 'advanced' && currentUserProgress[c.id]?.status === 'completed').length
145
+ };
146
+ const totalCompleted = counts[1] + counts[2] + counts[3];
147
+
148
+ let potentialStage = 0;
149
+ if (counts[1] >= 5) potentialStage = 1;
150
+ if (counts[2] >= 5 && potentialStage >= 1) potentialStage = 2;
151
+ if (counts[3] >= 5 && potentialStage >= 2) potentialStage = 3;
152
+
153
+ const actualStage = userProfile.monster_stage || 0;
154
+ const actualMonsterId = userProfile.monster_id || 'Egg';
155
+
156
+ // Regression Logic Check
157
+ if (actualStage > potentialStage) {
158
+ // Regression Logic: Pass 'Egg' (or null) to enforce downgrade
159
+ const targetId = potentialStage === 0 ? 'Egg' : null; // If going to 0, force Egg. Else keep null to let system decide? Or should we query?
160
+ // Actually, if we devolve, we lose the specific form. So we should probably reset ID to something generic or clear it.
161
+ // If we go to stage 0, 'Egg' is safe.
162
+ // If we go to stage 1 (from 2), we might want to keep the stage 1 form (if we knew it).
163
+ // But simplifying: just clear the ID so getNextMonster picks default for that stage.
164
+
165
+ updateUserMonster(userId, potentialStage, targetId).then(() => {
166
+ setTimeout(() => window.location.reload(), 500);
167
+ });
168
+ // Return corrected state temporary
169
+ return {
170
+ ...calculateMonsterState(currentUserProgress, classSize, { ...userProfile, monster_stage: potentialStage, monster_id: targetId }),
171
+ isRegressing: true
172
+ };
173
+ }
174
+
175
+ const canEvolve = potentialStage > actualStage;
176
+
177
+ // Scale Logic
178
+ const growthFactor = 0.08;
179
+ const baseScale = 1.0;
180
+ const currentScale = baseScale + (totalCompleted * growthFactor);
181
+
182
+ // Get Monster Data
183
+ let monster = getNextMonster(actualStage, 0, 0, actualMonsterId);
184
+ if (actualMonsterId && actualMonsterId !== 'Egg') {
185
+ const stored = MONSTER_DEFS.find(m => m.id === actualMonsterId);
186
+ if (stored) monster = stored;
187
+ } else {
188
+ monster = getNextMonster(actualStage, totalLikes, classSize);
189
+ }
190
+
191
+ return {
192
+ monster,
193
+ currentScale,
194
+ totalCompleted,
195
+ totalLikes,
196
+ canEvolve,
197
+ actualStage,
198
+ classSize,
199
+ counts
200
+ };
201
+ };
202
+
203
+ // 2. Render Stats HTML (Partial Update Target)
204
+ const renderMonsterStats = (state) => {
205
+ return `
206
+ <div class="font-bold text-white text-sm mb-1 text-center border-b border-gray-700 pb-1">${state.monster.name}</div>
207
+ <div class="space-y-1 mt-1 text-center">
208
+ <div class=""><span>💖</span> <span class="text-pink-400 font-bold ml-1">${state.totalLikes}</span></div>
209
+ </div>
210
+ `;
211
+ };
212
+
213
+ // 3. Render Full Monster Container (Initial or Full Update)
214
+ // Now positioned stats to TOP
215
+ const renderMonsterSection = (currentUserProgress, classSize, userProfile) => {
216
+ const state = calculateMonsterState(currentUserProgress, classSize, userProfile);
217
+
218
+ // Left sidebar position: Responsive
219
+ // Mobile: Top Left (in header space)
220
+ // Desktop: Fixed Left Sidebar
221
+ return `
222
+ <div id="monster-container-fixed" data-monster-id="${state.monster.id}" data-scale="${state.currentScale}"
223
+ class="fixed top-2 left-2 z-50 flex flex-col items-center group pointer-events-none sm:pointer-events-auto w-24 h-24 sm:w-32 sm:h-32 md:top-32 md:left-8">
224
+
225
+ <!-- Stats Tooltip (Moved to Top) -->
226
+ <div id="monster-stats-content" class="absolute bottom-full mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gray-900/90 backdrop-blur text-xs text-slate-300 p-3 rounded-xl border border-slate-700 text-left pointer-events-auto shadow-2xl min-w-[100px]">
227
+ ${renderMonsterStats(state)}
228
+ </div>
229
+
230
+ <!-- Walking Container (Handles Movement Only) -->
231
+ <div class="pixel-art-container relative transform transition-transform duration-500 ease-out origin-center"
232
+ style="transform: scale(${state.currentScale}); animation: patrol-move 15s linear infinite;">
233
+
234
+ <!-- Monster Sprite (Handles Flip & Breathe) -->
235
+ <div class="pixel-monster w-28 h-28 drop-shadow-2xl filter" style="animation: breathe 3s infinite ease-in-out, patrol-flip 15s linear infinite;">
236
+ ${generateMonsterSVG(state.monster)}
237
+ </div>
238
+
239
+ <!-- Level Indicator (No Flip) -->
240
+ <div class="absolute -bottom-2 -right-2 bg-gray-900/90 text-xs text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-500/50 font-mono font-bold transform scale-75 origin-top-left whitespace-nowrap">
241
+ Lv.${1 + state.totalCompleted}
242
+ </div>
243
+ </div>
244
+
245
+ <!-- Evolution Prompt -->
246
+ ${state.canEvolve ? `
247
+ <div id="evolution-prompt" class="absolute top-full mt-2 pointer-events-auto animate-bounce z-50">
248
+ <div class="flex flex-col items-center">
249
+ <div class="bg-gray-900/90 text-pink-200 text-xs py-2 px-3 rounded-xl border border-pink-500/30 shadow-lg text-center font-bold mb-1 backdrop-blur-sm whitespace-nowrap">
250
+ 咦,小怪獸的樣子<br>正在發生變化...
251
+ </div>
252
+ <button onclick="window.triggerEvolution(${state.actualStage}, ${state.actualStage + 1}, ${state.totalLikes}, ${classSize}, '${state.monster.id}')"
253
+ class="bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white text-sm font-black py-2 px-6 rounded-full shadow-[0_0_15px_rgba(236,72,153,0.8)] border border-white/30 transition-all hover:scale-110 active:scale-95">
254
+ 進化!
255
+ </button>
256
+ </div>
257
+ </div>
258
+ ` : ''}
259
+ </div>
260
+
261
+ <style>
262
+ @keyframes breathe {
263
+ 0%, 100% { transform: translateY(0); filter: brightness(1); }
264
+ 50% { transform: translateY(-3px); filter: brightness(1.1); }
265
+ }
266
+ @keyframes patrol-move {
267
+ 0% { transform: translateX(0); }
268
+ 45% { transform: translateX(120px); }
269
+ 55% { transform: translateX(120px); }
270
+ 95% { transform: translateX(0); }
271
+ 100% { transform: translateX(0); }
272
+ }
273
+ @keyframes patrol-flip {
274
+ 0% { transform: scaleX(1); }
275
+ 49% { transform: scaleX(1); }
276
+ 50% { transform: scaleX(-1); }
277
+ 99% { transform: scaleX(-1); }
278
+ 100% { transform: scaleX(1); }
279
+ }
280
+ </style>
281
+ `;
282
+ };
283
+
284
+ // Inject Initial Monster UI
285
+ const monsterContainerId = 'monster-ui-layer';
286
+ let monsterContainer = document.getElementById(monsterContainerId);
287
+ if (!monsterContainer) {
288
+ monsterContainer = document.createElement('div');
289
+ monsterContainer.id = monsterContainerId;
290
+ document.body.appendChild(monsterContainer);
291
+ }
292
+
293
+ // Initial Render
294
+ let classSize = 1;
295
+ let userProfile = {};
296
+ try {
297
+ classSize = await getClassSize(roomCode);
298
+ userProfile = await getUser(userId) || {};
299
+ } catch (e) { console.error("Fetch stats error", e); }
300
+
301
+ monsterContainer.innerHTML = renderMonsterSection(userProgress, classSize, userProfile);
302
+
303
+ // Setup Real-time Subscription
304
+ if (window.currentProgressUnsub) window.currentProgressUnsub();
305
+ window.currentProgressUnsub = subscribeToUserProgress(userId, (newProgressMap) => {
306
+ // Merge updates
307
+ const updatedProgress = { ...userProgress, ...newProgressMap };
308
+
309
+ // Smart Update: Check if visual refresh is needed
310
+ const newState = calculateMonsterState(updatedProgress, classSize, userProfile);
311
+ const fixedContainer = document.getElementById('monster-container-fixed');
312
+ const currentMonsterId = fixedContainer?.getAttribute('data-monster-id');
313
+
314
+ // Tolerance for scale check usually not needed if we want scale to update visuals immediately?
315
+ // Actually, scale change usually means totalCompleted changed.
316
+ // If we want smooth growth, replacing DOM resets animation which looks slight jumpy but acceptable.
317
+ // But the user complained about "position reset" (walk cycle reset).
318
+
319
+ if (fixedContainer && String(currentMonsterId) === String(newState.monster.id)) {
320
+ // Monster ID is same (no evolution/devolution).
321
+ // Just update stats tooltip
322
+ const statsContainer = document.getElementById('monster-stats-content');
323
+ if (statsContainer) {
324
+ statsContainer.innerHTML = renderMonsterStats(newState);
325
+ }
326
+
327
+ // What if level up (scale change)?
328
+ // If we don't replace DOM, scale won't update in style attribute.
329
+ // We should update the style manually.
330
+ const artContainer = fixedContainer.querySelector('.pixel-art-container');
331
+ if (artContainer && newState.currentScale) {
332
+ // Update animation with new scale
333
+ // Note: Modifying 'transform' directly might conflict with keyframes unless keyframes use relative or we update style variable.
334
+ // Keyframes use: scale(${currentScale}). This is hardcoded in specific keyframes string in <style>.
335
+ // We can't easily update keyframes dynamic values without replacing style block.
336
+
337
+ // If totalCompleted changed (level up), user *might* accept a reset because they levelled up.
338
+ // But simply giving a heart shouldn't reset.
339
+
340
+ const oldTotal = parseInt(fixedContainer.querySelector('.bg-gray-900\\/90')?.textContent?.replace('Lv.', '') || '1') - 1;
341
+ if (oldTotal !== newState.totalCompleted) {
342
+ // Level changed -> Scale changed -> Re-render full (reset animation is fine for Level Up)
343
+ monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile);
344
+ }
345
+ }
346
+ } else {
347
+ // Monster changed or clean slate -> Full Render
348
+ monsterContainer.innerHTML = renderMonsterSection(updatedProgress, classSize, userProfile);
349
+ }
350
+ });
351
+
352
+ // Accordion Layout
353
+ return `
354
+ <div class="min-h-screen p-4 pb-32 max-w-md mx-auto sm:max-w-4xl pt-24 sm:pt-4">
355
+ <header class="flex justify-end items-center mb-6 sticky top-0 bg-slate-900/95 backdrop-blur z-20 py-4 px-2 -mx-2 border-b border-gray-800">
356
+ <div class="flex flex-col items-end">
357
+ <div class="flex items-center space-x-2">
358
+ <div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
359
+ <span class="text-gray-400 text-sm truncate max-w-[150px]">${nickname}</span>
360
+ </div>
361
+ <div class="text-xs text-gray-500 mt-1">教室: <span class="font-mono text-cyan-400 font-bold">${roomCode}</span></div>
362
+ </div>
363
+ <button onclick="window.logout()" class="text-gray-500 hover:text-red-400 transition-colors p-2" title="登出">
364
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
365
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
366
+ </svg>
367
+ </button>
368
+ </header>
369
+
370
+ <div class="space-y-4">
371
+ ${['beginner', 'intermediate', 'advanced'].map(level => {
372
+ return renderLevelGroup(level, levelGroups[level] || [], userProgress, levelNames);
373
+ }).join('')}
374
+ </div>
375
+
376
+ <!-- Peer Learning FAB -->
377
+ <button onclick="window.openPeerModal()" class="fixed bottom-14 right-4 bg-purple-600 hover:bg-purple-500 text-white rounded-full p-4 shadow-xl shadow-purple-600/40 transition-transform hover:scale-110 active:scale-90 z-40 flex items-center space-x-2"
378
+ title="查看同學作業">
379
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
380
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0z" />
381
+ </svg>
382
+ <span class="font-bold text-sm hidden sm:inline">查看同學提示詞</span>
383
+ </button>
384
+
385
+ <!-- Credits Footer -->
386
+ <div class="fixed bottom-1 right-2 z-30 text-[10px] text-gray-400 font-mono text-right pointer-events-none sm:pointer-events-auto select-none opacity-80 hover:opacity-100 transition-opacity">
387
+ <div>程式設計者:新竹縣精華國中 藍星宇老師</div>
388
+ <div>教育社群:<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" class="text-gray-300 hover:text-white pointer-events-auto">萬物皆數</a></div>
389
+ </div>
390
+ </div>
391
+ `;
392
+ }
393
+
394
+ export function setupStudentEvents() {
395
+ // Start Level Logic
396
+ window.startLevel = async (challengeId, link) => {
397
+ // Open link
398
+ window.open(link, '_blank');
399
+
400
+ // Call service to update status
401
+ const roomCode = localStorage.getItem('vibecoding_room_code');
402
+ const userId = localStorage.getItem('vibecoding_user_id');
403
+
404
+ if (roomCode && userId) {
405
+ try {
406
+ await startChallenge(userId, roomCode, challengeId);
407
+ // Reload view to show Input State
408
+ // Ideally we should use state management, but checking URL hash or re-rendering works
409
+ const app = document.querySelector('#app');
410
+ app.innerHTML = await renderStudentView();
411
+ // Re-attach events (recursion safety check needed? No, navigateTo does this usually, but here we manually re-render)
412
+ // Or better: trigger a custom event or call navigateTo functionality?
413
+ // Simple re-render is fine for now.
414
+ } catch (e) {
415
+ console.error("Start challenge failed", e);
416
+ }
417
+ }
418
+ };
419
+
420
+ window.submitLevel = async (challengeId) => {
421
+ const input = document.getElementById(`input-${challengeId}`);
422
+ const errorMsg = document.getElementById(`error-${challengeId}`);
423
+ const prompt = input.value;
424
+ const roomCode = localStorage.getItem('vibecoding_room_code');
425
+ const userId = localStorage.getItem('vibecoding_user_id');
426
+
427
+ if (!participantDataCheck(roomCode, userId)) return;
428
+
429
+ if (prompt.trim().length < 5) {
430
+ errorMsg.classList.remove('hidden');
431
+ input.classList.add('border-red-500');
432
+ return;
433
+ }
434
+
435
+ errorMsg.classList.add('hidden');
436
+ input.classList.remove('border-red-500');
437
+
438
+ // Show loading state on button
439
+ const container = input.parentElement;
440
+ const btn = container.querySelector('button');
441
+ const originalText = btn.textContent;
442
+ btn.textContent = "提交中...";
443
+ btn.disabled = true;
444
+
445
+ try {
446
+ await submitPrompt(userId, roomCode, challengeId, prompt);
447
+
448
+ btn.textContent = "✓ 已通關";
449
+ btn.classList.add("bg-green-600");
450
+
451
+ // Fetch latest progress to ensure correct count
452
+ const { getUserProgress } = await import("../services/classroom.js");
453
+ const newProgress = await getUserProgress(userId);
454
+
455
+ // Re-render the level group to update count and all-clear status
456
+ const challenge = cachedChallenges.find(c => c.id === challengeId);
457
+ const level = challenge.level;
458
+
459
+ const levelGroups = {
460
+ beginner: cachedChallenges.filter(c => c.level === 'beginner'),
461
+ intermediate: cachedChallenges.filter(c => c.level === 'intermediate'),
462
+ advanced: cachedChallenges.filter(c => c.level === 'advanced')
463
+ };
464
+ const levelNames = {
465
+ beginner: "初級 (Beginner)",
466
+ intermediate: "中級 (Intermediate)",
467
+ advanced: "高級 (Advanced)"
468
+ };
469
+
470
+ const newGroupHTML = renderLevelGroup(level, levelGroups[level], newProgress, levelNames);
471
+ const detailEl = document.getElementById(`details-group-${level}`);
472
+
473
+ if (detailEl) {
474
+ detailEl.outerHTML = newGroupHTML;
475
+ } else {
476
+ const app = document.querySelector('#app');
477
+ app.innerHTML = await renderStudentView();
478
+ }
479
+
480
+ } catch (error) {
481
+ console.error(error);
482
+ btn.textContent = originalText;
483
+ btn.disabled = false;
484
+ alert("提交失敗: " + error.message);
485
+ }
486
+ };
487
+
488
+ window.resetLevel = async (challengeId) => {
489
+ if (!confirm("確定要重置這一題的進度嗎?(提示詞將會保留,但狀態會變回進行中)")) return;
490
+
491
+ const roomCode = localStorage.getItem('vibecoding_room_code');
492
+ const userId = localStorage.getItem('vibecoding_user_id');
493
+
494
+ try {
495
+ // Import and call resetProgress (Need to make sure it is imported or available globally?
496
+ // Ideally import it. But setupStudentEvents is in module scope so imports are available.
497
+ // Wait, import 'resetProgress' is not in the top import list yet. I need to add it.)
498
+ // Let's assume I will update the import in the next step or use the global trick if needed.
499
+ // But I should edit the import first.
500
+ // For now, let's assume it is there. I will add it to the import list in a parallel or subsequent edit.
501
+
502
+ // Checking imports above... I see 'getUserProgress' but not 'resetProgress'. I must update imports.
503
+ // I'll do it in a separate edit step to be safe.
504
+
505
+ // For now, just the logic:
506
+ const { resetProgress } = await import("../services/classroom.js"); // Dynamic import to avoid changing top file lines again?
507
+ // Or just rely on previous 'replace' having updated the file?
508
+ // Actually, I should update the top import.
509
+
510
+ await resetProgress(userId, roomCode, challengeId);
511
+
512
+ const app = document.querySelector('#app');
513
+ app.innerHTML = await renderStudentView();
514
+
515
+ } catch (e) {
516
+ console.error(e);
517
+ alert("重置失敗");
518
+ }
519
+ };
520
+ }
521
+
522
+ function participantDataCheck(roomCode, userId) {
523
+ if (!roomCode || !userId) {
524
+ alert("連線資訊遺失,請重新登入");
525
+ window.location.reload();
526
+ return false;
527
+ }
528
+ return true;
529
+ }
530
+
531
+ window.logout = () => {
532
+ if (!confirm("確定要登出嗎?")) return;
533
+ localStorage.removeItem('vibecoding_user_id');
534
+ localStorage.removeItem('vibecoding_room_code');
535
+ localStorage.removeItem('vibecoding_nickname');
536
+ window.location.reload();
537
+ };
538
+
539
+ // Peer Learning Modal Logic
540
+ function renderPeerModal() {
541
+ // We need to re-fetch challenges for the dropdown?
542
+ // They are cached in 'cachedChallenges' module variable
543
+ let optionsHtml = '<option value="" disabled selected>選擇題目...</option>';
544
+ if (cachedChallenges.length > 0) {
545
+ optionsHtml += cachedChallenges.map(c =>
546
+ `<option value="${c.id}">[${c.level}] ${c.title}</option>`
547
+ ).join('');
548
+ }
549
+
550
+ return `
551
+ <div id="peer-modal" class="fixed inset-0 bg-black bg-opacity-80 backdrop-blur-sm hidden flex items-center justify-center z-50 p-4">
552
+ <div class="bg-gray-800 rounded-2xl w-full max-w-md h-[80vh] flex flex-col border border-gray-700 shadow-2xl">
553
+ <div class="p-6 border-b border-gray-700 flex justify-between items-center">
554
+ <h3 class="text-xl font-bold text-white">同學的成功提示詞</h3>
555
+ <button onclick="closePeerModal()" class="text-gray-400 hover:text-white">✕</button>
556
+ </div>
557
+
558
+ <div class="p-4 bg-gray-900 border-b border-gray-700">
559
+ <select id="peer-challenge-select" onchange="loadPeerPrompts(this.value)" class="w-full bg-gray-800 border border-gray-600 rounded p-2 text-white">
560
+ ${optionsHtml}
561
+ </select>
562
+ </div>
563
+
564
+ <div class="p-4 flex-1 overflow-y-auto space-y-4" id="peer-prompts-container">
565
+ <div class="text-center text-gray-500 mt-10">請選擇一個題目來查看</div>
566
+ </div>
567
+ </div>
568
+ </div>
569
+ `;
570
+ }
571
+
572
+ window.openPeerModal = () => {
573
+ const existing = document.getElementById('peer-modal');
574
+ if (existing) existing.remove();
575
+
576
+ const div = document.createElement('div');
577
+ div.innerHTML = renderPeerModal();
578
+ document.body.appendChild(div.firstElementChild);
579
+
580
+ document.getElementById('peer-modal').classList.remove('hidden');
581
+ };
582
+
583
+ window.closePeerModal = () => {
584
+ document.getElementById('peer-modal').classList.add('hidden');
585
+ };
586
+
587
+ // Helper to render a level group (Accordion)
588
+ function renderLevelGroup(level, tasks, userProgress, levelNames) {
589
+ const detailsId = `details-group-${level}`;
590
+ const existingDetails = document.getElementById(detailsId);
591
+ let isOpenStr = '';
592
+
593
+ if (existingDetails) {
594
+ if (existingDetails.hasAttribute('open')) isOpenStr = 'open';
595
+ } else {
596
+ // Initial Load defaults
597
+ if (level === 'beginner') isOpenStr = 'open';
598
+ }
599
+
600
+ // Count completed
601
+ const completedCount = tasks.filter(t => userProgress[t.id]?.status === 'completed').length;
602
+
603
+ return `
604
+ <details id="${detailsId}" class="group border border-gray-700 rounded-xl bg-gray-800/30 overflow-hidden transition-all" ${isOpenStr}>
605
+ <summary class="flex items-center justify-between p-4 cursor-pointer bg-gray-800/80 hover:bg-gray-700 transition-colors select-none">
606
+ <div class="flex items-center space-x-3">
607
+ <h3 class="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-cyan-400">
608
+ ${levelNames[level]}
609
+ </h3>
610
+ ${completedCount === tasks.length && tasks.length > 0 ? '<span class="text-yellow-500 text-xs border border-yellow-500/50 px-2 py-0.5 rounded-full">ALL CLEAR</span>' : ''}
611
+ </div>
612
+ <div class="flex items-center space-x-2">
613
+ <span class="text-xs text-gray-400 bg-gray-900 px-2 py-1 rounded-full">${completedCount} / ${tasks.length}</span>
614
+ <svg class="w-5 h-5 text-gray-400 transform group-open:rotate-180 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
615
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
616
+ </svg>
617
+ </div>
618
+ </summary>
619
+ <div class="p-4 pt-0 grid grid-cols-1 gap-4 mt-4">
620
+ ${tasks.length > 0 ? tasks.map(c => renderTaskCard(c, userProgress)).join('') : '<div class="text-gray-500 text-sm italic">本區段尚無題目</div>'}
621
+ </div>
622
+ </details>
623
+ `;
624
+ }
625
+
626
+ window.loadPeerPrompts = async (challengeId) => {
627
+ const container = document.getElementById('peer-prompts-container');
628
+ container.innerHTML = '<div class="text-center text-gray-400 py-10">載入中...</div>';
629
+
630
+ const roomCode = localStorage.getItem('vibecoding_room_code');
631
+ const prompts = await getPeerPrompts(roomCode, challengeId);
632
+
633
+ if (prompts.length === 0) {
634
+ container.innerHTML = '<div class="text-center text-gray-500 py-10">尚無同學提交此關卡或您無權限查看(需相同教室代碼)</div>';
635
+ return;
636
+ }
637
+
638
+ container.innerHTML = prompts.map(p => {
639
+ const currentUserId = localStorage.getItem('vibecoding_user_id');
640
+ const isLiked = p.likedBy && p.likedBy.includes(currentUserId);
641
+
642
+ return `
643
+ <div class="bg-gray-700/30 p-4 rounded-xl border border-gray-600">
644
+ <div class="flex items-center justify-between mb-2">
645
+ <div class="flex items-center space-x-2">
646
+ <div class="w-6 h-6 rounded-full bg-cyan-600 flex items-center justify-center text-xs font-bold text-white">
647
+ ${p.nickname[0]}
648
+ </div>
649
+ <span class="font-bold text-cyan-300 text-sm">${p.nickname}</span>
650
+ <span class="text-gray-500 text-xs">${new Date(p.timestamp.seconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
651
+ </div>
652
+
653
+ <button onclick="handleLike('${p.id}', '${p.userId}')"
654
+ class="flex items-center space-x-1 px-2 py-1 rounded-full transition-colors ${isLiked ? 'bg-pink-900/50 text-pink-400' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}">
655
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ${isLiked ? 'fill-current' : 'none'}" viewBox="0 0 24 24" stroke="currentColor" fill="${isLiked ? 'currentColor' : 'none'}">
656
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
657
+ </svg>
658
+ <span class="text-xs font-bold">${p.likes}</span>
659
+ </button>
660
+ </div>
661
+ <p class="text-gray-300 font-mono text-sm bg-black/20 p-3 rounded-lg border border-gray-700/50 whitespace-pre-wrap">${p.prompt}</p>
662
+ </div>
663
+ `}).join('');
664
+
665
+ // Attach challenge title for notification context
666
+ window.currentPeerChallengeTitle = document.querySelector(`#peer-challenge-select option[value="${challengeId}"]`).text;
667
+ };
668
+
669
+ // Like Handler
670
+ window.handleLike = async (progressId, targetUserId) => {
671
+ const userId = localStorage.getItem('vibecoding_user_id');
672
+ const nickname = localStorage.getItem('vibecoding_nickname');
673
+ const challengeTitle = window.currentPeerChallengeTitle || '挑戰';
674
+
675
+ // Optimistic UI update could go here, but for simplicity let's re-load or just fire and forget (the view won't update until reload currently)
676
+ // To make it responsive, we should probably manually toggle the class on the button immediately.
677
+ // For now, let's just call service and reload the list to see updated count.
678
+
679
+ // Better UX: Find button and toggle 'processing' state?
680
+ // Let's just reload the list for data consistency.
681
+ const { toggleLike } = await import("../services/classroom.js");
682
+ await toggleLike(progressId, userId, nickname, targetUserId, challengeTitle);
683
+
684
+ // Reload to refresh count
685
+ const select = document.getElementById('peer-challenge-select');
686
+ if (select && select.value) {
687
+ loadPeerPrompts(select.value);
688
+ }
689
+ };
690
+
691
+ window.triggerEvolution = async (currentStage, nextStage, likes, classSize, currentMonsterId) => {
692
+ // 1. Hide Prompt
693
+ const prompt = document.getElementById('evolution-prompt');
694
+ if (prompt) prompt.style.display = 'none';
695
+
696
+ try {
697
+ const { getNextMonster, generateMonsterSVG, MONSTER_STAGES } = await import("../utils/monsterUtils.js");
698
+ const { updateUserMonster, getHigherStageCount } = await import("../services/classroom.js");
699
+
700
+ // Calculate Ranking Percentile
701
+ const roomCode = localStorage.getItem('vibecoding_room_code');
702
+ let percentile = 1.0;
703
+
704
+ // Query how many already at next stage (or same level but evolved)
705
+ // Actually, we want to know how many have *completed* this stage essentially.
706
+ // Since we trigger evolution AFTER completion, anyone AT or ABOVE nextStage has completed it.
707
+ // So we count how many are >= nextStage.
708
+ // Wait, if I am the first to evolve to stage 1, count >= 1 is 0.
709
+ // So rank is (0 + 1) / classSize.
710
+
711
+ try {
712
+ const higherCount = await getHigherStageCount(roomCode, nextStage);
713
+ percentile = higherCount / classSize;
714
+ console.log(`Evolution Rank: ${higherCount}/${classSize} (Percentile: ${percentile.toFixed(2)})`);
715
+ } catch (e) {
716
+ console.error("Rank calc failed", e);
717
+ }
718
+
719
+ // Calculate Stage-Specific Likes
720
+ // We need to fetch challenges to filter by level...
721
+ // cachedChallenges is available in module scope
722
+ // We need user progress...
723
+ // We can't easily get it here without re-fetching or passing it in.
724
+ // But we have 'likes' passed in, which is TOTAL likes.
725
+ // Re-fetching progress is safer.
726
+ const userId = localStorage.getItem('vibecoding_user_id');
727
+ const { getUserProgress } = await import("../services/classroom.js");
728
+ const progress = await getUserProgress(userId);
729
+
730
+ let stageLikes = 0;
731
+ // Map stage to challenge level
732
+ // Stage 0 -> 1 requires Beginner (Level 1) likes
733
+ // Stage 1 -> 2 requires Intermediate (Level 2) likes
734
+ // Stage 2 -> 3 requires Advanced (Level 3) likes
735
+
736
+ // Note: nextStage parameter is the TARGET stage.
737
+ // If nextStage is 1 (Egg->Baby), we count Beginner likes.
738
+
739
+ const targetLevelMap = {
740
+ 1: 'beginner',
741
+ 2: 'intermediate',
742
+ 3: 'advanced'
743
+ };
744
+ const targetLevel = targetLevelMap[nextStage];
745
+
746
+ if (targetLevel) {
747
+ stageLikes = cachedChallenges
748
+ .filter(c => c.level === targetLevel)
749
+ .reduce((acc, c) => acc + (progress[c.id]?.likes || 0), 0);
750
+ }
751
+ console.log(`Stage ${nextStage} Likes: ${stageLikes} (Total: ${likes})`);
752
+
753
+ // Calculate Next Monster with Lineage & Ranking
754
+ // IMPORTANT: Ensure we pass currentMonsterId to enforce lineage!
755
+ // We pass 'stageLikes' instead of total 'likes' now
756
+ const nextMonster = getNextMonster(nextStage, stageLikes, classSize, currentMonsterId, percentile);
757
+
758
+ console.log("Evolving from:", currentMonsterId, "to:", nextMonster.name);
759
+
760
+ const container = document.querySelector('#monster-container-fixed .pixel-monster');
761
+ const containerWrapper = document.querySelector('#monster-container-fixed .pixel-art-container');
762
+
763
+ // Stop breathing animation
764
+ container.style.animation = 'none';
765
+
766
+ // --- ANIMATION SEQUENCE ---
767
+ let count = 0;
768
+ const maxFlickers = 12; // Increased duration
769
+ let speed = 300;
770
+
771
+ const currentMonster = MONSTER_DEFS.find(m => m.id === currentMonsterId) || getNextMonster(currentStage, 0, 0, currentMonsterId);
772
+ const svgCurrent = generateMonsterSVG(currentMonster);
773
+ const svgNext = generateMonsterSVG(nextMonster);
774
+
775
+ const setFrame = (svg, isSilhouette) => {
776
+ container.innerHTML = svg;
777
+ container.style.filter = isSilhouette ? 'brightness(0)' : 'none';
778
+ };
779
+
780
+ const playFlicker = () => {
781
+ const isNext = count % 2 === 1;
782
+ setFrame(isNext ? svgNext : svgCurrent, true);
783
+ count++;
784
+
785
+ if (count < maxFlickers) {
786
+ speed *= 0.85;
787
+ setTimeout(playFlicker, speed);
788
+ } else {
789
+ // Final Reveal
790
+ setTimeout(() => {
791
+ setFrame(svgNext, true); // Hold silhouette
792
+
793
+ setTimeout(() => {
794
+ // Reveal Color with Flash
795
+ containerWrapper.style.transition = 'filter 0.8s ease-out';
796
+ containerWrapper.style.filter = 'drop-shadow(0 0 30px #ffffff) brightness(1.5)';
797
+
798
+ // Force SVG update to next monster for final state
799
+ setFrame(svgNext, false);
800
+
801
+ setTimeout(async () => {
802
+ containerWrapper.style.filter = 'none';
803
+ // DB Update with Monster ID
804
+ const userId = localStorage.getItem('vibecoding_user_id');
805
+ await updateUserMonster(userId, nextStage, nextMonster.id);
806
+
807
+ // Soft refresh to prevent screen flicker
808
+ const app = document.querySelector('#app');
809
+ if (app) app.innerHTML = await renderStudentView();
810
+ // Also need to ensure monster container is updated (handle in renderStudentView)
811
+ }, 1200);
812
+ }, 1000);
813
+ }, 300);
814
+ }
815
+ };
816
+
817
+ playFlicker();
818
+
819
+ } catch (e) {
820
+ console.error(e);
821
+ alert("進化失敗: " + (e.message || "未知錯誤"));
822
+ // Re-render instead of reload to keep state
823
+ const app = document.querySelector('#app');
824
+ if (app) app.innerHTML = await renderStudentView();
825
+ }
826
+ };