Overworld
包参考

@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:changeduseSceneStore.setScene(id) 切换场景时
proximity:enter / proximity:leave玩家进入 / 离开 NPC 或建筑的交互半径时
entity:interact附近有实体且按下交互键(useInteractKey / interact())时
interact(已弃用)entity:interact 同载荷双发;当前 3.2.x 仍保留,新代码不要订阅

核心组件 / API

  • <SceneShell> — 场景样板组合:碰撞注册 + 邻近检测 + NPC / 建筑循环 + 选中光环 + 玩家。场景专属内容(灯光、地面、传送门、装饰)作为 children 传入。 通过 npcIndicators(任务角标)与 interactHint(自定义交互提示)注入游戏状态; player prop 默认渲染 <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/inputcreateMovementInput() / <VirtualJoystick> 结构兼容——两个包互不 import。
  • <FollowCamera targetRef offset lerp> — 平滑跟随相机,可独立使用。
  • <BaseNPC> / <BaseBuilding> — 模型加载 + 名牌 + 发光 + 交互气泡, 颜色全部来自 theme;"是否在附近"读取本包的 useSceneStorelabelMode?: 'troika' | 'sprite' 切换标签文字的渲染方式(见下文"跨端标签")。
  • <SpriteLabel text color? background? fontSize? maxWidth? position?> — 跨端文字标签:offscreen canvas 纹理 + THREE.Sprite,零 DOM / Worker 依赖, 所有端可用(微信小游戏等 troika 不可用的环境必选)。
  • <SelectionRing> / <CollisionRegistration> / <Portal> — 地面选中环、 声明式碰撞注册、场景传送门(默认走 setScene,可用 onEnter 覆盖)。
  • useSceneStorecurrentScene / 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.errorpreloadSceneModels 因此只是性能优化(缓存命中时同步解析),不再是正确性前提。
  • 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/editorexportProject() 产出一份多场景项目 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 (渲染前请判空)。
  • 纯函数,且对松散输入容错(projectscenes 数组时返回 undefined)。
  • version / activeSceneId 会被接受但被 pickScene 忽略。

<SceneFromJson json={...}>SceneJson(NPC / 建筑 / 装饰放置)映射为 <SceneShell> 的内容 props,其余 prop(playerchildren 灯光/地面/传送门、 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 返回当前档 附近的预载顺序。

DecorationsDecorationSet[] 渲染 InstancedMesh,并可从同一份 instances 注册碰撞。公开辅助如下:

导出说明
instanceMatrix单个实例配置转 THREE.Matrix4
decorationColliders / collidersForSets从相同装饰数据派生 collider
setCentroid求装饰组中心
selectDecorationModel按距离与 LOD 配置选 modelPath
useDecorationCollision只复用碰撞注册,不使用默认渲染

详见密集世界指南

动画、相机、输入与质量辅助

导出说明
useModelClips从模型取得 animation clips
resolveClip / pickNpcClipName / deriveNpcAnimState解析 idle/walk/run clip
applyOrbitDelta / orbitToOffsetFollowCamera 轨道相机纯数学
useInputLockedReact 订阅共享 inputLock
resolveInputBlocked显式回调优先,否则查询共享输入锁
readWebglRenderer / isSoftwareRenderer安全读取并识别软件 WebGL renderer
qualityToLodCap将 high / medium / low 映射到 LOD 细节上限

getPlayerPosition() 返回当前玩家位置快照;teleportPlayer() 写入传送请求, consumePlayerTeleport() 由 Player 内部消费并清除。DEFAULT_NPC_SCALEDEFAULT_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?) 导出。

跨端标签(SpriteLabellabelMode)

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世界单位的文字高度(与 drei TextfontSize 同义), 栅格分辨率固定为 SPRITE_LABEL_FONT_PX(64px),两者互不影响;
  • maxWidth(世界单位)超宽时整体等比缩小;
  • 布局纯数学以 computeSpriteLabelLayout() 导出(可单测,无需 GL);
  • 无 DOM 环境先注册画布来源:setLabelCanvasFactory(() => wx.createCanvas())

BaseNPC / BaseBuildinglabelMode?: 'troika' | 'sprite'(默认 'troika',行为不变)一键切换名牌、任务角标与交互气泡的文字渲染; 'sprite' 模式使用系统字体(labelFont 被忽略)。微信小游戏传 labelMode="sprite"

本页目录