diff --git a/app/page.tsx b/app/page.tsx index 1398894..e39ab0d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,13 @@ import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { track } from "@/lib/analytics"; +import { + ART_STYLES, + GENDERS, + PACINGS, + PLOT_STYLES, + type Gender, +} from "@/lib/options"; /* ============================================================================ InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型) @@ -21,8 +28,6 @@ import { useEffect, useRef, useState } from "react"; ========================================================================== */ -type Gender = "男性向" | "女性向"; - const EXAMPLE_PHRASES: Record = { 男性向: [ "从小一起长大的青梅竹马,突然红着脸向我告白", @@ -45,33 +50,11 @@ type Opt = { }; const OPTS: Opt[] = [ - { label: "性向", items: ["男性向", "女性向"] }, - { - label: "绘画风格", - modal: true, - items: [ - "自动", - "自定义", - "京阿尼细腻日常", - "新海诚唯美光影", - "Galgame CG", - "3D 动漫电影", - "赛博朋克", - "蒸汽波", - "吉卜力治愈手绘", - "哥特庄园", - "废土科幻", - // 以下为小众/区域性画风,留作长尾选项 - "古典厚涂油画", - "极简中国水墨", - "浮世绘木刻", - "莫高窟壁画", - "波斯细密画", - ], - }, - { label: "剧情风格", items: ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"], defaultIndex: 1 }, + { label: "性向", items: [...GENDERS] }, + { label: "绘画风格", modal: true, items: [...ART_STYLES] }, + { label: "剧情风格", items: [...PLOT_STYLES], defaultIndex: 1 }, { label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 }, - { label: "内容节奏", items: ["慢热细腻", "紧凑爽快"], defaultIndex: 1 }, + { label: "内容节奏", items: [...PACINGS], defaultIndex: 1 }, ]; type StoryContent = { title: string; outline: string; style: string; tags: string[] }; @@ -1475,10 +1458,10 @@ export default function HomePage() { // 不会再出现「点开始 → 剧情和占位文字毫无关系」的体验断层。 const userPrompt = prompt.trim() || (phrases[phraseIdx] ?? "").trim(); - const artStyle = OPTS[1]!.items[sel[1] ?? 0]!; - const plotStyle = OPTS[2]!.items[sel[2] ?? 1]!; + const artStyle = ART_STYLES[sel[1] ?? 0] ?? "自动"; + const plotStyle = PLOT_STYLES[sel[2] ?? 1] ?? "多线转折"; const voice = OPTS[3]!.items[sel[3] ?? 1]!; - const pace = OPTS[4]!.items[sel[4] ?? 1]!; + const pace = PACINGS[sel[4] ?? 1] ?? "紧凑爽快"; // worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、 // 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出 @@ -1831,7 +1814,7 @@ export default function HomePage() { items={OPTS[styleRow]!.items} value={sel[styleRow] ?? 0} onPick={(i) => { - track("art_style_select", { style: OPTS[styleRow]!.items[i] ?? String(i) }); + track("art_style_select", { style: ART_STYLES[i] ?? "自动" }); setSel((s) => s.map((v, j) => (j === styleRow ? i : v))); }} onClose={() => setStyleOpen(false)} diff --git a/app/play/page.tsx b/app/play/page.tsx index 765b1da..c4b1545 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -377,8 +377,12 @@ function PlayInner() { // Coarse liveness ping for active-time analytics. /play is a single SPA // route, so page views alone read as ~0 duration; a 30s heartbeat (only // while the tab is visible) gives Umami the timestamps to derive real - // engaged time. Content-free — no payload, and a no-op without the tracker. + // engaged time. Content-free — no payload. The interval is never even + // scheduled unless the tracker is configured, so it's zero work when off. useEffect(() => { + if (!process.env.NEXT_PUBLIC_UMAMI_SRC || !process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID) { + return; + } const id = window.setInterval(() => { if (document.visibilityState === "visible") track("play_heartbeat"); }, 30_000); @@ -816,11 +820,13 @@ function PlayInner() { beatNext?.type === "choice" ? beatNext.choices.findIndex((c) => c.id === choice.id) : -1; - track("choice_select", { - scene_index: session.history.length, - choice_index: choiceIndex, - kind: choice.effect.kind, - }); + if (choiceIndex >= 0) { + track("choice_select", { + scene_index: session.history.length, + choice_index: choiceIndex, + kind: choice.effect.kind, + }); + } if (choice.effect.kind === "advance-beat") { // Pure local jump. No network. No pool changes. diff --git a/lib/analytics.ts b/lib/analytics.ts index 83d04b0..126a81f 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -10,6 +10,8 @@ // indices, counts and booleans — that is what keeps these events as // privacy-friendly as the cookieless page-view baseline. +import type { ArtStyle, Gender, Pacing, PlotStyle } from "./options"; + declare global { interface Window { umami?: { @@ -21,23 +23,24 @@ declare global { // Per-event payload schema. Fixing each event's allowed fields turns the RULE // above into a compile-time guarantee: an event simply has no slot for a prompt, // world/style guide or vision string, so free text can't be attached by mistake -// (a bare `Record` would happily accept it). Keep every field an -// enum, index, count or boolean. `never` marks events that carry no payload. +// (a bare `Record` would happily accept it). Every field is a +// literal union (shared with the selector UI via ./options), index, count or +// boolean — never a bare `string`. `never` marks events that carry no payload. type AnalyticsEventData = { game_start: | { source: "prompt"; - gender: string; - art_style: string; - plot_style: string; - pacing: string; + gender: Gender; + art_style: ArtStyle; + plot_style: PlotStyle; + pacing: Pacing; tts: boolean; has_prompt: boolean; has_style_ref: boolean; } - | { source: "curated"; gender: string; tts: boolean; card: string } + | { source: "curated"; gender: Gender; tts: boolean; card: `${"m" | "f"}${number}` } | { source: "custom" }; - art_style_select: { style: string }; + art_style_select: { style: ArtStyle }; style_image_upload: { ok: boolean }; scene_reached: { scene_index: number }; choice_select: { diff --git a/lib/options.ts b/lib/options.ts new file mode 100644 index 0000000..59e66bc --- /dev/null +++ b/lib/options.ts @@ -0,0 +1,37 @@ +// Single source of truth for the home-page selector option sets. Kept as +// `as const` so each list also yields a literal-union type: the play-start +// UI (app/page.tsx) renders from the arrays, and the analytics schema +// (lib/analytics.ts) types its payload fields from the unions. That shared +// origin is what keeps the "content-free" events honest — an event field can +// only ever be one of these fixed labels, never free-form player text. + +export const GENDERS = ["男性向", "女性向"] as const; + +export const ART_STYLES = [ + "自动", + "自定义", + "京阿尼细腻日常", + "新海诚唯美光影", + "Galgame CG", + "3D 动漫电影", + "赛博朋克", + "蒸汽波", + "吉卜力治愈手绘", + "哥特庄园", + "废土科幻", + // 以下为小众/区域性画风,留作长尾选项 + "古典厚涂油画", + "极简中国水墨", + "浮世绘木刻", + "莫高窟壁画", + "波斯细密画", +] as const; + +export const PLOT_STYLES = ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"] as const; + +export const PACINGS = ["慢热细腻", "紧凑爽快"] as const; + +export type Gender = (typeof GENDERS)[number]; +export type ArtStyle = (typeof ART_STYLES)[number]; +export type PlotStyle = (typeof PLOT_STYLES)[number]; +export type Pacing = (typeof PACINGS)[number];