import React, { useEffect, useRef, useState } from 'react' import { createRoot } from 'react-dom/client' import './styles.css' const files = [ { id: 'echo', no: '01', title: '回声', subtitle: 'ECHOES', year: '2021' }, { id: 'peach', no: '02', title: '误入桃源', subtitle: 'THE PEACH SPRING', year: '2022' }, { id: 'sleep', no: '03', title: '眠史', subtitle: 'THE HISTORY OF SLEEP', year: '2022' }, { id: 'reverse', no: '04', title: '逆溯备忘录', subtitle: 'REVERSE MEMORANDUM', year: '2023' }, { id: 'specimen', no: '05', title: '夜航样本', subtitle: 'NIGHT SPECIMEN', year: '2024' }, { id: 'tide', no: '06', title: '潮汐信札', subtitle: 'TIDAL LETTERS', year: '2024' }, { id: 'garden', no: '07', title: '无声花园', subtitle: 'THE SILENT GARDEN', year: '2025' }, { id: 'latitude', no: '08', title: '失落纬度', subtitle: 'LOST LATITUDES', year: '2026' }, ] const TRACK_START = 7 const TRACK_STEP = 16.5 const TRACK_END = 80 function useReducedMotion() { const [reduced, setReduced] = useState(() => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false) useEffect(() => { const media = window.matchMedia('(prefers-reduced-motion: reduce)') const update = () => setReduced(media.matches) media.addEventListener('change', update) return () => media.removeEventListener('change', update) }, []) return reduced } function Mark({ index, opening = false, reducedMotion = false }) { const styles = [ { wash: '#c7d8d4', ink: '#536f73' }, { wash: '#cbded5', ink: '#2b5a60' }, { wash: '#b9d1de', ink: '#31566d' }, { wash: '#d5d0d9', ink: '#6d5b6b' }, { wash: '#b7ced2', ink: '#284d5b' }, ][index % 5] return ( ) } function FileCard({ file, index, total, onOpen, hovered, onHover, opening, reducedMotion }) { const hasHover = hovered !== null // 只允许 React 的 is-active 控制转正;不要再添加 CSS :hover 放大,否则会同时弹出两张档案。 const isActive = opening || hovered === index // 固定 16.5% 间距让封面标签可读;超出视口的档案通过拖动/滚轮带入画面。 const baseX = TRACK_START + index * TRACK_STEP // 档案越多默认越侧放:5 张约 18°,当前 8 张约 45°。背景卡片不跟随当前卡片改变朝向。 const densityYaw = Math.max(0, total - 5) * 9 const yaw = isActive ? 0 : -18 - densityYaw + index * .6 const roll = isActive ? 0 : -1.8 + index * (3.4 / Math.max(total - 1, 1)) const depth = isActive ? 210 : -42 - index * 7 const scale = isActive ? 1.48 : total > 5 ? .9 : .96 return ( ) } function Header({ activeNo, count }) { return (
WORLD FILES世界档案 · 世界观察局
个人作品集 / 档案索引{activeNo ? `WF–${activeNo}` : `${String(count).padStart(2, '0')} FILES`}
) } function ArchiveDetail({ file, onClose }) { const closeButtonRef = useRef(null) useEffect(() => { closeButtonRef.current?.focus() }, []) return (
文学实验 · 视觉叙事 · 概念设计

{file.title}

WORLD
FILESARCHIVE

《{file.title}》是一份关于记忆、空间与叙述偏差的世界观档案。它把一个看似完整的地方拆成可以被观察、记录与反复质疑的规则。

LITERARY ADAPTATIONNARRATIVE PUZZLEWORLD BUILDING

01 · 核心概念

被记录的世界

档案中的世界并不是等待探索的固定地图,而是一组会因观察者而改变的证词。人物对同一段经历拥有不同版本,地点也会随着记录方式改变边界。读者需要从互相矛盾的材料里判断:哪些是真实发生的事件,哪些只是为了让世界继续运转而被共同相信的故事。

这种不稳定性并非谜底揭晓前的障眼法,而是世界本身的基本规则。每一次翻阅、批注和重新归档都会留下痕迹,让“阅读档案”成为叙事的一部分。

02 · 世界规则

不可复返

漂泊中的旅者误入一处从未被正式记录的聚落。每次回望,来路都会减少一个细节;每次试图留下,记忆里便多出一道无法解释的裂隙。离开并不意味着走出边界,而是逐渐失去证明自己曾经抵达的能力。

“有些地方不是找不到,而是只允许被找到一次。”

规则通过缺页、重复编号、相互覆盖的地图和逐渐变化的称谓呈现。它们不会由旁白一次解释清楚,而是在多份材料之间保持一致,让读者能够自行验证。

03 · 叙事结构

记录与遗忘

纸张上的批注、被水浸开的地图、口述片段与失效的时间表共同组成叙事。每一份材料只承担一个局部视角,重要信息则通过不同材料间的重合和冲突浮现。

读者不是旁观者,而是负责整理证据的最后一位档案员。章节顺序可以被打乱,但关键线索会形成足够稳定的路径,使自由阅读不会破坏故事的理解。

04 · 视觉系统

纸张、校勘与水痕

低饱和纸色建立长期保存的物质感,红色校勘标记负责提示人为干预,水墨状图形则对应无法被精确归类的内容。标签、编号和页边索引保持克制,让视觉信息像真实工作文件一样可以快速扫描。

档案袋的厚边、袋口阴影和底部投影共同制造轻量的 2.5D 体积;所有文字与图形跟随同一张卡片旋转,Hover 时才完整转向阅读者。

05 · 项目复盘

从观看转向参与

这一套展示方式的核心并不是模拟真实档案管理,而是把作品集的选择动作变成一次带有仪式感的开启。卡片侧放时强调数量与层次,转正时强调当前选择,解结和展开则把短暂的等待转化成进入故事的过渡。

后续内容可以继续复用同一结构,只替换标题、摘要与关键段落。这样既保留统一的交互语言,也避免为了演示前端效果维护八套完全独立的页面系统。

) } function OpeningLetter({ active, sourceRect, onClose, reducedMotion }) { const [expanded, setExpanded] = useState(reducedMotion) const [revealed, setRevealed] = useState(reducedMotion) useEffect(() => { if (reducedMotion) { setExpanded(true) setRevealed(true) return undefined } // 开档顺序:下一帧启动 FLIP → .68s 解结完成 → 约 1.33s 纸张铺开 → 1.34s 显示正文。 const frame = requestAnimationFrame(() => setExpanded(true)) const revealTimer = setTimeout(() => setRevealed(true), 1340) return () => { cancelAnimationFrame(frame); clearTimeout(revealTimer) } }, [reducedMotion]) const style = sourceRect ? { '--from-x': `${sourceRect.left}px`, '--from-y': `${sourceRect.top}px`, '--from-w': `${sourceRect.width}px`, '--from-h': `${sourceRect.height}px`, } : undefined return (
WORLD FILES / {active.no}
{revealed && }
) } function App() { const [active, setActive] = useState(null) const [sourceRect, setSourceRect] = useState(null) const [hovered, setHovered] = useState(null) const [dragOffset, setDragOffset] = useState(0) const [dragging, setDragging] = useState(false) const [wheeling, setWheeling] = useState(false) // Pointer Capture 会把原生 click 交给父容器,因此这里保存按下的原卡片,并在 pointerup 主动区分点击和拖动。 const dragState = useRef({ pointerId: null, startX: 0, startOffset: 0, moved: false, cardIndex: null, cardNode: null }) // pointerup 主动打开后,吞掉浏览器可能补发的一次 click,避免开档动画启动两遍。 const suppressClick = useRef(false) const wheelTimer = useRef(null) const stackRef = useRef(null) const triggerRef = useRef(null) const reducedMotion = useReducedMotion() const minDragOffset = Math.min(0, TRACK_END - (TRACK_START + (files.length - 1) * TRACK_STEP)) const openFile = (file, index, node) => { if (!node) return const rect = node.getBoundingClientRect() triggerRef.current = node setHovered(index) setSourceRect({ left: rect.left, top: rect.top, width: rect.width, height: rect.height }) setActive({ ...file, index }) } const clickFile = (file, index, node) => { if (suppressClick.current) { suppressClick.current = false return } openFile(file, index, node) } const closeFile = () => { setActive(null) setSourceRect(null) setHovered(null) requestAnimationFrame(() => triggerRef.current?.focus()) } const trackPointer = (event) => { if (event.pointerType === 'touch' || active || dragState.current.pointerId !== null) return const stack = stackRef.current if (!stack) return const rect = stack.getBoundingClientRect() if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) { setHovered(null) return } const pointerX = ((event.clientX - rect.left) / rect.width) * 100 if (pointerX < 2 || pointerX > 88) { setHovered(null) return } // 命中轨道必须扣除当前拖动偏移;否则拖动后视觉卡片与 Hover 索引会错位。 const index = Math.max(0, Math.min(files.length - 1, Math.round((pointerX - TRACK_START - dragOffset) / TRACK_STEP))) setHovered((current) => current === index ? current : index) } const startDrag = (event) => { if (active || event.button !== 0) return const cardNode = event.target.closest?.('.file-card') const cardIndex = cardNode ? Number(cardNode.dataset.fileIndex) : null dragState.current = { pointerId: event.pointerId, startX: event.clientX, startOffset: dragOffset, moved: false, cardIndex, cardNode } event.currentTarget.setPointerCapture(event.pointerId) setDragging(true) } const movePointer = (event) => { if (dragState.current.pointerId === event.pointerId) { const rect = event.currentTarget.getBoundingClientRect() const delta = ((event.clientX - dragState.current.startX) / rect.width) * 100 // 5px 是点击/拖动阈值:以内仍打开档案,超过才移动轨道并取消 Hover。 if (Math.abs(event.clientX - dragState.current.startX) > 5) { dragState.current.moved = true setHovered(null) } setDragOffset(Math.max(minDragOffset, Math.min(0, dragState.current.startOffset + delta))) return } trackPointer(event) } const finishDrag = (event) => { if (dragState.current.pointerId !== event.pointerId) return const { moved, cardIndex, cardNode } = dragState.current if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) dragState.current.pointerId = null setDragging(false) if (moved) { setHovered(null) return } // Pointer Capture 下不能依赖 button 的原生 click;轻点由这里明确打开最初按下的卡片。 if (Number.isInteger(cardIndex) && cardNode) { suppressClick.current = true openFile(files[cardIndex], cardIndex, cardNode) window.setTimeout(() => { suppressClick.current = false }, 0) } } const cancelDrag = (event) => { if (dragState.current.pointerId !== event.pointerId) return if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) dragState.current = { pointerId: null, startX: 0, startOffset: dragOffset, moved: false, cardIndex: null, cardNode: null } setDragging(false) setHovered(null) } useEffect(() => { const onKeyDown = (event) => event.key === 'Escape' && active && closeFile() window.addEventListener('keydown', onKeyDown) document.body.classList.toggle('locked', Boolean(active)) return () => { window.removeEventListener('keydown', onKeyDown) document.body.classList.remove('locked') } }, [active]) useEffect(() => () => window.clearTimeout(wheelTimer.current), []) useEffect(() => { const wheelFiles = (event) => { const stack = stackRef.current if (!stack || active) return const rect = stack.getBoundingClientRect() const rawDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY if (!rawDelta) return event.preventDefault() // 首页任意位置都接管滚轮;同时兼容普通鼠标纵向滚动和触控板横滑。 const delta = event.deltaMode === 1 ? rawDelta * 16 : event.deltaMode === 2 ? rawDelta * rect.width : rawDelta setDragOffset((current) => Math.max(minDragOffset, Math.min(0, current - (delta / rect.width) * 100))) setHovered(null) setWheeling(true) window.clearTimeout(wheelTimer.current) wheelTimer.current = window.setTimeout(() => setWheeling(false), 140) } window.addEventListener('wheel', wheelFiles, { passive: false }) return () => window.removeEventListener('wheel', wheelFiles) }, [active, minDragOffset]) return (
!active && !dragging && setHovered(null)} >
— ARCHIVE INDEX · 01–{String(files.length).padStart(2, '0')}

世界档案

世界观 · 人物与故事结构

记录那些半梦半醒的世界、人物关系、叙事结构与设计碎片。选择一份档案,查看其内容。

HOVER / DRAG / SCROLL
!active && !dragging && setHovered(null)} > {files.map((file, index) => )}
WORLD FILES © 2026DESIGN / ARCHIVE / SYSTEM
{active && }
) } createRoot(document.getElementById('root')).render()