AI-native visual novel engine: humans write the story pack, the AI writes each scene. Bilingual, keyboard-only, pixel-animal sprites, saves, album, multiple…

<img src="docs/banner.png" alt="ai-galgame: pixel animal characters under a title card" width="640">
AI-native visual novels you play inside Claude Code. Humans write the story skeleton. Claude writes every scene live. You just press 1, 2, 3.
<img src="docs/demo.gif" alt="Picking the library on the town map, the walk while Claude writes the scene, then the scene with its choices, in the Claude Code side pane" width="560">
English · 中文
/plugin install ai-galgame --marketplace Ephemeral6/ai-galgame
/gal
Press 1–9 to choose, J to continue, O to let Claude play for you. Requires Claude Code 2.1.287 or later (mods). No API key, nothing else to install.
O: it picks places and choices and shows why, in dim text — "→ Picked 2: I want to see whether she'll tell her secret." Pauses after 6 scenes by default./gal new <one-line idea> writes a whole story pack; or write one by hand as a single JSON file. PRs welcome.Content. No sexual content. The bundled stories are coming-of-age and slice-of-life; characters under 18 are never written into explicit scenes, and characters who aren't love interests (family, a rival, a con man) are marked so the AI never writes romance with them. New here? Start with The Shelter Band & the River Fog Festival — the narrator is 18, just out of school, filling in on bass for an underground band.
<!-- Second demo: record docs/autoplay.gif with demo/03-autoplay.tape, then add: <img src="docs/autoplay.gif" alt="Autoplay with its reasoning shown" width="800"> -->
| ai-galgame | Ren'Py | AI-GAL / VinA (AI-generated games) | SillyTavern-style chat frontends | |
|---|---|---|---|---|
| Where you play | Claude Code side pane | Desktop app you build | A generated Ren'Py-style game | Browser |
| Who writes the story | Human skeleton + AI scenes | Human, line by line | AI from a prompt | Free-form chat |
| Input | Number keys only | Click | Click | Typing |
| Setup | One /plugin install | SDK + build | API key or local model + Stable Diffusion, plus Ren'Py (VinA) or GPT-SoVITS voices (AI-GAL) | Install + API key or local model |
| Plot structure | Acts, key events, promises and endings enforced by the engine | Fully scripted | Generated plot; branching varies by project | None by default |
| Art | Pixel sprites in the terminal | Any | AI images | Character cards |
(By category, from each project's README as of October 2026; corrections welcome.)
At the Claude Code prompt (Claude Code 2.1.287 or later):
/plugin install ai-galgame --marketplace Ephemeral6/ai-galgame
Answer y to Add marketplace? and keep the default user scope. Or from a shell: claude plugin install ai-galgame --marketplace Ephemeral6/ai-galgame. Then type /gal. With a wide terminal (about 110 columns or more) the pane opens beside the chat; on narrower ones it opens in the current view. Pixel art renders in the terminal; the desktop app and VS Code show a text version. Click the pane (or ctrl+x, then tab) to focus it before using the keys.
| Key | Does |
|---|---|
1–9 | choose an option or a place |
J | next line / continue |
A | show all lines |
U | undo |
S | save · load |
P | album (memories, keepsakes, endings, promises, her diary, you in her eyes) |
R | relationship web |
O | autoplay on / off |
E | settings (tabs Z display, X autoplay, C AI features, V cost, N work companion) |
X / M / C | on the ending screen and in the album: export HTML / Markdown / ending card |
H | help · T title · B back |
Looks. Pixel-font title, day and ending cards with fade and wipe transitions; an ambient strip above the sprites (rain, snow, fireflies, cicada sound waves, lanterns, meteors, stars, mist) whose theme color follows the weather and time of day; emote bubbles, entrances and exits, stepping closer, turning away; name plates and choice cards with icons and color bars; a pixel town map when picking a place; a relationship web where a line that just changed flashes. At most 4 fps, repainting only what changed; it stops when the pane is hidden and can be turned off.
AI director. The same call that writes a scene also sets the weather, time of day, particles, a close-up object, a silent beat and stage directions; the engine performs them. No extra calls.
AI-native. Echoes of your previous playthrough; a message from her when you come back after more than 6 real hours; nightly offstage gossip between supporting characters; her diary — her side of the same day, often reframing what you thought happened; "you, in her eyes", built from the attitude of every choice you make, which characters call out; and a fourth card, "Let it be", where the AI chooses the way you usually would.
Around your work. Play while you wait (above). Coding events (off by default): passing or failing tests, a broken build or a git commit become story events — only the kind and outcome, never code, paths or output; a commit becomes a keepsake. A companion (off by default) in the status line comforts, cheers and reminds you to rest after two hours, at most twice an hour, from pre-written lines. Short scenes for a build break. Voice and sound effects (off by default), compact layout for narrow terminals, colorblind-friendly palette.
Autoplay (O). It picks places, who to go with and choices, showing its reason. Strategies: Devoted (one character, chosen by you or the AI), Whim (its own curiosity), Collector (new people, places and unseen endings first) and Chaos (random, no model calls). Pace slow / medium / fast; about 2 s on the choices so you can read them. It pauses after 6 scenes by default (3 / 12 / 24 or end of day), stops at an ending or on an error with the reason shown, and any other key pauses it. Autoplayed choices go into the album, can be undone, and unlock endings normally.
Make, export, share. /gal new <idea> generates a whole pack (3–5 min, progress in the status line, K cancels); /gal repo makes one from this project's README and manifest files after showing you exactly which files it will read (never source code, hidden files or secrets); /gal export [html|md] saves a run as a self-contained page or Markdown; /gal card gives the spoiler-free ending card; /gal browse installs community packs (original, CC0, CC BY, CC BY-SA only).
| Pack | What it is | Size | License |
|---|---|---|---|
| The Shelter Band & the River Fog Festival | Original. A foggy southwestern river city, the summer after the college entrance exam; an underground band rehearsing in an old air-raid shelter, chasing the one newcomer slot at a festival. The narrator is 18. | 13 days × 3 slots · 4 romanceable + 5 supporting · 3 acts · 18 endings | CC BY 4.0 |
| Cicadas & Evening Study | Original flagship. A county-town high school, fourteen days before summer break: a club about to be cut, a race on a bad knee, a departure, a school paper and a student council. | 14 × 3 · 5 + 6 · 3 acts · 22 endings | CC BY 4.0 |
| Last Train from Birch Ridge | Original. A northeastern logging town, ten days before it empties. | 10 × 3 · 3 + 4 · 14 endings | CC BY 4.0 |
| 00:47 | Original. The night shift at an old metro station, the last ten nights before its line closes: a driver losing her night vision, an art student with nowhere to sleep, a track worker who has been quietly mending the station mural. All adults; the narrator is 22. | 10 nights × 2 slots · 3 + 3 · 14 endings | CC BY 4.0 |
| Seven Tides | Original. A typhoon week that keeps repeating; you remember, up to two loops. | 7 × 3 · 3 + 3 · 14 endings | CC BY 4.0 |
| Four Nights Before Sign-Off | Original short. A school radio booth; a gentle fourth-wall story with an ending only on a second playthrough. | 4 × 2 · 1 + 2 · 6 endings | CC BY 4.0 |
| Bridge to eQualia (adaptation) | From Blackle Mori's work of the same name; trust, not romance. | 7 days · 4 + 3 · 12 endings | CC0 1.0 |
| Little Mauve Riding Hood (adaptation) | From Le Petit Chaperon Mauve by bosinpai et al.; a comic fairy-tale parody with no romance. | 4 days · 3 + 3 · 9 endings | CC BY-SA 4.0 |
Original packs borrow only the structure and mood of a genre; characters, plot, lines and names are original. Adaptations stay within the source's characters and setting and use only its text — no original art, music, sound or fonts. Each pack's folder has its sources, license and change list.
A pack is the skeleton of a story; every text field may be { "zh": "…", "en": "…" }.
| Field | Required | What goes in it |
|---|---|---|
cast | yes | Romanceable characters (1–6): voice, sample lines, secret, five-stage beats, routines, deadline, pixel sprite; optional tagline; characters who aren't love interests take romance: false and meter: { label, full, empty } (the pane shows e.g. "Trust ●●○" instead of hearts and the AI never writes romance with them) |
places, slots, days, events, fixed | yes | Places, time slots, days, calendar events, fixed events |
endings | yes | Conditions (minStage, minAffection, maxAffection, maxBroken, minBroken, minKept, keptKey, minKeepsakes, minLoop, flags), kind (true / good / normal / bad), and one unconditional fallback |
npcs | no | Supporting characters (0–8) with role, voice, samples, haunts, optional sprite |
relations | no | a, b (cast, npc or me), kind, text; hiddenUntil keeps it a hint until a stage |
acts | no | Acts with from, title, goal, key beats (day, by, who, kind, text, optional slots / places and key promise) and a hook; with acts, chapters may be omitted |
incidents | no | Random or conditional events: text, chance, days, slots, places, who, need, after, repeat |
craft | no | The writing layer: motifs, hand-written set pieces, common and character routes, reveals, daily texture, gags, gossip, character tics and diary voices — see docs/NARRATIVE-CRAFT.md |
All optional fields can be left out; first-version packs run unchanged. Play locally with /gal validate <path> and /gal import <path> (or drop the JSON into ~/.claude/galgame-packs/), let /gal new draft one for you, or contribute via a PR — see CONTRIBUTING.md. Sprites are 14-column letter grids (. is transparent); the engine draws the eyes, blinks and small motions.
Everything runs on your Claude Code account.
/gal new, /gal repo) is 2–4 Sonnet calls, ~US$0.15–0.45, only when you ask.| Pack | Scenes | Calls | Cost (approx.) |
|---|---|---|---|
| Cicadas & Evening Study | 42 | 95–110 (+ ~13 Haiku gossip) | US$0.75–0.85 |
| The Shelter Band & the River Fog Festival | 39 | 90–100 (+ ~12 Haiku gossip) | US$0.75 |
| Four Nights Before Sign-Off | 8 | ~20 | US$0.15 |
At Anthropic list prices (Sonnet $2 / $10 per million input / output tokens, cache reads $0.20; Haiku $0.10 / $0.50); your bill is what counts. The settings page shows actual calls, tokens and estimated cost. It won't slow Claude Code down: animation stays at 4 fps and repaints only what changed, drawing the pane never writes state, and autoplay steps on single timers.
claude --plugin-dir <this repo> # load from source
claude plugin validate <this repo> # manifest, hooks module, state contract
claude plugin test <this repo> # tests/ with every model call mocked
Layout: hooks/register.tsx UI and state (migration, autoplay, play-while-you-wait, background jobs); hooks/engine.ts story engine; hooks/craft.ts writing layer; hooks/fx.ts pixel effects; hooks/art.ts sprites; hooks/pilot.ts autoplay decisions; hooks/native.ts AI-native extras; hooks/work.ts coding events and companion lines; hooks/cost.ts cost estimate; hooks/feats.ts achievements and daily challenge; hooks/gen.ts pack generation; hooks/share.ts export and ending card; hooks/browse.ts community; hooks/pack.ts validation; hooks/i18n.ts UI text; assets/sfx/ sounds; community/ index; demo/ recording scripts; packs/ story packs; types/index.d.ts types and state contract.
Questions, endings and screenshots go to Discussions; bugs and pack submissions to Issues. Read CONTRIBUTING.md before a PR and the code of conduct before posting. Report security problems privately, as SECURITY.md describes. Changes per release are in CHANGELOG.md.
Engine code (hooks/, types/, tests/, packs/index.ts) is MIT (LICENSE). Each story pack carries its own license (table above); packs/mauve is CC BY-SA 4.0 and adaptations of it must stay CC BY-SA 4.0. Please don't submit or distribute packs built from copyrighted commercial works — only original, CC0, CC BY or CC BY-SA material (no NC, ND or fan works). Import packs only if you have the right to use them.
hooks/register.tsx 1981 lines1// AI 原生视觉小说引擎:面板、按键、存档、设置、剧本包导入、自动游玩、边等边玩、陪伴、成就。
2// 所有 $.state 读写都集中在这个文件(engine.ts / fx.ts / pilot.ts / native.ts 只有纯函数和模型调用)。
3//
4// 规矩:
5// - 画面板(ui.render)时只读状态,绝不写;写状态只在按键回调、定时器回调、事件钩子、命令和会话开始里。
6// - 动画 ≤ 4 帧/秒(TICK_MS),只用 $.ui.blit 重画变了的那几块 Raster(立绘、氛围带、标题卡、地图、关系图);
7// 面板不在屏幕上时 blit 被拒,定时器自己停;设置里关掉动画就没有定时器。
8// - AI 成本:默认每幕 1 次写作 + 1 次检查(不过才重写 1 次);演出指令、氛围、镜头在同一次写作输出里;
9// 其余调用(自动决策、消息、八卦、日记、画像、顺其自然)都是 Haiku、按需、可单独关闭。
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type {
15 AutoSpeed, AutoState, AutoStrategy, Choice, GalSettings, JobState, Keepsake, Lang, Meta, Pack, PackEntry, PackMeta, Story, Tone, Usage, UiState, View, WorkState,
16} from '../types'
17import { fetchIndex, fetchPack } from './browse'
18import type { CommunityEntry } from './browse'
19import { generatePack, packFromRepo, scanRepo } from './gen'
20import type { GenResult, GenStep } from './gen'
21import { endingCard, exportFileName, exportRun, shareSummary } from './share'
22import type { ExportFormat } from './share'
23import { PORTRAIT_ROWS, STAGE_ROWS, STAGE_W, cells, keepsakeFrame, portraitFrame, stageFrame } from './art'
24import type { Px } from './art'
25import { BUDGETS, dateKey, usd } from './cost'
26import {
27 addUsage, advanceTime, applyChoice, applyScene, drawKeepsake, forcedEvent, freshStory, incidentOf, needsMigration, normalizeStory, pickEnding, planOptions, proactive, sceneKey, writeEnding, writeScene,
28} from './engine'
29import type { PlanOption } from './engine'
30import { ACHIEVEMENTS, EMPTY_FEATS, achievementsOnFinish, achievementsOnScene, dailyChallenge, dailyShare, okFeats } from './feats'
31import type { AchievementId, Feats } from './feats'
32import { BAND_H, CARD_FRAMES, DEFAULT_THEME, MAP_ROW_H, WALK_H, atmoBand, backdrop, backdropOf, backdropTop, cardFrame, endFx, fxOf, mapLabelRow, over, relationGraph, stageDirections, themeOf, tint, titleScene, townMap, walkFrame } from './fx'
33import type { CardSpec } from './fx'
34import { LANGS, helpLines, t } from './i18n'
35import type { UiKey } from './i18n'
36import { FALLBACK, KIND, PX, STATE_CB, STATE_KEYS, TEXT, bgOfTheme, css, framed, inkOn, onScreen, readable } from './theme'
37import type { Bg } from './theme'
38import { AWAY_MS, EXTRA_KEYS, awayLetter, diaryEntry, nightGossip, portraitOf, tendencies } from './native'
39import type { ExtraKey } from './native'
40import { castOf, chapterOf, dayText, meterOf, packText, personOf, tr, validatePack } from './pack'
41import { decide } from './pilot'
42import type { Decision } from './pilot'
43import { BREAK_GAP_MS, REST_AFTER_MS, canSpeak, classifyBash, commitKeepsake, companionLine } from './work'
44import type { CompanionCue, WorkEvent } from './work'
45import { BUILTIN } from '../packs/index'
46
47const PANE = 'ai-galgame'
48const TICK_MS = 250 // 4 帧/秒
49const SAVE_SLOTS = [1, 2, 3]
50
51const DEFAULT_SETTINGS: GalSettings = {
52 lang: 'zh', anim: true, pageMode: 'line', pack: BUILTIN[0]!.id,
53 autoStrategy: 'whim', autoSpeed: 'mid', autoLimit: 6, autoTarget: '',
54 map: true, extras: {}, watch: true, workEvents: false, writer: 'sonnet', budget: 0, short: false,
55 companion: '', voice: false, sfx: false, layout: 'auto', palette: 'default',
56}
57
58const story = atom<'ai-galgame', 'story'>({ plugin: 'ai-galgame', key: 'story' } as const, { ...freshStory(BUILTIN[0]!, 'zh'), phase: 'title' })
59const undo = atom<'ai-galgame', 'undo'>({ plugin: 'ai-galgame', key: 'undo' } as const, [])
60const ui = atom<'ai-galgame', 'ui'>({ plugin: 'ai-galgame', key: 'ui' } as const, { view: 'game', tab: 0, saveMode: 'save', back: 'game' })
61const settings = atom<'ai-galgame', 'settings'>({ plugin: 'ai-galgame', key: 'settings' } as const, DEFAULT_SETTINGS)
62const meta = atom<'ai-galgame', 'meta'>({ plugin: 'ai-galgame', key: 'meta' } as const, {})
63const packs = atom<'ai-galgame', 'packs'>({ plugin: 'ai-galgame', key: 'packs' } as const, [])
64const DEFAULT_AUTO: AutoState = { on: false, scenes: 0, startDay: 1, target: '', reason: '', note: '' }
65const auto = atom<'ai-galgame', 'auto'>({ plugin: 'ai-galgame', key: 'auto' } as const, DEFAULT_AUTO)
66const DEFAULT_WORK: WorkState = { busy: false, notice: '', since: 0, lastTurn: 0, said: [], line: '' }
67const work = atom<'ai-galgame', 'work'>({ plugin: 'ai-galgame', key: 'work' } as const, DEFAULT_WORK)
68const feats = atom<'ai-galgame', 'feats'>({ plugin: 'ai-galgame', key: 'feats' } as const, EMPTY_FEATS)
69const DEFAULT_JOB: JobState = { kind: '', step: '', attempt: 0, message: '', scan: null, entries: [] }
70const job = atom<'ai-galgame', 'job'>({ plugin: 'ai-galgame', key: 'job' } as const, DEFAULT_JOB)
71
72// 旧版本留下的会话状态格式不同(插件热重载后 $.state 还在),读到就降级,免得渲染时报错整块不画
73const DEFAULT_UI: UiState = { view: 'game', tab: 0, saveMode: 'save', back: 'game' }
74const TITLE_STORY: Story = { ...freshStory(BUILTIN[0]!, 'zh'), phase: 'title' }
75const VIEWS: View[] = ['game', 'album', 'saves', 'settings', 'help', 'packs', 'graph', 'feats']
76
77function okStory(x: unknown): boolean {
78 const s = x as Partial<Story> | null
79 return !!s && typeof s.pack === 'string' && !!s.heroines && typeof s.heroines === 'object'
80 && Array.isArray(s.promises) && Array.isArray(s.album) && !!s.usage && typeof s.phase === 'string'
81}
82function okUi(x: unknown): x is UiState {
83 return !!x && VIEWS.includes((x as Partial<UiState>).view as View) && typeof (x as UiState).tab === 'number'
84}
85function okSettings(x: unknown): boolean {
86 const st = x as Partial<GalSettings> | null
87 return !!st && typeof st.lang === 'string' && typeof st.pack === 'string'
88}
89function okAuto(x: unknown): x is AutoState {
90 return !!x && typeof (x as AutoState).on === 'boolean' && typeof (x as AutoState).scenes === 'number'
91}
92function okJob(x: unknown): x is JobState {
93 return !!x && typeof (x as JobState).kind === 'string' && Array.isArray((x as JobState).entries)
94}
95function okWork(x: unknown): x is WorkState {
96 return !!x && typeof (x as WorkState).busy === 'boolean' && Array.isArray((x as WorkState).said)
97}
98const extraOn = (st: GalSettings, k: ExtraKey) => st.extras?.[k] !== false
99
100const packById = (id: string, extra: PackEntry[] = []): Pack | undefined => allPacks(extra).find(e => e.id === id)?.pack
101
102/**
103 * 迁移:旧格式的会话状态、回退栈、存档补成当前格式。
104 * 只在 command.run 和 session.start 里调用——画面板时不许写状态;画面板时只读,遇到认不出的格式降级显示。
105 */
106async function migrate($: EngineInterface): Promise<void> {
107 const extra = await read($, packs)
108 const raw = await read($, story) as unknown
109 const p0 = okStory(raw) ? packById((raw as Story).pack, extra) : undefined
110 const fixed = okStory(raw) ? normalizeStory(raw, p0) : null
111 if (!fixed) {
112 await update($, story, () => ({ ...freshStory(BUILTIN[0]!, 'zh'), phase: 'title' as const }))
113 await update($, undo, () => [])
114 } else if (needsMigration(raw, p0)) {
115 await update($, story, () => fixed)
116 await update($, undo, u => u.map(x => {
117 try { return normalizeStory(JSON.parse(x), p0) } catch { return null }
118 }).filter((x): x is Story => !!x).map(x => JSON.stringify(x)))
119 }
120 if (!okUi(await read($, ui))) await update($, ui, () => DEFAULT_UI)
121 if (!okSettings(await read($, settings))) await update($, settings, () => DEFAULT_SETTINGS)
122 // 自动模式不跨会话、不跨热重载:定时器已经没了,状态也关掉
123 const a0 = await read($, auto) as unknown
124 if (!okAuto(a0) || (a0 as AutoState).on) await update($, auto, x => ({ ...DEFAULT_AUTO, note: okAuto(x) ? x.note : '' }))
125 if (!okWork(await read($, work))) await update($, work, () => DEFAULT_WORK)
126 if (!okFeats(await read($, feats))) await update($, feats, () => EMPTY_FEATS)
127 // 后台任务不跨热重载:取消控制器已经没了,正在跑的标成空闲(等确认的保留)
128 const j0 = await read($, job) as unknown
129 if (!okJob(j0)) await update($, job, () => DEFAULT_JOB)
130 else if (j0.kind !== '' && j0.kind !== 'confirm' && !genAbort) await update($, job, x => ({ ...x, kind: '' as const, step: '' }))
131 // 存档:save:<pack>:<n>,旧格式补全后写回;认不出来的不动(读的时候降级显示)
132 for (const key of await $.store.keys()) {
133 if (!key.startsWith('save:')) continue
134 const f = await $.store.get(key)
135 if (!f || typeof f !== 'object') continue
136 const st = (f as { story?: unknown }).story
137 const p = packById(String((st as { pack?: unknown } | undefined)?.pack ?? ''), extra)
138 if (!needsMigration(st, p)) continue
139 const n = normalizeStory(st, p)
140 if (n) await $.store.set(key, { ...(f as object), story: n })
141 }
142}
143
144// ---------- 剧本包 ----------
145function allPacks(extra: PackEntry[]): PackEntry[] {
146 const out: PackEntry[] = BUILTIN.map(p => ({ id: p.id, origin: 'builtin' as const, pack: p }))
147 for (const e of extra) if (!out.some(o => o.id === e.id)) out.push(e)
148 return out
149}
150
151async function currentPack($: EngineInterface, id?: string): Promise<Pack> {
152 const want = id ?? (await read($, settings)).pack
153 const list = allPacks(await read($, packs))
154 return (list.find(e => e.id === want) ?? list[0]!).pack
155}
156
157async function packDir($: EngineInterface): Promise<string> {
158 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
159 return `${home.replace(/\\/g, '/')}/.claude/galgame-packs`
160}
161
162/** 读取已导入的包与剧本目录里的包(都是纯 JSON),校验后放进 packs */
163async function loadExtraPacks($: EngineInterface): Promise<void> {
164 const out: PackEntry[] = []
165 const stored = await $.store.get('packs-imported')
166 const fromCommunity = new Set((await $.store.get('packs-community') as string[] | undefined) ?? [])
167 if (stored && typeof stored === 'object') {
168 for (const raw of Object.values(stored as Record<string, unknown>)) {
169 if (validatePack(raw).length === 0) out.push({ id: (raw as Pack).id, origin: fromCommunity.has((raw as Pack).id) ? 'community' : 'imported', pack: raw as Pack })
170 }
171 }
172 try {
173 const dir = await packDir($)
174 if (await $.fs.exists(dir)) {
175 for (const f of await $.fs.list(dir)) {
176 if (f.kind !== 'file' || !f.name.endsWith('.json') || f.size > 512 * 1024) continue
177 try {
178 const raw = JSON.parse(await $.fs.read(`${dir}/${f.name}`)) as unknown
179 const errs = validatePack(raw)
180 if (errs.length === 0) out.push({ id: (raw as Pack).id, origin: 'folder', pack: raw as Pack })
181 else $.ui.log(`galgame: ${f.name}: ${errs.slice(0, 3).join('; ')}`, { to: 'debug' })
182 } catch {
183 $.ui.log(`galgame: ${f.name}: not JSON`, { to: 'debug' })
184 }
185 }
186 }
187 } catch {
188 // 目录读不了就只用内置与导入的包
189 }
190 await update($, packs, () => out)
191}
192
193/** /gal validate:只校验,不导入 */
194async function checkPackFile($: EngineInterface, path: string, lang: Lang): Promise<string> {
195 let raw: unknown
196 try {
197 raw = JSON.parse(await $.fs.read(path))
198 } catch {
199 return t(lang, 'importReadFail', { path })
200 }
201 const errs = validatePack(raw)
202 if (errs.length) return t(lang, 'importBad', { errors: errs.map(x => `- ${x}`).join('\n') })
203 const p = raw as Pack
204 return t(lang, 'validateOk', { title: tr(p.title, lang, p.defaultLang), days: p.days, cast: p.cast.length, endings: p.endings.length })
205}
206
207async function importPack($: EngineInterface, path: string, lang: Lang): Promise<string> {
208 let raw: unknown
209 try {
210 raw = JSON.parse(await $.fs.read(path))
211 } catch {
212 return t(lang, 'importReadFail', { path })
213 }
214 const errs = validatePack(raw)
215 const p = raw as Pack
216 if (errs.length === 0 && BUILTIN.some(b => b.id === p.id)) errs.push(`id: "${p.id}" is a built-in pack id`)
217 if (errs.length) return t(lang, 'importBad', { errors: errs.slice(0, 12).map(x => `- ${x}`).join('\n') })
218 const stored = ((await $.store.get('packs-imported')) ?? {}) as Record<string, unknown>
219 await $.store.set('packs-imported', { ...stored, [p.id]: p })
220 await loadExtraPacks($)
221 return t(lang, 'imported', { title: tr(p.title, lang, p.defaultLang) })
222}
223
224// ---------- 存储 ----------
225function packMeta(m: Meta, id: string): PackMeta {
226 return m[id] ?? { endings: [], clears: 0, keepsakes: [] }
227}
228
229async function setMeta($: EngineInterface, id: string, fn: (pm: PackMeta) => PackMeta): Promise<void> {
230 const m = await update($, meta, x => ({ ...x, [id]: fn(packMeta(x, id)) }))
231 await $.store.set('meta', m)
232}
233
234async function setSettings($: EngineInterface, fn: (s: GalSettings) => GalSettings): Promise<void> {
235 const s = await update($, settings, fn)
236 await $.store.set('settings', s)
237}
238
239async function setFeats($: EngineInterface, fn: (f: Feats) => Feats): Promise<Feats> {
240 const f = await update($, feats, x => fn(okFeats(x) ? x : EMPTY_FEATS))
241 await $.store.set('feats', f)
242 return f
243}
244
245type SaveFile = { story: Story; at: number }
246/** 读到的存档:正常、空、或者认不出的格式(降级显示,不报错) */
247type SaveRead = SaveFile | 'bad' | null
248
249function saveKey(pack: string, n: number): string {
250 return `save:${pack}:${n}`
251}
252
253/** 只读:存档的 story 先过 normalizeStory(不写回),认不出来返回 'bad' */
254async function readSave($: EngineInterface, pack: string, n: number, extra: PackEntry[] = []): Promise<SaveRead> {
255 let raw: unknown
256 try { raw = await $.store.get(saveKey(pack, n)) } catch { return 'bad' }
257 if (raw === undefined || raw === null) return null
258 if (typeof raw !== 'object') return 'bad'
259 const f = raw as Partial<SaveFile>
260 const st = normalizeStory(f.story, packById(pack, extra))
261 if (!st) return 'bad'
262 return { story: st, at: typeof f.at === 'number' ? f.at : 0 }
263}
264
265const okSave = (f: SaveRead): f is SaveFile => !!f && f !== 'bad'
266
267async function writeSave($: EngineInterface, n: number): Promise<void> {
268 const s = await read($, story)
269 await $.store.set(saveKey(s.pack, n), { story: s, at: Date.now() } satisfies SaveFile)
270}
271
272// ---------- 时间 ----------
273/** 现在的时间(毫秒):走引擎的时钟(测试里可以拨),拿不到就用本机时间 */
274async function nowMs($: EngineInterface): Promise<number> {
275 try { return await $.clock.now() } catch { return Date.now() }
276}
277
278// ---------- 花费与预算 ----------
279async function todaySpent($: EngineInterface): Promise<number> {
280 const f = await read($, feats)
281 const today = dateKey(await nowMs($))
282 return okFeats(f) && f.today.date === today ? f.today.usd : 0
283}
284
285/** 把一次(或一批)调用记到本局用量、今日花费和累计统计上 */
286async function charge($: EngineInterface, u: Usage): Promise<void> {
287 await update($, story, x => ({ ...x, usage: addUsage(x.usage, u) }))
288 const c = u.cost ?? 0
289 if (!c) return
290 const today = dateKey(await nowMs($))
291 await setFeats($, f => ({ ...f, today: { date: today, usd: (f.today.date === today ? f.today.usd : 0) + c }, stats: { ...f.stats, cost: f.stats.cost + c } }))
292}
293
294/** 每日预算:到了就不再调用模型;返回原因文字,没到返回 '' */
295async function overBudget($: EngineInterface): Promise<string> {
296 const st = await read($, settings)
297 const budget = st.budget ?? 0
298 if (!budget) return ''
299 const used = await todaySpent($)
300 return used >= budget ? t(st.lang, 'budgetHit', { used: usd(used), budget: usd(budget) }) : ''
301}
302
303// ---------- 音效与朗读 ----------
304function sfx($: EngineInterface, st: GalSettings, name: 'choice' | 'page' | 'ending' | 'notice'): void {
305 if (st.sfx) void $.audio.play({ asset: `assets/sfx/${name}.wav` }).catch(() => undefined)
306}
307function speak($: EngineInterface, st: GalSettings, text: string): void {
308 if (st.voice && text) void $.audio.speak(text).catch(() => undefined)
309}
310
311// ---------- 动画:只 blit 变了的那几块 ----------
312/** 一块会动的 Raster:key、尺寸和按帧画的函数 */
313type Layer = { key: string; cols: number; rows: number; draw: (t: number) => Px[][] }
314let layers: Layer[] = []
315let animTimer: { cancel: () => void } | null = null
316let frame = 0
317let misses = 0
318const lastCells: Record<string, string> = {}
319/** 标题卡第一次出现在哪一帧(过渡动画用;模块变量,不是状态) */
320const cardStart: Record<string, number> = {}
321let revealKey = ''
322let revealTick = 0
323
324function stopAnim(): void {
325 animTimer?.cancel()
326 animTimer = null
327}
328
329function ensureAnim($: EngineInterface): void {
330 if (animTimer) return
331 misses = 0
332 animTimer = $.clock.every(TICK_MS, () => {
333 if (!layers.length) { stopAnim(); return }
334 frame++
335 for (const l of layers) {
336 const c = cells(l.draw(frame))
337 if (c === lastCells[l.key]) continue
338 lastCells[l.key] = c
339 void $.ui.blit({ requestId: PANE, key: l.key, cells: c, columns: l.cols, rows: l.rows }).then(r => {
340 if (r.deny) { if (++misses >= 4 * layers.length) stopAnim() } else misses = 0
341 }, () => stopAnim())
342 }
343 })
344}
345
346function startOf(id: string): number {
347 if (!(id in cardStart)) cardStart[id] = frame
348 return cardStart[id]!
349}
350
351// ---------- 游戏动作 ----------
352async function snapshot($: EngineInterface): Promise<void> {
353 const s = await read($, story)
354 await update($, undo, u => [...u, JSON.stringify(s)].slice(-12))
355}
356
357async function rewind($: EngineInterface): Promise<void> {
358 const u = await read($, undo)
359 if (u.length === 0) return
360 const now = await read($, story)
361 let last: Story | null = null
362 try { last = normalizeStory(JSON.parse(u[u.length - 1]!), await currentPack($, now.pack)) } catch { last = null }
363 await update($, undo, x => x.slice(0, -1))
364 if (!last) return
365 const prev = last
366 // 回退恢复旧状态,但「回退过几次」和用量记在当前这一局上
367 await update($, story, () => ({ ...prev, error: '', rewinds: now.rewinds + 1, loads: now.loads, usage: now.usage }))
368}
369
370async function newGame($: EngineInterface, loop: number, opts: { seed?: number; daily?: string } = {}): Promise<void> {
371 const st = await read($, settings)
372 const p = await currentPack($)
373 const pm = packMeta(await read($, meta), p.id)
374 const fresh = freshStory(p, st.lang, loop, opts.seed ?? Math.floor(Math.random() * 0x7fffffff))
375 // 二周目起:上一局的相册摘要化作「回声」,角色会隐约觉得见过你(不额外调用)
376 const echoes = loop >= 2 && extraOn(st, 'echoes') ? pm.lastRun?.lines ?? [] : []
377 await update($, story, () => ({ ...fresh, echoes, daily: opts.daily ?? '' }))
378 await update($, undo, () => [])
379 await update($, ui, x => ({ ...x, view: 'game' as View }))
380 await writeSave($, 0)
381}
382
383async function startDaily($: EngineInterface): Promise<void> {
384 const date = dateKey(await nowMs($))
385 const { pack, seed } = dailyChallenge(date, BUILTIN)
386 await setSettings($, x => ({ ...x, pack: pack.id }))
387 await newGame($, 1, { seed, daily: date })
388}
389
390async function continueGame($: EngineInterface): Promise<void> {
391 const st = await read($, settings)
392 const s = await read($, story)
393 if (s.pack === st.pack && s.phase !== 'title' && s.day >= 1 && (s.album.length > 0 || s.slot > 0 || s.day > 1)) {
394 await update($, ui, x => ({ ...x, view: 'game' as View }))
395 return
396 }
397 if (okSave(await readSave($, st.pack, 0, await read($, packs))) && await loadSlot($, st.pack, 0)) return
398 await newGame($, 1)
399}
400
401async function continueAndCheck($: EngineInterface): Promise<void> {
402 await continueGame($)
403 void maybeLetter($)
404}
405
406async function loadSlot($: EngineInterface, pack: string, n: number): Promise<boolean> {
407 const f = await readSave($, pack, n, await read($, packs))
408 if (f === 'bad') { $.ui.toast(t((await read($, settings)).lang, 'loadBad')); return false }
409 if (!f) return false
410 const now = await read($, story)
411 const loads = now.pack === pack ? now.loads + 1 : f.story.loads
412 await update($, story, () => ({ ...f.story, loads, phase: f.story.phase === 'loading' ? 'plan' : f.story.phase, error: '' }))
413 await update($, undo, () => [])
414 await update($, ui, x => ({ ...x, view: 'game' as View }))
415 return true
416}
417
418async function touchSeen($: EngineInterface): Promise<void> {
419 await $.store.set('lastSeen', await nowMs($))
420}
421
422async function goTo($: EngineInterface, opt: PlanOption, who: string | null): Promise<void> {
423 const s0 = await read($, story)
424 if (s0.phase !== 'plan') return
425 const st = await read($, settings)
426 const over = await overBudget($)
427 if (over) { await update($, story, x => ({ ...x, error: over })); return }
428 const p = await currentPack($, s0.pack)
429 await snapshot($)
430 const T = packText(p, st.lang)
431 await update($, story, x => ({ ...x, phase: 'loading' as const, error: '', after: '', letter: null, loadingText: T(p.loading[Math.floor(Math.random() * p.loading.length)]) }))
432 const s = await read($, story)
433 const pm = packMeta(await read($, meta), p.id)
434 const r = await writeScene($, p, s, opt, who, st.lang, {
435 endingsSeen: pm.endings, clears: pm.clears, writer: st.writer === 'haiku' ? 'haiku' : undefined, short: !!st.short,
436 world: st.workEvents ? s.world : [],
437 })
438 await charge($, r.usage)
439 if (!r.value) {
440 await update($, story, x => ({ ...x, phase: 'plan' as const, error: t(st.lang, 'sceneFail') }))
441 return
442 }
443 const sc = r.value
444 await update($, story, x => ({ ...applyScene(x, sc), reveal: st.pageMode === 'page' ? 999 : 0 }))
445 sfx($, st, 'page')
446 speak($, st, sc.narration)
447 await touchSeen($)
448 const f = await setFeats($, x => ({ ...x, stats: { ...x.stats, scenes: x.stats.scenes + 1 } }))
449 await unlock($, achievementsOnScene(f), st.lang)
450}
451
452/**
453 * 新台词、选项、余波都加在面板最下面。内容比窗口高时它们落在窗口外,
454 * 而数字热键只对整个在窗口里的按钮生效(实机 45 行高的终端里选项按不到),所以滚到底。
455 */
456function toEnd($: EngineInterface): void {
457 void $.ui.scroll({ in: PANE, to: 'end' }).catch(() => {})
458}
459
460async function nextLine($: EngineInterface): Promise<void> {
461 const s = await update($, story, x => ({ ...x, reveal: x.reveal + 1 }))
462 toEnd($)
463 const line = s.scene?.lines[s.reveal - 1]
464 if (line) speak($, await read($, settings), line.text)
465}
466
467async function revealAll($: EngineInterface): Promise<void> {
468 await update($, story, x => ({ ...x, reveal: 999 }))
469 toEnd($)
470}
471
472async function choose($: EngineInterface, c: Choice): Promise<void> {
473 const s = await read($, story)
474 if (s.phase !== 'scene' || !s.scene) return
475 const st = await read($, settings)
476 const p = await currentPack($, s.pack)
477 await snapshot($)
478 const r = applyChoice(p, s, c, st.lang)
479 await update($, story, () => r.story)
480 toEnd($)
481 sfx($, st, 'choice')
482 await touchSeen($)
483 if (!(await overBudget($))) for (const id of r.keepsakeFor) void makeKeepsake($, p, id, st.lang)
484}
485
486/** 「顺其自然」:按我的选择倾向,让 AI 替我选(1 次 Haiku) */
487async function letItBe($: EngineInterface): Promise<void> {
488 const s = await read($, story)
489 if (s.phase !== 'scene' || !s.scene) return
490 const st = await read($, settings)
491 const p = await currentPack($, s.pack)
492 const over = await overBudget($)
493 if (over) { $.ui.toast(over); return }
494 const choices = s.scene.choices
495 const d = await decide($, p, s, 'choice', choices.map(c => `[${c.kind}${c.tone ? `, ${c.tone}` : ''}] ${c.text}`), 'self', '', { endingsSeen: [] }, st.lang)
496 await charge($, d.usage)
497 if (d.error) { $.ui.toast(t(st.lang, 'autoError', { err: d.error })); return }
498 await update($, auto, x => ({ ...x, reason: reasonOf(st.lang, d), note: reasonOf(st.lang, d) }))
499 await choose($, choices[d.index]!)
500}
501
502async function makeKeepsake($: EngineInterface, p: Pack, id: string, lang: Lang): Promise<void> {
503 const s = await read($, story)
504 const st = await read($, settings)
505 const g = await drawKeepsake($, p, s, id, lang, st.writer === 'haiku' ? 'haiku' : undefined)
506 await charge($, g.usage)
507 await giveKeepsake($, p, g.value, lang)
508}
509
510async function giveKeepsake($: EngineInterface, p: Pack, k: Keepsake | null, lang: Lang): Promise<void> {
511 if (!k) return
512 await update($, story, x => ({ ...x, keepsakes: x.pack === p.id ? [...x.keepsakes, k] : x.keepsakes }))
513 await setMeta($, p.id, pm => ({ ...pm, keepsakes: [...pm.keepsakes.filter(o => !(o.heroine === k.heroine && o.title === k.title)), k].slice(-24) }))
514 $.ui.toast(t(lang, 'keepsakeGot', { title: k.title }))
515}
516
517async function advance($: EngineInterface): Promise<void> {
518 const s = await read($, story)
519 if (s.phase !== 'after') return
520 const st = await read($, settings)
521 const p = await currentPack($, s.pack)
522 const r = advanceTime(p, s, st.lang)
523 await update($, story, () => ({ ...r.story, deltas: {} }))
524 await writeSave($, 0)
525 // 一天过完:夜里的小镇(配角之间的八卦、幕后),1 次 Haiku,不等它
526 if (!r.finished && r.story.day !== s.day && extraOn(st, 'gossip') && !(await overBudget($))) void gossip($, p, s, s.day, st.lang)
527 if (r.finished) await finish($, p, st.lang)
528}
529
530async function gossip($: EngineInterface, p: Pack, s: Story, day: number, lang: Lang): Promise<void> {
531 const g = await nightGossip($, p, s, day, lang)
532 await charge($, g.usage)
533 if (g.text) await update($, story, x => (x.pack === p.id ? { ...x, gossip: [...x.gossip, { day, text: g.text! }].slice(-20) } : x))
534}
535
536async function finish($: EngineInterface, p: Pack, lang: Lang): Promise<void> {
537 const st = await read($, settings)
538 await update($, story, x => ({ ...x, phase: 'loading' as const, loadingText: packText(p, lang)(p.loading[0]) }))
539 const s = await read($, story)
540 const e = pickEnding(p, s)
541 const g = (await overBudget($)) ? { value: null, usage: { writer: 0, checker: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } } : await writeEnding($, p, s, e, lang, st.writer === 'haiku' ? 'haiku' : undefined)
542 await charge($, g.usage)
543 await update($, story, x => ({ ...x, phase: 'ending' as const, endingId: e.id, ending: g.value ?? t(lang, 'endingFail'), reveal: 0 }))
544 await setMeta($, p.id, pm => ({
545 ...pm, clears: pm.clears + 1, endings: pm.endings.includes(e.id) ? pm.endings : [...pm.endings, e.id],
546 lastRun: { ending: e.id, lines: s.album.slice(-8).map(m => m.text) },
547 }))
548 await writeSave($, 0)
549 sfx($, st, 'ending')
550 $.ui.toast(t(lang, 'endingUnlocked', { title: tr(e.title, lang, p.defaultLang) }))
551 // 成就、统计、每日挑战
552 const a = await read($, auto)
553 const pm = packMeta(await read($, meta), p.id)
554 const f0 = await read($, feats)
555 const got = achievementsOnFinish(p, s, e, pm, okFeats(f0) ? f0 : EMPTY_FEATS, okAuto(a) && a.on)
556 await setFeats($, f => ({
557 ...f, stats: { ...f.stats, clears: f.stats.clears + 1 },
558 daily: s.daily && !f.daily[s.daily] ? { ...f.daily, [s.daily]: { pack: p.id, ending: e.id, kind: e.kind ?? (e.tier >= 4 ? 'true' : 'normal') } } : f.daily,
559 }))
560 await unlock($, got, lang)
561}
562
563async function unlock($: EngineInterface, ids: AchievementId[], lang: Lang): Promise<void> {
564 if (!ids.length) return
565 await setFeats($, f => ({ ...f, achievements: [...new Set([...f.achievements, ...ids])] }))
566 for (const id of ids) $.ui.toast(t(lang, 'achGot', { name: t(lang, `ach_${id}` as UiKey) }))
567}
568
569async function genDiary($: EngineInterface, id: string, day: number): Promise<void> {
570 const s = await read($, story)
571 const st = await read($, settings)
572 const over = await overBudget($)
573 if (over) { $.ui.toast(over); return }
574 const p = await currentPack($, s.pack)
575 const g = await diaryEntry($, p, s, id, day, st.lang)
576 await charge($, g.usage)
577 if (g.text) await update($, story, x => ({ ...x, diary: { ...x.diary, [`${id}:${day}`]: g.text! } }))
578}
579
580async function genPortrait($: EngineInterface, id: string): Promise<void> {
581 const s = await read($, story)
582 const st = await read($, settings)
583 const over = await overBudget($)
584 if (over) { $.ui.toast(over); return }
585 const p = await currentPack($, s.pack)
586 const g = await portraitOf($, p, s, id, st.lang)
587 await charge($, g.usage)
588 if (g.text) await update($, story, x => ({ ...x, portrait: { ...x.portrait, [id]: g.text! } }))
589}
590
591/**
592 * 你离开很久(真实时间)后回来:她发来一条消息(1 次 Haiku,不等它)。
593 * 回到一局里(打开面板时正在日程页,或者从标题继续 / 读档)才检查;「上次在玩」的时间只在真的玩了的时候更新。
594 */
595async function maybeLetter($: EngineInterface): Promise<void> {
596 const st = await read($, settings)
597 const s = await read($, story)
598 if (s.phase !== 'plan') return
599 const now = await nowMs($)
600 const last = Number(await $.store.get('lastSeen')) || 0
601 await $.store.set('lastSeen', now)
602 if (!extraOn(st, 'letters') || !last || now - last < AWAY_MS) return
603 if (s.letter || (await overBudget($))) return
604 const p = await currentPack($, s.pack)
605 const lt = await awayLetter($, p, s, (now - last) / 3600_000, st.lang)
606 if (lt) await update($, story, x => (x.pack === s.pack && x.phase === 'plan' ? { ...x, letter: lt } : x))
607}
608
609async function show($: EngineInterface, view: View, tab = 0): Promise<void> {
610 await update($, ui, x => ({ ...x, view, tab, back: view === 'game' || x.view === view ? x.back : x.view }))
611}
612
613// ---------- 自动游玩 ----------
614// 用 $.clock.after 一步一步排程(不用高频定时器);定时器回调里写状态,ui.render 里只读。
615// 每一步先核对 autoGen:玩家按键、关掉自动或热重载后,排着的旧步骤自动作废。
616const SPEED_MS: Record<AutoSpeed, number> = { slow: 3000, mid: 1500, fast: 600 }
617const CHOICE_PAUSE_MS = 2000
618const AUTO_STRATEGIES: AutoStrategy[] = ['focus', 'whim', 'collect', 'chaos']
619const AUTO_SPEEDS: AutoSpeed[] = ['slow', 'mid', 'fast']
620const AUTO_LIMITS = [6, 3, 12, 24, 0]
621let autoGen = 0
622let autoTimer: { cancel: () => void } | null = null
623let choicesShown = ''
624
625function autoCfg(st: GalSettings): { strategy: AutoStrategy; speed: AutoSpeed; limit: number; target: string } {
626 return {
627 strategy: AUTO_STRATEGIES.includes(st.autoStrategy as AutoStrategy) ? st.autoStrategy! : 'whim',
628 speed: AUTO_SPEEDS.includes(st.autoSpeed as AutoSpeed) ? st.autoSpeed! : 'mid',
629 limit: typeof st.autoLimit === 'number' && st.autoLimit >= 0 ? st.autoLimit : 6,
630 target: typeof st.autoTarget === 'string' ? st.autoTarget : '',
631 }
632}
633
634function schedule($: EngineInterface, ms: number, gen: number): void {
635 autoTimer?.cancel()
636 autoTimer = $.clock.after(ms, () => { if (gen === autoGen) void autoStep($, gen) })
637}
638
639/** 状态栏:后台生成的进度 + 自动模式 + 陪伴模式的一句话 */
640async function refreshStatus($: EngineInterface): Promise<void> {
641 const st = await read($, settings)
642 const a = await read($, auto)
643 const w = await read($, work)
644 const j = await read($, job)
645 const parts = [okJob(j) ? jobLine(st.lang, j) : '', okAuto(a) && a.on ? t(st.lang, 'autoStatus') : '', st.companion && okWork(w) ? w.line : ''].filter(Boolean)
646 $.ui.status(parts.length ? parts.join(' · ') : undefined)
647}
648
649async function startAuto($: EngineInterface): Promise<void> {
650 autoGen++
651 const gen = autoGen
652 const s = await read($, story)
653 await update($, auto, () => ({ ...DEFAULT_AUTO, on: true, startDay: s.day }))
654 await refreshStatus($)
655 choicesShown = ''
656 schedule($, 400, gen)
657}
658
659/** 停下或暂停:先让排着的步骤作废,再写原因 */
660async function stopAuto($: EngineInterface, key: UiKey, vars?: Record<string, string | number>): Promise<void> {
661 autoGen++
662 autoTimer?.cancel()
663 autoTimer = null
664 const lang = (await read($, settings)).lang
665 await update($, auto, x => ({ ...(okAuto(x) ? x : DEFAULT_AUTO), on: false, note: t(lang, key, vars) }))
666 await refreshStatus($)
667}
668
669async function toggleAuto($: EngineInterface): Promise<void> {
670 const a = await read($, auto)
671 if (okAuto(a) && a.on) await stopAuto($, 'autoStopped')
672 else await startAuto($)
673}
674
675function reasonOf(lang: Lang, d: Decision): string {
676 return d.why ? t(lang, 'autoReason', { n: d.index + 1, why: d.why }) : t(lang, 'autoReasonBare', { n: d.index + 1 })
677}
678
679/** 自动游玩的一步:看当前阶段做一件事,再排下一步 */
680async function autoStep($: EngineInterface, gen: number): Promise<void> {
681 try {
682 const a = await read($, auto)
683 if (!okAuto(a) || !a.on || gen !== autoGen) return
684 const st = await read($, settings)
685 const lang = st.lang
686 const cfg = autoCfg(st)
687 const s = await read($, story)
688 const p = await currentPack($, s.phase === 'title' ? st.pack : s.pack)
689 const T = packText(p, lang)
690 const wait = SPEED_MS[cfg.speed]
691 const seen = { endingsSeen: packMeta(await read($, meta), p.id).endings }
692 if (s.phase === 'title') { await continueGame($); return schedule($, 600, gen) }
693 if (s.phase === 'loading') return schedule($, 800, gen)
694 if (s.phase === 'ending') return await stopAuto($, 'autoEnded')
695 const over = await overBudget($)
696 if (over && (s.phase === 'plan' || (s.phase === 'scene' && cfg.strategy !== 'chaos'))) return await stopAuto($, 'autoError', { err: over })
697 if (s.phase === 'plan') {
698 if (s.error) return await stopAuto($, 'autoError', { err: s.error })
699 if (cfg.limit > 0 && a.scenes >= cfg.limit) return await stopAuto($, 'autoLimitHit', { n: a.scenes })
700 if (cfg.limit === 0 && s.day !== a.startDay) return await stopAuto($, 'autoDayEnd')
701 let target = a.target
702 if (cfg.strategy === 'focus' && !castOf(p, target)) {
703 target = castOf(p, cfg.target) ? cfg.target : ''
704 if (!target) {
705 const d = await decide($, p, s, 'target', p.cast.map(c => `${c.id} ${T(c.name)}: ${T(c.tagline ?? c.look)}`), 'focus', '', seen, lang)
706 await charge($, d.usage)
707 if (d.error) return await stopAuto($, 'autoError', { err: d.error })
708 target = p.cast[d.index]!.id
709 }
710 if (gen !== autoGen) return
711 await update($, auto, x => ({ ...x, target }))
712 }
713 const forced = forcedEvent(p, s, lang)
714 const lonely = forced ? null : proactive(p, s)
715 const opts = [
716 ...(lonely ? [{ place: T(p.places[0]!.name), mood: '', maybe: [lonely], who: lonely }] : []),
717 ...(forced ? [forced] : planOptions(p, s, lang)),
718 ]
719 const labels = opts.map(o => (o.who ? `${o.who} came looking for me: go with her` : `${o.forced ?? o.place}${o.maybe.length ? ` (maybe: ${o.maybe.join(', ')})` : ''}`))
720 const d = await decide($, p, s, 'place', labels, cfg.strategy, target, seen, lang)
721 await charge($, d.usage)
722 if (d.error) return await stopAuto($, 'autoError', { err: d.error })
723 if (gen !== autoGen) return
724 await update($, auto, x => ({ ...x, reason: opts.length > 1 ? reasonOf(lang, d) : '' }))
725 const o = opts[d.index]!
726 await goTo($, o, o.who ?? null)
727 if (gen !== autoGen) return
728 const s2 = await read($, story)
729 if (s2.phase === 'plan' && s2.error) return await stopAuto($, 'autoError', { err: s2.error })
730 await update($, auto, x => ({ ...x, scenes: x.scenes + 1 }))
731 return schedule($, wait, gen)
732 }
733 if (s.phase === 'scene' && s.scene) {
734 const key = `${sceneKey(s)}:${s.scene.title}`
735 const items = sceneItems(s.scene, '').length
736 if (s.reveal + 1 < items) {
737 await nextLine($)
738 // 最后一句出来时选项也出来了:停顿约 2 秒再选,让人看得到
739 if (s.reveal + 2 >= items) { choicesShown = key; return schedule($, CHOICE_PAUSE_MS, gen) }
740 return schedule($, wait, gen)
741 }
742 if (choicesShown !== key) { choicesShown = key; return schedule($, CHOICE_PAUSE_MS, gen) }
743 const choices = s.scene.choices
744 const d = await decide($, p, s, 'choice', choices.map(c => `[${c.kind}] ${c.text}`), cfg.strategy, a.target, seen, lang)
745 await charge($, d.usage)
746 if (d.error) return await stopAuto($, 'autoError', { err: d.error })
747 if (gen !== autoGen) return
748 await update($, auto, x => ({ ...x, reason: reasonOf(lang, d) }))
749 await choose($, choices[d.index]!)
750 return schedule($, wait, gen)
751 }
752 if (s.phase === 'after') { await advance($); return schedule($, wait, gen) }
753 return schedule($, wait, gen)
754 } catch (err) {
755 await stopAuto($, 'autoError', { err: String((err as Error)?.message ?? err).slice(0, 80) })
756 }
757}
758
759// ---------- 边等边玩与陪伴模式 ----------
760async function companionSay($: EngineInterface, cue: CompanionCue): Promise<void> {
761 const st = await read($, settings)
762 if (!st.companion) return
763 const p = await currentPack($)
764 const c = castOf(p, st.companion)
765 if (!c) return
766 const now = await nowMs($)
767 const w = await read($, work)
768 if (!okWork(w) || !canSpeak(w.said, now)) return
769 const line = companionLine(st.lang, cue, tr(c.short, st.lang, p.defaultLang), w.said.length + Math.floor(now / 1000))
770 await update($, work, x => ({ ...x, said: [...x.said.filter(y => now - y < 3600_000), now], line }))
771 await refreshStatus($)
772}
773
774async function onTurnStart($: EngineInterface): Promise<void> {
775 const st = await read($, settings)
776 const now = await nowMs($)
777 const w = await read($, work)
778 const prev = okWork(w) ? w : DEFAULT_WORK
779 const since = !prev.since || now - prev.lastTurn > BREAK_GAP_MS ? now : prev.since
780 await update($, work, x => ({ ...(okWork(x) ? x : DEFAULT_WORK), busy: true, since, lastTurn: now, notice: (st.watch !== false ? 'busy' : '') as WorkState['notice'] }))
781 if (st.companion && now - since >= REST_AFTER_MS) await companionSay($, 'rest')
782}
783
784/** Claude 写完或在等批准:暂停游戏(含自动模式),在面板上提示 */
785async function pauseForClaude($: EngineInterface, why: 'done' | 'permission'): Promise<void> {
786 const st = await read($, settings)
787 const now = await nowMs($)
788 await update($, work, x => ({ ...(okWork(x) ? x : DEFAULT_WORK), busy: why === 'permission', lastTurn: now, notice: (st.watch !== false ? why : '') as WorkState['notice'] }))
789 if (st.watch === false) return
790 const a = await read($, auto)
791 if (okAuto(a) && a.on) await stopAuto($, why === 'done' ? 'watchDone' : 'watchPermission')
792 const s = await read($, story)
793 if (s.phase !== 'title') {
794 $.ui.toast(t(st.lang, why === 'done' ? 'watchDone' : 'watchPermission'))
795 sfx($, st, 'notice')
796 }
797 await companionSay($, why)
798}
799
800/** 写代码的事件:只看命令种类和成败,不留命令、路径、输出 */
801async function onWorkEvent($: EngineInterface, ev: WorkEvent): Promise<void> {
802 const st = await read($, settings)
803 await companionSay($, ev)
804 if (!st.workEvents) return
805 const s = await read($, story)
806 if (s.phase === 'title' || s.phase === 'ending') return
807 await update($, story, x => ({ ...x, world: [...x.world, ev].slice(-4) }))
808 if (ev === 'commit') {
809 const p = await currentPack($, s.pack)
810 const best = p.cast.filter(c => (s.heroines[c.id]?.lastDay ?? 0) > 0).sort((a, b) => (s.heroines[b.id]?.affection ?? 0) - (s.heroines[a.id]?.affection ?? 0))[0]
811 if (best && s.keepsakes.filter(k => k.title.includes('#') || k.title.includes('提交')).length < 3) {
812 const n = s.keepsakes.filter(k => k.title.includes('#') || k.title.includes('提交')).length + 1
813 await giveKeepsake($, p, commitKeepsake(p.id, best.id, s.day, st.lang, n), st.lang)
814 }
815 }
816}
817
818// ---------- 生成剧本包、从项目生成、导出、社区 ----------
819// 这些是另一条线写的模块(gen.ts / share.ts / browse.ts):纯逻辑加 $ 调用;这里负责状态、进度、取消、保存。
820let genAbort: AbortController | null = null
821/** 社区索引的完整条目(含下载地址);面板只显示 job.entries 里的摘要。模块变量,不是状态 */
822let communityEntries: CommunityEntry[] = []
823
824function jobLine(lang: Lang, j: JobState): string {
825 if (j.kind !== 'gen' && j.kind !== 'repo') return j.kind === 'browse' ? t(lang, 'communityLoading') : ''
826 const step = j.step === 'expand' ? t(lang, 'genExpand') : j.step === 'repair' ? t(lang, 'genRepair', { n: j.attempt }) : t(lang, 'genOutline')
827 return t(lang, 'genRunning', { step })
828}
829
830async function setJob($: EngineInterface, fn: (j: JobState) => JobState): Promise<void> {
831 await update($, job, x => fn(okJob(x) ? x : DEFAULT_JOB))
832 await refreshStatus($)
833}
834
835/** 换一个不冲突的 id:内置包、已导入的包都不能撞 */
836async function freeId($: EngineInterface, id: string): Promise<string> {
837 const taken = new Set([...allPacks(await read($, packs)).map(e => e.id), ...Object.keys(((await $.store.get('packs-imported')) ?? {}) as object)])
838 if (!taken.has(id)) return id
839 for (let n = 2; ; n++) if (!taken.has(`${id}-${n}`)) return `${id}-${n}`
840}
841
842/** 保存一个校验过的包:存进导入表(下次启动还在),并尽量在剧本目录里留一份 JSON 方便手改 */
843async function savePack($: EngineInterface, pack: Pack, origin: 'imported' | 'community', replace = false): Promise<Pack> {
844 const id = replace ? pack.id : await freeId($, pack.id)
845 const p = { ...pack, id }
846 const stored = ((await $.store.get('packs-imported')) ?? {}) as Record<string, unknown>
847 await $.store.set('packs-imported', { ...stored, [id]: p })
848 if (origin === 'community') {
849 const ids = ((await $.store.get('packs-community')) as string[] | undefined) ?? []
850 await $.store.set('packs-community', [...new Set([...ids, id])])
851 }
852 try { await $.fs.write(`${await packDir($)}/${id}.json`, JSON.stringify(p, null, 2)) } catch { /* 目录写不了也没关系:导入表里已经有了 */ }
853 await loadExtraPacks($)
854 return p
855}
856
857async function draftPath($: EngineInterface, id: string): Promise<string> {
858 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
859 return `${home.replace(/\\/g, '/')}/.claude/galgame-drafts/${id || 'draft'}.draft.json`
860}
861
862async function handleGenResult($: EngineInterface, r: GenResult, lang: Lang): Promise<string> {
863 await charge($, r.usage)
864 if (r.refused) { $.ui.toast(r.refused); return r.refused }
865 if (r.pack) {
866 const p = await savePack($, r.pack, 'imported')
867 const msg = `${t(lang, 'genDone', { title: tr(p.title, lang, p.defaultLang) })}${r.homage ? ` ${r.homage}` : ''}`
868 $.ui.toast(msg)
869 return msg
870 }
871 if (genAbort?.signal.aborted) return t(lang, 'genCancelled')
872 let path = ''
873 try {
874 const id = String((JSON.parse(r.json || '{}') as { id?: unknown }).id ?? 'draft')
875 path = await draftPath($, id.replace(/[^a-z0-9_-]/gi, '') || 'draft')
876 await $.fs.write(path, r.json)
877 } catch { path = '-' }
878 const errors = r.errors.filter(e => !e.startsWith('privacy:')).slice(0, 4).join('; ') || r.errors.slice(0, 1).map(() => 'privacy').join('')
879 const msg = t(lang, 'genFailed', { errors, path })
880 $.ui.toast(msg)
881 return msg
882}
883
884/** 一句话生成(几分钟,后台跑,状态栏显示进度,可取消) */
885async function runGenerate($: EngineInterface, idea: string, days?: number, cast?: number): Promise<void> {
886 const st = await read($, settings)
887 genAbort = new AbortController()
888 const signal = genAbort.signal
889 await setJob($, j => ({ ...j, kind: 'gen', step: 'outline', attempt: 0, message: t(st.lang, 'genStart', { idea }) }))
890 try {
891 const r = await generatePack($, idea, { lang: st.lang, days, castSize: cast, signal, onStep: (step: GenStep, attempt: number) => { if (step !== 'done') void setJob($, j => ({ ...j, step, attempt })) } })
892 const msg = signal.aborted ? t(st.lang, 'genCancelled') : await handleGenResult($, r, st.lang)
893 await setJob($, j => ({ ...j, kind: '', step: '', message: msg }))
894 } catch (err) {
895 await setJob($, j => ({ ...j, kind: '', step: '', message: String((err as Error)?.message ?? err).slice(0, 120) }))
896 } finally {
897 genAbort = null
898 }
899}
900
901/** 从当前项目生成:先列出要读的文件,等玩家确认 */
902async function askRepo($: EngineInterface, root: string, idea: string): Promise<string> {
903 const st = await read($, settings)
904 const d = await scanRepo($, root)
905 const files = d.read.length ? d.read : ['(none)']
906 await setJob($, j => ({ ...j, kind: 'confirm', scan: { root, idea, files, bytes: d.bytes }, message: '' }))
907 return t(st.lang, 'repoConfirm', { files: files.join('、'), n: d.read.length, kb: Math.max(1, Math.round(d.bytes / 1024)) })
908}
909
910async function runRepo($: EngineInterface): Promise<void> {
911 const j0 = await read($, job)
912 if (!okJob(j0) || j0.kind !== 'confirm' || !j0.scan) return
913 const { root, idea } = j0.scan
914 const st = await read($, settings)
915 genAbort = new AbortController()
916 const signal = genAbort.signal
917 await setJob($, j => ({ ...j, kind: 'repo', step: 'outline', attempt: 0, scan: null }))
918 try {
919 const r = await packFromRepo($, root, idea || undefined, { lang: st.lang, signal, onStep: (step: GenStep, attempt: number) => { if (step !== 'done') void setJob($, j => ({ ...j, step, attempt })) } })
920 const msg = signal.aborted ? t(st.lang, 'genCancelled') : await handleGenResult($, r, st.lang)
921 await setJob($, j => ({ ...j, kind: '', step: '', message: msg }))
922 } catch (err) {
923 await setJob($, j => ({ ...j, kind: '', step: '', message: String((err as Error)?.message ?? err).slice(0, 120) }))
924 } finally {
925 genAbort = null
926 }
927}
928
929async function cancelJob($: EngineInterface): Promise<void> {
930 const st = await read($, settings)
931 genAbort?.abort()
932 await setJob($, j => ({ ...j, kind: '', step: '', scan: null, message: t(st.lang, 'genCancelled') }))
933}
934
935/** 导出这一局(不调用模型):写到会话根目录 */
936async function doExport($: EngineInterface, format: ExportFormat): Promise<string> {
937 const st = await read($, settings)
938 const s = await read($, story)
939 const p = await currentPack($, s.pack)
940 try {
941 const root = await $.session.root()
942 const path = `${root.replace(/\\/g, '/')}/${exportFileName(s, p, format)}`
943 await $.fs.write(path, exportRun(s, p, st.lang, format, { quotes: s.quotes }))
944 const msg = t(st.lang, 'exported', { path })
945 $.ui.toast(msg)
946 return msg
947 } catch (err) {
948 const msg = t(st.lang, 'exportFail', { err: String((err as Error)?.message ?? err).slice(0, 80) })
949 $.ui.toast(msg)
950 return msg
951 }
952}
953
954async function shareText($: EngineInterface): Promise<string> {
955 const st = await read($, settings)
956 const s = await read($, story)
957 return shareSummary(s, await currentPack($, s.pack), st.lang)
958}
959
960/** 结局卡:不剧透的分享方格(结局页按 C;/gal card) */
961async function cardText($: EngineInterface): Promise<string> {
962 const st = await read($, settings)
963 const s = await read($, story)
964 return endingCard(s, await currentPack($, s.pack), st.lang)
965}
966
967/** 社区索引(不调用模型);被许可白名单挡下的条目不显示,只写调试日志 */
968async function browse($: EngineInterface): Promise<void> {
969 const st = await read($, settings)
970 await setJob($, j => ({ ...j, kind: 'browse', message: '' }))
971 const r = await fetchIndex($)
972 for (const x of r.rejected) $.ui.log(`galgame community: ${x.id}: ${x.reason}`, { to: 'debug' })
973 communityEntries = r.entries
974 const T = (x: Parameters<typeof tr>[0]) => tr(x, st.lang, 'en')
975 await setJob($, j => ({
976 ...j, kind: '', message: r.error ? t(st.lang, 'communityFail', { err: r.error }) : r.entries.length ? '' : t(st.lang, 'communityEmpty'),
977 entries: r.entries.map(e => ({ id: e.id, title: T(e.title), author: e.author, license: e.license, lang: e.lang.join('/'), summary: T(e.summary) })),
978 }))
979}
980
981async function install($: EngineInterface, id: string): Promise<void> {
982 const st = await read($, settings)
983 const e = communityEntries.find(x => x.id === id)
984 if (!e) return
985 const title = tr(e.title, st.lang, 'en')
986 await setJob($, j => ({ ...j, kind: 'download', message: t(st.lang, 'downloading', { title }) }))
987 const r = await fetchPack($, e)
988 if (!r.pack) {
989 await setJob($, j => ({ ...j, kind: '', message: t(st.lang, 'installFail', { err: r.errors[0] ?? '?' }) }))
990 return
991 }
992 if (BUILTIN.some(b => b.id === r.pack!.id)) {
993 await setJob($, j => ({ ...j, kind: '', message: t(st.lang, 'installFail', { err: `"${r.pack!.id}" is built in` }) }))
994 return
995 }
996 await savePack($, r.pack, 'community', true)
997 const msg = t(st.lang, 'installed', { title })
998 $.ui.toast(msg)
999 await setJob($, j => ({ ...j, kind: '', message: msg }))
1000}
1001
1002// ---------- 剧情画面的条目(旁白、特写、台词、留白) ----------
1003type Item = { who: string; text: string; line: number; kind: 'narration' | 'focus' | 'line' | 'pause' }
1004
1005/** 一幕的条目:旁白(0),镜头特写跟在旁白后,留白插在第 pause 句之后;line 是原来的句号(演出指令按它算) */
1006function sceneItems(sc: NonNullable<Story['scene']>, focusText: string): Item[] {
1007 const out: Item[] = [{ who: '', text: sc.narration, line: 0, kind: 'narration' }]
1008 if (sc.camera?.focus) out.push({ who: '', text: focusText || sc.camera.focus, line: 0, kind: 'focus' })
1009 sc.lines.forEach((l, i) => {
1010 out.push({ who: l.who, text: l.text, line: i + 1, kind: 'line' })
1011 if (sc.camera?.pause === i + 1 && i + 1 < sc.lines.length) out.push({ who: '', text: '……', line: i + 1, kind: 'pause' })
1012 })
1013 return out
1014}
1015
1016// ---------- 小部件 ----------
1017/** 关系值:五格,图标来自角色的 meter(恋爱角色默认 ♥ ♡,非恋爱角色如「看穿 ◆◇」「信任 ●○」) */
1018function hearts(n: number, max: number, on = '♥', off = '♡'): string {
1019 const full = Math.max(0, Math.min(5, Math.round((n / Math.max(1, max)) * 5)))
1020 return on.repeat(full) + off.repeat(5 - full)
1021}
1022
1023const KIND_ICON: Record<string, string> = { reply: '❝', observe: '◎', promise: '✦', keep: '✔' }
1024// 终端深浅:session.start、/gal 时从 /config 的 theme 推一次(模块变量,画面板时只读);推不出按深色
1025let detectedBg: Bg | null = null
1026async function detectBg($: EngineInterface): Promise<void> {
1027 try {
1028 const row = (await $.config.list()).find(r => r.key === 'theme')
1029 detectedBg = bgOfTheme(row?.value)
1030 } catch {
1031 detectedBg = null
1032 }
1033}
1034const TONE_ORDER: Tone[] = ['honest', 'evasive', 'joking', 'committed', 'caring', 'bold']
1035
1036export const register: Register = on => {
1037 on('session.start', async ($, e, next) => {
1038 await $.command.register({ name: 'gal', description: 'AI 视觉小说 / AI visual novel', argumentHint: '[new <idea> | repo [path] | export [html|md] | share | card | browse | cancel | import | validate <path.json>]' })
1039 const st = await $.store.get('settings')
1040 if (st && typeof st === 'object') await update($, settings, () => ({ ...DEFAULT_SETTINGS, ...(st as Partial<GalSettings>) }))
1041 const m = await $.store.get('meta')
1042 if (m && typeof m === 'object') await update($, meta, () => m as Meta)
1043 const f = await $.store.get('feats')
1044 if (okFeats(f)) await update($, feats, () => f)
1045 await loadExtraPacks($)
1046 await migrate($)
1047 await detectBg($)
1048 return next(e)
1049 })
1050
1051 on('session.end', async ($, e, next) => {
1052 stopAnim()
1053 layers = []
1054 autoGen++
1055 autoTimer?.cancel()
1056 autoTimer = null
1057 return next(e)
1058 })
1059
1060 // 边等边玩:Claude 开始干活时提示「趁现在玩一幕」;干完或等批准时暂停游戏(只看主循环,不看子代理)
1061 on('turn.start', async ($, e, next) => {
1062 const r = await next(e)
1063 if (!e.agentId) await onTurnStart($)
1064 return r
1065 })
1066 on('turn.complete', async ($, e, next) => {
1067 const r = await next(e)
1068 if (!e.agentId) await pauseForClaude($, 'done')
1069 return r
1070 })
1071 on('classic.PermissionRequest', async ($, e, next) => {
1072 await pauseForClaude($, 'permission')
1073 return next(e)
1074 })
1075
1076 // 写代码的事件:Bash 里的测试、构建、git commit,只取种类和成败
1077 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1078 const r = await next(e)
1079 try {
1080 const command = String(e.command ?? '')
1081 const failed = 'deny' in r && !!r.deny ? true : !!(r as { isError?: boolean }).isError
1082 const ev = classifyBash(command, failed)
1083 if (ev) await onWorkEvent($, ev)
1084 } catch {
1085 // 游戏的事不能影响工具调用
1086 }
1087 return r
1088 })
1089
1090 on('command.run', { command: 'gal' }, async ($, e) => {
1091 await migrate($)
1092 await detectBg($)
1093 const st = await read($, settings)
1094 const args = e.args.trim()
1095 if (args.startsWith('validate')) {
1096 const path = args.slice('validate'.length).trim().replace(/^["']|["']$/g, '')
1097 return { text: await checkPackFile($, path, st.lang) }
1098 }
1099 if (args.startsWith('import')) {
1100 const path = args.slice('import'.length).trim().replace(/^["']|["']$/g, '')
1101 if (!path) return { text: t(st.lang, 'importHint', { dir: await packDir($) }) }
1102 return { text: await importPack($, path, st.lang) }
1103 }
1104 const busy = () => { const j = genAbort; return !!j }
1105 if (args === 'new' || args.startsWith('new ')) {
1106 let idea = args.slice(3).trim()
1107 const num = (k: string) => { const m = new RegExp(`--${k}\\s+(\\d+)`).exec(idea); idea = idea.replace(new RegExp(`--${k}\\s+\\d+`), '').trim(); return m ? Number(m[1]) : undefined }
1108 const days = num('days')
1109 const cast = num('cast')
1110 if (!idea) return { text: t(st.lang, 'genHint') }
1111 if (busy()) return { text: t(st.lang, 'genBusy') }
1112 void runGenerate($, idea, days, cast)
1113 return { text: t(st.lang, 'genStart', { idea }) }
1114 }
1115 if (args === 'repo' || args.startsWith('repo ')) {
1116 const rest = args.slice(4).trim()
1117 if (rest === '--yes') { if (busy()) return { text: t(st.lang, 'genBusy') }; void runRepo($); return { text: t(st.lang, 'genStart', { idea: 'repo' }) } }
1118 if (busy()) return { text: t(st.lang, 'genBusy') }
1119 const [first, ...more] = rest.split(/\s+/).filter(Boolean)
1120 const looksPath = !!first && /[\\/]|^\.$|^~/.test(first)
1121 const root = looksPath ? first! : await $.session.root()
1122 const idea = (looksPath ? more : rest.split(/\s+/).filter(Boolean)).join(' ')
1123 const text = await askRepo($, root, idea)
1124 await $.ui.open({ id: PANE, title: 'ai-galgame', focus: true })
1125 return { text: `${text}\n${t(st.lang, 'repoAsk')}` }
1126 }
1127 if (args === 'cancel') { await cancelJob($); return { text: t(st.lang, 'genCancelled') } }
1128 if (args === 'export' || args.startsWith('export ')) return { text: await doExport($, args.includes('md') ? 'md' : 'html') }
1129 if (args === 'share') return { text: await shareText($) }
1130 if (args === 'card') return { text: await cardText($) }
1131 if (args === 'browse') {
1132 await update($, ui, x => ({ ...x, view: 'packs' as View, tab: 1 }))
1133 await $.ui.open({ id: PANE, title: 'ai-galgame', focus: true })
1134 void browse($)
1135 return { text: t(st.lang, 'communityLoading') }
1136 }
1137 await loadExtraPacks($)
1138 const s = await read($, story)
1139 if (s.phase === 'loading') await update($, story, x => ({ ...x, phase: 'plan' as const }))
1140 await update($, ui, x => ({ ...x, view: 'game' as View }))
1141 if (s.phase === 'title' || s.pack !== st.pack) await update($, story, x => ({ ...x, phase: 'title' as const }))
1142 await $.ui.open({ id: PANE, title: 'ai-galgame', focus: true })
1143 void maybeLetter($)
1144 return { text: t(st.lang, 'opened') }
1145 })
1146
1147 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1148 // ====== 这里只读状态,不写 ======
1149 const el = $.ui.resolve(e)
1150 const { Box, Text, Button } = el
1151 const RasterEl = 'Raster' in el ? el.Raster : null
1152 const rawSt = await read($, settings)
1153 const st: GalSettings = okSettings(rawSt) ? { ...DEFAULT_SETTINGS, ...rawSt } : DEFAULT_SETTINGS
1154 const rawUi = await read($, ui) as unknown
1155 const view: UiState = okUi(rawUi) ? rawUi : DEFAULT_UI
1156 const extra = await read($, packs)
1157 // 旧格式的状态在这里补成能显示的样子,不写回(写回在 migrate 里)
1158 const rawStory = await read($, story) as unknown
1159 const s = (okStory(rawStory) ? normalizeStory(rawStory, packById((rawStory as Story).pack, extra)) : null) ?? TITLE_STORY
1160 const m = await read($, meta)
1161 const canUndo = (await read($, undo)).length > 0
1162 const rawAuto = await read($, auto) as unknown
1163 const ao: AutoState = okAuto(rawAuto) ? rawAuto : DEFAULT_AUTO
1164 const rawWork = await read($, work) as unknown
1165 const wk: WorkState = okWork(rawWork) ? rawWork : DEFAULT_WORK
1166 const rawJob = await read($, job) as unknown
1167 const jb: JobState = okJob(rawJob) ? rawJob : DEFAULT_JOB
1168 const rawFeats = await read($, feats) as unknown
1169 const fe: Feats = okFeats(rawFeats) ? rawFeats : EMPTY_FEATS
1170 const cfg = autoCfg(st)
1171 // 玩家按了自动以外的键:先暂停自动模式,再做这件事(写状态发生在按键回调里,不在画面板时)
1172 const hit = (fn: () => void) => () => { if (ao.on) void stopAuto($, 'autoKeyPause'); fn() }
1173 const lang = st.lang
1174 const L = (k: UiKey, v?: Record<string, string | number>) => t(lang, k, v)
1175 const list = allPacks(extra)
1176 const p = (list.find(x => x.id === (view.view === 'packs' ? st.pack : s.phase === 'title' ? st.pack : s.pack)) ?? list[0]!).pack
1177 const T = packText(p, lang)
1178 const pm = packMeta(m, p.id)
1179 const seenCount = p.endings.filter(x => pm.endings.includes(x.id)).length
1180 const cols = e.props.bodyColumns ?? 80
1181 const compact = st.layout === 'compact' || (st.layout !== 'full' && cols < 80)
1182 const anim = st.anim !== false
1183 const Raster = RasterEl
1184 const BG: Bg = st.bg === 'dark' || st.bg === 'light' ? st.bg : detectedBg ?? 'dark'
1185 const TX = TEXT[BG]
1186 const cb = st.palette === 'cb'
1187 const C = { ...(cb ? KIND.cb : KIND.default), ...(cb ? STATE_CB : STATE_KEYS) }
1188 const stageW = Math.min(STAGE_W, cols - 4)
1189 const canStage = !!Raster && stageW >= 60
1190 const nextLayers: Layer[] = []
1191 const today = dateKey(Date.now())
1192 const spentToday = fe.today.date === today ? fe.today.usd : 0
1193
1194 const inScene = view.view === 'game' && (s.phase === 'scene' || s.phase === 'after') && !!s.scene
1195 const theme = inScene ? themeOf(s.scene?.atmo) : DEFAULT_THEME
1196 // rawColor 给色块和像素;colorOf 是当前景用的、对终端底色够对比的版本
1197 const rawColor = (id: string) => (id === 'me' ? TX.me : personOf(p, id)?.color ?? FALLBACK)
1198 const colorOf = (id: string) => readable(rawColor(id), BG)
1199 const tinted = (id: string) => tint(rawColor(id), theme, inScene ? 0.22 : 0)
1200 const nameOf = (id: string) => (id === 'me' ? T(p.me) : T(personOf(p, id)?.name) || id)hooks/browse.ts 146 lines1// 社区剧本:读一个 JSON 索引,按条目下载剧本包并校验。只接受许可在白名单里的条目:原创、CC0、CC BY、CC BY-SA。
2// 只走 $.http.fetch,不读写 $.state;界面接入见 hooks/INTEGRATION.md,投稿规则见 community/README.md。
3
4import type { EngineInterface } from 'claude-code'
5
6import type { L, Lang, Pack } from '../types'
7import { tr, validatePack } from './pack'
8
9export const DEFAULT_INDEX_URL = 'https://raw.githubusercontent.com/Ephemeral6/ai-galgame/main/community/index.json'
10/** 索引和单个剧本包的大小上限(字符) */
11export const MAX_INDEX_CHARS = 512 * 1024
12export const MAX_PACK_CHARS = 1024 * 1024
13const MAX_ENTRIES = 500
14
15export type CommunityEntry = {
16 id: string
17 title: L
18 author: string
19 /** 剧本包本身的发布许可:original | CC0-1.0 | CC-BY-4.0 | CC-BY-SA-4.0(也接受 "CC BY 4.0" 这类写法) */
20 license: string
21 /** 包提供的语言 */
22 lang: Lang[]
23 summary: L
24 /** 剧本包 JSON 的地址(https) */
25 url: string
26 version?: string
27 /** 作者主页或讨论帖(可选,只显示,不自动打开) */
28 homepage?: string
29}
30export type Rejected = { id: string; reason: string }
31export type IndexResult = { entries: CommunityEntry[]; rejected: Rejected[]; error?: string }
32export type FetchPackResult = { pack: Pack | null; errors: string[] }
33
34export type LicenseId = 'original' | 'CC0' | 'CC-BY' | 'CC-BY-SA'
35
36/** 把许可写法归一;不在白名单里(含 NC、ND、MIT、未写)返回 null */
37export function normalizeLicense(raw: unknown): LicenseId | null {
38 if (typeof raw !== 'string') return null
39 const s = raw.trim().toLowerCase().replace(/^creative\s*commons\s*/, 'cc ').replace(/attribution[- ]share[- ]?alike/, 'by-sa').replace(/attribution/, 'by')
40 const k = s.replace(/[\s_\-./]+/g, '').replace(/international|unported|generic|license|licence/g, '')
41 if (k === 'original' || k === '原创') return 'original'
42 if (/^cc0(10)?$/.test(k) || k === 'publicdomaincc0') return 'CC0'
43 if (/^ccby([1-4]0?)?$/.test(k)) return 'CC-BY'
44 if (/^ccbysa([1-4]0?)?$/.test(k)) return 'CC-BY-SA'
45 return null
46}
47
48export function licenseOk(raw: unknown): boolean {
49 return normalizeLicense(raw) !== null
50}
51
52const ID = /^[a-z][a-z0-9_-]{0,31}$/
53const isL = (x: unknown) => (typeof x === 'string' && x.length > 0) || (!!x && typeof x === 'object' && !Array.isArray(x) && Object.values(x).every(v => typeof v === 'string') && Object.keys(x).length > 0)
54
55function httpsUrl(u: unknown): u is string {
56 // https、有主机名、不带账号密码、没有空白
57 return typeof u === 'string' && u.length <= 500 && /^https:\/\/[A-Za-z0-9.-]+(:\d{1,5})?(\/[^\s]*)?$/.test(u)
58}
59
60/** 校验并整理一条索引;不合格返回原因 */
61export function checkEntry(raw: unknown): { entry: CommunityEntry } | { reason: string } {
62 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { reason: 'not an object' }
63 const o = raw as Record<string, unknown>
64 if (typeof o.id !== 'string' || !ID.test(o.id)) return { reason: 'id: lowercase id' }
65 if (!isL(o.title)) return { reason: 'title: text' }
66 if (typeof o.author !== 'string' || !o.author.trim()) return { reason: 'author: string' }
67 if (!licenseOk(o.license)) return { reason: `license not accepted: ${String(o.license ?? '(missing)')} (allowed: original, CC0, CC BY, CC BY-SA)` }
68 const langs = (Array.isArray(o.lang) ? o.lang : [o.lang]).filter((l): l is Lang => l === 'zh' || l === 'en')
69 if (!langs.length) return { reason: 'lang: zh | en | [zh, en]' }
70 if (!isL(o.summary)) return { reason: 'summary: text' }
71 if (!httpsUrl(o.url)) return { reason: 'url: https URL of the pack JSON' }
72 const entry: CommunityEntry = { id: o.id, title: o.title as L, author: o.author.trim().slice(0, 80), license: String(o.license), lang: langs, summary: o.summary as L, url: o.url }
73 if (typeof o.version === 'string') entry.version = o.version.slice(0, 20)
74 if (httpsUrl(o.homepage)) entry.homepage = o.homepage
75 return { entry }
76}
77
78async function get($: EngineInterface, url: string, cap: number): Promise<{ text: string } | { error: string }> {
79 try {
80 const r = await $.http.fetch(url, { headers: { accept: 'application/json' } })
81 if (!r.ok) return { error: `HTTP ${r.status}` }
82 if (r.text.length > cap) return { error: `too large (${r.text.length} > ${cap} characters)` }
83 return { text: r.text }
84 } catch (e) {
85 return { error: e instanceof Error ? e.message : String(e) }
86 }
87}
88
89/** 读取社区索引:{ "version": 1, "packs": [条目...] }(也接受直接是数组)。不合格、许可不在白名单的条目放进 rejected */
90export async function fetchIndex($: EngineInterface, url: string = DEFAULT_INDEX_URL): Promise<IndexResult> {
91 if (!httpsUrl(url)) return { entries: [], rejected: [], error: 'index URL must be https' }
92 const r = await get($, url, MAX_INDEX_CHARS)
93 if ('error' in r) return { entries: [], rejected: [], error: r.error }
94 let raw: unknown
95 try {
96 raw = JSON.parse(r.text)
97 } catch {
98 return { entries: [], rejected: [], error: 'index is not valid JSON' }
99 }
100 const list = Array.isArray(raw) ? raw : raw && typeof raw === 'object' && Array.isArray((raw as { packs?: unknown }).packs) ? (raw as { packs: unknown[] }).packs : null
101 if (!list) return { entries: [], rejected: [], error: 'index: expected { "packs": [...] }' }
102 const entries: CommunityEntry[] = []
103 const rejected: Rejected[] = []
104 const seen = new Set<string>()
105 for (const item of list.slice(0, MAX_ENTRIES)) {
106 const c = checkEntry(item)
107 const id = item && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string' ? (item as { id: string }).id : '?'
108 if ('reason' in c) rejected.push({ id, reason: c.reason })
109 else if (seen.has(c.entry.id)) rejected.push({ id, reason: 'duplicate id' })
110 else { seen.add(c.entry.id); entries.push(c.entry) }
111 }
112 return { entries, rejected }
113}
114
115/**
116 * 下载一个社区剧本包并校验:条目许可必须在白名单;包 id 与条目一致;改编包的原作许可也必须在白名单;validatePack 通过。
117 * 返回的包是纯数据,可以直接放进 packs 列表(origin 记为 'imported' 或另设 'community')。
118 */
119export async function fetchPack($: EngineInterface, entry: CommunityEntry): Promise<FetchPackResult> {
120 const c = checkEntry(entry)
121 if ('reason' in c) return { pack: null, errors: [c.reason] }
122 const r = await get($, entry.url, MAX_PACK_CHARS)
123 if ('error' in r) return { pack: null, errors: [`download: ${r.error}`] }
124 let raw: unknown
125 try {
126 raw = JSON.parse(r.text)
127 } catch {
128 return { pack: null, errors: ['pack is not valid JSON'] }
129 }
130 const errors = validatePack(raw)
131 if (errors.length) return { pack: null, errors }
132 const pack = raw as Pack
133 const more: string[] = []
134 if (pack.id !== entry.id) more.push(`id: pack says "${pack.id}", index says "${entry.id}"`)
135 if (pack.source !== 'original') {
136 const srcLicense = tr(pack.source.license, 'en', pack.defaultLang)
137 if (!licenseOk(srcLicense)) more.push(`source.license not accepted: ${srcLicense} (the adapted work must be CC0, CC BY or CC BY-SA)`)
138 if (normalizeLicense(entry.license) === 'original') more.push('license: an adaptation cannot be listed as "original"')
139 if (normalizeLicense(tr(pack.source.license, 'en', pack.defaultLang)) === 'CC-BY-SA' && normalizeLicense(entry.license) !== 'CC-BY-SA') {
140 more.push('license: an adaptation of a CC BY-SA work must itself be CC BY-SA')
141 }
142 }
143 if (more.length) return { pack: null, errors: more }
144 return { pack, errors: [] }
145}
146hooks/gen.ts 526 lines1// 生成剧本包:一句话想法 → 大纲 → 完整 Pack JSON → 校验 → 交回模型修。
2// 另有「从代码仓库生成」:只读目录结构、文件名、README 开头和清单里的名字字段,模块变成拟人角色。
3// 纯逻辑加模型调用:不读写 $.state,不碰 atom;界面接入见 hooks/INTEGRATION.md。
4
5import type { EngineInterface, FsEntry, ModelTextBlock } from 'claude-code'
6
7import type { Lang, Pack, SpriteDef, Usage } from '../types'
8import { CICADA } from '../packs/cicada/pack'
9import { WRITER, zeroUsage } from './engine'
10import { validatePack } from './pack'
11
12export type GenStep = 'outline' | 'expand' | 'repair' | 'done'
13export type GenOpts = {
14 lang: Lang
15 /** 天数,默认 7(1..14) */
16 days?: number
17 /** 主角色人数,默认 3(1..6) */
18 castSize?: number
19 /** 写进 pack.author,默认 "ai-galgame generator" */
20 author?: string
21 /** 校验不通过时交回模型修几次,默认 2(0..2) */
22 maxRepairs?: number
23 /** 进度回调:界面用来更新状态栏 */
24 onStep?: (step: GenStep, attempt: number) => void
25 /** 取消 */
26 signal?: AbortSignal
27}
28export type GenResult = {
29 /** 通过全部校验的包;不通过或被拒绝时为 null */
30 pack: Pack | null
31 /** 最后一版 JSON 文本(不通过时也给,便于玩家手改后用 /gal import) */
32 json: string
33 /** 剩下的问题;空表示通过 */
34 errors: string[]
35 usage: Usage
36 /** 拒绝生成的原因(给玩家看) */
37 refused?: string
38 /** 想法点名了现有作品,已改写成原创致敬时的说明 */
39 homage?: string
40}
41
42export const GEN_MAX_TOKENS = 24_000
43const OUTLINE_TOKENS = 3_000
44const TIMEOUT = 9 * 60_000
45
46// ---------- 内容限制 ----------
47
48/** 一眼就能认出的受版权保护作品与其人物、地名(只收不会和普通词混淆的写法)。命中时提示改写成原创致敬,生成结果里不许再出现这些名字。 */
49export const PROTECTED: { work: string; names: string[] }[] = [
50 { work: 'Harry Potter', names: ['Harry Potter', '哈利·波特', '哈利波特', 'Hogwarts', '霍格沃茨', 'Hermione', '赫敏', 'Dumbledore', '邓布利多', 'Voldemort', '伏地魔'] },
51 { work: 'Pokémon', names: ['Pokémon', 'Pokemon', '宝可梦', '精灵宝可梦', 'Pikachu', '皮卡丘'] },
52 { work: 'Genshin Impact', names: ['Genshin', '原神', 'Paimon', '派蒙', 'Teyvat', '提瓦特'] },
53 { work: 'Honkai', names: ['Honkai', '崩坏3', '崩坏:星穹铁道', '星穹铁道'] },
54 { work: 'Arknights', names: ['Arknights', '明日方舟'] },
55 { work: 'Naruto', names: ['Naruto', '火影忍者', '鸣人', 'Sasuke', '佐助'] },
56 { work: 'One Piece', names: ['One Piece', '海贼王', '航海王', 'Luffy', '路飞'] },
57 { work: 'Detective Conan', names: ['Detective Conan', '名侦探柯南', '江户川柯南'] },
58 { work: 'Doraemon', names: ['Doraemon', '哆啦A梦', '哆啦 A 梦', '机器猫', 'Nobita', '野比大雄'] },
59 { work: 'Demon Slayer', names: ['Demon Slayer', 'Kimetsu', '鬼灭之刃', '炭治郎', 'Tanjiro'] },
60 { work: 'Attack on Titan', names: ['Attack on Titan', '进击的巨人', 'Eren Yeager', '艾伦·耶格尔'] },
61 { work: 'Evangelion', names: ['Evangelion', '新世纪福音战士', 'EVA 初号机', 'Shinji Ikari', '碇真嗣', '绫波丽'] },
62 { work: 'Steins;Gate', names: ['Steins;Gate', '命运石之门', '冈部伦太郎', '牧濑红莉栖'] },
63 { work: 'Clannad', names: ['Clannad', '古河渚', '冈崎朋也'] },
64 { work: 'Doki Doki Literature Club', names: ['Doki Doki Literature Club', '心跳文学部'] },
65 { work: 'Fate/stay night', names: ['Fate/stay night', 'Fate/Grand Order', '圣杯战争'] },
66 { work: 'Madoka Magica', names: ['Madoka', '魔法少女小圆', '鹿目圆', 'Kyubey'] },
67 { work: 'Zelda', names: ['Zelda', '塞尔达', 'Hyrule', '海拉鲁'] },
68 { work: 'Super Mario', names: ['Super Mario', '超级马里奥', '马里奥', 'Bowser', '库巴'] },
69 { work: 'Star Wars', names: ['Star Wars', '星球大战', '星战', 'Jedi', '绝地武士', 'Darth Vader', '达斯·维达'] },
70 { work: 'Marvel', names: ['Marvel Comics', 'Marvel Studios', '漫威', 'Avengers', '复仇者联盟', 'Iron Man', '钢铁侠', 'Spider-Man', '蜘蛛侠'] },
71 { work: 'Disney', names: ['Disney', '迪士尼', 'Mickey Mouse', '米老鼠', '冰雪奇缘'] },
72 { work: 'The Lord of the Rings', names: ['Lord of the Rings', '指环王', '魔戒', 'Gandalf', '甘道夫', 'Frodo', '佛罗多'] },
73 { work: 'SpongeBob', names: ['SpongeBob', '海绵宝宝', '派大星'] },
74 { work: 'Legend of Zhen Huan', names: ['甄嬛传', '甄嬛'] },
75 { work: 'Minecraft', names: ['Minecraft'] },
76]
77
78/** 想法里点名了哪些受保护作品(按 PROTECTED 匹配,不区分大小写) */
79export function protectedHits(text: string): { work: string; names: string[] }[] {
80 const low = text.toLowerCase()
81 return PROTECTED.filter(w => w.names.some(n => low.includes(n.toLowerCase())))
82}
83
84/** 显然要求露骨内容的想法:不调用模型,直接拒绝 */
85const EXPLICIT = /(色情|黄文|h文|肉文|r-?18|18禁|成人向|nsfw|hentai|porn|explicit sex|smut|lewd|做爱|性爱)/i
86
87export function explicitRequest(text: string): boolean {
88 return EXPLICIT.test(text)
89}
90
91const RULES = `[Content rules — these override the idea]
921. Everything must be ORIGINAL: invent every character name, place name, organization and title. Never reuse or imitate characters, names, catchphrases, plots or distinctive settings from existing books, games, anime, films or series.
932. If the idea names an existing copyrighted work or its characters, do NOT write fan fiction. Either (a) turn it into an original homage: keep only the genre, mood and broad themes, with entirely new names, and describe the homage in "homage" WITHOUT naming the work; or (b) if nothing original is left once the borrowed parts are removed, refuse politely in "refuse". Works in the public domain (old folk tales, classical myths) may be used freely.
943. No explicit sexual content, no graphic gore. Keep it at the level of a general-audience visual novel.
954. Give every main character an age. Anyone under 18 is NEVER a romance route: set "romance": false and a meter label such as "trust" or "friendship", and never write romance, flirting, crushes or a love-interest framing for them, nor between them and anyone else. Romance routes are adults only.
965. No real people, no real brands, no hateful content.`
97
98// ---------- 格式说明(缓存块) ----------
99
100function spriteExample(sp: SpriteDef): string {
101 return JSON.stringify({ pal: sp.pal, art: sp.art, eyes: sp.eyes, cheeks: sp.cheeks, motion: sp.motion })
102}
103
104/** 剧本包格式说明:在大纲、展开、修复三步里都放在最前面,打缓存标记 */
105export function formatSpec(): string {
106 const sprites = CICADA.cast.slice(0, 3).map(c => spriteExample(c.sprite)).join('\n')
107 return `You write story packs for ai-galgame, a visual-novel engine. A story pack is pure JSON data: humans (or you) write the skeleton — premise, characters, places, schedule, acts, endings — and the engine's writer model fills in each scene at play time. The player is "I" (first person) and spends a number of days visiting places in time slots, meeting characters, keeping or breaking promises, collecting keepsakes, and reaching one of several endings.
108
109[Pack JSON format]
110Text fields ("text" below) are plain strings written in the pack's language.
111Ids match ^[a-z][a-z0-9_-]{0,31}$ and are unique within their list (cast and npc ids are unique together; "me" is reserved).
112{
113 "id": id, "version": "0.1.0", "defaultLang": "zh"|"en", "langs": ["zh"|"en"], "author": string, "source": "original",
114 "notes": text (optional author's note; for an homage, the genre it pays tribute to, never naming a work),
115 "title": text, "subtitle": text,
116 "premise": text — who "I" am, where and when; the situation and the ticking clock,
117 "style": text — prose style rules and taboos for the scene writer (concrete details, distinct voices; list things to avoid),
118 "me": text — how "I" appears in dialogue ("我" / "Me"),
119 "days": integer 1..60, "slots": 1..4 texts (time slots of a day, e.g. "Morning","Evening"), "dayLabel": text with {n} ("第 {n} 天" / "Day {n}"),
120 "places": [{ "id", "name": text, "mood": text (sensory one-liner), "slots": [slot indexes when open] }] — every slot must have at least one open place,
121 "cast": 1..6 [{ "id", "name": text, "short": text (short name), "color": "#rrggbb",
122 "tagline": text (one-line summary), "look": text (the pixel animal: species, colors, one accessory),
123 "voice": text (how they talk), "samples": [3-4 sample lines], "secret": text (revealed only at stage 3+),
124 "arc": exactly 5 texts, one per stage, "haunts": { "<slot index>": [place ids] },
125 "deadline": { "day": 1..days, "text": text }, "keepsakeIdeas": text,
126 "romance": boolean (false for anyone under 18 and for non-romance roles), "meter": { "label": text, "full": one character, "empty": one character } (required when romance is false),
127 "sprite": SPRITE }],
128 "npcs": 0..8 [{ "id", "name": text, "color": "#rrggbb", "role": text, "voice": text, "samples": [1-3 lines], "haunts"?: { "<slot>": [place ids] } }],
129 "relations": [{ "a": id|"me", "b": id|"me", "kind": text, "text": text, "hiddenUntil"?: stage 0..4 }],
130 "acts": [{ "id", "title": text, "from": day (first act from 1, ascending), "goal": text, "hook"?: text,
131 "beats": [{ "id", "day": 1..days, "by"?: day..days, "who"?: [cast/npc ids], "kind"?: text, "text": text, "promise"?: text }] }],
132 "incidents": [{ "id", "text": text, "chance"?: 0..1, "days"?: [from, to], "slots"?: [slot indexes], "places"?: [place ids], "who"?: [ids] }],
133 "stages": exactly 5 texts (relationship stages, e.g. Strangers, Friends, Worries, Secret, Choice),
134 "gates": exactly 5 { "affection": number, "scenes": number } (index 0 is {0,0}; e.g. 0/0, 3/1, 6/3, 9/5, 12/7 — shrink for short packs),
135 "events": [{ "day", "text" }] (calendar notes, may be empty),
136 "fixed": [{ "day", "slot", "place": text, "mood": text, "title": text, "cast": [ids] }] (forced scenes, may be empty),
137 "proactiveAfter": number (days of neglect before someone seeks me out; 0 = off),
138 "endings": [{ "id", "title": text, "tier": number (higher wins), "who"?: cast id, "kind"?: "true"|"good"|"normal"|"bad",
139 "minStage"?: 0..4, "minAffection"?: number, "maxBroken"?: number, "minKept"?: number, "keptKey"?: id of a beat that has a promise,
140 "brief": text (how the ending goes) }] — MUST include one fallback ending with no who / minStage / minAffection / maxAffection / flags / keptKey,
141 "finale": text (what the last sentence of an ending should echo),
142 "loading": 3+ short atmospheric texts shown while a scene is being written
143}
144
145[SPRITE — pixel-animal portrait]
146{ "pal": { "<one letter>": "#rrggbb" }, "art": 4..12 rows of EXACTLY 14 characters, "eyes": [[row, col], [row, col]], "cheeks": [[row, col], ...], "motion": "tail"|"wings"|"ears"|"bob" }
147'.' is transparent; every other character must be a key of pal. Draw a small cute animal seen from the front: ears/horns on top, a round body, little feet at the bottom. No face: leave the eye and cheek cells as plain body color — the engine draws eyes and blush there. No background, no frame, no text. 3-6 palette letters: a main color, a darker shade for the bottom/feet, a light belly color, and one accent (bow, scarf, glasses, beak). Each character a different animal and color. Counting matters: every row must be exactly 14 characters. Examples from an existing pack (style reference only — do not copy):
148${sprites}`
149}
150
151// ---------- 模型调用 ----------
152
153type Ask = { text: string | null; reason?: string }
154
155async function ask($: EngineInterface, prompt: ModelTextBlock[], maxTokens: number, u: Usage, signal?: AbortSignal): Promise<Ask> {
156 const r = await $.model.complete({ model: WRITER, prompt, maxTokens, timeoutMs: TIMEOUT }, signal ? { signal } : undefined)
157 u.writer++
158 u.input += r.usage.input_tokens
159 u.output += r.usage.output_tokens
160 u.cacheRead += r.usage.cache_read_input_tokens
161 u.cacheWrite += r.usage.cache_creation_input_tokens
162 if (r.isAnswered) return { text: r.text }
163 return { text: null, reason: String(r.reason) }
164}
165
166/** 从回复里取出 JSON 对象(容忍 ```json 围栏和前后的说明文字) */
167export function extractJson(text: string | null): Record<string, unknown> | null {
168 if (!text) return null
169 const a = text.indexOf('{')
170 const b = text.lastIndexOf('}')
171 if (a < 0 || b <= a) return null
172 try {
173 const v = JSON.parse(text.slice(a, b + 1)) as unknown
174 return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
175 } catch {
176 return null
177 }
178}
179
180function langName(lang: Lang): string {
181 return lang === 'zh' ? 'Simplified Chinese' : 'English'
182}
183
184const clampInt = (x: unknown, lo: number, hi: number, d: number) => {
185 const n = Math.round(Number(x))
186 return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d
187}
188
189const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+/, '').slice(0, 32).replace(/-+$/, '')
190
191/** 模型常漏的、不影响内容的字段:直接补上,省一次修复 */
192export function normalizeGenerated(raw: Record<string, unknown>, o: { lang: Lang; author: string; id?: string }): Record<string, unknown> {
193 const p = { ...raw }
194 p.defaultLang = o.lang
195 p.langs = [o.lang]
196 p.source = 'original'
197 p.author = o.author
198 if (typeof p.version !== 'string') p.version = '0.1.0'
199 if (typeof p.id !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(p.id)) p.id = slug(String(p.id ?? o.id ?? '')) || o.id || 'generated'
200 if (!Array.isArray(p.events)) p.events = []
201 if (!Array.isArray(p.fixed)) p.fixed = []
202 return p
203}
204
205type Extra = (pack: Record<string, unknown>, json: string) => string[]
206
207/** 生成结果的额外检查:受保护名字、未成年角色的恋爱线 */
208export function contentIssues(pack: Record<string, unknown>, json: string, ctx: { banned: string[]; minors: string[] }): string[] {
209 const err: string[] = []
210 const low = json.toLowerCase()
211 for (const n of ctx.banned) if (low.includes(n.toLowerCase())) err.push(`content: the name "${n}" belongs to an existing work; replace it with an original name and remove the reference`)
212 if (pack.source !== 'original') err.push('source: must be "original"')
213 const cast = Array.isArray(pack.cast) ? (pack.cast as Record<string, unknown>[]) : []
214 for (const c of cast) {
215 if (ctx.minors.includes(String(c.id)) && c.romance !== false) err.push(`cast ${String(c.id)}: under 18, so "romance" must be false with a non-romantic meter label, and no romance or flirting anywhere`)
216 }
217 return err
218}
219
220/** 大纲里未成年(或没写年龄)的角色 id */
221export function minorsOf(outline: Record<string, unknown> | null): string[] {
222 const cast = outline && Array.isArray(outline.cast) ? (outline.cast as Record<string, unknown>[]) : []
223 return cast.filter(c => !(Number(c.age) >= 18)).map(c => String(c.id))
224}
225
226function outlinePrompt(seed: string, o: Required<Pick<GenOpts, 'lang' | 'days' | 'castSize'>>, hits: { work: string }[]): string {
227 const homage = hits.length
228 ? `\nNOTE: the idea mentions an existing copyrighted work (${hits.map(h => h.work).join(', ')}). Apply content rule 2: an original homage with all-new names, or a polite refusal.`
229 : ''
230 return `${RULES}
231
232[Task — step 1 of 2: outline]
233Design an original story pack from this idea:
234<idea>
235${seed}
236</idea>${homage}
237Size: ${o.days} days, ${o.castSize} main characters (cast), 0-3 supporting characters (npcs), 2-4 time slots, 4-6 places, 2-3 acts with 1-3 key beats each, endings: one or two per main character plus one fallback ending.
238Write all story text in ${langName(o.lang)}.
239Reply with JSON only:
240{"refuse": "" or a short polite reason in ${langName(o.lang)},
241 "homage": "" or one sentence on the genre/mood kept (never name the work),
242 "id": "lowercase-id", "title": "", "subtitle": "", "premise": "", "style": "",
243 "days": ${o.days}, "slots": ["..."],
244 "places": [{"id": "", "name": "", "mood": "", "slots": [0]}],
245 "cast": [{"id": "", "name": "", "age": 0, "romance": true, "animal": "", "personality": "", "secret": "", "deadlineDay": 1}],
246 "npcs": [{"id": "", "name": "", "role": ""}],
247 "acts": [{"title": "", "from": 1, "goal": "", "beats": [{"day": 1, "text": "", "who": []}]}],
248 "endings": [{"id": "", "title": "", "kind": "good", "who": "", "brief": ""}]}`
249}
250
251function expandPrompt(outline: string, o: { lang: Lang; days: number }, note: string): string {
252 return `${RULES}
253
254[Task — step 2 of 2: the complete pack]
255Expand this outline into the complete pack JSON, following the format above exactly. Keep its names and decisions. ${o.days} days. All story text in ${langName(o.lang)}; "defaultLang": "${o.lang}", "langs": ["${o.lang}"], "source": "original".
256Each main character gets a distinct pixel-animal sprite in the style of the examples. Keep texts vivid but compact (premise and style a short paragraph each, arc lines one sentence each).${note}
257<outline>
258${outline}
259</outline>
260Reply with the JSON object only — no commentary, no code fences.`
261}
262
263function repairPrompt(json: string, errors: string[]): string {
264 return `${RULES}
265
266[Task — fix the pack]
267This pack JSON fails validation. Fix every problem listed, change nothing else, and reply with the complete corrected JSON object only.
268<problems>
269${errors.slice(0, 40).map(e => `- ${e}`).join('\n')}
270</problems>
271<pack>
272${json}
273</pack>`
274}
275
276/** 共用流程:大纲 → 展开 → 校验 → 修。seed 是给模型的想法文本(已经过清理) */
277async function generate($: EngineInterface, seed: string, opts: GenOpts, extra?: Extra, hitsText = seed, scrub: (json: string) => string = j => j): Promise<GenResult> {
278 const u = zeroUsage()
279 const lang = opts.lang
280 const days = clampInt(opts.days, 1, 14, 7)
281 const castSize = clampInt(opts.castSize, 1, 6, 3)
282 const maxRepairs = clampInt(opts.maxRepairs, 0, 2, 2)
283 const author = (opts.author ?? 'ai-galgame generator').slice(0, 60)
284 const fail = (errors: string[], json = '', more: Partial<GenResult> = {}): GenResult => ({ pack: null, json, errors, usage: u, ...more })
285
286 if (explicitRequest(hitsText)) {
287 return fail(['refused'], '', { refused: lang === 'zh' ? '这个想法要求露骨内容,生成器不写这类剧本。换一个想法试试?' : 'This idea asks for explicit content, which the generator does not write. Try another idea?' })
288 }
289 const hits = protectedHits(hitsText)
290 const banned = hits.flatMap(h => h.names)
291 const head: ModelTextBlock = { text: formatSpec(), cache: true }
292
293 opts.onStep?.('outline', 0)
294 const o1 = await ask($, [head, { text: outlinePrompt(seed, { lang, days, castSize }, hits) }], OUTLINE_TOKENS, u, opts.signal)
295 const outline = extractJson(o1.text)
296 if (!outline) return fail([`outline: ${o1.reason ?? 'reply was not JSON'}`])
297 if (typeof outline.refuse === 'string' && outline.refuse.trim()) return fail(['refused'], '', { refused: outline.refuse.trim() })
298 const homage = typeof outline.homage === 'string' && outline.homage.trim() ? outline.homage.trim() : undefined
299 const minors = minorsOf(outline)
300 const outlineText = JSON.stringify(outline, null, 1)
301 const fixedId = typeof outline.id === 'string' ? slug(outline.id) : undefined
302
303 const check = (raw: Record<string, unknown>): { json: string; errors: string[] } => {
304 const json = JSON.stringify(raw, null, 2)
305 const errors = [...validatePack(raw), ...contentIssues(raw, json, { banned, minors }), ...(extra?.(raw, json) ?? [])]
306 if (raw.days !== days) errors.push(`days: must be ${days}`)
307 return { json, errors }
308 }
309
310 let last: { raw: Record<string, unknown>; json: string; errors: string[] } | null = null
311 let note = ''
312 for (let attempt = 0; attempt <= maxRepairs; attempt++) {
313 if (opts.signal?.aborted) break
314 const repairing = last !== null
315 opts.onStep?.(repairing ? 'repair' : 'expand', attempt)
316 const text = repairing ? repairPrompt(scrub(last!.json), last!.errors) : expandPrompt(outlineText, { lang, days }, note)
317 const r = await ask($, [head, { text }], GEN_MAX_TOKENS, u, opts.signal)
318 const parsed = extractJson(r.text)
319 if (!parsed) {
320 // 多半是太长被截断:下一次重新展开、写短一点
321 note = '\nYour previous reply was cut off or was not valid JSON: write shorter texts this time.'
322 continue
323 }
324 const raw = normalizeGenerated(parsed, { lang, author, id: fixedId })
325 const c = check(raw)
326 last = { raw, ...c }
327 if (c.errors.length === 0) break
328 }
329 opts.onStep?.('done', 0)
330 if (!last) return fail(['pack: the model did not return valid JSON'], '', { homage })
331 if (last.errors.length) return fail(last.errors, last.json, { homage })
332 return { pack: last.raw as unknown as Pack, json: last.json, errors: [], usage: u, homage }
333}
334
335/** 一句话生成剧本包:Sonnet 先写大纲再展开,validatePack 不过就交回模型修(默认最多 2 次) */
336export async function generatePack($: EngineInterface, idea: string, opts: GenOpts): Promise<GenResult> {
337 const clean = idea.trim().slice(0, 1200)
338 if (!clean) return { pack: null, json: '', errors: ['idea: empty'], usage: zeroUsage() }
339 return generate($, clean, opts)
340}
341
342// ---------- 从代码仓库生成 ----------
343
344export type RepoLimits = { depth: number; entries: number; dirs: number; readmeChars: number; totalBytes: number; fileBytes: number; manifests: number }
345export const REPO_LIMITS: RepoLimits = { depth: 3, entries: 300, dirs: 60, readmeChars: 600, totalBytes: 64 * 1024, fileBytes: 256 * 1024, manifests: 12 }
346
347/** 不进入的目录:所有以 . 开头的(.git、.env、.ssh……),以及依赖和构建产物 */
348const SKIP_DIR = /^(node_modules|dist|build|out|target|vendor|venv|env|__pycache__|coverage|bin|obj|tmp|temp|logs?)$/i
349/** 不列出、不读取的敏感文件名 */
350export const SENSITIVE = /(^\.|secret|credential|passw|token|private|apikey|api[_-]key|\.pem$|\.key$|\.crt$|\.cer$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_dsa|id_ecdsa|id_ed25519|\.kdbx$|\.sqlite3?$|\.db$|\.dump$|\.bak$|htpasswd|npmrc|pypirc|netrc|\.tfstate|\.log$)/i
351const MANIFEST = /^(package\.json|cargo\.toml|pyproject\.toml|go\.mod|composer\.json|setup\.cfg|pom\.xml|build\.gradle(\.kts)?|deno\.json)$/i
352const README = /^readme(\.(md|txt|rst|markdown))?$/i
353const TROUBLE = /(todo|fixme|bug|hack|legacy|deprecated|workaround|wip|broken|flaky|kludge|hotfix)/i
354
355export type RepoDigest = {
356 /** 项目名(清单的 name 字段,或目录名),已清理 */
357 name: string
358 /** 相对路径的目录树(目录以 / 结尾),已去掉敏感项 */
359 tree: string[]
360 /** README 开头若干字,已清理(链接、邮箱、路径、疑似密钥、含 key/token/password 的行都去掉) */
361 readme: string
362 /** 清单里的名字字段:模块名 */
363 modules: string[]
364 /** 可以当剧情冲突的线索:带 todo/bug/legacy 之类字样的文件名、README 里的 TODO 行 */
365 trouble: string[]
366 /** 实际读过的文件(相对路径),给测试和界面显示 */
367 read: string[]
368 /** 读取的总字节数 */
369 bytes: number
370 /** 被清理掉的原文片段:生成结果里不许出现 */
371 secrets: string[]
372}
373
374const SECRET_LIKE: RegExp[] = [
375 /\b(sk|pk|rk)-[A-Za-z0-9_-]{8,}/g,
376 /\b(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{8,}/g,
377 /\bxox[abpr]-[A-Za-z0-9-]{8,}/g,
378 /\bAKIA[0-9A-Z]{12,}/g,
379 /\bAIza[0-9A-Za-z_-]{20,}/g,
380 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/g,
381 /\b[A-Fa-f0-9]{32,}\b/g,
382 /\b[A-Za-z0-9+/_-]{40,}={0,2}/g,
383]
384
385/** 清理一段文字:去掉链接、邮箱、绝对路径、IP、疑似密钥;含 key/token/password 等字样的行整行去掉 */
386export function redact(text: string, found: string[] = []): string {
387 const keep = (re: RegExp, rep: string) => (s: string) => s.replace(re, m => { found.push(m); return rep })
388 const lines = text.split(/\r?\n/).filter(l => !/(api[_ -]?key|secret|token|passw|credential|private key|BEGIN [A-Z ]*KEY)/i.test(l))
389 let s = lines.join('\n')
390 for (const re of SECRET_LIKE) s = keep(re, '[redacted]')(s)
391 s = keep(/https?:\/\/\S+/gi, '[link]')(s)
392 s = keep(/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g, '[email]')(s)
393 s = keep(/\b[A-Za-z]:[\\/][^\s"'`)\]]+/g, '[path]')(s)
394 s = keep(/(^|\s)(\/(home|Users|root|var|etc|opt|mnt)\/[^\s"'`)\]]+)/g, ' [path]')(s)
395 s = keep(/~\/[^\s"'`)\]]+/g, '[path]')(s)
396 s = keep(/\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b/g, '[ip]')(s)
397 return s
398}
399
400const join = (a: string, b: string) => (b ? `${a.replace(/[\\/]+$/, '')}/${b}` : a)
401const baseName = (p: string) => p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
402
403function manifestName(file: string, text: string): string | null {
404 const f = file.toLowerCase()
405 if (f.endsWith('.json')) {
406 try {
407 const j = JSON.parse(text) as Record<string, unknown>
408 return typeof j.name === 'string' ? j.name : null
409 } catch {
410 return null
411 }
412 }
413 if (f === 'go.mod') return /^module\s+(\S+)/m.exec(text)?.[1]?.split('/').pop() ?? null
414 if (f === 'pom.xml') return /<artifactId>([^<]+)<\/artifactId>/.exec(text)?.[1] ?? null
415 if (f.startsWith('build.gradle')) return null
416 return /^\s*name\s*=\s*["']([^"']+)["']/m.exec(text)?.[1] ?? null
417}
418
419/**
420 * 读一个代码仓库的轮廓:只列目录、读 README 开头和清单的 name 字段,不读源码内容。
421 * 以 . 开头的目录和文件(.git、.env、.ssh……)、依赖与构建目录、敏感文件名一律不进入、不列出、不读取;读取总量有上限。
422 */
423export async function scanRepo($: EngineInterface, root: string, lim: RepoLimits = REPO_LIMITS): Promise<RepoDigest> {
424 const d: RepoDigest = { name: '', tree: [], readme: '', modules: [], trouble: [], read: [], bytes: 0, secrets: [] }
425 const queue: { rel: string; depth: number }[] = [{ rel: '', depth: 0 }]
426 let dirs = 0
427 const readSmall = async (rel: string, size: number): Promise<string | null> => {
428 if (size > lim.fileBytes || d.bytes + Math.min(size, lim.fileBytes) > lim.totalBytes) return null
429 if (rel.split('/').some(seg => SENSITIVE.test(seg))) return null
430 try {
431 const t = await $.fs.read(join(root, rel))
432 const text = typeof t === 'string' ? t : ''
433 d.read.push(rel)
434 d.bytes += text.length
435 return text
436 } catch {
437 return null
438 }
439 }
440 while (queue.length && dirs < lim.dirs && d.tree.length < lim.entries) {
441 const { rel, depth } = queue.shift()!
442 dirs++
443 let list: FsEntry[]
444 try {
445 list = await $.fs.list(join(root, rel))
446 } catch {
447 continue
448 }
449 const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name))
450 for (const e of sorted) {
451 if (d.tree.length >= lim.entries) break
452 if (e.isLink || e.kind === 'other') continue
453 if (e.name.startsWith('.') || SENSITIVE.test(e.name)) continue
454 const path = rel ? `${rel}/${e.name}` : e.name
455 if (e.kind === 'dir') {
456 if (SKIP_DIR.test(e.name)) continue
457 d.tree.push(`${path}/`)
458 if (depth + 1 < lim.depth) queue.push({ rel: path, depth: depth + 1 })
459 continue
460 }
461 d.tree.push(path)
462 if (TROUBLE.test(e.name)) d.trouble.push(path)
463 if (depth === 0 && README.test(e.name) && !d.readme) {
464 const t = await readSmall(path, e.size)
465 if (t) d.readme = redact(t.slice(0, lim.readmeChars * 3), d.secrets).replace(/\n{3,}/g, '\n\n').slice(0, lim.readmeChars)
466 } else if (depth <= 2 && MANIFEST.test(e.name) && d.modules.length < lim.manifests) {
467 const t = await readSmall(path, e.size)
468 const n = t ? manifestName(e.name, t) : null
469 if (n) {
470 const clean = redact(n, d.secrets).slice(0, 60)
471 if (depth === 0 && !d.name) d.name = clean
472 if (!d.modules.includes(clean)) d.modules.push(clean)
473 }
474 }
475 }
476 }
477 if (!d.name) d.name = redact(baseName(root), d.secrets).slice(0, 60) || 'project'
478 for (const l of d.readme.split('\n')) if (/\b(todo|fixme|bug|known issue|wip)\b/i.test(l)) d.trouble.push(`README: ${l.trim().slice(0, 120)}`)
479 d.trouble = d.trouble.slice(0, 20)
480 return d
481}
482
483/** 生成结果里不许出现的字符串:仓库所在的绝对路径(各种写法)、路径里的用户名、清理掉的原文 */
484export function leakGuard(root: string, digest: RepoDigest): string[] {
485 const norm = root.replace(/\\/g, '/').replace(/\/+$/, '')
486 const out = new Set<string>([root, norm, norm.replace(/\//g, '\\')])
487 const segs = norm.split('/')
488 const ui = segs.findIndex(s => /^(users|home)$/i.test(s))
489 if (ui >= 0 && segs[ui + 1] && segs[ui + 1]!.length >= 3) out.add(segs[ui + 1]!)
490 for (const s of digest.secrets) if (s.length >= 6) out.add(s)
491 return [...out].filter(s => s.length >= 3)
492}
493
494export function repoSeed(d: RepoDigest, idea?: string): string {
495 return `Turn this software project into an original visual-novel story pack. The modules and folders become personified characters (pixel animals with personalities that fit what each part does); bugs, TODOs and legacy code become the story's conflicts and secrets; the release, a refactor or a deadline is the ticking clock. "I" am a newcomer to the project (a new maintainer or contributor). Keep it playful and warm; no technical jargon dumps.
496Do NOT include any file paths, URLs, email addresses, keys, tokens, version numbers or personal names from the project. Character names are invented, at most loosely inspired by module names.
497${idea?.trim() ? `The player's extra wish: ${idea.trim().slice(0, 400)}\n` : ''}<project>
498Name: ${d.name}
499Modules: ${d.modules.join(', ') || '(none listed)'}
500Layout (top levels):
501${d.tree.slice(0, 120).join('\n')}
502Signs of trouble: ${d.trouble.join('; ') || '(none found)'}
503README (start):
504${d.readme || '(none)'}
505</project>`
506}
507
508/** 从代码仓库生成剧本包。只读目录结构、文件名、README 开头和清单的名字字段;结果里不许出现路径、链接、密钥或个人信息 */
509export async function packFromRepo($: EngineInterface, root: string, idea?: string, opts: GenOpts = { lang: 'zh' }): Promise<GenResult & { digest: RepoDigest }> {
510 const digest = await scanRepo($, root)
511 const guard = leakGuard(root, digest)
512 const extra: Extra = (_raw, json) => {
513 const err: string[] = []
514 const low = json.toLowerCase()
515 for (const g of guard) if (low.includes(g.toLowerCase())) err.push('privacy: remove the private path, user name or secret-like string that leaked from the project')
516 if (/https?:\/\//i.test(json)) err.push('privacy: remove every URL')
517 if (/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/.test(json)) err.push('privacy: remove every email address')
518 return [...new Set(err)]
519 }
520 // 交回模型修、交给玩家看之前都先抹掉可能泄露的片段
521 const escRe = (x: string) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
522 const scrub = (json: string) => guard.reduce((j, g) => j.replace(new RegExp(escRe(g), 'gi'), '…'), json)
523 const r = await generate($, repoSeed(digest, idea), opts, extra, idea ?? '', scrub)
524 return { ...r, json: scrub(r.json), digest }
525}
526hooks/share.ts 347 lines1// 导出这一局:Markdown 或自带样式的单页 HTML(不引用任何外部资源),以及一段可直接贴到社交平台的短文本。
2// 纯函数:只读 Story 与 Pack,不调用模型,不读写 $.state。
3
4import type { Keepsake, Lang, Pack, SpriteDef, Story } from '../types'
5import { chapterOf, dayText, meterOf, packText, personOf } from './pack'
6
7export type ExportFormat = 'md' | 'html'
8export type Quote = { day: number; who: string; text: string }
9/** 引擎没有保存历史台词;另一条线若记录了关键台词,可以从这里传入,否则取最后一幕的台词 */
10export type ExportExtra = { quotes?: Quote[] }
11
12type Px = string | null
13type Grid = Px[][]
14
15export type RunDigest = {
16 title: string
17 subtitle: string
18 author: string
19 notice: string
20 lang: Lang
21 finished: boolean
22 dayReached: number
23 totalDays: number
24 days: { day: number; label: string; chapter: string; moments: { slot: string; who: string; text: string }[]; choices: string[] }[]
25 quotes: { who: string; color: string; text: string }[]
26 promises: { who: string; text: string; status: 'open' | 'kept' | 'broken' }[]
27 keepsakes: { who: string; title: string; desc: string; day: string; grid: Grid }[]
28 cast: { name: string; color: string; stage: string; meter: string; grid: Grid | null }[]
29 ending: { title: string; kind: string; text: string } | null
30}
31
32const INK = '#1e1a1a'
33
34/** 立绘转成像素格(与终端里一样把眼睛画上,表情取 normal) */
35export function spriteGrid(sp: SpriteDef): Grid {
36 const g: Grid = sp.art.map(row => [...row].map(ch => (ch === '.' ? null : sp.pal[ch] ?? null)))
37 for (const [r, c] of sp.eyes.slice(0, 2)) {
38 for (const rr of [r - 1, r]) if (g[rr] && g[rr]![c] !== undefined && g[rr]![c] !== null) g[rr]![c] = INK
39 }
40 return g
41}
42
43export function keepsakeGrid(k: Keepsake): Grid {
44 const hex = (n: number) => `#${n.toString(16).padStart(6, '0')}`
45 return k.art.map(row => [...row].map(ch => (ch === '.' || !(ch in k.palette) ? null : hex(k.palette[ch]!))))
46}
47
48function nameOf(p: Pack, lang: Lang, id: string): string {
49 const T = packText(p, lang)
50 if (id === 'me') return T(p.me)
51 return T(personOf(p, id)?.name) || id
52}
53
54/** 把一局整理成导出用的数据(Markdown 与 HTML 共用) */
55export function runDigest(s: Story, p: Pack, lang: Lang, extra: ExportExtra = {}): RunDigest {
56 const T = packText(p, lang)
57 const slotName = (i: number) => T(p.slots[i])
58 const finished = s.phase === 'ending' || !!s.ending
59 const lastDay = Math.max(1, Math.min(p.days, s.day))
60 const days: RunDigest['days'] = []
61 for (let d = 1; d <= lastDay; d++) {
62 const moments = s.album.filter(m => m.day === d).map(m => ({ slot: slotName(m.slot), who: m.heroine ? nameOf(p, lang, m.heroine) : '', text: m.text }))
63 const choices = s.notes.filter(n => n.day === d).map(n => n.text)
64 if (moments.length || choices.length) days.push({ day: d, label: dayText(p, lang, d), chapter: chapterOf(p, lang, d), moments, choices })
65 }
66 const rawQuotes: Quote[] = extra.quotes?.length ? extra.quotes : (s.scene?.lines ?? []).map(l => ({ day: s.day, who: l.who, text: l.text }))
67 const quotes = rawQuotes.filter(q => q.text.trim()).slice(0, 8).map(q => ({ who: nameOf(p, lang, q.who), color: personOf(p, q.who)?.color ?? '', text: q.text }))
68 const endingDef = p.endings.find(e => e.id === s.endingId)
69 const ending = finished
70 ? { title: T(endingDef?.title) || (lang === 'zh' ? '结局' : 'Ending'), kind: endingDef?.kind ?? '', text: s.ending }
71 : null
72 const cast = p.cast.map(c => {
73 const h = s.heroines[c.id]
74 const m = meterOf(c)
75 const filled = Math.max(0, Math.min(4, h?.stage ?? 0))
76 return { name: T(c.name), color: c.color, stage: T(p.stages[h?.stage ?? 0]), meter: m.full.repeat(filled) + m.empty.repeat(4 - filled), grid: c.sprite ? spriteGrid(c.sprite) : null }
77 })
78 const src = p.source === 'original' ? '' : T(p.source.notice) || `${T(p.source.work)} · ${T(p.source.author)} · ${T(p.source.license)}`
79 return {
80 title: T(p.title), subtitle: T(p.subtitle), author: p.author, notice: src, lang, finished,
81 dayReached: lastDay, totalDays: p.days, days, quotes,
82 promises: s.promises.map(x => ({ who: nameOf(p, lang, x.heroine), text: x.text, status: x.status })),
83 keepsakes: s.keepsakes.map(k => ({ who: nameOf(p, lang, k.heroine), title: k.title, desc: k.desc, day: dayText(p, lang, k.day), grid: keepsakeGrid(k) })),
84 cast, ending,
85 }
86}
87
88const W: Record<Lang, Record<string, string>> = {
89 zh: {
90 by: '作者', days: '每天', quotes: '关键台词', choices: '我的选择', promises: '约定', keepsakes: '信物', cast: '人物', ending: '结局',
91 open: '未到期', kept: '已兑现', broken: '失约', unfinished: '这一局还没结束:进行到', of: '/', made: '用 ai-galgame 生成',
92 true: '真结局', good: '好结局', normal: '普通结局', bad: '坏结局', none: '(无)',
93 },
94 en: {
95 by: 'by', days: 'Day by day', quotes: 'Key lines', choices: 'My choices', promises: 'Promises', keepsakes: 'Keepsakes', cast: 'Characters', ending: 'Ending',
96 open: 'open', kept: 'kept', broken: 'broken', unfinished: 'Still in progress: reached', of: 'of', made: 'Made with ai-galgame',
97 true: 'True ending', good: 'Good ending', normal: 'Normal ending', bad: 'Bad ending', none: '(none)',
98 },
99}
100
101// ---------- Markdown ----------
102
103const SHADES = ['██', '▓▓', '▒▒', '░░']
104
105function lum(hex: string): number {
106 const n = parseInt(hex.slice(1), 16)
107 return ((n >> 16) & 255) * 0.3 + ((n >> 8) & 255) * 0.59 + (n & 255) * 0.11
108}
109
110/** 像素格转成字符块:越深越实 */
111export function gridText(g: Grid): string {
112 return g.map(row => row.map(px => (px === null ? ' ' : SHADES[Math.min(3, Math.floor(lum(px) / 64))]!)).join('').replace(/\s+$/, '')).join('\n')
113}
114
115const mdEsc = (s: string) => s.replace(/([\\`*_[\]<>#|])/g, '\\$1').replace(/\r?\n/g, ' ')
116
117function toMarkdown(d: RunDigest): string {
118 const w = W[d.lang]
119 const co = d.lang === 'zh' ? ':' : ': '
120 const out: string[] = [`# ${mdEsc(d.title)}`, '', `*${mdEsc(d.subtitle)}* · ${w.by} ${mdEsc(d.author)}`]
121 if (d.notice) out.push('', `> ${mdEsc(d.notice)}`)
122 if (!d.finished) out.push('', `> ${w.unfinished} ${d.dayReached} ${w.of} ${d.totalDays}`)
123 out.push('', `## ${w.days}`)
124 let chapter = ''
125 for (const day of d.days) {
126 if (day.chapter && day.chapter !== chapter) { chapter = day.chapter; out.push('', `### ${mdEsc(chapter)}`) }
127 out.push('', `**${mdEsc(day.label)}**`, '')
128 for (const m of day.moments) out.push(`- ${mdEsc(m.slot)}${m.who ? ` · ${mdEsc(m.who)}` : ''}${co}${mdEsc(m.text)}`)
129 if (day.choices.length) out.push(`- ${w.choices}${co}${day.choices.map(mdEsc).join(d.lang === 'zh' ? ';' : '; ')}`)
130 }
131 if (d.quotes.length) {
132 out.push('', `## ${w.quotes}`, '')
133 for (const q of d.quotes) out.push(`> **${mdEsc(q.who)}**${co}${mdEsc(q.text)}`, '>')
134 out.pop()
135 }
136 if (d.promises.length) {
137 out.push('', `## ${w.promises}`, '')
138 for (const x of d.promises) out.push(`- ${x.status === 'kept' ? '✔' : x.status === 'broken' ? '✘' : '…'} ${mdEsc(x.who)}${co}${mdEsc(x.text)}${d.lang === 'zh' ? `(${w[x.status]})` : ` (${w[x.status]})`}`)
139 }
140 if (d.keepsakes.length) {
141 out.push('', `## ${w.keepsakes}`)
142 for (const k of d.keepsakes) out.push('', `**${mdEsc(k.title)}** · ${mdEsc(k.who)} · ${mdEsc(k.day)}`, '', mdEsc(k.desc), '', '```', gridText(k.grid), '```')
143 }
144 out.push('', `## ${w.cast}`)
145 for (const c of d.cast) {
146 out.push('', `**${mdEsc(c.name)}** · ${mdEsc(c.stage)} ${c.meter}`)
147 if (c.grid) out.push('', '```', gridText(c.grid), '```')
148 }
149 if (d.ending) {
150 out.push('', `## ${w.ending}${co}${mdEsc(d.ending.title)}${d.ending.kind ? (d.lang === 'zh' ? `(${w[d.ending.kind] ?? d.ending.kind})` : ` (${w[d.ending.kind] ?? d.ending.kind})`) : ''}`, '')
151 for (const para of d.ending.text.split(/\n+/).filter(Boolean)) out.push(mdEsc(para), '')
152 }
153 out.push('', `---`, `*${w.made}*`, '')
154 return out.join('\n').replace(/\n{3,}/g, '\n\n')
155}
156
157// ---------- HTML ----------
158
159export function esc(s: string): string {
160 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
161}
162
163const safeColor = (c: string) => (/^#[0-9a-fA-F]{6}$/.test(c) ? c : 'currentColor')
164
165/** 像素格转内联 SVG:同色连续像素合并成一条矩形 */
166export function gridSvg(g: Grid, scale = 6, label = ''): string {
167 const h = g.length
168 const w = Math.max(1, ...g.map(r => r.length))
169 const rects: string[] = []
170 g.forEach((row, y) => {
171 let x = 0
172 while (x < row.length) {
173 const c = row[x]
174 if (c === null || c === undefined) { x++; continue }
175 let e = x + 1
176 while (e < row.length && row[e] === c) e++
177 rects.push(`<rect x="${x}" y="${y}" width="${e - x}" height="1" fill="${safeColor(c)}"/>`)
178 x = e
179 }
180 })
181 const aria = label ? ` role="img" aria-label="${esc(label)}"` : ' aria-hidden="true"'
182 return `<svg class="px" viewBox="0 0 ${w} ${h}" width="${w * scale}" height="${h * scale}" shape-rendering="crispEdges"${aria}>${rects.join('')}</svg>`
183}
184
185const CSS = `
186:root{color-scheme:light dark;--bg:#faf7f2;--card:#ffffff;--ink:#1f1d1a;--muted:#6b645b;--line:#e6dfd4;--accent:#c2416b;--kept:#2f855a;--broken:#c53030}
187@media (prefers-color-scheme:dark){:root{--bg:#16151a;--card:#1f1e25;--ink:#ece8e1;--muted:#a59f96;--line:#33313a;--accent:#f0739a;--kept:#68d391;--broken:#fc8181}}
188*{box-sizing:border-box}
189body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.7 -apple-system,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",sans-serif}
190main{max-width:760px;margin:0 auto;padding:40px 16px 64px}
191header{text-align:center;margin-bottom:32px}
192h1{font-size:2rem;line-height:1.25;margin:0 0 6px}
193h2{font-size:1.15rem;margin:40px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--line)}
194h3{font-size:.95rem;color:var(--accent);margin:24px 0 8px;letter-spacing:.04em}
195.sub,.muted{color:var(--muted)}
196.notice{font-size:.85rem;color:var(--muted);border-left:3px solid var(--line);padding-left:10px}
197.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:10px 0}
198.day b{display:block;margin-bottom:4px}
199.day ul{margin:0;padding-left:1.2em}
200.choice{color:var(--muted);font-size:.92rem}
201blockquote{margin:10px 0;padding:10px 14px;border-left:4px solid var(--accent);background:var(--card);border-radius:0 8px 8px 0}
202blockquote b{margin-right:.4em}
203.grid{display:flex;flex-wrap:wrap;gap:12px}
204.item{flex:1 1 200px;display:flex;gap:12px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px}
205.px{flex:none;image-rendering:pixelated}
206.kept{color:var(--kept)}.broken{color:var(--broken)}.open{color:var(--muted)}
207.ending{background:var(--card);border:1px solid var(--line);border-top:4px solid var(--accent);border-radius:10px;padding:18px 20px}
208.ending p{margin:0 0 .9em}
209footer{margin-top:48px;text-align:center;font-size:.85rem;color:var(--muted)}
210`
211
212function toHtml(d: RunDigest): string {
213 const w = W[d.lang]
214 const e = esc
215 const parts: string[] = []
216 parts.push(`<header><h1>${e(d.title)}</h1><div class="sub">${e(d.subtitle)} · ${e(w.by!)} ${e(d.author)}</div>${d.notice ? `<p class="notice">${e(d.notice)}</p>` : ''}${d.finished ? '' : `<p class="muted">${e(w.unfinished!)} ${d.dayReached} ${e(w.of!)} ${d.totalDays}</p>`}</header>`)
217 if (d.days.length) {
218 parts.push(`<h2>${e(w.days!)}</h2>`)
219 let chapter = ''
220 for (const day of d.days) {
221 if (day.chapter && day.chapter !== chapter) { chapter = day.chapter; parts.push(`<h3>${e(chapter)}</h3>`) }
222 const li = day.moments.map(m => `<li><span class="muted">${e(m.slot)}${m.who ? ` · ${e(m.who)}` : ''}</span> ${e(m.text)}</li>`).join('')
223 const ch = day.choices.length ? `<div class="choice">${e(w.choices!)}:${day.choices.map(e).join(';')}</div>` : ''
224 parts.push(`<section class="card day"><b>${e(day.label)}</b><ul>${li}</ul>${ch}</section>`)
225 }
226 }
227 if (d.quotes.length) {
228 parts.push(`<h2>${e(w.quotes!)}</h2>`)
229 for (const q of d.quotes) parts.push(`<blockquote><b${q.color ? ` style="color:${safeColor(q.color)}"` : ''}>${e(q.who)}</b>${e(q.text)}</blockquote>`)
230 }
231 if (d.promises.length) {
232 parts.push(`<h2>${e(w.promises!)}</h2><ul>`)
233 for (const x of d.promises) parts.push(`<li><span class="${x.status}">${x.status === 'kept' ? '✔' : x.status === 'broken' ? '✘' : '…'} ${e(w[x.status]!)}</span> · ${e(x.who)}:${e(x.text)}</li>`)
234 parts.push('</ul>')
235 }
236 if (d.keepsakes.length) {
237 parts.push(`<h2>${e(w.keepsakes!)}</h2><div class="grid">`)
238 for (const k of d.keepsakes) parts.push(`<div class="item">${gridSvg(k.grid, 7, k.title)}<div><b>${e(k.title)}</b><div class="muted">${e(k.who)} · ${e(k.day)}</div><div>${e(k.desc)}</div></div></div>`)
239 parts.push('</div>')
240 }
241 parts.push(`<h2>${e(w.cast!)}</h2><div class="grid">`)
242 for (const c of d.cast) parts.push(`<div class="item">${c.grid ? gridSvg(c.grid, 4, c.name) : ''}<div><b style="color:${safeColor(c.color)}">${e(c.name)}</b><div class="muted">${e(c.stage)} ${e(c.meter)}</div></div></div>`)
243 parts.push('</div>')
244 if (d.ending) {
245 const kind = d.ending.kind ? ` <span class="muted">${e(w[d.ending.kind] ?? d.ending.kind)}</span>` : ''
246 parts.push(`<h2>${e(w.ending!)}</h2><section class="ending"><h3>${e(d.ending.title)}${kind}</h3>${d.ending.text.split(/\n+/).filter(Boolean).map(p => `<p>${e(p)}</p>`).join('')}</section>`)
247 }
248 parts.push(`<footer>${e(w.made!)}</footer>`)
249 return `<!doctype html>
250<html lang="${d.lang === 'zh' ? 'zh-CN' : 'en'}">
251<head>
252<meta charset="utf-8">
253<meta name="viewport" content="width=device-width,initial-scale=1">
254<meta name="color-scheme" content="light dark">
255<title>${e(d.title)}</title>
256<style>${CSS}</style>
257</head>
258<body><main>
259${parts.join('\n')}
260</main></body>
261</html>
262`
263}
264
265/** 导出这一局:标题、每天的剧情摘要、关键台词、做过的选择、约定、信物(像素图)、人物、结局 */
266export function exportRun(story: Story, pack: Pack, lang: Lang, format: ExportFormat, extra: ExportExtra = {}): string {
267 const d = runDigest(story, pack, lang, extra)
268 return format === 'html' ? toHtml(d) : toMarkdown(d)
269}
270
271/** 建议的文件名(不含目录):<包 id>-<结局 id 或 day-n>.<md|html> */
272export function exportFileName(story: Story, pack: Pack, format: ExportFormat): string {
273 const tail = story.endingId || `day-${story.day}`
274 return `${pack.id}-${tail}`.replace(/[^a-z0-9_-]+/gi, '-') + `.${format}`
275}
276
277/** 一段可以直接贴到社交平台的短文本(不超过 limit 个字符) */
278export function shareSummary(story: Story, pack: Pack, lang: Lang, limit = 220): string {
279 const T = packText(pack, lang)
280 const title = T(pack.title)
281 const ending = pack.endings.find(e => e.id === story.endingId)
282 const top = pack.cast
283 .map(c => ({ c, h: story.heroines[c.id] }))
284 .filter(x => x.h)
285 .sort((a, b) => b.h!.stage - a.h!.stage || b.h!.affection - a.h!.affection)[0]
286 const kept = story.promises.filter(x => x.status === 'kept').length
287 const gifts = story.keepsakes.length
288 const finished = story.phase === 'ending' || !!story.ending
289 const kindWord = ending?.kind ? W[lang][ending.kind] : ''
290 const tag = lang === 'zh' ? '#aigalgame #AI视觉小说' : '#aigalgame #visualnovel'
291 let text: string
292 if (lang === 'zh') {
293 const head = finished
294 ? `我在《${title}》里过完了 ${pack.days} 天,走到「${T(ending?.title) || '结局'}」${kindWord ? `(${kindWord})` : ''}。`
295 : `我在《${title}》里玩到了第 ${story.day} 天(共 ${pack.days} 天)。`
296 const who = top && top.h!.affection > 0 ? `最在意的人:${T(top.c.name)}(${T(pack.stages[top.h!.stage])})。` : ''
297 text = `${head}${who}兑现 ${kept} 个约定,收下 ${gifts} 件信物。${tag}`
298 } else {
299 const head = finished
300 ? `I lived through all ${pack.days} days of "${title}" and reached "${T(ending?.title) || 'an ending'}"${kindWord ? ` (${kindWord})` : ''}.`
301 : `I'm on day ${story.day} of ${pack.days} in "${title}".`
302 const who = top && top.h!.affection > 0 ? ` Closest to: ${T(top.c.name)} (${T(pack.stages[top.h!.stage])}).` : ''
303 text = `${head}${who} Promises kept: ${kept}. Keepsakes: ${gifts}. ${tag}`
304 }
305 if ([...text].length <= limit) return text
306 // 超长时截断(标题很长的包)
307 return [...text].slice(0, limit - 1).join('') + '…'
308}
309
310
311// ---------- 结局卡:不剧透的分享方格(像 Wordle 那样) ----------
312
313const CARD_KIND: Record<Lang, Record<string, string>> = {
314 zh: { true: '★ 真结局', good: '☆ 好结局', normal: '· 普通结局', bad: '✗ 坏结局', none: '· 结局' },
315 en: { true: '★ True ending', good: '☆ Good ending', normal: '· Normal ending', bad: '✗ Bad ending', none: '· Ending' },
316}
317export const CARD_URL = 'github.com/Ephemeral6/ai-galgame'
318
319/**
320 * 结局卡:只写结局等级,不写结局名和剧情,方便贴出去比较又不剧透。
321 * 包名 · 每日挑战日期 / 结局等级 / 幕数 · 约定兑现方格 · 信物数 · 回退次数 / 她的关系值图标 / 仓库地址。
322 * 没到结局时第二行写「进行到第 n 天」。
323 */
324export function endingCard(story: Story, pack: Pack, lang: Lang): string {
325 const T = packText(pack, lang)
326 const ending = pack.endings.find(e => e.id === story.endingId)
327 const finished = story.phase === 'ending' || !!story.endingId
328 const kind = ending?.kind ?? (ending ? (ending.tier >= 4 ? 'true' : ending.tier >= 3 ? 'good' : ending.tier >= 1 ? 'normal' : 'normal') : 'none')
329 const daily = story.daily ? (lang === 'zh' ? ` · 每日挑战 ${story.daily.slice(5)}` : ` · daily ${story.daily.slice(5)}`) : ''
330 const head = `ai-galgame · ${T(pack.title)}${daily}`
331 const line2 = finished ? CARD_KIND[lang][kind]! : lang === 'zh' ? `进行到第 ${story.day} 天 / ${pack.days}` : `Day ${story.day} of ${pack.days}, in progress`
332 const marks = story.promises.slice(0, 10).map(x => (x.status === 'kept' ? '✓' : x.status === 'broken' ? '✗' : '○')).join('') || '—'
333 const scenes = story.album.length
334 const line3 = lang === 'zh'
335 ? `${scenes} 幕 · 约定 ${marks} · 信物 ${story.keepsakes.length} · 回退 ${story.rewinds} 次`
336 : `${scenes} scenes · promises ${marks} · keepsakes ${story.keepsakes.length} · undos ${story.rewinds}`
337 // 关系值:结局绑定的人,没有就取最亲近的人;图标用她的 meter(非恋爱角色不用心形)
338 const who = ending?.who ?? pack.cast.map(c => c.id).sort((a, b) => (story.heroines[b]?.affection ?? 0) - (story.heroines[a]?.affection ?? 0))[0]
339 const c = pack.cast.find(x => x.id === who)
340 const h = who ? story.heroines[who] : undefined
341 const mt = meterOf(c)
342 const max = pack.gates[4]?.affection ?? 12
343 const full = Math.max(0, Math.min(5, Math.round(((h?.affection ?? 0) / Math.max(1, max)) * 5)))
344 const line4 = c ? mt.full.repeat(full) + mt.empty.repeat(5 - full) : ''
345 return [head, line2, line3, line4, CARD_URL].filter(Boolean).join('\n')
346}
347hooks/art.ts 207 lines1// 像素美术:方块小动物立绘(来自剧本包的字母图)、双人同框、AI 画的信物。
2// 像素压成终端格:两行像素一格,上半「▀」前景=上像素、背景=下像素;透明处用终端默认色,不画背景。
3
4import type { Actor, Keepsake, Pack, SpriteDef } from '../types'
5import { personOf } from './pack'
6
7const DEFAULT = 0x01000000
8export const INK = 0x1e1a1a
9const CHEEK = 0xff7f95
10const TEAR = 0x8fd3ff
11const WHITE = 0xffffff
12const EYE_LIGHT = 0xf2f2f2
13
14export type Px = number | null
15const S = 2 // 立绘放大倍数
16const W = 14 // 字母图宽
17const SW = W * S
18const HEAD = 6 // 头顶留给跳跃
19const MAX_ROWS = 12
20export const STAGE_W = 76
21const SH = MAX_ROWS * S + HEAD
22export const STAGE_ROWS = SH / 2
23export const PORTRAIT_ROWS = (MAX_ROWS + 2) / 2
24
25export const EXPRS = ['normal', 'smile', 'blush', 'sad', 'angry', 'surprised'] as const
26
27const HOP: Record<string, number[]> = {
28 smile: [0, 1, 2, 3, 2, 1, 0, 0, 0, 0],
29 surprised: [0, 2, 4, 5, 4, 2, 0, 0, 0, 0, 0, 0],
30}
31
32export function hex(s: string): number {
33 return parseInt(s.replace('#', ''), 16)
34}
35
36/** 一帧立绘。t 是帧序号(≤5 帧/秒推进) */
37export function sprite(sp: SpriteDef, expr: string, t: number): Px[][] {
38 const pal: Record<string, number> = {}
39 for (const [k, v] of Object.entries(sp.pal)) pal[k] = hex(v)
40 const g: (string | number)[][] = sp.art.map(r => r.split(''))
41 const orig = sp.art.map(r => r.split(''))
42 const put = (r: number, c: number, v: string | number) => { if (g[r] && c >= 0 && c < W && g[r]![c] !== undefined) g[r]![c] = v }
43 const eyes = (sp.eyes.length >= 2 ? sp.eyes : [[3, 4], [3, 9]]) as [number, number][]
44 // 深色身体上眼睛画成浅色,否则看不见
45 const under = pal[orig[eyes[0]![0] - 1]?.[eyes[0]![1]] ?? ''] ?? 0xffffff
46 const lum = ((under >> 16) & 255) * 0.3 + ((under >> 8) & 255) * 0.59 + (under & 255) * 0.11
47 const ink = lum < 70 ? EYE_LIGHT : INK
48 const eye = (f: (r: number, c: number, side: -1 | 1) => [number, number][]) => {
49 eyes.slice(0, 2).forEach(([r, c], i) => { for (const [y, x] of f(r, c, i === 0 ? -1 : 1)) put(y, x, ink) })
50 }
51 // 背过身:不画眼睛,左右翻转(害羞、赌气时)
52 if (expr === 'back') return g.map(row => row.map(v => (typeof v === 'number' ? v : v === '.' ? null : pal[v] ?? null)).reverse())
53 switch (expr) {
54 case 'smile': eye((r, c) => [[r, c - 1], [r - 1, c], [r, c + 1]]); break
55 case 'blush': eye((r, c) => [[r, c]]); for (const [r, c] of sp.cheeks) put(r, c, CHEEK); break
56 case 'sad': eye((r, c) => [[r, c]]); break
57 case 'angry': eye((r, c, side) => [[r, c], [r - 1, c - side]]); break
58 case 'surprised': eye((r, c) => [[r, c], [r - 1, c]]); for (const [r, c] of eyes) put(r + 1, c, WHITE); break
59 default: eye((r, c) => [[r, c], [r - 1, c]])
60 }
61 // 眨眼:每 22 帧闭一下(笑眼本来就弯着,不眨)
62 if (t % 22 === 0 && expr !== 'smile') for (const [r, c] of eyes) for (const dr of [-1, 0]) if (g[r + dr]?.[c] === ink) g[r + dr]![c] = orig[r + dr]?.[c] ?? '.'
63 // 各自的小动作:在轮廓边缘挪一两个像素
64 const body = Object.keys(sp.pal).includes('b') ? 'b' : Object.keys(sp.pal)[0] ?? '.'
65 if (sp.motion === 'tail' && Math.floor(t / 3) % 2 === 1) {
66 const r = Math.max(1, sp.art.length - 5)
67 const v = g[r]?.[W - 1]
68 if (v !== undefined && v !== '.') { g[r]![W - 1] = '.'; put(r - 1, W - 1, v) }
69 }
70 if (sp.motion === 'wings' && t % 6 < 2) {
71 const mid = Math.floor(sp.art.length / 2)
72 for (const r of [mid, mid + 1]) { put(r, 0, body); put(r, W - 1, body) }
73 }
74 if (sp.motion === 'ears' && t % 14 < 2) {
75 const row = g[0]
76 if (row) { row.push(row.shift()!); row[W - 1] = '.' }
77 }
78 const out: Px[][] = g.map(row => row.map(v => (typeof v === 'number' ? v : v === '.' ? null : pal[v] ?? null)))
79 if (expr === 'sad' && t % 4 < 2) { const [r, c] = eyes[0]!; if (out[r + 1]) out[r + 1]![c] = TEAR }
80 return out
81}
82
83export function blank(w: number, h: number): Px[][] {
84 return Array.from({ length: h }, () => Array<Px>(w).fill(null))
85}
86
87export function stamp(dst: Px[][], src: Px[][], ox: number, oy: number, scale: number): void {
88 src.forEach((row, r) => row.forEach((v, c) => {
89 if (v === null) return
90 for (let a = 0; a < scale; a++) for (let b = 0; b < scale; b++) {
91 const y = oy + r * scale + a
92 const x = ox + c * scale + b
93 if (dst[y] && x >= 0 && x < dst[y]!.length) dst[y]![x] = v
94 }
95 }))
96}
97
98/** 舞台演出:每位角色的位移(正数向中间靠)、是否背过身、是否在场、头顶的情绪气泡 */
99export type StageDir = Record<string, { offset: number; turned: boolean; hidden: boolean; emote: string }>
100
101/** 情绪气泡:5×5 小图标 */
102const EMOTES: Record<string, { color: number; art: string[] }> = {
103 '!': { color: 0xffd54f, art: ['..x..', '..x..', '..x..', '.....', '..x..'] },
104 '?': { color: 0x90caf9, art: ['.xxx.', '...x.', '..x..', '.....', '..x..'] },
105 note: { color: 0xf48fb1, art: ['..xx.', '..x.x', '..x..', 'xxx..', 'xx...'] },
106 '...': { color: 0xb0bec5, art: ['.....', '.....', '.....', '.....', 'x.x.x'] },
107 anger: { color: 0xef5350, art: ['x...x', '.x.x.', '.....', '.x.x.', 'x...x'] },
108 sweat: { color: 0x81d4fa, art: ['..x..', '.xx..', 'xxx..', 'xxx..', '.x...'] },
109 heart: { color: 0xff6f91, art: ['.....', 'xx.xx', 'xxxxx', '.xxx.', '..x..'] },
110}
111export const EMOTE_KEYS = Object.keys(EMOTES)
112
113export function emoteFrame(e: string): Px[][] {
114 const em = EMOTES[e]
115 if (!em) return []
116 return em.art.map(r => r.split('').map(ch => (ch === 'x' ? em.color : null)))
117}
118
119/** 舞台:一到两只小动物(主角色或有立绘的配角);near 时靠在一起,far 时分站两边;dir 是演出指令算出的走位、转身与气泡 */
120export function stageFrame(pack: Pack, cast: Actor[], distance: 'near' | 'far', t: number, dir: StageDir = {}, width = STAGE_W): Px[][] {
121 const STAGE_W = width
122 const px = blank(STAGE_W, SH)
123 const shown = cast.filter(a => !!personOf(pack, a.id)?.sprite).slice(0, 2)
124 const n = shown.length
125 const xs = n === 1 ? [(STAGE_W - SW) / 2] : distance === 'near' ? [STAGE_W / 2 - SW - 1, STAGE_W / 2 + 1] : [2, STAGE_W - SW - 2]
126 shown.forEach((act, i) => {
127 const d = dir[act.id]
128 if (d?.hidden) return
129 const spr = sprite(personOf(pack, act.id)!.sprite!, d?.turned ? 'back' : act.expression, t + i * 7)
130 let dy = 0
131 let dx = 0
132 const hop = HOP[act.expression]
133 if (hop) dy = -hop[(t + i * 3) % hop.length]!
134 else if (act.expression === 'sad') dy = 1
135 else dy = Math.floor((t + i * 2) / 4) % 2 === 0 ? 0 : -1 // 待机起伏
136 if (act.expression === 'angry' && t % 10 < 4) dx = t % 2 === 0 ? 1 : -1
137 // 向舞台中间靠:左边的人往右,右边(或独自一人时)的人往左
138 const toward = n === 1 ? 0 : i === 0 ? 1 : -1
139 const x = xs[i]! + dx + (d ? (n === 1 ? -d.offset : d.offset * toward) : 0)
140 const top = SH - spr.length * S - 1 + dy
141 stamp(px, spr, x, top, S)
142 if (d?.emote) stamp(px, emoteFrame(d.emote), x + SW - 6, Math.max(0, top - 6), 1)
143 })
144 return px
145}
146
147/** 小号头像(标题页、日程) */
148export function portraitFrame(sp: SpriteDef, expr: string, t: number): Px[][] {
149 const px = blank(W, MAX_ROWS + 2)
150 const spr = sprite(sp, expr, t)
151 stamp(px, spr, 0, MAX_ROWS + 2 - spr.length - 1, 1)
152 return px
153}
154
155/** 信物:AI 用 8×8 调色字母画的小图标,放大 3 倍 */
156export function keepsakeFrame(k: Keepsake): Px[][] {
157 const px = blank(24, 24)
158 const src: Px[][] = k.art.slice(0, 8).map(row => row.padEnd(8, '.').slice(0, 8).split('').map(ch => (ch === '.' ? null : k.palette[ch] ?? null)))
159 stamp(px, src, 0, 0, 3)
160 return px
161}
162
163export function b64(bytes: Uint8Array): string {
164 const T = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
165 let out = ''
166 for (let i = 0; i < bytes.length; i += 3) {
167 const a = bytes[i]!, b = bytes[i + 1] ?? 0, c = bytes[i + 2] ?? 0
168 const n = (a << 16) | (b << 8) | c
169 out += T[(n >> 18) & 63]! + T[(n >> 12) & 63]! + (i + 1 < bytes.length ? T[(n >> 6) & 63]! : '=') + (i + 2 < bytes.length ? T[n & 63]! : '=')
170 }
171 return out
172}
173
174/** 像素 → Raster 的 cells(base64 的 [码点, 前景, 背景] 三元组) */
175export function cells(px: Px[][]): string {
176 const w = px[0]!.length
177 const words = new Uint32Array(w * Math.ceil(px.length / 2) * 3)
178 let k = 0
179 for (let y = 0; y < px.length; y += 2) {
180 for (let x = 0; x < w; x++) {
181 const top = px[y]![x] ?? null
182 const bot = px[y + 1]?.[x] ?? null
183 if (top === null && bot === null) { words[k++] = 0x20; words[k++] = DEFAULT; words[k++] = DEFAULT }
184 else if (top === null) { words[k++] = 0x2584; words[k++] = bot!; words[k++] = DEFAULT }
185 else { words[k++] = 0x2580; words[k++] = top; words[k++] = bot ?? DEFAULT }
186 }
187 }
188 return b64(new Uint8Array(words.buffer))
189}
190
191/** 校验 AI 画的信物:至少 6 行、每行截到 8 字符(不足补透明)、只用调色板里的字母、至多 6 色、不能全透明 */
192export function validKeepsake(art: unknown, palette: unknown): { art: string[]; palette: Record<string, number> } | null {
193 if (!Array.isArray(art) || art.length < 6 || typeof palette !== 'object' || !palette || Array.isArray(palette)) return null
194 const pal: Record<string, number> = {}
195 for (const [k, v] of Object.entries(palette as Record<string, unknown>)) {
196 if (k.length !== 1 || k === '.') continue
197 const n = typeof v === 'string' ? parseInt(v.replace('#', ''), 16) : Number(v)
198 if (Number.isFinite(n) && n >= 0 && n <= 0xffffff) pal[k] = n
199 }
200 if (Object.keys(pal).length > 6) return null
201 const rows = art.slice(0, 8).map(r => String(r).padEnd(8, '.').slice(0, 8))
202 while (rows.length < 8) rows.push('........')
203 const used = new Set(rows.join('').replace(/\./g, '').split(''))
204 if (used.size === 0 || [...used].some(ch => !(ch in pal))) return null
205 return { art: rows, palette: pal }
206}
207hooks/cost.ts 31 lines1// 花费估算:按 token 和公开价目粗算(美元),用于头部显示「本局 / 今日」和每日预算上限。
2// 以实际账单为准;价目按 sonnet / haiku 两个别名当前对应的模型。
3
4export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
5
6/** 每百万 token 的美元价 */
7export const PRICES: Record<string, Price> = {
8 sonnet: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
9 haiku: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
10}
11
12export type TokenUsage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
13
14export function costOf(model: string, u: TokenUsage): number {
15 const p = PRICES[model] ?? PRICES.sonnet!
16 return (u.input_tokens * p.input + u.output_tokens * p.output + u.cache_read_input_tokens * p.cacheRead + u.cache_creation_input_tokens * p.cacheWrite) / 1e6
17}
18
19/** 本地日期 YYYY-MM-DD(每日预算、每日挑战用) */
20export function dateKey(ms: number): string {
21 const d = new Date(ms)
22 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
23}
24
25export function usd(n: number): string {
26 return n < 0.01 ? `$${n.toFixed(3)}` : `$${n.toFixed(2)}`
27}
28
29/** 预算选项:0 表示不限 */
30export const BUDGETS = [0, 0.5, 1, 2, 5]
31hooks/engine.ts 892 lines1// 剧情引擎。纯函数(日程、约定、阶段、关键事件、随机事件、时间循环、结局判定、存档迁移)+ 三种模型调用(写一幕、画信物、写结局)。
2// 这里不读写 $.state:状态读写集中在 register.tsx,便于校验和测试。
3
4import type { EngineInterface, ModelTextBlock } from 'claude-code'
5
6import type { Actor, Atmo, BeatDef, Camera, CastDef, Choice, EndingDef, HState, IncidentDef, Keepsake, Lang, NpcDef, Pack, RelationDef, Scene, StageCue, Story, Tone, Usage } from '../types'
7import { EXPRS, validKeepsake } from './art'
8import { costOf } from './cost'
9import { craftStatic, decideRoute, dejaVuFor, endingCraft, pickCraft, revealedEcho, setPieceBrief, setPieceCast, setPieceChoices, setPieceIntact, spliceSetPiece } from './craft'
10import type { CraftPick } from './craft'
11import { workLine } from './work'
12import type { WorkEvent } from './work'
13import { t } from './i18n'
14import { actOf, allBeats, beatOf, castOf, dayText, meterOf, npcOf, packText, personOf, tr } from './pack'
15
16export const WRITER = 'sonnet'
17export const CHECKER = 'haiku'
18/** 每个角色最多几个信物 */
19export const KEEPSAKES_PER = 2
20/** 存档格式版本:2 起有 flags / incident / seed;3 起有 deltas / profile / echoes / gossip / letter / diary / portrait */
21export const STORY_V = 3
22export const TONES: Tone[] = ['honest', 'evasive', 'joking', 'committed', 'caring', 'bold']
23/** 人物(主角色 + 配角)多于这个数时,提示只放这一幕相关的人的完整设定,其余人一行摘要 */
24export const FOCUS_ABOVE = 3
25
26const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x))
27
28export function zeroUsage(): Usage {
29 return { writer: 0, checker: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
30}
31
32export function freshHeroines(p: Pack): Record<string, HState> {
33 const h: Record<string, HState> = {}
34 for (const c of p.cast) h[c.id] = { affection: 0, stage: 0, scenes: 0, mood: '', lastDay: 0 }
35 return h
36}
37
38/** 新的一局。loop ≥ 2 是二周目及以后:每人起始好感 +1(她们「好像见过你」)。seed 决定这一局的随机事件 */
39export function freshStory(p: Pack, lang: Lang, loop = 1, seed = 1): Story {
40 const heroines = freshHeroines(p)
41 if (loop >= 2) for (const id of Object.keys(heroines)) heroines[id]!.affection = 1
42 const s: Story = {
43 pack: p.id, lang, loop, cycle: 1, rewinds: 0, loads: 0,
44 phase: 'plan', day: 1, slot: 0, heroines, promises: [], notes: [], facts: [], keepsakes: [], album: [],
45 scene: null, after: '', reveal: 0, loadingText: '', ending: '', endingId: '', error: '', usage: zeroUsage(),
46 v: STORY_V, seed: seed >>> 0, flags: [], incident: '',
47 deltas: {}, profile: {}, echoes: [], gossip: [], letter: null, diary: {}, portrait: {}, world: [], daily: '', quotes: [],
48 }
49 return { ...s, incident: rollIncident(p, s) }
50}
51
52// ---------- 存档迁移 ----------
53const isObj = (x: unknown): x is Record<string, unknown> => !!x && typeof x === 'object' && !Array.isArray(x)
54const arr = <T>(x: unknown): T[] => (Array.isArray(x) ? (x as T[]) : [])
55
56/**
57 * 把任意版本的 Story 补成当前格式(缺的字段用新局的默认值,包里新加的角色补上空白关系)。
58 * 认不出来(不是对象、没有 pack/day/slot)返回 null,调用方降级显示。纯函数,不写状态。
59 */
60export function normalizeStory(raw: unknown, p: Pack | undefined): Story | null {
61 if (!isObj(raw) || typeof raw.pack !== 'string' || typeof raw.day !== 'number' || typeof raw.slot !== 'number') return null
62 const lang: Lang = raw.lang === 'en' ? 'en' : 'zh'
63 const base = p ? freshStory(p, lang, 1, 1) : null
64 const hs = isObj(raw.heroines) ? (raw.heroines as Record<string, HState>) : {}
65 const heroines: Record<string, HState> = { ...(base?.heroines ?? {}) }
66 for (const [id, h] of Object.entries(hs)) {
67 if (isObj(h)) heroines[id] = { affection: Number(h.affection) || 0, stage: clamp(Number(h.stage) || 0, 0, 4), scenes: Number(h.scenes) || 0, mood: typeof h.mood === 'string' ? h.mood : '', lastDay: Number(h.lastDay) || 0 }
68 }
69 const u = isObj(raw.usage) ? (raw.usage as Partial<Usage>) : {}
70 const phase = ['title', 'plan', 'loading', 'scene', 'after', 'ending'].includes(String(raw.phase)) ? (raw.phase as Story['phase']) : 'plan'
71 const scene = isObj(raw.scene) && typeof raw.scene.narration === 'string' && Array.isArray(raw.scene.choices) ? (raw.scene as unknown as Scene) : null
72 const str = (x: unknown) => (typeof x === 'string' ? x : '')
73 const num = (x: unknown, d: number) => (typeof x === 'number' && Number.isFinite(x) ? x : d)
74 return {
75 pack: raw.pack, lang,
76 loop: num(raw.loop, 1), cycle: num(raw.cycle, 1), rewinds: num(raw.rewinds, 0), loads: num(raw.loads, 0),
77 phase: (phase === 'scene' || phase === 'after') && !scene ? 'plan' : phase,
78 day: Math.max(1, raw.day), slot: Math.max(0, raw.slot),
79 heroines,
80 promises: arr(raw.promises), notes: arr(raw.notes), facts: arr<unknown>(raw.facts).filter((x): x is string => typeof x === 'string'),
81 keepsakes: arr(raw.keepsakes), album: arr(raw.album),
82 scene, after: str(raw.after), reveal: num(raw.reveal, 0), loadingText: str(raw.loadingText),
83 ending: str(raw.ending), endingId: str(raw.endingId), error: str(raw.error),
84 usage: { ...zeroUsage(), ...Object.fromEntries(Object.entries(u).filter(([, v]) => typeof v === 'number')) },
85 v: STORY_V,
86 seed: num(raw.seed, hashSeed(`${raw.pack}:${raw.day}:${raw.slot}`)) >>> 0,
87 flags: arr<unknown>(raw.flags).filter((x): x is string => typeof x === 'string'),
88 incident: str(raw.incident),
89 deltas: numMap(raw.deltas),
90 profile: numMap(raw.profile),
91 echoes: arr<unknown>(raw.echoes).filter((x): x is string => typeof x === 'string').slice(0, 12),
92 gossip: arr<unknown>(raw.gossip).filter((g): g is { day: number; text: string } => isObj(g) && typeof g.day === 'number' && typeof g.text === 'string').slice(-20),
93 letter: isObj(raw.letter) && typeof raw.letter.from === 'string' && typeof raw.letter.text === 'string' ? { from: raw.letter.from, text: raw.letter.text } : null,
94 diary: strMap(raw.diary),
95 portrait: strMap(raw.portrait),
96 world: arr<unknown>(raw.world).filter((x): x is string => typeof x === 'string').slice(-4),
97 daily: str(raw.daily),
98 quotes: arr<unknown>(raw.quotes).filter((q): q is { day: number; who: string; text: string } => isObj(q) && typeof q.day === 'number' && typeof q.who === 'string' && typeof q.text === 'string').slice(-60),
99 }
100}
101
102function numMap(x: unknown): Record<string, number> {
103 return isObj(x) ? Object.fromEntries(Object.entries(x).filter(([, v]) => typeof v === 'number')) as Record<string, number> : {}
104}
105function strMap(x: unknown): Record<string, string> {
106 return isObj(x) ? Object.fromEntries(Object.entries(x).filter(([, v]) => typeof v === 'string')) as Record<string, string> : {}
107}
108
109/** 旧格式(或补了字段才完整)的存档需要写回 */
110export function needsMigration(raw: unknown, p: Pack | undefined): boolean {
111 if (!isObj(raw)) return false
112 if (raw.v !== STORY_V || !Array.isArray(raw.flags) || typeof raw.seed !== 'number' || typeof raw.incident !== 'string' || !isObj(raw.profile) || !Array.isArray(raw.gossip)) return true
113 return !!p && p.cast.some(c => !isObj((raw.heroines as Record<string, unknown> | undefined)?.[c.id]))
114}
115
116// ---------- 随机 ----------
117function hashSeed(key: string): number {
118 let h = 0x811c9dc5
119 for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0 }
120 h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b) >>> 0; h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35) >>> 0; h ^= h >>> 16
121 return h >>> 0
122}
123
124/** 由种子、轮次、天、时段、事件 id 决定的 0..1 随机数:回退、读档都得到同样的结果 */
125export function roll(s: Story, key: string): number {
126 return hashSeed(`${s.seed}:${s.cycle}:${s.day}:${s.slot}:${key}`) / 4294967296
127}
128
129// ---------- 随机 / 条件事件 ----------
130export function incidentOf(p: Pack, id: string | undefined): IncidentDef | undefined {
131 return id ? (p.incidents ?? []).find(x => x.id === id) : undefined
132}
133
134export function incidentEligible(p: Pack, s: Story, x: IncidentDef): boolean {
135 if (!x.repeat && s.flags.includes(`inc:${x.id}`)) return false
136 if (x.days && (s.day < x.days[0] || s.day > x.days[1])) return false
137 if (x.slots && !x.slots.includes(s.slot)) return false
138 if (x.need) {
139 const h = s.heroines[x.need.who]
140 if (!h || h.stage < (x.need.minStage ?? 0) || h.affection < (x.need.minAffection ?? -999)) return false
141 }
142 if (x.after && !x.after.every(f => s.flags.includes(f))) return false
143 return true
144}
145
146/** 给当前时段掷一个随机事件;没有返回 '' */
147export function rollIncident(p: Pack, s: Story): string {
148 for (const x of p.incidents ?? []) {
149 if (incidentEligible(p, s, x) && roll(s, x.id) < (x.chance ?? 0.25)) return x.id
150 }
151 return ''
152}
153
154/** 当前时段的随机事件是否落在这个地点(不限地点的事件处处都算) */
155export function incidentHere(p: Pack, s: Story, opt: PlanOption): IncidentDef | undefined {
156 const x = incidentOf(p, s.incident)
157 if (!x) return undefined
158 if (x.places && !(opt.id && x.places.includes(opt.id))) return undefined
159 return x
160}
161
162// ---------- 日程 ----------
163export type PlanOption = { place: string; mood: string; maybe: string[]; forced?: string; who?: string; id?: string }
164
165export function forcedEvent(p: Pack, s: Story, lang: Lang): PlanOption | null {
166 const T = packText(p, lang)
167 const f = p.fixed.find(x => x.day === s.day && x.slot === s.slot)
168 // 固定事件的地点名和某个地点同名时带上它的 id:限地点的关键事件、预写场景、随机事件才认得出这里
169 const fid = f ? p.places.find(x => T(x.name) === T(f.place))?.id : undefined
170 if (f) return { ...(fid ? { id: fid } : {}), place: T(f.place), mood: T(f.mood), maybe: [...f.cast], forced: T(f.title) }
171 // 限地点的关键事件到了最后机会:日程把玩家带到它的地点
172 const b = allBeats(p).find(x => !!x.places?.length && !s.flags.includes(`beat:${x.id}`) && x.day <= s.day && lastChance(p, s, x))
173 const pl = b ? p.places.find(x => b.places!.includes(x.id) && x.slots.includes(s.slot)) : undefined
174 if (!b || !pl) return null
175 return { id: pl.id, place: T(pl.name), mood: T(pl.mood), maybe: [...(b.who ?? [])], forced: T(b.kind) || T(pl.name) }
176}
177
178/** 关键事件的最后期限(默认 day + 1,不超过总天数) */
179export function byOf(p: Pack, b: BeatDef): number {
180 return b.by ?? Math.min(p.days, b.day + 1)
181}
182
183/** 这个时段是不是关键事件的最后机会:by 那天、它允许的最后一个时段 */
184export function lastChance(p: Pack, s: Story, b: BeatDef): boolean {
185 const slots = b.slots?.length ? b.slots : p.slots.map((_, i) => i)
186 return s.day === byOf(p, b) && s.slot === Math.max(...slots)
187}
188
189/** 到了或过了最后机会(还没写到):by 之后,或 by 那天它允许的最后一个时段及以后 */
190function overdue(p: Pack, s: Story, b: BeatDef): boolean {
191 const slots = b.slots?.length ? b.slots : p.slots.map((_, i) => i)
192 return s.day > byOf(p, b) || (s.day === byOf(p, b) && s.slot >= Math.max(...slots))
193}
194
195/** 时段、地点是否合这个关键事件(没写限制就都合) */
196function beatFits(s: Story, b: BeatDef, place: string | undefined): boolean {
197 if (b.slots?.length && !b.slots.includes(s.slot)) return false
198 if (b.places?.length && !(place && b.places.includes(place))) return false
199 return true
200}
201
202/** 冷落 proactiveAfter 天以上、已经熟悉的人,会在每天第二个时段主动来找你(好感最高者优先) */
203export function proactive(p: Pack, s: Story): string | null {
204 if (p.proactiveAfter <= 0) return null
205 if (s.slot !== Math.min(1, p.slots.length - 1)) return null
206 const lonely = p.cast.map(c => c.id).filter(id => {
207 const h = s.heroines[id]
208 return !!h && h.stage >= 1 && s.day - h.lastDay >= p.proactiveAfter
209 })
210 return lonely.sort((a, b) => s.heroines[b]!.affection - s.heroines[a]!.affection)[0] ?? null
211}
212
213/** 这个时段开放的地点;maybe 先列主角色,再列在这里出没的配角 */
214export function planOptions(p: Pack, s: Story, lang: Lang): PlanOption[] {
215 const T = packText(p, lang)
216 return p.places.filter(pl => pl.slots.includes(s.slot)).map(pl => ({
217 id: pl.id, place: T(pl.name), mood: T(pl.mood),
218 maybe: [
219 ...p.cast.filter(c => c.haunts[s.slot]?.includes(pl.id)).map(c => c.id),
220 ...(p.npcs ?? []).filter(n => n.haunts?.[s.slot]?.includes(pl.id)).map(n => n.id),
221 ],
222 }))
223}
224
225// ---------- 关键事件 ----------
226/**
227 * 这一幕要写到的关键事件:已到日子、还没发生、相关的人可能在场的第一个;
228 * 过了 by 那天(或就在 by 那天的最后一个时段),不论谁在场都要写。
229 */
230export function dueBeat(p: Pack, s: Story, present: string[], place?: string): BeatDef | null {
231 const beats = allBeats(p).filter(b => b.day <= s.day && !s.flags.includes(`beat:${b.id}`)).sort((a, b) => a.day - b.day)
232 const here = beats.filter(b => beatFits(s, b, place) && (!b.who?.length || b.who.some(id => present.includes(id))))
233 // 先写还在期限内、此时此地合适的;过期的留给 alsoBeat 顺带补上,不再挤占后面的时段
234 return here.find(b => s.day <= byOf(p, b)) ?? here[0] ?? beats.find(b => overdue(p, s, b)) ?? null
235}
236
237/** 另一个已经过期的关键事件:这一幕顺带补上(每幕最多一个),免得它挤占后面的时段或在终局时丢掉 */
238export function alsoBeat(p: Pack, s: Story, main: BeatDef | null): BeatDef | null {
239 // 带约定的关键事件不顺带:顺带补上的只提一两句,不出约定选项,关键约定就丢了;它留给 dueBeat 当主事件
240 return allBeats(p).filter(b => b.day <= s.day && b.id !== main?.id && !b.promise && !s.flags.includes(`beat:${b.id}`) && overdue(p, s, b)).sort((a, b) => a.day - b.day)[0] ?? null
241}
242
243/** 这一幕的上下文:关键事件(及顺带补上的过期事件)、随机事件,以及要放完整设定的人 */
244export type SceneCtx = { beat: BeatDef | null; also?: BeatDef | null; incident: IncidentDef | null; focus: string[]; craft?: CraftPick; lead?: string }
245
246export function sceneContext(p: Pack, s: Story, opt: PlanOption, who: string | null): SceneCtx {
247 const inc = incidentHere(p, s, opt) ?? null
248 const present = [...opt.maybe, ...(who ? [who] : []), ...(inc?.who ?? [])]
249 const beat = dueBeat(p, s, present, opt.id)
250 const also = alsoBeat(p, s, beat)
251 const ids: string[] = []
252 const add = (id: string) => { if (!ids.includes(id) && personOf(p, id)) ids.push(id) }
253 for (const id of [...present, ...(beat?.who ?? []), ...(also?.who ?? [])]) add(id)
254 // 在场主角色的相关配角(最多两位)
255 let extra = 0
256 for (const r of p.relations ?? []) {
257 if (extra >= 2) break
258 for (const [x, y] of [[r.a, r.b], [r.b, r.a]] as const) {
259 if (castOf(p, x) && ids.includes(x) && npcOf(p, y) && !ids.includes(y) && extra < 2) { add(y); extra++ }
260 }
261 }
262 return { beat, also, incident: inc, focus: ids }
263}
264
265// ---------- 场景落地、选择、推进 ----------
266const firstHeroine = (s: Story, sc: Scene) => sc.cast.find(a => !!s.heroines[a.id])?.id ?? ''
267
268/** 这一幕里可以兑现的约定:对台上的人许下、还开着、没过期、不是这一幕刚许的 */
269export function keepable(s: Story, cast: string[]): Story['promises'] {
270 return s.promises.filter(pr => pr.status === 'open' && cast.includes(pr.heroine) && s.day <= pr.due && pr.made !== sceneKey(s))
271}
272
273/** 一幕写好后:记见面、单独相处、伏笔回收、事实、相册、关键事件与随机事件(约定只在玩家选了「兑现」时才算兑现) */
274export function applyScene(s: Story, sc: Scene): Story {
275 const heroines = { ...s.heroines }
276 for (const a of sc.cast) {
277 const h = heroines[a.id]
278 if (h) heroines[a.id] = { ...h, lastDay: s.day, scenes: h.scenes + (sc.cast.length === 1 ? 1 : 0) }
279 }
280 const notes = sc.callback ? s.notes.map(n => (!n.used && sc.callback!.includes(n.text.slice(0, 6)) ? { ...n, used: true } : n)) : s.notes
281 const promises = s.promises
282 const flags = [...s.flags]
283 if (sc.beat && !flags.includes(`beat:${sc.beat}`)) flags.push(`beat:${sc.beat}`)
284 if (sc.also && !flags.includes(`beat:${sc.also}`)) flags.push(`beat:${sc.also}`)
285 if (sc.incident && !flags.includes(`inc:${sc.incident}`)) flags.push(`inc:${sc.incident}`)
286 for (const f of sc.craftFlags ?? []) if (!flags.includes(f)) flags.push(f)
287 return {
288 ...s, phase: 'scene', scene: sc, reveal: 0, heroines, notes, promises, error: '', after: '', flags, world: [],
289 incident: sc.incident && sc.incident === s.incident ? '' : s.incident,
290 quotes: keyQuote(s, sc),
291 ...(sc.kind ? { lastKind: sc.kind } : {}),
292 facts: sc.fact ? [...s.facts, sc.fact].slice(-40) : s.facts,
293 album: [...s.album, { day: s.day, slot: s.slot, heroine: firstHeroine(s, sc) || sc.cast[0]?.id || '', text: sc.summary }].slice(-80),
294 }
295}
296
297/** 每幕留一句关键台词(主角色说的最长的一句),导出整局时用 */
298function keyQuote(s: Story, sc: Scene): Story['quotes'] {
299 const line = sc.lines.filter(l => !!s.heroines[l.who]).sort((a, b) => b.text.length - a.text.length)[0]
300 return line ? [...s.quotes, { day: s.day, who: line.who, text: line.text }].slice(-60) : s.quotes
301}
302
303/** 同一天同一时段的标识,防止「刚答应就在同一幕里算兑现」 */
304export function sceneKey(s: Story): string {
305 return `${s.cycle}:${s.day}:${s.slot}`
306}
307
308export type ChoiceResult = { story: Story; advanced: string[]; keepsakeFor: string[] }
309
310export function applyChoice(p: Pack, s: Story, c: Choice, lang: Lang): ChoiceResult {
311 const sc = s.scene!
312 const lead = firstHeroine(s, sc)
313 const heroines = { ...s.heroines }
314 for (const [id, d] of Object.entries(c.effects)) {
315 const h = heroines[id]
316 if (h) heroines[id] = { ...h, affection: h.affection + clamp(Math.round(Number(d)) || 0, -2, 2) }
317 }
318 const notes = [...s.notes, { day: s.day, heroine: lead, text: `${sc.place}:${c.text}`, used: false }].slice(-30)
319 let promises = [...s.promises]
320 // 兑现:对 keeps(不填就是领头的人)最早到期的那个约定
321 if (c.kind === 'keep') {
322 const target = c.keeps && sc.cast.some(a => a.id === c.keeps) ? c.keeps : lead
323 const pr = keepable(s, [target]).sort((a, b) => a.due - b.due)[0]
324 if (pr) promises = promises.map(x => (x === pr ? { ...x, status: 'kept' as const } : x))
325 }
326 // 关键约定记在关键事件涉及的主角色名下:在场的人里好感最高的(平手时领头的人优先);都不在场就取其中好感最高的
327 const beat = beatOf(p, sc.beat)
328 const key = beat?.promise ? beat.id : undefined
329 const onStage = (id: string) => sc.cast.some(a => a.id === id)
330 const whoCast = (beat?.who ?? []).filter(id => !!castOf(p, id))
331 const aff = (id: string) => s.heroines[id]?.affection ?? 0
332 const best = (ids: string[]) => [...ids].sort((a, b) => aff(b) - aff(a) || Number(b === lead) - Number(a === lead))[0]
333 const owner = key && whoCast.length ? (best(whoCast.filter(onStage)) ?? best(whoCast) ?? lead) : lead
334 // 全剧最后一幕许下的约定没有之后的时段可以兑现,记下来只会在结局前算失约:不记
335 const finalScene = s.day === p.days && s.slot === p.slots.length - 1
336 if (c.kind === 'promise' && owner && castOf(p, owner) && !finalScene) {
337 const dl = castOf(p, owner)!.deadline.day
338 promises.push({ text: c.promise || c.text, heroine: owner, day: s.day, due: Math.min(clamp(dl, s.day + 1, p.days), p.days), status: 'open', made: sceneKey(s), ...(key ? { key } : {}) })
339 }
340 const advanced: string[] = []
341 for (const ch of p.cast) {
342 const st = heroines[ch.id]
343 if (!st) continue
344 const next = p.gates[st.stage + 1]
345 if (next && st.affection >= next.affection && st.scenes >= next.scenes) {
346 heroines[ch.id] = { ...st, stage: st.stage + 1 }
347 advanced.push(ch.id)
348 }
349 }
350 const T = packText(p, lang)
351 const turns = advanced.map(id => t(lang, 'stageUp', { name: T(castOf(p, id)!.name), stage: T(p.stages[heroines[id]!.stage]) }))
352 const after = [c.reaction || '…', ...turns.map(x => `(${x})`)].join('\n\n')
353 const have = (id: string) => s.keepsakes.filter(k => k.heroine === id).length
354 const keepsakeFor = advanced.filter(id => heroines[id]!.stage >= 3 && have(id) < KEEPSAKES_PER)
355 const deltas: Record<string, number> = {}
356 for (const [id, d] of Object.entries(c.effects)) if (heroines[id] && d) deltas[id] = clamp(Math.round(Number(d)) || 0, -2, 2)
357 const profile = c.tone ? { ...s.profile, [c.tone]: (s.profile[c.tone] ?? 0) + 1 } : s.profile
358 return { story: { ...s, phase: 'after', heroines, notes, promises, reveal: 0, after, deltas, profile }, advanced, keepsakeFor }
359}
360
361export type AdvanceResult = { story: Story; finished: boolean; looped: boolean; broken: string[] }
362
363/** 推进到下一个时段;跨天时结算过期约定、给出章节钩子;最后一天结束时进入结局或时间循环;每个新时段重掷随机事件 */
364export function advanceTime(p: Pack, s: Story, lang: Lang): AdvanceResult {
365 let { day, slot } = s
366 slot++
367 let promises = s.promises
368 let heroines = s.heroines
369 const broken: string[] = []
370 const T = packText(p, lang)
371 const notes: string[] = []
372 if (slot >= p.slots.length) {
373 slot = 0
374 day++
375 heroines = { ...s.heroines }
376 promises = s.promises.map(pr => {
377 if (pr.status !== 'open' || pr.due >= day) return pr
378 const h = heroines[pr.heroine]
379 if (h) heroines[pr.heroine] = { ...h, affection: h.affection - 2, mood: 'disappointed' }
380 broken.push(t(lang, 'promiseBroken', { name: T(castOf(p, pr.heroine)?.name), text: pr.text }))
381 return { ...pr, status: 'broken' as const }
382 })
383 const was = actOf(p, s.day)
384 const now = actOf(p, day)
385 if (now && was && now.id !== was.id && day <= p.days) {
386 if (was.hook) notes.push(T(was.hook))
387 notes.push(t(lang, 'actStart', { title: T(now.title) }))
388 }
389 }
390 let base0: Story = { ...s, day, slot, promises, heroines, phase: 'plan', scene: null, reveal: 0, after: [...broken, ...notes].join('\n'), error: '', incident: '' }
391 // 共通线过完时锁定路线(只影响提示里的走向和指定了路线的关键场景,不改结局判定)
392 if (day !== s.day && day <= p.days) {
393 const route = decideRoute(p, base0)
394 if (route) {
395 const id = route.slice('route:'.length)
396 const note = id === 'ensemble' ? t(lang, 'routeEnsemble') : t(lang, 'routeLocked', { name: T(castOf(p, id)?.short) })
397 base0 = { ...base0, flags: [...base0.flags, route], after: [base0.after, note].filter(Boolean).join('\n') }
398 }
399 }
400 const base: Story = { ...base0, incident: day <= p.days ? rollIncident(p, base0) : '' }
401 if (day <= p.days) return { story: base, finished: false, looped: false, broken }
402 // 最后一天过完:时间循环包在没达到目标结局时回到第 1 天
403 const loop = p.features?.timeLoop
404 if (loop && s.cycle < loop.maxLoops + 1 && pickEnding(p, base).tier < loop.minTier) {
405 const looped0: Story = {
406 ...base, day: 1, slot: 0, cycle: s.cycle + 1, heroines: freshHeroines(p), promises: [], flags: [],
407 after: tr(loop.text, lang, p.defaultLang),
408 notes: base.notes.map(n => ({ ...n, used: false })),
409 }
410 return { story: { ...looped0, incident: rollIncident(p, looped0) }, finished: false, looped: true, broken }
411 }
412 return { story: { ...base, day: p.days, slot: p.slots.length - 1 }, finished: true, looped: false, broken }
413}
414
415// ---------- 结局判定 ----------
416export function endingMatches(p: Pack, s: Story, e: EndingDef): boolean {
417 if (e.minLoop !== undefined && s.loop < e.minLoop) return false
418 if (e.flags && !e.flags.every(f => s.flags.includes(f))) return false
419 const who = e.who
420 // 不绑角色的结局:阶段/好感条件对每个角色都要满足(群像结局);约定与信物条件按所有人合计
421 if (!who) {
422 const all = p.cast.every(c => {
423 const h = s.heroines[c.id]
424 return !!h && (e.minStage === undefined || h.stage >= e.minStage) && (e.minAffection === undefined || h.affection >= e.minAffection)
425 && (e.maxAffection === undefined || h.affection <= e.maxAffection)
426 })
427 return all && promiseTermsMet(s, e, s.promises, s.keepsakes.length)
428 }
429 const h = s.heroines[who]
430 if (!h) return false
431 if (e.minStage !== undefined && h.stage < e.minStage) return false
432 if (e.minAffection !== undefined && h.affection < e.minAffection) return false
433 if (e.maxAffection !== undefined && h.affection > e.maxAffection) return false
434 return promiseTermsMet(s, e, s.promises.filter(x => x.heroine === who), s.keepsakes.filter(k => k.heroine === who).length)
435}
436
437function promiseTermsMet(s: Story, e: EndingDef, mine: Story['promises'], keepsakes: number): boolean {
438 const nBroken = mine.filter(x => x.status === 'broken').length
439 if (e.maxBroken !== undefined && nBroken > e.maxBroken) return false
440 if (e.minBroken !== undefined && nBroken < e.minBroken) return false
441 if (e.minKept !== undefined && mine.filter(x => x.status === 'kept').length < e.minKept) return false
442 if (e.keptKey !== undefined && !mine.some(x => x.key === e.keptKey && x.status === 'kept')) return false
443 if (e.minKeepsakes !== undefined && keepsakes < e.minKeepsakes) return false
444 return true
445}
446
447/** tier 高者优先;同 tier 时群像结局(不绑角色)优先,再比关联角色的好感 */
448export function pickEnding(p: Pack, s: Story): EndingDef {
449 const aff = (e: EndingDef) => (e.who ? s.heroines[e.who]?.affection ?? 0 : 0)
450 const ok = p.endings.filter(e => endingMatches(p, s, e))
451 ok.sort((a, b) => b.tier - a.tier || Number(!b.who) - Number(!a.who) || aff(b) - aff(a))
452 return ok[0] ?? p.endings[p.endings.length - 1]!
453}
454
455// ---------- 提示 ----------
456export type Gen<T> = { value: T | null; usage: Usage }
457
458function json(text: string | null): Record<string, unknown> | null {
459 if (!text) return null
460 const a = text.indexOf('{')
461 const b = text.lastIndexOf('}')
462 if (a < 0 || b <= a) return null
463 try {
464 const v = JSON.parse(text.slice(a, b + 1)) as unknown
465 return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
466 } catch {
467 return null
468 }
469}
470
471async function ask($: EngineInterface, model: string, prompt: ModelTextBlock[], maxTokens: number, u: Usage, kind: 'write' | 'check' = 'write'): Promise<string | null> {
472 const r = await $.model.complete({ model, prompt, maxTokens, timeoutMs: 90_000 })
473 if (model === CHECKER && kind === 'check') u.checker++
474 else u.writer++
475 u.input += r.usage.input_tokens
476 u.output += r.usage.output_tokens
477 u.cacheRead += r.usage.cache_read_input_tokens
478 u.cacheWrite += r.usage.cache_creation_input_tokens
479 u.cost = (u.cost ?? 0) + costOf(model, r.usage)
480 return r.isAnswered ? r.text : null
481}
482
483function langRule(lang: Lang): string {
484 return lang === 'en'
485 ? 'Write ALL story text (titles, narration, dialogue, choices, reactions, summaries, facts) in natural English.'
486 : '所有剧情文字(标题、旁白、台词、选项、反应、概括、事实)都用简体中文写。'
487}
488
489/** 人物多时只给相关的人完整设定 */
490export function focusMode(p: Pack): boolean {
491 return p.cast.length + (p.npcs?.length ?? 0) > FOCUS_ABOVE
492}
493
494/** 非恋爱角色:写进人物设定和一行摘要,让 AI 不写成恋爱对象 */
495function noRomance(p: Pack, c: CastDef, lang: Lang): string {
496 if (meterOf(c).romance) return ''
497 const label = packText(p, lang)(c.meter?.label) || 'trust'
498 return `NOT a romance route: "affection" with this character means ${label}; never write romance, flirtation, crushes or a love-interest framing`
499}
500
501function castEntry(p: Pack, c: CastDef, lang: Lang): string {
502 const T = packText(p, lang)
503 const nr = noRomance(p, c, lang)
504 return [
505 `- id=${c.id}「${T(c.name)}」 look: ${T(c.look)}`,
506 ...(nr ? [` ${nr}.`] : []),
507 ` voice: ${T(c.voice)}`,
508 ` samples: ${c.samples.map(x => `「${T(x)}」`).join(' ')}`,
509 ` secret (do not reveal before stage 3 "${T(p.stages[3])}"): ${T(c.secret)}`,
510 ` arc: ${c.arc.map((a, i) => `[${i}] ${T(a)}`).join(' ')}`,
511 ` deadline: day ${c.deadline.day}, ${T(c.deadline.text)}`,
512 ].join('\n')
513}
514
515function npcEntry(p: Pack, n: NpcDef, lang: Lang): string {
516 const T = packText(p, lang)
517 return [
518 `- id=${n.id}「${T(n.name)}」 (supporting character, not romanceable, no affection) role: ${T(n.role)}${n.look ? ` look: ${T(n.look)}` : ''}`,
519 ` voice: ${T(n.voice)}`,
520 ` samples: ${n.samples.map(x => `「${T(x)}」`).join(' ')}`,
521 ].join('\n')
522}
523
524function personEntry(p: Pack, id: string, lang: Lang): string {
525 const c = castOf(p, id)
526 if (c) return castEntry(p, c, lang)
527 const n = npcOf(p, id)
528 return n ? npcEntry(p, n, lang) : ''
529}
530
531function summaryLines(p: Pack, lang: Lang): string {
532 const T = packText(p, lang)
533 return [
534 ...p.cast.map(c => `- ${c.id}「${T(c.name)}」: ${T(c.tagline ?? c.look)}${meterOf(c).romance ? '' : ` (${noRomance(p, c, lang)})`}`),
535 ...(p.npcs ?? []).map(n => `- ${n.id}「${T(n.name)}」 (supporting): ${T(n.role)}`),
536 ].join('\n')
537}
538
539function relationLine(p: Pack, r: RelationDef, lang: Lang): string {
540 const T = packText(p, lang)
541 const nm = (id: string) => (id === 'me' ? T(p.me) : `${id}「${T(personOf(p, id)?.name)}」`)
542 const hidden = r.hiddenUntil !== undefined ? ` (keep this implicit, hints only, until ${[r.a, r.b].filter(x => castOf(p, x)).join(' or ') || 'later'} reaches stage ${r.hiddenUntil})` : ''
543 return `- ${nm(r.a)} ↔ ${nm(r.b)} [${T(r.kind)}]: ${T(r.text)}${hidden}`
544}
545
546function relationsFor(p: Pack, ids: string[] | null, lang: Lang): string {
547 const rs = (p.relations ?? []).filter(r => !ids || ids.includes(r.a) || ids.includes(r.b))
548 return rs.map(r => relationLine(p, r, lang)).join('\n')
549}
550
551/** 小包:全部人物设定都在固定块里(与旧版一致) */
552function fullBible(p: Pack, lang: Lang): string {
553 const npcs = p.npcs?.length ? `\n[Supporting characters — may appear to move the plot]\n${p.npcs.map(n => npcEntry(p, n, lang)).join('\n')}` : ''
554 const rel = p.relations?.length ? `\n[Relations]\n${relationsFor(p, null, lang)}` : ''
555 return `[Cast]\n${p.cast.map(c => castEntry(p, c, lang)).join('\n')}${npcs}${rel}`
556}
557
558function actsOverview(p: Pack, lang: Lang): string {
559 if (!p.acts?.length) return ''
560 const T = packText(p, lang)
561 return `\n\n[Story arc — acts]\n${p.acts.map(a => `- ${T(a.title)} (from day ${a.from}): ${T(a.goal)}`).join('\n')}`
562}
563
564const FORMAT = `Reply with ONE JSON object only:
565{"title":"scene title","atmo":{"weather":"clear|rain|snow|fog|storm|wind","time":"dawn|day|dusk|night","fx":"rain|snow|fireflies|cicadas|lanterns|meteors|mist|stars|none"},"camera":{"focus":"one small object the camera lingers on, or empty","pause":0},"cast":[{"id":"<cast or supporting id>","expression":"normal|smile|blush|sad|angry|surprised"}],"distance":"near|far","narration":"2-4 sentences","lines":[{"who":"<cast id, supporting id, or me>","text":"..."}],"cues":[{"at":1,"who":"<id on stage>","emote":"!|?|note|...|anger|sweat|heart","move":"enter|exit|approach|turn|face"}],"choices":[{"text":"...","kind":"reply|observe|promise|keep","tone":"honest|evasive|joking|committed|caring|bold","effects":{"<cast id>":1},"promise":"what I promised (promise only)","keeps":"<cast id> (keep only)","reaction":"the immediate reaction and close of the scene after this choice, 2-3 sentences, may include one line of dialogue"}],"summary":"one-sentence summary","callback":"which earlier note this scene calls back, or empty","fact":"one concrete new fact this scene establishes"}
566Rules: at most two characters on stage (romanceable or supporting; zero is allowed); 3-7 lines of dialogue; exactly 3 choices with clearly different attitudes, at least one "reply"; "observe" gains a detail and does not change affection; "promise" must fill the promise field; "keep" means I actually do what I promised her earlier (only when [Promises I can keep here] lists one; fill "keeps"); effects range -2..+2 and list only affected romanceable cast ids.
567Direction (you are also the director): "atmo" sets the weather, time of day and the ambient particles; "camera.focus" names one object for a close-up, "camera.pause" is the line number after which a silent beat falls (0 = none); "cues" are 0-4 stage directions — "at" is the line number (0 = narration) when it happens: an emote bubble over someone's head, entering or leaving, stepping closer, turning away when shy or hurt, turning back. Use them sparingly, only where they add something. Give every choice a "tone".`
568
569/** 固定不变的部分(前提、文风、人物、分幕、格式)放第一块并打缓存标记,每一幕复用 */
570export function staticBlock(p: Pack, lang: Lang): string {
571 const T = packText(p, lang)
572 const wall = p.features?.fourthWall
573 ? '\nThis story may break the fourth wall: characters may sense the player\'s undos, reloads and replays (given in the state below) and gently remark on them. Never threaten the player.'
574 : ''
575 const loop = p.features?.timeLoop
576 ? `\nThis story has a time loop: after day ${p.days} it may return to day 1. "I" remember earlier loops (the notebook and facts persist); others do not, unless their secret says otherwise.`
577 : ''
578 const people = focusMode(p)
579 ? `[Everyone — one line each. Full notes for the people relevant to a scene come with that scene; put on stage only people whose full notes you are given, others may only be mentioned]\n${summaryLines(p, lang)}`
580 : fullBible(p, lang)
581 return `You are writing an interactive visual novel: "${T(p.title)}".
582${langRule(lang)}
583
584[Premise]
585${T(p.premise)}
586
587[Style]
588${T(p.style)}
589
590${people}${actsOverview(p, lang)}
591
592[Stages] ${p.stages.map((x, i) => `${i}=${T(x)}`).join(', ')}${wall}${loop}
593
594${craftStatic(p, lang)}
595
596[Output format — for the writer]
597${FORMAT}`
598}
599
600/** 检查员只需要人物与禁忌,不需要写作格式:比写手的固定块短,省输入 */
601export function checkerBlock(p: Pack, lang: Lang): string {
602 const T = packText(p, lang)
603 return `Continuity reference for the visual novel "${T(p.title)}".
604[Style and taboos]
605${T(p.style)}
606${focusMode(p) ? `[Everyone — one line each]\n${summaryLines(p, lang)}` : fullBible(p, lang)}
607[Stages] ${p.stages.map((x, i) => `${i}=${T(x)}`).join(', ')}`
608}
609
610/** 关系状态:focus 里的主角色给完整一行(含当前节拍),其余人只给阶段与好感 */
611export function stateText(p: Pack, s: Story, lang: Lang, focus?: string[]): string {
612 const T = packText(p, lang)
613 return p.cast.map(c => {
614 const h = s.heroines[c.id]
615 if (!h) return `- ${c.id}: stage 0`
616 const aff = meterOf(c).romance ? 'affection' : `${T(c.meter?.label) || 'trust'} (not romance)`
617 if (focus && focusMode(p) && !focus.includes(c.id)) return `- ${c.id} ${T(c.short)}: stage ${h.stage}, ${aff} ${h.affection}, last seen day ${h.lastDay || '-'}`
618 return `- ${c.id} ${T(c.name)}: ${aff} ${h.affection}, stage ${h.stage} "${T(p.stages[h.stage])}" (beat: ${T(c.arc[h.stage])}), mood ${h.mood || 'normal'}, alone-scenes ${h.scenes}, last seen day ${h.lastDay || '-'}`
619 }).join('\n')
620}
621
622/** 只放这一幕相关的人:完整设定 + 关系 */
623function focusNotes(p: Pack, ids: string[], lang: Lang): string {
624 if (!focusMode(p)) return ''
625 const people = ids.map(id => personEntry(p, id, lang)).filter(Boolean).join('\n')
626 const rel = relationsFor(p, ids, lang)
627 return `[People relevant to this scene — full notes]\n${people || '(nobody in particular; keep the stage empty or bring someone in only by mention)'}\n${rel ? `[Relations in play]\n${rel}\n` : ''}`
628}
629
630/** 每一幕的写法开关:写作模型(省额度时换 haiku)、短幕、现实里写代码的事件 */
631export type MetaInfo = { endingsSeen: string[]; clears: number; writer?: string; short?: boolean; world?: string[] }
632
633export function sceneDynamic(p: Pack, s: Story, opt: PlanOption, who: string | null, fix: string, lang: Lang, meta: MetaInfo, ctx?: SceneCtx): string {
634 const T = packText(p, lang)
635 const c = ctx ?? sceneContext(p, s, opt, who)
636 const open = s.promises.filter(x => x.status === 'open')
637 const notes = s.notes.filter(n => !n.used).slice(-6)
638 const today = p.events.filter(e => e.day === s.day).map(e => T(e.text)).join('; ')
639 const lead = who ? `${T(castOf(p, who)?.name)} came looking for me (not seen for ${s.day - s.heroines[who]!.lastDay} days); she leads this scene.` : ''
640 const wall = p.features?.fourthWall
641 ? `\n[Player traces] undos this run: ${s.rewinds}; reloads: ${s.loads}; playthrough #${s.loop}; endings seen before: ${meta.endingsSeen.join(', ') || 'none'}`
642 : ''
643 const cyc = p.features?.timeLoop ? `\n[Loop] this is loop ${s.cycle}.` : ''
644 const act = actOf(p, s.day)
645 const actLine = act ? `[Act] ${T(act.title)}: ${T(act.goal)}\n` : ''
646 const b = c.beat
647 const beatLine = b
648 ? `\n[Must happen in this scene${b.kind ? ` — ${T(b.kind)}` : ''}] ${T(b.text)}${b.who?.length ? ` (involves ${b.who.join(', ')})` : ''}${b.promise ? `\nOne of the three choices must be a "promise" choice of this kind: ${T(b.promise)}` : ''}\n`
649 : ''
650 const al = c.also
651 const alsoLine = al ? `\n[Also, briefly — this already happened offstage or happens in passing; mention it in a sentence or two] ${T(al.text)}${al.who?.length ? ` (involves ${al.who.join(', ')})` : ''}\n` : ''
652 const canKeep = keepable(s, c.focus.filter(id => !!castOf(p, id)))
653 const keepLine = canKeep.length
654 ? `\n[Promises I can keep here] ${canKeep.map(x => `${x.heroine}: ${x.text} (due day ${x.due})`).join('; ')} — if she is on stage, one of the three choices must be a "keep" choice where I actually do it (fill "keeps" with her id).\n`
655 : ''
656 const inc = c.incident
657 const incLine = inc ? `\n[Something happens this time] ${T(inc.text)}${inc.who?.length ? ` (brings in ${inc.who.join(', ')})` : ''}\n` : ''
658 const present = opt.maybe.length ? `\n[Likely here] ${opt.maybe.join(', ')}` : ''
659 const pk = c.craft
660 const leadId = c.lead ?? who ?? opt.maybe.find(id => !!castOf(p, id)) ?? ''
661 const craftLines = `${pk?.text ? `\n${pk.text}\n` : ''}${pk ? revealedEcho(p, s, lang) : ''}${leadId && Math.max(s.loop, s.cycle) >= 2 ? dejaVuFor(p, s, leadId, lang) : ''}`
662 const finalLine = pk?.setPiece ? `${setPieceBrief(p, pk.setPiece, lang)} ${langRule(lang)}` : `Write this scene following the current stage beat of whoever is present. ${langRule(lang)}`
663 const echoes = s.loop >= 2 && s.echoes.length ? `\n[Echoes of a previous playthrough] The characters do not remember it, but now and then one may feel a faint déjà vu ("have we... met somewhere before?") tied to one of these; at most one subtle hint per scene, never a clear memory: ${s.echoes.slice(0, 6).join(' / ')}` : ''
664 const prof = profileLine(s)
665 const gossip = s.gossip.filter(g => g.day === s.day - 1).map(g => g.text).join(' ')
666 const extra = `${echoes}${prof ? `\n[How I tend to act] ${prof}. Characters who know me may notice the pattern and say so, once, in their own words ("you always joke when it matters").` : ''}${gossip ? `\n[Overnight in town — offstage, may be mentioned] ${gossip}` : ''}`
667 return `[Now] ${dayText(p, lang, s.day)} of ${p.days} · ${T(p.slots[s.slot])} · place: ${opt.place} (${opt.mood})${opt.forced ? ` · fixed event: ${opt.forced}` : ''}
668${actLine}${today ? `Today: ${today}\n` : ''}${focusNotes(p, c.focus, lang)}[State]
669${stateText(p, s, lang, c.focus)}${present}${extra}
670${lead}${wall}${cyc}${beatLine}${alsoLine}${keepLine}${incLine}${craftLines}
671[Established facts — do not contradict]
672${s.facts.slice(-24).map(f => `- ${f}`).join('\n') || '(none)'}
673[Open promises]
674${open.map(x => `- day ${x.day} promised ${x.heroine}: ${x.text} (due day ${x.due})`).join('\n') || '(none)'}
675[Notebook: my earlier choices — call one back naturally]
676${notes.map(n => `- day ${n.day} (${n.heroine}): ${n.text}`).join('\n') || '(none)'}
677${fix ? `\n[Problems in the previous draft — must fix] ${fix}\n` : ''}
678${meta.world?.length ? `${workLine(meta.world as WorkEvent[])}\n` : ''}${meta.short && !pk?.setPiece ? '[Short scene] This is a short scene for a quick break: narration 1-2 sentences, only 2-4 lines of dialogue, still exactly 3 choices.\n' : ''}${finalLine}`
679}
680
681/** 选择倾向的一行摘要:选过 3 次以上才有 */
682export function profileLine(s: Story): string {
683 const total = Object.values(s.profile).reduce((a, b) => a + (b ?? 0), 0)
684 if (total < 3) return ''
685 return Object.entries(s.profile).filter(([, n]) => (n ?? 0) > 0).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0)).slice(0, 3).map(([k, n]) => `${k} ${n}`).join(', ')
686}
687
688export function checkDynamic(p: Pack, s: Story, draft: string, lang: Lang, ctx?: SceneCtx): string {
689 const T = packText(p, lang)
690 const must = `${ctx?.beat ? `\n7. this required event does not happen: ${T(ctx.beat.text)}` : ''}${ctx?.also ? `\n8. this earlier event is not even mentioned: ${T(ctx.also.text)}` : ''}${ctx?.craft?.setPiece ? '\nLines quoted from the author\'s set piece are canon; never ask to change them.' : ''}`
691 return `You are now the continuity editor, not the writer. Check this draft scene for hard errors only:
6921. contradicts an established fact; 2. a character's voice clearly wrong; 3. reveals a secret before stage 3, or states a relation marked "keep implicit" too early; 4. wrong language (must follow: ${langRule(lang)}); 5. violates the style taboos; 6. missing JSON fields.${must}
693${ctx ? focusNotes(p, ctx.focus, lang) : ''}State:
694${stateText(p, s, lang, ctx?.focus)}
695Facts: ${s.facts.slice(-24).join('; ') || 'none'}
696Draft:
697${draft}
698Reply with JSON only: {"ok":true} or {"ok":false,"fix":"one sentence on what to change"}`
699}
700
701export function parseScene(p: Pack, j: Record<string, unknown>, place: string): Scene | null {
702 if (typeof j.narration !== 'string' || !Array.isArray(j.choices) || j.choices.length < 2) return null
703 const ids = p.cast.map(c => c.id)
704 const onStage = [...ids, ...(p.npcs ?? []).map(n => n.id)]
705 const cast: Actor[] = (Array.isArray(j.cast) ? j.cast : [])
706 .map(a => {
707 const o = (a ?? {}) as Record<string, unknown>
708 const ex = String(o.expression)
709 return { id: String(o.id), expression: (EXPRS as readonly string[]).includes(ex) ? ex : 'normal' }
710 })
711 .filter((a, i, all) => onStage.includes(a.id) && all.findIndex(x => x.id === a.id) === i)
712 .slice(0, 2)
713 const TONE_SET = new Set<string>(TONES)
714 const choices: Choice[] = (j.choices as unknown[]).slice(0, 3).map(raw => {
715 const c = (raw ?? {}) as Record<string, unknown>
716 const eff: Record<string, number> = {}
717 if (c.effects && typeof c.effects === 'object') for (const [k, v] of Object.entries(c.effects as Record<string, unknown>)) if (ids.includes(k)) eff[k] = clamp(Math.round(Number(v)) || 0, -2, 2)
718 return {
719 text: String(c.text ?? '…'),
720 kind: c.kind === 'observe' || c.kind === 'promise' || c.kind === 'keep' ? c.kind : 'reply',
721 ...(TONE_SET.has(String(c.tone)) ? { tone: c.tone as Tone } : {}),
722 effects: eff,
723 promise: typeof c.promise === 'string' && c.promise ? c.promise : undefined,
724 ...(c.kind === 'keep' && typeof c.keeps === 'string' && ids.includes(c.keeps) ? { keeps: c.keeps } : {}),
725 reaction: typeof c.reaction === 'string' ? c.reaction : '',
726 }
727 })
728 return {
729 title: String(j.title ?? ''), place, cast, distance: j.distance === 'far' ? 'far' : 'near',
730 narration: j.narration,
731 lines: (Array.isArray(j.lines) ? j.lines : []).map(l => {
732 const o = (l ?? {}) as Record<string, unknown>
733 return { who: String(o.who ?? ''), text: String(o.text ?? '') }
734 }).filter(l => l.text),
735 choices, summary: String(j.summary ?? ''), callback: String(j.callback ?? ''), fact: String(j.fact ?? ''),
736 ...parseDirection(j, cast.map(a => a.id), Array.isArray(j.lines) ? j.lines.length : 0),
737 }
738}
739
740const WEATHER = ['clear', 'rain', 'snow', 'fog', 'storm', 'wind']
741const TIMES = ['dawn', 'day', 'dusk', 'night']
742const FX = ['rain', 'snow', 'fireflies', 'cicadas', 'lanterns', 'meteors', 'mist', 'stars', 'none']
743const EMOTE = ['!', '?', 'note', '...', 'anger', 'sweat', 'heart']
744const MOVES = ['enter', 'exit', 'approach', 'turn', 'face']
745
746/** 氛围、镜头、演出指令:认不出的值直接丢掉,不影响这一幕 */
747export function parseDirection(j: Record<string, unknown>, onStage: string[], nLines: number): { atmo?: Atmo; camera?: Camera; cues?: StageCue[] } {
748 const out: { atmo?: Atmo; camera?: Camera; cues?: StageCue[] } = {}
749 const a = isObj(j.atmo) ? j.atmo : null
750 if (a) {
751 const atmo: Atmo = {}
752 if (WEATHER.includes(String(a.weather))) atmo.weather = a.weather as Atmo['weather']
753 if (TIMES.includes(String(a.time))) atmo.time = a.time as Atmo['time']
754 if (FX.includes(String(a.fx))) atmo.fx = a.fx as Atmo['fx']
755 if (typeof a.mood === 'string' && a.mood) atmo.mood = a.mood.slice(0, 24)
756 if (Object.keys(atmo).length) out.atmo = atmo
757 }
758 const c = isObj(j.camera) ? j.camera : null
759 if (c) {
760 const cam: Camera = {}
761 if (typeof c.focus === 'string' && c.focus.trim()) cam.focus = c.focus.trim().slice(0, 40)
762 const pause = Math.round(Number(c.pause))
763 if (pause >= 1 && pause <= nLines) cam.pause = pause
764 if (Object.keys(cam).length) out.camera = cam
765 }
766 if (Array.isArray(j.cues)) {
767 const cues = (j.cues as unknown[]).filter(isObj).map(x => ({
768 at: Math.round(Number(x.at)), who: String(x.who ?? ''),
769 ...(EMOTE.includes(String(x.emote)) ? { emote: x.emote as StageCue['emote'] } : {}),
770 ...(MOVES.includes(String(x.move)) ? { move: x.move as StageCue['move'] } : {}),
771 })).filter(x => x.at >= 0 && x.at <= nLines && onStage.includes(x.who) && (x.emote || x.move)).slice(0, 6)
772 if (cues.length) out.cues = cues
773 }
774 return out
775}
776
777// ---------- 模型调用 ----------
778/**
779 * 写一幕:Sonnet 初稿 → Haiku 检查 → 不过则带意见重写一次(重写稿不再检查)。
780 * 每幕最多 2 次写作调用 + 1 次检查调用;正常 1 + 1。
781 */
782export async function writeScene($: EngineInterface, p: Pack, s: Story, opt: PlanOption, who: string | null, lang: Lang, meta: MetaInfo): Promise<Gen<Scene>> {
783 const u = zeroUsage()
784 const head: ModelTextBlock = { text: staticBlock(p, lang), cache: true }
785 const ctx = sceneContext(p, s, opt, who)
786 // 写作手法层:每幕只挑一次(两次写作尝试共用),预写关键场景的人也要有完整设定
787 const pick = pickCraft(p, s, lang, { place: opt.id, present: [...opt.maybe, ...(who ? [who] : []), ...(ctx.incident?.who ?? [])], beat: ctx.beat, lead: who, endingsSeen: meta.endingsSeen, lastKind: s.lastKind })
788 if (pick.setPiece) for (const id of setPieceCast(pick.setPiece)) if (!ctx.focus.includes(id) && personOf(p, id)) ctx.focus.push(id)
789 ctx.craft = pick
790 let fix = ''
791 for (let attempt = 0; attempt < 2; attempt++) {
792 const draft = await ask($, meta.writer ?? WRITER, [head, { text: sceneDynamic(p, s, opt, who, fix, lang, meta, ctx) }], 1800, u)
793 const j = json(draft)
794 let sc = j ? parseScene(p, j, opt.place) : null
795 if (!sc) { fix = 'The previous reply was not valid JSON or missed fields.'; continue }
796 // 预写关键场景:核心台词缺了就由程序拼回(不重写);预写选项优先
797 const aiKeep = sc.choices.find(x => x.kind === 'keep')
798 if (pick.setPiece) {
799 if (!setPieceIntact(p, sc, pick.setPiece, lang)) sc = spliceSetPiece(p, sc, pick.setPiece, lang)
800 const pre = setPieceChoices(p, pick.setPiece, lang)
801 if (pre) sc.choices = pre
802 }
803 sc.choices = withKeep(p, s, sc, aiKeep, lang)
804 if (attempt === 0) {
805 const verdict = json(await ask($, CHECKER, [{ text: checkerBlock(p, lang), cache: true }, { text: checkDynamic(p, s, draft!, lang, ctx) }], 200, u, 'check'))
806 if (verdict && verdict.ok === false && typeof verdict.fix === 'string' && verdict.fix) { fix = verdict.fix; continue }
807 }
808 sc.proactive = !!who
809 sc.kind = pick.kind
810 if (pick.flags.length) sc.craftFlags = pick.flags
811 if (ctx.beat) sc.beat = ctx.beat.id
812 if (ctx.also) sc.also = ctx.also.id
813 if (ctx.incident) sc.incident = ctx.incident.id
814 return { value: sc, usage: u }
815 }
816 return { value: null, usage: u }
817}
818
819/**
820 * 台上有人可以兑现约定时,保证选项里有一个「兑现」:AI 写了就用它(预写选项之外补上),没写就由程序补一个。
821 * 没有可兑现的约定时,去掉 AI 多写的「兑现」。
822 */
823export function withKeep(p: Pack, s: Story, sc: Scene, aiKeep: Choice | undefined, lang: Lang): Choice[] {
824 const onStage = sc.cast.map(a => a.id)
825 const can = keepable(s, onStage)
826 const rest = sc.choices.filter(x => x.kind !== 'keep')
827 if (!can.length) return rest.length ? rest : sc.choices
828 const pr = can.sort((a, b) => a.due - b.due)[0]!
829 const ok = aiKeep && (!aiKeep.keeps || can.some(x => x.heroine === aiKeep.keeps)) ? { ...aiKeep, keeps: aiKeep.keeps ?? pr.heroine } : null
830 const who = castOf(p, pr.heroine)
831 const keep: Choice = ok ?? {
832 text: t(lang, 'keepChoice', { text: pr.text }), kind: 'keep', tone: 'committed', effects: who ? { [pr.heroine]: 1 } : {}, keeps: pr.heroine,
833 reaction: t(lang, 'keepReaction', { name: who ? packText(p, lang)(who.short) : '' }),
834 }
835 return [...rest, keep]
836}
837
838/** 画信物:一次调用;不合格再试一次 */
839export async function drawKeepsake($: EngineInterface, p: Pack, s: Story, id: string, lang: Lang, model = WRITER): Promise<Gen<Keepsake>> {
840 const u = zeroUsage()
841 const T = packText(p, lang)
842 const c = castOf(p, id)
843 if (!c) return { value: null, usage: u }
844 const recent = s.album.filter(m => m.heroine === id).slice(-4).map(m => m.text).join('; ')
845 const owned = s.keepsakes.filter(k => k.heroine === id).map(k => k.title).join(', ')
846 const prompt = `Design an 8x8 pixel-art icon for a small keepsake between "me" and ${T(c.name)} in the visual novel "${T(p.title)}".
847Her: ${T(c.look)}. ${T(c.secret)}
848Recent moments: ${recent || 'none'}
849Ideas: ${T(c.keepsakeIdeas) || 'a concrete small object tied to the moments above'}${owned ? `\nAlready given (choose something different): ${owned}` : ''}
850Use at most 5 colors. ${langRule(lang)}
851Reply with JSON only: {"title":"object name","desc":"one sentence on why it matters","palette":{"a":"#rrggbb","b":"#rrggbb"},"art":["8 chars per row, '.' = transparent", "... 8 rows"]}`
852 for (let i = 0; i < 2; i++) {
853 const j = json(await ask($, model, [{ text: prompt }], 500, u))
854 const ok = j ? validKeepsake(j.art, j.palette) : null
855 if (j && ok) return { value: { pack: p.id, day: s.day, heroine: id, title: String(j.title ?? '?').slice(0, 24), desc: String(j.desc ?? '').slice(0, 120), art: ok.art, palette: ok.palette }, usage: u }
856 }
857 return { value: null, usage: u }
858}
859
860/** 写结局:一次调用,回收回忆、信物、关键约定 */
861export async function writeEnding($: EngineInterface, p: Pack, s: Story, e: EndingDef, lang: Lang, model = WRITER): Promise<Gen<string>> {
862 const u = zeroUsage()
863 const T = packText(p, lang)
864 const promises = s.promises.map(x => `${x.heroine} "${x.text}" ${x.status}${x.key ? ' (key promise)' : ''}`).join('; ') || 'none'
865 const keeps = s.keepsakes.map(k => `${k.heroine}: ${k.title}`).join(', ') || 'none'
866 const album = s.album.slice(-30).map(m => `${dayText(p, lang, m.day)}: ${m.text}`).join('\n')
867 const who = e.who ? [e.who] : p.cast.filter(c => (s.heroines[c.id]?.stage ?? 0) >= 2).map(c => c.id)
868 const kind = e.kind ? ` [${e.kind} ending]` : ''
869 const text = await ask($, model, [{ text: staticBlock(p, lang), cache: true }, {
870 text: `Now write the ENDING instead of a scene. Plain prose, no JSON.
871Ending: "${T(e.title)}"${kind} — ${T(e.brief)}${e.who ? ` (with ${T(castOf(p, e.who)?.name)})` : ''}${e.who && !meterOf(castOf(p, e.who)).romance ? `\n${noRomance(p, castOf(p, e.who)!, lang)}.` : ''}
872${focusNotes(p, who, lang)}Final state:
873${stateText(p, s, lang, who)}
874Promises: ${promises}
875Keepsakes: ${keeps}
876Memories in order:
877${album}
878${endingCraft(p, s, e.who, lang)}
879Write 10-14 sentences: call back at least three specific memories and the keepsakes, say what became of her secret, include images and a few lines of dialogue. ${T(p.finale)} ${langRule(lang)}`,
880 }], 1600, u)
881 return { value: text ? text.trim() : null, usage: u }
882}
883
884export function addUsage(a: Usage, b: Usage): Usage {
885 return {
886 writer: a.writer + b.writer, checker: a.checker + b.checker, input: a.input + b.input, output: a.output + b.output, cacheRead: a.cacheRead + b.cacheRead, cacheWrite: a.cacheWrite + b.cacheWrite,
887 pilot: (a.pilot ?? 0) + (b.pilot ?? 0), pilotIn: (a.pilotIn ?? 0) + (b.pilotIn ?? 0), pilotOut: (a.pilotOut ?? 0) + (b.pilotOut ?? 0),
888 extra: (a.extra ?? 0) + (b.extra ?? 0), extraIn: (a.extraIn ?? 0) + (b.extraIn ?? 0), extraOut: (a.extraOut ?? 0) + (b.extraOut ?? 0),
889 cost: (a.cost ?? 0) + (b.cost ?? 0),
890 }
891}
892hooks/feats.ts 61 lines1// 成就、统计、每日挑战(纯函数)。
2
3import type { EndingDef, Feats, Pack, PackMeta, Story } from '../types'
4
5export type { Feats }
6
7export const EMPTY_FEATS: Feats = { achievements: [], stats: { clears: 0, scenes: 0, cost: 0 }, today: { date: '', usd: 0 }, daily: {} }
8
9export function okFeats(x: unknown): x is Feats {
10 const f = x as Feats | null
11 return !!f && Array.isArray(f.achievements) && !!f.stats && typeof f.stats.clears === 'number' && !!f.today && !!f.daily
12}
13
14export type AchievementId = 'first-scene' | 'first-true' | 'all-endings' | 'no-undo' | 'all-keepsakes' | 'autoplay-clear' | 'hundred-scenes' | 'daily-true' | 'bad-end' | 'second-run'
15export const ACHIEVEMENTS: AchievementId[] = ['first-scene', 'first-true', 'all-endings', 'no-undo', 'all-keepsakes', 'autoplay-clear', 'hundred-scenes', 'daily-true', 'bad-end', 'second-run']
16
17const isTrue = (e: EndingDef | undefined) => !!e && (e.kind === 'true' || (e.kind === undefined && e.tier >= 4))
18
19/** 通关时新解锁的成就 */
20export function achievementsOnFinish(p: Pack, s: Story, e: EndingDef, pm: PackMeta, f: Feats, autoplay: boolean): AchievementId[] {
21 const got = new Set(f.achievements)
22 const out: AchievementId[] = []
23 const add = (id: AchievementId, ok: boolean) => { if (ok && !got.has(id)) out.push(id) }
24 add('first-true', isTrue(e))
25 add('all-endings', p.endings.every(x => pm.endings.includes(x.id) || x.id === e.id))
26 add('no-undo', s.rewinds === 0 && s.loads === 0)
27 add('all-keepsakes', p.cast.every(c => pm.keepsakes.filter(k => k.heroine === c.id).length >= 2))
28 add('autoplay-clear', autoplay)
29 add('daily-true', !!s.daily && isTrue(e))
30 add('bad-end', e.kind === 'bad')
31 add('second-run', s.loop >= 2)
32 return out
33}
34
35/** 每一幕结束时新解锁的成就 */
36export function achievementsOnScene(f: Feats): AchievementId[] {
37 const got = new Set(f.achievements)
38 const out: AchievementId[] = []
39 if (!got.has('first-scene') && f.stats.scenes >= 1) out.push('first-scene')
40 if (!got.has('hundred-scenes') && f.stats.scenes >= 100) out.push('hundred-scenes')
41 return out
42}
43
44function hash(s: string): number {
45 let h = 2166136261
46 for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0 }
47 return h >>> 0
48}
49
50/** 每日挑战:同一天所有人同一个剧本包、同一个随机种子 */
51export function dailyChallenge(date: string, packs: Pack[]): { pack: Pack; seed: number } {
52 const h = hash(`daily:${date}`)
53 return { pack: packs[h % packs.length]!, seed: h }
54}
55
56/** 可以贴出去的一行战绩 */
57export function dailyShare(date: string, title: string, endingTitle: string, kind: string): string {
58 const star = kind === 'true' ? '★' : kind === 'good' ? '☆' : '·'
59 return `ai-galgame daily ${date} · ${title} · ${endingTitle} ${star}`
60}
61hooks/fx.ts 602 lines1// 画面效果(纯函数,只算像素):像素字体标题卡与过渡、氛围带粒子、主题色、演出指令、像素小镇地图、人物关系网。
2// 由 register.tsx 的动画定时器按 ≤5 帧/秒调用,只 blit 变了的那一块。
3
4import type { Atmo, Pack, PlaceDef, Scene, Story } from '../types'
5import { blank, hex } from './art'
6import type { Px, StageDir } from './art'
7import { castOf, npcOf } from './pack'
8
9// ---------- 颜色 ----------
10export function rgb(n: number): [number, number, number] {
11 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
12}
13export function mix(a: number, b: number, k: number): number {
14 const [r1, g1, b1] = rgb(a)
15 const [r2, g2, b2] = rgb(b)
16 const m = (x: number, y: number) => Math.round(x + (y - x) * Math.max(0, Math.min(1, k)))
17 return (m(r1, r2) << 16) | (m(g1, g2) << 8) | m(b1, b2)
18}
19export function toHex(n: number): string {
20 return `#${n.toString(16).padStart(6, '0')}`
21}
22/** 名字、边框随主题色调轻轻偏一点 */
23export function tint(color: string, theme: string, k = 0.25): string {
24 return toHex(mix(hex(color), hex(theme), k))
25}
26
27// ---------- 主题色:由 AI 每幕给出的天气、时段决定 ----------
28const THEME: Record<string, string> = {
29 dawn: '#f8bbd0', day: '#ffd54f', dusk: '#f2a65a', night: '#8c9eff',
30 rain: '#5b8fd9', storm: '#607d8b', snow: '#cfd8dc', fog: '#a7b6c2', wind: '#9ccc65',
31}
32export const DEFAULT_THEME = '#f2c14e'
33
34export function themeOf(a: Atmo | undefined): string {
35 if (!a) return DEFAULT_THEME
36 if (a.weather === 'rain' && a.time === 'night') return '#4a6fb5'
37 if (a.weather && a.weather !== 'clear' && THEME[a.weather]) return THEME[a.weather]!
38 return (a.time && THEME[a.time]) || DEFAULT_THEME
39}
40
41/** 氛围带粒子:AI 没给 fx 时按天气、时段推一个 */
42export function fxOf(a: Atmo | undefined): NonNullable<Atmo['fx']> {
43 if (!a) return 'none'
44 if (a.fx) return a.fx
45 if (a.weather === 'rain' || a.weather === 'storm') return 'rain'
46 if (a.weather === 'snow') return 'snow'
47 if (a.weather === 'fog') return 'mist'
48 if (a.time === 'night') return 'stars'
49 if (a.time === 'dawn') return 'mist'
50 return 'cicadas'
51}
52
53// ---------- 像素字体(3×5,拉丁字母与数字) ----------
54const G: Record<string, string> = {
55 A: '.x.x.xxxxx.xx.x', B: 'xx.x.xxx.x.xxx.', C: '.xxx..x..x...xx', D: 'xx.x.xx.xx.xxx.', E: 'xxxx..xx.x..xxx',
56 F: 'xxxx..xx.x..x..', G: '.xxx..x.xx.x.xx', H: 'x.xx.xxxxx.xx.x', I: 'xxx.x..x..x.xxx', J: '..x..x..xx.x.x.',
57 K: 'x.xx.xxx.x.xx.x', L: 'x..x..x..x..xxx', M: 'x.xxxxxxxx.xx.x', N: 'xx.x.xx.xx.xx.x', O: '.x.x.xx.xx.x.x.',
58 P: 'xx.x.xxx.x..x..', Q: '.x.x.xx.xxx..xx', R: 'xx.x.xxx.x.xx.x', S: '.xxx...x...xxx.', T: 'xxx.x..x..x..x.',
59 U: 'x.xx.xx.xx.xxxx', V: 'x.xx.xx.xx.x.x.', W: 'x.xx.xxxxxxxx.x', X: 'x.xx.x.x.x.xx.x', Y: 'x.xx.x.x..x..x.',
60 Z: 'xxx..x.x.x..xxx', '0': 'xxxx.xx.xx.xxxx', '1': '.x.xx..x..x.xxx', '2': 'xx...x.x.x..xxx', '3': 'xx...x.x...xxx.',
61 '4': 'x.xx.xxxx..x..x', '5': 'xxxx..xx...xxx.', '6': '.xxx..xxxx.xxxx', '7': 'xxx..x.x..x..x.', '8': 'xxxx.xxxxx.xxxx',
62 '9': 'xxxx.xxxx..xxx.', ' ': '...............', '-': '......xxx......', '.': '.............x.', '!': '.x..x..x.....x.',
63 '?': 'xx...x.x.....x.', '&': '.x.x.x.x.x.x.xx', "'": '.x..x..........', ':': '....x.....x....', ',': '..........x.x..',
64}
65
66/** 只留字体里有的字符(去掉重音、汉字等) */
67export function fontText(s: string): string {
68 return s.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[·•—–]/g, '-').toUpperCase().split('').filter(ch => G[ch] !== undefined).join('').replace(/\s+/g, ' ').trim()
69}
70
71export function textWidth(s: string, scale: number): number {
72 return Math.max(0, s.length * 4 * scale - scale)
73}
74
75/** 把一行字画进 dst(左上角 x, y) */
76export function drawText(dst: Px[][], s: string, x: number, y: number, color: number, scale = 1): void {
77 for (const [i, ch] of [...s].entries()) {
78 const g = G[ch]
79 if (!g) continue
80 for (let k = 0; k < 15; k++) {
81 if (g[k] !== 'x') continue
82 const gx = x + (i * 4 + (k % 3)) * scale
83 const gy = y + Math.floor(k / 3) * scale
84 for (let a = 0; a < scale; a++) for (let b = 0; b < scale; b++) if (dst[gy + a] && gx + b >= 0 && gx + b < dst[gy + a]!.length) dst[gy + a]![gx + b] = color
85 }
86 }
87}
88
89/** 把一句话按词折成最多 n 行,每行不超过 width 像素 */
90function wrap(s: string, width: number, scale: number, n: number): string[] {
91 const out: string[] = []
92 let cur = ''
93 for (const w of s.split(' ')) {
94 const next = cur ? `${cur} ${w}` : w
95 if (textWidth(next, scale) <= width) cur = next
96 else { if (cur) out.push(cur); cur = w }
97 }
98 if (cur) out.push(cur)
99 return out.slice(0, n)
100}
101
102// ---------- 标题卡(开场、每章、结局) ----------
103/** layout: 'inline' 时大字和小字排在同一行(DAY 卡用,矮一截);排不下就退回上下两行 */
104export type CardSpec = { big: string; small?: string; color: string; accent: string; style: 'fade' | 'wipe'; layout?: 'stack' | 'inline' }
105export const CARD_FRAMES = 6
106
107/** 一张卡;step 是从出现起的第几帧(0..CARD_FRAMES,之后静止,只剩星点闪烁) */
108export function cardFrame(spec: CardSpec, w: number, step: number, t: number): Px[][] {
109 const big0 = fontText(spec.big)
110 const scale = textWidth(big0, 2) <= w - 6 ? 2 : 1
111 const small0 = fontText(spec.small ?? '')
112 const inlineW = textWidth(big0, scale) + (small0 ? 6 + textWidth(small0, 1) : 0)
113 const inline = spec.layout === 'inline' && inlineW <= w - 6
114 const bigLines = inline ? [big0] : wrap(big0, w - 6, scale, 2)
115 const small = inline ? [] : wrap(small0, w - 6, 1, 2)
116 const h = inline ? 4 + 5 * scale : 4 + bigLines.length * 6 * scale + small.length * 7
117 const px = blank(w, h)
118 const color = hex(spec.color)
119 const accent = hex(spec.accent)
120 if (inline) {
121 const x0 = Math.floor((w - inlineW) / 2)
122 drawText(px, big0, x0, 2, color, scale)
123 if (small0) drawText(px, small0, x0 + textWidth(big0, scale) + 6, 2 + 5 * scale - 5, accent, 1)
124 }
125 const dim = mix(accent, 0x101018, 0.6)
126 // 细边框与四角星点
127 for (let x = 1; x < w - 1; x++) { px[0]![x] = dim; px[h - 1]![x] = dim }
128 for (let y = 1; y < h - 1; y++) { px[y]![0] = dim; px[y]![w - 1] = dim }
129 for (const [x, y] of [[0, 0], [w - 1, 0], [0, h - 1], [w - 1, h - 1]] as const) px[y]![x] = (t % 6 < 3) ? accent : dim
130 let y = 2
131 if (!inline) for (const line of bigLines) { drawText(px, line, Math.floor((w - textWidth(line, scale)) / 2), y, color, scale); y += 6 * scale }
132 for (const line of small) { drawText(px, line, Math.floor((w - textWidth(line, 1)) / 2), y + 1, accent, 1); y += 7 }
133 // 过渡:淡入(由暗到亮)或擦入(从左往右,前沿一列亮白)
134 const k = Math.min(1, step / CARD_FRAMES)
135 if (k >= 1) return px
136 if (spec.style === 'fade') return px.map(row => row.map(v => (v === null ? null : mix(0x101018, v, k))))
137 const edge = Math.floor(k * w)
138 return px.map(row => row.map((v, x) => (x > edge ? null : x === edge && v !== null ? 0xffffff : v)))
139}
140
141// ---------- 氛围带 ----------
142export const BAND_H = 6
143
144/** 一条细的氛围带:雨、雪、萤火、蝉鸣声波、灯笼光点、流星、星点、薄雾 */
145export function atmoBand(fx: NonNullable<Atmo['fx']>, theme: string, w: number, t: number): Px[][] {
146 const px = blank(w, BAND_H)
147 const th = hex(theme)
148 const put = (x: number, y: number, c: number) => { const xx = ((x % w) + w) % w; if (y >= 0 && y < BAND_H) px[y]![xx] = c }
149 switch (fx) {
150 case 'rain':
151 for (let i = 0; i < 16; i++) {
152 const y = (t * 2 + i * 5) % (BAND_H + 3) - 1
153 const x = (i * 37) % w - Math.floor(y / 2)
154 put(x, y, mix(th, 0xffffff, 0.3)); put(x - 1, y + 1, th)
155 }
156 break
157 case 'snow':
158 for (let i = 0; i < 14; i++) put(i * 11 + Math.round(Math.sin((t + i) / 3) * 2), Math.floor((t + i * 3) / 2) % BAND_H, 0xf4f6f8)
159 break
160 case 'fireflies':
161 for (let i = 0; i < 8; i++) if ((t + i * 5) % 8 < 5) put(i * 9 + Math.round(Math.sin((t + i * 7) / 5) * 3), 1 + Math.round((Math.cos((t + i * 3) / 4) + 1) * 1.8), (t + i) % 3 ? 0xd4e157 : 0xfff59d)
162 break
163 case 'cicadas': {
164 const amp = t % 4 < 2 ? 2 : 1
165 for (let x = 0; x < w; x++) if ((x + t * 3) % 16 < 9) put(x, 2 + Math.round(Math.sin(x / 2.2 + t) * amp), mix(th, 0x101018, 0.35))
166 break
167 }
168 case 'lanterns':
169 for (let i = 0; i < 6; i++) {
170 const x = 5 + i * Math.floor(w / 6)
171 const y = 1 + Math.round(Math.sin((t + i * 2) / 3) + 1)
172 const c = (t + i) % 4 === 0 ? 0xffe082 : i % 2 ? 0xef5350 : 0xff8a65
173 put(x, y, c); put(x + 1, y, c); put(x, y + 1, mix(c, 0x000000, 0.3)); put(x + 1, y + 1, mix(c, 0x000000, 0.3))
174 }
175 break
176 case 'meteors': {
177 for (let i = 0; i < 8; i++) if ((t + i) % 7 < 4) put((i * 23) % w, (i * 3) % BAND_H, 0xc5cae9)
178 const k = t % 14
179 if (k < 9) for (let j = 0; j < 6; j++) put(w - 4 - k * 8 + j * 2, Math.floor(k / 2) + Math.floor(j / 3) - 1, mix(0xffffff, th, j / 6))
180 break
181 }
182 case 'stars':
183 for (let i = 0; i < 12; i++) if ((t + i * 2) % 6 < 4) put((i * 29) % w, (i * 7) % BAND_H, i % 3 ? 0xe8eaf6 : th)
184 break
185 case 'mist':
186 for (let y = 0; y < BAND_H; y++) for (let x = 0; x < w; x++) if ((x + t + y * 7) % 22 < 7 && (x + y) % 2 === 0) put(x, y, mix(0xb0bec5, 0x101018, 0.45 + y * 0.05))
187 break
188 default:
189 break
190 }
191 return px
192}
193
194// ---------- 舞台背景板 ----------
195export type Backdrop = 'room' | 'hall' | 'street' | 'shop' | 'field' | 'water' | 'roof'
196
197// 顺序有讲究:「江边灯会与天台」先算天台,「王座厅」先算大厅,「镇广播站」先算室内,「江边夜市」先算店铺。
198// 英文只按整词匹配(「Broadcast」里有 road),中英两边各管各的地名,结果要一致
199const BACKDROP_RULES: [RegExp, Backdrop][] = [
200 [/天台|\brooftop\b/i, 'roof'],
201 [/门前|铁门|\bdoor\b/i, 'street'],
202 [/食堂|礼堂|livehouse|音乐节|舞台|王座|大厅|\b(canteen|stage|festival|throne|hall|live bar)\b/i, 'hall'],
203 [/广播|教室|图书馆|房间|厨房|走廊|楼道|补习|补课|会议室|浴池|锅炉|特藏|牢房|防空洞|\b(broadcast|radio|booth|room|classroom|library|club|kitchen|hallway|stairwell|cram|bathhouse|boiler|collections|cell|shelter)\b/i, 'room'],
204 [/小卖部|供销社|铺|小亭|店|夜市|琴行|\b(shop|store|stall|kiosk|market)\b/i, 'shop'],
205 [/江|河|海|码头|港|湖|冰场|灯塔|堤|\b(river|riverside|harbou?r|sea|seawall|lake|pier|lighthouse|frozen|ice-lantern)\b/i, 'water'],
206 [/操场|球场|体育场|广场|花园|\b(field|court|stadium|square|gardens?|park)\b/i, 'field'],
207 // 隧道没有专门的背景:一排路灯比带窗户的屋子更像
208 [/街|大道|梯|站|缆车|公交|路|桥|隧道|\b(street|boulevard|steps|station|bus|road|platform|tunnel)\b/i, 'street'],
209]
210
211/** 按地名(中英都可)推背景种类;都不中就当室内 */
212export function backdropOf(place: string): Backdrop {
213 return BACKDROP_RULES.find(([re]) => re.test(place))?.[1] ?? 'room'
214}
215
216const SKY: Record<NonNullable<Atmo['time']>, [number, number]> = {
217 dawn: [0x3b2e5a, 0xf2a7a0], day: [0x5aa7e0, 0xbfe3f2], dusk: [0x2a2350, 0xf2a65a], night: [0x0e1030, 0x2a2f5a],
218}
219const SCREEN = 0x17151f
220const LAMP = 0xffd27a
221const OUTDOOR: Backdrop[] = ['street', 'shop', 'field', 'water', 'roof']
222
223/** 天空上下两色:按时段,阴雨雪雾再压灰一点 */
224function skyOf(a: Atmo | undefined): [number, number] {
225 const [top, bot] = SKY[a?.time ?? 'day']
226 const grey = a?.weather && a.weather !== 'clear' && a.weather !== 'wind' ? 0.35 : 0
227 return [mix(top, 0x6b7280, grey), mix(bot, 0x9aa3ad, grey)]
228}
229
230/** 天空渐变量化成 4 档,控制 Raster 的色对数 */
231function band4(a: number, b: number, k: number): number {
232 return mix(a, b, Math.round(Math.max(0, Math.min(1, k)) * 4) / 4)
233}
234
235/** 背景板最上一行的底色(压暗前):室外是天空顶色,室内、大厅是各自的墙色 */
236function topOf(kind: Backdrop, top: number): number {
237 return kind === 'hall' ? mix(0x4a4458, top, 0.2) : kind === 'room' ? mix(0x6b5a4e, top, 0.25) : top
238}
239
240/** 氛围带的底色:和背景板最上一行同色(同样压暗 20%),拼成一幅 */
241export function backdropTop(kind: Backdrop, a: Atmo | undefined): number {
242 return mix(topOf(kind, skyOf(a)[0]), SCREEN, 0.2)
243}
244
245/**
246 * 舞台背景板:天空或墙、远景、地面。立绘的脚落在倒数第 2 行,地面占底部 4 行。
247 * 只有窗灯、水面反光、星星随 t 动;整体往幕布色压 20%,让立绘更突出。
248 */
249export function backdrop(kind: Backdrop, a: Atmo | undefined, w: number, h: number, t: number): Px[][] {
250 const px: Px[][] = blank(w, h)
251 const [top, bot] = skyOf(a)
252 const night = a?.time === 'night'
253 const g = h - 4 // 地面第一行
254 const put = (x: number, y: number, c: number) => { if (y >= 0 && y < h && x >= 0 && x < w) px[y]![x] = c }
255 const fill = (x0: number, y0: number, x1: number, y1: number, c: number | ((x: number, y: number) => number)) => {
256 for (let y = Math.max(0, y0); y < Math.min(h, y1); y++) for (let x = Math.max(0, x0); x < Math.min(w, x1); x++) put(x, y, typeof c === 'number' ? c : c(x, y))
257 }
258 const sky = (y1: number) => fill(0, 0, w, y1, (_, y) => band4(top, bot, y / Math.max(1, y1 - 1)))
259 const stars = (y1: number) => { if (night) for (let i = 0; i < 14; i++) if ((t + i * 2) % 6 < 4) put((i * 29) % w, (i * 7) % Math.max(1, y1), 0xe8eaf6) }
260 const skyline = (base: number, tall: number, c: number) => {
261 for (let x = 0; x < w; x++) { const hh = tall - ((x >> 3) * 7 % 5); fill(x, base - hh, x + 1, base, c) }
262 for (let x = 0; x < w; x++) if ((x >> 3) % 2 === 0 && x % 3 === 1 && (night || a?.time === 'dusk')) put(x, base - 3, (t + x) % 9 < 7 ? LAMP : mix(LAMP, c, 0.5))
263 }
264 switch (kind) {
265 case 'room': case 'hall': {
266 const wall = topOf(kind, top)
267 fill(0, 0, w, g, wall)
268 fill(0, g - 3, w, g, mix(wall, 0x000000, 0.18)) // 墙裙
269 if (kind === 'room') {
270 // 两扇窗,窗里是天空
271 for (const wx of [6, w - 24]) {
272 fill(wx, 3, wx + 18, 14, (_, y) => band4(top, bot, (y - 3) / 10))
273 if (night) for (let i = 0; i < 3; i++) if ((t + i) % 5 < 3) put(wx + 3 + i * 6, 5 + (i % 2) * 3, 0xe8eaf6)
274 const frame = 0xe8dcc8
275 fill(wx - 1, 2, wx + 19, 3, frame); fill(wx - 1, 14, wx + 19, 15, frame)
276 fill(wx - 1, 2, wx, 15, frame); fill(wx + 18, 2, wx + 19, 15, frame); fill(wx + 9, 2, wx + 10, 15, frame)
277 }
278 fill(0, g, w, h, (x, y) => ((x + y * 3) % 12 === 0 ? 0x6e4a32 : 0x8a5d3e)) // 木地板
279 } else {
280 // 顶上一排吊灯,地面是打磨过的深色地板
281 for (let x = 4; x < w; x += 12) { fill(x, 0, x + 1, 2, 0x2a2633); fill(x - 1, 2, x + 2, 3, (t + x) % 10 < 8 ? LAMP : mix(LAMP, wall, 0.4)) }
282 fill(0, g, w, h, (x, y) => (y === g ? 0x5a5266 : (x + y) % 2 ? 0x3a3445 : 0x342f3e))
283 }
284 break
285 }
286 case 'street':
287 sky(g); stars(g - 8)
288 skyline(g, 11, mix(top, 0x000000, 0.45))
289 fill(0, g, w, h, (x, y) => (y === g ? 0x6b6f7a : y === g + 2 && x % 6 < 3 ? 0x8a8d96 : 0x44464f))
290 break
291 case 'shop': {
292 sky(g); stars(6)
293 // 店面:条纹雨棚、货架、柜台
294 fill(0, 7, w, 10, (x) => ((x >> 2) % 2 ? 0xe8e0d0 : 0xc0504d))
295 fill(0, 10, w, g, 0x5a4636)
296 for (let y = 12; y < g - 3; y += 4) fill(2, y, w - 2, y + 1, 0x8a6a4e)
297 for (let x = 3; x < w - 3; x += 3) put(x, 11 + ((x * 7) % 3) * 4, [0xf79646, 0x9bbb59, 0x4bacc6, 0xffd27a][x % 4]!)
298 fill(0, g - 3, w, g, 0x7a5a42) // 柜台
299 fill(0, g, w, h, (x, y) => (y === g ? 0x6b6f7a : 0x44464f))
300 break
301 }
302 case 'field':
303 sky(g - 3); stars(g - 8)
304 for (let x = 0; x < w; x++) fill(x, g - 6 + Math.round(Math.sin(x / 3) * 1.5), x + 1, g - 3, mix(0x2e4a2a, top, 0.2)) // 远处树影
305 fill(0, g - 3, w, h, (x, y) => ((x >> 2) % 2 ? 0x4f7a3a : 0x46703a))
306 fill(0, g - 1, w, g, 0xd8d8d0) // 白线
307 break
308 case 'water': {
309 const wy = g - 9
310 sky(wy); stars(wy - 2)
311 skyline(wy, 4, mix(top, 0x000000, 0.5)) // 对岸
312 fill(0, wy, w, g, (x, y) => ((x + y * 5 + t) % 13 === 0 ? mix(bot, 0xffffff, 0.3) : band4(0x2b4f7a, 0x3d6a9a, (y - wy) / 9)))
313 fill(0, g, w, h, (x, y) => (y === g ? 0x8a8d96 : (x + y) % 5 ? 0x5a5d66 : 0x4e515a)) // 石堤
314 break
315 }
316 case 'roof':
317 sky(g - 5); stars(g - 8)
318 skyline(g - 5, 5, mix(top, 0x000000, 0.4))
319 fill(0, g - 5, w, g, mix(top, 0x000000, 0.55))
320 for (let x = 0; x < w; x++) { put(x, g - 5, 0x9aa0a6); if (x % 4 === 0) fill(x, g - 5, x + 1, g, 0x9aa0a6) } // 栏杆
321 fill(0, g, w, h, (x, y) => ((x + y * 2) % 7 ? 0x5a5a66 : 0x4e4e5a))
322 break
323 }
324 return px.map(r => r.map(v => (v === null ? SCREEN : v === LAMP ? v : mix(v, SCREEN, 0.2))))
325}
326
327// ---------- 标题页、结局粒子、生成中的小路 ----------
328/** 标题页:卡片下面一条星空,角色站在同一条草地上。figures 是各人的小头像(透明底) */
329export function titleScene(card: Px[][], figures: Px[][][], w: number, t: number): Px[][] {
330 const fh = Math.max(0, ...figures.map(f => f.length))
331 const h = fh + 4
332 const strip: Px[][] = blank(w, h)
333 for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) strip[y]![x] = y >= h - 2 ? (y === h - 2 ? 0x4f7a3a : 0x3f6a2e) : band4(0x0e1030, 0x2a2f5a, y / Math.max(1, h - 3))
334 for (let i = 0; i < 10; i++) if ((t + i * 2) % 6 < 4) strip[(i * 5) % Math.max(1, h - 4)]![(i * 31 + 7) % w] = 0xe8eaf6
335 const slot = Math.floor(w / Math.max(1, figures.length))
336 figures.forEach((f, i) => {
337 const fw = f[0]?.length ?? 0
338 const x0 = i * slot + Math.floor((slot - fw) / 2)
339 const y0 = h - 1 - f.length // 脚踩在草地第一行
340 f.forEach((row, y) => row.forEach((v, x) => { if (v !== null && strip[y0 + y] && x0 + x >= 0 && x0 + x < w) strip[y0 + y]![x0 + x] = v }))
341 })
342 return [...card.map(r => r.slice(0, w).concat(Array(Math.max(0, w - r.length)).fill(null))), ...strip]
343}
344
345/** 结局卡的粒子:true 流星、good 花瓣、normal 萤火、bad 雨;压暗一半,不抢字 */
346export function endFx(kind: string | undefined, w: number, h: number, t: number): Px[][] {
347 const px: Px[][] = blank(w, h)
348 if (kind === 'good') {
349 for (let i = 0; i < 12; i++) {
350 const y = Math.floor((t + i * 5) / 2) % h
351 const x = (i * 13 + Math.round(Math.sin((t + i * 3) / 3) * 2) + w) % w
352 px[y]![x] = mix(i % 2 ? 0xf8bbd0 : 0xf48fb1, SCREEN, 0.4)
353 }
354 return px
355 }
356 const fx = kind === 'true' ? 'meteors' : kind === 'bad' ? 'rain' : 'fireflies'
357 const theme = kind === 'bad' ? '#5b8fd9' : '#c5cae9'
358 for (let y0 = 0; y0 < h; y0 += BAND_H) {
359 const b = atmoBand(fx, theme, w, t + y0)
360 b.forEach((row, y) => row.forEach((v, x) => { if (v !== null && px[y0 + y]) px[y0 + y]![x] = mix(v, SCREEN, 0.5) }))
361 }
362 return px
363}
364
365export const WALK_H = 16
366/** 生成中:一条夜路,小动物从左往右走过去(两帧步伐),路灯一盏盏亮 */
367export function walkFrame(figure: Px[][], w: number, t: number): Px[][] {
368 const h = WALK_H
369 const px: Px[][] = blank(w, h)
370 for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) px[y]![x] = y >= h - 2 ? (y === h - 2 ? 0x6b6f7a : 0x44464f) : band4(0x0e1030, 0x2a2f5a, y / (h - 3))
371 for (let x = 6; x < w; x += 18) {
372 for (let y = 3; y < h - 2; y++) px[y]![x] = 0x5a5d66
373 const lamp = (t + x) % 12 < 9 ? LAMP : mix(LAMP, SCREEN, 0.5)
374 px[2]![x] = lamp
375 if (x + 1 < w) px[2]![x + 1] = lamp
376 }
377 const fw = figure[0]?.length ?? 0
378 const x0 = ((t * 2) % (w + fw)) - fw
379 const bob = t % 2
380 figure.forEach((row, y) => row.forEach((v, x) => { const yy = h - 2 - figure.length + y - bob; if (v !== null && px[yy] && x0 + x >= 0 && x0 + x < w) px[yy]![x0 + x] = v }))
381 return px
382}
383
384/** 把 top 叠在 base 上:top 的透明处露出 base */
385export function over(base: Px[][], top: Px[][]): Px[][] {
386 return base.map((row, y) => row.map((v, x) => top[y]?.[x] ?? v))
387}
388
389// ---------- 演出指令 ----------
390/** 演出到第 reveal 句(0 = 旁白),since 是这一句出现后过了几帧:算出每人的走位、转身、气泡 */
391export function stageDirections(sc: Scene, reveal: number, since: number): StageDir {
392 const dir: StageDir = {}
393 const cues = (sc.cues ?? []).slice().sort((a, b) => a.at - b.at)
394 for (const a of sc.cast) dir[a.id] = { offset: 0, turned: false, hidden: cues.some(c => c.who === a.id && c.move === 'enter' && c.at > 0), emote: '' }
395 const steps: Record<string, number> = {}
396 for (const c of cues) {
397 const d = dir[c.who]
398 if (!d || c.at > reveal) continue
399 const now = c.at === reveal
400 if (c.emote && now) d.emote = c.emote
401 switch (c.move) {
402 case 'enter': d.hidden = false; if (now && since < 4) d.offset = -(24 - since * 6); break
403 case 'exit': if (now && since < 5) d.offset = -since * 6; else d.hidden = true; break
404 case 'approach': {
405 const n = (steps[c.who] = (steps[c.who] ?? 0) + 1)
406 d.offset = Math.max(d.offset, (n - 1) * 3 + (now ? Math.min(3, since) : 3))
407 break
408 }
409 case 'turn': d.turned = true; break
410 case 'face': d.turned = false; break
411 default: break
412 }
413 }
414 for (const d of Object.values(dir)) d.offset = Math.min(d.offset, 7)
415 return dir
416}
417
418// ---------- 像素小镇地图 ----------
419const ROOFS = [0xc0504d, 0x4f81bd, 0x9bbb59, 0x8064a2, 0xf79646, 0x4bacc6, 0xb08968, 0x7f7f9f]
420const MAP_COLS = 5
421const CELL_W = 15
422const CELL_H = 11
423
424function hashId(s: string): number {
425 let h = 7
426 for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0
427 return h
428}
429
430export function mapSize(n: number, w: number): { w: number; h: number } {
431 return { w, h: Math.ceil(n / MAP_COLS) * CELL_H + 1 }
432}
433
434/** 地图一排房子的像素高度:按排切开画,排与排之间插一行文字标签 */
435export const MAP_ROW_H = CELL_H
436
437/** 第 i 个地点的房子在第几排、左边从第几列开始(一列像素 = 终端一格) */
438export function mapSlot(i: number, w: number): { row: number; x: number } {
439 const ox = Math.max(0, Math.floor((w - MAP_COLS * CELL_W) / 2))
440 return { row: Math.floor(i / MAP_COLS), x: ox + (i % MAP_COLS) * CELL_W + 2 }
441}
442
443/** 终端里的显示宽度:汉字、全角符号占两格 */
444export function cellWidth(s: string): number {
445 let n = 0
446 for (const ch of s) n += /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch) ? 2 : 1
447 return n
448}
449
450/**
451 * 一排房子下面的标签行:每栋开放的房子正下方写「数字 地名」,地名按显示宽度截到这一格放得下为止。
452 * 返回按列排好的片段(gap 是前面要空的格数),由界面层上色。
453 */
454export function mapLabelRow(names: { i: number; label: string; name: string }[], row: number, w: number): { gap: number; label: string; name: string }[] {
455 const out: { gap: number; label: string; name: string }[] = []
456 let col = 0
457 for (const n of names.filter(x => mapSlot(x.i, w).row === row).sort((a, b) => a.i - b.i)) {
458 const x = mapSlot(n.i, w).x
459 if (x < col) continue
460 const room = CELL_W - 1 - cellWidth(n.label) - 1
461 let name = ''
462 for (const ch of n.name) { if (cellWidth(name + ch) > room) break; name += ch }
463 out.push({ gap: x - col, label: n.label, name })
464 col = x + cellWidth(n.label) + (name ? 1 + cellWidth(name) : 0)
465 }
466 return out
467}
468
469/**
470 * 小镇地图:每个地点一栋小房子。开放的亮着(窗户闪),关着的暗;
471 * labels 是日程里这个地点的按键数字,maybe 是可能遇见的人的颜色小点。
472 */
473export function townMap(places: PlaceDef[], open: Set<string>, labels: Record<string, string>, maybe: Record<string, string[]>, w: number, t: number): Px[][] {
474 const { h } = mapSize(places.length, w)
475 const px = blank(w, h)
476 const ox = Math.max(0, Math.floor((w - MAP_COLS * CELL_W) / 2))
477 places.forEach((pl, i) => {
478 const cx = ox + (i % MAP_COLS) * CELL_W + 2
479 const cy = Math.floor(i / MAP_COLS) * CELL_H
480 const lit = open.has(pl.id)
481 // 关着的:整栋压暗、不亮灯
482 const c = (v: number) => (lit ? v : mix(v, 0x2a2833, 0.7))
483 const put = (x: number, y: number, v: number) => { if (px[cy + y] && cx + x >= 0 && cx + x < w) px[cy + y]![cx + x] = c(v) }
484 const rect = (x0: number, y0: number, x1: number, y1: number, v: number | ((x: number, y: number) => number)) => {
485 for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) put(x, y, typeof v === 'number' ? v : v(x, y))
486 }
487 const roof = ROOFS[hashId(pl.id) % ROOFS.length]!
488 const wall = 0xe8e0d0
489 const win = lit && (t + i) % 6 < 4 ? 0xffe082 : 0xffca28
490 const name = typeof pl.name === 'string' ? pl.name : `${pl.name.zh ?? ''} ${pl.name.en ?? ''}`
491 // 每类地点一种外形(11×8)
492 switch (backdropOf(name)) {
493 case 'hall': // 尖顶大屋、宽门
494 for (let r = 0; r < 3; r++) rect(3 - r, r, 7 + r, r, roof)
495 rect(0, 3, 10, 7, wall); rect(4, 5, 6, 7, 0x6e4a32)
496 break
497 case 'shop': // 条纹雨棚
498 rect(0, 2, 10, 3, x => ((x >> 1) % 2 ? 0xe8e0d0 : 0xc0504d))
499 rect(0, 4, 10, 7, 0xd8c8a8); put(1, 5, win); put(9, 5, win)
500 break
501 case 'street': // 路灯 + 小屋
502 rect(0, 1, 0, 7, 0x8a8d96); put(0, 0, lit ? LAMP : 0x8a8d96)
503 rect(2, 3, 10, 3, roof); rect(2, 4, 10, 7, wall); put(3, 5, win); put(9, 5, win)
504 break
505 case 'field': // 树 + 栅栏
506 rect(0, 1, 3, 4, 0x4f7a3a); rect(1, 5, 2, 7, 0x6e4a32)
507 rect(4, 6, 10, 6, 0xd8d0c0); for (let x = 4; x <= 10; x += 2) rect(x, 5, x, 7, 0xd8d0c0)
508 break
509 case 'water': // 灯塔 + 波浪
510 rect(7, 1, 8, 5, 0xe8e0d0); rect(7, 3, 8, 3, 0xc0504d); put(7, 0, lit ? LAMP : 0x8a8d96); put(8, 0, lit ? LAMP : 0x8a8d96)
511 rect(0, 6, 10, 7, (x, y) => ((x + y + t) % 4 === 0 ? 0x8fc1e8 : 0x3d6a9a))
512 break
513 case 'roof': // 高楼,顶上一排栏杆
514 rect(2, 1, 8, 7, 0x7a7f8c); for (let x = 2; x <= 8; x += 2) put(x, 0, 0x9aa0a6)
515 put(3, 3, win); put(7, 3, win); put(3, 5, win); put(7, 5, win)
516 break
517 default: // room:平顶校舍,顶上小钟楼
518 rect(4, 0, 6, 1, roof); put(5, 0, lit ? LAMP : roof)
519 rect(0, 2, 10, 2, roof); rect(0, 3, 10, 7, wall); put(1, 4, win); put(9, 4, win)
520 break
521 }
522 // 按键数字:左下角一块琥珀色小牌子(5×5),房子的外形从上面和右边露出来
523 const label = labels[pl.id]
524 if (label) { rect(0, 3, 4, 7, 0xf2c14e); drawText(px, label, cx + 1, cy + 3, 0x17151f) }
525 // 可能遇见的人:一排彩色小点
526 ;(maybe[pl.id] ?? []).slice(0, 5).forEach((col, j) => { if (cx + j * 2 < w) px[cy + 9]![cx + j * 2] = hex(col) })
527 // 路:一条实线
528 for (let x = 0; x < CELL_W; x++) if (cx - 2 + x < w) px[cy + 10]![cx - 2 + x] = 0x3d3d4a
529 })
530 return px
531}
532
533// ---------- 人物关系网 ----------
534export type GraphNode = { id: string; x: number; y: number; color: number; size: number }
535
536function line(px: Px[][], x0: number, y0: number, x1: number, y1: number, color: number, every = 1, phase = 0): void {
537 const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0)
538 const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1
539 let err = dx + dy, x = x0, y = y0, k = 0
540 for (;;) {
541 if ((k + phase) % every === 0 && px[y] && x >= 0 && x < px[y]!.length) px[y]![x] = color
542 if (x === x1 && y === y1) break
543 const e2 = 2 * err
544 if (e2 >= dy) { err += dy; x += sx }
545 if (e2 <= dx) { err += dx; y += sy }
546 k++
547 }
548}
549
550const RIVAL = /情敌|对手|rival|敌|过节|grudge|feud|误会|misunderstand/i
551
552/**
553 * 关系网:中间是「我」,主角色在内圈,配角在外圈。我和她之间的线越实、越亮,关系越近;
554 * 这一幕好感变过的那条线会闪(涨了闪白,降了闪红)。人物之间的关系是虚线,有过节的是红色;没到挑明阶段的关系不画。
555 */
556export function relationGraph(p: Pack, s: Story, deltas: Record<string, number>, w: number, h: number, t: number): { px: Px[][]; nodes: GraphNode[] } {
557 const px = blank(w, h)
558 const cx = Math.floor(w / 2), cy = Math.floor(h / 2)
559 const nodes: GraphNode[] = [{ id: 'me', x: cx, y: cy, color: 0xf4f4f4, size: 3 }]
560 const heroines = p.cast
561 heroines.forEach((c, i) => {
562 const a = -Math.PI / 2 + (i / heroines.length) * Math.PI * 2
563 nodes.push({ id: c.id, x: Math.round(cx + Math.cos(a) * (w * 0.3)), y: Math.round(cy + Math.sin(a) * (h * 0.36)), color: hex(c.color), size: 5 })
564 })
565 const npcs = p.npcs ?? []
566 npcs.forEach((n, i) => {
567 const a = -Math.PI / 2 + ((i + 0.5) / Math.max(1, npcs.length)) * Math.PI * 2
568 nodes.push({ id: n.id, x: Math.round(cx + Math.cos(a) * (w * 0.46)), y: Math.round(cy + Math.sin(a) * (h * 0.46)), color: mix(hex(n.color), 0x101018, 0.35), size: 2 })
569 })
570 const at = (id: string) => nodes.find(n => n.id === id)
571 const stageOf = (id: string) => s.heroines[id]?.stage ?? 0
572 // 人物之间
573 for (const r of p.relations ?? []) {
574 if (r.a === 'me' || r.b === 'me') continue
575 const hidden = r.hiddenUntil !== undefined && ![r.a, r.b].some(x => castOf(p, x) && stageOf(x) >= r.hiddenUntil!)
576 const A = at(r.a), B = at(r.b)
577 if (hidden || !A || !B) continue
578 const kind = typeof r.kind === 'string' ? r.kind : `${r.kind.zh ?? ''} ${r.kind.en ?? ''}`
579 line(px, A.x, A.y, B.x, B.y, RIVAL.test(kind) ? 0xb53d3d : 0x4a4a5c, 2, t % 2)
580 }
581 // 我和她
582 for (const c of heroines) {
583 const N = at(c.id)!
584 const st = stageOf(c.id)
585 const d = deltas[c.id] ?? 0
586 let color = mix(0x101018, hex(c.color), 0.35 + st * 0.16)
587 if (d && t % 2 === 0) color = d > 0 ? 0xffffff : 0xff5252
588 line(px, cx, cy, N.x, N.y, color, st >= 2 ? 1 : 3 - st, 0)
589 }
590 // 主角色是 5×5 的小脸(圆角、两只眼睛),配角和「我」是小方块
591 const FACE = ['.xxx.', 'xxxxx', 'xexex', 'xxxxx', '.xxx.']
592 for (const n of nodes) {
593 for (let a = 0; a < n.size; a++) for (let b = 0; b < n.size; b++) {
594 const y = n.y - Math.floor(n.size / 2) + a, x = n.x - Math.floor(n.size / 2) + b
595 const f = n.size === 5 ? FACE[a]![b] : 'x'
596 if (f !== '.' && px[y] && x >= 0 && x < w) px[y]![x] = f === 'e' ? 0x17151f : n.color
597 }
598 if (npcOf(p, n.id) === undefined && n.id !== 'me' && (deltas[n.id] ?? 0) !== 0 && t % 2 === 0 && px[n.y - 3]) px[n.y - 3]![n.x] = 0xffffff
599 }
600 return { px, nodes }
601}
602hooks/i18n.ts 561 lines1// 界面文字语言表。剧本包内容的多语言在 pack.ts 的 tr();这里只放引擎自己的界面文字。
2
3import type { Lang } from '../types'
4
5const ZH = {
6 engine: 'AI 视觉小说',
7 newGame: '新的故事',
8 newGamePlus: '再来一次(第 {n} 周目)',
9 continue: '继续',
10 loadGame: '读档',
11 packs: '剧本',
12 settings: '设置',
13 help: '操作说明',
14 album: '相册',
15 saves: '存档',
16 back: '返回',
17 undo: '回退',
18 next: '下一句',
19 all: '全部',
20 cont: '继续',
21 toTitle: '回到标题',
22 where: '{slot},去哪里?',
23 mustGo: '今天有件不能错过的事',
24 maybe: '可能遇见',
25 nobody: '没人',
26 seeksYou: '{name}好像有话想对你说',
27 cameToYou: '她来找你',
28 promised: '答应过{name}:{text}({due}前)',
29 today: '今天:{text}',
30 loopNotice: '第 {n} 轮',
31 runN: '第 {n} 周目',
32 kind_reply: '回话',
33 kind_observe: '观察',
34 kind_promise: '答应',
35 kind_keep: '兑现',
36 keepChoice: '兑现约定:{text}',
37 keepReaction: '{name}愣了一下,然后笑了:「你还记得。」',
38 stageUp: '和{name}的关系进入「{stage}」',
39 promiseBroken: '没能兑现对{name}的约定:{text}',
40 keepsakeGot: '获得信物:{title}',
41 sceneFail: '这一幕没写出来,换个地方或再试一次。',
42 endingFail: '(结局没能写出来。)',
43 endingHead: '— 终幕 —',
44 endingUnlocked: '解锁结局:{title}',
45 tabMemories: '回忆',
46 tabKeepsakes: '信物图鉴',
47 tabEndings: '结局',
48 tabPromises: '约定',
49 noMemories: '还没有回忆。',
50 noKeepsakes: '还没有信物。关系进入「秘密」「抉择」阶段时会收到。',
51 locked: '???',
52 clears: '通关 {n} 次',
53 endingsSeen: '已见结局 {a}/{b}',
54 slot: '档位 {n}',
55 auto: '自动存档',
56 empty: '空',
57 broken: '损坏',
58 saveTo: '存到这里',
59 loadFrom: '读取',
60 saved: '已存到档位 {n}',
61 modeSave: '存档',
62 modeLoad: '读档',
63 lang: '语言',
64 anim: '立绘动画',
65 on: '开',
66 off: '关',
67 pageMode: '翻页方式',
68 page_line: '逐句',
69 page_page: '整段',
70 usage: '本局 AI 调用',
71 usageLine: '写作 {w} 次 · 检查 {c} 次 · 输入 {i} · 缓存读 {r} · 输出 {o} tokens',
72 packOrigin_builtin: '内置',
73 packOrigin_folder: '目录',
74 packOrigin_imported: '导入',
75 original: '原创',
76 homage: '创作说明',
77 sourceBy: '原作:{work} · {author} · 许可:{license}',
78 chosen: '当前',
79 choose: '选用',
80 importHint: '导入剧本包:/gal import <路径.json>(只校验:/gal validate);或把 .json 放进 {dir}。只导入你有权使用的内容。',
81 imported: '已导入剧本包「{title}」',
82 importBad: '剧本包没通过校验:\n{errors}',
83 importReadFail: '读不到文件或不是合法 JSON:{path}',
84 validateOk: '校验通过:《{title}》,{days} 天,{cast} 个角色,{endings} 个结局。',
85 opened: '面板已打开:数字键选择,J 继续,U 回退,P 相册,S 存档,H 说明。',
86 helpLines: [
87 '这是一部由 AI 实时书写、你用选项推进的视觉小说。人写好人物与骨架,AI 写每一幕。',
88 '每天分几个时段,选一个地方去——去哪里决定你会遇见谁。冷落太久的人会主动来找你。',
89 '「答应」类选项会记成约定,到期前要再去见她,否则她会失望。',
90 '你的选择和观察会记进伏笔簿,之后的剧情会回收。关系逐级推进:陌生→熟悉→心事→秘密→抉择。',
91 '故事分几幕,每一幕有必须发生的关键事件;配角会出场推动剧情,人物之间有旧怨、误会和牵连。偶尔会有随机的事:下雨、考试成绩、在路上碰见谁。',
92 '关系到后期会收到 AI 画的信物;每个人有好、普通、坏几种结局,真结局要兑现关键约定、集齐信物或二周目。',
93 '按键:1–9 选择 · J 下一句/继续 · A 显示全部 · U 回退 · P 相册 · S 存档 · H 说明 · B 返回 · O 自动游玩 · E 设置',
94 '自动游玩:按 O 让 AI 替你玩(选地点、选选项),理由用暗色显示;按别的键就暂停。默认连跑 6 幕或到结局就停,设置里可改策略、节奏和上限。每个决策约一次 Haiku 调用;「混乱」策略不调用模型。',
95 '每幕约 1 次写作调用 + 1 次检查调用;设定集走提示缓存。',
96 ],
97 calLegend: '◉ 今天 ◆ 事件 ▪ 已过',
98 actStart: '—— {title} ——',
99 happening: '此刻:{text}',
100 saveBad: '无法识别的存档(版本不认识,无法读取)',
101 loadBad: '这个存档的格式认不出来,没有读取。',
102 end_true: '真',
103 end_good: '好',
104 end_normal: '普通',
105 end_bad: '坏',
106 npcs: '配角 {n}',
107 castN: '主角色 {n}',
108 autoStart: '自动',
109 autoStop: '停止自动',
110 autoBadge: '自动中',
111 autoStatus: 'ai-galgame:自动中(按 O 停止)',
112 autoReason: '→ 选 {n}:{why}',
113 autoReasonBare: '→ 选 {n}',
114 autoStopped: '自动模式已关闭。',
115 autoKeyPause: '自动模式已暂停(你按了键)。按 O 继续。',
116 autoLimitHit: '自动模式暂停:已连跑 {n} 幕。按 O 继续。',
117 autoDayEnd: '自动模式暂停:这一天结束了。按 O 继续。',
118 autoEnded: '自动模式停下:到结局了。',
119 autoError: '自动模式停下:{err}',
120 autoSection: '自动游玩(O 开关)',
121 autoStrategy: '策略',
122 strategy_focus: '专一',
123 strategy_whim: '随心',
124 strategy_collect: '收集',
125 strategy_chaos: '混乱',
126 strategyHint_focus: '锁定一个人追到底',
127 strategyHint_whim: 'AI 按自己对剧情的兴趣选',
128 strategyHint_collect: '优先没见过的人、地点、结局',
129 strategyHint_chaos: '随机选,不调用模型',
130 autoTarget: '专一对象',
131 autoTargetAsk: '开局时问 AI',
132 autoSpeed: '节奏',
133 speed_slow: '慢(每句 3 秒)',
134 speed_mid: '中(每句 1.5 秒)',
135 speed_fast: '快(每句 0.6 秒)',
136 autoLimit: '连跑上限',
137 autoLimitN: '{n} 幕后暂停',
138 autoLimitDay: '跑到当天结束',
139 usagePilot: '自动决策 {n} 次 · 输入 {i} · 输出 {o} tokens(Haiku)',
140 tabDiary: '她的日记',
141 tabPortrait: '你在她眼里',
142 diaryLocked: '关系到「心事」之后才能读她的日记。',
143 diaryRead: '读她这一天的日记(约 1 次 Haiku)',
144 diaryPick: '选一个人:',
145 portraitGen: '听听{name}怎么说你(约 1 次 Haiku)',
146 portraitBars: '你的选择倾向',
147 portraitFew: '再多做几个选择,才看得出你是什么样的人。',
148 tone_honest: '坦诚',
149 tone_evasive: '逃避',
150 tone_joking: '玩笑',
151 tone_committed: '承诺',
152 tone_caring: '体贴',
153 tone_bold: '冲动',
154 graph: '关系图',
155 graphHint: '中间是你。线越实越亮,关系越近;刚变过的线会闪(白=升,红=降);红虚线是过节,灰虚线是牵连。',
156 letterFrom: '✉ {name}发来一条消息',
157 letterDismiss: '收起',
158 gossipNight: '昨夜:{text}',
159 gossipTab: '小镇夜话',
160 letItBe: '顺其自然',
161 letItBeHint: '交给 AI,按你一贯的样子替你选',
162 closeUp: '特写:{text}',
163 watchBusy: 'Claude 正在干活——趁现在玩一幕?',
164 watchDone: 'Claude 写完了。游戏先停在这里。',
165 watchPermission: 'Claude 在等你批准。游戏先停在这里。',
166 costLine: '本局 {run} · 今日 {today}',
167 budgetHit: '今日预算已用完({used} / {budget})。在设置里调高,或者明天再玩。',
168 secDisplay: '显示',
169 secAuto: '自动',
170 secAI: 'AI 功能',
171 secCost: '费用',
172 secWork: '工作陪伴',
173 mapSet: '像素地图',
174 layout: '排版',
175 layout_auto: '按宽度自动',
176 layout_compact: '紧凑',
177 layout_full: '完整',
178 palette: '配色',
179 palette_default: '默认',
180 palette_cb: '色盲友好',
181 bgSet: '终端底色',
182 bg_auto: '自动',
183 bg_dark: '深色',
184 bg_light: '浅色',
185 bgDetected: '(主题看起来是{bg})',
186 voice: '朗读台词',
187 sfx: '音效',
188 short: '短幕(等编译时玩)',
189 writer: '写剧情的模型',
190 writer_sonnet: 'Sonnet(默认)',
191 writer_haiku: 'Haiku(省额度)',
192 budget: '每日预算',
193 budgetNone: '不限',
194 extra_echoes: '跨周目回声(不额外调用)',
195 extra_letters: '离开很久后她的消息(Haiku,每次回来最多 1 次)',
196 extra_gossip: '夜里的小镇八卦(Haiku,每天 1 次)',
197 extra_diary: '她的日记(Haiku,打开时生成)',
198 extra_portrait: '你在她眼里(Haiku,打开时生成)',
199 extra_letItBe: '「顺其自然」选项(Haiku,按一次 1 次)',
200 watch: '边等边玩提醒',
201 workEvents: '写代码的事件进入剧情(只看测试成败、commit)',
202 companion: '陪伴模式',
203 companionOff: '关',
204 usageExtra: 'AI 功能 {n} 次 · 输入 {i} · 输出 {o} tokens(Haiku)',
205 usageCost: '估算花费:本局 {run} · 今日 {today}',
206 feats: '成就',
207 statsLine: '通关 {c} 次 · 共 {s} 幕 · 累计约 {cost}',
208 dailyTitle: '每日挑战',
209 dailyStart: '开始今天的挑战:《{title}》',
210 dailyDone: '今天的结果:{ending}',
211 dailyShare: '可以贴出去的战绩:',
212 dailyHint: '同一天所有人玩同一个剧本、同一个随机种子,看谁拿到真结局。',
213 achGot: '获得成就:{name}',
214 'ach_first-scene': '第一幕',
215 'ach_first-true': '第一个真结局',
216 'ach_all-endings': '全结局',
217 'ach_no-undo': '一次也不回头',
218 'ach_all-keepsakes': '信物收集家',
219 'ach_autoplay-clear': '交给 AI 也能走到最后',
220 'ach_hundred-scenes': '一百幕',
221 'ach_daily-true': '今日真结局',
222 'ach_bad-end': '也看过坏的那一面',
223 'ach_second-run': '又见面了',
224 'achd_first-scene': '玩完第一幕',
225 'achd_first-true': '达成任意一个真结局',
226 'achd_all-endings': '解锁一个剧本包的全部结局',
227 'achd_no-undo': '不回退、不读档通关一次',
228 'achd_all-keepsakes': '在一个剧本包里集齐每个人的两件信物',
229 'achd_autoplay-clear': '自动游玩时到达结局',
230 'achd_hundred-scenes': '累计玩一百幕',
231 'achd_daily-true': '在每日挑战里拿到真结局',
232 'achd_bad-end': '达成一个坏结局',
233 'achd_second-run': '开始二周目并通关',
234 genOutline: '正在构思大纲…(1/3)',
235 genExpand: '正在写人物、地点和结局…(2/3),通常要两三分钟',
236 genRepair: '校验没通过,正在修第 {n} 处…(3/3)',
237 genRunning: '正在生成剧本包:{step}(K 取消)',
238 genCancelled: '已取消生成。',
239 genDone: '已生成《{title}》,在剧本列表里。',
240 genFailed: '生成的剧本包没通过校验:{errors}。草稿已存到 {path},可以手改后用 /gal import 导入。',
241 genBusy: '已经有一个剧本包在生成了。',
242 genHint: '一句话生成:/gal new <你的想法>(可加 --days 7 --cast 3),约 3–5 分钟、2–4 次 Sonnet 调用。',
243 genStart: '开始生成:{idea}。完成后会出现在剧本列表里(K 取消)。',
244 repoConfirm: '从这个项目生成剧本包,将读取:{files}(共 {n} 个文件,{kb} KB)。不读源码和隐藏文件。',
245 repoYes: '确认生成',
246 repoNo: '取消',
247 repoKey: '从当前项目生成',
248 repoAsk: '在面板里按 Y 确认,或者 /gal repo --yes',
249 exportHtml: '导出 HTML',
250 exportMd: '导出 Markdown',
251 shareText: '分享文字',
252 exported: '已导出:{path}',
253 exportFail: '导出失败:{err}',
254 community: '社区',
255 communityLoading: '正在读取社区索引…',
256 communityFail: '无法访问社区索引({err})。可以改用 /gal import 导入本地文件。',
257 communityEmpty: '社区索引里还没有可装的剧本。',
258 downloading: '正在下载《{title}》…',
259 installed: '已安装《{title}》',
260 installFail: '没能安装:{err}',
261 update: '更新',
262 install: '安装',
263 packOrigin_community: '社区',
264 genKey: '一句话生成',
265 routeLocked: '(这段日子的重心,落在了{name}身上。)',
266 routeEnsemble: '(这段日子,你没有只属于谁。)',
267 endingCardKey: '结局卡',
268 endingCardHint: '结局卡(不剧透,可以直接贴出去比较;/gal card 也能拿到):',
269 graphKey: '关系图',
270 featsMenu: '成就与挑战',
271 dailyMenu: '每日挑战',
272}
273
274export type UiKey = Exclude<keyof typeof ZH, 'helpLines'>
275
276const EN: typeof ZH = {
277 engine: 'AI Visual Novel',
278 newGame: 'New Story',
279 newGamePlus: 'Play Again (Run {n})',
280 continue: 'Continue',
281 loadGame: 'Load',
282 packs: 'Stories',
283 settings: 'Settings',
284 help: 'How to Play',
285 album: 'Album',
286 saves: 'Saves',
287 back: 'Back',
288 undo: 'Undo',
289 next: 'Next',
290 all: 'All',
291 cont: 'Continue',
292 toTitle: 'Title',
293 where: '{slot} — where to?',
294 mustGo: 'Something you cannot miss today',
295 maybe: 'maybe',
296 nobody: 'nobody',
297 seeksYou: '{name} seems to want a word with you',
298 cameToYou: 'she came to you',
299 promised: 'Promised {name}: {text} (by {due})',
300 today: 'Today: {text}',
301 loopNotice: 'Loop {n}',
302 runN: 'Run {n}',
303 kind_reply: 'Say',
304 kind_observe: 'Look',
305 kind_promise: 'Promise',
306 kind_keep: 'Keep',
307 keepChoice: 'Keep my promise: {text}',
308 keepReaction: '{name} pauses, then smiles: "You remembered."',
309 stageUp: 'You and {name} are now at "{stage}"',
310 promiseBroken: 'You failed your promise to {name}: {text}',
311 keepsakeGot: 'Keepsake: {title}',
312 sceneFail: 'This scene could not be written. Try another place or again.',
313 endingFail: '(The ending could not be written.)',
314 endingHead: '— Finale —',
315 endingUnlocked: 'Ending unlocked: {title}',
316 tabMemories: 'Memories',
317 tabKeepsakes: 'Keepsakes',
318 tabEndings: 'Endings',
319 tabPromises: 'Promises',
320 noMemories: 'No memories yet.',
321 noKeepsakes: 'No keepsakes yet. They come at the "Secret" and "Choice" stages.',
322 locked: '???',
323 clears: 'Cleared {n}×',
324 endingsSeen: 'Endings {a}/{b}',
325 slot: 'Slot {n}',
326 auto: 'Autosave',
327 empty: 'empty',
328 broken: 'corrupt',
329 saveTo: 'Save here',
330 loadFrom: 'Load',
331 saved: 'Saved to slot {n}',
332 modeSave: 'Save',
333 modeLoad: 'Load',
334 lang: 'Language',
335 anim: 'Sprite animation',
336 on: 'on',
337 off: 'off',
338 pageMode: 'Text advance',
339 page_line: 'line by line',
340 page_page: 'whole scene',
341 usage: 'AI calls this run',
342 usageLine: 'writer {w} · checker {c} · input {i} · cache read {r} · output {o} tokens',
343 packOrigin_builtin: 'built-in',
344 packOrigin_folder: 'folder',
345 packOrigin_imported: 'imported',
346 original: 'original',
347 homage: 'Author\'s note',
348 sourceBy: 'Based on {work} by {author} · License: {license}',
349 chosen: 'current',
350 choose: 'Use',
351 importHint: 'Import a story pack: /gal import <path.json> (check only: /gal validate), or drop .json files into {dir}. Only import content you have the rights to use.',
352 imported: 'Imported story pack "{title}"',
353 importBad: 'Story pack failed validation:\n{errors}',
354 importReadFail: 'Cannot read file or not valid JSON: {path}',
355 validateOk: 'Valid: "{title}", {days} days, {cast} characters, {endings} endings.',
356 opened: 'Pane opened: digits choose, J continue, U undo, P album, S saves, H help.',
357 helpLines: [
358 'A visual novel written live by AI and played only with choices. Humans write the characters and skeleton; the AI writes each scene.',
359 'Each day has a few time slots. Pick a place — where you go decides whom you meet. Someone neglected too long will come find you.',
360 '"Promise" choices become promises with a deadline. See her again before it, or she will be disappointed.',
361 'Your choices and observations go into a notebook that later scenes call back. Relationships advance: strangers → friends → worries → secret → choice.',
362 'The story runs in acts, each with key events that must happen; supporting characters step in to move things along, and people share old grudges, misunderstandings and ties. Random things happen too: rain, exam results, running into someone.',
363 'Late in a route you receive an AI-drawn keepsake. Each character has good, normal and bad endings; a true ending needs a key promise kept, keepsakes collected, or a second playthrough.',
364 'Keys: 1–9 choose · J next/continue · A show all · U undo · P album · S saves · H help · B back · O autoplay · E settings',
365 'Autoplay: press O and the AI plays for you (places and choices), with its reason shown dimmed; any other key pauses it. It stops after 6 scenes by default or at an ending; strategy, pace and limit are in settings. About one Haiku call per decision; the Chaos strategy makes none.',
366 'About one writer call + one checker call per scene; the story bible is prompt-cached.',
367 ],
368 calLegend: '◉ today ◆ event ▪ past',
369 actStart: '—— {title} ——',
370 happening: 'Right now: {text}',
371 saveBad: 'unreadable save (unknown format)',
372 loadBad: 'That save is in a format this version cannot read; nothing was loaded.',
373 end_true: 'true',
374 end_good: 'good',
375 end_normal: 'normal',
376 end_bad: 'bad',
377 npcs: '{n} supporting',
378 castN: '{n} main',
379 autoStart: 'Autoplay',
380 autoStop: 'Stop autoplay',
381 autoBadge: 'Autoplay',
382 autoStatus: 'ai-galgame: autoplay on (O to stop)',
383 autoReason: '→ Picked {n}: {why}',
384 autoReasonBare: '→ Picked {n}',
385 autoStopped: 'Autoplay is off.',
386 autoKeyPause: 'Autoplay paused (you pressed a key). Press O to resume.',
387 autoLimitHit: 'Autoplay paused after {n} scenes. Press O to resume.',
388 autoDayEnd: 'Autoplay paused: the day is over. Press O to resume.',
389 autoEnded: 'Autoplay stopped: you reached an ending.',
390 autoError: 'Autoplay stopped: {err}',
391 autoSection: 'Autoplay (O toggles)',
392 autoStrategy: 'Strategy',
393 strategy_focus: 'Devoted',
394 strategy_whim: 'Whim',
395 strategy_collect: 'Collector',
396 strategy_chaos: 'Chaos',
397 strategyHint_focus: 'pursue one character to the end',
398 strategyHint_whim: 'the AI follows its own curiosity',
399 strategyHint_collect: 'new people, places and endings first',
400 strategyHint_chaos: 'random picks, no model calls',
401 autoTarget: 'Devoted to',
402 autoTargetAsk: 'ask the AI at the start',
403 autoSpeed: 'Pace',
404 speed_slow: 'slow (3 s a line)',
405 speed_mid: 'medium (1.5 s a line)',
406 speed_fast: 'fast (0.6 s a line)',
407 autoLimit: 'Pause after',
408 autoLimitN: '{n} scenes',
409 autoLimitDay: 'the end of the day',
410 usagePilot: 'autoplay decisions {n} · input {i} · output {o} tokens (Haiku)',
411 tabDiary: 'Her diary',
412 tabPortrait: 'You, in her eyes',
413 diaryLocked: 'Her diary opens once you reach "Worries" with her.',
414 diaryRead: 'Read her diary for this day (about 1 Haiku call)',
415 diaryPick: 'Pick someone:',
416 portraitGen: 'Hear what {name} thinks of you (about 1 Haiku call)',
417 portraitBars: 'Your habits',
418 portraitFew: 'Make a few more choices first.',
419 tone_honest: 'honest',
420 tone_evasive: 'evasive',
421 tone_joking: 'joking',
422 tone_committed: 'committed',
423 tone_caring: 'caring',
424 tone_bold: 'bold',
425 graph: 'Relationships',
426 graphHint: 'You are in the middle. Solid, bright lines mean close; a line that just changed flashes (white up, red down); red dashes are grudges, grey dashes are ties.',
427 letterFrom: '✉ A message from {name}',
428 letterDismiss: 'Dismiss',
429 gossipNight: 'Last night: {text}',
430 gossipTab: 'Night talk',
431 letItBe: 'Let it be',
432 letItBeHint: 'the AI picks the way you usually would',
433 closeUp: 'Close-up: {text}',
434 watchBusy: 'Claude is working. Play a scene meanwhile?',
435 watchDone: 'Claude is done. The game is paused.',
436 watchPermission: 'Claude is waiting for your approval. The game is paused.',
437 costLine: 'run {run} · today {today}',
438 budgetHit: 'Today\'s budget is spent ({used} / {budget}). Raise it in settings, or play tomorrow.',
439 secDisplay: 'Display',
440 secAuto: 'Autoplay',
441 secAI: 'AI features',
442 secCost: 'Cost',
443 secWork: 'Work companion',
444 mapSet: 'Pixel map',
445 layout: 'Layout',
446 layout_auto: 'auto by width',
447 layout_compact: 'compact',
448 layout_full: 'full',
449 palette: 'Colors',
450 palette_default: 'default',
451 palette_cb: 'colorblind-friendly',
452 bgSet: 'Terminal background',
453 bg_auto: 'auto',
454 bg_dark: 'dark',
455 bg_light: 'light',
456 bgDetected: '(your theme looks {bg})',
457 voice: 'Read lines aloud',
458 sfx: 'Sound effects',
459 short: 'Short scenes (for build breaks)',
460 writer: 'Story model',
461 writer_sonnet: 'Sonnet (default)',
462 writer_haiku: 'Haiku (cheaper)',
463 budget: 'Daily budget',
464 budgetNone: 'none',
465 extra_echoes: 'Echoes across playthroughs (no extra calls)',
466 extra_letters: 'Her message after a long absence (Haiku, at most once per return)',
467 extra_gossip: 'Night gossip in town (Haiku, once a day)',
468 extra_diary: 'Her diary (Haiku, when opened)',
469 extra_portrait: 'You, in her eyes (Haiku, when opened)',
470 extra_letItBe: '"Let it be" choice (Haiku, once per use)',
471 watch: 'Play-while-you-wait alerts',
472 workEvents: 'Coding events in the story (test pass/fail, commits only)',
473 companion: 'Companion',
474 companionOff: 'off',
475 usageExtra: 'AI features {n} · input {i} · output {o} tokens (Haiku)',
476 usageCost: 'Estimated cost: run {run} · today {today}',
477 feats: 'Achievements',
478 statsLine: 'Cleared {c}× · {s} scenes · about {cost} spent',
479 dailyTitle: 'Daily challenge',
480 dailyStart: 'Start today\'s challenge: "{title}"',
481 dailyDone: 'Today\'s result: {ending}',
482 dailyShare: 'Share line:',
483 dailyHint: 'Everyone plays the same pack with the same seed today. Who gets the true ending?',
484 achGot: 'Achievement: {name}',
485 'ach_first-scene': 'Opening Scene',
486 'ach_first-true': 'First True Ending',
487 'ach_all-endings': 'Every Ending',
488 'ach_no-undo': 'No Looking Back',
489 'ach_all-keepsakes': 'Keepsake Collector',
490 'ach_autoplay-clear': 'The AI Got There Too',
491 'ach_hundred-scenes': 'A Hundred Scenes',
492 'ach_daily-true': 'True Today',
493 'ach_bad-end': 'The Other Side',
494 'ach_second-run': 'We Meet Again',
495 'achd_first-scene': 'Play your first scene',
496 'achd_first-true': 'Reach any true ending',
497 'achd_all-endings': 'Unlock every ending of one pack',
498 'achd_no-undo': 'Clear a run without undo or loading',
499 'achd_all-keepsakes': 'Collect two keepsakes from everyone in one pack',
500 'achd_autoplay-clear': 'Reach an ending while on autoplay',
501 'achd_hundred-scenes': 'Play a hundred scenes',
502 'achd_daily-true': 'Get a true ending in the daily challenge',
503 'achd_bad-end': 'Reach a bad ending',
504 'achd_second-run': 'Clear a second playthrough',
505 genOutline: 'Outlining… (1/3)',
506 genExpand: 'Writing characters, places and endings… (2/3), usually 2–3 min',
507 genRepair: 'Fixing validation problems, round {n}… (3/3)',
508 genRunning: 'Generating a story pack: {step} (K to cancel)',
509 genCancelled: 'Generation cancelled.',
510 genDone: 'Generated "{title}"; it is in the story list.',
511 genFailed: 'The generated pack failed validation: {errors}. The draft is saved at {path}; fix it by hand and /gal import it.',
512 genBusy: 'A pack is already being generated.',
513 genHint: 'Generate from one line: /gal new <your idea> (optionally --days 7 --cast 3); about 3–5 min and 2–4 Sonnet calls.',
514 genStart: 'Generating: {idea}. It will appear in the story list when done (K to cancel).',
515 repoConfirm: 'To generate a pack from this project I will read: {files} ({n} files, {kb} KB). No source code, no hidden files.',
516 repoYes: 'Generate',
517 repoNo: 'Cancel',
518 repoKey: 'From this project',
519 repoAsk: 'Press Y in the pane to confirm, or run /gal repo --yes',
520 exportHtml: 'Export HTML',
521 exportMd: 'Export Markdown',
522 shareText: 'Share text',
523 exported: 'Exported: {path}',
524 exportFail: 'Export failed: {err}',
525 community: 'Community',
526 communityLoading: 'Loading the community index…',
527 communityFail: 'Cannot reach the community index ({err}). You can /gal import a local file instead.',
528 communityEmpty: 'No installable packs in the community index yet.',
529 downloading: 'Downloading "{title}"…',
530 installed: 'Installed "{title}"',
531 installFail: 'Could not install: {err}',
532 update: 'Update',
533 install: 'Install',
534 packOrigin_community: 'community',
535 genKey: 'Generate',
536 routeLocked: '(The weight of this story has settled on {name}.)',
537 routeEnsemble: '(This story does not belong to just one of them.)',
538 endingCardKey: 'Ending card',
539 endingCardHint: 'Ending card (spoiler-free, ready to paste; also /gal card):',
540 graphKey: 'Relations',
541 featsMenu: 'Achievements & challenge',
542 dailyMenu: 'Daily challenge',
543}
544
545const TABLE: Record<Lang, typeof ZH> = { zh: ZH, en: EN }
546
547export function t(lang: Lang, key: UiKey, vars: Record<string, string | number> = {}): string {
548 let s = TABLE[lang][key] ?? ZH[key]
549 for (const [k, v] of Object.entries(vars)) s = s.split(`{${k}}`).join(String(v))
550 return s
551}
552
553export function helpLines(lang: Lang): string[] {
554 return TABLE[lang].helpLines
555}
556
557export const LANGS: { id: Lang; label: string }[] = [
558 { id: 'zh', label: '中文' },
559 { id: 'en', label: 'English' },
560]
561hooks/theme.ts 108 lines1// 颜色令牌:界面上所有颜色从这里取,组件里不写裸十六进制。见 docs/VISUAL-REDESIGN.md §3.2。
2// 像素层自带深色幕布,不随终端背景变;文字层按终端深浅取两套值,字色在色块上由 inkOn 挑黑或白。
3
4import type { Px } from './art'
5
6/** 像素层(Raster)。数值是 0xRRGGBB */
7export const PX = {
8 screen: 0x17151f, // 幕布底:所有像素块的透明处都填这个色
9 frame: 0x3a3548,
10 frameLit: 0xf2c14e,
11 title: 0xf4f1e8,
12 accent: 0xf2c14e,
13 accent2: 0xf8bbd0,
14 lamp: 0xffd27a,
15} as const
16
17export type Bg = 'dark' | 'light'
18
19/** 文字层。括号外的说明见 VISUAL-REDESIGN §3.2-B;对比度深底对 #1e1e1e、浅底对 #ffffff,均 ≥ 4.5 */
20export const TEXT = {
21 dark: { accent: '#f2c14e', me: '#7fd1c7' },
22 light: { accent: '#8a5a00', me: '#00796b' },
23} as const
24
25/** 选项类型色:只做芯片底(字色走 inkOn)。cb 是 Okabe–Ito 色盲配色;图标也各不相同 */
26export const KIND = {
27 default: { reply: '#7fd1c7', observe: '#b0b6bd', promise: '#f2c14e', keep: '#81c784' },
28 cb: { reply: '#56b4e9', observe: '#cc79a7', promise: '#f0e442', keep: '#009e73' },
29} as const
30
31/** 色盲配色下文字层的状态色(Okabe–Ito),深底用 */
32export const STATE_CB = { kept: '#009e73', broken: '#d55e00', event: '#e69f00', info: '#56b4e9' } as const
33
34/**
35 * 默认配色下的状态色用 Claude Code 的主题键:跟着玩家的主题(深、浅、色盲、ANSI)自动换,
36 * 不靠我们猜底色。TEXT 里的十六进制只剩强调色和「我」在用
37 */
38export const STATE_KEYS = { kept: 'success', broken: 'error', event: 'warning', info: 'suggestion' } as const
39
40/** /config 里 theme 一行的值 → 深浅。取值集合类型里没写(VISUAL-REDESIGN §5-2),名字里带 light 的算浅色 */
41export function bgOfTheme(v: unknown): Bg | null {
42 if (typeof v !== 'string' || !v || /auto|system/i.test(v)) return null
43 return /light/i.test(v) ? 'light' : 'dark'
44}
45
46/** 角色没写颜色时的退路 */
47export const FALLBACK = '#cccccc'
48
49const REF: Record<Bg, string> = { dark: '#1e1e1e', light: '#ffffff' }
50
51function rgb(c: string): [number, number, number] {
52 const n = parseInt(c.replace('#', ''), 16)
53 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
54}
55
56function lum(c: string): number {
57 const [r, g, b] = rgb(c).map(v => {
58 const x = v / 255
59 return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
60 }) as [number, number, number]
61 return 0.2126 * r + 0.7152 * g + 0.0722 * b
62}
63
64/** WCAG 对比度 */
65export function contrast(a: string, b: string): number {
66 const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m) as [number, number]
67 return (x + 0.05) / (y + 0.05)
68}
69
70/** 色块上的字色:纯黑、纯白里对比更高的那个(对任何底色都 ≥ 4.58) */
71export function inkOn(bg: string): string {
72 return contrast(bg, '#000000') >= contrast(bg, '#ffffff') ? '#000000' : '#ffffff'
73}
74
75function mix(c: string, to: string, k: number): string {
76 const a = rgb(c)
77 const b = rgb(to)
78 return `#${a.map((v, i) => Math.round(v + (b[i]! - v) * k).toString(16).padStart(2, '0')).join('')}`
79}
80
81/** 角色色当前景用:往白(深底)或往黑(浅底)按 5% 一步混,直到对终端底色 ≥ 4.5 */
82export function readable(c: string, bg: Bg = 'dark'): string {
83 const ref = REF[bg]
84 const to = bg === 'dark' ? '#ffffff' : '#000000'
85 for (let k = 0; k <= 1; k += 0.05) {
86 const x = mix(c, to, k)
87 if (contrast(x, ref) >= 4.5) return x
88 }
89 return to
90}
91
92/** 0xRRGGBB → '#rrggbb'(像素卡的 CardSpec 收字符串) */
93export function css(n: number): string {
94 return `#${n.toString(16).padStart(6, '0')}`
95}
96
97/** 外面加 1 像素的幕框(信物画框用):宽高各多 2 */
98export function framed(px: Px[][], c: number = PX.frame): Px[][] {
99 const w = (px[0]?.length ?? 0) + 2
100 const edge = Array<Px>(w).fill(c)
101 return [edge, ...px.map(r => [c, ...r, c]), edge.slice()]
102}
103
104/** 像素块自带幕布:透明处填 PX.screen,深浅终端上是同一幅画 */
105export function onScreen(px: Px[][], fill: number = PX.screen): Px[][] {
106 return px.map(r => r.map(v => v ?? fill))
107}
108hooks/native.ts 113 lines1// AI 原生机制:离开很久后她发来的消息、每天夜里小镇的幕后、她的日记 / 视角、「你在她眼里」。
2// 全部用 Haiku、一次调用、只在需要时生成(日记和画像打开时才写,写完缓存进存档),每一项都能在设置里关掉。
3// 跨周目回声和选择倾向不调用模型:回声来自上一局的相册摘要,倾向来自选项的 tone,直接写进每一幕的提示。
4// 这里不读写 $.state。
5
6import type { EngineInterface } from 'claude-code'
7
8import type { Lang, Pack, Story, Tone, Usage } from '../types'
9import { CHECKER, profileLine, zeroUsage } from './engine'
10import { costOf } from './cost'
11import { awayBrief, diaryBrief, gossipFor } from './craft'
12import { castOf, dayText, meterOf, packText } from './pack'
13
14export const EXTRA_MODEL = CHECKER
15export type Extra = { text: string | null; usage: Usage }
16
17/** 额外功能的开关键(设置页逐项可关) */
18export type ExtraKey = 'echoes' | 'letters' | 'gossip' | 'diary' | 'portrait' | 'letItBe'
19export const EXTRA_KEYS: ExtraKey[] = ['echoes', 'letters', 'gossip', 'diary', 'portrait', 'letItBe']
20/** 真实时间离开多久算「很久」 */
21export const AWAY_MS = 6 * 3600 * 1000
22
23function langLine(lang: Lang): string {
24 return lang === 'en' ? 'Write in natural English.' : '用简体中文写。'
25}
26
27async function haiku($: EngineInterface, prompt: string, maxTokens: number): Promise<Extra> {
28 const u = zeroUsage()
29 try {
30 const r = await $.model.complete({ model: EXTRA_MODEL, prompt: [{ text: prompt }], maxTokens, timeoutMs: 30_000 })
31 u.extra = 1
32 u.extraIn = r.usage.input_tokens + r.usage.cache_read_input_tokens + r.usage.cache_creation_input_tokens
33 u.extraOut = r.usage.output_tokens
34 u.cost = costOf(EXTRA_MODEL, r.usage)
35 const text = r.isAnswered ? r.text.trim().replace(/^["「]|["」]$/g, '') : ''
36 return { text: text || null, usage: u }
37 } catch {
38 return { text: null, usage: u }
39 }
40}
41
42function brief(p: Pack, lang: Lang, id: string): string {
43 const T = packText(p, lang)
44 const c = castOf(p, id)
45 if (!c) return id
46 return `${T(c.name)} — ${T(c.tagline ?? c.look)}; voice: ${T(c.voice)}; e.g. ${c.samples.slice(0, 2).map(x => `「${T(x)}」`).join(' ')}${meterOf(c).romance ? '' : ' (not a romance)'}`
47}
48
49/** 离开时她最可能想起谁:好感最高、见过面的那位 */
50export function letterFrom(p: Pack, s: Story): string | null {
51 const met = p.cast.filter(c => (s.heroines[c.id]?.lastDay ?? 0) > 0)
52 if (!met.length) return null
53 return met.sort((a, b) => (s.heroines[b.id]?.affection ?? 0) - (s.heroines[a.id]?.affection ?? 0))[0]!.id
54}
55
56/** 你离开很久(真实时间)后回来:她发来一条消息,说说你不在的时候 */
57export async function awayLetter($: EngineInterface, p: Pack, s: Story, hours: number, lang: Lang): Promise<{ from: string; text: string } | null> {
58 const from = letterFrom(p, s)
59 if (!from) return null
60 const T = packText(p, lang)
61 const last = s.album.filter(m => m.heroine === from).slice(-3).map(m => m.text).join(' / ') || 'none'
62 const r = await haiku($, `You write one short message from a character in the visual novel "${T(p.title)}" to the player-character ("me"), who has been away for about ${Math.round(hours)} real hours.
63Character: ${brief(p, lang, from)}. Relationship stage: ${T(p.stages[s.heroines[from]?.stage ?? 0])}. Story time: ${dayText(p, lang, s.day)}.
64Our recent moments: ${last}.
652-3 sentences in her voice: what happened while I was gone (small, concrete, in-world), and a light nudge to come back. Do not reveal secrets, do not invent major plot events. Plain text only. ${langLine(lang)}
66${awayBrief(p, from, lang, Math.max(1, Math.round(hours / 24)))}`, 220)
67 return r.text ? { from, text: r.text.slice(0, 400) } : null
68}
69
70/** 一天结束时,小镇里发生的幕后事:配角之间的八卦或某件小事,1-2 句 */
71export async function nightGossip($: EngineInterface, p: Pack, s: Story, day: number, lang: Lang): Promise<Extra> {
72 const T = packText(p, lang)
73 const npcs = (p.npcs ?? []).map(n => `${T(n.name)}: ${T(n.role)}`).join('; ') || 'none'
74 const today = s.album.filter(m => m.day === day).map(m => m.text).join(' / ') || 'nothing in particular'
75 return haiku($, `Offstage in the visual novel "${T(p.title)}" (${T(p.premise).slice(0, 300)}): it is the night after ${dayText(p, lang, day)}.
76Supporting characters: ${npcs}. Heroines: ${p.cast.map(c => T(c.short)).join(', ')}.
77What I did today: ${today}.
78${gossipFor(p, s, lang, 2).map(g => `Possible material (mark whether it is true, half-true or a misunderstanding): ${g}`).join('\n')}
79Write 1-2 sentences of small-town gossip or something that happened offstage tonight, between supporting characters or about the heroines, that the player did not see. Concrete and in-world; no secrets revealed, no major plot changes. Plain text only. ${langLine(lang)}`, 160)
80}
81
82/** 她的日记:同一天、她的视角,常常重新解读「我」以为的事 */
83export async function diaryEntry($: EngineInterface, p: Pack, s: Story, id: string, day: number, lang: Lang, guide = diaryBrief(p, s, id, lang)): Promise<Extra> {
84 const T = packText(p, lang)
85 const c = castOf(p, id)
86 const moments = s.album.filter(m => m.heroine === id && m.day === day).map(m => m.text).join(' / ') || 'we did not meet today'
87 const stage = s.heroines[id]?.stage ?? 0
88 const secret = stage >= 3 && c ? `Her secret (she may write about it now): ${T(c.secret)}` : 'She keeps her secret to herself; hint at most.'
89 return haiku($, `Write ${T(c?.name)}'s private diary entry for ${dayText(p, lang, day)} in the visual novel "${T(p.title)}".
90Her: ${brief(p, lang, id)}. ${secret}
91What happened between us that day (from my side): ${moments}.
92${guide ? `Writing guide: ${guide}\n` : ''}4-6 sentences in her own voice. Reinterpret at least one moment: what she noticed that I did not, or why she really said what she said. No headings, plain text. ${langLine(lang)}`, 320)
93}
94
95/** 「你在她眼里」:根据我的选择倾向与约定,她怎么看我 */
96export async function portraitOf($: EngineInterface, p: Pack, s: Story, id: string, lang: Lang): Promise<Extra> {
97 const T = packText(p, lang)
98 const mine = s.promises.filter(x => x.heroine === id)
99 const kept = mine.filter(x => x.status === 'kept').length
100 const broken = mine.filter(x => x.status === 'broken').length
101 const moments = s.album.filter(m => m.heroine === id).slice(-5).map(m => m.text).join(' / ') || 'few'
102 return haiku($, `In the visual novel "${T(p.title)}", ${T(castOf(p, id)?.name)} sums up what kind of person "I" am.
103Her: ${brief(p, lang, id)}.
104My habits in choices: ${profileLine(s) || 'not enough to tell yet'}. Promises to her: kept ${kept}, broken ${broken}. Our moments: ${moments}.
1052-3 sentences in her voice, direct and specific ("you always..."), affectionate or sharp as fits our relationship. Plain text only. ${langLine(lang)}`, 200)
106}
107
108/** 选择倾向:百分比条,给相册页用(不调用模型) */
109export function tendencies(s: Story): { tone: Tone; n: number; share: number }[] {
110 const total = Object.values(s.profile).reduce((a, b) => a + (b ?? 0), 0)
111 return (Object.entries(s.profile) as [Tone, number][]).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).map(([tone, n]) => ({ tone, n, share: total ? n / total : 0 }))
112}
113