feat: add puzzle onboarding and match3d entry updates
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-07 23:30:54 +08:00
parent df80876f60
commit e8fee0172a
27 changed files with 1802 additions and 68 deletions

View File

@@ -1,4 +1,4 @@
import { Loader2 } from 'lucide-react';
import { Loader2, Sparkles } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import {
lazy,
@@ -166,6 +166,10 @@ import {
submitLocalPuzzleLeaderboard,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import {
generatePuzzleOnboardingWork,
savePuzzleOnboardingWork,
} from '../../services/puzzle-onboarding';
import {
claimPuzzleWorkPointIncentive,
deletePuzzleWork,
@@ -251,6 +255,13 @@ type PuzzleRuntimeReturnStage =
| 'work-detail'
| 'platform';
type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
type PuzzleOnboardingDraft = {
promptText: string;
item: PuzzleWorkSummary;
};
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
type SquareHoleRuntimeReturnStage =
@@ -605,6 +616,157 @@ function mergePuzzleWorkSummary(
return current.profileId === updated.profileId ? updated : current;
}
const PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY =
'genarrative.puzzle-onboarding.first-visit.v1';
const PUZZLE_ONBOARDING_COPY = '待定待定待定';
const PUZZLE_ONBOARDING_CLEAR_COPY = '只差一步,就可以永久保留你的梦';
const PUZZLE_ONBOARDING_GENERATED_DELAY_MS = 700;
function hasSeenPuzzleOnboarding() {
if (typeof window === 'undefined') {
return true;
}
try {
return (
window.localStorage.getItem(PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY) ===
'1'
);
} catch {
return false;
}
}
function markPuzzleOnboardingSeen() {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY, '1');
} catch {
// 中文注释localStorage 不可写时只降级为本次会话展示,不影响主流程。
}
}
function PuzzleOnboardingView({
prompt,
phase,
error,
onPromptChange,
onSubmit,
}: {
prompt: string;
phase: PuzzleOnboardingPhase;
error: string | null;
onPromptChange: (value: string) => void;
onSubmit: () => void;
}) {
const isGenerating = phase === 'generating';
const isGenerated = phase === 'generated';
const canSubmit = Boolean(prompt.trim()) && !isGenerating && !isGenerated;
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_30%_15%,rgba(251,191,36,0.22),transparent_30%),linear-gradient(135deg,#0f172a,#111827_46%,#1e1b4b)] px-4 py-8 text-white">
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.045)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:38px_38px] opacity-30" />
<section className="relative flex w-full max-w-[34rem] flex-col items-center gap-5 text-center">
<div className="grid h-14 w-14 place-items-center rounded-[1.2rem] border border-amber-200/32 bg-amber-200/14 text-amber-100 shadow-[0_18px_48px_rgba(251,191,36,0.18)]">
{isGenerating ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<Sparkles className="h-6 w-6" />
)}
</div>
<h1 className="text-[2rem] font-black leading-tight sm:text-[2.85rem]">
{PUZZLE_ONBOARDING_COPY}
</h1>
<form
className="flex w-full flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<textarea
value={prompt}
disabled={isGenerating || isGenerated}
onChange={(event) => onPromptChange(event.target.value)}
placeholder="把你的梦讲给我听吧"
rows={4}
className="min-h-32 w-full resize-none rounded-[1.25rem] border border-white/14 bg-black/28 px-4 py-4 text-base font-semibold leading-7 text-white shadow-[0_18px_50px_rgba(0,0,0,0.24)] outline-none backdrop-blur placeholder:text-white/42 focus:border-amber-200/70 focus:ring-2 focus:ring-amber-200/20 disabled:opacity-70"
/>
<button
type="submit"
disabled={!canSubmit}
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'生成'
)}
</button>
</form>
{error ? (
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
{error}
</div>
) : null}
</section>
</div>
);
}
function PuzzleOnboardingLoginOverlay({
isSaving,
error,
onLogin,
}: {
isSaving: boolean;
error: string | null;
onLogin: () => void;
}) {
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-950/72 px-4 py-6 text-white backdrop-blur-md">
<section className="flex w-full max-w-[24rem] flex-col items-center gap-5 rounded-[1.35rem] border border-white/14 bg-slate-950/94 px-5 py-6 text-center shadow-[0_28px_90px_rgba(0,0,0,0.5)]">
<div className="grid h-12 w-12 place-items-center rounded-[1rem] bg-amber-200 text-slate-950">
{isSaving ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Sparkles className="h-5 w-5" />
)}
</div>
<h2 className="text-2xl font-black leading-tight">
{PUZZLE_ONBOARDING_CLEAR_COPY}
</h2>
<button
type="button"
disabled={isSaving}
onClick={onLogin}
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
/
</>
) : (
'注册账号 / 登录'
)}
</button>
{error ? (
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
{error}
</div>
) : null}
</section>
</div>
);
}
function mergeBigFishWorkSummary(
current: BigFishWorkSummary,
updated: BigFishWorkSummary,
@@ -1124,10 +1286,24 @@ export function PlatformEntryFlowShellImpl({
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
const puzzleRunRef = useRef<PuzzleRunSnapshot | null>(null);
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
const [puzzleShelfError, setPuzzleShelfError] = useState<string | null>(null);
const [puzzleCreationError, setPuzzleCreationError] = useState<string | null>(
null,
);
const [puzzleGenerationState, setPuzzleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
useState<CreatePuzzleAgentSessionRequest | null>(null);
const [puzzleOnboardingPrompt, setPuzzleOnboardingPrompt] = useState('');
const [puzzleOnboardingPhase, setPuzzleOnboardingPhase] =
useState<PuzzleOnboardingPhase>('input');
const [puzzleOnboardingDraft, setPuzzleOnboardingDraft] =
useState<PuzzleOnboardingDraft | null>(null);
const [puzzleOnboardingError, setPuzzleOnboardingError] = useState<
string | null
>(null);
const [isPuzzleOnboardingSaving, setIsPuzzleOnboardingSaving] =
useState(false);
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
useState(false);
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
@@ -1295,9 +1471,9 @@ export function PlatformEntryFlowShellImpl({
try {
const worksResponse = await listPuzzleWorks();
setPuzzleWorks(worksResponse.items);
setPuzzleError(null);
setPuzzleShelfError(null);
} catch (error) {
setPuzzleError(
setPuzzleShelfError(
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
);
} finally {
@@ -1609,6 +1785,106 @@ export function PlatformEntryFlowShellImpl({
[authUi],
);
const savePuzzleOnboardingDraft = useCallback(async () => {
if (!puzzleOnboardingDraft || isPuzzleOnboardingSaving) {
return;
}
setIsPuzzleOnboardingSaving(true);
setPuzzleOnboardingError(null);
try {
const response = await savePuzzleOnboardingWork({
promptText: puzzleOnboardingDraft.promptText,
item: puzzleOnboardingDraft.item,
});
setPuzzleWorks((current) => [response.item, ...current]);
setSelectedPuzzleDetail(null);
setPuzzleRun(null);
setPuzzleOnboardingDraft(null);
setPuzzleOnboardingPrompt('');
setPuzzleOnboardingPhase('input');
platformBootstrap.setPlatformTab('home');
setSelectionStage('platform');
void refreshPuzzleShelf();
} catch (error) {
setPuzzleOnboardingError(
resolvePuzzleErrorMessage(error, '保存新手引导拼图失败。'),
);
} finally {
setIsPuzzleOnboardingSaving(false);
}
}, [
isPuzzleOnboardingSaving,
platformBootstrap,
puzzleOnboardingDraft,
refreshPuzzleShelf,
resolvePuzzleErrorMessage,
setSelectionStage,
]);
const requestPuzzleOnboardingLogin = useCallback(() => {
if (isPuzzleOnboardingSaving) {
return;
}
authUi?.openLoginModal(() => {
void savePuzzleOnboardingDraft();
});
}, [authUi, isPuzzleOnboardingSaving, savePuzzleOnboardingDraft]);
useEffect(() => {
if (
!authUi ||
authUi?.user ||
selectionStage !== 'platform' ||
hasSeenPuzzleOnboarding()
) {
return;
}
setPuzzleOnboardingPhase('input');
setPuzzleOnboardingError(null);
setSelectionStage('puzzle-onboarding');
}, [authUi, authUi?.user, selectionStage, setSelectionStage]);
const submitPuzzleOnboardingPrompt = useCallback(async () => {
const promptText = puzzleOnboardingPrompt.trim();
if (!promptText || puzzleOnboardingPhase === 'generating') {
return;
}
setPuzzleOnboardingPhase('generating');
setPuzzleOnboardingError(null);
try {
const response = await generatePuzzleOnboardingWork({ promptText });
const item: PuzzleWorkSummary = {
...response.item,
levels:
response.item.levels && response.item.levels.length > 0
? response.item.levels
: [response.level],
};
setPuzzleOnboardingDraft({ promptText, item });
setSelectedPuzzleDetail(item);
setPuzzleOnboardingPhase('generated');
markPuzzleOnboardingSeen();
window.setTimeout(() => {
setPuzzleRun(startLocalPuzzleRun(item));
setPuzzleRuntimeReturnStage('platform');
setSelectionStage('puzzle-runtime');
}, PUZZLE_ONBOARDING_GENERATED_DELAY_MS);
} catch (error) {
setPuzzleOnboardingPhase('input');
setPuzzleOnboardingError(
resolvePuzzleErrorMessage(error, '生成新手引导拼图失败。'),
);
}
}, [
puzzleOnboardingPhase,
puzzleOnboardingPrompt,
resolvePuzzleErrorMessage,
setSelectionStage,
]);
const requestDeleteCreationWork = useCallback(
(confirmation: DeleteCreationWorkConfirmation) => {
if (deletingCreationWorkId) {
@@ -1986,8 +2262,14 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab,
setSelectionStage,
onSessionOpened: () => {
sessionController.setCreationTypeError(null);
setPuzzleCreationError(null);
setShowCreationTypeModal(false);
},
onOpenError: ({ errorMessage }) => {
sessionController.setCreationTypeError(errorMessage);
setPuzzleCreationError(errorMessage);
},
onActionComplete: async ({ payload, response, setSession }) => {
setPuzzleOperation(response.operation);
setSession(response.session);
@@ -2167,6 +2449,8 @@ export function PlatformEntryFlowShellImpl({
setPuzzleOperation(null);
setPuzzleGenerationState(null);
setPuzzleFormDraftPayload(null);
sessionController.setCreationTypeError(null);
setPuzzleCreationError(null);
const nextSession = await puzzleFlow.openWorkspace({});
if (nextSession) {
void refreshPuzzleShelf();
@@ -2274,6 +2558,8 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(null);
setPuzzleGenerationState(null);
setIsPuzzleNextLevelGenerating(false);
setPuzzleShelfError(null);
setPuzzleCreationError(null);
setPuzzleError(null);
setDeletingCreationWorkId(null);
setClaimingPuzzlePointIncentiveProfileId(null);
@@ -4970,6 +5256,7 @@ export function PlatformEntryFlowShellImpl({
bigFishError ??
match3dError ??
squareHoleError ??
puzzleShelfError ??
puzzleError)
}
onRetry={() => {
@@ -4977,6 +5264,8 @@ export function PlatformEntryFlowShellImpl({
setBigFishError(null);
setMatch3DError(null);
setSquareHoleError(null);
setPuzzleShelfError(null);
setPuzzleCreationError(null);
setPuzzleError(null);
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
platformBootstrap.setPlatformError(
@@ -4995,6 +5284,7 @@ export function PlatformEntryFlowShellImpl({
bigFishError ??
match3dError ??
squareHoleError ??
puzzleCreationError ??
puzzleError
}
createBusy={
@@ -5926,6 +6216,26 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'puzzle-onboarding' && (
<motion.div
key="puzzle-onboarding"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100]"
>
<PuzzleOnboardingView
prompt={puzzleOnboardingPrompt}
phase={puzzleOnboardingPhase}
error={puzzleOnboardingError}
onPromptChange={setPuzzleOnboardingPrompt}
onSubmit={() => {
void submitPuzzleOnboardingPrompt();
}}
/>
</motion.div>
)}
{selectionStage === 'puzzle-generating' && (
<motion.div
key="puzzle-generating"
@@ -6064,6 +6374,7 @@ export function PlatformEntryFlowShellImpl({
isPuzzleLeaderboardBusy
}
error={puzzleError}
hideBackButton={Boolean(puzzleOnboardingDraft)}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
@@ -6100,6 +6411,14 @@ export function PlatformEntryFlowShellImpl({
</div>
</div>
) : null}
{puzzleOnboardingDraft &&
puzzleRun?.currentLevel?.status === 'cleared' ? (
<PuzzleOnboardingLoginOverlay
isSaving={isPuzzleOnboardingSaving}
error={puzzleOnboardingError}
onLogin={requestPuzzleOnboardingLogin}
/>
) : null}
</motion.div>
)}
@@ -6345,6 +6664,7 @@ export function PlatformEntryFlowShellImpl({
bigFishError ??
match3dError ??
squareHoleError ??
puzzleCreationError ??
puzzleError ??
sessionController.creationTypeError
}