feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
checkAdultContent,
|
||||
fetchRomanceEligibility,
|
||||
setAdultModeConsent,
|
||||
type AdultContentCheckResult,
|
||||
type RomanceEligibility,
|
||||
} from "../../lib/api";
|
||||
|
||||
const CHARACTER_ID = "seed-character-genki";
|
||||
const USER_ID = "seed-user-primary";
|
||||
|
||||
// L-8 成人模式 opt-in 介面:只做開關與既有 API 串接。
|
||||
// 「成年使用者驗證」的實際機制計畫未指定(需人工確認),這裡先讓 verifiedAdult 等同使用者自我宣告,
|
||||
// 待正式驗證流程拍板後再另行補項——不在這裡臆測或假造一套驗證邏輯。
|
||||
export function AdultModeSection() {
|
||||
const [optedIn, setOptedIn] = useState(false);
|
||||
const [eligibility, setEligibility] = useState<RomanceEligibility | null>(null);
|
||||
const [checkResult, setCheckResult] = useState<AdultContentCheckResult | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
setError(null);
|
||||
try {
|
||||
const [eligibilityResult, checkResultValue] = await Promise.all([
|
||||
fetchRomanceEligibility(CHARACTER_ID),
|
||||
checkAdultContent(CHARACTER_ID, USER_ID),
|
||||
]);
|
||||
setEligibility(eligibilityResult);
|
||||
setCheckResult(checkResultValue);
|
||||
} catch {
|
||||
setError("無法連線到 api 服務,成人模式狀態暫時無法顯示");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await setAdultModeConsent(USER_ID, next, next);
|
||||
setOptedIn(next);
|
||||
await refresh();
|
||||
} catch {
|
||||
setError("成人模式設定失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-2xl border p-4" style={{ borderColor: "var(--line)" }}>
|
||||
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
|
||||
成人模式
|
||||
</h2>
|
||||
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
系統預設為全年齡純愛尺度;開啟後也只有正史成年角色能解鎖,正史未成年、後日談推演才成年的角色永久限純愛尺度。
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm" style={{ color: "var(--ink)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={optedIn}
|
||||
disabled={pending}
|
||||
onChange={(event) => void handleToggle(event.target.checked)}
|
||||
/>
|
||||
主動開啟成人模式(正式的成年使用者驗證機制尚待人工確認,目前僅為自我宣告已成年)
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs" style={{ color: "var(--accent)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{eligibility && (
|
||||
<div className="flex flex-col gap-1 text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
<span>戀愛軸資格:{eligibility.romanceAxisEligible ? "可用" : "尚未解鎖"}</span>
|
||||
<span>成人模式角色資格:{eligibility.adultModeEligible ? "符合" : "不符合"}</span>
|
||||
{eligibility.isEpilogueAdult && <span>此角色為後日談推演成年,永久限純愛尺度。</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkResult && (
|
||||
<p
|
||||
className="rounded-lg px-3 py-2 text-xs"
|
||||
style={{
|
||||
background: checkResult.allowed ? "var(--chip-bg)" : "var(--bg-soft)",
|
||||
color: checkResult.allowed ? "var(--accent)" : "var(--ink-soft)",
|
||||
}}
|
||||
>
|
||||
{checkResult.allowed ? "目前可使用成人內容。" : `目前無法使用成人內容:${checkResult.reason}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { isVoiceAutoplayEnabled, setVoiceAutoplayEnabled } from "../../lib/voice-autoplay-settings";
|
||||
|
||||
// T26:多人房內角色新發言的自動語音播放開關;關閉後回到只能逐則點擊播放(見 VoicePlaybackButton)。
|
||||
export function VoiceAutoplaySection() {
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(isVoiceAutoplayEnabled());
|
||||
}, []);
|
||||
|
||||
function handleToggle(next: boolean) {
|
||||
setVoiceAutoplayEnabled(next);
|
||||
setEnabled(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-2xl border p-4" style={{ borderColor: "var(--line)" }}>
|
||||
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
|
||||
自走語音自動播放
|
||||
</h2>
|
||||
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
開啟後,在房間畫面時角色的新發言會自動依序念出來;關閉則回到逐則點擊才播放。
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm" style={{ color: "var(--ink)" }}>
|
||||
<input type="checkbox" checked={enabled} onChange={(event) => handleToggle(event.target.checked)} />
|
||||
在房間時自動播放角色語音
|
||||
</label>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Link from "next/link";
|
||||
import { AdultModeSection } from "./AdultModeSection";
|
||||
import { VoiceAutoplaySection } from "./VoiceAutoplaySection";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
|
||||
設定
|
||||
</h1>
|
||||
<Link href="/room/solo" className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
回到聊天
|
||||
</Link>
|
||||
</div>
|
||||
<AdultModeSection />
|
||||
<VoiceAutoplaySection />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user