| <!doctype html> |
| <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 { |
| 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-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 { |
| 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 { |
| 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; } |
| |
| |
| .npc-portrait-card { display: flex; flex-direction: column; overflow: hidden; } |
| |
| |
| .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; |
| } |
| |
| .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% |
| ); |
| } |
| |
| .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; |
| } |
| |
| .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 { 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 { 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; } |
| |
| |
| .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-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); } |
| |
| |
| .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 { 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-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 { |
| 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 { 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 { 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; } |
| |
| |
| @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)} } |
| |
| |
| @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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div class="secret-layer" id="secretLayer" aria-live="polite"></div> |
|
|
| |
| <main class="app-shell"> |
| <header> |
| <h1>⚔ NPCverse</h1> |
| <p class="subtitle">Turn anyone into a living RPG character</p> |
| </header> |
|
|
| <div class="layout"> |
|
|
| |
| <section class="stack"> |
|
|
| |
| <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> |
|
|
| |
| <article class="npc-portrait-card panel" id="characterCard" style="display:none"></article> |
|
|
| |
| <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> |
|
|
| |
| <section class="stack"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"; |
| |
| |
| |
| |
| let currentNPC = null; |
| let chatHistory = []; |
| let displayHistory = []; |
| let messageCount = 0; |
| let unlockedSecrets = []; |
| let clientPromise = null; |
| let imageFile = null; |
| let portraitUrl = null; |
| |
| |
| let questStates = {}; |
| let questMsgsSince = {}; |
| |
| |
| let cameraStream = null; |
| |
| |
| 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; |
| |
| |
| |
| |
| 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"); |
| |
| |
| |
| |
| 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.", |
| }; |
| |
| |
| |
| |
| 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(); |
| } |
| |
| |
| |
| |
| 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(); }); |
| |
| |
| |
| |
| 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; |
| } |
| } |
| |
| |
| |
| |
| 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); |
| } |
| } |
| |
| |
| |
| |
| 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); |
| |
| |
| 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); |
| 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); |
| } |
| } |
| |
| |
| |
| |
| const QUEST_MSGS_NEEDED = 3; |
| |
| 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(""); |
| |
| |
| 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); |
| } |
| |
| |
| |
| |
| shareBtn.addEventListener("click", exportShareCard); |
| |
| async function exportShareCard() { |
| if (!currentNPC) return; |
| shareBtn.disabled = true; |
| shareBtn.textContent = "📤 Exporting…"; |
| try { |
| |
| 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); |
| }); |
| } |
| |
| |
| const canvas = await html2canvas(characterCard, { |
| backgroundColor: "#0d0d1a", |
| scale: 2, |
| 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"; |
| } |
| } |
| |
| |
| |
| |
| 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"]; |
| |
| |
| 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>`; |
| } |
| |
| |
| |
| |
| 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; |
| } |
| |
| |
| |
| |
| 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); |
| }); |
| } |
| |
| |
| |
| |
| 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; |
| } |
| |
| |
| |
| |
| 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> |
|
|