@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 同载荷双发;当前 3.2.x 仍保留,新代码不要订阅 |
核心组件 / 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。labelMode?: 'troika' | 'sprite'切换标签文字的渲染方式(见下文"跨端标签")。<SpriteLabel text color? background? fontSize? maxWidth? position?>— 跨端文字标签:offscreen canvas 纹理 +THREE.Sprite,零 DOM / Worker 依赖, 所有端可用(微信小游戏等 troika 不可用的环境必选)。<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事件(当前 3.2.x 同载荷双发 已弃用的interact;新代码只订阅前者)。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>
)
}从多场景项目取关(pickScene)
@overworld-engine/editor 的 exportProject() 产出一份多场景项目
SceneProjectJson({ version?, scenes: [{ id, name, scene }], activeSceneId? })。
本包提供 pickScene(project, nameOrId) 从中取出单个关卡的 SceneJson,交给
<SceneFromJson> 渲染 —— 游戏据此按关卡切换世界,而无需 import 编辑器包
(项目类型 SceneProjectLike 是结构化定义,直接传入解析后的 JSON 即可):
import { SceneFromJson, pickScene } from '@overworld-engine/scene'
const level = pickScene(project, 'level-1') // 先按 id 匹配,再回退按 name 匹配
return level ? <SceneFromJson json={level} player={null} /> : <Fallback />- 先按
id匹配、匹配不到再按显示name匹配;都没有时返回undefined(渲染前请判空)。 - 纯函数,且对松散输入容错(
project缺scenes数组时返回undefined)。 version/activeSceneId会被接受但被pickScene忽略。
<SceneFromJson json={...}> 把 SceneJson(NPC / 建筑 / 装饰放置)映射为
<SceneShell> 的内容 props,其余 prop(player、children 灯光/地面/传送门、
theme……)原样透传;SceneJson 与编辑器 exportScene() 的 EditorSceneJSON
结构等价,可直接互通。
同一转换也以纯函数公开:sceneJsonToShellProps(json) 只做 JSON → SceneShell
内容映射;sceneConfigToSceneJson(config) 把结构兼容的场景配置规范化为
SceneJson。工具链和测试不需要挂载 React 组件即可验证往返。
性能预设
面向移动端 / 低端设备的渲染质量分档:一个 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>
)
}生产世界 API
移动 NPC
SceneShell.npcPositionRefs 是首选路径:同一个 ref 同步 BaseNPC 的模型、名牌、
动画、碰撞、邻近检测与选择环。AgentNPC 则把结构类型 agent 接到 R3F frame
loop,适合自定义可视层:
<AgentNPC
npcId="guard"
agent={guardAgent}
positionRef={guardPositionRef}
animStateRef={guardAnimStateRef}
>
<GuardModel />
</AgentNPC>如果该 id 同时由 BaseNPC 跟随 ref,不要再用 AgentNPC 渲染第二份可视对象。
完全脱离 SceneShell 时,如需碰撞/邻近,应由应用显式注册 collider。
LOD 与实例化装饰
Lod 根据 playerPositionRef 到目标的距离选择 modelPath,只在 level 改变时
重新渲染,并预载相邻两档:
<Lod
position={[30, 0, 30]}
levels={[
{ distance: 0, modelPath: '/tower-high.glb' },
{ distance: 60, modelPath: '/tower-low.glb' },
]}
deviceCap={qualityToLodCap(useQualityStore.getState().preset)}
render={(url) => <Model url={url} />}
/>selectLodLevel 是带 hysteresis/deviceCap 的纯选择器,orderPreload 返回当前档
附近的预载顺序。
Decorations 用 DecorationSet[] 渲染 InstancedMesh,并可从同一份 instances
注册碰撞。公开辅助如下:
| 导出 | 说明 |
|---|---|
instanceMatrix | 单个实例配置转 THREE.Matrix4 |
decorationColliders / collidersForSets | 从相同装饰数据派生 collider |
setCentroid | 求装饰组中心 |
selectDecorationModel | 按距离与 LOD 配置选 modelPath |
useDecorationCollision | 只复用碰撞注册,不使用默认渲染 |
详见密集世界指南。
动画、相机、输入与质量辅助
| 导出 | 说明 |
|---|---|
useModelClips | 从模型取得 animation clips |
resolveClip / pickNpcClipName / deriveNpcAnimState | 解析 idle/walk/run clip |
applyOrbitDelta / orbitToOffset | FollowCamera 轨道相机纯数学 |
useInputLocked | React 订阅共享 inputLock |
resolveInputBlocked | 显式回调优先,否则查询共享输入锁 |
readWebglRenderer / isSoftwareRenderer | 安全读取并识别软件 WebGL renderer |
qualityToLodCap | 将 high / medium / low 映射到 LOD 细节上限 |
getPlayerPosition() 返回当前玩家位置快照;teleportPlayer() 写入传送请求,
consumePlayerTeleport() 由 Player 内部消费并清除。DEFAULT_NPC_SCALE 与
DEFAULT_BUILDING_SCALE 供自定义可视布局保持和内置组件一致。
注意事项
模型加载语义
无美术资产也能跑:省略 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?) 导出。
跨端标签(SpriteLabel 与 labelMode)
drei <Text>(troika)依赖 DOM / Worker,在微信小游戏等环境不可用。
<SpriteLabel> 提供零依赖替代:offscreen canvas 把文字栅格化成
THREE.CanvasTexture,贴到按文本宽高比缩放的 THREE.Sprite 上
(始终面向相机,居中锚点,同 anchorX/anchorY="center"):
<SpriteLabel text="铁匠铺" fontSize={0.5} background="rgba(0,0,0,0.6)" maxWidth={4} />fontSize为世界单位的文字高度(与 dreiText的fontSize同义), 栅格分辨率固定为SPRITE_LABEL_FONT_PX(64px),两者互不影响;maxWidth(世界单位)超宽时整体等比缩小;- 布局纯数学以
computeSpriteLabelLayout()导出(可单测,无需 GL); - 无 DOM 环境先注册画布来源:
setLabelCanvasFactory(() => wx.createCanvas())。
BaseNPC / BaseBuilding 的 labelMode?: 'troika' | 'sprite'(默认
'troika',行为不变)一键切换名牌、任务角标与交互气泡的文字渲染;
'sprite' 模式使用系统字体(labelFont 被忽略)。微信小游戏传
labelMode="sprite"。