KEXEL commited on
Commit
afb40ea
·
verified ·
1 Parent(s): 261cdcd
Files changed (1) hide show
  1. arcanum.html +1770 -0
arcanum.html ADDED
@@ -0,0 +1,1770 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Arcanum — Memory of the Spheres</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;900&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
+
13
+ :root {
14
+ --bg-deep: #0a1620;
15
+ --bg-mid: #122838;
16
+ --bg-light: #1c3a52;
17
+ --gold: #e8b65a;
18
+ --gold-bright: #f4d27a;
19
+ --gold-pale: #fff5d6;
20
+ --gold-deep: #a87a30;
21
+ --jade: #3dd68c;
22
+ --jade-bright: #6ff0b0;
23
+ --coral: #ff6b6b;
24
+ --coral-bright: #ff9a8b;
25
+ --cream: #f5e6d3;
26
+ --muted: #8ba3b8;
27
+ --border: rgba(232, 182, 90, 0.22);
28
+ }
29
+
30
+ html, body { height: 100%; }
31
+ body {
32
+ font-family: 'Outfit', sans-serif;
33
+ background: var(--bg-deep);
34
+ color: var(--cream);
35
+ min-height: 100vh;
36
+ position: relative;
37
+ overflow-x: hidden;
38
+ line-height: 1.5;
39
+ }
40
+
41
+ /* ====== Cosmic background ====== */
42
+ .cosmos-bg {
43
+ position: fixed;
44
+ inset: 0;
45
+ z-index: -2;
46
+ background:
47
+ radial-gradient(ellipse at 15% 20%, rgba(61, 214, 140, 0.08), transparent 50%),
48
+ radial-gradient(ellipse at 85% 80%, rgba(232, 182, 90, 0.1), transparent 50%),
49
+ radial-gradient(ellipse at center, rgba(28, 58, 82, 0.55), transparent 70%),
50
+ linear-gradient(180deg, #0a1620 0%, #050d15 100%);
51
+ }
52
+ .aurora {
53
+ position: fixed;
54
+ inset: -30%;
55
+ z-index: -2;
56
+ background:
57
+ radial-gradient(circle at 25% 30%, rgba(61, 214, 140, 0.18), transparent 35%),
58
+ radial-gradient(circle at 75% 70%, rgba(232, 182, 90, 0.16), transparent 35%),
59
+ radial-gradient(circle at 50% 50%, rgba(255, 107, 107, 0.06), transparent 45%);
60
+ filter: blur(70px);
61
+ animation: auroraDrift 35s ease-in-out infinite;
62
+ }
63
+ @keyframes auroraDrift {
64
+ 0%, 100% { transform: translate(0,0) rotate(0deg); }
65
+ 33% { transform: translate(4%, -3%) rotate(120deg); }
66
+ 66% { transform: translate(-3%, 4%) rotate(240deg); }
67
+ }
68
+ #starfield {
69
+ position: fixed;
70
+ inset: 0;
71
+ z-index: -1;
72
+ pointer-events: none;
73
+ }
74
+
75
+ /* ====== Main layout ====== */
76
+ main {
77
+ max-width: 1280px;
78
+ margin: 0 auto;
79
+ padding: 28px 32px 24px;
80
+ display: flex;
81
+ flex-direction: column;
82
+ min-height: 100vh;
83
+ gap: 24px;
84
+ }
85
+
86
+ /* ====== Header ====== */
87
+ .app-header {
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: space-between;
91
+ flex-wrap: wrap;
92
+ gap: 20px;
93
+ padding-bottom: 22px;
94
+ border-bottom: 1px solid var(--border);
95
+ position: relative;
96
+ }
97
+ .app-header::after {
98
+ content: '';
99
+ position: absolute;
100
+ left: 0; right: 0; bottom: -1px;
101
+ height: 1px;
102
+ background: linear-gradient(90deg, transparent, var(--gold), transparent);
103
+ opacity: 0.5;
104
+ }
105
+
106
+ .brand {
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 16px;
110
+ }
111
+ .brand-seal {
112
+ width: 56px;
113
+ height: 56px;
114
+ flex-shrink: 0;
115
+ animation: sealRotate 60s linear infinite;
116
+ }
117
+ @keyframes sealRotate {
118
+ to { transform: rotate(360deg); }
119
+ }
120
+ .brand-text h1 {
121
+ font-family: 'Cinzel', serif;
122
+ font-size: 30px;
123
+ font-weight: 900;
124
+ letter-spacing: 0.18em;
125
+ background: linear-gradient(135deg, var(--gold-pale), var(--gold) 50%, var(--gold-deep));
126
+ -webkit-background-clip: text;
127
+ background-clip: text;
128
+ -webkit-text-fill-color: transparent;
129
+ line-height: 1;
130
+ }
131
+ .brand-text p {
132
+ font-size: 11px;
133
+ color: var(--muted);
134
+ letter-spacing: 0.35em;
135
+ text-transform: uppercase;
136
+ margin-top: 6px;
137
+ }
138
+
139
+ .stats-bar {
140
+ display: flex;
141
+ gap: 10px;
142
+ align-items: center;
143
+ flex-wrap: wrap;
144
+ }
145
+ .stat {
146
+ background: rgba(18, 40, 56, 0.55);
147
+ backdrop-filter: blur(12px);
148
+ -webkit-backdrop-filter: blur(12px);
149
+ border: 1px solid var(--border);
150
+ padding: 10px 18px;
151
+ border-radius: 10px;
152
+ min-width: 84px;
153
+ text-align: center;
154
+ transition: all 0.3s;
155
+ position: relative;
156
+ overflow: hidden;
157
+ }
158
+ .stat::before {
159
+ content: '';
160
+ position: absolute;
161
+ inset: 0;
162
+ background: linear-gradient(180deg, rgba(232,182,90,0.08), transparent);
163
+ pointer-events: none;
164
+ }
165
+ .stat-label {
166
+ font-size: 10px;
167
+ color: var(--muted);
168
+ text-transform: uppercase;
169
+ letter-spacing: 0.22em;
170
+ margin-bottom: 4px;
171
+ }
172
+ .stat-value {
173
+ font-family: 'Cinzel', serif;
174
+ font-size: 22px;
175
+ font-weight: 700;
176
+ color: var(--gold-bright);
177
+ line-height: 1;
178
+ transition: color 0.3s;
179
+ }
180
+
181
+ @keyframes streakPulse {
182
+ 0%, 100% { transform: scale(1); box-shadow: 0 0 0 rgba(255,107,107,0); }
183
+ 50% { transform: scale(1.06); box-shadow: 0 0 30px rgba(255,107,107,0.4); }
184
+ }
185
+ .stat.streak-active {
186
+ background: linear-gradient(135deg, rgba(255,107,107,0.18), rgba(232,182,90,0.18));
187
+ border-color: var(--coral);
188
+ animation: streakPulse 1.2s ease-in-out infinite;
189
+ }
190
+ .stat.streak-active .stat-value {
191
+ color: var(--coral-bright);
192
+ text-shadow: 0 0 18px rgba(255,154,139,0.7);
193
+ }
194
+
195
+ /* ====== Buttons ====== */
196
+ .controls {
197
+ display: flex;
198
+ gap: 10px;
199
+ }
200
+ .btn {
201
+ background: rgba(18, 40, 56, 0.55);
202
+ backdrop-filter: blur(12px);
203
+ -webkit-backdrop-filter: blur(12px);
204
+ border: 1px solid var(--border);
205
+ color: var(--cream);
206
+ padding: 11px 18px;
207
+ border-radius: 10px;
208
+ cursor: pointer;
209
+ font-family: 'Outfit', sans-serif;
210
+ font-size: 12px;
211
+ font-weight: 600;
212
+ letter-spacing: 0.12em;
213
+ text-transform: uppercase;
214
+ transition: all 0.25s;
215
+ display: inline-flex;
216
+ align-items: center;
217
+ gap: 8px;
218
+ position: relative;
219
+ overflow: hidden;
220
+ }
221
+ .btn::before {
222
+ content: '';
223
+ position: absolute;
224
+ inset: 0;
225
+ background: linear-gradient(135deg, rgba(232,182,90,0.15), transparent 60%);
226
+ opacity: 0;
227
+ transition: opacity 0.25s;
228
+ }
229
+ .btn:hover {
230
+ border-color: var(--gold);
231
+ transform: translateY(-2px);
232
+ box-shadow: 0 10px 28px rgba(232, 182, 90, 0.18);
233
+ }
234
+ .btn:hover::before { opacity: 1; }
235
+ .btn.active {
236
+ background: linear-gradient(135deg, var(--gold-deep), var(--gold));
237
+ color: var(--bg-deep);
238
+ border-color: var(--gold-bright);
239
+ box-shadow: 0 6px 20px rgba(232,182,90,0.3);
240
+ }
241
+ .btn svg { display: block; }
242
+ .btn-primary {
243
+ background: linear-gradient(135deg, var(--gold-deep), var(--gold));
244
+ color: var(--bg-deep);
245
+ border-color: var(--gold-bright);
246
+ font-weight: 700;
247
+ padding: 14px 28px;
248
+ font-size: 13px;
249
+ }
250
+ .btn-primary:hover {
251
+ background: linear-gradient(135deg, var(--gold), var(--gold-bright));
252
+ box-shadow: 0 12px 35px rgba(232,182,90,0.4);
253
+ }
254
+
255
+ /* ====== Board ====== */
256
+ .board-container {
257
+ flex: 1;
258
+ display: flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ padding: 16px 0;
262
+ min-height: 0;
263
+ }
264
+ .board {
265
+ display: grid;
266
+ gap: 14px;
267
+ perspective: 1400px;
268
+ width: 100%;
269
+ max-width: 480px;
270
+ }
271
+
272
+ .card {
273
+ aspect-ratio: 3 / 4;
274
+ cursor: pointer;
275
+ position: relative;
276
+ transform-style: preserve-3d;
277
+ transition: transform 0.3s;
278
+ }
279
+ .card:not(.flipped):not(.matched):hover {
280
+ transform: translateY(-6px);
281
+ }
282
+ .card-inner {
283
+ position: relative;
284
+ width: 100%;
285
+ height: 100%;
286
+ transition: transform 0.65s cubic-bezier(0.4, 0, 0.2, 1);
287
+ transform-style: preserve-3d;
288
+ }
289
+ .card.flipped .card-inner {
290
+ transform: rotateY(180deg);
291
+ }
292
+
293
+ .card-face {
294
+ position: absolute;
295
+ inset: 0;
296
+ backface-visibility: hidden;
297
+ -webkit-backface-visibility: hidden;
298
+ border-radius: 12px;
299
+ display: flex;
300
+ align-items: center;
301
+ justify-content: center;
302
+ overflow: hidden;
303
+ }
304
+
305
+ /* Card back — ornate alchemical seal */
306
+ .card-back {
307
+ background:
308
+ radial-gradient(circle at center, rgba(232,182,90,0.18) 0%, transparent 55%),
309
+ linear-gradient(135deg, #1a3447 0%, #0a1c28 100%);
310
+ border: 1.5px solid rgba(232, 182, 90, 0.35);
311
+ box-shadow:
312
+ 0 6px 22px rgba(0,0,0,0.5),
313
+ inset 0 1px 0 rgba(255,255,255,0.04);
314
+ transition: all 0.3s;
315
+ }
316
+ .card:not(.flipped):not(.matched):hover .card-back {
317
+ border-color: var(--gold);
318
+ box-shadow:
319
+ 0 12px 32px rgba(0,0,0,0.5),
320
+ 0 0 30px rgba(232,182,90,0.25),
321
+ inset 0 1px 0 rgba(255,255,255,0.08);
322
+ }
323
+ .card-back::before {
324
+ content: '';
325
+ position: absolute;
326
+ inset: 6px;
327
+ border: 1px solid rgba(232,182,90,0.22);
328
+ border-radius: 8px;
329
+ pointer-events: none;
330
+ }
331
+ .card-back-seal {
332
+ width: 70%;
333
+ height: 70%;
334
+ opacity: 0.85;
335
+ }
336
+
337
+ /* Card front — revealed sigil */
338
+ .card-front {
339
+ transform: rotateY(180deg);
340
+ background:
341
+ radial-gradient(circle at center, rgba(232,182,90,0.28) 0%, rgba(28,58,82,0.5) 70%),
342
+ linear-gradient(135deg, #243f54, #122434);
343
+ border: 1.5px solid var(--gold);
344
+ box-shadow:
345
+ 0 6px 22px rgba(232, 182, 90, 0.25),
346
+ inset 0 0 28px rgba(232, 182, 90, 0.12);
347
+ transition: all 0.3s;
348
+ }
349
+ .card-front::before {
350
+ content: '';
351
+ position: absolute;
352
+ inset: 6px;
353
+ border: 1px solid rgba(232,182,90,0.4);
354
+ border-radius: 8px;
355
+ pointer-events: none;
356
+ }
357
+ .card-symbol {
358
+ font-size: clamp(34px, 7vw, 64px);
359
+ color: var(--gold-pale);
360
+ text-shadow:
361
+ 0 0 18px rgba(244, 210, 122, 0.9),
362
+ 0 0 36px rgba(232, 182, 90, 0.5);
363
+ position: relative;
364
+ z-index: 1;
365
+ line-height: 1;
366
+ }
367
+
368
+ /* Match — float and fade */
369
+ @keyframes floatFade {
370
+ 0% {
371
+ transform: translateY(0) scale(1) rotateY(180deg);
372
+ opacity: 1;
373
+ filter: brightness(1);
374
+ }
375
+ 30% {
376
+ transform: translateY(-12px) scale(1.12) rotateY(180deg);
377
+ opacity: 1;
378
+ filter: brightness(1.5);
379
+ }
380
+ 100% {
381
+ transform: translateY(-90px) scale(0.4) rotateY(180deg);
382
+ opacity: 0;
383
+ filter: brightness(2);
384
+ }
385
+ }
386
+ .card.matched {
387
+ pointer-events: none;
388
+ }
389
+ .card.matched .card-inner {
390
+ animation: floatFade 0.95s cubic-bezier(0.4, 0, 0.2, 1) forwards;
391
+ }
392
+
393
+ /* Miss — shake and flip back */
394
+ @keyframes shake {
395
+ 0%, 100% { transform: rotateY(180deg) translateX(0); }
396
+ 15% { transform: rotateY(180deg) translateX(-10px); }
397
+ 30% { transform: rotateY(180deg) translateX(10px); }
398
+ 45% { transform: rotateY(180deg) translateX(-9px); }
399
+ 60% { transform: rotateY(180deg) translateX(9px); }
400
+ 75% { transform: rotateY(180deg) translateX(-5px); }
401
+ 90% { transform: rotateY(180deg) translateX(5px); }
402
+ }
403
+ .card.miss .card-inner {
404
+ animation: shake 0.55s ease-in-out;
405
+ }
406
+ .card.miss .card-front {
407
+ border-color: var(--coral);
408
+ box-shadow:
409
+ 0 6px 22px rgba(255, 107, 107, 0.4),
410
+ inset 0 0 30px rgba(255, 107, 107, 0.2);
411
+ }
412
+ .card.miss .card-symbol {
413
+ color: var(--coral-bright);
414
+ text-shadow:
415
+ 0 0 18px rgba(255, 154, 139, 0.9),
416
+ 0 0 36px rgba(255, 107, 107, 0.5);
417
+ }
418
+
419
+ /* Card entrance */
420
+ @keyframes cardDeal {
421
+ 0% {
422
+ opacity: 0;
423
+ transform: translateY(30px) rotateY(-180deg) scale(0.8);
424
+ }
425
+ 100% {
426
+ opacity: 1;
427
+ transform: translateY(0) rotateY(0) scale(1);
428
+ }
429
+ }
430
+ .card.dealing {
431
+ animation: cardDeal 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
432
+ }
433
+
434
+ /* ====== Difficulty bar ====== */
435
+ .difficulty-bar {
436
+ display: flex;
437
+ gap: 8px;
438
+ justify-content: center;
439
+ align-items: center;
440
+ padding: 18px 0 6px;
441
+ border-top: 1px solid var(--border);
442
+ flex-wrap: wrap;
443
+ position: relative;
444
+ }
445
+ .difficulty-bar::before {
446
+ content: '';
447
+ position: absolute;
448
+ left: 0; right: 0; top: -1px;
449
+ height: 1px;
450
+ background: linear-gradient(90deg, transparent, var(--gold), transparent);
451
+ opacity: 0.4;
452
+ }
453
+ .difficulty-label {
454
+ font-size: 10px;
455
+ color: var(--muted);
456
+ letter-spacing: 0.3em;
457
+ text-transform: uppercase;
458
+ margin-right: 6px;
459
+ }
460
+
461
+ /* ====== Modal ====== */
462
+ .modal-overlay {
463
+ position: fixed;
464
+ inset: 0;
465
+ background: rgba(5, 13, 21, 0.88);
466
+ backdrop-filter: blur(22px);
467
+ -webkit-backdrop-filter: blur(22px);
468
+ z-index: 100;
469
+ display: flex;
470
+ align-items: center;
471
+ justify-content: center;
472
+ opacity: 0;
473
+ pointer-events: none;
474
+ transition: opacity 0.5s;
475
+ padding: 20px;
476
+ }
477
+ .modal-overlay.active {
478
+ opacity: 1;
479
+ pointer-events: auto;
480
+ }
481
+ .modal {
482
+ background:
483
+ radial-gradient(circle at top, rgba(232,182,90,0.12), transparent 65%),
484
+ linear-gradient(180deg, var(--bg-mid), var(--bg-deep));
485
+ border: 1px solid var(--gold);
486
+ border-radius: 24px;
487
+ padding: 48px 48px 40px;
488
+ text-align: center;
489
+ max-width: 480px;
490
+ width: 100%;
491
+ box-shadow:
492
+ 0 30px 80px rgba(0, 0, 0, 0.6),
493
+ 0 0 100px rgba(232, 182, 90, 0.2);
494
+ transform: scale(0.85) translateY(30px);
495
+ transition: transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1);
496
+ position: relative;
497
+ overflow: hidden;
498
+ }
499
+ .modal::before {
500
+ content: '';
501
+ position: absolute;
502
+ inset: 0;
503
+ background: linear-gradient(135deg, rgba(232,182,90,0.05), transparent 50%);
504
+ pointer-events: none;
505
+ }
506
+ .modal-overlay.active .modal {
507
+ transform: scale(1) translateY(0);
508
+ }
509
+ .modal-rune {
510
+ width: 56px;
511
+ height: 56px;
512
+ margin: 0 auto 12px;
513
+ opacity: 0.85;
514
+ }
515
+ .modal h2 {
516
+ font-family: 'Cinzel', serif;
517
+ font-size: 32px;
518
+ font-weight: 900;
519
+ letter-spacing: 0.12em;
520
+ background: linear-gradient(135deg, var(--gold-pale), var(--gold) 60%, var(--gold-deep));
521
+ -webkit-background-clip: text;
522
+ background-clip: text;
523
+ -webkit-text-fill-color: transparent;
524
+ margin-bottom: 6px;
525
+ line-height: 1.1;
526
+ }
527
+ .modal-subtitle {
528
+ color: var(--muted);
529
+ margin-bottom: 28px;
530
+ letter-spacing: 0.22em;
531
+ text-transform: uppercase;
532
+ font-size: 11px;
533
+ }
534
+
535
+ .stars {
536
+ display: flex;
537
+ justify-content: center;
538
+ gap: 18px;
539
+ margin: 28px 0;
540
+ min-height: 70px;
541
+ align-items: center;
542
+ }
543
+ .star {
544
+ width: 64px;
545
+ height: 64px;
546
+ opacity: 0.25;
547
+ transform: scale(0.7) rotate(-15deg);
548
+ transition: all 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
549
+ filter: drop-shadow(0 0 0 transparent);
550
+ }
551
+ .star.lit {
552
+ opacity: 1;
553
+ transform: scale(1) rotate(0deg);
554
+ filter: drop-shadow(0 0 18px rgba(244, 210, 122, 0.8));
555
+ animation: starGlow 2s ease-in-out infinite;
556
+ }
557
+ @keyframes starGlow {
558
+ 0%, 100% { filter: drop-shadow(0 0 18px rgba(244, 210, 122, 0.8)); }
559
+ 50% { filter: drop-shadow(0 0 28px rgba(244, 210, 122, 1)); }
560
+ }
561
+
562
+ .modal-stats {
563
+ display: grid;
564
+ grid-template-columns: repeat(3, 1fr);
565
+ gap: 12px;
566
+ margin: 24px 0 28px;
567
+ padding: 18px 12px;
568
+ background: rgba(10, 22, 32, 0.6);
569
+ border-radius: 12px;
570
+ border: 1px solid var(--border);
571
+ }
572
+ .modal-stat-value {
573
+ font-family: 'Cinzel', serif;
574
+ font-size: 24px;
575
+ font-weight: 700;
576
+ color: var(--gold-bright);
577
+ line-height: 1;
578
+ margin-bottom: 6px;
579
+ }
580
+ .modal-stat-label {
581
+ font-size: 10px;
582
+ color: var(--muted);
583
+ letter-spacing: 0.22em;
584
+ text-transform: uppercase;
585
+ }
586
+
587
+ /* ====== Floating particles / streak text ====== */
588
+ .particle {
589
+ position: fixed;
590
+ pointer-events: none;
591
+ border-radius: 50%;
592
+ z-index: 50;
593
+ }
594
+ .streak-text {
595
+ position: fixed;
596
+ top: 32%;
597
+ left: 50%;
598
+ transform: translate(-50%, -50%);
599
+ font-family: 'Cinzel', serif;
600
+ font-size: clamp(36px, 6vw, 56px);
601
+ font-weight: 900;
602
+ letter-spacing: 0.18em;
603
+ background: linear-gradient(135deg, var(--coral-bright), var(--gold-bright));
604
+ -webkit-background-clip: text;
605
+ background-clip: text;
606
+ -webkit-text-fill-color: transparent;
607
+ pointer-events: none;
608
+ z-index: 60;
609
+ text-shadow: 0 0 40px rgba(255, 154, 139, 0.5);
610
+ white-space: nowrap;
611
+ }
612
+
613
+ /* ====== Confetti ====== */
614
+ #confetti {
615
+ position: fixed;
616
+ inset: 0;
617
+ pointer-events: none;
618
+ z-index: 200;
619
+ }
620
+
621
+ /* ====== Music indicator pulse ====== */
622
+ .music-wave {
623
+ display: inline-flex;
624
+ gap: 2px;
625
+ align-items: flex-end;
626
+ height: 12px;
627
+ }
628
+ .music-wave span {
629
+ width: 2px;
630
+ background: currentColor;
631
+ border-radius: 1px;
632
+ animation: wave 0.9s ease-in-out infinite;
633
+ }
634
+ .music-wave span:nth-child(1) { height: 60%; animation-delay: 0s; }
635
+ .music-wave span:nth-child(2) { height: 100%; animation-delay: 0.15s; }
636
+ .music-wave span:nth-child(3) { height: 70%; animation-delay: 0.3s; }
637
+ .music-wave span:nth-child(4) { height: 90%; animation-delay: 0.45s; }
638
+ @keyframes wave {
639
+ 0%, 100% { transform: scaleY(0.4); }
640
+ 50% { transform: scaleY(1); }
641
+ }
642
+ .btn:not(.active) .music-wave { display: none; }
643
+ .btn.active .music-icon-static { display: none; }
644
+
645
+ /* ====== Responsive ====== */
646
+ @media (max-width: 820px) {
647
+ main { padding: 20px 16px; gap: 16px; }
648
+ .app-header { gap: 14px; }
649
+ .brand-text h1 { font-size: 24px; letter-spacing: 0.14em; }
650
+ .brand-text p { font-size: 10px; letter-spacing: 0.25em; }
651
+ .brand-seal { width: 46px; height: 46px; }
652
+ .stats-bar { gap: 6px; width: 100%; justify-content: space-between; }
653
+ .stat { padding: 8px 10px; min-width: 0; flex: 1; }
654
+ .stat-value { font-size: 17px; }
655
+ .stat-label { font-size: 9px; letter-spacing: 0.15em; }
656
+ .controls { width: 100%; }
657
+ .controls .btn { flex: 1; justify-content: center; padding: 10px 12px; font-size: 11px; }
658
+ .board { gap: 10px; }
659
+ .difficulty-bar { gap: 6px; }
660
+ .difficulty-label { display: none; }
661
+ .difficulty-bar .btn { padding: 9px 12px; font-size: 11px; }
662
+ .modal { padding: 36px 28px 30px; }
663
+ .modal h2 { font-size: 26px; }
664
+ .star { width: 52px; height: 52px; }
665
+ }
666
+ @media (max-width: 480px) {
667
+ .stats-bar { gap: 4px; }
668
+ .stat { padding: 6px 8px; }
669
+ .stat-value { font-size: 15px; }
670
+ }
671
+
672
+ @media (prefers-reduced-motion: reduce) {
673
+ *, *::before, *::after {
674
+ animation-duration: 0.01ms !important;
675
+ animation-iteration-count: 1 !important;
676
+ transition-duration: 0.01ms !important;
677
+ }
678
+ }
679
+ </style>
680
+ </head>
681
+ <body>
682
+
683
+ <div class="cosmos-bg"></div>
684
+ <div class="aurora"></div>
685
+ <canvas id="starfield"></canvas>
686
+
687
+ <main>
688
+ <header class="app-header">
689
+ <div class="brand">
690
+ <svg class="brand-seal" viewBox="0 0 56 56" aria-hidden="true">
691
+ <defs>
692
+ <linearGradient id="goldGrad" x1="0" y1="0" x2="1" y2="1">
693
+ <stop offset="0" stop-color="#fff5d6"/>
694
+ <stop offset="0.5" stop-color="#e8b65a"/>
695
+ <stop offset="1" stop-color="#a87a30"/>
696
+ </linearGradient>
697
+ </defs>
698
+ <circle cx="28" cy="28" r="26" fill="none" stroke="url(#goldGrad)" stroke-width="1"/>
699
+ <circle cx="28" cy="28" r="20" fill="none" stroke="url(#goldGrad)" stroke-width="0.6" opacity="0.6"/>
700
+ <circle cx="28" cy="28" r="14" fill="none" stroke="url(#goldGrad)" stroke-width="0.5" opacity="0.4"/>
701
+ <path d="M28 4 L28 52 M4 28 L52 28" stroke="url(#goldGrad)" stroke-width="0.4" opacity="0.5"/>
702
+ <path d="M11 11 L45 45 M45 11 L11 45" stroke="url(#goldGrad)" stroke-width="0.3" opacity="0.4"/>
703
+ <circle cx="28" cy="28" r="3.5" fill="url(#goldGrad)"/>
704
+ <circle cx="28" cy="6" r="1.5" fill="url(#goldGrad)"/>
705
+ <circle cx="28" cy="50" r="1.5" fill="url(#goldGrad)"/>
706
+ <circle cx="6" cy="28" r="1.5" fill="url(#goldGrad)"/>
707
+ <circle cx="50" cy="28" r="1.5" fill="url(#goldGrad)"/>
708
+ </svg>
709
+ <div class="brand-text">
710
+ <h1>ARCANUM</h1>
711
+ <p>Memory of the Spheres</p>
712
+ </div>
713
+ </div>
714
+
715
+ <div class="stats-bar">
716
+ <div class="stat" id="streakStat">
717
+ <div class="stat-label">Streak</div>
718
+ <div class="stat-value" id="streakVal">0</div>
719
+ </div>
720
+ <div class="stat">
721
+ <div class="stat-label">Moves</div>
722
+ <div class="stat-value" id="movesVal">0</div>
723
+ </div>
724
+ <div class="stat">
725
+ <div class="stat-label">Pairs</div>
726
+ <div class="stat-value" id="pairsVal">0/8</div>
727
+ </div>
728
+ <div class="stat">
729
+ <div class="stat-label">Time</div>
730
+ <div class="stat-value" id="timeVal">0:00</div>
731
+ </div>
732
+ </div>
733
+
734
+ <div class="controls">
735
+ <button class="btn" id="musicToggle" aria-label="Toggle music">
736
+ <span class="music-icon-static" aria-hidden="true">
737
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
738
+ </span>
739
+ <span class="music-wave" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
740
+ <span id="musicLabel">Music</span>
741
+ </button>
742
+ <button class="btn" id="resetBtn" aria-label="Reset game">
743
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
744
+ <span>Reset</span>
745
+ </button>
746
+ </div>
747
+ </header>
748
+
749
+ <section class="board-container">
750
+ <div class="board" id="board" role="grid" aria-label="Memory game board"></div>
751
+ </section>
752
+
753
+ <footer class="difficulty-bar">
754
+ <span class="difficulty-label">Path</span>
755
+ <button class="btn active" data-diff="apprentice">Apprentice · 4×4</button>
756
+ <button class="btn" data-diff="adept">Adept · 6×4</button>
757
+ <button class="btn" data-diff="master">Master · 6×6</button>
758
+ </footer>
759
+ </main>
760
+
761
+ <div class="modal-overlay" id="modalOverlay" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
762
+ <div class="modal">
763
+ <svg class="modal-rune" viewBox="0 0 56 56" aria-hidden="true">
764
+ <circle cx="28" cy="28" r="24" fill="none" stroke="url(#goldGrad)" stroke-width="1"/>
765
+ <path d="M28 8 L34 28 L52 28 L38 40 L44 56 L28 46 L12 56 L18 40 L4 28 L22 28 Z"
766
+ fill="url(#goldGrad)" opacity="0.85"/>
767
+ </svg>
768
+ <h2 id="modalTitle">Mastery Achieved</h2>
769
+ <p class="modal-subtitle">The cosmos align in your favor</p>
770
+
771
+ <div class="stars" id="starsContainer"></div>
772
+
773
+ <div class="modal-stats">
774
+ <div>
775
+ <div class="modal-stat-value" id="finalMoves">0</div>
776
+ <div class="modal-stat-label">Moves</div>
777
+ </div>
778
+ <div>
779
+ <div class="modal-stat-value" id="finalTime">0:00</div>
780
+ <div class="modal-stat-label">Time</div>
781
+ </div>
782
+ <div>
783
+ <div class="modal-stat-value" id="finalStreak">0</div>
784
+ <div class="modal-stat-label">Best Streak</div>
785
+ </div>
786
+ </div>
787
+
788
+ <button class="btn btn-primary" id="playAgainBtn">Begin Anew</button>
789
+ </div>
790
+ </div>
791
+
792
+ <canvas id="confetti" aria-hidden="true"></canvas>
793
+
794
+ <script>
795
+ /* ============================================================
796
+ ARCANUM — Memory of the Spheres
797
+ A celestial memory-matching game
798
+ ============================================================ */
799
+
800
+ // Sigils used as card faces (celestial / alchemical glyphs)
801
+ const SYMBOLS = ['☉','☽','★','✦','♄','♃','♂','♀','☿','♆','♅','⚸','⚹','✧','☄','❋','⚛','◊'];
802
+
803
+ const DIFFICULTIES = {
804
+ apprentice: { cols: 4, rows: 4, pairs: 8, maxWidth: 480, title: 'Apprentice Complete' },
805
+ adept: { cols: 6, rows: 4, pairs: 12, maxWidth: 660, title: 'Adept Ascended' },
806
+ master: { cols: 6, rows: 6, pairs: 18, maxWidth: 720, title: 'Master of the Arcane' },
807
+ };
808
+
809
+ const STATE = {
810
+ difficulty: 'apprentice',
811
+ cards: [],
812
+ flippedCards: [],
813
+ matchedCount: 0,
814
+ moves: 0,
815
+ streak: 0,
816
+ maxStreak: 0,
817
+ startTime: null,
818
+ elapsedTime: 0,
819
+ timerInterval: null,
820
+ locked: true,
821
+ gameStarted: false,
822
+ gameComplete: false,
823
+ };
824
+
825
+ /* ============================================================
826
+ AUDIO ENGINE — Web Audio API, fully synthesized
827
+ ============================================================ */
828
+ class AudioEngine {
829
+ constructor() {
830
+ this.ctx = null;
831
+ this.masterGain = null;
832
+ this.musicGain = null;
833
+ this.sfxGain = null;
834
+ this.musicPlaying = false;
835
+ this.musicLayers = [];
836
+ this.arpeggioTimeout = null;
837
+ this.reverbNode = null;
838
+ }
839
+
840
+ init() {
841
+ if (this.ctx) return;
842
+ this.ctx = new (window.AudioContext || window.webkitAudioContext)();
843
+ this.masterGain = this.ctx.createGain();
844
+ this.masterGain.gain.value = 0.75;
845
+ this.masterGain.connect(this.ctx.destination);
846
+
847
+ // Simple reverb-like effect using delay + feedback
848
+ this.reverbNode = this.ctx.createDelay(1.0);
849
+ this.reverbNode.delayTime.value = 0.18;
850
+ const feedback = this.ctx.createGain();
851
+ feedback.gain.value = 0.32;
852
+ const reverbWet = this.ctx.createGain();
853
+ reverbWet.gain.value = 0.35;
854
+ this.reverbNode.connect(feedback);
855
+ feedback.connect(this.reverbNode);
856
+ this.reverbNode.connect(reverbWet);
857
+ reverbWet.connect(this.masterGain);
858
+
859
+ this.musicGain = this.ctx.createGain();
860
+ this.musicGain.gain.value = 0.42;
861
+ this.musicGain.connect(this.masterGain);
862
+
863
+ this.sfxGain = this.ctx.createGain();
864
+ this.sfxGain.gain.value = 0.85;
865
+ this.sfxGain.connect(this.masterGain);
866
+ this.sfxGain.connect(this.reverbNode);
867
+ }
868
+
869
+ resume() {
870
+ if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
871
+ }
872
+
873
+ // Bell-like chime for a successful match
874
+ playChime(baseFreq = 523.25) {
875
+ if (!this.ctx) return;
876
+ const now = this.ctx.currentTime;
877
+ const harmonics = [
878
+ { f: baseFreq, type: 'sine', g: 0.32, d: 1.8 },
879
+ { f: baseFreq * 1.5, type: 'sine', g: 0.18, d: 1.4 },
880
+ { f: baseFreq * 2, type: 'triangle', g: 0.12, d: 1.0 },
881
+ { f: baseFreq * 3, type: 'sine', g: 0.06, d: 0.6 },
882
+ ];
883
+ harmonics.forEach(h => {
884
+ const osc = this.ctx.createOscillator();
885
+ const gain = this.ctx.createGain();
886
+ osc.type = h.type;
887
+ osc.frequency.value = h.f;
888
+ gain.gain.setValueAtTime(0, now);
889
+ gain.gain.linearRampToValueAtTime(h.g, now + 0.008);
890
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + h.d);
891
+ osc.connect(gain).connect(this.sfxGain);
892
+ osc.start(now);
893
+ osc.stop(now + h.d + 0.05);
894
+ });
895
+ }
896
+
897
+ // Descending dissonant tone for a miss
898
+ playMiss() {
899
+ if (!this.ctx) return;
900
+ const now = this.ctx.currentTime;
901
+ const osc = this.ctx.createOscillator();
902
+ const osc2 = this.ctx.createOscillator();
903
+ const gain = this.ctx.createGain();
904
+ const filter = this.ctx.createBiquadFilter();
905
+
906
+ osc.type = 'sawtooth';
907
+ osc.frequency.setValueAtTime(240, now);
908
+ osc.frequency.exponentialRampToValueAtTime(90, now + 0.55);
909
+
910
+ osc2.type = 'square';
911
+ osc2.frequency.setValueAtTime(242, now);
912
+ osc2.frequency.exponentialRampToValueAtTime(88, now + 0.55);
913
+
914
+ filter.type = 'lowpass';
915
+ filter.frequency.setValueAtTime(1200, now);
916
+ filter.frequency.exponentialRampToValueAtTime(400, now + 0.55);
917
+ filter.Q.value = 4;
918
+
919
+ gain.gain.setValueAtTime(0, now);
920
+ gain.gain.linearRampToValueAtTime(0.22, now + 0.02);
921
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.65);
922
+
923
+ osc.connect(filter);
924
+ osc2.connect(filter);
925
+ filter.connect(gain).connect(this.sfxGain);
926
+ osc.start(now); osc2.start(now);
927
+ osc.stop(now + 0.7); osc2.stop(now + 0.7);
928
+ }
929
+
930
+ // Quick soft click on flip
931
+ playFlip() {
932
+ if (!this.ctx) return;
933
+ const now = this.ctx.currentTime;
934
+ const osc = this.ctx.createOscillator();
935
+ const gain = this.ctx.createGain();
936
+ osc.type = 'triangle';
937
+ osc.frequency.setValueAtTime(880, now);
938
+ osc.frequency.exponentialRampToValueAtTime(420, now + 0.08);
939
+ gain.gain.setValueAtTime(0, now);
940
+ gain.gain.linearRampToValueAtTime(0.1, now + 0.005);
941
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.12);
942
+ osc.connect(gain).connect(this.sfxGain);
943
+ osc.start(now);
944
+ osc.stop(now + 0.15);
945
+ }
946
+
947
+ // Ascending arpeggio + sparkles for streak fanfare
948
+ playStreakFanfare(streakCount) {
949
+ if (!this.ctx) return;
950
+ const now = this.ctx.currentTime;
951
+ // Pitch climbs with streak (cap at +7 semitones)
952
+ const semitones = Math.min(Math.max(streakCount - 2, 0), 7);
953
+ const baseFreq = 392 * Math.pow(2, semitones / 12); // G4 base
954
+ const scale = [1, 1.25, 1.5, 2, 2.5, 3];
955
+ const notes = scale.map(s => baseFreq * s);
956
+
957
+ notes.forEach((freq, i) => {
958
+ const osc = this.ctx.createOscillator();
959
+ const gain = this.ctx.createGain();
960
+ osc.type = 'triangle';
961
+ osc.frequency.value = freq;
962
+ const start = now + i * 0.06;
963
+ gain.gain.setValueAtTime(0, start);
964
+ gain.gain.linearRampToValueAtTime(0.22, start + 0.015);
965
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.7);
966
+ osc.connect(gain).connect(this.sfxGain);
967
+ osc.start(start);
968
+ osc.stop(start + 0.75);
969
+ });
970
+
971
+ // High sparkles
972
+ setTimeout(() => {
973
+ if (!this.ctx) return;
974
+ const t = this.ctx.currentTime;
975
+ for (let i = 0; i < 6; i++) {
976
+ const osc = this.ctx.createOscillator();
977
+ const gain = this.ctx.createGain();
978
+ osc.type = 'sine';
979
+ osc.frequency.value = 2200 + Math.random() * 2500;
980
+ const start = t + i * 0.035;
981
+ gain.gain.setValueAtTime(0, start);
982
+ gain.gain.linearRampToValueAtTime(0.07, start + 0.005);
983
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.18);
984
+ osc.connect(gain).connect(this.sfxGain);
985
+ osc.start(start);
986
+ osc.stop(start + 0.2);
987
+ }
988
+ }, 180);
989
+ }
990
+
991
+ // Triumphant chord sweep on game completion
992
+ playCompletion() {
993
+ if (!this.ctx) return;
994
+ const now = this.ctx.currentTime;
995
+ // I - V - vi - IV in C major, layered
996
+ const progression = [
997
+ { root: 261.63, third: 329.63, fifth: 392.0, oct: 523.25 }, // C
998
+ { root: 392.0, third: 493.88, fifth: 587.33, oct: 783.99 }, // G
999
+ { root: 220.0, third: 261.63, fifth: 329.63, oct: 440.0 }, // Am
1000
+ { root: 349.23, third: 440.0, fifth: 523.25, oct: 698.46 }, // F
1001
+ { root: 523.25, third: 659.25, fifth: 783.99, oct: 1046.5 }, // C oct up
1002
+ ];
1003
+ progression.forEach((chord, idx) => {
1004
+ const start = now + idx * 0.32;
1005
+ [chord.root, chord.third, chord.fifth, chord.oct].forEach((freq, i) => {
1006
+ const osc = this.ctx.createOscillator();
1007
+ const gain = this.ctx.createGain();
1008
+ osc.type = i === 0 ? 'sawtooth' : 'triangle';
1009
+ osc.frequency.value = freq;
1010
+ const peak = 0.12 / (i + 1);
1011
+ gain.gain.setValueAtTime(0, start);
1012
+ gain.gain.linearRampToValueAtTime(peak, start + 0.06);
1013
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + 1.4);
1014
+
1015
+ const filter = this.ctx.createBiquadFilter();
1016
+ filter.type = 'lowpass';
1017
+ filter.frequency.value = 2200;
1018
+ osc.connect(filter).connect(gain).connect(this.sfxGain);
1019
+ osc.start(start);
1020
+ osc.stop(start + 1.45);
1021
+ });
1022
+ });
1023
+ }
1024
+
1025
+ // Layered ambient background music
1026
+ startMusic() {
1027
+ if (!this.ctx || this.musicPlaying) return;
1028
+ this.musicPlaying = true;
1029
+ const ctx = this.ctx;
1030
+
1031
+ // LAYER 1: Deep drone — C2 + G2 sine
1032
+ const drone1 = ctx.createOscillator();
1033
+ const drone2 = ctx.createOscillator();
1034
+ const droneGain = ctx.createGain();
1035
+ drone1.type = 'sine'; drone1.frequency.value = 65.41;
1036
+ drone2.type = 'sine'; drone2.frequency.value = 98.0;
1037
+ droneGain.gain.value = 0.11;
1038
+ drone1.connect(droneGain); drone2.connect(droneGain);
1039
+ droneGain.connect(this.musicGain);
1040
+ drone1.start(); drone2.start();
1041
+
1042
+ // LAYER 2: Pad — sawtooth through filter with slow LFO
1043
+ const pad = ctx.createOscillator();
1044
+ const padGain = ctx.createGain();
1045
+ const padFilter = ctx.createBiquadFilter();
1046
+ const padLfo = ctx.createOscillator();
1047
+ const padLfoGain = ctx.createGain();
1048
+ pad.type = 'sawtooth'; pad.frequency.value = 261.63;
1049
+ padFilter.type = 'lowpass'; padFilter.frequency.value = 600; padFilter.Q.value = 3;
1050
+ padGain.gain.value = 0.05;
1051
+ padLfo.frequency.value = 0.13;
1052
+ padLfoGain.gain.value = 4;
1053
+ padLfo.connect(padLfoGain).connect(pad.frequency);
1054
+ pad.connect(padFilter).connect(padGain).connect(this.musicGain);
1055
+ pad.start(); padLfo.start();
1056
+
1057
+ // LAYER 3: High shimmer — sine with tremolo
1058
+ const shimmer = ctx.createOscillator();
1059
+ const shimmerGain = ctx.createGain();
1060
+ const shimmerLfo = ctx.createOscillator();
1061
+ const shimmerLfoGain = ctx.createGain();
1062
+ shimmer.type = 'sine'; shimmer.frequency.value = 1046.5;
1063
+ shimmerGain.gain.value = 0.015;
1064
+ shimmerLfo.type = 'sine'; shimmerLfo.frequency.value = 0.27;
1065
+ shimmerLfoGain.gain.value = 0.012;
1066
+ shimmerLfo.connect(shimmerLfoGain).connect(shimmerGain.gain);
1067
+ shimmer.connect(shimmerGain).connect(this.musicGain);
1068
+ shimmer.start(); shimmerLfo.start();
1069
+
1070
+ this.musicLayers = [drone1, drone2, pad, padLfo, shimmer, shimmerLfo];
1071
+
1072
+ // LAYER 4: Random arpeggios from C major pentatonic
1073
+ this._scheduleArpeggio();
1074
+ }
1075
+
1076
+ _scheduleArpeggio() {
1077
+ if (!this.musicPlaying || !this.ctx) return;
1078
+ const notes = [523.25, 587.33, 659.25, 783.99, 880.0, 1046.5];
1079
+ const note = notes[Math.floor(Math.random() * notes.length)];
1080
+ const now = this.ctx.currentTime;
1081
+
1082
+ const osc = this.ctx.createOscillator();
1083
+ const gain = this.ctx.createGain();
1084
+ osc.type = 'sine';
1085
+ osc.frequency.value = note;
1086
+ gain.gain.setValueAtTime(0, now);
1087
+ gain.gain.linearRampToValueAtTime(0.045, now + 0.1);
1088
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 2.8);
1089
+ osc.connect(gain).connect(this.musicGain);
1090
+ osc.start(now);
1091
+ osc.stop(now + 2.85);
1092
+
1093
+ this.arpeggioTimeout = setTimeout(() => this._scheduleArpeggio(), 2400 + Math.random() * 2800);
1094
+ }
1095
+
1096
+ stopMusic() {
1097
+ if (!this.musicPlaying) return;
1098
+ this.musicPlaying = false;
1099
+ clearTimeout(this.arpeggioTimeout);
1100
+ this.musicLayers.forEach(osc => { try { osc.stop(); } catch(e){} });
1101
+ this.musicLayers = [];
1102
+ }
1103
+ }
1104
+ const audio = new AudioEngine();
1105
+
1106
+ /* ============================================================
1107
+ STARFIELD — twinkling background canvas
1108
+ ============================================================ */
1109
+ function initStarfield() {
1110
+ const canvas = document.getElementById('starfield');
1111
+ const ctx = canvas.getContext('2d');
1112
+ let stars = [];
1113
+ let w, h;
1114
+
1115
+ function resize() {
1116
+ w = canvas.width = window.innerWidth;
1117
+ h = canvas.height = window.innerHeight;
1118
+ const count = Math.floor((w * h) / 7000);
1119
+ stars = [];
1120
+ for (let i = 0; i < count; i++) {
1121
+ stars.push({
1122
+ x: Math.random() * w,
1123
+ y: Math.random() * h,
1124
+ r: Math.random() * 1.4 + 0.3,
1125
+ baseAlpha: Math.random() * 0.5 + 0.25,
1126
+ phase: Math.random() * Math.PI * 2,
1127
+ speed: Math.random() * 0.6 + 0.2,
1128
+ hue: Math.random() < 0.15 ? 'gold' : 'cream',
1129
+ });
1130
+ }
1131
+ }
1132
+ window.addEventListener('resize', resize);
1133
+ resize();
1134
+
1135
+ let t = 0;
1136
+ function animate() {
1137
+ ctx.clearRect(0, 0, w, h);
1138
+ t += 0.016;
1139
+ stars.forEach(s => {
1140
+ const alpha = Math.max(0, s.baseAlpha + Math.sin(t * s.speed + s.phase) * 0.4);
1141
+ ctx.beginPath();
1142
+ ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
1143
+ ctx.fillStyle = s.hue === 'gold'
1144
+ ? `rgba(244, 210, 122, ${alpha})`
1145
+ : `rgba(245, 230, 211, ${alpha})`;
1146
+ ctx.fill();
1147
+ if (s.r > 1) {
1148
+ const grad = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.r * 4);
1149
+ const color = s.hue === 'gold'
1150
+ ? `rgba(232, 182, 90, ${alpha * 0.35})`
1151
+ : `rgba(245, 230, 211, ${alpha * 0.25})`;
1152
+ grad.addColorStop(0, color);
1153
+ grad.addColorStop(1, 'rgba(0,0,0,0)');
1154
+ ctx.fillStyle = grad;
1155
+ ctx.beginPath();
1156
+ ctx.arc(s.x, s.y, s.r * 4, 0, Math.PI * 2);
1157
+ ctx.fill();
1158
+ }
1159
+ });
1160
+ requestAnimationFrame(animate);
1161
+ }
1162
+ animate();
1163
+ }
1164
+
1165
+ /* ============================================================
1166
+ GAME LOGIC
1167
+ ============================================================ */
1168
+ function initGame() {
1169
+ const diff = DIFFICULTIES[STATE.difficulty];
1170
+ const symbols = SYMBOLS.slice(0, diff.pairs);
1171
+
1172
+ // Build paired deck
1173
+ let cards = [];
1174
+ symbols.forEach((sym, idx) => {
1175
+ cards.push({ id: idx * 2, symbol: sym, matched: false });
1176
+ cards.push({ id: idx * 2 + 1, symbol: sym, matched: false });
1177
+ });
1178
+
1179
+ // Fisher-Yates shuffle
1180
+ for (let i = cards.length - 1; i > 0; i--) {
1181
+ const j = Math.floor(Math.random() * (i + 1));
1182
+ [cards[i], cards[j]] = [cards[j], cards[i]];
1183
+ }
1184
+
1185
+ STATE.cards = cards;
1186
+ STATE.flippedCards = [];
1187
+ STATE.matchedCount = 0;
1188
+ STATE.moves = 0;
1189
+ STATE.streak = 0;
1190
+ STATE.maxStreak = 0;
1191
+ STATE.elapsedTime = 0;
1192
+ STATE.locked = true;
1193
+ STATE.gameStarted = false;
1194
+ STATE.gameComplete = false;
1195
+
1196
+ if (STATE.timerInterval) {
1197
+ clearInterval(STATE.timerInterval);
1198
+ STATE.timerInterval = null;
1199
+ }
1200
+
1201
+ renderBoard();
1202
+ updateStats();
1203
+
1204
+ // Brief preview of all cards at start
1205
+ setTimeout(() => startPreview(), 700);
1206
+ }
1207
+
1208
+ function renderBoard() {
1209
+ const board = document.getElementById('board');
1210
+ const diff = DIFFICULTIES[STATE.difficulty];
1211
+ board.style.gridTemplateColumns = `repeat(${diff.cols}, minmax(0, 1fr))`;
1212
+ board.style.maxWidth = `${diff.maxWidth}px`;
1213
+
1214
+ board.innerHTML = '';
1215
+
1216
+ STATE.cards.forEach((card, idx) => {
1217
+ const el = document.createElement('div');
1218
+ el.className = 'card dealing';
1219
+ el.dataset.index = idx;
1220
+ el.setAttribute('role', 'gridcell');
1221
+ el.setAttribute('tabindex', '0');
1222
+ el.setAttribute('aria-label', `Card ${idx + 1}`);
1223
+ el.style.animationDelay = `${idx * 0.035}s`;
1224
+
1225
+ el.innerHTML = `
1226
+ <div class="card-inner">
1227
+ <div class="card-face card-back">
1228
+ <svg class="card-back-seal" viewBox="0 0 60 60" aria-hidden="true">
1229
+ <circle cx="30" cy="30" r="24" fill="none" stroke="rgba(232,182,90,0.55)" stroke-width="0.6"/>
1230
+ <circle cx="30" cy="30" r="18" fill="none" stroke="rgba(232,182,90,0.4)" stroke-width="0.4"/>
1231
+ <circle cx="30" cy="30" r="12" fill="none" stroke="rgba(232,182,90,0.3)" stroke-width="0.4"/>
1232
+ <path d="M30 6 L33 27 L54 30 L33 33 L30 54 L27 33 L6 30 L27 27 Z"
1233
+ fill="rgba(232,182,90,0.45)" stroke="rgba(244,210,122,0.7)" stroke-width="0.4"/>
1234
+ <circle cx="30" cy="30" r="2.5" fill="rgba(244,210,122,0.9)"/>
1235
+ <circle cx="30" cy="6" r="1" fill="rgba(232,182,90,0.7)"/>
1236
+ <circle cx="30" cy="54" r="1" fill="rgba(232,182,90,0.7)"/>
1237
+ <circle cx="6" cy="30" r="1" fill="rgba(232,182,90,0.7)"/>
1238
+ <circle cx="54" cy="30" r="1" fill="rgba(232,182,90,0.7)"/>
1239
+ </svg>
1240
+ </div>
1241
+ <div class="card-face card-front">
1242
+ <span class="card-symbol">${card.symbol}</span>
1243
+ </div>
1244
+ </div>
1245
+ `;
1246
+
1247
+ el.addEventListener('click', () => handleCardClick(idx));
1248
+ el.addEventListener('keydown', (e) => {
1249
+ if (e.key === 'Enter' || e.key === ' ') {
1250
+ e.preventDefault();
1251
+ handleCardClick(idx);
1252
+ }
1253
+ });
1254
+
1255
+ board.appendChild(el);
1256
+ });
1257
+ }
1258
+
1259
+ function startPreview() {
1260
+ const cards = document.querySelectorAll('.card');
1261
+ cards.forEach(c => c.classList.add('flipped'));
1262
+ setTimeout(() => {
1263
+ cards.forEach(c => c.classList.remove('flipped'));
1264
+ setTimeout(() => { STATE.locked = false; }, 650);
1265
+ }, 1600);
1266
+ }
1267
+
1268
+ function handleCardClick(idx) {
1269
+ audio.init();
1270
+ audio.resume();
1271
+
1272
+ if (STATE.locked || STATE.gameComplete) return;
1273
+ if (STATE.flippedCards.includes(idx)) return;
1274
+ if (STATE.cards[idx].matched) return;
1275
+
1276
+ if (!STATE.gameStarted) {
1277
+ STATE.gameStarted = true;
1278
+ startTimer();
1279
+ // Auto-start music on first interaction unless user explicitly disabled
1280
+ if (!audio.musicPlaying && !window._userDisabledMusic) {
1281
+ audio.startMusic();
1282
+ document.getElementById('musicToggle').classList.add('active');
1283
+ document.getElementById('musicLabel').textContent = 'Playing';
1284
+ }
1285
+ }
1286
+
1287
+ const cardEl = document.querySelector(`.card[data-index="${idx}"]`);
1288
+ audio.playFlip();
1289
+ cardEl.classList.add('flipped');
1290
+ STATE.flippedCards.push(idx);
1291
+
1292
+ if (STATE.flippedCards.length === 2) {
1293
+ STATE.locked = true;
1294
+ STATE.moves++;
1295
+ updateStats();
1296
+ setTimeout(() => checkMatch(), 720);
1297
+ }
1298
+ }
1299
+
1300
+ function checkMatch() {
1301
+ const [a, b] = STATE.flippedCards;
1302
+ const cardA = STATE.cards[a];
1303
+ const cardB = STATE.cards[b];
1304
+ const elA = document.querySelector(`.card[data-index="${a}"]`);
1305
+ const elB = document.querySelector(`.card[data-index="${b}"]`);
1306
+
1307
+ if (cardA.symbol === cardB.symbol) {
1308
+ // MATCH
1309
+ cardA.matched = true;
1310
+ cardB.matched = true;
1311
+ STATE.matchedCount++;
1312
+ STATE.streak++;
1313
+ STATE.maxStreak = Math.max(STATE.maxStreak, STATE.streak);
1314
+
1315
+ // Chime pitch rises slightly with each match
1316
+ const baseFreq = 523.25 * Math.pow(2, Math.min(STATE.matchedCount - 1, 8) / 12);
1317
+ audio.playChime(baseFreq);
1318
+
1319
+ if (STATE.streak >= 3) {
1320
+ setTimeout(() => {
1321
+ audio.playStreakFanfare(STATE.streak);
1322
+ triggerStreakEffect(STATE.streak);
1323
+ }, 180);
1324
+ }
1325
+
1326
+ // Float-fade after a brief beat
1327
+ setTimeout(() => {
1328
+ elA.classList.add('matched');
1329
+ elB.classList.add('matched');
1330
+ burstParticles(elA, 'gold');
1331
+ burstParticles(elB, 'gold');
1332
+ }, 320);
1333
+
1334
+ STATE.flippedCards = [];
1335
+ STATE.locked = false;
1336
+ updateStats();
1337
+
1338
+ if (STATE.matchedCount === DIFFICULTIES[STATE.difficulty].pairs) {
1339
+ setTimeout(() => completeGame(), 1100);
1340
+ }
1341
+ } else {
1342
+ // MISS
1343
+ STATE.streak = 0;
1344
+ audio.playMiss();
1345
+
1346
+ elA.classList.add('miss');
1347
+ elB.classList.add('miss');
1348
+
1349
+ setTimeout(() => {
1350
+ elA.classList.remove('miss', 'flipped');
1351
+ elB.classList.remove('miss', 'flipped');
1352
+ STATE.flippedCards = [];
1353
+ STATE.locked = false;
1354
+ updateStats();
1355
+ }, 680);
1356
+ }
1357
+ }
1358
+
1359
+ /* ============================================================
1360
+ VISUAL EFFECTS
1361
+ ============================================================ */
1362
+ function triggerStreakEffect(streak) {
1363
+ const streakStat = document.getElementById('streakStat');
1364
+
1365
+ // Floating "STREAK ×N" text
1366
+ const text = document.createElement('div');
1367
+ text.className = 'streak-text';
1368
+ text.textContent = `STREAK ×${streak}`;
1369
+ document.body.appendChild(text);
1370
+ text.animate([
1371
+ { transform: 'translate(-50%, 20px) scale(0.4)', opacity: 0 },
1372
+ { transform: 'translate(-50%, -20px) scale(1.15)', opacity: 1, offset: 0.25 },
1373
+ { transform: 'translate(-50%, -60px) scale(1)', opacity: 1, offset: 0.65 },
1374
+ { transform: 'translate(-50%, -120px) scale(0.9)', opacity: 0 }
1375
+ ], { duration: 1700, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' })
1376
+ .onfinish = () => text.remove();
1377
+
1378
+ // Golden particle burst from streak stat
1379
+ const rect = streakStat.getBoundingClientRect();
1380
+ const cx = rect.left + rect.width / 2;
1381
+ const cy = rect.top + rect.height / 2;
1382
+
1383
+ for (let i = 0; i < 18; i++) {
1384
+ const p = document.createElement('div');
1385
+ p.className = 'particle';
1386
+ const size = 3 + Math.random() * 6;
1387
+ p.style.width = `${size}px`;
1388
+ p.style.height = `${size}px`;
1389
+ p.style.left = `${cx - size/2}px`;
1390
+ p.style.top = `${cy - size/2}px`;
1391
+ const hue = 30 + Math.random() * 30;
1392
+ p.style.background = `hsl(${hue}, 90%, 65%)`;
1393
+ p.style.boxShadow = `0 0 12px hsl(${hue}, 90%, 65%)`;
1394
+ document.body.appendChild(p);
1395
+
1396
+ const angle = Math.random() * Math.PI * 2;
1397
+ const speed = 70 + Math.random() * 130;
1398
+ const dx = Math.cos(angle) * speed;
1399
+ const dy = Math.sin(angle) * speed - 40;
1400
+
1401
+ p.animate([
1402
+ { transform: 'translate(0,0) scale(1)', opacity: 1 },
1403
+ { transform: `translate(${dx}px, ${dy}px) scale(0)`, opacity: 0 }
1404
+ ], { duration: 900 + Math.random() * 500, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' })
1405
+ .onfinish = () => p.remove();
1406
+ }
1407
+
1408
+ // Subtle screen-edge glow
1409
+ const flash = document.createElement('div');
1410
+ flash.style.cssText = `
1411
+ position: fixed; inset: 0; pointer-events: none; z-index: 30;
1412
+ background: radial-gradient(circle at center, transparent 50%, rgba(255,154,139,0.18) 100%);
1413
+ opacity: 0;
1414
+ `;
1415
+ document.body.appendChild(flash);
1416
+ flash.animate([
1417
+ { opacity: 0 }, { opacity: 1 }, { opacity: 0 }
1418
+ ], { duration: 600, easing: 'ease-out' }).onfinish = () => flash.remove();
1419
+ }
1420
+
1421
+ function burstParticles(cardEl, kind = 'gold') {
1422
+ const rect = cardEl.getBoundingClientRect();
1423
+ const cx = rect.left + rect.width / 2;
1424
+ const cy = rect.top + rect.height / 2;
1425
+ const count = 22;
1426
+ const baseHue = kind === 'gold' ? 40 : 0;
1427
+
1428
+ for (let i = 0; i < count; i++) {
1429
+ const p = document.createElement('div');
1430
+ p.className = 'particle';
1431
+ const size = 3 + Math.random() * 6;
1432
+ p.style.width = `${size}px`;
1433
+ p.style.height = `${size}px`;
1434
+ p.style.left = `${cx - size/2}px`;
1435
+ p.style.top = `${cy - size/2}px`;
1436
+ const hue = baseHue + (Math.random() - 0.5) * 30;
1437
+ const sat = kind === 'gold' ? 90 : 80;
1438
+ const light = 60 + Math.random() * 15;
1439
+ p.style.background = `hsl(${hue}, ${sat}%, ${light}%)`;
1440
+ p.style.boxShadow = `0 0 12px hsl(${hue}, ${sat}%, ${light}%)`;
1441
+ document.body.appendChild(p);
1442
+
1443
+ const angle = (i / count) * Math.PI * 2 + Math.random() * 0.3;
1444
+ const speed = 70 + Math.random() * 110;
1445
+ const dx = Math.cos(angle) * speed;
1446
+ const dy = Math.sin(angle) * speed - 50;
1447
+
1448
+ p.animate([
1449
+ { transform: 'translate(0,0) scale(1)', opacity: 1 },
1450
+ { transform: `translate(${dx}px, ${dy}px) scale(0)`, opacity: 0 }
1451
+ ], { duration: 950 + Math.random() * 400, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' })
1452
+ .onfinish = () => p.remove();
1453
+ }
1454
+ }
1455
+
1456
+ /* ============================================================
1457
+ TIMER & STATS
1458
+ ============================================================ */
1459
+ function startTimer() {
1460
+ STATE.startTime = Date.now();
1461
+ STATE.timerInterval = setInterval(() => {
1462
+ STATE.elapsedTime = Math.floor((Date.now() - STATE.startTime) / 1000);
1463
+ updateStats();
1464
+ }, 250);
1465
+ }
1466
+
1467
+ function updateStats() {
1468
+ document.getElementById('streakVal').textContent = STATE.streak;
1469
+ document.getElementById('movesVal').textContent = STATE.moves;
1470
+ const diff = DIFFICULTIES[STATE.difficulty];
1471
+ document.getElementById('pairsVal').textContent = `${STATE.matchedCount}/${diff.pairs}`;
1472
+
1473
+ const mins = Math.floor(STATE.elapsedTime / 60);
1474
+ const secs = STATE.elapsedTime % 60;
1475
+ document.getElementById('timeVal').textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
1476
+
1477
+ const streakStat = document.getElementById('streakStat');
1478
+ if (STATE.streak >= 3) streakStat.classList.add('streak-active');
1479
+ else streakStat.classList.remove('streak-active');
1480
+ }
1481
+
1482
+ /* ============================================================
1483
+ GAME COMPLETION
1484
+ ============================================================ */
1485
+ function completeGame() {
1486
+ STATE.gameComplete = true;
1487
+ clearInterval(STATE.timerInterval);
1488
+
1489
+ audio.playCompletion();
1490
+
1491
+ // Calculate star rating
1492
+ const diff = DIFFICULTIES[STATE.difficulty];
1493
+ const minMoves = diff.pairs;
1494
+ const ratio = STATE.moves / minMoves;
1495
+ let stars = 1;
1496
+ if (ratio <= 1.6) stars = 3;
1497
+ else if (ratio <= 2.3) stars = 2;
1498
+
1499
+ // Update modal content
1500
+ document.getElementById('modalTitle').textContent = diff.title;
1501
+ document.getElementById('finalMoves').textContent = STATE.moves;
1502
+ const mins = Math.floor(STATE.elapsedTime / 60);
1503
+ const secs = STATE.elapsedTime % 60;
1504
+ document.getElementById('finalTime').textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
1505
+ document.getElementById('finalStreak').textContent = STATE.maxStreak;
1506
+
1507
+ // Render stars
1508
+ const starsContainer = document.getElementById('starsContainer');
1509
+ starsContainer.innerHTML = '';
1510
+ for (let i = 0; i < 3; i++) {
1511
+ const star = document.createElement('div');
1512
+ star.className = 'star';
1513
+ star.innerHTML = `
1514
+ <svg viewBox="0 0 60 60" width="64" height="64">
1515
+ <defs>
1516
+ <linearGradient id="starGrad${i}" x1="0" y1="0" x2="0" y2="1">
1517
+ <stop offset="0" stop-color="#fff5d6"/>
1518
+ <stop offset="0.5" stop-color="#f4d27a"/>
1519
+ <stop offset="1" stop-color="#a87a30"/>
1520
+ </linearGradient>
1521
+ </defs>
1522
+ <path d="M30 4 L37 22 L56 22 L41 33 L47 52 L30 41 L13 52 L19 33 L4 22 L23 22 Z"
1523
+ fill="url(#starGrad${i})"
1524
+ stroke="#fff5d6"
1525
+ stroke-width="1.2"/>
1526
+ </svg>
1527
+ `;
1528
+ starsContainer.appendChild(star);
1529
+ setTimeout(() => {
1530
+ if (i < stars) {
1531
+ star.classList.add('lit');
1532
+ addStarSparkles(star);
1533
+ }
1534
+ }, 700 + i * 320);
1535
+ }
1536
+
1537
+ setTimeout(() => {
1538
+ document.getElementById('modalOverlay').classList.add('active');
1539
+ startConfetti();
1540
+ }, 900);
1541
+ }
1542
+
1543
+ function addStarSparkles(starEl) {
1544
+ const rect = starEl.getBoundingClientRect();
1545
+ const cx = rect.left + rect.width / 2;
1546
+ const cy = rect.top + rect.height / 2;
1547
+ for (let i = 0; i < 8; i++) {
1548
+ setTimeout(() => {
1549
+ const p = document.createElement('div');
1550
+ p.className = 'particle';
1551
+ const size = 3 + Math.random() * 3;
1552
+ p.style.width = `${size}px`;
1553
+ p.style.height = `${size}px`;
1554
+ p.style.left = `${cx - size/2}px`;
1555
+ p.style.top = `${cy - size/2}px`;
1556
+ p.style.background = '#fff5d6';
1557
+ p.style.boxShadow = '0 0 10px #f4d27a';
1558
+ document.body.appendChild(p);
1559
+ const angle = Math.random() * Math.PI * 2;
1560
+ const dist = 30 + Math.random() * 50;
1561
+ p.animate([
1562
+ { transform: 'translate(0,0) scale(1)', opacity: 1 },
1563
+ { transform: `translate(${Math.cos(angle)*dist}px, ${Math.sin(angle)*dist}px) scale(0)`, opacity: 0 }
1564
+ ], { duration: 800, easing: 'ease-out' }).onfinish = () => p.remove();
1565
+ }, i * 60);
1566
+ }
1567
+ }
1568
+
1569
+ /* ============================================================
1570
+ CONFETTI — multi-burst canvas particles
1571
+ ============================================================ */
1572
+ let confettiActive = false;
1573
+ let confettiParticles = [];
1574
+
1575
+ function startConfetti() {
1576
+ const canvas = document.getElementById('confetti');
1577
+ const ctx = canvas.getContext('2d');
1578
+ canvas.width = window.innerWidth;
1579
+ canvas.height = window.innerHeight;
1580
+
1581
+ confettiActive = true;
1582
+ confettiParticles = [];
1583
+
1584
+ const colors = ['#f4d27a','#e8b65a','#fff5d6','#3dd68c','#6ff0b0','#ff9a8b','#ff6b6b','#f5e6d3'];
1585
+
1586
+ // Top-center burst
1587
+ for (let i = 0; i < 220; i++) {
1588
+ confettiParticles.push(makeConfetti(
1589
+ window.innerWidth / 2 + (Math.random() - 0.5) * 240,
1590
+ -20,
1591
+ (Math.random() - 0.5) * 10,
1592
+ Math.random() * 4 + 2,
1593
+ colors
1594
+ ));
1595
+ }
1596
+ // Side bursts (staggered)
1597
+ setTimeout(() => {
1598
+ if (!confettiActive) return;
1599
+ for (let i = 0; i < 80; i++) {
1600
+ confettiParticles.push(makeConfetti(
1601
+ 0,
1602
+ window.innerHeight * 0.45,
1603
+ Math.random() * 8 + 3,
1604
+ -(Math.random() * 9 + 3),
1605
+ colors
1606
+ ));
1607
+ confettiParticles.push(makeConfetti(
1608
+ window.innerWidth,
1609
+ window.innerHeight * 0.45,
1610
+ -(Math.random() * 8 + 3),
1611
+ -(Math.random() * 9 + 3),
1612
+ colors
1613
+ ));
1614
+ }
1615
+ }, 450);
1616
+
1617
+ // Continuous gentle rain
1618
+ let rainCount = 0;
1619
+ const rainInterval = setInterval(() => {
1620
+ if (!confettiActive || rainCount > 30) { clearInterval(rainInterval); return; }
1621
+ for (let i = 0; i < 12; i++) {
1622
+ confettiParticles.push(makeConfetti(
1623
+ Math.random() * window.innerWidth,
1624
+ -20,
1625
+ (Math.random() - 0.5) * 4,
1626
+ Math.random() * 2 + 1,
1627
+ colors
1628
+ ));
1629
+ }
1630
+ rainCount++;
1631
+ }, 400);
1632
+
1633
+ animateConfetti();
1634
+ }
1635
+
1636
+ function makeConfetti(x, y, vx, vy, colors) {
1637
+ return {
1638
+ x, y, vx, vy,
1639
+ gravity: 0.18,
1640
+ friction: 0.99,
1641
+ size: Math.random() * 8 + 5,
1642
+ color: colors[Math.floor(Math.random() * colors.length)],
1643
+ rotation: Math.random() * Math.PI * 2,
1644
+ rotationSpeed: (Math.random() - 0.5) * 0.35,
1645
+ shape: Math.random() < 0.4 ? 'rect' : (Math.random() < 0.7 ? 'circle' : 'star'),
1646
+ life: 1,
1647
+ flutter: Math.random() * Math.PI * 2,
1648
+ flutterSpeed: 0.05 + Math.random() * 0.05,
1649
+ };
1650
+ }
1651
+
1652
+ function drawConfettiShape(ctx, p) {
1653
+ ctx.fillStyle = p.color;
1654
+ if (p.shape === 'rect') {
1655
+ ctx.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2);
1656
+ } else if (p.shape === 'circle') {
1657
+ ctx.beginPath();
1658
+ ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2);
1659
+ ctx.fill();
1660
+ } else {
1661
+ // 5-point star
1662
+ const r1 = p.size / 2;
1663
+ const r2 = r1 * 0.45;
1664
+ ctx.beginPath();
1665
+ for (let i = 0; i < 10; i++) {
1666
+ const r = i % 2 === 0 ? r1 : r2;
1667
+ const a = (i / 10) * Math.PI * 2 - Math.PI / 2;
1668
+ const x = Math.cos(a) * r;
1669
+ const y = Math.sin(a) * r;
1670
+ if (i === 0) ctx.moveTo(x, y);
1671
+ else ctx.lineTo(x, y);
1672
+ }
1673
+ ctx.closePath();
1674
+ ctx.fill();
1675
+ }
1676
+ }
1677
+
1678
+ function animateConfetti() {
1679
+ if (!confettiActive) return;
1680
+ const canvas = document.getElementById('confetti');
1681
+ const ctx = canvas.getContext('2d');
1682
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1683
+
1684
+ confettiParticles = confettiParticles.filter(p => {
1685
+ p.vy += p.gravity;
1686
+ p.vx *= p.friction;
1687
+ p.flutter += p.flutterSpeed;
1688
+ p.x += p.vx + Math.sin(p.flutter) * 0.8;
1689
+ p.y += p.vy;
1690
+ p.rotation += p.rotationSpeed;
1691
+ p.life -= 0.004;
1692
+
1693
+ if (p.y > canvas.height + 60 || p.life <= 0) return false;
1694
+
1695
+ ctx.save();
1696
+ ctx.translate(p.x, p.y);
1697
+ ctx.rotate(p.rotation);
1698
+ ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
1699
+ drawConfettiShape(ctx, p);
1700
+ ctx.restore();
1701
+ return true;
1702
+ });
1703
+
1704
+ if (confettiParticles.length > 0) {
1705
+ requestAnimationFrame(animateConfetti);
1706
+ } else {
1707
+ confettiActive = false;
1708
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1709
+ }
1710
+ }
1711
+
1712
+ /* ============================================================
1713
+ EVENT WIRING
1714
+ ============================================================ */
1715
+ document.getElementById('musicToggle').addEventListener('click', (e) => {
1716
+ audio.init();
1717
+ audio.resume();
1718
+ const btn = e.currentTarget;
1719
+ const label = document.getElementById('musicLabel');
1720
+ if (audio.musicPlaying) {
1721
+ audio.stopMusic();
1722
+ btn.classList.remove('active');
1723
+ label.textContent = 'Music';
1724
+ window._userDisabledMusic = true;
1725
+ } else {
1726
+ audio.startMusic();
1727
+ btn.classList.add('active');
1728
+ label.textContent = 'Playing';
1729
+ window._userDisabledMusic = false;
1730
+ }
1731
+ });
1732
+
1733
+ document.getElementById('resetBtn').addEventListener('click', () => {
1734
+ document.getElementById('modalOverlay').classList.remove('active');
1735
+ confettiActive = false;
1736
+ initGame();
1737
+ });
1738
+
1739
+ document.getElementById('playAgainBtn').addEventListener('click', () => {
1740
+ document.getElementById('modalOverlay').classList.remove('active');
1741
+ confettiActive = false;
1742
+ setTimeout(() => initGame(), 300);
1743
+ });
1744
+
1745
+ document.querySelectorAll('[data-diff]').forEach(btn => {
1746
+ btn.addEventListener('click', () => {
1747
+ document.querySelectorAll('[data-diff]').forEach(b => b.classList.remove('active'));
1748
+ btn.classList.add('active');
1749
+ STATE.difficulty = btn.dataset.diff;
1750
+ document.getElementById('modalOverlay').classList.remove('active');
1751
+ confettiActive = false;
1752
+ initGame();
1753
+ });
1754
+ });
1755
+
1756
+ // Resize confetti canvas with window
1757
+ window.addEventListener('resize', () => {
1758
+ const canvas = document.getElementById('confetti');
1759
+ canvas.width = window.innerWidth;
1760
+ canvas.height = window.innerHeight;
1761
+ });
1762
+
1763
+ /* ============================================================
1764
+ INITIALIZE
1765
+ ============================================================ */
1766
+ initStarfield();
1767
+ initGame();
1768
+ </script>
1769
+ </body>
1770
+ </html>