NPCverse / index.html
LazyHuman10
fix: explicit JSON template for NPC gen + improved chat memory (20 turns)
1e98e8c
Raw
History Blame Contribute Delete
50.3 kB
<!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 ─── */
.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("&","&amp;")
.replaceAll("<","&lt;")
.replaceAll(">","&gt;")
.replaceAll('"',"&quot;")
.replaceAll("'","&#039;");
}
</script>
</body>
</html>