LazyHuman10 commited on
Commit
c9ed0b5
·
1 Parent(s): db7a1f1

Add NPCverse frontend

Browse files
Files changed (1) hide show
  1. index.html +1003 -0
index.html ADDED
@@ -0,0 +1,1003 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">
6
+ <title>NPCverse</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@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg-void: #0d0d1a;
13
+ --bg-dark: #1a1a2e;
14
+ --bg-card: #16213e;
15
+ --gold: #c9a227;
16
+ --gold-dark: #8b6914;
17
+ --text-gold: #e8d5b7;
18
+ --text-muted: #8a7565;
19
+ --border-dim: #2a2a4a;
20
+ }
21
+
22
+ * { box-sizing: border-box; }
23
+
24
+ body {
25
+ margin: 0;
26
+ min-height: 100vh;
27
+ color: var(--text-gold);
28
+ font-family: "Crimson Text", Georgia, serif;
29
+ background-color: var(--bg-void);
30
+ background-image: url("data:image/svg+xml,%3Csvg width='18' height='18' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 9h18M9 0v18' stroke='%232a2a4a' stroke-opacity='.26' stroke-width='.5'/%3E%3Cpath d='M0 0l18 18M18 0L0 18' stroke='%2316213e' stroke-opacity='.24' stroke-width='.45'/%3E%3C/svg%3E");
31
+ }
32
+
33
+ button, input {
34
+ font: inherit;
35
+ }
36
+
37
+ button {
38
+ cursor: pointer;
39
+ transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease, filter .2s ease;
40
+ }
41
+
42
+ button:hover:not(:disabled) {
43
+ transform: translateY(-1px);
44
+ filter: brightness(1.08);
45
+ }
46
+
47
+ button:disabled {
48
+ cursor: not-allowed;
49
+ opacity: .6;
50
+ }
51
+
52
+ .app-shell {
53
+ width: min(1100px, 100%);
54
+ margin: 0 auto;
55
+ padding: 20px;
56
+ }
57
+
58
+ header {
59
+ padding: 28px 0 20px;
60
+ text-align: center;
61
+ }
62
+
63
+ h1, h2, h3 {
64
+ margin: 0;
65
+ font-family: "Cinzel", serif;
66
+ letter-spacing: 0;
67
+ }
68
+
69
+ h1 {
70
+ color: var(--gold);
71
+ font-size: clamp(2.2rem, 5vw, 4rem);
72
+ font-weight: 700;
73
+ text-shadow: 0 0 26px rgba(201, 162, 39, .32);
74
+ }
75
+
76
+ .subtitle {
77
+ margin-top: 6px;
78
+ color: var(--text-gold);
79
+ font-size: 1.25rem;
80
+ }
81
+
82
+ .layout {
83
+ display: grid;
84
+ grid-template-columns: minmax(0, 40%) minmax(0, 60%);
85
+ gap: 20px;
86
+ align-items: start;
87
+ }
88
+
89
+ .stack {
90
+ display: grid;
91
+ gap: 16px;
92
+ }
93
+
94
+ .panel {
95
+ border: 1px solid var(--border-dim);
96
+ background: linear-gradient(180deg, rgba(22, 33, 62, .95), rgba(26, 26, 46, .95));
97
+ box-shadow: 0 18px 50px rgba(0, 0, 0, .35);
98
+ }
99
+
100
+ .upload-zone {
101
+ min-height: 310px;
102
+ display: grid;
103
+ place-items: center;
104
+ gap: 14px;
105
+ padding: 18px;
106
+ border: 2px dashed rgba(201, 162, 39, .46);
107
+ color: var(--text-gold);
108
+ text-align: center;
109
+ transition: border-color .2s ease, background .2s ease, box-shadow .2s ease;
110
+ }
111
+
112
+ .upload-zone.dragover,
113
+ .upload-zone:hover {
114
+ border-color: var(--gold);
115
+ background: rgba(201, 162, 39, .06);
116
+ box-shadow: 0 0 28px rgba(201, 162, 39, .16);
117
+ }
118
+
119
+ .upload-prompt {
120
+ display: grid;
121
+ gap: 8px;
122
+ justify-items: center;
123
+ }
124
+
125
+ .upload-icon {
126
+ font-size: 2.5rem;
127
+ }
128
+
129
+ .upload-copy {
130
+ color: var(--text-muted);
131
+ font-family: system-ui, sans-serif;
132
+ font-size: .9rem;
133
+ }
134
+
135
+ .preview {
136
+ max-width: 100%;
137
+ width: 100%;
138
+ max-height: 280px;
139
+ object-fit: cover;
140
+ border: 1px solid rgba(201, 162, 39, .35);
141
+ box-shadow: 0 0 24px rgba(0, 0, 0, .34);
142
+ }
143
+
144
+ .primary-btn {
145
+ width: 100%;
146
+ min-height: 48px;
147
+ border: 1px solid #efc94a;
148
+ color: #130f07;
149
+ background: linear-gradient(135deg, #f4d76a, var(--gold), var(--gold-dark));
150
+ font-family: "Cinzel", serif;
151
+ font-weight: 700;
152
+ box-shadow: 0 0 22px rgba(201, 162, 39, .28);
153
+ }
154
+
155
+ .secondary-btn {
156
+ min-height: 42px;
157
+ border: 1px solid rgba(201, 162, 39, .5);
158
+ color: var(--text-gold);
159
+ background: rgba(13, 13, 26, .55);
160
+ font-family: system-ui, sans-serif;
161
+ font-weight: 700;
162
+ }
163
+
164
+ .loading {
165
+ display: none;
166
+ padding: 12px;
167
+ color: var(--gold);
168
+ text-align: center;
169
+ font-family: "Cinzel", serif;
170
+ animation: goldPulse 1.35s ease-in-out infinite;
171
+ }
172
+
173
+ .loading.active { display: block; }
174
+
175
+ .character-card {
176
+ display: none;
177
+ padding: 18px;
178
+ }
179
+
180
+ .character-card.active { display: block; }
181
+
182
+ .card-top {
183
+ display: flex;
184
+ gap: 12px;
185
+ justify-content: space-between;
186
+ align-items: flex-start;
187
+ margin-bottom: 14px;
188
+ }
189
+
190
+ .npc-name {
191
+ color: var(--gold);
192
+ font-family: "Cinzel", serif;
193
+ font-size: 1.4rem;
194
+ font-weight: 700;
195
+ }
196
+
197
+ .npc-class {
198
+ margin-top: 2px;
199
+ color: var(--text-muted);
200
+ font-family: system-ui, sans-serif;
201
+ font-size: .86rem;
202
+ }
203
+
204
+ .badge-row {
205
+ display: flex;
206
+ flex-wrap: wrap;
207
+ gap: 8px;
208
+ margin-bottom: 14px;
209
+ }
210
+
211
+ .badge {
212
+ display: inline-flex;
213
+ align-items: center;
214
+ min-height: 26px;
215
+ padding: 3px 9px;
216
+ border: 1px solid var(--border-dim);
217
+ color: var(--text-gold);
218
+ background: rgba(13, 13, 26, .58);
219
+ font-family: system-ui, sans-serif;
220
+ font-size: .78rem;
221
+ font-weight: 700;
222
+ text-transform: uppercase;
223
+ }
224
+
225
+ .rarity {
226
+ border-color: currentColor;
227
+ color: #9d9d9d;
228
+ }
229
+
230
+ .stats {
231
+ display: grid;
232
+ gap: 9px;
233
+ margin: 16px 0;
234
+ }
235
+
236
+ .stat {
237
+ display: grid;
238
+ grid-template-columns: 102px minmax(0, 1fr) 38px;
239
+ gap: 10px;
240
+ align-items: center;
241
+ font-family: system-ui, sans-serif;
242
+ font-size: .82rem;
243
+ }
244
+
245
+ .stat-track {
246
+ height: 9px;
247
+ overflow: hidden;
248
+ background: rgba(13, 13, 26, .72);
249
+ border: 1px solid var(--border-dim);
250
+ }
251
+
252
+ .stat-fill {
253
+ height: 100%;
254
+ width: 0;
255
+ transition: width .7s ease, background .3s ease;
256
+ }
257
+
258
+ .ability-grid {
259
+ display: grid;
260
+ grid-template-columns: 1fr 1fr;
261
+ gap: 10px;
262
+ margin: 14px 0;
263
+ }
264
+
265
+ .ability {
266
+ padding: 12px;
267
+ border: 1px solid var(--border-dim);
268
+ background: rgba(13, 13, 26, .4);
269
+ }
270
+
271
+ .ability h3,
272
+ .quest h3 {
273
+ color: var(--gold);
274
+ font-size: .9rem;
275
+ }
276
+
277
+ .ability p,
278
+ .quest p {
279
+ margin: 6px 0 0;
280
+ line-height: 1.35;
281
+ }
282
+
283
+ .lore {
284
+ margin: 14px 0;
285
+ color: var(--text-gold);
286
+ font-style: italic;
287
+ line-height: 1.48;
288
+ }
289
+
290
+ .world-line {
291
+ display: flex;
292
+ flex-wrap: wrap;
293
+ gap: 8px;
294
+ color: var(--text-muted);
295
+ font-family: system-ui, sans-serif;
296
+ font-size: .82rem;
297
+ }
298
+
299
+ .friendship {
300
+ padding: 16px;
301
+ }
302
+
303
+ .section-heading {
304
+ display: flex;
305
+ justify-content: space-between;
306
+ align-items: baseline;
307
+ gap: 12px;
308
+ margin-bottom: 10px;
309
+ }
310
+
311
+ .section-heading h2 {
312
+ color: var(--gold);
313
+ font-size: 1.05rem;
314
+ }
315
+
316
+ .friendship-pct {
317
+ color: var(--text-muted);
318
+ font-family: system-ui, sans-serif;
319
+ font-size: .86rem;
320
+ font-weight: 700;
321
+ }
322
+
323
+ .friendship-track {
324
+ height: 14px;
325
+ overflow: hidden;
326
+ border: 1px solid rgba(201, 162, 39, .34);
327
+ background: rgba(13, 13, 26, .78);
328
+ }
329
+
330
+ .friendship-fill {
331
+ height: 100%;
332
+ width: 0;
333
+ background: linear-gradient(90deg, var(--gold-dark), var(--gold), #f4d76a);
334
+ box-shadow: 0 0 18px rgba(201, 162, 39, .4);
335
+ transition: width .75s ease;
336
+ }
337
+
338
+ .friendship-flavor {
339
+ margin: 8px 0 0;
340
+ color: var(--text-muted);
341
+ font-style: italic;
342
+ }
343
+
344
+ .secret-layer {
345
+ position: fixed;
346
+ top: 18px;
347
+ right: 18px;
348
+ z-index: 20;
349
+ display: grid;
350
+ width: min(380px, calc(100vw - 36px));
351
+ gap: 10px;
352
+ }
353
+
354
+ .secret-toast {
355
+ padding: 14px;
356
+ border: 1px solid #a335ee;
357
+ color: var(--text-gold);
358
+ background: rgba(22, 33, 62, .96);
359
+ box-shadow: 0 0 28px rgba(163, 53, 238, .4), 0 0 18px rgba(201, 162, 39, .22);
360
+ animation: secretReveal .45s ease both;
361
+ }
362
+
363
+ .secret-title {
364
+ color: var(--gold);
365
+ font-family: "Cinzel", serif;
366
+ font-weight: 700;
367
+ }
368
+
369
+ .secret-body {
370
+ margin-top: 6px;
371
+ opacity: 0;
372
+ animation: secretText .4s ease .5s both;
373
+ }
374
+
375
+ .chat-panel {
376
+ display: grid;
377
+ grid-template-rows: minmax(360px, 50vh) auto;
378
+ overflow: hidden;
379
+ }
380
+
381
+ .chat-log {
382
+ overflow-y: auto;
383
+ padding: 16px;
384
+ display: flex;
385
+ flex-direction: column;
386
+ gap: 12px;
387
+ scroll-behavior: smooth;
388
+ }
389
+
390
+ .message {
391
+ max-width: 78%;
392
+ padding: 10px 12px;
393
+ line-height: 1.5;
394
+ font-size: 1rem;
395
+ border: 1px solid var(--border-dim);
396
+ }
397
+
398
+ .message.user {
399
+ align-self: flex-end;
400
+ background: rgba(13, 13, 26, .82);
401
+ color: #f2e6d0;
402
+ }
403
+
404
+ .message.npc {
405
+ align-self: flex-start;
406
+ background: rgba(42, 42, 74, .72);
407
+ color: var(--text-gold);
408
+ border-color: rgba(201, 162, 39, .28);
409
+ }
410
+
411
+ .sender {
412
+ display: block;
413
+ margin-bottom: 4px;
414
+ color: var(--gold);
415
+ font-family: system-ui, sans-serif;
416
+ font-size: .76rem;
417
+ font-weight: 800;
418
+ }
419
+
420
+ .typing {
421
+ animation: goldPulse 1s ease-in-out infinite;
422
+ }
423
+
424
+ .chat-form {
425
+ display: grid;
426
+ grid-template-columns: minmax(0, 1fr) 110px;
427
+ gap: 10px;
428
+ padding: 12px;
429
+ border-top: 1px solid var(--border-dim);
430
+ background: rgba(13, 13, 26, .54);
431
+ }
432
+
433
+ .chat-form input {
434
+ width: 100%;
435
+ min-height: 44px;
436
+ padding: 0 12px;
437
+ border: 1px solid var(--border-dim);
438
+ color: var(--text-gold);
439
+ background: rgba(13, 13, 26, .85);
440
+ outline: none;
441
+ transition: border-color .2s ease, box-shadow .2s ease;
442
+ }
443
+
444
+ .chat-form input:focus {
445
+ border-color: var(--gold);
446
+ box-shadow: 0 0 0 3px rgba(201, 162, 39, .12);
447
+ }
448
+
449
+ .quest-panel,
450
+ .share-card {
451
+ padding: 16px;
452
+ }
453
+
454
+ .quest-list {
455
+ display: grid;
456
+ gap: 10px;
457
+ margin-bottom: 12px;
458
+ }
459
+
460
+ .quest {
461
+ padding: 11px 12px;
462
+ border: 1px solid var(--border-dim);
463
+ border-left: 4px solid #9d9d9d;
464
+ background: rgba(13, 13, 26, .38);
465
+ }
466
+
467
+ .reward {
468
+ display: block;
469
+ margin-top: 7px;
470
+ color: var(--text-muted);
471
+ font-family: system-ui, sans-serif;
472
+ font-size: .8rem;
473
+ font-weight: 700;
474
+ }
475
+
476
+ .share-image {
477
+ display: none;
478
+ width: 100%;
479
+ margin-top: 12px;
480
+ border: 1px solid rgba(201, 162, 39, .35);
481
+ }
482
+
483
+ .download-link {
484
+ display: none;
485
+ margin-top: 8px;
486
+ color: var(--gold);
487
+ font-family: system-ui, sans-serif;
488
+ font-weight: 700;
489
+ }
490
+
491
+ .error {
492
+ display: none;
493
+ padding: 12px;
494
+ border: 1px solid rgba(163, 53, 238, .65);
495
+ color: var(--text-gold);
496
+ background: rgba(163, 53, 238, .12);
497
+ font-style: italic;
498
+ }
499
+
500
+ .error.active { display: block; }
501
+
502
+ .hidden-input {
503
+ display: none;
504
+ }
505
+
506
+ @keyframes goldPulse {
507
+ 0%, 100% { opacity: .62; text-shadow: 0 0 6px rgba(201, 162, 39, .2); }
508
+ 50% { opacity: 1; text-shadow: 0 0 18px rgba(201, 162, 39, .66); }
509
+ }
510
+
511
+ @keyframes pulse {
512
+ 0%, 100% { filter: brightness(1); }
513
+ 50% { filter: brightness(1.35); }
514
+ }
515
+
516
+ @keyframes secretReveal {
517
+ from { opacity: 0; transform: scale(.88) translateY(-12px); }
518
+ to { opacity: 1; transform: scale(1) translateY(0); }
519
+ }
520
+
521
+ @keyframes secretText {
522
+ to { opacity: 1; }
523
+ }
524
+
525
+ @media (max-width: 899px) {
526
+ .layout {
527
+ grid-template-columns: 1fr;
528
+ }
529
+
530
+ .ability-grid {
531
+ grid-template-columns: 1fr;
532
+ }
533
+
534
+ .chat-panel {
535
+ grid-template-rows: 420px auto;
536
+ }
537
+
538
+ .message {
539
+ max-width: 90%;
540
+ }
541
+ }
542
+
543
+ @media (max-width: 520px) {
544
+ .app-shell {
545
+ padding: 14px;
546
+ }
547
+
548
+ .stat {
549
+ grid-template-columns: 88px minmax(0, 1fr) 34px;
550
+ gap: 7px;
551
+ font-size: .76rem;
552
+ }
553
+
554
+ .chat-form {
555
+ grid-template-columns: 1fr;
556
+ }
557
+ }
558
+ </style>
559
+ </head>
560
+ <body>
561
+ <div class="secret-layer" id="secretLayer" aria-live="polite"></div>
562
+
563
+ <main class="app-shell">
564
+ <header>
565
+ <h1>⚔ NPCverse</h1>
566
+ <p class="subtitle">Turn anyone into a living RPG character</p>
567
+ </header>
568
+
569
+ <div class="layout">
570
+ <section class="stack">
571
+ <div class="upload-zone panel" id="uploadZone" role="button" tabindex="0" aria-label="Upload a character photo">
572
+ <input class="hidden-input" id="imageInput" type="file" accept="image/*">
573
+ <div class="upload-prompt" id="uploadPrompt">
574
+ <div class="upload-icon">🜂</div>
575
+ <h2>Drop a portrait into the rift</h2>
576
+ <div class="upload-copy">Click or drag an image here</div>
577
+ </div>
578
+ <img class="preview" id="imagePreview" alt="Uploaded preview" hidden>
579
+ </div>
580
+
581
+ <button class="primary-btn" id="summonBtn" disabled>⚡ SUMMON NPC</button>
582
+ <div class="loading" id="loadingText">Forging character...</div>
583
+ <div class="error" id="errorBox">*The realm connection falters... try again, traveler.*</div>
584
+
585
+ <article class="character-card panel" id="characterCard"></article>
586
+
587
+ <section class="share-card panel">
588
+ <button class="secondary-btn" id="shareBtn" disabled>📤 Export Share Card</button>
589
+ <img class="share-image" id="shareImage" alt="Generated NPC share card">
590
+ <a class="download-link" id="downloadLink" download="npcverse-share-card.png">Download share card</a>
591
+ </section>
592
+ </section>
593
+
594
+ <section class="stack">
595
+ <section class="friendship panel">
596
+ <div class="section-heading">
597
+ <h2 id="friendshipLabel">Stranger</h2>
598
+ <span class="friendship-pct" id="friendshipPct">0%</span>
599
+ </div>
600
+ <div class="friendship-track">
601
+ <div class="friendship-fill" id="friendshipFill"></div>
602
+ </div>
603
+ <p class="friendship-flavor" id="friendshipFlavor">A story waits at the edge of the campfire.</p>
604
+ </section>
605
+
606
+ <section class="chat-panel panel">
607
+ <div class="chat-log" id="chatLog" aria-live="polite"></div>
608
+ <form class="chat-form" id="chatForm">
609
+ <input id="chatInput" type="text" placeholder="Speak to your summoned NPC..." autocomplete="off" disabled>
610
+ <button class="secondary-btn" id="sendBtn" type="submit" disabled>Send</button>
611
+ </form>
612
+ </section>
613
+
614
+ <section class="quest-panel panel">
615
+ <div class="section-heading">
616
+ <h2>Active Quests</h2>
617
+ </div>
618
+ <div class="quest-list" id="questList"></div>
619
+ <button class="secondary-btn" id="newQuestBtn" disabled>New Quest</button>
620
+ </section>
621
+ </section>
622
+ </div>
623
+ </main>
624
+
625
+ <script type="module">
626
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
627
+
628
+ let currentNPC = null;
629
+ let chatHistory = [];
630
+ let displayHistory = [];
631
+ let messageCount = 0;
632
+ let unlockedSecrets = [];
633
+ let clientPromise = null;
634
+ let imageFile = null;
635
+
636
+ const uploadZone = document.getElementById("uploadZone");
637
+ const imageInput = document.getElementById("imageInput");
638
+ const uploadPrompt = document.getElementById("uploadPrompt");
639
+ const imagePreview = document.getElementById("imagePreview");
640
+ const summonBtn = document.getElementById("summonBtn");
641
+ const loadingText = document.getElementById("loadingText");
642
+ const errorBox = document.getElementById("errorBox");
643
+ const characterCard = document.getElementById("characterCard");
644
+ const friendshipLabel = document.getElementById("friendshipLabel");
645
+ const friendshipPct = document.getElementById("friendshipPct");
646
+ const friendshipFill = document.getElementById("friendshipFill");
647
+ const friendshipFlavor = document.getElementById("friendshipFlavor");
648
+ const chatLog = document.getElementById("chatLog");
649
+ const chatForm = document.getElementById("chatForm");
650
+ const chatInput = document.getElementById("chatInput");
651
+ const sendBtn = document.getElementById("sendBtn");
652
+ const questList = document.getElementById("questList");
653
+ const newQuestBtn = document.getElementById("newQuestBtn");
654
+ const shareBtn = document.getElementById("shareBtn");
655
+ const shareImage = document.getElementById("shareImage");
656
+ const downloadLink = document.getElementById("downloadLink");
657
+ const secretLayer = document.getElementById("secretLayer");
658
+
659
+ const rarityMap = {
660
+ Common: { color: "#9d9d9d", glow: "none", pulse: false },
661
+ Uncommon: { color: "#1eff00", glow: "0 0 20px rgba(30,255,0,.25)", pulse: false },
662
+ Rare: { color: "#0070dd", glow: "0 0 28px rgba(0,112,221,.35)", pulse: false },
663
+ Epic: { color: "#a335ee", glow: "0 0 38px rgba(163,53,238,.45)", pulse: false },
664
+ Legendary: { color: "#ff8000", glow: "0 0 50px rgba(255,128,0,.6)", pulse: true }
665
+ };
666
+
667
+ const friendshipCopy = {
668
+ Stranger: "A story waits at the edge of the campfire.",
669
+ Acquaintance: "Names have power, and yours is beginning to matter.",
670
+ Friend: "Trust gathers like warm light around the party.",
671
+ "Trusted Ally": "Oaths are forming beneath every shared word.",
672
+ "Legendary Bond": "Your legend and theirs now share the same ink."
673
+ };
674
+
675
+ uploadZone.addEventListener("click", () => imageInput.click());
676
+ uploadZone.addEventListener("keydown", (event) => {
677
+ if (event.key === "Enter" || event.key === " ") {
678
+ event.preventDefault();
679
+ imageInput.click();
680
+ }
681
+ });
682
+
683
+ uploadZone.addEventListener("dragover", (event) => {
684
+ event.preventDefault();
685
+ uploadZone.classList.add("dragover");
686
+ });
687
+
688
+ uploadZone.addEventListener("dragleave", () => uploadZone.classList.remove("dragover"));
689
+
690
+ uploadZone.addEventListener("drop", (event) => {
691
+ event.preventDefault();
692
+ uploadZone.classList.remove("dragover");
693
+ const dropped = event.dataTransfer.files[0];
694
+ if (dropped) setImageFile(dropped);
695
+ });
696
+
697
+ imageInput.addEventListener("change", () => {
698
+ if (imageInput.files[0]) setImageFile(imageInput.files[0]);
699
+ });
700
+
701
+ summonBtn.addEventListener("click", summonNPC);
702
+ shareBtn.addEventListener("click", exportShareCard);
703
+ newQuestBtn.addEventListener("click", () => sendChatMessage("Give me a new quest"));
704
+ chatForm.addEventListener("submit", (event) => {
705
+ event.preventDefault();
706
+ const userText = chatInput.value.trim();
707
+ if (userText) sendChatMessage(userText);
708
+ });
709
+
710
+ function getClient() {
711
+ if (!clientPromise) {
712
+ clientPromise = Client.connect(window.location.origin);
713
+ }
714
+ return clientPromise;
715
+ }
716
+
717
+ function setImageFile(file) {
718
+ imageFile = file;
719
+ imagePreview.src = URL.createObjectURL(file);
720
+ imagePreview.hidden = false;
721
+ uploadPrompt.style.display = "none";
722
+ summonBtn.disabled = false;
723
+ clearError();
724
+ }
725
+
726
+ async function summonNPC() {
727
+ if (!imageFile) return;
728
+ setLoading(true);
729
+ clearError();
730
+
731
+ try {
732
+ const client = await getClient();
733
+ const result = await client.predict("/summon_npc", { image_path: handle_file(imageFile) });
734
+ const npc = result.data[0];
735
+
736
+ currentNPC = npc;
737
+ chatHistory = [];
738
+ displayHistory = [];
739
+ messageCount = 0;
740
+ unlockedSecrets = [];
741
+
742
+ renderNPC(npc);
743
+ updateFriendship("Stranger", 0);
744
+ renderChat();
745
+ renderQuests(npc.quests || []);
746
+ enablePlay(true);
747
+ addNPCMessage(npc.opening_line || "The veil parts. I have arrived.");
748
+ } catch (error) {
749
+ showError();
750
+ } finally {
751
+ setLoading(false);
752
+ }
753
+ }
754
+
755
+ async function sendChatMessage(userText) {
756
+ if (!currentNPC || !userText) return;
757
+
758
+ chatInput.value = "";
759
+ appendDisplay("user", userText);
760
+ const typingId = appendTyping();
761
+ toggleChat(false);
762
+
763
+ try {
764
+ const client = await getClient();
765
+ const chatResult = await client.predict("/chat_with_npc", {
766
+ npc_json: JSON.stringify(currentNPC),
767
+ history_json: JSON.stringify(chatHistory),
768
+ user_message: userText,
769
+ msg_count: messageCount,
770
+ unlocked_secrets_json: JSON.stringify(unlockedSecrets)
771
+ });
772
+ const { response, new_msg_count, friendship_label, friendship_pct, newly_unlocked, secret_texts } = chatResult.data[0];
773
+
774
+ removeMessage(typingId);
775
+ appendDisplay("npc", response);
776
+ chatHistory.push({ role: "user", content: userText }, { role: "assistant", content: response });
777
+ chatHistory = chatHistory.slice(-20);
778
+ messageCount = new_msg_count;
779
+ unlockedSecrets = Array.from(new Set([...unlockedSecrets, ...newly_unlocked]));
780
+ updateFriendship(friendship_label, friendship_pct);
781
+ showSecrets(secret_texts);
782
+ } catch (error) {
783
+ removeMessage(typingId);
784
+ appendDisplay("npc", "*The realm connection falters... try again, traveler.*");
785
+ } finally {
786
+ toggleChat(true);
787
+ }
788
+ }
789
+
790
+ async function exportShareCard() {
791
+ if (!currentNPC) return;
792
+ shareBtn.disabled = true;
793
+
794
+ try {
795
+ const client = await getClient();
796
+ const cardResult = await client.predict("/generate_share_card", { npc_json: JSON.stringify(currentNPC) });
797
+ const { image_b64 } = cardResult.data[0];
798
+ shareImage.src = image_b64;
799
+ shareImage.style.display = "block";
800
+ downloadLink.href = image_b64;
801
+ downloadLink.style.display = "inline-block";
802
+ } catch (error) {
803
+ showError();
804
+ } finally {
805
+ shareBtn.disabled = false;
806
+ }
807
+ }
808
+
809
+ function renderNPC(npc) {
810
+ const rarity = npc.rarity || "Common";
811
+ const rarityStyle = rarityMap[rarity] || rarityMap.Common;
812
+ const stats = npc.stats || {};
813
+ const statNames = ["strength", "intelligence", "charisma", "luck", "stealth", "chaos"];
814
+
815
+ characterCard.innerHTML = `
816
+ <div class="card-top">
817
+ <div>
818
+ <div class="npc-name">${escapeHtml(npc.name || "Unknown Wanderer")}</div>
819
+ <div class="npc-class">${escapeHtml(npc.class || "Adventurer")} — Lv.${escapeHtml(String(npc.level || 1))}</div>
820
+ </div>
821
+ <span class="badge rarity" id="rarityBadge">${escapeHtml(rarity)}</span>
822
+ </div>
823
+ <div class="badge-row">
824
+ <span class="badge">${escapeHtml(npc.alignment || "Unaligned")}</span>
825
+ <span class="badge">${escapeHtml(npc.title || "Untitled")}</span>
826
+ </div>
827
+ <div class="stats">
828
+ ${statNames.map((name) => statRow(name, Number(stats[name] || 0))).join("")}
829
+ </div>
830
+ <div class="ability-grid">
831
+ <div class="ability">
832
+ <h3>Passive: ${escapeHtml(npc.passive_ability?.name || "Unknown")}</h3>
833
+ <p>${escapeHtml(npc.passive_ability?.description || "")}</p>
834
+ </div>
835
+ <div class="ability">
836
+ <h3>Ultimate: ${escapeHtml(npc.ultimate?.name || "Unknown")}</h3>
837
+ <p>${escapeHtml(npc.ultimate?.description || "")}</p>
838
+ </div>
839
+ </div>
840
+ <p class="lore">${escapeHtml(npc.lore || "")}</p>
841
+ <div class="world-line">
842
+ <span>World: ${escapeHtml(npc.world || "Unknown")}</span>
843
+ <span>Faction: ${escapeHtml(npc.faction || "Unaffiliated")}</span>
844
+ </div>
845
+ `;
846
+
847
+ const rarityBadge = document.getElementById("rarityBadge");
848
+ rarityBadge.style.color = rarityStyle.color;
849
+ rarityBadge.style.boxShadow = rarityStyle.glow;
850
+ rarityBadge.style.animation = rarityStyle.pulse ? "pulse 2s infinite" : "none";
851
+ characterCard.style.boxShadow = rarityStyle.glow === "none" ? "" : rarityStyle.glow;
852
+ characterCard.classList.add("active");
853
+ }
854
+
855
+ function statRow(name, value) {
856
+ const displayName = name.charAt(0).toUpperCase() + name.slice(1);
857
+ return `
858
+ <div class="stat">
859
+ <span>${displayName}</span>
860
+ <div class="stat-track"><div class="stat-fill" style="width:${clamp(value, 0, 100)}%; background:${statColor(value)}"></div></div>
861
+ <strong>${clamp(value, 0, 100)}</strong>
862
+ </div>
863
+ `;
864
+ }
865
+
866
+ function renderQuests(quests) {
867
+ const visibleQuests = quests.slice(0, 3);
868
+ questList.innerHTML = visibleQuests.map((quest) => {
869
+ const rarity = quest.rarity || "Common";
870
+ const color = (rarityMap[rarity] || rarityMap.Common).color;
871
+ return `
872
+ <article class="quest" style="border-left-color:${color}">
873
+ <h3>${escapeHtml(quest.title || "Unnamed Quest")}</h3>
874
+ <p>${escapeHtml(quest.description || "")}</p>
875
+ <span class="reward">Reward: ${escapeHtml(quest.reward || "Unknown")} · ${escapeHtml(rarity)}</span>
876
+ </article>
877
+ `;
878
+ }).join("");
879
+ }
880
+
881
+ function updateFriendship(label, pct) {
882
+ const safePct = clamp(Number(pct || 0), 0, 100);
883
+ friendshipLabel.textContent = label;
884
+ friendshipPct.textContent = `${Math.round(safePct)}%`;
885
+ friendshipFill.style.width = `${safePct}%`;
886
+ friendshipFlavor.textContent = friendshipCopy[label] || friendshipCopy.Stranger;
887
+ }
888
+
889
+ function addNPCMessage(text) {
890
+ appendDisplay("npc", text);
891
+ chatHistory.push({ role: "assistant", content: text });
892
+ }
893
+
894
+ function appendDisplay(sender, text) {
895
+ const entry = { id: crypto.randomUUID(), sender, text };
896
+ displayHistory.push(entry);
897
+ renderMessage(entry);
898
+ scrollChat();
899
+ return entry.id;
900
+ }
901
+
902
+ function appendTyping() {
903
+ const entry = { id: crypto.randomUUID(), sender: "npc", text: "...", typing: true };
904
+ displayHistory.push(entry);
905
+ renderMessage(entry);
906
+ scrollChat();
907
+ return entry.id;
908
+ }
909
+
910
+ function removeMessage(id) {
911
+ displayHistory = displayHistory.filter((item) => item.id !== id);
912
+ renderChat();
913
+ }
914
+
915
+ function renderChat() {
916
+ chatLog.innerHTML = "";
917
+ displayHistory.forEach(renderMessage);
918
+ scrollChat();
919
+ }
920
+
921
+ function renderMessage(entry) {
922
+ const bubble = document.createElement("div");
923
+ bubble.className = `message ${entry.sender}${entry.typing ? " typing" : ""}`;
924
+ bubble.dataset.id = entry.id;
925
+
926
+ if (entry.sender === "npc") {
927
+ const sender = document.createElement("span");
928
+ sender.className = "sender";
929
+ sender.textContent = `${currentNPC?.emoji || "✦"} ${currentNPC?.name || "NPC"}`;
930
+ bubble.appendChild(sender);
931
+ }
932
+
933
+ bubble.append(document.createTextNode(entry.text));
934
+ chatLog.appendChild(bubble);
935
+ }
936
+
937
+ function showSecrets(secretTexts) {
938
+ secretTexts.forEach((text) => {
939
+ const toast = document.createElement("div");
940
+ toast.className = "secret-toast";
941
+ toast.innerHTML = `
942
+ <div class="secret-title">Secret Revealed</div>
943
+ <div class="secret-body">${escapeHtml(text)}</div>
944
+ `;
945
+ secretLayer.appendChild(toast);
946
+ setTimeout(() => toast.remove(), 6000);
947
+ });
948
+ }
949
+
950
+ function setLoading(isLoading) {
951
+ loadingText.classList.toggle("active", isLoading);
952
+ summonBtn.disabled = isLoading || !imageFile;
953
+ }
954
+
955
+ function enablePlay(enabled) {
956
+ chatInput.disabled = !enabled;
957
+ sendBtn.disabled = !enabled;
958
+ newQuestBtn.disabled = !enabled;
959
+ shareBtn.disabled = !enabled;
960
+ }
961
+
962
+ function toggleChat(enabled) {
963
+ chatInput.disabled = !enabled;
964
+ sendBtn.disabled = !enabled;
965
+ newQuestBtn.disabled = !enabled;
966
+ }
967
+
968
+ function showError() {
969
+ errorBox.classList.add("active");
970
+ }
971
+
972
+ function clearError() {
973
+ errorBox.classList.remove("active");
974
+ }
975
+
976
+ function scrollChat() {
977
+ requestAnimationFrame(() => {
978
+ chatLog.scrollTop = chatLog.scrollHeight;
979
+ });
980
+ }
981
+
982
+ function statColor(value) {
983
+ if (value >= 80) return "#ff8000";
984
+ if (value >= 65) return "#c9a227";
985
+ if (value >= 45) return "#4488cc";
986
+ return "#777";
987
+ }
988
+
989
+ function clamp(value, min, max) {
990
+ return Math.max(min, Math.min(max, value));
991
+ }
992
+
993
+ function escapeHtml(value) {
994
+ return String(value)
995
+ .replaceAll("&", "&amp;")
996
+ .replaceAll("<", "&lt;")
997
+ .replaceAll(">", "&gt;")
998
+ .replaceAll('"', "&quot;")
999
+ .replaceAll("'", "&#039;");
1000
+ }
1001
+ </script>
1002
+ </body>
1003
+ </html>