LazyHuman10 commited on
Commit
65482b4
·
1 Parent(s): 553b155

Updated UI

Browse files
Files changed (1) hide show
  1. index.html +998 -921
index.html CHANGED
@@ -3,18 +3,20 @@
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
  }
@@ -30,974 +32,1049 @@
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>
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="description" content="Turn anyone into a living RPG character. Upload a portrait to summon a complete NPC with stats, lore, quests, and secrets.">
7
+ <title>NPCverse — Summon Your Character</title>
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
  <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">
11
  <style>
12
  :root {
13
+ --bg-void: #0d0d1a;
14
+ --bg-dark: #1a1a2e;
15
+ --bg-card: #16213e;
16
+ --gold: #c9a227;
17
+ --gold-dark: #8b6914;
18
+ --gold-light: #f4d76a;
19
+ --text-gold: #e8d5b7;
20
  --text-muted: #8a7565;
21
  --border-dim: #2a2a4a;
22
  }
 
32
  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");
33
  }
34
 
35
+ button, input { font: inherit; }
36
+ button { cursor: pointer; transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease, filter .2s ease; }
37
+ button:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.1); }
38
+ button:disabled { cursor: not-allowed; opacity: .5; }
39
 
40
+ .app-shell { width: min(1100px, 100%); margin: 0 auto; padding: 20px; }
 
 
 
41
 
42
+ header { padding: 28px 0 20px; text-align: center; }
43
+ h1, h2, h3 { margin: 0; font-family: "Cinzel", serif; }
44
+ h1 { color: var(--gold); font-size: clamp(2.2rem, 5vw, 4rem); font-weight: 700; text-shadow: 0 0 26px rgba(201,162,39,.32); }
45
+ .subtitle { margin-top: 6px; color: var(--text-gold); font-size: 1.25rem; }
46
 
47
+ .layout { display: grid; grid-template-columns: minmax(0, 40%) minmax(0, 60%); gap: 20px; align-items: start; }
48
+ .stack { display: grid; gap: 16px; }
49
+ .panel { border: 1px solid var(--border-dim); background: linear-gradient(180deg, rgba(22,33,62,.95), rgba(26,26,46,.95)); box-shadow: 0 18px 50px rgba(0,0,0,.35); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
+ /* ─── Upload Zone ─── */
52
  .upload-zone {
53
+ min-height: 260px;
54
+ display: grid; place-items: center;
 
 
55
  padding: 18px;
56
+ border: 2px dashed rgba(201,162,39,.46);
57
  color: var(--text-gold);
58
  text-align: center;
59
  transition: border-color .2s ease, background .2s ease, box-shadow .2s ease;
60
+ cursor: pointer;
61
  }
 
62
  .upload-zone.dragover,
63
+ .upload-zone:hover { border-color: var(--gold); background: rgba(201,162,39,.06); box-shadow: 0 0 28px rgba(201,162,39,.16); }
64
+ .upload-zone.has-image { cursor: default; }
65
+
66
+ .upload-prompt { display: grid; gap: 10px; justify-items: center; }
67
+ .upload-icon { font-size: 2.5rem; }
68
+ .upload-copy { color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .88rem; }
69
+
70
+ .upload-action-row { display: flex; gap: 10px; margin-top: 4px; }
71
+ .upload-action-btn {
72
+ display: flex; align-items: center; gap: 6px;
73
+ padding: 8px 18px;
74
+ border: 1px solid rgba(201,162,39,.5);
75
+ color: var(--text-gold); background: rgba(13,13,26,.7);
76
+ font-family: system-ui, sans-serif; font-size: .84rem; font-weight: 700;
77
+ }
78
+ .upload-action-btn:hover:not(:disabled) { border-color: var(--gold); background: rgba(201,162,39,.14); }
79
+ .upload-action-btn.camera-btn { border-color: rgba(0,180,255,.4); color: #66d9ff; }
80
+ .upload-action-btn.camera-btn:hover { border-color: #66d9ff; background: rgba(0,180,255,.1); }
81
+
82
+ .preview { max-width: 100%; width: 100%; max-height: 260px; object-fit: cover; border: 1px solid rgba(201,162,39,.35); box-shadow: 0 0 24px rgba(0,0,0,.34); }
83
+
84
+ /* ─── Primary / Secondary Buttons ─── */
85
+ .primary-btn { width: 100%; min-height: 50px; border: 1px solid #efc94a; color: #130f07; background: linear-gradient(135deg, #f4d76a, var(--gold), var(--gold-dark)); font-family: "Cinzel", serif; font-size: 1rem; font-weight: 700; box-shadow: 0 0 22px rgba(201,162,39,.28); letter-spacing: .06em; }
86
+ .secondary-btn { min-height: 42px; border: 1px solid rgba(201,162,39,.5); color: var(--text-gold); background: rgba(13,13,26,.55); font-family: system-ui, sans-serif; font-weight: 700; }
87
+
88
+ /* ─── Camera Modal ─── */
89
+ .camera-modal {
90
+ position: fixed; inset: 0; z-index: 200;
91
+ background: rgba(0,0,0,.92);
92
+ display: none; flex-direction: column; align-items: center; justify-content: center;
93
+ backdrop-filter: blur(6px);
94
+ }
95
+ .camera-modal.active { display: flex; }
96
+ .camera-box {
97
+ display: flex; flex-direction: column; align-items: center; gap: 18px;
98
+ padding: 28px 24px 24px;
99
+ border: 1px solid var(--gold-dark);
100
+ background: var(--bg-card);
101
+ max-width: 92vw;
102
+ box-shadow: 0 0 60px rgba(0,0,0,.6), 0 0 30px rgba(201,162,39,.1);
103
+ }
104
+ .camera-box h2 { color: var(--gold); font-size: 1.1rem; letter-spacing: .08em; }
105
+ .camera-video {
106
+ max-width: min(500px, 84vw);
107
+ max-height: 54vh;
108
+ border: 2px solid rgba(201,162,39,.5);
109
+ background: #000;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  display: block;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  }
112
+ .camera-canvas { display: none; }
113
+ .camera-controls { display: flex; gap: 12px; }
114
+ .camera-capture-btn {
115
+ padding: 12px 30px;
116
+ border: 2px solid #efc94a; color: #130f07;
117
+ background: linear-gradient(135deg, #f4d76a, var(--gold));
118
+ font-family: "Cinzel", serif; font-weight: 700; font-size: .95rem;
119
+ }
120
+ .camera-cancel-btn {
121
+ padding: 12px 20px;
122
+ border: 1px solid rgba(201,162,39,.35); color: var(--text-muted);
123
+ background: rgba(13,13,26,.8);
124
+ font-family: system-ui; font-size: .9rem;
125
+ }
126
+ .camera-error {
127
+ display: none; color: #ff7a7a;
128
+ font-family: system-ui, sans-serif; font-size: .85rem;
129
+ text-align: center; max-width: 360px;
130
+ padding: 10px; border: 1px solid rgba(255,80,80,.3);
131
+ background: rgba(255,50,50,.06);
132
+ }
133
+
134
+ /* ─── Loading Overlay ─── */
135
+ .loading-overlay {
136
+ position: fixed; inset: 0; z-index: 100;
137
+ background: rgba(13,13,26,.96);
138
+ display: none; flex-direction: column; align-items: center; justify-content: center; gap: 32px;
139
+ backdrop-filter: blur(4px);
140
+ }
141
+ .loading-overlay.active { display: flex; }
142
+
143
+ .loading-rune-ring {
144
+ position: relative; width: 120px; height: 120px;
145
+ display: flex; align-items: center; justify-content: center;
146
+ }
147
+ .rl-outer {
148
+ position: absolute; inset: 0;
149
+ border: 2px solid transparent;
150
+ border-top-color: var(--gold);
151
+ border-right-color: rgba(201,162,39,.35);
152
+ border-radius: 50%;
153
+ animation: spinCW 1.6s linear infinite;
154
+ }
155
+ .rl-mid {
156
+ position: absolute; inset: 14px;
157
+ border: 2px solid transparent;
158
+ border-bottom-color: rgba(201,162,39,.55);
159
+ border-left-color: rgba(201,162,39,.2);
160
+ border-radius: 50%;
161
+ animation: spinCCW 1.1s linear infinite;
162
+ }
163
+ .rl-inner {
164
+ position: absolute; inset: 28px;
165
+ border: 1px solid rgba(201,162,39,.28);
166
+ border-radius: 50%;
167
+ animation: spinCW 2.4s linear infinite;
168
+ }
169
+ .rl-center { font-size: 2.6rem; animation: runeGlow 2s ease-in-out infinite; z-index: 1; }
170
+
171
+ .loading-text-block { text-align: center; max-width: 360px; }
172
+ .loading-msg {
173
+ font-family: "Cinzel", serif; color: var(--gold);
174
+ font-size: 1.1rem; letter-spacing: .04em;
175
+ min-height: 1.8em;
176
+ transition: opacity .4s ease;
177
+ }
178
+ .loading-submsg { color: var(--text-muted); font-family: system-ui; font-size: .8rem; margin-top: 8px; }
179
+
180
+ .loading-orbs { display: flex; gap: 10px; justify-content: center; }
181
+ .loading-orb { width: 8px; height: 8px; background: var(--gold-dark); border-radius: 50%; animation: orbPulse 1.5s ease-in-out infinite; }
182
+ .loading-orb:nth-child(2) { animation-delay: .28s; }
183
+ .loading-orb:nth-child(3) { animation-delay: .56s; }
184
+
185
+ /* ─── Portrait NPC Card ─── */
186
+ .npc-portrait-card { display: flex; flex-direction: column; overflow: hidden; }
187
+
188
+ /* Image frame — portrait crop */
189
+ .card-img-frame {
190
+ position: relative; height: 280px; flex-shrink: 0; overflow: hidden;
191
+ background: var(--bg-dark);
192
+ }
193
+ .card-img-frame img {
194
+ width: 100%; height: 100%;
195
+ object-fit: cover; object-position: top center;
196
+ display: block;
197
  }
198
+ /* Gradient overlay on image */
199
+ .card-img-gradient {
200
+ position: absolute; inset: 0;
201
+ background: linear-gradient(
202
+ to bottom,
203
+ rgba(13,13,26,0) 25%,
204
+ rgba(13,13,26,.45) 60%,
205
+ rgba(22,33,62,.98) 100%
206
+ );
207
+ }
208
+ /* Decorative corner rune lines */
209
+ .card-img-corner {
210
+ position: absolute; top: 10px; left: 10px; right: 10px;
211
+ display: flex; justify-content: space-between;
212
+ pointer-events: none;
213
+ }
214
+ .corner-gem {
215
+ width: 38px; height: 38px;
216
+ border: 2px solid currentColor;
217
+ display: flex; align-items: center; justify-content: center;
218
+ background: rgba(13,13,26,.78);
219
+ font-family: system-ui; font-size: .6rem; font-weight: 900; text-transform: uppercase;
220
+ }
221
+ /* Name overlay on bottom of image */
222
+ .card-img-name-overlay {
223
+ position: absolute; bottom: 12px; left: 16px; right: 16px;
224
+ }
225
+ .npc-name { color: var(--gold); font-family: "Cinzel", serif; font-size: 1.35rem; font-weight: 700; line-height: 1.2; text-shadow: 0 2px 10px rgba(0,0,0,.95); }
226
+ .npc-class { color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .8rem; margin-top: 2px; text-shadow: 0 1px 6px rgba(0,0,0,.9); }
227
+
228
+ /* Card body */
229
+ .card-body { padding: 14px 16px 18px; }
230
+
231
+ .badge-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
232
+ .badge { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 8px; border: 1px solid var(--border-dim); color: var(--text-gold); background: rgba(13,13,26,.58); font-family: system-ui, sans-serif; font-size: .72rem; font-weight: 700; text-transform: uppercase; }
233
+ .rarity { border-color: currentColor; }
234
+
235
+ .stats { display: grid; gap: 8px; margin-bottom: 12px; }
236
+ .stat { display: grid; grid-template-columns: 98px minmax(0,1fr) 34px; gap: 8px; align-items: center; font-family: system-ui, sans-serif; font-size: .8rem; }
237
+ .stat-track { height: 8px; overflow: hidden; background: rgba(13,13,26,.72); border: 1px solid var(--border-dim); }
238
+ .stat-fill { height: 100%; width: 0; transition: width .7s ease, background .3s ease; }
239
+
240
+ .ability-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; }
241
+ .ability { padding: 10px; border: 1px solid var(--border-dim); background: rgba(13,13,26,.4); }
242
+ .ability h3 { color: var(--gold); font-size: .8rem; }
243
+ .ability p { margin: 5px 0 0; line-height: 1.35; font-size: .86rem; }
244
+
245
+ .lore { margin-bottom: 10px; color: var(--text-gold); font-style: italic; line-height: 1.48; font-size: .93rem; }
246
+ .world-line { display: flex; flex-wrap: wrap; gap: 8px; color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .76rem; }
247
+
248
+ /* ─── Friendship ─── */
249
+ .friendship { padding: 16px; }
250
+ .section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 10px; }
251
+ .section-heading h2 { color: var(--gold); font-size: 1.05rem; }
252
+ .friendship-pct { color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .86rem; font-weight: 700; }
253
+ .friendship-track { height: 14px; overflow: hidden; border: 1px solid rgba(201,162,39,.34); background: rgba(13,13,26,.78); }
254
+ .friendship-fill { height: 100%; width: 0; background: linear-gradient(90deg, var(--gold-dark), var(--gold), #f4d76a); box-shadow: 0 0 18px rgba(201,162,39,.4); transition: width .75s ease; }
255
+ .friendship-flavor { margin: 8px 0 0; color: var(--text-muted); font-style: italic; font-size: .92rem; }
256
+
257
+ /* ─── Secrets ─── */
258
+ .secret-layer { position: fixed; top: 18px; right: 18px; z-index: 20; display: grid; width: min(380px, calc(100vw - 36px)); gap: 10px; }
259
+ .secret-toast { padding: 14px; border: 1px solid #a335ee; color: var(--text-gold); background: rgba(22,33,62,.96); box-shadow: 0 0 28px rgba(163,53,238,.4), 0 0 18px rgba(201,162,39,.22); animation: secretReveal .45s ease both; }
260
+ .secret-title { color: var(--gold); font-family: "Cinzel", serif; font-weight: 700; }
261
+ .secret-body { margin-top: 6px; opacity: 0; animation: secretText .4s ease .5s both; }
262
+
263
+ /* ─── Chat ─── */
264
+ .chat-panel { display: grid; grid-template-rows: minmax(360px, 50vh) auto; overflow: hidden; }
265
+ .chat-log { overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; scroll-behavior: smooth; }
266
+ .message { max-width: 78%; padding: 10px 12px; line-height: 1.5; font-size: 1rem; border: 1px solid var(--border-dim); }
267
+ .message.user { align-self: flex-end; background: rgba(13,13,26,.82); color: #f2e6d0; }
268
+ .message.npc { align-self: flex-start; background: rgba(42,42,74,.72); color: var(--text-gold); border-color: rgba(201,162,39,.28); }
269
+ .sender { display: block; margin-bottom: 4px; color: var(--gold); font-family: system-ui, sans-serif; font-size: .76rem; font-weight: 800; }
270
+ .typing { animation: goldPulse 1s ease-in-out infinite; }
271
+ .chat-form { display: grid; grid-template-columns: minmax(0,1fr) 110px; gap: 10px; padding: 12px; border-top: 1px solid var(--border-dim); background: rgba(13,13,26,.54); }
272
+ .chat-form input { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--border-dim); color: var(--text-gold); background: rgba(13,13,26,.85); outline: none; transition: border-color .2s ease, box-shadow .2s ease; }
273
+ .chat-form input:focus { border-color: var(--gold); box-shadow: 0 0 0 3px rgba(201,162,39,.12); }
274
+
275
+ /* ─── Quests ─── */
276
+ .quest-panel { padding: 16px; }
277
+ .quest-list { display: grid; gap: 12px; margin-bottom: 12px; }
278
  .quest {
279
+ padding: 12px 14px;
280
  border: 1px solid var(--border-dim);
281
  border-left: 4px solid #9d9d9d;
282
+ background: rgba(13,13,26,.38);
283
+ transition: background .35s ease, box-shadow .35s ease;
284
+ }
285
+ .quest.quest-active { background: rgba(201,162,39,.06); box-shadow: 0 0 18px rgba(201,162,39,.1); }
286
+ .quest.quest-done { opacity: .65; }
287
+ .quest h3 { color: var(--gold); font-size: .88rem; }
288
+ .quest p { margin: 5px 0 6px; line-height: 1.38; font-size: .9rem; }
289
+ .reward-line { display: block; margin-top: 4px; color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .76rem; font-weight: 700; }
290
+
291
+ /* Quest progress bar */
292
+ .quest-progress { height: 4px; background: rgba(201,162,39,.12); margin: 8px 0 6px; overflow: hidden; border-radius: 2px; }
293
+ .quest-progress-fill { height: 100%; background: linear-gradient(90deg, var(--gold-dark), var(--gold)); transition: width .5s ease; border-radius: 2px; }
294
+
295
+ /* Quest action area */
296
+ .quest-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
297
+ .quest-btn {
298
+ padding: 6px 14px; border: 1px solid; cursor: pointer;
299
+ font-family: system-ui, sans-serif; font-size: .78rem; font-weight: 700;
300
+ transition: all .2s ease;
301
+ }
302
+ .begin-btn { color: var(--gold); border-color: rgba(201,162,39,.55); background: rgba(201,162,39,.08); }
303
+ .begin-btn:hover:not(:disabled) { background: rgba(201,162,39,.2); }
304
+ .complete-btn { color: #1eff00; border-color: rgba(30,255,0,.45); background: rgba(30,255,0,.07); }
305
+ .complete-btn:hover:not(:disabled) { background: rgba(30,255,0,.18); }
306
+ .complete-btn:disabled { color: rgba(30,255,0,.3); border-color: rgba(30,255,0,.18); background: transparent; cursor: not-allowed; opacity: 1; }
307
+ .quest-hint { color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .72rem; font-style: italic; }
308
+ .quest-done-badge { color: #1eff00; font-family: system-ui, sans-serif; font-size: .78rem; font-weight: 700; }
309
+ .quest-summary-label { color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .78rem; }
310
+
311
+ /* ─── Reward Toast ─── */
312
+ .reward-toast {
313
+ position: fixed; top: 22px; left: 50%; transform: translateX(-50%) translateY(-12px);
314
+ z-index: 50;
315
+ padding: 14px 28px; text-align: center;
316
+ border: 2px solid #1eff00;
317
+ background: rgba(10,22,10,.97);
318
+ box-shadow: 0 0 40px rgba(30,255,0,.4);
319
+ font-family: "Cinzel", serif; color: #1eff00; font-size: .92rem;
320
+ animation: rewardIn .45s ease forwards;
321
+ max-width: 90vw;
322
+ }
323
+ .reward-toast-sub { display: block; margin-top: 4px; font-size: .78rem; opacity: .8; font-family: system-ui; color: var(--text-gold); }
324
+
325
+ /* ─── Share Card ─── */
326
+ .share-card { padding: 16px; }
327
+ .share-image { display: none; width: 100%; margin-top: 12px; border: 1px solid rgba(201,162,39,.35); }
328
+ .download-link { display: none; margin-top: 8px; color: var(--gold); font-family: system-ui, sans-serif; font-weight: 700; }
329
+
330
+ /* ─── Error ─── */
331
+ .error { display: none; padding: 12px; border: 1px solid rgba(163,53,238,.65); color: var(--text-gold); background: rgba(163,53,238,.12); font-style: italic; }
332
  .error.active { display: block; }
333
+ .hidden-input { display: none; }
334
+
335
+ /* ─── Animations ─── */
336
+ @keyframes goldPulse { 0%,100%{opacity:.62;text-shadow:0 0 6px rgba(201,162,39,.2)} 50%{opacity:1;text-shadow:0 0 18px rgba(201,162,39,.66)} }
337
+ @keyframes pulse { 0%,100%{filter:brightness(1)} 50%{filter:brightness(1.35)} }
338
+ @keyframes secretReveal { from{opacity:0;transform:scale(.88) translateY(-12px)} to{opacity:1;transform:scale(1) translateY(0)} }
339
+ @keyframes secretText { to{opacity:1} }
340
+ @keyframes spinCW { to{ transform: rotate(360deg); } }
341
+ @keyframes spinCCW { to{ transform: rotate(-360deg); } }
342
+ @keyframes runeGlow { 0%,100%{text-shadow:0 0 8px rgba(201,162,39,.3);opacity:.7} 50%{text-shadow:0 0 30px rgba(201,162,39,.85);opacity:1} }
343
+ @keyframes orbPulse { 0%,80%,100%{transform:scale(.7);background:var(--gold-dark)} 40%{transform:scale(1.3);background:var(--gold)} }
344
+ @keyframes rewardIn { from{opacity:0;transform:translateX(-50%) translateY(-12px)} to{opacity:1;transform:translateX(-50%) translateY(0)} }
345
+
346
+ /* ─── Responsive ─── */
 
 
 
 
 
 
 
 
 
 
347
  @media (max-width: 899px) {
348
+ .layout { grid-template-columns: 1fr; }
349
+ .ability-grid { grid-template-columns: 1fr; }
350
+ .chat-panel { grid-template-rows: 420px auto; }
351
+ .message { max-width: 90%; }
 
 
 
 
 
 
 
 
 
 
 
352
  }
 
353
  @media (max-width: 520px) {
354
+ .app-shell { padding: 14px; }
355
+ .stat { grid-template-columns: 84px minmax(0,1fr) 30px; gap: 6px; font-size: .76rem; }
356
+ .chat-form { grid-template-columns: 1fr; }
357
+ .card-img-frame { height: 230px; }
 
 
 
 
 
 
 
 
 
358
  }
359
  </style>
360
  </head>
361
  <body>
362
+
363
+ <!-- ══════════════ CAMERA MODAL ══════════════ -->
364
+ <div class="camera-modal" id="cameraModal" aria-modal="true" role="dialog" aria-label="Camera capture">
365
+ <div class="camera-box">
366
+ <h2>📷 Capture Your Portrait</h2>
367
+ <video class="camera-video" id="cameraVideo" autoplay playsinline muted></video>
368
+ <canvas class="camera-canvas" id="cameraCanvas"></canvas>
369
+ <div class="camera-error" id="cameraError"></div>
370
+ <div class="camera-controls">
371
+ <button class="camera-capture-btn" id="cameraCaptureBtn">⚡ Capture</button>
372
+ <button class="camera-cancel-btn" id="cameraCloseBtn">✕ Cancel</button>
373
+ </div>
374
+ </div>
375
+ </div>
376
+
377
+ <!-- ══════════════ LOADING OVERLAY ══════════════ -->
378
+ <div class="loading-overlay" id="loadingOverlay" aria-live="polite" aria-busy="true">
379
+ <div class="loading-rune-ring">
380
+ <div class="rl-outer"></div>
381
+ <div class="rl-mid"></div>
382
+ <div class="rl-inner"></div>
383
+ <div class="rl-center">⚔</div>
384
+ </div>
385
+ <div class="loading-text-block">
386
+ <div class="loading-msg" id="loadingMsg">Summoning your character...</div>
387
+ <div class="loading-submsg">ZeroGPU is forging your destiny — this may take a moment</div>
388
+ </div>
389
+ <div class="loading-orbs">
390
+ <div class="loading-orb"></div>
391
+ <div class="loading-orb"></div>
392
+ <div class="loading-orb"></div>
393
+ </div>
394
+ </div>
395
+
396
+ <!-- ══════════════ SECRET LAYER ══════════════ -->
397
+ <div class="secret-layer" id="secretLayer" aria-live="polite"></div>
398
+
399
+ <!-- ══════════════ MAIN APP ══════════════ -->
400
+ <main class="app-shell">
401
+ <header>
402
+ <h1>⚔ NPCverse</h1>
403
+ <p class="subtitle">Turn anyone into a living RPG character</p>
404
+ </header>
405
+
406
+ <div class="layout">
407
+
408
+ <!-- ── LEFT COLUMN ── -->
409
+ <section class="stack">
410
+
411
+ <!-- Upload Zone -->
412
+ <div class="upload-zone panel" id="uploadZone" tabindex="0" aria-label="Upload a character photo">
413
+ <input class="hidden-input" id="imageInput" type="file" accept="image/*">
414
+ <div class="upload-prompt" id="uploadPrompt">
415
+ <div class="upload-icon">🜂</div>
416
+ <h2>Drop a portrait into the rift</h2>
417
+ <div class="upload-copy">Drag an image, choose a file, or capture from camera</div>
418
+ <div class="upload-action-row">
419
+ <button class="upload-action-btn" id="filePickBtn" type="button">🖼 Choose File</button>
420
+ <button class="upload-action-btn camera-btn" id="cameraOpenBtn" type="button">📷 Camera</button>
421
  </div>
 
422
  </div>
423
+ <img class="preview" id="imagePreview" alt="Uploaded portrait preview" hidden>
424
+ </div>
425
 
426
+ <button class="primary-btn" id="summonBtn" disabled>⚡ SUMMON NPC</button>
427
+ <div class="error" id="errorBox">*The realm connection falters... try again, traveler.*</div>
 
 
 
428
 
429
+ <!-- Portrait NPC Card -->
430
+ <article class="npc-portrait-card panel" id="characterCard" style="display:none"></article>
 
 
 
 
431
 
432
+ <!-- Share Card -->
433
+ <section class="share-card panel">
434
+ <button class="secondary-btn" id="shareBtn" disabled style="width:100%">📤 Export Share Card</button>
435
+ <img class="share-image" id="shareImage" alt="Generated NPC share card">
436
+ <a class="download-link" id="downloadLink" download="npcverse-share-card.png">⬇ Download share card</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  </section>
438
+ </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
 
440
+ <!-- ── RIGHT COLUMN ── -->
441
+ <section class="stack">
 
 
 
 
442
 
443
+ <!-- Friendship -->
444
+ <section class="friendship panel">
445
+ <div class="section-heading">
446
+ <h2 id="friendshipLabel">Stranger</h2>
447
+ <span class="friendship-pct" id="friendshipPct">0%</span>
448
+ </div>
449
+ <div class="friendship-track">
450
+ <div class="friendship-fill" id="friendshipFill"></div>
451
+ </div>
452
+ <p class="friendship-flavor" id="friendshipFlavor">A story waits at the edge of the campfire.</p>
453
+ </section>
454
 
455
+ <!-- Chat -->
456
+ <section class="chat-panel panel">
457
+ <div class="chat-log" id="chatLog" aria-live="polite"></div>
458
+ <form class="chat-form" id="chatForm">
459
+ <input id="chatInput" type="text" placeholder="Speak to your summoned NPC..." autocomplete="off" disabled>
460
+ <button class="secondary-btn" id="sendBtn" type="submit" disabled>Send</button>
461
+ </form>
462
+ </section>
463
 
464
+ <!-- Quests -->
465
+ <section class="quest-panel panel">
466
+ <div class="section-heading">
467
+ <h2>Active Quests</h2>
468
+ <span class="quest-summary-label" id="questSummary"></span>
469
+ </div>
470
+ <div class="quest-list" id="questList"></div>
471
+ <button class="secondary-btn" id="newQuestBtn" disabled style="width:100%">⚔ Request New Quest</button>
472
+ </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
 
474
+ </section>
475
+ </div>
476
+ </main>
477
+
478
+ <script type="module">
479
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
480
+
481
+ /* ═══════════════════════════════════════════
482
+ STATE
483
+ ═══════════════════════════════════════════ */
484
+ let currentNPC = null;
485
+ let chatHistory = [];
486
+ let displayHistory = [];
487
+ let messageCount = 0;
488
+ let unlockedSecrets = [];
489
+ let clientPromise = null;
490
+ let imageFile = null;
491
+ let portraitUrl = null; // object URL for the uploaded image
492
+
493
+ // Quest state per index
494
+ let questStates = {}; // { [i]: 'available' | 'active' | 'completed' }
495
+ let questMsgsSince = {}; // messages sent while quest was active
496
+
497
+ // Camera
498
+ let cameraStream = null;
499
+
500
+ // Loading message cycling
501
+ const LOADING_MSGS = [
502
+ "Summoning your character from the void...",
503
+ "Reading the aura of your portrait...",
504
+ "Consulting the ancient grimoire...",
505
+ "Weaving lore from starlight...",
506
+ "Forging stats from the cosmos...",
507
+ "Inscribing your destiny in runes...",
508
+ "Calculating rarity from the celestial alignments...",
509
+ "Breathing a soul into your NPC...",
510
+ "Transcribing powers from the arcane codex...",
511
+ "Almost there — destiny takes time...",
512
+ ];
513
+ let loadingMsgIdx = 0;
514
+ let loadingMsgInterval = null;
515
+
516
+ /* ═══════════════════════════════════════════
517
+ DOM REFS
518
+ ═══════════════════════════════════════════ */
519
+ const $ = id => document.getElementById(id);
520
+ const uploadZone = $("uploadZone");
521
+ const imageInput = $("imageInput");
522
+ const uploadPrompt = $("uploadPrompt");
523
+ const imagePreview = $("imagePreview");
524
+ const filePickBtn = $("filePickBtn");
525
+ const cameraOpenBtn = $("cameraOpenBtn");
526
+ const summonBtn = $("summonBtn");
527
+ const loadingOverlay = $("loadingOverlay");
528
+ const loadingMsg = $("loadingMsg");
529
+ const errorBox = $("errorBox");
530
+ const characterCard = $("characterCard");
531
+ const friendshipLabel = $("friendshipLabel");
532
+ const friendshipPct = $("friendshipPct");
533
+ const friendshipFill = $("friendshipFill");
534
+ const friendshipFlavor = $("friendshipFlavor");
535
+ const chatLog = $("chatLog");
536
+ const chatForm = $("chatForm");
537
+ const chatInput = $("chatInput");
538
+ const sendBtn = $("sendBtn");
539
+ const questList = $("questList");
540
+ const questSummary = $("questSummary");
541
+ const newQuestBtn = $("newQuestBtn");
542
+ const shareBtn = $("shareBtn");
543
+ const shareImage = $("shareImage");
544
+ const downloadLink = $("downloadLink");
545
+ const secretLayer = $("secretLayer");
546
+ const cameraModal = $("cameraModal");
547
+ const cameraVideo = $("cameraVideo");
548
+ const cameraCanvas = $("cameraCanvas");
549
+ const cameraCaptureBtn = $("cameraCaptureBtn");
550
+ const cameraCloseBtn = $("cameraCloseBtn");
551
+ const cameraError = $("cameraError");
552
+
553
+ /* ═══════════════════════════════════════════
554
+ LOOKUP TABLES
555
+ ═══════════════════════════════════════════ */
556
+ const rarityMap = {
557
+ Common: { color: "#9d9d9d", glow: "none", pulse: false },
558
+ Uncommon: { color: "#1eff00", glow: "0 0 20px rgba(30,255,0,.25)", pulse: false },
559
+ Rare: { color: "#0070dd", glow: "0 0 28px rgba(0,112,221,.35)", pulse: false },
560
+ Epic: { color: "#a335ee", glow: "0 0 38px rgba(163,53,238,.45)", pulse: false },
561
+ Legendary: { color: "#ff8000", glow: "0 0 50px rgba(255,128,0,.6)", pulse: true },
562
+ };
563
+
564
+ const friendshipCopy = {
565
+ Stranger: "A story waits at the edge of the campfire.",
566
+ Acquaintance: "Names have power, and yours is beginning to matter.",
567
+ Friend: "Trust gathers like warm light around the party.",
568
+ "Trusted Ally": "Oaths are forming beneath every shared word.",
569
+ "Legendary Bond": "Your legend and theirs now share the same ink.",
570
+ };
571
+
572
+ /* ═══════════════════════════════════════════
573
+ UPLOAD — file / drag-drop
574
+ ═══════════════════════════════════════════ */
575
+ uploadZone.addEventListener("click", e => {
576
+ if (e.target === filePickBtn || e.target === cameraOpenBtn) return;
577
+ if (!imageFile) imageInput.click();
578
+ });
579
+ uploadZone.addEventListener("keydown", e => {
580
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); imageInput.click(); }
581
+ });
582
+
583
+ filePickBtn.addEventListener("click", e => { e.stopPropagation(); imageInput.click(); });
584
+ cameraOpenBtn.addEventListener("click", e => { e.stopPropagation(); openCamera(); });
585
+
586
+ uploadZone.addEventListener("dragover", e => { e.preventDefault(); uploadZone.classList.add("dragover"); });
587
+ uploadZone.addEventListener("dragleave", () => uploadZone.classList.remove("dragover"));
588
+ uploadZone.addEventListener("drop", e => {
589
+ e.preventDefault(); uploadZone.classList.remove("dragover");
590
+ const f = e.dataTransfer.files[0];
591
+ if (f) setImageFile(f);
592
+ });
593
+ imageInput.addEventListener("change", () => { if (imageInput.files[0]) setImageFile(imageInput.files[0]); });
594
+
595
+ function setImageFile(file) {
596
+ imageFile = file;
597
+ if (portraitUrl) URL.revokeObjectURL(portraitUrl);
598
+ portraitUrl = URL.createObjectURL(file);
599
+ imagePreview.src = portraitUrl;
600
+ imagePreview.hidden = false;
601
+ uploadPrompt.style.display = "none";
602
+ uploadZone.classList.add("has-image");
603
+ summonBtn.disabled = false;
604
+ clearError();
605
+ }
606
+
607
+ /* ══��════════════════════════════════════════
608
+ CAMERA
609
+ ═══════════════════════════════════════════ */
610
+ async function openCamera() {
611
+ cameraError.style.display = "none";
612
+ cameraError.textContent = "";
613
+ cameraCaptureBtn.disabled = false;
614
+ cameraModal.classList.add("active");
615
+
616
+ try {
617
+ cameraStream = await navigator.mediaDevices.getUserMedia({
618
+ video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } }
619
+ });
620
+ cameraVideo.srcObject = cameraStream;
621
+ } catch (err) {
622
+ cameraError.textContent = `Camera unavailable: ${err.message}. Please use file upload instead.`;
623
+ cameraError.style.display = "block";
624
+ cameraCaptureBtn.disabled = true;
625
+ }
626
+ }
627
+
628
+ function closeCamera() {
629
+ if (cameraStream) { cameraStream.getTracks().forEach(t => t.stop()); cameraStream = null; }
630
+ cameraVideo.srcObject = null;
631
+ cameraModal.classList.remove("active");
632
+ }
633
+
634
+ cameraCaptureBtn.addEventListener("click", () => {
635
+ if (!cameraStream) return;
636
+ const w = cameraVideo.videoWidth || 640;
637
+ const h = cameraVideo.videoHeight || 480;
638
+ cameraCanvas.width = w;
639
+ cameraCanvas.height = h;
640
+ cameraCanvas.getContext("2d").drawImage(cameraVideo, 0, 0, w, h);
641
+ cameraCanvas.toBlob(blob => {
642
+ const file = new File([blob], "camera-capture.jpg", { type: "image/jpeg" });
643
+ setImageFile(file);
644
+ closeCamera();
645
+ }, "image/jpeg", 0.92);
646
+ });
647
+
648
+ cameraCloseBtn.addEventListener("click", closeCamera);
649
+ cameraModal.addEventListener("click", e => { if (e.target === cameraModal) closeCamera(); });
650
+ document.addEventListener("keydown", e => { if (e.key === "Escape" && cameraModal.classList.contains("active")) closeCamera(); });
651
+
652
+ /* ═══════════════════════════════════════════
653
+ LOADING OVERLAY
654
+ ═══════════════════════════════════════════ */
655
+ function showLoading(on) {
656
+ if (on) {
657
+ loadingMsgIdx = 0;
658
+ loadingMsg.textContent = LOADING_MSGS[0];
659
+ loadingOverlay.classList.add("active");
660
+ summonBtn.disabled = true;
661
+ loadingMsgInterval = setInterval(() => {
662
+ loadingMsgIdx = (loadingMsgIdx + 1) % LOADING_MSGS.length;
663
+ loadingMsg.style.opacity = "0";
664
+ setTimeout(() => {
665
+ loadingMsg.textContent = LOADING_MSGS[loadingMsgIdx];
666
+ loadingMsg.style.opacity = "1";
667
+ }, 200);
668
+ }, 2800);
669
+ } else {
670
+ loadingOverlay.classList.remove("active");
671
+ clearInterval(loadingMsgInterval);
672
+ loadingMsgInterval = null;
673
+ summonBtn.disabled = !imageFile;
674
+ }
675
+ }
676
+
677
+ /* ═══════════════════════════════════════════
678
+ SUMMON NPC
679
+ ═══════════════════════════════════════════ */
680
+ summonBtn.addEventListener("click", summonNPC);
681
+
682
+ async function summonNPC() {
683
+ if (!imageFile) return;
684
+ showLoading(true);
685
+ clearError();
686
+
687
+ try {
688
+ const client = await getClient();
689
+ const result = await client.predict("/summon_npc", { image_path: handle_file(imageFile) });
690
+ const npc = result.data[0];
691
+
692
+ currentNPC = npc;
693
+ chatHistory = [];
694
+ displayHistory = [];
695
+ messageCount = 0;
696
+ unlockedSecrets = [];
697
+ questStates = {};
698
+ questMsgsSince = {};
699
+
700
+ renderNPC(npc);
701
+ updateFriendship("Stranger", 0);
702
+ renderChat();
703
+ renderQuests(npc.quests || []);
704
+ enablePlay(true);
705
+ addNPCMessage(npc.opening_line || "The veil parts. I have arrived.");
706
+ } catch (err) {
707
+ showError();
708
+ } finally {
709
+ showLoading(false);
710
+ }
711
+ }
712
+
713
+ /* ═══════════════════════════════════════════
714
+ CHAT
715
+ ═══════════════════════════════════════════ */
716
+ chatForm.addEventListener("submit", e => {
717
+ e.preventDefault();
718
+ const txt = chatInput.value.trim();
719
+ if (txt) sendChatMessage(txt);
720
+ });
721
+
722
+ newQuestBtn.addEventListener("click", () =>
723
+ sendChatMessage("Give me a new quest — something I haven't attempted yet.")
724
+ );
725
+
726
+ async function sendChatMessage(userText) {
727
+ if (!currentNPC || !userText) return;
728
+ chatInput.value = "";
729
+ appendDisplay("user", userText);
730
+ const typingId = appendTyping();
731
+ toggleChat(false);
732
+
733
+ // Increment message counter for each active quest
734
+ Object.keys(questStates).forEach(idx => {
735
+ if (questStates[idx] === "active") {
736
+ questMsgsSince[idx] = (questMsgsSince[idx] || 0) + 1;
737
  }
738
+ });
739
 
740
+ try {
741
+ const client = await getClient();
742
+ const res = await client.predict("/chat_with_npc", {
743
+ npc_json: JSON.stringify(currentNPC),
744
+ history_json: JSON.stringify(chatHistory),
745
+ user_message: userText,
746
+ msg_count: messageCount,
747
+ unlocked_secrets_json: JSON.stringify(unlockedSecrets),
748
+ });
749
+ const { response, new_msg_count, friendship_label, friendship_pct, newly_unlocked, secret_texts } = res.data[0];
750
+
751
+ removeMessage(typingId);
752
+ appendDisplay("npc", response);
753
+ chatHistory.push({ role: "user", content: userText }, { role: "assistant", content: response });
754
+ chatHistory = chatHistory.slice(-20);
755
+ messageCount = new_msg_count;
756
+ unlockedSecrets = Array.from(new Set([...unlockedSecrets, ...newly_unlocked]));
757
+ updateFriendship(friendship_label, friendship_pct);
758
+ showSecrets(secret_texts);
759
+ if (currentNPC?.quests) renderQuests(currentNPC.quests);
760
+ } catch (err) {
761
+ removeMessage(typingId);
762
+ appendDisplay("npc", "*The realm connection falters... try again, traveler.*");
763
+ } finally {
764
+ toggleChat(true);
765
+ }
766
+ }
767
+
768
+ /* ═══════════════════════════════════════════
769
+ QUEST SYSTEM
770
+ ═══════════════════════════════════════════ */
771
+ const QUEST_MSGS_NEEDED = 3; // exchanges before "Complete" unlocks
772
+
773
+ function renderQuests(quests) {
774
+ const total = Math.min(quests.length, 3);
775
+ const completed = Object.values(questStates).filter(s => s === "completed").length;
776
+ questSummary.textContent = completed > 0 ? `${completed} / ${total} completed` : "";
777
+
778
+ questList.innerHTML = quests.slice(0, 3).map((quest, i) => {
779
+ const state = questStates[i] || "available";
780
+ const rarity = quest.rarity || "Common";
781
+ const color = (rarityMap[rarity] || rarityMap.Common).color;
782
+ const msgs = questMsgsSince[i] || 0;
783
+ const pct = Math.min(100, (msgs / QUEST_MSGS_NEEDED) * 100);
784
+ const ready = msgs >= QUEST_MSGS_NEEDED;
785
+
786
+ let progressHtml = "";
787
+ let actionsHtml = "";
788
+
789
+ if (state === "available") {
790
+ actionsHtml = `<button class="quest-btn begin-btn" data-qi="${i}">⚔ Begin Quest</button>`;
791
+ } else if (state === "active") {
792
+ progressHtml = `
793
+ <div class="quest-progress">
794
+ <div class="quest-progress-fill" style="width:${pct}%"></div>
795
+ </div>`;
796
+ actionsHtml = `
797
+ <button class="quest-btn complete-btn" ${ready ? "" : "disabled"} data-qi="${i}">
798
+ ${ready ? "✓ Claim Reward" : `In Progress — ${msgs}/${QUEST_MSGS_NEEDED} exchanges`}
799
+ </button>
800
+ ${!ready ? `<span class="quest-hint">Keep talking to advance</span>` : ""}`;
801
+ } else {
802
+ actionsHtml = `<span class="quest-done-badge">✦ Completed</span>`;
803
  }
 
 
 
 
 
 
 
804
 
805
+ return `
806
+ <article class="quest ${state === "active" ? "quest-active" : state === "completed" ? "quest-done" : ""}"
807
+ style="border-left-color:${color}">
808
+ <h3>${escapeHtml(quest.title)}${state === "active" ? " ⚔" : state === "completed" ? " ✓" : ""}</h3>
809
+ <p>${escapeHtml(quest.description)}</p>
810
+ <span class="reward-line">Reward: ${escapeHtml(quest.reward)} · ${escapeHtml(rarity)}</span>
811
+ ${progressHtml}
812
+ <div class="quest-actions">${actionsHtml}</div>
813
+ </article>`;
814
+ }).join("");
815
+
816
+ // Attach button handlers
817
+ questList.querySelectorAll(".begin-btn").forEach(btn =>
818
+ btn.addEventListener("click", () => beginQuest(Number(btn.dataset.qi)))
819
+ );
820
+ questList.querySelectorAll(".complete-btn:not(:disabled)").forEach(btn =>
821
+ btn.addEventListener("click", () => completeQuest(Number(btn.dataset.qi)))
822
+ );
823
+ }
824
+
825
+ async function beginQuest(idx) {
826
+ if (!currentNPC?.quests?.[idx]) return;
827
+ const quest = currentNPC.quests[idx];
828
+ questStates[idx] = "active";
829
+ questMsgsSince[idx] = 0;
830
+ renderQuests(currentNPC.quests);
831
+ await sendChatMessage(`I accept your quest: "${quest.title}". Tell me where to begin.`);
832
+ }
833
+
834
+ async function completeQuest(idx) {
835
+ if (!currentNPC?.quests?.[idx]) return;
836
+ const quest = currentNPC.quests[idx];
837
+ questStates[idx] = "completed";
838
+ renderQuests(currentNPC.quests);
839
+ await sendChatMessage(`I've completed "${quest.title}"! I'm here to claim my reward: ${quest.reward}.`);
840
+ showRewardToast(quest.reward, quest.rarity);
841
+ }
842
+
843
+ function showRewardToast(reward, rarity) {
844
+ const rs = rarityMap[rarity] || rarityMap.Common;
845
+ const toast = document.createElement("div");
846
+ toast.className = "reward-toast";
847
+ toast.style.borderColor = rs.color;
848
+ toast.style.color = rs.color;
849
+ toast.style.boxShadow = `0 0 40px ${rs.color}55`;
850
+ toast.innerHTML = `✦ Quest Complete ✦<span class="reward-toast-sub">Reward obtained: ${escapeHtml(reward)}</span>`;
851
+ document.body.appendChild(toast);
852
+ setTimeout(() => toast.remove(), 5500);
853
+ }
854
+
855
+ /* ═══════════════════════════════════════════
856
+ SHARE CARD
857
+ ═══════════════════════════════════════════ */
858
+ shareBtn.addEventListener("click", exportShareCard);
859
+
860
+ async function exportShareCard() {
861
+ if (!currentNPC) return;
862
+ shareBtn.disabled = true;
863
+ try {
864
+ const client = await getClient();
865
+ const result = await client.predict("/generate_share_card", { npc_json: JSON.stringify(currentNPC) });
866
+ const { image_b64 } = result.data[0];
867
+ shareImage.src = image_b64;
868
+ shareImage.style.display = "block";
869
+ downloadLink.href = image_b64;
870
+ downloadLink.style.display = "inline-block";
871
+ } catch (err) {
872
+ showError();
873
+ } finally {
874
+ shareBtn.disabled = false;
875
+ }
876
+ }
877
+
878
+ /* ═══════════════════════════════════════════
879
+ PORTRAIT NPC CARD RENDER
880
+ ═══════════════════════════════════════════ */
881
+ function renderNPC(npc) {
882
+ const rarity = npc.rarity || "Common";
883
+ const rarityStyle = rarityMap[rarity] || rarityMap.Common;
884
+ const stats = npc.stats || {};
885
+ const statNames = ["strength","intelligence","charisma","luck","stealth","chaos"];
886
+
887
+ // Portrait image — use the uploaded photo
888
+ const imgHtml = portraitUrl
889
+ ? `<img src="${escapeHtml(portraitUrl)}" alt="Portrait of ${escapeHtml(npc.name || 'NPC')}">`
890
+ : `<div style="height:100%;display:flex;align-items:center;justify-content:center;font-size:4rem;background:var(--bg-dark)">${escapeHtml(npc.emoji||"⚔")}</div>`;
891
+
892
+ characterCard.innerHTML = `
893
+ <!-- ── Image Frame ── -->
894
+ <div class="card-img-frame">
895
+ ${imgHtml}
896
+ <div class="card-img-gradient"></div>
897
+
898
+ <!-- Corner gems -->
899
+ <div class="card-img-corner">
900
+ <div class="corner-gem" style="color:${rarityStyle.color};border-color:${rarityStyle.color};box-shadow:0 0 14px ${rarityStyle.color}55">
901
+ Lv.${escapeHtml(String(npc.level||1))}
902
+ </div>
903
+ <div class="corner-gem" style="color:${rarityStyle.color};border-color:${rarityStyle.color};box-shadow:0 0 14px ${rarityStyle.color}55;animation:${rarityStyle.pulse?"pulse 2s infinite":"none"}">
904
+ ${escapeHtml(rarity.slice(0,3).toUpperCase())}
905
  </div>
 
906
  </div>
907
+
908
+ <!-- Name overlaid on image gradient -->
909
+ <div class="card-img-name-overlay">
910
+ <div class="npc-name">${escapeHtml(npc.name||"Unknown Wanderer")}</div>
911
+ <div class="npc-class">${escapeHtml(npc.class||"Adventurer")} — Lv.${escapeHtml(String(npc.level||1))}</div>
912
+ </div>
913
+ </div>
914
+
915
+ <!-- ── Card Body ── -->
916
+ <div class="card-body">
917
  <div class="badge-row">
918
+ <span class="badge rarity" style="color:${rarityStyle.color};border-color:${rarityStyle.color};box-shadow:${rarityStyle.glow}">${escapeHtml(rarity)}</span>
919
+ <span class="badge">${escapeHtml(npc.alignment||"Unaligned")}</span>
920
+ <span class="badge">${escapeHtml(npc.title||"Untitled")}</span>
921
  </div>
922
+
923
  <div class="stats">
924
+ ${statNames.map(n => statRow(n, Number(stats[n]||0))).join("")}
925
  </div>
926
+
927
  <div class="ability-grid">
928
  <div class="ability">
929
+ <h3>Passive: ${escapeHtml(npc.passive_ability?.name||"Unknown")}</h3>
930
+ <p>${escapeHtml(npc.passive_ability?.description||"")}</p>
931
  </div>
932
  <div class="ability">
933
+ <h3>Ultimate: ${escapeHtml(npc.ultimate?.name||"Unknown")}</h3>
934
+ <p>${escapeHtml(npc.ultimate?.description||"")}</p>
935
  </div>
936
  </div>
937
+
938
+ <p class="lore">"${escapeHtml(npc.lore||"")}"</p>
939
  <div class="world-line">
940
+ <span>🌍 ${escapeHtml(npc.world||"Unknown")}</span>
941
+ <span> ${escapeHtml(npc.faction||"Unaffiliated")}</span>
942
+ <span>⚠ ${escapeHtml(npc.weakness||"Unknown")}</span>
943
  </div>
944
+ </div>
945
+ `;
946
+
947
+ characterCard.style.display = "flex";
948
+ characterCard.style.boxShadow = rarityStyle.glow === "none" ? "" : rarityStyle.glow;
949
+ }
950
+
951
+ function statRow(name, value) {
952
+ const disp = name.charAt(0).toUpperCase() + name.slice(1);
953
+ return `
954
+ <div class="stat">
955
+ <span>${disp}</span>
956
+ <div class="stat-track">
957
+ <div class="stat-fill" style="width:${clamp(value,0,100)}%;background:${statColor(value)}"></div>
 
 
 
958
  </div>
959
+ <strong>${clamp(value,0,100)}</strong>
960
+ </div>`;
961
+ }
962
+
963
+ /* ═══════════════════════════════════════════
964
+ FRIENDSHIP
965
+ ═══════════════════════════════════════════ */
966
+ function updateFriendship(label, pct) {
967
+ const safe = clamp(Number(pct||0), 0, 100);
968
+ friendshipLabel.textContent = label;
969
+ friendshipPct.textContent = `${Math.round(safe)}%`;
970
+ friendshipFill.style.width = `${safe}%`;
971
+ friendshipFlavor.textContent = friendshipCopy[label] || friendshipCopy.Stranger;
972
+ }
973
+
974
+ /* ═══════════════════════════════════════════
975
+ CHAT DISPLAY
976
+ ═══════════════════════════════════════════ */
977
+ function addNPCMessage(text) {
978
+ appendDisplay("npc", text);
979
+ chatHistory.push({ role: "assistant", content: text });
980
+ }
981
+
982
+ function appendDisplay(sender, text) {
983
+ const entry = { id: crypto.randomUUID(), sender, text };
984
+ displayHistory.push(entry);
985
+ renderMessage(entry);
986
+ scrollChat();
987
+ return entry.id;
988
+ }
989
+
990
+ function appendTyping() {
991
+ const entry = { id: crypto.randomUUID(), sender: "npc", text: "...", typing: true };
992
+ displayHistory.push(entry);
993
+ renderMessage(entry);
994
+ scrollChat();
995
+ return entry.id;
996
+ }
997
+
998
+ function removeMessage(id) {
999
+ displayHistory = displayHistory.filter(m => m.id !== id);
1000
+ renderChat();
1001
+ }
1002
+
1003
+ function renderChat() {
1004
+ chatLog.innerHTML = "";
1005
+ displayHistory.forEach(renderMessage);
1006
+ scrollChat();
1007
+ }
1008
+
1009
+ function renderMessage(entry) {
1010
+ const bubble = document.createElement("div");
1011
+ bubble.className = `message ${entry.sender}${entry.typing ? " typing" : ""}`;
1012
+ bubble.dataset.id = entry.id;
1013
+ if (entry.sender === "npc") {
1014
+ const lbl = document.createElement("span");
1015
+ lbl.className = "sender";
1016
+ lbl.textContent = `${currentNPC?.emoji || "✦"} ${currentNPC?.name || "NPC"}`;
1017
+ bubble.appendChild(lbl);
1018
+ }
1019
+ bubble.append(document.createTextNode(entry.text));
1020
+ chatLog.appendChild(bubble);
1021
+ }
1022
+
1023
+ function showSecrets(texts) {
1024
+ texts.forEach(text => {
1025
+ const toast = document.createElement("div");
1026
+ toast.className = "secret-toast";
1027
+ toast.innerHTML = `<div class="secret-title">🔮 Secret Revealed</div><div class="secret-body">${escapeHtml(text)}</div>`;
1028
+ secretLayer.appendChild(toast);
1029
+ setTimeout(() => toast.remove(), 6000);
1030
+ });
1031
+ }
1032
+
1033
+ /* ═══════════════════════════════════════════
1034
+ PLAY STATE HELPERS
1035
+ ═══════════════════════════════════════════ */
1036
+ function enablePlay(on) {
1037
+ chatInput.disabled = !on;
1038
+ sendBtn.disabled = !on;
1039
+ newQuestBtn.disabled = !on;
1040
+ shareBtn.disabled = !on;
1041
+ }
1042
+
1043
+ function toggleChat(on) {
1044
+ chatInput.disabled = !on;
1045
+ sendBtn.disabled = !on;
1046
+ newQuestBtn.disabled = !on;
1047
+ }
1048
+
1049
+ function showError() { errorBox.classList.add("active"); }
1050
+ function clearError() { errorBox.classList.remove("active"); }
1051
+ function scrollChat() { requestAnimationFrame(() => { chatLog.scrollTop = chatLog.scrollHeight; }); }
1052
+
1053
+ function getClient() {
1054
+ if (!clientPromise) clientPromise = Client.connect(window.location.origin);
1055
+ return clientPromise;
1056
+ }
1057
+
1058
+ /* ═══════════════════════════════════════════
1059
+ UTILITIES
1060
+ ═══════════════════════════════════════════ */
1061
+ function statColor(v) {
1062
+ if (v >= 80) return "#ff8000";
1063
+ if (v >= 65) return "#c9a227";
1064
+ if (v >= 45) return "#4488cc";
1065
+ return "#777";
1066
+ }
1067
+
1068
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
1069
+
1070
+ function escapeHtml(value) {
1071
+ return String(value)
1072
+ .replaceAll("&","&amp;")
1073
+ .replaceAll("<","&lt;")
1074
+ .replaceAll(">","&gt;")
1075
+ .replaceAll('"',"&quot;")
1076
+ .replaceAll("'","&#039;");
1077
+ }
1078
+ </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1079
  </body>
1080
  </html>