Junhoee commited on
Commit
f8a6c42
Β·
verified Β·
1 Parent(s): 222f32e

Upload 57 files

Browse files
Files changed (2) hide show
  1. engine/schemas/chapter.py +2 -1
  2. web/index.html +73 -54
engine/schemas/chapter.py CHANGED
@@ -49,7 +49,8 @@ class Chapter(BaseModel):
49
  canon_rules: list[str] = Field(default_factory=list)
50
  r_cards: list[RCardRef] = Field(default_factory=list)
51
  pocket_max_turns: int = 6 # κ°œμž… 포켓 μžμœ μ±„νŒ… ν„΄ μƒν•œ (κ°•μ œ 수렴)
52
- page_target_chars: int = 500 # νŽ˜μ΄μ§€ 병합 λͺ©ν‘œ κΈ€μž 수 (λ„˜μœΌλ©΄ λ‹€μŒ μΉ΄λ“œλΆ€ν„° μƒˆ νŽ˜μ΄μ§€)
 
53
 
54
 
55
  class ChapterFile(BaseModel):
 
49
  canon_rules: list[str] = Field(default_factory=list)
50
  r_cards: list[RCardRef] = Field(default_factory=list)
51
  pocket_max_turns: int = 6 # κ°œμž… 포켓 μžμœ μ±„νŒ… ν„΄ μƒν•œ (κ°•μ œ 수렴)
52
+ page_target_chars: int = 850 # νŽ˜μ΄μ§€ 병합 λͺ©ν‘œ κΈ€μž 수 β€” ν…μŠ€νŠΈ 화면을 가득 μ±„μš°λŠ” κΈ°μ€€
53
+ # (ν”„λ‘ νŠΈκ°€ 뷰포트 μ‹€μΈ‘μœΌλ‘œ λ‹€μ‹œ ν™”λ©΄ λΆ„ν• ν•˜λ―€λ‘œ, 크게 μž‘μ„μˆ˜λ‘ 화면이 μ•Œμ°¨κ²Œ μ±„μ›Œμ§„λ‹€)
54
 
55
 
56
  class ChapterFile(BaseModel):
web/index.html CHANGED
@@ -67,29 +67,26 @@
67
  .vn-stage { position: relative; margin: 1.2em auto 0; height: min(76vh, 860px);
68
  overflow: hidden; border: 1px solid rgba(201,168,106,.28);
69
  box-shadow: 0 0 80px rgba(0,0,0,.9); }
70
- .vn-stage.with-lead { height: min(62vh, 720px); } /* λ¦¬λ“œμΈ 지문이 μœ„μ— λΆ™λŠ” ν™”λ©΄ */
71
  .vn-stage-art { position: absolute; inset: 0; width: 100%; height: 100%;
72
  object-fit: cover; object-position: top center; }
73
  .vn-stage::after { content: ""; position: absolute; inset: 0;
74
- background: linear-gradient(rgba(11,14,20,.12) 0%, transparent 22%,
75
- transparent 40%, rgba(11,14,20,.96) 82%); }
76
  .vn-stage-panel { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1;
77
- padding: 0 22px 20px; text-align: center; }
78
- .vn-stage-panel .vn-line { font-size: 1.02rem; line-height: 1.9; margin: .3em 0;
79
- text-shadow: 0 1px 8px rgba(0,0,0,.9); }
80
- .vn-stage-panel #choice-area { margin: 14px auto 0; max-width: 470px; }
81
- .vn-stage-panel #choice-area button { padding: 13px 10px; background: rgba(11,14,20,.35); }
82
- .vn-name { display: inline-block; position: relative; margin-bottom: 14px;
83
- padding: 5px 30px; font-size: .9rem; color: var(--gold);
84
- letter-spacing: .3em; text-indent: .3em;
85
- border: 1px solid rgba(201,168,106,.5); background: rgba(11,14,20,.72); }
86
- .vn-name::before, .vn-name::after {
87
- content: ""; position: absolute; top: 50%; width: 46px; height: 1px;
88
- background: linear-gradient(90deg, transparent, var(--gold-dim)); }
89
- .vn-name::before { right: 100%; margin-right: 10px; }
90
- .vn-name::after { left: 100%; margin-left: 10px; transform: scaleX(-1); }
91
- .vn-line { font-size: 1.06rem; color: #e5dfd2; line-height: 2.05;
92
- word-break: keep-all; margin: .35em 0; }
93
  .stage-dir { color: var(--text-dim); font-style: italic; font-size: .92rem;
94
  text-align: center; margin: 1.4em 0; }
95
  .result-block { margin: 1.6em 0; padding: 1em 1.2em; background: rgba(201,168,106,.05);
@@ -348,12 +345,20 @@ function charLines(g) { // μ„œμ‚¬ 속 λ°œν™” β€” 쒌 ν™”μžλͺ… Β·
348
  `<div class="char-line fade"><span class="spk">${esc(name)}</span>β€œ${esc(t)}”</div>`).join('');
349
  }
350
 
351
- function vnStage(g, choices, withLead) { // μ΄ˆμƒ + μ΄λ¦„ν‘œ + κ·Έ μΊλ¦­ν„°μ˜ λŒ€μ‚¬ (+선택지)
352
- let h = `<div class="vn-stage${withLead ? ' with-lead' : ''} fade">` +
353
- `<img class="vn-stage-art" src="${encodeURI(g.portrait)}" alt="${esc(g.spk)}">` +
354
- `<div class="vn-stage-panel"><div class="vn-name">${esc(g.spk)}</div>`;
355
- for (const t of g.lines) h += `<div class="vn-line">β€œ${esc(t)}”</div>`;
356
- return h + `<div id="choice-area">${choices}</div></div></div>`;
 
 
 
 
 
 
 
 
357
  }
358
 
359
  function groupNarration(blocks) {
@@ -377,9 +382,10 @@ function groupNarration(blocks) {
377
 
378
  /* μ„œλ²„ νŽ˜μ΄μ§€ β†’ ν™”λ©΄λ“€ (슀크둀 μ—†λŠ” λ„˜κΉ€).
379
  - 타이틀은 μ—ν”Όμ†Œλ“œ 첫 νŽ˜μ΄μ§€μ˜ ν™”(θ©±) ν—€λ”λ§Œ β€” μΉ΄λ“œ μ†Œμ œλͺ©μ€ ν‘œμ‹œν•˜μ§€ μ•ŠλŠ”λ‹€ (μ±…μ˜ 흐름)
380
- - οΏ½οΏ½ν…Œμ΄μ§€(μ΄ˆμƒ ν™”λ©΄)λŠ” 이미지+κ·Έ μΊλ¦­ν„°μ˜ λŒ€μ‚¬(+선택지)만 β€” 지문은 μ„žμ§€ μ•ŠλŠ”λ‹€
381
- - μ§€λ¬ΈΒ·λ°œν™”(char-line)λŠ” μ„Έκ·Έλ¨ΌνŠΈ(μŠ€ν…Œμ΄μ§€ 경계 사이)λ³„λ‘œ μ‹€μΈ‘ κ· ν˜• λΆ„ν• :
382
- ν™”λ©΄ 수 k = ceil(총높이/μ˜ˆμ‚°)λ₯Ό λ¨Όμ € μ •ν•˜κ³  κ· λ“± κ²½κ³„μ—μ„œ μ ˆλ‹¨ β€” ν•œ λ¬Έμž₯ κ³ μ•„ ν™”λ©΄ λ°©μ§€ */
 
383
  function buildScreens(step, fresh) {
384
  const c = step.card;
385
  let head = '';
@@ -389,17 +395,15 @@ function buildScreens(step, fresh) {
389
 
390
  const groups = groupNarration(step.narration);
391
  const choices = choicesHtml(step);
392
- let stageIdx = -1; // μΈν„°λž™μ…˜ νŽ˜μ΄μ§€μ˜ λ§ˆμ§€λ§‰ μ΄ˆμƒ λ°œν™”λ§Œ μŠ€ν…Œμ΄μ§€
 
393
  if (step.can_chat || step.choices.length) {
394
- for (let k = groups.length - 1; k >= 0; k--) {
395
- const g = groups[k];
396
- if (g.lines && g.portrait && !PLAYER_SPEAKERS.has(g.spk)) { stageIdx = k; break; }
397
- }
398
  }
399
- const stageIsLast = stageIdx === groups.length - 1;
400
 
401
  const meas = $('page-measure');
402
- const budget = Math.max((window.innerHeight || 900) - 190, 320);
403
  const measure = (html) => { meas.innerHTML = html; return meas.offsetHeight; };
404
 
405
  const screens = [];
@@ -427,28 +431,43 @@ function buildScreens(step, fresh) {
427
  seg = [];
428
  };
429
 
430
- groups.forEach((g, k) => {
431
- if (k === stageIdx) { // μŠ€ν…Œμ΄μ§€ = 독립 ν™”λ©΄ (이미지+λŒ€μ‚¬λ§Œ)
432
- // 직전 λ¦¬λ“œμΈ(role=lead_in, 짧은 λ„μž… μ§€λ¬Έ)은 같은 ν™”λ©΄μ˜ ν”„λ ˆμž„ "λ°– μœ„μͺ½"에 β€”
433
- // 혼자 화면을 μ°¨μ§€ν•˜μ§€λ„, 이미지에 κ²ΉμΉ˜μ§€λ„ μ•Šκ²Œ. ν”„λ ˆμž„μ€ with-lead둜 μΆ•μ†Œ
434
- let leadHtml = '';
435
- const prev = groups[k - 1];
436
- if (prev && prev.block && prev.block.role === 'lead_in'
437
- && seg.length && seg[seg.length - 1].html === block(prev.block)) {
438
- leadHtml = seg.pop().html;
439
- }
440
  flushSeg();
441
- screens.push(leadHtml + vnStage(g, stageIsLast ? choices : '', !!leadHtml));
442
- return;
 
 
443
  }
444
- const html = g.lines ? charLines(g) : block(g.block);
445
- seg.push({html, h: measure(html)});
446
- });
447
- if (choices && !stageIsLast) {
448
- const area = `<div id="choice-area">${choices}</div>`;
449
- seg.push({html: area, h: measure(area)});
450
  }
451
- flushSeg();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  meas.innerHTML = '';
453
 
454
  if (screens.length) screens[0] = head + screens[0]; // ν™”(θ©±) ν—€λ”λŠ” 첫 화면에
 
67
  .vn-stage { position: relative; margin: 1.2em auto 0; height: min(76vh, 860px);
68
  overflow: hidden; border: 1px solid rgba(201,168,106,.28);
69
  box-shadow: 0 0 80px rgba(0,0,0,.9); }
 
70
  .vn-stage-art { position: absolute; inset: 0; width: 100%; height: 100%;
71
  object-fit: cover; object-position: top center; }
72
  .vn-stage::after { content: ""; position: absolute; inset: 0;
73
+ background: linear-gradient(rgba(11,14,20,.1) 0%, transparent 20%,
74
+ transparent 46%, rgba(11,14,20,.9) 88%); }
75
  .vn-stage-panel { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1;
76
+ padding: 0 14px 14px; }
77
+ /* 사각 말풍선 β€” 쒌츑 ν™”μžλͺ… Β· 우츑 λŒ€μ‚¬ */
78
+ .vn-box { display: flex; gap: 14px; align-items: flex-start; text-align: left;
79
+ background: rgba(11,14,20,.82); border: 1px solid rgba(201,168,106,.45);
80
+ border-radius: 2px; padding: 14px 18px; animation: fadein .5s ease both; }
81
+ .vn-box .spk { flex: 0 0 auto; color: var(--gold); font-size: .88rem;
82
+ letter-spacing: .1em; padding-top: 3px; max-width: 7em;
83
+ word-break: keep-all; }
84
+ .vn-box .lines { flex: 1; min-width: 0; }
85
+ .vn-box .vn-line { font-size: 1rem; color: #e5dfd2; line-height: 1.9;
86
+ word-break: keep-all; margin: .2em 0; }
87
+ .vn-box.narr .vn-line { color: var(--text-dim); font-style: normal; } /* μ§€λ¬Έ μ „ν™˜ 단계 */
88
+ .vn-stage-panel #choice-area { margin: 12px auto 0; max-width: 470px; }
89
+ .vn-stage-panel #choice-area button { padding: 12px 10px; background: rgba(11,14,20,.45); }
 
 
90
  .stage-dir { color: var(--text-dim); font-style: italic; font-size: .92rem;
91
  text-align: center; margin: 1.4em 0; }
92
  .result-block { margin: 1.6em 0; padding: 1em 1.2em; background: rgba(201,168,106,.05);
 
345
  `<div class="char-line fade"><span class="spk">${esc(name)}</span>β€œ${esc(t)}”</div>`).join('');
346
  }
347
 
348
+ function speechBox(name, lines, mode) { // 사각 말풍선: 쒌 ν™”μžλͺ… Β· 우 λŒ€μ‚¬. mode: speech|narr|center
349
+ let h = `<div class="vn-box${mode === 'narr' ? ' narr' : ''}">`;
350
+ if (name) h += `<span class="spk">${esc(name)}</span>`;
351
+ h += `<div class="lines">`;
352
+ for (const t of lines)
353
+ h += `<div class="vn-line">${mode === 'narr' ? esc(t) : 'β€œ' + esc(t) + '”'}</div>`;
354
+ return h + `</div></div>`;
355
+ }
356
+
357
+ function stageScreen(g, boxHtml, choices) { // μ „λ©΄ 이미지 + 말풍선 (+선택지)
358
+ return `<div class="vn-stage">` +
359
+ `<img class="vn-stage-art" src="${encodeURI(g.portrait)}" alt="${esc(g.spk)}">` +
360
+ `<div class="vn-stage-panel">${boxHtml}` +
361
+ `<div id="choice-area">${choices}</div></div></div>`;
362
  }
363
 
364
  function groupNarration(blocks) {
 
382
 
383
  /* μ„œλ²„ νŽ˜μ΄μ§€ β†’ ν™”λ©΄λ“€ (슀크둀 μ—†λŠ” λ„˜κΉ€).
384
  - 타이틀은 μ—ν”Όμ†Œλ“œ 첫 νŽ˜μ΄μ§€μ˜ ν™”(θ©±) ν—€λ”λ§Œ β€” μΉ΄λ“œ μ†Œμ œλͺ©μ€ ν‘œμ‹œν•˜μ§€ μ•ŠλŠ”λ‹€ (μ±…μ˜ 흐름)
385
+ - μ΄ˆμƒ μžˆλŠ” ν™”μžμ˜ λ°œν™”λŠ” μ „λΆ€ μ „λ©΄ μŠ€ν…Œμ΄μ§€(이미지 + 사각 말풍선) 단독 ν™”λ©΄
386
+ - μΈν„°λž™μ…˜ νŽ˜μ΄μ§€μ˜ λ§ˆμ§€λ§‰ μ΄ˆμƒ λ°œν™”λΆ€ν„°λŠ” "말풍선 단계 μ‹œν€€μŠ€" β€” 같은 이미지 μœ„μ—μ„œ
387
+ λŒ€μ‚¬ β†’ (이후 μ§€λ¬ΈΒ·λŒ€μ‚¬) β†’ λ§ˆμ§€λ§‰ 단계에 선택지. 원문 μˆœμ„œ 보쑴 + λ°œν™”Β·μ„ νƒμ§€ 인접
388
+ - λ‚˜λ¨Έμ§€ μ§€λ¬ΈΒ·λ°œν™”λŠ” μ„Έκ·Έλ¨ΌνŠΈλ³„ μ‹€μΈ‘ κ· ν˜• λΆ„ν• λ‘œ 화면을 μ΅œλŒ€ν•œ μ±„μš΄λ‹€ */
389
  function buildScreens(step, fresh) {
390
  const c = step.card;
391
  let head = '';
 
395
 
396
  const groups = groupNarration(step.narration);
397
  const choices = choicesHtml(step);
398
+ const isPortraitG = (g) => g.lines && g.portrait && !PLAYER_SPEAKERS.has(g.spk);
399
+ let seqIdx = -1; // μΈν„°λž™μ…˜ νŽ˜μ΄μ§€: λ§ˆμ§€λ§‰ μ΄ˆμƒ λ°œν™” = μ‹œν€€μŠ€ μ‹œμž‘
400
  if (step.can_chat || step.choices.length) {
401
+ for (let k = groups.length - 1; k >= 0; k--)
402
+ if (isPortraitG(groups[k])) { seqIdx = k; break; }
 
 
403
  }
 
404
 
405
  const meas = $('page-measure');
406
+ const budget = Math.max((window.innerHeight || 900) - 130, 320); // μ΅œλŒ€ν•œ 가득 채움
407
  const measure = (html) => { meas.innerHTML = html; return meas.offsetHeight; };
408
 
409
  const screens = [];
 
431
  seg = [];
432
  };
433
 
434
+ const upto = seqIdx >= 0 ? seqIdx : groups.length;
435
+ for (let k = 0; k < upto; k++) {
436
+ const g = groups[k];
437
+ if (isPortraitG(g)) { // 일반 μ΄ˆμƒ λ°œν™” β€” μ „λ©΄ μŠ€ν…Œμ΄μ§€ 1μž₯ (μ„œμ‚¬ μœ„μΉ˜ μœ μ§€)
 
 
 
 
 
 
438
  flushSeg();
439
+ screens.push(stageScreen(g, speechBox(g.spk, g.lines, 'speech'), ''));
440
+ } else {
441
+ const html = g.lines ? charLines(g) : block(g.block);
442
+ seg.push({html, h: measure(html)});
443
  }
 
 
 
 
 
 
444
  }
445
+
446
+ if (seqIdx < 0) { // μŠ€ν…Œμ΄μ§€ μ‹œν€€μŠ€ μ—†μŒ β€” μ„ νƒμ§€λŠ” λ§ˆμ§€λ§‰ ν™”λ©΄ ν•˜λ‹¨
447
+ if (choices) {
448
+ const area = `<div id="choice-area">${choices}</div>`;
449
+ seg.push({html: area, h: measure(area)});
450
+ }
451
+ flushSeg();
452
+ } else { // 말풍선 단계 μ‹œν€€μŠ€ (같은 이미지, λ°•μŠ€ λ‚΄μš©λ§Œ μ „ν™˜)
453
+ flushSeg();
454
+ const sg = groups[seqIdx];
455
+ const steps = [{name: sg.spk, lines: sg.lines, mode: 'speech'}];
456
+ for (let k = seqIdx + 1; k < groups.length; k++) {
457
+ const g = groups[k];
458
+ if (g.lines)
459
+ steps.push({name: PLAYER_SPEAKERS.has(g.spk) ? 'λ‚˜' : g.spk,
460
+ lines: g.lines, mode: 'speech'});
461
+ else if (g.block.type === 'dialogue_center')
462
+ steps.push({name: null, lines: [g.block.text], mode: 'center'});
463
+ else
464
+ steps.push({name: null, lines: [g.block.text], mode: 'narr'});
465
+ }
466
+ steps.forEach((s, i) => {
467
+ screens.push(stageScreen(sg, speechBox(s.name, s.lines, s.mode),
468
+ i === steps.length - 1 ? choices : ''));
469
+ });
470
+ }
471
  meas.innerHTML = '';
472
 
473
  if (screens.length) screens[0] = head + screens[0]; // ν™”(θ©±) ν—€λ”λŠ” 첫 화면에