interactive-chat / web /index.html
Junhoee's picture
Upload 21 files
c5ec122 verified
Raw
History Blame Contribute Delete
17.6 kB
<!DOCTYPE html>
<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,'&amp;').replace(/</g,'&lt;');
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>