450 lines
19 KiB
TypeScript
450 lines
19 KiB
TypeScript
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 { 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">
|
||
<div className="rounded-full border border-emerald-300/25 bg-emerald-500/10 px-2 py-0.5 text-[9px] tracking-[0.16em] text-emerald-100/85">
|
||
{label}
|
||
</div>
|
||
<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>
|
||
<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>
|
||
</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>
|
||
<button
|
||
type="button"
|
||
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>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-5">
|
||
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 px-4 py-4">
|
||
<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>
|
||
<div className="mt-2 rounded-full border border-amber-300/20 bg-black/20 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-50">
|
||
{pendingScene.label}
|
||
</div>
|
||
<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}
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">当前</div>
|
||
<div className="mt-2 text-sm font-semibold text-white">{currentScenePreset.name}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPendingScene(null)}
|
||
className="rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-xs text-zinc-200"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={isTraveling || !canTravel}
|
||
onClick={confirmTravel}
|
||
className={`rounded-lg border px-3 py-2 text-xs ${isTraveling || !canTravel ? 'border-white/10 bg-black/20 text-zinc-500' : 'border-amber-400/30 bg-amber-500/20 text-amber-50'}`}
|
||
>
|
||
{isTraveling ? '切换中...' : canTravel ? '确认前往' : '当前不可切换'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
);
|
||
}
|