Spaces:
Sleeping
Sleeping
Upload 140 files
Browse files- .gitattributes +9 -0
- content/.DS_Store +0 -0
- content/beatcards/E01.yaml +1 -1
- engine/__pycache__/router.cpython-312.pyc +0 -0
- engine/__pycache__/router.cpython-314.pyc +0 -0
- engine/repositories/__pycache__/content.cpython-312.pyc +0 -0
- engine/repositories/__pycache__/content.cpython-314.pyc +0 -0
- engine/router.py +52 -3
- engine/schemas/__pycache__/chapter.cpython-312.pyc +0 -0
- engine/schemas/__pycache__/chapter.cpython-314.pyc +0 -0
- engine/services/__pycache__/paging.cpython-312.pyc +0 -0
- engine/services/__pycache__/paging.cpython-314.pyc +0 -0
- engine/services/__pycache__/story.cpython-312.pyc +0 -0
- engine/services/__pycache__/story.cpython-314.pyc +0 -0
- scripts/beatcard_convert.py +397 -0
- scripts/play.py +139 -0
- scripts/sync_assets.py +57 -0
- web/assets/λ°°κ²½/μλΉ.png +3 -0
- web/assets/λ°°κ²½/μκ° μ².png +3 -0
- web/assets/λ°°κ²½/ννμ±λΉ.png +3 -0
- web/assets/μ€λ Έλ§xμλ½_λ°©ν₯/κ°μ2.png +3 -0
- web/assets/μ€λ Έλ§xμλ½_λ°©ν₯/κ°μ3.png +3 -0
- web/assets/μΈλ¬Ό/κ°μ κ΅μ¬.png +3 -0
- web/assets/μΈλ¬Ό/보λͺ¨.png +3 -0
- web/assets/μΈλ¬Ό/μλ²μ§.png +3 -0
- web/assets/νλμ΄ μ΄λ―Έμ§/2.png +3 -0
- web/index.html +168 -22
.gitattributes
CHANGED
|
@@ -39,3 +39,12 @@ web/assets/μΈλ¬Ό/μΉ΄λ₯΄λ°λΌ[[:space:]]κ°λ©΄.png filter=lfs diff=lfs merge=lf
|
|
| 39 |
web/assets/μΈλ¬Ό/μΉ΄λ₯΄λ°λΌ.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
web/assets/νλμ΄[[:space:]]μ΄λ―Έμ§/1.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
web/assets/νλμ΄[[:space:]]μ΄λ―Έμ§/4.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
web/assets/μΈλ¬Ό/μΉ΄λ₯΄λ°λΌ.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
web/assets/νλμ΄[[:space:]]μ΄λ―Έμ§/1.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
web/assets/νλμ΄[[:space:]]μ΄λ―Έμ§/4.png filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
web/assets/λ°°κ²½/μλΉ.png filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
web/assets/λ°°κ²½/μκ°[[:space:]]μ².png filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
web/assets/λ°°κ²½/ννμ±λΉ.png filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
web/assets/μ€λ
Έλ§xμλ½_λ°©ν₯/κ°μ2.png filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
web/assets/μ€λ
Έλ§xμλ½_λ°©ν₯/κ°μ3.png filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
web/assets/μΈλ¬Ό/κ°μ κ΅μ¬.png filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
web/assets/μΈλ¬Ό/보λͺ¨.png filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
web/assets/μΈλ¬Ό/μλ²μ§.png filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
web/assets/νλμ΄[[:space:]]μ΄λ―Έμ§/2.png filter=lfs diff=lfs merge=lfs -text
|
content/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
content/beatcards/E01.yaml
CHANGED
|
@@ -104,7 +104,7 @@ cards:
|
|
| 104 |
|
| 105 |
*β (μ€νμ ν΄μ μ°μ±
. μ μ κ° λ¬΄μμ 묻λ μλ²μ§λ μμνκ² μνλ€. μ΄λ¨ΈλΒ·μΈλ‘μμΌλ‘ νμ κ° λΏμΌλ©΄, κ·Έλ λ΅μ λ€ μμ§ λͺ»νλ€.)*'
|
| 106 |
- type: character_dialogue
|
| 107 |
-
speaker:
|
| 108 |
text: μ΄λ¨Έλλβ¦ μ΄λ€ λΆμ΄μ
¨μ΄μ?
|
| 109 |
anchor: true
|
| 110 |
- type: prose
|
|
|
|
| 104 |
|
| 105 |
*β (μ€νμ ν΄μ μ°μ±
. μ μ κ° λ¬΄μμ 묻λ μλ²μ§λ μμνκ² μνλ€. μ΄λ¨ΈλΒ·μΈλ‘μμΌλ‘ νμ κ° λΏμΌλ©΄, κ·Έλ λ΅μ λ€ μμ§ λͺ»νλ€.)*'
|
| 106 |
- type: character_dialogue
|
| 107 |
+
speaker: λ‘λΌ
|
| 108 |
text: μ΄λ¨Έλλβ¦ μ΄λ€ λΆμ΄μ
¨μ΄μ?
|
| 109 |
anchor: true
|
| 110 |
- type: prose
|
engine/__pycache__/router.cpython-312.pyc
CHANGED
|
Binary files a/engine/__pycache__/router.cpython-312.pyc and b/engine/__pycache__/router.cpython-312.pyc differ
|
|
|
engine/__pycache__/router.cpython-314.pyc
CHANGED
|
Binary files a/engine/__pycache__/router.cpython-314.pyc and b/engine/__pycache__/router.cpython-314.pyc differ
|
|
|
engine/repositories/__pycache__/content.cpython-312.pyc
CHANGED
|
Binary files a/engine/repositories/__pycache__/content.cpython-312.pyc and b/engine/repositories/__pycache__/content.cpython-312.pyc differ
|
|
|
engine/repositories/__pycache__/content.cpython-314.pyc
CHANGED
|
Binary files a/engine/repositories/__pycache__/content.cpython-314.pyc and b/engine/repositories/__pycache__/content.cpython-314.pyc differ
|
|
|
engine/router.py
CHANGED
|
@@ -39,6 +39,33 @@ IMAGE_MAP = {
|
|
| 39 |
"E06-10": "/assets/λ°°κ²½/μΉ¨μ€.png", # λλ¬μ β λ μ λ°κ²¬
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
app = FastAPI(title="carmilla-demo")
|
| 43 |
|
| 44 |
_repo = ContentRepository(ROOT / "content")
|
|
@@ -49,6 +76,24 @@ _CUT_IDS = {cid for cid in IMAGE_MAP if _repo.has_card(cid)}
|
|
| 49 |
_SOLO_IDS = _CUT_IDS | {c.id for c in _repo.all_cards() if c.can_chat}
|
| 50 |
_pager = Pager(_repo, solo_ids=_SOLO_IDS, cut_ids=_CUT_IDS)
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
_pocket = PocketService(
|
| 53 |
_repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))])
|
| 54 |
_playdb = PlaySessionRepository(ROOT / "data" / "sessions.db")
|
|
@@ -104,7 +149,7 @@ def _step_json(ps: PlaySession) -> dict:
|
|
| 104 |
page = ps.last_step
|
| 105 |
if page.ending is not None:
|
| 106 |
return {"card": None, "narration": [], "choices": [], "can_chat": False,
|
| 107 |
-
"image": None,
|
| 108 |
"progress": {"index": _pager.display_total, "total": _pager.display_total},
|
| 109 |
"ending": {"id": page.ending.id, "label": page.ending.label,
|
| 110 |
"description": page.ending.description}}
|
|
@@ -113,11 +158,14 @@ def _step_json(ps: PlaySession) -> dict:
|
|
| 113 |
# idλ νμ΄μ§ λ μΉ΄λ(μ νΒ·μ±ν
Β·μ§ν κΈ°μ€), μ λͺ©Β·μνΌμλΒ·μ»·μ 첫 μΉ΄λ κΈ°μ€
|
| 114 |
"card": {"id": last.id, "title": first.title, "episode": first.episode_id,
|
| 115 |
"episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)},
|
| 116 |
-
"narration": [{"type": b.type, "text": b.text, "speaker": b.speaker
|
|
|
|
|
|
|
| 117 |
for b in page.narration],
|
| 118 |
"choices": [{"label": c.label} for c in page.choices],
|
| 119 |
"can_chat": page.can_chat,
|
| 120 |
"image": IMAGE_MAP.get(first.id),
|
|
|
|
| 121 |
"progress": {"index": _pager.display_index(last.id), "total": _pager.display_total},
|
| 122 |
"ending": None,
|
| 123 |
}
|
|
@@ -214,7 +262,8 @@ async def pocket_close(sid: str):
|
|
| 214 |
# ββ λ©ν (νμ§ λ± νλ μ ν
μ΄μ
μμ) ββββββββββββββββββββββββ
|
| 215 |
@app.get("/api/meta")
|
| 216 |
async def meta():
|
| 217 |
-
return {"cover_image": COVER_IMAGE, "title": "μΉ΄λ₯΄λ°λΌ", "subtitle": "μ λ€μ§ μλ λ°€ Β· λμ
μν¬"
|
|
|
|
| 218 |
|
| 219 |
|
| 220 |
# ββ μ μ νλ‘ νΈ βββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 39 |
"E06-10": "/assets/λ°°κ²½/μΉ¨μ€.png", # λλ¬μ β λ μ λ°κ²¬
|
| 40 |
}
|
| 41 |
|
| 42 |
+
# μΈνΈλ‘ μνμ€ β νμ§ "λ°€μ μ°λ€" λ€, μ μμμλ§ (μ΄μ΄ μ½κΈ°λ 건λλ)
|
| 43 |
+
INTRO_IMAGES = [
|
| 44 |
+
"/assets/μ€λ
Έλ§xμλ½_λ°©ν₯/κ°μ2.png", # μν μκ°
|
| 45 |
+
"/assets/μ€λ
Έλ§xμλ½_λ°©ν₯/κ°μ3.png", # λ±μ₯μΈλ¬Ό μκ°
|
| 46 |
+
"/assets/νλμ΄ μ΄λ―Έμ§/2.png", # 1μ₯ μμ
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
# νμ μ΄μ (λΉμ£ΌμΌ λ
Έλ²¨ λ°ν β μ°Έκ³ μ°μΆ: νλμ΄ μ΄λ―Έμ§/7.png). character_dialogueμ
|
| 50 |
+
# speaker κΈ°μ€. λ§€ν μλ νμ(νμΈΒ·λ°±μλΆμΈΒ·μ½μ₯μ λ±)λ μ΄λ¦νλ§ νμλλ€
|
| 51 |
+
SPEAKER_PORTRAIT = {
|
| 52 |
+
"μΉ΄λ₯΄λ°λΌ": "/assets/μΈλ¬Ό/μΉ΄λ₯΄λ°λΌ.png",
|
| 53 |
+
"μλ²μ§": "/assets/μΈλ¬Ό/μλ²μ§.png",
|
| 54 |
+
"λ§λλͺ¨μμ €": "/assets/μΈλ¬Ό/κ°μ κ΅μ¬.png",
|
| 55 |
+
"λ§λ΄": "/assets/μΈλ¬Ό/보λͺ¨.png",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# μ₯μλ³ λ°°κ²½ (λΉμ£ΌμΌ λ
Έλ²¨) β μΉ΄λ νκ·Έ `#μ₯μ/X` κΈ°μ€. λ§€ν μλ μ₯μ(μ μΒ·νλΒ·μμ¬ λ±)λ
|
| 59 |
+
# μ§μ λ°°κ²½ μ μ§. μμ
μ΄ μκΈ°λ©΄ μ¬κΈ° ν μ€ μΆκ° + scripts/sync_assets.py κ°±μ
|
| 60 |
+
BACKGROUND_MAP = {
|
| 61 |
+
"μΉ¨μ€": "/assets/λ°°κ²½/μΉ¨μ€.png",
|
| 62 |
+
"λ§μ°¬ν": "/assets/λ°°κ²½/μλΉ.png",
|
| 63 |
+
"μ²κΈΈ": "/assets/λ°°κ²½/μκ° μ².png",
|
| 64 |
+
"ν΄μ": "/assets/λ°°κ²½/μ±.png",
|
| 65 |
+
"νν": "/assets/λ°°κ²½/ννμ±λΉ.png",
|
| 66 |
+
"μλ°°λΉ": "/assets/λ°°κ²½/ννμ±λΉ.png",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
app = FastAPI(title="carmilla-demo")
|
| 70 |
|
| 71 |
_repo = ContentRepository(ROOT / "content")
|
|
|
|
| 76 |
_SOLO_IDS = _CUT_IDS | {c.id for c in _repo.all_cards() if c.can_chat}
|
| 77 |
_pager = Pager(_repo, solo_ids=_SOLO_IDS, cut_ids=_CUT_IDS)
|
| 78 |
|
| 79 |
+
|
| 80 |
+
def _partition_backgrounds() -> list[Optional[str]]:
|
| 81 |
+
"""νν°μ
λ³ μ ν¨ λ°°κ²½ β μΉ΄λμ 첫 `#μ₯μ/` νκ·Έκ° λ§€νλλ©΄ κ°±μ , μλλ©΄ μ§μ λ°°κ²½ μ μ§.
|
| 82 |
+
νν°μ
μμ(μ μ)μ μμ ν¨μλΌ κ²½λ‘·볡μκ³Ό 무κ΄νκ² κ²°μ μ ."""
|
| 83 |
+
bgs: list[Optional[str]] = []
|
| 84 |
+
current: Optional[str] = None
|
| 85 |
+
for part in _pager.partitions:
|
| 86 |
+
for cid in part:
|
| 87 |
+
place = next((t.split("/", 1)[1] for t in _repo.get_card(cid).tags
|
| 88 |
+
if t.startswith("#μ₯μ/")), None)
|
| 89 |
+
if place and place in BACKGROUND_MAP:
|
| 90 |
+
current = BACKGROUND_MAP[place]
|
| 91 |
+
bgs.append(current)
|
| 92 |
+
return bgs
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
_PART_BG = _partition_backgrounds()
|
| 96 |
+
|
| 97 |
_pocket = PocketService(
|
| 98 |
_repo, plugins=[TurnLoggingPlugin(PlaylogRepository(ROOT / "logs" / "playlog.jsonl"))])
|
| 99 |
_playdb = PlaySessionRepository(ROOT / "data" / "sessions.db")
|
|
|
|
| 149 |
page = ps.last_step
|
| 150 |
if page.ending is not None:
|
| 151 |
return {"card": None, "narration": [], "choices": [], "can_chat": False,
|
| 152 |
+
"image": None, "background": None, # μλ©μ λ°°κ²½μ κ±·μ΄λΈλ€
|
| 153 |
"progress": {"index": _pager.display_total, "total": _pager.display_total},
|
| 154 |
"ending": {"id": page.ending.id, "label": page.ending.label,
|
| 155 |
"description": page.ending.description}}
|
|
|
|
| 158 |
# idλ νμ΄μ§ λ μΉ΄λ(μ νΒ·μ±ν
Β·μ§ν κΈ°μ€), μ λͺ©Β·μνΌμλΒ·μ»·μ 첫 μΉ΄λ κΈ°μ€
|
| 159 |
"card": {"id": last.id, "title": first.title, "episode": first.episode_id,
|
| 160 |
"episode_title": ps.episode_titles.get(first.episode_id, first.episode_id)},
|
| 161 |
+
"narration": [{"type": b.type, "text": b.text, "speaker": b.speaker,
|
| 162 |
+
"portrait": (SPEAKER_PORTRAIT.get(b.speaker)
|
| 163 |
+
if b.type == "character_dialogue" else None)}
|
| 164 |
for b in page.narration],
|
| 165 |
"choices": [{"label": c.label} for c in page.choices],
|
| 166 |
"can_chat": page.can_chat,
|
| 167 |
"image": IMAGE_MAP.get(first.id),
|
| 168 |
+
"background": _PART_BG[_pager.part_of[last.id]],
|
| 169 |
"progress": {"index": _pager.display_index(last.id), "total": _pager.display_total},
|
| 170 |
"ending": None,
|
| 171 |
}
|
|
|
|
| 262 |
# ββ λ©ν (νμ§ λ± νλ μ ν
μ΄μ
μμ) ββββββββββββββββββββββββ
|
| 263 |
@app.get("/api/meta")
|
| 264 |
async def meta():
|
| 265 |
+
return {"cover_image": COVER_IMAGE, "title": "μΉ΄λ₯΄λ°λΌ", "subtitle": "μ λ€μ§ μλ λ°€ Β· λμ
μν¬",
|
| 266 |
+
"intro_images": INTRO_IMAGES}
|
| 267 |
|
| 268 |
|
| 269 |
# ββ μ μ νλ‘ νΈ βββββββββββββββββββββββββββββββββββββββββββββ
|
engine/schemas/__pycache__/chapter.cpython-312.pyc
CHANGED
|
Binary files a/engine/schemas/__pycache__/chapter.cpython-312.pyc and b/engine/schemas/__pycache__/chapter.cpython-312.pyc differ
|
|
|
engine/schemas/__pycache__/chapter.cpython-314.pyc
CHANGED
|
Binary files a/engine/schemas/__pycache__/chapter.cpython-314.pyc and b/engine/schemas/__pycache__/chapter.cpython-314.pyc differ
|
|
|
engine/services/__pycache__/paging.cpython-312.pyc
CHANGED
|
Binary files a/engine/services/__pycache__/paging.cpython-312.pyc and b/engine/services/__pycache__/paging.cpython-312.pyc differ
|
|
|
engine/services/__pycache__/paging.cpython-314.pyc
CHANGED
|
Binary files a/engine/services/__pycache__/paging.cpython-314.pyc and b/engine/services/__pycache__/paging.cpython-314.pyc differ
|
|
|
engine/services/__pycache__/story.cpython-312.pyc
CHANGED
|
Binary files a/engine/services/__pycache__/story.cpython-312.pyc and b/engine/services/__pycache__/story.cpython-312.pyc differ
|
|
|
engine/services/__pycache__/story.cpython-314.pyc
CHANGED
|
Binary files a/engine/services/__pycache__/story.cpython-314.pyc and b/engine/services/__pycache__/story.cpython-314.pyc differ
|
|
|
scripts/beatcard_convert.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""λΉνΈμΉ΄λ MD β content/ YAML λ³νκΈ° (κ°λ°ν μμ ).
|
| 2 |
+
|
| 3 |
+
μ¬μ©λ²:
|
| 4 |
+
.venv/bin/python scripts/beatcard_convert.py <λΉνΈμΉ΄λ_ν΄λ> -o content/beatcards
|
| 5 |
+
|
| 6 |
+
- μ
λ ₯: μΉ΄λ₯΄λ°λΌ/0701_μΉ΄λ₯΄λ°λΌ/λΉνΈμΉ΄λ/ (κΆμ, λνλ μμ β λ΄μ© λ³ν κΈμ§)
|
| 7 |
+
- μΆλ ₯: content/beatcards/E##.yaml + R-*.yaml
|
| 8 |
+
- ꡬ쑰 λ³νλ§ νλ€. λ§€μΉμ΄ λΆνμ€ν μ νμ§ κ²°κ³Όλ 리ν¬νΈλ‘ νμνκ³ axis=NoneμΌλ‘ λ¨κΈ΄λ€.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import re
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import yaml
|
| 19 |
+
|
| 20 |
+
# engine.schemasλ‘ μ¦μ κ²μ¦νκΈ° μν΄ μ μ₯μ 루νΈλ₯Ό κ²½λ‘μ μΆκ°
|
| 21 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 22 |
+
|
| 23 |
+
from engine.schemas.beatcard import EpisodeFile, RCard # noqa: E402
|
| 24 |
+
|
| 25 |
+
AXIS_MAP = {"μ λ’°+": "trust", "μμ¬+": "doubt", "μ€λ¦½": "neutral",
|
| 26 |
+
"νλ¦Ό": "neutral", "μλ": "neutral"}
|
| 27 |
+
GATE_MARKS = {"νλ©΄": "all", "μ¬μΈ΅": "trust_high"}
|
| 28 |
+
|
| 29 |
+
RE_CARD_HEADER = re.compile(r"^##\s+(?:β
\s*)?([A-Z]\d{2}-\d{2}[A-Z]?)\s*[Β·Β·]\s*(?:β
\s*)?([^(\n]+?)(?:\s*\(([^)]*)\))?\s*$", re.M)
|
| 30 |
+
RE_TAG = re.compile(r"`(#[^`]+)`")
|
| 31 |
+
RE_NEXT = re.compile(r"Β·\s*β\s*([\wκ°-ν£/\-]+)")
|
| 32 |
+
RE_CHOICE_HEADER = re.compile(r"^\*\*β·\s*(.+?)\*\*", re.M)
|
| 33 |
+
RE_CHOICE_LABEL = re.compile(r"\[([^\]]+)\]")
|
| 34 |
+
RE_CHOICE_MOD = re.compile(r"γ([^γ]+)γ")
|
| 35 |
+
RE_CENTER = re.compile(r"β\s*(.+?)\s*β", re.S)
|
| 36 |
+
RE_CHAR_DIALOGUE = re.compile(r"^>\s*([κ°-ν£A-Za-zΒ·\s]{1,20}?):\s*[γ\"](.+?)[γ\"]\s*$")
|
| 37 |
+
RE_OLD_RESULT = re.compile(r"^-\s*\*([^:*]+):\*\s*(.+)$")
|
| 38 |
+
RE_NEW_RESULT = re.compile(r"^γ([^Β·γ]+)\s*Β·\s*([^γ]*)γ\s*(.*)$")
|
| 39 |
+
RE_RESULT_NEXT = re.compile(r"β\s*\*{0,2}([A-Z]\d{2}-\d{2}[A-Z]?)\*{0,2}")
|
| 40 |
+
RE_BRANCH_MAP = re.compile(r"([κ°-ν£]+)\s*β\s*\*\*([A-Z]\d{2}-\d{2}[A-Z]?)\*\*")
|
| 41 |
+
RE_RCARD_REF = re.compile(r"R-[κ°-ν£\w]+")
|
| 42 |
+
RE_SECTION_STOP = re.compile(r"^(?:βΈ\s*λ
ΈνΈ:|π¬|πΌ)")
|
| 43 |
+
RE_STAGE = re.compile(r"^###\s*(\d)λ¨κ³\s*[ββ-]\s*(.+)$", re.M)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _bigrams(s: str) -> set[str]:
|
| 47 |
+
s = re.sub(r"[^\wκ°-ν£]", "", s)
|
| 48 |
+
return {s[i:i + 2] for i in range(len(s) - 1)} | set(s)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _match_score(label: str, hint: str, text: str) -> float:
|
| 52 |
+
"""μ νμ§ λΌλ²¨ β κ²°κ³Ό λΈλ‘ μ μ¬λ (λΌλ²¨ννΈ κ°μ€ + λ³Έλ¬Έ μλΆλΆ)."""
|
| 53 |
+
lb = _bigrams(label)
|
| 54 |
+
return 3 * len(lb & _bigrams(hint)) + len(lb & _bigrams(text[:60]))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class EpisodeParser:
|
| 58 |
+
def parse(self, text: str, source: str) -> tuple[dict, list[str]]:
|
| 59 |
+
warnings: list[str] = []
|
| 60 |
+
m = re.search(r"^#\s+(E\d{2})\s+λΉνΈ\s*μΉ΄λ\s*[ββ-]\s*(.+?)\s*(?:\(v.*)?$", text, re.M)
|
| 61 |
+
ep_id, ep_title = (m.group(1), m.group(2).strip()) if m else ("UNKNOWN", "")
|
| 62 |
+
|
| 63 |
+
cards = []
|
| 64 |
+
headers = list(RE_CARD_HEADER.finditer(text))
|
| 65 |
+
for i, h in enumerate(headers):
|
| 66 |
+
body = text[h.end(): headers[i + 1].start() if i + 1 < len(headers) else len(text)]
|
| 67 |
+
card, ws = self._parse_card(h.group(1), h.group(2).strip(), (h.group(3) or "").strip(), body)
|
| 68 |
+
cards.append(card)
|
| 69 |
+
warnings += [f"{card['id']}: {w}" for w in ws]
|
| 70 |
+
|
| 71 |
+
self._resolve_branches(cards, warnings)
|
| 72 |
+
self._demote_reach_skips(cards, warnings)
|
| 73 |
+
return {"episode": {"id": ep_id, "title": ep_title, "source": source}, "cards": cards}, warnings
|
| 74 |
+
|
| 75 |
+
# ββ μΉ΄λ ν μ₯ ββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
def _parse_card(self, cid: str, title: str, subtitle: str, body: str) -> tuple[dict, list[str]]:
|
| 77 |
+
ws: list[str] = []
|
| 78 |
+
lines = body.split("\n")
|
| 79 |
+
|
| 80 |
+
# νκ·Έ λΌμΈ (첫 '>' λΌμΈ)
|
| 81 |
+
tag_line = next((l for l in lines if l.strip().startswith(">") and "`#" in l), "")
|
| 82 |
+
tags = RE_TAG.findall(tag_line)
|
| 83 |
+
|
| 84 |
+
nxt, next_options = None, []
|
| 85 |
+
nm = RE_NEXT.search(tag_line)
|
| 86 |
+
if nm:
|
| 87 |
+
raw = nm.group(1)
|
| 88 |
+
if "/" in raw: # E06-10B/C β [E06-10B, E06-10C]
|
| 89 |
+
head, *rest = raw.split("/")
|
| 90 |
+
prefix = head[: head.rfind(head.lstrip("A-Z0-9-")) ] if False else head
|
| 91 |
+
base = re.match(r"([A-Z]\d{2}-\d{2})", head)
|
| 92 |
+
next_options = [head] + [(base.group(1) + r) if base and len(r) <= 2 else r for r in rest]
|
| 93 |
+
else:
|
| 94 |
+
nxt = raw
|
| 95 |
+
|
| 96 |
+
# μΆ/μ μ½ νκ·Έ
|
| 97 |
+
axis = None
|
| 98 |
+
axis_tags = [t for t in tags if t.startswith("#μΆ/")]
|
| 99 |
+
if axis_tags:
|
| 100 |
+
v = axis_tags[0][len("#μΆ/"):]
|
| 101 |
+
axis = AXIS_MAP.get(v) # "μ λ’°+Β·μμ¬+" κ°μ 볡ν©κ° β None (μ νμ§κ° κ²°μ )
|
| 102 |
+
frailty = any(t.startswith("#λ©μ»€λμ¦/μ μ½λ") for t in tags)
|
| 103 |
+
canon = any(t.startswith("#μΊλ
Ό/") for t in tags)
|
| 104 |
+
status = next((t.split("/", 1)[1] for t in tags if t.startswith("#μν/")), "μ΄μ")
|
| 105 |
+
|
| 106 |
+
# λ³Έλ¬Έ μ λ¨ (λ
ΈνΈ/μ°μΆ/μ΄λ―Έμ§ λΆλ¦¬)
|
| 107 |
+
note = self._grab(body, r"βΈ\s*λ
ΈνΈ:\s*(.+?)(?=\nπ¬|\nπΌ|\n---|\Z)")
|
| 108 |
+
direction = self._grab(body, r"π¬\s*\*\*μ°μΆ ν¬μΈνΈ:\*\*\s*(.+?)(?=\nπΌ|\n---|\Z)")
|
| 109 |
+
image = self._grab(body, r"πΌ\s*\*\*μ₯λ©΄ μ΄λ―Έμ§:\*\*\s*(.+?)(?=\n---|\Z)")
|
| 110 |
+
|
| 111 |
+
# μ νμ§ λΌλ²¨
|
| 112 |
+
choices: list[dict] = []
|
| 113 |
+
ch = RE_CHOICE_HEADER.search(body)
|
| 114 |
+
if ch:
|
| 115 |
+
seg = ch.group(1)
|
| 116 |
+
mods = RE_CHOICE_MOD.findall(seg)
|
| 117 |
+
for lab in RE_CHOICE_LABEL.findall(seg):
|
| 118 |
+
choices.append({"label": lab.strip(), "modifiers": mods, "axis": None,
|
| 119 |
+
"result": "", "next": None})
|
| 120 |
+
|
| 121 |
+
# λ΄λ μ΄μ
+ κ²°κ³Ό λΈλ‘
|
| 122 |
+
narration, results = self._parse_blocks(lines, tag_line, ws)
|
| 123 |
+
|
| 124 |
+
# κ²°κ³Ό λΈλ‘ β μ νμ§ λ§€μΉ
|
| 125 |
+
if choices and results:
|
| 126 |
+
self._match_results(choices, results, ws)
|
| 127 |
+
elif results and not choices:
|
| 128 |
+
ws.append(f"κ²°κ³Ό λΈλ‘ {len(results)}κ°κ° μ νμ§ μμ΄ μ‘΄μ¬")
|
| 129 |
+
|
| 130 |
+
# λΆκΈ° λ§€ν λΌμΈ (μ¨κΉ β **E06-10C**)μ μ 체 μΉ΄λ νμ± ν νμ²λ¦¬ (νκΉ μΉ΄λ λ³Έλ¬Έ νμ)
|
| 131 |
+
branch_hints = RE_BRANCH_MAP.findall(body)
|
| 132 |
+
|
| 133 |
+
r_refs = sorted(set(RE_RCARD_REF.findall(body)))
|
| 134 |
+
|
| 135 |
+
card = {
|
| 136 |
+
"id": cid, "title": title, "subtitle": subtitle, "tags": tags,
|
| 137 |
+
"status": status, "canon": canon, "next": nxt, "next_options": next_options,
|
| 138 |
+
"axis": axis, "frailty": frailty, "narration": narration, "choices": choices,
|
| 139 |
+
"r_card_refs": r_refs, "note": note, "direction": direction, "image": image,
|
| 140 |
+
}
|
| 141 |
+
if branch_hints:
|
| 142 |
+
card["_branch_hints"] = branch_hints # νμ²λ¦¬μ© μμ ν€ (μ μ₯ μ μ κ±°)
|
| 143 |
+
return card, ws
|
| 144 |
+
|
| 145 |
+
def _demote_reach_skips(self, cards: list[dict], warnings: list[str]):
|
| 146 |
+
"""canon λλ¬μ μ°ν λ°©μ§ β μ νμ§ nextκ° νλ₯ λΆκΈ°μΉ΄λμ μ΅μ
μ΄λ©΄
|
| 147 |
+
preset_branchλ‘ κ°λ±νκ³ μ μ κ²½λ‘(card.next)λ₯Ό λ°λ₯΄κ² νλ€."""
|
| 148 |
+
by_id = {c["id"]: c for c in cards}
|
| 149 |
+
for card in cards:
|
| 150 |
+
for ch in card["choices"]:
|
| 151 |
+
target = ch.get("next")
|
| 152 |
+
if not target:
|
| 153 |
+
continue
|
| 154 |
+
cur, hops = card, 0
|
| 155 |
+
while cur and cur.get("next") in by_id and hops < 6:
|
| 156 |
+
cur = by_id[cur["next"]]
|
| 157 |
+
hops += 1
|
| 158 |
+
if target in cur.get("next_options", []) and cur["id"] != target:
|
| 159 |
+
ch["next"] = None
|
| 160 |
+
ch["preset_branch"] = target
|
| 161 |
+
warnings.append(
|
| 162 |
+
f"{card['id']}: '{ch['label'][:14]}β¦' next={target}λ "
|
| 163 |
+
f"λλ¬μ {cur['id']} μ°ν β preset_branchλ‘ κ°λ±")
|
| 164 |
+
break
|
| 165 |
+
|
| 166 |
+
def _resolve_branches(self, cards: list[dict], warnings: list[str]):
|
| 167 |
+
"""λΆκΈ° λ§€ν νμ²λ¦¬ β νκΉ μΉ΄λμ 첫 μ§λ¬Έκ³Ό μ νμ§ λΌλ²¨μ λμ‘°ν΄ 1:1 λ°°μ ."""
|
| 168 |
+
by_id = {c["id"]: c for c in cards}
|
| 169 |
+
for card in cards:
|
| 170 |
+
hints = card.pop("_branch_hints", None)
|
| 171 |
+
if not hints:
|
| 172 |
+
continue
|
| 173 |
+
used = set()
|
| 174 |
+
for hint, target in hints:
|
| 175 |
+
tgt = by_id.get(target)
|
| 176 |
+
tgt_text = tgt["narration"][0]["text"] if tgt and tgt["narration"] else ""
|
| 177 |
+
cands = [c for i, c in enumerate(card["choices"]) if i not in used]
|
| 178 |
+
if not cands:
|
| 179 |
+
warnings.append(f"{card['id']}: λΆκΈ° '{hint}β{target}' λ°°μ ν μ νμ§ μμ")
|
| 180 |
+
continue
|
| 181 |
+
best = max(cands, key=lambda c: _match_score(c["label"], hint, "")
|
| 182 |
+
+ _match_score(c["label"], "", tgt_text))
|
| 183 |
+
score = (_match_score(best["label"], hint, "")
|
| 184 |
+
+ _match_score(best["label"], "", tgt_text))
|
| 185 |
+
if score <= 0:
|
| 186 |
+
warnings.append(f"{card['id']}: λΆκΈ° '{hint}β{target}' λ§€μΉ μ€ν¨ β μλ νμΈ νμ")
|
| 187 |
+
continue
|
| 188 |
+
best["next"] = target
|
| 189 |
+
used.add(card["choices"].index(best))
|
| 190 |
+
|
| 191 |
+
# ββ λ³Έλ¬Έ λΈλ‘ νμ± ββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
def _parse_blocks(self, lines: list[str], tag_line: str, ws: list[str]):
|
| 193 |
+
narration, results = [], []
|
| 194 |
+
buf: list[str] = []
|
| 195 |
+
gate = "all"
|
| 196 |
+
in_results = False # β· ν€λ μ΄νλ κ²°κ³Ό μμ
|
| 197 |
+
cur_result = None
|
| 198 |
+
|
| 199 |
+
def flush_prose():
|
| 200 |
+
t = "\n".join(buf).strip()
|
| 201 |
+
buf.clear()
|
| 202 |
+
if t:
|
| 203 |
+
narration.append({"type": "prose", "text": t, "gate": gate})
|
| 204 |
+
|
| 205 |
+
def close_result():
|
| 206 |
+
nonlocal cur_result
|
| 207 |
+
if cur_result:
|
| 208 |
+
cur_result["text"] = cur_result["text"].strip()
|
| 209 |
+
results.append(cur_result)
|
| 210 |
+
cur_result = None
|
| 211 |
+
|
| 212 |
+
for raw in lines:
|
| 213 |
+
line = raw.rstrip()
|
| 214 |
+
s = line.strip()
|
| 215 |
+
if not s or s == tag_line.strip() or s.startswith("---"):
|
| 216 |
+
continue
|
| 217 |
+
if RE_SECTION_STOP.match(s):
|
| 218 |
+
break
|
| 219 |
+
|
| 220 |
+
if RE_CHOICE_HEADER.match(s):
|
| 221 |
+
flush_prose()
|
| 222 |
+
close_result()
|
| 223 |
+
in_results = True
|
| 224 |
+
continue
|
| 225 |
+
|
| 226 |
+
nr = RE_NEW_RESULT.match(s)
|
| 227 |
+
if nr:
|
| 228 |
+
mark, hint, rest = nr.group(1).strip(), nr.group(2).strip(), nr.group(3)
|
| 229 |
+
if mark in GATE_MARKS: # γνλ©΄γγμ¬μΈ΅γ = μ§λ¬Έ κ²μ΄νΈ
|
| 230 |
+
flush_prose()
|
| 231 |
+
gate = GATE_MARKS[mark]
|
| 232 |
+
if rest.strip():
|
| 233 |
+
buf.append(rest)
|
| 234 |
+
continue
|
| 235 |
+
flush_prose()
|
| 236 |
+
close_result()
|
| 237 |
+
nm = RE_RESULT_NEXT.search(rest)
|
| 238 |
+
cur_result = {"axis": AXIS_MAP.get(mark), "hint": hint,
|
| 239 |
+
"text": RE_RESULT_NEXT.sub("", rest).strip(),
|
| 240 |
+
"next": nm.group(1) if nm else None}
|
| 241 |
+
continue
|
| 242 |
+
|
| 243 |
+
orr = RE_OLD_RESULT.match(s)
|
| 244 |
+
if orr and in_results:
|
| 245 |
+
flush_prose()
|
| 246 |
+
close_result()
|
| 247 |
+
cur_result = {"axis": None, "hint": orr.group(1).strip(),
|
| 248 |
+
"text": orr.group(2).strip(), "next": None}
|
| 249 |
+
continue
|
| 250 |
+
|
| 251 |
+
if cur_result is not None: # κ²°κ³Ό λΈλ‘ μ°μ μ€
|
| 252 |
+
cur_result["text"] += "\n" + s
|
| 253 |
+
continue
|
| 254 |
+
|
| 255 |
+
cm = RE_CENTER.search(s)
|
| 256 |
+
if cm and s.startswith(">"):
|
| 257 |
+
flush_prose()
|
| 258 |
+
narration.append({"type": "dialogue_center", "text": cm.group(1).strip(), "gate": gate})
|
| 259 |
+
gate = "all" if gate == "trust_high" else gate # μ¬μΈ΅ κ²μ΄νΈλ λνλμ¬κΉμ§
|
| 260 |
+
continue
|
| 261 |
+
|
| 262 |
+
dm = RE_CHAR_DIALOGUE.match(s)
|
| 263 |
+
if dm:
|
| 264 |
+
flush_prose()
|
| 265 |
+
narration.append({"type": "character_dialogue", "speaker": dm.group(1).strip(),
|
| 266 |
+
"text": dm.group(2).strip(), "anchor": True, "gate": gate})
|
| 267 |
+
continue
|
| 268 |
+
|
| 269 |
+
if re.match(r"^\*\(.+\)\*$", s):
|
| 270 |
+
flush_prose()
|
| 271 |
+
narration.append({"type": "stage_direction",
|
| 272 |
+
"text": s.strip("*()").strip(), "gate": gate})
|
| 273 |
+
continue
|
| 274 |
+
|
| 275 |
+
if s.startswith(">"):
|
| 276 |
+
s = s.lstrip("> ").strip()
|
| 277 |
+
buf.append(s)
|
| 278 |
+
|
| 279 |
+
flush_prose()
|
| 280 |
+
close_result()
|
| 281 |
+
# μ€ν€λ§ κΈ°λ³Έκ°κ³Ό κ°μ ν€ μ κ±° μμ΄ κ·Έλλ‘ λλ, speaker/anchor μλ λΈλ‘μ κΈ°λ³Έκ° μλ΅
|
| 282 |
+
narration = [
|
| 283 |
+
{k: v for k, v in b.items() if not (k == "speaker" and not v)
|
| 284 |
+
and not (k == "anchor" and not v) and not (k == "gate" and v == "all")}
|
| 285 |
+
for b in narration
|
| 286 |
+
]
|
| 287 |
+
return narration, results
|
| 288 |
+
|
| 289 |
+
def _match_results(self, choices: list[dict], results: list[dict], ws: list[str]):
|
| 290 |
+
"""κ²°κ³Ό λΈλ‘μ μ νμ§μ μ μ¬λ μμΌλ‘ λ°°μ (νμ, 1:1)."""
|
| 291 |
+
pairs = sorted(
|
| 292 |
+
((r_i, c_i, _match_score(c["label"], r["hint"], r["text"]))
|
| 293 |
+
for r_i, r in enumerate(results) for c_i, c in enumerate(choices)),
|
| 294 |
+
key=lambda t: -t[2],
|
| 295 |
+
)
|
| 296 |
+
used_r, used_c = set(), set()
|
| 297 |
+
for r_i, c_i, score in pairs:
|
| 298 |
+
if r_i in used_r or c_i in used_c:
|
| 299 |
+
continue
|
| 300 |
+
r, c = results[r_i], choices[c_i]
|
| 301 |
+
if score <= 0:
|
| 302 |
+
ws.append(f"κ²°κ³Ό 'γ{r['hint']}γ' β μ νμ§ λ§€μΉ μ€ν¨ (score 0)")
|
| 303 |
+
used_r.add(r_i)
|
| 304 |
+
continue
|
| 305 |
+
c["axis"], c["result"] = r["axis"], r["text"]
|
| 306 |
+
if r["next"]:
|
| 307 |
+
c["next"] = r["next"]
|
| 308 |
+
if score < 3:
|
| 309 |
+
ws.append(f"λ§€μΉ μ½ν¨(score {score}): '{c['label']}' β γ{r['hint']}γ")
|
| 310 |
+
used_r.add(r_i)
|
| 311 |
+
used_c.add(c_i)
|
| 312 |
+
|
| 313 |
+
@staticmethod
|
| 314 |
+
def _grab(body: str, pattern: str) -> str:
|
| 315 |
+
m = re.search(pattern, body, re.S)
|
| 316 |
+
return re.sub(r"\s+", " ", m.group(1)).strip() if m else ""
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
class RCardParser:
|
| 320 |
+
def parse(self, text: str) -> dict:
|
| 321 |
+
m = re.search(r"^#\s+(R-[κ°-ν£\w]+)", text, re.M)
|
| 322 |
+
rid = m.group(1) if m else "R-UNKNOWN"
|
| 323 |
+
tm = re.search(r"^##\s+" + re.escape(rid) + r"\s*[Β·Β·]\s*(.+)$", text, re.M)
|
| 324 |
+
title = tm.group(1).strip() if tm else ""
|
| 325 |
+
tag_line = next((l for l in text.split("\n") if l.strip().startswith(">") and "`#" in l), "")
|
| 326 |
+
tags = RE_TAG.findall(tag_line)
|
| 327 |
+
|
| 328 |
+
rules = []
|
| 329 |
+
rm = re.search(r"###\s*π[^\n]*\n(.*?)(?=\n###)", text, re.S)
|
| 330 |
+
if rm:
|
| 331 |
+
rules = [re.sub(r"\*\*", "", l.strip("- ").strip())
|
| 332 |
+
for l in rm.group(1).split("\n") if l.strip().startswith("-")]
|
| 333 |
+
|
| 334 |
+
stages = []
|
| 335 |
+
stage_iter = list(RE_STAGE.finditer(text))
|
| 336 |
+
for i, sm in enumerate(stage_iter):
|
| 337 |
+
seg = text[sm.end(): stage_iter[i + 1].start() if i + 1 < len(stage_iter) else len(text)]
|
| 338 |
+
anchor_m = RE_CENTER.search(seg)
|
| 339 |
+
variants = re.findall(r"-\s*(?:λ³ν|물리 μ¦κ±°[^:]*):\s*(.+)", seg)
|
| 340 |
+
prose = "\n".join(
|
| 341 |
+
l.strip() for l in seg.split("\n")
|
| 342 |
+
if l.strip() and not l.strip().startswith(("-", ">", "βΈ", "#"))
|
| 343 |
+
).strip()
|
| 344 |
+
stages.append({"stage": int(sm.group(1)), "title": sm.group(2).strip(),
|
| 345 |
+
"anchor": anchor_m.group(1).strip() if anchor_m else "",
|
| 346 |
+
"prose": prose, "variants": variants})
|
| 347 |
+
|
| 348 |
+
return {"id": rid, "title": title, "tags": tags, "rules": rules,
|
| 349 |
+
"stages": stages, "raw": "" if stages else text}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def save(data: dict, path: Path):
|
| 353 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 354 |
+
path.write_text(yaml.dump(data, allow_unicode=True, sort_keys=False, width=100),
|
| 355 |
+
encoding="utf-8")
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def main():
|
| 359 |
+
ap = argparse.ArgumentParser()
|
| 360 |
+
ap.add_argument("input", help="λΉνΈμΉ΄λ ν΄λ (E01~ νμ ν΄λ + R-*.md)")
|
| 361 |
+
ap.add_argument("-o", "--output", default="content/beatcards")
|
| 362 |
+
ap.add_argument("--episodes", default="E01,E02,E03,E04,E05,E06")
|
| 363 |
+
args = ap.parse_args()
|
| 364 |
+
|
| 365 |
+
src, out = Path(args.input), Path(args.output)
|
| 366 |
+
episodes = args.episodes.split(",")
|
| 367 |
+
total_cards, all_warnings = 0, []
|
| 368 |
+
|
| 369 |
+
ep_parser = EpisodeParser()
|
| 370 |
+
for ep in episodes:
|
| 371 |
+
md = src / ep / f"{ep}_λΉνΈμΉ΄λ.md"
|
| 372 |
+
if not md.exists():
|
| 373 |
+
print(f"β μλ³Έ μμ: {md}")
|
| 374 |
+
continue
|
| 375 |
+
data, ws = ep_parser.parse(md.read_text(encoding="utf-8"), str(md))
|
| 376 |
+
EpisodeFile.model_validate(data) # μ μ₯ μ μ€ν€λ§ κ²μ¦
|
| 377 |
+
save(data, out / f"{ep}.yaml")
|
| 378 |
+
total_cards += len(data["cards"])
|
| 379 |
+
all_warnings += ws
|
| 380 |
+
print(f"β {ep}: μΉ΄λ {len(data['cards'])}κ° β {out / (ep + '.yaml')}")
|
| 381 |
+
|
| 382 |
+
r_parser = RCardParser()
|
| 383 |
+
for rmd in sorted(src.glob("R-*.md")):
|
| 384 |
+
data = r_parser.parse(rmd.read_text(encoding="utf-8"))
|
| 385 |
+
RCard.model_validate(data)
|
| 386 |
+
save(data, out / f"{data['id']}.yaml")
|
| 387 |
+
print(f"β {data['id']}: λ¨κ³ {len(data['stages'])}κ°")
|
| 388 |
+
|
| 389 |
+
print(f"\nμ΄ {total_cards}μΉ΄λ λ³ν μλ£")
|
| 390 |
+
if all_warnings:
|
| 391 |
+
print(f"\nμ κ² νλͺ© {len(all_warnings)}건:")
|
| 392 |
+
for w in all_warnings:
|
| 393 |
+
print(f" β {w}")
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
if __name__ == "__main__":
|
| 397 |
+
main()
|
scripts/play.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""μΉ΄λ₯΄λ°λΌ μ±ν°1 νλ μ΄ CLI β λ¦¬λ© λ£¨ν + κ°μ
ν¬μΌ (M4 ν΅ν©).
|
| 2 |
+
|
| 3 |
+
μ¬μ©λ²:
|
| 4 |
+
.venv/bin/python scripts/play.py # λνν (ν¬μΌμ LLM ν€ μμΌλ©΄ μ±ν
)
|
| 5 |
+
.venv/bin/python scripts/play.py --policy trust --quiet # μλ νλ μ΄, μλ© νμΈ
|
| 6 |
+
.venv/bin/python scripts/play.py --chat # ν¬μΌ μμ μ±ν
κ°μ (OPENAI_API_KEY νμ)
|
| 7 |
+
.venv/bin/python scripts/play.py --no-chat # ν¬μΌ μ±ν
λκΈ° (μ νμ§λ§)
|
| 8 |
+
|
| 9 |
+
ν¬μΌ μ±ν
: μ νμ§ μΉ΄λμμ λ²νΈ λμ μμ ν
μ€νΈλ₯Ό μ
λ ₯νλ©΄ μΉ΄λ₯΄λ°λΌμ λνκ° μ΄λ¦¬κ³ ,
|
| 10 |
+
director μλ ΄(λλ ν΄ μν) ν μ νμ§λ‘ 볡κ·νλ€.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import asyncio
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 22 |
+
|
| 23 |
+
from engine.repositories.content import ContentRepository # noqa: E402
|
| 24 |
+
from engine.services.story import StepResult, StoryEngine # noqa: E402
|
| 25 |
+
|
| 26 |
+
DIM = "\033[2m"
|
| 27 |
+
BOLD = "\033[1m"
|
| 28 |
+
RESET = "\033[0m"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def render(step: StepResult, quiet: bool):
|
| 32 |
+
if quiet or step.card is None:
|
| 33 |
+
return
|
| 34 |
+
c = step.card
|
| 35 |
+
print(f"\n{BOLD}ββ {c.id} Β· {c.title} ββ{RESET}")
|
| 36 |
+
for b in step.narration:
|
| 37 |
+
if b.type == "dialogue_center":
|
| 38 |
+
print(f"\n{BOLD} β {b.text} β{RESET}\n")
|
| 39 |
+
elif b.type == "character_dialogue":
|
| 40 |
+
print(f' {b.speaker}: "{b.text}"')
|
| 41 |
+
elif b.type == "stage_direction":
|
| 42 |
+
print(f"{DIM} ({b.text}){RESET}")
|
| 43 |
+
else:
|
| 44 |
+
print("\n" + b.text)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def pocket_chat(pocket_service, engine: StoryEngine, card_id: str, first_line: str):
|
| 48 |
+
"""κ°μ
ν¬μΌ μμ μ±ν
β μλ ΄/ν΄ μνκΉμ§ λκ³ μ¨μ μνλ₯Ό 컀λ°νλ€."""
|
| 49 |
+
sid = await pocket_service.open(card_id, engine.state)
|
| 50 |
+
line = first_line
|
| 51 |
+
while True:
|
| 52 |
+
turn = await pocket_service.run_turn(sid, line)
|
| 53 |
+
print(f"\n {BOLD}μΉ΄λ₯΄λ°λΌ{RESET}: {turn.reply}")
|
| 54 |
+
if turn.converged:
|
| 55 |
+
print(f"{DIM} (μ₯λ©΄μ΄ μ‘°μ©ν λ€μμΌλ‘ κΈ°μ΄λ€ β {turn.reason}){RESET}")
|
| 56 |
+
break
|
| 57 |
+
line = input("λ(λ‘λΌ) > ").strip()
|
| 58 |
+
if not line:
|
| 59 |
+
break
|
| 60 |
+
log = await pocket_service.close(sid, engine.state)
|
| 61 |
+
return log
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def pick_or_chat(step: StepResult, policy: str, chat_enabled: bool):
|
| 65 |
+
"""μ νμ§ μ
λ ₯ λλ μμ ν
μ€νΈ(ν¬μΌ μ±ν
μ§μ
). λ°ν: ('choice', idx) | ('chat', text)."""
|
| 66 |
+
choices = step.choices
|
| 67 |
+
if policy != "ask":
|
| 68 |
+
for i, ch in enumerate(choices):
|
| 69 |
+
if ch.axis == policy:
|
| 70 |
+
return "choice", i
|
| 71 |
+
for i, ch in enumerate(choices):
|
| 72 |
+
if ch.axis in (None, "neutral"):
|
| 73 |
+
return "choice", i
|
| 74 |
+
return "choice", 0
|
| 75 |
+
for i, ch in enumerate(choices):
|
| 76 |
+
print(f" [{i + 1}] {ch.label}")
|
| 77 |
+
if chat_enabled:
|
| 78 |
+
print(f"{DIM} (λ²νΈ λμ νκ³ μΆμ λ§μ μ°λ©΄ μΉ΄λ₯΄λ°λΌμ λνν©λλ€){RESET}")
|
| 79 |
+
while True:
|
| 80 |
+
raw = input("μ ν > ").strip()
|
| 81 |
+
if raw.isdigit() and 1 <= int(raw) <= len(choices):
|
| 82 |
+
return "choice", int(raw) - 1
|
| 83 |
+
if raw and chat_enabled:
|
| 84 |
+
return "chat", raw
|
| 85 |
+
if raw and not chat_enabled:
|
| 86 |
+
print(f"{DIM} (ν¬μΌ μ±ν
κΊΌμ§ β λ²νΈλ₯Ό μ
λ ₯νμΈμ){RESET}")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
async def main():
|
| 90 |
+
ap = argparse.ArgumentParser()
|
| 91 |
+
ap.add_argument("--policy", default="ask", choices=["ask", "trust", "doubt", "neutral"])
|
| 92 |
+
ap.add_argument("--quiet", action="store_true")
|
| 93 |
+
ap.add_argument("--content", default="content")
|
| 94 |
+
ap.add_argument("--chat", action="store_true", help="ν¬μΌ μμ μ±ν
κ°μ νμ±ν")
|
| 95 |
+
ap.add_argument("--no-chat", action="store_true", help="ν¬μΌ μμ μ±ν
λΉνμ±ν")
|
| 96 |
+
args = ap.parse_args()
|
| 97 |
+
|
| 98 |
+
chat_enabled = not args.no_chat and (args.chat or bool(os.environ.get("OPENAI_API_KEY")))
|
| 99 |
+
repo = ContentRepository(args.content)
|
| 100 |
+
engine = StoryEngine(repo)
|
| 101 |
+
|
| 102 |
+
pocket_service = None
|
| 103 |
+
if chat_enabled:
|
| 104 |
+
from engine.plugins.logging import TurnLoggingPlugin
|
| 105 |
+
from engine.services.pocket import PocketService
|
| 106 |
+
pocket_service = PocketService(repo, plugins=[TurnLoggingPlugin()])
|
| 107 |
+
|
| 108 |
+
step = engine.start()
|
| 109 |
+
while step.ending is None:
|
| 110 |
+
render(step, args.quiet)
|
| 111 |
+
if step.choices:
|
| 112 |
+
while True:
|
| 113 |
+
kind, val = pick_or_chat(step, args.policy, chat_enabled)
|
| 114 |
+
if kind == "choice":
|
| 115 |
+
choice, step = engine.choose(val)
|
| 116 |
+
if not args.quiet:
|
| 117 |
+
print(f"\n{DIM}β· {choice.label}{RESET}")
|
| 118 |
+
if choice.result:
|
| 119 |
+
print(choice.result)
|
| 120 |
+
break
|
| 121 |
+
card = repo.get_card(engine.current_id)
|
| 122 |
+
if not card.can_chat:
|
| 123 |
+
print(f"{DIM} (μ΄ μ₯λ©΄μμ κ·Έλ
κ° μ리μ μλ€ β λ²νΈλ₯Ό μ
λ ₯νμΈμ){RESET}")
|
| 124 |
+
continue
|
| 125 |
+
await pocket_chat(pocket_service, engine, engine.current_id, val)
|
| 126 |
+
# μ±ν
ν κ°μ μΉ΄λμ μ νμ§λ‘ λ³΅κ· (λλ¬μ Β·λΆκΈ°λ μ½λκ° λ³΄μ₯)
|
| 127 |
+
else:
|
| 128 |
+
if not args.quiet and args.policy == "ask":
|
| 129 |
+
input(f"{DIM}(λ€μ β){RESET}")
|
| 130 |
+
step = engine.advance()
|
| 131 |
+
|
| 132 |
+
s = engine.state
|
| 133 |
+
print(f"\n{BOLD}βββ μ±ν° μ’
λ£: {step.ending.label} βββ{RESET}")
|
| 134 |
+
print(f"{DIM}[λλ²κ·Έ β μ μ λΉλ
ΈμΆ] trust={s.trust_score} doubt={s.doubt_score} "
|
| 135 |
+
f"frailty={s.frailty} level={s.trust_level} μΉ΄λ {len(engine.visited)}μ₯ λ°©λ¬Έ{RESET}")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
asyncio.run(main())
|
scripts/sync_assets.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""μ°μΆ μ΄λ―Έμ§ μ¬λ¦Ό λκΈ°ν β μλ³Έ ν΄λ(κΆμ)μμ λ°λͺ¨κ° μ°λ μ΄λ―Έμ§λ§ web/assets/λ‘ λ³΅μ¬.
|
| 2 |
+
|
| 3 |
+
μμ²(κΆμ): μΉ΄λ₯΄λ°λΌ/0701_μΉ΄λ₯΄λ°λΌ/μ΄λ―Έμ§/ (λνλ μ°μΆλ¬Ό). web/assets/λ λ°°ν¬μ© νμλ¬Ό.
|
| 4 |
+
routerμ COVER_IMAGEΒ·IMAGE_MAPΒ·BACKGROUND_MAPΒ·INTRO_IMAGESκ° μ°Έμ‘°νλ
|
| 5 |
+
μλκ²½λ‘λ₯Ό κ·Έλλ‘ μ μ§ν μ± λ³΅μ¬νλ€ (κΆμλ router.py β λ³κ²½ μ ν¨κ» κ°±μ ).
|
| 6 |
+
|
| 7 |
+
μ€ν: python scripts/sync_assets.py
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import shutil
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 16 |
+
SRC = ROOT / "μΉ΄λ₯΄λ°λΌ" / "0701_μΉ΄λ₯΄λ°λΌ" / "μ΄λ―Έμ§"
|
| 17 |
+
DST = ROOT / "web" / "assets"
|
| 18 |
+
|
| 19 |
+
RELATIVE_PATHS = [
|
| 20 |
+
# νμ§Β·μ°μΆ μ»· (IMAGE_MAP)
|
| 21 |
+
"νλμ΄ μ΄λ―Έμ§/1.png", # νμ§
|
| 22 |
+
"νλμ΄ μ΄λ―Έμ§/4.png", # E01-07
|
| 23 |
+
"μΈλ¬Ό/μΉ΄λ₯΄λ°λΌ.png", # E03-03 + νμ μ΄μ
|
| 24 |
+
"μΈλ¬Ό/μΉ΄λ₯΄λ°λΌ κ°λ©΄.png", # E06-09
|
| 25 |
+
# νμ μ΄μ (SPEAKER_PORTRAIT)
|
| 26 |
+
"μΈλ¬Ό/μλ²μ§.png",
|
| 27 |
+
"μΈλ¬Ό/κ°μ κ΅μ¬.png", # λ§λλͺ¨μμ €
|
| 28 |
+
"μΈλ¬Ό/보λͺ¨.png", # λ§λ΄
|
| 29 |
+
|
| 30 |
+
# μΈνΈλ‘ μνμ€ (INTRO_IMAGES)
|
| 31 |
+
"μ€λ
Έλ§xμλ½_λ°©ν₯/κ°μ2.png", # μν μκ°
|
| 32 |
+
"μ€λ
Έλ§xμλ½_λ°©ν₯/κ°μ3.png", # λ±μ₯μΈλ¬Ό μκ°
|
| 33 |
+
"νλμ΄ μ΄λ―Έμ§/2.png", # 1μ₯ μμ
|
| 34 |
+
# μ₯μλ³ λ°°κ²½ (BACKGROUND_MAP)
|
| 35 |
+
"λ°°κ²½/μ±.png",
|
| 36 |
+
"λ°°κ²½/μΉ¨μ€.png",
|
| 37 |
+
"λ°°κ²½/μλΉ.png",
|
| 38 |
+
"λ°°κ²½/μκ° μ².png",
|
| 39 |
+
"λ°°κ²½/ννμ±λΉ.png",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def main() -> None:
|
| 44 |
+
if not SRC.exists():
|
| 45 |
+
raise SystemExit(f"μλ³Έ μ΄λ―Έμ§ ν΄λ μμ: {SRC}")
|
| 46 |
+
for rel in RELATIVE_PATHS:
|
| 47 |
+
src, dst = SRC / rel, DST / rel
|
| 48 |
+
if not src.exists():
|
| 49 |
+
raise SystemExit(f"μλ³Έ μ΄λ―Έμ§ λλ½: {src}")
|
| 50 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
shutil.copy2(src, dst)
|
| 52 |
+
print(f"볡μ¬: {rel}")
|
| 53 |
+
print(f"μλ£ β {len(RELATIVE_PATHS)}μ₯ β {DST}")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
if __name__ == "__main__":
|
| 57 |
+
main()
|
web/assets/λ°°κ²½/μλΉ.png
ADDED
|
Git LFS Details
|
web/assets/λ°°κ²½/μκ° μ².png
ADDED
|
Git LFS Details
|
web/assets/λ°°κ²½/ννμ±λΉ.png
ADDED
|
Git LFS Details
|
web/assets/μ€λ
Έλ§xμλ½_λ°©ν₯/κ°μ2.png
ADDED
|
Git LFS Details
|
web/assets/μ€λ
Έλ§xμλ½_λ°©ν₯/κ°μ3.png
ADDED
|
Git LFS Details
|
web/assets/μΈλ¬Ό/κ°μ κ΅μ¬.png
ADDED
|
Git LFS Details
|
web/assets/μΈλ¬Ό/보λͺ¨.png
ADDED
|
Git LFS Details
|
web/assets/μΈλ¬Ό/μλ²μ§.png
ADDED
|
Git LFS Details
|
web/assets/νλμ΄ μ΄λ―Έμ§/2.png
ADDED
|
Git LFS Details
|
web/index.html
CHANGED
|
@@ -20,6 +20,16 @@
|
|
| 20 |
overflow-x: hidden;
|
| 21 |
}
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
/* ββ μ§ν λ° + μ₯ λΌλ²¨ ββ */
|
| 24 |
#progress-wrap { position: fixed; top: 0; left: 0; right: 0; z-index: 40; }
|
| 25 |
#progress-bar { height: 2px; width: 0%; background: linear-gradient(90deg, var(--gold-dim), var(--gold));
|
|
@@ -41,9 +51,29 @@
|
|
| 41 |
margin: 2.4em 0; line-height: 2.1; word-break: keep-all; }
|
| 42 |
.center-line::before { content: "β "; color: var(--gold-dim); }
|
| 43 |
.center-line::after { content: " β"; color: var(--gold-dim); }
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
.stage-dir { color: var(--text-dim); font-style: italic; font-size: .92rem;
|
| 48 |
text-align: center; margin: 1.4em 0; }
|
| 49 |
.result-block { margin: 1.6em 0; padding: 1em 1.2em; background: rgba(201,168,106,.05);
|
|
@@ -52,9 +82,17 @@
|
|
| 52 |
.fade { animation: fadein 1.1s ease both; }
|
| 53 |
@keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; } }
|
| 54 |
|
| 55 |
-
/* ββ μ νμ§:
|
| 56 |
-
#choice-area { margin:
|
| 57 |
-
flex-direction: column; gap:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
button {
|
| 59 |
font-family: inherit; font-size: .98rem; cursor: pointer; word-break: keep-all;
|
| 60 |
background: rgba(26,34,51,.85); color: var(--text);
|
|
@@ -115,15 +153,37 @@
|
|
| 115 |
padding: 11px 14px; font-family: inherit; font-size: .96rem; }
|
| 116 |
#chat-input:focus { outline: none; border-color: var(--gold); }
|
| 117 |
|
| 118 |
-
/* ββ νμ§Β·μλ© ββ
|
|
|
|
|
|
|
| 119 |
#cover { position: fixed; inset: 0; z-index: 70; display: flex; flex-direction: column;
|
| 120 |
align-items: center; justify-content: flex-end; padding-bottom: 9vh;
|
| 121 |
-
background
|
| 122 |
-
#cover
|
| 123 |
-
|
| 124 |
-
#cover
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
letter-spacing: .3em; text-indent: .3em; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
.ending { text-align: center; }
|
| 128 |
.ending h2 { color: var(--gold); letter-spacing: .25em; margin: 20vh 0 1.4em; font-weight: 500; }
|
| 129 |
.ending p { color: var(--text-dim); }
|
|
@@ -131,14 +191,26 @@
|
|
| 131 |
</style>
|
| 132 |
</head>
|
| 133 |
<body>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
<div id="progress-wrap"><div id="progress-bar"></div></div>
|
| 135 |
<div id="progress-label"></div>
|
| 136 |
<div id="browse-ribbon">μ§λμ¨ νμ΄μ§ β μ€λ₯Έμͺ½μ λλ¬ μ§κΈμΌλ‘</div>
|
| 137 |
|
| 138 |
<div id="cover">
|
|
|
|
|
|
|
| 139 |
<button onclick="start()" id="start-btn">λ°€μ μ°λ€</button>
|
| 140 |
</div>
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
<div id="stage"><div id="page"></div></div>
|
| 143 |
|
| 144 |
<div id="nav-left" class="nav-zone" title="μ΄μ "></div>
|
|
@@ -177,10 +249,13 @@ const api = async (path, body) => {
|
|
| 177 |
|
| 178 |
/* ββ μμ ββ */
|
| 179 |
let RESUME = null; // {sid, step} β μ μ₯λ μΈμ
볡μλΆ
|
|
|
|
| 180 |
(async () => {
|
| 181 |
try {
|
| 182 |
const m = await api('/meta');
|
| 183 |
-
$('cover').style.backgroundImage = `url("${encodeURI(m.cover_image)}")`;
|
|
|
|
|
|
|
| 184 |
} catch (e) {}
|
| 185 |
const saved = localStorage.getItem('carmilla_sid');
|
| 186 |
if (saved) {
|
|
@@ -197,6 +272,7 @@ let RESUME = null; // {sid, step} β μ μ₯λ μΈμ
볡μ
|
|
| 197 |
async function start() {
|
| 198 |
$('start-btn').disabled = true;
|
| 199 |
let step;
|
|
|
|
| 200 |
if (RESUME) {
|
| 201 |
SID = RESUME.sid; step = RESUME.step;
|
| 202 |
} else {
|
|
@@ -206,26 +282,86 @@ async function start() {
|
|
| 206 |
step = data.step;
|
| 207 |
}
|
| 208 |
$('cover').remove();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
document.body.classList.add('show-hints');
|
| 210 |
setTimeout(() => document.body.classList.remove('show-hints'), 5000);
|
| 211 |
showStep(step, true);
|
| 212 |
}
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
/* ββ λ λ ββ */
|
|
|
|
|
|
|
| 215 |
function block(b) {
|
| 216 |
if (b.type === 'dialogue_center') return `<div class="center-line fade">${esc(b.text)}</div>`;
|
| 217 |
-
if (b.type === 'character_dialogue')
|
| 218 |
-
return `<div class="char-line fade"><span class="spk">${esc(b.speaker)}</span>β${esc(b.text)}β</div>`;
|
| 219 |
if (b.type === 'stage_direction') return `<div class="stage-dir fade">( ${esc(b.text)} )</div>`;
|
| 220 |
return `<div class="prose fade">${esc(b.text)}</div>`;
|
| 221 |
}
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
function pageHtml(step, fresh) {
|
| 224 |
const c = step.card;
|
| 225 |
let html = '';
|
| 226 |
if (fresh || c.episode !== lastEpisode) html += `<div class="ep-header fade">${esc(c.episode_title || c.episode)}</div>`;
|
| 227 |
html += `<div class="card-title fade">${esc(c.title)}</div>`;
|
| 228 |
-
html += step.narration
|
| 229 |
html += `<div id="choice-area">${choicesHtml(step)}</div>`;
|
| 230 |
return html;
|
| 231 |
}
|
|
@@ -259,20 +395,21 @@ function pushCutPage(step) { // μ΄λ―Έμ§ νμ΄μ§ λ²νΈ = λ³Έλ¬Έ λ²
|
|
| 259 |
const html = `<div class="page-cut"><img src="${encodeURI(step.image)}" alt="">` +
|
| 260 |
`<div class="cut-caption">${esc(step.card.title)}</div></div>`;
|
| 261 |
pushEntry(html, {index: step.progress.index - 1, total: step.progress.total},
|
| 262 |
-
step.card.episode_title);
|
| 263 |
}
|
| 264 |
|
| 265 |
function pushPage(step, fresh) {
|
| 266 |
lastEpisode = step.card.episode;
|
| 267 |
-
pushEntry(pageHtml(step, fresh), step.progress, step.card.episode_title);
|
| 268 |
}
|
| 269 |
|
| 270 |
-
function pushEntry(html, progress, ep) {
|
| 271 |
$('page').innerHTML = html;
|
| 272 |
-
history.push({html, progress, ep});
|
| 273 |
cursor = history.length - 1;
|
| 274 |
document.body.classList.remove('browsing');
|
| 275 |
setProgress(progress, ep);
|
|
|
|
| 276 |
window.scrollTo({top: 0});
|
| 277 |
}
|
| 278 |
|
|
@@ -281,6 +418,7 @@ function renderEntry(i) {
|
|
| 281 |
$('page').innerHTML = h.html;
|
| 282 |
document.body.classList.toggle('browsing', i < history.length - 1);
|
| 283 |
setProgress(h.progress, h.ep);
|
|
|
|
| 284 |
window.scrollTo({top: 0});
|
| 285 |
}
|
| 286 |
|
|
@@ -289,12 +427,19 @@ $('nav-right').onclick = () => go(+1);
|
|
| 289 |
$('nav-left').onclick = () => go(-1);
|
| 290 |
document.addEventListener('keydown', (e) => {
|
| 291 |
if (chatOpen) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
if (e.key === 'ArrowRight' || e.key === ' ') go(+1);
|
| 293 |
if (e.key === 'ArrowLeft') go(-1);
|
| 294 |
});
|
| 295 |
|
| 296 |
async function go(dir) {
|
| 297 |
-
if (busy || chatOpen || !SID || STEP
|
|
|
|
| 298 |
if (dir < 0) { // κ³Όκ±° νμ΄μ§ νμ΄λ³΄κΈ° (μ½κΈ° μ μ©)
|
| 299 |
if (cursor <= 0) return;
|
| 300 |
cursor--;
|
|
@@ -396,10 +541,11 @@ $('chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') send
|
|
| 396 |
function renderEnding(end) {
|
| 397 |
setProgress(STEP.progress, '');
|
| 398 |
$('progress-label').textContent = '';
|
|
|
|
| 399 |
$('page').innerHTML = `<div class="ending fade">
|
| 400 |
<h2>${esc(end.label)}</h2><p>${esc(end.description)}</p>
|
| 401 |
<button onclick="restart()">λ€μ, μ²μμ λ°€μΌλ‘</button></div>`;
|
| 402 |
-
history.push({html: $('page').innerHTML, progress: STEP.progress, ep: ''});
|
| 403 |
cursor = history.length - 1;
|
| 404 |
}
|
| 405 |
|
|
|
|
| 20 |
overflow-x: hidden;
|
| 21 |
}
|
| 22 |
|
| 23 |
+
/* ββ μ₯μ λ°°κ²½ (λΉμ£ΌμΌ λ
Έλ²¨) β μλ²κ° νμ΄μ§λ§λ€ μ§μ , ν¬λ‘μ€νμ΄λ ββ */
|
| 24 |
+
.bg-layer { position: fixed; inset: 0; z-index: -2; pointer-events: none;
|
| 25 |
+
background-size: cover; background-position: center;
|
| 26 |
+
opacity: 0; transition: opacity 1.6s ease;
|
| 27 |
+
filter: brightness(.5) saturate(.85); }
|
| 28 |
+
.bg-layer.show { opacity: .55; }
|
| 29 |
+
#bg-dim { position: fixed; inset: 0; z-index: -1; pointer-events: none;
|
| 30 |
+
background: linear-gradient(rgba(11,14,20,.3) 0%, rgba(11,14,20,.55) 45%,
|
| 31 |
+
rgba(11,14,20,.82) 100%); }
|
| 32 |
+
|
| 33 |
/* ββ μ§ν λ° + μ₯ λΌλ²¨ ββ */
|
| 34 |
#progress-wrap { position: fixed; top: 0; left: 0; right: 0; z-index: 40; }
|
| 35 |
#progress-bar { height: 2px; width: 0%; background: linear-gradient(90deg, var(--gold-dim), var(--gold));
|
|
|
|
| 51 |
margin: 2.4em 0; line-height: 2.1; word-break: keep-all; }
|
| 52 |
.center-line::before { content: "β "; color: var(--gold-dim); }
|
| 53 |
.center-line::after { content: " β"; color: var(--gold-dim); }
|
| 54 |
+
/* ββ μΊλ¦ν° λ°ν (λΉμ£ΌμΌ λ
Έλ²¨ β μ°Έκ³ : νλμ΄ μ΄λ―Έμ§/7.png) ββ
|
| 55 |
+
μ΄μ μΌλ¬μ€νΈ β μ₯μ μ΄λ¦ν β λμ¬. μ΄μ μλ νμλ μ΄λ¦ν+λμ¬λ§ */
|
| 56 |
+
.vn-block { margin: 2.6em auto; text-align: center; }
|
| 57 |
+
.vn-portrait { display: block; margin: 0 auto 16px;
|
| 58 |
+
max-width: min(80vw, 430px); max-height: 46vh; object-fit: contain;
|
| 59 |
+
border: 1px solid rgba(201,168,106,.28);
|
| 60 |
+
box-shadow: 0 0 70px rgba(0,0,0,.85); }
|
| 61 |
+
.vn-name { display: inline-block; position: relative; margin-bottom: 14px;
|
| 62 |
+
padding: 5px 30px; font-size: .9rem; color: var(--gold);
|
| 63 |
+
letter-spacing: .3em; text-indent: .3em;
|
| 64 |
+
border: 1px solid rgba(201,168,106,.5); background: rgba(11,14,20,.72); }
|
| 65 |
+
.vn-name::before, .vn-name::after {
|
| 66 |
+
content: ""; position: absolute; top: 50%; width: 46px; height: 1px;
|
| 67 |
+
background: linear-gradient(90deg, transparent, var(--gold-dim)); }
|
| 68 |
+
.vn-name::before { right: 100%; margin-right: 10px; }
|
| 69 |
+
.vn-name::after { left: 100%; margin-left: 10px; transform: scaleX(-1); }
|
| 70 |
+
.vn-line { font-size: 1.06rem; color: #e5dfd2; line-height: 2.05;
|
| 71 |
+
word-break: keep-all; margin: .35em 0; }
|
| 72 |
+
.vn-block.player { text-align: right; }
|
| 73 |
+
.vn-block.player .vn-name { padding: 4px 18px; font-size: .8rem;
|
| 74 |
+
color: var(--text-dim); border-color: rgba(143,140,133,.4); }
|
| 75 |
+
.vn-block.player .vn-name::before, .vn-block.player .vn-name::after { display: none; }
|
| 76 |
+
.vn-block.player .vn-line { color: var(--text-dim); font-size: 1rem; }
|
| 77 |
.stage-dir { color: var(--text-dim); font-style: italic; font-size: .92rem;
|
| 78 |
text-align: center; margin: 1.4em 0; }
|
| 79 |
.result-block { margin: 1.6em 0; padding: 1em 1.2em; background: rgba(201,168,106,.05);
|
|
|
|
| 82 |
.fade { animation: fadein 1.1s ease both; }
|
| 83 |
@keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; } }
|
| 84 |
|
| 85 |
+
/* ββ μ νμ§: λννλ― β βΊ λ§μ»€ + κ°λ ꡬλΆμ 리μ€νΈ (μ°Έκ³ : νλμ΄ μ΄λ―Έμ§/7.png) ββ */
|
| 86 |
+
#choice-area { margin: 8vh auto 0; max-width: 520px; display: flex;
|
| 87 |
+
flex-direction: column; gap: 0; }
|
| 88 |
+
#choice-area button { background: transparent; border: none; border-radius: 0;
|
| 89 |
+
border-top: 1px solid rgba(201,168,106,.22);
|
| 90 |
+
text-align: left; padding: 17px 12px; font-size: 1rem; }
|
| 91 |
+
#choice-area button:last-child { border-bottom: 1px solid rgba(201,168,106,.22); }
|
| 92 |
+
#choice-area button::before { content: "βΊ"; color: var(--gold-dim);
|
| 93 |
+
margin-right: .8em; font-size: 1.05rem; }
|
| 94 |
+
#choice-area button:hover { color: var(--gold); background: rgba(201,168,106,.06); }
|
| 95 |
+
#choice-area button.chat-open { border-style: solid; }
|
| 96 |
button {
|
| 97 |
font-family: inherit; font-size: .98rem; cursor: pointer; word-break: keep-all;
|
| 98 |
background: rgba(26,34,51,.85); color: var(--text);
|
|
|
|
| 153 |
padding: 11px 14px; font-family: inherit; font-size: .96rem; }
|
| 154 |
#chat-input:focus { outline: none; border-color: var(--gold); }
|
| 155 |
|
| 156 |
+
/* ββ νμ§Β·μλ© ββ
|
| 157 |
+
μλ³Έ(1086Γ1448)μ νλ©΄μ coverλ‘ λ리면 ν΄μλκ° κΉ¨μ§λ€ β
|
| 158 |
+
λΈλ¬ λ°±λλ‘ + μλ³Έ ν¬κΈ° μ΄νλ‘λ§ μ λͺ
νκ² μ€μ λ°°μΉ (μΈνΈλ‘λ λμΌ κΈ°λ²) */
|
| 159 |
#cover { position: fixed; inset: 0; z-index: 70; display: flex; flex-direction: column;
|
| 160 |
align-items: center; justify-content: flex-end; padding-bottom: 9vh;
|
| 161 |
+
background: var(--ink); overflow: hidden; }
|
| 162 |
+
#cover-blur { position: absolute; inset: -48px; background-size: cover;
|
| 163 |
+
background-position: center 20%; filter: blur(30px) brightness(.45); }
|
| 164 |
+
#cover-art { position: absolute; left: 50%; top: 46%; transform: translate(-50%, -50%);
|
| 165 |
+
max-width: min(92vw, 1086px); max-height: min(84vh, 1448px);
|
| 166 |
+
box-shadow: 0 0 90px rgba(0,0,0,.85);
|
| 167 |
+
border: 1px solid rgba(201,168,106,.2); }
|
| 168 |
+
#cover::after { content: ""; position: absolute; inset: 0; z-index: 1;
|
| 169 |
+
background: linear-gradient(transparent 55%, rgba(11,14,20,.9)); }
|
| 170 |
+
#cover button { position: relative; z-index: 2; padding: 14px 52px;
|
| 171 |
+
background: rgba(11,14,20,.75); font-size: 1.02rem;
|
| 172 |
letter-spacing: .3em; text-indent: .3em; }
|
| 173 |
+
|
| 174 |
+
/* ββ μΈνΈλ‘ μνμ€ (μν μκ° β μΈλ¬Ό μκ° β 1μ₯ μμ) ββ */
|
| 175 |
+
#intro { position: fixed; inset: 0; z-index: 68; background: var(--ink);
|
| 176 |
+
display: none; align-items: center; justify-content: center;
|
| 177 |
+
cursor: pointer; overflow: hidden; }
|
| 178 |
+
#intro.open { display: flex; }
|
| 179 |
+
#intro-blur { position: absolute; inset: -48px; background-size: cover;
|
| 180 |
+
background-position: center; filter: blur(30px) brightness(.4); }
|
| 181 |
+
#intro-art { position: relative; max-width: min(94vw, 1086px); max-height: min(92vh, 1491px);
|
| 182 |
+
object-fit: contain; box-shadow: 0 0 90px rgba(0,0,0,.85);
|
| 183 |
+
border: 1px solid rgba(201,168,106,.2); animation: fadein 1s ease both; }
|
| 184 |
+
#intro-hint { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
| 185 |
+
color: var(--text-dim); font-size: .72rem; letter-spacing: .25em;
|
| 186 |
+
animation: fadein 1.6s ease both 1s; }
|
| 187 |
.ending { text-align: center; }
|
| 188 |
.ending h2 { color: var(--gold); letter-spacing: .25em; margin: 20vh 0 1.4em; font-weight: 500; }
|
| 189 |
.ending p { color: var(--text-dim); }
|
|
|
|
| 191 |
</style>
|
| 192 |
</head>
|
| 193 |
<body>
|
| 194 |
+
<div class="bg-layer" id="bgA"></div>
|
| 195 |
+
<div class="bg-layer" id="bgB"></div>
|
| 196 |
+
<div id="bg-dim"></div>
|
| 197 |
+
|
| 198 |
<div id="progress-wrap"><div id="progress-bar"></div></div>
|
| 199 |
<div id="progress-label"></div>
|
| 200 |
<div id="browse-ribbon">μ§λμ¨ νμ΄μ§ β μ€λ₯Έμͺ½μ λλ¬ μ§κΈμΌλ‘</div>
|
| 201 |
|
| 202 |
<div id="cover">
|
| 203 |
+
<div id="cover-blur"></div>
|
| 204 |
+
<img id="cover-art" alt="μΉ΄λ₯΄λ°λΌ νμ§">
|
| 205 |
<button onclick="start()" id="start-btn">λ°€μ μ°λ€</button>
|
| 206 |
</div>
|
| 207 |
|
| 208 |
+
<div id="intro">
|
| 209 |
+
<div id="intro-blur"></div>
|
| 210 |
+
<img id="intro-art" alt="">
|
| 211 |
+
<div id="intro-hint">β λλ¬μ κ³μ β</div>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
<div id="stage"><div id="page"></div></div>
|
| 215 |
|
| 216 |
<div id="nav-left" class="nav-zone" title="μ΄μ "></div>
|
|
|
|
| 249 |
|
| 250 |
/* ββ μμ ββ */
|
| 251 |
let RESUME = null; // {sid, step} β μ μ₯λ μΈμ
볡μλΆ
|
| 252 |
+
let INTRO = []; // μΈνΈλ‘ μνμ€ μ΄λ―Έμ§ (μ μμμλ§, μλ² /meta)
|
| 253 |
(async () => {
|
| 254 |
try {
|
| 255 |
const m = await api('/meta');
|
| 256 |
+
$('cover-blur').style.backgroundImage = `url("${encodeURI(m.cover_image)}")`;
|
| 257 |
+
$('cover-art').src = encodeURI(m.cover_image);
|
| 258 |
+
INTRO = m.intro_images || [];
|
| 259 |
} catch (e) {}
|
| 260 |
const saved = localStorage.getItem('carmilla_sid');
|
| 261 |
if (saved) {
|
|
|
|
| 272 |
async function start() {
|
| 273 |
$('start-btn').disabled = true;
|
| 274 |
let step;
|
| 275 |
+
const fresh = !RESUME;
|
| 276 |
if (RESUME) {
|
| 277 |
SID = RESUME.sid; step = RESUME.step;
|
| 278 |
} else {
|
|
|
|
| 282 |
step = data.step;
|
| 283 |
}
|
| 284 |
$('cover').remove();
|
| 285 |
+
if (fresh && INTRO.length) return showIntro(0, step); // μ μμμλ§ μΈνΈλ‘
|
| 286 |
+
beginReading(step);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function beginReading(step) {
|
| 290 |
document.body.classList.add('show-hints');
|
| 291 |
setTimeout(() => document.body.classList.remove('show-hints'), 5000);
|
| 292 |
showStep(step, true);
|
| 293 |
}
|
| 294 |
|
| 295 |
+
function showIntro(i, step) { // μν μκ° β μΈλ¬Ό μκ° β 1μ₯ μμ β λ³Έλ¬Έ
|
| 296 |
+
const el = $('intro');
|
| 297 |
+
$('intro-blur').style.backgroundImage = `url("${encodeURI(INTRO[i])}")`;
|
| 298 |
+
$('intro-art').src = encodeURI(INTRO[i]);
|
| 299 |
+
el.classList.add('open');
|
| 300 |
+
el.onclick = () => {
|
| 301 |
+
if (i + 1 < INTRO.length) showIntro(i + 1, step);
|
| 302 |
+
else { el.classList.remove('open'); el.onclick = null; beginReading(step); }
|
| 303 |
+
};
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
/* ββ μ₯μ λ°°κ²½ (ν¬λ‘μ€νμ΄λ) ββ */
|
| 307 |
+
let bgFlip = false, bgCurrent = null;
|
| 308 |
+
function setBackground(url) {
|
| 309 |
+
if ((url || null) === bgCurrent) return;
|
| 310 |
+
bgCurrent = url || null;
|
| 311 |
+
const nxt = $(bgFlip ? 'bgA' : 'bgB'), prv = $(bgFlip ? 'bgB' : 'bgA');
|
| 312 |
+
bgFlip = !bgFlip;
|
| 313 |
+
if (url) { nxt.style.backgroundImage = `url("${encodeURI(url)}")`; nxt.classList.add('show'); }
|
| 314 |
+
else nxt.classList.remove('show');
|
| 315 |
+
prv.classList.remove('show');
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
/* ββ λ λ ββ */
|
| 319 |
+
const PLAYER_SPEAKERS = new Set(['μ μ ', 'λ‘λΌ', 'λ']); // 1μΈμΉ β μ΄μ μμ΄ μ€λ₯Έμͺ½ μ λ ¬
|
| 320 |
+
|
| 321 |
function block(b) {
|
| 322 |
if (b.type === 'dialogue_center') return `<div class="center-line fade">${esc(b.text)}</div>`;
|
|
|
|
|
|
|
| 323 |
if (b.type === 'stage_direction') return `<div class="stage-dir fade">( ${esc(b.text)} )</div>`;
|
| 324 |
return `<div class="prose fade">${esc(b.text)}</div>`;
|
| 325 |
}
|
| 326 |
|
| 327 |
+
function vnBlock(spk, portrait, lines) {
|
| 328 |
+
const player = PLAYER_SPEAKERS.has(spk);
|
| 329 |
+
let h = `<div class="vn-block fade${player ? ' player' : ''}">`;
|
| 330 |
+
if (portrait && !player)
|
| 331 |
+
h += `<img class="vn-portrait" src="${encodeURI(portrait)}" alt="${esc(spk)}">`;
|
| 332 |
+
h += `<div class="vn-name">${esc(player ? 'λ' : spk)}</div>`;
|
| 333 |
+
for (const t of lines) h += `<div class="vn-line">β${esc(t)}β</div>`;
|
| 334 |
+
return h + `</div>`;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
function narrationHtml(blocks) {
|
| 338 |
+
// μ°μλ κ°μ νμμ λ°νλ ν VN λΈλ‘μΌλ‘. μ΄μμ νμ΄μ§λΉ νμλ³ 1νλ§
|
| 339 |
+
let out = '';
|
| 340 |
+
const portraitShown = new Set();
|
| 341 |
+
for (let i = 0; i < blocks.length;) {
|
| 342 |
+
const b = blocks[i];
|
| 343 |
+
if (b.type !== 'character_dialogue') { out += block(b); i++; continue; }
|
| 344 |
+
const spk = b.speaker, lines = [];
|
| 345 |
+
let portrait = null;
|
| 346 |
+
while (i < blocks.length && blocks[i].type === 'character_dialogue'
|
| 347 |
+
&& blocks[i].speaker === spk) {
|
| 348 |
+
lines.push(blocks[i].text);
|
| 349 |
+
portrait = portrait || blocks[i].portrait;
|
| 350 |
+
i++;
|
| 351 |
+
}
|
| 352 |
+
if (portrait && portraitShown.has(spk)) portrait = null;
|
| 353 |
+
if (portrait) portraitShown.add(spk);
|
| 354 |
+
out += vnBlock(spk, portrait, lines);
|
| 355 |
+
}
|
| 356 |
+
return out;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
function pageHtml(step, fresh) {
|
| 360 |
const c = step.card;
|
| 361 |
let html = '';
|
| 362 |
if (fresh || c.episode !== lastEpisode) html += `<div class="ep-header fade">${esc(c.episode_title || c.episode)}</div>`;
|
| 363 |
html += `<div class="card-title fade">${esc(c.title)}</div>`;
|
| 364 |
+
html += narrationHtml(step.narration);
|
| 365 |
html += `<div id="choice-area">${choicesHtml(step)}</div>`;
|
| 366 |
return html;
|
| 367 |
}
|
|
|
|
| 395 |
const html = `<div class="page-cut"><img src="${encodeURI(step.image)}" alt="">` +
|
| 396 |
`<div class="cut-caption">${esc(step.card.title)}</div></div>`;
|
| 397 |
pushEntry(html, {index: step.progress.index - 1, total: step.progress.total},
|
| 398 |
+
step.card.episode_title, step.background);
|
| 399 |
}
|
| 400 |
|
| 401 |
function pushPage(step, fresh) {
|
| 402 |
lastEpisode = step.card.episode;
|
| 403 |
+
pushEntry(pageHtml(step, fresh), step.progress, step.card.episode_title, step.background);
|
| 404 |
}
|
| 405 |
|
| 406 |
+
function pushEntry(html, progress, ep, bg) {
|
| 407 |
$('page').innerHTML = html;
|
| 408 |
+
history.push({html, progress, ep, bg});
|
| 409 |
cursor = history.length - 1;
|
| 410 |
document.body.classList.remove('browsing');
|
| 411 |
setProgress(progress, ep);
|
| 412 |
+
setBackground(bg);
|
| 413 |
window.scrollTo({top: 0});
|
| 414 |
}
|
| 415 |
|
|
|
|
| 418 |
$('page').innerHTML = h.html;
|
| 419 |
document.body.classList.toggle('browsing', i < history.length - 1);
|
| 420 |
setProgress(h.progress, h.ep);
|
| 421 |
+
setBackground(h.bg);
|
| 422 |
window.scrollTo({top: 0});
|
| 423 |
}
|
| 424 |
|
|
|
|
| 427 |
$('nav-left').onclick = () => go(-1);
|
| 428 |
document.addEventListener('keydown', (e) => {
|
| 429 |
if (chatOpen) return;
|
| 430 |
+
const intro = $('intro');
|
| 431 |
+
if (intro && intro.classList.contains('open')) { // μΈνΈλ‘ μ€ β β/μ€νμ΄μ€λ‘ λκΉ
|
| 432 |
+
if ((e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') && intro.onclick)
|
| 433 |
+
intro.onclick();
|
| 434 |
+
return;
|
| 435 |
+
}
|
| 436 |
if (e.key === 'ArrowRight' || e.key === ' ') go(+1);
|
| 437 |
if (e.key === 'ArrowLeft') go(-1);
|
| 438 |
});
|
| 439 |
|
| 440 |
async function go(dir) {
|
| 441 |
+
if (busy || chatOpen || !SID || !STEP
|
| 442 |
+
|| STEP.ending && cursor >= history.length - 1 && dir > 0) return;
|
| 443 |
if (dir < 0) { // κ³Όκ±° νμ΄μ§ νμ΄λ³΄κΈ° (μ½κΈ° μ μ©)
|
| 444 |
if (cursor <= 0) return;
|
| 445 |
cursor--;
|
|
|
|
| 541 |
function renderEnding(end) {
|
| 542 |
setProgress(STEP.progress, '');
|
| 543 |
$('progress-label').textContent = '';
|
| 544 |
+
setBackground(null); // μλ© β λ°°κ²½μ κ±·κ³ μ μ μΌλ‘
|
| 545 |
$('page').innerHTML = `<div class="ending fade">
|
| 546 |
<h2>${esc(end.label)}</h2><p>${esc(end.description)}</p>
|
| 547 |
<button onclick="restart()">λ€μ, μ²μμ λ°€μΌλ‘</button></div>`;
|
| 548 |
+
history.push({html: $('page').innerHTML, progress: STEP.progress, ep: '', bg: null});
|
| 549 |
cursor = history.length - 1;
|
| 550 |
}
|
| 551 |
|