合并最新 master 到组件库收口分支

合入 origin/master 的 VectorEngine、后端主干和跳一跳更新
保留 PlatformUiKit 组件库收口提交并处理跳一跳运行态冲突
合并 Hermes 决策记录中的前端组件库、微信能力和后端流程决策
This commit is contained in:
2026-06-10 10:34:52 +08:00
90 changed files with 6260 additions and 2735 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1774,6 +1774,7 @@ export function PlatformEntryFlowShellImpl({
shouldRestoreCustomWorldAgentUiState(),
);
const handledInitialPublicWorkCodeRef = useRef<string | null>(null);
const handledJumpHopRuntimeRestoreRef = useRef<string | null>(null);
const selectionStageRef = useRef(selectionStage);
const creationFlowReturnTargetRef =
useRef<CreationFlowReturnTarget>('create');
@@ -7494,6 +7495,7 @@ export function PlatformEntryFlowShellImpl({
profileId: string,
options: {
embedded?: boolean;
preloadedWork?: JumpHopWorkProfileResponse | null;
returnStage?: 'work-detail' | 'platform';
} = {},
) => {
@@ -7526,7 +7528,11 @@ export function PlatformEntryFlowShellImpl({
: null,
);
const [detail, runResponse] = await Promise.all([
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
options.preloadedWork
? Promise.resolve({ item: options.preloadedWork })
: jumpHopClient
.getWorkDetail(normalizedProfileId)
.catch(() => null),
jumpHopClient.startRun(normalizedProfileId, {
...runtimeGuestOptions,
runtimeMode: 'published',
@@ -7558,6 +7564,78 @@ export function PlatformEntryFlowShellImpl({
[authUi, setSelectionStage],
);
useEffect(() => {
if (selectionStage !== 'jump-hop-runtime' || jumpHopRun) {
return;
}
const publicWorkCode = initialPublicWorkCode?.trim() ?? '';
const restoreKey = publicWorkCode || '__jump-hop-runtime-empty__';
if (handledJumpHopRuntimeRestoreRef.current === restoreKey) {
return;
}
handledJumpHopRuntimeRestoreRef.current = restoreKey;
if (!publicWorkCode) {
setJumpHopError(null);
setJumpHopRuntimeRequestOptions(null);
setJumpHopRuntimeReturnStage('platform');
setSelectionStage('platform');
pushAppHistoryPath('/');
return;
}
let cancelled = false;
const restoreJumpHopRuntime = async () => {
setIsJumpHopBusy(true);
setJumpHopError(null);
try {
const detail = await jumpHopClient.getGalleryDetail(publicWorkCode);
if (cancelled) {
return;
}
const profileId = detail.item.summary.profileId;
const started = await startJumpHopRunFromProfile(profileId, {
preloadedWork: detail.item,
returnStage: 'work-detail',
});
if (!started && !cancelled) {
setSelectionStage('platform');
pushAppHistoryPath('/');
}
} catch (error) {
if (cancelled) {
return;
}
setJumpHopError(
resolveRpgCreationErrorMessage(error, '恢复跳一跳玩法失败。'),
);
setJumpHopRun(null);
setJumpHopRuntimeRequestOptions(null);
setJumpHopRuntimeReturnStage('platform');
setSelectionStage('platform');
pushAppHistoryPath('/');
} finally {
if (!cancelled) {
setIsJumpHopBusy(false);
}
}
};
void restoreJumpHopRuntime();
return () => {
cancelled = true;
};
}, [
initialPublicWorkCode,
jumpHopRun,
selectionStage,
setSelectionStage,
startJumpHopRunFromProfile,
]);
const restartJumpHopRuntimeRun = useCallback(async () => {
const runId = jumpHopRun?.runId;
if (!runId) {
@@ -13945,16 +14023,20 @@ export function PlatformEntryFlowShellImpl({
useEffect(() => {
const publicWorkCode = initialPublicWorkCode?.trim();
if (
!publicWorkCode ||
handledInitialPublicWorkCodeRef.current === publicWorkCode
) {
if (!publicWorkCode) {
return;
}
if (selectionStage === 'jump-hop-runtime') {
handledInitialPublicWorkCodeRef.current = publicWorkCode;
return;
}
if (handledInitialPublicWorkCodeRef.current === publicWorkCode) {
return;
}
handledInitialPublicWorkCodeRef.current = publicWorkCode;
void handlePublicCodeSearch(publicWorkCode);
}, [handlePublicCodeSearch, initialPublicWorkCode]);
}, [handlePublicCodeSearch, initialPublicWorkCode, selectionStage]);
useEffect(() => {
if (selectionStage === 'platform') {
@@ -1697,6 +1697,28 @@ function buildMockJumpHopWork(
};
}
function buildMockJumpHopRuntimeRun(
work: JumpHopWorkProfileResponse,
overrides: Partial<JumpHopRuntimeRunSnapshotResponse> = {},
): JumpHopRuntimeRunSnapshotResponse {
return {
runId: 'jump-hop-run-1',
profileId: work.summary.profileId,
ownerUserId: work.summary.ownerUserId,
status: 'playing',
currentPlatformIndex: 0,
successfulJumpCount: 0,
durationMs: 0,
score: 0,
combo: 0,
path: work.path,
lastJump: null,
startedAtMs: 1_779_999_000_000,
finishedAtMs: null,
...overrides,
};
}
function buildMockBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
@@ -6938,6 +6960,89 @@ test('logged out public jump-hop detail starts runtime without requireAuth', asy
expect(requireAuth).not.toHaveBeenCalled();
});
test('direct jump hop runtime route without work code returns platform home', async () => {
window.history.replaceState(null, '', '/runtime/jump-hop');
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(window.location.pathname).toBe('/');
});
expect(window.location.search).toBe('');
expect(jumpHopClient.getGalleryDetail).not.toHaveBeenCalled();
expect(jumpHopClient.startRun).not.toHaveBeenCalled();
expect(await screen.findByRole('button', { name: '创作' })).toBeTruthy();
});
test('direct jump hop runtime route with public work code starts published run', async () => {
const publishedJumpHopWork = buildMockJumpHopWork({
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-direct-1',
profileId: 'jump-hop-profile-direct-12345678',
ownerUserId: 'user-2',
sourceSessionId: 'jump-hop-session-direct-1',
themeText: '星星果园',
workTitle: '星星果园跳一跳',
workDescription: '沿着水果一路弹跳。',
themeTags: ['果园', '星星'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: null,
publicationStatus: 'published',
playCount: 8,
updatedAt: '2026-06-07T10:00:00.000Z',
publishedAt: '2026-06-07T10:00:00.000Z',
publishReady: true,
generationStatus: 'ready',
},
});
const publishedJumpHopRun = buildMockJumpHopRuntimeRun(publishedJumpHopWork, {
runId: 'jump-hop-run-direct-1',
ownerUserId: '',
});
window.history.replaceState(null, '', '/runtime/jump-hop?work=JH-12345678');
vi.mocked(jumpHopClient.getGalleryDetail).mockResolvedValue({
item: publishedJumpHopWork,
});
vi.mocked(jumpHopClient.startRun).mockResolvedValue({
run: publishedJumpHopRun,
});
vi.mocked(jumpHopClient.getLeaderboard).mockResolvedValue({
profileId: publishedJumpHopWork.summary.profileId,
items: [],
viewerBest: null,
});
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: vi.fn(),
})}
/>,
);
await waitFor(() => {
expect(jumpHopClient.getGalleryDetail).toHaveBeenCalledWith('JH-12345678');
});
await waitFor(() => {
expect(jumpHopClient.startRun).toHaveBeenCalledWith(
publishedJumpHopWork.summary.profileId,
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
runtimeMode: 'published',
}),
);
});
expect(jumpHopClient.getWorkDetail).not.toHaveBeenCalled();
expect(window.location.pathname).toBe('/runtime/jump-hop');
expect(window.location.search).toContain('work=JH-12345678');
});
test('owned public puzzle detail edits original draft instead of remixing', async () => {
const user = userEvent.setup();
const ownedPuzzleWork = {
@@ -65,8 +65,7 @@ test('jump hop workspace submits theme payload after required field is filled',
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt:
'云朵跳台主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
tilePrompt: '云朵跳台主题的3D立方体主题身份方块包装图集',
endMoodPrompt: null,
});
});
@@ -90,13 +89,6 @@ test('jump hop workspace calls back when return button is clicked', async () =>
expect(onBack).toHaveBeenCalledTimes(1);
});
test('jump hop workspace theme label uses platform section label style', () => {
render(<JumpHopCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />);
expect(screen.getByText('主题').className).toContain('tracking-[0.18em]');
expect(screen.getByLabelText('主题').className).toContain('bg-white/90');
});
test('jump hop workspace can defer visible chrome to the unified creation page', () => {
const { container } = render(
<JumpHopCreationWorkspace
@@ -44,7 +44,7 @@ function buildJumpHopWorkspacePayload(
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: `${themeText}主题的正面30度视角主题物体图集,物体本身作为跳跃落点`,
tilePrompt: `${themeText}主题的3D立方体主题身份方块包装图集`,
endMoodPrompt: null,
};
}
@@ -6,28 +6,28 @@ import type {
} from '../../../packages/shared/src/contracts/jumpHop';
import {
buildJumpHopVisiblePlatforms,
getJumpHopBackendDragVector,
getJumpHopCharacterVisualPosition,
getJumpHopJumpFeedbackLabel,
getJumpHopLandingAssistVisualPosition,
getJumpHopPlatformVisualSize,
getJumpHopStatusLabel,
isJumpHopLandingInsidePlatformFootprint,
resolveJumpHopCharacterCanvasPosition,
selectJumpHopTileAsset,
} from './jumpHopRuntimeModel';
test('跳一跳地块池按平台编号从 25 个素材中抽取而不是按类型压扁', () => {
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
test('跳一跳地块池按平台编号从 18 个素材中抽取而不是按类型压扁', () => {
const tileAssets = Array.from({ length: 18 }, (_, index) => ({
tileType: 'normal',
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
imageSrc: `asset-${index + 1}`,
imageObjectKey: `key-${index + 1}`,
assetObjectId: `object-${index + 1}`,
sourceAtlasCell: `row-1-col-${index + 1}`,
atlasRow: 1,
atlasCol: index + 1,
sourceAtlasCell: `row-${Math.floor(index / 3) + 1}-col-${(index % 3) + 1}`,
atlasRow: Math.floor(index / 3) + 1,
atlasCol: (index % 3) + 1,
visualWidth: 256,
visualHeight: 192,
visualHeight: 256,
topSurfaceRadius: 42,
landingRadius: 34,
})) satisfies JumpHopTileAsset[];
@@ -59,15 +59,17 @@ test('跳一跳可见平台窗口固定为 3 个并携带选中的地块素材',
platform(0.8, 5.1, 'normal'),
],
};
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
const tileAssets = Array.from({ length: 18 }, (_, index) => ({
tileType: 'normal',
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
imageSrc: `asset-${index + 1}`,
imageObjectKey: `key-${index + 1}`,
assetObjectId: `object-${index + 1}`,
sourceAtlasCell: `row-1-col-${index + 1}`,
sourceAtlasCell: `row-${Math.floor(index / 3) + 1}-col-${(index % 3) + 1}`,
atlasRow: Math.floor(index / 3) + 1,
atlasCol: (index % 3) + 1,
visualWidth: 256,
visualHeight: 192,
visualHeight: 256,
topSurfaceRadius: 42,
landingRadius: 34,
})) satisfies JumpHopTileAsset[];
@@ -119,12 +121,12 @@ test('跳一跳三块可见地块按下方中部上方展开且角色落在当
visible,
);
expect(visible[0]?.screenY).toBeGreaterThanOrEqual(68);
expect(visible[0]?.screenY).toBeLessThanOrEqual(80);
expect(visible[0]?.screenY).toBeGreaterThanOrEqual(60);
expect(visible[0]?.screenY).toBeLessThanOrEqual(66);
expect(visible[1]?.screenY).toBeGreaterThanOrEqual(40);
expect(visible[1]?.screenY).toBeLessThan(visible[0]?.screenY ?? 0);
expect(visible[2]?.screenY).toBeLessThan(visible[1]?.screenY ?? 0);
expect(visible[2]?.screenY).toBeLessThanOrEqual(26);
expect(visible[2]?.screenY).toBeLessThanOrEqual(32);
expect(Math.abs((visible[1]?.screenX ?? 0) - (visible[0]?.screenX ?? 0))).toBeGreaterThan(5);
expect(Math.abs((visible[2]?.screenX ?? 0) - (visible[1]?.screenX ?? 0))).toBeGreaterThan(5);
expect(character?.screenX).toBeCloseTo(visible[0]?.screenX ?? 0, 1);
@@ -216,8 +218,8 @@ test('跳一跳三维角色画布坐标与屏幕坐标同向映射到下方起
expect(canvasPosition?.x).toBeGreaterThan(140);
expect(canvasPosition?.x).toBeLessThan(180);
expect(canvasPosition?.y).toBeGreaterThan(380);
expect(canvasPosition?.y).toBeLessThan(450);
expect(canvasPosition?.y).toBeGreaterThan(330);
expect(canvasPosition?.y).toBeLessThan(370);
});
test('跳一跳运行态当前地块视觉尺寸按原调参结果放大一倍', () => {
@@ -227,7 +229,7 @@ test('跳一跳运行态当前地块视觉尺寸按原调参结果放大一倍',
expect(size.height).toBeCloseTo(103.68, 2);
});
test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离投影', () => {
test('跳一跳落点预测按蓄力值沿下一地块中心方向投影', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -265,22 +267,12 @@ test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离
const current = visible[0]!;
const target = visible[1]!;
const stageSize = { width: 320, height: 568 };
const currentCanvasPosition = {
x: (current.screenX / 100) * stageSize.width,
y: (current.screenY / 100) * stageSize.height,
};
const targetCanvasPosition = {
x: (target.screenX / 100) * stageSize.width,
y: (target.screenY / 100) * stageSize.height,
};
const targetWorldDistance = Math.hypot(
target.platform.x - current.platform.x,
target.platform.y - current.platform.y,
);
const fullDragDistance =
targetWorldDistance / path.scoring.chargeToDistanceRatio;
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
const dragVectorY = -(targetCanvasPosition.y - currentCanvasPosition.y);
const fullAssist = getJumpHopLandingAssistVisualPosition(
run,
@@ -288,8 +280,6 @@ test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离
character,
stageSize,
fullDragDistance,
dragVectorX,
dragVectorY,
);
const halfAssist = getJumpHopLandingAssistVisualPosition(
run,
@@ -297,23 +287,21 @@ test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离
character,
stageSize,
fullDragDistance / 2,
dragVectorX,
dragVectorY,
);
expect(fullAssist?.screenX).toBeCloseTo(target.screenX, 1);
expect(fullAssist?.screenY).toBeCloseTo(target.screenY, 1);
expect(fullAssist?.screenY).toBeCloseTo(target.screenY - 3, 1);
expect(halfAssist?.screenX).toBeCloseTo(
current.screenX + (target.screenX - current.screenX) / 2,
1,
);
expect(halfAssist?.screenY).toBeCloseTo(
current.screenY + (target.screenY - current.screenY) / 2,
current.screenY + (target.screenY - current.screenY) / 2 - 3,
1,
);
});
test('跳一跳落点辅助标识使用屏幕坐标向后拖拽并投向上方目标地块', () => {
test('跳一跳落点预测忽略旧客户端拖拽方向', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -351,16 +339,6 @@ test('跳一跳落点辅助标识使用屏幕坐标向后拖拽并投向上方
const current = visible[0]!;
const target = visible[1]!;
const stageSize = { width: 320, height: 568 };
const currentCanvasPosition = {
x: (current.screenX / 100) * stageSize.width,
y: (current.screenY / 100) * stageSize.height,
};
const targetCanvasPosition = {
x: (target.screenX / 100) * stageSize.width,
y: (target.screenY / 100) * stageSize.height,
};
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
const targetWorldDistance = Math.hypot(
target.platform.x - current.platform.x,
target.platform.y - current.platform.y,
@@ -374,16 +352,29 @@ test('跳一跳落点辅助标识使用屏幕坐标向后拖拽并投向上方
character,
stageSize,
fullDragDistance,
dragVectorX,
dragVectorY,
-999,
-999,
);
expect(dragVectorY).toBeGreaterThan(0);
expect(assist?.screenX).toBeCloseTo(target.screenX, 1);
expect(assist?.screenY).toBeCloseTo(target.screenY, 1);
expect(assist?.screenY).toBeCloseTo(target.screenY - 3, 1);
expect(assist?.isOnTargetPlatform).toBe(true);
});
test('跳一跳后端落点向量会把屏幕拖拽换算为世界尺度一致的反向弹射', () => {
test('跳一跳落点预测用收缩后的视觉顶面 footprint 判断命中', () => {
const target = {
...platform(1, 0, 'normal'),
width: 2,
height: 0.6,
landingRadius: 0.2,
};
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.6, 0)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.8, 0)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1, 0.18)).toBe(false);
});
test('跳一跳成功落地后保留真实落点偏移而不是吸附到地块中心', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -406,41 +397,34 @@ test('跳一跳后端落点向量会把屏幕拖拽换算为世界尺度一致
profileId: 'profile-1',
ownerUserId: 'user-1',
status: 'playing',
currentPlatformIndex: 0,
successfulJumpCount: 0,
currentPlatformIndex: 1,
successfulJumpCount: 1,
durationMs: 0,
score: 0,
score: 1,
combo: 0,
path,
lastJump: null,
lastJump: {
chargeMs: 300,
jumpDistance: 1.0,
targetPlatformIndex: 1,
landedX: 0.52,
landedY: 0.78,
result: 'hit',
},
startedAtMs: 1000,
finishedAtMs: null,
} as const;
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
const current = visible[0]!;
const target = visible[1]!;
const stageSize = { width: 320, height: 568 };
const currentCanvasPosition = {
x: (current.screenX / 100) * stageSize.width,
y: (current.screenY / 100) * stageSize.height,
};
const targetCanvasPosition = {
x: (target.screenX / 100) * stageSize.width,
y: (target.screenY / 100) * stageSize.height,
};
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
const backendVector = getJumpHopBackendDragVector(
run,
visible,
stageSize,
dragVectorX,
dragVectorY,
);
const visible = buildJumpHopVisiblePlatforms(path, 1, []);
const character = getJumpHopCharacterVisualPosition(run, visible, {
width: 320,
height: 568,
});
const currentCenter = visible[0]!;
expect(backendVector.dragVectorX).toBeLessThan(0);
expect(backendVector.dragVectorY).toBeGreaterThan(0);
expect(Math.abs(backendVector.dragVectorY)).toBeLessThan(Math.abs(dragVectorY));
expect(character?.screenX).not.toBeCloseTo(currentCenter.screenX, 1);
expect(character?.screenY).not.toBeCloseTo(currentCenter.screenY - 3, 1);
expect(character?.screenX).toBeLessThan(currentCenter.screenX);
expect(character?.screenY).toBeGreaterThan(currentCenter.screenY - 3);
});
test('跳一跳运行态公开反馈不再展示旧 perfect 和通关语义', () => {
+240 -40
View File
@@ -42,6 +42,7 @@ export type JumpHopLandingAssistVisualPosition = {
screenX: number;
screenY: number;
targetPlatformIndex: number;
isOnTargetPlatform: boolean;
};
export type JumpHopBackendDragVector = {
@@ -49,12 +50,19 @@ export type JumpHopBackendDragVector = {
dragVectorY: number;
};
const JUMP_HOP_DEFAULT_CHARGE_TO_DISTANCE_RATIO = 0.004;
const JUMP_HOP_DEFAULT_STAGE_SIZE: JumpHopCanvasSize = {
width: 320,
height: 568,
};
const VISIBLE_PLATFORM_COUNT = 3;
const JUMP_HOP_STAGE_WORLD_SCALE = 4.2;
const JUMP_HOP_STAGE_FORWARD_SCALE = 3;
const JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y = [78, 50, 22] as const;
const JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y = [64, 47, 30] as const;
const JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER = 2;
const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 16 * 0.96;
const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 11.2;
const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO = 0.72;
const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO = 0.52;
const tileToneByType: Record<JumpHopTileType, string> = {
accent: '#e0f2fe',
@@ -128,7 +136,7 @@ export function buildJumpHopVisiblePlatforms(
: depth === 1
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[1]
: JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[2];
const screenX = clamp(50 + dx * 16 * worldScale, 14, 86);
const screenX = clamp(50 + dx * JUMP_HOP_SCREEN_X_WORLD_PERCENT, 14, 86);
return {
platform,
@@ -198,6 +206,31 @@ function getJumpHopCanvasPosition(
};
}
function getJumpHopCharacterVisualPositionFromPlatform(
platform: JumpHopVisiblePlatform,
isMiss = false,
): JumpHopCharacterVisualPosition {
if (isMiss) {
return {
screenX: platform.screenX + 8,
screenY: platform.screenY - 2,
sceneX: platform.sceneX + 0.7,
sceneY: platform.sceneY + 0.48,
sceneZ: platform.sceneZ - 0.4,
isMiss: true,
};
}
return {
screenX: platform.screenX,
screenY: platform.screenY - 3,
sceneX: platform.sceneX,
sceneY: platform.sceneY + 0.84,
sceneZ: platform.sceneZ,
isMiss: false,
};
}
function getJumpHopScreenWorldScales(
currentPlatform: JumpHopVisiblePlatform,
targetPlatform: JumpHopVisiblePlatform,
@@ -257,6 +290,155 @@ function getJumpHopScreenWorldScales(
};
}
export function getJumpHopWorldLandingVisualPosition(
originPlatform: JumpHopVisiblePlatform | null | undefined,
scalePlatform: JumpHopVisiblePlatform | null | undefined,
stageSize: JumpHopCanvasSize,
landedX: number,
landedY: number,
isMiss = false,
): JumpHopCharacterVisualPosition | null {
if (
!originPlatform ||
!scalePlatform ||
stageSize.width <= 0 ||
stageSize.height <= 0 ||
!Number.isFinite(landedX) ||
!Number.isFinite(landedY)
) {
return null;
}
const scales = getJumpHopScreenWorldScales(
originPlatform,
scalePlatform,
stageSize,
);
const worldDeltaX = landedX - originPlatform.platform.x;
const worldDeltaY = landedY - originPlatform.platform.y;
const landedPixelX =
scales.currentCanvasPosition.x +
worldDeltaX * scales.signedXScreenPerWorld;
const landedPixelY =
scales.currentCanvasPosition.y +
worldDeltaY * scales.signedYScreenPerWorld;
const sceneDeltaX =
(landedX - originPlatform.platform.x) * JUMP_HOP_STAGE_WORLD_SCALE;
const sceneDeltaZ =
(landedY - originPlatform.platform.y) * JUMP_HOP_STAGE_FORWARD_SCALE;
return {
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
screenY: clamp((landedPixelY / stageSize.height) * 100 - 3, 10, 92),
sceneX: originPlatform.sceneX + sceneDeltaX,
sceneY: originPlatform.sceneY + (isMiss ? 0.48 : 0.84),
sceneZ: originPlatform.sceneZ + sceneDeltaZ,
isMiss,
};
}
export function isJumpHopLandingInsidePlatformFootprint(
platform: JumpHopPlatform | null | undefined,
landedX: number,
landedY: number,
) {
if (
!platform ||
!Number.isFinite(landedX) ||
!Number.isFinite(landedY)
) {
return false;
}
const halfWidth = Math.max(
0,
platform.width * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO,
);
const halfHeight = Math.max(
0,
platform.height * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO,
);
return (
Math.abs(landedX - platform.x) <= halfWidth &&
Math.abs(landedY - platform.y) <= halfHeight
);
}
function getJumpHopSuccessfulLandingVisualPosition(
run: JumpHopRuntimeRunSnapshotResponse,
platforms: JumpHopVisiblePlatform[],
stageSize: JumpHopCanvasSize,
) {
const lastJump = run.lastJump;
if (!lastJump) {
return null;
}
const landedPlatform =
platforms.find((item) => item.index === run.currentPlatformIndex) ??
platforms.find((item) => item.index === lastJump.targetPlatformIndex) ??
null;
if (!landedPlatform) {
return null;
}
const previousPlatformIndex = Math.max(0, lastJump.targetPlatformIndex - 1);
const previousWindowPlatforms = buildJumpHopVisiblePlatforms(
run.path,
previousPlatformIndex,
[],
);
const previousPlatform =
previousWindowPlatforms.find(
(item) => item.index === previousPlatformIndex,
) ?? null;
const targetPlatformInPreviousWindow =
previousWindowPlatforms.find(
(item) => item.index === lastJump.targetPlatformIndex,
) ?? null;
const landingInPreviousWindow = getJumpHopWorldLandingVisualPosition(
previousPlatform,
targetPlatformInPreviousWindow,
stageSize,
lastJump.landedX,
lastJump.landedY,
false,
);
if (!landingInPreviousWindow || !targetPlatformInPreviousWindow) {
return null;
}
const targetCenterInPreviousWindow =
getJumpHopCharacterVisualPositionFromPlatform(
targetPlatformInPreviousWindow,
);
const landedPlatformCenter =
getJumpHopCharacterVisualPositionFromPlatform(landedPlatform);
const worldDeltaX = lastJump.landedX - landedPlatform.platform.x;
const worldDeltaY = lastJump.landedY - landedPlatform.platform.y;
return {
screenX: clamp(
landedPlatformCenter.screenX +
landingInPreviousWindow.screenX -
targetCenterInPreviousWindow.screenX,
6,
94,
),
screenY: clamp(
landedPlatformCenter.screenY +
landingInPreviousWindow.screenY -
targetCenterInPreviousWindow.screenY,
10,
92,
),
sceneX: landedPlatform.sceneX + worldDeltaX * JUMP_HOP_STAGE_WORLD_SCALE,
sceneY: landedPlatform.sceneY + 0.84,
sceneZ: landedPlatform.sceneZ + worldDeltaY * JUMP_HOP_STAGE_FORWARD_SCALE,
isMiss: false,
};
}
export function getJumpHopBackendDragVector(
run: JumpHopRuntimeRunSnapshotResponse | null,
platforms: JumpHopVisiblePlatform[],
@@ -290,8 +472,8 @@ export function getJumpHopLandingAssistVisualPosition(
characterPosition: JumpHopCharacterVisualPosition | null,
stageSize: JumpHopCanvasSize,
dragDistance: number,
dragVectorX: number | null,
dragVectorY: number | null,
_dragVectorX?: number | null,
_dragVectorY?: number | null,
) {
if (
!run ||
@@ -310,27 +492,13 @@ export function getJumpHopLandingAssistVisualPosition(
}
const { currentPlatform, targetPlatform } = pair;
const dragX = dragVectorX ?? 0;
const dragY = dragVectorY ?? 0;
const dragLength = Math.hypot(dragX, dragY);
if (dragLength < 0.0001) {
return null;
}
const scales = getJumpHopScreenWorldScales(
currentPlatform,
targetPlatform,
stageSize,
);
const backendDragVector = getJumpHopBackendDragVector(
run,
platforms,
stageSize,
dragX,
dragY,
);
const jumpWorldX = -backendDragVector.dragVectorX;
const jumpWorldY = backendDragVector.dragVectorY;
const jumpWorldX = targetPlatform.platform.x - currentPlatform.platform.x;
const jumpWorldY = targetPlatform.platform.y - currentPlatform.platform.y;
const jumpWorldLength = Math.hypot(jumpWorldX, jumpWorldY);
if (jumpWorldLength < 0.0001) {
return null;
@@ -341,13 +509,15 @@ export function getJumpHopLandingAssistVisualPosition(
const chargeToDistanceRatio =
run.path.scoring.chargeToDistanceRatio > 0
? run.path.scoring.chargeToDistanceRatio
: 0.008;
: JUMP_HOP_DEFAULT_CHARGE_TO_DISTANCE_RATIO;
const projectedWorldDistance =
clamp(dragDistance, 0, maxDragDistance) * chargeToDistanceRatio;
const landedWorldDeltaX =
(jumpWorldX / jumpWorldLength) * projectedWorldDistance;
const landedWorldDeltaY =
(jumpWorldY / jumpWorldLength) * projectedWorldDistance;
const landedWorldX = currentPlatform.platform.x + landedWorldDeltaX;
const landedWorldY = currentPlatform.platform.y + landedWorldDeltaY;
const landedPixelX =
scales.currentCanvasPosition.x +
landedWorldDeltaX * scales.signedXScreenPerWorld;
@@ -357,8 +527,13 @@ export function getJumpHopLandingAssistVisualPosition(
return {
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
screenY: clamp((landedPixelY / stageSize.height) * 100, 10, 92),
screenY: clamp((landedPixelY / stageSize.height) * 100 - 3, 10, 92),
targetPlatformIndex: targetPlatform.index,
isOnTargetPlatform: isJumpHopLandingInsidePlatformFootprint(
targetPlatform.platform,
landedWorldX,
landedWorldY,
),
};
}
@@ -379,39 +554,64 @@ export function resolveJumpHopCharacterCanvasPosition(
export function getJumpHopCharacterVisualPosition(
run: JumpHopRuntimeRunSnapshotResponse | null,
platforms: JumpHopVisiblePlatform[],
stageSize: JumpHopCanvasSize = JUMP_HOP_DEFAULT_STAGE_SIZE,
) {
if (!run) {
return null;
}
const lastJump = run.lastJump;
if (lastJump) {
const isMiss = lastJump.result === 'miss';
if (!isMiss) {
const landedPosition = getJumpHopSuccessfulLandingVisualPosition(
run,
platforms,
stageSize,
);
if (landedPosition) {
return landedPosition;
}
}
const originPlatform =
platforms.find((item) => item.index === run.currentPlatformIndex) ??
platforms[0] ??
null;
const scalePlatform =
platforms.find((item) =>
isMiss
? item.index === lastJump.targetPlatformIndex
: item.index === run.currentPlatformIndex + 1,
) ??
platforms.find((item) => item.index === lastJump.targetPlatformIndex) ??
originPlatform;
const landedPosition = getJumpHopWorldLandingVisualPosition(
originPlatform,
scalePlatform,
stageSize,
lastJump.landedX,
lastJump.landedY,
isMiss,
);
if (landedPosition) {
return landedPosition;
}
}
const landedPlatform = platforms.find(
(item) => item.index === run.currentPlatformIndex,
);
if (landedPlatform) {
return {
screenX: landedPlatform.screenX,
screenY: landedPlatform.screenY - 3,
sceneX: landedPlatform.sceneX,
sceneY: landedPlatform.sceneY + 0.84,
sceneZ: landedPlatform.sceneZ,
isMiss: false,
};
return getJumpHopCharacterVisualPositionFromPlatform(landedPlatform);
}
const lastJump = run.lastJump;
if (lastJump && run.status === 'failed') {
const targetPlatform = platforms.find(
(item) => item.index === lastJump.targetPlatformIndex,
);
if (targetPlatform) {
return {
screenX: targetPlatform.screenX + 8,
screenY: targetPlatform.screenY - 2,
sceneX: targetPlatform.sceneX + 0.7,
sceneY: targetPlatform.sceneY + 0.48,
sceneZ: targetPlatform.sceneZ - 0.4,
isMiss: true,
};
return getJumpHopCharacterVisualPositionFromPlatform(targetPlatform, true);
}
}
@@ -505,7 +505,7 @@ describe('miniGameDraftGenerationProgress', () => {
'jump-hop-write-draft',
]);
expect(progress?.phaseId).toBe('jump-hop-tile-atlas');
expect(progress?.phaseLabel).toBe('生成 5x5 地块图集');
expect(progress?.phaseLabel).toBe('生成 UV 贴图图集');
expect(progress?.estimatedRemainingMs).toBe(265_000);
});
@@ -513,7 +513,7 @@ describe('miniGameDraftGenerationProgress', () => {
const entries = buildJumpHopGenerationAnchorEntries(null, {
themeText: '云端糖果塔',
templateId: 'jump-hop',
tilePrompt: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
tilePrompt: '云端糖果塔主题的3D立方体主题身份方块包装图集',
});
expect(entries).toEqual([
@@ -524,8 +524,8 @@ describe('miniGameDraftGenerationProgress', () => {
},
{
id: 'jump-hop-tile-style',
label: '地块图',
value: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
label: '地块图',
value: '云端糖果塔主题的3D立方体主题身份方块包装图集',
},
]);
});
@@ -408,20 +408,20 @@ const JUMP_HOP_STEPS = [
},
{
id: 'jump-hop-tile-atlas',
label: '生成 5x5 地块图集',
detail: '调用 image2 生成 25 个主题地块素材。',
label: '生成 UV 贴图图集',
detail: '调用 image2 一次生成 18 个立方体六面展开包装。',
weight: 54,
},
{
id: 'jump-hop-slice-tiles',
label: '切分 25 个地块',
detail: '按 5 行 5 列切分透明地块 PNG。',
label: '切分六面贴图',
detail: '按 3 列 6 行与 4x3 UV 网切分 108 张面贴图 PNG。',
weight: 24,
},
{
id: 'jump-hop-write-draft',
label: '写入正式草稿',
detail: '保存地池、无限路径缓冲和运行态配置。',
detail: '保存地板贴图池、无限路径缓冲和运行态配置。',
weight: 10,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
@@ -1183,7 +1183,7 @@ export function buildJumpHopGenerationAnchorEntries(
},
{
key: 'jump-hop-tile-style',
label: '地块图',
label: '地块图',
value:
formPayload?.tilePrompt?.trim() ||
config?.tilePrompt?.trim() ||