| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> |
| <title>Ephemeral Hearts</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=Cormorant+Garamond:ital,wght@0,500;0,600;1,500&family=Spectral:wght@300;400;500&display=swap" rel="stylesheet" /> |
| <style> |
| :root{ |
| --ink:#f3ece0; --ink-dim:#c9bfae; |
| --dusk-0:#0b0e14; --dusk-1:#151a26; --dusk-2:#1d2740; |
| --gold:#e8c172; --moss:#9fd29a; --rose:#e9a6a6; |
| --box: rgba(10,12,18,.78); |
| --display:"Cormorant Garamond", serif; |
| --body:"Spectral", Georgia, serif; |
| } |
| *{box-sizing:border-box} html,body{height:100%;margin:0} |
| body{ |
| font-family:var(--body); color:var(--ink); background:var(--dusk-0); |
| overflow:hidden; -webkit-font-smoothing:antialiased; |
| } |
| |
| #stage{position:fixed; inset:0; display:grid; place-items:stretch} |
| #backdrop{ |
| position:absolute; inset:0; z-index:0; background-size:cover; background-position:center; |
| transition:opacity 1.1s ease, transform 8s ease-out; opacity:0; transform:scale(1.06); |
| filter:saturate(1.05); |
| } |
| #backdrop.on{opacity:1; transform:scale(1)} |
| |
| #stage::after{ |
| content:""; position:absolute; inset:0; z-index:3; pointer-events:none; |
| background:radial-gradient(120% 90% at 50% 40%, transparent 55%, rgba(0,0,0,.55) 100%); |
| mix-blend-mode:multiply; |
| } |
| #sprites{position:absolute; inset:0; z-index:2; display:flex; align-items:flex-end; |
| justify-content:center; gap:4vw; padding:0 6vw 22vh} |
| .sprite-wrap{position:relative; display:inline-flex; align-items:flex-end} |
| .sprite{height:64vh; max-height:680px; filter:drop-shadow(0 18px 30px rgba(0,0,0,.5)); |
| opacity:0; transform:translateY(24px); transition:opacity .7s ease, transform .7s ease, |
| filter .25s ease} |
| .sprite.on{opacity:1; transform:translateY(0)} |
| .sprite-wrap:hover .sprite.on{filter:drop-shadow(0 0 28px rgba(232,193,114,.7)) brightness(1.08)} |
| |
| .char-card{ |
| position:absolute; top:50%; left:50%; |
| width:220px; max-width:92%; |
| background:rgba(10,13,24,.93); border:1px solid rgba(232,193,114,.35); |
| border-radius:12px; padding:14px 16px; backdrop-filter:blur(10px); |
| box-shadow:0 8px 32px rgba(0,0,0,.7); pointer-events:none; |
| opacity:0; transform:translate(-50%,-50%) scale(.96); |
| transition:opacity .2s ease, transform .2s ease; z-index:10; |
| } |
| .sprite-wrap:hover .char-card{opacity:1; transform:translate(-50%,-50%) scale(1)} |
| .char-card-name{font-family:var(--display); font-size:1.15rem; color:var(--gold); margin-bottom:4px} |
| .char-card-bio{font-size:.82rem; color:var(--ink-dim); margin-bottom:10px; line-height:1.4} |
| .rel-row{display:flex; align-items:center; gap:8px; margin-bottom:10px} |
| .rel-bar{flex:1; height:5px; background:rgba(255,255,255,.10); border-radius:3px; overflow:hidden; position:relative} |
| .rel-fill-pos{position:absolute; left:0; top:0; bottom:0; width:0; |
| background:var(--moss); border-radius:3px; transition:width .5s ease} |
| .rel-fill-neg{position:absolute; right:0; top:0; bottom:0; width:0; |
| background:var(--rose); border-radius:3px; transition:width .5s ease} |
| .rel-val{font-size:.78rem; color:var(--ink-dim); min-width:28px; text-align:right} |
| .traits-section{margin-bottom:8px} |
| .traits-label{font-size:.72rem; text-transform:uppercase; letter-spacing:.8px; |
| color:var(--ink-dim); margin-bottom:5px} |
| .trait-item{font-size:.83rem; padding:2px 0; display:flex; align-items:center; gap:5px} |
| .trait-item.unlocked{color:var(--moss)} |
| .trait-item.locked{color:rgba(255,255,255,.3)} |
| .secret-goal{font-size:.83rem; padding:6px 8px; border-radius:6px; margin-top:6px} |
| .secret-goal.unlocked{background:rgba(232,193,114,.12); color:var(--gold)} |
| .secret-goal.locked{color:rgba(255,255,255,.25); font-style:italic} |
| |
| #notif-stack{ |
| position:fixed; bottom:calc(3.2vh + 130px); left:50%; transform:translateX(-50%); |
| z-index:20; display:flex; flex-direction:column; align-items:center; gap:8px; |
| pointer-events:none; |
| } |
| .notif{ |
| background:rgba(13,16,28,.95); border:1px solid rgba(232,193,114,.6); |
| border-radius:10px; padding:12px 24px; font-size:1rem; color:var(--ink); |
| box-shadow:0 0 18px rgba(232,193,114,.25), 0 4px 20px rgba(0,0,0,.6); |
| white-space:nowrap; animation:notif-in .3s ease forwards; |
| } |
| .notif.out{animation:notif-out .4s ease forwards} |
| @keyframes notif-in{from{opacity:0;transform:translateY(10px) scale(.95)} to{opacity:1;transform:translateY(0) scale(1)}} |
| @keyframes notif-out{from{opacity:1;transform:translateY(0)} to{opacity:0;transform:translateY(-8px)}} |
| |
| #dialogue{ |
| position:absolute; left:50%; bottom:3.2vh; z-index:4; transform:translateX(-50%); |
| width:min(900px,92vw); background:var(--box); border:1px solid rgba(232,193,114,.25); |
| border-radius:14px; padding:18px 22px 16px; backdrop-filter:blur(6px); |
| box-shadow:0 10px 40px rgba(0,0,0,.45); |
| } |
| #speaker{font-family:var(--display); font-size:1.5rem; letter-spacing:.3px; color:var(--gold); |
| margin:0 0 4px} |
| #line{font-size:1.12rem; line-height:1.55; min-height:3.2em; color:var(--ink)} |
| #place{position:absolute; top:18px; left:22px; z-index:4; font-family:var(--display); |
| font-size:1.05rem; color:var(--ink-dim); letter-spacing:1px; text-transform:uppercase; |
| text-shadow:0 1px 8px #000} |
| |
| #target-row{ |
| display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap; |
| } |
| .target-label{font-size:.75rem; color:var(--ink-dim); letter-spacing:.4px; flex-shrink:0} |
| .target-chip{ |
| font-family:var(--body); cursor:pointer; |
| border:1px solid rgba(255,255,255,.15); border-radius:20px; |
| padding:4px 12px; font-size:.83rem; |
| background:rgba(255,255,255,.04); color:var(--ink-dim); |
| transition:background .15s, border-color .15s, color .15s; |
| } |
| .target-chip:hover{background:rgba(233,166,166,.1); border-color:rgba(233,166,166,.4); color:var(--ink)} |
| .target-chip.active{background:rgba(233,166,166,.18); border-color:var(--rose); color:var(--rose); font-weight:600} |
| .target-chip:disabled{opacity:.4; cursor:wait} |
| |
| #actions{display:flex; gap:7px; margin-bottom:10px; flex-wrap:wrap} |
| .action-chip{ |
| font-family:var(--body); cursor:pointer; border:1px solid rgba(255,255,255,.18); |
| border-radius:20px; padding:6px 13px; font-size:.88rem; font-weight:500; |
| background:rgba(255,255,255,.05); color:var(--ink-dim); |
| transition:background .15s, border-color .15s, color .15s; white-space:nowrap; |
| } |
| .action-chip:hover{background:rgba(232,193,114,.1); border-color:rgba(232,193,114,.4); color:var(--ink)} |
| .action-chip.active{ |
| background:rgba(232,193,114,.18); border-color:var(--gold); |
| color:var(--gold); font-weight:600; |
| } |
| .action-chip:disabled{opacity:.4; cursor:wait} |
| |
| #inputrow{display:flex; gap:10px; margin-top:4px} |
| #say{flex:1; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.15); |
| color:var(--ink); border-radius:10px; padding:11px 14px; font-family:var(--body); font-size:1rem} |
| #say:focus{outline:none; border-color:var(--gold)} |
| button{font-family:var(--body); cursor:pointer; border:none; border-radius:10px; |
| padding:11px 16px; color:#10131a; background:var(--gold); font-weight:600; font-size:.98rem} |
| #send{background:var(--moss); color:#0d1a0e; font-size:1.2rem; padding:11px 18px; min-width:46px} |
| button.ghost{background:transparent; color:var(--ink); border:1px solid rgba(255,255,255,.22)} |
| button:disabled{opacity:.45; cursor:wait} |
| @keyframes pulse-rec{0%,100%{opacity:1}50%{opacity:.6}} |
| #mic.live{background:var(--rose); animation:pulse-rec 1.2s ease-in-out infinite} |
| #mic.waiting{background:rgba(255,255,255,.12); color:var(--ink-dim); cursor:wait} |
| |
| #settings-btn{ |
| position:fixed; top:56px; left:16px; z-index:8; |
| display:flex; align-items:center; gap:7px; |
| background:rgba(10,13,24,.82); border:1px solid rgba(232,193,114,.30); |
| border-radius:10px; padding:9px 14px; |
| color:var(--ink-dim); font-family:var(--display); font-size:1rem; |
| backdrop-filter:blur(6px); cursor:pointer; |
| transition:background .15s, border-color .15s, color .15s, transform .15s; |
| } |
| #settings-btn:hover{ |
| background:rgba(232,193,114,.10); border-color:rgba(232,193,114,.5); |
| color:var(--gold); transform:translateY(-1px); |
| } |
| |
| #settings-overlay{ |
| display:none; position:fixed; inset:0; z-index:35; |
| background:rgba(0,0,0,.55); backdrop-filter:blur(4px); |
| align-items:center; justify-content:center; |
| } |
| #settings-overlay.open{display:flex} |
| #settings-panel{ |
| width:min(380px,90vw); background:var(--dusk-1); |
| border:1px solid rgba(232,193,114,.25); border-radius:16px; |
| padding:28px 28px 24px; box-shadow:0 8px 40px rgba(0,0,0,.5); |
| } |
| #settings-header{ |
| display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; |
| } |
| #settings-header h3{ |
| font-family:var(--display); font-size:1.2rem; color:var(--gold); font-weight:600; |
| } |
| #settings-close{ |
| background:none; border:none; color:var(--ink-dim); font-size:1.1rem; |
| padding:4px 8px; cursor:pointer; border-radius:6px; |
| } |
| #settings-close:hover{color:var(--ink)} |
| .settings-row{ |
| display:flex; align-items:center; gap:12px; margin-bottom:20px; |
| } |
| .settings-row label{ |
| font-family:var(--display); font-size:.9rem; color:var(--ink); min-width:120px; flex-shrink:0; |
| } |
| .settings-row input[type=range]{ |
| flex:1; -webkit-appearance:none; appearance:none; |
| height:4px; border-radius:2px; background:rgba(255,255,255,.15); outline:none; cursor:pointer; |
| } |
| .settings-row input[type=range]::-webkit-slider-thumb{ |
| -webkit-appearance:none; appearance:none; |
| width:16px; height:16px; border-radius:50%; |
| background:var(--gold); cursor:pointer; box-shadow:0 0 4px rgba(232,193,114,.4); |
| } |
| .settings-row input[type=range]::-moz-range-thumb{ |
| width:16px; height:16px; border-radius:50%; border:none; |
| background:var(--gold); cursor:pointer; |
| } |
| .settings-vol-val{ |
| font-family:var(--body); font-size:.82rem; color:var(--ink-dim); |
| min-width:36px; text-align:right; |
| } |
| .settings-divider{ |
| border:none; border-top:1px solid rgba(232,193,114,.1); margin:20px 0; |
| } |
| #settings-save-btn{ |
| width:100%; padding:13px; font-size:1rem; font-family:var(--display); |
| background:rgba(232,193,114,.12); border:1px solid rgba(232,193,114,.4); |
| border-radius:10px; color:var(--gold); cursor:pointer; letter-spacing:.3px; |
| transition:background .15s; |
| } |
| #settings-save-btn:hover{background:rgba(232,193,114,.22)} |
| #settings-save-btn:disabled{opacity:.5; cursor:wait} |
| #settings-main-menu-btn{ |
| width:100%; padding:13px; font-size:1rem; font-family:var(--display); |
| background:rgba(233,166,166,.08); border:1px solid rgba(233,166,166,.3); |
| border-radius:10px; color:var(--rose); cursor:pointer; letter-spacing:.3px; |
| transition:background .15s; |
| } |
| #settings-main-menu-btn:hover{background:rgba(233,166,166,.18)} |
| |
| #ending-overlay{ |
| position:fixed; inset:0; z-index:50; display:none; flex-direction:column; |
| align-items:center; justify-content:center; padding:40px 24px; |
| text-align:center; overflow:hidden; |
| } |
| #ending-overlay.show{ display:flex } |
| #ending-overlay.victory{ |
| background:radial-gradient(ellipse at 50% 0%, #2e1030 0%, #150920 50%, #08050f 100%); |
| } |
| #ending-overlay.defeat{ |
| background:radial-gradient(ellipse at 50% 0%, #1a0808 0%, #0d0505 50%, #050305 100%); |
| } |
| |
| #ending-overlay::before{ |
| content:""; position:absolute; inset:0; pointer-events:none; |
| background:var(--ending-dim, rgba(10,6,18,.72)); |
| z-index:0; |
| } |
| |
| #ending-overlay::after{ |
| content:""; position:absolute; inset:0; pointer-events:none; z-index:0; |
| background-image: |
| radial-gradient(circle, rgba(255,255,255,.12) 1px, transparent 1px), |
| radial-gradient(circle, rgba(255,255,255,.06) 1px, transparent 1px); |
| background-size:120px 120px, 80px 80px; |
| background-position:0 0, 40px 40px; |
| animation:endingDrift 20s linear infinite; |
| opacity:.5; |
| } |
| #ending-overlay.defeat::after{ opacity:.18 } |
| |
| #ending-overlay > *{ position:relative; z-index:1 } |
| @keyframes endingDrift{ |
| from{ background-position:0 0, 40px 40px } |
| to { background-position:120px 120px, 160px 160px } |
| } |
| .ending-icon{ font-size:3.6rem; margin-bottom:16px; animation:endingPulse 2s ease-in-out infinite } |
| @keyframes endingPulse{ 0%,100%{transform:scale(1)} 50%{transform:scale(1.12)} } |
| .ending-title{ |
| font-family:var(--display); font-size:2rem; letter-spacing:.15em; |
| margin-bottom:10px; position:relative; |
| } |
| .victory .ending-title{ color:#e8c172 } |
| .defeat .ending-title{ color:#c87e7e } |
| .ending-subtitle{ |
| font-size:.78rem; letter-spacing:.3em; color:var(--ink-dim); margin-bottom:28px; |
| text-transform:uppercase; |
| } |
| .ending-divider{ |
| font-size:.65rem; letter-spacing:.5em; margin-bottom:20px; |
| } |
| .victory .ending-divider{ color:#a07c3a } |
| .defeat .ending-divider{ color:#7c3a3a } |
| #ending-text{ |
| font-family:var(--body); font-size:1.02rem; line-height:1.75; |
| max-width:520px; color:var(--ink); font-style:italic; |
| margin-bottom:36px; position:relative; |
| } |
| #ending-replay{ |
| padding:12px 32px; border-radius:8px; cursor:pointer; |
| font-family:var(--display); font-size:.95rem; letter-spacing:.2px; |
| border:none; position:relative; transition:opacity .2s; |
| } |
| #ending-replay:hover{ opacity:.85 } |
| .victory #ending-replay{ background:rgba(232,193,114,.2); border:1px solid rgba(232,193,114,.5); color:var(--gold) } |
| .defeat #ending-replay{ background:rgba(200,126,126,.15); border:1px solid rgba(200,126,126,.4); color:#c87e7e } |
| |
| #intro-book{ |
| position:fixed; inset:0; z-index:30; display:flex; align-items:center; justify-content:center; |
| background:rgba(5,7,12,.70); backdrop-filter:blur(5px); |
| animation:fade-in .6s ease; |
| } |
| @keyframes fade-in{from{opacity:0} to{opacity:1}} |
| .book-wrap{ |
| width:min(560px,90vw); position:relative; |
| background:linear-gradient(155deg,#f5edd6 0%,#e8d9b5 55%,#dccfa0 100%); |
| border-radius:3px 6px 6px 3px; padding:44px 48px 36px; |
| box-shadow: |
| -4px 0 8px rgba(0,0,0,.35), |
| 0 20px 60px rgba(0,0,0,.65), |
| inset 3px 0 6px rgba(0,0,0,.12), |
| inset 0 0 40px rgba(139,99,43,.08); |
| color:#3a2a14; border:1px solid #c4a96a; |
| } |
| |
| .book-wrap::before{ |
| content:""; position:absolute; left:0; top:0; bottom:0; width:18px; |
| background:linear-gradient(90deg,rgba(0,0,0,.25),transparent); |
| border-radius:3px 0 0 3px; pointer-events:none; |
| } |
| |
| .book-wrap::after{ |
| content:""; position:absolute; inset:10px 10px 10px 18px; |
| border:1px solid rgba(139,99,43,.25); border-radius:2px; pointer-events:none; |
| } |
| .book-ornament{text-align:center; color:#a07c3a; font-size:.85rem; letter-spacing:5px; margin:0 0 10px} |
| #book-title{ |
| font-family:var(--display); font-size:1.6rem; font-weight:600; color:#2a1a08; |
| text-align:center; margin:0 0 16px; line-height:1.2; |
| } |
| .book-divider{text-align:center; color:#c4a96a; font-size:.7rem; letter-spacing:8px; margin:10px 0 18px} |
| #book-text{ |
| font-family:var(--body); font-size:1.05rem; line-height:1.8; color:#3a2a14; |
| text-align:justify; font-style:italic; margin:0 0 24px; min-height:4em; |
| } |
| #book-close{ |
| display:block; margin:0 auto; background:#7a5120; color:#f5edd6; |
| font-family:var(--display); font-size:1rem; font-weight:500; letter-spacing:.5px; |
| padding:11px 36px; border-radius:3px; border:1px solid #a07840; cursor:pointer; |
| box-shadow:0 3px 10px rgba(0,0,0,.3); transition:background .15s ease; |
| } |
| #book-close:hover{background:#9a6930} |
| |
| #setup{position:fixed; inset:0; z-index:9; display:grid; place-items:center; |
| background:radial-gradient(80% 80% at 50% 30%, var(--dusk-2), var(--dusk-0));} |
| .card{width:min(520px,92vw); text-align:center; padding:34px 30px} |
| .card h1{font-family:var(--display); font-weight:600; font-size:2.6rem; margin:.1em 0 .1em; |
| color:var(--ink)} |
| .card p{color:var(--ink-dim); margin:.2em 0 1.4em} |
| .field{display:flex; gap:10px; margin:10px 0; align-items:center; justify-content:space-between} |
| .field label{font-family:var(--display); font-size:1.2rem; color:var(--gold)} |
| select{flex:1; margin-left:14px; background:var(--dusk-2); color:var(--ink); |
| border:1px solid rgba(255,255,255,.18); border-radius:10px; padding:10px 12px; font-family:var(--body)} |
| select option{background:var(--dusk-2); color:var(--ink)} |
| #enter{width:100%; margin-top:16px; padding:14px; font-size:1.05rem} |
| #start-progress{margin-top:18px; display:none; text-align:left} |
| .sp-step{margin-bottom:12px} |
| .sp-step-header{display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px} |
| .sp-step-label{font-size:.78rem; font-family:var(--display); color:var(--gold); letter-spacing:.04em} |
| .sp-step-pct{font-size:.75rem; color:var(--ink-dim)} |
| .sp-track{height:5px; border-radius:3px; background:rgba(255,255,255,.08); overflow:hidden} |
| .sp-fill{height:100%; border-radius:3px; width:0%; transition:width .55s ease} |
| .sp-fill.phase-llm{background:linear-gradient(90deg,#b08a4a,var(--gold))} |
| .sp-fill.phase-img{background:linear-gradient(90deg,#6a9cc8,#a8d4f0)} |
| .sp-note{font-size:.78rem; color:var(--ink-dim); font-style:italic; margin-top:4px; min-height:1.1em; transition:opacity .35s} |
| .shimmer{color:var(--ink-dim); font-style:italic} |
| .hidden{display:none !important} |
| |
| #journal-btn{ |
| position:fixed; top:16px; right:16px; z-index:8; |
| display:flex; align-items:center; gap:7px; |
| background:rgba(10,13,24,.82); border:1px solid rgba(232,193,114,.30); |
| border-radius:10px; padding:9px 15px; |
| color:var(--gold); font-family:var(--display); font-size:1rem; |
| backdrop-filter:blur(6px); cursor:pointer; |
| transition:background .15s, border-color .15s, transform .15s; |
| position:fixed; |
| } |
| #journal-btn:hover{ |
| background:rgba(232,193,114,.12); border-color:rgba(232,193,114,.55); |
| transform:translateY(-1px); |
| } |
| #journal-btn .jb-icon{font-size:1.15rem; line-height:1} |
| .je-badge{ |
| display:none; position:absolute; top:-5px; right:-5px; |
| width:10px; height:10px; background:var(--rose); border-radius:50%; |
| border:2px solid var(--dusk-0); |
| } |
| #journal-btn.has-new .je-badge{display:block} |
| |
| #journal-overlay{ |
| position:fixed; top:0; right:0; bottom:0; |
| width:min(400px,92vw); z-index:25; |
| display:flex; flex-direction:column; |
| background:rgba(8,10,18,.97); |
| border-left:1px solid rgba(232,193,114,.18); |
| backdrop-filter:blur(14px); |
| transform:translateX(105%); |
| transition:transform .35s cubic-bezier(.4,0,.2,1); |
| box-shadow:-14px 0 48px rgba(0,0,0,.55); |
| } |
| #journal-overlay.open{transform:translateX(0)} |
| #journal-header{ |
| display:flex; align-items:center; justify-content:space-between; |
| padding:18px 20px 14px; |
| border-bottom:1px solid rgba(232,193,114,.13); |
| flex-shrink:0; |
| } |
| #journal-header h3{ |
| font-family:var(--display); font-size:1.35rem; |
| color:var(--gold); margin:0; letter-spacing:.3px; |
| } |
| #journal-close{ |
| background:transparent; border:none; color:var(--ink-dim); |
| font-size:1.2rem; cursor:pointer; padding:4px 9px; |
| border-radius:6px; transition:color .15s, background .15s; |
| } |
| #journal-close:hover{color:var(--ink); background:rgba(255,255,255,.07)} |
| #journal-list{ |
| flex:1; overflow-y:auto; padding:16px 20px 28px; |
| scroll-behavior:smooth; |
| } |
| #journal-list::-webkit-scrollbar{width:4px} |
| #journal-list::-webkit-scrollbar-track{background:transparent} |
| #journal-list::-webkit-scrollbar-thumb{background:rgba(232,193,114,.22); border-radius:2px} |
| .journal-entry{margin-bottom:4px} |
| .je-meta{ |
| font-size:.72rem; color:var(--ink-dim); letter-spacing:.6px; |
| text-transform:uppercase; margin-bottom:9px; |
| } |
| .je-meta em{font-style:normal; color:rgba(232,193,114,.55)} |
| .je-player{ |
| font-size:.9rem; color:rgba(200,190,175,.6); |
| font-style:italic; margin-bottom:10px; |
| padding:6px 10px; border-left:2px solid rgba(255,255,255,.1); |
| border-radius:0 4px 4px 0; |
| } |
| .je-you{font-style:normal; color:rgba(210,200,185,.9); font-weight:500; margin-right:5px} |
| .je-speaker{ |
| font-family:var(--display); font-size:1.1rem; |
| color:var(--gold); margin-bottom:3px; |
| } |
| .je-dialogue{font-size:.95rem; line-height:1.65; color:var(--ink)} |
| .je-sep{ |
| text-align:center; color:rgba(232,193,114,.22); |
| font-size:.68rem; letter-spacing:7px; margin:18px 0; |
| } |
| .je-empty{ |
| color:var(--ink-dim); font-style:italic; font-family:var(--display); |
| text-align:center; margin-top:50px; font-size:1.05rem; line-height:1.7; |
| } |
| |
| #journal-tabs{ |
| display:flex; gap:0; flex-shrink:0; |
| border-bottom:1px solid rgba(232,193,114,.13); |
| } |
| .jtab{ |
| flex:1; padding:10px 0; font-family:var(--body); font-size:.88rem; |
| color:var(--ink-dim); cursor:pointer; background:none; border:none; |
| border-bottom:2px solid transparent; transition:color .15s, border-color .15s; |
| letter-spacing:.3px; |
| } |
| .jtab:hover{color:var(--ink)} |
| .jtab.active{color:var(--gold); border-bottom-color:var(--gold)} |
| #journal-rel{ |
| flex:1; overflow-y:auto; padding:16px 20px 28px; |
| scroll-behavior:smooth; |
| } |
| #journal-rel::-webkit-scrollbar{width:4px} |
| #journal-rel::-webkit-scrollbar-track{background:transparent} |
| #journal-rel::-webkit-scrollbar-thumb{background:rgba(232,193,114,.22); border-radius:2px} |
| .rel-entry{ |
| display:flex; align-items:center; gap:12px; |
| padding:12px 0; border-bottom:1px solid rgba(255,255,255,.06); |
| } |
| .rel-entry:last-child{border-bottom:none} |
| .rel-avatar{ |
| width:42px; height:42px; border-radius:50%; object-fit:cover; flex-shrink:0; |
| border:1px solid rgba(232,193,114,.2); background:rgba(255,255,255,.05); |
| display:flex; align-items:center; justify-content:center; |
| font-size:1.3rem; color:var(--ink-dim); |
| } |
| .rel-avatar img{width:42px; height:42px; border-radius:50%; object-fit:cover} |
| .rel-info{flex:1; min-width:0} |
| .rel-name{font-family:var(--display); font-size:1rem; color:var(--gold); margin-bottom:6px} |
| .rel-mini-bar{height:4px; border-radius:3px; background:rgba(255,255,255,.10); position:relative; overflow:hidden} |
| .rel-mini-pos{position:absolute; left:0; top:0; bottom:0; background:var(--moss); border-radius:3px; transition:width .5s ease} |
| .rel-mini-neg{position:absolute; right:0; top:0; bottom:0; background:var(--rose); border-radius:3px; transition:width .5s ease} |
| .rel-score{font-size:.78rem; min-width:32px; text-align:right; flex-shrink:0} |
| .rel-score.pos{color:var(--moss)} |
| .rel-score.neg{color:var(--rose)} |
| .rel-score.zero{color:var(--ink-dim)} |
| .rel-offstage{font-size:.7rem; color:rgba(200,190,175,.75); font-style:italic} |
| .rel-section-title{ |
| font-family:var(--display); font-size:.72rem; color:var(--gold); opacity:.65; |
| letter-spacing:1.2px; text-transform:uppercase; |
| padding:14px 0 6px; border-bottom:1px solid rgba(232,193,114,.12); margin-bottom:8px; |
| } |
| .bond-graph-wrap{ |
| padding:8px 0 8px; display:flex; justify-content:center; |
| } |
| .bond-graph-wrap svg{ width:100%; max-height:280px; display:block; overflow:visible } |
| .bond-list{ padding:0 4px 12px } |
| .bond-pair{ margin-bottom:7px; padding:6px 9px; background:rgba(255,255,255,.04); border-radius:7px } |
| .bond-row{ display:flex; align-items:center; gap:5px; padding:4px 0; font-size:.78rem } |
| .bond-row + .bond-row{ border-top:1px solid rgba(255,255,255,.06); margin-top:2px } |
| .bond-name{ font-family:var(--display); min-width:56px; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis } |
| .bond-arrow{ color:var(--ink-dim); flex-shrink:0; font-size:.7rem } |
| .bond-note{ font-style:italic; flex:1; font-size:.73rem } |
| .bond-val{ font-family:var(--display); font-weight:600; min-width:30px; text-align:right; font-size:.78rem } |
| </style> |
| </head> |
| <body> |
| <div id="notif-stack"></div> |
| <div id="stage"> |
| <div id="backdrop"></div> |
| <div id="sprites"></div> |
| <div id="place"></div> |
| <div id="dialogue"> |
| <h2 id="speaker">β¦</h2> |
| <div id="line"><span class="shimmer">the story is awakeningβ¦</span></div> |
| <div id="target-row" class="hidden"></div> |
| <div id="actions"> |
| <button class="action-chip active" data-action="talk">π¬ Talk</button> |
| <button class="action-chip" data-action="touch">π€ Touch</button> |
| <button class="action-chip" data-action="silent">πΆ Silence</button> |
| <button class="action-chip" data-action="give">π Give</button> |
| <button class="action-chip" data-action="move">πͺ Move</button> |
| <button class="action-chip" data-action="scout">ποΈ Look around</button> |
| </div> |
| <div id="inputrow"> |
| <input id="say" placeholder="say somethingβ¦" autocomplete="off" /> |
| <button id="send">β</button> |
| <button id="mic" class="ghost" title="hold to speak">ποΈ</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <button id="settings-btn" class="hidden" title="Settings">βοΈ</button> |
|
|
| |
| <div id="settings-overlay"> |
| <div id="settings-panel"> |
| <div id="settings-header"> |
| <h3>βοΈ Settings</h3> |
| <button id="settings-close">β</button> |
| </div> |
| <div class="settings-row"> |
| <label>π΅ Music</label> |
| <input type="range" id="bgm-vol-slider" min="0" max="100" value="35" /> |
| <span class="settings-vol-val" id="bgm-vol-val">35%</span> |
| </div> |
| <div class="settings-row"> |
| <label>π Voice</label> |
| <input type="range" id="tts-vol-slider" min="0" max="100" value="100" /> |
| <span class="settings-vol-val" id="tts-vol-val">100%</span> |
| </div> |
| <hr class="settings-divider" /> |
| <button id="settings-save-btn">πΎ Save current game</button> |
| <hr class="settings-divider" /> |
| <button id="settings-main-menu-btn">π Back to main menu</button> |
| </div> |
| </div> |
|
|
| |
| <button id="journal-btn" class="hidden" title="Story journal"> |
| <span class="jb-icon">π</span> |
| <span>Journal</span> |
| <span class="je-badge"></span> |
| </button> |
|
|
| |
| <div id="journal-overlay"> |
| <div id="journal-header"> |
| <h3>π Journal</h3> |
| <button id="journal-close" title="Close">β</button> |
| </div> |
| <div id="journal-tabs"> |
| <button class="jtab active" data-tab="story">Story</button> |
| <button class="jtab" data-tab="relations">Relations</button> |
| </div> |
| <div id="journal-list" style="display:block"> |
| <div class="je-empty">The story has not yet begunβ¦</div> |
| </div> |
| <div id="journal-rel" style="display:none"> |
| <div class="je-empty">No characters met yetβ¦</div> |
| </div> |
| </div> |
|
|
| |
| <div id="intro-book" class="hidden"> |
| <div class="book-wrap"> |
| <div class="book-ornament">β β¦ β</div> |
| <h2 id="book-title">Ephemeral Hearts</h2> |
| <div class="book-divider">Β· Β· Β·</div> |
| <p id="book-text"></p> |
| <div class="book-divider">Β· Β· Β·</div> |
| <button id="book-close">Begin the story βΊ</button> |
| </div> |
| </div> |
|
|
| |
| <div id="ending-overlay"> |
| <div class="ending-icon" id="ending-icon">β¦</div> |
| <div class="ending-title" id="ending-title">The End</div> |
| <div class="ending-subtitle" id="ending-subtitle"></div> |
| <div class="ending-divider">Β· Β· Β· Β· Β·</div> |
| <p id="ending-text"></p> |
| <button id="ending-replay">Play again</button> |
| </div> |
|
|
| |
| <div id="choice-screen" style="position:fixed;inset:0;z-index:10;display:grid;place-items:center; |
| background:radial-gradient(80% 80% at 50% 30%, var(--dusk-2), var(--dusk-0))"> |
| <div class="card"> |
| <h1>Ephemeral Hearts</h1> |
| <p>Every encounter changes everything.</p> |
| <button id="btn-new" style="width:100%;margin-bottom:12px;padding:14px;font-size:1.1rem; |
| background:rgba(232,193,114,.15);border:1px solid rgba(232,193,114,.4);border-radius:8px; |
| color:var(--gold);cursor:pointer;font-family:var(--display);letter-spacing:.3px; |
| transition:background .15s">β¨ New Story</button> |
| <label id="btn-load-label" style="display:block;width:100%;padding:13px;font-size:1rem; |
| background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.15);border-radius:8px; |
| color:var(--ink-dim);cursor:pointer;font-family:var(--body);text-align:center; |
| transition:background .15s">π Load a save file |
| <input type="file" id="load-file-input" accept=".json" style="display:none" /> |
| </label> |
| </div> |
| </div> |
|
|
| <div id="setup" class="hidden"> |
| <div class="card"> |
| <h1>Ephemeral Hearts</h1> |
| <p>Every encounter changes everything. Choose where the story begins.</p> |
| <div class="field"><label>Your name</label> |
| <input id="player-name" placeholder="optional - what should they call you?" maxlength="24" |
| style="width:100%;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.15); |
| color:var(--ink);border-radius:10px;padding:11px 14px;font-family:var(--body);font-size:1rem;box-sizing:border-box" /> |
| </div> |
| <div class="field"><label>Theme</label><select id="theme"></select></div> |
| <div class="field"><label>Tone</label><select id="tone"></select></div> |
| <button id="enter">Enter the story</button> |
| <div id="start-progress"> |
| <div class="sp-step" id="sp-step-llm"> |
| <div class="sp-step-header"> |
| <span class="sp-step-label">β¦ Writing the story</span> |
| <span class="sp-step-pct" id="sp-pct-llm">0%</span> |
| </div> |
| <div class="sp-track"><div class="sp-fill phase-llm" id="sp-fill-llm"></div></div> |
| <div class="sp-note" id="sp-note-llm">Waiting for the AIβ¦</div> |
| </div> |
| <div class="sp-step" id="sp-step-img" style="opacity:.35"> |
| <div class="sp-step-header"> |
| <span class="sp-step-label">π¨ Painting the scene</span> |
| <span class="sp-step-pct" id="sp-pct-img">β</span> |
| </div> |
| <div class="sp-track"><div class="sp-fill phase-img" id="sp-fill-img"></div></div> |
| <div class="sp-note" id="sp-note-img">Starts once the story is written</div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script type="module"> |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; |
| |
| const $ = (id) => document.getElementById(id); |
| let client; |
| |
| |
| (async () => { |
| client = await Client.connect(window.location.origin); |
| try { |
| const { data } = await client.predict("/themes", {}); |
| const { themes, tones } = data[0]; |
| for (const [key, t] of Object.entries(themes)) { |
| $("theme").insertAdjacentHTML("beforeend", `<option value="${key}">${t.label}</option>`); |
| } |
| for (const t of tones) $("tone").insertAdjacentHTML("beforeend", `<option value="${t}">${t}</option>`); |
| } catch (e) { console.warn("themes endpoint not ready", e); } |
| |
| |
| try { |
| const { data } = await client.predict("/session_info", {}); |
| const info = data[0]; |
| if (info?.exists && !info.ended) { |
| const btn = document.createElement("button"); |
| btn.id = "btn-continue"; |
| btn.style.cssText = $("btn-new").style.cssText; |
| btn.textContent = `βΆ Continue last story (turn ${info.turn_index} - ${info.place})`; |
| $("btn-new").after(btn); |
| btn.addEventListener("click", async () => { |
| if (bgmEnabled) switchMusic("calm"); |
| $("choice-screen").classList.add("hidden"); |
| busy(true); |
| try { |
| const { data: d } = await client.predict("/resume", {}); |
| if (d[0]?.error) throw new Error(d[0].error); |
| const v = d[0]; |
| |
| |
| const hist = v.history || []; |
| const recapCount = hist.filter(t => t.speaker !== "The tale so far").length; |
| let n = (v.turn_index || recapCount) - recapCount; |
| history = hist.map(t => ({ |
| turn: t.speaker === "The tale so far" ? "β¦" : ++n, |
| place: v.place || "", |
| player: t.player === "(arrives)" ? "" : (t.player || ""), |
| action: "talk", |
| speaker: t.speaker, |
| dialogue: t.dialogue, |
| })); |
| journalTurn = v.turn_index || recapCount; |
| render(v); |
| $("journal-btn").classList.remove("hidden"); |
| $("settings-btn").classList.remove("hidden"); |
| } catch (err) { |
| console.error("resume failed", err); |
| $("choice-screen").classList.remove("hidden"); |
| } finally { |
| busy(false); |
| } |
| }); |
| } |
| } catch (e) { console.warn("session_info not available", e); } |
| })(); |
| |
| |
| let history = []; |
| let journalTurn = 0; |
| let knownChars = []; |
| let npcBonds = []; |
| const spriteCache = {}; |
| |
| const ACTION_ICONS = { talk:"π¬", flirt:"πΈ", touch:"π€", ask:"π€", silent:"πΆ", give:"π", move:"πͺ" }; |
| |
| function addToHistory(playerText, v, action = "talk") { |
| if (!v?.dialogue || !v?.speaker) return; |
| history.push({ |
| turn: ++journalTurn, |
| place: v.place || "", |
| player: playerText || "", |
| action: action, |
| speaker: v.speaker, |
| dialogue: v.dialogue, |
| }); |
| if ($("journal-overlay").classList.contains("open")) { |
| if (activeTab === "story") renderJournal(); |
| else renderRelations(); |
| } else { |
| $("journal-btn").classList.add("has-new"); |
| } |
| } |
| |
| function renderJournal() { |
| const list = $("journal-list"); |
| if (history.length === 0) { |
| list.innerHTML = '<div class="je-empty">The story has not yet begunβ¦</div>'; |
| return; |
| } |
| list.innerHTML = history.map((e, i) => ` |
| ${i > 0 ? '<div class="je-sep">Β· Β· Β·</div>' : ""} |
| <div class="journal-entry"> |
| <div class="je-meta">Turn ${e.turn}${e.place ? ` Β· <em>${e.place}</em>` : ""}</div> |
| ${(e.player || e.action !== "talk") ? `<div class="je-player"><span class="je-you">${ACTION_ICONS[e.action] || "π¬"} You</span>${e.player ? ` "${e.player}"` : ""}</div>` : ""} |
| <div class="je-speaker">${e.speaker}</div> |
| <div class="je-dialogue">${e.dialogue}</div> |
| </div> |
| `).join(""); |
| setTimeout(() => { list.scrollTop = list.scrollHeight; }, 30); |
| } |
| |
| function renderRelations() { |
| const panel = $("journal-rel"); |
| if (!knownChars.length) { |
| panel.innerHTML = '<div class="je-empty">No characters met yetβ¦</div>'; |
| return; |
| } |
| |
| |
| const sorted = [...knownChars].sort((a, b) => b.relationship - a.relationship); |
| const barsHtml = sorted.map(ch => { |
| const rel = ch.relationship ?? 0; |
| const posW = rel > 0 ? rel.toFixed(1) : "0"; |
| const negW = rel < 0 ? Math.abs(rel).toFixed(1) : "0"; |
| const scoreClass = rel > 0 ? "pos" : rel < 0 ? "neg" : "zero"; |
| const label = rel > 0 ? `+${rel}` : `${rel}`; |
| const spriteUrl = ch.sprite_url || spriteCache[ch.id] || null; |
| const avatarHtml = spriteUrl |
| ? `<img src="${spriteUrl}" alt="${ch.name}" />` |
| : `<span>π€</span>`; |
| const isPresent = (window._presentIds || []).includes(ch.id); |
| return `<div class="rel-entry"> |
| <div class="rel-avatar">${avatarHtml}</div> |
| <div class="rel-info"> |
| <div class="rel-name">${ch.name} <span class="rel-offstage">${isPresent ? "on stage" : "away"}</span></div> |
| <div class="rel-mini-bar"> |
| <div class="rel-mini-neg" style="width:${negW}%"></div> |
| <div class="rel-mini-pos" style="width:${posW}%"></div> |
| </div> |
| </div> |
| <span class="rel-score ${scoreClass}">${label}</span> |
| </div>`; |
| }).join(""); |
| |
| |
| let graphHtml = ""; |
| if (knownChars.length >= 2) { |
| |
| const bondIndex = {}; |
| for (const b of npcBonds) bondIndex[`${b.source_id}|${b.target_id}`] = b; |
| |
| |
| const seenPairs = new Set(); |
| const pairList = []; |
| for (let i = 0; i < knownChars.length; i++) { |
| for (let j = i + 1; j < knownChars.length; j++) { |
| const a = knownChars[i], b = knownChars[j]; |
| const key = [a.id, b.id].sort().join("|"); |
| if (seenPairs.has(key)) continue; |
| seenPairs.add(key); |
| |
| const ab = bondIndex[`${a.id}|${b.id}`]; |
| const ba = bondIndex[`${b.id}|${a.id}`]; |
| if (!ab && !ba) continue; |
| pairList.push({ a, b, ab, ba }); |
| } |
| } |
| |
| const _bondRow = (srcName, tgtName, bond) => { |
| const val = bond ? bond.value : 0; |
| const note = bond ? bond.note : ""; |
| const col = _bondColor(note, val); |
| const sign = val > 0 ? "+" : ""; |
| const noteStr = note || (val === 0 ? "neutral" : ""); |
| return `<div class="bond-row"> |
| <span class="bond-name">${srcName}</span> |
| <span class="bond-arrow">β</span> |
| <span class="bond-name">${tgtName}</span> |
| <span class="bond-note" style="color:${col}">${noteStr}</span> |
| <span class="bond-val" style="color:${col}">${sign}${val}</span> |
| </div>`; |
| }; |
| |
| const bondListHtml = pairList.map(({ a, b, ab, ba }) => |
| `<div class="bond-pair"> |
| ${_bondRow(a.name, b.name, ab)} |
| ${_bondRow(b.name, a.name, ba)} |
| </div>` |
| ).join("") || `<div class="je-empty" style="font-size:.78rem">No bonds recorded yet</div>`; |
| |
| graphHtml = `<div class="rel-section-title">Between them</div> |
| <div class="bond-graph-wrap">${_buildBondGraph(knownChars, npcBonds)}</div> |
| <div class="bond-list">${bondListHtml}</div>`; |
| } |
| |
| panel.innerHTML = |
| `<div class="rel-section-title">With you</div>${barsHtml}${graphHtml}`; |
| } |
| |
| |
| const _NEG_NOTE = /jealou|rival|resent|env[iy]|bitter|hostil|scorn|distrust|spite|hate|anger/i; |
| const _POS_NOTE = /fond|loyal|protect|admire|camaraderie|trust|warm|friend|love|cherish/i; |
| function _bondColor(note, value) { |
| if (note && _NEG_NOTE.test(note)) return "#c87e7e"; |
| if (note && _POS_NOTE.test(note)) return "#7ec8a0"; |
| return value >= 0 ? "#7ec8a0" : "#c87e7e"; |
| } |
| |
| function _buildBondGraph(chars, bonds) { |
| const W = 340, H = 280, cx = W / 2, cy = H / 2; |
| |
| const R = Math.min(cx, cy) - 44; |
| |
| |
| const nodes = chars.map((ch, i) => { |
| const angle = (2 * Math.PI * i / chars.length) - Math.PI / 2; |
| return { |
| id: ch.id, name: ch.name, |
| x: cx + R * Math.cos(angle), |
| y: cy + R * Math.sin(angle), |
| rel: ch.relationship ?? 0, |
| sprite: ch.sprite_url || spriteCache[ch.id] || null, |
| }; |
| }); |
| const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n])); |
| |
| |
| let arcs = ""; |
| |
| const pairOffset = {}; |
| for (const b of bonds) { |
| const key = [b.source_id, b.target_id].sort().join("|"); |
| pairOffset[key] = (pairOffset[key] || 0) + 1; |
| } |
| const pairUsed = {}; |
| for (const b of bonds) { |
| const src = nodeMap[b.source_id], tgt = nodeMap[b.target_id]; |
| if (!src || !tgt) continue; |
| const pairKey = [b.source_id, b.target_id].sort().join("|"); |
| const side = (pairOffset[pairKey] > 1 && (pairUsed[pairKey] || 0) === 0) ? 1 : -1; |
| pairUsed[pairKey] = (pairUsed[pairKey] || 0) + 1; |
| |
| |
| |
| |
| const [canonSrc, canonTgt] = b.source_id < b.target_id ? [src, tgt] : [tgt, src]; |
| const cdx = canonTgt.x - canonSrc.x, cdy = canonTgt.y - canonSrc.y; |
| const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1; |
| const perpX = -cdy / clen, perpY = cdx / clen; |
| |
| const curveStrength = pairOffset[pairKey] > 1 ? 48 : 18; |
| const cpx = (src.x + tgt.x) / 2 + perpX * curveStrength * side; |
| const cpy = (src.y + tgt.y) / 2 + perpY * curveStrength * side; |
| |
| const col = _bondColor(b.note, b.value); |
| const op = (0.35 + Math.abs(b.value) / 100 * 0.65).toFixed(2); |
| const sw = (1 + Math.abs(b.value) / 100 * 3.5).toFixed(1); |
| |
| |
| const markerId = `arr-${b.source_id}-${b.target_id}`.replace(/[^a-z0-9-]/gi,"_"); |
| arcs += `<defs><marker id="${markerId}" markerWidth="5" markerHeight="5" |
| refX="4" refY="2.5" orient="auto"> |
| <polygon points="0,0 5,2.5 0,5" fill="${col}" fill-opacity="${op}"/> |
| </marker></defs> |
| <path d="M${src.x},${src.y} Q${cpx},${cpy} ${tgt.x},${tgt.y}" |
| fill="none" stroke="${col}" stroke-width="${sw}" stroke-opacity="${op}" |
| stroke-linecap="round" marker-end="url(#${markerId})"/>`; |
| |
| if (b.note) { |
| |
| |
| const bidir = pairOffset[pairKey] > 1; |
| const baseLx = 0.25 * src.x + 0.5 * cpx + 0.25 * tgt.x; |
| const baseLy = 0.25 * src.y + 0.5 * cpy + 0.25 * tgt.y; |
| const labelOffsetX = bidir ? perpX * 10 * side : 0; |
| const labelOffsetY = bidir ? perpY * 10 * side : -4; |
| const lx = baseLx + labelOffsetX; |
| const ly = baseLy + labelOffsetY; |
| arcs += `<text x="${lx}" y="${ly}" text-anchor="middle" |
| font-size="9" fill="${col}" |
| paint-order="stroke" stroke="rgba(10,8,18,.85)" stroke-width="3" stroke-linejoin="round" |
| font-family="Georgia,serif" font-style="italic">${b.note}</text>`; |
| } |
| } |
| |
| |
| let nodesSvg = ""; |
| for (const n of nodes) { |
| const clipId = `cl_${n.id}`.replace(/[^a-z0-9_]/gi,"_"); |
| |
| const borderCol = n.rel > 20 ? "#7ec8a0" : n.rel < -20 ? "#c87e7e" : "rgba(232,193,114,.55)"; |
| const nameCol = n.rel > 20 ? "#9ddcba" : n.rel < -20 ? "#dba0a0" : "rgba(232,193,114,.92)"; |
| nodesSvg += `<circle cx="${n.x}" cy="${n.y}" r="22" |
| fill="rgba(255,255,255,.05)" stroke="${borderCol}" stroke-width="1.8"/>`; |
| if (n.sprite) { |
| nodesSvg += `<clipPath id="${clipId}"><circle cx="${n.x}" cy="${n.y}" r="20"/></clipPath> |
| <image href="${n.sprite}" x="${n.x-20}" y="${n.y-20}" width="40" height="40" |
| clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice"/>`; |
| } else { |
| nodesSvg += `<text x="${n.x}" y="${n.y+5}" text-anchor="middle" font-size="16">π€</text>`; |
| } |
| |
| nodesSvg += `<text x="${n.x}" y="${n.y+38}" text-anchor="middle" |
| font-size="10.5" fill="${nameCol}" font-family="Georgia,serif" |
| paint-order="stroke" stroke="rgba(10,8,18,.9)" stroke-width="3.5" stroke-linejoin="round">${n.name}</text>`; |
| } |
| |
| return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg"> |
| ${arcs}${nodesSvg} |
| </svg>`; |
| } |
| |
| |
| let _presentIds = []; |
| window._presentIds = _presentIds; |
| |
| |
| let activeTab = "story"; |
| document.querySelectorAll(".jtab").forEach(btn => { |
| btn.addEventListener("click", () => { |
| activeTab = btn.dataset.tab; |
| document.querySelectorAll(".jtab").forEach(b => b.classList.remove("active")); |
| btn.classList.add("active"); |
| if (activeTab === "story") { |
| $("journal-list").style.display = "block"; |
| $("journal-rel").style.display = "none"; |
| renderJournal(); |
| } else { |
| $("journal-list").style.display = "none"; |
| $("journal-rel").style.display = "block"; |
| renderRelations(); |
| } |
| }); |
| }); |
| |
| $("journal-btn").addEventListener("click", () => { |
| const open = $("journal-overlay").classList.toggle("open"); |
| if (open) { |
| $("journal-btn").classList.remove("has-new"); |
| if (activeTab === "story") { |
| $("journal-list").style.display = "block"; |
| $("journal-rel").style.display = "none"; |
| renderJournal(); |
| } else { |
| $("journal-list").style.display = "none"; |
| $("journal-rel").style.display = "block"; |
| renderRelations(); |
| } |
| } |
| }); |
| $("journal-close").addEventListener("click", () => { |
| $("journal-overlay").classList.remove("open"); |
| }); |
| |
| |
| function typeOut(el, text){ |
| el.textContent = ""; let i = 0; |
| clearInterval(el._timer); |
| el._timer = setInterval(() => { |
| el.textContent = text.slice(0, ++i); |
| if (i >= text.length) clearInterval(el._timer); |
| }, 16); |
| } |
| |
| |
| let _currentAudio = null; |
| function playAudio(b64) { |
| if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; } |
| if (!b64) return; |
| _currentAudio = new Audio(b64); |
| _currentAudio.play().catch(() => {}); |
| } |
| |
| |
| |
| |
| function renderText(v){ |
| if (v.known_characters?.length) knownChars = v.known_characters; |
| $("place").textContent = v.place || ""; |
| $("speaker").textContent = v.speaker || ""; |
| typeOut($("line"), v.dialogue || ""); |
| if (v.current_music) switchMusic(v.current_music); |
| for (const msg of (v.notifications || [])) showNotif(msg); |
| } |
| |
| |
| let lastBg = null; |
| function render(v, opts = {}){ |
| if (v.known_characters?.length) { |
| knownChars = v.known_characters; |
| for (const ch of knownChars) { |
| if (ch.sprite_url) spriteCache[ch.id] = ch.sprite_url; |
| } |
| } |
| if (v.npc_bonds !== undefined) npcBonds = v.npc_bonds || []; |
| $("place").textContent = v.place || ""; |
| $("speaker").textContent = v.speaker || ""; |
| if (!opts.skipText) typeOut($("line"), v.dialogue || ""); |
| playTTS(v.audio_b64); |
| if (v.current_music) switchMusic(v.current_music); |
| if (v.backdrop_url && v.backdrop_url !== lastBg){ |
| const bg = $("backdrop"); |
| bg.classList.remove("on"); |
| setTimeout(() => { bg.style.backgroundImage = `url(${v.backdrop_url})`; bg.classList.add("on"); }, 120); |
| lastBg = v.backdrop_url; |
| } |
| window._presentIds = (v.present || []).map(p => p.id); |
| const wrap = $("sprites"); wrap.innerHTML = ""; |
| for (const p of (v.present || [])){ |
| if (!p.sprite_url) continue; |
| const div = document.createElement("div"); |
| div.className = "sprite-wrap"; |
| |
| const img = document.createElement("img"); |
| img.className = "sprite"; img.src = p.sprite_url; img.alt = p.name; |
| |
| |
| const rel = p.relationship ?? 0; |
| const relPosW = rel > 0 ? rel.toFixed(1) : "0"; |
| const relNegW = rel < 0 ? Math.abs(rel).toFixed(1) : "0"; |
| const relLabel = rel > 0 ? `+${rel}` : `${rel}`; |
| |
| |
| const total = p.total_traits ?? 0; |
| let traitsHtml = ""; |
| for (let i = 0; i < total; i++){ |
| const unlocked = p.discovered_traits && p.discovered_traits[i]; |
| traitsHtml += unlocked |
| ? `<div class="trait-item unlocked">β ${p.discovered_traits[i]}</div>` |
| : `<div class="trait-item locked">π ???</div>`; |
| } |
| |
| |
| const goalHtml = p.secret_goal |
| ? `<div class="secret-goal unlocked">π« ${p.secret_goal}</div>` |
| : `<div class="secret-goal locked">π Secret goal unknown</div>`; |
| |
| const card = document.createElement("div"); |
| card.className = "char-card"; |
| card.innerHTML = ` |
| <div class="char-card-name">${p.name}</div> |
| <div class="rel-row"> |
| <div class="rel-bar"> |
| <div class="rel-fill-neg" style="width:${relNegW}%"></div> |
| <div class="rel-fill-pos" style="width:${relPosW}%"></div> |
| </div> |
| <span class="rel-val">${relLabel}</span> |
| </div> |
| <div class="traits-section"> |
| <div class="traits-label">Traits</div> |
| ${traitsHtml || '<div class="trait-item locked">π ???</div>'} |
| </div> |
| ${goalHtml} |
| `; |
| |
| div.appendChild(img); |
| div.appendChild(card); |
| wrap.appendChild(div); |
| requestAnimationFrame(() => img.classList.add("on")); |
| } |
| |
| |
| for (const msg of (v.notifications || [])) showNotif(msg); |
| |
| |
| updateTargetRow(v.present); |
| |
| |
| if (v.ended && v.ending_text) { |
| setTimeout(() => showEnding(v.ending_kind, v.ending_text, v.backdrop_url), 1800); |
| } |
| } |
| |
| function showEnding(kind, text, backdropUrl) { |
| const overlay = $("ending-overlay"); |
| const isDefeat = kind === "defeat"; |
| overlay.className = "show " + (isDefeat ? "defeat" : "victory"); |
| |
| if (backdropUrl) { |
| overlay.style.backgroundImage = `url(${backdropUrl})`; |
| overlay.style.backgroundSize = "cover"; |
| overlay.style.backgroundPosition = "center"; |
| |
| overlay.style.setProperty("--ending-dim", isDefeat |
| ? "rgba(5,3,5,.78)" : "rgba(10,6,18,.65)"); |
| } |
| $("ending-icon").textContent = isDefeat ? "π" : "π"; |
| $("ending-title").textContent = isDefeat ? "Game Over" : "β¦ Fin β¦"; |
| $("ending-subtitle").textContent = isDefeat |
| ? "The wanderer walks aloneβ¦" |
| : "A warm ending"; |
| |
| const el = $("ending-text"); |
| el.textContent = ""; |
| let i = 0; |
| const iv = setInterval(() => { |
| if (i >= text.length) { clearInterval(iv); return; } |
| el.textContent += text[i++]; |
| }, 28); |
| |
| $("ending-replay").textContent = isDefeat ? "Try again" : "Play again"; |
| |
| if (isDefeat && !bgmTrackName?.includes("sad")) switchMusic("sad"); |
| if (!isDefeat && !bgmTrackName?.includes("romantic")) switchMusic("romantic"); |
| } |
| |
| function showNotif(msg){ |
| const el = document.createElement("div"); |
| el.className = "notif"; |
| el.textContent = msg; |
| $("notif-stack").appendChild(el); |
| setTimeout(() => { |
| el.classList.add("out"); |
| el.addEventListener("animationend", () => el.remove(), { once: true }); |
| }, 5000); |
| } |
| |
| |
| let currentTarget = ""; |
| |
| function updateTargetRow(present) { |
| const row = $("target-row"); |
| if (!present || present.length <= 1) { |
| row.classList.add("hidden"); |
| currentTarget = ""; |
| return; |
| } |
| row.classList.remove("hidden"); |
| row.innerHTML = ` |
| <span class="target-label">To:</span> |
| <button class="target-chip active" data-name="">Everyone</button> |
| ${present.map(p => `<button class="target-chip" data-name="${p.name}">${p.name}</button>`).join("")} |
| `; |
| row.querySelectorAll(".target-chip").forEach(btn => { |
| btn.addEventListener("click", () => { |
| if (btn.disabled) return; |
| row.querySelectorAll(".target-chip").forEach(b => b.classList.remove("active")); |
| btn.classList.add("active"); |
| currentTarget = btn.dataset.name; |
| }); |
| }); |
| } |
| |
| function resetTarget() { |
| currentTarget = ""; |
| const row = $("target-row"); |
| row.querySelectorAll(".target-chip").forEach(b => b.classList.remove("active")); |
| const everyone = row.querySelector('.target-chip[data-name=""]'); |
| if (everyone) everyone.classList.add("active"); |
| } |
| |
| |
| |
| const ACTION_PLACEHOLDERS = { |
| talk: "say somethingβ¦", |
| flirt: "add a flirty line⦠(or leave blank)", |
| touch: "add words⦠(optional)", |
| ask: "what do you want to know about them?", |
| silent: "", |
| give: "what do you offer?", |
| move: "where? (leave blank and the story decides)", |
| scout: "name someone specific⦠(or leave blank)", |
| }; |
| let currentAction = "talk"; |
| |
| document.querySelectorAll(".action-chip").forEach(btn => { |
| btn.addEventListener("click", () => { |
| if (btn.disabled) return; |
| document.querySelectorAll(".action-chip").forEach(b => b.classList.remove("active")); |
| btn.classList.add("active"); |
| currentAction = btn.dataset.action; |
| if (currentAction === "silent") { |
| $("say").value = ""; |
| $("say").disabled = true; |
| } else { |
| $("say").disabled = false; |
| $("say").placeholder = ACTION_PLACEHOLDERS[currentAction] || "say somethingβ¦"; |
| $("say").focus(); |
| } |
| }); |
| }); |
| |
| function resetAction() { |
| currentAction = "talk"; |
| document.querySelectorAll(".action-chip").forEach(b => b.classList.remove("active")); |
| document.querySelector('.action-chip[data-action="talk"]').classList.add("active"); |
| $("say").disabled = false; |
| $("say").placeholder = ACTION_PLACEHOLDERS.talk; |
| } |
| |
| function busy(state){ |
| $("send").disabled = state; $("say").disabled = state; $("mic").disabled = state; |
| document.querySelectorAll(".action-chip").forEach(b => b.disabled = state); |
| document.querySelectorAll(".target-chip").forEach(b => b.disabled = state); |
| } |
| |
| |
| let ttsVol = 1.0; |
| let ttsEnabled = true; |
| |
| const ttsBtn = { textContent: "", classList: { add:()=>{}, toggle:()=>{}, remove:()=>{} } }; |
| |
| function playTTS(audio_b64) { |
| if (!ttsEnabled || !audio_b64) return; |
| let audio = $("vn-audio"); |
| if (!audio) { |
| audio = document.createElement("audio"); |
| audio.id = "vn-audio"; |
| document.body.appendChild(audio); |
| } |
| audio.volume = ttsVol; |
| audio.src = audio_b64; |
| audio.play().catch(() => {}); |
| } |
| |
| |
| const BGM_TRACKS = { |
| calm: "/music/calm.mp3", |
| romantic: "/music/romantic.mp3", |
| dramatic: "/music/dramatic.mp3", |
| mystery: "/music/mystery.mp3", |
| sad: "/music/sad.mp3", |
| joyful: "/music/joyful.mp3", |
| }; |
| let bgmMasterVolume = 0.35; |
| const BGM_FADE_OUT_MS = 2800; |
| const BGM_FADE_IN_MS = 3200; |
| const BGM_OVERLAP_MS = 1200; |
| |
| let bgmEnabled = true; |
| let bgmCurrent = null; |
| let bgmTrackName = null; |
| |
| |
| const bgmBtn = { textContent: "", classList: { add:()=>{}, toggle:()=>{}, remove:()=>{} } }; |
| |
| function switchMusic(name) { |
| if (!name || !BGM_TRACKS[name]) return; |
| if (name === bgmTrackName) return; |
| if (!bgmEnabled) { bgmTrackName = name; return; } |
| _bgmPlay(name); |
| } |
| |
| function _bgmPlay(name) { |
| const old = bgmCurrent; |
| bgmTrackName = name; |
| |
| const a = new Audio(BGM_TRACKS[name]); |
| a.loop = true; |
| a.volume = 0; |
| bgmCurrent = a; |
| |
| |
| const startFadeIn = () => { |
| const p = a.play(); |
| if (p !== undefined) { |
| p.catch(() => { |
| |
| const retry = () => { |
| if (bgmCurrent === a && bgmEnabled) { |
| a.play().then(() => _bgmFade(a, 0, bgmMasterVolume, BGM_FADE_IN_MS)).catch(() => {}); |
| } |
| }; |
| document.addEventListener("click", retry, { once: true }); |
| document.addEventListener("keydown", retry, { once: true }); |
| }); |
| } |
| _bgmFade(a, 0, bgmMasterVolume, BGM_FADE_IN_MS); |
| }; |
| |
| if (old) { |
| _bgmFade(old, old.volume, 0, BGM_FADE_OUT_MS, () => { old.pause(); old.src = ""; }); |
| setTimeout(startFadeIn, BGM_FADE_OUT_MS - BGM_OVERLAP_MS); |
| } else { |
| startFadeIn(); |
| } |
| } |
| |
| |
| function _bgmFade(audio, fromVol, toVol, ms, onDone) { |
| const start = performance.now(); |
| function tick(now) { |
| const raw = Math.min(1, (now - start) / ms); |
| |
| const t = raw * raw * (3 - 2 * raw); |
| audio.volume = fromVol + (toVol - fromVol) * t; |
| if (raw < 1) { |
| requestAnimationFrame(tick); |
| } else { |
| audio.volume = toVol; |
| if (onDone) onDone(); |
| } |
| } |
| requestAnimationFrame(tick); |
| } |
| |
| |
| $("book-close").addEventListener("click", () => { |
| $("intro-book").classList.add("hidden"); |
| $("say").focus(); |
| }); |
| |
| |
| $("ending-replay").addEventListener("click", () => { |
| |
| if (bgmCurrent) { _bgmFade(bgmCurrent, bgmCurrent.volume, 0, 800, () => { bgmCurrent.pause(); bgmCurrent.src = ""; }); bgmCurrent = null; } |
| bgmTrackName = null; |
| $("ending-overlay").className = ""; |
| |
| history = []; journalTurn = 0; knownChars = []; npcBonds = []; |
| $("place").textContent = ""; $("speaker").textContent = ""; $("line").textContent = ""; |
| $("sprites").innerHTML = ""; lastBg = null; |
| $("backdrop").style.backgroundImage = "none"; $("backdrop").classList.remove("on"); |
| $("journal-btn").classList.add("hidden"); |
| $("settings-btn").classList.add("hidden"); |
| $("enter").disabled = false; |
| $("enter").textContent = "Enter the story"; |
| $("choice-screen").classList.remove("hidden"); |
| }); |
| |
| |
| $("btn-new").addEventListener("click", () => { |
| $("choice-screen").classList.add("hidden"); |
| $("setup").classList.remove("hidden"); |
| }); |
| |
| $("load-file-input").addEventListener("change", async (e) => { |
| const file = e.target.files[0]; |
| if (!file) return; |
| const text = await file.text(); |
| |
| |
| if (bgmEnabled) switchMusic("calm"); |
| |
| busy(true); |
| try { |
| |
| let parsed; |
| try { parsed = JSON.parse(text); } catch { parsed = {}; } |
| const savedHistory = Array.isArray(parsed.journal_history) ? parsed.journal_history : []; |
| const savedAudio = parsed.audio_settings || null; |
| |
| const { data } = await client.predict("/load_file", { data: text }); |
| $("choice-screen").classList.add("hidden"); |
| $("setup").classList.add("hidden"); |
| |
| |
| history = savedHistory; |
| journalTurn = savedHistory.length > 0 ? savedHistory[savedHistory.length - 1].turn : 0; |
| |
| |
| if (savedAudio) { |
| const bgmPct = Math.max(0, Math.min(100, savedAudio.bgm_vol ?? 35)); |
| const ttsPct = Math.max(0, Math.min(100, savedAudio.tts_vol ?? 100)); |
| bgmMasterVolume = bgmPct / 100; |
| ttsVol = ttsPct / 100; |
| bgmEnabled = bgmPct > 0; |
| ttsEnabled = ttsPct > 0; |
| bgmVolSlider.value = bgmPct; |
| bgmVolVal.textContent = bgmPct + "%"; |
| ttsVolSlider.value = ttsPct; |
| ttsVolVal.textContent = ttsPct + "%"; |
| if (bgmCurrent) bgmCurrent.volume = bgmMasterVolume; |
| |
| |
| if (savedAudio.bgm_track && bgmEnabled) bgmTrackName = null; |
| } |
| |
| render(data[0]); |
| |
| if (savedAudio?.bgm_track && bgmEnabled && !bgmTrackName) { |
| switchMusic(savedAudio.bgm_track); |
| } |
| $("journal-btn").classList.remove("hidden"); |
| $("settings-btn").classList.remove("hidden"); |
| } catch(err) { alert("Could not load save: " + (err?.message || err)); } |
| busy(false); |
| }); |
| |
| |
| $("settings-btn").addEventListener("click", () => $("settings-overlay").classList.add("open")); |
| $("settings-close").addEventListener("click", () => $("settings-overlay").classList.remove("open")); |
| $("settings-overlay").addEventListener("click", e => { |
| if (e.target === $("settings-overlay")) $("settings-overlay").classList.remove("open"); |
| }); |
| |
| |
| |
| const bgmVolSlider = $("bgm-vol-slider"); |
| const bgmVolVal = $("bgm-vol-val"); |
| bgmVolSlider.addEventListener("input", () => { |
| const pct = parseInt(bgmVolSlider.value, 10); |
| bgmVolVal.textContent = pct + "%"; |
| bgmMasterVolume = pct / 100; |
| if (pct === 0) { |
| if (bgmEnabled) { |
| bgmEnabled = false; |
| if (bgmCurrent) { |
| _bgmFade(bgmCurrent, bgmCurrent.volume, 0, 500, () => { bgmCurrent.pause(); bgmCurrent.src = ""; }); |
| bgmCurrent = null; |
| } |
| } |
| } else { |
| if (!bgmEnabled) { |
| bgmEnabled = true; |
| if (bgmTrackName) _bgmPlay(bgmTrackName); |
| } else if (bgmCurrent) { |
| bgmCurrent.volume = bgmMasterVolume; |
| } |
| } |
| bgmBtn.classList.toggle("active", bgmEnabled); |
| bgmBtn.textContent = bgmEnabled ? "π΅" : "π"; |
| }); |
| |
| |
| const ttsVolSlider = $("tts-vol-slider"); |
| const ttsVolVal = $("tts-vol-val"); |
| ttsVolSlider.addEventListener("input", () => { |
| const pct = parseInt(ttsVolSlider.value, 10); |
| ttsVolVal.textContent = pct + "%"; |
| ttsVol = pct / 100; |
| ttsEnabled = pct > 0; |
| const a = $("vn-audio"); if (a) a.volume = ttsVol; |
| ttsBtn.classList.toggle("active", ttsEnabled); |
| ttsBtn.textContent = ttsEnabled ? "π" : "π"; |
| }); |
| |
| |
| $("settings-save-btn").addEventListener("click", async () => { |
| const btn = $("settings-save-btn"); |
| btn.disabled = true; |
| btn.textContent = "Savingβ¦"; |
| try { |
| const { data } = await client.predict("/save_data", {}); |
| const saveObj = JSON.parse(data[0].json); |
| saveObj.journal_history = history; |
| saveObj.audio_settings = { |
| bgm_vol: Math.round(bgmMasterVolume * 100), |
| tts_vol: Math.round(ttsVol * 100), |
| bgm_track: bgmTrackName || null, |
| }; |
| const blob = new Blob([JSON.stringify(saveObj)], { type: "application/json" }); |
| const a = document.createElement("a"); |
| a.href = URL.createObjectURL(blob); |
| a.download = "ephemeral-hearts-save.json"; |
| a.click(); |
| URL.revokeObjectURL(a.href); |
| btn.textContent = "β Saved!"; |
| setTimeout(() => { btn.textContent = "πΎ Save current game"; btn.disabled = false; }, 2000); |
| } catch(err) { |
| console.error("save failed", err); |
| btn.textContent = "β οΈ Failed"; |
| setTimeout(() => { btn.textContent = "πΎ Save current game"; btn.disabled = false; }, 2000); |
| } |
| }); |
| |
| |
| $("settings-main-menu-btn").addEventListener("click", () => { |
| $("settings-overlay").classList.remove("open"); |
| if (bgmCurrent) { _bgmFade(bgmCurrent, bgmCurrent.volume, 0, 800, () => { bgmCurrent.pause(); bgmCurrent.src = ""; }); bgmCurrent = null; } |
| bgmTrackName = null; |
| history = []; journalTurn = 0; knownChars = []; npcBonds = []; |
| $("place").textContent = ""; $("speaker").textContent = ""; $("line").textContent = ""; |
| $("sprites").innerHTML = ""; lastBg = null; |
| $("backdrop").style.backgroundImage = "none"; $("backdrop").classList.remove("on"); |
| $("journal-btn").classList.add("hidden"); |
| $("settings-btn").classList.add("hidden"); |
| $("journal-overlay").classList.remove("open"); |
| $("ending-overlay").className = ""; |
| $("enter").disabled = false; |
| $("enter").textContent = "Enter the story"; |
| $("setup").classList.add("hidden"); |
| $("choice-screen").classList.remove("hidden"); |
| }); |
| |
| |
| const TONE_MUSIC = { romantic:"romantic", flirty:"romantic", dramatic:"dramatic", bittersweet:"sad", comedic:"joyful" }; |
| |
| |
| $("enter").addEventListener("click", async () => { |
| const btn = $("enter"); |
| btn.disabled = true; |
| const orig = btn.textContent; |
| btn.textContent = "β¨ Creating your storyβ¦"; |
| |
| |
| if (bgmEnabled) switchMusic(TONE_MUSIC[$("tone").value] || "calm"); |
| |
| |
| const spEl = $("start-progress"); |
| spEl.style.display = "block"; |
| |
| |
| let _spTick = null; |
| const _spNote = (id, msg) => { |
| const el = $(id); |
| el.style.opacity = "0"; |
| setTimeout(() => { el.textContent = msg; el.style.opacity = "1"; }, 200); |
| }; |
| |
| const _spCrawl = (fillId, pctId, target, stepMs) => new Promise(resolve => { |
| if (_spTick) { clearInterval(_spTick); _spTick = null; } |
| const fill = $(fillId), pctEl = $(pctId); |
| let cur = parseFloat(fill.style.width) || 0; |
| _spTick = setInterval(() => { |
| cur = Math.min(target, cur + 0.7); |
| fill.style.width = cur + "%"; |
| pctEl.textContent = Math.round(cur) + "%"; |
| if (cur >= target) { clearInterval(_spTick); _spTick = null; resolve(); } |
| }, stepMs); |
| }); |
| const _spFinish = (fillId, pctId, label) => { |
| if (_spTick) { clearInterval(_spTick); _spTick = null; } |
| $(fillId).style.width = "100%"; |
| $(pctId).textContent = label || "100%"; |
| }; |
| const spHide = () => { |
| spEl.style.display = "none"; |
| ["sp-fill-llm","sp-fill-img"].forEach(id => { $(id).style.width = "0%"; }); |
| }; |
| |
| |
| const LLM_NOTES = [ |
| "The AI is generating your opening sceneβ¦", |
| "First load can take 30β60 s while the model warms upβ¦", |
| "Inventing characters, secrets, and a first line just for youβ¦", |
| "Almost done writing β hang onβ¦", |
| ]; |
| let _llmNoteIdx = 0; |
| const _llmNoteTick = setInterval(() => { |
| _llmNoteIdx = (_llmNoteIdx + 1) % LLM_NOTES.length; |
| _spNote("sp-note-llm", LLM_NOTES[_llmNoteIdx]); |
| }, 9000); |
| |
| try { |
| |
| _spNote("sp-note-llm", LLM_NOTES[0]); |
| |
| _spCrawl("sp-fill-llm", "sp-pct-llm", 88, 800); |
| |
| const { data: d1 } = await client.predict("/start_text", { |
| theme: $("theme").value, |
| tone: $("tone").value, |
| player_name: $("player-name").value.trim(), |
| }); |
| |
| |
| clearInterval(_llmNoteTick); |
| _spFinish("sp-fill-llm", "sp-pct-llm", "100%"); |
| _spNote("sp-note-llm", "Story written β"); |
| $("sp-step-img").style.opacity = "1"; |
| _spNote("sp-note-img", "Generating backdrop and character illustrationβ¦"); |
| $("sp-pct-img").textContent = "0%"; |
| |
| |
| |
| _spCrawl("sp-fill-img", "sp-pct-img", 85, 350); |
| |
| const { data: d2 } = await client.predict("/start_images", {}); |
| |
| |
| _spFinish("sp-fill-img", "sp-pct-img", "100%"); |
| _spNote("sp-note-img", "Scene ready β"); |
| render(d2[0]); |
| addToHistory("", d2[0]); |
| $("setup").classList.add("hidden"); |
| $("journal-btn").classList.remove("hidden"); |
| $("settings-btn").classList.remove("hidden"); |
| |
| setTimeout(() => { |
| spHide(); |
| if (d2[0]?.intro_text) { |
| $("book-text").textContent = d2[0].intro_text; |
| $("intro-book").classList.remove("hidden"); |
| } |
| }, 700); |
| |
| } catch(err) { |
| console.error("start failed", err); |
| clearInterval(_llmNoteTick); |
| if (_spTick) { clearInterval(_spTick); _spTick = null; } |
| spHide(); |
| btn.textContent = "β οΈ Failed β try again"; |
| btn.disabled = false; |
| setTimeout(() => { btn.textContent = orig; }, 3000); |
| } |
| }); |
| |
| |
| async function sendTurn(){ |
| const text = $("say").value.trim(); |
| |
| if (!text && currentAction === "talk") return; |
| const action = currentAction; |
| $("say").value = ""; busy(true); |
| try { |
| |
| const { data: d1 } = await client.predict("/turn_text", { player_input: text, action, target: currentTarget }); |
| renderText(d1[0]); |
| |
| const { data: d2 } = await client.predict("/turn_images", {}); |
| render(d2[0], { skipText: true }); |
| addToHistory(text, d2[0], action); |
| } finally { |
| busy(false); |
| resetAction(); |
| resetTarget(); |
| $("say").focus(); |
| } |
| } |
| $("send").addEventListener("click", sendTurn); |
| $("say").addEventListener("keydown", (e) => { if (e.key === "Enter") sendTurn(); }); |
| |
| |
| let rec, chunks = [], recTimerId = null; |
| |
| function micIdle() { const m=$("mic"); m.textContent="ποΈ"; m.title="click to record"; m.classList.remove("live","waiting"); } |
| function micTick(s) { const m=Math.floor(s/60), sec=String(s%60).padStart(2,"0"); $("mic").textContent=`β ${m}:${sec}`; } |
| function micWait() { const m=$("mic"); m.textContent="β³"; m.title="transcribingβ¦"; m.classList.remove("live"); m.classList.add("waiting"); } |
| |
| $("mic").addEventListener("click", async () => { |
| const mic = $("mic"); |
| if (rec && rec.state === "recording") { rec.stop(); return; } |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| rec = new MediaRecorder(stream); chunks = []; |
| rec.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); }; |
| |
| |
| $("say").disabled = true; $("send").disabled = true; |
| let secs = 0; micTick(0); mic.classList.add("live"); |
| recTimerId = setInterval(() => micTick(++secs), 1000); |
| |
| rec.onstop = async () => { |
| clearInterval(recTimerId); |
| stream.getTracks().forEach((t) => t.stop()); |
| const blob = new Blob(chunks, { type: "audio/webm" }); |
| console.debug("[stt] blob size", blob.size); |
| if (blob.size < 1000) { micIdle(); $("say").disabled = false; $("send").disabled = false; return; } |
| micWait(); mic.disabled = true; |
| try { |
| const file = new File([blob], "speech.webm", { type: "audio/webm" }); |
| const { data } = await client.predict("/transcribe", { audio: handle_file(file) }); |
| micIdle(); mic.disabled = false; $("say").disabled = false; $("send").disabled = false; |
| if (data[0]?.text) { $("say").value = data[0].text; $("say").focus(); return; } |
| } catch (e) { console.error("[stt] transcribe error", e); } |
| |
| mic.disabled = false; $("say").disabled = false; $("send").disabled = false; |
| }; |
| rec.start(100); |
| } catch (e) { |
| console.warn("mic unavailable", e); |
| micIdle(); $("say").disabled = false; $("send").disabled = false; |
| } |
| }); |
| </script> |
| </body> |
| </html> |
|
|