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:
2026-05-20 12:12:00 +08:00
parent f370539a6f
commit 3931442249
123 changed files with 15514 additions and 3419 deletions

View File

@@ -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();
});

View File

@@ -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 ? (

View 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;

View 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;

View 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;

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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'

View File

@@ -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();

View File

@@ -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(),

View File

@@ -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);
}

View File

@@ -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;
}

View 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,
};

View File

@@ -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',

View File

@@ -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,

View File

@@ -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)
);
}