合并 master 并保留外部生成 worker 模式
合入 master 的生产健康巡检、JumpHop 和 SpacetimeDB 更新 保留外部生成 worker、队列/内联模式与 lease guard 口径 合并 Server-Provision 工具复用、health patrol 和外部生成 worker systemd 配置 补齐 SpacetimeDB 生成绑定并通过本地检查
This commit is contained in:
@@ -8,9 +8,13 @@ import type {
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { buildJumpHopVisiblePlatforms } from '../../services/jump-hop/jumpHopRuntimeModel';
|
||||
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
||||
import { JumpHopRuntimeShell } from './JumpHopRuntimeShell';
|
||||
import {
|
||||
JUMP_HOP_THREE_CAMERA_UP_Y,
|
||||
JumpHopRuntimeShell,
|
||||
getJumpHopThreeProjectedY,
|
||||
getJumpHopTileTextureSignature,
|
||||
} from './JumpHopRuntimeShell';
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
|
||||
@@ -44,22 +48,10 @@ function dispatchPointerEvent(
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
test('跳一跳运行态松手时提交向后拖动向量', async () => {
|
||||
test('跳一跳运行态松手时提交长按蓄力值和后端方向向量', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const run = buildRun();
|
||||
const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []);
|
||||
const current = visiblePlatforms[0]!;
|
||||
const target = visiblePlatforms[1]!;
|
||||
const stageSize = { width: 320, height: 568 };
|
||||
const xPixelsPerWorldUnit =
|
||||
Math.abs(
|
||||
((target.screenX - current.screenX) / 100) * stageSize.width,
|
||||
) / Math.abs(target.platform.x - current.platform.x);
|
||||
const yPixelsPerWorldUnit =
|
||||
Math.abs(
|
||||
((target.screenY - current.screenY) / 100) * stageSize.height,
|
||||
) / Math.abs(target.platform.y - current.platform.y);
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
@@ -85,6 +77,9 @@ test('跳一跳运行态松手时提交向后拖动向量', async () => {
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(360);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
@@ -96,28 +91,19 @@ test('跳一跳运行态松手时提交向后拖动向量', async () => {
|
||||
|
||||
expect(onJump).toHaveBeenCalledTimes(1);
|
||||
const jumpPayload = onJump.mock.calls[0]?.[0];
|
||||
expect(jumpPayload?.dragVectorX).toBeCloseTo(-48 / xPixelsPerWorldUnit, 2);
|
||||
expect(jumpPayload?.dragVectorY).toBeCloseTo(58 / yPixelsPerWorldUnit, 2);
|
||||
expect(jumpPayload?.dragDistance).toBeGreaterThan(74);
|
||||
expect(jumpPayload?.dragDistance).toBeLessThan(76);
|
||||
expect(typeof jumpPayload?.dragVectorX).toBe('number');
|
||||
expect(typeof jumpPayload?.dragVectorY).toBe('number');
|
||||
expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true);
|
||||
expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true);
|
||||
expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(360);
|
||||
expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(380);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳运行态拖拽方向按手指起点到松手点计算', async () => {
|
||||
test('跳一跳运行态手指移动不改变蓄力时长但仍提交方向向量', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const run = buildRun();
|
||||
const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []);
|
||||
const current = visiblePlatforms[0]!;
|
||||
const target = visiblePlatforms[1]!;
|
||||
const stageSize = { width: 320, height: 568 };
|
||||
const xPixelsPerWorldUnit =
|
||||
Math.abs(
|
||||
((target.screenX - current.screenX) / 100) * stageSize.width,
|
||||
) / Math.abs(target.platform.x - current.platform.x);
|
||||
const yPixelsPerWorldUnit =
|
||||
Math.abs(
|
||||
((target.screenY - current.screenY) / 100) * stageSize.height,
|
||||
) / Math.abs(target.platform.y - current.platform.y);
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
@@ -143,6 +129,9 @@ test('跳一跳运行态拖拽方向按手指起点到松手点计算', async ()
|
||||
clientY: 20,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(240);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
@@ -152,15 +141,63 @@ test('跳一跳运行态拖拽方向按手指起点到松手点计算', async ()
|
||||
});
|
||||
|
||||
const jumpPayload = onJump.mock.calls[0]?.[0];
|
||||
expect(jumpPayload?.dragVectorX).toBeLessThan(0);
|
||||
expect(jumpPayload?.dragVectorY).toBeLessThan(0);
|
||||
expect(Math.abs(jumpPayload?.dragVectorX ?? 0)).toBeLessThan(30);
|
||||
expect(Math.abs(jumpPayload?.dragVectorY ?? 0)).toBeLessThan(20);
|
||||
expect(jumpPayload?.dragVectorX).toBeCloseTo(-30 / xPixelsPerWorldUnit, 2);
|
||||
expect(jumpPayload?.dragVectorY).toBeCloseTo(-20 / yPixelsPerWorldUnit, 2);
|
||||
expect(typeof jumpPayload?.dragVectorX).toBe('number');
|
||||
expect(typeof jumpPayload?.dragVectorY).toBe('number');
|
||||
expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true);
|
||||
expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true);
|
||||
expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(240);
|
||||
expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线', async () => {
|
||||
test('跳一跳运行态长按蓄力不会超过后端上限', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const baseRun = buildRun();
|
||||
const run: JumpHopRuntimeRunSnapshotResponse = {
|
||||
...baseRun,
|
||||
path: {
|
||||
...baseRun.path,
|
||||
scoring: {
|
||||
...baseRun.path.scoring,
|
||||
maxChargeMs: 300,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile()}
|
||||
run={run}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(780);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onJump.mock.calls[0]?.[0]?.dragDistance).toBe(300);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳运行态不再显示旧圆弧蓄力条而是显示长按蓄力引导', async () => {
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
@@ -183,10 +220,12 @@ test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线',
|
||||
|
||||
expect(screen.queryByText('起跳')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
|
||||
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
|
||||
test('跳一跳蓄力时角色只做垂直压缩', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
@@ -205,11 +244,7 @@ test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 132,
|
||||
clientY: 478,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(180);
|
||||
});
|
||||
|
||||
const character = screen.getByTestId('jump-hop-character-logo')
|
||||
@@ -221,12 +256,20 @@ test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
|
||||
.map((style) => style.textContent ?? '')
|
||||
.join('\n');
|
||||
|
||||
expect(stretchTransform).toContain('matrix(');
|
||||
expect(stretchTransform).not.toBe('matrix(1, 0, 0, 1, 0, 0)');
|
||||
expect(stretchTransform).toMatch(/^scale\((?<x>[\d.]+), (?<y>[\d.]+)\)$/);
|
||||
const scaleMatch = stretchTransform.match(
|
||||
/^scale\((?<x>[\d.]+), (?<y>[\d.]+)\)$/,
|
||||
);
|
||||
const scaleX = Number(scaleMatch?.groups?.x ?? 1);
|
||||
const scaleY = Number(scaleMatch?.groups?.y ?? 1);
|
||||
expect(scaleX).toBeGreaterThan(1);
|
||||
expect(scaleY).toBeLessThan(1);
|
||||
expect(scaleY).toBeLessThan(scaleX);
|
||||
expect(styleText).toContain('var(--jump-hop-character-stretch-transform)');
|
||||
expect(styleText).not.toContain(
|
||||
'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))',
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳运行态游玩中只保留得分并隐藏常驻排行榜', () => {
|
||||
@@ -379,7 +422,7 @@ test('跳一跳草稿运行失败后不请求公开排行榜', () => {
|
||||
expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳角色层永远压在地块层之上', () => {
|
||||
test('跳一跳 Three.js 地板层位于 DOM 角色层下方', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
@@ -393,13 +436,19 @@ test('跳一跳角色层永远压在地块层之上', () => {
|
||||
const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0]
|
||||
?.parentElement?.parentElement as HTMLElement | undefined;
|
||||
|
||||
expect(threeScene.style.zIndex).toBe('100');
|
||||
expect(threeScene.style.zIndex).toBe('42');
|
||||
expect(Number(threeScene.style.zIndex)).toBeGreaterThan(
|
||||
Number(firstPlatform?.style.zIndex ?? 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async () => {
|
||||
test('跳一跳 Three.js 平台层和 DOM 角色层保持同向屏幕坐标', () => {
|
||||
expect(JUMP_HOP_THREE_CAMERA_UP_Y).toBe(1);
|
||||
expect(getJumpHopThreeProjectedY(360, 568)).toBeLessThan(284);
|
||||
expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284);
|
||||
});
|
||||
|
||||
test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async () => {
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
@@ -429,7 +478,7 @@ test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async ()
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
|
||||
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
@@ -440,10 +489,11 @@ test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async ()
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
|
||||
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳运行态直接渲染生成的地块切片图片', () => {
|
||||
test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
@@ -498,6 +548,71 @@ test('跳一跳运行态提前预加载下一屏地块且不在真实图片加
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳新 UV 地板资源会解析六张面贴图而不是复用单张图', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets({ withFaceAssets: true }) })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image');
|
||||
const faceImageSources = preloadImages
|
||||
.map((image) => image.getAttribute('src') ?? '')
|
||||
.filter((source) =>
|
||||
source.includes('/generated-jump-hop-assets/jump-hop-profile-test/tile-'),
|
||||
);
|
||||
|
||||
const firstTileMatch = faceImageSources[0]?.match(/tile-(\d{2})-/);
|
||||
const firstTileNumber = firstTileMatch?.[1];
|
||||
expect(firstTileNumber).toBeTruthy();
|
||||
expect(faceImageSources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-top/image.png`),
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-front/image.png`),
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-right/image.png`),
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-back/image.png`),
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-left/image.png`),
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-bottom/image.png`),
|
||||
]),
|
||||
);
|
||||
const frontSource = `/tile-${firstTileNumber}-front/image.png`;
|
||||
const frontRefreshKey = `asset-object-${firstTileNumber}-front`;
|
||||
expect(
|
||||
vi
|
||||
.mocked(useResolvedAssetReadUrl)
|
||||
.mock.calls.some(
|
||||
([source, options]) =>
|
||||
source?.includes(frontSource) && options?.refreshKey === frontRefreshKey,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('跳一跳 Three.js 地板贴图签名包含六面贴图 URL', () => {
|
||||
const asset = buildTileAssets({ withFaceAssets: true })[0];
|
||||
const signature = getJumpHopTileTextureSignature(
|
||||
{
|
||||
'p1::top': 'top-url',
|
||||
'p1::front': 'front-url',
|
||||
'p1::right': 'right-url',
|
||||
'p1::back': 'back-url',
|
||||
'p1::left': 'left-url',
|
||||
'p1::bottom': 'bottom-url',
|
||||
},
|
||||
'p1',
|
||||
asset,
|
||||
);
|
||||
|
||||
expect(signature).toContain('top-url');
|
||||
expect(signature).toContain('front-url');
|
||||
expect(signature).toContain('right-url');
|
||||
expect(signature).toContain('back-url');
|
||||
expect(signature).toContain('left-url');
|
||||
expect(signature).toContain('bottom-url');
|
||||
});
|
||||
|
||||
test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
@@ -513,9 +628,9 @@ test('跳一跳运行态首块地块落在中下方并且后续两块向中央
|
||||
const first = tileImages[0]?.parentElement?.parentElement as HTMLElement | undefined;
|
||||
const second = tileImages[1]?.parentElement?.parentElement as HTMLElement | undefined;
|
||||
const third = tileImages[2]?.parentElement?.parentElement as HTMLElement | undefined;
|
||||
expect(first?.style.top).toBe('78%');
|
||||
expect(second?.style.top).toBe('50%');
|
||||
expect(third?.style.top).toBe('22%');
|
||||
expect(first?.style.top).toBe('64%');
|
||||
expect(second?.style.top).toBe('47%');
|
||||
expect(third?.style.top).toBe('30%');
|
||||
});
|
||||
|
||||
test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => {
|
||||
@@ -604,11 +719,11 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
|
||||
successfulJumpCount: 1,
|
||||
score: 1,
|
||||
lastJump: {
|
||||
chargeMs: 150,
|
||||
jumpDistance: 1.44,
|
||||
chargeMs: 420,
|
||||
jumpDistance: 1.68,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 0.8,
|
||||
landedY: 1.2,
|
||||
landedX: 0.93,
|
||||
landedY: 1.4,
|
||||
result: 'hit',
|
||||
},
|
||||
};
|
||||
@@ -636,6 +751,9 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(420);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
@@ -678,6 +796,63 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳成功落点偏移后下一跳视觉仍朝下一块地块方向', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const run: JumpHopRuntimeRunSnapshotResponse = {
|
||||
...buildRun(),
|
||||
currentPlatformIndex: 1,
|
||||
successfulJumpCount: 1,
|
||||
score: 1,
|
||||
lastJump: {
|
||||
chargeMs: 300,
|
||||
jumpDistance: 1.0,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 0,
|
||||
landedY: 1.2,
|
||||
result: 'hit',
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={run}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const character = screen.getByTestId('jump-hop-character-logo')
|
||||
.parentElement as HTMLElement;
|
||||
const initialLeft = Number.parseFloat(character.style.left);
|
||||
const initialTop = Number.parseFloat(character.style.top);
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onJump).toHaveBeenCalledTimes(1);
|
||||
expect(Number.parseFloat(character.style.left)).toBeLessThan(initialLeft);
|
||||
expect(Number.parseFloat(character.style.top)).toBeLessThan(initialTop);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳松手后先播放飞行动画再切换到下一块地块', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -688,11 +863,25 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
successfulJumpCount: 1,
|
||||
score: 1,
|
||||
lastJump: {
|
||||
chargeMs: 150,
|
||||
jumpDistance: 1.44,
|
||||
chargeMs: 420,
|
||||
jumpDistance: 1.68,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 0.8,
|
||||
landedY: 1.2,
|
||||
landedX: 0.93,
|
||||
landedY: 1.4,
|
||||
result: 'hit',
|
||||
},
|
||||
};
|
||||
const runAfterSecondJump: JumpHopRuntimeRunSnapshotResponse = {
|
||||
...buildRunWithExtraPreviewPlatform(),
|
||||
currentPlatformIndex: 2,
|
||||
successfulJumpCount: 2,
|
||||
score: 2,
|
||||
lastJump: {
|
||||
chargeMs: 360,
|
||||
jumpDistance: 1.44,
|
||||
targetPlatformIndex: 2,
|
||||
landedX: -0.2,
|
||||
landedY: 2.4,
|
||||
result: 'hit',
|
||||
},
|
||||
};
|
||||
@@ -720,6 +909,9 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(420);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
@@ -731,7 +923,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
expect(onJump).toHaveBeenCalledTimes(1);
|
||||
expect(stage.getAttribute('data-jump-animating')).toBe('true');
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'78%',
|
||||
'64%',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
||||
'true',
|
||||
@@ -753,7 +945,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
'true',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'78%',
|
||||
'64%',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
||||
'true',
|
||||
@@ -775,6 +967,8 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
const landedCharacter = screen.getByTestId('jump-hop-character-logo')
|
||||
.parentElement as HTMLElement;
|
||||
expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true');
|
||||
expect(Number.parseFloat(landedCharacter.style.left)).not.toBeCloseTo(50, 1);
|
||||
expect(Number.parseFloat(landedCharacter.style.top)).not.toBeCloseTo(75, 1);
|
||||
expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe(
|
||||
'0px',
|
||||
);
|
||||
@@ -783,18 +977,23 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
);
|
||||
const cameraLayer = screen.getByTestId('jump-hop-camera-layer');
|
||||
expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true');
|
||||
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-zoom')).toBe(
|
||||
'1.3',
|
||||
);
|
||||
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe(
|
||||
'-28%',
|
||||
'-17%',
|
||||
);
|
||||
expect(
|
||||
Number.parseFloat(
|
||||
cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
|
||||
),
|
||||
).toBeCloseTo(12.29, 2);
|
||||
).toBeCloseTo(8.96, 2);
|
||||
const styleText = Array.from(document.querySelectorAll('style'))
|
||||
.map((style) => style.textContent ?? '')
|
||||
.join('\n');
|
||||
expect(styleText).toContain('@keyframes jump-hop-character-recoil');
|
||||
expect(styleText).not.toContain('@keyframes jump-hop-platform-exit-drift');
|
||||
expect(styleText).toContain('scale(var(--jump-hop-camera-zoom, 1))');
|
||||
expect(styleText).toMatch(
|
||||
/data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/,
|
||||
);
|
||||
@@ -826,7 +1025,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
'p1',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'78%',
|
||||
'64%',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
|
||||
'1.08',
|
||||
@@ -835,7 +1034,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
'p2',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'50%',
|
||||
'47%',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@@ -870,19 +1069,78 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
expect(screen.getByTestId('jump-hop-camera-layer').getAttribute('data-platform-advancing')).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'78%',
|
||||
const retainedOldPlatform = screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-platform-id='p0']") as HTMLElement | null;
|
||||
expect(retainedOldPlatform?.getAttribute('data-advance-state')).toBe(
|
||||
'exiting',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
||||
expect(retainedOldPlatform?.style.top).toBe('81%');
|
||||
const currentPlatform = screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-platform-id='p1']") as HTMLElement | null;
|
||||
expect(currentPlatform?.style.top).toBe('64%');
|
||||
expect(currentPlatform?.getAttribute('data-current')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
||||
expect(currentPlatform?.getAttribute('data-platform-id')).toBe(
|
||||
'p1',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'50%',
|
||||
expect(
|
||||
(
|
||||
screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-platform-id='p2']") as HTMLElement | null
|
||||
)?.style.top,
|
||||
).toBe('47%');
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 2,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(160);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 2,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onJump).toHaveBeenCalledTimes(2);
|
||||
|
||||
rerender(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={runAfterSecondJump}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(580);
|
||||
});
|
||||
|
||||
const movedOldPlatform = screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-platform-id='p0']") as HTMLElement | null;
|
||||
if (movedOldPlatform) {
|
||||
expect(Number.parseFloat(movedOldPlatform.style.top)).toBeGreaterThan(81);
|
||||
}
|
||||
expect(
|
||||
(
|
||||
screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-current='true']") as HTMLElement | null
|
||||
)?.getAttribute('data-platform-id'),
|
||||
).toBe('p2');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -994,22 +1252,51 @@ function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function buildTileAssets() {
|
||||
return Array.from({ length: 25 }, (_, index) => {
|
||||
function buildTileAssets(options: { withFaceAssets?: boolean } = {}) {
|
||||
return Array.from({ length: 18 }, (_, index) => {
|
||||
const tileNumber = String(index + 1).padStart(2, '0');
|
||||
const atlasRow = Math.floor(index / 3) + 1;
|
||||
const atlasCol = (index % 3) + 1;
|
||||
const buildFaceAsset = (
|
||||
face: keyof NonNullable<
|
||||
JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets']
|
||||
>,
|
||||
) => ({
|
||||
face,
|
||||
assetId: `asset-${tileNumber}-${face}`,
|
||||
imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`,
|
||||
imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`,
|
||||
assetObjectId: `asset-object-${tileNumber}-${face}`,
|
||||
generationProvider: 'vector-engine',
|
||||
prompt: `tile ${tileNumber} ${face}`,
|
||||
width: 256,
|
||||
height: 256,
|
||||
sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}/${face}`,
|
||||
});
|
||||
const faceAssets: NonNullable<
|
||||
JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets']
|
||||
> = {
|
||||
top: buildFaceAsset('top'),
|
||||
front: buildFaceAsset('front'),
|
||||
right: buildFaceAsset('right'),
|
||||
back: buildFaceAsset('back'),
|
||||
left: buildFaceAsset('left'),
|
||||
bottom: buildFaceAsset('bottom'),
|
||||
};
|
||||
return {
|
||||
tileType: index === 0 ? 'start' : 'normal',
|
||||
tileId: `tile-${tileNumber}`,
|
||||
imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
|
||||
imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
|
||||
assetObjectId: `asset-object-${tileNumber}`,
|
||||
sourceAtlasCell: `row-${Math.floor(index / 5) + 1}-col-${(index % 5) + 1}`,
|
||||
atlasRow: Math.floor(index / 5) + 1,
|
||||
atlasCol: (index % 5) + 1,
|
||||
sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}`,
|
||||
atlasRow,
|
||||
atlasCol,
|
||||
visualWidth: 256,
|
||||
visualHeight: 192,
|
||||
visualHeight: 256,
|
||||
topSurfaceRadius: 42,
|
||||
landingRadius: 34,
|
||||
faceAssets: options.withFaceAssets ? faceAssets : undefined,
|
||||
} satisfies JumpHopWorkProfileResponse['tileAssets'][number];
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -372,9 +372,7 @@ import {
|
||||
type CreationWorkShelfItem,
|
||||
isPersistedBarkBattleDraftGenerating,
|
||||
} from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
selectAdjacentPlatformRecommendEntry,
|
||||
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
||||
import { selectAdjacentPlatformRecommendEntry } from '../rpg-entry/rpgEntryPublicGalleryViewModel';
|
||||
import {
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
@@ -1328,7 +1326,9 @@ const PuzzleClearResultView = lazy(async () => {
|
||||
});
|
||||
|
||||
const PuzzleClearRuntimeShell = lazy(async () => {
|
||||
const module = await import('../puzzle-clear-runtime/PuzzleClearRuntimeShell');
|
||||
const module = await import(
|
||||
'../puzzle-clear-runtime/PuzzleClearRuntimeShell'
|
||||
);
|
||||
return {
|
||||
default: module.PuzzleClearRuntimeShell,
|
||||
};
|
||||
@@ -1848,6 +1848,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
const entries = creationEntryConfig?.creationTypes ?? [];
|
||||
return new Map(entries.map((entry) => [entry.id, entry]));
|
||||
}, [creationEntryConfig]);
|
||||
const publicWorkInteractions = useMemo(
|
||||
() => creationEntryConfig?.publicWorkInteractions ?? [],
|
||||
[creationEntryConfig],
|
||||
);
|
||||
const getUnifiedSpec = useCallback(
|
||||
(playId: UnifiedCreationPlayId) =>
|
||||
getUnifiedCreationSpec(playId, unifiedCreationConfigById.get(playId)),
|
||||
@@ -1903,6 +1907,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');
|
||||
@@ -3035,8 +3040,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
woodenFishGalleryEntries,
|
||||
],
|
||||
);
|
||||
const { featuredEntries: featuredGalleryEntries, latestEntries: latestGalleryEntries } =
|
||||
publicGalleryFeeds;
|
||||
const {
|
||||
featuredEntries: featuredGalleryEntries,
|
||||
latestEntries: latestGalleryEntries,
|
||||
} = publicGalleryFeeds;
|
||||
const recommendRuntimeEntries = useMemo(
|
||||
() =>
|
||||
buildPlatformRecommendedEntries({
|
||||
@@ -3219,23 +3226,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const progressTickDecision =
|
||||
resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage,
|
||||
miniGameStates: {
|
||||
puzzle: puzzleGenerationState,
|
||||
match3d: match3dGenerationState,
|
||||
'big-fish': bigFishGenerationState,
|
||||
'square-hole': squareHoleGenerationState,
|
||||
'jump-hop': jumpHopGenerationState,
|
||||
'wooden-fish': woodenFishGenerationState,
|
||||
'baby-object-match': babyObjectMatchGenerationState,
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: visualNovelGenerationStartedAtMs,
|
||||
phase: visualNovelGenerationPhase,
|
||||
},
|
||||
});
|
||||
const progressTickDecision = resolvePlatformGenerationProgressTickDecision({
|
||||
selectionStage,
|
||||
miniGameStates: {
|
||||
puzzle: puzzleGenerationState,
|
||||
match3d: match3dGenerationState,
|
||||
'big-fish': bigFishGenerationState,
|
||||
'square-hole': squareHoleGenerationState,
|
||||
'jump-hop': jumpHopGenerationState,
|
||||
'wooden-fish': woodenFishGenerationState,
|
||||
'baby-object-match': babyObjectMatchGenerationState,
|
||||
},
|
||||
visualNovel: {
|
||||
startedAtMs: visualNovelGenerationStartedAtMs,
|
||||
phase: visualNovelGenerationPhase,
|
||||
},
|
||||
});
|
||||
|
||||
if (!progressTickDecision.shouldTick) {
|
||||
return undefined;
|
||||
@@ -3738,7 +3744,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftFailed('match3d', session.sessionId);
|
||||
markDraftFailed(
|
||||
'match3d',
|
||||
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
|
||||
[
|
||||
session.draft?.profileId,
|
||||
session.publishedProfileId,
|
||||
session.sessionId,
|
||||
],
|
||||
errorMessage,
|
||||
);
|
||||
try {
|
||||
@@ -4020,7 +4030,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftFailed('square-hole', session.sessionId);
|
||||
markDraftFailed(
|
||||
'square-hole',
|
||||
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
|
||||
[
|
||||
session.draft?.profileId,
|
||||
session.publishedProfileId,
|
||||
session.sessionId,
|
||||
],
|
||||
errorMessage,
|
||||
);
|
||||
void refreshSquareHoleShelf().catch(() => undefined);
|
||||
@@ -4100,15 +4114,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (!isPuzzleCompileActionReady(response.session)) {
|
||||
const nextPayload =
|
||||
formPayload ?? buildPuzzleFormPayloadFromSession(response.session);
|
||||
const fallbackGenerationState = createPuzzleDraftGenerationStateFromPayload(
|
||||
nextPayload,
|
||||
response.session,
|
||||
);
|
||||
const nextGenerationState = mergePuzzleSessionProgressIntoGenerationState(
|
||||
puzzleGenerationState ?? fallbackGenerationState,
|
||||
response.session,
|
||||
);
|
||||
activePuzzleGenerationSessionIdRef.current = response.session.sessionId;
|
||||
const fallbackGenerationState =
|
||||
createPuzzleDraftGenerationStateFromPayload(
|
||||
nextPayload,
|
||||
response.session,
|
||||
);
|
||||
const nextGenerationState =
|
||||
mergePuzzleSessionProgressIntoGenerationState(
|
||||
puzzleGenerationState ?? fallbackGenerationState,
|
||||
response.session,
|
||||
);
|
||||
activePuzzleGenerationSessionIdRef.current =
|
||||
response.session.sessionId;
|
||||
setSelectionStage('puzzle-generating');
|
||||
markDraftGenerating('puzzle', [
|
||||
response.session.sessionId,
|
||||
@@ -7604,6 +7621,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
profileId: string,
|
||||
options: {
|
||||
embedded?: boolean;
|
||||
preloadedWork?: JumpHopWorkProfileResponse | null;
|
||||
returnStage?: 'work-detail' | 'platform';
|
||||
} = {},
|
||||
) => {
|
||||
@@ -7636,7 +7654,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',
|
||||
@@ -7668,6 +7690,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) {
|
||||
@@ -7787,8 +7881,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
...current.filter(
|
||||
(item) =>
|
||||
item.workId !== response.work!.summary.workId &&
|
||||
item.sourceSessionId !==
|
||||
response.work!.summary.sourceSessionId,
|
||||
item.sourceSessionId !== response.work!.summary.sourceSessionId,
|
||||
),
|
||||
]);
|
||||
markPendingDraftReady(
|
||||
@@ -7910,7 +8003,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
workTitle: puzzleClearSession.draft?.workTitle,
|
||||
workDescription: puzzleClearSession.draft?.workDescription,
|
||||
themePrompt: puzzleClearSession.draft?.themePrompt,
|
||||
boardBackgroundPrompt: puzzleClearSession.draft?.boardBackgroundPrompt,
|
||||
boardBackgroundPrompt:
|
||||
puzzleClearSession.draft?.boardBackgroundPrompt,
|
||||
generateBoardBackground:
|
||||
puzzleClearSession.draft?.generateBoardBackground,
|
||||
boardBackgroundAsset: puzzleClearSession.draft?.boardBackgroundAsset,
|
||||
@@ -7935,11 +8029,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
setPuzzleClearError(errorMessage);
|
||||
setPuzzleClearGenerationState(
|
||||
resolveFinishedMiniGameDraftGenerationState(
|
||||
generationState,
|
||||
'failed',
|
||||
{ error: errorMessage },
|
||||
),
|
||||
resolveFinishedMiniGameDraftGenerationState(generationState, 'failed', {
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsPuzzleClearBusy(false);
|
||||
@@ -7966,7 +8058,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleClearWork(response.item);
|
||||
setPuzzleClearWorks((current) => [
|
||||
response.item.summary,
|
||||
...current.filter((item) => item.workId !== response.item.summary.workId),
|
||||
...current.filter(
|
||||
(item) => item.workId !== response.item.summary.workId,
|
||||
),
|
||||
]);
|
||||
void refreshPuzzleClearShelf();
|
||||
void refreshPuzzleClearGallery();
|
||||
@@ -7979,7 +8073,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPublicWorkDetailError(null);
|
||||
selectionStageRef.current = 'work-detail';
|
||||
setSelectionStage('work-detail');
|
||||
pushAppHistoryPath(buildPublicWorkStagePath('work-detail', publicWorkCode));
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath('work-detail', publicWorkCode),
|
||||
);
|
||||
openPublishShareModal({
|
||||
title: response.item.summary.workTitle || '拼消消',
|
||||
publicWorkCode,
|
||||
@@ -8081,7 +8177,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
setPuzzleClearError(null);
|
||||
setPuzzleClearRun(retryPuzzleClearLocalLevel(puzzleClearRun, puzzleClearWork));
|
||||
setPuzzleClearRun(
|
||||
retryPuzzleClearLocalLevel(puzzleClearRun, puzzleClearWork),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10854,7 +10952,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPublicWorkDetailBusy(true);
|
||||
setPublicWorkDetailError(null);
|
||||
|
||||
const intent = resolvePlatformPublicWorkLikeIntent(entry);
|
||||
const intent = resolvePlatformPublicWorkLikeIntent(
|
||||
entry,
|
||||
publicWorkInteractions,
|
||||
);
|
||||
|
||||
if (intent.type === 'like-big-fish') {
|
||||
void likeBigFishGalleryWork(intent.profileId)
|
||||
@@ -10964,6 +11065,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[
|
||||
isPublicWorkDetailBusy,
|
||||
platformBootstrap,
|
||||
publicWorkInteractions,
|
||||
resolveBigFishErrorMessage,
|
||||
resolvePuzzleErrorMessage,
|
||||
runProtectedAction,
|
||||
@@ -11250,7 +11352,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
notices: draftGenerationNotices,
|
||||
generation: {
|
||||
activeSessionId: jumpHopSession?.sessionId,
|
||||
hasActiveGenerationFailure: jumpHopGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationFailure:
|
||||
jumpHopGenerationState?.phase === 'failed',
|
||||
},
|
||||
});
|
||||
markDraftNoticeSeen(openIntent.noticeKeys);
|
||||
@@ -11334,7 +11437,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const detail = await puzzleClearClient.getRuntimeWorkDetail(profileId);
|
||||
setPuzzleClearWork(detail.item);
|
||||
openPublicWorkDetail(mapPuzzleClearWorkToPlatformGalleryCard(detail.item));
|
||||
openPublicWorkDetail(
|
||||
mapPuzzleClearWorkToPlatformGalleryCard(detail.item),
|
||||
);
|
||||
} catch (error) {
|
||||
setPublicWorkDetailError(
|
||||
resolveRpgCreationErrorMessage(error, '读取拼消消详情失败。'),
|
||||
@@ -11636,8 +11741,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
notices: draftGenerationNotices,
|
||||
generation: {
|
||||
activeSessionId: puzzleSession?.sessionId,
|
||||
hasActiveGenerationFailure:
|
||||
activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationFailure: activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationRunning: isMiniGameDraftGenerating(
|
||||
activeGenerationState ?? null,
|
||||
),
|
||||
@@ -11684,9 +11788,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
const failedError = backgroundTask?.error ?? openIntent.errorMessage;
|
||||
if (!failedSession) {
|
||||
try {
|
||||
const { session: latestSession } = await getPuzzleAgentSession(
|
||||
sourceSessionId,
|
||||
);
|
||||
const { session: latestSession } =
|
||||
await getPuzzleAgentSession(sourceSessionId);
|
||||
failedSession = latestSession;
|
||||
failedPayload = buildPuzzleFormPayloadFromSession(latestSession);
|
||||
} catch {
|
||||
@@ -11769,9 +11872,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
if (openIntent.type === 'restore-generating') {
|
||||
try {
|
||||
const { session: latestSession } = await getPuzzleAgentSession(
|
||||
sourceSessionId,
|
||||
);
|
||||
const { session: latestSession } =
|
||||
await getPuzzleAgentSession(sourceSessionId);
|
||||
const payload = buildPuzzleFormPayloadFromSession(latestSession);
|
||||
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
|
||||
latestSession.updatedAt,
|
||||
@@ -11820,9 +11922,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
markDraftNoticeSeen(noticeKeys);
|
||||
|
||||
const restoredSession = await puzzleFlow.restoreDraft(
|
||||
sourceSessionId,
|
||||
);
|
||||
const restoredSession = await puzzleFlow.restoreDraft(sourceSessionId);
|
||||
if (!restoredSession) {
|
||||
await refreshPuzzleShelf().catch(() => undefined);
|
||||
return;
|
||||
@@ -11870,8 +11970,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
forceDraft: options.forceDraft,
|
||||
generation: {
|
||||
activeSessionId: match3dSession?.sessionId,
|
||||
hasActiveGenerationFailure:
|
||||
activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationFailure: activeGenerationState?.phase === 'failed',
|
||||
hasActiveGenerationRunning: isMiniGameDraftGenerating(
|
||||
activeGenerationState ?? null,
|
||||
),
|
||||
@@ -12066,9 +12165,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
markDraftNoticeSeen(noticeKeys);
|
||||
|
||||
const restoredSession = await match3dFlow.restoreDraft(
|
||||
sourceSessionId,
|
||||
);
|
||||
const restoredSession = await match3dFlow.restoreDraft(sourceSessionId);
|
||||
if (!restoredSession) {
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
return;
|
||||
@@ -13517,8 +13614,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeRecommendEntryKey && !isDesktopLayout
|
||||
? (recommendRuntimeEntries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) ===
|
||||
activeRecommendEntryKey,
|
||||
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRecommendRuntimeReady =
|
||||
@@ -13534,7 +13630,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
hasVisualNovelRun: Boolean(visualNovelRun),
|
||||
hasWoodenFishRun: Boolean(woodenFishRun),
|
||||
puzzleRunEntryProfileId: puzzleRun?.entryProfileId ?? null,
|
||||
puzzleRunCurrentLevelProfileId: puzzleRun?.currentLevel?.profileId ?? null,
|
||||
puzzleRunCurrentLevelProfileId:
|
||||
puzzleRun?.currentLevel?.profileId ?? null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -13608,7 +13705,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPublicWorkDetailBusy(true);
|
||||
setPublicWorkDetailError(null);
|
||||
|
||||
const intent = resolvePlatformPublicWorkRemixIntent(entry);
|
||||
const intent = resolvePlatformPublicWorkRemixIntent(
|
||||
entry,
|
||||
publicWorkInteractions,
|
||||
);
|
||||
|
||||
if (intent.type === 'remix-big-fish') {
|
||||
void remixBigFishGalleryWork(intent.profileId)
|
||||
@@ -13683,6 +13783,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isPublicWorkDetailBusy,
|
||||
platformBootstrap,
|
||||
puzzleFlow,
|
||||
publicWorkInteractions,
|
||||
resetRecommendRuntimeSelection,
|
||||
resolveBigFishErrorMessage,
|
||||
resolvePuzzleErrorMessage,
|
||||
@@ -13899,10 +14000,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
const detailEntry = mapPuzzleClearWorkToPlatformGalleryCard(entry);
|
||||
return (
|
||||
canExposePublicWork(detailEntry) &&
|
||||
isSamePuzzleClearPublicWorkCode(
|
||||
normalizedKeyword,
|
||||
entry.profileId,
|
||||
)
|
||||
isSamePuzzleClearPublicWorkCode(normalizedKeyword, entry.profileId)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14202,16 +14300,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') {
|
||||
@@ -14323,7 +14425,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
jumpHopItems: isJumpHopCreationVisible ? jumpHopShelfItems : [],
|
||||
woodenFishItems: woodenFishShelfItems,
|
||||
match3dItems: match3dShelfItems,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleShelfItems : [],
|
||||
squareHoleItems: isSquareHoleCreationVisible
|
||||
? squareHoleShelfItems
|
||||
: [],
|
||||
puzzleItems: puzzleShelfItems,
|
||||
babyObjectMatchItems: isBabyObjectMatchVisible
|
||||
? babyObjectMatchDrafts
|
||||
@@ -14555,7 +14659,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleShelfError ??
|
||||
puzzleError ??
|
||||
(isVisualNovelCreationOpen ? visualNovelError : null) ??
|
||||
babyObjectMatchError ??
|
||||
babyObjectMatchError ??
|
||||
puzzleClearError ??
|
||||
barkBattleError)
|
||||
}
|
||||
@@ -15963,7 +16067,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
profile={jumpHopWork}
|
||||
isBusy={isJumpHopBusy}
|
||||
error={jumpHopError}
|
||||
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
|
||||
runtimeRequestOptions={
|
||||
jumpHopRuntimeRequestOptions ?? undefined
|
||||
}
|
||||
onBack={() => {
|
||||
setSelectionStage(jumpHopRuntimeReturnStage);
|
||||
}}
|
||||
@@ -16423,7 +16529,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
<UnifiedCreationPage
|
||||
spec={getUnifiedSpec('visual-novel')}
|
||||
onBack={leaveVisualNovelFlow}
|
||||
isBackDisabled={isVisualNovelBusy || isVisualNovelStreamingReply}
|
||||
isBackDisabled={
|
||||
isVisualNovelBusy || isVisualNovelStreamingReply
|
||||
}
|
||||
>
|
||||
<VisualNovelAgentWorkspace
|
||||
session={visualNovelSession}
|
||||
|
||||
@@ -899,6 +899,23 @@ test('platform public work detail flow resolves like intent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow respects configured like disable', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkLikeIntent(buildTypedEntry('puzzle'), [
|
||||
{
|
||||
sourceType: 'puzzle',
|
||||
likeEnabled: false,
|
||||
remixEnabled: true,
|
||||
likeDisabledMessage: '拼图点赞维护中。',
|
||||
remixDisabledMessage: '拼图改造维护中。',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
type: 'unsupported',
|
||||
errorMessage: '拼图点赞维护中。',
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow resolves remix intent', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkRemixIntent(buildTypedEntry('big-fish')),
|
||||
@@ -969,13 +986,31 @@ test('platform public work detail flow resolves remix intent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow respects configured remix disable', () => {
|
||||
expect(
|
||||
resolvePlatformPublicWorkRemixIntent(buildRpgEntry(), [
|
||||
{
|
||||
sourceType: 'custom-world',
|
||||
likeEnabled: true,
|
||||
remixEnabled: false,
|
||||
likeDisabledMessage: 'RPG 点赞维护中。',
|
||||
remixDisabledMessage: 'RPG 改造维护中。',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
type: 'unsupported',
|
||||
errorMessage: 'RPG 改造维护中。',
|
||||
});
|
||||
});
|
||||
|
||||
test('platform public work detail flow resolves edit intent for draft-backed works', () => {
|
||||
const bigFishEntry = buildTypedEntry('big-fish');
|
||||
expect(resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps()))
|
||||
.toEqual({
|
||||
type: 'edit-big-fish',
|
||||
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
|
||||
});
|
||||
expect(
|
||||
resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps()),
|
||||
).toEqual({
|
||||
type: 'edit-big-fish',
|
||||
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
|
||||
});
|
||||
|
||||
const selectedPuzzleDetail = buildPuzzleWork({
|
||||
profileId: 'puzzle-profile',
|
||||
@@ -1153,7 +1188,10 @@ test('platform public work detail flow resolves edit intent for unsupported and
|
||||
|
||||
const edutainmentEntry = buildTypedEntry('edutainment');
|
||||
expect(
|
||||
resolvePlatformPublicWorkEditIntent(edutainmentEntry, buildEditIntentDeps()),
|
||||
resolvePlatformPublicWorkEditIntent(
|
||||
edutainmentEntry,
|
||||
buildEditIntentDeps(),
|
||||
),
|
||||
).toEqual({
|
||||
type: 'resolve-edutainment-draft',
|
||||
entry: edutainmentEntry,
|
||||
|
||||
@@ -97,6 +97,14 @@ export type PlatformPublicWorkDetailOpenStrategy =
|
||||
|
||||
export type PlatformPublicWorkActionMode = 'edit' | 'remix';
|
||||
|
||||
export type PlatformPublicWorkInteractionConfig = {
|
||||
sourceType: string;
|
||||
likeEnabled: boolean;
|
||||
remixEnabled: boolean;
|
||||
likeDisabledMessage: string;
|
||||
remixDisabledMessage: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicWorkLikeIntent =
|
||||
| {
|
||||
type: 'like-big-fish';
|
||||
@@ -678,9 +686,55 @@ export function resolvePlatformPublicWorkActionMode(
|
||||
: 'remix';
|
||||
}
|
||||
|
||||
export function getPlatformPublicWorkInteractionSourceType(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
return 'sourceType' in entry ? entry.sourceType : 'custom-world';
|
||||
}
|
||||
|
||||
function resolveConfiguredPublicWorkInteractionBlock(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
configs: readonly PlatformPublicWorkInteractionConfig[] | null | undefined,
|
||||
action: 'like' | 'remix',
|
||||
): PlatformPublicWorkLikeIntent | PlatformPublicWorkRemixIntent | null {
|
||||
const sourceType = getPlatformPublicWorkInteractionSourceType(entry);
|
||||
const config = configs?.find((item) => item.sourceType === sourceType);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action === 'like' && !config.likeEnabled) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage:
|
||||
config.likeDisabledMessage.trim() || '该作品类型暂不支持点赞。',
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'remix' && !config.remixEnabled) {
|
||||
return {
|
||||
type: 'unsupported',
|
||||
errorMessage:
|
||||
config.remixDisabledMessage.trim() || '该作品类型暂不支持改造。',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePlatformPublicWorkLikeIntent(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
configs?: readonly PlatformPublicWorkInteractionConfig[] | null,
|
||||
): PlatformPublicWorkLikeIntent {
|
||||
const configuredBlock = resolveConfiguredPublicWorkInteractionBlock(
|
||||
entry,
|
||||
configs,
|
||||
'like',
|
||||
);
|
||||
if (configuredBlock) {
|
||||
return configuredBlock as PlatformPublicWorkLikeIntent;
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'like-big-fish',
|
||||
@@ -760,7 +814,17 @@ export function resolvePlatformPublicWorkLikeIntent(
|
||||
|
||||
export function resolvePlatformPublicWorkRemixIntent(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
configs?: readonly PlatformPublicWorkInteractionConfig[] | null,
|
||||
): PlatformPublicWorkRemixIntent {
|
||||
const configuredBlock = resolveConfiguredPublicWorkInteractionBlock(
|
||||
entry,
|
||||
configs,
|
||||
'remix',
|
||||
);
|
||||
if (configuredBlock) {
|
||||
return configuredBlock as PlatformPublicWorkRemixIntent;
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return {
|
||||
type: 'remix-big-fish',
|
||||
@@ -933,8 +997,9 @@ export function resolvePlatformPublicWorkEditIntent(
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
const work =
|
||||
deps.visualNovelWorks?.find((item) => item.profileId === entry.profileId) ??
|
||||
null;
|
||||
deps.visualNovelWorks?.find(
|
||||
(item) => item.profileId === entry.profileId,
|
||||
) ?? null;
|
||||
if (!work) {
|
||||
return {
|
||||
type: 'blocked',
|
||||
|
||||
@@ -1695,6 +1695,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 {
|
||||
@@ -6991,6 +7013,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,7 +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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ function buildJumpHopWorkspacePayload(
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '内置默认 3D 角色',
|
||||
tilePrompt: `${themeText}主题的正面30度视角主题物体图集,物体本身作为跳跃落点`,
|
||||
tilePrompt: `${themeText}主题的3D立方体主题身份方块包装图集`,
|
||||
endMoodPrompt: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -156,7 +155,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -334,6 +332,64 @@ describe('apiClient', () => {
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('keeps local token when refresh fails with transient server unavailable', async () => {
|
||||
setStoredAccessToken('usable-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 503 }));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('keeps local token when refresh cannot reach the restarting server', async () => {
|
||||
setStoredAccessToken('usable-local-token', { emit: false });
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toBeInstanceOf(TypeError);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('clears local token when refresh confirms the session is unauthorized', async () => {
|
||||
setStoredAccessToken('expired-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
await expect(refreshStoredAccessToken()).rejects.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('does not clear auth when protected request refresh fails transiently', async () => {
|
||||
setStoredAccessToken('expired-token-during-restart', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 503 }));
|
||||
|
||||
const response = await fetchWithApiAuth('/api/runtime/protected', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('expired-token-during-restart');
|
||||
});
|
||||
|
||||
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
@@ -344,7 +400,6 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -366,7 +421,7 @@ describe('apiClient', () => {
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects refresh responses that do not return a renewed bearer token', async () => {
|
||||
it('rejects malformed refresh responses without treating them as logout', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
@@ -397,8 +452,8 @@ describe('apiClient', () => {
|
||||
message: '读取受保护数据失败',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(getStoredAccessToken()).toBe('expired-token');
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the current access token when a public request explicitly skips auth', async () => {
|
||||
|
||||
@@ -497,6 +497,13 @@ function withAuthorizationHeaders(
|
||||
|
||||
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||||
|
||||
function shouldClearAuthAfterRefreshFailure(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
(error.status === 401 || error.status === 403)
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshAccessTokenPromise) {
|
||||
return refreshAccessTokenPromise;
|
||||
@@ -522,11 +529,11 @@ async function refreshAccessToken() {
|
||||
)
|
||||
: null;
|
||||
|
||||
if (payload?.ok !== true || !payload.token?.trim()) {
|
||||
const nextToken = payload?.token?.trim();
|
||||
if (!nextToken) {
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
const nextToken = payload.token.trim();
|
||||
setStoredAccessToken(nextToken, { emit: false });
|
||||
return nextToken;
|
||||
})();
|
||||
@@ -556,7 +563,10 @@ export async function refreshStoredAccessToken(
|
||||
try {
|
||||
return await refreshAccessToken();
|
||||
} catch (error) {
|
||||
if (options.clearOnFailure !== false) {
|
||||
if (
|
||||
options.clearOnFailure !== false &&
|
||||
shouldClearAuthAfterRefreshFailure(error)
|
||||
) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
throw error;
|
||||
@@ -629,11 +639,15 @@ export async function fetchWithApiAuth(
|
||||
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
|
||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||
continue;
|
||||
} catch {
|
||||
if (hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized) {
|
||||
} catch (refreshError) {
|
||||
const shouldClearAuth =
|
||||
hasAuthHeader &&
|
||||
authFailurePolicy.clearAuthOnUnauthorized &&
|
||||
shouldClearAuthAfterRefreshFailure(refreshError);
|
||||
if (shouldClearAuth) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (authFailurePolicy.notifyAuthStateChange) {
|
||||
if (shouldClearAuth && authFailurePolicy.notifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,15 @@ export type CreationEntryEventBannerConfig = {
|
||||
htmlCode?: string | null;
|
||||
};
|
||||
|
||||
/** 公开作品详情页互动能力配置,前端只据此关闭已接入动作。 */
|
||||
export type PublicWorkInteractionConfig = {
|
||||
sourceType: string;
|
||||
likeEnabled: boolean;
|
||||
remixEnabled: boolean;
|
||||
likeDisabledMessage: string;
|
||||
remixDisabledMessage: string;
|
||||
};
|
||||
|
||||
/** 创作入口页完整配置;前端只展示后端事实源,不内置入口默认值。 */
|
||||
export type CreationEntryConfig = {
|
||||
startCard: {
|
||||
@@ -67,6 +76,8 @@ export type CreationEntryConfig = {
|
||||
eventBanner: CreationEntryEventBannerConfig;
|
||||
/** 底部加号创作入口页的多公告轮播配置。 */
|
||||
eventBanners?: CreationEntryEventBannerConfig[];
|
||||
/** 公开作品详情页点赞 / 改造能力矩阵。 */
|
||||
publicWorkInteractions?: PublicWorkInteractionConfig[];
|
||||
creationTypes: CreationEntryTypeConfig[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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 和通关语义', () => {
|
||||
|
||||
@@ -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() ||
|
||||
|
||||
Reference in New Issue
Block a user