初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

354
src/components/MapModal.tsx Normal file
View File

@@ -0,0 +1,354 @@
import { AnimatePresence, motion } from 'motion/react';
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import { getConnectedScenePresets } from '../data/scenePresets';
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: _label,
compact = false,
isInteractive = false,
onClick,
}: {
key?: string;
scene: ScenePresetInfo | null | undefined;
label: string;
compact?: boolean;
isInteractive?: boolean;
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' : ''}`}
style={getNineSliceStyle(UI_CHROME.mapRoomCell)}
>
<div className={`flex min-h-[3.25rem] items-center justify-center px-3 py-2 text-center ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}>
{scene.name}
</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;
}
export function MapModal({
isOpen,
currentScenePreset,
worldType,
onClose,
onTravelToScene,
isTraveling = false,
canTravel = true,
}: MapModalProps) {
const [pendingScene, setPendingScene] = useState<ScenePresetInfo | null>(null);
const connectedScenes = useMemo(
() =>
worldType && currentScenePreset
? getConnectedScenePresets(worldType, currentScenePreset.id)
: [],
[currentScenePreset, worldType],
);
const forwardSceneId = currentScenePreset?.forwardSceneId;
const forwardScene = connectedScenes.find(scene => scene.id === forwardSceneId) ?? null;
const branchScenes = connectedScenes.filter(scene => scene.id !== forwardSceneId);
const leftBranchScene = branchScenes[0] ?? null;
const rightBranchScene = branchScenes[1] ?? null;
const destinationScenes = [forwardScene, leftBranchScene, rightBranchScene].filter(Boolean) as ScenePresetInfo[];
const sceneBackdropStyle = buildSceneBackdropStyle(currentScenePreset?.imageSrc);
const destinationStackHeightPx = getMapDestinationStackHeight(destinationScenes.length);
useEffect(() => {
if (!isOpen) {
setPendingScene(null);
}
}, [isOpen]);
useEffect(() => {
if (!pendingScene) return;
if (!connectedScenes.some(scene => scene.id === pendingScene.id)) {
setPendingScene(null);
}
}, [connectedScenes, pendingScene]);
const handleSceneSelect = (scene: ScenePresetInfo | null) => {
if (!scene || scene.id === currentScenePreset?.id) return;
setPendingScene(scene);
};
const confirmTravel = () => {
if (!pendingScene) return;
onTravelToScene(pendingScene);
setPendingScene(null);
};
return (
<AnimatePresence>
{isOpen && currentScenePreset && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="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="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="pointer-events-none absolute inset-0"
style={sceneBackdropStyle}
/>
<div className="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="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">
{forwardScene && <div>{`- 前路:${forwardScene.name}`}</div>}
{branchScenes.map((scene, index) => (
<div key={scene.id}>{`- 支路 ${index + 1}${scene.name}`}</div>
))}
{connectedScenes.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` }}>
{destinationScenes.length > 0 && (
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
{destinationScenes.map((scene, index) => (
<line
key={`connector-${scene.id}`}
x1="0"
y1={`${getMapDestinationCenterPercent(0, destinationScenes.length)}%`}
x2="100%"
y2={`${getMapDestinationCenterPercent(index, destinationScenes.length)}%`}
stroke="rgba(74, 222, 128, 0.35)"
strokeWidth="1.5"
/>
))}
</svg>
)}
</div>
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
{destinationScenes.map(scene => (
<MudMapRoom
key={scene.id}
scene={scene}
label={scene.id === forwardScene?.id ? '前路' : '支路'}
compact
isInteractive={canTravel}
onClick={() => handleSceneSelect(scene)}
/>
))}
</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` }}>
{destinationScenes.length > 0 && (
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
{destinationScenes.map((scene, index) => (
<line
key={`connector-desktop-${scene.id}`}
x1="0"
y1={`${getMapDestinationCenterPercent(0, destinationScenes.length)}%`}
x2="100%"
y2={`${getMapDestinationCenterPercent(index, destinationScenes.length)}%`}
stroke="rgba(74, 222, 128, 0.35)"
strokeWidth="1.5"
/>
))}
</svg>
)}
</div>
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
{destinationScenes.map(scene => (
<MudMapRoom
key={scene.id}
scene={scene}
label={scene.id === forwardScene?.id ? '前路' : '支路'}
isInteractive={canTravel}
onClick={() => handleSceneSelect(scene)}
/>
))}
</div>
</div>
</div>
</div>
</div>
</motion.div>
<AnimatePresence>
{pendingScene && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="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="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.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.name}</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{pendingScene.description}</div>
</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.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>
);
}