feat:添加孙中山场景
This commit is contained in:
1 parent
bf4a98bf9f
commit
cad38e43df
55 files changed
+1704
-44
No files matched your search
@@ -0,0 +1,245 @@
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
|
||||
export type FlyToPosition =
|
||||
| "center"
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right";
|
||||
|
||||
export interface FlyToAnimationOptions {
|
||||
/** 起始元素的 ref */
|
||||
fromRef: React.RefObject<HTMLElement | null>;
|
||||
/** 目标元素的 ref */
|
||||
toRef: React.RefObject<HTMLElement | null>;
|
||||
/** 目标位置(相对于目标元素) */
|
||||
targetPosition?: FlyToPosition;
|
||||
/** 动画持续时间(毫秒) */
|
||||
duration?: number;
|
||||
/** 动画缓动函数 */
|
||||
ease?: string | number[];
|
||||
/** 动画完成回调 */
|
||||
onComplete?: () => void;
|
||||
/** 自定义样式 */
|
||||
className?: string;
|
||||
/** 缩放比例(从起始到目标) */
|
||||
scale?: { from: number; to: number };
|
||||
/** 透明度(从起始到目标) */
|
||||
opacity?: number[];
|
||||
}
|
||||
|
||||
interface AnimationInstance extends FlyToAnimationOptions {
|
||||
id: string;
|
||||
startPos: { x: number; y: number };
|
||||
targetPos: { x: number; y: number };
|
||||
elementHtml: string;
|
||||
}
|
||||
|
||||
class FlyToAnimationManagerClass {
|
||||
private animations: Map<string, AnimationInstance> = new Map();
|
||||
private containerRoot: Root | null = null;
|
||||
|
||||
// 初始化容器
|
||||
private initContainer() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (this.containerRoot) return;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.id = "fly-to-animation-container";
|
||||
document.body.appendChild(container);
|
||||
|
||||
this.containerRoot = createRoot(container);
|
||||
this.render();
|
||||
}
|
||||
|
||||
// 渲染动画容器
|
||||
private render() {
|
||||
if (!this.containerRoot) return;
|
||||
|
||||
const animations = Array.from(this.animations.values());
|
||||
|
||||
this.containerRoot.render(
|
||||
<>
|
||||
{animations.map((animation) => (
|
||||
<FlyToAnimationItem key={animation.id} animation={animation} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 通知更新
|
||||
private notify() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
// 创建动画
|
||||
flyTo(options: FlyToAnimationOptions) {
|
||||
const { fromRef, toRef } = options;
|
||||
|
||||
if (!fromRef?.current || !toRef?.current) {
|
||||
console.warn("FlyToAnimation: fromRef 或 toRef 未设置");
|
||||
return;
|
||||
}
|
||||
|
||||
this.initContainer();
|
||||
|
||||
// 使用 requestAnimationFrame 确保在下一帧获取准确的位置
|
||||
requestAnimationFrame(() => {
|
||||
const fromRect = fromRef.current!.getBoundingClientRect();
|
||||
const toRect = toRef.current!.getBoundingClientRect();
|
||||
|
||||
// 计算起始位置(起始元素的中心)
|
||||
const startX = fromRect.left + fromRect.width / 2;
|
||||
const startY = fromRect.top + fromRect.height / 2;
|
||||
|
||||
// 计算目标位置
|
||||
const targetPosition = options.targetPosition || "center";
|
||||
let targetX = 0;
|
||||
let targetY = 0;
|
||||
|
||||
switch (targetPosition) {
|
||||
case "center":
|
||||
targetX = toRect.left + toRect.width / 2;
|
||||
targetY = toRect.top + toRect.height / 2;
|
||||
break;
|
||||
case "top-left":
|
||||
targetX = toRect.left;
|
||||
targetY = toRect.top;
|
||||
break;
|
||||
case "top-right":
|
||||
targetX = toRect.right;
|
||||
targetY = toRect.top;
|
||||
break;
|
||||
case "bottom-left":
|
||||
targetX = toRect.left;
|
||||
targetY = toRect.bottom;
|
||||
break;
|
||||
case "bottom-right":
|
||||
targetX = toRect.right;
|
||||
targetY = toRect.bottom;
|
||||
break;
|
||||
}
|
||||
|
||||
// 克隆起始元素的 HTML 内容和样式
|
||||
// 使用 cloneNode 深度克隆,包括所有子元素
|
||||
const clonedElement = fromRef.current!.cloneNode(true) as HTMLElement;
|
||||
|
||||
// 复制计算后的样式到克隆元素
|
||||
const computedStyle = window.getComputedStyle(fromRef.current!);
|
||||
const allStyles = Array.from(computedStyle);
|
||||
allStyles.forEach((prop) => {
|
||||
const value = computedStyle.getPropertyValue(prop);
|
||||
if (value) {
|
||||
clonedElement.style.setProperty(prop, value);
|
||||
}
|
||||
});
|
||||
|
||||
// 获取克隆后的 HTML(只获取内部内容,不包含外层元素)
|
||||
const elementHtml = clonedElement.innerHTML;
|
||||
|
||||
// 创建动画实例
|
||||
const id = `fly-to-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 11)}`;
|
||||
const instance: AnimationInstance = {
|
||||
id,
|
||||
...options,
|
||||
startPos: { x: startX, y: startY },
|
||||
targetPos: { x: targetX, y: targetY },
|
||||
elementHtml,
|
||||
};
|
||||
|
||||
this.animations.set(id, instance);
|
||||
this.notify();
|
||||
|
||||
// 动画完成后自动清理
|
||||
const duration = options.duration || 1200;
|
||||
setTimeout(() => {
|
||||
this.remove(id);
|
||||
options.onComplete?.();
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
// 移除动画
|
||||
remove(id: string) {
|
||||
this.animations.delete(id);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局单例
|
||||
export const flyToAnimationManager = new FlyToAnimationManagerClass();
|
||||
|
||||
// 单个动画项组件
|
||||
const FlyToAnimationItem = ({
|
||||
animation,
|
||||
}: {
|
||||
animation: AnimationInstance;
|
||||
}) => {
|
||||
const {
|
||||
startPos,
|
||||
targetPos,
|
||||
elementHtml,
|
||||
duration = 1200,
|
||||
ease = "easeInOut",
|
||||
className = "",
|
||||
scale = { from: 1, to: 0.3 },
|
||||
opacity = [1, 0.8, 0],
|
||||
} = animation;
|
||||
|
||||
// 计算 opacity 的 times 数组
|
||||
const opacityTimes =
|
||||
opacity.length > 1
|
||||
? opacity.map((_, i) => i / (opacity.length - 1))
|
||||
: [0, 1];
|
||||
|
||||
// 如果 opacity 是数组,使用整个数组;否则使用单个值
|
||||
const animateOpacity =
|
||||
opacity.length > 1 ? opacity : opacity[opacity.length - 1] ?? 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{
|
||||
x: startPos.x,
|
||||
y: startPos.y,
|
||||
scale: scale.from,
|
||||
opacity: opacity[0] ?? 1,
|
||||
}}
|
||||
animate={{
|
||||
x: targetPos.x,
|
||||
y: targetPos.y,
|
||||
scale: scale.to,
|
||||
opacity: animateOpacity,
|
||||
}}
|
||||
transition={{
|
||||
duration: duration / 1000,
|
||||
ease: ease as any,
|
||||
opacity:
|
||||
opacity.length > 1
|
||||
? {
|
||||
times: opacityTimes,
|
||||
duration: duration / 1000,
|
||||
}
|
||||
: {
|
||||
duration: duration / 1000,
|
||||
},
|
||||
}}
|
||||
className={`fixed pointer-events-none z-[99999] ${className}`}
|
||||
style={{
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
transformTemplate={({ x, y, scale }) =>
|
||||
`translate(-50%, -50%) translateX(${x}) translateY(${y}) scale(${scale})`
|
||||
}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: elementHtml }}
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { flyToAnimationManager } from "./FlyToAnimationManager";
|
||||
import type { FlyToAnimationOptions } from "./FlyToAnimationManager";
|
||||
|
||||
/**
|
||||
* 飞行动画函数
|
||||
* 将一个元素从起始位置飞向目标元素
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const fromRef = useRef<HTMLDivElement>(null);
|
||||
* const toRef = useRef<HTMLDivElement>(null);
|
||||
*
|
||||
* flyToAnimation({
|
||||
* fromRef,
|
||||
* toRef,
|
||||
* targetPosition: "center",
|
||||
* onComplete: () => console.log("动画完成"),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const flyToAnimation = (options: FlyToAnimationOptions) => {
|
||||
flyToAnimationManager.flyTo(options);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// 函数式 API
|
||||
export { flyToAnimation } from "./flyToAnimation";
|
||||
export type { FlyToAnimationOptions } from "./FlyToAnimationManager";
|
||||
export type { FlyToPosition } from "./FlyToAnimationManager";
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Image } from "@/components";
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Clue } from "@/store/clueStore";
|
||||
|
||||
export interface ClueBookModalProps {
|
||||
visible: boolean;
|
||||
clues: Clue[];
|
||||
onClose: () => void;
|
||||
getClueImage: (clueId: string) => string;
|
||||
getClueDetailImage: (clueId: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索簿组件 - 显示已收集的线索和详情
|
||||
*/
|
||||
export const ClueBookModal = ({
|
||||
visible,
|
||||
clues,
|
||||
onClose,
|
||||
getClueImage,
|
||||
getClueDetailImage,
|
||||
}: ClueBookModalProps) => {
|
||||
const [selectedClueId, setSelectedClueId] = useState<string | null>(null);
|
||||
|
||||
// 默认选中第一个已收集的线索
|
||||
useEffect(() => {
|
||||
if (visible && clues.length > 0 && !selectedClueId) {
|
||||
const firstCollected = clues.find((c) => c.collected);
|
||||
if (firstCollected) {
|
||||
setSelectedClueId(firstCollected.id);
|
||||
}
|
||||
}
|
||||
}, [visible, clues, selectedClueId]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const selectedClue = clues.find((c) => c.id === selectedClueId);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
{/* 蒙版背景 */}
|
||||
{/* <div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/> */}
|
||||
|
||||
{/* 线索簿内容 */}
|
||||
<div className="relative w-full h-full bg-cover bg-center bg-no-repeat">
|
||||
{/* 背景图片 - 线索簿 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage(
|
||||
"sunzhongshan/reflection/详情线索簿背景"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="relative z-10 w-full h-full flex px-40 pb-40">
|
||||
{/* 左侧:线索列表 */}
|
||||
<div className="grid grid-cols-2 gap-x-10 pt-36">
|
||||
{clues.length === 0 ? (
|
||||
<div className="text-center text-gray-500 mt-8">暂无线索</div>
|
||||
) : (
|
||||
clues.map((clue) => {
|
||||
const isSelected = clue.id === selectedClueId;
|
||||
const isCollected = clue.collected;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={clue.id}
|
||||
// className={cn(
|
||||
// "relative cursor-pointer transition-all duration-300",
|
||||
// "hover:scale-105",
|
||||
// isSelected && "scale-110 z-10",
|
||||
//
|
||||
// )}
|
||||
onClick={() => {
|
||||
if (isCollected) {
|
||||
setSelectedClueId(clue.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 线索缩略图 - 带倾斜效果 */}
|
||||
<Image
|
||||
src={
|
||||
isCollected
|
||||
? getClueImage(clue.id)
|
||||
: getClueImage(clue.id)
|
||||
}
|
||||
className={cn(
|
||||
"hover:scale-110 hover:brightness-115 cursor-pointer transition-all duration-300",
|
||||
isSelected &&
|
||||
"scale-115 brightness-115 z-10 grayscale-0",
|
||||
!isCollected &&
|
||||
"opacity-50 grayscale cursor-not-allowed hover:scale-100 hover:brightness-100"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:线索详情或对话 */}
|
||||
<div className="flex-1 px-10 flex items-center justify-center pt-58">
|
||||
{selectedClue && selectedClue.collected ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||
{/* 显示线索详情图片 */}
|
||||
<div className="relative w-full h-full text-center">
|
||||
<Image
|
||||
src={getClueDetailImage(selectedClue.id)}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 text-4xl">
|
||||
{selectedClue ? "该线索尚未收集" : "请选择一个线索查看详情"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<Image
|
||||
className="absolute top-0 right-0 z-10"
|
||||
src={getStaticImage(`sunzhongshan/reflection/关闭`)}
|
||||
button
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Image } from "@/components";
|
||||
import { getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
|
||||
export interface ClueHint {
|
||||
id: string;
|
||||
name: string;
|
||||
position: {
|
||||
top: string;
|
||||
left: string;
|
||||
width: string;
|
||||
height: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClueHintModalProps {
|
||||
visible: boolean;
|
||||
backgroundImage: string;
|
||||
clues: ClueHint[];
|
||||
onClose: () => void;
|
||||
sourceContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索提示窗组件 - 显示所有线索位置
|
||||
*/
|
||||
export const ClueHintModal = ({
|
||||
visible,
|
||||
backgroundImage,
|
||||
clues,
|
||||
onClose,
|
||||
sourceContainerRef,
|
||||
}: ClueHintModalProps) => {
|
||||
const modalContainerRef = useRef<HTMLDivElement>(null);
|
||||
const backgroundRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [size, setSize] = useState({ width: "100%", height: "100%" });
|
||||
|
||||
// 计算缩放比例和偏移
|
||||
useEffect(() => {
|
||||
if (
|
||||
!visible ||
|
||||
!sourceContainerRef?.current ||
|
||||
!modalContainerRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const calculateScale = () => {
|
||||
const sourceRect = sourceContainerRef.current!.getBoundingClientRect();
|
||||
const modalRect = modalContainerRef.current!.getBoundingClientRect();
|
||||
|
||||
if (sourceRect.width === 0 || sourceRect.height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算宽高缩放比例,取较小的值以确保完全显示
|
||||
const scaleX = modalRect.width / sourceRect.width;
|
||||
const scaleY = modalRect.height / sourceRect.height;
|
||||
const newScale = Math.min(scaleX, scaleY);
|
||||
|
||||
setScale(newScale);
|
||||
setSize({
|
||||
width: `${sourceRect.width}px`,
|
||||
height: `${sourceRect.height}px`,
|
||||
});
|
||||
};
|
||||
|
||||
// 延迟计算以确保 DOM 已渲染
|
||||
const timer = setTimeout(calculateScale, 0);
|
||||
calculateScale();
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener("resize", calculateScale);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("resize", calculateScale);
|
||||
};
|
||||
}, [visible, sourceContainerRef]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center"
|
||||
ref={modalContainerRef}
|
||||
>
|
||||
{/* 蒙版背景 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 背景图片容器 - 使用缩放匹配源容器尺寸并居中 */}
|
||||
<div
|
||||
ref={backgroundRef}
|
||||
className="relative bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImage})`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
border: "40px solid transparent",
|
||||
borderImageSource: getBackgroundImage("sunzhongshan/reflection/框"),
|
||||
borderImageSlice: "40 fill",
|
||||
borderImageRepeat: "stretch round",
|
||||
}}
|
||||
>
|
||||
{/* 线索标记 */}
|
||||
{clues.map(
|
||||
(clue) =>
|
||||
clue.position.width !== "0%" && (
|
||||
<div
|
||||
key={clue.id}
|
||||
className="absolute border-4 border-yellow-400 bg-yellow-400/20 rounded-lg flex items-center justify-center animate-pulse"
|
||||
style={{
|
||||
top: clue.position.top,
|
||||
left: clue.position.left,
|
||||
width: clue.position.width,
|
||||
height: clue.position.height,
|
||||
}}
|
||||
>
|
||||
<span className="text-yellow-400 font-bold text-xl md:text-2xl bg-black/70 px-3 py-2 rounded shadow-lg whitespace-nowrap">
|
||||
{clue.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<Image
|
||||
className="absolute top-0 right-0 z-10"
|
||||
src={getStaticImage(`sunzhongshan/reflection/关闭`)}
|
||||
button
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ClueHintModal } from "./ClueHintModal";
|
||||
export type { ClueHintModalProps, ClueHint } from "./ClueHintModal";
|
||||
|
||||
export { ClueBookModal } from "./ClueBookModal";
|
||||
export type { ClueBookModalProps } from "./ClueBookModal";
|
||||
@@ -71,3 +71,11 @@ export type {
|
||||
// Game components
|
||||
export { BambooPuzzleGame } from "./game";
|
||||
export type { BambooPuzzleGameProps } from "./game";
|
||||
|
||||
// Clue components
|
||||
export { ClueHintModal, ClueBookModal } from "./clue";
|
||||
export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue";
|
||||
|
||||
// Animation components
|
||||
export { flyToAnimation } from "./animation";
|
||||
export type { FlyToAnimationOptions, FlyToPosition } from "./animation";
|
||||
Reference in new issue
Block a user