Spaces:
Running
Running
| <html lang="ko"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>μΉ΄λ₯΄λ°λΌ β μ λ€μ§ μλ λ°€</title> | |
| <style> | |
| :root { | |
| --ink: #0b0e14; --mist: #1a2233; --mist2: #232f47; | |
| --gold: #c9a86a; --gold-dim: #8a7448; | |
| --text: #d8d5cd; --text-dim: #8f8c85; --blood: #6e2233; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| html, body { height: 100%; } | |
| body { | |
| background: radial-gradient(ellipse at 50% -20%, var(--mist) 0%, var(--ink) 62%); | |
| color: var(--text); | |
| font-family: "Noto Serif KR", "Apple SD Gothic Neo", serif; | |
| line-height: 1.95; letter-spacing: .01em; | |
| overflow-x: hidden; | |
| } | |
| /* ββ μ§ν λ° + μ₯ λΌλ²¨ ββ */ | |
| #progress-wrap { position: fixed; top: 0; left: 0; right: 0; z-index: 40; } | |
| #progress-bar { height: 2px; width: 0%; background: linear-gradient(90deg, var(--gold-dim), var(--gold)); | |
| transition: width .8s ease; box-shadow: 0 0 6px rgba(201,168,106,.5); } | |
| #progress-label { position: fixed; top: 10px; right: 16px; color: var(--gold-dim); | |
| font-size: .74rem; letter-spacing: .22em; opacity: .85; z-index: 40; } | |
| /* ββ λ³Έλ¬Έ ββ */ | |
| #stage { max-width: 680px; margin: 0 auto; padding: 54px 24px 40vh; min-height: 100%; } | |
| .ep-header { text-align: center; color: var(--gold-dim); font-size: .82rem; | |
| letter-spacing: .35em; margin: 26px 0 6px; } | |
| .card-title { text-align: center; color: var(--gold); font-size: 1.05rem; | |
| letter-spacing: .18em; margin-bottom: 44px; } | |
| .card-title::after { content: ""; display: block; width: 42px; height: 1px; | |
| background: var(--gold-dim); margin: 20px auto 0; opacity: .6; } | |
| .prose { margin: 0 0 1.6em; font-size: 1.02rem; text-align: justify; | |
| word-break: keep-all; white-space: pre-line; } | |
| .center-line { text-align: center; color: var(--gold); font-size: 1.12rem; | |
| margin: 2.4em 0; line-height: 2.1; word-break: keep-all; } | |
| .center-line::before { content: "β "; color: var(--gold-dim); } | |
| .center-line::after { content: " β"; color: var(--gold-dim); } | |
| .char-line { margin: 1.2em 0; padding-left: 1em; border-left: 2px solid var(--gold-dim); | |
| color: #cfc4ad; word-break: keep-all; } | |
| .char-line .spk { color: var(--gold-dim); font-size: .85rem; margin-right: .6em; } | |
| .stage-dir { color: var(--text-dim); font-style: italic; font-size: .92rem; | |
| text-align: center; margin: 1.4em 0; } | |
| .result-block { margin: 1.6em 0; padding: 1em 1.2em; background: rgba(201,168,106,.05); | |
| border: 1px solid rgba(201,168,106,.18); border-radius: 2px; | |
| white-space: pre-line; word-break: keep-all; } | |
| .fade { animation: fadein 1.1s ease both; } | |
| @keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; } } | |
| /* ββ μ νμ§: λ³Έλ¬Έ νλ¦ μ, μ€μ μ€νλΆ 1μ΄ ββ */ | |
| #choice-area { margin: 9vh auto 0; max-width: 430px; display: flex; | |
| flex-direction: column; gap: 12px; } | |
| button { | |
| font-family: inherit; font-size: .98rem; cursor: pointer; word-break: keep-all; | |
| background: rgba(26,34,51,.85); color: var(--text); | |
| border: 1px solid rgba(201,168,106,.35); border-radius: 2px; | |
| padding: 13px 22px; text-align: center; transition: all .25s; | |
| } | |
| button:hover { border-color: var(--gold); color: var(--gold); background: var(--mist2); } | |
| button:disabled { opacity: .4; cursor: wait; } | |
| button.chat-open { font-style: italic; color: var(--gold-dim); | |
| background: transparent; border-style: dashed; } | |
| @keyframes nudge { 0%,100% { transform: none; } 30% { transform: translateX(-4px); } | |
| 60% { transform: translateX(4px); } } | |
| .nudge { animation: nudge .4s ease; } | |
| /* ββ μ’/μ° λ΄λΉ μ‘΄ ββ */ | |
| .nav-zone { position: fixed; top: 0; bottom: 0; width: 26%; z-index: 5; cursor: pointer; } | |
| #nav-left { left: 0; } #nav-right { right: 0; width: 40%; } | |
| .nav-hint { position: fixed; bottom: 18px; color: var(--gold-dim); font-size: .72rem; | |
| letter-spacing: .2em; opacity: 0; transition: opacity .6s; z-index: 6; | |
| pointer-events: none; } | |
| #hint-left { left: 18px; } #hint-right { right: 18px; } | |
| body.show-hints .nav-hint { opacity: .6; } | |
| #browse-ribbon { position: fixed; top: 14px; left: 50%; transform: translateX(-50%); | |
| color: var(--text-dim); font-size: .76rem; letter-spacing: .2em; | |
| border: 1px solid rgba(201,168,106,.25); padding: 4px 14px; | |
| background: rgba(11,14,20,.85); border-radius: 2px; z-index: 41; display: none; } | |
| body.browsing #browse-ribbon { display: block; } | |
| body.browsing #choice-area { pointer-events: none; opacity: .45; } | |
| /* ββ μ°μΆ μ»· νμ΄μ§ (μ΄λ―Έμ§ = λ 립 νμ΄μ§) ββ */ | |
| .page-cut { display: flex; flex-direction: column; align-items: center; | |
| justify-content: center; min-height: 64vh; } | |
| .page-cut img { max-width: min(92vw, 620px); max-height: 66vh; object-fit: contain; | |
| box-shadow: 0 0 80px rgba(0,0,0,.9); border: 1px solid rgba(201,168,106,.25); | |
| animation: still 6s ease both; } | |
| @keyframes still { from { opacity: 0; transform: scale(1.015); } 20% { opacity: 1; } | |
| to { opacity: 1; transform: scale(1); } } | |
| .cut-caption { color: var(--gold); letter-spacing: .2em; margin-top: 26px; | |
| font-size: .95rem; animation: fadein 2.2s ease both .8s; } | |
| /* ββ μ±ν (νλ¨ λλ‘μ΄ μ μ§) ββ */ | |
| #chat-wrap { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; | |
| background: linear-gradient(transparent, var(--ink) 30%); | |
| padding: 46px 24px 24px; display: none; } | |
| #chat-wrap.open { display: block; } | |
| .chat-inner { max-width: 680px; margin: 0 auto; } | |
| #chat-log { max-height: 40vh; overflow-y: auto; margin-bottom: 12px; | |
| border-top: 1px solid rgba(201,168,106,.25); padding-top: 12px; } | |
| .msg { margin: .7em 0; word-break: keep-all; } | |
| .msg.laura { text-align: right; color: var(--text-dim); } | |
| .msg.laura::before { content: "λ β "; font-size: .8rem; color: var(--gold-dim); } | |
| .msg.carmilla { color: #cfc4ad; } | |
| .msg.carmilla::before { content: "μΉ΄λ₯΄λ°λΌ β "; font-size: .8rem; color: var(--gold); } | |
| .msg.system { text-align: center; color: var(--text-dim); font-style: italic; font-size: .88rem; } | |
| #chat-input-row { display: flex; gap: 8px; } | |
| #chat-input { flex: 1; background: var(--mist); color: var(--text); | |
| border: 1px solid rgba(201,168,106,.35); border-radius: 2px; | |
| padding: 11px 14px; font-family: inherit; font-size: .96rem; } | |
| #chat-input:focus { outline: none; border-color: var(--gold); } | |
| /* ββ νμ§Β·μλ© ββ */ | |
| #cover { position: fixed; inset: 0; z-index: 70; display: flex; flex-direction: column; | |
| align-items: center; justify-content: flex-end; padding-bottom: 9vh; | |
| background-size: cover; background-position: center 20%; } | |
| #cover::after { content: ""; position: absolute; inset: 0; | |
| background: linear-gradient(transparent 40%, rgba(11,14,20,.92)); } | |
| #cover > * { position: relative; z-index: 1; } | |
| #cover button { padding: 14px 52px; background: rgba(11,14,20,.75); font-size: 1.02rem; | |
| letter-spacing: .3em; text-indent: .3em; } | |
| .ending { text-align: center; } | |
| .ending h2 { color: var(--gold); letter-spacing: .25em; margin: 20vh 0 1.4em; font-weight: 500; } | |
| .ending p { color: var(--text-dim); } | |
| .ending button { margin: 3.5em auto 0; display: block; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="progress-wrap"><div id="progress-bar"></div></div> | |
| <div id="progress-label"></div> | |
| <div id="browse-ribbon">μ§λμ¨ νμ΄μ§ β μ€λ₯Έμͺ½μ λλ¬ μ§κΈμΌλ‘</div> | |
| <div id="cover"> | |
| <button onclick="start()" id="start-btn">λ°€μ μ°λ€</button> | |
| </div> | |
| <div id="stage"><div id="page"></div></div> | |
| <div id="nav-left" class="nav-zone" title="μ΄μ "></div> | |
| <div id="nav-right" class="nav-zone" title="λ€μ"></div> | |
| <div id="hint-left" class="nav-hint">β μ΄μ </div> | |
| <div id="hint-right" class="nav-hint">λ€μ βΈ</div> | |
| <div id="chat-wrap"> | |
| <div class="chat-inner"> | |
| <div id="chat-log"></div> | |
| <div id="chat-input-row"> | |
| <input id="chat-input" placeholder="κ·Έλ μκ² νκ³ μΆμ λ§β¦" autocomplete="off"> | |
| <button onclick="sendChat()" id="chat-send">λ§νλ€</button> | |
| <button onclick="endChat()" id="chat-end" title="λνλ₯Ό μ λλ€">β¦κ·Έλ§</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let SID = null, STEP = null, lastEpisode = null, busy = false; | |
| let history = [], cursor = -1; // λ·° νμ€ν 리 β {html, progress, ep} (μ½κΈ° μ μ© λλμ보기) | |
| let pendingStep = null; // λ€μ λκΉμ νμν step (μ ν κ²°κ³Ό λ€ Β· μ°μΆ μ»· λ€ λ³Έλ¬Έ) | |
| let chatOpen = false; | |
| const $ = (id) => document.getElementById(id); | |
| const esc = (s) => (s || '').replace(/&/g,'&').replace(/</g,'<'); | |
| const api = async (path, body) => { | |
| const res = await fetch('/api' + path, { | |
| method: body === undefined ? 'GET' : 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: body === undefined ? undefined : JSON.stringify(body || {}), | |
| }); | |
| if (!res.ok) throw new Error((await res.json()).detail || res.status); | |
| return res.json(); | |
| }; | |
| /* ββ μμ ββ */ | |
| let RESUME = null; // {sid, step} β μ μ₯λ μΈμ 볡μλΆ | |
| (async () => { | |
| try { | |
| const m = await api('/meta'); | |
| $('cover').style.backgroundImage = `url("${encodeURI(m.cover_image)}")`; | |
| } catch (e) {} | |
| const saved = localStorage.getItem('carmilla_sid'); | |
| if (saved) { | |
| try { | |
| const d = await api(`/session/${saved}`); | |
| RESUME = {sid: saved, step: d.step}; | |
| $('start-btn').textContent = 'λ°€μ μλλ€'; // μ΄μ΄ μ½κΈ° | |
| } catch (e) { | |
| localStorage.removeItem('carmilla_sid'); // λ§λ£Β·μ½ν μΈ κ°±μ β μλ‘ μμ | |
| } | |
| } | |
| })(); | |
| async function start() { | |
| $('start-btn').disabled = true; | |
| let step; | |
| if (RESUME) { | |
| SID = RESUME.sid; step = RESUME.step; | |
| } else { | |
| const data = await api('/session', {}); | |
| SID = data.session_id; | |
| localStorage.setItem('carmilla_sid', SID); | |
| step = data.step; | |
| } | |
| $('cover').remove(); | |
| document.body.classList.add('show-hints'); | |
| setTimeout(() => document.body.classList.remove('show-hints'), 5000); | |
| showStep(step, true); | |
| } | |
| /* ββ λ λ ββ */ | |
| function block(b) { | |
| if (b.type === 'dialogue_center') return `<div class="center-line fade">${esc(b.text)}</div>`; | |
| if (b.type === 'character_dialogue') | |
| return `<div class="char-line fade"><span class="spk">${esc(b.speaker)}</span>β${esc(b.text)}β</div>`; | |
| if (b.type === 'stage_direction') return `<div class="stage-dir fade">( ${esc(b.text)} )</div>`; | |
| return `<div class="prose fade">${esc(b.text)}</div>`; | |
| } | |
| function pageHtml(step, fresh) { | |
| const c = step.card; | |
| let html = ''; | |
| if (fresh || c.episode !== lastEpisode) html += `<div class="ep-header fade">${esc(c.episode_title || c.episode)}</div>`; | |
| html += `<div class="card-title fade">${esc(c.title)}</div>`; | |
| html += step.narration.map(block).join(''); | |
| html += `<div id="choice-area">${choicesHtml(step)}</div>`; | |
| return html; | |
| } | |
| function choicesHtml(step) { | |
| let h = step.choices.map((ch, i) => | |
| `<button onclick="choose(${i})">${esc(ch.label)}</button>`).join(''); | |
| if (step.can_chat) | |
| h += `<button class="chat-open" onclick="openChat()">β¦μ ννκΈ° μ μ, κ·Έλ μκ² λ§μ 건λ€</button>`; | |
| return h; | |
| } | |
| function setProgress(p, epLabel) { | |
| if (!p) return; | |
| $('progress-bar').style.width = (p.index / p.total * 100).toFixed(1) + '%'; | |
| $('progress-label').textContent = `${epLabel || ''} Β· ${p.index} / ${p.total}`; | |
| } | |
| function showStep(step, fresh) { | |
| STEP = step; pendingStep = null; | |
| if (step.ending) return renderEnding(step.ending); | |
| if (step.image) { // μ°μΆ μ»· = μ체 λ²νΈλ₯Ό κ°μ§ λ 립 νμ΄μ§, λ³Έλ¬Έμ λ€μ λκΉμ | |
| pushCutPage(step); | |
| pendingStep = {...step, image: null}; | |
| return; | |
| } | |
| pushPage(step, fresh); | |
| } | |
| function pushCutPage(step) { // μ΄λ―Έμ§ νμ΄μ§ λ²νΈ = λ³Έλ¬Έ λ²νΈ - 1 (μλ² κ³μ½) | |
| const html = `<div class="page-cut"><img src="${encodeURI(step.image)}" alt="">` + | |
| `<div class="cut-caption">${esc(step.card.title)}</div></div>`; | |
| pushEntry(html, {index: step.progress.index - 1, total: step.progress.total}, | |
| step.card.episode_title); | |
| } | |
| function pushPage(step, fresh) { | |
| lastEpisode = step.card.episode; | |
| pushEntry(pageHtml(step, fresh), step.progress, step.card.episode_title); | |
| } | |
| function pushEntry(html, progress, ep) { | |
| $('page').innerHTML = html; | |
| history.push({html, progress, ep}); | |
| cursor = history.length - 1; | |
| document.body.classList.remove('browsing'); | |
| setProgress(progress, ep); | |
| window.scrollTo({top: 0}); | |
| } | |
| function renderEntry(i) { | |
| const h = history[i]; | |
| $('page').innerHTML = h.html; | |
| document.body.classList.toggle('browsing', i < history.length - 1); | |
| setProgress(h.progress, h.ep); | |
| window.scrollTo({top: 0}); | |
| } | |
| /* ββ λ΄λΉκ²μ΄μ (μ’/μ° ν΄λ¦) ββ */ | |
| $('nav-right').onclick = () => go(+1); | |
| $('nav-left').onclick = () => go(-1); | |
| document.addEventListener('keydown', (e) => { | |
| if (chatOpen) return; | |
| if (e.key === 'ArrowRight' || e.key === ' ') go(+1); | |
| if (e.key === 'ArrowLeft') go(-1); | |
| }); | |
| async function go(dir) { | |
| if (busy || chatOpen || !SID || STEP?.ending && cursor >= history.length - 1 && dir > 0) return; | |
| if (dir < 0) { // κ³Όκ±° νμ΄μ§ νμ΄λ³΄κΈ° (μ½κΈ° μ μ©) | |
| if (cursor <= 0) return; | |
| cursor--; | |
| renderEntry(cursor); | |
| return; | |
| } | |
| if (cursor < history.length - 1) { // νμ¬λ‘ λ³΅κ· | |
| cursor++; | |
| renderEntry(cursor); | |
| return; | |
| } | |
| if (pendingStep) { // μ ν κ²°κ³Ό νμΈ ν λ€μ μ₯ | |
| const s = pendingStep; pendingStep = null; | |
| showStep(s); | |
| return; | |
| } | |
| if (STEP.choices.length) { // μ νμ΄ νμν νμ΄μ§ | |
| const area = $('choice-area'); | |
| if (area) { area.classList.remove('nudge'); void area.offsetWidth; area.classList.add('nudge'); } | |
| return; | |
| } | |
| busy = true; | |
| try { showStep((await api(`/session/${SID}/advance`, {})).step); } | |
| finally { busy = false; } | |
| } | |
| /* ββ μ ν ββ */ | |
| async function choose(i) { | |
| if (busy || document.body.classList.contains('browsing')) return; | |
| busy = true; | |
| try { | |
| const data = await api(`/session/${SID}/choose`, {index: i}); | |
| if (data.picked.result) { | |
| $('choice-area').outerHTML = | |
| `<div class="result-block fade">${esc(data.picked.result)}</div>`; | |
| history[cursor].html = $('page').innerHTML; // μ€λ μ· κ°±μ (μ ν λ°μ) | |
| pendingStep = data.step; // μ°μΈ‘ ν΄λ¦μΌλ‘ λ€μ μ₯ | |
| document.body.classList.add('show-hints'); | |
| setTimeout(() => document.body.classList.remove('show-hints'), 2500); | |
| } else { | |
| showStep(data.step); | |
| } | |
| } finally { busy = false; } | |
| } | |
| /* ββ ν¬μΌ μ±ν ββ */ | |
| async function openChat() { | |
| if (document.body.classList.contains('browsing')) return; | |
| await api(`/session/${SID}/pocket/open`, {}); | |
| chatOpen = true; | |
| $('chat-wrap').classList.add('open'); | |
| $('chat-log').innerHTML = ''; | |
| addMsg('system', 'κ·Έλ κ° κ³ μν μ΄μͺ½μ λ°λΌλ³Έλ€.'); | |
| $('chat-input').focus(); | |
| } | |
| function addMsg(who, text) { | |
| $('chat-log').insertAdjacentHTML('beforeend', `<div class="msg ${who} fade">${esc(text)}</div>`); | |
| $('chat-log').scrollTop = $('chat-log').scrollHeight; | |
| } | |
| async function sendChat() { | |
| const input = $('chat-input'); | |
| const text = input.value.trim(); | |
| if (!text || busy) return; | |
| busy = true; input.value = ''; | |
| addMsg('laura', text); | |
| addMsg('system', 'β¦'); | |
| const typing = $('chat-log').lastElementChild; | |
| $('chat-send').disabled = true; | |
| try { | |
| const t = await api(`/session/${SID}/pocket/turn`, {text}); | |
| typing.remove(); | |
| addMsg('carmilla', t.reply); | |
| if (t.converged) { | |
| setTimeout(() => addMsg('system', 'β¦μ₯λ©΄μ΄ μ‘°μ©ν λ€μμΌλ‘ κΈ°μ΄λ€.'), 1200); | |
| setTimeout(() => closeChatUI(), 3400); // λ΅λ³μ μ½μ μ¬μ΄μ μ€λ€ | |
| } | |
| } catch (e) { | |
| typing.remove(); | |
| addMsg('system', '(λ°€λ°λμ΄ λ§μ μΌμΌ°λ€ β λ€μ μλ)'); | |
| } finally { $('chat-send').disabled = false; busy = false; } | |
| } | |
| async function endChat() { // μ μ κ° λ¨Όμ μ κΈ° | |
| if (busy) return; | |
| await api(`/session/${SID}/pocket/close`, {}); | |
| closeChatUI(); | |
| } | |
| function closeChatUI() { | |
| chatOpen = false; | |
| $('chat-wrap').classList.remove('open'); | |
| } | |
| $('chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChat(); }); | |
| /* ββ μλ© ββ */ | |
| function renderEnding(end) { | |
| setProgress(STEP.progress, ''); | |
| $('progress-label').textContent = ''; | |
| $('page').innerHTML = `<div class="ending fade"> | |
| <h2>${esc(end.label)}</h2><p>${esc(end.description)}</p> | |
| <button onclick="restart()">λ€μ, μ²μμ λ°€μΌλ‘</button></div>`; | |
| history.push({html: $('page').innerHTML, progress: STEP.progress, ep: ''}); | |
| cursor = history.length - 1; | |
| } | |
| function restart() { // μλ© ν μ¬μμ β μ μ₯ μΈμ μ λ²λ¦¬κ³ μ²μλΆν° | |
| localStorage.removeItem('carmilla_sid'); | |
| location.reload(); | |
| } | |
| </script> | |
| </body> | |
| </html> | |