Merge remote-tracking branch 'origin/master' into hermes/hermes-996d586b
Some checks failed
CI / verify (pull_request) Has been cancelled
Some checks failed
CI / verify (pull_request) Has been cancelled
This commit is contained in:
@@ -87,14 +87,16 @@ const baseDraftItem: CustomWorldWorkSummary = {
|
||||
canEnterWorld: false,
|
||||
};
|
||||
|
||||
test('creation hub reflects updated draft title summary and counts after rerender', () => {
|
||||
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateType = vi.fn();
|
||||
const { rerender } = render(
|
||||
<CustomWorldCreationHub
|
||||
items={[baseDraftItem]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onCreateType={onCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
@@ -105,14 +107,21 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
expect(screen.queryByText('角色 3')).toBeNull();
|
||||
expect(screen.queryByText('地点 4')).toBeNull();
|
||||
const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
||||
const match3dButton = screen.getByRole('button', {
|
||||
name: /抓大鹅.*经典消除玩法/u,
|
||||
});
|
||||
const squareHoleButton = screen.getByRole('button', { name: /方洞挑战/u });
|
||||
expect((squareHoleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(puzzleButton).toBeTruthy();
|
||||
expect(match3dButton).toBeTruthy();
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.getByText('反直觉形状分拣')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /抓大鹅/u })).toBeNull();
|
||||
|
||||
await user.click(match3dButton);
|
||||
expect(onCreateType).toHaveBeenCalledWith('match3d');
|
||||
|
||||
rerender(
|
||||
<CustomWorldCreationHub
|
||||
|
||||
@@ -44,9 +44,10 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
expect(html).toContain('拼图');
|
||||
expect(html).toContain('创意礼物,生活分享');
|
||||
expect(html).toContain('抓大鹅');
|
||||
expect(html).toContain('经典消除玩法');
|
||||
expect(html).not.toContain('角色扮演');
|
||||
expect(html).not.toContain('大鱼吃小鱼');
|
||||
expect(html).not.toContain('抓大鹅');
|
||||
});
|
||||
|
||||
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
||||
|
||||
@@ -32,34 +32,116 @@ type ThreeRenderer = import('three').WebGLRenderer;
|
||||
type ThreeCamera = import('three').OrthographicCamera;
|
||||
|
||||
type PhysicsEntry = {
|
||||
boundaryRadius: number;
|
||||
colliderHeight: number;
|
||||
item: Match3DItemSnapshot;
|
||||
body: PhysicsBody;
|
||||
lockReadableTop: boolean;
|
||||
mesh: ThreeObject3D;
|
||||
renderSignature: string;
|
||||
spawnStartedAt: number;
|
||||
targetY: number;
|
||||
topRotationY: number;
|
||||
};
|
||||
|
||||
type PendingPhysicsSpawn = {
|
||||
activeLayerRank: number;
|
||||
item: Match3DItemSnapshot;
|
||||
renderSignature: string;
|
||||
spawnAtMs: number;
|
||||
layerCapacity: number;
|
||||
targetY: number;
|
||||
};
|
||||
|
||||
type StackHeightTarget = {
|
||||
activeLayerRank: number;
|
||||
targetY: number;
|
||||
};
|
||||
|
||||
type BoardDepthPlan = {
|
||||
activeDepth: number;
|
||||
activeItemCount: number;
|
||||
baseY: number;
|
||||
initialDepth: number;
|
||||
layerCapacity: number;
|
||||
layerCount: number;
|
||||
layerStep: number;
|
||||
maxVerticalSpeed: number;
|
||||
surfaceY: number;
|
||||
};
|
||||
|
||||
type PhysicsStabilityPlan = {
|
||||
angularDamping: number;
|
||||
contactFriction: number;
|
||||
contactRestitution: number;
|
||||
linearDamping: number;
|
||||
maxHorizontalSpeed: number;
|
||||
maxVerticalSpeed: number;
|
||||
solverIterations: number;
|
||||
solverTolerance: number;
|
||||
};
|
||||
|
||||
type Match3DSpawnTimingPlan = {
|
||||
frameSpawnLimit: number;
|
||||
initialDelayMs: number;
|
||||
layerDelayMs: number;
|
||||
burstSize: number;
|
||||
staggerMs: number;
|
||||
};
|
||||
|
||||
type Match3DSpawnHeightObstacle = {
|
||||
boundaryRadius: number;
|
||||
colliderHeight: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
|
||||
type PhysicsRuntime = {
|
||||
animationId: number | null;
|
||||
camera: ThreeCamera;
|
||||
entries: Map<string, PhysicsEntry>;
|
||||
pendingSpawns: Map<string, PendingPhysicsSpawn>;
|
||||
raycaster: import('three').Raycaster;
|
||||
renderer: ThreeRenderer;
|
||||
scene: ThreeScene;
|
||||
spawnTimingPlan: Match3DSpawnTimingPlan;
|
||||
stabilityPlan: PhysicsStabilityPlan;
|
||||
world: PhysicsWorld;
|
||||
three: ThreeModule;
|
||||
cannon: CannonModule;
|
||||
};
|
||||
|
||||
type Match3DStackHeightPlan = {
|
||||
layerCapacity: number;
|
||||
targets: Map<string, StackHeightTarget>;
|
||||
};
|
||||
|
||||
const MATCH3D_POT_FLOOR_RADIUS = 4.75;
|
||||
const MATCH3D_POT_INNER_RADIUS = 4.52;
|
||||
const MATCH3D_POT_OUTER_RADIUS = 5.18;
|
||||
const MATCH3D_POT_WALL_HEIGHT = 2.15;
|
||||
const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58;
|
||||
const MATCH3D_ITEM_POSITION_RADIUS = 3.34;
|
||||
const MATCH3D_ITEM_SPAWN_HEIGHT = 1.25;
|
||||
const MATCH3D_ITEM_STACK_HEIGHT_STEP = 0.024;
|
||||
const MATCH3D_ITEM_BASE_HEIGHT = 1.18;
|
||||
const MATCH3D_ITEM_VERTICAL_DEPTH_BASE = 2.8;
|
||||
const MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE = 0.52;
|
||||
const MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE = 0.032;
|
||||
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE = 12;
|
||||
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE = 0.04;
|
||||
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE = 18;
|
||||
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN = 10;
|
||||
const MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX = 1.04;
|
||||
const MATCH3D_ITEM_LIFT_FORCE_SCALE = 18;
|
||||
const MATCH3D_ITEM_LIFT_MAX_SPEED = 4.2;
|
||||
const MATCH3D_ITEM_SPAWN_RISE_OFFSET = 0.42;
|
||||
const MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS = 54;
|
||||
const MATCH3D_ITEM_SPAWN_STAGGER_MS = 4;
|
||||
const MATCH3D_ITEM_SPAWN_STACK_CLEARANCE = 0.14;
|
||||
const MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING = 0.08;
|
||||
const MATCH3D_ITEM_SPAWN_ANIMATION_MS = 260;
|
||||
const MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT = 8.6;
|
||||
const MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT = 4.4;
|
||||
const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
|
||||
const MATCH3D_BOARD_CENTER = 0.5;
|
||||
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
||||
@@ -100,12 +182,350 @@ function toWorldPosition(item: Match3DItemSnapshot) {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatch3DBoardDepthPlan(
|
||||
totalItemCount: number,
|
||||
activeItemCount: number,
|
||||
): BoardDepthPlan {
|
||||
const normalizedTotalItemCount = Math.max(
|
||||
1,
|
||||
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
|
||||
);
|
||||
const normalizedActiveItemCount = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
normalizedTotalItemCount,
|
||||
Math.round(Number.isFinite(activeItemCount) ? activeItemCount : 0),
|
||||
),
|
||||
);
|
||||
const volumePressure = Math.max(0, normalizedTotalItemCount - 90);
|
||||
const depthMax =
|
||||
MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE +
|
||||
volumePressure * MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE;
|
||||
const initialDepth = Math.min(
|
||||
depthMax,
|
||||
MATCH3D_ITEM_VERTICAL_DEPTH_BASE +
|
||||
Math.sqrt(normalizedTotalItemCount) *
|
||||
MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE +
|
||||
normalizedTotalItemCount * MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE,
|
||||
);
|
||||
const remainingRatio =
|
||||
normalizedActiveItemCount / normalizedTotalItemCount;
|
||||
const activeDepth =
|
||||
normalizedActiveItemCount <= 1 ? 0 : initialDepth * remainingRatio;
|
||||
const pressureRatio = Math.min(1, volumePressure / 210);
|
||||
const layerCapacity = Math.max(
|
||||
MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN,
|
||||
Math.round(
|
||||
MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE - pressureRatio * 8,
|
||||
),
|
||||
);
|
||||
const layerCount = Math.max(
|
||||
1,
|
||||
Math.ceil(normalizedActiveItemCount / layerCapacity),
|
||||
);
|
||||
const layerStep =
|
||||
layerCount <= 1
|
||||
? 0
|
||||
: Math.min(
|
||||
MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX,
|
||||
activeDepth / Math.max(1, layerCount - 1),
|
||||
);
|
||||
|
||||
return {
|
||||
activeDepth,
|
||||
activeItemCount: normalizedActiveItemCount,
|
||||
baseY: MATCH3D_ITEM_BASE_HEIGHT,
|
||||
initialDepth,
|
||||
layerCapacity,
|
||||
layerCount,
|
||||
layerStep,
|
||||
maxVerticalSpeed:
|
||||
MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT + pressureRatio * 2.4,
|
||||
surfaceY: MATCH3D_ITEM_BASE_HEIGHT + initialDepth,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatch3DStackTargetY(
|
||||
totalItemCount: number,
|
||||
activeItemCount: number,
|
||||
activeLayerRank: number,
|
||||
) {
|
||||
const depthPlan = resolveMatch3DBoardDepthPlan(
|
||||
totalItemCount,
|
||||
activeItemCount,
|
||||
);
|
||||
if (depthPlan.activeItemCount <= 1) {
|
||||
return depthPlan.surfaceY;
|
||||
}
|
||||
const clampedRank = Math.max(
|
||||
0,
|
||||
Math.min(depthPlan.activeItemCount - 1, activeLayerRank),
|
||||
);
|
||||
const layerIndex = Math.floor(
|
||||
(clampedRank / Math.max(1, depthPlan.activeItemCount - 1)) *
|
||||
(depthPlan.layerCount - 1),
|
||||
);
|
||||
|
||||
return (
|
||||
depthPlan.surfaceY -
|
||||
(depthPlan.layerCount - 1 - layerIndex) * depthPlan.layerStep
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatch3DBoundaryRadius(
|
||||
asset: Match3DGeometryAsset,
|
||||
radius: number,
|
||||
) {
|
||||
const bounds = resolveMatch3DColliderBounds(asset, radius);
|
||||
return Math.hypot(bounds.width / 2, bounds.depth / 2);
|
||||
}
|
||||
|
||||
export function resolveMatch3DSpawnY(
|
||||
plannedSpawnY: number,
|
||||
colliderHeight: number,
|
||||
boundaryRadius: number,
|
||||
position: Pick<Match3DSpawnHeightObstacle, 'x' | 'z'>,
|
||||
obstacles: readonly Match3DSpawnHeightObstacle[],
|
||||
) {
|
||||
const normalizedPlannedY = Number.isFinite(plannedSpawnY)
|
||||
? plannedSpawnY
|
||||
: MATCH3D_ITEM_BASE_HEIGHT;
|
||||
const selfHalfHeight = Math.max(0, colliderHeight / 2);
|
||||
const selfBoundaryRadius = Math.max(0, boundaryRadius);
|
||||
|
||||
return obstacles.reduce((spawnY, obstacle) => {
|
||||
const horizontalDistance = Math.hypot(
|
||||
position.x - obstacle.x,
|
||||
position.z - obstacle.z,
|
||||
);
|
||||
const overlapDistance =
|
||||
selfBoundaryRadius +
|
||||
Math.max(0, obstacle.boundaryRadius) +
|
||||
MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING;
|
||||
if (horizontalDistance > overlapDistance) {
|
||||
return spawnY;
|
||||
}
|
||||
|
||||
// 中文注释:新物体生成时先避开同位置已有堆叠顶部,避免最后一波直接塞进未稳定的上层模型。
|
||||
const obstacleTopY =
|
||||
obstacle.y + Math.max(0, obstacle.colliderHeight) / 2;
|
||||
return Math.max(
|
||||
spawnY,
|
||||
obstacleTopY + selfHalfHeight + MATCH3D_ITEM_SPAWN_STACK_CLEARANCE,
|
||||
);
|
||||
}, normalizedPlannedY);
|
||||
}
|
||||
|
||||
export function resolveMatch3DSpawnDelay(
|
||||
activeLayerRank: number,
|
||||
layerCapacity = MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE,
|
||||
timingPlan?: Pick<
|
||||
Match3DSpawnTimingPlan,
|
||||
'burstSize' | 'layerDelayMs' | 'staggerMs'
|
||||
>,
|
||||
) {
|
||||
const normalizedLayerCapacity = Math.max(1, layerCapacity);
|
||||
const normalizedLayerRank = Math.max(0, activeLayerRank);
|
||||
const layerDelayMs =
|
||||
timingPlan?.layerDelayMs ?? MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS;
|
||||
const staggerMs = timingPlan?.staggerMs ?? MATCH3D_ITEM_SPAWN_STAGGER_MS;
|
||||
const burstSize = Math.max(
|
||||
1,
|
||||
timingPlan?.burstSize ?? normalizedLayerCapacity,
|
||||
);
|
||||
const layerIndex = Math.floor(
|
||||
normalizedLayerRank / normalizedLayerCapacity,
|
||||
);
|
||||
const burstIndex = Math.floor(normalizedLayerRank / burstSize);
|
||||
return (
|
||||
Math.max(layerIndex, burstIndex) * layerDelayMs +
|
||||
(normalizedLayerRank % burstSize) * staggerMs
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatch3DSpawnTimingPlan(
|
||||
totalItemCount: number,
|
||||
): Match3DSpawnTimingPlan {
|
||||
const normalizedTotalItemCount = Math.max(
|
||||
1,
|
||||
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
|
||||
);
|
||||
const crowdPressureRatio = Math.min(
|
||||
1,
|
||||
Math.max(0, normalizedTotalItemCount - 50) / 250,
|
||||
);
|
||||
const highCrowdRatio = Math.pow(crowdPressureRatio, 0.82);
|
||||
const midCrowdRatio = Math.min(
|
||||
1,
|
||||
Math.max(0, normalizedTotalItemCount - 30) / 20,
|
||||
);
|
||||
|
||||
return {
|
||||
frameSpawnLimit:
|
||||
normalizedTotalItemCount < 30
|
||||
? 4
|
||||
: normalizedTotalItemCount <= 120
|
||||
? 2
|
||||
: 1,
|
||||
initialDelayMs: Math.round(
|
||||
normalizedTotalItemCount < 30
|
||||
? 220
|
||||
: normalizedTotalItemCount <= 50
|
||||
? 240 + midCrowdRatio * 40
|
||||
: 260 + highCrowdRatio * 140,
|
||||
),
|
||||
layerDelayMs: Math.round(
|
||||
normalizedTotalItemCount < 30
|
||||
? MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS
|
||||
: normalizedTotalItemCount <= 50
|
||||
? 96 + midCrowdRatio * 24
|
||||
: 110 + highCrowdRatio * 50,
|
||||
),
|
||||
burstSize:
|
||||
normalizedTotalItemCount < 30
|
||||
? 8
|
||||
: normalizedTotalItemCount <= 50
|
||||
? 5
|
||||
: normalizedTotalItemCount <= 120
|
||||
? 7
|
||||
: 6,
|
||||
staggerMs: Math.round(
|
||||
normalizedTotalItemCount < 30
|
||||
? MATCH3D_ITEM_SPAWN_STAGGER_MS
|
||||
: normalizedTotalItemCount <= 50
|
||||
? 9 + midCrowdRatio * 3
|
||||
: 12 + highCrowdRatio * 6,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DStackHeightTargets(
|
||||
run: Match3DRunSnapshot,
|
||||
): Match3DStackHeightPlan {
|
||||
const activeItems = run.items
|
||||
.filter((item) => isItemState(item.state, 'in_board'))
|
||||
.sort((left, right) => {
|
||||
if (left.layer !== right.layer) {
|
||||
return left.layer - right.layer;
|
||||
}
|
||||
return left.itemInstanceId.localeCompare(right.itemInstanceId);
|
||||
});
|
||||
const targets = new Map<string, StackHeightTarget>();
|
||||
const depthPlan = resolveMatch3DBoardDepthPlan(
|
||||
run.totalItemCount,
|
||||
activeItems.length,
|
||||
);
|
||||
activeItems.forEach((item, activeLayerRank) => {
|
||||
targets.set(
|
||||
item.itemInstanceId,
|
||||
{
|
||||
activeLayerRank,
|
||||
targetY: resolveMatch3DStackTargetY(
|
||||
run.totalItemCount,
|
||||
activeItems.length,
|
||||
activeLayerRank,
|
||||
),
|
||||
},
|
||||
);
|
||||
});
|
||||
return {
|
||||
layerCapacity: depthPlan.layerCapacity,
|
||||
targets,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatch3DPhysicsStabilityPlan(
|
||||
totalItemCount: number,
|
||||
): PhysicsStabilityPlan {
|
||||
const normalizedTotalItemCount = Math.max(
|
||||
1,
|
||||
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
|
||||
);
|
||||
const pressureRatio = Math.min(
|
||||
1,
|
||||
Math.max(0, normalizedTotalItemCount - 90) / 210,
|
||||
);
|
||||
|
||||
return {
|
||||
angularDamping: 0.48 + pressureRatio * 0.18,
|
||||
contactFriction: 0.55 + pressureRatio * 0.22,
|
||||
contactRestitution: 0.28 - pressureRatio * 0.14,
|
||||
linearDamping: 0.38 + pressureRatio * 0.2,
|
||||
maxHorizontalSpeed:
|
||||
MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT - pressureRatio * 0.9,
|
||||
maxVerticalSpeed:
|
||||
MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT + pressureRatio * 2.4,
|
||||
solverIterations: Math.round(10 + pressureRatio * 8),
|
||||
solverTolerance: 0.001 - pressureRatio * 0.0006,
|
||||
};
|
||||
}
|
||||
|
||||
function applyDynamicStackLift(entry: PhysicsEntry) {
|
||||
const liftDistance = entry.targetY - entry.body.position.y;
|
||||
if (liftDistance <= 0.04) {
|
||||
return;
|
||||
}
|
||||
const liftSpeed = Math.min(
|
||||
MATCH3D_ITEM_LIFT_MAX_SPEED,
|
||||
Math.max(0.7, liftDistance * 1.8),
|
||||
);
|
||||
|
||||
// 中文注释:纵深只作为隐藏的表现层支撑;消除后给低层物体向上的托举,避免它们长期陷在锅底。
|
||||
entry.body.force.y +=
|
||||
entry.body.mass * liftDistance * MATCH3D_ITEM_LIFT_FORCE_SCALE;
|
||||
entry.body.velocity.y = Math.max(entry.body.velocity.y, liftSpeed);
|
||||
entry.body.wakeUp();
|
||||
}
|
||||
|
||||
function applyStabilityPlanToBody(
|
||||
entry: PhysicsEntry,
|
||||
stabilityPlan: PhysicsStabilityPlan,
|
||||
) {
|
||||
const horizontalSpeed = Math.hypot(
|
||||
entry.body.velocity.x,
|
||||
entry.body.velocity.z,
|
||||
);
|
||||
if (horizontalSpeed > stabilityPlan.maxHorizontalSpeed) {
|
||||
const ratio = stabilityPlan.maxHorizontalSpeed / horizontalSpeed;
|
||||
entry.body.velocity.x *= ratio;
|
||||
entry.body.velocity.z *= ratio;
|
||||
}
|
||||
entry.body.velocity.y = Math.max(
|
||||
-stabilityPlan.maxVerticalSpeed,
|
||||
Math.min(stabilityPlan.maxVerticalSpeed, entry.body.velocity.y),
|
||||
);
|
||||
}
|
||||
|
||||
function syncRuntimeStabilityPlan(
|
||||
runtime: PhysicsRuntime,
|
||||
totalItemCount: number,
|
||||
) {
|
||||
const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(totalItemCount);
|
||||
runtime.stabilityPlan = stabilityPlan;
|
||||
runtime.world.defaultContactMaterial.friction =
|
||||
stabilityPlan.contactFriction;
|
||||
runtime.world.defaultContactMaterial.restitution =
|
||||
stabilityPlan.contactRestitution;
|
||||
const solver = runtime.world.solver as import('cannon-es').GSSolver;
|
||||
solver.iterations = stabilityPlan.solverIterations;
|
||||
solver.tolerance = stabilityPlan.solverTolerance;
|
||||
|
||||
runtime.entries.forEach((entry) => {
|
||||
entry.body.angularDamping = stabilityPlan.angularDamping;
|
||||
entry.body.linearDamping = stabilityPlan.linearDamping;
|
||||
entry.body.sleepSpeedLimit = Math.max(
|
||||
0.08,
|
||||
0.16 - Math.min(1, totalItemCount / 300) * 0.05,
|
||||
);
|
||||
entry.body.sleepTimeLimit = 0.18;
|
||||
});
|
||||
}
|
||||
|
||||
function constrainBodyInsidePot(entry: PhysicsEntry) {
|
||||
const visualRadius = toWorldPosition(entry.item).radius;
|
||||
// 中文注释:锅壁和锅沿是视觉边界,物体活动圈要更内缩,避免 3D 透视下贴边后被圆形 DOM 裁切。
|
||||
// 中文注释:空气墙按真实碰撞外接半径收束,长条积木不能再只按近似圆半径贴近锅边。
|
||||
const maxDistance = Math.max(
|
||||
0,
|
||||
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
|
||||
MATCH3D_ITEM_ACTIVITY_RADIUS - entry.boundaryRadius,
|
||||
);
|
||||
const horizontalDistance = Math.hypot(
|
||||
entry.body.position.x,
|
||||
@@ -128,6 +548,16 @@ function constrainBodyInsidePot(entry: PhysicsEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSpawnAnimationProgress(entry: PhysicsEntry, now: number) {
|
||||
return Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(now - entry.spawnStartedAt) / MATCH3D_ITEM_SPAWN_ANIMATION_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function applyCenterGravity(entry: PhysicsEntry) {
|
||||
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
|
||||
return;
|
||||
@@ -536,6 +966,105 @@ function removePhysicsEntry(
|
||||
runtime.entries.delete(itemInstanceId);
|
||||
}
|
||||
|
||||
function createPhysicsEntryFromPendingSpawn(
|
||||
runtime: PhysicsRuntime,
|
||||
pendingSpawn: PendingPhysicsSpawn,
|
||||
now: number,
|
||||
) {
|
||||
const visual = createItemMesh(runtime.three, pendingSpawn.item);
|
||||
const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
|
||||
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
|
||||
const boundaryRadius = resolveMatch3DBoundaryRadius(asset, visual.radius);
|
||||
const position = visual.position;
|
||||
const maxDistance = Math.max(0, MATCH3D_ITEM_ACTIVITY_RADIUS - boundaryRadius);
|
||||
const horizontalDistance = Math.hypot(position.x, position.z);
|
||||
if (horizontalDistance > maxDistance && horizontalDistance > 0) {
|
||||
const ratio = maxDistance / horizontalDistance;
|
||||
position.x *= ratio;
|
||||
position.z *= ratio;
|
||||
}
|
||||
const spawnLayerIndex = Math.floor(
|
||||
Math.max(0, pendingSpawn.activeLayerRank) /
|
||||
pendingSpawn.layerCapacity,
|
||||
);
|
||||
const plannedSpawnY =
|
||||
pendingSpawn.targetY +
|
||||
MATCH3D_ITEM_SPAWN_RISE_OFFSET +
|
||||
Math.min(spawnLayerIndex * 0.05, 0.62);
|
||||
const spawnY = resolveMatch3DSpawnY(
|
||||
plannedSpawnY,
|
||||
colliderBounds.height,
|
||||
boundaryRadius,
|
||||
position,
|
||||
[...runtime.entries.values()].map((entry) => ({
|
||||
boundaryRadius: entry.boundaryRadius,
|
||||
colliderHeight: entry.colliderHeight,
|
||||
x: entry.body.position.x,
|
||||
y: entry.body.position.y,
|
||||
z: entry.body.position.z,
|
||||
})),
|
||||
);
|
||||
const body = new runtime.cannon.Body({
|
||||
angularDamping: runtime.stabilityPlan.angularDamping,
|
||||
allowSleep: true,
|
||||
linearDamping: runtime.stabilityPlan.linearDamping,
|
||||
mass: 1 + visual.radius * 0.7,
|
||||
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
|
||||
sleepSpeedLimit: 0.12,
|
||||
sleepTimeLimit: 0.18,
|
||||
position: new runtime.cannon.Vec3(
|
||||
position.x,
|
||||
spawnY,
|
||||
position.z,
|
||||
),
|
||||
});
|
||||
body.velocity.set(
|
||||
((pendingSpawn.item.layer % 5) - 2) * 0.06,
|
||||
-0.35,
|
||||
(((pendingSpawn.item.layer + 2) % 5) - 2) * 0.06,
|
||||
);
|
||||
body.angularVelocity.set(
|
||||
0.1 + (pendingSpawn.item.layer % 3) * 0.025,
|
||||
0.08,
|
||||
0.08 + (pendingSpawn.item.layer % 4) * 0.02,
|
||||
);
|
||||
visual.mesh.scale.setScalar(0.82);
|
||||
|
||||
runtime.world.addBody(body);
|
||||
runtime.scene.add(visual.mesh);
|
||||
runtime.entries.set(pendingSpawn.item.itemInstanceId, {
|
||||
body,
|
||||
boundaryRadius,
|
||||
colliderHeight: colliderBounds.height,
|
||||
item: pendingSpawn.item,
|
||||
lockReadableTop: visual.lockReadableTop,
|
||||
mesh: visual.mesh,
|
||||
renderSignature: pendingSpawn.renderSignature,
|
||||
spawnStartedAt: now,
|
||||
targetY: pendingSpawn.targetY,
|
||||
topRotationY: visual.topRotationY,
|
||||
});
|
||||
}
|
||||
|
||||
function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
|
||||
const readySpawns = [...runtime.pendingSpawns.entries()]
|
||||
.filter(([, pendingSpawn]) => now >= pendingSpawn.spawnAtMs)
|
||||
.sort((left, right) => {
|
||||
if (left[1].spawnAtMs !== right[1].spawnAtMs) {
|
||||
return left[1].spawnAtMs - right[1].spawnAtMs;
|
||||
}
|
||||
if (left[1].activeLayerRank !== right[1].activeLayerRank) {
|
||||
return left[1].activeLayerRank - right[1].activeLayerRank;
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
});
|
||||
const spawnBudget = runtime.spawnTimingPlan.frameSpawnLimit;
|
||||
readySpawns.slice(0, spawnBudget).forEach(([itemInstanceId, pendingSpawn]) => {
|
||||
runtime.pendingSpawns.delete(itemInstanceId);
|
||||
createPhysicsEntryFromPendingSpawn(runtime, pendingSpawn, now);
|
||||
});
|
||||
}
|
||||
|
||||
function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||
if (!runtime) {
|
||||
return;
|
||||
@@ -1092,13 +1621,23 @@ export function Match3DPhysicsBoard({
|
||||
rim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.1;
|
||||
scene.add(rim);
|
||||
|
||||
const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(
|
||||
runRef.current.totalItemCount,
|
||||
);
|
||||
const spawnTimingPlan = resolveMatch3DSpawnTimingPlan(
|
||||
runRef.current.totalItemCount,
|
||||
);
|
||||
const world = new cannon.World({
|
||||
gravity: new cannon.Vec3(0, -6.2, 0),
|
||||
});
|
||||
world.allowSleep = true;
|
||||
world.broadphase = new cannon.SAPBroadphase(world);
|
||||
world.defaultContactMaterial.friction = 0.55;
|
||||
world.defaultContactMaterial.restitution = 0.28;
|
||||
world.defaultContactMaterial.friction = stabilityPlan.contactFriction;
|
||||
world.defaultContactMaterial.restitution =
|
||||
stabilityPlan.contactRestitution;
|
||||
const solver = world.solver as import('cannon-es').GSSolver;
|
||||
solver.iterations = stabilityPlan.solverIterations;
|
||||
solver.tolerance = stabilityPlan.solverTolerance;
|
||||
|
||||
const floorBody = new cannon.Body({
|
||||
mass: 0,
|
||||
@@ -1125,9 +1664,12 @@ export function Match3DPhysicsBoard({
|
||||
animationId: null,
|
||||
camera,
|
||||
entries: new Map(),
|
||||
pendingSpawns: new Map(),
|
||||
raycaster: new three.Raycaster(),
|
||||
renderer,
|
||||
scene,
|
||||
spawnTimingPlan,
|
||||
stabilityPlan,
|
||||
world,
|
||||
three,
|
||||
cannon,
|
||||
@@ -1157,17 +1699,26 @@ export function Match3DPhysicsBoard({
|
||||
}
|
||||
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
|
||||
lastTime = now;
|
||||
flushPendingPhysicsSpawns(activeRuntime, now);
|
||||
activeRuntime.entries.forEach((entry) => {
|
||||
applyCenterGravity(entry);
|
||||
applyDynamicStackLift(entry);
|
||||
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
|
||||
constrainBodyInsidePot(entry);
|
||||
});
|
||||
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3);
|
||||
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 5);
|
||||
|
||||
activeRuntime.entries.forEach((entry) => {
|
||||
applyCenterGravity(entry);
|
||||
applyDynamicStackLift(entry);
|
||||
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
|
||||
constrainBodyInsidePot(entry);
|
||||
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
|
||||
const spawnScale = 0.82 + spawnProgress * 0.18;
|
||||
entry.mesh.scale.setScalar(spawnScale);
|
||||
entry.mesh.position.set(
|
||||
entry.body.position.x,
|
||||
entry.body.position.y,
|
||||
entry.body.position.y - (1 - spawnProgress) * 0.06,
|
||||
entry.body.position.z,
|
||||
);
|
||||
entry.mesh.quaternion.set(
|
||||
@@ -1231,6 +1782,16 @@ export function Match3DPhysicsBoard({
|
||||
}
|
||||
});
|
||||
|
||||
runtime.pendingSpawns.forEach((pendingSpawn, itemInstanceId) => {
|
||||
if (!activeItemIds.has(itemInstanceId)) {
|
||||
runtime.pendingSpawns.delete(itemInstanceId);
|
||||
}
|
||||
});
|
||||
|
||||
syncRuntimeStabilityPlan(runtime, run.totalItemCount);
|
||||
runtime.spawnTimingPlan = resolveMatch3DSpawnTimingPlan(run.totalItemCount);
|
||||
const stackHeightPlan = buildMatch3DStackHeightTargets(run);
|
||||
|
||||
run.items.forEach((item) => {
|
||||
if (!isItemState(item.state, 'in_board')) {
|
||||
return;
|
||||
@@ -1247,44 +1808,46 @@ export function Match3DPhysicsBoard({
|
||||
removePhysicsEntry(runtime, item.itemInstanceId, existing);
|
||||
} else {
|
||||
existing.item = item;
|
||||
existing.targetY =
|
||||
stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ??
|
||||
existing.body.position.y;
|
||||
existing.mesh.visible = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const visual = createItemMesh(runtime.three, item);
|
||||
const asset = resolveGeometryAsset(item.visualKey);
|
||||
const body = new runtime.cannon.Body({
|
||||
angularDamping: 0.48,
|
||||
linearDamping: 0.38,
|
||||
mass: 1 + visual.radius * 0.7,
|
||||
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
|
||||
position: new runtime.cannon.Vec3(
|
||||
visual.position.x,
|
||||
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP,
|
||||
visual.position.z,
|
||||
),
|
||||
});
|
||||
body.velocity.set(
|
||||
((item.layer % 5) - 2) * 0.08,
|
||||
0,
|
||||
(((item.layer + 2) % 5) - 2) * 0.08,
|
||||
);
|
||||
body.angularVelocity.set(
|
||||
0.18 + (item.layer % 3) * 0.04,
|
||||
0.12,
|
||||
0.1 + (item.layer % 4) * 0.03,
|
||||
);
|
||||
const existingPending = runtime.pendingSpawns.get(item.itemInstanceId);
|
||||
if (existingPending) {
|
||||
if (existingPending.renderSignature !== renderSignature) {
|
||||
runtime.pendingSpawns.delete(item.itemInstanceId);
|
||||
} else {
|
||||
existingPending.item = item;
|
||||
existingPending.layerCapacity = stackHeightPlan.layerCapacity;
|
||||
existingPending.targetY =
|
||||
stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ??
|
||||
existingPending.targetY;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
runtime.world.addBody(body);
|
||||
runtime.scene.add(visual.mesh);
|
||||
runtime.entries.set(item.itemInstanceId, {
|
||||
body,
|
||||
const stackTarget = stackHeightPlan.targets.get(item.itemInstanceId);
|
||||
const spawnAtMs =
|
||||
performance.now() +
|
||||
runtime.spawnTimingPlan.initialDelayMs +
|
||||
resolveMatch3DSpawnDelay(
|
||||
stackTarget?.activeLayerRank ?? item.layer - 1,
|
||||
stackHeightPlan.layerCapacity,
|
||||
runtime.spawnTimingPlan,
|
||||
);
|
||||
runtime.pendingSpawns.set(item.itemInstanceId, {
|
||||
activeLayerRank: stackTarget?.activeLayerRank ?? item.layer - 1,
|
||||
item,
|
||||
lockReadableTop: visual.lockReadableTop,
|
||||
mesh: visual.mesh,
|
||||
layerCapacity: stackHeightPlan.layerCapacity,
|
||||
renderSignature,
|
||||
topRotationY: visual.topRotationY,
|
||||
spawnAtMs,
|
||||
targetY:
|
||||
stackTarget?.targetY ??
|
||||
resolveMatch3DStackTargetY(run.totalItemCount, activeItemIds.size, 0),
|
||||
});
|
||||
});
|
||||
}, [ready, run.items, run.runId, run.snapshotVersion]);
|
||||
|
||||
@@ -25,6 +25,13 @@ import {
|
||||
createMatch3DThreeGeometry,
|
||||
measureMatch3DItemPreviewDimension,
|
||||
resolveMatch3DColliderBounds,
|
||||
resolveMatch3DBoardDepthPlan,
|
||||
resolveMatch3DBoundaryRadius,
|
||||
resolveMatch3DPhysicsStabilityPlan,
|
||||
resolveMatch3DSpawnTimingPlan,
|
||||
resolveMatch3DStackTargetY,
|
||||
resolveMatch3DSpawnDelay,
|
||||
resolveMatch3DSpawnY,
|
||||
resolveMatch3DTrayPreviewRotation,
|
||||
resolveMatch3DTrayPreviewReferenceDimension,
|
||||
resolveMatch3DTrayPreviewScale,
|
||||
@@ -447,6 +454,143 @@ test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
|
||||
).toBeCloseTo(cylinderBounds.height);
|
||||
});
|
||||
|
||||
test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () => {
|
||||
const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
|
||||
const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
|
||||
const earlyBottomY = resolveMatch3DStackTargetY(300, 300, 0);
|
||||
const lateBottomY = resolveMatch3DStackTargetY(300, 60, 0);
|
||||
|
||||
expect(largeDepthPlan.initialDepth).toBeGreaterThan(
|
||||
smallDepthPlan.initialDepth,
|
||||
);
|
||||
expect(largeDepthPlan.layerCapacity).toBeLessThan(
|
||||
smallDepthPlan.layerCapacity,
|
||||
);
|
||||
expect(largeDepthPlan.layerCount).toBeGreaterThan(
|
||||
smallDepthPlan.layerCount,
|
||||
);
|
||||
expect(largeDepthPlan.surfaceY).toBeGreaterThan(largeDepthPlan.baseY);
|
||||
expect(lateBottomY).toBeGreaterThan(earlyBottomY);
|
||||
expect(lateBottomY).toBeLessThanOrEqual(largeDepthPlan.surfaceY);
|
||||
});
|
||||
|
||||
test('高数量 3D 局面使用更稳定的物理参数', () => {
|
||||
const smallPlan = resolveMatch3DPhysicsStabilityPlan(30);
|
||||
const largePlan = resolveMatch3DPhysicsStabilityPlan(300);
|
||||
|
||||
expect(largePlan.contactFriction).toBeGreaterThan(
|
||||
smallPlan.contactFriction,
|
||||
);
|
||||
expect(largePlan.contactRestitution).toBeLessThan(
|
||||
smallPlan.contactRestitution,
|
||||
);
|
||||
expect(largePlan.linearDamping).toBeGreaterThan(smallPlan.linearDamping);
|
||||
expect(largePlan.angularDamping).toBeGreaterThan(smallPlan.angularDamping);
|
||||
expect(largePlan.solverIterations).toBeGreaterThan(
|
||||
smallPlan.solverIterations,
|
||||
);
|
||||
expect(largePlan.maxHorizontalSpeed).toBeLessThan(
|
||||
smallPlan.maxHorizontalSpeed,
|
||||
);
|
||||
});
|
||||
|
||||
test('3D 真实边界半径比视觉半径更保守,避免长条贴边穿出锅壁', () => {
|
||||
const longBrick = resolveGeometryAsset('block-black-1x8');
|
||||
const radius = 1;
|
||||
const boundaryRadius = resolveMatch3DBoundaryRadius(longBrick, radius);
|
||||
const visualRadius = Math.hypot(
|
||||
resolveMatch3DColliderBounds(longBrick, radius).width / 2,
|
||||
resolveMatch3DColliderBounds(longBrick, radius).depth / 2,
|
||||
);
|
||||
|
||||
expect(boundaryRadius).toBeCloseTo(visualRadius);
|
||||
expect(boundaryRadius).toBeGreaterThan(2.4);
|
||||
});
|
||||
|
||||
test('100 次局面的新物体会按层级延迟生成并逐层回落', () => {
|
||||
const fastTimingPlan = resolveMatch3DSpawnTimingPlan(29);
|
||||
const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
|
||||
const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
|
||||
const smallTimingPlan = resolveMatch3DSpawnTimingPlan(30);
|
||||
const largeTimingPlan = resolveMatch3DSpawnTimingPlan(300);
|
||||
const bottomDelay = resolveMatch3DSpawnDelay(0, largeDepthPlan.layerCapacity);
|
||||
const middleDelay = resolveMatch3DSpawnDelay(30, largeDepthPlan.layerCapacity);
|
||||
const topDelay = resolveMatch3DSpawnDelay(120, largeDepthPlan.layerCapacity);
|
||||
const dynamicCapacityDelay = resolveMatch3DSpawnDelay(
|
||||
120,
|
||||
largeDepthPlan.layerCapacity,
|
||||
);
|
||||
const defaultCapacityDelay = resolveMatch3DSpawnDelay(
|
||||
120,
|
||||
smallDepthPlan.layerCapacity,
|
||||
);
|
||||
|
||||
expect(bottomDelay).toBe(0);
|
||||
expect(middleDelay).toBeGreaterThan(bottomDelay);
|
||||
expect(topDelay).toBeGreaterThan(middleDelay);
|
||||
expect(dynamicCapacityDelay).toBeGreaterThan(defaultCapacityDelay);
|
||||
expect(smallTimingPlan.frameSpawnLimit).toBeLessThan(
|
||||
fastTimingPlan.frameSpawnLimit,
|
||||
);
|
||||
expect(smallTimingPlan.burstSize).toBeLessThan(fastTimingPlan.burstSize);
|
||||
expect(smallTimingPlan.layerDelayMs).toBeGreaterThan(
|
||||
fastTimingPlan.layerDelayMs,
|
||||
);
|
||||
expect(
|
||||
resolveMatch3DSpawnDelay(29, smallDepthPlan.layerCapacity, smallTimingPlan),
|
||||
).toBeGreaterThan(450);
|
||||
expect(largeTimingPlan.initialDelayMs).toBeGreaterThan(
|
||||
smallTimingPlan.initialDelayMs,
|
||||
);
|
||||
expect(largeTimingPlan.frameSpawnLimit).toBeLessThan(
|
||||
smallTimingPlan.frameSpawnLimit,
|
||||
);
|
||||
expect(largeTimingPlan.burstSize).toBeLessThanOrEqual(6);
|
||||
expect(largeTimingPlan.layerDelayMs).toBeGreaterThanOrEqual(
|
||||
smallTimingPlan.layerDelayMs,
|
||||
);
|
||||
expect(
|
||||
resolveMatch3DSpawnDelay(299, largeDepthPlan.layerCapacity, largeTimingPlan),
|
||||
).toBeGreaterThan(5000);
|
||||
});
|
||||
|
||||
test('3D 新物体生成高度会避让同位置已有堆叠', () => {
|
||||
const plannedSpawnY = 2;
|
||||
const raisedSpawnY = resolveMatch3DSpawnY(
|
||||
plannedSpawnY,
|
||||
0.8,
|
||||
0.7,
|
||||
{ x: 0.1, z: 0.1 },
|
||||
[
|
||||
{
|
||||
boundaryRadius: 0.7,
|
||||
colliderHeight: 0.9,
|
||||
x: 0.18,
|
||||
y: 2.4,
|
||||
z: 0.15,
|
||||
},
|
||||
],
|
||||
);
|
||||
const unchangedSpawnY = resolveMatch3DSpawnY(
|
||||
plannedSpawnY,
|
||||
0.8,
|
||||
0.7,
|
||||
{ x: 0.1, z: 0.1 },
|
||||
[
|
||||
{
|
||||
boundaryRadius: 0.7,
|
||||
colliderHeight: 0.9,
|
||||
x: 3,
|
||||
y: 4,
|
||||
z: 3,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(raisedSpawnY).toBeGreaterThan(plannedSpawnY);
|
||||
expect(unchangedSpawnY).toBe(plannedSpawnY);
|
||||
});
|
||||
|
||||
test('积木视觉键不会被统一兜底成红色苹字', () => {
|
||||
const run = startLocalMatch3DRun(2);
|
||||
run.items = run.items.slice(0, 2).map((item, index) => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
lazy,
|
||||
@@ -166,6 +166,10 @@ import {
|
||||
submitLocalPuzzleLeaderboard,
|
||||
swapLocalPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||
import {
|
||||
generatePuzzleOnboardingWork,
|
||||
savePuzzleOnboardingWork,
|
||||
} from '../../services/puzzle-onboarding';
|
||||
import {
|
||||
claimPuzzleWorkPointIncentive,
|
||||
deletePuzzleWork,
|
||||
@@ -251,6 +255,13 @@ type PuzzleRuntimeReturnStage =
|
||||
| 'work-detail'
|
||||
| 'platform';
|
||||
|
||||
type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
|
||||
|
||||
type PuzzleOnboardingDraft = {
|
||||
promptText: string;
|
||||
item: PuzzleWorkSummary;
|
||||
};
|
||||
|
||||
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
|
||||
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
|
||||
type SquareHoleRuntimeReturnStage =
|
||||
@@ -605,6 +616,157 @@ function mergePuzzleWorkSummary(
|
||||
return current.profileId === updated.profileId ? updated : current;
|
||||
}
|
||||
|
||||
const PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY =
|
||||
'genarrative.puzzle-onboarding.first-visit.v1';
|
||||
const PUZZLE_ONBOARDING_COPY = '待定待定待定';
|
||||
const PUZZLE_ONBOARDING_CLEAR_COPY = '只差一步,就可以永久保留你的梦';
|
||||
const PUZZLE_ONBOARDING_GENERATED_DELAY_MS = 700;
|
||||
|
||||
function hasSeenPuzzleOnboarding() {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return (
|
||||
window.localStorage.getItem(PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY) ===
|
||||
'1'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markPuzzleOnboardingSeen() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY, '1');
|
||||
} catch {
|
||||
// 中文注释:localStorage 不可写时只降级为本次会话展示,不影响主流程。
|
||||
}
|
||||
}
|
||||
|
||||
function PuzzleOnboardingView({
|
||||
prompt,
|
||||
phase,
|
||||
error,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
prompt: string;
|
||||
phase: PuzzleOnboardingPhase;
|
||||
error: string | null;
|
||||
onPromptChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const isGenerating = phase === 'generating';
|
||||
const isGenerated = phase === 'generated';
|
||||
const canSubmit = Boolean(prompt.trim()) && !isGenerating && !isGenerated;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_30%_15%,rgba(251,191,36,0.22),transparent_30%),linear-gradient(135deg,#0f172a,#111827_46%,#1e1b4b)] px-4 py-8 text-white">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.045)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:38px_38px] opacity-30" />
|
||||
<section className="relative flex w-full max-w-[34rem] flex-col items-center gap-5 text-center">
|
||||
<div className="grid h-14 w-14 place-items-center rounded-[1.2rem] border border-amber-200/32 bg-amber-200/14 text-amber-100 shadow-[0_18px_48px_rgba(251,191,36,0.18)]">
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-6 w-6" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-[2rem] font-black leading-tight sm:text-[2.85rem]">
|
||||
{PUZZLE_ONBOARDING_COPY}
|
||||
</h1>
|
||||
<form
|
||||
className="flex w-full flex-col gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={isGenerating || isGenerated}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
placeholder="把你的梦讲给我听吧"
|
||||
rows={4}
|
||||
className="min-h-32 w-full resize-none rounded-[1.25rem] border border-white/14 bg-black/28 px-4 py-4 text-base font-semibold leading-7 text-white shadow-[0_18px_50px_rgba(0,0,0,0.24)] outline-none backdrop-blur placeholder:text-white/42 focus:border-amber-200/70 focus:ring-2 focus:ring-amber-200/20 disabled:opacity-70"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
生成
|
||||
</>
|
||||
) : (
|
||||
'生成'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{error ? (
|
||||
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleOnboardingLoginOverlay({
|
||||
isSaving,
|
||||
error,
|
||||
onLogin,
|
||||
}: {
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
onLogin: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-950/72 px-4 py-6 text-white backdrop-blur-md">
|
||||
<section className="flex w-full max-w-[24rem] flex-col items-center gap-5 rounded-[1.35rem] border border-white/14 bg-slate-950/94 px-5 py-6 text-center shadow-[0_28px_90px_rgba(0,0,0,0.5)]">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-[1rem] bg-amber-200 text-slate-950">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-2xl font-black leading-tight">
|
||||
{PUZZLE_ONBOARDING_CLEAR_COPY}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
onClick={onLogin}
|
||||
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
注册账号 / 登录
|
||||
</>
|
||||
) : (
|
||||
'注册账号 / 登录'
|
||||
)}
|
||||
</button>
|
||||
{error ? (
|
||||
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeBigFishWorkSummary(
|
||||
current: BigFishWorkSummary,
|
||||
updated: BigFishWorkSummary,
|
||||
@@ -1124,10 +1286,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||
const puzzleRunRef = useRef<PuzzleRunSnapshot | null>(null);
|
||||
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
||||
const [puzzleShelfError, setPuzzleShelfError] = useState<string | null>(null);
|
||||
const [puzzleCreationError, setPuzzleCreationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [puzzleGenerationState, setPuzzleGenerationState] =
|
||||
useState<MiniGameDraftGenerationState | null>(null);
|
||||
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
|
||||
useState<CreatePuzzleAgentSessionRequest | null>(null);
|
||||
const [puzzleOnboardingPrompt, setPuzzleOnboardingPrompt] = useState('');
|
||||
const [puzzleOnboardingPhase, setPuzzleOnboardingPhase] =
|
||||
useState<PuzzleOnboardingPhase>('input');
|
||||
const [puzzleOnboardingDraft, setPuzzleOnboardingDraft] =
|
||||
useState<PuzzleOnboardingDraft | null>(null);
|
||||
const [puzzleOnboardingError, setPuzzleOnboardingError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isPuzzleOnboardingSaving, setIsPuzzleOnboardingSaving] =
|
||||
useState(false);
|
||||
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
|
||||
useState(false);
|
||||
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
|
||||
@@ -1295,9 +1471,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const worksResponse = await listPuzzleWorks();
|
||||
setPuzzleWorks(worksResponse.items);
|
||||
setPuzzleError(null);
|
||||
setPuzzleShelfError(null);
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
setPuzzleShelfError(
|
||||
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
|
||||
);
|
||||
} finally {
|
||||
@@ -1609,6 +1785,106 @@ export function PlatformEntryFlowShellImpl({
|
||||
[authUi],
|
||||
);
|
||||
|
||||
const savePuzzleOnboardingDraft = useCallback(async () => {
|
||||
if (!puzzleOnboardingDraft || isPuzzleOnboardingSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleOnboardingSaving(true);
|
||||
setPuzzleOnboardingError(null);
|
||||
try {
|
||||
const response = await savePuzzleOnboardingWork({
|
||||
promptText: puzzleOnboardingDraft.promptText,
|
||||
item: puzzleOnboardingDraft.item,
|
||||
});
|
||||
setPuzzleWorks((current) => [response.item, ...current]);
|
||||
setSelectedPuzzleDetail(null);
|
||||
setPuzzleRun(null);
|
||||
setPuzzleOnboardingDraft(null);
|
||||
setPuzzleOnboardingPrompt('');
|
||||
setPuzzleOnboardingPhase('input');
|
||||
platformBootstrap.setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
void refreshPuzzleShelf();
|
||||
} catch (error) {
|
||||
setPuzzleOnboardingError(
|
||||
resolvePuzzleErrorMessage(error, '保存新手引导拼图失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsPuzzleOnboardingSaving(false);
|
||||
}
|
||||
}, [
|
||||
isPuzzleOnboardingSaving,
|
||||
platformBootstrap,
|
||||
puzzleOnboardingDraft,
|
||||
refreshPuzzleShelf,
|
||||
resolvePuzzleErrorMessage,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const requestPuzzleOnboardingLogin = useCallback(() => {
|
||||
if (isPuzzleOnboardingSaving) {
|
||||
return;
|
||||
}
|
||||
authUi?.openLoginModal(() => {
|
||||
void savePuzzleOnboardingDraft();
|
||||
});
|
||||
}, [authUi, isPuzzleOnboardingSaving, savePuzzleOnboardingDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!authUi ||
|
||||
authUi?.user ||
|
||||
selectionStage !== 'platform' ||
|
||||
hasSeenPuzzleOnboarding()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleOnboardingPhase('input');
|
||||
setPuzzleOnboardingError(null);
|
||||
setSelectionStage('puzzle-onboarding');
|
||||
}, [authUi, authUi?.user, selectionStage, setSelectionStage]);
|
||||
|
||||
const submitPuzzleOnboardingPrompt = useCallback(async () => {
|
||||
const promptText = puzzleOnboardingPrompt.trim();
|
||||
if (!promptText || puzzleOnboardingPhase === 'generating') {
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleOnboardingPhase('generating');
|
||||
setPuzzleOnboardingError(null);
|
||||
try {
|
||||
const response = await generatePuzzleOnboardingWork({ promptText });
|
||||
const item: PuzzleWorkSummary = {
|
||||
...response.item,
|
||||
levels:
|
||||
response.item.levels && response.item.levels.length > 0
|
||||
? response.item.levels
|
||||
: [response.level],
|
||||
};
|
||||
setPuzzleOnboardingDraft({ promptText, item });
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleOnboardingPhase('generated');
|
||||
markPuzzleOnboardingSeen();
|
||||
window.setTimeout(() => {
|
||||
setPuzzleRun(startLocalPuzzleRun(item));
|
||||
setPuzzleRuntimeReturnStage('platform');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
}, PUZZLE_ONBOARDING_GENERATED_DELAY_MS);
|
||||
} catch (error) {
|
||||
setPuzzleOnboardingPhase('input');
|
||||
setPuzzleOnboardingError(
|
||||
resolvePuzzleErrorMessage(error, '生成新手引导拼图失败。'),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
puzzleOnboardingPhase,
|
||||
puzzleOnboardingPrompt,
|
||||
resolvePuzzleErrorMessage,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const requestDeleteCreationWork = useCallback(
|
||||
(confirmation: DeleteCreationWorkConfirmation) => {
|
||||
if (deletingCreationWorkId) {
|
||||
@@ -1986,8 +2262,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
enterCreateTab,
|
||||
setSelectionStage,
|
||||
onSessionOpened: () => {
|
||||
sessionController.setCreationTypeError(null);
|
||||
setPuzzleCreationError(null);
|
||||
setShowCreationTypeModal(false);
|
||||
},
|
||||
onOpenError: ({ errorMessage }) => {
|
||||
sessionController.setCreationTypeError(errorMessage);
|
||||
setPuzzleCreationError(errorMessage);
|
||||
},
|
||||
onActionComplete: async ({ payload, response, setSession }) => {
|
||||
setPuzzleOperation(response.operation);
|
||||
setSession(response.session);
|
||||
@@ -2167,6 +2449,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleGenerationState(null);
|
||||
setPuzzleFormDraftPayload(null);
|
||||
sessionController.setCreationTypeError(null);
|
||||
setPuzzleCreationError(null);
|
||||
const nextSession = await puzzleFlow.openWorkspace({});
|
||||
if (nextSession) {
|
||||
void refreshPuzzleShelf();
|
||||
@@ -2274,6 +2558,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(null);
|
||||
setPuzzleGenerationState(null);
|
||||
setIsPuzzleNextLevelGenerating(false);
|
||||
setPuzzleShelfError(null);
|
||||
setPuzzleCreationError(null);
|
||||
setPuzzleError(null);
|
||||
setDeletingCreationWorkId(null);
|
||||
setClaimingPuzzlePointIncentiveProfileId(null);
|
||||
@@ -4970,6 +5256,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
squareHoleError ??
|
||||
puzzleShelfError ??
|
||||
puzzleError)
|
||||
}
|
||||
onRetry={() => {
|
||||
@@ -4977,6 +5264,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishError(null);
|
||||
setMatch3DError(null);
|
||||
setSquareHoleError(null);
|
||||
setPuzzleShelfError(null);
|
||||
setPuzzleCreationError(null);
|
||||
setPuzzleError(null);
|
||||
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
|
||||
platformBootstrap.setPlatformError(
|
||||
@@ -4995,6 +5284,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
squareHoleError ??
|
||||
puzzleCreationError ??
|
||||
puzzleError
|
||||
}
|
||||
createBusy={
|
||||
@@ -5926,6 +6216,26 @@ export function PlatformEntryFlowShellImpl({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'puzzle-onboarding' && (
|
||||
<motion.div
|
||||
key="puzzle-onboarding"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100]"
|
||||
>
|
||||
<PuzzleOnboardingView
|
||||
prompt={puzzleOnboardingPrompt}
|
||||
phase={puzzleOnboardingPhase}
|
||||
error={puzzleOnboardingError}
|
||||
onPromptChange={setPuzzleOnboardingPrompt}
|
||||
onSubmit={() => {
|
||||
void submitPuzzleOnboardingPrompt();
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'puzzle-generating' && (
|
||||
<motion.div
|
||||
key="puzzle-generating"
|
||||
@@ -6064,6 +6374,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isPuzzleLeaderboardBusy
|
||||
}
|
||||
error={puzzleError}
|
||||
hideBackButton={Boolean(puzzleOnboardingDraft)}
|
||||
onBack={() => {
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
}}
|
||||
@@ -6100,6 +6411,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{puzzleOnboardingDraft &&
|
||||
puzzleRun?.currentLevel?.status === 'cleared' ? (
|
||||
<PuzzleOnboardingLoginOverlay
|
||||
isSaving={isPuzzleOnboardingSaving}
|
||||
error={puzzleOnboardingError}
|
||||
onLogin={requestPuzzleOnboardingLogin}
|
||||
/>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -6345,6 +6664,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
squareHoleError ??
|
||||
puzzleCreationError ??
|
||||
puzzleError ??
|
||||
sessionController.creationTypeError
|
||||
}
|
||||
|
||||
@@ -11,8 +11,12 @@ test('platform creation types are derived from new work entry config', () => {
|
||||
const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
|
||||
(item) => item.id === 'puzzle',
|
||||
);
|
||||
const match3dConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
|
||||
(item) => item.id === 'match3d',
|
||||
);
|
||||
|
||||
expect(puzzleConfig).toBeTruthy();
|
||||
expect(match3dConfig).toBeTruthy();
|
||||
expect(PLATFORM_CREATION_TYPES).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'puzzle',
|
||||
@@ -23,6 +27,16 @@ test('platform creation types are derived from new work entry config', () => {
|
||||
hidden: false,
|
||||
}),
|
||||
);
|
||||
expect(PLATFORM_CREATION_TYPES).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '经典消除玩法',
|
||||
badge: match3dConfig?.badge,
|
||||
locked: false,
|
||||
hidden: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('new work entry config controls visibility and open order', () => {
|
||||
@@ -30,13 +44,14 @@ test('new work entry config controls visibility and open order', () => {
|
||||
|
||||
expect(isPlatformCreationTypeVisible('rpg')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible('match3d')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible('match3d')).toBe(true);
|
||||
expect(visibleIds).not.toContain('rpg');
|
||||
expect(visibleIds).not.toContain('big-fish');
|
||||
expect(visibleIds).not.toContain('match3d');
|
||||
expect(visibleIds).toContain('match3d');
|
||||
expect(visibleIds[0]).toBe('puzzle');
|
||||
expect(visibleIds).toEqual([
|
||||
'puzzle',
|
||||
'match3d',
|
||||
'square-hole',
|
||||
'airp',
|
||||
'visual-novel',
|
||||
|
||||
@@ -31,6 +31,7 @@ export type SelectionStage =
|
||||
| 'square-hole-runtime'
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'puzzle-generating'
|
||||
| 'puzzle-onboarding'
|
||||
| 'puzzle-result'
|
||||
| 'puzzle-gallery-detail'
|
||||
| 'puzzle-runtime'
|
||||
|
||||
@@ -75,6 +75,10 @@ type PlatformCreationAgentFlowControllerOptions<
|
||||
enterCreateTab: () => void;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
onSessionOpened?: () => void;
|
||||
onOpenError?: (params: {
|
||||
error: unknown;
|
||||
errorMessage: string;
|
||||
}) => void;
|
||||
onActionComplete?: (params: {
|
||||
payload: TActionPayload;
|
||||
response: TActionResponse;
|
||||
@@ -173,9 +177,15 @@ export function usePlatformCreationAgentFlowController<
|
||||
options.setSelectionStage(options.workspaceStage);
|
||||
return nextSession;
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.open),
|
||||
const errorMessage = options.resolveErrorMessage(
|
||||
caughtError,
|
||||
options.errorMessages.open,
|
||||
);
|
||||
setError(errorMessage);
|
||||
options.onOpenError?.({
|
||||
error: caughtError,
|
||||
errorMessage,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
|
||||
@@ -40,6 +40,7 @@ type PuzzleRuntimeShellProps = {
|
||||
run: PuzzleRunSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
hideBackButton?: boolean;
|
||||
onBack: () => void;
|
||||
onRemodelWork?: (profileId: string) => void | Promise<void>;
|
||||
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
|
||||
@@ -306,6 +307,7 @@ export function PuzzleRuntimeShell({
|
||||
run,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
hideBackButton = false,
|
||||
onBack,
|
||||
onRemodelWork,
|
||||
onSwapPieces,
|
||||
@@ -1095,7 +1097,10 @@ export function PuzzleRuntimeShell({
|
||||
type="button"
|
||||
onClick={handleBackRequest}
|
||||
aria-label="返回上一页"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
||||
disabled={hideBackButton}
|
||||
className={`h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur ${
|
||||
hideBackButton ? 'invisible pointer-events-none' : 'inline-flex'
|
||||
}`}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -1732,7 +1737,9 @@ export function PuzzleRuntimeShell({
|
||||
setIsSettingsPanelOpen(false);
|
||||
onBack();
|
||||
}}
|
||||
className="rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100"
|
||||
className={`rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100 ${
|
||||
hideBackButton ? 'hidden' : ''
|
||||
}`}
|
||||
>
|
||||
返回上一页
|
||||
</button>
|
||||
|
||||
@@ -1143,6 +1143,10 @@ beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
'1',
|
||||
);
|
||||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
@@ -1869,22 +1873,25 @@ beforeEach(() => {
|
||||
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
|
||||
});
|
||||
|
||||
test('create hub hides RPG and Match3D while keeping AIRP and visual novel locked', async () => {
|
||||
test('create hub hides RPG while keeping Match3D open and future templates locked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
const match3dButton = screen.getByRole('button', {
|
||||
name: /抓大鹅.*经典消除玩法/u,
|
||||
});
|
||||
const airpButton = screen.getByRole('button', { name: /AIRP/u });
|
||||
const visualNovelButton = screen.getByRole('button', {
|
||||
name: /视觉小说/u,
|
||||
});
|
||||
|
||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /抓大鹅/u })).toBeNull();
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2841,7 +2848,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
||||
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
|
||||
test('visible match3d creation card opens workspace even when public galleries fail', async () => {
|
||||
const user = userEvent.setup();
|
||||
const match3dSession = buildMockMatch3DAgentSession();
|
||||
|
||||
@@ -2860,10 +2867,14 @@ test('hidden match3d creation card stays closed even when public galleries fail'
|
||||
await openCreationHub(user);
|
||||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /抓大鹅.*经典消除玩法/u }),
|
||||
).toBeNull();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /抓大鹅.*经典消除玩法/u }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(await screen.findByText('抓大鹅工作区:match3d-agent-session-1')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('puzzle draft result back button returns to creation hub', async () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
|
||||
title: '抓大鹅',
|
||||
subtitle: '经典消除玩法',
|
||||
badge: '可创建',
|
||||
visible: false,
|
||||
visible: true,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
|
||||
5
src/services/puzzle-onboarding/index.ts
Normal file
5
src/services/puzzle-onboarding/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
generatePuzzleOnboardingWork,
|
||||
puzzleOnboardingClient,
|
||||
savePuzzleOnboardingWork,
|
||||
} from './puzzleOnboardingClient';
|
||||
62
src/services/puzzle-onboarding/puzzleOnboardingClient.ts
Normal file
62
src/services/puzzle-onboarding/puzzleOnboardingClient.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
PuzzleOnboardingGenerateRequest,
|
||||
PuzzleOnboardingGenerateResponse,
|
||||
PuzzleOnboardingSaveRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleOnboarding';
|
||||
import type { PuzzleWorkMutationResponse } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const PUZZLE_ONBOARDING_API_BASE = '/api/runtime/puzzle/onboarding';
|
||||
const PUZZLE_ONBOARDING_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 未登录首次访问生成的临时 1 关拼图,不写入用户作品库。
|
||||
*/
|
||||
export async function generatePuzzleOnboardingWork(
|
||||
payload: PuzzleOnboardingGenerateRequest,
|
||||
) {
|
||||
return requestJson<PuzzleOnboardingGenerateResponse>(
|
||||
`${PUZZLE_ONBOARDING_API_BASE}/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成新手引导拼图失败',
|
||||
{
|
||||
retry: PUZZLE_ONBOARDING_WRITE_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录后把临时拼图保存成当前用户的草稿作品。
|
||||
*/
|
||||
export async function savePuzzleOnboardingWork(
|
||||
payload: PuzzleOnboardingSaveRequest,
|
||||
) {
|
||||
return requestJson<PuzzleWorkMutationResponse>(
|
||||
`${PUZZLE_ONBOARDING_API_BASE}/save`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'保存新手引导拼图失败',
|
||||
{
|
||||
retry: PUZZLE_ONBOARDING_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleOnboardingClient = {
|
||||
generate: generatePuzzleOnboardingWork,
|
||||
save: savePuzzleOnboardingWork,
|
||||
};
|
||||
Reference in New Issue
Block a user