feat: complete bark battle playable demo

This commit is contained in:
2026-05-12 14:42:58 +08:00
parent 22810245f5
commit 33c9079d3b
16 changed files with 639 additions and 196 deletions

View File

@@ -7,8 +7,8 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldProfile } from '../../types';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import type { CustomWorldProfile } from '../../types';
import type {
PlatformCreationTypeCard,
PlatformCreationTypeId,
@@ -185,6 +185,20 @@ export function CustomWorldCreationHub({
canDeleteSquareHole: Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
onDeleteRpg: onDeletePublished ?? undefined,
onOpenBigFishDetail,
onDeleteBigFish: onDeleteBigFish ?? undefined,
onOpenMatch3DDetail,
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
onOpenSquareHoleDetail,
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
}),
[
bigFishItems,
@@ -196,6 +210,14 @@ export function CustomWorldCreationHub({
onDeletePublished,
onDeletePuzzle,
onDeleteVisualNovel,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
onOpenMatch3DDetail,
onOpenPuzzleDetail,
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
onEnterPublished,
puzzleItems,
rpgLibraryEntries,
squareHoleItems,
@@ -222,89 +244,16 @@ export function CustomWorldCreationHub({
[activeFilter, shelfItems],
);
function handleOpenShelfItem(item: CreationWorkShelfItem) {
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
return null;
}
switch (item.source.kind) {
case 'puzzle': {
const sourceItem = item.source.item;
return () => {
onDeletePuzzle?.(sourceItem);
};
}
case 'visual-novel': {
const sourceItem = item.source.item;
return () => {
onDeleteVisualNovel?.(sourceItem);
};
}
case 'big-fish': {
const sourceItem = item.source.item;
return () => {
onDeleteBigFish?.(sourceItem);
};
}
case 'match3d': {
const sourceItem = item.source.item;
return () => {
onDeleteMatch3D?.(sourceItem);
};
}
case 'square-hole': {
const sourceItem = item.source.item;
return () => {
onDeleteSquareHole?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {
onDeletePublished?.(sourceItem);
};
}
}
return item.actions.delete ?? null;
}
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
if (item.source.kind !== 'puzzle' || !onClaimPuzzlePointIncentive) {
return null;
}
const sourceItem = item.source.item;
return () => {
onClaimPuzzlePointIncentive(sourceItem);
};
return item.actions.claimPointIncentive ?? null;
}
const showStartCard = mode !== 'works-only';
@@ -373,7 +322,7 @@ export function CustomWorldCreationHub({
previousMetricValues={
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => handleOpenShelfItem(item)}
onOpen={item.actions.open}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onClaimPointIncentive={buildPointIncentiveAction(item)}

View File

@@ -1,4 +1,4 @@
import { expect, test } from 'vitest';
import { expect, test, vi } from 'vitest';
import { buildCreationWorkShelfItems } from './creationWorkShelf';
@@ -45,3 +45,39 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
expect(items[1]?.status).toBe('draft');
expect(items[1]?.publicWorkCode).toBeNull();
});
test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => {
const onOpenPuzzleDetail = vi.fn();
const onDeletePuzzle = vi.fn();
const puzzleWork = {
workId: 'puzzle:work-action',
profileId: 'puzzle-profile-action',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '动作拼图',
summary: '验证作品架动作 Adapter。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft' as const,
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
};
const [item] = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [puzzleWork],
onOpenPuzzleDetail,
onDeletePuzzle,
});
item?.actions.open();
item?.actions.delete?.();
expect(onOpenPuzzleDetail).toHaveBeenCalledWith(puzzleWork);
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
});

View File

@@ -2,9 +2,9 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBigFishPublicWorkCode,
@@ -79,6 +79,12 @@ export type CreationWorkShelfSource =
item: VisualNovelWorkSummary;
};
export type CreationWorkShelfActions = {
open: () => void;
delete?: () => void;
claimPointIncentive?: () => void;
};
export type CreationWorkShelfItem = {
id: string;
kind: CreationWorkShelfKind;
@@ -97,6 +103,7 @@ export type CreationWorkShelfItem = {
badges: CreationWorkShelfBadge[];
metrics: CreationWorkShelfMetric[];
pointIncentive?: CreationWorkShelfPointIncentive;
actions: CreationWorkShelfActions;
source: CreationWorkShelfSource;
};
@@ -114,6 +121,20 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole?: boolean;
canDeletePuzzle?: boolean;
canDeleteVisualNovel?: boolean;
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
onEnterRpgPublished?: (profileId: string) => void;
onDeleteRpg?: (item: CustomWorldWorkSummary) => void;
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onDeleteBigFish?: (item: BigFishWorkSummary) => void;
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
onDeleteMatch3D?: (item: Match3DWorkSummary) => void;
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
}) {
const {
rpgItems,
@@ -129,26 +150,60 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole = false,
canDeletePuzzle = false,
canDeleteVisualNovel = false,
onOpenRpgDraft,
onEnterRpgPublished,
onDeleteRpg,
onOpenBigFishDetail,
onDeleteBigFish,
onOpenMatch3DDetail,
onDeleteMatch3D,
onOpenSquareHoleDetail,
onDeleteSquareHole,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
} = params;
return [
...rpgItems.map((item) =>
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries),
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
onOpenDraft: onOpenRpgDraft,
onEnterPublished: onEnterRpgPublished,
onDelete: onDeleteRpg,
}),
),
...bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
onOpen: onOpenBigFishDetail,
onDelete: onDeleteBigFish,
}),
),
...match3dItems.map((item) =>
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D),
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
onOpen: onOpenMatch3DDetail,
onDelete: onDeleteMatch3D,
}),
),
...squareHoleItems.map((item) =>
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole),
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
onOpen: onOpenSquareHoleDetail,
onDelete: onDeleteSquareHole,
}),
),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
onOpen: onOpenPuzzleDetail,
onDelete: onDeletePuzzle,
onClaimPointIncentive: onClaimPuzzlePointIncentive,
}),
),
...visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel),
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail,
onDelete: onDeleteVisualNovel,
}),
),
].sort(
(left, right) =>
@@ -156,10 +211,26 @@ export function buildCreationWorkShelfItems(params: {
);
}
type RpgWorkShelfAdapter = {
onOpenDraft?: (item: CustomWorldWorkSummary) => void;
onEnterPublished?: (profileId: string) => void;
onDelete?: (item: CustomWorldWorkSummary) => void;
};
type WorkShelfAdapter<TItem> = {
onOpen?: (item: TItem) => void;
onDelete?: (item: TItem) => void;
};
type PuzzleWorkShelfAdapter = WorkShelfAdapter<PuzzleWorkSummary> & {
onClaimPointIncentive?: (item: PuzzleWorkSummary) => void;
};
function mapRpgWorkToShelfItem(
item: CustomWorldWorkSummary,
canDelete: boolean,
libraryEntries: CustomWorldLibraryEntry<CustomWorldProfile>[],
adapter: RpgWorkShelfAdapter,
): CreationWorkShelfItem {
const isDraft = item.status === 'draft';
const libraryEntry = item.profileId
@@ -200,6 +271,7 @@ function mapRpgWorkToShelfItem(
: '查看详情',
canDelete,
canShare: item.status === 'published' && Boolean(publicWorkCode),
actions: buildRpgWorkShelfActions(item, adapter),
badges,
metrics: isDraft ? [] : metrics,
source: { kind: 'rpg', item },
@@ -209,6 +281,7 @@ function mapRpgWorkToShelfItem(
function mapBigFishWorkToShelfItem(
item: BigFishWorkSummary,
canDelete: boolean,
adapter: WorkShelfAdapter<BigFishWorkSummary>,
): CreationWorkShelfItem {
const isPublished = item.status === 'published';
const publicWorkCode = isPublished
@@ -233,6 +306,7 @@ function mapBigFishWorkToShelfItem(
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
canDelete,
canShare: isPublished && Boolean(publicWorkCode),
actions: buildWorkShelfActions(item, adapter),
badges: [
buildStatusBadge(item.status),
{ id: 'type', label: '大鱼', tone: 'neutral' },
@@ -251,6 +325,7 @@ function mapBigFishWorkToShelfItem(
function mapMatch3DWorkToShelfItem(
item: Match3DWorkSummary,
canDelete: boolean,
adapter: WorkShelfAdapter<Match3DWorkSummary>,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
@@ -274,6 +349,7 @@ function mapMatch3DWorkToShelfItem(
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
actions: buildWorkShelfActions(item, adapter),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '抓鹅', tone: 'neutral' },
@@ -293,6 +369,7 @@ function mapMatch3DWorkToShelfItem(
function mapPuzzleWorkToShelfItem(
item: PuzzleWorkSummary,
canDelete: boolean,
adapter: PuzzleWorkShelfAdapter,
): CreationWorkShelfItem {
const status = item.publicationStatus;
const publicWorkCode =
@@ -320,6 +397,7 @@ function mapPuzzleWorkToShelfItem(
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
actions: buildPuzzleWorkShelfActions(item, adapter),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '拼图', tone: 'neutral' },
@@ -354,6 +432,7 @@ function mapPuzzleWorkToShelfItem(
function mapVisualNovelWorkToShelfItem(
item: VisualNovelWorkSummary,
canDelete: boolean,
adapter: WorkShelfAdapter<VisualNovelWorkSummary>,
): CreationWorkShelfItem {
const status =
item.publishStatus === 'published' ? 'published' : 'draft';
@@ -394,6 +473,7 @@ function mapVisualNovelWorkToShelfItem(
likeCount: 0,
})
: [],
actions: buildWorkShelfActions(item, adapter),
source: { kind: 'visual-novel', item },
};
}
@@ -401,6 +481,7 @@ function mapVisualNovelWorkToShelfItem(
function mapSquareHoleWorkToShelfItem(
item: SquareHoleWorkSummary,
canDelete: boolean,
adapter: WorkShelfAdapter<SquareHoleWorkSummary>,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
@@ -438,10 +519,65 @@ function mapSquareHoleWorkToShelfItem(
likeCount: 0,
})
: [],
actions: buildWorkShelfActions(item, adapter),
source: { kind: 'square-hole', item },
};
}
function buildWorkShelfActions<TItem>(
item: TItem,
adapter: WorkShelfAdapter<TItem>,
): CreationWorkShelfActions {
return {
open: () => {
adapter.onOpen?.(item);
},
delete: adapter.onDelete
? () => {
adapter.onDelete?.(item);
}
: undefined,
};
}
function buildPuzzleWorkShelfActions(
item: PuzzleWorkSummary,
adapter: PuzzleWorkShelfAdapter,
): CreationWorkShelfActions {
return {
...buildWorkShelfActions(item, adapter),
claimPointIncentive: adapter.onClaimPointIncentive
? () => {
adapter.onClaimPointIncentive?.(item);
}
: undefined,
};
}
function buildRpgWorkShelfActions(
item: CustomWorldWorkSummary,
adapter: RpgWorkShelfAdapter,
): CreationWorkShelfActions {
return {
open: () => {
if (item.status === 'draft') {
adapter.onOpenDraft?.(item);
return;
}
if (item.profileId) {
adapter.onEnterPublished?.(item.profileId);
}
},
delete: adapter.onDelete
? () => {
adapter.onDelete?.(item);
}
: undefined,
};
}
function buildPublishedMetrics(params: {
playCount?: number | null;
remixCount?: number | null;