Enforce Genarrative play-type SOP and update docs
Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows.
This commit is contained in:
@@ -143,3 +143,135 @@ test('creative image input panel confirms before removing uploaded image', () =>
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '移除' }));
|
||||
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel supports a preview-only main image mode', () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
mainImageMode="preview"
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/ui/background.png"
|
||||
uploadedImageAlt="UI背景预览"
|
||||
mainImageInputId="background-image-upload-input"
|
||||
promptTextareaId="background-prompt-input"
|
||||
prompt="雨夜猫街竖屏拼图UI背景"
|
||||
promptLabel="UI背景提示词"
|
||||
aiRedraw={false}
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="生成UI背景"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: 'UI背景预览',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('UI背景预览').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
);
|
||||
expect(screen.queryByLabelText('上传拼图图片')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.getByLabelText('UI背景提示词')).toHaveProperty(
|
||||
'value',
|
||||
'雨夜猫街竖屏拼图UI背景',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成UI背景' }));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel does not show empty upload hint over a non-removable image', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
canRemoveMainImage={false}
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面AI重绘要求(提示词)"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('拼图关卡图')).toBeTruthy();
|
||||
expect(screen.queryByText('上传图片/填写画面描述')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '移除参考图' })).toBeNull();
|
||||
});
|
||||
|
||||
test('creative image input panel can show an image without exposing AI redraw controls', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
canToggleAiRedraw={false}
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('拼图关卡图')).toBeTruthy();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.getByLabelText('画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -34,13 +34,19 @@ export type CreativeImageInputPanelProps = {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
mainImageMode?: 'edit' | 'preview';
|
||||
canRemoveMainImage?: boolean;
|
||||
canToggleAiRedraw?: boolean;
|
||||
uploadedImageSrc: string;
|
||||
uploadedImageAlt: string;
|
||||
uploadedImageRefreshKey?: string | number | null;
|
||||
mainImageMeta?: ReactNode;
|
||||
mainImageInputId: string;
|
||||
mainImageAccept?: string;
|
||||
promptTextareaId: string;
|
||||
prompt: string;
|
||||
promptLabel: string;
|
||||
promptAriaLabel?: string;
|
||||
promptRows?: number;
|
||||
aiRedraw: boolean;
|
||||
promptReferenceImages: CreativeImageInputReferenceImage[];
|
||||
@@ -69,13 +75,19 @@ export function CreativeImageInputPanel({
|
||||
className = '',
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
mainImageMode = 'edit',
|
||||
canRemoveMainImage = true,
|
||||
canToggleAiRedraw = true,
|
||||
uploadedImageSrc,
|
||||
uploadedImageAlt,
|
||||
uploadedImageRefreshKey = null,
|
||||
mainImageMeta = null,
|
||||
mainImageInputId,
|
||||
mainImageAccept = DEFAULT_IMAGE_ACCEPT,
|
||||
promptTextareaId,
|
||||
prompt,
|
||||
promptLabel,
|
||||
promptAriaLabel,
|
||||
promptRows = 2,
|
||||
aiRedraw,
|
||||
promptReferenceImages,
|
||||
@@ -100,9 +112,10 @@ export function CreativeImageInputPanel({
|
||||
useState<CreativeImageInputReferenceImage | null>(null);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const showPrompt = !uploadedImageSrc || aiRedraw;
|
||||
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
|
||||
const promptReferenceUploadDisabled =
|
||||
disabled || promptReferenceImages.length >= promptReferenceLimit;
|
||||
const canEditMainImage = mainImageMode === 'edit';
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedImageSrc) {
|
||||
@@ -144,33 +157,40 @@ export function CreativeImageInputPanel({
|
||||
</div>
|
||||
<div className="creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
|
||||
<input
|
||||
id={mainImageInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.uploadImage}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
title={uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
{canEditMainImage ? (
|
||||
<>
|
||||
<input
|
||||
id={mainImageInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.uploadImage}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
uploadedImageSrc ? labels.replaceImage : labels.uploadImage
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={uploadedImageSrc}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
alt={uploadedImageAlt}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
@@ -182,7 +202,7 @@ export function CreativeImageInputPanel({
|
||||
</span>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
|
||||
{onHistoryClick ? (
|
||||
{canEditMainImage && onHistoryClick ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
@@ -197,7 +217,7 @@ export function CreativeImageInputPanel({
|
||||
<span>历史</span>
|
||||
</button>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
{canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? (
|
||||
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
|
||||
<span>AI重绘</span>
|
||||
<input
|
||||
@@ -223,7 +243,7 @@ export function CreativeImageInputPanel({
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
{canEditMainImage && uploadedImageSrc && canRemoveMainImage ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
@@ -234,7 +254,7 @@ export function CreativeImageInputPanel({
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
) : canEditMainImage && !uploadedImageSrc ? (
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${
|
||||
@@ -245,9 +265,10 @@ export function CreativeImageInputPanel({
|
||||
>
|
||||
{labels.emptyImageHint}
|
||||
</label>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{mainImageMeta ? <div className="mt-3 shrink-0">{mainImageMeta}</div> : null}
|
||||
</div>
|
||||
|
||||
{showPrompt ? (
|
||||
@@ -267,7 +288,7 @@ export function CreativeImageInputPanel({
|
||||
placeholder=""
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
|
||||
aria-label={promptLabel}
|
||||
aria-label={promptAriaLabel ?? promptLabel}
|
||||
/>
|
||||
{imageModelPicker}
|
||||
{!uploadedImageSrc && onPromptReferenceFilesSelect ? (
|
||||
|
||||
278
src/components/jump-hop-creation/JumpHopWorkspace.tsx
Normal file
278
src/components/jump-hop-creation/JumpHopWorkspace.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { ArrowLeft, Loader2, Send } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
JumpHopDifficulty,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopStylePreset,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
|
||||
type JumpHopWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitted: (
|
||||
result: JumpHopSessionResponse,
|
||||
payload: JumpHopWorkspaceCreateRequest,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type JumpHopWorkspaceFormState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
themeTags: string;
|
||||
difficulty: JumpHopDifficulty;
|
||||
stylePreset: JumpHopStylePreset;
|
||||
characterPrompt: string;
|
||||
tilePrompt: string;
|
||||
endMoodPrompt: string;
|
||||
};
|
||||
|
||||
const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = {
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
themeTags: '',
|
||||
difficulty: 'easy',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: '',
|
||||
};
|
||||
|
||||
export function JumpHopWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitted,
|
||||
}: JumpHopWorkspaceProps) {
|
||||
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
formState.workTitle.trim() &&
|
||||
formState.workDescription.trim() &&
|
||||
formState.themeTags.trim() &&
|
||||
formState.characterPrompt.trim() &&
|
||||
formState.tilePrompt.trim(),
|
||||
),
|
||||
[formState],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || isSubmitting || isBusy) {
|
||||
setLocalError('请先补全输入。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const payload: JumpHopWorkspaceCreateRequest = {
|
||||
templateId: 'jump-hop',
|
||||
workTitle: formState.workTitle.trim(),
|
||||
workDescription: formState.workDescription.trim(),
|
||||
themeTags: formState.themeTags
|
||||
.split(/[,,、\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
difficulty: formState.difficulty,
|
||||
stylePreset: formState.stylePreset,
|
||||
characterPrompt: formState.characterPrompt.trim(),
|
||||
tilePrompt: formState.tilePrompt.trim(),
|
||||
endMoodPrompt: formState.endMoodPrompt.trim() || null,
|
||||
};
|
||||
const response = await jumpHopClient.createSession(payload);
|
||||
onSubmitted(response, payload);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标题
|
||||
</span>
|
||||
<input
|
||||
value={formState.workTitle}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品简介
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.workDescription}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
主题标签
|
||||
</span>
|
||||
<input
|
||||
value={formState.themeTags}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
themeTags: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
难度
|
||||
</span>
|
||||
<select
|
||||
value={formState.difficulty}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
difficulty: event.target.value as JumpHopDifficulty,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
>
|
||||
<option value="easy">easy</option>
|
||||
<option value="standard">standard</option>
|
||||
<option value="advanced">advanced</option>
|
||||
<option value="challenge">challenge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
风格
|
||||
</span>
|
||||
<select
|
||||
value={formState.stylePreset}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
stylePreset: event.target.value as JumpHopStylePreset,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
>
|
||||
<option value="minimal-blocks">minimal-blocks</option>
|
||||
<option value="paper-toy">paper-toy</option>
|
||||
<option value="neon-glass">neon-glass</option>
|
||||
<option value="forest-stone">forest-stone</option>
|
||||
<option value="future-metal">future-metal</option>
|
||||
<option value="custom">custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
角色提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.characterPrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
characterPrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
地块提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.tilePrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
tilePrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
终点氛围
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.endMoodPrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
endMoodPrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{localError || error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{localError ?? error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isSubmitting || isBusy}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || isSubmitting || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JumpHopWorkspace;
|
||||
468
src/components/jump-hop-result/JumpHopResultView.tsx
Normal file
468
src/components/jump-hop-result/JumpHopResultView.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Send,
|
||||
Shuffle,
|
||||
} from 'lucide-react';
|
||||
import { type CSSProperties, useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
JumpHopDraftResponse,
|
||||
JumpHopPath,
|
||||
JumpHopPlatform,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type JumpHopResultViewProps = {
|
||||
profile:
|
||||
| (JumpHopDraftResponse & {
|
||||
characterImageSrc?: string | null;
|
||||
tileAtlasImageSrc?: string | null;
|
||||
pathPreviewImageSrc?: string | null;
|
||||
})
|
||||
| (JumpHopWorkProfileResponse & {
|
||||
characterImageSrc?: string | null;
|
||||
tileAtlasImageSrc?: string | null;
|
||||
pathPreviewImageSrc?: string | null;
|
||||
});
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEdit: () => void;
|
||||
onStartTestRun: () => void;
|
||||
onPublish: () => void;
|
||||
onRegenerateCharacter: () => void;
|
||||
onRegenerateTiles: () => void;
|
||||
};
|
||||
|
||||
function isJumpHopWorkProfile(
|
||||
profile: JumpHopResultViewProps['profile'],
|
||||
): profile is JumpHopWorkProfileResponse {
|
||||
return 'summary' in profile;
|
||||
}
|
||||
|
||||
type MiniMapPlatform = {
|
||||
platform: JumpHopPlatform;
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isStart: boolean;
|
||||
isFinish: boolean;
|
||||
};
|
||||
|
||||
const difficultyToneByValue: Record<
|
||||
JumpHopPath['difficulty'],
|
||||
{ accent: string; soft: string; label: string }
|
||||
> = {
|
||||
advanced: {
|
||||
accent: '#f97316',
|
||||
soft: 'rgba(249, 115, 22, 0.16)',
|
||||
label: '进阶',
|
||||
},
|
||||
challenge: {
|
||||
accent: '#e11d48',
|
||||
soft: 'rgba(225, 29, 72, 0.16)',
|
||||
label: '挑战',
|
||||
},
|
||||
easy: {
|
||||
accent: '#14b8a6',
|
||||
soft: 'rgba(20, 184, 166, 0.16)',
|
||||
label: '轻松',
|
||||
},
|
||||
standard: {
|
||||
accent: '#2563eb',
|
||||
soft: 'rgba(37, 99, 235, 0.16)',
|
||||
label: '标准',
|
||||
},
|
||||
};
|
||||
|
||||
const tileToneByType: Record<string, string> = {
|
||||
accent: '#c4b5fd',
|
||||
bonus: '#fde68a',
|
||||
finish: '#86efac',
|
||||
normal: '#e0f2fe',
|
||||
start: '#bae6fd',
|
||||
target: '#fecdd3',
|
||||
};
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizePathPlatforms(path: JumpHopPath | null | undefined) {
|
||||
const platforms = path?.platforms ?? [];
|
||||
if (platforms.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const coordinatePlatforms = platforms.filter(
|
||||
(platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y),
|
||||
);
|
||||
const shouldUseCoordinates = coordinatePlatforms.length >= 2;
|
||||
const xValues = shouldUseCoordinates
|
||||
? coordinatePlatforms.map((platform) => platform.x)
|
||||
: [];
|
||||
const yValues = shouldUseCoordinates
|
||||
? coordinatePlatforms.map((platform) => platform.y)
|
||||
: [];
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
const minY = Math.min(...yValues);
|
||||
const maxY = Math.max(...yValues);
|
||||
const xRange = Math.max(maxX - minX, 1);
|
||||
const yRange = Math.max(maxY - minY, 1);
|
||||
const denominator = Math.max(platforms.length - 1, 1);
|
||||
|
||||
return platforms.map((platform, index): MiniMapPlatform => {
|
||||
const sequenceRatio = index / denominator;
|
||||
const hasCoordinates =
|
||||
shouldUseCoordinates &&
|
||||
isFiniteNumber(platform.x) &&
|
||||
isFiniteNumber(platform.y);
|
||||
const x = hasCoordinates
|
||||
? 12 + ((platform.x - minX) / xRange) * 76
|
||||
: 12 + sequenceRatio * 76;
|
||||
const y = hasCoordinates
|
||||
? 14 + ((platform.y - minY) / yRange) * 72
|
||||
: 50 + Math.sin(sequenceRatio * Math.PI * 2.3) * 18;
|
||||
|
||||
return {
|
||||
platform,
|
||||
index,
|
||||
x,
|
||||
y,
|
||||
width: Math.min(Math.max(platform.width || 54, 42), 82),
|
||||
height: Math.min(Math.max(platform.height || 42, 34), 68),
|
||||
isStart: index === 0 || platform.tileType === 'start',
|
||||
isFinish:
|
||||
index === path?.finishIndex ||
|
||||
platform.tileType === 'finish' ||
|
||||
platform.tileType === 'target',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function JumpHopPathMiniMap({ path }: { path: JumpHopPath }) {
|
||||
const platforms = useMemo(() => normalizePathPlatforms(path), [path]);
|
||||
const tone =
|
||||
difficultyToneByValue[path.difficulty] ?? difficultyToneByValue.standard;
|
||||
const pathPoints = platforms
|
||||
.map((platform) => `${platform.x},${platform.y}`)
|
||||
.join(' ');
|
||||
|
||||
if (platforms.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-[1/1] w-full overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]"
|
||||
style={
|
||||
{
|
||||
'--jump-hop-path-accent': tone.accent,
|
||||
'--jump-hop-path-soft': tone.soft,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.92),transparent_28%),radial-gradient(circle_at_75%_78%,rgba(125,211,252,0.24),transparent_32%)]" />
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline
|
||||
points={pathPoints}
|
||||
fill="none"
|
||||
stroke="var(--jump-hop-path-soft)"
|
||||
strokeWidth="11"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points={pathPoints}
|
||||
fill="none"
|
||||
stroke="var(--jump-hop-path-accent)"
|
||||
strokeWidth="2.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
</svg>
|
||||
{platforms.map((item) => {
|
||||
const tileTone =
|
||||
tileToneByType[item.platform.tileType] ?? tileToneByType.normal;
|
||||
const scoreBoost =
|
||||
isFiniteNumber(item.platform.scoreValue) &&
|
||||
item.platform.scoreValue > 1;
|
||||
const style = {
|
||||
left: `${item.x}%`,
|
||||
top: `${item.y}%`,
|
||||
width: `${item.width}%`,
|
||||
height: `${item.height}%`,
|
||||
background: tileTone,
|
||||
borderColor: item.isFinish ? tone.accent : 'rgba(255,255,255,0.92)',
|
||||
zIndex: 10 + item.index,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
item.platform.platformId ||
|
||||
`${item.index}-${item.platform.tileType}`
|
||||
}
|
||||
className="absolute grid max-h-9 max-w-11 min-h-6 min-w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-[0.72rem] border-2 shadow-[0_8px_18px_rgba(15,23,42,0.13)]"
|
||||
style={style}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
item.isStart || item.isFinish ? tone.accent : '#ffffff',
|
||||
boxShadow: scoreBoost ? `0 0 0 4px ${tone.soft}` : undefined,
|
||||
}}
|
||||
/>
|
||||
{item.isStart || item.isFinish ? (
|
||||
<span className="absolute -top-2.5 rounded-full bg-slate-950/78 px-1.5 py-0.5 text-[0.58rem] font-black leading-none text-white">
|
||||
{item.isStart ? '起' : '终'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="absolute left-2 top-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-strong)] shadow-sm">
|
||||
{tone.label}
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-base)] shadow-sm">
|
||||
{platforms.length}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JumpHopResultView({
|
||||
profile,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onEdit,
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
onRegenerateCharacter,
|
||||
onRegenerateTiles,
|
||||
}: JumpHopResultViewProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const isWorkProfile = isJumpHopWorkProfile(profile);
|
||||
const draft = isWorkProfile ? profile.draft : profile;
|
||||
const safeDraft = draft as JumpHopDraftResponse & {
|
||||
characterAsset: NonNullable<JumpHopDraftResponse['characterAsset']>;
|
||||
tileAtlasAsset: NonNullable<JumpHopDraftResponse['tileAtlasAsset']>;
|
||||
path: NonNullable<JumpHopDraftResponse['path']>;
|
||||
};
|
||||
const path = isWorkProfile ? profile.path : safeDraft.path;
|
||||
const characterAsset = isWorkProfile
|
||||
? profile.characterAsset
|
||||
: safeDraft.characterAsset;
|
||||
const tileAtlasAsset = isWorkProfile
|
||||
? profile.tileAtlasAsset
|
||||
: safeDraft.tileAtlasAsset;
|
||||
const titleSource = isWorkProfile
|
||||
? profile.summary.workTitle
|
||||
: profile.workTitle;
|
||||
const summarySource = isWorkProfile
|
||||
? profile.summary.workDescription
|
||||
: profile.workDescription;
|
||||
const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳';
|
||||
const summary = summarySource?.trim() || safeDraft.workDescription.trim();
|
||||
const pathPlatforms = normalizePathPlatforms(path);
|
||||
const canRenderPathMiniMap = pathPlatforms.length > 0;
|
||||
const hasAssets = Boolean(
|
||||
profile.characterImageSrc?.trim() ||
|
||||
profile.tileAtlasImageSrc?.trim() ||
|
||||
profile.pathPreviewImageSrc?.trim() ||
|
||||
characterAsset?.imageSrc?.trim() ||
|
||||
tileAtlasAsset?.imageSrc?.trim() ||
|
||||
canRenderPathMiniMap,
|
||||
);
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
await Promise.resolve(onPublish());
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateCharacter}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
角色
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateTiles}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<Shuffle className="h-4 w-4" />
|
||||
地块
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{summary}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{profile.characterImageSrc || characterAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
('characterImageSrc' in profile
|
||||
? profile.characterImageSrc
|
||||
: null) ??
|
||||
characterAsset?.imageSrc ??
|
||||
''
|
||||
}
|
||||
alt="角色图"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
角色
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{profile.tileAtlasImageSrc || tileAtlasAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
('tileAtlasImageSrc' in profile
|
||||
? profile.tileAtlasImageSrc
|
||||
: null) ??
|
||||
tileAtlasAsset?.imageSrc ??
|
||||
''
|
||||
}
|
||||
alt="地块图"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
地块
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{path && canRenderPathMiniMap ? (
|
||||
<JumpHopPathMiniMap path={path} />
|
||||
) : 'pathPreviewImageSrc' in profile &&
|
||||
profile.pathPreviewImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={profile.pathPreviewImageSrc}
|
||||
alt="路径预览"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : path ? (
|
||||
<div className="grid aspect-[1/1] place-items-center px-3 text-center">
|
||||
<div>
|
||||
<div className="text-3xl font-black text-[var(--platform-text-strong)]">
|
||||
{path.platforms.length}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{path.difficulty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
路径
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!hasAssets ? (
|
||||
<div className="platform-banner platform-banner--neutral mt-3 rounded-2xl text-sm leading-6">
|
||||
生成资源尚未准备完成。
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel flex flex-col rounded-[1.25rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
结果操作
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-auto grid gap-2 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartTestRun}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
试玩
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handlePublish();
|
||||
}}
|
||||
disabled={isBusy || isPublishing}
|
||||
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
发布
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JumpHopResultView;
|
||||
731
src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx
Normal file
731
src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx
Normal file
@@ -0,0 +1,731 @@
|
||||
import { ArrowLeft, Hand, Loader2, RotateCcw } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type PointerEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
JumpHopPlatform,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopTileAsset,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
|
||||
type JumpHopRuntimeShellProps = {
|
||||
profile?: JumpHopWorkProfileResponse | null;
|
||||
run?: JumpHopRuntimeRunSnapshotResponse | null;
|
||||
snapshot?: JumpHopRuntimeRunSnapshotResponse | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onJump: (payload: { chargeMs: number }) => Promise<unknown>;
|
||||
onRestart: () => void;
|
||||
onExit?: () => void;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
type VisiblePlatform = {
|
||||
platform: JumpHopPlatform;
|
||||
index: number;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
scale: number;
|
||||
asset: JumpHopTileAsset | null;
|
||||
};
|
||||
|
||||
const MAX_CHARGE_RATIO = 1;
|
||||
const DEFAULT_MAX_CHARGE_MS = 1800;
|
||||
const VISIBLE_FORWARD_COUNT = 6;
|
||||
|
||||
const tileToneByType: Record<string, string> = {
|
||||
accent: '#e0f2fe',
|
||||
bonus: '#fef3c7',
|
||||
finish: '#dcfce7',
|
||||
normal: '#f8fafc',
|
||||
start: '#e0f2fe',
|
||||
target: '#fee2e2',
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getRun(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null | undefined,
|
||||
snapshot: JumpHopRuntimeRunSnapshotResponse | null | undefined,
|
||||
) {
|
||||
return run ?? snapshot ?? null;
|
||||
}
|
||||
|
||||
function buildTileAssetMap(tileAssets: JumpHopTileAsset[] | undefined) {
|
||||
const map = new Map<string, JumpHopTileAsset>();
|
||||
for (const asset of tileAssets ?? []) {
|
||||
if (!map.has(asset.tileType)) {
|
||||
map.set(asset.tileType, asset);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function getStatusLabel(
|
||||
status: JumpHopRuntimeRunSnapshotResponse['status'] | undefined,
|
||||
) {
|
||||
if (status === 'cleared') {
|
||||
return '通关';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
return '进行中';
|
||||
}
|
||||
|
||||
function getJumpFeedback(run: JumpHopRuntimeRunSnapshotResponse | null) {
|
||||
const result = run?.lastJump?.result;
|
||||
if (result === 'perfect') {
|
||||
return 'Perfect';
|
||||
}
|
||||
if (result === 'finish') {
|
||||
return 'Finish';
|
||||
}
|
||||
if (result === 'hit') {
|
||||
return 'Hit';
|
||||
}
|
||||
if (result === 'miss') {
|
||||
return 'Miss';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectPlatformPath(
|
||||
platforms: JumpHopPlatform[],
|
||||
currentIndex: number,
|
||||
tileAssetMap: Map<string, JumpHopTileAsset>,
|
||||
) {
|
||||
const current = platforms[currentIndex] ?? platforms[0];
|
||||
if (!current) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const start = Math.max(0, currentIndex - 1);
|
||||
const end = Math.min(platforms.length, currentIndex + VISIBLE_FORWARD_COUNT);
|
||||
const visible = platforms.slice(start, end);
|
||||
const worldScale = 0.86;
|
||||
|
||||
return visible.map((platform, offset): VisiblePlatform => {
|
||||
const index = start + offset;
|
||||
const dx = platform.x - current.x;
|
||||
const dy = platform.y - current.y;
|
||||
const isoX = (dx - dy) * worldScale;
|
||||
const isoY = (dx + dy) * 0.46 * worldScale;
|
||||
const depth = index - currentIndex;
|
||||
|
||||
return {
|
||||
platform,
|
||||
index,
|
||||
screenX: 50 + isoX,
|
||||
screenY: 58 + isoY - depth * 0.8,
|
||||
scale: clamp(1 - Math.max(0, depth) * 0.035, 0.78, 1.08),
|
||||
asset:
|
||||
tileAssetMap.get(platform.tileType) ??
|
||||
tileAssetMap.get('normal') ??
|
||||
tileAssetMap.get('start') ??
|
||||
null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getCharacterPosition(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
platforms: VisiblePlatform[],
|
||||
) {
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const landedPlatform = platforms.find(
|
||||
(item) => item.index === run.currentPlatformIndex,
|
||||
);
|
||||
if (landedPlatform) {
|
||||
return {
|
||||
x: landedPlatform.screenX,
|
||||
y: landedPlatform.screenY - 8,
|
||||
isMiss: false,
|
||||
};
|
||||
}
|
||||
|
||||
const lastJump = run.lastJump;
|
||||
if (lastJump && run.status === 'failed') {
|
||||
const targetPlatform = platforms.find(
|
||||
(item) => item.index === lastJump.targetPlatformIndex,
|
||||
);
|
||||
if (targetPlatform) {
|
||||
return {
|
||||
x: targetPlatform.screenX + 8,
|
||||
y: targetPlatform.screenY - 2,
|
||||
isMiss: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function IsometricFallbackTile({ platform }: { platform: JumpHopPlatform }) {
|
||||
const tone = tileToneByType[platform.tileType] ?? tileToneByType.normal;
|
||||
const style = {
|
||||
'--jump-hop-tile-tone': tone,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="jump-hop-runtime__fallback-tile"
|
||||
style={style}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="jump-hop-runtime__fallback-top" />
|
||||
<div className="jump-hop-runtime__fallback-side jump-hop-runtime__fallback-side--left" />
|
||||
<div className="jump-hop-runtime__fallback-side jump-hop-runtime__fallback-side--right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JumpHopRuntimeShell({
|
||||
profile = null,
|
||||
run,
|
||||
snapshot,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onExit,
|
||||
onBack,
|
||||
onRestart,
|
||||
onJump,
|
||||
}: JumpHopRuntimeShellProps) {
|
||||
const activeRun = getRun(run, snapshot);
|
||||
const [isCharging, setIsCharging] = useState(false);
|
||||
const [chargeMs, setChargeMs] = useState(0);
|
||||
const chargeStartRef = useRef<number | null>(null);
|
||||
|
||||
const maxChargeMs =
|
||||
activeRun?.path.scoring.maxChargeMs &&
|
||||
activeRun.path.scoring.maxChargeMs > 0
|
||||
? activeRun.path.scoring.maxChargeMs
|
||||
: DEFAULT_MAX_CHARGE_MS;
|
||||
const chargeRatio = clamp(chargeMs / maxChargeMs, 0, MAX_CHARGE_RATIO);
|
||||
const canJump = Boolean(
|
||||
activeRun && activeRun.status === 'playing' && !isBusy,
|
||||
);
|
||||
const exitHandler = onExit ?? onBack;
|
||||
const tileAssetMap = useMemo(
|
||||
() => buildTileAssetMap(profile?.tileAssets),
|
||||
[profile?.tileAssets],
|
||||
);
|
||||
const visiblePlatforms = useMemo(
|
||||
() =>
|
||||
projectPlatformPath(
|
||||
activeRun?.path.platforms ?? [],
|
||||
activeRun?.currentPlatformIndex ?? 0,
|
||||
tileAssetMap,
|
||||
),
|
||||
[activeRun?.currentPlatformIndex, activeRun?.path.platforms, tileAssetMap],
|
||||
);
|
||||
const characterPosition = getCharacterPosition(activeRun, visiblePlatforms);
|
||||
const jumpFeedback = getJumpFeedback(activeRun);
|
||||
const isSettled =
|
||||
activeRun?.status === 'failed' || activeRun?.status === 'cleared';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCharging) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (chargeStartRef.current == null) {
|
||||
return;
|
||||
}
|
||||
setChargeMs(clamp(Date.now() - chargeStartRef.current, 0, maxChargeMs));
|
||||
}, 16);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isCharging, maxChargeMs]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsCharging(false);
|
||||
chargeStartRef.current = null;
|
||||
setChargeMs(0);
|
||||
}, [activeRun?.runId, activeRun?.currentPlatformIndex, activeRun?.status]);
|
||||
|
||||
const beginCharge = (event: PointerEvent<HTMLElement>) => {
|
||||
if (!canJump) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
chargeStartRef.current = Date.now();
|
||||
setIsCharging(true);
|
||||
setChargeMs(0);
|
||||
};
|
||||
|
||||
const finishCharge = async () => {
|
||||
if (!isCharging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextChargeMs = clamp(
|
||||
chargeStartRef.current ? Date.now() - chargeStartRef.current : chargeMs,
|
||||
0,
|
||||
maxChargeMs,
|
||||
);
|
||||
chargeStartRef.current = null;
|
||||
setIsCharging(false);
|
||||
setChargeMs(nextChargeMs);
|
||||
await onJump({ chargeMs: nextChargeMs });
|
||||
};
|
||||
|
||||
const cancelCharge = () => {
|
||||
chargeStartRef.current = null;
|
||||
setIsCharging(false);
|
||||
setChargeMs(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface jump-hop-runtime relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#eef8ff] text-slate-950">
|
||||
<div className="jump-hop-runtime__sky" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.82),transparent_30%),linear-gradient(180deg,rgba(255,255,255,0.18),rgba(148,210,255,0.2))]" />
|
||||
|
||||
<header className="relative z-20 flex items-center justify-between gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitHandler}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/80 px-3 py-2 text-sm shadow-sm backdrop-blur"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/70 bg-white/82 px-3 py-2 text-sm font-black shadow-sm backdrop-blur">
|
||||
<span>{activeRun?.score ?? 0}</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-300" />
|
||||
<span>{activeRun?.combo ?? 0}x</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestart}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/80 px-3 py-2 text-sm shadow-sm backdrop-blur"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重开
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 mx-auto flex w-full max-w-[30rem] flex-1 flex-col px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-4">
|
||||
<section
|
||||
className="jump-hop-runtime__stage relative min-h-0 flex-1 touch-none select-none overflow-hidden rounded-[1.5rem] border border-white/70 bg-white/40 shadow-[0_24px_70px_rgba(44,125,182,0.2)]"
|
||||
onPointerDown={beginCharge}
|
||||
onPointerUp={() => void finishCharge()}
|
||||
onPointerCancel={cancelCharge}
|
||||
onPointerLeave={() => {
|
||||
if (isCharging) {
|
||||
void finishCharge();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="jump-hop-runtime__horizon" />
|
||||
<div className="jump-hop-runtime__path-shadow" />
|
||||
|
||||
{visiblePlatforms.map((item) => {
|
||||
const width =
|
||||
clamp(item.platform.width * 0.92, 58, 112) * item.scale;
|
||||
const height =
|
||||
clamp(item.platform.height * 0.72, 46, 86) * item.scale;
|
||||
const style = {
|
||||
left: `${item.screenX}%`,
|
||||
top: `${item.screenY}%`,
|
||||
width,
|
||||
height,
|
||||
zIndex: 20 + item.index,
|
||||
} as CSSProperties;
|
||||
const isCurrent = item.index === activeRun?.currentPlatformIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.platform.platformId}
|
||||
className="jump-hop-runtime__platform"
|
||||
style={style}
|
||||
data-current={isCurrent ? 'true' : 'false'}
|
||||
>
|
||||
<div className="jump-hop-runtime__platform-shadow" />
|
||||
{item.asset?.imageSrc ? (
|
||||
<img
|
||||
src={item.asset.imageSrc}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="jump-hop-runtime__tile-image"
|
||||
/>
|
||||
) : (
|
||||
<IsometricFallbackTile platform={item.platform} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{characterPosition ? (
|
||||
<div
|
||||
className="jump-hop-runtime__character"
|
||||
data-charging={isCharging ? 'true' : 'false'}
|
||||
data-miss={characterPosition.isMiss ? 'true' : 'false'}
|
||||
style={
|
||||
{
|
||||
left: `${characterPosition.x}%`,
|
||||
top: `${characterPosition.y}%`,
|
||||
'--jump-hop-charge': chargeRatio,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="jump-hop-runtime__character-shadow" />
|
||||
{profile?.characterAsset?.imageSrc ? (
|
||||
<img
|
||||
src={profile.characterAsset.imageSrc}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="jump-hop-runtime__character-image"
|
||||
/>
|
||||
) : (
|
||||
<div className="jump-hop-runtime__character-fallback" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{jumpFeedback ? (
|
||||
<div
|
||||
key={`${activeRun?.currentPlatformIndex}-${activeRun?.lastJump?.result}`}
|
||||
className="jump-hop-runtime__feedback"
|
||||
>
|
||||
{jumpFeedback}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isCharging ? (
|
||||
<div className="jump-hop-runtime__charge-orbit" aria-hidden="true">
|
||||
<div
|
||||
className="jump-hop-runtime__charge-fill"
|
||||
style={{ transform: `scaleX(${chargeRatio})` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!activeRun ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-white/35 text-sm font-black text-slate-600 backdrop-blur-sm">
|
||||
等待开局
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSettled ? (
|
||||
<div className="absolute inset-0 z-50 grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
|
||||
<div className="w-full max-w-[18rem] rounded-[1.25rem] border border-white/70 bg-white/90 p-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.22)]">
|
||||
<div className="text-2xl font-black">
|
||||
{getStatusLabel(activeRun?.status)}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center gap-4 text-sm font-bold text-slate-600">
|
||||
<span>{activeRun?.score ?? 0} 分</span>
|
||||
<span>{activeRun?.combo ?? 0}x</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestart}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--primary min-h-11 px-3 py-2 text-sm"
|
||||
>
|
||||
重开
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitHandler}
|
||||
className="platform-button platform-button--ghost min-h-11 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<footer className="relative z-20 mt-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 text-sm font-bold text-slate-700">
|
||||
{error ? (
|
||||
<span className="text-rose-600">{error}</span>
|
||||
) : (
|
||||
<span>{getStatusLabel(activeRun?.status)}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canJump}
|
||||
onPointerDown={beginCharge}
|
||||
onPointerUp={() => void finishCharge()}
|
||||
onPointerCancel={cancelCharge}
|
||||
className="platform-button platform-button--primary min-h-12 shrink-0 gap-2 rounded-full px-5 py-3 text-sm shadow-[0_12px_28px_rgba(14,165,233,0.28)]"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Hand className="h-4 w-4" />
|
||||
)}
|
||||
起跳
|
||||
</button>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<style>{`
|
||||
.jump-hop-runtime__sky {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, rgba(253, 230, 138, 0.36), transparent 24%),
|
||||
radial-gradient(circle at 82% 22%, rgba(125, 211, 252, 0.34), transparent 28%),
|
||||
linear-gradient(180deg, #f7fdff 0%, #e8f6ff 52%, #d9eefc 100%);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__stage {
|
||||
min-height: 31rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__horizon {
|
||||
position: absolute;
|
||||
inset: 10% 5% 8%;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.32) 38%, transparent 70%);
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__path-shadow {
|
||||
position: absolute;
|
||||
left: 21%;
|
||||
right: 16%;
|
||||
top: 55%;
|
||||
height: 18%;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(ellipse at center, rgba(45, 118, 170, 0.16), transparent 68%);
|
||||
transform: rotate(22deg);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__platform {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition:
|
||||
left 220ms ease,
|
||||
top 220ms ease,
|
||||
transform 220ms ease;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__platform[data-current='true'] {
|
||||
transform: translate(-50%, -50%) scale(1.07);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__platform-shadow {
|
||||
position: absolute;
|
||||
left: 8%;
|
||||
right: 8%;
|
||||
bottom: -16%;
|
||||
height: 32%;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 82, 126, 0.18);
|
||||
filter: blur(7px);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__tile-image {
|
||||
position: relative;
|
||||
width: 124%;
|
||||
height: 124%;
|
||||
object-fit: contain;
|
||||
image-rendering: auto;
|
||||
filter: drop-shadow(0 16px 16px rgba(24, 75, 112, 0.18));
|
||||
}
|
||||
|
||||
.jump-hop-runtime__fallback-tile {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotateX(58deg) rotateZ(45deg);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__fallback-top {
|
||||
position: absolute;
|
||||
inset: 8%;
|
||||
border-radius: 14%;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255,255,255,0.8), transparent 44%),
|
||||
var(--jump-hop-tile-tone);
|
||||
border: 2px solid rgba(255, 255, 255, 0.86);
|
||||
box-shadow: inset -8px -8px 14px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__fallback-side {
|
||||
position: absolute;
|
||||
background: color-mix(in srgb, var(--jump-hop-tile-tone) 72%, #0f766e);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__fallback-side--left {
|
||||
left: 8%;
|
||||
bottom: -9%;
|
||||
width: 84%;
|
||||
height: 18%;
|
||||
transform: skewX(45deg);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__fallback-side--right {
|
||||
right: -9%;
|
||||
top: 8%;
|
||||
width: 18%;
|
||||
height: 84%;
|
||||
transform: skewY(45deg);
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__character {
|
||||
position: absolute;
|
||||
z-index: 80;
|
||||
width: 4.7rem;
|
||||
height: 5.5rem;
|
||||
transform:
|
||||
translate(-50%, -86%)
|
||||
scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))
|
||||
scaleX(calc(1 + (var(--jump-hop-charge) * 0.08)));
|
||||
transform-origin: 50% 100%;
|
||||
transition:
|
||||
left 240ms ease,
|
||||
top 240ms ease,
|
||||
transform 120ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__character[data-miss='true'] {
|
||||
opacity: 0.78;
|
||||
transform: translate(-50%, -60%) rotate(18deg) scale(0.88);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__character-shadow {
|
||||
position: absolute;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
bottom: 0.16rem;
|
||||
height: 0.86rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.18);
|
||||
filter: blur(3px);
|
||||
transform: scaleX(calc(1 + (var(--jump-hop-charge) * 0.42)));
|
||||
}
|
||||
|
||||
.jump-hop-runtime__character-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 12px 10px rgba(15, 23, 42, 0.18));
|
||||
}
|
||||
|
||||
.jump-hop-runtime__character-fallback {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0.68rem;
|
||||
width: 2.3rem;
|
||||
height: 3.3rem;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px 999px 0.9rem 0.9rem;
|
||||
background:
|
||||
radial-gradient(circle at 50% 22%, #fff 0 17%, transparent 18%),
|
||||
linear-gradient(180deg, #fb7185 0%, #f97316 100%);
|
||||
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.72), 0 12px 18px rgba(190, 80, 40, 0.24);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__feedback {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 23%;
|
||||
z-index: 90;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
color: white;
|
||||
padding: 0.42rem 0.86rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0;
|
||||
animation: jump-hop-feedback 900ms ease both;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__charge-orbit {
|
||||
position: absolute;
|
||||
left: 1.1rem;
|
||||
right: 1.1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 85;
|
||||
height: 0.62rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.16);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.65);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__charge-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: left center;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #38bdf8, #facc15, #fb7185);
|
||||
}
|
||||
|
||||
@keyframes jump-hop-feedback {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 0.8rem) scale(0.96);
|
||||
}
|
||||
20%, 72% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -0.6rem) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.jump-hop-runtime__stage {
|
||||
min-height: min(68vh, 36rem);
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__character {
|
||||
width: 4.15rem;
|
||||
height: 4.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.jump-hop-runtime__feedback {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__platform,
|
||||
.jump-hop-runtime__character {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JumpHopRuntimeShell;
|
||||
@@ -18,6 +18,7 @@ export interface PlatformEntryCreationTypeModalProps {
|
||||
onSelectBigFish: () => void;
|
||||
onSelectMatch3D: () => void;
|
||||
onSelectSquareHole: () => void;
|
||||
onSelectJumpHop: () => void;
|
||||
onSelectPuzzle: () => void;
|
||||
onSelectCreativeAgent: () => void;
|
||||
onSelectBarkBattle: () => void;
|
||||
@@ -100,6 +101,7 @@ export function PlatformEntryCreationTypeModal({
|
||||
onSelectBigFish,
|
||||
onSelectMatch3D,
|
||||
onSelectSquareHole,
|
||||
onSelectJumpHop,
|
||||
onSelectPuzzle,
|
||||
onSelectCreativeAgent,
|
||||
onSelectBarkBattle,
|
||||
@@ -142,6 +144,9 @@ export function PlatformEntryCreationTypeModal({
|
||||
if (item.id === 'square-hole') {
|
||||
onSelectSquareHole();
|
||||
}
|
||||
if (item.id === 'jump-hop') {
|
||||
onSelectJumpHop();
|
||||
}
|
||||
if (item.id === 'puzzle') {
|
||||
onSelectPuzzle();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import {
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
@@ -67,6 +68,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
|
||||
return '视觉小说';
|
||||
}
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return '跳一跳';
|
||||
}
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.templateName;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ export type SelectionStage =
|
||||
| 'square-hole-generating'
|
||||
| 'square-hole-result'
|
||||
| 'square-hole-runtime'
|
||||
| 'jump-hop-workspace'
|
||||
| 'jump-hop-generating'
|
||||
| 'jump-hop-result'
|
||||
| 'jump-hop-runtime'
|
||||
| 'jump-hop-gallery-detail'
|
||||
| 'bark-battle-runtime'
|
||||
| 'creative-agent-workspace'
|
||||
| 'visual-novel-agent-workspace'
|
||||
|
||||
@@ -390,9 +390,6 @@ describe('PuzzleResultView', () => {
|
||||
within(dialog).getByRole('button', { name: /生成画面/u }),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByText('消耗2泥点')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText('等待时间可以制作更多关卡哦~'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByText('画面图')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: /关卡测试/u }),
|
||||
@@ -1013,7 +1010,7 @@ describe('PuzzleResultView', () => {
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新拼图UI背景提示词' },
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /生成UI背景 · 2泥点/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /生成UI背景.*2泥点/u })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '确认消耗泥点',
|
||||
@@ -1352,6 +1349,184 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor exposes entrance image editing controls without sharing UI background state', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const session = createSession({
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
levels: [
|
||||
{
|
||||
...createSession().draft!.levels![0]!,
|
||||
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={session}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
|
||||
expect(within(dialog).getByText('画面图')).toBeTruthy();
|
||||
expect(within(dialog).getByLabelText('上传参考图')).toBeTruthy();
|
||||
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toHaveProperty(
|
||||
'checked',
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '选择历史图片' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('画面AI重绘要求(提示词)'), {
|
||||
target: { value: '只重绘第一关猫街画面' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '只重绘第一关猫街画面',
|
||||
aiRedraw: true,
|
||||
}),
|
||||
);
|
||||
expect(onExecuteAction).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor keeps AI redraw switch scoped to the level image action', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const session = createSession({
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
levels: [
|
||||
{
|
||||
...createSession().draft!.levels![0]!,
|
||||
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={session}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
aiRedraw: false,
|
||||
}),
|
||||
);
|
||||
expect(onExecuteAction).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
aiRedraw: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor hides AI redraw controls when only the formal image is shown', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
|
||||
expect(within(dialog).queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(within(dialog).getByLabelText('画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('UI background generator reuses common image input UI without sharing level image fields', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByText('UI背景预览')).toBeTruthy();
|
||||
expect(screen.getByLabelText('UI背景提示词')).toBeTruthy();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.queryByLabelText('上传拼图图片')).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('UI背景提示词'), {
|
||||
target: { value: '独立的草稿UI背景提示词' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
promptText: '独立的草稿UI背景提示词',
|
||||
}),
|
||||
);
|
||||
const payload = onExecuteAction.mock.calls[0]![0];
|
||||
expect(payload).not.toHaveProperty('referenceImageSrc');
|
||||
expect(payload).not.toHaveProperty('aiRedraw');
|
||||
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
uiBackgroundPrompt: '独立的草稿UI背景提示词',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('shows creative agent draft edit bar and submits the current draft', () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
History,
|
||||
ImagePlus,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
@@ -11,10 +9,9 @@ import {
|
||||
Plus,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wand2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
@@ -29,6 +26,7 @@ import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -659,6 +657,7 @@ function PuzzleLevelDetailDialog({
|
||||
promptText?: string | null,
|
||||
referenceImageSrc?: string | null,
|
||||
imageModel?: PuzzleImageModelId | null,
|
||||
aiRedraw?: boolean | null,
|
||||
) => void;
|
||||
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
|
||||
onStartTestRun?: (level: PuzzleDraftLevel) => void;
|
||||
@@ -674,6 +673,7 @@ function PuzzleLevelDetailDialog({
|
||||
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
);
|
||||
const [aiRedraw, setAiRedraw] = useState(true);
|
||||
const formalImageSrc = resolveLevelFormalImageSrc(level);
|
||||
const hasFormalImage = Boolean(formalImageSrc);
|
||||
const effectiveReferenceImageSrc =
|
||||
@@ -688,15 +688,7 @@ function PuzzleLevelDetailDialog({
|
||||
generationNowMs,
|
||||
);
|
||||
|
||||
const handleReferenceImageChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleReferenceImageFile = async (file: File) => {
|
||||
try {
|
||||
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
|
||||
setReferenceImageSrc(dataUrl);
|
||||
@@ -722,6 +714,7 @@ function PuzzleLevelDetailDialog({
|
||||
nextLevel.pictureDescription.trim() || undefined,
|
||||
effectiveReferenceImageSrc || undefined,
|
||||
imageModel,
|
||||
aiRedraw,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -776,134 +769,91 @@ function PuzzleLevelDetailDialog({
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(13rem,0.9fr)_minmax(0,1.1fr)]">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="mb-3 text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
画面图
|
||||
</div>
|
||||
<div className="relative aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)]">
|
||||
<input
|
||||
id={`puzzle-level-reference-upload-${level.levelId}`}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
aria-label="上传参考图"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`puzzle-level-reference-upload-${level.levelId}`}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'}
|
||||
</span>
|
||||
</label>
|
||||
{displayImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={displayImageSrc}
|
||||
refreshKey={`${imageRefreshKey}:${level.levelId}`}
|
||||
alt={displayImageAlt}
|
||||
className="pointer-events-none h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.92),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm">
|
||||
<ImagePlus className="h-7 w-7" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{generationProgress.isGenerating ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/80 bg-white/94 px-4 py-2 text-sm font-black text-[var(--platform-text-strong)] shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-amber-600" />
|
||||
生成中
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<CreativeImageInputPanel
|
||||
disabled={isBusy || generationProgress.isGenerating}
|
||||
isSubmitting={generationProgress.isGenerating}
|
||||
uploadedImageSrc={displayImageSrc}
|
||||
uploadedImageAlt={displayImageAlt}
|
||||
uploadedImageRefreshKey={`${imageRefreshKey}:${level.levelId}`}
|
||||
canRemoveMainImage={Boolean(effectiveReferenceImageSrc)}
|
||||
canToggleAiRedraw={Boolean(effectiveReferenceImageSrc)}
|
||||
mainImageMeta={
|
||||
effectiveReferenceImageSrc ? (
|
||||
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<div className="h-12 w-12 overflow-hidden rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={effectiveReferenceImageSrc}
|
||||
alt="拼图参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsHistoryPickerOpen(true)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-label="选择历史图片"
|
||||
title="选择历史图片"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
<span>历史</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{effectiveReferenceImageSrc ? (
|
||||
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<div className="h-12 w-12 overflow-hidden rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={effectiveReferenceImageSrc}
|
||||
alt="拼图参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{referenceImageLabel || '已选择参考图'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setReferenceImageSrc('');
|
||||
setReferenceImageLabel('');
|
||||
setReferenceImageError(null);
|
||||
onLevelChange({ ...level, pictureReference: null });
|
||||
}}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="移除参考图"
|
||||
title="移除参考图"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{referenceImageError ? (
|
||||
<div className="mt-2 text-xs leading-5 text-red-600">
|
||||
{referenceImageError}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
画面描述
|
||||
</div>
|
||||
<div className="relative mt-3">
|
||||
<textarea
|
||||
value={level.pictureDescription}
|
||||
disabled={isBusy}
|
||||
rows={7}
|
||||
onChange={(event) =>
|
||||
onLevelChange({
|
||||
...level,
|
||||
pictureDescription: event.target.value,
|
||||
})
|
||||
}
|
||||
className="h-[12rem] min-h-[12rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none sm:h-[14rem] sm:min-h-[14rem] lg:h-full lg:min-h-[18rem]"
|
||||
aria-label="画面描述"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
mainImageInputId={`puzzle-level-reference-upload-${level.levelId}`}
|
||||
promptTextareaId={`puzzle-level-picture-description-${level.levelId}`}
|
||||
prompt={level.pictureDescription}
|
||||
promptLabel={
|
||||
effectiveReferenceImageSrc
|
||||
? '画面AI重绘要求(提示词)'
|
||||
: '画面描述'
|
||||
}
|
||||
promptRows={7}
|
||||
aiRedraw={aiRedraw}
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={
|
||||
<PuzzleImageModelPicker
|
||||
value={imageModel}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || generationProgress.isGenerating}
|
||||
onChange={setImageModel}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
inputError={referenceImageError}
|
||||
submitLabel={hasFormalImage ? '重新生成画面' : '生成画面'}
|
||||
submitCostLabel={`消耗${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`}
|
||||
submitDisabled={
|
||||
isBusy ||
|
||||
generationProgress.isGenerating ||
|
||||
(!level.pictureDescription.trim() && !effectiveReferenceImageSrc)
|
||||
}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
history: '选择历史图片',
|
||||
}}
|
||||
onMainImageFileSelect={(file) => {
|
||||
void handleReferenceImageFile(file);
|
||||
}}
|
||||
onMainImageRemove={() => {
|
||||
setReferenceImageSrc('');
|
||||
setReferenceImageLabel('');
|
||||
setReferenceImageError(null);
|
||||
setAiRedraw(true);
|
||||
onLevelChange({ ...level, pictureReference: null });
|
||||
}}
|
||||
onAiRedrawChange={setAiRedraw}
|
||||
onPromptChange={(value) =>
|
||||
onLevelChange({
|
||||
...level,
|
||||
pictureDescription: value,
|
||||
})
|
||||
}
|
||||
onHistoryClick={() => setIsHistoryPickerOpen(true)}
|
||||
onSubmit={() => setIsCostConfirmOpen(true)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -940,25 +890,7 @@ function PuzzleLevelDetailDialog({
|
||||
预计剩余 {generationProgress.secondsLeft} 秒
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsCostConfirmOpen(true)}
|
||||
className="inline-flex w-full flex-col items-center justify-center gap-1 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗{PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold leading-none text-white/78">
|
||||
等待时间可以制作更多关卡哦~
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isCostConfirmOpen ? (
|
||||
@@ -1453,82 +1385,75 @@ function PuzzleUiAssetsTab({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(13rem,0.78fr)_minmax(0,1fr)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="mx-auto aspect-[9/16] max-h-[min(62dvh,34rem)] w-full max-w-[18rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 text-left shadow-sm"
|
||||
aria-label="打开拼图UI预览"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={backgroundPreviewSrc}
|
||||
refreshKey={`${imageRefreshKey}:ui-background`}
|
||||
alt="拼图UI背景图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
UI背景提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={isBusy || !firstLevel}
|
||||
rows={8}
|
||||
onChange={(event) => {
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: event.target.value,
|
||||
});
|
||||
}}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="拼图UI背景提示词"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
预览UI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
setIsCostConfirmOpen(true);
|
||||
}}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isBusy || isGeneratingUiBackground ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
{isGeneratingUiBackground
|
||||
? '生成中'
|
||||
: hasGeneratedUiBackground
|
||||
? '重新生成'
|
||||
: '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreativeImageInputPanel
|
||||
mainImageMode="preview"
|
||||
disabled={isBusy || !firstLevel || isGeneratingUiBackground}
|
||||
isSubmitting={isGeneratingUiBackground}
|
||||
uploadedImageSrc={backgroundPreviewSrc}
|
||||
uploadedImageAlt="拼图UI背景图"
|
||||
uploadedImageRefreshKey={`${imageRefreshKey}:ui-background`}
|
||||
mainImageInputId="puzzle-ui-background-preview"
|
||||
promptTextareaId="puzzle-ui-background-prompt-input"
|
||||
prompt={prompt}
|
||||
promptLabel="UI背景提示词"
|
||||
promptAriaLabel="拼图UI背景提示词"
|
||||
promptRows={8}
|
||||
aiRedraw={false}
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel={
|
||||
isGeneratingUiBackground
|
||||
? '生成中'
|
||||
: hasGeneratedUiBackground
|
||||
? '重新生成'
|
||||
: '生成UI背景'
|
||||
}
|
||||
submitCostLabel={`· ${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`}
|
||||
submitDisabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
labels={{
|
||||
imageField: 'UI背景预览',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={(value) => {
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: value,
|
||||
});
|
||||
}}
|
||||
onSubmit={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
setIsCostConfirmOpen(true);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="platform-button platform-button--ghost mt-3 min-h-11 w-full justify-center gap-2 px-4 py-3"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
预览UI
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{isPreviewOpen ? (
|
||||
@@ -2301,7 +2226,13 @@ export function PuzzleResultView({
|
||||
isBusy={isBusy}
|
||||
level={activeLevel}
|
||||
onClose={() => setActiveLevelId(null)}
|
||||
onGenerate={(nextLevel, promptText, referenceImageSrc, imageModel) => {
|
||||
onGenerate={(
|
||||
nextLevel,
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
imageModel,
|
||||
aiRedraw,
|
||||
) => {
|
||||
updateLevel(nextLevel);
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_images',
|
||||
@@ -2309,7 +2240,7 @@ export function PuzzleResultView({
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: true,
|
||||
aiRedraw: aiRedraw ?? true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: !nextLevel.levelName.trim(),
|
||||
workTitle: editState.workTitle.trim(),
|
||||
|
||||
@@ -128,6 +128,7 @@ import {
|
||||
formatPlatformWorldTime,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
@@ -1844,11 +1845,13 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
? '抓鹅'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? '方洞'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? '视觉'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? entry.templateName
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
: isJumpHopGalleryEntry(entry)
|
||||
? '跳一跳'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? '视觉'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? entry.templateName
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return formatPlatformWorkDisplayTag(kind);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
@@ -23,6 +27,7 @@ import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
@@ -42,6 +47,7 @@ export type PlatformWorldCardLike =
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformJumpHopGalleryCard
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
@@ -172,6 +178,30 @@ export type PlatformVisualNovelGalleryCard = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformJumpHopGalleryCard = {
|
||||
sourceType: 'jump-hop';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
difficulty?: string;
|
||||
stylePreset?: string;
|
||||
};
|
||||
|
||||
export type PlatformEdutainmentGalleryCard = {
|
||||
sourceType: 'edutainment';
|
||||
templateId: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID;
|
||||
@@ -202,6 +232,7 @@ export type PlatformPublicGalleryCard =
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformJumpHopGalleryCard
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
@@ -241,6 +272,12 @@ export function isVisualNovelGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'visual-novel';
|
||||
}
|
||||
|
||||
export function isJumpHopGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformJumpHopGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'jump-hop';
|
||||
}
|
||||
|
||||
export function isEdutainmentGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformEdutainmentGalleryCard {
|
||||
@@ -388,6 +425,53 @@ export function mapVisualNovelWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapJumpHopWorkToPlatformGalleryCard(
|
||||
work: JumpHopGalleryCardResponse | JumpHopWorkProfileResponse,
|
||||
): PlatformJumpHopGalleryCard {
|
||||
const summary = 'summary' in work ? work.summary : work;
|
||||
const difficulty = summary.difficulty;
|
||||
const difficultyLabel =
|
||||
difficulty === 'easy'
|
||||
? '轻松节奏'
|
||||
: difficulty === 'advanced'
|
||||
? '进阶跳台'
|
||||
: difficulty === 'challenge'
|
||||
? '极限路线'
|
||||
: '标准路线';
|
||||
|
||||
return {
|
||||
sourceType: 'jump-hop',
|
||||
workId: summary.workId,
|
||||
profileId: summary.profileId,
|
||||
sourceSessionId:
|
||||
'sourceSessionId' in summary ? (summary.sourceSessionId ?? null) : null,
|
||||
publicWorkCode:
|
||||
'publicWorkCode' in summary && summary.publicWorkCode.trim()
|
||||
? summary.publicWorkCode
|
||||
: buildJumpHopPublicWorkCode(summary.profileId),
|
||||
ownerUserId: summary.ownerUserId,
|
||||
authorDisplayName:
|
||||
'authorDisplayName' in summary ? summary.authorDisplayName : '玩家',
|
||||
worldName: summary.workTitle,
|
||||
subtitle: difficultyLabel,
|
||||
summaryText: summary.workDescription,
|
||||
coverImageSrc: summary.coverImageSrc ?? null,
|
||||
themeTags:
|
||||
summary.themeTags.length > 0
|
||||
? summary.themeTags
|
||||
: ['跳一跳', difficultyLabel],
|
||||
playCount: summary.playCount ?? 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: summary.publishedAt ?? null,
|
||||
updatedAt: summary.updatedAt,
|
||||
difficulty,
|
||||
stylePreset: summary.stylePreset,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBabyObjectMatchDraftToPlatformGalleryCard(
|
||||
draft: BabyObjectMatchDraft,
|
||||
): PlatformEdutainmentGalleryCard {
|
||||
@@ -465,6 +549,10 @@ export function resolvePlatformWorldFallbackCoverImage(
|
||||
return '/creation-type-references/visual-novel.webp';
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return '/creation-type-references/jump-hop.webp';
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return '/creation-type-references/big-fish.webp';
|
||||
}
|
||||
@@ -628,6 +716,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
: ['视觉小说'];
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: ['跳一跳'];
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
@@ -720,6 +814,10 @@ export function resolvePlatformPublicWorkCode(
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
274
src/services/jump-hop/jumpHopClient.ts
Normal file
274
src/services/jump-hop/jumpHopClient.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type {
|
||||
JumpHopActionRequest,
|
||||
JumpHopActionResponse,
|
||||
JumpHopDraftResponse,
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse,
|
||||
JumpHopGalleryResponse,
|
||||
JumpHopRunResponse,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
|
||||
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
|
||||
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
|
||||
const JUMP_HOP_RUNTIME_API_BASE = '/api/runtime/jump-hop';
|
||||
const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
|
||||
export type {
|
||||
JumpHopActionRequest,
|
||||
JumpHopActionResponse,
|
||||
JumpHopDraftResponse,
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse,
|
||||
JumpHopGalleryResponse,
|
||||
JumpHopRunResponse,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse,
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
};
|
||||
export type CreateJumpHopSessionRequest = {
|
||||
themeText: string;
|
||||
characterDescription: string;
|
||||
tileStyle: string;
|
||||
difficulty: string;
|
||||
rhythmPreference: string;
|
||||
};
|
||||
export type ExecuteJumpHopActionRequest = JumpHopActionRequest;
|
||||
export type JumpHopSessionSnapshot = JumpHopSessionSnapshotResponse;
|
||||
|
||||
const jumpHopCreationClient = createCreationAgentClient<
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse,
|
||||
never,
|
||||
never,
|
||||
JumpHopActionRequest,
|
||||
JumpHopActionResponse
|
||||
>({
|
||||
apiBase: JUMP_HOP_API_BASE,
|
||||
messages: {
|
||||
createSession: '创建跳一跳共创会话失败',
|
||||
getSession: '读取跳一跳共创会话失败',
|
||||
sendMessage: '发送跳一跳共创消息失败',
|
||||
streamIncomplete: '跳一跳共创消息流式结果不完整',
|
||||
executeAction: '执行跳一跳共创操作失败',
|
||||
},
|
||||
});
|
||||
|
||||
type FlattenedJumpHopWorkProfileResponse = Omit<
|
||||
JumpHopWorkProfileResponse,
|
||||
'summary'
|
||||
> &
|
||||
JumpHopWorkSummaryResponse;
|
||||
|
||||
function normalizeJumpHopWorkProfile(
|
||||
work: JumpHopWorkProfileResponse | FlattenedJumpHopWorkProfileResponse,
|
||||
): JumpHopWorkProfileResponse {
|
||||
if ('summary' in work && work.summary) {
|
||||
return work;
|
||||
}
|
||||
|
||||
const flattened = work as FlattenedJumpHopWorkProfileResponse;
|
||||
const summary: JumpHopWorkProfileResponse['summary'] = {
|
||||
runtimeKind: flattened.runtimeKind,
|
||||
workId: flattened.workId,
|
||||
profileId: flattened.profileId,
|
||||
ownerUserId: flattened.ownerUserId,
|
||||
sourceSessionId: flattened.sourceSessionId ?? null,
|
||||
workTitle: flattened.workTitle,
|
||||
workDescription: flattened.workDescription,
|
||||
themeTags: flattened.themeTags,
|
||||
difficulty: flattened.difficulty,
|
||||
stylePreset: flattened.stylePreset,
|
||||
coverImageSrc: flattened.coverImageSrc ?? null,
|
||||
publicationStatus: flattened.publicationStatus,
|
||||
playCount: flattened.playCount,
|
||||
updatedAt: flattened.updatedAt,
|
||||
publishedAt: flattened.publishedAt ?? null,
|
||||
publishReady: flattened.publishReady,
|
||||
generationStatus: flattened.generationStatus,
|
||||
};
|
||||
|
||||
return {
|
||||
summary,
|
||||
draft: flattened.draft,
|
||||
path: flattened.path,
|
||||
characterAsset: flattened.characterAsset,
|
||||
tileAtlasAsset: flattened.tileAtlasAsset,
|
||||
tileAssets: flattened.tileAssets,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJumpHopActionResponse(
|
||||
response: JumpHopActionResponse,
|
||||
): JumpHopActionResponse {
|
||||
return {
|
||||
...response,
|
||||
work: response.work ? normalizeJumpHopWorkProfile(response.work) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJumpHopWorkDetailResponse(
|
||||
response: JumpHopWorkDetailResponse,
|
||||
): JumpHopWorkDetailResponse {
|
||||
return {
|
||||
...response,
|
||||
item: normalizeJumpHopWorkProfile(response.item),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJumpHopWorkMutationResponse(
|
||||
response: JumpHopWorkMutationResponse,
|
||||
): JumpHopWorkMutationResponse {
|
||||
return {
|
||||
...response,
|
||||
item: normalizeJumpHopWorkProfile(response.item),
|
||||
};
|
||||
}
|
||||
|
||||
export function createJumpHopCreationSession(
|
||||
payload: JumpHopWorkspaceCreateRequest,
|
||||
) {
|
||||
return jumpHopCreationClient.createSession(payload);
|
||||
}
|
||||
|
||||
export function getJumpHopCreationSession(sessionId: string) {
|
||||
return jumpHopCreationClient.getSession(sessionId);
|
||||
}
|
||||
|
||||
export function executeJumpHopCreationAction(
|
||||
sessionId: string,
|
||||
payload: JumpHopActionRequest,
|
||||
) {
|
||||
return jumpHopCreationClient
|
||||
.executeAction(sessionId, payload)
|
||||
.then(normalizeJumpHopActionResponse);
|
||||
}
|
||||
|
||||
export async function getJumpHopWorkDetail(profileId: string) {
|
||||
const response = await requestJson<JumpHopWorkDetailResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取跳一跳作品详情失败',
|
||||
);
|
||||
return normalizeJumpHopWorkDetailResponse(response);
|
||||
}
|
||||
|
||||
export async function listJumpHopGallery() {
|
||||
return requestJson<JumpHopGalleryResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/gallery`,
|
||||
{ method: 'GET' },
|
||||
'读取跳一跳广场失败',
|
||||
{
|
||||
retry: JUMP_HOP_RUNTIME_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getJumpHopGalleryDetail(publicWorkCode: string) {
|
||||
const response = await requestJson<JumpHopGalleryDetailResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/gallery/${encodeURIComponent(publicWorkCode)}`,
|
||||
{ method: 'GET' },
|
||||
'读取跳一跳广场详情失败',
|
||||
{
|
||||
retry: JUMP_HOP_RUNTIME_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
return normalizeJumpHopWorkDetailResponse(response);
|
||||
}
|
||||
|
||||
export async function publishJumpHopWork(profileId: string) {
|
||||
const response = await requestJson<JumpHopWorkMutationResponse>(
|
||||
`${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布跳一跳作品失败',
|
||||
);
|
||||
return normalizeJumpHopWorkMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function startJumpHopRuntimeRun(profileId: string) {
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ profileId }),
|
||||
},
|
||||
'启动跳一跳运行态失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitJumpHopJump(
|
||||
runId: string,
|
||||
payload: { chargeMs: number },
|
||||
) {
|
||||
const requestPayload = {
|
||||
chargeMs: payload.chargeMs,
|
||||
clientEventId: `jump-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/jump`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
},
|
||||
'提交跳一跳起跳失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function restartJumpHopRuntimeRun(runId: string) {
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientActionId: `restart-${runId}-${Date.now()}`,
|
||||
}),
|
||||
},
|
||||
'重新开始跳一跳失败',
|
||||
);
|
||||
}
|
||||
|
||||
export const jumpHopClient = {
|
||||
createSession: createJumpHopCreationSession,
|
||||
getSession: getJumpHopCreationSession,
|
||||
executeAction: executeJumpHopCreationAction,
|
||||
getGalleryDetail: getJumpHopGalleryDetail,
|
||||
getWorkDetail: getJumpHopWorkDetail,
|
||||
listGallery: listJumpHopGallery,
|
||||
publishWork: publishJumpHopWork,
|
||||
restartRun: restartJumpHopRuntimeRun,
|
||||
startRun: startJumpHopRuntimeRun,
|
||||
submitJump: submitJumpHopJump,
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBabyObjectMatchGenerationAnchorEntries,
|
||||
buildJumpHopGenerationAnchorEntries,
|
||||
buildMatch3DGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
@@ -306,6 +307,54 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('jump hop draft generation exposes character and tile atlas pipeline', () => {
|
||||
const state = createMiniGameDraftGenerationState('jump-hop');
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 35_000,
|
||||
);
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'jump-hop-draft',
|
||||
'jump-hop-character',
|
||||
'jump-hop-tile-atlas',
|
||||
'jump-hop-slice-tiles',
|
||||
'jump-hop-write-draft',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('jump-hop-character');
|
||||
expect(progress?.phaseLabel).toBe('生成角色形象');
|
||||
expect(progress?.estimatedRemainingMs).toBe(265_000);
|
||||
});
|
||||
|
||||
test('jump hop generation anchors expose theme, character and tile style', () => {
|
||||
const entries = buildJumpHopGenerationAnchorEntries(null, {
|
||||
themeText: '云端糖果塔',
|
||||
characterDescription: '披着星星披风的小旅人',
|
||||
tileStyle: '纸模玩具',
|
||||
difficulty: '标准',
|
||||
rhythmPreference: '轻快',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'jump-hop-theme',
|
||||
label: '主题',
|
||||
value: '云端糖果塔',
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-character',
|
||||
label: '角色',
|
||||
value: '披着星星披风的小旅人',
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-tile-style',
|
||||
label: '地块',
|
||||
value: '纸模玩具',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('puzzle generation anchors expose form payload as the display source', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries({
|
||||
sessionId: 'puzzle-session-1',
|
||||
|
||||
@@ -17,13 +17,18 @@ import type {
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
|
||||
import type {
|
||||
CreateJumpHopSessionRequest,
|
||||
JumpHopSessionSnapshot,
|
||||
} from './jump-hop/jumpHopClient';
|
||||
|
||||
export type MiniGameDraftGenerationKind =
|
||||
| 'puzzle'
|
||||
| 'big-fish'
|
||||
| 'square-hole'
|
||||
| 'match3d'
|
||||
| 'baby-object-match';
|
||||
| 'baby-object-match'
|
||||
| 'jump-hop';
|
||||
|
||||
export type MiniGameDraftGenerationPhase =
|
||||
| 'idle'
|
||||
@@ -49,6 +54,11 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'baby-object-draft'
|
||||
| 'baby-object-images'
|
||||
| 'baby-object-ready'
|
||||
| 'jump-hop-draft'
|
||||
| 'jump-hop-character'
|
||||
| 'jump-hop-tile-atlas'
|
||||
| 'jump-hop-slice-tiles'
|
||||
| 'jump-hop-write-draft'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-ui-background'
|
||||
| 'puzzle-select-image'
|
||||
@@ -268,6 +278,41 @@ const BABY_OBJECT_MATCH_STEPS = [
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const JUMP_HOP_STEPS = [
|
||||
{
|
||||
id: 'jump-hop-draft',
|
||||
label: '整理玩法草稿',
|
||||
detail: '建立主题、难度和路径基础数据。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-character',
|
||||
label: '生成角色形象',
|
||||
detail: '生成可进入运行态的俯视角角色图。',
|
||||
weight: 34,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-tile-atlas',
|
||||
label: '生成地块图集',
|
||||
detail: '生成起点、普通、目标和终点地块图集。',
|
||||
weight: 34,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-slice-tiles',
|
||||
label: '切分地块素材',
|
||||
detail: '切分透明地块 PNG 并校验落点半径。',
|
||||
weight: 14,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-write-draft',
|
||||
label: '写入正式草稿',
|
||||
detail: '保存角色、地块、路径和封面合成结果。',
|
||||
weight: 8,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const JUMP_HOP_ESTIMATED_WAIT_MS = 5 * 60_000;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
@@ -285,6 +330,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'baby-object-match') {
|
||||
return BABY_OBJECT_MATCH_STEPS;
|
||||
}
|
||||
if (kind === 'jump-hop') {
|
||||
return JUMP_HOP_STEPS;
|
||||
}
|
||||
return BIG_FISH_STEPS;
|
||||
}
|
||||
|
||||
@@ -340,8 +388,10 @@ export function createMiniGameDraftGenerationState(
|
||||
? 'square-hole-draft'
|
||||
: kind === 'match3d'
|
||||
? 'match3d-work-title'
|
||||
: kind === 'baby-object-match'
|
||||
? 'baby-object-draft'
|
||||
: kind === 'baby-object-match'
|
||||
? 'baby-object-draft'
|
||||
: kind === 'jump-hop'
|
||||
? 'jump-hop-draft'
|
||||
: 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
completedAssetCount: 0,
|
||||
@@ -413,6 +463,24 @@ function resolveBabyObjectMatchPhaseByElapsedMs(
|
||||
return 'baby-object-draft';
|
||||
}
|
||||
|
||||
function resolveJumpHopPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 270_000) {
|
||||
return 'jump-hop-write-draft';
|
||||
}
|
||||
if (elapsedMs >= 220_000) {
|
||||
return 'jump-hop-slice-tiles';
|
||||
}
|
||||
if (elapsedMs >= 115_000) {
|
||||
return 'jump-hop-tile-atlas';
|
||||
}
|
||||
if (elapsedMs >= 12_000) {
|
||||
return 'jump-hop-character';
|
||||
}
|
||||
return 'jump-hop-draft';
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||||
let elapsedBeforePhase = 0;
|
||||
|
||||
@@ -491,7 +559,14 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
...state,
|
||||
phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
: state.kind === 'jump-hop' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
@@ -518,9 +593,11 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 0.42
|
||||
: normalizedState.kind === 'match3d'
|
||||
? 0.5
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? 0.52
|
||||
: 0;
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? 0.52
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? 0.5
|
||||
: 0;
|
||||
const overallProgress =
|
||||
normalizedState.phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
@@ -551,6 +628,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? '跳一跳草稿已准备完成,可进入结果页试玩或发布。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
@@ -574,6 +653,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
0,
|
||||
BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS - elapsedMs,
|
||||
)
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? Math.max(0, JUMP_HOP_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(
|
||||
@@ -585,6 +666,65 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJumpHopGenerationAnchorEntries(
|
||||
session: JumpHopSessionSnapshot | null | undefined,
|
||||
formPayload: CreateJumpHopSessionRequest | null | undefined = null,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
const sessionRecord = session as
|
||||
| {
|
||||
config?: Partial<CreateJumpHopSessionRequest>;
|
||||
draft?: {
|
||||
workTitle?: string;
|
||||
themeText?: string;
|
||||
characterPrompt?: string;
|
||||
stylePreset?: string;
|
||||
} | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const config = sessionRecord?.config;
|
||||
const draft = sessionRecord?.draft;
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
{
|
||||
key: 'jump-hop-theme',
|
||||
label: '主题',
|
||||
value:
|
||||
formPayload?.themeText?.trim() ||
|
||||
config?.themeText?.trim() ||
|
||||
draft?.themeText?.trim() ||
|
||||
draft?.workTitle?.trim() ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'jump-hop-character',
|
||||
label: '角色',
|
||||
value:
|
||||
formPayload?.characterDescription?.trim() ||
|
||||
config?.characterDescription?.trim() ||
|
||||
draft?.characterPrompt?.trim() ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'jump-hop-tile-style',
|
||||
label: '地块',
|
||||
value:
|
||||
formPayload?.tileStyle?.trim() ||
|
||||
config?.tileStyle?.trim() ||
|
||||
draft?.stylePreset?.trim() ||
|
||||
'',
|
||||
},
|
||||
];
|
||||
|
||||
return entries
|
||||
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
|
||||
.map((entry) => ({
|
||||
id: entry.key,
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
}))
|
||||
.filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
export function buildPuzzleGenerationAnchorEntries(
|
||||
session: PuzzleAgentSessionSnapshot | null | undefined,
|
||||
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,
|
||||
|
||||
@@ -53,6 +53,14 @@ export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
|
||||
return `BO-${suffix}`;
|
||||
}
|
||||
|
||||
export function buildJumpHopPublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `JH-${suffix}`;
|
||||
}
|
||||
|
||||
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
@@ -124,3 +132,13 @@ export function isSameBabyObjectMatchPublicWorkCode(
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameJumpHopPublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildJumpHopPublicWorkCode(profileId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user