Merge pull request #11 from zonghaoyuan/merge-staging-to-main
Merge staging into main (#10 的冲突已解决)
@@ -14,6 +14,9 @@ out
|
||||
.turbo
|
||||
.claude
|
||||
|
||||
.open-next
|
||||
.wrangler
|
||||
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -22,3 +25,4 @@ pnpm-debug.log*
|
||||
repomix-output.xml
|
||||
|
||||
users.md
|
||||
.dev.vars
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
InfiPlot Copyright (C) 2025-2026 InfiPlot Contributors
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="docs/banner.svg" alt="InfiPlot" width="100%">
|
||||
|
||||
<p><b>An interactive story game, generated in real time for you</b></p>
|
||||
|
||||
[](https://github.com/zonghaoyuan/infiplot/stargazers)
|
||||
[](https://github.com/zonghaoyuan/infiplot/watchers)
|
||||
[](https://github.com/zonghaoyuan/infiplot/network)
|
||||
[](https://github.com/zonghaoyuan/infiplot/issues)
|
||||
|
||||
[](https://infiplot.com)
|
||||
[](LICENSE)
|
||||
[](https://linux.do)
|
||||
|
||||
[简体中文](https://github.com/zonghaoyuan/infiplot) · English · [日本語](README.ja.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Overview
|
||||
|
||||
InfiPlot is an interactive story game with content generated by AI in real time. There are no pre-written plots and no pre-made characters — everything is generated on demand, tailored to you.
|
||||
|
||||
In one line: what we're building is an AI-generated, real-time take on *Love Is All Around* (《完蛋!我被美女包围了!》).
|
||||
|
||||
Whether you're a six-year-old, a twenty-something, thirty-five, or sixty, there's a fantasy here that belongs to you and you alone:
|
||||
|
||||
Learn magic in the world of Harry Potter; become the one everyone at school adores and confesses to; publish paper after paper in top journals and conferences with grant money to spare; step into *Empresses in the Palace* and live out the court intrigue; or return to your younger self and make a different choice about something you regret…
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Live Demo
|
||||
|
||||
Free to play, no setup required: [infiplot.com](https://infiplot.com)
|
||||
|
||||
---
|
||||
|
||||
## One-click deploy
|
||||
|
||||
InfiPlot deploys to both Vercel and Cloudflare Workers — pick whichever you prefer.
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&root-directory=apps/web&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot/blob/main/README.en.md%23configuration-guide) [](https://deploy.workers.cloudflare.com/?url=https://github.com/zonghaoyuan/infiplot/tree/main/apps/web)
|
||||
|
||||
After deploy, fill in the environment variables — see the [Configuration guide](#configuration-guide) below. Both platforms need `apps/web` as the project root (Vercel's button passes this automatically; on Cloudflare, set the build root to `apps/web` and the build command to `pnpm --filter @infiplot/web build:cf`).
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/4.webp"><img src="docs/screenshots/4.webp" width="280" alt="School rooftop scene"></a></td>
|
||||
<td><a href="docs/screenshots/c3.webp"><img src="docs/screenshots/c3.webp" width="280" alt="Golden hour rooftop dialogue"></a></td>
|
||||
<td><a href="docs/screenshots/c5.webp"><img src="docs/screenshots/c5.webp" width="280" alt="Sunset rooftop confession"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/c7.webp"><img src="docs/screenshots/c7.webp" width="280" alt="Rooftop close-up choice"></a></td>
|
||||
<td><a href="docs/screenshots/a4.webp"><img src="docs/screenshots/a4.webp" width="280" alt="Twilight rooftop drama"></a></td>
|
||||
<td><a href="docs/screenshots/5.webp"><img src="docs/screenshots/5.webp" width="280" alt="Cityscape evening scene"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/d2.webp"><img src="docs/screenshots/d2.webp" width="280" alt="Cyberpunk neon alley"></a></td>
|
||||
<td><a href="docs/screenshots/f2.webp"><img src="docs/screenshots/f2.webp" width="280" alt="Night street anime scene"></a></td>
|
||||
<td><a href="docs/screenshots/f5.webp"><img src="docs/screenshots/f5.webp" width="280" alt="Late-night encounter"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Built on text, image, and audio models, we've assembled a multi-agent framework to deliver on InfiPlot's goal. We split the agents into five roles — **Architect, Writer, Character Designer, Cinematographer, and Painter** — that work together to keep the plot coherent, the characters consistent, and the scenes continuous, all while making the story as compelling as we can.
|
||||
|
||||
We call each complete playthrough a **story**.
|
||||
|
||||
A story unfolds as a sequence of scenes. Each scene is one AI-painted background plus a short tree of beats — moments of narration, dialogue, and the occasional choice. You tap through a scene's beats and the image stays put; only when a choice leads somewhere genuinely new — another place, a new point of view, a jump in time — does the AI paint the next scene.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U["Your input: world setting + art style"] --> A["Architect<br/>parses input → full story structure (first step)"]
|
||||
A --> W["Writer<br/>directs this scene's beats: narration · dialogue · choices"]
|
||||
subgraph SCENE["Generating one scene"]
|
||||
direction TB
|
||||
W --> C["Character Designer<br/>portrait + voice (parallel, per new character)"]
|
||||
W --> S["Cinematographer<br/>shot composition + background prompt"]
|
||||
C --> P["Painter<br/>renders the 16:9 background using portraits as reference"]
|
||||
S --> P
|
||||
end
|
||||
P --> SC["One scene: background image + beat tree"]
|
||||
SC -. speculatively pre-generate the next scene .-> W
|
||||
```
|
||||
|
||||
While you're reading one scene, the engine speculatively generates the scenes your choices could lead to — and, for unavoidable next steps, the scene after that. By the time you pick a direction, its image is usually already painted, so the cut feels instant. If you still notice some lag today, don't worry — we're working hard to bring it down.
|
||||
|
||||
Clicking the background itself (not a button) routes through a vision model: it reads where you tapped and decides whether you're exploring the current scene (it inserts a beat — no new image) or moving on (a new scene). This builds on a valuable lesson we learned from flipbook, and we believe it will become one of InfiPlot's defining features — taking the experience to the next level.
|
||||
|
||||
There is no traditional game UI baked into the art. The AI paints the world in whatever style you pick — "stick figure on grid paper" or "cyberpunk noir" — and the dialogue panel and choice buttons are a light HTML layer drawn on top, tuned to sit over the scene. In other words, the UI fits the story of each playthrough, rather than staying the same every time.
|
||||
|
||||
---
|
||||
|
||||
## Team & Vision
|
||||
|
||||
We're a group of young people from Tsinghua University and other schools.
|
||||
|
||||
On one hand, we're longtime, devoted players of galgames, otome games, FMV, and AI role-play games. Even while enjoying them, we kept imagining how much more delightful and thrilling it would be if the story choices weren't fixed in advance — or if you could truly interact with an AI character in depth, instead of just texting it through a chat app.
|
||||
|
||||
On the other hand, we happen to know a little about large-model technology: enough to turn ideas into working software quickly with AI, and to have formed some modest views on the technical paths available and the limits of what today's tech can build.
|
||||
|
||||
The spark came on April 22, 2026, when [@zan2434](https://x.com/zan2434) and others released [flipbook](https://flipbook.page/). We were stunned and delighted by this entirely new form of interaction.
|
||||
|
||||
So one day in May, we agreed on the spot to build something like this — both to help people live out the fantasies they'd once set aside, and to explore the new modes of interaction that multimodal models make possible.
|
||||
|
||||
The project is still very early and many features are far from polished. We'd love your feedback — open an [issue](https://github.com/zonghaoyuan/infiplot/issues), or join our dev team and explore the new possibilities with us, and satisfy your own curiosity.
|
||||
|
||||
Get in touch: hi@infiplot.com
|
||||
|
||||
Scan to join our **beta community on QQ** (group ID `575404333`) to share feedback and help shape the project:
|
||||
|
||||
<img src="apps/web/public/qq-group.webp" alt="InfiPlot beta community QQ group QR code" width="200" />
|
||||
|
||||
---
|
||||
|
||||
## Configuration guide
|
||||
|
||||
InfiPlot talks to four kinds of model providers. **Text and Vision use any OpenAI-compatible endpoint**, so you can mix and match freely. **Image** currently goes to **Runware** (its own task-array protocol, not OpenAI-compatible). **TTS** uses **Xiaomi MiMo**'s own voice design / clone protocol — per-character voice design, clone, and per-line delivery direction.
|
||||
|
||||
**1. Choose your providers**
|
||||
|
||||
| Provider | Variables | Required? | Recommended |
|
||||
|---|---|---|---|
|
||||
| Text · story director | `TEXT_BASE_URL` `TEXT_API_KEY` `TEXT_MODEL` | ✅ | `deepseek-v4-flash` via DeepSeek |
|
||||
| Image · scene renderer | `IMAGE_BASE_URL` `IMAGE_API_KEY` `IMAGE_MODEL` | ✅ | `runware:400@6` (FLUX.2 [klein] 9B KV) via [Runware](https://runware.ai) |
|
||||
| Vision · click reader | `VISION_BASE_URL` `VISION_API_KEY` `VISION_MODEL` | ✅ | `gemini-3.5-flash` via Google |
|
||||
| TTS · per-character voice | `TTS_BASE_URL` `TTS_API_KEY` `TTS_SPEECH_MODEL` | optional — leave blank to run silently | `mimo-v2.5-tts` via Xiaomi MiMo |
|
||||
|
||||
**2. Set the environment variables**
|
||||
|
||||
Nine variables are required; TTS is optional (leave blank to run silently). There's also a flag for cheap testing:
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `MOCK_IMAGE=true` | Skip image generation; the renderer returns a static placeholder. Story, voice, and choices still run normally. Great for iterating on TTS without burning Runware credits. |
|
||||
|
||||
Where to set them (see `apps/web/.env.example` for the exact shape):
|
||||
|
||||
- **Local dev** — `apps/web/.env.local`
|
||||
- **Vercel** — Project Settings → Environment Variables
|
||||
- **Cloudflare Workers** — from `apps/web/`, run `wrangler secret put <NAME>` for each variable, or set them in the dashboard (Workers → infiplot → Settings → Variables and Secrets). For a private staging instance, gate the Worker behind [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/) — zero-code email-whitelist auth in front of the Worker.
|
||||
|
||||
**3. Mind the cost**
|
||||
|
||||
With the recommended trio, each scene's cost comes mainly from the image generation model. The FLUX.2 [klein] 9B KV image is roughly **\$0.00078** per scene (1792×1024, 4 steps, sub-second); the text model uses `deepseek-v4-flash`, so text costs are negligible by comparison. Tapping through a scene's beats is free. To keep transitions instant, the engine also pre-generates scenes you might pick but ultimately don't — so real spend runs somewhat higher than the scenes you actually see.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Make generation latency imperceptible
|
||||
- [ ] Compatibility with more model providers
|
||||
- [ ] Free-form player input mid-story
|
||||
- [ ] Mobile browser support
|
||||
- [ ] User accounts and login
|
||||
- [ ] Upgrade from static images to motion video
|
||||
- [ ] Voice interaction
|
||||
- [ ] Share the story you're playing
|
||||
- [ ] Mobile app
|
||||
|
||||
---
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://star-history.com/#zonghaoyuan/infiplot&Date)
|
||||
@@ -1,21 +1,27 @@
|
||||
[English](README.md) · [简体中文](README.zh-CN.md) · 日本語
|
||||
<div align="center">
|
||||
|
||||
# InfiPlot
|
||||
<img src="docs/banner.svg" alt="InfiPlot" width="100%">
|
||||
|
||||
> AI がリアルタイムに生成する、初のインタラクティブ・ストーリーゲーム —— あなたが思い描く場面を入力すれば、それが目の前に、没入感たっぷりのビジュアルとして立ち上がり、あなた自身がその中へ飛び込めます。一幕一幕の筋書きも、一枚一枚の画像も、一人ひとりのキャラクターも、すべてマルチモーダル AI エンジンがその場で設計・生成します —— 幼い頃に夢見た「アニメの中に飛び込む」あの願いを叶えるために。
|
||||
<p><b>あなたのためにリアルタイム生成されるインタラクティブ・ストーリーゲーム</b></p>
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/zonghaoyuan/infiplot/stargazers)
|
||||
[](https://github.com/zonghaoyuan/infiplot/stargazers)
|
||||
[](https://github.com/zonghaoyuan/infiplot/watchers)
|
||||
[](https://github.com/zonghaoyuan/infiplot/network)
|
||||
[](https://github.com/zonghaoyuan/infiplot/issues)
|
||||
|
||||
**▶ ベータ期間中は無料でプレイ、セットアップ不要 —— [infiplot.com](https://infiplot.com)**
|
||||
[](https://infiplot.com)
|
||||
[](LICENSE)
|
||||
[](https://linux.do)
|
||||
|
||||
このプロジェクトを面白いと思っていただけたら、リポジトリへの ⭐ が私たちにとって何よりの励みになり、より多くの人に届く助けにもなります。ありがとうございます!
|
||||
[简体中文](https://github.com/zonghaoyuan/infiplot) · [English](README.en.md) · 日本語
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## InfiPlot とは
|
||||
## ⚡ 概要
|
||||
|
||||
InfiPlot は、AI がコンテンツをリアルタイムに生成する世界初のインタラクティブ・ストーリーゲームです。あらかじめ用意された筋書きもキャラクターも、声色さえもありません。すべてはあなたの求めに応じて、その場でカスタマイズして生成されます。私たちは、美少女ゲーム(galgame)、乙女ゲーム、フルモーション・ビデオ(FMV)ゲームに比肩する体験を目指しつつ、一人ひとりに合わせた*参加型のファンタジー*を提供します —— より没入感のある視聴体験で、あなたの想像力と好奇心を存分に満たします。
|
||||
InfiPlot は、AI がコンテンツをリアルタイムに生成するインタラクティブ・ストーリーゲームです。あらかじめ用意された筋書きもキャラクターもなく、すべてがあなたの求めに応じてその場で生成されます。
|
||||
|
||||
ひとことで言えば、私たちが作っているのは、AI がリアルタイムにコンテンツを生成する『Love Is All Around(完蛋!我被美女包围了!)』です。
|
||||
|
||||
@@ -25,19 +31,41 @@ InfiPlot は、AI がコンテンツをリアルタイムに生成する世界
|
||||
|
||||
---
|
||||
|
||||
## 開発の動機
|
||||
## 🌐 ライブデモ
|
||||
|
||||
私たちは、清華大学をはじめとする大学に集う若者のグループです。
|
||||
無料でプレイ、セットアップ不要:[infiplot.com](https://infiplot.com)
|
||||
|
||||
一方で、私たち自身が galgame、乙女ゲーム、FMV、AI ロールプレイといったゲームのヘビーユーザーでした。楽しみながらも、もし筋書きが固定された選択肢に縛られず、チャットアプリ越しの会話ではなく AI キャラクターと深く関われたら、どれほど愉快で刺激的だろうと想像していました。
|
||||
---
|
||||
|
||||
もう一方で、私たちはたまたま大規模モデルの技術を少しばかり理解しており、AI でアイデアを素早く形にでき、技術の道筋や既存技術で実現できる製品の限界について、ささやかな考えを持っていました。
|
||||
## ワンクリックデプロイ
|
||||
|
||||
きっかけは 2026 年 4 月 22 日、[@zan2434](https://x.com/zan2434) たちが [flipbook](https://flipbook.page/) を公開したことでした。この全く新しいインタラクションの形に、私たちは驚き、心を躍らせました。そして 5 月のある日、意気投合し、こうした製品を作ろうと決めました —— かつて諦めた幻想を叶える手助けをしつつ、マルチモーダルモデルがもたらす新しいインタラクションの形を探るために。
|
||||
InfiPlot は Vercel と Cloudflare Workers の両方にそのままデプロイできます —— お好みの方をお選びください。
|
||||
|
||||
プロジェクトはまだごく初期で、多くの機能が未完成です。[issue](https://github.com/zonghaoyuan/infiplot/issues) でのフィードバックを歓迎します。あるいは開発チームに加わって、一緒に新たな可能性を探り、あなた自身の好奇心を満たしてください。
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&root-directory=apps/web&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot/blob/main/README.ja.md%23%E8%A8%AD%E5%AE%9A%E3%82%AC%E3%82%A4%E3%83%89) [](https://deploy.workers.cloudflare.com/?url=https://github.com/zonghaoyuan/infiplot/tree/main/apps/web)
|
||||
|
||||
お問い合わせ:hi@infiplot.com
|
||||
デプロイ後、環境変数を設定してください —— 下記の[設定ガイド](#設定ガイド)を参照。両方のプラットフォームで、プロジェクトのルートを `apps/web` に設定する必要があります(Vercel のデプロイボタンが自動で渡します。Cloudflare では build root を `apps/web`、ビルドコマンドを `pnpm --filter @infiplot/web build:cf` に設定してください)。
|
||||
|
||||
---
|
||||
|
||||
## 📸 スクリーンショット
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/4.webp"><img src="docs/screenshots/4.webp" width="280" alt="学校の屋上のシーン"></a></td>
|
||||
<td><a href="docs/screenshots/c3.webp"><img src="docs/screenshots/c3.webp" width="280" alt="夕暮れの屋上の対話"></a></td>
|
||||
<td><a href="docs/screenshots/c5.webp"><img src="docs/screenshots/c5.webp" width="280" alt="夕日の屋上の告白"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/c7.webp"><img src="docs/screenshots/c7.webp" width="280" alt="屋上のクローズアップの選択"></a></td>
|
||||
<td><a href="docs/screenshots/a4.webp"><img src="docs/screenshots/a4.webp" width="280" alt="黄昏の屋上のドラマ"></a></td>
|
||||
<td><a href="docs/screenshots/5.webp"><img src="docs/screenshots/5.webp" width="280" alt="夕暮れの街並みのシーン"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/d2.webp"><img src="docs/screenshots/d2.webp" width="280" alt="サイバーパンクのネオン横丁"></a></td>
|
||||
<td><a href="docs/screenshots/f2.webp"><img src="docs/screenshots/f2.webp" width="280" alt="夜の街のアニメシーン"></a></td>
|
||||
<td><a href="docs/screenshots/f5.webp"><img src="docs/screenshots/f5.webp" width="280" alt="深夜の出会い"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
@@ -47,7 +75,7 @@ InfiPlot は、AI がコンテンツをリアルタイムに生成する世界
|
||||
|
||||
一回のプレイ全体を、私たちは**ストーリー(story)**と呼んでいます。
|
||||
|
||||
物語は一連の**シーン(scene)**として展開します。各シーンは、AI が描いた 1 枚の背景画と、短い**ビート(beat)**のツリー —— ナレーション、セリフ、ときおりの選択肢 —— で構成されます。シーン内のビートをタップしていく間、画像はそのまま動きません。選択肢が本当に新しい場所 —— 別の空間、新しい視点、時間の跳躍 —— へ導いたときだけ、AI は次のシーンを描きます。
|
||||
物語は一連のシーン(scene)として展開します。各シーンは、AI が描いた 1 枚の背景画と、短いビート(beat)のツリー —— ナレーション、セリフ、ときおりの選択肢 —— で構成されます。シーン内のビートをタップしていく間、画像はそのまま動きません。選択肢が本当に新しい場所 —— 別の空間、新しい視点、時間の跳躍 —— へ導いたときだけ、AI は次のシーンを描きます。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -64,25 +92,38 @@ flowchart TD
|
||||
SC -. 次のシーンを先回り生成 .-> W
|
||||
```
|
||||
|
||||
あなたがひとつのシーンを読んでいる間に、エンジンは**選択肢が導きうるシーンを先回りして生成**します —— 避けられない次の一歩については、そのさらに先のシーンまで。あなたが方向を選ぶ頃には、その画像はたいてい描き上がっているので、切り替えは一瞬に感じられます。いまはまだ多少の遅延を感じるかもしれませんが、ご安心ください —— 私たちは鋭意改善に取り組んでいます。
|
||||
あなたがひとつのシーンを読んでいる間に、エンジンは選択肢が導きうるシーンを先回りして生成します —— 避けられない次の一歩については、そのさらに先のシーンまで。あなたが方向を選ぶ頃には、その画像はたいてい描き上がっているので、切り替えは一瞬に感じられます。いまはまだ多少の遅延を感じるかもしれませんが、ご安心ください —— 私たちは鋭意改善に取り組んでいます。
|
||||
|
||||
ボタンではなく背景そのものをクリックすると、**ビジョン(vision)**モデルを経由します。タップした位置を読み取り、いまのシーンを探索しているのか(新しい画像なしでビートを挿入)、先へ進もうとしているのか(新しいシーン)を判断します。これは flipbook から学んだ貴重な知見に基づくもので、この機能はいずれ InfiPlot を特徴づける鍵となり、プレイ体験をもう一段引き上げてくれると信じています。
|
||||
ボタンではなく背景そのものをクリックすると、ビジョン(vision)モデルを経由します。タップした位置を読み取り、いまのシーンを探索しているのか(新しい画像なしでビートを挿入)、先へ進もうとしているのか(新しいシーン)を判断します。これは flipbook から学んだ貴重な知見に基づくもので、この機能はいずれ InfiPlot を特徴づける鍵となり、プレイ体験をもう一段引き上げてくれると信じています。
|
||||
|
||||
アートの中には、従来型のゲーム UI は一切焼き込まれていません。AI は、あなたが選んだ任意のスタイル —— 「方眼紙の棒人間」でも「サイバーパンク・ノワール」でも —— で世界を描きます。セリフ枠と選択肢ボタンは、その上に重ねた軽量な HTML レイヤーで、シーンになじむよう調整されています。つまり UI は、毎回同じではなく、そのプレイの物語に寄り添って変化するのです。
|
||||
|
||||
---
|
||||
|
||||
## ワンクリックデプロイ
|
||||
## チームとビジョン
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&root-directory=apps/web&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot%23configuration-guide)
|
||||
私たちは、清華大学をはじめとする大学に集う若者のグループです。
|
||||
|
||||
デプロイ後、Vercel プロジェクトで環境変数を設定してください —— 下記の[設定ガイド](#設定ガイド)を参照。Vercel プロジェクトの **Root Directory** は `apps/web` に設定する必要があります(デプロイボタンが自動で渡します。手動設定の場合は Project Settings で指定してください)。
|
||||
一方で、私たち自身が galgame、乙女ゲーム、FMV、AI ロールプレイといったゲームのヘビーユーザーでした。楽しみながらも、もし筋書きが固定された選択肢に縛られず、チャットアプリ越しの会話ではなく AI キャラクターと深く関われたら、どれほど愉快で刺激的だろうと想像していました。
|
||||
|
||||
もう一方で、私たちはたまたま大規模モデルの技術を少しばかり理解しており、AI でアイデアを素早く形にでき、技術の道筋や既存技術で実現できる製品の限界について、ささやかな考えを持っていました。
|
||||
|
||||
きっかけは 2026 年 4 月 22 日、[@zan2434](https://x.com/zan2434) たちが [flipbook](https://flipbook.page/) を公開したことでした。この全く新しいインタラクションの形に、私たちは驚き、心を躍らせました。
|
||||
そして 5 月のある日、意気投合し、こうした製品を作ろうと決めました —— かつて諦めた幻想を叶える手助けをしつつ、マルチモーダルモデルがもたらす新しいインタラクションの形を探るために。
|
||||
|
||||
プロジェクトはまだごく初期で、多くの機能が未完成です。[issue](https://github.com/zonghaoyuan/infiplot/issues) でのフィードバックを歓迎します。あるいは開発チームに加わって、一緒に新たな可能性を探り、あなた自身の好奇心を満たしてください。
|
||||
|
||||
お問い合わせ:hi@infiplot.com
|
||||
|
||||
**InfiPlot ベータ交流グループ**(QQ グループ番号 `575404333`)—— QR コードを読み取って参加し、フィードバックや共同開発にご参加ください:
|
||||
|
||||
<img src="apps/web/public/qq-group.webp" alt="InfiPlot ベータ交流グループ QQ QRコード" width="200" />
|
||||
|
||||
---
|
||||
|
||||
## 設定ガイド
|
||||
|
||||
InfiPlot は 4 種類のモデルプロバイダと通信します。**テキスト(Text)・ビジョン(Vision)は、任意の OpenAI 互換エンドポイント**(OpenAI、OpenAI 互換プロキシ経由の Anthropic、Gemini、OpenRouter、DeepSeek、ローカルの Ollama など)を使用でき、自由に組み合わせられます。**画像(Image)**は現在 **Runware**(OpenAI 互換ではなく、独自の task-array プロトコル)を使用します —— レイテンシとコストを総合的に考慮した選択です。**音声(TTS)**は **Xiaomi MiMo** の独自音声デザイン/クローンプロトコルを使用します(これも OpenAI 互換ではありません)—— キャラクターごとの音声デザイン、クローン、行ごとの抑揚指示に対応します。
|
||||
InfiPlot は 4 種類のモデルプロバイダと通信します。**テキスト(Text)・ビジョン(Vision)は、任意の OpenAI 互換エンドポイント**を使用でき、自由に組み合わせられます。**画像(Image)**は現在 **Runware**(OpenAI 互換ではなく、独自の task-array プロトコル)を使用します。**音声(TTS)**は **Xiaomi MiMo** の独自音声デザイン/クローンプロトコルを使用します —— キャラクターごとの音声デザイン、クローン、行ごとの抑揚指示に対応します。
|
||||
|
||||
**1. プロバイダを選ぶ**
|
||||
|
||||
@@ -91,58 +132,42 @@ InfiPlot は 4 種類のモデルプロバイダと通信します。**テキス
|
||||
| Text · ストーリー監督 | `TEXT_BASE_URL` `TEXT_API_KEY` `TEXT_MODEL` | ✅ | DeepSeek の `deepseek-v4-flash` |
|
||||
| Image · シーン描画 | `IMAGE_BASE_URL` `IMAGE_API_KEY` `IMAGE_MODEL` | ✅ | [Runware](https://runware.ai) の `runware:400@6`(FLUX.2 [klein] 9B KV) |
|
||||
| Vision · クリック解釈 | `VISION_BASE_URL` `VISION_API_KEY` `VISION_MODEL` | ✅ | Google の `gemini-3.5-flash` |
|
||||
| TTS · キャラクター音声 | `TTS_BASE_URL` `TTS_API_KEY` `TTS_SPEECH_MODEL` | 任意 —— 空欄なら無音で動作 | Xiaomi MiMo の `mimo-v2.5-tts`(独自プロトコル、OpenAI 互換ではない) |
|
||||
| TTS · キャラクター音声 | `TTS_BASE_URL` `TTS_API_KEY` `TTS_SPEECH_MODEL` | 任意 —— 空欄なら無音で動作 | Xiaomi MiMo の `mimo-v2.5-tts` |
|
||||
|
||||
**2. 環境変数を設定する**
|
||||
|
||||
Vercel プロジェクト(**Settings → Environment Variables**)、またはローカル実行時は `apps/web/.env.local` に設定します。9 つの変数が必須で、TTS は任意です(空欄なら無音で動作)。低コストなテスト用のフラグもあります。
|
||||
9 つの変数が必須で、TTS は任意です(空欄なら無音で動作)。低コストなテスト用のフラグもあります。
|
||||
|
||||
| 変数 | 効果 |
|
||||
|---|---|
|
||||
| `MOCK_IMAGE=true` | 画像生成をスキップし、レンダラが静的なプレースホルダを返します。ストーリー・音声・選択肢は通常どおり動作します。Runware のクレジットを消費せずに TTS を調整するのに最適です。 |
|
||||
|
||||
正確なフォーマットは `apps/web/.env.example` を参照してください。
|
||||
設定場所(正確なフォーマットは `apps/web/.env.example` を参照):
|
||||
|
||||
- **ローカル開発** —— `apps/web/.env.local`
|
||||
- **Vercel** —— Project Settings → Environment Variables
|
||||
- **Cloudflare Workers** —— `apps/web/` から各変数について `wrangler secret put <NAME>` を実行するか、ダッシュボード(Workers → infiplot → Settings → Variables and Secrets)で設定します。ステージング環境にアクセス制限を掛けたい場合は、Worker の前に [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/) を挟むと、ゼロコードでメール許可リスト方式の認証が利用できます。
|
||||
|
||||
**3. コストに注意**
|
||||
|
||||
推奨の 3 点セットでは、各**シーン**のコストの大半はテキスト LLM の呼び出しです。FLUX.2 [klein] 9B KV の画像は 1 シーンあたり概ね **\$0.00078**(1792×1024、4 ステップ、サブ秒)で、残りはテキスト呼び出しです(deepseek-v4-flash を使えば非常に安価)。シーン内のビートをタップしていくのは無料です。切り替えを一瞬に保つため、エンジンは**選ぶ可能性はあるが実際には選ばないシーンも先行生成**します —— そのため実際の支出は、あなたが実際に見るシーン数よりやや高くなります。標準ではレート制限も認証もありません —— デプロイを公開すれば、請求額にそのまま反映されます。広く共有する前に、制限を追加し(必要に応じてプリフェッチの深さを下げ)てください。
|
||||
推奨の 3 点セットでは、各シーンのコストは主に画像生成モデルによるものです。FLUX.2 [klein] 9B KV の画像は 1 シーンあたり概ね **$0.00078**(1792×1024、4 ステップ、サブ秒)。テキストモデルは `deepseek-v4-flash` を使用するため、テキストコストは比較になりません。シーン内のビートをタップしていくのは無料です。切り替えを一瞬に保つため、エンジンは選ぶ可能性はあるが最終的に選ばないシーンも先行生成します —— そのため実際の支出は、あなたが実際に見るシーン数よりやや高くなります。
|
||||
|
||||
---
|
||||
|
||||
## ロードマップ
|
||||
## Roadmap
|
||||
|
||||
- [ ] 知覚できないほどの低遅延
|
||||
- [ ] 生成遅延を体感できないレベルまで下げる
|
||||
- [ ] より多くのモデルプロバイダに対応
|
||||
- [ ] プレイ中の自由入力対応
|
||||
- [ ] モバイルブラウザ対応
|
||||
- [ ] 大半のプロバイダに対応
|
||||
- [ ] ユーザー登録・ログイン機能
|
||||
- [ ] 静止画から動画へのアップグレード
|
||||
- [ ] プレイ中の自由入力対応
|
||||
- [ ] 音声インタラクション
|
||||
- [ ] ストーリー共有機能
|
||||
- [ ] プレイ中のストーリーを共有
|
||||
- [ ] モバイルアプリ
|
||||
|
||||
---
|
||||
|
||||
## コントリビュート
|
||||
|
||||
Issue と Pull Request を歓迎します。InfiPlot をローカルで実行するには(Node 20+ と pnpm 9+ が必要):
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp apps/web/.env.example apps/web/.env.local # キーを記入 —— 設定ガイドを参照
|
||||
pnpm dev # http://localhost:3000 を開く
|
||||
```
|
||||
|
||||
コントリビュートすることで、あなたの貢献が AGPL-3.0 の下でライセンスされることに同意したものとみなされます。
|
||||
|
||||
---
|
||||
|
||||
## スター推移
|
||||
|
||||
[](https://star-history.com/#zonghaoyuan/infiplot&Date)
|
||||
|
||||
---
|
||||
|
||||
## ライセンス
|
||||
|
||||
[AGPL-3.0](LICENSE) © InfiPlot。コアは完全にオープンソースです。AGPL の「ネットワーク利用は配布とみなす」条項により、改変版をネットワークサービスとして運用する者は、そのソースコードも公開しなければなりません —— これによりコアをオープンに保ちつつ、将来のホスティング版や商用版の余地も残しています。
|
||||
|
||||
@@ -1,148 +1,173 @@
|
||||
English · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
|
||||
<div align="center">
|
||||
|
||||
# InfiPlot
|
||||
<img src="docs/banner.svg" alt="InfiPlot" width="100%">
|
||||
|
||||
> The first real-time, AI-generated interactive story game — describe the scene you've always fantasized about and watch it come alive in front of you: immersive, visual, and yours to step into. Every plot beat, every image, every character is designed and generated on the fly by a multimodal AI engine — all to grant that childhood wish of crossing over into the cartoon.
|
||||
<p><b>为你实时生成的互动剧情游戏</b></p>
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/zonghaoyuan/infiplot/stargazers)
|
||||
[](https://github.com/zonghaoyuan/infiplot/stargazers)
|
||||
[](https://github.com/zonghaoyuan/infiplot/watchers)
|
||||
[](https://github.com/zonghaoyuan/infiplot/network)
|
||||
[](https://github.com/zonghaoyuan/infiplot/issues)
|
||||
|
||||
**▶ Free to play during the beta, no setup required — [infiplot.com](https://infiplot.com)**
|
||||
[](https://infiplot.com)
|
||||
[](LICENSE)
|
||||
[](https://linux.do)
|
||||
|
||||
If you find this project interesting, a free ⭐ on the repo means the world to us — and it genuinely helps more people discover it. Thank you!
|
||||
[English](README.en.md) · 简体中文 · [日本語](README.ja.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What is InfiPlot
|
||||
## ⚡ 概览
|
||||
|
||||
InfiPlot is the world's first interactive story game with content generated by AI in real time. There are no pre-written plots, no pre-made characters, not even pre-recorded voices — everything is generated on demand, tailored to you. We're aiming for an experience on par with bishōjo games (galgame), otome games, and full-motion-video (FMV) games, while adding a personal, *participatory fantasy* — a richer, more immersive audio-visual experience that gives your imagination and curiosity free rein.
|
||||
InfiPlot是一款AI实时生成内容的互动剧情游戏,这里没有预设好的剧情、角色,所有内容都根据你的需求定制化的生成。
|
||||
|
||||
In one line: what we're building is an AI-generated, real-time take on *Love Is All Around* (《完蛋!我被美女包围了!》).
|
||||
用一句话说,我们要做的是一款用AI实时生成内容的《完蛋!我被美女包围了!》
|
||||
|
||||
Whether you're a six-year-old, a twenty-something, thirty-five, or sixty, there's a fantasy here that belongs to you and you alone:
|
||||
无论你是六岁的小朋友,20岁的年轻人,35岁的青年还是60岁的长者,都能在这里满足独属于你的幻想:
|
||||
|
||||
Learn magic in the world of Harry Potter; become the one everyone at school adores and confesses to; publish paper after paper in top journals and conferences with grant money to spare; step into *Empresses in the Palace* and live out the court intrigue; or return to your younger self and make a different choice about something you regret…
|
||||
穿越到哈利波特世界学习魔法、成为学校里所有异性青睐和表达爱意的对象、顶刊顶会发不停科研经费拿到手软、穿越到甄嬛传体验宫廷斗争、或者重返年轻为遗憾的事情重新做选择......
|
||||
|
||||
---
|
||||
|
||||
## Why we built it
|
||||
## 🌐 在线体验
|
||||
|
||||
We're a group of young people from Tsinghua University and other schools.
|
||||
|
||||
On one hand, we're longtime, devoted players of galgames, otome games, FMV, and AI role-play games. Even while enjoying them, we kept imagining how much more delightful and thrilling it would be if the story choices weren't fixed in advance — or if you could truly interact with an AI character in depth, instead of just texting it through a chat app.
|
||||
|
||||
On the other hand, we happen to know a little about large-model technology: enough to turn ideas into working software quickly with AI, and to have formed some modest views on the technical paths available and the limits of what today's tech can build.
|
||||
|
||||
The spark came on April 22, 2026, when [@zan2434](https://x.com/zan2434) and others released [flipbook](https://flipbook.page/). We were stunned and delighted by this entirely new form of interaction. So one day in May, we agreed on the spot to build something like this — both to help people live out the fantasies they'd once set aside, and to explore the new modes of interaction that multimodal models make possible.
|
||||
|
||||
The project is still very early and many features are far from polished. We'd love your feedback — open an [issue](https://github.com/zonghaoyuan/infiplot/issues), or join our dev team and explore the new possibilities with us, and satisfy your own curiosity.
|
||||
|
||||
Get in touch: hi@infiplot.com
|
||||
免费在线试玩,无需本地部署:[infiplot.com](https://infiplot.com)
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
## 一键部署
|
||||
|
||||
Built on text, image, and audio models, we've assembled a multi-agent framework to deliver on InfiPlot's goal. We split the agents into five roles — **Architect, Writer, Character Designer, Cinematographer, and Painter** — that work together to keep the plot coherent, the characters consistent, and the scenes continuous, all while making the story as compelling as we can.
|
||||
InfiPlot 同时支持部署到 Vercel 与 Cloudflare Workers —— 任选其一即可。
|
||||
|
||||
We call each complete playthrough a **story**.
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&root-directory=apps/web&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot%23%E9%85%8D%E7%BD%AE%E6%95%99%E7%A8%8B) [](https://deploy.workers.cloudflare.com/?url=https://github.com/zonghaoyuan/infiplot/tree/main/apps/web)
|
||||
|
||||
A story unfolds as a sequence of **scenes**. Each scene is one AI-painted background plus a short tree of **beats** — moments of narration, dialogue, and the occasional choice. You tap through a scene's beats and the image stays put; only when a choice leads somewhere genuinely new — another place, a new point of view, a jump in time — does the AI paint the next scene.
|
||||
部署完成后,填好环境变量 —— 详见下方的[配置教程](#配置教程)。两个平台都需要把项目根目录设为 `apps/web`(Vercel 一键部署按钮会自动带上;在 Cloudflare 上请把 build root 设为 `apps/web`,构建命令设为 `pnpm --filter @infiplot/web build:cf`)。
|
||||
|
||||
---
|
||||
|
||||
## 📸 游戏截图
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/4.webp"><img src="docs/screenshots/4.webp" width="280" alt="高中天台黄昏对话"></a></td>
|
||||
<td><a href="docs/screenshots/c3.webp"><img src="docs/screenshots/c3.webp" width="280" alt="夕阳下的告白"></a></td>
|
||||
<td><a href="docs/screenshots/c5.webp"><img src="docs/screenshots/c5.webp" width="280" alt="天台上的选择"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/c7.webp"><img src="docs/screenshots/c7.webp" width="280" alt="近景特写镜头"></a></td>
|
||||
<td><a href="docs/screenshots/a4.webp"><img src="docs/screenshots/a4.webp" width="280" alt="校园暮色"></a></td>
|
||||
<td><a href="docs/screenshots/5.webp"><img src="docs/screenshots/5.webp" width="280" alt="城市夜景"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/d2.webp"><img src="docs/screenshots/d2.webp" width="280" alt="赛博朋克霓虹巷"></a></td>
|
||||
<td><a href="docs/screenshots/f2.webp"><img src="docs/screenshots/f2.webp" width="280" alt="日系夜街相遇"></a></td>
|
||||
<td><a href="docs/screenshots/f5.webp"><img src="docs/screenshots/f5.webp" width="280" alt="深夜街角邂逅"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
基于文本、图像和音频模型,我们搭建了一个多智能体框架来实现InfiPlot的目标。我们把agent分为架构师、编剧、角色设计师、场景布置师和画家五个职能,让他们之间相互配合,在保证剧情连贯性、角色一致性、场景一致性的基础上,尽可能使得剧情足够富有吸引力。
|
||||
|
||||
我们把每一次游玩的整体体验称为故事(story)。
|
||||
|
||||
故事以一连串场景(scene)的形式展开。每个场景由一张 AI 绘制的背景图,加上一棵简短的节拍(beat)树组成 —— 也就是旁白、对话和偶尔出现的选项。你逐拍点过一个场景时,画面始终不变;只有当某个选项把你带到真正全新的地方 —— 换了空间、换了视角、跳跃了时间 —— AI 才会绘制下一幕场景。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U["Your input: world setting + art style"] --> A["Architect<br/>parses input → full story structure (first step)"]
|
||||
A --> W["Writer<br/>directs this scene's beats: narration · dialogue · choices"]
|
||||
subgraph SCENE["Generating one scene"]
|
||||
U["你的输入:世界设定 + 画风"] --> A["架构师 / Architect<br/>解析输入 → 完整剧情结构(第一步)"]
|
||||
A --> W["编剧 / Writer<br/>生成本幕节拍:旁白 · 对话 · 选项"]
|
||||
subgraph SCENE["每一幕场景的生成"]
|
||||
direction TB
|
||||
W --> C["Character Designer<br/>portrait + voice (parallel, per new character)"]
|
||||
W --> S["Cinematographer<br/>shot composition + background prompt"]
|
||||
C --> P["Painter<br/>renders the 16:9 background using portraits as reference"]
|
||||
W --> C["角色设计师 / Character Designer<br/>立绘 + 音色(每个新角色并行)"]
|
||||
W --> S["场景布置师 / Cinematographer<br/>镜头编排 + 背景提示词"]
|
||||
C --> P["画家 / Painter<br/>以立绘为参考渲染 16:9 背景"]
|
||||
S --> P
|
||||
end
|
||||
P --> SC["One scene: background image + beat tree"]
|
||||
SC -. speculatively pre-generate the next scene .-> W
|
||||
P --> SC["一幕场景:背景图 + 节拍树"]
|
||||
SC -. 预测式预生成下一幕 .-> W
|
||||
```
|
||||
|
||||
While you're reading one scene, the engine **speculatively generates the scenes your choices could lead to** — and, for unavoidable next steps, the scene after that. By the time you pick a direction, its image is usually already painted, so the cut feels instant. If you still notice some lag today, don't worry — we're working hard to bring it down.
|
||||
当你正在阅读一幕场景时,引擎会预测式地生成你的选项可能通向的那些场景 —— 对于无法回避的下一步,还会再往前生成一幕。等你真正选定方向时,那一幕的图通常已经画好了,于是切换瞬间完成、毫无停顿。如果你现在仍然感到有些延迟,别担心,我们正在努力优化它。
|
||||
|
||||
Clicking the background itself (not a button) routes through a **vision** model: it reads where you tapped and decides whether you're exploring the current scene (it inserts a beat — no new image) or moving on (a new scene). This builds on a valuable lesson we learned from flipbook, and we believe it will become one of InfiPlot's defining features — taking the experience to the next level.
|
||||
直接点击背景本身(而非按钮)会走一个视觉(vision)模型:它读取你点击的位置,判断你是在探索当前场景(于是插入一个节拍 —— 不生成新图),还是要继续前进(生成一幕新场景)。这是基于我们从flipbook那里学到的宝贵认知,我们相信这个功能会在未来成为InfiPlot的关键功能,让你的游玩体验更上一层楼。
|
||||
|
||||
There is no traditional game UI baked into the art. The AI paints the world in whatever style you pick — "stick figure on grid paper" or "cyberpunk noir" — and the dialogue panel and choice buttons are a light HTML layer drawn on top, tuned to sit over the scene. In other words, the UI fits the story of each playthrough, rather than staying the same every time.
|
||||
未来,画面里将没有烤进任何传统的游戏 UI。AI 会用你选择的任意风格来描绘整个世界 —— 「方格纸上的火柴人」也好,「赛博朋克黑色电影」也罢 —— 而对话框和选项按钮,只是叠在画面之上、并为贴合场景而精心调校过的一层轻量 HTML。也就是说,每次游玩时,UI都会契合当前的故事,而不是一成不变。
|
||||
|
||||
---
|
||||
|
||||
## One-click deploy
|
||||
## 团队与愿景
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&root-directory=apps/web&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot%23configuration-guide)
|
||||
我们是一群来自清华大学等高校的年轻人。
|
||||
|
||||
After deploy, set your environment variables in the Vercel project — see the [Configuration guide](#configuration-guide) below. The Vercel project's **Root Directory** must be `apps/web` (the deploy button passes this automatically; if you configure manually, set it in Project Settings).
|
||||
一方面,我们本来就是galgame、乙女游戏、FMV、AI角色扮演游戏这类游戏的深度用户,在享受游戏体验的同时,也会想象如果能选择不被预设的剧情选项,或者和对话的AI角色深度互动而不只是通过聊天软件聊天,该是多么愉快刺激的体验。
|
||||
|
||||
另一方面,我们恰好又对大模型技术有些了解,能用AI快速实现想法,对技术路线和基于已有技术的产品能力边界有一些浅薄的思考。
|
||||
|
||||
契机发生在 2026 年 4 月 22 日,[@zan2434](https://x.com/zan2434) 等人发布了 [flipbook](https://flipbook.page/),我们对这种全新的交互形态感到震惊和欣喜。
|
||||
于是在 5 月的某一天,我们一拍即合,决定做一款这样的产品,既帮助大家满足那些曾经遗憾过的幻想,又能够探索多模态模型所带来的新的交互形态。
|
||||
|
||||
目前我们的项目还很早期,有许多功能尚不完善,欢迎提交 [issues](https://github.com/zonghaoyuan/infiplot/issues) 反馈问题,或者加入我们的开发团队一起探索新的可能性,满足你的好奇心。
|
||||
|
||||
联系方式:hi@infiplot.com
|
||||
|
||||
欢迎扫码加入 **InfiPlot 内测交流群**(QQ 群号 `575404333`),一起反馈体验、参与共建:
|
||||
|
||||
<img src="apps/web/public/qq-group.webp" alt="InfiPlot 内测交流群 QQ 二维码" width="200" />
|
||||
|
||||
---
|
||||
|
||||
## Configuration guide
|
||||
## 配置教程
|
||||
|
||||
InfiPlot talks to four kinds of model providers. **Text and Vision use any OpenAI-compatible endpoint** (OpenAI, Anthropic via an OpenAI-compat proxy, Gemini, OpenRouter, DeepSeek, local Ollama, …), so you can mix and match freely. **Image** currently goes to **Runware** (its own task-array protocol, not OpenAI-compatible), chosen for its combination of latency and cost. **TTS** uses **Xiaomi MiMo**'s own voice design / clone protocol (also not OpenAI-compatible) — per-character voice design, clone, and per-line delivery direction.
|
||||
InfiPlot 会与四类模型供应商通信。**文本(Text)和视觉(Vision)都使用 OpenAI 兼容的接口**,可以自由搭配。**图像(Image)**目前接入 **Runware**(其自有的 task-array 协议,并非 OpenAI 兼容)。**语音(TTS)**使用**小米 MiMo** 自有的音色设计/克隆协议——支持角色级音色设计、克隆与逐行演绎指导。
|
||||
|
||||
**1. Choose your providers**
|
||||
**1. 选择你的供应商**
|
||||
|
||||
| Provider | Variables | Required? | Recommended |
|
||||
| 供应商 | 环境变量 | 是否必填 | 推荐 |
|
||||
|---|---|---|---|
|
||||
| Text · story director | `TEXT_BASE_URL` `TEXT_API_KEY` `TEXT_MODEL` | ✅ | `deepseek-v4-flash` via DeepSeek |
|
||||
| Image · scene renderer | `IMAGE_BASE_URL` `IMAGE_API_KEY` `IMAGE_MODEL` | ✅ | `runware:400@6` (FLUX.2 [klein] 9B KV) via [Runware](https://runware.ai) |
|
||||
| Vision · click reader | `VISION_BASE_URL` `VISION_API_KEY` `VISION_MODEL` | ✅ | `gemini-3.5-flash` via Google |
|
||||
| TTS · per-character voice | `TTS_BASE_URL` `TTS_API_KEY` `TTS_SPEECH_MODEL` | optional — leave blank to run silently | `mimo-v2.5-tts` via Xiaomi MiMo (own protocol, not OpenAI-compat) |
|
||||
| Text · 剧情导演 | `TEXT_BASE_URL` `TEXT_API_KEY` `TEXT_MODEL` | ✅ | DeepSeek 的 `deepseek-v4-flash` |
|
||||
| Image · 场景渲染 | `IMAGE_BASE_URL` `IMAGE_API_KEY` `IMAGE_MODEL` | ✅ | [Runware](https://runware.ai) 的 `runware:400@6`(FLUX.2 [klein] 9B KV) |
|
||||
| Vision · 点击解读 | `VISION_BASE_URL` `VISION_API_KEY` `VISION_MODEL` | ✅ | Google 的 `gemini-3.5-flash` |
|
||||
| TTS · 角色配音 | `TTS_BASE_URL` `TTS_API_KEY` `TTS_SPEECH_MODEL` | 可选 —— 留空则静音运行 | 小米 MiMo 的 `mimo-v2.5-tts` |
|
||||
|
||||
**2. Set the environment variables**
|
||||
**2. 填写环境变量**
|
||||
|
||||
Set these in your Vercel project (**Settings → Environment Variables**), or in `apps/web/.env.local` for local runs. Nine variables are required; TTS is optional (leave blank to run silently). There's also a flag for cheap testing:
|
||||
九个变量为必填;TTS 可选(留空则静音运行)。此外还有一个用于低成本测试的开关:
|
||||
|
||||
| Variable | Effect |
|
||||
| 变量 | 作用 |
|
||||
|---|---|
|
||||
| `MOCK_IMAGE=true` | Skip image generation; the renderer returns a static placeholder. Story, voice, and choices still run normally. Great for iterating on TTS without burning Runware credits. |
|
||||
| `MOCK_IMAGE=true` | 跳过图像生成,渲染器返回一张静态占位图。剧情、语音、选项照常运行。非常适合在不消耗 Runware 额度的情况下调试 TTS。 |
|
||||
|
||||
See `apps/web/.env.example` for the exact shape.
|
||||
在哪里设置(确切字段见 `apps/web/.env.example`):
|
||||
|
||||
**3. Mind the cost**
|
||||
- **本地开发** —— `apps/web/.env.local`
|
||||
- **Vercel** —— Project Settings → Environment Variables
|
||||
- **Cloudflare Workers** —— 在 `apps/web/` 目录下逐个执行 `wrangler secret put <NAME>`,或在 dashboard 里设置(Workers → infiplot → Settings → Variables and Secrets)。如果要给 staging 加访问限制,可以在 Worker 前面挂一个 [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/)(零代码,邮箱白名单)。
|
||||
|
||||
With the recommended trio, each **scene** is dominated by the text-LLM call. The FLUX.2 [klein] 9B KV image is roughly **\$0.00078** per scene (1792×1024, 4 steps, sub-second); the text call is the rest (very cheap with deepseek-v4-flash). Tapping through a scene's beats is free. To keep transitions instant, the engine also **pre-generates scenes you might pick but don't** — so real spend runs somewhat higher than the scenes you actually see. There is no rate limiting or auth out of the box — if you make your deployment public, your bill will reflect that. Add limits (and consider lowering the prefetch depth) before sharing widely.
|
||||
**3. 注意成本**
|
||||
|
||||
使用推荐的三件套时,每一幕场景的开销主要来自图像生成模型。FLUX.2 [klein] 9B KV 的图像大约 **$0.00078** 一张(1792×1024,4 步,亚秒级);文本模型使用 `deepseek-v4-flash` 时,成本极低。逐拍点过一个场景是免费的。为了让切换瞬间完成,引擎还会预测式地生成那些你可能选、但最终可能没选的场景 —— 所以真实花费会比你实际看到的场景数略高一些。
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Latency you can't perceive
|
||||
- [ ] Mobile browser support
|
||||
- [ ] Compatibility with most providers
|
||||
- [ ] User accounts and login
|
||||
- [ ] Upgrade from static images to motion video
|
||||
- [ ] Free-form player input mid-story
|
||||
- [ ] Voice interaction
|
||||
- [ ] Story sharing
|
||||
- [ ] Mobile app
|
||||
- [ ] 让用户感知不到生成延迟
|
||||
- [ ] 兼容更多模型 provider
|
||||
- [ ] 游玩过程中支持用户自定义输入
|
||||
- [ ] 移动端浏览器适配
|
||||
- [ ] 用户注册登录系统
|
||||
- [ ] 由静态图升级为动态视频
|
||||
- [ ] 语音交互
|
||||
- [ ] 分享正在游玩的故事
|
||||
- [ ] 移动端 app
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome. To run InfiPlot locally (Node 20+, pnpm 9+):
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp apps/web/.env.example apps/web/.env.local # fill in your keys — see the Configuration guide
|
||||
pnpm dev # open http://localhost:3000
|
||||
```
|
||||
|
||||
By contributing, you agree that your contributions are licensed under the AGPL-3.0.
|
||||
|
||||
---
|
||||
|
||||
## Star history
|
||||
## Star 趋势
|
||||
|
||||
[](https://star-history.com/#zonghaoyuan/infiplot&Date)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE) © InfiPlot. The core is fully open source. AGPL's "network use is distribution" clause means anyone who runs a modified version as a network service must also publish their source — this keeps the core open while leaving room for a future hosted or commercial edition.
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
[English](README.md) · 简体中文 · [日本語](README.ja.md)
|
||||
|
||||
# InfiPlot
|
||||
|
||||
> 第一款 AI 实时生成的交互式剧情游戏 —— 输入你幻想中的场景,然后让它沉浸式、可视化地呈现在你面前,并且亲身参与其中。每一幕剧情、每一张图片、每一个角色,都由多模态AI引擎实时设计和生成,只为满足你儿时那个穿越进动画里的梦想。
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/zonghaoyuan/infiplot/stargazers)
|
||||
|
||||
**▶ 内测期间,免费在线试玩,无需本地部署 —— [infiplot.com](https://infiplot.com)**
|
||||
|
||||
如果你觉得这个项目有意思,麻烦给仓库点一个免费的star ⭐ 这是对我们努力的最大肯定,也能实实在在地帮助更多人发现它。谢谢你!
|
||||
|
||||
---
|
||||
|
||||
## 项目介绍
|
||||
|
||||
InfiPlot是世界上第一款实现了AI实时生成内容的互动剧情游戏,这里没有预设好的剧情、角色,甚至没有预设好的音色,所有内容都根据你的需求定制化的生成。我们力求实现比肩美少女游戏(galgame)、乙女游戏(Otome games)和全动态真人互动游戏(FMV)的体验,同时又能提供个性化的”参与式幻想”,让视听体验更沉浸,充分满足你的想象力和好奇心。
|
||||
|
||||
用一句话说,我们要做的是一款用AI实时生成内容的《完蛋!我被美女包围了!》
|
||||
|
||||
无论你是六岁的小朋友,20岁的年轻人,35岁的青年还是60岁的长者,都能在这里满足独属于你的幻想:
|
||||
|
||||
穿越到哈利波特世界学习魔法、成为学校里所有异性青睐和表达爱意的对象、顶刊顶会发不停科研经费拿到手软、穿越到甄嬛传体验宫廷斗争、或者重返年轻为遗憾的事情重新做选择......
|
||||
|
||||
---
|
||||
|
||||
## 开发初衷
|
||||
|
||||
我们是一群来自清华大学等高校的年轻人。
|
||||
|
||||
一方面,我们本来就是galgame、乙女游戏、FMV、AI角色扮演游戏这类游戏的深度用户,在享受游戏体验的同时,也会想象如果能选择不被预设的剧情选项,或者和对话的AI角色深度互动而不只是通过聊天软件聊天,该是多么愉快刺激的体验。
|
||||
|
||||
另一方面,我们恰好又对大模型技术有些了解,能用AI快速实现想法,对技术路线和基于已有技术的产品能力边界有一些浅薄的思考。
|
||||
|
||||
契机发生在 2026 年 4 月 22 日,[@zan2434](https://x.com/zan2434) 等人发布了 [flipbook](https://flipbook.page/),我们对这种全新的交互形态感到震惊和欣喜。于是在 5 月的某一天,我们一拍即合,决定做一款这样的产品,既帮助大家满足那些曾经遗憾过的幻想,又能够探索多模态模型所带来的新的交互形态。
|
||||
|
||||
目前我们的项目还很早期,有许多功能尚不完善,欢迎提交 [issues](https://github.com/zonghaoyuan/infiplot/issues) 反馈问题,或者加入我们的开发团队一起探索新的可能性,满足你的好奇心。
|
||||
|
||||
联系方式:hi@infiplot.com
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
基于文本、图像和音频模型,我们搭建了一个多智能体框架来实现InfiPlot的目标。我们把agent分为架构师、编剧、角色设计师、场景布置师和画家五个职能,让他们之间相互配合,在保证剧情连贯性、角色一致性、场景一致性的基础上,尽可能使得剧情足够富有吸引力。
|
||||
|
||||
我们把每一次游玩的整体体验称为故事(story)。
|
||||
|
||||
故事以一连串**场景(scene)**的形式展开。每个场景由一张 AI 绘制的背景图,加上一棵简短的**节拍(beat)**树组成 —— 也就是旁白、对话和偶尔出现的选项。你逐拍点过一个场景时,画面始终不变;只有当某个选项把你带到真正全新的地方 —— 换了空间、换了视角、跳跃了时间 —— AI 才会绘制下一幕场景。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U["你的输入:世界设定 + 画风"] --> A["架构师 / Architect<br/>解析输入 → 完整剧情结构(第一步)"]
|
||||
A --> W["编剧 / Writer<br/>生成本幕节拍:旁白 · 对话 · 选项"]
|
||||
subgraph SCENE["每一幕场景的生成"]
|
||||
direction TB
|
||||
W --> C["角色设计师 / Character Designer<br/>立绘 + 音色(每个新角色并行)"]
|
||||
W --> S["场景布置师 / Cinematographer<br/>镜头编排 + 背景提示词"]
|
||||
C --> P["画家 / Painter<br/>以立绘为参考渲染 16:9 背景"]
|
||||
S --> P
|
||||
end
|
||||
P --> SC["一幕场景:背景图 + 节拍树"]
|
||||
SC -. 预测式预生成下一幕 .-> W
|
||||
```
|
||||
|
||||
当你正在阅读一幕场景时,引擎会**预测式地生成你的选项可能通向的那些场景** —— 对于无法回避的下一步,还会再往前生成一幕。等你真正选定方向时,那一幕的图通常已经画好了,于是切换瞬间完成、毫无停顿。如果你现在仍然感到有些延迟,别担心,我们正在努力优化它。
|
||||
|
||||
直接点击背景本身(而非按钮)会走一个**视觉(vision)**模型:它读取你点击的位置,判断你是在探索当前场景(于是插入一个节拍 —— 不生成新图),还是要继续前进(生成一幕新场景)。这是基于我们从flipbook那里学到的宝贵认知,我们相信这个功能会在未来成为InfiPlot的关键功能,让你的游玩体验更上一层楼。
|
||||
|
||||
未来,画面里将没有烤进任何传统的游戏 UI。AI 会用你选择的任意风格来描绘整个世界 —— 「方格纸上的火柴人」也好,「赛博朋克黑色电影」也罢 —— 而对话框和选项按钮,只是叠在画面之上、并为贴合场景而精心调校过的一层轻量 HTML。也就是说,每次游玩时,UI都会契合当前的故事,而不是一成不变。
|
||||
|
||||
---
|
||||
|
||||
## 一键部署
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/zonghaoyuan/infiplot&root-directory=apps/web&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL,TTS_BASE_URL,TTS_API_KEY,TTS_SPEECH_MODEL,MOCK_IMAGE&envDescription=Three%20required%20providers%20%2B%20optional%20TTS.%20Any%20OpenAI-compatible%20endpoint%20works%20for%20text%2Fvision.%20TTS%20uses%20MiMo%27s%20own%20protocol.&envLink=https://github.com/zonghaoyuan/infiplot%23configuration-guide)
|
||||
|
||||
部署完成后,在 Vercel 项目里填好环境变量 —— 详见下方的[配置教程](#配置教程)。Vercel 项目的 **Root Directory** 必须设为 `apps/web`(一键部署按钮会自动带上;若手动配置,请在 Project Settings 里设置)。
|
||||
|
||||
---
|
||||
|
||||
## 配置教程
|
||||
|
||||
InfiPlot 会与四类模型供应商通信。**文本(Text)和视觉(Vision)都使用 OpenAI 兼容的接口**(OpenAI、通过 OpenAI 兼容代理的 Anthropic、Gemini、OpenRouter、DeepSeek、本地 Ollama……),可以自由搭配。**图像(Image)**目前接入 **Runware**(其自有的 task-array 协议,并非 OpenAI 兼容),因为延迟和成本的叠加考量。**语音(TTS)**使用**小米 MiMo** 自有的音色设计/克隆协议(同样不是 OpenAI 兼容)——支持角色级音色设计、克隆与逐行演绎指导。
|
||||
|
||||
**1. 选择你的供应商**
|
||||
|
||||
| 供应商 | 环境变量 | 是否必填 | 推荐 |
|
||||
|---|---|---|---|
|
||||
| Text · 剧情导演 | `TEXT_BASE_URL` `TEXT_API_KEY` `TEXT_MODEL` | ✅ | DeepSeek 的 `deepseek-v4-flash` |
|
||||
| Image · 场景渲染 | `IMAGE_BASE_URL` `IMAGE_API_KEY` `IMAGE_MODEL` | ✅ | [Runware](https://runware.ai) 的 `runware:400@6`(FLUX.2 [klein] 9B KV) |
|
||||
| Vision · 点击解读 | `VISION_BASE_URL` `VISION_API_KEY` `VISION_MODEL` | ✅ | Google 的 `gemini-3.5-flash` |
|
||||
| TTS · 角色配音 | `TTS_BASE_URL` `TTS_API_KEY` `TTS_SPEECH_MODEL` | 可选 —— 留空则静音运行 | 小米 MiMo 的 `mimo-v2.5-tts`(自有协议,非 OpenAI 兼容) |
|
||||
|
||||
**2. 填写环境变量**
|
||||
|
||||
在 Vercel 项目里设置(**Settings → Environment Variables**),或在本地运行时写进 `apps/web/.env.local`。九个变量为必填;TTS 可选(留空则静音运行)。此外还有一个用于低成本测试的开关:
|
||||
|
||||
| 变量 | 作用 |
|
||||
|---|---|
|
||||
| `MOCK_IMAGE=true` | 跳过图像生成,渲染器返回一张静态占位图。剧情、语音、选项照常运行。非常适合在不消耗 Runware 额度的情况下调试 TTS。 |
|
||||
|
||||
确切的字段格式见 `apps/web/.env.example`。
|
||||
|
||||
**3. 注意成本**
|
||||
|
||||
使用推荐的三件套时,每一幕**场景**的开销主要来自文本 LLM 调用。FLUX.2 [klein] 9B KV 的图像大约 **\$0.00078** 一张(1792×1024,4 步,亚秒级);其余主要是文本调用(使用 `deepseek-v4-flash` 模型时成本极低)。逐拍点过一个场景是免费的。为了让切换瞬间完成,引擎还会**预先生成那些你可能选、但最终没选的场景** —— 所以真实花费会比你实际看到的场景数略高一些。开箱状态下没有任何限流或鉴权 —— 如果你把部署公开出去,账单会如实反映这一点。在大范围分享之前,请先加上限流(并酌情降低预取深度)。
|
||||
|
||||
---
|
||||
|
||||
## 路线图
|
||||
|
||||
- [ ] 用户感知不到的延迟
|
||||
- [ ] 移动端浏览器适配
|
||||
- [ ] 兼容大多数 provider
|
||||
- [ ] 用户注册登录系统
|
||||
- [ ] 由静态图升级为动态视频
|
||||
- [ ] 游玩过程中支持用户自定义输入
|
||||
- [ ] 语音交互
|
||||
- [ ] 分享故事的功能
|
||||
- [ ] 移动端 app
|
||||
|
||||
---
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request。在本地运行 InfiPlot(需要 Node 20+ 和 pnpm 9+):
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp apps/web/.env.example apps/web/.env.local # 填入你的密钥 —— 参见「配置教程」
|
||||
pnpm dev # 打开 http://localhost:3000
|
||||
```
|
||||
|
||||
提交贡献即表示你同意你的贡献以 AGPL-3.0 协议授权。
|
||||
|
||||
---
|
||||
|
||||
## Star 趋势
|
||||
|
||||
[](https://star-history.com/#zonghaoyuan/infiplot&Date)
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
[AGPL-3.0](LICENSE) © InfiPlot。内核完全开源。AGPL 的「网络使用即分发」条款意味着:任何人若将修改后的版本作为网络服务运行,也必须公开其源代码 —— 这既让内核保持开放,又为未来的托管版或商业版本留下了空间。
|
||||
@@ -6,6 +6,11 @@ import { loadEngineConfig } from "@/lib/config";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
// Browser annotator resizes to 768 wide → typically 200-800 KB base64.
|
||||
// 3 MB caps abusive direct-API payloads (which would inflate upstream
|
||||
// vision LLM costs) while leaving ~4x headroom for legitimate inputs.
|
||||
const MAX_ANNOTATED_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: VisionRequest;
|
||||
try {
|
||||
@@ -14,12 +19,27 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.session || !body.prevImageUrl || !body.click) {
|
||||
if (!body.session) {
|
||||
return NextResponse.json(
|
||||
{ error: "session, prevImageUrl, click are required" },
|
||||
{ error: "session is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof body.annotatedImageBase64 !== "string" ||
|
||||
body.annotatedImageBase64.length === 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "annotatedImageBase64 must be a non-empty string" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (body.annotatedImageBase64.length > MAX_ANNOTATED_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `annotatedImageBase64 exceeds ${MAX_ANNOTATED_BYTES} bytes` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = loadEngineConfig();
|
||||
|
||||
@@ -60,74 +60,94 @@ const OPTS: Opt[] = [
|
||||
{ label: "内容节奏", items: ["慢热细腻", "紧凑爽快"], defaultIndex: 1 },
|
||||
];
|
||||
|
||||
type StoryContent = { title: string; outline: string };
|
||||
type StoryContent = { title: string; outline: string; style: string };
|
||||
|
||||
/* 每个性向 30 篇预设剧情,与图片 /home/{m|f}{i}.webp 按索引一一对应。
|
||||
const STYLE_MAP: Record<string, string> = {
|
||||
二次元: "唯美二次元动漫插画,日系 galgame 精致质感,柔和温暖的自然光照。",
|
||||
吉卜力: "吉卜力工作室风格,手绘动画质感,柔和水彩底色,温暖治愈的氛围。",
|
||||
真实系: "真实电影感,柔和自然光照,胶片颗粒。",
|
||||
超写实: "超写实人像与场景,电影级布光,皮肤与材质细节精致。",
|
||||
水彩: "水彩插画,湿润晕染笔触,纸纹底色。",
|
||||
像素风: "像素风格,复古游戏 16-bit 调色,方块化几何造型。",
|
||||
日系动画: "现代日系动画 cel-shading,硬光阴影分层,赛璐璐风。",
|
||||
"3D 渲染": "3D 渲染卡通风格,柔和次表面散射,干净的电影级布光。",
|
||||
蒸汽朋克: "蒸汽朋克美学,铜色齿轮与蒸汽,工业革命氛围。",
|
||||
玄幻: "国风玄幻插画,仙气缭绕,群山烟雨与神兽萦绕。",
|
||||
国风水墨: "国潮唯美古风插画,水墨微晕渲染,仙侠浪漫色彩,极具东方神韵。",
|
||||
赛博朋克: "赛博朋克都市,霓虹反射湿润街道,电子义体高光。",
|
||||
};
|
||||
|
||||
/* 每个性向 32 篇预设剧情(红果短视频式开场钩子)。与封面 /home/{m|f}{i}.webp 按索引
|
||||
一一对应;style 字段决定点卡片进入 /play 时使用的画风(对应 styleMap 的 12 种风格)。
|
||||
男/女同索引共享画面尺寸,切性向 crossfade 时卡片高度不跳变。 */
|
||||
const STORIES: Record<Gender, StoryContent[]> = {
|
||||
男性向: [
|
||||
{ title: "樱の约定", outline: "樱花纷飞的黄昏,他终于鼓起勇气,向并肩走过六年的青梅竹马说出那句话……" },
|
||||
{ title: "锈色边境", outline: "漫天黄沙的废土,机械心脏在胸腔中沉重轰鸣。我从钢铁山中挖出一个完好的休眠舱……" },
|
||||
{ title: "云海仙踪", outline: "凡骨少年偶得神秘残碑,登顶云海仙山,神魔同修之路自此开启。" },
|
||||
{ title: "六月雨季", outline: "南方县城的多雨六月,转学第一天,注意到那个总在天台读诗的同学。雨水打湿了未送出的伞……" },
|
||||
{ title: "雨夜霓虹", outline: "2087 年东亚特区的酸雨之夜,丢失了三天记忆的我,手腕终端响起一通匿名警告:「他们来找你了」。" },
|
||||
{ title: "学院秘闻", outline: "深夜图书馆地下密室,清冷孤僻的班长跪在圆环阵法前,吟诵着不属于人类的咒词。" },
|
||||
{ title: "异界召唤", outline: "再睁眼,没有班主任,只有昏暗的魔法阵与一位哭得梨花带雨的圣女:「勇者大人,请拯救这个世界。」" },
|
||||
{ title: "花火之夜", outline: "夏祭的夜空下,浴衣女孩与你约定,今晚最后一发烟火,要一起看完。" },
|
||||
{ title: "霓虹之外", outline: "漂浮的飞车与古老方块字的全息广告——这是赛博东亚的另一种黎明。" },
|
||||
{ title: "放学后的车站", outline: "夕阳染红的乡间月台,无人列车迟迟未来,你和她沉默并立。" },
|
||||
{ title: "星辰咒语", outline: "古老图书馆深处,星纹长袍下的法师女孩低声念出禁咒。" },
|
||||
{ title: "战姬启动", outline: "紧急警报红光中,少女握紧操纵杆——决战时刻已到。" },
|
||||
{ title: "街灯之下", outline: "午夜独行的女侦探,雨雾中藏着尚未揭晓的真相。" },
|
||||
{ title: "全息伞下", outline: "霓虹雨夜,两人共撑全息伞——这一次,是道别还是开始?" },
|
||||
{ title: "竹林之约", outline: "竹林深处的快意一战,落叶纷飞——谁先收剑?" },
|
||||
{ title: "暗夜王座", outline: "烛光摇曳的古老王座之上,公主等待着她唯一的回信。" },
|
||||
{ title: "放学独白", outline: "阳光斜射的空教室,最后一个学生在笔记本上写着什么?" },
|
||||
{ title: "第七封信", outline: "樱花树下展开的信纸,淡淡的笔迹,字字千钧。" },
|
||||
{ title: "月神降临", outline: "银发倾泻、极光环绕——传说中的月神,今夜降临凡间。" },
|
||||
{ title: "血月武士", outline: "血色满月之下,刀光与樱瓣同时落下。" },
|
||||
{ title: "森林女巫", outline: "烛光摇曳的森林小屋,女巫熬制着能改变命运的魔药。" },
|
||||
{ title: "夏日海岸", outline: "粉橙色的夕阳,两个挚友坐在海岸边,把秘密轻轻放进海风里。" },
|
||||
{ title: "屏幕之间", outline: "霓虹青光映在脸上,全屏代码下藏着被遗忘的真相。" },
|
||||
{ title: "雨夜客栈", outline: "雨夜投宿的破败客栈,邻桌蒙面女子的剑匣里,似乎封着一段江湖恩怨。" },
|
||||
{ title: "深空警报", outline: "殖民舰舰桥警报骤响,舷窗外那颗未知行星正泛起诡异的红光。" },
|
||||
{ title: "上海滩暗号", outline: "1936 年的上海滩,留声机旋律里,舞女递来一张写着暗号的牌。" },
|
||||
{ title: "三长两短", outline: "末世第 173 天,卷帘门外的抓挠声停了,取而代之的是规律的敲门——三长两短。" },
|
||||
{ title: "正午对决", outline: "正午烈日下的无人小镇,唯一的酒馆门口,一个陌生枪手正等着与我决斗。" },
|
||||
{ title: "万米之城", outline: "潜水钟沉入万米海沟,探照灯扫过的不是岩壁,而是一座沉睡的远古之城。" },
|
||||
{ title: "云上海盗", outline: "齿轮轰鸣的飞空艇甲板,云海之上,海盗的黑色气球正逼近舷侧。" },
|
||||
{ title: "战神归来", outline: "五年前我战死边境,灵柩送回家时她抱着儿子改嫁了。今天我站在他们的婚礼门口,新郎刚要骂人,跪在他面前的二十个保镖喊了我一声「上将」。", style: "真实系" },
|
||||
{ title: "神医归乡", outline: "在城里被嘲笑成「江湖野医生」的我,回了一趟老家。村口的老人见到我直接哭了:「您终于回来了,您当年的师父…病了。」其实他们不知道,我现在是国手第一。", style: "吉卜力" },
|
||||
{ title: "赘婿亮剑", outline: "岳父大寿,我端着茶被全场嫌弃,一句「废物」让我滚出去。门外停着九辆悬挂军牌的劳斯莱斯,下来的人朝我深深一鞠躬:「少爷,集团等您回去签字。」", style: "真实系" },
|
||||
{ title: "送外卖的少主", outline: "你以为我是给你送了三个月外卖的那个小哥?昨晚有人对我说:「少主,您隐姓埋名的三年,到了。」——而你昨天还笑我连一杯咖啡都买不起。", style: "二次元" },
|
||||
{ title: "兵王食言", outline: "退役那天我答应过队长:「这辈子不再开枪。」但你今天在我面前打了她一巴掌,那我食言一次。", style: "真实系" },
|
||||
{ title: "重生分手前夜", outline: "凌晨四点,我醒在我们分手的那个夜晚——她正打开门要走。这一次,我先把戒指递了出去:「分手,但戒指你拿好,下个月你会用到它。」", style: "日系动画" },
|
||||
{ title: "重生回到高考前", outline: "我重生回到高考前一周。这一次,我提前知道了每一道压轴题,也知道了——三天后,她会在天台上跳下去。", style: "吉卜力" },
|
||||
{ title: "墓前签到", outline: "我每天去亡妻的墓地签到,第七天,系统弹出一行字:「奖励到账:未亡人 × 1。」墓碑后走出一个长得和她一模一样的姑娘:「你是…谁?」", style: "二次元" },
|
||||
{ title: "凌晨四点抽卡", outline: "凌晨三点,我十连抽 SSR 出货,光柱从屏幕里溢出来。客厅响起脚步声,一个穿着我 T 恤的女人揉着眼睛走出来:「老公,你也太晚了。」", style: "3D 渲染" },
|
||||
{ title: "系统选妃", outline: "系统给了我七个未婚妻候选,每错一个,地图上就有一座城被抹掉。倒计时 30 秒,她们七个同时朝我看过来。", style: "二次元" },
|
||||
{ title: "穿成废柴皇子", outline: "睁眼是冷宫废柴皇子,太监正在念赐死圣旨。我笑了——上辈子读的那本《这就是大唐》,是我自己写的。", style: "国风水墨" },
|
||||
{ title: "穿成乙游男配", outline: "我穿成了乙游里第一章就被处刑的反派男配。倒计时三个月。可女主她…昨天竟然主动来找我了。", style: "二次元" },
|
||||
{ title: "毒酒之后", outline: "睁眼是 1928 年,我刚被亲弟弟下毒,倒在少帅府的红毯上。门外军靴声逼近——他来确认我是不是真死了。", style: "真实系" },
|
||||
{ title: "九重雷劫", outline: "修了三百年,今夜九重雷劫降下。第八道劫雷劈开时,我看见劫云之上,那个一直在偷偷护我的人,竟是她。", style: "玄幻" },
|
||||
{ title: "山门扫地僧", outline: "我在山门扫地三十年,谁都看不起我。今日魔尊踏破山门,宗主跪地求饶。我抬头:「让一让,我去扫他。」", style: "国风水墨" },
|
||||
{ title: "末世第一夜", outline: "同寝的兄弟开始啃我的脖子。我抬手将他甩开——指尖滴下的血珠悬在半空,凝结成了一柄银白小剑。", style: "真实系" },
|
||||
{ title: "雷霆觉醒", outline: "雷劈不死的第七天,我握紧了拳头。掌心炸开一道闪电,把面前的丧尸群一齐劈成了灰。", style: "赛博朋克" },
|
||||
{ title: "家宴镇压", outline: "家宴上岳父冷笑:「你也敢上桌?」我手机震了一下,是父亲发来的:「儿,神州七大家主,已到楼下。」", style: "真实系" },
|
||||
{ title: "买葱归来", outline: "二十年前那场天工大会上消失的人——今天回菜市场买葱,被小贩多收了两毛。他笑了:「这二十年的利息,连本带利,今晚一起还。」", style: "国风水墨" },
|
||||
{ title: "红盖头之下", outline: "敌对家族送来一个新娘,遮着红盖头。我掀开那一刻,下面是和我死去的妹妹一模一样的脸。她抬眼:「哥…你别杀我。」", style: "超写实" },
|
||||
{ title: "上海双面谍", outline: "1936 年。军统让我潜入日方,日方让我潜入军统。今晚——他们要见面,而我必须同时出现在两间房里。", style: "真实系" },
|
||||
{ title: "比武场的茶博士", outline: "比武大会上,我端着茶水路过,宗主们的剑突然全都举不起来了。我抬眼:「老衲只是看不下去你们吵架。」", style: "国风水墨" },
|
||||
{ title: "高考前夜", outline: "全市模考垫底的我,高考前夜被四个西装男按在桌前:「这次,你必须考第一。」原来——我爸是教育部的人。", style: "日系动画" },
|
||||
{ title: "失踪一年", outline: "我被宣告死亡 12 个月后,背着血迹斑斑的包,站在了她婚礼现场的门口。新郎认出我,杯子摔到了地上。", style: "真实系" },
|
||||
{ title: "天台堵她", outline: "学校最不好惹的那位转学生,第一天就堵了我的天台。我把她书包一扯——里面掉出来一沓我从小写的情书。", style: "日系动画" },
|
||||
{ title: "转学第一天", outline: "转学第一天,年级第一坐我后桌。下课她把试卷拍在我面前:「这道题,你为什么写得和我答案一字不差?」", style: "二次元" },
|
||||
{ title: "无职觉醒", outline: "成年礼上全班觉醒职业,只有我天命「无职」。所有人嘲笑我的时候,光柱从我身上炸开——觉醒结果:「神」。", style: "玄幻" },
|
||||
{ title: "草稿纸里的我", outline: "睁眼发现自己是一张草稿纸上的火柴小人,住在 16-bit 的网格世界里。橡皮擦从天而降,正在抹掉这一行字——也包括我。", style: "像素风" },
|
||||
{ title: "云上的国家", outline: "齿轮轰鸣的飞艇甲板上,独眼船长把望远镜递到我手里:「云的那一头有个国家,专门关像你这样的人。」", style: "蒸汽朋克" },
|
||||
{ title: "舰桥上的少年", outline: "殖民母舰只剩 30 秒,主炮指挥官的椅子是空的。舰长抬眼看着 17 岁的我:「上去。整个人类,就交给你了。」", style: "赛博朋克" },
|
||||
{ title: "末节队长服", outline: "全联盟都骂我废柴,机甲赛决赛末节,教练把队长徽章按在我手里:「上去,把这局赢回来——这一台,是人类最后的机甲。」", style: "赛博朋克" },
|
||||
{ title: "学长的真面目", outline: "三年青梅当众接过富二代的玫瑰,转身扑进他怀里。我笑了笑——明天,是我接手父亲那个上市公司的日子。", style: "真实系" },
|
||||
],
|
||||
女性向: [
|
||||
{ title: "摄政王独宠", outline: "穿越成将军府的废物嫡女,冷面摄政王却把整个京城最名贵的红玉镯,亲手戴在了我的腕上……" },
|
||||
{ title: "重生前夕", outline: "重生回到分手前夜,他还没说出那句「对不起」。这一次,让我先转身。" },
|
||||
{ title: "恶役千金", outline: "一觉醒来,竟成了乙游里被命运钦点的恶役千金,要躲开所有 BAD END……" },
|
||||
{ title: "天台之上", outline: "南方多雨的六月,转学第一天,我把伞悄悄递给了那个在天台读诗的少年。" },
|
||||
{ title: "登基之夜", outline: "登基大典上群臣俯首,而我只想看那个一直立在阴影里的人,今夜会不会上前一步。" },
|
||||
{ title: "江湖玉颜", outline: "江湖传言,那位执剑女侠从不动情。可那个雨夜,她为他收剑而立。" },
|
||||
{ title: "学长的告白", outline: "夕阳染红了天台,那个总在篮球场被全校女生围观的学长,第一次叫住了我。" },
|
||||
{ title: "夏祭灯影", outline: "夏祭的夜空下,他替你挡开人潮,低声说:最后一发烟火,只想和你一起看完。" },
|
||||
{ title: "雨夜车站", outline: "末班电车迟迟未至,他脱下外套披在你肩上,霓虹在积水里碎成星河。" },
|
||||
{ title: "黄昏并肩", outline: "夕阳染红的乡间月台,他终于停下脚步回头看你——那句话堵在喉咙里很久了。" },
|
||||
{ title: "禁书之约", outline: "图书馆最深处,清冷的学生会长合上禁书,抬眼时眸色温柔得不像他。" },
|
||||
{ title: "骑士誓约", outline: "红色警报响彻舰桥,他单膝跪在你面前:以剑起誓,此生只为你出鞘。" },
|
||||
{ title: "雨巷追影", outline: "午夜雨巷,他撑伞追上独行的你:这条路太黑,我送你回去。" },
|
||||
{ title: "共伞之间", outline: "霓虹雨夜,他把全息伞偏向你这侧,自己半边肩膀已被雨打湿。" },
|
||||
{ title: "竹影收剑", outline: "竹林深处刀光骤停,他为你收剑而立,落叶落在你们之间。" },
|
||||
{ title: "深宫回眸", outline: "烛影摇红的宫宴上,冷面摄政王越过群臣,只朝你伸出了手。" },
|
||||
{ title: "空教室", outline: "夕照斜斜铺满空教室,他把写满字的笔记本推到你面前,耳尖泛红。" },
|
||||
{ title: "樱下情书", outline: "樱花树下,他递来第七封信,这一次落款不再是匿名。" },
|
||||
{ title: "月下倾心", outline: "银发垂落、极光环绕,传说中的月神俯身,指尖轻触你的脸颊。" },
|
||||
{ title: "血月相护", outline: "血色满月之下,他挡在你身前,刀光与樱瓣同时落下。" },
|
||||
{ title: "魔药之约", outline: "森林小屋烛火摇曳,他为你熬一剂改写命运的魔药,只求换你一笑。" },
|
||||
{ title: "海岸絮语", outline: "粉橙色夕阳里,他和你并肩坐在堤岸,把没说出口的心事交给海风。" },
|
||||
{ title: "屏光之后", outline: "幽蓝屏光映在他脸上,敲下最后一行代码,他转头:我找到你了。" },
|
||||
{ title: "龙王契约", outline: "古龙巢穴深处,化为人形的银发龙王单膝跪地,将一枚龙鳞戒指推到我面前。" },
|
||||
{ title: "洋场先生", outline: "1936 年的上海公馆,那位留洋先生替我挡下流弹,西装袖口洇开一片猩红。" },
|
||||
{ title: "最后一颗子弹", outline: "末世第 173 天,他用最后一颗子弹打穿破门的丧尸,转身把我护在身后。" },
|
||||
{ title: "古堡伯爵", outline: "雾锁古堡的舞会上,苍白俊美的伯爵俯身吻过我的手背,唇下却没有一丝温度。" },
|
||||
{ title: "鞍前", outline: "黄沙漫天的西部小镇,沉默的赏金猎人翻身上马,伸手把我拉上他的鞍前。" },
|
||||
{ title: "深海王子", outline: "潜入万米海沟的遗迹,发光的人鱼王子环住我的腰,带我穿过沉睡的古城。" },
|
||||
{ title: "只属于我们的航线", outline: "飞空艇甲板上,独眼船长把望远镜递到我眼前:「看,那是只属于我们的航线。」" },
|
||||
{ title: "废柴嫡女", outline: "穿成将军府众人嫌弃的废柴嫡女,第一天就被打了一巴掌。门外冷面摄政王翻身下马,「我夫人的脸,谁敢动?」", style: "国风水墨" },
|
||||
{ title: "乙游恶役", outline: "睁眼是乙游里五分钟必死的恶役千金,所有男主都恨我。我合上剧本笑了——上一世我是这游戏的主笔。", style: "二次元" },
|
||||
{ title: "白月光归来", outline: "穿成男主念念不忘的白月光,但全书她只有死亡这一种结局。我捏着男主送的玉佩走进祠堂——这一次,我不躲了。", style: "玄幻" },
|
||||
{ title: "凤袍之下", outline: "穿越来就是当朝皇后,三千佳丽看我笑话。皇上掀开龙袍跪在我面前:「皇后,朕想她想了三十年了。」", style: "国风水墨" },
|
||||
{ title: "嫁错重生", outline: "嫁错了人毁了一辈子,重生回到婚礼前夜。这一次新娘休书我先写。新郎的弟弟突然走进来:「嫂子,要换人,换我。」", style: "二次元" },
|
||||
{ title: "那杯咖啡", outline: "重生回到他亲手把我送进车祸的前夜。我笑着接过他递来的咖啡——这是一杯我前世死前最想泼他脸上的咖啡。", style: "真实系" },
|
||||
{ title: "雨中撑伞", outline: "重生回到我亲手要了她命的前一天。她正抱着公文包路过我的车——这一次,我下车撑伞。", style: "真实系" },
|
||||
{ title: "三十亿合同", outline: "重生回到我被父亲扫地出门的那个清晨。这一次,扫地出门前我把家族 30 亿的合同提前签了。", style: "真实系" },
|
||||
{ title: "替嫁霸总", outline: "替姐姐嫁给那个传说眼瞎心冷的总裁。新婚夜他俯身在我耳边:「你姐没告诉你?我等了你三年了。」", style: "二次元" },
|
||||
{ title: "错嫁那一夜", outline: "醉酒夜我闯进了错的酒店房间,醒来戒指已在手上。他穿好西装回头:「夫人,签字仪式三小时后。」", style: "真实系" },
|
||||
{ title: "撕了离婚书", outline: "为了避税,我和那个最讨厌我的总裁假结婚一年。半年后他突然把离婚协议撕了——「续约。」", style: "真实系" },
|
||||
{ title: "死对头跪了", outline: "天天和我互掐的死对头,今天跪在我面前。他递上戒指:「再吵下去要影响我们的孩子。」——什么孩子?!", style: "二次元" },
|
||||
{ title: "抽到的霸总", outline: "凌晨四点抽到 UR 卡——画面里是城里那个传说没人见过脸的盛家总裁。第二天他敲我家门:「我来报到。」", style: "3D 渲染" },
|
||||
{ title: "攻略任务", outline: "系统说:「攻略他,否则你死。」可他是这本书里唯一恨我入骨的人。今天他亲手把我堵在了墙角。", style: "二次元" },
|
||||
{ title: "商城上架", outline: "系统商城上架了「市值 800 亿盛总 × 1」。我咬牙刷光积蓄。下一秒,他出现在我家门口:「夫人,我已购入。」", style: "二次元" },
|
||||
{ title: "老公赞助", outline: "直播间打赏榜第一名连续 30 天,备注写着「老公赞助」。我点开他的资料——城里那位传说从不出门的盛少。", style: "日系动画" },
|
||||
{ title: "门外的他", outline: "末世第一夜,门外是丧尸群的撕咬声。隔壁刚搬来的男人撞开我家门:「我能进来吗?我有一把枪。」", style: "真实系" },
|
||||
{ title: "末世空间", outline: "末世爆发的第一天,我意外觉醒了储物空间。屯了三车物资回家,发现那个总欺负我的高冷邻居跪在我门口。", style: "真实系" },
|
||||
{ title: "异能撒娇", outline: "末世里所有男人都怕的那位 S 级异能者,今天蹲在我家门口:「姐姐,能让我进去吗?外面…丧尸太可怕了。」", style: "二次元" },
|
||||
{ title: "末世重生", outline: "重生回到末世爆发前一周。这一次,那个抛弃我的男人——我先把他赶出门,把上一世救我的人接回家。", style: "真实系" },
|
||||
{ title: "课桌里的纸条", outline: "隔壁班那个高冷年级第一,今天把一本日记塞进我课桌。第一页写着:「她笑起来的时候,三角函数都没那么复杂。」", style: "二次元" },
|
||||
{ title: "校草八年", outline: "暗恋了八年的校草,今天突然走到我面前:「跟我走,我已经查清楚了——把你妹妹接走的那个人在哪。」", style: "吉卜力" },
|
||||
{ title: "班长的秘密", outline: "天天和我同桌的班长,今天被四个保镖按在校门口接走。临走前他回头喊:「老婆,我先回总部一趟。」", style: "二次元" },
|
||||
{ title: "走廊的手腕", outline: "走廊上人最多的时候,全校最不好惹的学长抓住了我的手腕:「我等了你三年,今天给我一个回应。」", style: "日系动画" },
|
||||
{ title: "上海公馆", outline: "1936,我是父亲遗产的唯一继承人,全上海都在等看我嫁谁。今晚我推开门——那个传说不要女人的留洋先生,在喝我父亲的茶。", style: "超写实" },
|
||||
{ title: "书店里的他", outline: "我是租界一家书店的老板娘。今晚穿西装的他第三次坐在窗边,第一次开口:「小姐,可以借您的店…藏一个东西吗?」", style: "真实系" },
|
||||
{ title: "炼丹意外", outline: "我是仙门最废柴的炼丹弟子,三年没炼出一颗丹。今天偶然撞翻师尊的丹炉——一道光柱直冲云霄,惊动了三大长老。", style: "玄幻" },
|
||||
{ title: "江湖归人", outline: "我一个人闯江湖三年,今天回到那座小镇。门口的少年抬头:「师姐,你说过五年就回,我等了三年又两个月。」", style: "国风水墨" },
|
||||
{ title: "顶流的西瓜", outline: "顶流男星上节目被问感情,他笑了笑:「我老婆?她现在大概在家里啃我刚买的西瓜。」全网爆炸——我正趴在沙发上看直播。", style: "真实系" },
|
||||
{ title: "同居一年", outline: "和合租室友同居一年了,今晚他突然把我堵在门口:「你说,我们…要不要别再装陌生人了?」", style: "日系动画" },
|
||||
{ title: "机甲撞门", outline: "丧尸潮第七夜,全城断电。地下室的门被撞开,一架满是弹痕的机甲低下头,舱门弹开——里面坐着我那个失联三年的他。", style: "赛博朋克" },
|
||||
{ title: "三分绝杀", outline: "决赛最后一秒,他在场边看了我一眼,转身投出那一记三分。哨声响时,他把奖杯举过头顶,朝我跑来。", style: "日系动画" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -183,54 +203,49 @@ function StoryCard({
|
||||
title,
|
||||
outline,
|
||||
image,
|
||||
placeholderRatio = 4 / 5,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
outline: string;
|
||||
image: string;
|
||||
placeholderRatio?: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
// 卡片高度 = 图片真实宽高比。加载前先用 placeholderRatio 占好位(按该类卡片
|
||||
// 的典型比例),加载后用 naturalWidth/Height 锁死真实比例——绝不塌成 0、也绝不
|
||||
// 在 lazy 图加载或性向换图时跳变高度。运行时读取,故换任意图都自动适配。
|
||||
const [ratio, setRatio] = useState<number>();
|
||||
// 全卡片统一 4:5 portrait 比例。原来按图片真实 naturalWidth/Height 动态设 aspectRatio
|
||||
// 会跟懒加载顺序耦合:视口下方还没加载的卡停在 placeholder 比例,上方已加载的卡变成
|
||||
// 图片真实比例(可能是 1.6 横图或 0.75 竖图),视觉差异巨大;刷新后图从缓存读,
|
||||
// onLoad 几乎同步触发,看起来又恢复正常 —— 用户感知到的「偶尔尺寸不一样」就是这个。
|
||||
// 改为固定比例后所有卡片视觉一致,object-cover 让不同长宽比的图自动裁切适配。
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{ aspectRatio: ratio ?? placeholderRatio }}
|
||||
style={{ aspectRatio: "4 / 5" }}
|
||||
className="group relative block w-full mb-4 md:mb-5 break-inside-avoid overflow-hidden rounded-sm border border-clay-900/10 bg-cream-100 text-left transition-transform duration-300 ease-out hover:-translate-y-1"
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget;
|
||||
if (el.naturalWidth && el.naturalHeight) {
|
||||
setRatio(el.naturalWidth / el.naturalHeight);
|
||||
}
|
||||
}}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
{/* hover 浮层:卡片高度已由图片比例锁定,磨砂带占比恒定,hover 前后零回流。 */}
|
||||
<div className="absolute inset-x-0 bottom-0">
|
||||
<div className="relative px-4 pt-10 pb-4">
|
||||
{/* 毛玻璃底:backdrop-blur 0→md(不走 opacity,避免比文字慢半拍);上沿 mask 羽化,避免生硬分界 */}
|
||||
<div className="absolute inset-0 backdrop-blur-0 transition-[backdrop-filter] duration-300 ease-out group-hover:backdrop-blur-md [mask-image:linear-gradient(to_top,black_62%,transparent)] [-webkit-mask-image:linear-gradient(to_top,black_62%,transparent)]" />
|
||||
{/* 暗色渐变:opacity 淡入(自带 to-transparent 上沿,无需额外 mask) */}
|
||||
<div className="absolute inset-0 opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100 bg-gradient-to-t from-clay-900/92 via-clay-900/60 to-transparent" />
|
||||
<div className="relative opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100">
|
||||
<h4 className="font-serif text-cream-50 text-base md:text-lg leading-snug mb-1 [text-shadow:0_1px_8px_rgba(20,10,4,0.6)]">
|
||||
{title}
|
||||
</h4>
|
||||
<p className="font-serif italic text-cream-50/95 text-xs md:text-[13px] leading-relaxed line-clamp-4 [text-shadow:0_1px_6px_rgba(20,10,4,0.55)]">
|
||||
{outline}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* hover 浮层:照参考项目(yunmeng0530/yume)的写法——满卡片单元素,纯 rgba
|
||||
黑色 linear-gradient + opacity 过渡。完全不用 backdrop-filter / mask-image,
|
||||
从根上消除 Chromium 上「矩形磨砂 → 渐变磨砂」的跳变(这两个属性的合成顺序
|
||||
是真正的元凶;只要不用它们,就不会有这个 bug)。
|
||||
- bottom 0.9 → 45% 处 0.45 → top 0:自然羽化,底部聚焦文字、顶部完全透出图。 */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100 flex flex-col justify-end p-4 md:p-5"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, rgba(0,0,0,0.9), rgba(0,0,0,0.45) 45%, rgba(0,0,0,0) 100%)",
|
||||
}}
|
||||
>
|
||||
<h4 className="font-serif text-cream-50 text-base md:text-lg leading-snug mb-1 [text-shadow:0_1px_8px_rgba(20,10,4,0.7)]">
|
||||
{title}
|
||||
</h4>
|
||||
<p className="font-serif italic text-cream-50/95 text-xs md:text-[13px] leading-relaxed line-clamp-4 [text-shadow:0_1px_6px_rgba(20,10,4,0.6)]">
|
||||
{outline}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -471,20 +486,6 @@ export default function HomePage() {
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const styleMap: Record<string, string> = {
|
||||
二次元: "唯美二次元动漫插画,日系 galgame 精致质感,柔和温暖的自然光照。",
|
||||
吉卜力: "吉卜力工作室风格,手绘动画质感,柔和水彩底色,温暖治愈的氛围。",
|
||||
真实系: "真实电影感,柔和自然光照,胶片颗粒。",
|
||||
超写实: "超写实人像与场景,电影级布光,皮肤与材质细节精致。",
|
||||
水彩: "水彩插画,湿润晕染笔触,纸纹底色。",
|
||||
像素风: "像素风格,复古游戏 16-bit 调色,方块化几何造型。",
|
||||
日系动画: "现代日系动画 cel-shading,硬光阴影分层,赛璐璐风。",
|
||||
"3D 渲染": "3D 渲染卡通风格,柔和次表面散射,干净的电影级布光。",
|
||||
蒸汽朋克: "蒸汽朋克美学,铜色齿轮与蒸汽,工业革命氛围。",
|
||||
玄幻: "国风玄幻插画,仙气缭绕,群山烟雨与神兽萦绕。",
|
||||
国风水墨: "国潮唯美古风插画,水墨微晕渲染,仙侠浪漫色彩,极具东方神韵。",
|
||||
赛博朋克: "赛博朋克都市,霓虹反射湿润街道,电子义体高光。",
|
||||
};
|
||||
// 「自动」→ fall back to 二次元 (project default). Plain prompts like
|
||||
// "由模型自动判断画风" are not understood by FLUX — it just paints them
|
||||
// literally, so we'd rather lock in a sensible default.
|
||||
@@ -492,7 +493,7 @@ export default function HomePage() {
|
||||
// 选出最合适的画风,再映射到对应风格提示词,而非固定回退到二次元。届时
|
||||
// 同步更新风格弹窗副标题(「由模型根据 prompt 判断风格」)使文案与行为一致。
|
||||
const effectiveStyle = artStyle === "自动" ? "二次元" : artStyle;
|
||||
const styleGuide = styleMap[effectiveStyle] ?? styleMap["二次元"]!;
|
||||
const styleGuide = STYLE_MAP[effectiveStyle] ?? STYLE_MAP["二次元"]!;
|
||||
const audioEnabled = voice === "开启";
|
||||
|
||||
sessionStorage.setItem(
|
||||
@@ -502,14 +503,28 @@ export default function HomePage() {
|
||||
router.push("/play?custom=1");
|
||||
};
|
||||
|
||||
const onCardClick = (seed?: string) => {
|
||||
if (seed) setPrompt(seed);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const stories = STORIES[galleryGender];
|
||||
const imgPrefix = galleryGender === "女性向" ? "f" : "m";
|
||||
|
||||
// 点卡片 = 直接开始这张卡的故事,零等待:跳 /play?card=m0/f0... 由 /play
|
||||
// 页面从 /home/firstact/{name}.json 静态文件加载预烘焙好的首幕(含 scene /
|
||||
// 角色 / 图片 URL / storyState),整张图都已在 FLUX 上画好且 URL 缓存命中。
|
||||
// 「语音配音」选择仍然生效:把 audioEnabled 留在 sessionStorage 里,/play 的
|
||||
// useState 初始化器会读它来设 muted 初值。其余选项(剧情风格 / 内容节奏)
|
||||
// 在预烘焙时已锁成「多线转折 / 紧凑爽快」的红果默认基调,对精选卡不再生效。
|
||||
const onCardClick = (idx: number, card: StoryContent) => {
|
||||
const voice = OPTS[3]!.items[sel[3] ?? 1]!;
|
||||
const audioEnabled = voice === "开启";
|
||||
// 复用 infiplot:custom 这个 key 只为传递 audioEnabled —— ws/sg 在 ?card= 路径
|
||||
// 上不会被读取(/play 里 cardName 优先级高于 sessionStorage)。这样实现量最小,
|
||||
// 不必另起一个 audio-only 的 storage key。
|
||||
sessionStorage.setItem(
|
||||
"infiplot:custom",
|
||||
JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled }),
|
||||
);
|
||||
router.push(`/play?card=${imgPrefix}${idx}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* ================== HEADER ================== */}
|
||||
@@ -645,7 +660,7 @@ export default function HomePage() {
|
||||
title={c.title}
|
||||
outline={c.outline}
|
||||
image={`/home/${imgPrefix}${i}.webp`}
|
||||
onClick={() => onCardClick(c.outline)}
|
||||
onClick={() => onCardClick(i, c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -707,8 +722,17 @@ export default function HomePage() {
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">内 测 用 户 群</p>
|
||||
<p className="font-serif italic text-clay-500 text-base leading-relaxed">
|
||||
群二维码 / 邀请链接(待补充)
|
||||
<img
|
||||
src="/qq-group.webp"
|
||||
alt="InfiPlot 内测交流群 QQ 群二维码(群号 575404333)"
|
||||
width={760}
|
||||
height={760}
|
||||
loading="lazy"
|
||||
className="mx-auto mb-3 w-32 max-w-full rounded-sm border border-clay-900/10 shadow-sm shadow-clay-900/5"
|
||||
/>
|
||||
<p className="font-serif text-clay-700 text-base leading-relaxed">
|
||||
QQ群号:
|
||||
<span className="font-sans text-sm text-clay-900">575404333</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { PlayCanvas, type Phase } from "@/components/PlayCanvas";
|
||||
import { annotateClick } from "@/lib/annotateClient";
|
||||
import { PRESETS } from "@/lib/presets";
|
||||
import type {
|
||||
Beat,
|
||||
@@ -238,12 +239,20 @@ function PlayInner() {
|
||||
const [currentBeatId, setCurrentBeatId] = useState<string | null>(null);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [beatAudioMap, setBeatAudioMap] = useState<Record<string, BeatAudio>>({});
|
||||
// Lazy-initialize from localStorage so PlayCanvas never mounts with the
|
||||
// wrong muted value (an effect-based read would briefly let audio play
|
||||
// before the preference settled in a scenario where audio arrives early).
|
||||
// Lazy-initialize 优先级:本局选择(homepage 的「语音配音」存到 sessionStorage:infiplot:custom)
|
||||
// > 上次会话的粘性偏好(localStorage:infiplot:muted) > 默认非静音。
|
||||
// 这样首页选了「关闭」开始游戏,进来就是静音;选「开启」就不是静音;进入 play 页后用户自己
|
||||
// 切换 静音/有声 时再用 localStorage 持久化,下一局开新游戏 sessionStorage 选择会再覆盖。
|
||||
const [muted, setMuted] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const stored = window.sessionStorage.getItem("infiplot:custom");
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as { audioEnabled?: boolean };
|
||||
if (typeof parsed.audioEnabled === "boolean") {
|
||||
return !parsed.audioEnabled;
|
||||
}
|
||||
}
|
||||
return window.localStorage.getItem(MUTED_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
@@ -263,13 +272,11 @@ function PlayInner() {
|
||||
// changes so stale in-flight requests can't poison the new scene's map
|
||||
// (beat ids like "b1" are scene-local and would collide across scenes).
|
||||
const beatAudioAbortRef = useRef<Map<string, AbortController>>(new Map());
|
||||
// User-toggled "语音配音" from the homepage. Defaults to true for back-compat
|
||||
// when older sessionStorage payloads omit the field. Mutated once in
|
||||
// bootstrap and read by fetchBeatAudio to early-return without any /api call.
|
||||
const audioEnabledRef = useRef<boolean>(true);
|
||||
// Mirrors `muted` so the closure-stable fetchBeatAudio (deps []) can gate on
|
||||
// it. Muting stops TTS *synthesis*, not just playback — TTS is the only sound
|
||||
// source, so synthesizing audio the user can't hear just burns quota.
|
||||
// 首页「语音配音 关闭」会把 muted 初值置为 true(见上方 useState 初始化),
|
||||
// 不再单独维护 audioEnabledRef —— 单一来源避免两个 flag 漂移。
|
||||
const mutedRef = useRef<boolean>(muted);
|
||||
|
||||
// Mirrors for use inside async handlers (closure-stable)
|
||||
@@ -329,8 +336,8 @@ function PlayInner() {
|
||||
sess: Session,
|
||||
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
|
||||
): Promise<void> => {
|
||||
if (!audioEnabledRef.current) return; // user toggled 语音配音 → 关闭
|
||||
if (mutedRef.current) return; // 静音 → 不合成 TTS(避免无谓的调用与花费)
|
||||
if (mutedRef.current) return; // 静音 → 不合成 TTS(避免无谓的调用与花费)。
|
||||
// 「首页选关闭」也走这条路:bootstrap 时 muted 已被初始化为 true。
|
||||
if (!beat.speaker || !beat.line) return;
|
||||
const speaker = sess.characters.find((c) => c.name === beat.speaker);
|
||||
if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway
|
||||
@@ -479,49 +486,74 @@ function PlayInner() {
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
|
||||
let payload: { worldSetting: string; styleGuide: string } | null = null;
|
||||
// 三条进入路径:
|
||||
// ?card=<m0..f31> → 首页精选卡,直接从 /home/firstact/{name}.json
|
||||
// 静态文件加载(已在构建期 prebake,免一切引擎调用)
|
||||
// ?preset=<id> → 内置 PRESETS(仍走 /api/start 现场生成)
|
||||
// ?custom=1 → 用户自定义 prompt,sessionStorage 取 ws/sg
|
||||
// 后走 /api/start 现场生成
|
||||
const cardName = params.get("card");
|
||||
const presetId = params.get("preset");
|
||||
const isCustom = params.get("custom") === "1";
|
||||
|
||||
if (presetId) {
|
||||
const p = PRESETS.find((x) => x.id === presetId);
|
||||
if (p) payload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
|
||||
} else if (params.get("custom") === "1") {
|
||||
const stored = sessionStorage.getItem("infiplot:custom");
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as {
|
||||
worldSetting: string;
|
||||
styleGuide: string;
|
||||
audioEnabled?: boolean;
|
||||
};
|
||||
payload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide };
|
||||
// default true for older payloads that omit the flag
|
||||
audioEnabledRef.current = parsed.audioEnabled !== false;
|
||||
} catch {
|
||||
payload = null;
|
||||
let livePayload: { worldSetting: string; styleGuide: string } | null = null;
|
||||
if (!cardName) {
|
||||
if (presetId) {
|
||||
const p = PRESETS.find((x) => x.id === presetId);
|
||||
if (p) livePayload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
|
||||
} else if (isCustom) {
|
||||
const stored = sessionStorage.getItem("infiplot:custom");
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as {
|
||||
worldSetting: string;
|
||||
styleGuide: string;
|
||||
audioEnabled?: boolean;
|
||||
};
|
||||
livePayload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide };
|
||||
// audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。
|
||||
} catch {
|
||||
livePayload = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
if (!cardName && !livePayload) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
|
||||
const finalPayload = payload;
|
||||
type PrebakedFirstAct = StartResponse & {
|
||||
worldSetting: string;
|
||||
styleGuide: string;
|
||||
cardName?: string;
|
||||
cardTitle?: string;
|
||||
cardGender?: string;
|
||||
};
|
||||
|
||||
fetch("/api/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(finalPayload),
|
||||
})
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(j.error ?? r.statusText);
|
||||
}
|
||||
return (await r.json()) as StartResponse;
|
||||
})
|
||||
const fetchStart: Promise<PrebakedFirstAct> = cardName
|
||||
? fetch(`/home/firstact/${encodeURIComponent(cardName)}.json`).then(
|
||||
async (r) => {
|
||||
if (!r.ok) throw new Error(`找不到精选剧情:${cardName}`);
|
||||
return (await r.json()) as PrebakedFirstAct;
|
||||
},
|
||||
)
|
||||
: fetch("/api/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(livePayload),
|
||||
}).then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(j.error ?? r.statusText);
|
||||
}
|
||||
const data = (await r.json()) as StartResponse;
|
||||
// Live /api/start doesn't echo ws/sg back — splice in what we sent.
|
||||
return { ...data, worldSetting: livePayload!.worldSetting, styleGuide: livePayload!.styleGuide };
|
||||
});
|
||||
|
||||
fetchStart
|
||||
.then(async (data) => {
|
||||
// Decode the Runware image in memory before committing to state, so
|
||||
// the <img> renders instantly when it mounts (same rationale as the
|
||||
@@ -531,8 +563,8 @@ function PlayInner() {
|
||||
const initial: Session = {
|
||||
id: data.sessionId,
|
||||
createdAt: Date.now(),
|
||||
worldSetting: finalPayload.worldSetting,
|
||||
styleGuide: finalPayload.styleGuide,
|
||||
worldSetting: data.worldSetting,
|
||||
styleGuide: data.styleGuide,
|
||||
history: [
|
||||
{
|
||||
scene: data.scene,
|
||||
@@ -715,10 +747,11 @@ function PlayInner() {
|
||||
setPendingClick(click);
|
||||
|
||||
try {
|
||||
const annotatedImageBase64 = await annotateClick(imageUrl, click);
|
||||
const visionRes = await fetch("/api/vision", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session, prevImageUrl: imageUrl, click }),
|
||||
body: JSON.stringify({ session, annotatedImageBase64 }),
|
||||
});
|
||||
if (!visionRes.ok) {
|
||||
const j = (await visionRes.json().catch(() => ({}))) as {
|
||||
@@ -890,10 +923,12 @@ function PlayInner() {
|
||||
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[10px] smallcaps text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-2"
|
||||
className="text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||||
InfiPlot
|
||||
<i className="fa-solid fa-arrow-left text-[12px]" />
|
||||
<span className="font-serif text-[22px] md:text-[26px] leading-none tracking-tight">
|
||||
Infi<em className="italic font-light text-ember-500">Plot</em>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500 num">
|
||||
<span>第 · {String(sceneCount).padStart(3, "0")} · 幕</span>
|
||||
@@ -918,6 +953,32 @@ function PlayInner() {
|
||||
onBackgroundClick={onBackgroundClick}
|
||||
onAdvance={onAdvance}
|
||||
onSelectChoice={onSelectChoice}
|
||||
aboveCanvas={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void togglePresentation()}
|
||||
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
|
||||
aria-label="进入全屏"
|
||||
title="全屏 (F)"
|
||||
>
|
||||
<i className="fa-solid fa-expand text-[10px]" />
|
||||
F · 全 · 屏
|
||||
</button>
|
||||
}
|
||||
aboveCanvasLeft={
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMuted}
|
||||
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
title={muted ? "取消静音" : "静音"}
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${muted ? "fa-volume-xmark" : "fa-volume-high"} text-[10px]`}
|
||||
/>
|
||||
{muted ? "静 · 音" : "有 · 声"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 max-w-md w-full text-center min-h-[28px] flex items-center justify-center">
|
||||
@@ -935,28 +996,9 @@ function PlayInner() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="px-5 md:px-12 pb-6 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void togglePresentation()}
|
||||
className="text-[9px] smallcaps text-clay-400 hover:text-clay-700 transition-colors flex items-center gap-2"
|
||||
aria-label="进入演示模式"
|
||||
>
|
||||
<i className="fa-solid fa-expand text-[10px]" />
|
||||
F · 演 · 示
|
||||
</button>
|
||||
<footer className="px-5 md:px-12 pb-6 flex items-center justify-center">
|
||||
{/* 演示 / 静音入口已搬到画面正上方左右两侧;footer 仅留中间的「Ⅰ · Ⅰ」标记 */}
|
||||
<div className="text-[9px] smallcaps text-clay-400 num">Ⅰ · Ⅰ</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMuted}
|
||||
className="text-[9px] smallcaps text-clay-400 hover:text-clay-700 transition-colors flex items-center gap-2 w-[80px] justify-end"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
>
|
||||
<i
|
||||
className={`fa-solid ${muted ? "fa-volume-xmark" : "fa-volume-high"} text-[10px]`}
|
||||
/>
|
||||
{muted ? "静 · 音" : "有 · 声"}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { Beat, BeatChoice } from "@infiplot/types";
|
||||
|
||||
export type Phase =
|
||||
@@ -170,6 +170,8 @@ export function PlayCanvas({
|
||||
onAdvance,
|
||||
onSelectChoice,
|
||||
fullViewport = false,
|
||||
aboveCanvas,
|
||||
aboveCanvasLeft,
|
||||
}: {
|
||||
imageUrl: string | null;
|
||||
audioBase64: string | null;
|
||||
@@ -182,10 +184,13 @@ export function PlayCanvas({
|
||||
onAdvance: () => void;
|
||||
onSelectChoice: (choice: BeatChoice) => void;
|
||||
fullViewport?: boolean;
|
||||
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
|
||||
aboveCanvas?: ReactNode;
|
||||
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
||||
aboveCanvasLeft?: ReactNode;
|
||||
}) {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
||||
const [audioDurationMs, setAudioDurationMs] = useState<number | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -282,12 +287,6 @@ export function PlayCanvas({
|
||||
? "min(100vw, calc(100dvh * 16 / 9))"
|
||||
: "min(96vw, calc((100dvh - 200px) * 16 / 9))";
|
||||
|
||||
const footerHint =
|
||||
phase === "ready"
|
||||
? isChoiceBeat
|
||||
? "选 · 择 · 一 · 项"
|
||||
: "点 · 击 · 推 · 进"
|
||||
: "···";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -318,10 +317,6 @@ export function PlayCanvas({
|
||||
src={imageUrl}
|
||||
alt="Generated scene"
|
||||
onClick={handleImageClick}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
setDims({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
}}
|
||||
draggable={false}
|
||||
className={`block w-auto h-auto select-none animate-fade-in transition-opacity duration-700 ease-out ${
|
||||
interactive ? "cursor-pointer" : "cursor-wait"
|
||||
@@ -333,6 +328,18 @@ export function PlayCanvas({
|
||||
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-clay-900/12 to-transparent pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* 画面正上方右对齐的 slot —— 用 bottom-full + right-0 让它整体浮在图片之外、紧贴右上角 */}
|
||||
{!fullViewport && aboveCanvas && (
|
||||
<div className="absolute bottom-full right-0 mb-2 flex items-center gap-2">
|
||||
{aboveCanvas}
|
||||
</div>
|
||||
)}
|
||||
{!fullViewport && aboveCanvasLeft && (
|
||||
<div className="absolute bottom-full left-0 mb-2 flex items-center gap-2">
|
||||
{aboveCanvasLeft}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{beat && (
|
||||
<div className="absolute inset-0 flex flex-col justify-end pointer-events-none select-none">
|
||||
{choices.length > 0 && (
|
||||
@@ -470,20 +477,20 @@ export function PlayCanvas({
|
||||
<p className="text-[9px] smallcaps text-clay-500 animate-slow-pulse">
|
||||
正 · 在 · 绘 · 制 · 第 · 一 · 幕
|
||||
</p>
|
||||
{/* 加载占位也挂同一对 slot,让右上 / 左上的操作按钮在第一帧就出现 */}
|
||||
{!fullViewport && aboveCanvas && (
|
||||
<div className="absolute bottom-full right-0 mb-2 flex items-center gap-2">
|
||||
{aboveCanvas}
|
||||
</div>
|
||||
)}
|
||||
{!fullViewport && aboveCanvasLeft && (
|
||||
<div className="absolute bottom-full left-0 mb-2 flex items-center gap-2">
|
||||
{aboveCanvasLeft}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullViewport && (
|
||||
<div
|
||||
className="flex items-center justify-between mt-3 px-1 w-full"
|
||||
style={{ maxWidth: "96vw" }}
|
||||
>
|
||||
<span className="text-[9px] smallcaps text-clay-400 num">
|
||||
{dims ? `${dims.w} × ${dims.h} · png` : "—"}
|
||||
</span>
|
||||
<span className="text-[9px] smallcaps text-clay-400">{footerHint}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const TARGET_WIDTH = 768;
|
||||
|
||||
// Browser-side equivalent of the former engine/src/annotate.ts. Redraws the
|
||||
// scene image with the player's click marker on a Canvas 2D and returns the
|
||||
// raw PNG base64 (no `data:` prefix) — interpretClick wraps it back into a
|
||||
// data URL before posting to the vision LLM.
|
||||
//
|
||||
// crossOrigin="anonymous" + the CDN's Access-Control-Allow-Origin header are
|
||||
// both required to keep the canvas un-tainted; without them toDataURL throws
|
||||
// SecurityError. Runware's image CDN supports anonymous CORS; data: URIs
|
||||
// (MOCK_IMAGE mode) load without CORS.
|
||||
export async function annotateClick(
|
||||
imageUrl: string,
|
||||
click: { x: number; y: number },
|
||||
): Promise<string> {
|
||||
const img = await loadImage(imageUrl);
|
||||
|
||||
const scale = Math.min(1, TARGET_WIDTH / img.naturalWidth);
|
||||
const w = Math.max(1, Math.round(img.naturalWidth * scale));
|
||||
const h = Math.max(1, Math.round(img.naturalHeight * scale));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
||||
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
|
||||
const cx = Math.round(click.x * w);
|
||||
const cy = Math.round(click.y * h);
|
||||
const r = Math.max(8, Math.round(Math.min(w, h) * 0.025));
|
||||
const stroke = Math.max(2, Math.round(r * 0.25));
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(255,40,40,0.55)";
|
||||
ctx.fill();
|
||||
ctx.lineWidth = stroke;
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, Math.max(2, Math.round(r * 0.25)), 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(255,255,255,1)";
|
||||
ctx.fill();
|
||||
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
return dataUrl.replace(/^data:image\/png;base64,/, "");
|
||||
}
|
||||
|
||||
// 10s timeout mirrors the old server-side annotator's 5s fetch budget +
|
||||
// headroom for browser decode. Without it a hung CDN response would strand
|
||||
// the player in `vision-thinking` forever.
|
||||
function loadImage(
|
||||
url: string,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const timer = setTimeout(() => {
|
||||
// removeAttribute, not `src = ""` — setting empty string can trigger
|
||||
// a navigation to the current document URL in some browsers.
|
||||
img.removeAttribute("src");
|
||||
reject(new Error(`Image load timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
new Error(`Failed to load image for annotation: ${url.slice(0, 80)}`),
|
||||
);
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,6 @@ const config: NextConfig = {
|
||||
"@infiplot/types",
|
||||
"@infiplot/tts-client",
|
||||
],
|
||||
serverExternalPackages: ["sharp"],
|
||||
turbopack: {
|
||||
root: path.join(__dirname, "..", ".."),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
// Minimal config — the project is fully stateless (sessions live on the
|
||||
// client), so no R2/KV/D1 incremental cache is needed.
|
||||
export default defineCloudflareConfig();
|
||||
@@ -8,7 +8,10 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:cf": "opennextjs-cloudflare build",
|
||||
"preview:cf": "opennextjs-cloudflare preview",
|
||||
"deploy:cf": "opennextjs-cloudflare deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@infiplot/ai-client": "workspace:*",
|
||||
@@ -16,16 +19,18 @@
|
||||
"@infiplot/types": "workspace:*",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sharp": "^0.33.5"
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opennextjs/cloudflare": "^1.19.11",
|
||||
"sharp": "^0.33.5",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.6.3",
|
||||
"wrangler": "^4.96.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1 @@
|
||||
{"sessionId":"s_1780390588623_46anec","scene":{"id":"scene_1780390628580_h42b","scenePrompt":"Wide establishing shot of a surreal digital landscape resembling a vast sheet of gridded draft paper. A colossal white eraser descends from the sky like a catastrophic meteor, casting a long, ominous shadow across the terrain. Pixelated mathematical formulas and geometric shapes are violently fracturing and collapsing into blocky debris. The atmosphere is tense and apocalyptic, rendered in a retro 16-bit pixel art style with a limited color palette. Vibrant cyan grid lines contrast against the stark white of the falling eraser. High-contrast lighting, dramatic shadows, geometric destruction, 16-bit aesthetic, vintage game graphics, no characters present.","beats":[{"id":"b1","narration":"耳边传来震耳欲聋的摩擦声,整片白色大地在剧烈颤抖。你抬头望去,一块遮天蔽日的白色矩形——橡皮擦,正带着毁灭的气息碾压而来。","next":{"type":"continue","nextBeatId":"b2"}},{"id":"b2","narration":"你身边的‘E=mc²’像素块瞬间粉碎成渣,逻辑正在被物理抹除。再慢一秒,你就会变成一堆毫无意义的石墨粉末。","speaker":"你","line":"开什么玩笑……我可是顶级黑客,怎么能死在这这种鬼地方!","next":{"type":"continue","nextBeatId":"b3"}},{"id":"b3","narration":"生死关头,你注意到左前方有一滩尚未干透的蓝黑色墨水渍,那里是规则混乱的盲区;而正上方,那双名为‘造物主’的巨大眼睛正透过镜片冷漠地注视着纸面。","speaker":"你","line":"拼了!到底是躲进阴影,还是赌她会停手?","next":{"type":"choice","choices":[{"id":"c1","label":"跳入深色墨水渍","effect":{"kind":"change-scene","nextSceneSeed":"墨水渍内部是混乱的16-bit废墟,你在这里遇到了那个自称‘黑影’的家伙"}},{"id":"c2","label":"拼成SOS求救","effect":{"kind":"change-scene","nextSceneSeed":"巨大的笔尖停在你面前,苏清月疑惑地靠近纸面,你第一次近距离看清这位造物主的脸"}}]}}],"entryBeatId":"b1","sceneKey":"draft-paper-grid-white","imageUuid":"95a0f0bb-961b-4e0c-9728-68f809c68619","imageUrl":"https://im.runware.ai/image/os/a05d22/ws/3/ii/95a0f0bb-961b-4e0c-9728-68f809c68619.png"},"imageUrl":"/home/firstscene/m27.webp","characters":[],"storyState":{"logline":"命悬一线的草稿纸火柴人,如何操纵现实世界的“造物主”,在被彻底抹除前逆天改命?","genreTags":"脑洞快穿 / 爽剧 / 元叙事 / 像素风生存","protagonist":"你曾是现实中被陷害的顶级黑客,意识意外坠入死对头女儿的草稿纸上。此刻你不仅失去了实体,还面临物理意义上的“抹除”。你必须利用16-bit世界的底层逻辑生存下去,并试图向纸外的现实世界复仇,你的软肋是只要纸张湿透,你的记忆就会永久溶解。","castNotes":"苏清月:手持画笔的“造物主”,看似清纯内向的艺术生,实则在草稿纸上发泄着极其阴暗的杀戮欲望;她是唯一能改写你命运的人。\n黑影:纸张折痕处生存的“涂鸦前辈”,性格乖张狡诈,曾多次从橡皮擦下逃生,他知道跳出这张纸的方法,但需要拿你的意识做交易。","synopsis":"你在死对头女儿的草稿纸上以火柴人身份苏醒,在橡皮擦的灭顶之灾中极限逃生。你意识到自己必须在被彻底抹除前,利用纸上的规则向外界传递信号。","openThreads":["导致你意识坠入草稿纸的幕后黑手身份","苏清月在草稿纸边缘画下的那些诡异血腥图案的含义"],"relationships":["苏清月:视你为随手涂鸦的造物主,目前尚未意识到你的自我意识"],"nextHook":"无论你选择躲藏还是求救,你都即将接触到这个纸面世界背后的第一个禁忌——苏清月的阴暗面。"},"cardName":"m27","cardTitle":"草稿纸里的我","cardGender":"男性向","worldSetting":"这是一款面向【男性向】观众的 AI 交互剧情游戏,整体走红果短视频式的强戏剧冲突与快速反转。\n剧情风格:多线转折。内容节奏:紧凑爽快。\n精选剧情《草稿纸里的我》的开场设定:睁眼发现自己是一张草稿纸上的火柴小人,住在 16-bit 的网格世界里。橡皮擦从天而降,正在抹掉这一行字——也包括我。\n请直接以此开场切入,给玩家强烈的代入感与爽点;后续分支保持短剧式的反转密度,让玩家每一次选择都能立刻看到回响。","styleGuide":"像素风格,复古游戏 16-bit 调色,方块化几何造型。","imageUrlRemote":"https://im.runware.ai/image/os/a05d22/ws/3/ii/95a0f0bb-961b-4e0c-9728-68f809c68619.png"}
|
||||
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 103 KiB |