"use client"; import { type ReactNode, useEffect, useState } from "react"; const PLAYER_NAME_STORAGE_KEY = "infiplot:playerName"; const VISION_CLICK_STORAGE_KEY = "infiplot:visionClick"; export function readStoredPlayerName(): string { try { return localStorage.getItem(PLAYER_NAME_STORAGE_KEY) ?? ""; } catch { return ""; } } export function writeStoredPlayerName(name: string): void { try { if (name) { localStorage.setItem(PLAYER_NAME_STORAGE_KEY, name); } else { localStorage.removeItem(PLAYER_NAME_STORAGE_KEY); } } catch { /* ignore */ } } export function readStoredVisionClick(): boolean { try { return localStorage.getItem(VISION_CLICK_STORAGE_KEY) !== "0"; } catch { return true; } } export function SettingsModal({ initialVisionClickEnabled = true, onClose, onSaved, footerNote, }: { initialVisionClickEnabled?: boolean; onClose: () => void; onSaved: (settings: { playerName: string; visionClickEnabled: boolean }) => void; footerNote?: ReactNode; }) { const [playerName, setPlayerName] = useState(() => readStoredPlayerName()); const [visionClick, setVisionClick] = useState(initialVisionClickEnabled); const [shown, setShown] = useState(false); useEffect(() => { const id = requestAnimationFrame(() => setShown(true)); return () => cancelAnimationFrame(id); }, []); const close = () => { setShown(false); setTimeout(onClose, 280); }; const save = () => { const name = playerName.trim(); writeStoredPlayerName(name); try { localStorage.setItem(VISION_CLICK_STORAGE_KEY, visionClick ? "1" : "0"); } catch { /* ignore */ } onSaved({ playerName: name, visionClickEnabled: visionClick }); close(); }; const clearAll = () => { writeStoredPlayerName(""); try { localStorage.removeItem(VISION_CLICK_STORAGE_KEY); } catch { /* ignore */ } onSaved({ playerName: "", visionClickEnabled: true }); close(); }; const hasAnySetting = readStoredPlayerName().length > 0; return (
e.stopPropagation()} className={ "flex w-[560px] max-w-[94vw] max-h-[88vh] 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") } > {/* Header */}
设置 可选 · 这些设置仅保存在本地浏览器
{/* ── Player Name Section ── */}
玩家名字
setPlayerName(e.target.value)} type="text" maxLength={20} autoComplete="off" spellCheck={false} placeholder="不填则使用「你」" className="h-11 w-full rounded-sm border border-clay-900/15 bg-cream-100 px-4 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400" /> NPC 会在对话中用这个名字称呼你。不填则默认以「你」称呼。
{/* ── Vision Click Section ── */}
点击画面识别
{( [ { on: true, label: "开启", icon: "fa-solid fa-wand-magic-sparkles" }, { on: false, label: "关闭", icon: "fa-solid fa-ban" }, ] as const ).map((t) => { const active = visionClick === t.on; return ( ); })}
开启后,在选择节点点击画面会触发 AI 识图并生成新的剧情分支。
{footerNote && (

{footerNote}

)}
{/* Footer */}
{hasAnySetting && ( )}
); }