diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 43faabb..b15dc09 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -2,650 +2,67 @@ @tailwind components; @tailwind utilities; -/* ==================== InfiPlot — low-fi prototype tokens ==================== */ -:root { - --ink: #3a3a38; - --ink-soft: #6f6e69; - --ink-faint: #a9a7a0; - --line: #cfccc4; - --paper: #f3f1ec; - --paper-2: #e9e6df; - --fill: #ddd9d0; - --accent: #d4824a; - --jit: 1; - --sketch-filter: url(#s2); -} - @layer base { html { font-feature-settings: "ss01", "kern", "liga"; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background: var(--paper); } body { - background: - repeating-linear-gradient(0deg, transparent 0 38px, rgba(0, 0, 0, 0.018) 38px 39px), - var(--paper); - color: var(--ink); - font-family: "Noto Sans SC", "PingFang SC", system-ui, sans-serif; + background-image: + radial-gradient(rgba(133, 79, 37, 0.025) 1px, transparent 1px), + radial-gradient(rgba(133, 79, 37, 0.018) 1px, transparent 1px); + background-size: 28px 28px, 38px 38px; + background-position: 0 0, 14px 19px; } ::selection { - background-color: rgb(212 130 74 / 0.28); - color: var(--ink); + background-color: rgb(217 122 46 / 0.28); + color: #2d1810; + } + + textarea::placeholder { + color: rgb(168 105 59 / 0.45); } } @layer utilities { - .latin { - font-family: "Patrick Hand", "Caveat", "Cormorant Garamond", cursive; + .hairline { + background-image: linear-gradient( + to right, + transparent, + rgba(45, 24, 16, 0.18) 18%, + rgba(45, 24, 16, 0.18) 82%, + transparent + ); + height: 1px; + } + + .hairline-full { + height: 1px; + background: rgba(45, 24, 16, 0.14); + } + + .num { + font-variant-numeric: tabular-nums lining-nums; + } + + .smallcaps { + text-transform: uppercase; + letter-spacing: 0.32em; } } -/* ==================== hand-drawn frame ==================== */ -.frame { - position: absolute; - inset: 0; - border: 2px solid var(--ink); - border-radius: 12px; - filter: var(--sketch-filter); - background: transparent; - pointer-events: none; -} -.frame.soft { - border-color: var(--ink-soft); -} - -/* ==================== logo ==================== */ -.ip-logo { - display: inline-flex; - align-items: center; - gap: 12px; -} -.ip-logo .mark { - position: relative; - width: 30px; - height: 30px; -} -.ip-logo .mark .frame { - border-radius: 50%; -} -.ip-logo .mark span { - position: absolute; - inset: 0; - display: grid; - place-items: center; - font-size: 15px; - color: var(--ink-soft); -} -.ip-logo .word { - font-family: "Patrick Hand", "Caveat", cursive; - font-size: 24px; - letter-spacing: 2px; - color: var(--ink); - font-weight: 400; -} - -/* ==================== input bar (prompt + start) ==================== */ -.ip-tagline { - font-size: clamp(22px, 2.6vw, 33px); - color: var(--ink); - font-weight: 500; - letter-spacing: 1px; - text-align: center; -} -.ip-bar { - display: flex; - gap: 16px; - align-items: stretch; - width: min(1100px, 92vw); - height: 68px; -} -.ip-field { - position: relative; - flex: 1; -} -.ip-field .frame { - border-radius: 16px; -} -.ip-field input { - position: relative; - width: 100%; - height: 100%; - padding: 0 28px; - background: transparent; - border: none; - outline: none; - font: inherit; - font-size: 20px; - color: var(--ink); - z-index: 1; -} -.ip-field input::placeholder { - color: transparent; -} -.ip-field .ph { - position: absolute; - inset: 0; - display: flex; - align-items: center; - padding: 0 28px; - font-size: 20px; - color: var(--ink-faint); - font-weight: 300; - white-space: nowrap; - overflow: hidden; - pointer-events: none; - z-index: 0; -} -.ip-cursor { - display: inline-block; - width: 2px; - height: 23px; - background: var(--ink-faint); - margin: 0 1px 0 3px; - vertical-align: -4px; - animation: ip-blink 1.1s steps(1) infinite; -} -@keyframes ip-blink { - 50% { opacity: 0; } -} - -.ip-start { - position: relative; - width: 176px; - flex: none; - display: grid; - place-items: center; - cursor: pointer; - border: none; - background: transparent; - padding: 0; -} -.ip-start .frame { - border-radius: 16px; - background: var(--accent); - border-color: var(--accent); - z-index: 0; -} -.ip-start span { - position: relative; - z-index: 1; - color: #fff; - font-size: 22px; - letter-spacing: 6px; - font-weight: 500; - padding-left: 6px; -} -.ip-start:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -/* ==================== collapsible category pills ==================== */ -.ip-cat { - position: relative; -} -.ip-catbtn { - position: relative; - height: 42px; - padding: 0 16px; - display: flex; - align-items: center; - gap: 9px; - cursor: pointer; - white-space: nowrap; - border: none; - background: transparent; -} -.ip-catbtn .frame { - border-radius: 21px; - border-color: var(--line); -} -.ip-catname { - position: relative; - z-index: 2; - font-size: 12.5px; - color: var(--ink-faint); -} -.ip-catval { - position: relative; - z-index: 2; - font-size: 15px; - color: var(--ink); - font-weight: 600; -} -.ip-caret { - position: relative; - z-index: 2; - font-size: 11px; - color: var(--ink-soft); - transition: transform 0.15s; -} -.ip-cat.open .ip-caret { - transform: rotate(180deg); -} -.ip-cat.open .ip-catbtn .frame { - border-color: var(--accent); -} -.ip-cat.open .ip-catval { - color: var(--accent); -} -.ip-catmenu { - position: absolute; - top: 50px; - left: 0; - min-width: calc(100% + 8px); - padding: 7px; - z-index: 20; - display: flex; - flex-direction: column; - gap: 2px; - background: var(--paper); -} -.ip-catmenu .frame { - border-radius: 12px; - border-color: var(--ink-soft); -} -.ip-catopt { - position: relative; - z-index: 1; - padding: 8px 16px; - border-radius: 8px; - font-size: 14px; - color: var(--ink-soft); - cursor: pointer; - white-space: nowrap; - background: transparent; - border: none; - text-align: left; - font: inherit; -} -.ip-catopt:hover { - background: var(--paper-2); -} -.ip-catopt.on { - color: var(--accent); - font-weight: 600; -} -.ip-catopt.on::after { - content: "\2713"; - margin-left: 8px; - font-size: 12px; -} - -/* ==================== scattered story cards ==================== */ -.ip-card { - position: absolute; - cursor: pointer; - transition: transform 0.25s ease; -} -.ip-card:hover { - transform: rotate(0deg) translateY(-4px) !important; -} -.ip-card .inner { - position: absolute; - inset: 0; - border-radius: 12px; - overflow: hidden; -} -.ip-card .img { - position: absolute; - inset: 0; - background: var(--fill); - display: grid; - place-items: center; -} -.ip-card .img svg { - width: 40%; - max-width: 120px; - opacity: 0.55; -} -.ip-card .img img.card-photo { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} -.ip-card .frame { - z-index: 3; -} -/* hover overlay: bottom-up dark gradient with title + outline */ -.ip-hover { - position: absolute; - inset: 0; - z-index: 2; - display: flex; - flex-direction: column; - justify-content: flex-end; - padding: 16px 18px 16px; - color: #fff; - background: linear-gradient(to top, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.45) 45%, rgba(0, 0, 0, 0) 100%); - opacity: 0; - transition: opacity 0.3s ease; - user-select: none; - border-radius: 12px; -} -.ip-card:hover .ip-hover { - opacity: 1; -} -.ip-hover-title { - margin: 0 0 6px; - font-size: 16px; - font-weight: 700; - letter-spacing: 0.08em; - color: #fff; -} -.ip-hover-outline { - margin: 0; - font-size: 12px; - line-height: 1.55; - color: rgba(231, 226, 215, 0.92); - font-style: italic; - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; -} - -/* ==================== gallery masonry ==================== */ -.ip-gallery { - width: min(1640px, 96vw); - margin: 14px auto 0; - column-count: 4; - column-gap: 24px; -} -@media (max-width: 1100px) { - .ip-gallery { column-count: 3; } -} -@media (max-width: 780px) { - .ip-gallery { column-count: 2; } -} -@media (max-width: 480px) { - .ip-gallery { column-count: 1; } -} -.ip-card.gcard { - position: relative; - width: 100%; - display: inline-block; - margin: 0 0 24px; - break-inside: avoid; - transform: rotate(calc(var(--gr, 0deg) * var(--jit))); -} - -.ip-sectionnote { - width: 100%; - margin: 34px auto 6px; - text-align: center; - font-family: "Patrick Hand", "Caveat", cursive; - font-size: 19px; - color: var(--accent); -} -.ip-sectionnote .arr { - display: block; - font-size: 22px; - line-height: 1; - margin-bottom: 2px; -} - -/* ==================== project intro ==================== */ -.ip-intro { - position: relative; - width: min(1500px, 94vw); - margin: 70px auto 110px; - padding: 58px clamp(28px, 5vw, 80px) 64px; -} -.ip-intro .frame { - border-radius: 16px; - border-color: var(--ink-soft); -} -.ip-intro .imark { - position: relative; - z-index: 1; - display: flex; - align-items: center; - gap: 14px; - margin-bottom: 6px; -} -.ip-intro .imark .gl { - width: 34px; - height: 34px; - display: grid; - place-items: center; - font-size: 18px; - color: var(--ink-soft); - border: 2px solid var(--ink-soft); - border-radius: 50%; -} -.ip-intro h2 { - position: relative; - z-index: 1; - margin: 0; - font-family: "Patrick Hand", "Caveat", cursive; - font-size: 38px; - letter-spacing: 1px; - color: var(--ink); - font-weight: 400; -} -.ip-intro .kicker { - position: relative; - z-index: 1; - font-size: 15px; - color: var(--ink-soft); - letter-spacing: 3px; - margin: 2px 0 26px; - font-family: "Patrick Hand", "Caveat", cursive; -} -.ip-intro p { - position: relative; - z-index: 1; - max-width: 1180px; - font-size: 18px; - line-height: 1.9; - color: var(--ink-soft); - margin: 0 0 18px; - font-weight: 300; - text-wrap: pretty; -} -.ip-intro .label { - position: relative; - z-index: 1; - font-size: 13px; - color: var(--ink-faint); - letter-spacing: 2px; - margin: 26px 0 8px; - font-weight: 500; -} -.ip-intro b { - color: var(--ink); - font-weight: 600; -} -.ip-intro .mail { - color: var(--accent); - font-weight: 500; -} - -/* ==================== style picker modal ==================== */ -.ip-modal-ov { - position: fixed; - inset: 0; - z-index: 60; - display: flex; - align-items: center; - justify-content: center; - padding: 40px; - background: rgba(40, 38, 34, 0); - backdrop-filter: blur(0px); - -webkit-backdrop-filter: blur(0px); - transition: background 0.28s ease, backdrop-filter 0.28s ease, -webkit-backdrop-filter 0.28s ease; -} -.ip-modal-ov.show { - background: rgba(40, 38, 34, 0.34); - backdrop-filter: blur(7px); - -webkit-backdrop-filter: blur(7px); -} -.ip-modal { - position: relative; - width: 1120px; - max-width: 94vw; - max-height: 88vh; - display: flex; - flex-direction: column; - background: var(--paper); - border-radius: 16px; - overflow: hidden; - box-shadow: 0 30px 80px rgba(0, 0, 0, 0.32); - transform: scale(0.92); - opacity: 0; - transition: transform 0.3s cubic-bezier(0.2, 0.82, 0.25, 1), opacity 0.24s ease; -} -.ip-modal-ov.show .ip-modal { - transform: scale(1); - opacity: 1; -} -.ip-modal .frame { - border-radius: 16px; - border-color: var(--ink-soft); - z-index: 0; -} -.ip-modal-hd { - position: relative; - z-index: 1; - display: flex; - align-items: center; - gap: 20px; - padding: 22px 26px; - border-bottom: 1.5px dashed var(--line); -} -.ip-modal-ttl { - display: flex; - flex-direction: column; - font-size: 22px; - font-weight: 600; - color: var(--ink); - white-space: nowrap; -} -.ip-modal-sub { - font-size: 13px; - font-weight: 400; - color: var(--ink-faint); - margin-top: 3px; -} -.ip-modal-search { - position: relative; - margin-left: auto; - width: 320px; - max-width: 46vw; -} -.ip-modal-search input { - width: 100%; - height: 42px; - padding: 0 40px 0 16px; - border-radius: 21px; - border: 1.5px solid var(--line); - background: var(--paper-2); - font: inherit; - font-size: 15px; - color: var(--ink); - outline: none; -} -.ip-modal-search input::placeholder { - color: var(--ink-faint); -} -.ip-modal-search .si { - position: absolute; - right: 15px; - top: 50%; - transform: translateY(-50%); - color: var(--ink-faint); - font-size: 18px; - pointer-events: none; -} -.ip-modal-x { - font-size: 28px; - color: var(--ink-soft); - cursor: pointer; - line-height: 1; - padding: 0 2px; - background: transparent; - border: none; -} -.ip-modal-grid { - position: relative; - z-index: 1; - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 18px; - padding: 24px 26px 28px; - overflow-y: auto; -} -@media (max-width: 780px) { - .ip-modal-grid { grid-template-columns: repeat(2, 1fr); } -} -.ip-scard { - position: relative; - cursor: pointer; -} -.ip-scard .sthumb { - position: relative; - height: 160px; - background: var(--fill); - display: grid; - place-items: center; - border-radius: 11px; - overflow: hidden; -} -.ip-scard .sthumb svg { - width: 32%; - max-width: 84px; - opacity: 0.5; -} -.ip-scard .sthumb::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 11px; - border: 2px solid transparent; -} -.ip-scard.on .sthumb::after { - border-color: var(--accent); -} -.ip-scard .sname { - text-align: center; - padding: 10px 4px 2px; - font-size: 15px; - color: var(--ink); -} -.ip-scard.on .sname { - color: var(--accent); - font-weight: 600; -} -.ip-noresult { - grid-column: 1 / -1; - text-align: center; - color: var(--ink-faint); - padding: 48px 0; - font-size: 15px; -} - -/* ==================== avatar (bottom-left of hero) ==================== */ -.ip-avatar { - position: relative; - width: 46px; - height: 46px; -} -.ip-avatar .frame { - border-radius: 50%; - border-color: var(--ink-soft); -} -.ip-avatar span { - position: absolute; - inset: 0; - display: grid; - place-items: center; - font-family: "Patrick Hand", "Caveat", cursive; - font-size: 22px; - color: var(--ink-soft); +@keyframes yume-ripple { + 0% { + width: 14px; + height: 14px; + opacity: 0.95; + } + 100% { + width: 110px; + height: 110px; + opacity: 0; + } } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index e7ae148..ecccb0f 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,24 @@ import type { Metadata } from "next"; +import { Cormorant_Garamond, Inter } from "next/font/google"; import "./globals.css"; +// Editorial 云梦 fonts: drive tailwind `font-serif`/`font-sans` via +// --font-serif / --font-sans across every page (home, /play, /new, CustomForm). +const cormorant = Cormorant_Garamond({ + subsets: ["latin"], + weight: ["300", "400", "500", "600"], + style: ["normal", "italic"], + variable: "--font-serif", + display: "swap", +}); + +const inter = Inter({ + subsets: ["latin"], + weight: ["300", "400", "500"], + variable: "--font-sans", + display: "swap", +}); + export const metadata: Metadata = { title: "InfiPlot — AI 实时交互剧情游戏", description: "InfiPlot 是一款用 AI 实时生成图片、语音与剧情分支的交互式剧情游戏 Demo。", @@ -12,31 +30,19 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + - - + {/* Font Awesome — fa-solid icons used by home, /play, /new, CustomForm. */} - - {/* Hand-drawn jitter filters used by every .frame element */} - - - - - - - - - - - - - - + {children} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 3b96ae4..fde43ab 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,18 +1,19 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; /* ============================================================================ - InfiPlot · 低保真原型首页 - - 1900px 设计画布 + 等比缩放至视口宽度,最大程度还原原型版式 - - 顶部 Hero 浮动散落卡片;下方瀑布流;尾部项目介绍 + InfiPlot · 首页(云梦编辑式视觉风格 · 居中构图,呼应低保真原型) + - 顶部 Header:左上角衬线 wordmark logo + - Hero 控制区(居中):标题 / prompt 输入框 + 开始 / 5 个类别选择器 + - 统一瀑布流(居中定宽):7 张主推 + 16 张画廊,按性向整体 crossfade 切换 + - 项目介绍(题跋式排版) ========================================================================== */ -const HERO_CANVAS_W = 1900; -const HERO_CANVAS_H = 980; +type Gender = "男性向" | "女性向"; -const EXAMPLE_PHRASES: Record<"男性向" | "女性向", string[]> = { +const EXAMPLE_PHRASES: Record = { 男性向: [ "从小一起长大的青梅竹马,突然红着脸向我告白", "一觉醒来,班上的女生好像都偷偷喜欢上了我", @@ -59,20 +60,11 @@ const OPTS: Opt[] = [ { label: "内容节奏", items: ["慢热细腻", "紧凑爽快"], defaultIndex: 1 }, ]; -/* Hero slot geometry — 7 fixed positions, contents switch by 性向 */ -const HERO_SLOTS = [ - { x: 55, y: 470, w: 330, h: 196, rot: -1.6 }, - { x: 55, y: 690, w: 330, h: 196, rot: 1.3 }, - { x: 418, y: 566, w: 286, h: 352, rot: -1.1 }, - { x: 765, y: 642, w: 326, h: 258, rot: 1.1 }, - { x: 1130, y: 570, w: 326, h: 352, rot: 1.4 }, - { x: 1492, y: 478, w: 358, h: 200, rot: 1.6 }, - { x: 1492, y: 688, w: 358, h: 200, rot: -1.3 }, -] as const; +type StoryContent = { title: string; outline: string }; -type HeroContent = { title: string; outline: string }; - -const HERO_CONTENT: Record<"男性向" | "女性向", HeroContent[]> = { +/* 每个性向 30 篇预设剧情,与图片 /home/{m|f}{i}.webp 按索引一一对应。 + 男/女同索引共享画面尺寸,切性向 crossfade 时卡片高度不跳变。 */ +const STORIES: Record = { 男性向: [ { title: "樱の约定", outline: "樱花纷飞的黄昏,他终于鼓起勇气,向并肩走过六年的青梅竹马说出那句话……" }, { title: "锈色边境", outline: "漫天黄沙的废土,机械心脏在胸腔中沉重轰鸣。我从钢铁山中挖出一个完好的休眠舱……" }, @@ -81,79 +73,64 @@ const HERO_CONTENT: Record<"男性向" | "女性向", HeroContent[]> = { { 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: "穿越成将军府的废物嫡女,冷面摄政王却把整个京城最名贵的红玉镯,亲手戴在了我的腕上……" }, - { title: "重生前夕", outline: "重生回到分手前夜,他还没说出那句「对不起」。这一次,让我先转身。" }, - { title: "恶役千金", outline: "一觉醒来,竟成了乙游里被命运钦点的恶役千金,要躲开所有 BAD END……" }, - { title: "天台之上", outline: "南方多雨的六月,转学第一天,我把伞悄悄递给了那个在天台读诗的少年。" }, - { title: "登基之夜", outline: "登基大典上群臣俯首,而我只想看那个一直立在阴影里的人,今夜会不会上前一步。" }, - { 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: "飞空艇甲板上,独眼船长把望远镜递到我眼前:「看,那是只属于我们的航线。」" }, ], }; -const GALLERY: Array<{ h: number; rot: number; title: string; outline: string }> = [ - { h: 300, rot: -0.8, title: "花火之夜", outline: "夏祭的夜空下,浴衣女孩与你约定,今晚最后一发烟火,要一起看完。" }, - { h: 200, rot: 0.6, title: "霓虹之外", outline: "漂浮的飞车与古老方块字的全息广告——这是赛博东亚的另一种黎明。" }, - { h: 260, rot: 0.9, title: "放学后的车站", outline: "夕阳染红的乡间月台,无人列车迟迟未来,你和她沉默并立。" }, - { h: 330, rot: -0.6, title: "星辰咒语", outline: "古老图书馆深处,星纹长袍下的法师女孩低声念出禁咒。" }, - { h: 200, rot: 1.1, title: "战姬启动", outline: "紧急警报红光中,少女握紧操纵杆——决战时刻已到。" }, - { h: 300, rot: -1.0, title: "街灯之下", outline: "午夜独行的女侦探,雨雾中藏着尚未揭晓的真相。" }, - { h: 240, rot: 0.7, title: "全息伞下", outline: "霓虹雨夜,两人共撑全息伞——这一次,是道别还是开始?" }, - { h: 200, rot: -0.7, title: "竹林之约", outline: "竹林深处的快意一战,落叶纷飞——谁先收剑?" }, - { h: 330, rot: 0.8, title: "暗夜王座", outline: "烛光摇曳的古老王座之上,公主等待着她唯一的回信。" }, - { h: 200, rot: -1.1, title: "放学独白", outline: "阳光斜射的空教室,最后一个学生在笔记本上写着什么?" }, - { h: 260, rot: 0.5, title: "第七封信", outline: "樱花树下展开的信纸,淡淡的笔迹,字字千钧。" }, - { h: 300, rot: -0.6, title: "月神降临", outline: "银发倾泻、极光环绕——传说中的月神,今夜降临凡间。" }, - { h: 200, rot: 0.9, title: "血月武士", outline: "血色满月之下,刀光与樱瓣同时落下。" }, - { h: 330, rot: -0.9, title: "森林女巫", outline: "烛光摇曳的森林小屋,女巫熬制着能改变命运的魔药。" }, - { h: 200, rot: 0.6, title: "夏日海岸", outline: "粉橙色的夕阳,两个挚友坐在海岸边,把秘密轻轻放进海风里。" }, - { h: 260, rot: -0.7, title: "屏幕之间", outline: "霓虹青光映在脸上,全屏代码下藏着被遗忘的真相。" }, -]; - -/* ---------- shared primitives ---------- */ - -function ImgGlyph() { - return ( - - - - - - ); -} - -function Frame({ className = "" }: { className?: string }) { - return
; -} - -function CardBody({ - title, - outline, - image, -}: { - title: string; - outline: string; - image?: string; -}) { - return ( -
-
- {image ? ( - {title} - ) : ( - - )} -
-
-

{title}

-

{outline}

-
-
- ); -} - /* ---------- typewriter ---------- */ function Typewriter({ phrases }: { phrases: string[] }) { @@ -195,11 +172,70 @@ function Typewriter({ phrases }: { phrases: string[] }) { return ( <> {txt} - + ); } +/* ---------- masonry story card ---------- */ + +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(); + return ( + + ); +} + /* ---------- collapsible category selector ---------- */ function CategorySelect({ @@ -218,26 +254,39 @@ function CategorySelect({ onPick: (i: number) => void; }) { return ( -
- {open && ( -
+
{items.map((it, i) => ( ))} -
)}
@@ -265,102 +314,80 @@ function StyleModal({ }, []); const close = () => { setShown(false); - setTimeout(onClose, 300); + setTimeout(onClose, 280); }; const list = items.map((name, i) => ({ name, i })).filter((x) => x.name.includes(q.trim())); return ( -
-
e.stopPropagation()}> - -
-
- 选择绘画风格 - 默认「自动」· 由模型根据 prompt 判断风格 +
+
e.stopPropagation()} + className={ + "flex w-[1000px] max-w-[94vw] max-h-[86vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " + + (shown ? "opacity-100 scale-100" : "opacity-0 scale-95") + } + > +
+
+ 选择绘画风格 + + 默认「自动」· 由模型根据 prompt 判断风格 +
-
+
setQ(e.target.value)} placeholder="搜索风格…" autoFocus + className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-10 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" /> - +
-
-
+
{list.map(({ name, i }) => ( -
{ onPick(i); close(); }} + className={ + "flex h-20 items-center justify-center rounded-sm border px-3 text-center transition-all " + + (i === value + ? "border-ember-500 bg-ember-500/5 text-ember-500" + : "border-clay-900/12 text-clay-700 hover:border-clay-900/35 hover:bg-cream-100") + } > -
- -
-
{name}
-
+ {name} + ))} - {list.length === 0 &&
没有匹配的风格
} + {list.length === 0 && ( +
+ 没有匹配的风格 +
+ )}
); } -/* ---------- scale-to-fit hero canvas ---------- */ - -function HeroCanvas({ children }: { children: React.ReactNode }) { - const stageRef = useRef(null); - const canvasRef = useRef(null); - - useLayoutEffect(() => { - const fit = () => { - const stage = stageRef.current; - const canvas = canvasRef.current; - if (!stage || !canvas) return; - // scale to fit width; clamp so very wide screens don't pixel-up the design - const s = Math.min(1, stage.clientWidth / HERO_CANVAS_W); - canvas.style.transform = `scale(${s})`; - stage.style.height = HERO_CANVAS_H * s + "px"; - }; - fit(); - window.addEventListener("resize", fit); - const ro = new ResizeObserver(fit); - if (stageRef.current) ro.observe(stageRef.current); - return () => { - window.removeEventListener("resize", fit); - ro.disconnect(); - }; - }, []); - - return ( -
-
- {children} -
-
- ); -} - /* ---------- page ---------- */ export default function HomePage() { @@ -370,23 +397,64 @@ export default function HomePage() { const [open, setOpen] = useState(-1); const [styleOpen, setStyleOpen] = useState(false); const [prompt, setPrompt] = useState(""); - const inputRef = useRef(null); + const inputRef = useRef(null); + + // 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:yume:hintClosed)。 + const [hintClosed, setHintClosed] = useState(false); const styleRow = OPTS.findIndex((o) => o.modal); const genderIndex = sel[0] ?? 0; - const gender = (OPTS[0]!.items[genderIndex] as "男性向" | "女性向") ?? "男性向"; + const gender = (OPTS[0]!.items[genderIndex] as Gender) ?? "男性向"; const phrases = EXAMPLE_PHRASES[gender]; + // 性向切换时,整片瀑布流做淡出→换图→淡入的过渡(而非瞬切)。 + const [galleryGender, setGalleryGender] = useState(gender); + const [fading, setFading] = useState(false); + useEffect(() => { + if (gender === galleryGender) return; + setFading(true); + const t = setTimeout(() => { + setGalleryGender(gender); + setFading(false); + }, 280); + return () => clearTimeout(t); + }, [gender, galleryGender]); + /* close any open dropdown on outside click */ useEffect(() => { const h = (e: MouseEvent) => { const target = e.target as HTMLElement | null; - if (!target?.closest?.(".ip-cat")) setOpen(-1); + if (!target?.closest?.("[data-cat]")) setOpen(-1); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []); + useEffect(() => { + try { + if (localStorage.getItem("yume:hintClosed") === "1") setHintClosed(true); + } catch { + /* ignore */ + } + }, []); + + // 输入框随内容自动增高:长文本整段可见(打字与点卡片填入都覆盖)。 + useEffect(() => { + const el = inputRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [prompt]); + + const closeHint = () => { + setHintClosed(true); + try { + localStorage.setItem("yume:hintClosed", "1"); + } catch { + /* ignore */ + } + }; + const start = () => { const userPrompt = prompt.trim(); const artStyle = OPTS[1]!.items[sel[1] ?? 0]!; @@ -420,6 +488,9 @@ export default function HomePage() { // 「自动」→ 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. + // TODO(自动路由): 后续实现真正的「自动」——由模型依据世界观 / 玩家 prompt + // 选出最合适的画风,再映射到对应风格提示词,而非固定回退到二次元。届时 + // 同步更新风格弹窗副标题(「由模型根据 prompt 判断风格」)使文案与行为一致。 const effectiveStyle = artStyle === "自动" ? "二次元" : artStyle; const styleGuide = styleMap[effectiveStyle] ?? styleMap["二次元"]!; const audioEnabled = voice === "开启"; @@ -436,196 +507,228 @@ export default function HomePage() { inputRef.current?.focus(); }; - return ( -
- {/* ================== HERO (scale-to-fit 1900×980 canvas) ================== */} - - {/* tagline */} -
-
- 今天想穿越到什么故事? -
-
+ const stories = STORIES[galleryGender]; + const imgPrefix = galleryGender === "女性向" ? "f" : "m"; - {/* prompt bar */} -
+ return ( +
+ {/* ================== HEADER ================== */} +
+ + InfiPlot + + +
+ + {/* ================== HERO 控制区(居中,呼应原型布局) ================== */} +
+
+

+ 今天想体验什么故事? +

+ + {/* prompt 输入(居中) */}
{ e.preventDefault(); start(); }} + className="mx-auto mt-9 md:mt-12 max-w-[760px]" > -
- +