interactive-chat / web /index.html
Junhoee's picture
Upload 140 files
c609294 verified
Raw
History Blame Contribute Delete
30.3 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;
}
/* ── μž₯μ†Œ λ°°κ²½ (λΉ„μ£Όμ–Ό 노벨) β€” μ„œλ²„κ°€ νŽ˜μ΄μ§€λ§ˆλ‹€ μ§€μ •, ν¬λ‘œμŠ€νŽ˜μ΄λ“œ ── */
.bg-layer { position: fixed; inset: 0; z-index: -2; pointer-events: none;
background-size: cover; background-position: center;
opacity: 0; transition: opacity 1.6s ease;
filter: brightness(.5) saturate(.85); }
.bg-layer.show { opacity: .55; }
#bg-dim { position: fixed; inset: 0; z-index: -1; pointer-events: none;
background: linear-gradient(rgba(11,14,20,.3) 0%, rgba(11,14,20,.55) 45%,
rgba(11,14,20,.82) 100%); }
/* ── μ§„ν–‰ λ°” + μž₯ 라벨 ── */
#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; }
#chapter-label { position: fixed; top: 10px; left: 50%; transform: translateX(-50%);
color: var(--gold-dim); font-size: .72rem; letter-spacing: .3em;
text-indent: .3em; opacity: .8; z-index: 40; white-space: nowrap; }
/* ── λ³Έλ¬Έ β€” ν™”λ©΄ λ‹¨μœ„ 읽기: ν•œ 화면은 뷰포트 μ•ˆμ— λ“€μ–΄κ°„λ‹€ (슀크둀 λŒ€μ‹  νŽ˜μ΄μ§€ λ„˜κΉ€) ── */
#stage { max-width: 680px; margin: 0 auto; padding: 54px 24px 96px;
min-height: 100%; position: relative; }
#page-measure { position: absolute; left: 24px; right: 24px; top: 54px;
visibility: hidden; pointer-events: none; }
.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; }
/* ── VN μŠ€ν…Œμ΄μ§€ β€” μ„ νƒμ§€Β·μ±„νŒ…μ΄ μžˆλŠ” νŽ˜μ΄μ§€: μ΄ˆμƒμ΄ ν”„λ ˆμž„μ„ μ±„μš°κ³ 
ν•˜λ‹¨ νŒ¨λ„μ— μ΄λ¦„ν‘œΒ·λŒ€μ‚¬Β·μ„ νƒμ§€κ°€ 겹쳐 μ–Ήνž˜ (μ°Έκ³ : ν”Œλž˜μ΄ 이미지/7.png) ── */
.vn-stage { position: relative; margin: 1.2em auto 0; height: min(76vh, 860px);
overflow: hidden; border: 1px solid rgba(201,168,106,.28);
box-shadow: 0 0 80px rgba(0,0,0,.9); }
.vn-stage-art { position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; object-position: top center; }
.vn-stage::after { content: ""; position: absolute; inset: 0;
background: linear-gradient(rgba(11,14,20,.1) 0%, transparent 20%,
transparent 46%, rgba(11,14,20,.9) 88%); }
.vn-stage-panel { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1;
padding: 0 14px 14px; }
/* 사각 말풍선 β€” 쒌츑 ν™”μžλͺ… Β· 우츑 λŒ€μ‚¬ */
.vn-box { display: flex; gap: 14px; align-items: flex-start; text-align: left;
background: rgba(11,14,20,.82); border: 1px solid rgba(201,168,106,.45);
border-radius: 2px; padding: 14px 18px; animation: fadein .5s ease both; }
.vn-box .spk { flex: 0 0 auto; color: var(--gold); font-size: .88rem;
letter-spacing: .1em; padding-top: 3px; max-width: 7em;
word-break: keep-all; }
.vn-box .lines { flex: 1; min-width: 0; }
.vn-box .vn-line { font-size: 1rem; color: #e5dfd2; line-height: 1.9;
word-break: keep-all; margin: .2em 0; }
.vn-box.narr .vn-line { color: var(--text-dim); font-style: normal; } /* μ§€λ¬Έ μ „ν™˜ 단계 */
.vn-stage-panel #choice-area { margin: 12px auto 0; max-width: 470px; }
.vn-stage-panel #choice-area button { padding: 12px 10px; background: rgba(11,14,20,.45); }
.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; } }
/* ── 선택지: λŒ€ν™”ν•˜λ“― β€” β€Ί 마컀 + κ°€λŠ” ꡬ뢄선 리슀트 (μ°Έκ³ : ν”Œλž˜μ΄ 이미지/7.png) ── */
#choice-area { margin: 8vh auto 0; max-width: 520px; display: flex;
flex-direction: column; gap: 0; }
#choice-area button { background: transparent; border: none; border-radius: 0;
border-top: 1px solid rgba(201,168,106,.22);
text-align: left; padding: 17px 12px; font-size: 1rem; }
#choice-area button:last-child { border-bottom: 1px solid rgba(201,168,106,.22); }
#choice-area button::before { content: "β€Ί"; color: var(--gold-dim);
margin-right: .8em; font-size: 1.05rem; }
#choice-area button:hover { color: var(--gold); background: rgba(201,168,106,.06); }
#choice-area button.chat-open { border-style: solid; }
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; }
/* ── μ±„νŒ… (ν•˜λ‹¨ λ“œλ‘œμ–΄ μœ μ§€) ── */
#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); }
/* ── ν‘œμ§€Β·μ—”λ”© ──
원본(1086Γ—1448)을 화면에 cover둜 늘리면 해상도가 κΉ¨μ§„λ‹€ β€”
λΈ”λŸ¬ λ°±λ“œλ‘­ + 원본 크기 μ΄ν•˜λ‘œλ§Œ μ„ λͺ…ν•˜κ²Œ 쀑앙 배치 (μΈνŠΈλ‘œλ„ 동일 기법) */
#cover { position: fixed; inset: 0; z-index: 70; display: flex; flex-direction: column;
align-items: center; justify-content: flex-end; padding-bottom: 9vh;
background: var(--ink); overflow: hidden; }
#cover-blur { position: absolute; inset: -48px; background-size: cover;
background-position: center 20%; filter: blur(30px) brightness(.45); }
#cover-art { position: absolute; left: 50%; top: 46%; transform: translate(-50%, -50%);
max-width: min(92vw, 1086px); max-height: min(84vh, 1448px);
box-shadow: 0 0 90px rgba(0,0,0,.85);
border: 1px solid rgba(201,168,106,.2); }
#cover::after { content: ""; position: absolute; inset: 0; z-index: 1;
background: linear-gradient(transparent 55%, rgba(11,14,20,.9)); }
#cover button { position: relative; z-index: 2; padding: 14px 52px;
background: rgba(11,14,20,.75); font-size: 1.02rem;
letter-spacing: .3em; text-indent: .3em; }
/* ── 인트둜 μ‹œν€€μŠ€ (μž‘ν’ˆ μ†Œκ°œ β†’ 인물 μ†Œκ°œ β†’ 1μž₯ μ‹œμž‘) ── */
#intro { position: fixed; inset: 0; z-index: 68; background: var(--ink);
display: none; align-items: center; justify-content: center;
cursor: pointer; overflow: hidden; }
#intro.open { display: flex; }
#intro-blur { position: absolute; inset: -48px; background-size: cover;
background-position: center; filter: blur(30px) brightness(.4); }
#intro-art { position: relative; max-width: min(94vw, 1086px); max-height: min(92vh, 1491px);
object-fit: contain; box-shadow: 0 0 90px rgba(0,0,0,.85);
border: 1px solid rgba(201,168,106,.2); animation: fadein 1s ease both; }
#intro-hint { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
color: var(--text-dim); font-size: .72rem; letter-spacing: .25em;
animation: fadein 1.6s ease both 1s; }
.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 class="bg-layer" id="bgA"></div>
<div class="bg-layer" id="bgB"></div>
<div id="bg-dim"></div>
<div id="progress-wrap"><div id="progress-bar"></div></div>
<div id="progress-label"></div>
<div id="chapter-label"></div>
<div id="browse-ribbon">μ§€λ‚˜μ˜¨ νŽ˜μ΄μ§€ β€” 였λ₯Έμͺ½μ„ 눌러 μ§€κΈˆμœΌλ‘œ</div>
<div id="cover">
<div id="cover-blur"></div>
<img id="cover-art" alt="μΉ΄λ₯΄λ°€λΌ ν‘œμ§€">
<button onclick="start()" id="start-btn">밀을 μ—°λ‹€</button>
</div>
<div id="intro">
<div id="intro-blur"></div>
<img id="intro-art" alt="">
<div id="intro-hint">β€” λˆŒλŸ¬μ„œ 계속 β€”</div>
</div>
<div id="stage"><div id="page"></div><div id="page-measure" aria-hidden="true"></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, busy = false;
let history = [], cursor = -1; // λ·° νžˆμŠ€ν† λ¦¬ β€” {html, progress, ep, bg} (읽기 μ „μš© λ˜λŒμ•„λ³΄κΈ°)
let pendingStep = null; // λ‹€μŒ λ„˜κΉ€μ— ν‘œμ‹œν•  step (선택 κ²°κ³Ό λ’€ Β· μ—°μΆœ μ»· λ’€ λ³Έλ¬Έ)
let pendingScreens = []; // ν˜„μž¬ μ„œλ²„ νŽ˜μ΄μ§€μ˜ 남은 ν™”λ©΄λ“€ (뷰포트 λΆ„ν• λΆ„)
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} β€” μ €μž₯된 μ„Έμ…˜ 볡원뢄
let INTRO = []; // 인트둜 μ‹œν€€μŠ€ 이미지 (μƒˆ μ‹œμž‘μ—λ§Œ, μ„œλ²„ /meta)
(async () => {
try {
const m = await api('/meta');
$('cover-blur').style.backgroundImage = `url("${encodeURI(m.cover_image)}")`;
$('cover-art').src = encodeURI(m.cover_image);
INTRO = m.intro_images || [];
} 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;
const fresh = !RESUME;
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();
if (fresh && INTRO.length) return showIntro(0, step); // μƒˆ μ‹œμž‘μ—λ§Œ 인트둜
beginReading(step);
}
function beginReading(step) {
document.body.classList.add('show-hints');
setTimeout(() => document.body.classList.remove('show-hints'), 5000);
showStep(step, true);
}
function showIntro(i, step) { // μž‘ν’ˆ μ†Œκ°œ β†’ 인물 μ†Œκ°œ β†’ 1μž₯ μ‹œμž‘ β†’ λ³Έλ¬Έ
const el = $('intro');
$('intro-blur').style.backgroundImage = `url("${encodeURI(INTRO[i])}")`;
$('intro-art').src = encodeURI(INTRO[i]);
el.classList.add('open');
el.onclick = () => {
if (i + 1 < INTRO.length) showIntro(i + 1, step);
else { el.classList.remove('open'); el.onclick = null; beginReading(step); }
};
}
/* ── μž₯μ†Œ λ°°κ²½ (ν¬λ‘œμŠ€νŽ˜μ΄λ“œ) ── */
let bgFlip = false, bgCurrent = null;
function setBackground(url) {
if ((url || null) === bgCurrent) return;
bgCurrent = url || null;
const nxt = $(bgFlip ? 'bgA' : 'bgB'), prv = $(bgFlip ? 'bgB' : 'bgA');
bgFlip = !bgFlip;
if (url) { nxt.style.backgroundImage = `url("${encodeURI(url)}")`; nxt.classList.add('show'); }
else nxt.classList.remove('show');
prv.classList.remove('show');
}
/* ── λ Œλ” ── */
const PLAYER_SPEAKERS = new Set(['μœ μ €', '둜라', 'λ‚˜']); // 1인칭 β€” μ΄ˆμƒ 없이 였λ₯Έμͺ½ μ •λ ¬
function block(b) {
if (b.type === 'dialogue_center') return `<div class="center-line fade">${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 charLines(g) { // μ„œμ‚¬ 속 λ°œν™” β€” 쒌 ν™”μžλͺ… Β· 우 λŒ€μ‚¬ (μ±…μ˜ 흐름)
const name = PLAYER_SPEAKERS.has(g.spk) ? 'λ‚˜' : g.spk;
return g.lines.map(t =>
`<div class="char-line fade"><span class="spk">${esc(name)}</span>β€œ${esc(t)}”</div>`).join('');
}
function speechBox(name, lines, mode) { // 사각 말풍선: 쒌 ν™”μžλͺ… Β· 우 λŒ€μ‚¬. mode: speech|narr|center
let h = `<div class="vn-box${mode === 'narr' ? ' narr' : ''}">`;
if (name) h += `<span class="spk">${esc(name)}</span>`;
h += `<div class="lines">`;
for (const t of lines)
h += `<div class="vn-line">${mode === 'narr' ? esc(t) : 'β€œ' + esc(t) + '”'}</div>`;
return h + `</div></div>`;
}
function stageScreen(art, alt, boxHtml, choices) { // μ „λ©΄ 이미지 + 말풍선 (+선택지)
return `<div class="vn-stage">` +
`<img class="vn-stage-art" src="${encodeURI(art)}" alt="${esc(alt)}">` +
`<div class="vn-stage-panel">${boxHtml}` +
`<div id="choice-area">${choices}</div></div></div>`;
}
function groupToStep(g) { // μŠ€ν…Œμ΄μ§€ νƒ‘μŠΉ κ·Έλ£Ή β†’ 말풍선 단계
if (g.lines)
return {mode: 'speech', name: PLAYER_SPEAKERS.has(g.spk) ? 'λ‚˜' : g.spk, lines: g.lines};
if (g.block.type === 'dialogue_center')
return {mode: 'center', name: null, lines: [g.block.text]};
return {mode: 'narr', name: null, lines: [g.block.text]};
}
function groupNarration(blocks) {
// μ—°μ†λœ 같은 ν™”μžμ˜ λ°œν™”λŠ” ν•œ 그룹으둜. card = μ†Œμ† μΉ΄λ“œ (μŠ€ν…Œμ΄μ§€ νƒ‘μŠΉ 경계)
const groups = [];
for (let i = 0; i < blocks.length;) {
const b = blocks[i];
if (b.type !== 'character_dialogue') { groups.push({card: b.card, block: b}); i++; continue; }
const spk = b.speaker, lines = [];
let portrait = null;
while (i < blocks.length && blocks[i].type === 'character_dialogue'
&& blocks[i].speaker === spk) {
lines.push(blocks[i].text);
portrait = portrait || blocks[i].portrait;
i++;
}
groups.push({card: b.card, spk, portrait, lines});
}
return groups;
}
/* μ„œλ²„ νŽ˜μ΄μ§€ β†’ ν™”λ©΄λ“€ {html, prog} (슀크둀 μ—†λŠ” λ„˜κΉ€).
ν™”(θ©±) ν‘œκΈ°λŠ” 상단 쀑앙 μƒμ‹œ 라벨이 λ‹΄λ‹Ή β€” λ³Έλ¬Έ ν—€λ”λŠ” λ„£μ§€ μ•ŠλŠ”λ‹€.
- μ΄ˆμƒ ν™”μž(μΉ΄λ₯΄λ°€λΌΒ·μ•„버지)의 λ°œν™” = μ „λ©΄ μŠ€ν…Œμ΄μ§€. 같은 μΉ΄λ“œ μ•ˆμ˜ 후속 μ§€λ¬ΈΒ·
λ¬΄μ΄ˆμƒ λ°œν™”Β·μ€‘μ•™ λŒ€μ‚¬λŠ” κ·Έ 이미지 μœ„ 말풍선 λ‹¨κ³„λ‘œ νƒ‘μŠΉ (원문 μˆœμ„œ 보쑴)
- μ§μ „μ˜ 짧은 λ¦¬λ“œμΈ(role=lead_in, 같은 μΉ΄λ“œ)은 μŠ€ν…Œμ΄μ§€μ˜ 첫 단계(μ§€λ¬Έ λ°•μŠ€)둜
- λ§ˆμ§€λ§‰ μž₯면이 μŠ€ν…Œμ΄μ§€λ©΄ μ„ νƒμ§€λŠ” μ΅œμ’… 단계에, μ•„λ‹ˆλ©΄ ν…μŠ€νŠΈ ν™”λ©΄ ν•˜λ‹¨μ—
- ν…μŠ€νŠΈλŠ” μ„Έκ·Έλ¨ΌνŠΈλ³„ μ‹€μΈ‘ κ· ν˜• λΆ„ν• λ‘œ 화면을 μ΅œλŒ€ν•œ μ±„μš΄λ‹€ */
function buildScreens(step) {
const groups = groupNarration(step.narration);
const choices = choicesHtml(step);
const isP = (g) => g.lines && g.portrait && !PLAYER_SPEAKERS.has(g.spk);
// 1) μž₯λ©΄ 쑰립: text(κ·Έλ£Ή λ‚˜μ—΄) | stage(μ΄ˆμƒ + 말풍선 단계듀)
const scenes = [];
for (let i = 0; i < groups.length;) {
const g = groups[i];
if (!isP(g)) {
let last = scenes[scenes.length - 1];
if (!last || last.type !== 'text') { last = {type: 'text', groups: []}; scenes.push(last); }
last.groups.push(g); i++;
continue;
}
const steps = [];
// λ¦¬λ“œμΈ β†’ 첫 λ‹¨κ³„λ‘œ 이동. 단, λ¦¬λ“œμΈμ΄ μ•ž ν…μŠ€νŠΈ μž₯면의 "μœ μΌν•œ" λ‚΄μš©μΌ λ•Œλ§Œ β€”
// μ•žμ— λ‹€λ₯Έ 지문이 있으면 κ·Έ 흐름에 합쳐 λ‘λŠ” 게 μžμ—°μŠ€λŸ½λ‹€ (κ³ μ•„ 화면일 λ•Œλ§Œ ꡬ제)
const prev = scenes[scenes.length - 1];
if (prev && prev.type === 'text' && prev.groups.length === 1) {
const lg = prev.groups[0];
if (lg.block && lg.block.role === 'lead_in' && lg.card === g.card) {
scenes.pop();
steps.push({mode: 'narr', name: null, lines: [lg.block.text]});
}
}
steps.push({mode: 'speech', name: g.spk, lines: g.lines});
i++;
while (i < groups.length && !isP(groups[i]) && groups[i].card === g.card) {
steps.push(groupToStep(groups[i])); // 같은 μΉ΄λ“œ β€” 이미지에 νƒ‘μŠΉ
i++;
}
scenes.push({type: 'stage', g, steps});
}
// 2) λ Œλ”: ν…μŠ€νŠΈ κ· ν˜• λΆ„ν•  + μŠ€ν…Œμ΄μ§€ 단계 ν™”λ©΄
const meas = $('page-measure');
const budget = Math.max((window.innerHeight || 900) - 130, 320);
const measure = (html) => { meas.innerHTML = html; return meas.offsetHeight; };
const screens = [];
const flushSeg = (seg) => {
if (!seg.length) return;
const total = seg.reduce((s, u) => s + u.h, 0);
const k = Math.max(1, Math.ceil(total / budget));
const target = total / k;
let cur = '', acc = 0, cuts = 0;
seg.forEach((u, i) => {
cur += u.html; acc += u.h;
const remainUnits = seg.length - i - 1;
const remainCuts = (k - 1) - cuts;
if (cuts < k - 1 && remainUnits > 0) {
const boundary = target * (cuts + 1);
const nextAcc = acc + seg[i + 1].h;
if (Math.abs(acc - boundary) <= Math.abs(nextAcc - boundary)
|| remainUnits === remainCuts) {
screens.push(cur); cur = ''; cuts++;
}
}
});
if (cur) screens.push(cur);
};
const lastScene = scenes[scenes.length - 1];
scenes.forEach((sc) => {
if (sc.type === 'stage') {
sc.steps.forEach((s, si) => {
const withChoices = (sc === lastScene && si === sc.steps.length - 1) ? choices : '';
screens.push(stageScreen(sc.g.portrait, sc.g.spk,
speechBox(s.name, s.lines, s.mode), withChoices));
});
} else {
const seg = sc.groups.map((g) => {
const html = g.lines ? charLines(g) : block(g.block);
return {html, h: measure(html)};
});
if (sc === lastScene && choices) {
const area = `<div id="choice-area">${choices}</div>`;
seg.push({html: area, h: measure(area)});
}
flushSeg(seg);
}
});
meas.innerHTML = '';
if (!screens.length) screens.push('');
return screens.map((html) => ({html, prog: step.progress}));
}
/* μ—°μΆœ μ»·(이미지 μΉ΄λ“œ) β†’ μΊ”λ²„μŠ€ ν™”λ©΄λ“€: 이미지 μœ„ μ§€λ¬ΈΒ·λ°œν™” 말풍선 단계 β†’ 선택지.
단독 μΊ‘μ…˜ 화면은 두지 μ•ŠλŠ”λ‹€ (같은 이미지가 λ°”λ‘œ μΊ”λ²„μŠ€λ‘œ μ΄μ–΄μ§€λ―€λ‘œ ꡰ더더기) */
function buildCutScreens(step) {
const steps = groupNarration(step.narration).map(groupToStep);
const choices = choicesHtml(step);
if (!steps.length) steps.push({mode: 'narr', name: null, lines: []});
return steps.map((s, i) => ({
html: stageScreen(step.image, step.card.title,
s.lines.length ? speechBox(s.name, s.lines, s.mode) : '',
i === steps.length - 1 ? choices : ''),
prog: step.progress,
}));
}
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 = `${p.index} / ${p.total}`;
$('chapter-label').textContent = epLabel || ''; // ν˜„μž¬ μž₯(θ©±) β€” 상단 쀑앙 μƒμ‹œ ν‘œμ‹œ
}
function showStep(step, fresh) {
STEP = step; pendingStep = null; pendingScreens = [];
if (step.ending) return renderEnding(step.ending);
const screens = step.image ? buildCutScreens(step) : buildScreens(step);
const first = screens.shift();
pushEntry(first.html, first.prog, step.card.episode_title, step.background);
pendingScreens = screens; // λ‚˜λ¨Έμ§€λŠ” νŽ˜μ΄μ§€ λ„˜κΉ€μœΌλ‘œ
}
function pushEntry(html, progress, ep, bg) {
$('page').innerHTML = html;
history.push({html, progress, ep, bg});
cursor = history.length - 1;
document.body.classList.remove('browsing');
setProgress(progress, ep);
setBackground(bg);
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);
setBackground(h.bg);
window.scrollTo({top: 0});
}
/* ── λ‚΄λΉ„κ²Œμ΄μ…˜ (쒌/우 클릭) ── */
$('nav-right').onclick = () => go(+1);
$('nav-left').onclick = () => go(-1);
document.addEventListener('keydown', (e) => {
if (chatOpen) return;
const intro = $('intro');
if (intro && intro.classList.contains('open')) { // 인트둜 쀑 β€” β†’/슀페이슀둜 λ„˜κΉ€
if ((e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') && intro.onclick)
intro.onclick();
return;
}
if (e.key === 'ArrowRight' || e.key === ' ') go(+1);
if (e.key === 'ArrowLeft') go(-1);
});
async function go(dir) {
if (busy || chatOpen || !SID || !STEP
|| 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 (pendingScreens.length) { // 같은 μ„œλ²„ νŽ˜μ΄μ§€μ˜ λ‹€μŒ ν™”λ©΄
const s = pendingScreens.shift();
pushEntry(s.html, s.prog, STEP.card.episode_title, STEP.background);
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 = '';
setBackground(null); // μ—”λ”© β€” 배경을 κ±·κ³  μ •μ μœΌλ‘œ
$('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: '', bg: null});
cursor = history.length - 1;
}
function restart() { // μ—”λ”© ν›„ μž¬μ‹œμž‘ β€” μ €μž₯ μ„Έμ…˜μ„ 버리고 μ²˜μŒλΆ€ν„°
localStorage.removeItem('carmilla_sid');
location.reload();
}
</script>
</body>
</html>