feat: add puzzle onboarding and match3d entry updates
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user