1
This commit is contained in:
@@ -7,8 +7,10 @@ import type {
|
||||
import type { PuzzleWorkSummary } from '../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { PuzzleRuntimeShell } from './components/puzzle-runtime/PuzzleRuntimeShell';
|
||||
import {
|
||||
applyLocalPuzzleFreezeTime,
|
||||
advanceLocalPuzzleLevel,
|
||||
dragLocalPuzzlePiece,
|
||||
setLocalPuzzlePaused,
|
||||
startLocalPuzzleRun,
|
||||
swapLocalPuzzlePieces,
|
||||
} from './services/puzzle-runtime/puzzleLocalRuntime';
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
const PLACEHOLDER_PUZZLE_IMAGE =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#fef3c7" />
|
||||
@@ -28,13 +30,13 @@ const PLACEHOLDER_PUZZLE_IMAGE =
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="960" height="960" fill="url(#sky)" />
|
||||
<circle cx="312" cy="282" r="210" fill="url(#glow)" />
|
||||
<path d="M0 680 C170 610 278 724 424 650 C574 574 704 612 960 512 V960 H0 Z" fill="#1e1b4b" opacity="0.9" />
|
||||
<path d="M0 766 C178 710 320 794 492 732 C642 678 780 708 960 652 V960 H0 Z" fill="#111827" opacity="0.78" />
|
||||
<path d="M160 356 C238 298 326 304 388 376 C456 456 550 436 626 374 C710 306 824 330 882 410" fill="none" stroke="#fff7ed" stroke-width="18" stroke-linecap="round" opacity="0.72" />
|
||||
<path d="M204 502 h552" stroke="#ffffff" stroke-width="16" stroke-linecap="round" opacity="0.3" />
|
||||
<path d="M268 566 h424" stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.22" />
|
||||
<rect width="720" height="1280" fill="url(#sky)" />
|
||||
<circle cx="310" cy="318" r="210" fill="url(#glow)" />
|
||||
<path d="M0 860 C118 808 226 894 348 828 C474 760 594 824 720 774 V1280 H0 Z" fill="#1e1b4b" opacity="0.9" />
|
||||
<path d="M0 1010 C142 954 282 1040 428 974 C552 918 638 938 720 904 V1280 H0 Z" fill="#111827" opacity="0.78" />
|
||||
<path d="M86 310 C184 242 302 252 376 334 C460 426 574 386 646 310" fill="none" stroke="#fff7ed" stroke-width="16" stroke-linecap="round" opacity="0.72" />
|
||||
<path d="M128 610 h464" stroke="#ffffff" stroke-width="14" stroke-linecap="round" opacity="0.3" />
|
||||
<path d="M174 704 h372" stroke="#ffffff" stroke-width="10" stroke-linecap="round" opacity="0.22" />
|
||||
</svg>`);
|
||||
|
||||
function buildPlaceholderPuzzleWork(): PuzzleWorkSummary {
|
||||
@@ -78,6 +80,20 @@ export default function PuzzlePlaygroundApp() {
|
||||
setRun((currentRun) => advanceLocalPuzzleLevel(currentRun));
|
||||
};
|
||||
|
||||
const handlePauseChange = async (paused: boolean) => {
|
||||
setRun((currentRun) => setLocalPuzzlePaused(currentRun, paused));
|
||||
};
|
||||
|
||||
const handleUseProp = async (
|
||||
propKind: 'hint' | 'reference' | 'freezeTime',
|
||||
) => {
|
||||
setRun((currentRun) =>
|
||||
propKind === 'freezeTime'
|
||||
? applyLocalPuzzleFreezeTime(currentRun)
|
||||
: setLocalPuzzlePaused(currentRun, propKind === 'reference'),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PuzzleRuntimeShell
|
||||
run={run}
|
||||
@@ -87,6 +103,8 @@ export default function PuzzlePlaygroundApp() {
|
||||
onSwapPieces={handleSwapPieces}
|
||||
onDragPiece={handleDragPiece}
|
||||
onAdvanceNextLevel={handleAdvanceNextLevel}
|
||||
onPauseChange={handlePauseChange}
|
||||
onUseProp={handleUseProp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -88,6 +87,7 @@ import {
|
||||
InventoryItemGrid,
|
||||
} from './InventoryItemViews';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
import { SkillEffectPreview } from './SkillEffectPreview';
|
||||
|
||||
@@ -957,8 +957,8 @@ export function AdventureEntityModal({
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="relative flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-[10px] uppercase tracking-[0.24em] text-zinc-500">
|
||||
详情
|
||||
</div>
|
||||
@@ -975,13 +975,7 @@ export function AdventureEntityModal({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={onClose} label="关闭冒险详情" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||||
@@ -1319,13 +1313,10 @@ export function AdventureEntityModal({
|
||||
{detailCharacter.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setSelectedContributionLabel(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭标签效果"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 sm:p-5">
|
||||
@@ -1431,13 +1422,10 @@ export function AdventureEntityModal({
|
||||
{selectedSkillOwnerName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setSelectedSkillId(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭技能详情"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 overflow-y-auto p-4 sm:p-5">
|
||||
|
||||
@@ -2,8 +2,8 @@ import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import type { CharacterChatModalState } from '../hooks/rpg-runtime-story';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
|
||||
interface CharacterChatModalProps {
|
||||
modal: CharacterChatModalState | null;
|
||||
@@ -56,13 +56,11 @@ export function CharacterChatModal({
|
||||
{modal.target.character.title} / {modal.target.roleLabel}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭角色聊天"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] sm:overflow-hidden sm:p-5">
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
type WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getNineSliceStyle,
|
||||
type NineSliceTexture,
|
||||
UI_CHROME,
|
||||
@@ -38,7 +37,7 @@ import {
|
||||
CharacterSkillsList,
|
||||
} from './CharacterInfoShared';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
|
||||
interface CharacterDetailModalProps {
|
||||
character: Character | null;
|
||||
@@ -194,14 +193,7 @@ export function CharacterDetailModal({
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
aria-label="关闭角色详情"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={onClose} label="关闭角色详情" />
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePlayerProgressionState } from '../data/playerProgression';
|
||||
import {
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
getEquipmentSlotLabel,
|
||||
} from '../data/equipmentEffects';
|
||||
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
|
||||
import { normalizePlayerProgressionState } from '../data/playerProgression';
|
||||
import type { CharacterChatTarget } from '../hooks/rpg-runtime-story';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import {
|
||||
@@ -38,12 +38,10 @@ import {
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
QuestLogEntry,
|
||||
TimedBuildBuff,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getEquipmentSlotIcon,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
@@ -66,6 +64,7 @@ import {
|
||||
} from './CharacterInfoShared';
|
||||
import type { GameCanvasEntitySelection } from './GameCanvas';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
@@ -82,7 +81,6 @@ interface CharacterPanelProps {
|
||||
activeBuildBuffs?: TimedBuildBuff[];
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
npcStates?: GameState['npcStates'];
|
||||
quests: QuestLogEntry[];
|
||||
onOpenCamp?: () => void;
|
||||
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
|
||||
chatSummaries?: Record<string, string>;
|
||||
@@ -155,7 +153,6 @@ export function CharacterPanel({
|
||||
activeBuildBuffs = [],
|
||||
companionRenderStates,
|
||||
npcStates = {},
|
||||
quests,
|
||||
onInspectMember,
|
||||
companionArcStates = [],
|
||||
companionResolutions = [],
|
||||
@@ -215,11 +212,6 @@ export function CharacterPanel({
|
||||
[partyMembers, selectedMemberId],
|
||||
);
|
||||
|
||||
const activeQuests = useMemo(
|
||||
() => quests.filter((quest) => quest.status !== 'turned_in'),
|
||||
[quests],
|
||||
);
|
||||
|
||||
const buildBreakdownByMemberId = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
@@ -374,29 +366,6 @@ export function CharacterPanel({
|
||||
paddingY: 12,
|
||||
})}
|
||||
>
|
||||
{activeQuests.length > 0 && (
|
||||
<div className="mb-3 rounded-xl border border-sky-400/15 bg-sky-500/8 px-3 py-3">
|
||||
<div className="mb-2 text-xs font-bold text-sky-100">
|
||||
褰撳墠濮旀墭
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{activeQuests.map((quest) => (
|
||||
<div
|
||||
key={quest.id}
|
||||
className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="font-semibold text-white">
|
||||
{quest.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{quest.summary}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 text-xs font-bold text-white">队伍成员</div>
|
||||
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
|
||||
{partyMembers.map((member) => (
|
||||
@@ -497,13 +466,10 @@ export function CharacterPanel({
|
||||
{selectedMember.character.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setSelectedContributionLabel(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭标签效果"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4 sm:p-5">
|
||||
@@ -619,13 +585,10 @@ export function CharacterPanel({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setSelectedMemberId(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭角色详情"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { getCharacterById } from '../data/characterPresets';
|
||||
import { MAX_COMPANIONS } from '../data/npcInteractions';
|
||||
import { Character, CompanionState } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
interface CompanionCampModalProps {
|
||||
@@ -145,13 +145,7 @@ export function CompanionCampModal({
|
||||
{playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={onClose} label="关闭营地编组" />
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[1.05fr_0.95fr] lg:overflow-hidden">
|
||||
|
||||
@@ -1099,7 +1099,7 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="flex flex-wrap items-center gap-2 px-1">
|
||||
{lockedCharacterNames.has(role.name.trim()) ? (
|
||||
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]">
|
||||
创作者锁定
|
||||
陶泥主锁定
|
||||
</span>
|
||||
) : null}
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
|
||||
@@ -5,11 +5,11 @@ import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import { buildInventoryItemDescription } from '../data/itemPresentation';
|
||||
import type { Character, InventoryItem, WorldType } from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getInventoryItemVisualSrc,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
|
||||
@@ -185,13 +185,7 @@ export function InventoryItemDetailModal({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 z-10 rounded-full border border-white/10 bg-black/25 p-1.5 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-5"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={onClose} label="关闭物品详情" className="top-4 sm:top-5" />
|
||||
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
|
||||
|
||||
@@ -38,7 +38,6 @@ interface InventoryPanelProps {
|
||||
onCraftRecipe: (recipeId: string) => Promise<boolean>;
|
||||
onDismantleItem: (itemId: string) => Promise<boolean>;
|
||||
onReforgeItem: (itemId: string) => Promise<boolean>;
|
||||
continueGameDigest?: string | null;
|
||||
narrativeCodex?: NarrativeCodexSection[];
|
||||
narrativeQaReport?: NarrativeQaReport | null;
|
||||
}
|
||||
@@ -58,7 +57,6 @@ export function InventoryPanel({
|
||||
onCraftRecipe,
|
||||
onDismantleItem: _onDismantleItem,
|
||||
onReforgeItem: _onReforgeItem,
|
||||
continueGameDigest = null,
|
||||
narrativeCodex = [],
|
||||
narrativeQaReport = null,
|
||||
}: InventoryPanelProps) {
|
||||
@@ -92,14 +90,6 @@ export function InventoryPanel({
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
||||
{continueGameDigest && (
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs leading-relaxed text-zinc-300">
|
||||
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
|
||||
旅程回顾
|
||||
</div>
|
||||
{continueGameDigest}
|
||||
</div>
|
||||
)}
|
||||
<InventoryItemGrid
|
||||
items={inventoryItems}
|
||||
selectedItemId={selectedItem?.id ?? null}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getConnectedScenePresets } from '../data/scenePresets';
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
import { ScenePresetInfo, WorldType } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
function buildSceneBackdropStyle(imageSrc?: string | null): CSSProperties {
|
||||
@@ -252,13 +253,7 @@ export function MapModal({
|
||||
<span>地图</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton onClick={onClose} label="关闭地图" />
|
||||
</div>
|
||||
|
||||
<div className="relative grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 md:grid-cols-[minmax(15rem,18rem)_minmax(0,1fr)] md:overflow-hidden">
|
||||
@@ -385,13 +380,10 @@ export function MapModal({
|
||||
<div className="text-[10px] tracking-[0.22em] text-amber-200/80">场景切换</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.scene.name}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setPendingScene(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭场景切换"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-5">
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
RuntimeNpcGiftItemView,
|
||||
RuntimeNpcTradeItemView,
|
||||
} from '../types';
|
||||
import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
interface NpcModalsProps {
|
||||
@@ -232,13 +233,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} / 你当前{currencyName}:{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={npcUi.closeTradeModal}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭交易"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
|
||||
@@ -385,13 +384,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
{tradeDetail.source === 'buy' ? '对方物品' : '背包物品'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PixelCloseButton
|
||||
onClick={() => setTradeDetail(null)}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭物品详情"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
@@ -474,9 +471,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
<div className="text-sm font-semibold text-white">赠送礼物</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{npcUi.giftModal.encounter.npcName}</div>
|
||||
</div>
|
||||
<button type="button" onClick={npcUi.closeGiftModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton
|
||||
onClick={npcUi.closeGiftModal}
|
||||
label="关闭赠礼"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||||
@@ -550,9 +549,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
<div className="text-sm font-semibold text-white">调整同行位置</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">队伍已满,请先选择一名当前同行角色离队,再邀请对方加入。</div>
|
||||
</div>
|
||||
<button type="button" onClick={npcUi.closeRecruitModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
<PixelCloseButton
|
||||
onClick={npcUi.closeRecruitModal}
|
||||
label="关闭招募"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PixelCloseButton } from './PixelCloseButton';
|
||||
|
||||
test('pixel close button closes without bubbling to the overlay', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const onOverlayClick = vi.fn();
|
||||
|
||||
render(
|
||||
<div onClick={onOverlayClick}>
|
||||
<PixelCloseButton onClick={onClose} label="关闭测试面板" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭测试面板' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onOverlayClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('inline pixel close button keeps the same click boundary', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const onHeaderClick = vi.fn();
|
||||
|
||||
render(
|
||||
<div onClick={onHeaderClick}>
|
||||
<PixelCloseButton
|
||||
onClick={onClose}
|
||||
label="关闭标题栏面板"
|
||||
placement="inline"
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭标题栏面板' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onHeaderClick).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { CHROME_ICONS } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
type PixelCloseButtonProps = {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
placement?: 'absolute' | 'inline';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* RPG 像素风弹窗右上关闭按钮。
|
||||
* 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为。
|
||||
*/
|
||||
export function PixelCloseButton({
|
||||
onClick,
|
||||
label = '关闭面板',
|
||||
placement = 'absolute',
|
||||
className = '',
|
||||
}: PixelCloseButtonProps) {
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
|
||||
const placementClassName =
|
||||
placement === 'absolute'
|
||||
? 'absolute right-4 top-3 sm:right-5 sm:top-4'
|
||||
: 'relative shrink-0';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={handleClick}
|
||||
className={`${placementClassName} z-20 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 ${className}`.trim()}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const baseUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '138****8000',
|
||||
avatarUrl: null,
|
||||
publicUserCode: 'user-tester',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
|
||||
@@ -78,6 +78,7 @@ const mockUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
publicUserCode: 'user-tester',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
|
||||
@@ -410,6 +410,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
requireAuth,
|
||||
openSettingsModal,
|
||||
openAccountModal,
|
||||
setCurrentUser: setUser,
|
||||
logout: logoutCurrentSession,
|
||||
musicVolume: settings.musicVolume,
|
||||
setMusicVolume: settings.setMusicVolume,
|
||||
|
||||
@@ -17,6 +17,7 @@ type AuthUiContextValue = {
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
setCurrentUser: (user: AuthUser) => void;
|
||||
logout: () => Promise<void>;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
|
||||
@@ -62,7 +62,7 @@ export function BindPhoneScreen({
|
||||
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__title">陶泥</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
|
||||
|
||||
@@ -11,12 +11,62 @@ const noopCreateType = () => {};
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
afterEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
});
|
||||
|
||||
test('creation hub shows published metric growth from cached page snapshot', async () => {
|
||||
window.sessionStorage.setItem(
|
||||
'genarrative.creationHub.publishedMetrics.v1',
|
||||
JSON.stringify({
|
||||
'puzzle:puzzle:work-growth': {
|
||||
'play-count': 7,
|
||||
'remix-count': 1,
|
||||
'like-count': 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:work-growth',
|
||||
profileId: 'puzzle-profile-growth',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '涨潮拼图',
|
||||
summary: '公开指标会从缓存快照涨到最新值。',
|
||||
themeTags: ['涨潮'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
|
||||
playCount: 10,
|
||||
remixCount: 4,
|
||||
likeCount: 2,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('游玩 10次')).toBeTruthy();
|
||||
expect(screen.getByLabelText('改造 4次')).toBeTruthy();
|
||||
expect(await screen.findAllByText('↑')).toHaveLength(2);
|
||||
});
|
||||
|
||||
const baseDraftItem: CustomWorldWorkSummary = {
|
||||
workId: 'draft:session-1',
|
||||
sourceType: 'agent_session',
|
||||
@@ -52,10 +102,12 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
|
||||
expect(screen.getByText('潮雾列岛')).toBeTruthy();
|
||||
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
|
||||
expect(screen.getByText('角色 3')).toBeTruthy();
|
||||
expect(screen.getByText('地点 4')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /角色扮演 RPG/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /拼图玩法/u })).toBeTruthy();
|
||||
expect(screen.queryByText('角色 3')).toBeNull();
|
||||
expect(screen.queryByText('地点 4')).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /角色扮演.*剧情演绎/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /拼图.*创意礼物/u })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
|
||||
rerender(
|
||||
@@ -83,8 +135,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
expect(
|
||||
screen.getByText('世界总卡和角色网已经继续长出了新的支线。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('角色 5')).toBeTruthy();
|
||||
expect(screen.getByText('地点 6')).toBeTruthy();
|
||||
expect(screen.queryByText('角色 5')).toBeNull();
|
||||
expect(screen.queryByText('地点 6')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
|
||||
@@ -105,7 +157,8 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
|
||||
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
|
||||
playCount: 8,
|
||||
likeCount: 0,
|
||||
remixCount: 2,
|
||||
likeCount: 3,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
@@ -121,8 +174,14 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
|
||||
|
||||
expect(screen.getByText('潮雾列岛')).toBeTruthy();
|
||||
expect(screen.getByText('沉钟拼图')).toBeTruthy();
|
||||
expect(screen.getByText('PZ-PROFILE1')).toBeTruthy();
|
||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.getByLabelText('游玩 8次')).toBeTruthy();
|
||||
expect(screen.getByLabelText('改造 2次')).toBeTruthy();
|
||||
expect(screen.getByLabelText('点赞 3赞')).toBeTruthy();
|
||||
expect(screen.queryByText('Remix')).toBeNull();
|
||||
expect(screen.queryByText('PZ-PROFILE1')).toBeNull();
|
||||
expect(screen.queryByText('潮雾')).toBeNull();
|
||||
expect(screen.queryByText('沉钟')).toBeNull();
|
||||
expect(screen.queryByText('我的拼图作品')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -159,7 +218,9 @@ test('creation hub shows RPG public work code from published library entry', ()
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
likeCount: 0,
|
||||
playCount: 12,
|
||||
remixCount: 4,
|
||||
likeCount: 5,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
@@ -172,7 +233,11 @@ test('creation hub shows RPG public work code from published library entry', ()
|
||||
);
|
||||
|
||||
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
|
||||
expect(screen.getByText('CW-00000001')).toBeTruthy();
|
||||
expect(screen.getByLabelText('游玩 12次')).toBeTruthy();
|
||||
expect(screen.getByLabelText('改造 4次')).toBeTruthy();
|
||||
expect(screen.getByLabelText('点赞 5赞')).toBeTruthy();
|
||||
expect(screen.queryByText('Remix')).toBeNull();
|
||||
expect(screen.queryByText('CW-00000001')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub shows delete action for persisted rpg drafts', () => {
|
||||
@@ -225,7 +290,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
|
||||
expect(openedItems).toEqual([persistedDraft]);
|
||||
});
|
||||
|
||||
test('creation hub work code copy button copies without opening the card', async () => {
|
||||
test('creation hub published share button copies share text without opening the card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
const onOpenPuzzleDetail = vi.fn();
|
||||
@@ -251,6 +316,7 @@ test('creation hub work code copy button copies without opening the card', async
|
||||
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
|
||||
playCount: 8,
|
||||
remixCount: 2,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
},
|
||||
@@ -265,11 +331,19 @@ test('creation hub work code copy button copies without opening the card', async
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '分享' }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1');
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('邀请你来玩《沉钟拼图》'),
|
||||
);
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('作品号:PZ-PROFILE1'),
|
||||
);
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/gallery/puzzle/detail?work=PZ-PROFILE1'),
|
||||
);
|
||||
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('已复制')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '分享内容已复制' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -42,12 +42,59 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
expect(html).toContain('一个被潮雾切开的列岛世界');
|
||||
expect(html).toContain('玩家是失职返乡的守灯人');
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
expect(html).toContain('角色扮演 RPG');
|
||||
expect(html).toContain('拼图玩法');
|
||||
expect(html).toContain('角色扮演');
|
||||
expect(html).toContain('剧情演绎,冒险成长');
|
||||
expect(html).toContain('拼图');
|
||||
expect(html).toContain('创意礼物,生活分享');
|
||||
expect(html).not.toContain('大鱼吃小鱼');
|
||||
});
|
||||
|
||||
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:work-1',
|
||||
profileId: 'puzzle-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '一张被切成拼图的潮雾港口主视觉。',
|
||||
themeTags: ['潮雾', '港口'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T10:05:00.000Z').toISOString(),
|
||||
playCount: 12,
|
||||
remixCount: 3,
|
||||
likeCount: 4,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('潮雾拼图');
|
||||
expect(html).toContain('拼图');
|
||||
expect(html).toContain('aria-label="游玩 12次"');
|
||||
expect(html).toContain('aria-label="改造 3次"');
|
||||
expect(html).toContain('aria-label="点赞 4赞"');
|
||||
expect(html).not.toContain('作品号');
|
||||
expect(html).not.toContain('PZ-PROFILE1');
|
||||
expect(html).not.toContain('潮雾</span>');
|
||||
expect(html).not.toContain('港口</span>');
|
||||
expect(html).not.toContain('我的拼图作品');
|
||||
});
|
||||
|
||||
test('creation hub published work spans full mobile row', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
@@ -79,9 +126,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('潮雾拼图');
|
||||
expect(html).toContain('拼图');
|
||||
expect(html).toContain('作品号');
|
||||
expect(html).toContain('PZ-PROFILE1');
|
||||
expect(html).not.toContain('我的拼图作品');
|
||||
expect(html).toContain('grid-cols-2');
|
||||
expect(html).toContain('col-span-2 sm:col-span-1');
|
||||
expect(html).not.toContain('grid-cols-1 gap-3 md:grid-cols-2');
|
||||
});
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
type CreationWorkShelfItem,
|
||||
type CreationWorkShelfMetricId,
|
||||
} from './creationWorkShelf';
|
||||
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
|
||||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||||
import {
|
||||
type CustomWorldWorkFilter,
|
||||
CustomWorldWorkTabs,
|
||||
} from './CustomWorldWorkTabs';
|
||||
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
type CreationWorkShelfItem,
|
||||
} from './creationWorkShelf';
|
||||
|
||||
// 中文注释:草稿在手机端保持双列,已发布卡片由卡片自身跨两列展示公开指标。
|
||||
const WORK_GRID_CLASS =
|
||||
'grid grid-cols-2 gap-2.5 sm:gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4';
|
||||
const WORK_METRIC_CACHE_KEY = 'genarrative.creationHub.publishedMetrics.v1';
|
||||
|
||||
type WorkMetricSnapshot = Record<
|
||||
string,
|
||||
Partial<Record<CreationWorkShelfMetricId, number>>
|
||||
>;
|
||||
|
||||
type CustomWorldCreationHubProps = {
|
||||
items: CustomWorldWorkSummary[];
|
||||
@@ -29,15 +40,12 @@ type CustomWorldCreationHubProps = {
|
||||
onEnterPublished: (profileId: string) => void;
|
||||
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
deletingWorkId?: string | null;
|
||||
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
|
||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
bigFishItems?: BigFishWorkSummary[];
|
||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||
puzzleItems?: PuzzleWorkSummary[];
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onExperiencePuzzle?: ((profileId: string) => void) | null;
|
||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
};
|
||||
|
||||
@@ -51,6 +59,59 @@ function EmptyState({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildWorkMetricCacheItemKey(item: CreationWorkShelfItem) {
|
||||
return `${item.kind}:${item.id}`;
|
||||
}
|
||||
|
||||
function readWorkMetricSnapshot(): WorkMetricSnapshot {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const rawSnapshot = window.sessionStorage.getItem(WORK_METRIC_CACHE_KEY);
|
||||
if (!rawSnapshot) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawSnapshot) as WorkMetricSnapshot;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot: WorkMetricSnapshot = {};
|
||||
for (const item of items) {
|
||||
if (item.status !== 'published' || item.metrics.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
snapshot[buildWorkMetricCacheItemKey(item)] = Object.fromEntries(
|
||||
item.metrics.map((metric) => [metric.id, metric.value]),
|
||||
);
|
||||
}
|
||||
|
||||
// 中文注释:缓存只作为下一次进入创作页的数字动画起点,真实展示值仍以接口返回为准。
|
||||
if (Object.keys(snapshot).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
WORK_METRIC_CACHE_KEY,
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
} catch {
|
||||
// 中文注释:浏览器禁用 sessionStorage 时降级为无缓存动画,不影响作品列表使用。
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomWorldCreationHub({
|
||||
items,
|
||||
loading,
|
||||
@@ -63,15 +124,12 @@ export function CustomWorldCreationHub({
|
||||
onEnterPublished,
|
||||
onDeletePublished = null,
|
||||
deletingWorkId = null,
|
||||
onExperienceRpg = null,
|
||||
rpgLibraryEntries = [],
|
||||
bigFishItems = [],
|
||||
onOpenBigFishDetail,
|
||||
onExperienceBigFish = null,
|
||||
onDeleteBigFish = null,
|
||||
puzzleItems = [],
|
||||
onOpenPuzzleDetail,
|
||||
onExperiencePuzzle = null,
|
||||
onDeletePuzzle = null,
|
||||
}: CustomWorldCreationHubProps) {
|
||||
const [activeFilter, setActiveFilter] =
|
||||
@@ -97,6 +155,12 @@ export function CustomWorldCreationHub({
|
||||
rpgLibraryEntries,
|
||||
],
|
||||
);
|
||||
const [metricSnapshot] = useState<WorkMetricSnapshot>(() =>
|
||||
readWorkMetricSnapshot(),
|
||||
);
|
||||
useEffect(() => {
|
||||
writeWorkMetricSnapshot(shelfItems);
|
||||
}, [shelfItems]);
|
||||
const draftCount = shelfItems.filter(
|
||||
(entry) => entry.status === 'draft',
|
||||
).length;
|
||||
@@ -131,33 +195,6 @@ export function CustomWorldCreationHub({
|
||||
}
|
||||
}
|
||||
|
||||
function buildExperienceAction(item: CreationWorkShelfItem) {
|
||||
if (!item.canExperience) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onExperiencePuzzle?.(sourceItem.profileId);
|
||||
};
|
||||
}
|
||||
case 'big-fish': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onExperienceBigFish?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'rpg': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onExperienceRpg?.(sourceItem);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildDeleteAction(item: CreationWorkShelfItem) {
|
||||
if (!item.canDelete) {
|
||||
return null;
|
||||
@@ -215,31 +252,33 @@ export function CustomWorldCreationHub({
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<div className={WORK_GRID_CLASS}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={`skeleton-${index}`}
|
||||
className="platform-subpanel min-h-[12rem] rounded-[1.6rem] p-5"
|
||||
className="platform-subpanel min-h-[10.5rem] rounded-[1.2rem] p-3 sm:min-h-[12rem] sm:rounded-[1.6rem] sm:p-5"
|
||||
>
|
||||
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-6 h-8 w-36 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-4 h-4 w-full rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-5 h-6 w-24 rounded-full bg-[var(--platform-track-fill)] sm:mt-6 sm:h-8 sm:w-36" />
|
||||
<div className="mt-3 h-3 w-full rounded-full bg-[var(--platform-track-fill)] sm:mt-4 sm:h-4" />
|
||||
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-8 flex gap-2">
|
||||
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-6 flex flex-col gap-2 sm:mt-8 sm:flex-row">
|
||||
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
|
||||
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<div className={WORK_GRID_CLASS}>
|
||||
{filteredItems.map((item) => (
|
||||
<CustomWorldWorkCard
|
||||
key={`${item.kind}-${item.id}`}
|
||||
item={item}
|
||||
previousMetricValues={
|
||||
metricSnapshot[buildWorkMetricCacheItemKey(item)]
|
||||
}
|
||||
onOpen={() => handleOpenShelfItem(item)}
|
||||
onExperience={buildExperienceAction(item)}
|
||||
onDelete={buildDeleteAction(item)}
|
||||
deleteBusy={deletingWorkId === item.id}
|
||||
/>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function CustomWorldCreationStartCard({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
|
||||
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 scrollbar-hide sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
|
||||
{visibleCreationTypes.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
@@ -49,22 +49,18 @@ export function CustomWorldCreationStartCard({
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
|
||||
className={`platform-interactive-card relative flex min-h-[4rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
|
||||
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 sm:items-start sm:gap-3">
|
||||
<span
|
||||
className={`platform-pill px-2.5 text-xs sm:px-3 sm:text-sm ${
|
||||
item.locked
|
||||
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
|
||||
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
|
||||
}`}
|
||||
>
|
||||
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
|
||||
</span>
|
||||
<div className="flex min-h-5 items-center justify-end gap-2 sm:items-start sm:gap-3">
|
||||
{item.locked ? (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 text-xs text-[var(--platform-text-soft)] sm:px-3 sm:text-sm">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
{item.locked ? (
|
||||
<span className="text-base leading-none text-white/40">·</span>
|
||||
) : (
|
||||
@@ -72,15 +68,17 @@ export function CustomWorldCreationStartCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg xl:mt-4 xl:text-base">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
|
||||
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
<div className="mt-auto pt-1.5 sm:pt-4 xl:pt-2">
|
||||
<div className="truncate text-base font-black leading-tight text-inherit sm:text-lg xl:text-base">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
|
||||
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,11 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBigFishPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
|
||||
@@ -16,10 +20,19 @@ export type CreationWorkShelfBadge = {
|
||||
tone: CreationWorkShelfBadgeTone;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfMetricId =
|
||||
| 'play-count'
|
||||
| 'remix-count'
|
||||
| 'like-count';
|
||||
|
||||
export type CreationWorkShelfMetricTone = 'play' | 'remix' | 'like';
|
||||
|
||||
export type CreationWorkShelfMetric = {
|
||||
id: string;
|
||||
id: CreationWorkShelfMetricId;
|
||||
label: string;
|
||||
tone?: CreationWorkShelfBadgeTone;
|
||||
value: number;
|
||||
unit: string;
|
||||
tone: CreationWorkShelfMetricTone;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfSource =
|
||||
@@ -41,17 +54,16 @@ export type CreationWorkShelfItem = {
|
||||
kind: CreationWorkShelfKind;
|
||||
status: CreationWorkShelfStatus;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
coverImageSrc: string | null;
|
||||
coverRenderMode: 'image' | 'scene_with_roles';
|
||||
coverCharacterImageSrcs: string[];
|
||||
publicWorkCode: string | null;
|
||||
typeLabel: string;
|
||||
sharePath: string | null;
|
||||
openActionLabel: string;
|
||||
canExperience: boolean;
|
||||
canDelete: boolean;
|
||||
canShare: boolean;
|
||||
badges: CreationWorkShelfBadge[];
|
||||
metrics: CreationWorkShelfMetric[];
|
||||
source: CreationWorkShelfSource;
|
||||
@@ -101,67 +113,43 @@ function mapRpgWorkToShelfItem(
|
||||
const libraryEntry = item.profileId
|
||||
? libraryEntries.find((entry) => entry.profileId === item.profileId)
|
||||
: null;
|
||||
const publicWorkCode =
|
||||
item.status === 'published' ? (libraryEntry?.publicWorkCode ?? null) : null;
|
||||
const badges: CreationWorkShelfBadge[] = [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: 'RPG', tone: 'neutral' },
|
||||
];
|
||||
if (item.stageLabel) {
|
||||
badges.push({ id: 'stage', label: item.stageLabel, tone: 'neutral' });
|
||||
}
|
||||
|
||||
const metrics: CreationWorkShelfMetric[] = [
|
||||
{
|
||||
id: 'playable-npc-count',
|
||||
label: `${isDraft ? '角色' : '可扮演角色'} ${item.playableNpcCount}`,
|
||||
},
|
||||
{ id: 'landmark-count', label: `地点 ${item.landmarkCount}` },
|
||||
];
|
||||
if (item.roleVisualReadyCount) {
|
||||
metrics.push({
|
||||
id: 'role-visual-ready-count',
|
||||
label: `主图 ${item.roleVisualReadyCount}`,
|
||||
tone: 'warm',
|
||||
});
|
||||
}
|
||||
if (item.roleAnimationReadyCount) {
|
||||
metrics.push({
|
||||
id: 'role-animation-ready-count',
|
||||
label: `动作 ${item.roleAnimationReadyCount}`,
|
||||
tone: 'success',
|
||||
});
|
||||
}
|
||||
if (item.roleAssetSummaryLabel) {
|
||||
metrics.push({
|
||||
id: 'role-asset-summary',
|
||||
label: item.roleAssetSummaryLabel,
|
||||
});
|
||||
}
|
||||
const metrics = buildPublishedMetrics({
|
||||
playCount: libraryEntry?.playCount,
|
||||
remixCount: libraryEntry?.remixCount,
|
||||
likeCount: libraryEntry?.likeCount,
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'rpg',
|
||||
status: item.status,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
summary: item.summary,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: item.coverRenderMode ?? 'image',
|
||||
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
|
||||
publicWorkCode:
|
||||
item.status === 'published'
|
||||
? (libraryEntry?.publicWorkCode ?? null)
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && item.status === 'published'
|
||||
? buildPublicWorkStagePath('work-detail', publicWorkCode)
|
||||
: null,
|
||||
typeLabel: 'RPG',
|
||||
openActionLabel: isDraft
|
||||
? item.playableNpcCount > 0 || item.landmarkCount > 0
|
||||
? '继续完善'
|
||||
: '继续创作'
|
||||
: '查看详情',
|
||||
canExperience: item.status === 'published' && item.canEnterWorld,
|
||||
canDelete,
|
||||
canShare: item.status === 'published' && Boolean(publicWorkCode),
|
||||
badges,
|
||||
metrics,
|
||||
metrics: isDraft ? [] : metrics,
|
||||
source: { kind: 'rpg', item },
|
||||
};
|
||||
}
|
||||
@@ -170,47 +158,40 @@ function mapBigFishWorkToShelfItem(
|
||||
item: BigFishWorkSummary,
|
||||
canDelete: boolean,
|
||||
): CreationWorkShelfItem {
|
||||
const isPublished = item.status === 'published';
|
||||
const publicWorkCode = isPublished
|
||||
? buildBigFishPublicWorkCode(item.sourceSessionId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'big-fish',
|
||||
status: item.status,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
summary: item.summary,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode: null,
|
||||
typeLabel: '大鱼',
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && isPublished
|
||||
? buildPublicWorkStagePath('big-fish-runtime', publicWorkCode)
|
||||
: null,
|
||||
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
|
||||
canExperience: item.status === 'published',
|
||||
canDelete,
|
||||
canShare: isPublished && Boolean(publicWorkCode),
|
||||
badges: [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: '大鱼', tone: 'neutral' },
|
||||
],
|
||||
metrics: [
|
||||
{ id: 'level-count', label: `关卡 ${item.levelCount}` },
|
||||
{
|
||||
id: 'level-main-image-ready-count',
|
||||
label: `主图 ${item.levelMainImageReadyCount}`,
|
||||
},
|
||||
{
|
||||
id: 'level-motion-ready-count',
|
||||
label: `动作 ${item.levelMotionReadyCount}`,
|
||||
},
|
||||
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
|
||||
...(item.backgroundReady
|
||||
? [
|
||||
{
|
||||
id: 'background-ready',
|
||||
label: '背景已就绪',
|
||||
tone: 'success' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
metrics: isPublished
|
||||
? buildPublishedMetrics({
|
||||
playCount: item.playCount,
|
||||
remixCount: item.remixCount,
|
||||
likeCount: item.likeCount,
|
||||
})
|
||||
: [],
|
||||
source: { kind: 'big-fish', item },
|
||||
};
|
||||
}
|
||||
@@ -220,42 +201,88 @@ function mapPuzzleWorkToShelfItem(
|
||||
canDelete: boolean,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus;
|
||||
const publicWorkCode =
|
||||
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null;
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'puzzle',
|
||||
status,
|
||||
title: item.levelName,
|
||||
subtitle: item.authorDisplayName,
|
||||
summary: item.summary,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode:
|
||||
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null,
|
||||
typeLabel: '拼图',
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && status === 'published'
|
||||
? buildPublicWorkStagePath('puzzle-gallery-detail', publicWorkCode)
|
||||
: null,
|
||||
openActionLabel:
|
||||
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
|
||||
canExperience: status === 'published',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '拼图', tone: 'neutral' },
|
||||
...item.themeTags.slice(0, 2).map((tag) => ({
|
||||
id: `tag:${tag}`,
|
||||
label: tag,
|
||||
tone: 'neutral' as const,
|
||||
})),
|
||||
],
|
||||
metrics: [
|
||||
{ id: 'author', label: `作者 ${item.authorDisplayName}` },
|
||||
{ id: 'play-count', label: `游玩 ${item.playCount}` },
|
||||
],
|
||||
metrics:
|
||||
status === 'published'
|
||||
? buildPublishedMetrics({
|
||||
playCount: item.playCount,
|
||||
remixCount: item.remixCount,
|
||||
likeCount: item.likeCount,
|
||||
})
|
||||
: [],
|
||||
source: { kind: 'puzzle', item },
|
||||
};
|
||||
}
|
||||
|
||||
function buildPublishedMetrics(params: {
|
||||
playCount?: number | null;
|
||||
remixCount?: number | null;
|
||||
likeCount?: number | null;
|
||||
}): CreationWorkShelfMetric[] {
|
||||
return [
|
||||
{
|
||||
id: 'play-count',
|
||||
label: '游玩',
|
||||
value: normalizeMetricCount(params.playCount),
|
||||
unit: '次',
|
||||
tone: 'play',
|
||||
},
|
||||
{
|
||||
id: 'remix-count',
|
||||
label: '改造',
|
||||
value: normalizeMetricCount(params.remixCount),
|
||||
unit: '次',
|
||||
tone: 'remix',
|
||||
},
|
||||
{
|
||||
id: 'like-count',
|
||||
label: '点赞',
|
||||
value: normalizeMetricCount(params.likeCount),
|
||||
unit: '赞',
|
||||
tone: 'like',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function normalizeMetricCount(value?: number | null) {
|
||||
return Math.max(0, Math.floor(value ?? 0));
|
||||
}
|
||||
|
||||
export function formatCreationMetricCount(value?: number | null) {
|
||||
const normalized = Math.max(0, Math.floor(value ?? 0));
|
||||
if (normalized >= 10000) {
|
||||
const wanValue = normalized / 10000;
|
||||
return `${Number.isInteger(wanValue) ? wanValue.toFixed(0) : wanValue.toFixed(1)}万`;
|
||||
}
|
||||
|
||||
return `${normalized}`;
|
||||
}
|
||||
|
||||
function buildStatusBadge(
|
||||
status: CreationWorkShelfStatus,
|
||||
): CreationWorkShelfBadge {
|
||||
|
||||
@@ -234,6 +234,55 @@ describe('GameCanvasEntityLayer', () => {
|
||||
expect(html).toContain('aria-label="好感度变化 +3"');
|
||||
});
|
||||
|
||||
it('keeps battle opponent visible when compat payload misses encounter context', () => {
|
||||
const hostileNpc = createHostileNpc({
|
||||
encounter: undefined,
|
||||
name: '断桥匪首',
|
||||
description: '刚进入战斗时的旧快照目标',
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
sceneActAmbientEncounters={[]}
|
||||
currentScenePreset={null}
|
||||
sceneTransitionToken={0}
|
||||
isSceneTransitionEntering={false}
|
||||
isSceneTransitionExiting={false}
|
||||
transitionSweepPx={320}
|
||||
sceneTransitionExitDurationS={0.2}
|
||||
sceneTransitionEntryDurationS={0.2}
|
||||
companionAnchorLeft="10%"
|
||||
companionAnchorBottom="20%"
|
||||
playerBottomOffsetPx={0}
|
||||
sceneTransitionPhase="idle"
|
||||
inBattle={true}
|
||||
onEntitySelect={null}
|
||||
playerLeft="20%"
|
||||
playerCharacter={createCharacter()}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
effectivePlayerFacing="right"
|
||||
effectivePlayerAnimationState={AnimationState.IDLE}
|
||||
shouldShowPlayerDialogueIcon={false}
|
||||
dialogueIndicator={null}
|
||||
npcAffinityEffect={null}
|
||||
sceneCombatants={[hostileNpc]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '70%'}
|
||||
groundBottom="18%"
|
||||
stageLiftPx={68}
|
||||
encounter={null}
|
||||
sideAnchor="15%"
|
||||
cameraAnchorX={0}
|
||||
monsterAnchorMeters={3.2}
|
||||
playerX={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('查看断桥匪首详情');
|
||||
expect(html).toContain('from-rose-500 to-red-400');
|
||||
});
|
||||
|
||||
it('does not render affinity effect on a different npc', () => {
|
||||
const html = renderEntityLayer('npc-other');
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user