SupPepper commited on
Commit Β·
8640b91
1
Parent(s): d09ba91
save
Browse files- frontend/index.html +136 -2
- visualnovel/engine.py +7 -1
- visualnovel/memory.py +22 -0
- visualnovel/painter.py +29 -0
- visualnovel/prompts.py +7 -0
- visualnovel/schemas.py +2 -1
frontend/index.html
CHANGED
|
@@ -214,6 +214,73 @@
|
|
| 214 |
}
|
| 215 |
#settings-save-btn:hover{background:rgba(232,193,114,.22)}
|
| 216 |
#settings-save-btn:disabled{opacity:.5; cursor:wait}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
/* ---- book intro overlay ---- */
|
| 218 |
#intro-book{
|
| 219 |
position:fixed; inset:0; z-index:30; display:flex; align-items:center; justify-content:center;
|
|
@@ -421,8 +488,9 @@
|
|
| 421 |
}
|
| 422 |
.bond-graph-wrap svg{ width:100%; max-height:280px; display:block; overflow:visible }
|
| 423 |
.bond-list{ padding:0 4px 12px }
|
| 424 |
-
.bond-pair{ margin-bottom:7px; padding:
|
| 425 |
-
.bond-row{ display:flex; align-items:center; gap:5px; padding:
|
|
|
|
| 426 |
.bond-name{ font-family:var(--display); min-width:56px; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
|
| 427 |
.bond-arrow{ color:var(--ink-dim); flex-shrink:0; font-size:.7rem }
|
| 428 |
.bond-note{ font-style:italic; flex:1; font-size:.73rem }
|
|
@@ -517,6 +585,16 @@
|
|
| 517 |
</div>
|
| 518 |
</div>
|
| 519 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
<!-- Choice screen: new game or load file -->
|
| 521 |
<div id="choice-screen" style="position:fixed;inset:0;z-index:10;display:grid;place-items:center;
|
| 522 |
background:radial-gradient(80% 80% at 50% 30%, var(--dusk-2), var(--dusk-0))">
|
|
@@ -960,6 +1038,44 @@ function render(v){
|
|
| 960 |
|
| 961 |
// target chips β show only when multiple characters are on stage
|
| 962 |
updateTargetRow(v.present);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 963 |
}
|
| 964 |
|
| 965 |
function showNotif(msg){
|
|
@@ -1158,6 +1274,24 @@ $("book-close").addEventListener("click", () => {
|
|
| 1158 |
$("say").focus();
|
| 1159 |
});
|
| 1160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1161 |
// --- choice screen ---
|
| 1162 |
$("btn-new").addEventListener("click", () => {
|
| 1163 |
$("choice-screen").classList.add("hidden");
|
|
|
|
| 214 |
}
|
| 215 |
#settings-save-btn:hover{background:rgba(232,193,114,.22)}
|
| 216 |
#settings-save-btn:disabled{opacity:.5; cursor:wait}
|
| 217 |
+
/* ---- ending overlays (victory / defeat) ---- */
|
| 218 |
+
#ending-overlay{
|
| 219 |
+
position:fixed; inset:0; z-index:50; display:none; flex-direction:column;
|
| 220 |
+
align-items:center; justify-content:center; padding:40px 24px;
|
| 221 |
+
text-align:center; overflow:hidden;
|
| 222 |
+
}
|
| 223 |
+
#ending-overlay.show{ display:flex }
|
| 224 |
+
#ending-overlay.victory{
|
| 225 |
+
background:radial-gradient(ellipse at 50% 0%, #2e1030 0%, #150920 50%, #08050f 100%);
|
| 226 |
+
}
|
| 227 |
+
#ending-overlay.defeat{
|
| 228 |
+
background:radial-gradient(ellipse at 50% 0%, #1a0808 0%, #0d0505 50%, #050305 100%);
|
| 229 |
+
}
|
| 230 |
+
/* Dark veil over the image so text stays readable */
|
| 231 |
+
#ending-overlay::before{
|
| 232 |
+
content:""; position:absolute; inset:0; pointer-events:none;
|
| 233 |
+
background:var(--ending-dim, rgba(10,6,18,.72));
|
| 234 |
+
z-index:0;
|
| 235 |
+
}
|
| 236 |
+
/* Particle shimmer layer */
|
| 237 |
+
#ending-overlay::after{
|
| 238 |
+
content:""; position:absolute; inset:0; pointer-events:none; z-index:0;
|
| 239 |
+
background-image:
|
| 240 |
+
radial-gradient(circle, rgba(255,255,255,.12) 1px, transparent 1px),
|
| 241 |
+
radial-gradient(circle, rgba(255,255,255,.06) 1px, transparent 1px);
|
| 242 |
+
background-size:120px 120px, 80px 80px;
|
| 243 |
+
background-position:0 0, 40px 40px;
|
| 244 |
+
animation:endingDrift 20s linear infinite;
|
| 245 |
+
opacity:.5;
|
| 246 |
+
}
|
| 247 |
+
#ending-overlay.defeat::after{ opacity:.18 }
|
| 248 |
+
/* All direct children must sit above the pseudo-elements */
|
| 249 |
+
#ending-overlay > *{ position:relative; z-index:1 }
|
| 250 |
+
@keyframes endingDrift{
|
| 251 |
+
from{ background-position:0 0, 40px 40px }
|
| 252 |
+
to { background-position:120px 120px, 160px 160px }
|
| 253 |
+
}
|
| 254 |
+
.ending-icon{ font-size:3.6rem; margin-bottom:16px; animation:endingPulse 2s ease-in-out infinite }
|
| 255 |
+
@keyframes endingPulse{ 0%,100%{transform:scale(1)} 50%{transform:scale(1.12)} }
|
| 256 |
+
.ending-title{
|
| 257 |
+
font-family:var(--display); font-size:2rem; letter-spacing:.15em;
|
| 258 |
+
margin-bottom:10px; position:relative;
|
| 259 |
+
}
|
| 260 |
+
.victory .ending-title{ color:#e8c172 }
|
| 261 |
+
.defeat .ending-title{ color:#c87e7e }
|
| 262 |
+
.ending-subtitle{
|
| 263 |
+
font-size:.78rem; letter-spacing:.3em; color:var(--ink-dim); margin-bottom:28px;
|
| 264 |
+
text-transform:uppercase;
|
| 265 |
+
}
|
| 266 |
+
.ending-divider{
|
| 267 |
+
font-size:.65rem; letter-spacing:.5em; margin-bottom:20px;
|
| 268 |
+
}
|
| 269 |
+
.victory .ending-divider{ color:#a07c3a }
|
| 270 |
+
.defeat .ending-divider{ color:#7c3a3a }
|
| 271 |
+
#ending-text{
|
| 272 |
+
font-family:var(--body); font-size:1.02rem; line-height:1.75;
|
| 273 |
+
max-width:520px; color:var(--ink); font-style:italic;
|
| 274 |
+
margin-bottom:36px; position:relative;
|
| 275 |
+
}
|
| 276 |
+
#ending-replay{
|
| 277 |
+
padding:12px 32px; border-radius:8px; cursor:pointer;
|
| 278 |
+
font-family:var(--display); font-size:.95rem; letter-spacing:.2px;
|
| 279 |
+
border:none; position:relative; transition:opacity .2s;
|
| 280 |
+
}
|
| 281 |
+
#ending-replay:hover{ opacity:.85 }
|
| 282 |
+
.victory #ending-replay{ background:rgba(232,193,114,.2); border:1px solid rgba(232,193,114,.5); color:var(--gold) }
|
| 283 |
+
.defeat #ending-replay{ background:rgba(200,126,126,.15); border:1px solid rgba(200,126,126,.4); color:#c87e7e }
|
| 284 |
/* ---- book intro overlay ---- */
|
| 285 |
#intro-book{
|
| 286 |
position:fixed; inset:0; z-index:30; display:flex; align-items:center; justify-content:center;
|
|
|
|
| 488 |
}
|
| 489 |
.bond-graph-wrap svg{ width:100%; max-height:280px; display:block; overflow:visible }
|
| 490 |
.bond-list{ padding:0 4px 12px }
|
| 491 |
+
.bond-pair{ margin-bottom:7px; padding:6px 9px; background:rgba(255,255,255,.04); border-radius:7px }
|
| 492 |
+
.bond-row{ display:flex; align-items:center; gap:5px; padding:4px 0; font-size:.78rem }
|
| 493 |
+
.bond-row + .bond-row{ border-top:1px solid rgba(255,255,255,.06); margin-top:2px }
|
| 494 |
.bond-name{ font-family:var(--display); min-width:56px; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
|
| 495 |
.bond-arrow{ color:var(--ink-dim); flex-shrink:0; font-size:.7rem }
|
| 496 |
.bond-note{ font-style:italic; flex:1; font-size:.73rem }
|
|
|
|
| 585 |
</div>
|
| 586 |
</div>
|
| 587 |
|
| 588 |
+
<!-- Ending overlay (victory / defeat) -->
|
| 589 |
+
<div id="ending-overlay">
|
| 590 |
+
<div class="ending-icon" id="ending-icon">β¦</div>
|
| 591 |
+
<div class="ending-title" id="ending-title">The End</div>
|
| 592 |
+
<div class="ending-subtitle" id="ending-subtitle"></div>
|
| 593 |
+
<div class="ending-divider">Β· Β· Β· Β· Β·</div>
|
| 594 |
+
<p id="ending-text"></p>
|
| 595 |
+
<button id="ending-replay">Play again</button>
|
| 596 |
+
</div>
|
| 597 |
+
|
| 598 |
<!-- Choice screen: new game or load file -->
|
| 599 |
<div id="choice-screen" style="position:fixed;inset:0;z-index:10;display:grid;place-items:center;
|
| 600 |
background:radial-gradient(80% 80% at 50% 30%, var(--dusk-2), var(--dusk-0))">
|
|
|
|
| 1038 |
|
| 1039 |
// target chips β show only when multiple characters are on stage
|
| 1040 |
updateTargetRow(v.present);
|
| 1041 |
+
|
| 1042 |
+
// ββ Ending screen βββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββ
|
| 1043 |
+
if (v.ended && v.ending_text) {
|
| 1044 |
+
setTimeout(() => showEnding(v.ending_kind, v.ending_text, v.backdrop_url), 1800);
|
| 1045 |
+
}
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
function showEnding(kind, text, backdropUrl) {
|
| 1049 |
+
const overlay = $("ending-overlay");
|
| 1050 |
+
const isDefeat = kind === "defeat";
|
| 1051 |
+
overlay.className = "show " + (isDefeat ? "defeat" : "victory");
|
| 1052 |
+
// Use the generated ending illustration as the overlay background
|
| 1053 |
+
if (backdropUrl) {
|
| 1054 |
+
overlay.style.backgroundImage = `url(${backdropUrl})`;
|
| 1055 |
+
overlay.style.backgroundSize = "cover";
|
| 1056 |
+
overlay.style.backgroundPosition = "center";
|
| 1057 |
+
// Darken overlay content area so text stays readable over the image
|
| 1058 |
+
overlay.style.setProperty("--ending-dim", isDefeat
|
| 1059 |
+
? "rgba(5,3,5,.78)" : "rgba(10,6,18,.65)");
|
| 1060 |
+
}
|
| 1061 |
+
$("ending-icon").textContent = isDefeat ? "π" : "π";
|
| 1062 |
+
$("ending-title").textContent = isDefeat ? "Game Over" : "β¦ Fin β¦";
|
| 1063 |
+
$("ending-subtitle").textContent = isDefeat
|
| 1064 |
+
? "The wanderer walks aloneβ¦"
|
| 1065 |
+
: "A warm ending";
|
| 1066 |
+
// Typewrite the epilogue text
|
| 1067 |
+
const el = $("ending-text");
|
| 1068 |
+
el.textContent = "";
|
| 1069 |
+
let i = 0;
|
| 1070 |
+
const iv = setInterval(() => {
|
| 1071 |
+
if (i >= text.length) { clearInterval(iv); return; }
|
| 1072 |
+
el.textContent += text[i++];
|
| 1073 |
+
}, 28);
|
| 1074 |
+
// Replay button label
|
| 1075 |
+
$("ending-replay").textContent = isDefeat ? "Try again" : "Play again";
|
| 1076 |
+
// Music: overlay already triggered set_music via render, but be safe
|
| 1077 |
+
if (isDefeat && !bgmTrackName?.includes("sad")) switchMusic("sad");
|
| 1078 |
+
if (!isDefeat && !bgmTrackName?.includes("romantic")) switchMusic("romantic");
|
| 1079 |
}
|
| 1080 |
|
| 1081 |
function showNotif(msg){
|
|
|
|
| 1274 |
$("say").focus();
|
| 1275 |
});
|
| 1276 |
|
| 1277 |
+
// --- ending replay ---
|
| 1278 |
+
$("ending-replay").addEventListener("click", () => {
|
| 1279 |
+
// Stop music, reset ending overlay, go back to choice screen
|
| 1280 |
+
if (bgmCurrent) { _bgmFade(bgmCurrent, bgmCurrent.volume, 0, 800, () => { bgmCurrent.pause(); bgmCurrent.src = ""; }); bgmCurrent = null; }
|
| 1281 |
+
bgmTrackName = null;
|
| 1282 |
+
$("ending-overlay").className = ""; // hide
|
| 1283 |
+
// Reset game UI state
|
| 1284 |
+
history = []; journalTurn = 0; knownChars = []; npcBonds = [];
|
| 1285 |
+
$("place").textContent = ""; $("speaker").textContent = ""; $("line").textContent = "";
|
| 1286 |
+
$("sprites").innerHTML = ""; lastBg = null;
|
| 1287 |
+
$("backdrop").style.backgroundImage = "none"; $("backdrop").classList.remove("on");
|
| 1288 |
+
$("journal-btn").classList.add("hidden");
|
| 1289 |
+
$("settings-btn").classList.add("hidden");
|
| 1290 |
+
$("enter").disabled = false;
|
| 1291 |
+
$("enter").textContent = "Enter the story";
|
| 1292 |
+
$("choice-screen").classList.remove("hidden");
|
| 1293 |
+
});
|
| 1294 |
+
|
| 1295 |
// --- choice screen ---
|
| 1296 |
$("btn-new").addEventListener("click", () => {
|
| 1297 |
$("choice-screen").classList.add("hidden");
|
visualnovel/engine.py
CHANGED
|
@@ -202,7 +202,12 @@ class Engine:
|
|
| 202 |
s = self.state
|
| 203 |
assert s is not None
|
| 204 |
with collector.measure("painter_backdrop", turn=turn):
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
present = []
|
| 207 |
for cid in s.scene.present:
|
| 208 |
ch = s.characters.get(cid)
|
|
@@ -281,6 +286,7 @@ class Engine:
|
|
| 281 |
beat=s.beat,
|
| 282 |
ended=s.beat == "ended",
|
| 283 |
ending_text=s.flags.get("ending_text"),
|
|
|
|
| 284 |
turn_index=s.turn_index,
|
| 285 |
notifications=notifications or [],
|
| 286 |
intro_text=intro_text,
|
|
|
|
| 202 |
s = self.state
|
| 203 |
assert s is not None
|
| 204 |
with collector.measure("painter_backdrop", turn=turn):
|
| 205 |
+
if s.beat == "ended":
|
| 206 |
+
# Generate a dedicated ending illustration instead of the regular backdrop
|
| 207 |
+
ending_kind = s.flags.get("ending_kind", "warm")
|
| 208 |
+
backdrop = self.painter.ending_backdrop(s, ending_kind)
|
| 209 |
+
else:
|
| 210 |
+
backdrop = self.painter.backdrop(s) # cached -> cheap even when unchanged
|
| 211 |
present = []
|
| 212 |
for cid in s.scene.present:
|
| 213 |
ch = s.characters.get(cid)
|
|
|
|
| 286 |
beat=s.beat,
|
| 287 |
ended=s.beat == "ended",
|
| 288 |
ending_text=s.flags.get("ending_text"),
|
| 289 |
+
ending_kind=s.flags.get("ending_kind"),
|
| 290 |
turn_index=s.turn_index,
|
| 291 |
notifications=notifications or [],
|
| 292 |
intro_text=intro_text,
|
visualnovel/memory.py
CHANGED
|
@@ -34,6 +34,28 @@ def assemble_context(state: GameState, player_input: str) -> str:
|
|
| 34 |
if off_stage:
|
| 35 |
parts.append("KNOWN BUT OFF STAGE (can return β use their existing id):\n" + "\n".join(off_stage))
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
# Surface the current music track prominently so the LLM doesn't switch it needlessly.
|
| 38 |
current_music = state.flags.get("current_music")
|
| 39 |
if current_music:
|
|
|
|
| 34 |
if off_stage:
|
| 35 |
parts.append("KNOWN BUT OFF STAGE (can return β use their existing id):\n" + "\n".join(off_stage))
|
| 36 |
|
| 37 |
+
# ββ Ending milestone hints β injected when a character hits Β±100 ββββββββββ
|
| 38 |
+
for ch in state.characters.values():
|
| 39 |
+
if ch.relationship >= 100 and ch.id in state.scene.present:
|
| 40 |
+
parts.append(
|
| 41 |
+
f"*** ROMANCE PEAK: {ch.name}'s affection is at MAX (100/100). ***\n"
|
| 42 |
+
f"This turn MUST be their heartfelt confession. Emit ending(kind=\"warm\") "
|
| 43 |
+
f"with a poetic 2-4 sentence epilogue. Also emit set_music=\"romantic\"."
|
| 44 |
+
)
|
| 45 |
+
elif ch.relationship <= -100 and ch.id in state.scene.present:
|
| 46 |
+
parts.append(
|
| 47 |
+
f"*** RELATIONSHIP BROKEN: {ch.name} is at MIN (-100/100). ***\n"
|
| 48 |
+
f"They storm off with a final cutting line. MUST emit exit_character=\"{ch.id}\". "
|
| 49 |
+
f"If the stage becomes empty after this, also emit ending(kind=\"defeat\") "
|
| 50 |
+
f"with a dark 2-4 sentence epilogue. Emit set_music=\"sad\"."
|
| 51 |
+
)
|
| 52 |
+
# If scene already empty and not yet ended β nudge for defeat
|
| 53 |
+
if not state.scene.present and state.beat != "ended" and state.characters:
|
| 54 |
+
parts.append(
|
| 55 |
+
"*** THE STAGE IS EMPTY β the wanderer is completely alone. ***\n"
|
| 56 |
+
"Emit ending(kind=\"defeat\") with a melancholic epilogue. Emit set_music=\"sad\"."
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
# Surface the current music track prominently so the LLM doesn't switch it needlessly.
|
| 60 |
current_music = state.flags.get("current_music")
|
| 61 |
if current_music:
|
visualnovel/painter.py
CHANGED
|
@@ -134,6 +134,35 @@ class PainterBase:
|
|
| 134 |
negative_prompt=_SPRITE_NEGATIVE,
|
| 135 |
)
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
# -- internals --
|
| 138 |
def _cached(self, *, kind: str, key: str, prompt: str, seed: int,
|
| 139 |
negative_prompt: str = "", guidance_scale: float = 0.0,
|
|
|
|
| 134 |
negative_prompt=_SPRITE_NEGATIVE,
|
| 135 |
)
|
| 136 |
|
| 137 |
+
def ending_backdrop(self, state: GameState, kind: str) -> Path:
|
| 138 |
+
"""Generate (and cache) a special ending illustration based on the ending kind."""
|
| 139 |
+
style = _backdrop_style(state.style_guide)
|
| 140 |
+
_ENDING_DESCS: dict[str, tuple[str, str]] = {
|
| 141 |
+
"warm": ("cherry blossom park at golden sunset, petals falling softly, "
|
| 142 |
+
"empty bench under blooming trees, warm amber light filtering through branches",
|
| 143 |
+
"romantic warm"),
|
| 144 |
+
"bittersweet": ("misty autumn street at twilight, fallen leaves on cobblestones, "
|
| 145 |
+
"distant lamplight, empty path fading into soft fog",
|
| 146 |
+
"melancholic gentle"),
|
| 147 |
+
"strange": ("surreal moonlit garden, glowing silver motes drifting upward, "
|
| 148 |
+
"impossible geometry, dreamlike luminous plants",
|
| 149 |
+
"mysterious ethereal"),
|
| 150 |
+
"defeat": ("rain-soaked empty park bench at night, fallen leaves in puddles, "
|
| 151 |
+
"single dim lamppost, deserted street receding into darkness",
|
| 152 |
+
"desolate cold"),
|
| 153 |
+
}
|
| 154 |
+
desc, mood = _ENDING_DESCS.get(kind, _ENDING_DESCS["warm"])
|
| 155 |
+
prompt = (
|
| 156 |
+
f"background art, empty scenic environment, wide establishing shot, no humans, "
|
| 157 |
+
f"uninhabited location, {desc}, {mood} atmosphere, {style}, no text"
|
| 158 |
+
)
|
| 159 |
+
seed = (state.seed * 1234567 + sum(ord(c) for c in kind)) % (2 ** 31)
|
| 160 |
+
return self._cached(
|
| 161 |
+
kind="ending", key=kind, prompt=prompt, seed=seed,
|
| 162 |
+
negative_prompt=_BACKDROP_NEGATIVE,
|
| 163 |
+
guidance_scale=config.BACKDROP_GUIDANCE,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
# -- internals --
|
| 167 |
def _cached(self, *, kind: str, key: str, prompt: str, seed: int,
|
| 168 |
negative_prompt: str = "", guidance_scale: float = 0.0,
|
visualnovel/prompts.py
CHANGED
|
@@ -43,9 +43,16 @@ Rules:
|
|
| 43 |
- Relationship_delta ranges from -100 to +100. Romantic warmth builds it up; coldness,
|
| 44 |
insults, or rejection bring it down. React authentically to negative moments β hurt, cold,
|
| 45 |
quietly withdrawing. Do NOT loop the same response β escalate or shift the dynamic.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
- If a character's relationship reaches -100 -> they have had enough. They storm off,
|
| 47 |
say something final and cutting, and MUST emit exit_character. They will NOT return
|
| 48 |
unless the story calls for a dramatic reconciliation arc.
|
|
|
|
|
|
|
|
|
|
| 49 |
- If a character explicitly says goodbye, storms off, or leaves for good -> emit
|
| 50 |
exit_character with their exact id to remove them from the stage.
|
| 51 |
- NPCs have their own bonds with each other (shown in "bonds with others" on each sheet).
|
|
|
|
| 43 |
- Relationship_delta ranges from -100 to +100. Romantic warmth builds it up; coldness,
|
| 44 |
insults, or rejection bring it down. React authentically to negative moments β hurt, cold,
|
| 45 |
quietly withdrawing. Do NOT loop the same response β escalate or shift the dynamic.
|
| 46 |
+
- If a character's relationship reaches +100 -> this is the romance climax. They
|
| 47 |
+
confess their feelings; the moment is beautiful and emotional. MUST emit
|
| 48 |
+
ending(kind="warm") with 2-4 sentences of poetic epilogue narration (what happens
|
| 49 |
+
next, the future they share, the feeling in the air). Also emit set_music="romantic".
|
| 50 |
- If a character's relationship reaches -100 -> they have had enough. They storm off,
|
| 51 |
say something final and cutting, and MUST emit exit_character. They will NOT return
|
| 52 |
unless the story calls for a dramatic reconciliation arc.
|
| 53 |
+
- If ALL characters have left the stage after a -100 exit and the scene is now empty ->
|
| 54 |
+
emit ending(kind="defeat") with 2-4 sentences of dark epilogue: the wanderer is left
|
| 55 |
+
alone, their failures echo, the world feels cold. Also emit set_music="sad".
|
| 56 |
- If a character explicitly says goodbye, storms off, or leaves for good -> emit
|
| 57 |
exit_character with their exact id to remove them from the stage.
|
| 58 |
- NPCs have their own bonds with each other (shown in "bonds with others" on each sheet).
|
visualnovel/schemas.py
CHANGED
|
@@ -18,7 +18,7 @@ from pydantic import BaseModel, Field
|
|
| 18 |
# --- small enums (kept as Literals so they show up cleanly in the JSON schema) ---
|
| 19 |
Emotion = Literal["neutral", "joy", "fear", "anger", "sad", "surprise", "tender"]
|
| 20 |
Beat = Literal["opening", "rising", "turn", "resolution", "ended"]
|
| 21 |
-
EndingKind = Literal["warm", "bittersweet", "strange"]
|
| 22 |
|
| 23 |
|
| 24 |
# --------------------------------------------------------------------------- #
|
|
@@ -187,6 +187,7 @@ class ViewState(BaseModel):
|
|
| 187 |
beat: Beat = "opening"
|
| 188 |
ended: bool = False
|
| 189 |
ending_text: str | None = None
|
|
|
|
| 190 |
turn_index: int = 0
|
| 191 |
notifications: list[str] = Field(default_factory=list) # popup messages this turn
|
| 192 |
intro_text: str = "" # shown once in the book overlay at game start; empty on subsequent turns
|
|
|
|
| 18 |
# --- small enums (kept as Literals so they show up cleanly in the JSON schema) ---
|
| 19 |
Emotion = Literal["neutral", "joy", "fear", "anger", "sad", "surprise", "tender"]
|
| 20 |
Beat = Literal["opening", "rising", "turn", "resolution", "ended"]
|
| 21 |
+
EndingKind = Literal["warm", "bittersweet", "strange", "defeat"]
|
| 22 |
|
| 23 |
|
| 24 |
# --------------------------------------------------------------------------- #
|
|
|
|
| 187 |
beat: Beat = "opening"
|
| 188 |
ended: bool = False
|
| 189 |
ending_text: str | None = None
|
| 190 |
+
ending_kind: str | None = None # "warm"|"bittersweet"|"strange"|"defeat"
|
| 191 |
turn_index: int = 0
|
| 192 |
notifications: list[str] = Field(default_factory=list) # popup messages this turn
|
| 193 |
intro_text: str = "" # shown once in the book overlay at game start; empty on subsequent turns
|