Download index.html from build-small-hackathon/NPCverse: direct link, hf CLI and curl.
- Browser
- Download file 50.3 kB
-
https://huggingface.co/spaces/build-small-hackathon/NPCverse/resolve/main/index.html
- Command line
-
hf download hf://spaces/build-small-hackathon/NPCverse/index.html
-
curl -L -o index.html https://huggingface.co/spaces/build-small-hackathon/NPCverse/resolve/main/index.html
50.3 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <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."> | |
| <title>NPCverse — Summon Your Character</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <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"> | |
| <style> | |
| :root { | |
| --bg-void: #0d0d1a; | |
| --bg-dark: #1a1a2e; | |
| --bg-card: #16213e; | |
| --gold: #c9a227; | |
| --gold-dark: #8b6914; | |
| --gold-light: #f4d76a; | |
| --text-gold: #e8d5b7; | |
| --text-muted: #8a7565; | |
| --border-dim: #2a2a4a; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| color: var(--text-gold); | |
| font-family: "Crimson Text", Georgia, serif; | |
| background-color: var(--bg-void); | |
| 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"); | |
| } | |
| button, input { font: inherit; } | |
| button { cursor: pointer; transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease, filter .2s ease; } | |
| button:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.1); } | |
| button:disabled { cursor: not-allowed; opacity: .5; } | |
| .app-shell { width: min(1100px, 100%); margin: 0 auto; padding: 20px; } | |
| header { padding: 28px 0 20px; text-align: center; } | |
| h1, h2, h3 { margin: 0; font-family: "Cinzel", serif; } | |
| h1 { color: var(--gold); font-size: clamp(2.2rem, 5vw, 4rem); font-weight: 700; text-shadow: 0 0 26px rgba(201,162,39,.32); } | |
| .subtitle { margin-top: 6px; color: var(--text-gold); font-size: 1.25rem; } | |
| .layout { display: grid; grid-template-columns: minmax(0, 40%) minmax(0, 60%); gap: 20px; align-items: start; } | |
| .stack { display: grid; gap: 16px; } | |
| .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); } | |
| /* ─── Upload Zone ─── */ | |
| .upload-zone { | |
| min-height: 260px; | |
| display: grid; place-items: center; | |
| padding: 18px; | |
| border: 2px dashed rgba(201,162,39,.46); | |
| color: var(--text-gold); | |
| text-align: center; | |
| transition: border-color .2s ease, background .2s ease, box-shadow .2s ease; | |
| cursor: pointer; | |
| } | |
| .upload-zone.dragover, | |
| .upload-zone:hover { border-color: var(--gold); background: rgba(201,162,39,.06); box-shadow: 0 0 28px rgba(201,162,39,.16); } | |
| .upload-zone.has-image { cursor: default; } | |
| .upload-prompt { display: grid; gap: 10px; justify-items: center; } | |
| .upload-icon { font-size: 2.5rem; } | |
| .upload-copy { color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .88rem; } | |
| .upload-action-row { display: flex; gap: 10px; margin-top: 4px; } | |
| .upload-action-btn { | |
| display: flex; align-items: center; gap: 6px; | |
| padding: 8px 18px; | |
| border: 1px solid rgba(201,162,39,.5); | |
| color: var(--text-gold); background: rgba(13,13,26,.7); | |
| font-family: system-ui, sans-serif; font-size: .84rem; font-weight: 700; | |
| } | |
| .upload-action-btn:hover:not(:disabled) { border-color: var(--gold); background: rgba(201,162,39,.14); } | |
| .upload-action-btn.camera-btn { border-color: rgba(0,180,255,.4); color: #66d9ff; } | |
| .upload-action-btn.camera-btn:hover { border-color: #66d9ff; background: rgba(0,180,255,.1); } | |
| .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); } | |
| /* ─── Primary / Secondary Buttons ─── */ | |
| .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; } | |
| .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; } | |
| /* ─── Camera Modal ─── */ | |
| .camera-modal { | |
| position: fixed; inset: 0; z-index: 200; | |
| background: rgba(0,0,0,.92); | |
| display: none; flex-direction: column; align-items: center; justify-content: center; | |
| backdrop-filter: blur(6px); | |
| } | |
| .camera-modal.active { display: flex; } | |
| .camera-box { | |
| display: flex; flex-direction: column; align-items: center; gap: 18px; | |
| padding: 28px 24px 24px; | |
| border: 1px solid var(--gold-dark); | |
| background: var(--bg-card); | |
| max-width: 92vw; | |
| box-shadow: 0 0 60px rgba(0,0,0,.6), 0 0 30px rgba(201,162,39,.1); | |
| } | |
| .camera-box h2 { color: var(--gold); font-size: 1.1rem; letter-spacing: .08em; } | |
| .camera-video { | |
| max-width: min(500px, 84vw); | |
| max-height: 54vh; | |
| border: 2px solid rgba(201,162,39,.5); | |
| background: #000; | |
| display: block; | |
| } | |
| .camera-canvas { display: none; } | |
| .camera-controls { display: flex; gap: 12px; } | |
| .camera-capture-btn { | |
| padding: 12px 30px; | |
| border: 2px solid #efc94a; color: #130f07; | |
| background: linear-gradient(135deg, #f4d76a, var(--gold)); | |
| font-family: "Cinzel", serif; font-weight: 700; font-size: .95rem; | |
| } | |
| .camera-cancel-btn { | |
| padding: 12px 20px; | |
| border: 1px solid rgba(201,162,39,.35); color: var(--text-muted); | |
| background: rgba(13,13,26,.8); | |
| font-family: system-ui; font-size: .9rem; | |
| } | |
| .camera-error { | |
| display: none; color: #ff7a7a; | |
| font-family: system-ui, sans-serif; font-size: .85rem; | |
| text-align: center; max-width: 360px; | |
| padding: 10px; border: 1px solid rgba(255,80,80,.3); | |
| background: rgba(255,50,50,.06); | |
| } | |
| /* ─── Loading Overlay ─── */ | |
| .loading-overlay { | |
| position: fixed; inset: 0; z-index: 100; | |
| background: rgba(13,13,26,.96); | |
| display: none; flex-direction: column; align-items: center; justify-content: center; gap: 32px; | |
| backdrop-filter: blur(4px); | |
| } | |
| .loading-overlay.active { display: flex; } | |
| .loading-rune-ring { | |
| position: relative; width: 120px; height: 120px; | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .rl-outer { | |
| position: absolute; inset: 0; | |
| border: 2px solid transparent; | |
| border-top-color: var(--gold); | |
| border-right-color: rgba(201,162,39,.35); | |
| border-radius: 50%; | |
| animation: spinCW 1.6s linear infinite; | |
| } | |
| .rl-mid { | |
| position: absolute; inset: 14px; | |
| border: 2px solid transparent; | |
| border-bottom-color: rgba(201,162,39,.55); | |
| border-left-color: rgba(201,162,39,.2); | |
| border-radius: 50%; | |
| animation: spinCCW 1.1s linear infinite; | |
| } | |
| .rl-inner { | |
| position: absolute; inset: 28px; | |
| border: 1px solid rgba(201,162,39,.28); | |
| border-radius: 50%; | |
| animation: spinCW 2.4s linear infinite; | |
| } | |
| .rl-center { font-size: 2.6rem; animation: runeGlow 2s ease-in-out infinite; z-index: 1; } | |
| .loading-text-block { text-align: center; max-width: 360px; } | |
| .loading-msg { | |
| font-family: "Cinzel", serif; color: var(--gold); | |
| font-size: 1.1rem; letter-spacing: .04em; | |
| min-height: 1.8em; | |
| transition: opacity .4s ease; | |
| } | |
| .loading-submsg { color: var(--text-muted); font-family: system-ui; font-size: .8rem; margin-top: 8px; } | |
| .loading-orbs { display: flex; gap: 10px; justify-content: center; } | |
| .loading-orb { width: 8px; height: 8px; background: var(--gold-dark); border-radius: 50%; animation: orbPulse 1.5s ease-in-out infinite; } | |
| .loading-orb:nth-child(2) { animation-delay: .28s; } | |
| .loading-orb:nth-child(3) { animation-delay: .56s; } | |
| /* ─── Portrait NPC Card ─── */ | |
| .npc-portrait-card { display: flex; flex-direction: column; overflow: hidden; } | |
| /* Image frame — portrait crop */ | |
| .card-img-frame { | |
| position: relative; height: 280px; flex-shrink: 0; overflow: hidden; | |
| background: var(--bg-dark); | |
| } | |
| .card-img-frame img { | |
| width: 100%; height: 100%; | |
| object-fit: cover; object-position: top center; | |
| display: block; | |
| } | |
| /* Gradient overlay on image */ | |
| .card-img-gradient { | |
| position: absolute; inset: 0; | |
| background: linear-gradient( | |
| to bottom, | |
| rgba(13,13,26,0) 25%, | |
| rgba(13,13,26,.45) 60%, | |
| rgba(22,33,62,.98) 100% | |
| ); | |
| } | |
| /* Decorative corner rune lines */ | |
| .card-img-corner { | |
| position: absolute; top: 10px; left: 10px; right: 10px; | |
| display: flex; justify-content: space-between; | |
| pointer-events: none; | |
| } | |
| .corner-gem { | |
| width: 38px; height: 38px; | |
| border: 2px solid currentColor; | |
| display: flex; align-items: center; justify-content: center; | |
| background: rgba(13,13,26,.78); | |
| font-family: system-ui; font-size: .6rem; font-weight: 900; text-transform: uppercase; | |
| } | |
| /* Name overlay on bottom of image */ | |
| .card-img-name-overlay { | |
| position: absolute; bottom: 12px; left: 16px; right: 16px; | |
| } | |
| .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); } | |
| .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); } | |
| /* Card body */ | |
| .card-body { padding: 14px 16px 18px; } | |
| .badge-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } | |
| .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; } | |
| .rarity { border-color: currentColor; } | |
| .stats { display: grid; gap: 8px; margin-bottom: 12px; } | |
| .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; } | |
| .stat-track { height: 8px; overflow: hidden; background: rgba(13,13,26,.72); border: 1px solid var(--border-dim); } | |
| .stat-fill { height: 100%; width: 0; transition: width .7s ease, background .3s ease; } | |
| .ability-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; } | |
| .ability { padding: 10px; border: 1px solid var(--border-dim); background: rgba(13,13,26,.4); } | |
| .ability h3 { color: var(--gold); font-size: .8rem; } | |
| .ability p { margin: 5px 0 0; line-height: 1.35; font-size: .86rem; } | |
| .lore { margin-bottom: 10px; color: var(--text-gold); font-style: italic; line-height: 1.48; font-size: .93rem; } | |
| .world-line { display: flex; flex-wrap: wrap; gap: 8px; color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .76rem; } | |
| /* ─── Friendship ─── */ | |
| .friendship { padding: 16px; } | |
| .section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 10px; } | |
| .section-heading h2 { color: var(--gold); font-size: 1.05rem; } | |
| .friendship-pct { color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .86rem; font-weight: 700; } | |
| .friendship-track { height: 14px; overflow: hidden; border: 1px solid rgba(201,162,39,.34); background: rgba(13,13,26,.78); } | |
| .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; } | |
| .friendship-flavor { margin: 8px 0 0; color: var(--text-muted); font-style: italic; font-size: .92rem; } | |
| /* ─── Secrets ─── */ | |
| .secret-layer { position: fixed; top: 18px; right: 18px; z-index: 20; display: grid; width: min(380px, calc(100vw - 36px)); gap: 10px; } | |
| .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; } | |
| .secret-title { color: var(--gold); font-family: "Cinzel", serif; font-weight: 700; } | |
| .secret-body { margin-top: 6px; opacity: 0; animation: secretText .4s ease .5s both; } | |
| /* ─── Chat ─── */ | |
| .chat-panel { display: grid; grid-template-rows: minmax(360px, 50vh) auto; overflow: hidden; } | |
| .chat-log { overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; scroll-behavior: smooth; } | |
| .message { max-width: 78%; padding: 10px 12px; line-height: 1.5; font-size: 1rem; border: 1px solid var(--border-dim); } | |
| .message.user { align-self: flex-end; background: rgba(13,13,26,.82); color: #f2e6d0; } | |
| .message.npc { align-self: flex-start; background: rgba(42,42,74,.72); color: var(--text-gold); border-color: rgba(201,162,39,.28); } | |
| .sender { display: block; margin-bottom: 4px; color: var(--gold); font-family: system-ui, sans-serif; font-size: .76rem; font-weight: 800; } | |
| .typing { animation: goldPulse 1s ease-in-out infinite; } | |
| .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); } | |
| .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; } | |
| .chat-form input:focus { border-color: var(--gold); box-shadow: 0 0 0 3px rgba(201,162,39,.12); } | |
| /* ─── Quests ─── */ | |
| .quest-panel { padding: 16px; } | |
| .quest-list { display: grid; gap: 12px; margin-bottom: 12px; } | |
| .quest { | |
| padding: 12px 14px; | |
| border: 1px solid var(--border-dim); | |
| border-left: 4px solid #9d9d9d; | |
| background: rgba(13,13,26,.38); | |
| transition: background .35s ease, box-shadow .35s ease; | |
| } | |
| .quest.quest-active { background: rgba(201,162,39,.06); box-shadow: 0 0 18px rgba(201,162,39,.1); } | |
| .quest.quest-done { opacity: .65; } | |
| .quest h3 { color: var(--gold); font-size: .88rem; } | |
| .quest p { margin: 5px 0 6px; line-height: 1.38; font-size: .9rem; } | |
| .reward-line { display: block; margin-top: 4px; color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .76rem; font-weight: 700; } | |
| /* Quest progress bar */ | |
| .quest-progress { height: 4px; background: rgba(201,162,39,.12); margin: 8px 0 6px; overflow: hidden; border-radius: 2px; } | |
| .quest-progress-fill { height: 100%; background: linear-gradient(90deg, var(--gold-dark), var(--gold)); transition: width .5s ease; border-radius: 2px; } | |
| /* Quest action area */ | |
| .quest-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; } | |
| .quest-btn { | |
| padding: 6px 14px; border: 1px solid; cursor: pointer; | |
| font-family: system-ui, sans-serif; font-size: .78rem; font-weight: 700; | |
| transition: all .2s ease; | |
| } | |
| .begin-btn { color: var(--gold); border-color: rgba(201,162,39,.55); background: rgba(201,162,39,.08); } | |
| .begin-btn:hover:not(:disabled) { background: rgba(201,162,39,.2); } | |
| .complete-btn { color: #1eff00; border-color: rgba(30,255,0,.45); background: rgba(30,255,0,.07); } | |
| .complete-btn:hover:not(:disabled) { background: rgba(30,255,0,.18); } | |
| .complete-btn:disabled { color: rgba(30,255,0,.3); border-color: rgba(30,255,0,.18); background: transparent; cursor: not-allowed; opacity: 1; } | |
| .quest-hint { color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .72rem; font-style: italic; } | |
| .quest-done-badge { color: #1eff00; font-family: system-ui, sans-serif; font-size: .78rem; font-weight: 700; } | |
| .quest-summary-label { color: var(--text-muted); font-family: system-ui, sans-serif; font-size: .78rem; } | |
| /* ─── Reward Toast ─── */ | |
| .reward-toast { | |
| position: fixed; top: 22px; left: 50%; transform: translateX(-50%) translateY(-12px); | |
| z-index: 50; | |
| padding: 14px 28px; text-align: center; | |
| border: 2px solid #1eff00; | |
| background: rgba(10,22,10,.97); | |
| box-shadow: 0 0 40px rgba(30,255,0,.4); | |
| font-family: "Cinzel", serif; color: #1eff00; font-size: .92rem; | |
| animation: rewardIn .45s ease forwards; | |
| max-width: 90vw; | |
| } | |
| .reward-toast-sub { display: block; margin-top: 4px; font-size: .78rem; opacity: .8; font-family: system-ui; color: var(--text-gold); } | |
| /* ─── Share Card ─── */ | |
| .share-card { padding: 16px; } | |
| .share-image { display: none; width: 100%; margin-top: 12px; border: 1px solid rgba(201,162,39,.35); } | |
| .download-link { display: none; margin-top: 8px; color: var(--gold); font-family: system-ui, sans-serif; font-weight: 700; } | |
| /* ─── Error ─── */ | |
| .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; } | |
| .error.active { display: block; } | |
| .hidden-input { display: none; } | |
| /* ─── Animations ─── */ | |
| @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)} } | |
| @keyframes pulse { 0%,100%{filter:brightness(1)} 50%{filter:brightness(1.35)} } | |
| @keyframes secretReveal { from{opacity:0;transform:scale(.88) translateY(-12px)} to{opacity:1;transform:scale(1) translateY(0)} } | |
| @keyframes secretText { to{opacity:1} } | |
| @keyframes spinCW { to{ transform: rotate(360deg); } } | |
| @keyframes spinCCW { to{ transform: rotate(-360deg); } } | |
| @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} } | |
| @keyframes orbPulse { 0%,80%,100%{transform:scale(.7);background:var(--gold-dark)} 40%{transform:scale(1.3);background:var(--gold)} } | |
| @keyframes rewardIn { from{opacity:0;transform:translateX(-50%) translateY(-12px)} to{opacity:1;transform:translateX(-50%) translateY(0)} } | |
| /* ─── Responsive ─── */ | |
| @media (max-width: 899px) { | |
| .layout { grid-template-columns: 1fr; } | |
| .ability-grid { grid-template-columns: 1fr; } | |
| .chat-panel { grid-template-rows: 420px auto; } | |
| .message { max-width: 90%; } | |
| } | |
| @media (max-width: 520px) { | |
| .app-shell { padding: 14px; } | |
| .stat { grid-template-columns: 84px minmax(0,1fr) 30px; gap: 6px; font-size: .76rem; } | |
| .chat-form { grid-template-columns: 1fr; } | |
| .card-img-frame { height: 230px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ══════════════ CAMERA MODAL ══════════════ --> | |
| <div class="camera-modal" id="cameraModal" aria-modal="true" role="dialog" aria-label="Camera capture"> | |
| <div class="camera-box"> | |
| <h2>📷 Capture Your Portrait</h2> | |
| <video class="camera-video" id="cameraVideo" autoplay playsinline muted></video> | |
| <canvas class="camera-canvas" id="cameraCanvas"></canvas> | |
| <div class="camera-error" id="cameraError"></div> | |
| <div class="camera-controls"> | |
| <button class="camera-capture-btn" id="cameraCaptureBtn">⚡ Capture</button> | |
| <button class="camera-cancel-btn" id="cameraCloseBtn">✕ Cancel</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ══════════════ LOADING OVERLAY ══════════════ --> | |
| <div class="loading-overlay" id="loadingOverlay" aria-live="polite" aria-busy="true"> | |
| <div class="loading-rune-ring"> | |
| <div class="rl-outer"></div> | |
| <div class="rl-mid"></div> | |
| <div class="rl-inner"></div> | |
| <div class="rl-center">⚔</div> | |
| </div> | |
| <div class="loading-text-block"> | |
| <div class="loading-msg" id="loadingMsg">Summoning your character...</div> | |
| <div class="loading-submsg">ZeroGPU is forging your destiny — this may take a moment</div> | |
| </div> | |
| <div class="loading-orbs"> | |
| <div class="loading-orb"></div> | |
| <div class="loading-orb"></div> | |
| <div class="loading-orb"></div> | |
| </div> | |
| </div> | |
| <!-- ══════════════ SECRET LAYER ══════════════ --> | |
| <div class="secret-layer" id="secretLayer" aria-live="polite"></div> | |
| <!-- ══════════════ MAIN APP ══════════════ --> | |
| <main class="app-shell"> | |
| <header> | |
| <h1>⚔ NPCverse</h1> | |
| <p class="subtitle">Turn anyone into a living RPG character</p> | |
| </header> | |
| <div class="layout"> | |
| <!-- ── LEFT COLUMN ── --> | |
| <section class="stack"> | |
| <!-- Upload Zone --> | |
| <div class="upload-zone panel" id="uploadZone" tabindex="0" aria-label="Upload a character photo"> | |
| <input class="hidden-input" id="imageInput" type="file" accept="image/*"> | |
| <div class="upload-prompt" id="uploadPrompt"> | |
| <div class="upload-icon">🜂</div> | |
| <h2>Drop a portrait into the rift</h2> | |
| <div class="upload-copy">Drag an image, choose a file, or capture from camera</div> | |
| <div class="upload-action-row"> | |
| <button class="upload-action-btn" id="filePickBtn" type="button">🖼 Choose File</button> | |
| <button class="upload-action-btn camera-btn" id="cameraOpenBtn" type="button">📷 Camera</button> | |
| </div> | |
| </div> | |
| <img class="preview" id="imagePreview" alt="Uploaded portrait preview" hidden> | |
| </div> | |
| <button class="primary-btn" id="summonBtn" disabled>⚡ SUMMON NPC</button> | |
| <div class="error" id="errorBox">*The realm connection falters... try again, traveler.*</div> | |
| <!-- Portrait NPC Card --> | |
| <article class="npc-portrait-card panel" id="characterCard" style="display:none"></article> | |
| <!-- Share Card --> | |
| <section class="share-card panel"> | |
| <button class="secondary-btn" id="shareBtn" disabled style="width:100%">📤 Export Share Card</button> | |
| <img class="share-image" id="shareImage" alt="Generated NPC share card"> | |
| <a class="download-link" id="downloadLink" download="npcverse-share-card.png">⬇ Download share card</a> | |
| </section> | |
| </section> | |
| <!-- ── RIGHT COLUMN ── --> | |
| <section class="stack"> | |
| <!-- Friendship --> | |
| <section class="friendship panel"> | |
| <div class="section-heading"> | |
| <h2 id="friendshipLabel">Stranger</h2> | |
| <span class="friendship-pct" id="friendshipPct">0%</span> | |
| </div> | |
| <div class="friendship-track"> | |
| <div class="friendship-fill" id="friendshipFill"></div> | |
| </div> | |
| <p class="friendship-flavor" id="friendshipFlavor">A story waits at the edge of the campfire.</p> | |
| </section> | |
| <!-- Chat --> | |
| <section class="chat-panel panel"> | |
| <div class="chat-log" id="chatLog" aria-live="polite"></div> | |
| <form class="chat-form" id="chatForm"> | |
| <input id="chatInput" type="text" placeholder="Speak to your summoned NPC..." autocomplete="off" disabled> | |
| <button class="secondary-btn" id="sendBtn" type="submit" disabled>Send</button> | |
| </form> | |
| </section> | |
| <!-- Quests --> | |
| <section class="quest-panel panel"> | |
| <div class="section-heading"> | |
| <h2>Active Quests</h2> | |
| <span class="quest-summary-label" id="questSummary"></span> | |
| </div> | |
| <div class="quest-list" id="questList"></div> | |
| <button class="secondary-btn" id="newQuestBtn" disabled style="width:100%">⚔ Request New Quest</button> | |
| </section> | |
| </section> | |
| </div> | |
| </main> | |
| <script type="module"> | |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| /* ═══════════════════════════════════════════ | |
| STATE | |
| ═══════════════════════════════════════════ */ | |
| let currentNPC = null; | |
| let chatHistory = []; | |
| let displayHistory = []; | |
| let messageCount = 0; | |
| let unlockedSecrets = []; | |
| let clientPromise = null; | |
| let imageFile = null; | |
| let portraitUrl = null; // object URL for the uploaded image | |
| // Quest state per index | |
| let questStates = {}; // { [i]: 'available' | 'active' | 'completed' } | |
| let questMsgsSince = {}; // messages sent while quest was active | |
| // Camera | |
| let cameraStream = null; | |
| // Loading message cycling | |
| const LOADING_MSGS = [ | |
| "Summoning your character from the void...", | |
| "Reading the aura of your portrait...", | |
| "Consulting the ancient grimoire...", | |
| "Weaving lore from starlight...", | |
| "Forging stats from the cosmos...", | |
| "Inscribing your destiny in runes...", | |
| "Calculating rarity from the celestial alignments...", | |
| "Breathing a soul into your NPC...", | |
| "Transcribing powers from the arcane codex...", | |
| "Almost there — destiny takes time...", | |
| ]; | |
| let loadingMsgIdx = 0; | |
| let loadingMsgInterval = null; | |
| /* ═══════════════════════════════════════════ | |
| DOM REFS | |
| ═══════════════════════════════════════════ */ | |
| const $ = id => document.getElementById(id); | |
| const uploadZone = $("uploadZone"); | |
| const imageInput = $("imageInput"); | |
| const uploadPrompt = $("uploadPrompt"); | |
| const imagePreview = $("imagePreview"); | |
| const filePickBtn = $("filePickBtn"); | |
| const cameraOpenBtn = $("cameraOpenBtn"); | |
| const summonBtn = $("summonBtn"); | |
| const loadingOverlay = $("loadingOverlay"); | |
| const loadingMsg = $("loadingMsg"); | |
| const errorBox = $("errorBox"); | |
| const characterCard = $("characterCard"); | |
| const friendshipLabel = $("friendshipLabel"); | |
| const friendshipPct = $("friendshipPct"); | |
| const friendshipFill = $("friendshipFill"); | |
| const friendshipFlavor = $("friendshipFlavor"); | |
| const chatLog = $("chatLog"); | |
| const chatForm = $("chatForm"); | |
| const chatInput = $("chatInput"); | |
| const sendBtn = $("sendBtn"); | |
| const questList = $("questList"); | |
| const questSummary = $("questSummary"); | |
| const newQuestBtn = $("newQuestBtn"); | |
| const shareBtn = $("shareBtn"); | |
| const shareImage = $("shareImage"); | |
| const downloadLink = $("downloadLink"); | |
| const secretLayer = $("secretLayer"); | |
| const cameraModal = $("cameraModal"); | |
| const cameraVideo = $("cameraVideo"); | |
| const cameraCanvas = $("cameraCanvas"); | |
| const cameraCaptureBtn = $("cameraCaptureBtn"); | |
| const cameraCloseBtn = $("cameraCloseBtn"); | |
| const cameraError = $("cameraError"); | |
| /* ═══════════════════════════════════════════ | |
| LOOKUP TABLES | |
| ═══════════════════════════════════════════ */ | |
| const rarityMap = { | |
| Common: { color: "#9d9d9d", glow: "none", pulse: false }, | |
| Uncommon: { color: "#1eff00", glow: "0 0 20px rgba(30,255,0,.25)", pulse: false }, | |
| Rare: { color: "#0070dd", glow: "0 0 28px rgba(0,112,221,.35)", pulse: false }, | |
| Epic: { color: "#a335ee", glow: "0 0 38px rgba(163,53,238,.45)", pulse: false }, | |
| Legendary: { color: "#ff8000", glow: "0 0 50px rgba(255,128,0,.6)", pulse: true }, | |
| }; | |
| const friendshipCopy = { | |
| Stranger: "A story waits at the edge of the campfire.", | |
| Acquaintance: "Names have power, and yours is beginning to matter.", | |
| Friend: "Trust gathers like warm light around the party.", | |
| "Trusted Ally": "Oaths are forming beneath every shared word.", | |
| "Legendary Bond": "Your legend and theirs now share the same ink.", | |
| }; | |
| /* ═══════════════════════════════════════════ | |
| UPLOAD — file / drag-drop | |
| ═══════════════════════════════════════════ */ | |
| uploadZone.addEventListener("click", e => { | |
| if (e.target === filePickBtn || e.target === cameraOpenBtn) return; | |
| if (!imageFile) imageInput.click(); | |
| }); | |
| uploadZone.addEventListener("keydown", e => { | |
| if (e.key === "Enter" || e.key === " ") { e.preventDefault(); imageInput.click(); } | |
| }); | |
| filePickBtn.addEventListener("click", e => { e.stopPropagation(); imageInput.click(); }); | |
| cameraOpenBtn.addEventListener("click", e => { e.stopPropagation(); openCamera(); }); | |
| uploadZone.addEventListener("dragover", e => { e.preventDefault(); uploadZone.classList.add("dragover"); }); | |
| uploadZone.addEventListener("dragleave", () => uploadZone.classList.remove("dragover")); | |
| uploadZone.addEventListener("drop", e => { | |
| e.preventDefault(); uploadZone.classList.remove("dragover"); | |
| const f = e.dataTransfer.files[0]; | |
| if (f) setImageFile(f); | |
| }); | |
| imageInput.addEventListener("change", () => { if (imageInput.files[0]) setImageFile(imageInput.files[0]); }); | |
| function setImageFile(file) { | |
| imageFile = file; | |
| if (portraitUrl) URL.revokeObjectURL(portraitUrl); | |
| portraitUrl = URL.createObjectURL(file); | |
| imagePreview.src = portraitUrl; | |
| imagePreview.hidden = false; | |
| uploadPrompt.style.display = "none"; | |
| uploadZone.classList.add("has-image"); | |
| summonBtn.disabled = false; | |
| clearError(); | |
| } | |
| /* ═══════════════════════════════════════════ | |
| CAMERA | |
| ═══════════════════════════════════════════ */ | |
| async function openCamera() { | |
| cameraError.style.display = "none"; | |
| cameraError.textContent = ""; | |
| cameraCaptureBtn.disabled = false; | |
| cameraModal.classList.add("active"); | |
| try { | |
| cameraStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } } | |
| }); | |
| cameraVideo.srcObject = cameraStream; | |
| } catch (err) { | |
| cameraError.textContent = `Camera unavailable: ${err.message}. Please use file upload instead.`; | |
| cameraError.style.display = "block"; | |
| cameraCaptureBtn.disabled = true; | |
| } | |
| } | |
| function closeCamera() { | |
| if (cameraStream) { cameraStream.getTracks().forEach(t => t.stop()); cameraStream = null; } | |
| cameraVideo.srcObject = null; | |
| cameraModal.classList.remove("active"); | |
| } | |
| cameraCaptureBtn.addEventListener("click", () => { | |
| if (!cameraStream) return; | |
| const w = cameraVideo.videoWidth || 640; | |
| const h = cameraVideo.videoHeight || 480; | |
| cameraCanvas.width = w; | |
| cameraCanvas.height = h; | |
| cameraCanvas.getContext("2d").drawImage(cameraVideo, 0, 0, w, h); | |
| cameraCanvas.toBlob(blob => { | |
| const file = new File([blob], "camera-capture.jpg", { type: "image/jpeg" }); | |
| setImageFile(file); | |
| closeCamera(); | |
| }, "image/jpeg", 0.92); | |
| }); | |
| cameraCloseBtn.addEventListener("click", closeCamera); | |
| cameraModal.addEventListener("click", e => { if (e.target === cameraModal) closeCamera(); }); | |
| document.addEventListener("keydown", e => { if (e.key === "Escape" && cameraModal.classList.contains("active")) closeCamera(); }); | |
| /* ═══════════════════════════════════════════ | |
| LOADING OVERLAY | |
| ═══════════════════════════════════════════ */ | |
| function showLoading(on) { | |
| if (on) { | |
| loadingMsgIdx = 0; | |
| loadingMsg.textContent = LOADING_MSGS[0]; | |
| loadingOverlay.classList.add("active"); | |
| summonBtn.disabled = true; | |
| loadingMsgInterval = setInterval(() => { | |
| loadingMsgIdx = (loadingMsgIdx + 1) % LOADING_MSGS.length; | |
| loadingMsg.style.opacity = "0"; | |
| setTimeout(() => { | |
| loadingMsg.textContent = LOADING_MSGS[loadingMsgIdx]; | |
| loadingMsg.style.opacity = "1"; | |
| }, 200); | |
| }, 2800); | |
| } else { | |
| loadingOverlay.classList.remove("active"); | |
| clearInterval(loadingMsgInterval); | |
| loadingMsgInterval = null; | |
| summonBtn.disabled = !imageFile; | |
| } | |
| } | |
| /* ═══════════════════════════════════════════ | |
| SUMMON NPC | |
| ═══════════════════════════════════════════ */ | |
| summonBtn.addEventListener("click", summonNPC); | |
| async function summonNPC() { | |
| if (!imageFile) return; | |
| showLoading(true); | |
| clearError(); | |
| try { | |
| const client = await getClient(); | |
| const result = await client.predict("/summon_npc", { image_path: handle_file(imageFile) }); | |
| const npc = result.data[0]; | |
| currentNPC = npc; | |
| chatHistory = []; | |
| displayHistory = []; | |
| messageCount = 0; | |
| unlockedSecrets = []; | |
| questStates = {}; | |
| questMsgsSince = {}; | |
| renderNPC(npc); | |
| updateFriendship("Stranger", 0); | |
| renderChat(); | |
| renderQuests(npc.quests || []); | |
| enablePlay(true); | |
| addNPCMessage(npc.opening_line || "The veil parts. I have arrived."); | |
| } catch (err) { | |
| showError(); | |
| } finally { | |
| showLoading(false); | |
| } | |
| } | |
| /* ═══════════════════════════════════════════ | |
| CHAT | |
| ═══════════════════════════════════════════ */ | |
| chatForm.addEventListener("submit", e => { | |
| e.preventDefault(); | |
| const txt = chatInput.value.trim(); | |
| if (txt) sendChatMessage(txt); | |
| }); | |
| newQuestBtn.addEventListener("click", () => | |
| sendChatMessage("Give me a new quest — something I haven't attempted yet.") | |
| ); | |
| async function sendChatMessage(userText) { | |
| if (!currentNPC || !userText) return; | |
| chatInput.value = ""; | |
| appendDisplay("user", userText); | |
| const typingId = appendTyping(); | |
| toggleChat(false); | |
| // Increment message counter for each active quest | |
| Object.keys(questStates).forEach(idx => { | |
| if (questStates[idx] === "active") { | |
| questMsgsSince[idx] = (questMsgsSince[idx] || 0) + 1; | |
| } | |
| }); | |
| try { | |
| const client = await getClient(); | |
| const res = await client.predict("/chat_with_npc", { | |
| npc_json: JSON.stringify(currentNPC), | |
| history_json: JSON.stringify(chatHistory), | |
| user_message: userText, | |
| msg_count: messageCount, | |
| unlocked_secrets_json: JSON.stringify(unlockedSecrets), | |
| }); | |
| const { response, new_msg_count, friendship_label, friendship_pct, newly_unlocked, secret_texts } = res.data[0]; | |
| removeMessage(typingId); | |
| appendDisplay("npc", response); | |
| chatHistory.push({ role: "user", content: userText }, { role: "assistant", content: response }); | |
| chatHistory = chatHistory.slice(-40); // keep 40 messages for richer NPC memory | |
| messageCount = new_msg_count; | |
| unlockedSecrets = Array.from(new Set([...unlockedSecrets, ...newly_unlocked])); | |
| updateFriendship(friendship_label, friendship_pct); | |
| showSecrets(secret_texts); | |
| if (currentNPC?.quests) renderQuests(currentNPC.quests); | |
| } catch (err) { | |
| removeMessage(typingId); | |
| appendDisplay("npc", "*The realm connection falters... try again, traveler.*"); | |
| } finally { | |
| toggleChat(true); | |
| } | |
| } | |
| /* ═══════════════════════════════════════════ | |
| QUEST SYSTEM | |
| ═══════════════════════════════════════════ */ | |
| const QUEST_MSGS_NEEDED = 3; // exchanges before "Complete" unlocks | |
| function renderQuests(quests) { | |
| const total = Math.min(quests.length, 3); | |
| const completed = Object.values(questStates).filter(s => s === "completed").length; | |
| questSummary.textContent = completed > 0 ? `${completed} / ${total} completed` : ""; | |
| questList.innerHTML = quests.slice(0, 3).map((quest, i) => { | |
| const state = questStates[i] || "available"; | |
| const rarity = quest.rarity || "Common"; | |
| const color = (rarityMap[rarity] || rarityMap.Common).color; | |
| const msgs = questMsgsSince[i] || 0; | |
| const pct = Math.min(100, (msgs / QUEST_MSGS_NEEDED) * 100); | |
| const ready = msgs >= QUEST_MSGS_NEEDED; | |
| let progressHtml = ""; | |
| let actionsHtml = ""; | |
| if (state === "available") { | |
| actionsHtml = `<button class="quest-btn begin-btn" data-qi="${i}">⚔ Begin Quest</button>`; | |
| } else if (state === "active") { | |
| progressHtml = ` | |
| <div class="quest-progress"> | |
| <div class="quest-progress-fill" style="width:${pct}%"></div> | |
| </div>`; | |
| actionsHtml = ` | |
| <button class="quest-btn complete-btn" ${ready ? "" : "disabled"} data-qi="${i}"> | |
| ${ready ? "✓ Claim Reward" : `In Progress — ${msgs}/${QUEST_MSGS_NEEDED} exchanges`} | |
| </button> | |
| ${!ready ? `<span class="quest-hint">Keep talking to advance</span>` : ""}`; | |
| } else { | |
| actionsHtml = `<span class="quest-done-badge">✦ Completed</span>`; | |
| } | |
| return ` | |
| <article class="quest ${state === "active" ? "quest-active" : state === "completed" ? "quest-done" : ""}" | |
| style="border-left-color:${color}"> | |
| <h3>${escapeHtml(quest.title)}${state === "active" ? " ⚔" : state === "completed" ? " ✓" : ""}</h3> | |
| <p>${escapeHtml(quest.description)}</p> | |
| <span class="reward-line">Reward: ${escapeHtml(quest.reward)} · ${escapeHtml(rarity)}</span> | |
| ${progressHtml} | |
| <div class="quest-actions">${actionsHtml}</div> | |
| </article>`; | |
| }).join(""); | |
| // Attach button handlers | |
| questList.querySelectorAll(".begin-btn").forEach(btn => | |
| btn.addEventListener("click", () => beginQuest(Number(btn.dataset.qi))) | |
| ); | |
| questList.querySelectorAll(".complete-btn:not(:disabled)").forEach(btn => | |
| btn.addEventListener("click", () => completeQuest(Number(btn.dataset.qi))) | |
| ); | |
| } | |
| async function beginQuest(idx) { | |
| if (!currentNPC?.quests?.[idx]) return; | |
| const quest = currentNPC.quests[idx]; | |
| questStates[idx] = "active"; | |
| questMsgsSince[idx] = 0; | |
| renderQuests(currentNPC.quests); | |
| await sendChatMessage(`I accept your quest: "${quest.title}". Tell me where to begin.`); | |
| } | |
| async function completeQuest(idx) { | |
| if (!currentNPC?.quests?.[idx]) return; | |
| const quest = currentNPC.quests[idx]; | |
| questStates[idx] = "completed"; | |
| renderQuests(currentNPC.quests); | |
| await sendChatMessage(`I've completed "${quest.title}"! I'm here to claim my reward: ${quest.reward}.`); | |
| showRewardToast(quest.reward, quest.rarity); | |
| } | |
| function showRewardToast(reward, rarity) { | |
| const rs = rarityMap[rarity] || rarityMap.Common; | |
| const toast = document.createElement("div"); | |
| toast.className = "reward-toast"; | |
| toast.style.borderColor = rs.color; | |
| toast.style.color = rs.color; | |
| toast.style.boxShadow = `0 0 40px ${rs.color}55`; | |
| toast.innerHTML = `✦ Quest Complete ✦<span class="reward-toast-sub">Reward obtained: ${escapeHtml(reward)}</span>`; | |
| document.body.appendChild(toast); | |
| setTimeout(() => toast.remove(), 5500); | |
| } | |
| /* ═══════════════════════════════════════════ | |
| SHARE CARD | |
| ═══════════════════════════════════════════ */ | |
| shareBtn.addEventListener("click", exportShareCard); | |
| async function exportShareCard() { | |
| if (!currentNPC) return; | |
| shareBtn.disabled = true; | |
| shareBtn.textContent = "📤 Exporting…"; | |
| try { | |
| // Dynamically load html2canvas if not already loaded | |
| if (typeof html2canvas === "undefined") { | |
| await new Promise((resolve, reject) => { | |
| const s = document.createElement("script"); | |
| s.src = "https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"; | |
| s.onload = resolve; | |
| s.onerror = () => reject(new Error("Failed to load html2canvas")); | |
| document.head.appendChild(s); | |
| }); | |
| } | |
| // Capture the actual rendered card element | |
| const canvas = await html2canvas(characterCard, { | |
| backgroundColor: "#0d0d1a", | |
| scale: 2, // high-res export | |
| useCORS: true, | |
| allowTaint: true, | |
| logging: false, | |
| }); | |
| const dataUrl = canvas.toDataURL("image/png"); | |
| shareImage.src = dataUrl; | |
| shareImage.style.display = "block"; | |
| downloadLink.href = dataUrl; | |
| downloadLink.style.display = "inline-block"; | |
| } catch (err) { | |
| console.error("Export failed:", err); | |
| showError(); | |
| } finally { | |
| shareBtn.disabled = false; | |
| shareBtn.textContent = "📤 Export Share Card"; | |
| } | |
| } | |
| /* ═══════════════════════════════════════════ | |
| PORTRAIT NPC CARD RENDER | |
| ═══════════════════════════════════════════ */ | |
| function renderNPC(npc) { | |
| const rarity = npc.rarity || "Common"; | |
| const rarityStyle = rarityMap[rarity] || rarityMap.Common; | |
| const stats = npc.stats || {}; | |
| const statNames = ["strength","intelligence","charisma","luck","stealth","chaos"]; | |
| // Portrait image — use the uploaded photo | |
| const imgHtml = portraitUrl | |
| ? `<img src="${escapeHtml(portraitUrl)}" alt="Portrait of ${escapeHtml(npc.name || 'NPC')}">` | |
| : `<div style="height:100%;display:flex;align-items:center;justify-content:center;font-size:4rem;background:var(--bg-dark)">${escapeHtml(npc.emoji||"⚔")}</div>`; | |
| characterCard.innerHTML = ` | |
| <!-- ── Image Frame ── --> | |
| <div class="card-img-frame"> | |
| ${imgHtml} | |
| <div class="card-img-gradient"></div> | |
| <!-- Corner gems --> | |
| <div class="card-img-corner"> | |
| <div class="corner-gem" style="color:${rarityStyle.color};border-color:${rarityStyle.color};box-shadow:0 0 14px ${rarityStyle.color}55"> | |
| Lv.${escapeHtml(String(npc.level||1))} | |
| </div> | |
| <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"}"> | |
| ${escapeHtml(rarity.slice(0,3).toUpperCase())} | |
| </div> | |
| </div> | |
| <!-- Name overlaid on image gradient --> | |
| <div class="card-img-name-overlay"> | |
| <div class="npc-name">${escapeHtml(npc.name||"Unknown Wanderer")}</div> | |
| <div class="npc-class">${escapeHtml(npc.class||"Adventurer")} — Lv.${escapeHtml(String(npc.level||1))}</div> | |
| </div> | |
| </div> | |
| <!-- ── Card Body ── --> | |
| <div class="card-body"> | |
| <div class="badge-row"> | |
| <span class="badge rarity" style="color:${rarityStyle.color};border-color:${rarityStyle.color};box-shadow:${rarityStyle.glow}">${escapeHtml(rarity)}</span> | |
| <span class="badge">${escapeHtml(npc.alignment||"Unaligned")}</span> | |
| <span class="badge">${escapeHtml(npc.title||"Untitled")}</span> | |
| </div> | |
| <div class="stats"> | |
| ${statNames.map(n => statRow(n, Number(stats[n]||0))).join("")} | |
| </div> | |
| <div class="ability-grid"> | |
| <div class="ability"> | |
| <h3>Passive: ${escapeHtml(npc.passive_ability?.name||"Unknown")}</h3> | |
| <p>${escapeHtml(npc.passive_ability?.description||"")}</p> | |
| </div> | |
| <div class="ability"> | |
| <h3>Ultimate: ${escapeHtml(npc.ultimate?.name||"Unknown")}</h3> | |
| <p>${escapeHtml(npc.ultimate?.description||"")}</p> | |
| </div> | |
| </div> | |
| <p class="lore">"${escapeHtml(npc.lore||"")}"</p> | |
| <div class="world-line"> | |
| <span>🌍 ${escapeHtml(npc.world||"Unknown")}</span> | |
| <span>⚜ ${escapeHtml(npc.faction||"Unaffiliated")}</span> | |
| <span>⚠ ${escapeHtml(npc.weakness||"Unknown")}</span> | |
| </div> | |
| </div> | |
| `; | |
| characterCard.style.display = "flex"; | |
| characterCard.style.boxShadow = rarityStyle.glow === "none" ? "" : rarityStyle.glow; | |
| } | |
| function statRow(name, value) { | |
| const disp = name.charAt(0).toUpperCase() + name.slice(1); | |
| return ` | |
| <div class="stat"> | |
| <span>${disp}</span> | |
| <div class="stat-track"> | |
| <div class="stat-fill" style="width:${clamp(value,0,100)}%;background:${statColor(value)}"></div> | |
| </div> | |
| <strong>${clamp(value,0,100)}</strong> | |
| </div>`; | |
| } | |
| /* ═══════════════════════════════════════════ | |
| FRIENDSHIP | |
| ═══════════════════════════════════════════ */ | |
| function updateFriendship(label, pct) { | |
| const safe = clamp(Number(pct||0), 0, 100); | |
| friendshipLabel.textContent = label; | |
| friendshipPct.textContent = `${Math.round(safe)}%`; | |
| friendshipFill.style.width = `${safe}%`; | |
| friendshipFlavor.textContent = friendshipCopy[label] || friendshipCopy.Stranger; | |
| } | |
| /* ═══════════════════════════════════════════ | |
| CHAT DISPLAY | |
| ═══════════════════════════════════════════ */ | |
| function addNPCMessage(text) { | |
| appendDisplay("npc", text); | |
| chatHistory.push({ role: "assistant", content: text }); | |
| } | |
| function appendDisplay(sender, text) { | |
| const entry = { id: crypto.randomUUID(), sender, text }; | |
| displayHistory.push(entry); | |
| renderMessage(entry); | |
| scrollChat(); | |
| return entry.id; | |
| } | |
| function appendTyping() { | |
| const entry = { id: crypto.randomUUID(), sender: "npc", text: "...", typing: true }; | |
| displayHistory.push(entry); | |
| renderMessage(entry); | |
| scrollChat(); | |
| return entry.id; | |
| } | |
| function removeMessage(id) { | |
| displayHistory = displayHistory.filter(m => m.id !== id); | |
| renderChat(); | |
| } | |
| function renderChat() { | |
| chatLog.innerHTML = ""; | |
| displayHistory.forEach(renderMessage); | |
| scrollChat(); | |
| } | |
| function renderMessage(entry) { | |
| const bubble = document.createElement("div"); | |
| bubble.className = `message ${entry.sender}${entry.typing ? " typing" : ""}`; | |
| bubble.dataset.id = entry.id; | |
| if (entry.sender === "npc") { | |
| const lbl = document.createElement("span"); | |
| lbl.className = "sender"; | |
| lbl.textContent = `${currentNPC?.emoji || "✦"} ${currentNPC?.name || "NPC"}`; | |
| bubble.appendChild(lbl); | |
| } | |
| bubble.append(document.createTextNode(entry.text)); | |
| chatLog.appendChild(bubble); | |
| } | |
| function showSecrets(texts) { | |
| texts.forEach(text => { | |
| const toast = document.createElement("div"); | |
| toast.className = "secret-toast"; | |
| toast.innerHTML = `<div class="secret-title">🔮 Secret Revealed</div><div class="secret-body">${escapeHtml(text)}</div>`; | |
| secretLayer.appendChild(toast); | |
| setTimeout(() => toast.remove(), 6000); | |
| }); | |
| } | |
| /* ═══════════════════════════════════════════ | |
| PLAY STATE HELPERS | |
| ═══════════════════════════════════════════ */ | |
| function enablePlay(on) { | |
| chatInput.disabled = !on; | |
| sendBtn.disabled = !on; | |
| newQuestBtn.disabled = !on; | |
| shareBtn.disabled = !on; | |
| } | |
| function toggleChat(on) { | |
| chatInput.disabled = !on; | |
| sendBtn.disabled = !on; | |
| newQuestBtn.disabled = !on; | |
| } | |
| function showError() { errorBox.classList.add("active"); } | |
| function clearError() { errorBox.classList.remove("active"); } | |
| function scrollChat() { requestAnimationFrame(() => { chatLog.scrollTop = chatLog.scrollHeight; }); } | |
| function getClient() { | |
| if (!clientPromise) clientPromise = Client.connect(window.location.origin); | |
| return clientPromise; | |
| } | |
| /* ═══════════════════════════════════════════ | |
| UTILITIES | |
| ═══════════════════════════════════════════ */ | |
| function statColor(v) { | |
| if (v >= 80) return "#ff8000"; | |
| if (v >= 65) return "#c9a227"; | |
| if (v >= 45) return "#4488cc"; | |
| return "#777"; | |
| } | |
| function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } | |
| function escapeHtml(value) { | |
| return String(value) | |
| .replaceAll("&","&") | |
| .replaceAll("<","<") | |
| .replaceAll(">",">") | |
| .replaceAll('"',""") | |
| .replaceAll("'","'"); | |
| } | |
| </script> | |
| </body> | |
| </html> | |