Files
Genarrative/src/components/MapModal.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

473 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AnimatePresence, motion } from 'motion/react';
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import { getConnectedScenePresets } from '../data/scenePresets';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
import { ScenePresetInfo, WorldType } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
function buildSceneBackdropStyle(imageSrc?: string | null): CSSProperties {
return {
backgroundImage: imageSrc
? `linear-gradient(rgba(7,10,18,0.82), rgba(7,10,18,0.76)), url("${imageSrc}")`
: 'linear-gradient(rgba(7,10,18,0.82), rgba(7,10,18,0.76))',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
};
}
const MAP_NODE_MIN_HEIGHT_PX = 52;
const MAP_NODE_GAP_PX = 12;
function getMapDestinationStackHeight(count: number) {
if (count <= 0) return MAP_NODE_MIN_HEIGHT_PX;
return count * MAP_NODE_MIN_HEIGHT_PX + (count - 1) * MAP_NODE_GAP_PX;
}
function getMapDestinationCenterPercent(index: number, count: number) {
const totalHeight = getMapDestinationStackHeight(count);
const centerY = index * (MAP_NODE_MIN_HEIGHT_PX + MAP_NODE_GAP_PX) + MAP_NODE_MIN_HEIGHT_PX / 2;
return (centerY / totalHeight) * 100;
}
function MudMapRoom({
scene,
label,
compact = false,
isInteractive = false,
isSelected = false,
description,
onClick,
}: {
key?: string;
scene: ScenePresetInfo | null | undefined;
label: string;
compact?: boolean;
isInteractive?: boolean;
isSelected?: boolean;
description?: string;
onClick?: (() => void) | null;
}) {
if (!scene) {
return (
<div
className="pixel-nine-slice map-room-cell h-full min-h-[3.25rem] opacity-40"
style={getNineSliceStyle(UI_CHROME.mapRoomCell)}
/>
);
}
const content = (
<div
className={`pixel-nine-slice map-room-cell h-full font-mono ${compact ? 'text-[10px]' : 'text-[11px]'} leading-relaxed ${isInteractive ? 'transition-transform duration-150 hover:-translate-y-0.5 hover:brightness-110' : ''} ${isSelected ? 'brightness-125' : ''}`}
style={getNineSliceStyle(UI_CHROME.mapRoomCell)}
>
<div className="flex min-h-[3.25rem] flex-col items-center justify-center px-3 py-2 text-center">
<PlatformPillBadge
tone="darkEmerald"
size="xxs"
className="tracking-[0.16em] text-emerald-100/85"
>
{label}
</PlatformPillBadge>
<div className={`mt-1 ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}>
{scene.name}
</div>
{!compact && description ? (
<div className="mt-2 text-[10px] leading-5 text-zinc-300/85">
{description}
</div>
) : null}
</div>
</div>
);
if (!isInteractive || !onClick) {
return content;
}
return (
<button type="button" onClick={onClick} className="block h-full w-full text-left">
{content}
</button>
);
}
interface MapModalProps {
isOpen: boolean;
currentScenePreset: ScenePresetInfo | null;
worldType: WorldType | null;
onClose: () => void;
onTravelToScene: (scene: ScenePresetInfo) => void;
isTraveling?: boolean;
canTravel?: boolean;
}
type MapConnectionEntry = {
scene: ScenePresetInfo;
label: string;
summary: string;
};
function isMapConnectionEntry(
entry: MapConnectionEntry | null,
): entry is MapConnectionEntry {
return entry !== null;
}
function buildFallbackConnectionEntries(
currentScenePreset: ScenePresetInfo | null,
connectedScenes: ScenePresetInfo[],
) {
const forwardSceneId = currentScenePreset?.forwardSceneId;
const forwardScene =
connectedScenes.find((scene) => scene.id === forwardSceneId) ?? null;
const branchScenes = connectedScenes.filter((scene) => scene.id !== forwardSceneId);
const fallbackEntries: Array<MapConnectionEntry | null> = [
forwardScene
? ({
scene: forwardScene,
label: '前方',
summary: '沿主路继续深入',
} satisfies MapConnectionEntry)
: null,
...branchScenes.map(
(scene, index) =>
({
scene,
label: index === 0 ? '支路左侧' : index === 1 ? '支路右侧' : `支路${index + 1}`,
summary: '可转向另一片区域',
}) satisfies MapConnectionEntry,
),
];
return fallbackEntries.filter(isMapConnectionEntry);
}
export function MapModal({
isOpen,
currentScenePreset,
worldType,
onClose,
onTravelToScene,
isTraveling = false,
canTravel = true,
}: MapModalProps) {
const [pendingScene, setPendingScene] = useState<MapConnectionEntry | null>(null);
const {
resolvedUrl: resolvedBackdropImageSrc,
shouldResolve: shouldResolveBackdropImage,
} = useResolvedAssetReadUrl(currentScenePreset?.imageSrc);
const connectedScenes = useMemo(
() =>
worldType && currentScenePreset
? getConnectedScenePresets(worldType, currentScenePreset.id)
: [],
[currentScenePreset, worldType],
);
const connectionEntries = useMemo(() => {
if (currentScenePreset?.connections?.length) {
const entries: Array<MapConnectionEntry | null> = currentScenePreset.connections
.map((connection) => {
const scene = connectedScenes.find(
(item) => item.id === connection.sceneId,
);
if (!scene) {
return null;
}
return {
scene,
label: getCustomWorldSceneRelativePositionLabel(
connection.relativePosition,
),
summary: connection.summary,
} satisfies MapConnectionEntry;
});
return entries.filter(isMapConnectionEntry);
}
return buildFallbackConnectionEntries(currentScenePreset, connectedScenes);
}, [connectedScenes, currentScenePreset]);
const sceneBackdropStyle = buildSceneBackdropStyle(
resolvedBackdropImageSrc
|| (!shouldResolveBackdropImage ? currentScenePreset?.imageSrc : ''),
);
const destinationStackHeightPx = getMapDestinationStackHeight(connectionEntries.length);
useEffect(() => {
if (!isOpen) {
setPendingScene(null);
}
}, [isOpen]);
useEffect(() => {
if (!pendingScene) return;
if (!connectionEntries.some((scene) => scene.scene.id === pendingScene.scene.id)) {
setPendingScene(null);
}
}, [connectionEntries, pendingScene]);
const handleSceneSelect = (scene: MapConnectionEntry | null) => {
if (!scene || scene.scene.id === currentScenePreset?.id) return;
setPendingScene(scene);
};
const confirmTravel = () => {
if (!pendingScene) return;
onTravelToScene(pendingScene.scene);
setPendingScene(null);
};
return (
<AnimatePresence>
{isOpen && currentScenePreset && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="map-modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3 sm:p-4 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="platform-remap-surface map-modal-shell pixel-nine-slice pixel-modal-shell relative flex max-h-[min(92vh,62rem)] w-full max-w-[min(96vw,64rem)] flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div
className="map-modal-backdrop pointer-events-none absolute inset-0"
style={sceneBackdropStyle}
/>
<div className="map-modal-shade pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.18),rgba(2,6,23,0.68))]" />
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="inline-flex items-center gap-2 text-[10px] tracking-[0.22em] text-emerald-300/75">
<PixelIcon src={CHROME_ICONS.map} className="h-3.5 w-3.5" />
<span></span>
</div>
</div>
<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">
<div
className="map-info-panel pixel-nine-slice pixel-panel min-w-0 font-mono text-[11px] leading-relaxed text-emerald-100/85 md:max-h-full md:self-start md:overflow-y-auto"
style={getNineSliceStyle(UI_CHROME.infoPanel)}
>
<div className="text-emerald-200/75"></div>
<div className="mt-1 text-sm text-white">{currentScenePreset.name}</div>
<div className="mt-2 text-zinc-300">{currentScenePreset.description}</div>
<div className="mt-2 space-y-1.5 text-zinc-300">
{connectionEntries.map((entry) => (
<div key={entry.scene.id}>{`- ${entry.label}${entry.scene.name}`}</div>
))}
{connectionEntries.length === 0 && <div>- </div>}
</div>
</div>
<div className="min-h-0 p-1 font-mono md:overflow-y-auto">
<div className="md:hidden">
<div className="grid grid-cols-[minmax(0,0.9fr)_2rem_minmax(0,1.1fr)] items-start gap-3">
<div className="w-full max-w-[7.5rem] justify-self-start" style={{ minHeight: `${destinationStackHeightPx}px` }}>
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
</div>
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
{connectionEntries.length > 0 && (
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
{connectionEntries.map((entry, index) => (
<line
key={`connector-${entry.scene.id}`}
x1="0"
y1={`${getMapDestinationCenterPercent(0, connectionEntries.length)}%`}
x2="100%"
y2={`${getMapDestinationCenterPercent(index, connectionEntries.length)}%`}
stroke="rgba(74, 222, 128, 0.35)"
strokeWidth="1.5"
/>
))}
</svg>
)}
</div>
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
{connectionEntries.map(entry => (
<MudMapRoom
key={entry.scene.id}
scene={entry.scene}
label={entry.label}
description={entry.summary}
compact
isInteractive={canTravel}
isSelected={pendingScene?.scene.id === entry.scene.id}
onClick={() => handleSceneSelect(entry)}
/>
))}
</div>
</div>
</div>
<div className="hidden md:block">
<div className="grid grid-cols-[minmax(0,12rem)_4rem_minmax(0,1fr)] items-start gap-4">
<div className="w-full max-w-[9rem] justify-self-start" style={{ minHeight: `${destinationStackHeightPx}px` }}>
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
</div>
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
{connectionEntries.length > 0 && (
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
{connectionEntries.map((entry, index) => (
<line
key={`connector-desktop-${entry.scene.id}`}
x1="0"
y1={`${getMapDestinationCenterPercent(0, connectionEntries.length)}%`}
x2="100%"
y2={`${getMapDestinationCenterPercent(index, connectionEntries.length)}%`}
stroke="rgba(74, 222, 128, 0.35)"
strokeWidth="1.5"
/>
))}
</svg>
)}
</div>
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
{connectionEntries.map(entry => (
<MudMapRoom
key={entry.scene.id}
scene={entry.scene}
label={entry.label}
description={entry.summary}
isInteractive={canTravel}
isSelected={pendingScene?.scene.id === entry.scene.id}
onClick={() => handleSceneSelect(entry)}
/>
))}
</div>
</div>
</div>
</div>
</div>
</motion.div>
<AnimatePresence>
{pendingScene && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="map-modal-overlay fixed inset-0 z-[60] flex items-center justify-center bg-black/45 p-4 backdrop-blur-[2px]"
onClick={event => {
event.stopPropagation();
setPendingScene(null);
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 10 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="platform-remap-surface map-modal-shell pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<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>
<PixelCloseButton
onClick={() => setPendingScene(null)}
label="关闭场景切换"
/>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-5">
<PlatformSubpanel
as="div"
data-testid="map-target-scene-panel"
surface="darkAmber"
radius="xs"
padding="md"
>
<div className="text-[10px] tracking-[0.18em] text-amber-200/75"></div>
<div className="mt-2 text-base font-semibold text-white">{pendingScene.scene.name}</div>
<PlatformPillBadge
tone="darkAmber"
size="xxs"
className="mt-2 tracking-[0.18em]"
>
{pendingScene.label}
</PlatformPillBadge>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{pendingScene.scene.description}</div>
{pendingScene.summary ? (
<div className="mt-2 text-xs leading-6 text-zinc-400">
{pendingScene.summary}
</div>
) : null}
</PlatformSubpanel>
<div className="grid gap-3 sm:grid-cols-2">
<PlatformSubpanel
as="div"
data-testid="map-current-scene-summary"
surface="dark"
radius="xs"
padding="md"
>
<div className="text-[10px] tracking-[0.18em] text-zinc-500"></div>
<div className="mt-2 text-sm font-semibold text-white">{currentScenePreset.name}</div>
</PlatformSubpanel>
<PlatformSubpanel
as="div"
data-testid="map-next-scene-summary"
surface="dark"
radius="xs"
padding="md"
>
<div className="text-[10px] tracking-[0.18em] text-zinc-500"></div>
<div className="mt-2 text-sm font-semibold text-white">{pendingScene.scene.name}</div>
</PlatformSubpanel>
</div>
<div className="flex justify-end gap-2">
<PlatformActionButton
surface="editorDark"
tone="ghost"
size="xs"
onClick={() => setPendingScene(null)}
>
</PlatformActionButton>
<PlatformActionButton
surface="editorDark"
tone={isTraveling || !canTravel ? 'ghost' : 'warning'}
size="xs"
disabled={isTraveling || !canTravel}
onClick={confirmTravel}
>
{isTraveling ? '切换中...' : canTravel ? '确认前往' : '当前不可切换'}
</PlatformActionButton>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
);
}