Spaces:
Sleeping
Sleeping
| <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,'&').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} β μ μ₯λ μΈμ 볡μλΆ | |
| 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> | |