Files
Genarrative/src/components/MapModal.tsx
2026-04-24 12:21:33 +08:00

450 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 { 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>
);
}