@overworld-engine/scene
3D 世界层:场景外壳、玩家控制器、跟随相机、碰撞、邻近检测与模型加载
Overworld 框架的 3D 世界层:场景外壳、玩家控制器、跟随相机、圆形碰撞、邻近检测、 GLTF 模型加载、场景主题与传送门。基于 React + three.js + @react-three/fiber + drei + zustand。
定位
本包只负责"可探索的 3D 世界"这一层,零游戏内容:没有内置模型路径、NPC 名字、
配色预设或世界边界,全部通过 props / 配置传入。跨系统通信一律走
@overworld-engine/core 的事件总线(gameEvents),因此对话、任务、音频等系统无需
import 本包即可响应玩家移动、场景切换与交互。
发出的事件:
| 事件 | 时机 |
|---|---|
player:moved | 玩家累计移动约 0.5 米(可配)时,携带位置与移动距离 |
scene:changed | useSceneStore.setScene(id) 切换场景时 |
proximity:enter / proximity:leave | 玩家进入 / 离开 NPC 或建筑的交互半径时 |
entity:interact | 附近有实体且按下交互键(useInteractKey / interact())时 |
interact(已弃用) | 与 entity:interact 同载荷双发,仅为过渡期兼容,2.0 移除 |
核心组件 / API
<SceneShell>— 场景样板组合:碰撞注册 + 邻近检测 + NPC / 建筑循环 + 选中光环 + 玩家。场景专属内容(灯光、地面、传送门、装饰)作为children传入。 通过npcIndicators(任务角标)与interactHint(自定义交互提示)注入游戏状态;playerprop 默认渲染<Player />,传null可关闭。<Player>— WASD / 方向键移动,Shift 奔跑;圆形碰撞解算、可选世界边界钳制、 动画 crossfade(idle/walk/run)。modelUrl省略时渲染胶囊体占位。isInputBlocked?: () => boolean用于接入你的输入优先级系统(如@overworld-engine/input)。externalInput?: MovementInputRef接受外部移动源(虚拟摇杆/手柄等,形如{ current: { x, z, running } },模长 ≤ 1),每帧与键盘输入合并:方向相加后归一化,running = Shift 或 externalInput.current.running;模拟量模长 < 1 时速度按比例缩放 (纯键盘保持全速),同样受isInputBlocked约束。与@overworld-engine/input的createMovementInput()/<VirtualJoystick>结构兼容——两个包互不 import。<FollowCamera targetRef offset lerp>— 平滑跟随相机,可独立使用。<BaseNPC>/<BaseBuilding>— 模型加载 + 名牌 + 发光 + 交互气泡, 颜色全部来自theme;"是否在附近"读取本包的useSceneStore。<SelectionRing>/<CollisionRegistration>/<Portal>— 地面选中环、 声明式碰撞注册、场景传送门(默认走setScene,可用onEnter覆盖)。useSceneStore—currentScene/nearbyNpcId/nearbyBuildingId;setScene(id)会发出scene:changed。useCollisionStore— 圆形碰撞注册表:registerCollider/checkCollision/resolveCollision(推出式解算)。playerPositionRef/playerRotationRef/teleportPlayer(pos)— 模块级可变引用,每帧由 Player 写入,供逐帧系统(小地图、邻近检测)读取而不触发 React 重渲染;teleportPlayer用于场景切换后落点。useProximityDetection({ npcs, buildings, npcRadius, buildingRadius })— 每帧找出最近的在半径内实体,写入 sceneStore 并发出 proximity 事件 (SceneShell 已内置调用)。useModelLoader/preloadSceneModels— GLTF 加载(克隆 + 阴影配置)与预加载。 加载中会 Suspense 挂起(挂起的 promise 会重新抛出,不会被吞),必须在<Suspense>边界之下调用——BaseNPC/BaseBuilding/Player已内置<Suspense>+ModelErrorBoundary(按模型 URL 作 key,改路径即重试); 加载中与加载失败都显示主题化占位体,失败只打一条console.error。preloadSceneModels因此只是性能优化(缓存命中时同步解析),不再是正确性前提。interact()/useInteractKey(key = 'e', { isInputBlocked })— 把"按 E 交互"翻译成总线上的entity:interact事件(过渡期同载荷双发已弃用的interact,2.0 移除)。defaultSceneTheme/createSceneTheme(partial)— 中性默认主题与深合并辅助。
最小使用示例
import { Canvas } from '@react-three/fiber'
import { gameEvents } from '@overworld-engine/core'
import {
SceneShell,
Player,
Portal,
useInteractKey,
useSceneStore,
createSceneTheme,
type NPCConfig,
} from '@overworld-engine/scene'
const theme = createSceneTheme({ npc: { primaryColor: '#ff9f43' } })
const npcs: NPCConfig[] = [
{ id: 'guide', name: '向导', modelPath: '/models/guide.glb',
position: [4, 0, 2], rotation: [0, Math.PI, 0] },
]
// 任意系统都可以订阅交互事件,无需 import 场景组件
gameEvents.on('entity:interact', ({ kind, id }) => {
if (kind === 'npc') console.log('开始对话:', id)
})
function World() {
return (
<SceneShell
theme={theme}
npcs={npcs}
npcIndicators={{ guide: 'quest-available' }}
player={<Player bounds={{ minX: -24, maxX: 24, minZ: -24, maxZ: 24 }} />}
>
{/* 场景专属内容 */}
<ambientLight intensity={0.6} />
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[50, 50]} />
<meshStandardMaterial color="#2d3436" />
</mesh>
<Portal position={[0, 0, -20]} targetScene="downtown" label="市中心" />
</SceneShell>
)
}
export function Game() {
useInteractKey('e')
const scene = useSceneStore((s) => s.currentScene)
return (
<Canvas shadows camera={{ position: [0, 10, 30], fov: 50 }}>
{scene !== 'downtown' ? <World /> : null /* 其他场景 */}
</Canvas>
)
}性能预设
面向移动端 / 低端设备的渲染质量分档:一个 zustand 单例存当前
QualitySettings,<ApplyQuality /> 挂在 Canvas 内负责把 GL 相关的部分
(DPR、阴影开关)应用到渲染器,其余数值由游戏自己消费。
-
QUALITY_PRESETS— 三档内置预设:档位 dpr 阴影 shadowMapSize 粒子倍率 high[1, 2]开 2048 ×1 medium[1, 1.5]开 1024 ×0.6 low[0.75, 1]关 512 ×0.3 -
useQualityStore—{ preset, settings, setPreset(name), setSettings(partial) }。 默认high;setSettings合并部分覆盖并把preset置为'custom'。 不持久化——玩家的画质选择由游戏自己存(localStorage / 存档槽)。 -
detectQualityPreset()— 设备启发式:无navigator(SSR / 测试)返回'high';"弱设备" =hardwareConcurrency≤ 4 或deviceMemory≤ 4GB (两者都有守卫,缺失不计);"移动端" = 粗指针(pointer: coarse)或移动 UA。 移动 + 弱 →low,移动 →medium,桌面 + 弱 →medium,其余 →high。 只是起点,把结果喂给setPreset并允许玩家覆盖。 -
<ApplyQuality />— 挂在<Canvas>内:把settings.dpr作为[min, max]区间交给 R3F 的setDpr(真实devicePixelRatio被钳制进该 区间);切换gl.shadowMap.enabled并置gl.shadowMap.needsUpdate = true。 注意:shadowMapSize不会被自动应用——投影灯光归游戏所有,自己在创建 灯光处读值(shadow-mapSize={[size, size]});运行中开关阴影后,阴影关闭期间 创建的材质可能需要needsUpdate/ 重挂载,尽量在场景挂载前定档。 -
useParticleMultiplier()— 读当前粒子倍率的便捷 selector。
import { ApplyQuality, detectQualityPreset, useQualityStore, useParticleMultiplier } from '@overworld-engine/scene'
useQualityStore.getState().setPreset(detectQualityPreset()) // 启动时定档
function World() {
const shadowMapSize = useQualityStore((s) => s.settings.shadowMapSize)
const particles = Math.round(200 * useParticleMultiplier())
return (
<Canvas shadows>
<ApplyQuality />
<directionalLight castShadow shadow-mapSize={[shadowMapSize, shadowMapSize]} />
{/* 用 particles 决定粒子数量 */}
</Canvas>
)
}注意事项
模型加载语义
无美术资产也能跑:省略 modelUrl / modelPath 时,玩家与 NPC 回退为胶囊体、
建筑回退为盒体、传送门回退为发光圆环。有模型路径时的完整语义:
- 加载中 — 显示同一个主题化占位体(
BaseNPC/BaseBuilding/Player内置<Suspense>,useModelLoader会正常挂起而不是吞掉 promise), 加载完成后模型自动出现,无需预加载。 - 加载失败(404 / 解析错误)— 打印一条
console.error并永久显示占位体; 组件内部的ModelErrorBoundary按模型 URL 作 key,修改路径即可重试。 preloadSceneModels— 纯性能优化:预加载后useGLTF从缓存同步解析, 跳过占位体闪现;不预加载也完全正确。
NPC 回退胶囊与名牌 / 角标 / 交互气泡高度随 scale 等比缩放(基准:NPC 默认
scale = 2.5,建筑基准 scale = 1,默认值下与旧版完全一致);
labelHeight prop 可覆盖名牌高度(角标与气泡保持其上方的比例间距)。
纯数学部分以 npcVisualHeights(scale, labelHeight?) /
buildingVisualHeights(scale, labelHeight?) 导出。