agentworkshop 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (895) hide show
  1. package/.AgentWorkShop/prompts/host-tools.json +631 -0
  2. package/.AgentWorkShop/prompts/lead-supervise.md +42 -0
  3. package/.AgentWorkShop/prompts/mode-goal.md +26 -0
  4. package/.AgentWorkShop/prompts/mode-loop.md +10 -0
  5. package/.AgentWorkShop/prompts/mode-pipeline.md +16 -0
  6. package/.AgentWorkShop/prompts/peer-lead-roster.md +3 -0
  7. package/.AgentWorkShop/prompts/peer-message.md +14 -0
  8. package/.AgentWorkShop/prompts/peer-reply-optional.md +2 -0
  9. package/.AgentWorkShop/prompts/peer-reply-required.md +8 -0
  10. package/.AgentWorkShop/prompts/scenario-default.md +11 -0
  11. package/.AgentWorkShop/prompts/system-manual.md +18 -0
  12. package/.AgentWorkShop/prompts/team-roster.md +9 -0
  13. package/.AgentWorkShop/prompts/worker-workflow.md +19 -0
  14. package/.env.example +34 -0
  15. package/README-zh.md +424 -0
  16. package/README.md +424 -0
  17. package/app/.mimosa/hook-state/sess_05189059-e42d-4188-8911-4f7baabe009b.json +1 -0
  18. package/app/.mimosa/hook-state/sess_05189059-e42d-4188-8911-4f7baabe009b.mtg0vz68-49792-75b0709555.baseline/1459e6269990a5d229b9228c8b2f62ffaa7d350068380ae8b875fee0db45cca6.source +180 -0
  19. package/app/.mimosa/hook-state/sess_05189059-e42d-4188-8911-4f7baabe009b.mtg0vz68-49792-75b0709555.baseline/c2991e909c70c5c12b90a407c7adcf63384c51668a2b334e6e047fa675c268fe.source +305 -0
  20. package/app/.mimosa/hook-state/sess_05189059-e42d-4188-8911-4f7baabe009b.mtg0vz68-49792-75b0709555.baseline/e09129039d1e27e3fac24becc9bdafd2d1a7c8049bff24173df007be865f0165.source +264 -0
  21. package/app/.mimosa/hook-status/sess_05189059-e42d-4188-8911-4f7baabe009b-28ef7c75ea.json +14 -0
  22. package/app/app.vue +139 -0
  23. package/app/assets/css/main.css +1257 -0
  24. package/app/components/AppHeader.vue +675 -0
  25. package/app/components/AppSidebar.vue +286 -0
  26. package/app/components/AwChart.vue +44 -0
  27. package/app/components/AwLogo.vue +75 -0
  28. package/app/components/workshop/A2aRpcDebugger.vue +237 -0
  29. package/app/components/workshop/AgentInspectorDrawer.vue +424 -0
  30. package/app/components/workshop/AgentLanesView.vue +947 -0
  31. package/app/components/workshop/ArtifactCard.vue +120 -0
  32. package/app/components/workshop/AssetLibrary.vue +450 -0
  33. package/app/components/workshop/ChannelSessionList.vue +593 -0
  34. package/app/components/workshop/CommandPalette.vue +270 -0
  35. package/app/components/workshop/Composer.vue +731 -0
  36. package/app/components/workshop/DeviceTwinPanel.vue +607 -0
  37. package/app/components/workshop/EventBlock.vue +602 -0
  38. package/app/components/workshop/FileSelectorModal.vue +188 -0
  39. package/app/components/workshop/InspectorPanel.vue +265 -0
  40. package/app/components/workshop/LiveAgentBar.vue +362 -0
  41. package/app/components/workshop/MemoryPanel.vue +364 -0
  42. package/app/components/workshop/MentionHoverCard.vue +155 -0
  43. package/app/components/workshop/MultiChannelView.vue +189 -0
  44. package/app/components/workshop/PaneSplitter.vue +142 -0
  45. package/app/components/workshop/StatsPanel.vue +168 -0
  46. package/app/components/workshop/TaskBoardView.vue +636 -0
  47. package/app/components/workshop/TaskInspectorDrawer.vue +390 -0
  48. package/app/components/workshop/TranscriptTimeline.vue +545 -0
  49. package/app/components/workshop/blocks/ClusterArtifact.vue +77 -0
  50. package/app/components/workshop/blocks/ClusterError.vue +85 -0
  51. package/app/components/workshop/blocks/ClusterLife.vue +87 -0
  52. package/app/components/workshop/blocks/ClusterMember.vue +93 -0
  53. package/app/components/workshop/blocks/ClusterMemory.vue +84 -0
  54. package/app/components/workshop/blocks/ClusterOther.vue +67 -0
  55. package/app/components/workshop/blocks/ClusterRoute.vue +359 -0
  56. package/app/components/workshop/blocks/ClusterStatus.vue +147 -0
  57. package/app/components/workshop/blocks/ClusterStream.vue +325 -0
  58. package/app/components/workshop/blocks/ClusterTask.vue +151 -0
  59. package/app/components/workshop/blocks/ClusterTool.vue +158 -0
  60. package/app/components/workshop/lanes/LaneBlocks.vue +149 -0
  61. package/app/components/workshop/terminal/OmpTerminalPanel.vue +1010 -0
  62. package/app/components/workshop/town/ModelPreview3D.vue +276 -0
  63. package/app/components/workshop/town/TownScene.ts +1355 -0
  64. package/app/components/workshop/town/TownScene3D.ts +4533 -0
  65. package/app/components/workshop/town/TownView.vue +7071 -0
  66. package/app/composables/useCodeCopy.ts +30 -0
  67. package/app/composables/useHttp.ts +8 -0
  68. package/app/composables/useRouteMeta.ts +50 -0
  69. package/app/composables/useSiteConfig.ts +21 -0
  70. package/app/composables/workshop/apiClient.ts +95 -0
  71. package/app/composables/workshop/useCharacterAssets.ts +292 -0
  72. package/app/composables/workshop/useClusteredBlocks.ts +150 -0
  73. package/app/composables/workshop/useComposerBus.ts +16 -0
  74. package/app/composables/workshop/useDaqStream.ts +379 -0
  75. package/app/composables/workshop/useDcwStream.ts +300 -0
  76. package/app/composables/workshop/useDeviceTwins.ts +147 -0
  77. package/app/composables/workshop/useEventBlocks.ts +662 -0
  78. package/app/composables/workshop/useLocalTime.ts +33 -0
  79. package/app/composables/workshop/useOpsLog.ts +164 -0
  80. package/app/composables/workshop/useSceneLayouts.ts +166 -0
  81. package/app/composables/workshop/useTownBus.ts +47 -0
  82. package/app/composables/workshop/useVisibleInterval.ts +42 -0
  83. package/app/composables/workshop/useWorkshopApi.ts +224 -0
  84. package/app/composables/workshop/useWorkshopWs.ts +113 -0
  85. package/app/config/index.ts +66 -0
  86. package/app/config/schema.ts +68 -0
  87. package/app/error.vue +76 -0
  88. package/app/layouts/default.vue +100 -0
  89. package/app/layouts/town.vue +16 -0
  90. package/app/pages/daq/[id].vue +796 -0
  91. package/app/pages/daq/index.vue +2577 -0
  92. package/app/pages/dcw/[id].vue +2259 -0
  93. package/app/pages/dcw/index.vue +1079 -0
  94. package/app/pages/index.vue +830 -0
  95. package/app/pages/logs/index.vue +621 -0
  96. package/app/pages/monitor.vue +665 -0
  97. package/app/pages/settings.vue +757 -0
  98. package/app/pages/tokens.vue +610 -0
  99. package/app/pages/town.vue +145 -0
  100. package/app/pages/users.vue +257 -0
  101. package/app/pages/workshop/agents.vue +382 -0
  102. package/app/pages/workshop/channel-templates.vue +456 -0
  103. package/app/pages/workshop/index.vue +604 -0
  104. package/app/pages/workshop/teams.vue +448 -0
  105. package/app/pages/workshop/w/[wsId].vue +516 -0
  106. package/app/plugins/http.ts +71 -0
  107. package/app/plugins/locale-restore.client.ts +13 -0
  108. package/app/plugins/runtime-config.client.ts +65 -0
  109. package/app/plugins/session-restore.client.ts +24 -0
  110. package/app/plugins/theme.client.ts +15 -0
  111. package/app/stores/app.ts +32 -0
  112. package/app/stores/route-trail.ts +45 -0
  113. package/app/stores/runtime-config.ts +212 -0
  114. package/app/stores/workshop/connection.ts +184 -0
  115. package/app/stores/workshop/entities.ts +350 -0
  116. package/app/stores/workshop/events.ts +292 -0
  117. package/app/stores/workshop/user.ts +176 -0
  118. package/app/stores/workshop/workspaces.ts +133 -0
  119. package/bin/aw.mjs +13 -0
  120. package/cli/aw.mjs +184 -0
  121. package/cli/commands/build.mjs +33 -0
  122. package/cli/commands/config.mjs +157 -0
  123. package/cli/commands/dev.mjs +74 -0
  124. package/cli/commands/doctor.mjs +120 -0
  125. package/cli/commands/home.mjs +75 -0
  126. package/cli/commands/init.mjs +150 -0
  127. package/cli/commands/register.mjs +123 -0
  128. package/cli/commands/start.mjs +106 -0
  129. package/cli/commands/status.mjs +67 -0
  130. package/cli/commands/update.mjs +116 -0
  131. package/cli/commands/version.mjs +32 -0
  132. package/cli/core/args.mjs +95 -0
  133. package/cli/core/context.mjs +264 -0
  134. package/cli/core/errors.mjs +18 -0
  135. package/cli/core/help.mjs +68 -0
  136. package/cli/core/logger.mjs +40 -0
  137. package/cli/core/registry.mjs +111 -0
  138. package/config.yml +70 -0
  139. package/docker-compose.yml +34 -0
  140. package/docs/cli.md +220 -0
  141. package/i18n/dicts/_en.json +1097 -0
  142. package/i18n/dicts/_en2.json +300 -0
  143. package/i18n/dicts/_en3.json +102 -0
  144. package/i18n/dicts/_en4.json +115 -0
  145. package/i18n/dicts/_en5.json +33 -0
  146. package/i18n/dicts/a2aRpcDebugger.json +10 -0
  147. package/i18n/dicts/agentInspectorDrawer.json +25 -0
  148. package/i18n/dicts/agentLanesView.json +57 -0
  149. package/i18n/dicts/agents.json +27 -0
  150. package/i18n/dicts/appHeader.json +7 -0
  151. package/i18n/dicts/appSidebar.json +1 -0
  152. package/i18n/dicts/artifactCard.json +6 -0
  153. package/i18n/dicts/assetLibrary.json +18 -0
  154. package/i18n/dicts/awChart.json +1 -0
  155. package/i18n/dicts/awLogo.json +1 -0
  156. package/i18n/dicts/channelSessionList.json +45 -0
  157. package/i18n/dicts/clusterArtifact.json +4 -0
  158. package/i18n/dicts/clusterError.json +3 -0
  159. package/i18n/dicts/clusterLife.json +5 -0
  160. package/i18n/dicts/clusterMember.json +3 -0
  161. package/i18n/dicts/clusterMemory.json +3 -0
  162. package/i18n/dicts/clusterOther.json +3 -0
  163. package/i18n/dicts/clusterRoute.json +15 -0
  164. package/i18n/dicts/clusterStatus.json +3 -0
  165. package/i18n/dicts/clusterStream.json +3 -0
  166. package/i18n/dicts/clusterTask.json +10 -0
  167. package/i18n/dicts/clusterTool.json +3 -0
  168. package/i18n/dicts/commandPalette.json +16 -0
  169. package/i18n/dicts/composer.json +38 -0
  170. package/i18n/dicts/ctpl.json +32 -0
  171. package/i18n/dicts/daq.json +157 -0
  172. package/i18n/dicts/daqDetail.json +55 -0
  173. package/i18n/dicts/dcw.json +68 -0
  174. package/i18n/dicts/dcwDetail.json +199 -0
  175. package/i18n/dicts/deviceTwinPanel.json +12 -0
  176. package/i18n/dicts/eventBlock.json +8 -0
  177. package/i18n/dicts/fileSelectorModal.json +6 -0
  178. package/i18n/dicts/inspectorPanel.json +12 -0
  179. package/i18n/dicts/laneBlocks.json +6 -0
  180. package/i18n/dicts/liveAgentBar.json +8 -0
  181. package/i18n/dicts/logs.json +39 -0
  182. package/i18n/dicts/memoryPanel.json +20 -0
  183. package/i18n/dicts/mentionHoverCard.json +4 -0
  184. package/i18n/dicts/modelPreview3D.json +1 -0
  185. package/i18n/dicts/monitor.json +5 -0
  186. package/i18n/dicts/multiChannelView.json +13 -0
  187. package/i18n/dicts/ompTerminalPanel.json +7 -0
  188. package/i18n/dicts/paneSplitter.json +3 -0
  189. package/i18n/dicts/settings.json +1 -0
  190. package/i18n/dicts/statsPanel.json +6 -0
  191. package/i18n/dicts/taskBoardView.json +22 -0
  192. package/i18n/dicts/taskInspectorDrawer.json +19 -0
  193. package/i18n/dicts/teams.json +33 -0
  194. package/i18n/dicts/tokens.json +30 -0
  195. package/i18n/dicts/town.json +7 -0
  196. package/i18n/dicts/townView.json +209 -0
  197. package/i18n/dicts/transcriptTimeline.json +31 -0
  198. package/i18n/dicts/users.json +15 -0
  199. package/i18n/dicts/wsHome.json +33 -0
  200. package/i18n/dicts/wsView.json +15 -0
  201. package/i18n/locales/en.ts +1737 -0
  202. package/i18n/locales/zh-CN.ts +1736 -0
  203. package/nuxt.config.ts +209 -0
  204. package/package.json +123 -0
  205. package/public/assets/game/character/bot.png +0 -0
  206. package/public/assets/game/character/device-3d.glb +0 -0
  207. package/public/assets/game/character/hero-3d.glb +0 -0
  208. package/public/assets/game/character/hero-anime-1.glb +0 -0
  209. package/public/assets/game/character/hero-anime-2.glb +0 -0
  210. package/public/assets/game/character/hero-anime-3.glb +0 -0
  211. package/public/assets/game/character/hero-anime-4.glb +0 -0
  212. package/public/assets/game/character/knight.png +0 -0
  213. package/public/assets/game/character/mage.png +0 -0
  214. package/public/assets/game/coin.png +0 -0
  215. package/public/assets/game/devices/agv.glb +0 -0
  216. package/public/assets/game/devices/caster.glb +0 -0
  217. package/public/assets/game/devices/device-console.glb +0 -0
  218. package/public/assets/game/devices/device-robot-arm.glb +0 -0
  219. package/public/assets/game/devices/device-scanner.glb +0 -0
  220. package/public/assets/game/devices/extruder.glb +0 -0
  221. package/public/assets/game/devices/mdo.glb +0 -0
  222. package/public/assets/game/devices/power-cabinet.glb +0 -0
  223. package/public/assets/game/devices/pump.glb +0 -0
  224. package/public/assets/game/devices/tdo.glb +0 -0
  225. package/public/assets/game/devices/thickness-scanner.glb +0 -0
  226. package/public/assets/game/devices/winder.glb +0 -0
  227. package/public/assets/game/npc/boltnu-sheet.png +0 -0
  228. package/public/assets/game/npc/fluttaflap-sheet.png +0 -0
  229. package/public/assets/game/npc/rockitten-sheet.png +0 -0
  230. package/public/assets/game/npc/tux-sheet.png +0 -0
  231. package/public/assets/game/player/misa-back-walk.000.png +0 -0
  232. package/public/assets/game/player/misa-back-walk.001.png +0 -0
  233. package/public/assets/game/player/misa-back-walk.002.png +0 -0
  234. package/public/assets/game/player/misa-back-walk.003.png +0 -0
  235. package/public/assets/game/player/misa-back.png +0 -0
  236. package/public/assets/game/player/misa-front-walk.000.png +0 -0
  237. package/public/assets/game/player/misa-front-walk.001.png +0 -0
  238. package/public/assets/game/player/misa-front-walk.002.png +0 -0
  239. package/public/assets/game/player/misa-front-walk.003.png +0 -0
  240. package/public/assets/game/player/misa-front.png +0 -0
  241. package/public/assets/game/player/misa-left-walk.000.png +0 -0
  242. package/public/assets/game/player/misa-left-walk.001.png +0 -0
  243. package/public/assets/game/player/misa-left-walk.002.png +0 -0
  244. package/public/assets/game/player/misa-left-walk.003.png +0 -0
  245. package/public/assets/game/player/misa-left.png +0 -0
  246. package/public/assets/game/player/misa-right-walk.000.png +0 -0
  247. package/public/assets/game/player/misa-right-walk.001.png +0 -0
  248. package/public/assets/game/player/misa-right-walk.002.png +0 -0
  249. package/public/assets/game/player/misa-right-walk.003.png +0 -0
  250. package/public/assets/game/player/misa-right.png +0 -0
  251. package/public/assets/game/player-sheet.png +0 -0
  252. package/public/assets/game/tuxemon-town.json +1 -0
  253. package/public/assets/game/tuxmon-sample-32px-extruded.png +0 -0
  254. package/public/assets/game/wuwa/scene-map.png +0 -0
  255. package/public/assets/game/wuwa/world-far.png +0 -0
  256. package/public/assets/game/wuwa/world-middle.png +0 -0
  257. package/public/assets/game/wuwa/world-near.png +0 -0
  258. package/public/assets/game/wuwa/wu-aura.png +0 -0
  259. package/public/assets/game/wuwa/wu-lead.png +0 -0
  260. package/public/assets/game/wuwa/wu-ring.png +0 -0
  261. package/public/assets/game/wuwa/wu-slash.png +0 -0
  262. package/public/assets/game/wuwa/wu-worker-0.png +0 -0
  263. package/public/assets/game/wuwa/wu-worker-1.png +0 -0
  264. package/public/assets/game/wuwa/wu-worker-2.png +0 -0
  265. package/public/favicon.svg +14 -0
  266. package/public/scene/background/cyber-town-background.svg +110 -0
  267. package/scripts/_daq-realtime-probe.mjs +68 -0
  268. package/scripts/_daq-warn-probe.mjs +50 -0
  269. package/scripts/_dbg-agent-tools-audit.mjs +154 -0
  270. package/scripts/_dbg-agent-workflow-audit.mjs +177 -0
  271. package/scripts/_dbg-anim-verify.mjs +81 -0
  272. package/scripts/_dbg-audit-all-pages.mjs +43 -0
  273. package/scripts/_dbg-autoframe.mjs +103 -0
  274. package/scripts/_dbg-beacon-final.mjs +181 -0
  275. package/scripts/_dbg-bind-bidi-e2e.mjs +239 -0
  276. package/scripts/_dbg-callout-near.mjs +108 -0
  277. package/scripts/_dbg-chan-focus.mjs +49 -0
  278. package/scripts/_dbg-cleanup.mjs +31 -0
  279. package/scripts/_dbg-closedloop-ui-shot.mjs +61 -0
  280. package/scripts/_dbg-console-audit.mjs +61 -0
  281. package/scripts/_dbg-console-probe.mjs +23 -0
  282. package/scripts/_dbg-context-inject-audit.mjs +231 -0
  283. package/scripts/_dbg-crash-recovery.mjs +143 -0
  284. package/scripts/_dbg-crop-shot.mjs +16 -0
  285. package/scripts/_dbg-daq-acc-probe.mjs +24 -0
  286. package/scripts/_dbg-daq-attrib.mjs +34 -0
  287. package/scripts/_dbg-daq-bind-audit.mjs +119 -0
  288. package/scripts/_dbg-daq-cadence.mjs +104 -0
  289. package/scripts/_dbg-daq-debug.mjs +22 -0
  290. package/scripts/_dbg-daq-diag.mjs +33 -0
  291. package/scripts/_dbg-daq-e2e.mjs +171 -0
  292. package/scripts/_dbg-daq-film.mjs +136 -0
  293. package/scripts/_dbg-daq-filter-e2e.mjs +109 -0
  294. package/scripts/_dbg-daq-filter-e2e2.mjs +62 -0
  295. package/scripts/_dbg-daq-final-regression.mjs +78 -0
  296. package/scripts/_dbg-daq-full-check.mjs +179 -0
  297. package/scripts/_dbg-daq-line-note-verify.mjs +61 -0
  298. package/scripts/_dbg-daq-linebanner-audit.mjs +107 -0
  299. package/scripts/_dbg-daq-pages-shot.mjs +37 -0
  300. package/scripts/_dbg-daq-perf.mjs +61 -0
  301. package/scripts/_dbg-daq-probe.mjs +27 -0
  302. package/scripts/_dbg-daq-recipe-e2e.mjs +30 -0
  303. package/scripts/_dbg-daq-redesign-shot.mjs +88 -0
  304. package/scripts/_dbg-daq-rows-probe.mjs +26 -0
  305. package/scripts/_dbg-daq-rowstate-e2e.mjs +152 -0
  306. package/scripts/_dbg-daq-rowstate-shot.mjs +45 -0
  307. package/scripts/_dbg-daq-rowstate-shot2.mjs +38 -0
  308. package/scripts/_dbg-daq-tpl.mjs +144 -0
  309. package/scripts/_dbg-dash-console-check.mjs +49 -0
  310. package/scripts/_dbg-dash-deep-probe.mjs +61 -0
  311. package/scripts/_dbg-dash-hitpoint.mjs +44 -0
  312. package/scripts/_dbg-dash-live-shot.mjs +36 -0
  313. package/scripts/_dbg-dash-shot.mjs +21 -0
  314. package/scripts/_dbg-dashboard-shot.mjs +35 -0
  315. package/scripts/_dbg-dcw-audit.mjs +126 -0
  316. package/scripts/_dbg-dcw-card-e2e.mjs +108 -0
  317. package/scripts/_dbg-dcw-channel-ui.mjs +144 -0
  318. package/scripts/_dbg-dcw-examples.mjs +81 -0
  319. package/scripts/_dbg-dcw-light-shot.mjs +19 -0
  320. package/scripts/_dbg-dcw-line-audit.mjs +107 -0
  321. package/scripts/_dbg-dcw-pause-tpl-audit.mjs +161 -0
  322. package/scripts/_dbg-dcw-pause-ui.mjs +175 -0
  323. package/scripts/_dbg-dcw-scene.mjs +72 -0
  324. package/scripts/_dbg-dcw-shot2.mjs +14 -0
  325. package/scripts/_dbg-dcw-twin-e2e.mjs +192 -0
  326. package/scripts/_dbg-dt-interact.mjs +193 -0
  327. package/scripts/_dbg-error-probe.mjs +54 -0
  328. package/scripts/_dbg-filter-light-verify.mjs +54 -0
  329. package/scripts/_dbg-final-acceptance-2.mjs +72 -0
  330. package/scripts/_dbg-final-acceptance.mjs +192 -0
  331. package/scripts/_dbg-final-agent-e2e.mjs +151 -0
  332. package/scripts/_dbg-final-full-e2e.mjs +218 -0
  333. package/scripts/_dbg-final-line-e2e.mjs +173 -0
  334. package/scripts/_dbg-final-t6-hitl.mjs +38 -0
  335. package/scripts/_dbg-focus-range.mjs +115 -0
  336. package/scripts/_dbg-fresh-scene-e2e.mjs +125 -0
  337. package/scripts/_dbg-full-feature-e2e.mjs +219 -0
  338. package/scripts/_dbg-fullchain-e2e.mjs +178 -0
  339. package/scripts/_dbg-gate-debug.mjs +37 -0
  340. package/scripts/_dbg-gif-encode.mjs +111 -0
  341. package/scripts/_dbg-gizmo.mjs +95 -0
  342. package/scripts/_dbg-glass-probe.mjs +57 -0
  343. package/scripts/_dbg-glass-shot.mjs +50 -0
  344. package/scripts/_dbg-glass-sweep.mjs +25 -0
  345. package/scripts/_dbg-glb-probe.mjs +43 -0
  346. package/scripts/_dbg-hardening-probe.mjs +90 -0
  347. package/scripts/_dbg-hero-btn-probe.mjs +21 -0
  348. package/scripts/_dbg-hidden-check.mjs +21 -0
  349. package/scripts/_dbg-hitl-timeout-e2e.mjs +93 -0
  350. package/scripts/_dbg-hmi-audit.mjs +164 -0
  351. package/scripts/_dbg-home-repro.mjs +24 -0
  352. package/scripts/_dbg-home-stack.mjs +13 -0
  353. package/scripts/_dbg-huashu-survey.mjs +31 -0
  354. package/scripts/_dbg-huashu-survey2.mjs +35 -0
  355. package/scripts/_dbg-huashu-verify.mjs +25 -0
  356. package/scripts/_dbg-huashu-verify2.mjs +26 -0
  357. package/scripts/_dbg-hydration-detail.mjs +18 -0
  358. package/scripts/_dbg-i18n-debug.mjs +27 -0
  359. package/scripts/_dbg-i18n-debug2.mjs +26 -0
  360. package/scripts/_dbg-i18n-final.mjs +42 -0
  361. package/scripts/_dbg-i18n-scan.mjs +38 -0
  362. package/scripts/_dbg-infra-lifecycle.mjs +44 -0
  363. package/scripts/_dbg-line-aura-shots.mjs +54 -0
  364. package/scripts/_dbg-line-control-e2e.mjs +120 -0
  365. package/scripts/_dbg-line-gate-audit.mjs +108 -0
  366. package/scripts/_dbg-line-live-e2e.mjs +144 -0
  367. package/scripts/_dbg-line-scenario-e2e.mjs +256 -0
  368. package/scripts/_dbg-minimal-omp.mjs +77 -0
  369. package/scripts/_dbg-mode-nav.mjs +173 -0
  370. package/scripts/_dbg-mqtt-chain-e2e.mjs +90 -0
  371. package/scripts/_dbg-nav-rpg.mjs +84 -0
  372. package/scripts/_dbg-nav-shot.mjs +28 -0
  373. package/scripts/_dbg-nav.mjs +27 -0
  374. package/scripts/_dbg-opslog-e2e.mjs +121 -0
  375. package/scripts/_dbg-opslog-shots.mjs +62 -0
  376. package/scripts/_dbg-opt-team-e2e.mjs +133 -0
  377. package/scripts/_dbg-optimization-e2e.mjs +94 -0
  378. package/scripts/_dbg-orbit.mjs +58 -0
  379. package/scripts/_dbg-panesplitter-fix-check.mjs +62 -0
  380. package/scripts/_dbg-phase-e3-monitor.mjs +25 -0
  381. package/scripts/_dbg-pop-clamp-verify.mjs +77 -0
  382. package/scripts/_dbg-preset-e2e.mjs +116 -0
  383. package/scripts/_dbg-preview-console.mjs +28 -0
  384. package/scripts/_dbg-preview-minrepro.mjs +156 -0
  385. package/scripts/_dbg-preview-pixels.mjs +93 -0
  386. package/scripts/_dbg-quality-pages-e2e.mjs +84 -0
  387. package/scripts/_dbg-query-cancel.ts +12 -0
  388. package/scripts/_dbg-rail-tree-e2e.mjs +183 -0
  389. package/scripts/_dbg-real-driver-e2e.mjs +55 -0
  390. package/scripts/_dbg-redesign-shots.mjs +34 -0
  391. package/scripts/_dbg-restart-persist.mjs +41 -0
  392. package/scripts/_dbg-rollback-mgr-audit.mjs +169 -0
  393. package/scripts/_dbg-rpg.mjs +71 -0
  394. package/scripts/_dbg-sc001-dispatch.mjs +63 -0
  395. package/scripts/_dbg-scenario-final-shot.mjs +24 -0
  396. package/scripts/_dbg-scenario-phase-e2.mjs +75 -0
  397. package/scripts/_dbg-scenario-phase-e3.mjs +96 -0
  398. package/scripts/_dbg-scenario-phase1.mjs +90 -0
  399. package/scripts/_dbg-scenario-phase2.mjs +105 -0
  400. package/scripts/_dbg-scenario-phase3-monitor.mjs +48 -0
  401. package/scripts/_dbg-scenario-phase3.mjs +111 -0
  402. package/scripts/_dbg-scene-polish-shot.mjs +118 -0
  403. package/scripts/_dbg-selring-probe.mjs +47 -0
  404. package/scripts/_dbg-sideinfo.mjs +101 -0
  405. package/scripts/_dbg-splitter.mjs +88 -0
  406. package/scripts/_dbg-team-repro.mjs +76 -0
  407. package/scripts/_dbg-town-alarm-e2e.mjs +40 -0
  408. package/scripts/_dbg-town-beauty-shots.mjs +54 -0
  409. package/scripts/_dbg-town-daq-shot.mjs +26 -0
  410. package/scripts/_dbg-town-full-e2e.mjs +104 -0
  411. package/scripts/_dbg-town-gate.mjs +44 -0
  412. package/scripts/_dbg-town-ins-probe.mjs +37 -0
  413. package/scripts/_dbg-town-perf.mjs +50 -0
  414. package/scripts/_dbg-town-polish-shots.mjs +82 -0
  415. package/scripts/_dbg-town-realtime.mjs +42 -0
  416. package/scripts/_dbg-town-shot3.mjs +17 -0
  417. package/scripts/_dbg-town-truth.mjs +37 -0
  418. package/scripts/_dbg-town-truth2.mjs +27 -0
  419. package/scripts/_dbg-transform-audit.mjs +98 -0
  420. package/scripts/_dbg-trend-expand-test.mjs +38 -0
  421. package/scripts/_dbg-twindaq-focus.mjs +81 -0
  422. package/scripts/_dbg-ui-polish-shot.mjs +43 -0
  423. package/scripts/_dbg-ui-polish-verify1.mjs +92 -0
  424. package/scripts/_dbg-ui-polish-verify2.mjs +50 -0
  425. package/scripts/_dbg-ui-polish-verify3.mjs +51 -0
  426. package/scripts/_dbg-ui-polish-verify4.mjs +50 -0
  427. package/scripts/_dbg-ui-shot.mjs +26 -0
  428. package/scripts/_dbg-unify-shot.mjs +43 -0
  429. package/scripts/_dbg-uplink-probe.mjs +36 -0
  430. package/scripts/_dbg-wizard-preview-shot.mjs +58 -0
  431. package/scripts/_dbg-wizard-ui-e2e.mjs +93 -0
  432. package/scripts/_dbg-ws-probe.mjs +86 -0
  433. package/scripts/_dbg-ws-shot2.mjs +23 -0
  434. package/scripts/_dbg-wsdot-sanity.mjs +25 -0
  435. package/scripts/_dev-stack.mjs +46 -0
  436. package/scripts/_http-driver-probe.mjs +30 -0
  437. package/scripts/_lib-dcw-line.mjs +24 -0
  438. package/scripts/_mqtt-driver-probe.mjs +30 -0
  439. package/scripts/_newdriver-rest-probe.mjs +100 -0
  440. package/scripts/_node-state-probe.mjs +5 -0
  441. package/scripts/_opcua-driver-probe.mjs +31 -0
  442. package/scripts/_rtu-debug.mjs +41 -0
  443. package/scripts/_rtu-direct.mjs +6 -0
  444. package/scripts/_rtu-driver-probe.mjs +53 -0
  445. package/scripts/_rtu-mini-slave.mjs +63 -0
  446. package/scripts/_townview_style.css +1185 -0
  447. package/scripts/_townview_template.html +1215 -0
  448. package/scripts/_wizard-shots.mjs +90 -0
  449. package/scripts/acceptance-suite.py +207 -0
  450. package/scripts/api-live-e2e.mjs +467 -0
  451. package/scripts/build-anime-models.mjs +339 -0
  452. package/scripts/build-character-models.mjs +175 -0
  453. package/scripts/build-film-devices.mjs +227 -0
  454. package/scripts/build-model-3d.mjs +173 -0
  455. package/scripts/build-player-sheet.mjs +58 -0
  456. package/scripts/build-world-map.mjs +188 -0
  457. package/scripts/build-wuwa-assets.mjs +345 -0
  458. package/scripts/collab-live-test.py +177 -0
  459. package/scripts/debug-mcp-http.ts +64 -0
  460. package/scripts/demo-lead-worker.ts +256 -0
  461. package/scripts/demo-modes.ts +415 -0
  462. package/scripts/demo-omp-channel.ts +135 -0
  463. package/scripts/design-preflight.mjs +223 -0
  464. package/scripts/design-verify-templates.mjs +110 -0
  465. package/scripts/design-verify.mjs +170 -0
  466. package/scripts/dev-guard.mjs +74 -0
  467. package/scripts/dev-modbus-simulator.mjs +55 -0
  468. package/scripts/dev-opcua-simulator.mjs +57 -0
  469. package/scripts/dev-protocol-simulators.mjs +145 -0
  470. package/scripts/dev-window.cmd +6 -0
  471. package/scripts/device-line-restore.mjs +34 -0
  472. package/scripts/diag-lead-status.ts +101 -0
  473. package/scripts/e2e-a2a-steer-live.mjs +193 -0
  474. package/scripts/e2e-agent-channel-task.ts +151 -0
  475. package/scripts/e2e-agent-team.mjs +168 -0
  476. package/scripts/e2e-auth-matrix.mjs +119 -0
  477. package/scripts/e2e-channel-isolation.ts +325 -0
  478. package/scripts/e2e-dual-drive-comm.ts +259 -0
  479. package/scripts/e2e-lead-team-mgmt.ts +217 -0
  480. package/scripts/e2e-memory-persistence.ts +320 -0
  481. package/scripts/e2e-memory-system.ts +332 -0
  482. package/scripts/e2e-omp-modes.ts +274 -0
  483. package/scripts/e2e-omp-realtime.ts +258 -0
  484. package/scripts/e2e-omp-workspace.ts +180 -0
  485. package/scripts/e2e-parallel-execution.mjs +151 -0
  486. package/scripts/e2e-rest-memory.mjs +251 -0
  487. package/scripts/e2e-rest-robustness.mjs +177 -0
  488. package/scripts/e2e-resume-crash.mjs +254 -0
  489. package/scripts/e2e-tab-close.mjs +136 -0
  490. package/scripts/e2e-task-queue.ts +277 -0
  491. package/scripts/e2e-template-isolation.mjs +267 -0
  492. package/scripts/e2e-token-ui.mjs +149 -0
  493. package/scripts/final-soup-run.py +192 -0
  494. package/scripts/final-soup-watch.py +83 -0
  495. package/scripts/home-bootstrap.mjs +117 -0
  496. package/scripts/i18n-codemod.mjs +173 -0
  497. package/scripts/i18n-fix-en.py +28 -0
  498. package/scripts/i18n-fix-en2.py +31 -0
  499. package/scripts/i18n-merge.mjs +67 -0
  500. package/scripts/omp-real-test.py +328 -0
  501. package/scripts/smoke-optimization-verify.ts +72 -0
  502. package/scripts/start.mjs +60 -0
  503. package/scripts/test-a2a-relay.mjs +222 -0
  504. package/scripts/test-a2a-types.ts +296 -0
  505. package/scripts/test-agent-brain.ts +135 -0
  506. package/scripts/test-agent-multi-channel.ts +160 -0
  507. package/scripts/test-agent-runtime.ts +348 -0
  508. package/scripts/test-api-client.ts +108 -0
  509. package/scripts/test-api-suite.mjs +271 -0
  510. package/scripts/test-block-consumption.mjs +194 -0
  511. package/scripts/test-channel-routing.ts +301 -0
  512. package/scripts/test-cmd-render.mjs +218 -0
  513. package/scripts/test-collab-e2e.mjs +136 -0
  514. package/scripts/test-collab-omp.mjs +192 -0
  515. package/scripts/test-dedup-logic.ts +132 -0
  516. package/scripts/test-dual-drive.ts +227 -0
  517. package/scripts/test-e2e-collaboration.ts +262 -0
  518. package/scripts/test-e2e-monitor.ts +192 -0
  519. package/scripts/test-event-history.ts +112 -0
  520. package/scripts/test-event-persistence.mjs +169 -0
  521. package/scripts/test-exec-modes.ts +316 -0
  522. package/scripts/test-fifo-realtime.mjs +193 -0
  523. package/scripts/test-final-e2e.mjs +290 -0
  524. package/scripts/test-full-system.ts +439 -0
  525. package/scripts/test-game-session.ts +160 -0
  526. package/scripts/test-goal-summary.ts +202 -0
  527. package/scripts/test-harness-liveness.ts +109 -0
  528. package/scripts/test-harness-terminal.ts +164 -0
  529. package/scripts/test-header-trail.mjs +95 -0
  530. package/scripts/test-hitl.ts +360 -0
  531. package/scripts/test-koda-restyle.mjs +105 -0
  532. package/scripts/test-lane-history-live.mjs +103 -0
  533. package/scripts/test-lane-raw.ts +67 -0
  534. package/scripts/test-lanes-history-render.mjs +138 -0
  535. package/scripts/test-loop-omp.mjs +154 -0
  536. package/scripts/test-mcp-tools.ts +285 -0
  537. package/scripts/test-mdlite.ts +53 -0
  538. package/scripts/test-memory-dynamic.ts +99 -0
  539. package/scripts/test-memory-maintenance.ts +117 -0
  540. package/scripts/test-memory-vector.ts +154 -0
  541. package/scripts/test-memory.ts +431 -0
  542. package/scripts/test-mock-collab.mjs +226 -0
  543. package/scripts/test-modern-render.mjs +174 -0
  544. package/scripts/test-omp-terminal.mjs +187 -0
  545. package/scripts/test-orchestration.ts +425 -0
  546. package/scripts/test-persistence-lazy.ts +323 -0
  547. package/scripts/test-prompt-system.mjs +181 -0
  548. package/scripts/test-prompts-loader.ts +56 -0
  549. package/scripts/test-protocol.ts +156 -0
  550. package/scripts/test-real-scenario.mjs +290 -0
  551. package/scripts/test-route-reason.mjs +111 -0
  552. package/scripts/test-scheduler-loop.ts +469 -0
  553. package/scripts/test-stream-render.ts +37 -0
  554. package/scripts/test-task-drawer.mjs +204 -0
  555. package/scripts/test-task-engine.ts +433 -0
  556. package/scripts/test-task-invariants.mjs +169 -0
  557. package/scripts/test-terminal-e2e.mjs +264 -0
  558. package/scripts/test-town-3d.mjs +121 -0
  559. package/scripts/test-town-drag.mjs +218 -0
  560. package/scripts/test-town-layout.mjs +200 -0
  561. package/scripts/test-town-page.mjs +136 -0
  562. package/scripts/test-town-scene-math.ts +169 -0
  563. package/scripts/test-town-twin.mjs +121 -0
  564. package/scripts/test-town-upload.mjs +89 -0
  565. package/scripts/test-town-user-flow.mjs +619 -0
  566. package/scripts/test-town-world.mjs +76 -0
  567. package/scripts/test-town-wuwa.mjs +198 -0
  568. package/scripts/test-user-isolation.mjs +193 -0
  569. package/scripts/test-workshop-db.ts +280 -0
  570. package/scripts/test-workshop-entries.ts +370 -0
  571. package/scripts/test-workspace-api.ts +329 -0
  572. package/scripts/test-ws-aep.mjs +187 -0
  573. package/scripts/test-ws-protocol.ts +134 -0
  574. package/scripts/test-ws-stress.mjs +208 -0
  575. package/scripts/town-demo-seed.mjs +75 -0
  576. package/scripts/town-scene-clear.mjs +56 -0
  577. package/scripts/ui-screenshot.mjs +128 -0
  578. package/scripts/verify-a2a-live.mjs +124 -0
  579. package/scripts/verify-exec-modes-report.ts +248 -0
  580. package/scripts/verify-game.mjs +477 -0
  581. package/scripts/verify-lead-mail.ts +188 -0
  582. package/scripts/verify-page-smoke.mjs +87 -0
  583. package/scripts/verify-ws-events.mjs +123 -0
  584. package/scripts/ws-test.mjs +176 -0
  585. package/scripts/ws-test.py +4 -0
  586. package/server/api/health.get.ts +32 -0
  587. package/server/api/mcp/workshop.ts +78 -0
  588. package/server/api/metrics.ts +75 -0
  589. package/server/api/system/config.get.ts +12 -0
  590. package/server/api/system/monitor/terminal/ws.ts +177 -0
  591. package/server/api/system/monitor/terminate.post.ts +36 -0
  592. package/server/api/system/monitor.get.ts +13 -0
  593. package/server/api/system/settings/events.get.ts +28 -0
  594. package/server/api/system/settings/index.get.ts +14 -0
  595. package/server/api/system/settings/index.patch.ts +22 -0
  596. package/server/api/system/settings/reset.post.ts +12 -0
  597. package/server/api/users/[id].delete.ts +11 -0
  598. package/server/api/users/[id].get.ts +11 -0
  599. package/server/api/users/[id].put.ts +14 -0
  600. package/server/api/users/index.get.ts +13 -0
  601. package/server/api/users/index.post.ts +13 -0
  602. package/server/api/users/login.post.ts +11 -0
  603. package/server/api/users/logout.post.ts +9 -0
  604. package/server/api/users/me.get.ts +9 -0
  605. package/server/api/users/register.post.ts +11 -0
  606. package/server/api/users/tokens/[id]/token.get.ts +11 -0
  607. package/server/api/users/tokens/[id].delete.ts +11 -0
  608. package/server/api/users/tokens/[id].patch.ts +14 -0
  609. package/server/api/users/tokens/index.get.ts +9 -0
  610. package/server/api/users/tokens/index.post.ts +13 -0
  611. package/server/api/workshop/[...path].ts +13 -0
  612. package/server/api/workshop/a2a/[agentId]/card/index.get.ts +75 -0
  613. package/server/api/workshop/a2a/[agentId]/rpc/index.post.ts +429 -0
  614. package/server/api/workshop/a2a/send.post.ts +28 -0
  615. package/server/api/workshop/agent-tools/approvals/[id]/decide.post.ts +21 -0
  616. package/server/api/workshop/agent-tools/approvals/index.get.ts +18 -0
  617. package/server/api/workshop/agent-tools/bindings/[id].delete.ts +21 -0
  618. package/server/api/workshop/agent-tools/bindings/[id].patch.ts +14 -0
  619. package/server/api/workshop/agent-tools/bindings/index.get.ts +14 -0
  620. package/server/api/workshop/agent-tools/bindings/index.post.ts +20 -0
  621. package/server/api/workshop/agent-tools/invoke.post.ts +34 -0
  622. package/server/api/workshop/agents/[id].delete.ts +20 -0
  623. package/server/api/workshop/agents/[id].get.ts +19 -0
  624. package/server/api/workshop/agents/[id].patch.ts +30 -0
  625. package/server/api/workshop/agents/index.get.ts +13 -0
  626. package/server/api/workshop/agents/index.post.ts +29 -0
  627. package/server/api/workshop/agents/subscribe.post.ts +21 -0
  628. package/server/api/workshop/approvals/[id]/decide.post.ts +30 -0
  629. package/server/api/workshop/approvals/index.get.ts +22 -0
  630. package/server/api/workshop/assets/character/[id].delete.ts +33 -0
  631. package/server/api/workshop/assets/character/index.get.ts +17 -0
  632. package/server/api/workshop/assets/character/index.post.ts +87 -0
  633. package/server/api/workshop/assets/devices/[id].delete.ts +43 -0
  634. package/server/api/workshop/assets/devices/index.get.ts +103 -0
  635. package/server/api/workshop/assets/devices/index.post.ts +53 -0
  636. package/server/api/workshop/audit.get.ts +43 -0
  637. package/server/api/workshop/caller.ts +141 -0
  638. package/server/api/workshop/channel-templates/[id]/instantiate.post.ts +31 -0
  639. package/server/api/workshop/channel-templates/[id].delete.ts +20 -0
  640. package/server/api/workshop/channel-templates/[id].get.ts +19 -0
  641. package/server/api/workshop/channel-templates/[id].patch.ts +48 -0
  642. package/server/api/workshop/channel-templates/from-channel.post.ts +31 -0
  643. package/server/api/workshop/channel-templates/index.get.ts +13 -0
  644. package/server/api/workshop/channel-templates/index.post.ts +61 -0
  645. package/server/api/workshop/channels/[id]/activate.post.ts +19 -0
  646. package/server/api/workshop/channels/[id]/agents/[agentId]/memories/[memoryId].delete.ts +30 -0
  647. package/server/api/workshop/channels/[id]/agents/[agentId]/memories/index.get.ts +29 -0
  648. package/server/api/workshop/channels/[id]/agents/[agentId]/memories/index.post.ts +48 -0
  649. package/server/api/workshop/channels/[id]/agents/[agentId]/memories/search.post.ts +43 -0
  650. package/server/api/workshop/channels/[id]/agents/[agentId]/model.patch.ts +39 -0
  651. package/server/api/workshop/channels/[id]/agents/[agentId]/position.patch.ts +33 -0
  652. package/server/api/workshop/channels/[id]/agents/[agentId]/range.patch.ts +54 -0
  653. package/server/api/workshop/channels/[id]/agents/[agentId]/stop.post.ts +25 -0
  654. package/server/api/workshop/channels/[id]/agents/[agentId].delete.ts +23 -0
  655. package/server/api/workshop/channels/[id]/agents/[agentId].patch.ts +28 -0
  656. package/server/api/workshop/channels/[id]/agents/index.get.ts +27 -0
  657. package/server/api/workshop/channels/[id]/agents/index.post.ts +61 -0
  658. package/server/api/workshop/channels/[id]/events.get.ts +55 -0
  659. package/server/api/workshop/channels/[id]/index.get.ts +18 -0
  660. package/server/api/workshop/channels/[id]/index.patch.ts +30 -0
  661. package/server/api/workshop/channels/[id]/memories/[memoryId].delete.ts +29 -0
  662. package/server/api/workshop/channels/[id]/memories/index.get.ts +28 -0
  663. package/server/api/workshop/channels/[id]/memories/index.post.ts +42 -0
  664. package/server/api/workshop/channels/[id]/messages/index.get.ts +56 -0
  665. package/server/api/workshop/channels/[id]/messages/index.post.ts +75 -0
  666. package/server/api/workshop/channels/[id]/queue.get.ts +25 -0
  667. package/server/api/workshop/channels/[id]/tasks/index.get.ts +24 -0
  668. package/server/api/workshop/channels/[id]/tasks/index.post.ts +79 -0
  669. package/server/api/workshop/channels/[id]/terminals.get.ts +21 -0
  670. package/server/api/workshop/channels/[id].delete.ts +18 -0
  671. package/server/api/workshop/channels.get.ts +11 -0
  672. package/server/api/workshop/channels.post.ts +48 -0
  673. package/server/api/workshop/daq/[id]/bind.post.ts +19 -0
  674. package/server/api/workshop/daq/[id]/samples.get.ts +22 -0
  675. package/server/api/workshop/daq/[id]/test.post.ts +17 -0
  676. package/server/api/workshop/daq/[id].delete.ts +30 -0
  677. package/server/api/workshop/daq/[id].patch.ts +31 -0
  678. package/server/api/workshop/daq/alarms/[id]/ack.post.ts +15 -0
  679. package/server/api/workshop/daq/alarms.get.ts +16 -0
  680. package/server/api/workshop/daq/controller.post.ts +52 -0
  681. package/server/api/workshop/daq/index.get.ts +40 -0
  682. package/server/api/workshop/daq/index.post.ts +33 -0
  683. package/server/api/workshop/daq/infra/reconnect.post.ts +17 -0
  684. package/server/api/workshop/daq/infra.get.ts +11 -0
  685. package/server/api/workshop/daq/templates/[key].delete.ts +17 -0
  686. package/server/api/workshop/daq/templates/[key].patch.ts +18 -0
  687. package/server/api/workshop/daq/templates/index.post.ts +17 -0
  688. package/server/api/workshop/daq/test-driver.post.ts +23 -0
  689. package/server/api/workshop/dcw/[id]/bind.post.ts +17 -0
  690. package/server/api/workshop/dcw/[id]/param-ledger.get.ts +15 -0
  691. package/server/api/workshop/dcw/[id]/test.post.ts +15 -0
  692. package/server/api/workshop/dcw/[id]/write.post.ts +19 -0
  693. package/server/api/workshop/dcw/[id].delete.ts +30 -0
  694. package/server/api/workshop/dcw/[id].patch.ts +18 -0
  695. package/server/api/workshop/dcw/controller.post.ts +43 -0
  696. package/server/api/workshop/dcw/index.get.ts +25 -0
  697. package/server/api/workshop/dcw/index.post.ts +20 -0
  698. package/server/api/workshop/dcw/journal/index.get.ts +21 -0
  699. package/server/api/workshop/dcw/journal/node/[nodeId]/rollback.post.ts +22 -0
  700. package/server/api/workshop/dcw/line/query.get.ts +31 -0
  701. package/server/api/workshop/dcw/lines/[id]/start.post.ts +35 -0
  702. package/server/api/workshop/dcw/lines/[id]/stop.post.ts +34 -0
  703. package/server/api/workshop/dcw/lines/[id].delete.ts +35 -0
  704. package/server/api/workshop/dcw/lines/[id].patch.ts +15 -0
  705. package/server/api/workshop/dcw/lines/index.get.ts +14 -0
  706. package/server/api/workshop/dcw/lines/index.post.ts +31 -0
  707. package/server/api/workshop/dcw/optimizations/[id]/judge.post.ts +24 -0
  708. package/server/api/workshop/dcw/optimizations/[id]/rollback.post.ts +29 -0
  709. package/server/api/workshop/dcw/optimizations/[id]/series.get.ts +18 -0
  710. package/server/api/workshop/dcw/optimizations/[id].get.ts +17 -0
  711. package/server/api/workshop/dcw/optimizations/index.get.ts +23 -0
  712. package/server/api/workshop/dcw/products/[id].delete.ts +16 -0
  713. package/server/api/workshop/dcw/products/[id].patch.ts +17 -0
  714. package/server/api/workshop/dcw/products/index.post.ts +16 -0
  715. package/server/api/workshop/dcw/recipes/[id]/apply.post.ts +40 -0
  716. package/server/api/workshop/dcw/recipes/[id]/mark-good.post.ts +21 -0
  717. package/server/api/workshop/dcw/recipes/[id]/rollback-good.post.ts +19 -0
  718. package/server/api/workshop/dcw/recipes/[id].delete.ts +14 -0
  719. package/server/api/workshop/dcw/recipes/[id].patch.ts +16 -0
  720. package/server/api/workshop/dcw/recipes/index.get.ts +12 -0
  721. package/server/api/workshop/dcw/recipes/index.post.ts +15 -0
  722. package/server/api/workshop/dcw/runs/[id]/close.post.ts +15 -0
  723. package/server/api/workshop/dcw/runs/[id]/data.get.ts +15 -0
  724. package/server/api/workshop/dcw/runs/[id]/rollback.post.ts +20 -0
  725. package/server/api/workshop/dcw/runs/index.get.ts +11 -0
  726. package/server/api/workshop/dcw/templates/[key].delete.ts +14 -0
  727. package/server/api/workshop/dcw/templates/[key].patch.ts +16 -0
  728. package/server/api/workshop/dcw/templates/index.post.ts +15 -0
  729. package/server/api/workshop/dcw/test-driver.post.ts +16 -0
  730. package/server/api/workshop/device-twins/[id]/control.post.ts +21 -0
  731. package/server/api/workshop/device-twins/[id]/telemetry.post.ts +21 -0
  732. package/server/api/workshop/device-twins/[id].delete.ts +25 -0
  733. package/server/api/workshop/device-twins/[id].patch.ts +29 -0
  734. package/server/api/workshop/device-twins/index.get.ts +16 -0
  735. package/server/api/workshop/device-twins/index.post.ts +32 -0
  736. package/server/api/workshop/fs/dirs.get.ts +40 -0
  737. package/server/api/workshop/mailbox/all.get.ts +19 -0
  738. package/server/api/workshop/mailbox.get.ts +14 -0
  739. package/server/api/workshop/memories/maintenance.post.ts +15 -0
  740. package/server/api/workshop/ops-logs/index.get.ts +30 -0
  741. package/server/api/workshop/ops-logs/index.post.ts +39 -0
  742. package/server/api/workshop/runtime.get.ts +11 -0
  743. package/server/api/workshop/scene/layouts/[channelId].delete.ts +23 -0
  744. package/server/api/workshop/scene/layouts/[channelId].put.ts +31 -0
  745. package/server/api/workshop/scene/layouts.get.ts +13 -0
  746. package/server/api/workshop/tasks/[id]/cancel/index.post.ts +34 -0
  747. package/server/api/workshop/tasks/[id]/complete.post.ts +32 -0
  748. package/server/api/workshop/tasks/[id]/dispatch.post.ts +30 -0
  749. package/server/api/workshop/tasks/[id]/report.post.ts +38 -0
  750. package/server/api/workshop/tasks/[id]/retry.post.ts +33 -0
  751. package/server/api/workshop/tasks/[id].get.ts +32 -0
  752. package/server/api/workshop/tasks.get.ts +13 -0
  753. package/server/api/workshop/teams/[id]/deploy.post.ts +40 -0
  754. package/server/api/workshop/teams/[id]/members/[agentId].delete.ts +19 -0
  755. package/server/api/workshop/teams/[id]/members/index.get.ts +19 -0
  756. package/server/api/workshop/teams/[id]/members/index.post.ts +32 -0
  757. package/server/api/workshop/teams/[id].delete.ts +20 -0
  758. package/server/api/workshop/teams/[id].get.ts +19 -0
  759. package/server/api/workshop/teams/[id].patch.ts +32 -0
  760. package/server/api/workshop/teams/index.get.ts +13 -0
  761. package/server/api/workshop/teams/index.post.ts +27 -0
  762. package/server/api/workshop/users/me.get.ts +23 -0
  763. package/server/api/workshop/users/register.post.ts +33 -0
  764. package/server/api/workshop/workspaces/[id]/channel-templates/[tplId].post.ts +31 -0
  765. package/server/api/workshop/workspaces/[id]/channels/[channelId].delete.ts +15 -0
  766. package/server/api/workshop/workspaces/[id]/channels/[channelId].post.ts +15 -0
  767. package/server/api/workshop/workspaces/[id]/index.delete.ts +14 -0
  768. package/server/api/workshop/workspaces/index.get.ts +11 -0
  769. package/server/api/workshop/workspaces/index.post.ts +17 -0
  770. package/server/api/workshop/ws.ts +694 -0
  771. package/server/data/agent-node-bindings.json +554 -0
  772. package/server/data/daq-templates.json +16 -0
  773. package/server/data/daqs.json +2680 -0
  774. package/server/data/dcw-lines.json +919 -0
  775. package/server/data/dcw-products.json +835 -0
  776. package/server/data/dcw-recipes.json +1650 -0
  777. package/server/data/dcw-rollback.json +9339 -0
  778. package/server/data/dcw-runs.json +4633 -0
  779. package/server/data/dcw-templates.json +26 -0
  780. package/server/data/dcw-writes.json +9770 -0
  781. package/server/data/dcws.json +1736 -0
  782. package/server/data/device-twins.json +133 -0
  783. package/server/data/line-runs.json +152 -0
  784. package/server/data/scene-layouts.json +200 -0
  785. package/server/mcp/workshop-server.ts +510 -0
  786. package/server/middleware/request-log.ts +15 -0
  787. package/server/plugins/backup.ts +85 -0
  788. package/server/plugins/daq.ts +65 -0
  789. package/server/plugins/dev-stability-guard.ts +42 -0
  790. package/server/plugins/security.ts +29 -0
  791. package/server/plugins/system-config.ts +38 -0
  792. package/server/plugins/workshop.ts +145 -0
  793. package/server/repositories/user.repository.ts +391 -0
  794. package/server/schemas/user.schema.ts +67 -0
  795. package/server/services/system-config.ts +356 -0
  796. package/server/services/user.service.ts +186 -0
  797. package/server/services/workshop/agents/adapters/omp-rpc-client.ts +539 -0
  798. package/server/services/workshop/agents/adapters/to-a2a.ts +40 -0
  799. package/server/services/workshop/agents/agent-interface.ts +219 -0
  800. package/server/services/workshop/agents/claude-agent.ts +47 -0
  801. package/server/services/workshop/agents/factory.ts +24 -0
  802. package/server/services/workshop/agents/harness-process.ts +158 -0
  803. package/server/services/workshop/agents/harness-terminal.ts +545 -0
  804. package/server/services/workshop/agents/industrial-context.ts +222 -0
  805. package/server/services/workshop/agents/industrial-tools.ts +362 -0
  806. package/server/services/workshop/agents/mock-agent.ts +396 -0
  807. package/server/services/workshop/agents/node-bindings.repo.ts +165 -0
  808. package/server/services/workshop/agents/omp-agent.ts +1598 -0
  809. package/server/services/workshop/agents/tool-approvals.ts +174 -0
  810. package/server/services/workshop/assets/character-asset.repo.ts +120 -0
  811. package/server/services/workshop/assets/device-twin.repo.ts +236 -0
  812. package/server/services/workshop/daq/alarm-notify.ts +100 -0
  813. package/server/services/workshop/daq/bus/index.ts +46 -0
  814. package/server/services/workshop/daq/bus/inproc.adapter.ts +62 -0
  815. package/server/services/workshop/daq/bus/mqtt.adapter.ts +112 -0
  816. package/server/services/workshop/daq/bus/queue-port.ts +36 -0
  817. package/server/services/workshop/daq/daq-controller.ts +778 -0
  818. package/server/services/workshop/daq/daq-node.repo.ts +96 -0
  819. package/server/services/workshop/daq/daq-node.ts +247 -0
  820. package/server/services/workshop/daq/daq-runtime.ts +168 -0
  821. package/server/services/workshop/daq/daq-templates.ts +157 -0
  822. package/server/services/workshop/daq/drivers.ts +771 -0
  823. package/server/services/workshop/daq/host-bindings.ts +71 -0
  824. package/server/services/workshop/daq/host-ports.ts +64 -0
  825. package/server/services/workshop/daq/infra.ts +202 -0
  826. package/server/services/workshop/daq/storage/index.ts +96 -0
  827. package/server/services/workshop/daq/storage/sqlite.adapter.ts +205 -0
  828. package/server/services/workshop/daq/storage/timescale.adapter.ts +185 -0
  829. package/server/services/workshop/daq/storage/tsdb-port.ts +74 -0
  830. package/server/services/workshop/db/agent.repo.ts +105 -0
  831. package/server/services/workshop/db/channel-agent.repo.ts +119 -0
  832. package/server/services/workshop/db/channel-event.repo.ts +154 -0
  833. package/server/services/workshop/db/channel-template.repo.ts +113 -0
  834. package/server/services/workshop/db/channel.repo.ts +101 -0
  835. package/server/services/workshop/db/database.ts +934 -0
  836. package/server/services/workshop/db/memory.repo.ts +249 -0
  837. package/server/services/workshop/db/message.repo.ts +149 -0
  838. package/server/services/workshop/db/ops.repo.ts +323 -0
  839. package/server/services/workshop/db/schema.sql +143 -0
  840. package/server/services/workshop/db/subscription.repo.ts +62 -0
  841. package/server/services/workshop/db/task.repo.ts +175 -0
  842. package/server/services/workshop/db/team-member.repo.ts +70 -0
  843. package/server/services/workshop/db/team.repo.ts +109 -0
  844. package/server/services/workshop/db/user.repo.ts +100 -0
  845. package/server/services/workshop/dcw/dcw-controller.ts +841 -0
  846. package/server/services/workshop/dcw/dcw-line.repo.ts +86 -0
  847. package/server/services/workshop/dcw/dcw-node.repo.ts +89 -0
  848. package/server/services/workshop/dcw/dcw-node.ts +200 -0
  849. package/server/services/workshop/dcw/dcw-product.repo.ts +95 -0
  850. package/server/services/workshop/dcw/dcw-recipe.repo.ts +349 -0
  851. package/server/services/workshop/dcw/dcw-runtime.ts +82 -0
  852. package/server/services/workshop/dcw/dcw-templates.ts +138 -0
  853. package/server/services/workshop/dcw/drivers.ts +523 -0
  854. package/server/services/workshop/dcw/line-run.ts +101 -0
  855. package/server/services/workshop/dcw/recipe-rollback-manager.ts +702 -0
  856. package/server/services/workshop/dcw/recipe-rollback.repo.ts +197 -0
  857. package/server/services/workshop/logger.ts +58 -0
  858. package/server/services/workshop/ops/ops.ts +78 -0
  859. package/server/services/workshop/prompts/loader.ts +112 -0
  860. package/server/services/workshop/runtime/agent-runtime.ts +698 -0
  861. package/server/services/workshop/runtime/channel-runtime.ts +202 -0
  862. package/server/services/workshop/runtime/embedding-provider.ts +79 -0
  863. package/server/services/workshop/runtime/execution-mode.ts +215 -0
  864. package/server/services/workshop/runtime/mailbox.ts +148 -0
  865. package/server/services/workshop/runtime/manager.ts +2543 -0
  866. package/server/services/workshop/runtime/memory.ts +439 -0
  867. package/server/services/workshop/runtime/monitor.ts +194 -0
  868. package/server/services/workshop/runtime/scheduler-loop.ts +773 -0
  869. package/server/services/workshop/runtime/task-engine.ts +574 -0
  870. package/server/services/workshop/scene/scene-layout.repo.ts +112 -0
  871. package/server/services/workshop/scene-events.ts +63 -0
  872. package/server/services/workshop/types/a2a.ts +61 -0
  873. package/server/services/workshop/types/task.ts +85 -0
  874. package/server/types/user.ts +53 -0
  875. package/server/utils/approval-gate.ts +67 -0
  876. package/server/utils/auth.ts +13 -0
  877. package/server/utils/config.ts +40 -0
  878. package/server/utils/errors.ts +24 -0
  879. package/server/utils/response.ts +49 -0
  880. package/server/utils/validate.ts +19 -0
  881. package/shared/config/engine.d.mts +41 -0
  882. package/shared/config/engine.mjs +291 -0
  883. package/shared/config/home.d.mts +48 -0
  884. package/shared/config/home.mjs +146 -0
  885. package/shared/config/schema.json +30 -0
  886. package/shared/daq-protocol.ts +361 -0
  887. package/shared/dcw-protocol.ts +576 -0
  888. package/shared/terminal-protocol.ts +70 -0
  889. package/shared/town-anim.ts +56 -0
  890. package/shared/town-behavior.ts +136 -0
  891. package/shared/town-protocol.ts +110 -0
  892. package/shared/town-scene-math.ts +283 -0
  893. package/shared/workshop-protocol.ts +199 -0
  894. package/tsconfig.json +10 -0
  895. package/uno.config.ts +37 -0
@@ -0,0 +1,4533 @@
1
+ /**
2
+ * AgentTeam RPG 小镇 — Three.js 3D 表现层(默认渲染器)。
3
+ *
4
+ * 职责(只渲染,不决策):
5
+ * - 3D 斜俯视 2.5D 场景:渲染器/相机/灯下/地面/环形布点 + 领地平台 + 频道名牌;
6
+ * - 每个 Agent → 一个 3D 角色 Group(模型 Mesh + 同频道色环 + 头顶名字 Sprite);
7
+ * - 模型绑定:modelRef → GLTFLoader 加载 .glb(GLTF/GLB),归一化 scale/锚点贴地,
8
+ * 有 animation clip 则 AnimationMixer 播 idle/walk,否则静态;
9
+ * - 复用决策层:eventToBubble/mapEnvelopeToIntent/parseActionFromEnvelope/stepToward;
10
+ * - 行为 FSM(idle/roam/approach/wait/returnHome)在 x/z 平面复刻;
11
+ * - `getDebugState()/getMinimapState()/getRecentActivity()` 与 2D 同构,供 HUD/E2E。
12
+ *
13
+ * 与 TownView 的分发:默认挂载本场景,`?render=2d` 回退 Phaser TownScene。
14
+ * 公开接口与 2D 镜像,让 TownView 无感切换(经最小公共接口 TownViewScene 约束)。
15
+ */
16
+ import * as THREE from 'three'
17
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
18
+ import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
19
+ import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'
20
+ import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
21
+ import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
22
+ import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'
23
+ import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js'
24
+ import type { AepEnvelope } from '#shared/workshop-protocol'
25
+ import { mapEnvelopeToIntent, type TownBubbleKind } from '#shared/town-protocol'
26
+ import { parseActionFromEnvelope, stepToward, type ActionKind, type ActionContext } from '#shared/town-behavior'
27
+ // 纯几何/色彩/尺度决策层(与测试共用,零渲染依赖)
28
+ import {
29
+ AGENT_SPEED, ARRIVE, BUBBLE_Y, GROUND_Y, SNAP_SIZE, UNITS, WAIT_MS,
30
+ WORLD_CX, WORLD_CZ, WORLD_H, WORLD_W,
31
+ boundaryPoints, bubbleDisplayMs, channelColorNum, drainDisplayMs,
32
+ clampToAgentRange, clampToBoundary, distToRangeBoundary,
33
+ normLayout, pointInBoundary, toLocal,
34
+ type AgentRangeLayout, type ChannelLayout,
35
+ } from '#shared/town-scene-math'
36
+
37
+ export type { AgentRangeLayout, ChannelLayout } from '#shared/town-scene-math'
38
+
39
+ /** 场景 → Vue HUD 事件(TownEventMap 与 2D 同构) */
40
+ export type TownEventMap = {
41
+ ready: boolean
42
+ fps: number
43
+ agentCount: number
44
+ blockCount: number
45
+ lastActivity: { channelId: string, agentName: string, text: string, at?: number } | null
46
+ behavior: { agentName: string, action: string, targetName: string | null } | null
47
+ /** 选中 Agent/设备(供 Vue 弹缩放/旋转滑杆);null 取消选中 */
48
+ select: { kind: 'agent' | 'device', id: string, scale: number, rotation: number } | null
49
+ /** 选中频道(供 Vue 边界编辑面板);null 取消 */
50
+ selectChannel: string | null
51
+ /** 频道布局被拖拽/手柄调整(供 Vue 刷新边界面板草稿);null 取消 */
52
+ channelResized: { channelId: string, layout: ChannelLayout } | null
53
+ /** Agent 活动范围被框选绘制/拖移/手柄调整/清除(供 Vue 刷新对象面板草稿);null 取消 */
54
+ agentRangeChanged: { agentId: string } | null
55
+ /** 动画状态切换(数据驱动:Agent 在 idle/walk 之间切换时广播);null 表示无 */
56
+ motion: { agentName: string, anim: 'idle' | 'walk', at: number } | null
57
+ /** 场景保存状态(设备/角色布局持久化进度) */
58
+ saveState: { state: 'idle' | 'dirty' | 'saving' | 'saved' | 'error', at: number } | null
59
+ }
60
+
61
+ /** 编辑 / 浏览模式:浏览只读(相机+点选),编辑可拖拽设备/调整角色落点 */
62
+ export type TownScene3DMode = 'browse' | 'edit'
63
+
64
+ /** 设备 transform 补丁(拖拽/滑杆结束防抖保存) */
65
+ export interface DeviceTransformPatch {
66
+ posX?: number
67
+ posZ?: number
68
+ rotationY?: number
69
+ scale?: number
70
+ }
71
+
72
+ /** syncDevices 输入:与 useDeviceTwins.DeviceTwinView 同构的子集 */
73
+ export interface DeviceTwinSync {
74
+ id: string
75
+ name: string
76
+ modelRef: string
77
+ /** 实体类别:'device' 常规设备(GLB) / 'daq' 数采节点(程序化网格);后端为宽 string */
78
+ kind?: string
79
+ state?: 'idle' | 'running' | 'offline' | 'alarm'
80
+ telemetry?: Record<string, number | string | boolean>
81
+ posX?: number
82
+ posZ?: number
83
+ rotationY?: number
84
+ scale?: number
85
+ /** 所属产线(场景光晕分色依据) */
86
+ lineId?: string
87
+ /** 产线光晕色(Hex;同产线节点同色光环) */
88
+ lineColor?: string
89
+ }
90
+
91
+ /** 场景内可缩放目标(Agent 或设备节点) */
92
+ interface ScaledTarget {
93
+ kind: 'agent' | 'device'
94
+ id: string
95
+ /** 用户缩放倍率(1 = 默认归一化尺寸) */
96
+ userScale: number
97
+ /** 模型子节点(缩放施加于此) */
98
+ holder: THREE.Group
99
+ }
100
+
101
+ /** 与 2D TownEntityInput 同构的实体基线 */
102
+ export interface TownEntityInput {
103
+ channelId: string
104
+ channelName: string
105
+ agents: Array<{
106
+ agentId: string
107
+ name: string
108
+ role: 'lead' | 'worker'
109
+ harness: string
110
+ state: 'idle' | 'busy' | 'stopped'
111
+ currentTaskId?: string | null
112
+ currentTaskTitle?: string | null
113
+ currentTaskProgress?: number | null
114
+ modelRef?: string | null
115
+ /** 管理员布局落点(来自 config.homeX/homeZ;缺省 = 领地环形排布) */
116
+ homeX?: number | null
117
+ homeZ?: number | null
118
+ /** 管理员布局活动范围(来自 config.range;缺省 = 沿用频道边界) */
119
+ range?: AgentRangeLayout | null
120
+ }>
121
+ }
122
+
123
+ /** 频道布局(3D 小镇放置):与共享 AepSceneLayout/useSceneLayouts 同构 —— 定义见 #shared/town-scene-math */
124
+
125
+ /** Agent 独立活动范围(编辑模式框选绘制/手柄调整;经 config.range 持久化)。
126
+ * 缺省(null)= 未设置,该 Agent 沿用频道边界活动。 —— 定义见 #shared/town-scene-math */
127
+
128
+ /** 频道领地 3D 实例(面向对象:持有布局/网格/成员,封装移动/边界/成员钳制等行为)。
129
+ * 数据来自 scene-layouts 持久化,由场景按数据库元数据实例化,并注入宿主控制器。 */
130
+ class Block3D {
131
+ channelId!: string
132
+ name!: string
133
+ x!: number
134
+ z!: number
135
+ radiusX!: number
136
+ radiusZ!: number
137
+ shape!: 'ellipse' | 'rect'
138
+ rotationY!: number
139
+ color!: number
140
+ platform!: THREE.Mesh
141
+ /** 领地边缘发光环(工业孪生边界告示;随平台移动) */
142
+ padRing!: THREE.Mesh
143
+ /** 中央信标(HMI 定位销:点击 → 定位频道中心 + 唤醒边界编辑) */
144
+ beacon!: THREE.Group
145
+ /** 活动边界(编辑高亮) */
146
+ boundary!: THREE.LineLoop
147
+ label!: THREE.Sprite
148
+ /** 本频道的 Agent3D 成员实例(面向对象聚合) */
149
+ members: Agent3D[] = []
150
+ /** 宿主场景控制器(实例化后注入;提供跨实体服务) */
151
+ host!: TownScene3D
152
+
153
+ constructor(init: {
154
+ channelId: string
155
+ name: string
156
+ x: number
157
+ z: number
158
+ radiusX: number
159
+ radiusZ: number
160
+ shape: 'ellipse' | 'rect'
161
+ rotationY: number
162
+ color: number
163
+ platform: THREE.Mesh
164
+ padRing: THREE.Mesh
165
+ beacon: THREE.Group
166
+ boundary: THREE.LineLoop
167
+ label: THREE.Sprite
168
+ }) {
169
+ Object.assign(this, init)
170
+ }
171
+
172
+ /** 当前布局(与共享 ChannelLayout 同构;供边界面板/落库/E2E) */
173
+ layout(): ChannelLayout {
174
+ return { channelId: this.channelId, x: this.x, z: this.z, radiusX: this.radiusX, radiusZ: this.radiusZ, shape: this.shape, rotationY: this.rotationY }
175
+ }
176
+
177
+ /** 归一化布局(radius 钳制下限) */
178
+ normLayout(): ChannelLayout {
179
+ return normLayout(this.layout())
180
+ }
181
+
182
+ /** 边界手柄本地坐标(矩形四角 / 椭圆轴向四点;radius 是半轴或半宽)。 */
183
+ handlePoints(): Array<[number, number]> {
184
+ if (this.shape === 'rect') return [[this.radiusX, this.radiusZ], [-this.radiusX, this.radiusZ], [-this.radiusX, -this.radiusZ], [this.radiusX, -this.radiusZ]]
185
+ return [[this.radiusX, 0], [-this.radiusX, 0], [0, this.radiusZ], [0, -this.radiusZ]]
186
+ }
187
+
188
+ /** 世界点是否落在领地边界内(点选/命中判定) */
189
+ pointIn(x: number, z: number): boolean {
190
+ return pointInBoundary(this.layout(), x, z)
191
+ }
192
+
193
+ /** 整体平移:更新中心、平台/边界/名牌网格,并携带全部成员落点与各自活动范围(行为内聚) */
194
+ moveBy(dx: number, dz: number): void {
195
+ this.x += dx
196
+ this.z += dz
197
+ this.platform.position.set(this.x, 0.16, this.z)
198
+ this.padRing.position.set(this.x, 0.32, this.z)
199
+ this.beacon.position.set(this.x, 0, this.z)
200
+ this.boundary.position.set(this.x, 0.3, this.z)
201
+ this.boundary.rotation.y = this.rotationY * Math.PI / 180
202
+ this.label.position.set(this.x, 30, this.z)
203
+ for (const a of this.members) {
204
+ a.root.position.x += dx
205
+ a.root.position.z += dz
206
+ a.homeX += dx
207
+ a.homeZ += dz
208
+ if (a.range) {
209
+ a.range.x += dx
210
+ a.range.z += dz
211
+ a.renderRangeLine()
212
+ }
213
+ }
214
+ this.host.trackLayout(this)
215
+ }
216
+
217
+ /** 按补丁更新布局字段(边界编辑;钳制下限由宿主统一保证) */
218
+ applyPatch(patch: Partial<ChannelLayout>): void {
219
+ if (patch.x !== undefined) this.x = patch.x
220
+ if (patch.z !== undefined) this.z = patch.z
221
+ if (patch.radiusX !== undefined) this.radiusX = patch.radiusX
222
+ if (patch.radiusZ !== undefined) this.radiusZ = patch.radiusZ
223
+ if (patch.shape !== undefined) this.shape = patch.shape
224
+ if (patch.rotationY !== undefined) this.rotationY = patch.rotationY
225
+ }
226
+
227
+ /** 把成员落点与各自活动范围收进当前频道边界(边界缩放/整体移动后调用) */
228
+ clampMembersAndRanges(): void {
229
+ const layout = this.layout()
230
+ for (const a of this.members) {
231
+ const c = clampToBoundary(layout, a.root.position.x, a.root.position.z, 16)
232
+ a.root.position.x = c.x
233
+ a.root.position.z = c.z
234
+ a.homeX = c.x
235
+ a.homeZ = c.z
236
+ if (a.range) {
237
+ a.renderRangeLine()
238
+ const cc = clampToAgentRange(a.range, a.homeX, a.homeZ, 0)
239
+ if (cc.x !== a.homeX || cc.z !== a.homeZ) {
240
+ a.homeX = cc.x
241
+ a.homeZ = cc.z
242
+ a.root.position.x = cc.x
243
+ a.root.position.z = cc.z
244
+ }
245
+ }
246
+ }
247
+ this.host.markDirty()
248
+ }
249
+ }
250
+
251
+ /** 边界几何体:椭圆/矩形线框,弯折朝向 rotationY(度)。
252
+ * 轮廓点来自 #shared/town-scene-math.boundaryPoints(纯几何,场景只做渲染)。 */
253
+ function makeBoundary(shape: 'ellipse' | 'rect', rx: number, rz: number, color: number): THREE.LineLoop {
254
+ const pts = boundaryPoints(shape, rx, rz, 48).map(([x, z]) => new THREE.Vector3(x, 0, z))
255
+ const geo = new THREE.BufferGeometry().setFromPoints(pts)
256
+ const line = new THREE.LineLoop(geo, new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75, depthTest: false }))
257
+ return line
258
+ }
259
+
260
+ /** 归一化边界(radius 钳制下限;rect 用半宽) —— 定义见 #shared/town-scene-math */
261
+
262
+ /** 把点钳制到频道边界内 —— 定义见 #shared/town-scene-math */
263
+
264
+ /** 点到边界内判定 —— 定义见 #shared/town-scene-math */
265
+
266
+ /** 边界/范围的极值点(矩形四角 / 椭圆轴向四点;世界坐标,含 rotationY) —— 定义见 #shared/town-scene-math */
267
+
268
+ /** 把 Agent 活动范围整体收进频道边界 —— 定义见 #shared/town-scene-math */
269
+
270
+ /** 把点钳制到 Agent 自己活动范围内 —— 定义见 #shared/town-scene-math */
271
+
272
+ /** 世界点到活动范围边界线的最近距离 —— 定义见 #shared/town-scene-math */
273
+
274
+ /** 角色 3D 实例(面向对象:持有模型/状态/行为,封装漫游 FSM、移动钳制、动画、活动范围)。
275
+ * 由场景按 entities/数据库元数据(home/range/modelRef)实例化并注入宿主;动画状态驱动 motion 事件。 */
276
+ class Agent3D {
277
+ channelId!: string
278
+ agentId!: string
279
+ name!: string
280
+ role!: 'lead' | 'worker'
281
+ /** 根 Group(位置=落地点,脚底) */
282
+ root!: THREE.Group
283
+ /** 模型子节点(换模型时替换) —— 始终存在(空组占位) */
284
+ model!: THREE.Group
285
+ mixer!: THREE.AnimationMixer | null
286
+ /** 当前模型动画 clip(若有) */
287
+ clips!: THREE.AnimationClip[]
288
+ /** 身份色(频道哈希色;气泡/小地图等取色数据源) */
289
+ colorNum!: number
290
+ /** 头顶名字 Sprite */
291
+ nameSprite!: THREE.Sprite
292
+ /** 当前气泡 */
293
+ bubble!: THREE.Sprite | null
294
+ /** 当前头顶聊天气泡文本(调试/HUD 用;null = 无气泡) */
295
+ bubbleText!: string | null
296
+ bubbleTimer!: ReturnType<typeof setTimeout> | null
297
+ /** 状态/进度/行为 */
298
+ state!: 'idle' | 'busy' | 'stopped'
299
+ progress!: number | null
300
+ /** 行为 FSM */
301
+ behavior!: BehaviorState
302
+ /** 用户拖动中 */
303
+ dragging!: boolean
304
+ /** home(行为结束后回归) */
305
+ homeX!: number
306
+ homeZ!: number
307
+ /** 独立活动范围(编辑模式框选/手柄;null = 沿用频道边界) */
308
+ range!: AgentRangeLayout | null
309
+ /** 活动范围线框(随 range 渲染;清除/重建时移除) */
310
+ rangeLine!: THREE.LineLoop | null
311
+ textureKey!: string
312
+ modelRef!: string
313
+ /** 当前动画状态(数据驱动模型:idle/walk;经宿主 motion 事件监听) */
314
+ animState: 'idle' | 'walk' = 'idle'
315
+ /** 当前 mixer action(crossfade 切换用;null = 未开始) */
316
+ activeAction: THREE.AnimationAction | null = null
317
+ /** 宿主场景控制器(实例化后注入) */
318
+ host!: TownScene3D
319
+ /** 本帧是否在移动(update 结束时驱动动画) */
320
+ private moving = false
321
+ /** 脚下光环组(频道色身份标识;root 子节点,随移动跟随,零每帧定位成本) */
322
+ aura: THREE.Group | null = null
323
+ /** 光环材质(渲染循环呼吸;userData.baseOp 存基准透明度) */
324
+ auraMats: THREE.MeshBasicMaterial[] = []
325
+ /** 呼吸相位(每实例随机,避免全场同频闪烁) */
326
+ auraPhase = 0
327
+
328
+ /** 挂脚下光环(频道色,与领地同源;lead 加外环 —— 角色身份一眼可辨) */
329
+ attachAura(color: number, role: 'lead' | 'worker'): void {
330
+ const g = new THREE.Group()
331
+ const mats: THREE.MeshBasicMaterial[] = []
332
+ const mk = (r0: number, r1: number, y: number, op: number): void => {
333
+ const m = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: op, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending })
334
+ m.userData.baseOp = op
335
+ mats.push(m)
336
+ const mesh = new THREE.Mesh(new THREE.RingGeometry(r0, r1, 44), m)
337
+ mesh.rotation.x = -Math.PI / 2
338
+ mesh.position.y = y
339
+ g.add(mesh)
340
+ }
341
+ mk(34, 40, 0.36, 0.26)
342
+ if (role === 'lead') mk(46, 50, 0.42, 0.2)
343
+ this.root.add(g)
344
+ this.aura = g
345
+ this.auraMats = mats
346
+ this.auraPhase = Math.random() * Math.PI * 2
347
+ }
348
+
349
+ constructor(init: {
350
+ channelId: string
351
+ agentId: string
352
+ name: string
353
+ role: 'lead' | 'worker'
354
+ root: THREE.Group
355
+ model: THREE.Group
356
+ mixer: THREE.AnimationMixer | null
357
+ clips: THREE.AnimationClip[]
358
+ colorNum: number
359
+ nameSprite: THREE.Sprite
360
+ bubble: THREE.Sprite | null
361
+ bubbleText: string | null
362
+ bubbleTimer: ReturnType<typeof setTimeout> | null
363
+ state: 'idle' | 'busy' | 'stopped'
364
+ progress: number | null
365
+ behavior: BehaviorState
366
+ dragging: boolean
367
+ homeX: number
368
+ homeZ: number
369
+ range: AgentRangeLayout | null
370
+ rangeLine: THREE.LineLoop | null
371
+ textureKey: string
372
+ modelRef: string
373
+ }) {
374
+ Object.assign(this, init)
375
+ }
376
+
377
+ /** 到达判定 */
378
+ reached(target: { x: number, z: number }): boolean {
379
+ return Math.hypot(this.root.position.x - target.x, this.root.position.z - target.z) <= ARRIVE
380
+ }
381
+
382
+ /** 朝目标匀速走一步(按自身活动范围/频道边界钳制;驱动朝向与动画) */
383
+ driveToward(target: { x: number, z: number }, speed: number, dt: number): void {
384
+ this.moving = true
385
+ const cur = { x: this.root.position.x, z: this.root.position.z }
386
+ const next = stepToward({ x: cur.x, y: cur.z }, { x: target.x, y: target.z }, speed, dt)
387
+ let nx = next.x
388
+ let nz = next.y
389
+ if (this.range) {
390
+ const clamped = clampToAgentRange(this.range, nx, nz, 6)
391
+ nx = clamped.x
392
+ nz = clamped.z
393
+ }
394
+ else {
395
+ const layout = this.host.blockLayoutOf(this.channelId)
396
+ if (layout) {
397
+ const clamped = clampToBoundary(layout, nx, nz, 6)
398
+ nx = clamped.x
399
+ nz = clamped.z
400
+ }
401
+ }
402
+ this.root.position.x = nx
403
+ this.root.position.z = nz
404
+ // 朝向平滑:朝移动方向插值转动(左/右两态,不用瞬间 snap,2.5D 行走更顺)
405
+ const dir = next.dir
406
+ const targetY = dir === 'left' ? Math.PI : 0
407
+ const curY = this.root.rotation.y
408
+ // 最短角差插值(0 ↔ π 之间取捷径;步长限速防抖)
409
+ let d = targetY - curY
410
+ if (d > Math.PI) d -= Math.PI * 2
411
+ else if (d < -Math.PI) d += Math.PI * 2
412
+ const maxStep = dt * 3.2
413
+ this.root.rotation.y = curY + Math.max(-maxStep, Math.min(maxStep, d))
414
+ void dt
415
+ }
416
+
417
+ /** 行为 FSM(idle/roam/approach/wait/returnHome);由渲染循环每帧驱动,动画随移动状态切换 */
418
+ update(dt: number): void {
419
+ this.moving = false
420
+ try {
421
+ this.updateBehavior(dt)
422
+ }
423
+ finally {
424
+ this.playWalkAnim(this.moving)
425
+ }
426
+ }
427
+
428
+ private updateBehavior(dt: number): void {
429
+ const b = this.behavior
430
+ if (b.mode === 'idle' || b.mode === 'roam') {
431
+ if (b.engaged || this.dragging) return
432
+ b.mode = 'roam'
433
+ if (!b.roamTarget) {
434
+ // 到达后就地停顿片刻:保持 idle 动画,营造走走停停的闲逛节奏(并产生 motion 事件)
435
+ if (performance.now() < b.pauseUntil) return
436
+ // 漫游目标:有独立活动范围 → 在自身范围内取点;否则沿用频道边界内取点
437
+ const layout = this.host.blockLayoutOf(this.channelId)
438
+ let tx: number
439
+ let tz: number
440
+ if (this.range) {
441
+ tx = this.homeX + (Math.random() * 2 - 1) * this.range.radiusX * 0.9
442
+ tz = this.homeZ + (Math.random() * 2 - 1) * this.range.radiusZ * 0.9
443
+ const clamped = clampToAgentRange(this.range, tx, tz, 8)
444
+ tx = clamped.x
445
+ tz = clamped.z
446
+ }
447
+ else {
448
+ const range = layout ? Math.min(layout.radiusX, layout.radiusZ) * 0.45 : 80
449
+ tx = this.homeX + (Math.random() * 2 - 1) * range
450
+ tz = this.homeZ + (Math.random() * 2 - 1) * range * 0.6
451
+ if (layout) {
452
+ const clamped = clampToBoundary(layout, tx, tz, 16)
453
+ tx = clamped.x
454
+ tz = clamped.z
455
+ }
456
+ }
457
+ b.roamTarget = { x: tx, z: tz }
458
+ }
459
+ this.driveToward(b.roamTarget, AGENT_SPEED * 0.5, dt)
460
+ if (this.reached(b.roamTarget)) {
461
+ b.roamTarget = null
462
+ b.pauseUntil = performance.now() + 350 + Math.random() * 900
463
+ }
464
+ return
465
+ }
466
+ if (b.mode === 'approach') {
467
+ const target = b.targetId ? this.host.getAgent(b.targetId) : undefined
468
+ if (!target) {
469
+ b.mode = 'idle'
470
+ return
471
+ }
472
+ let pos = { x: target.root.position.x, z: target.root.position.z }
473
+ // 目标在自身活动范围外 → 逼近到自身范围边界(送达交接在边界处完成)
474
+ if (this.range) {
475
+ const c = clampToAgentRange(this.range, pos.x, pos.z, 8)
476
+ pos = { x: c.x, z: c.z }
477
+ }
478
+ this.driveToward(pos, AGENT_SPEED, dt)
479
+ if (this.reached(pos)) {
480
+ this.host.deliverBehavior(this, target)
481
+ if (b.action?.requireReply) {
482
+ b.mode = 'wait'
483
+ b.waitUntil = performance.now() + WAIT_MS
484
+ }
485
+ else {
486
+ b.mode = 'returnHome'
487
+ this.host.releaseEngaged(this)
488
+ b.targetId = null
489
+ }
490
+ }
491
+ return
492
+ }
493
+ if (b.mode === 'wait') {
494
+ if (this.dragging) return
495
+ const target = b.targetId ? this.host.getAgent(b.targetId) : undefined
496
+ if (target && !target.dragging) {
497
+ const stand = { x: target.root.position.x + 28, z: target.root.position.z + 8 }
498
+ this.driveToward(stand, AGENT_SPEED * 0.6, dt)
499
+ }
500
+ if (performance.now() >= b.waitUntil) {
501
+ b.mode = 'returnHome'
502
+ this.host.releaseEngaged(this)
503
+ }
504
+ return
505
+ }
506
+ if (b.mode === 'returnHome') {
507
+ if (this.dragging) return
508
+ const home = { x: this.homeX, z: this.homeZ }
509
+ this.driveToward(home, AGENT_SPEED * 0.7, dt)
510
+ if (this.reached(home)) {
511
+ this.root.position.set(this.homeX, GROUND_Y, this.homeZ)
512
+ b.mode = 'idle'
513
+ b.targetId = null
514
+ b.action = undefined
515
+ }
516
+ }
517
+ }
518
+
519
+ /** 动画状态切换(有 clip: idle/walk 动作;无 clip: 上下浮动 bob);切换时经宿主广播 motion 事件 */
520
+ playWalkAnim(moving: boolean): void {
521
+ const next = moving ? 'walk' : 'idle'
522
+ if (this.animState !== next) {
523
+ this.animState = next
524
+ this.host.notifyMotion(this)
525
+ }
526
+ if (this.clips.length === 0) {
527
+ // 无动画 clip → 程序化动作绑定(model 局部,色环/名牌保持贴地稳定):
528
+ // 待机呼吸浮动 + 行走跳跃颠簸 + 行走左右微摆
529
+ const t = performance.now()
530
+ const breathe = 1.2 + Math.sin(t * 0.0016) * 1.6
531
+ const hop = Math.abs(Math.sin(t * 0.005)) * 6
532
+ this.model.position.y = moving ? hop : breathe
533
+ const swayTarget = moving ? Math.sin(t * 0.006) * 0.07 : 0
534
+ this.model.rotation.z += (swayTarget - this.model.rotation.z) * 0.12
535
+ return
536
+ }
537
+ // 有动画 clip:按移动状态切换 idle/walk,crossfade 平滑过渡(避免双 action 叠加)
538
+ if (!this.mixer) return
539
+ const idx = moving ? (this.clips.length > 1 ? 1 : 0) : 0
540
+ const clip = this.clips[idx]
541
+ if (!clip) return
542
+ const nextAction = this.mixer.clipAction(clip)
543
+ if (this.activeAction === nextAction) {
544
+ nextAction.timeScale = moving ? 1.3 : 0.9
545
+ return
546
+ }
547
+ nextAction.reset().setEffectiveTimeScale(moving ? 1.3 : 0.9).fadeIn(0.15).play()
548
+ if (this.activeAction) this.activeAction.fadeOut(0.15)
549
+ this.activeAction = nextAction
550
+ }
551
+
552
+ /** 更新 home 落点(行为结束后回归点;含拖拽/范围位移后落库路径) */
553
+ setHome(x: number, z: number): void {
554
+ this.homeX = x
555
+ this.homeZ = z
556
+ }
557
+
558
+ /** 渲染/刷新活动范围线框(数据驱动:range 变更 → 线框重建)+ 可见性 */
559
+ renderRangeLine(): void {
560
+ if (this.rangeLine) {
561
+ this.host.threeScene.remove(this.rangeLine)
562
+ this.rangeLine = null
563
+ }
564
+ if (!this.range) return
565
+ const color = 0x41c8f4
566
+ const line = makeBoundary(this.range.shape, this.range.radiusX, this.range.radiusZ, color)
567
+ // 虚线描边(与频道实线边界区分;2.5D 可读性)
568
+ line.material = new THREE.LineDashedMaterial({ color, transparent: true, opacity: 0.75, depthTest: false, dashSize: 14, gapSize: 9 })
569
+ line.computeLineDistances()
570
+ line.position.set(this.range.x, 0.5, this.range.z)
571
+ line.rotation.y = this.range.rotationY * Math.PI / 180
572
+ line.visible = this.host.rangeLineVisibleFor(this)
573
+ this.host.threeScene.add(line)
574
+ this.rangeLine = line
575
+ this.host.markDirty()
576
+ }
577
+
578
+ /** 挂气泡(替换旧气泡;宿主完成纹理渲染) */
579
+ attachBubble(sprite: THREE.Sprite, text: string): void {
580
+ if (this.bubble) {
581
+ this.host.threeScene.remove(this.bubble)
582
+ this.bubbleText = null
583
+ }
584
+ this.bubble = sprite
585
+ this.bubbleText = text
586
+ }
587
+
588
+ /** 移除气泡(宿主负责从场景摘除) */
589
+ clearBubble(): void {
590
+ if (!this.bubble) return
591
+ this.host.threeScene.remove(this.bubble)
592
+ this.bubble = null
593
+ this.bubbleText = null
594
+ }
595
+ }
596
+
597
+ type BehaviorMode
598
+ = 'idle'
599
+ | 'roam'
600
+ | 'approach'
601
+ | 'wait'
602
+ | 'returnHome'
603
+
604
+ /** 数字孪生设备 3D 实例(面向对象:持有节点网格/状态/遥测,封装讑生同步、状态环、模型重挂、transform 记忆)。
605
+ * 由场景按 device-twins 数据库元数据实例化;state/telemetry 数据驱动渲染。 */
606
+ class DeviceNode {
607
+ twinId!: string
608
+ name!: string
609
+ modelRef!: string
610
+ root!: THREE.Group
611
+ /** 模型挂载组(换模型时 clear 重挂;缩放施加于此) */
612
+ holder!: THREE.Group
613
+ ring!: THREE.Mesh
614
+ /** 运行态动效弧(仅 running 可见;渲染循环缓转 —— 语义动效:设备在转 = 在产) */
615
+ arc: THREE.Mesh | null = null
616
+ label!: THREE.Sprite
617
+ state!: 'idle' | 'running' | 'offline' | 'alarm'
618
+ telemetry!: Record<string, number | string | boolean>
619
+ /** 宿主场景控制器(实例化后注入) */
620
+ host!: TownScene3D
621
+
622
+ constructor(init: {
623
+ twinId: string
624
+ name: string
625
+ modelRef: string
626
+ root: THREE.Group
627
+ holder: THREE.Group
628
+ ring: THREE.Mesh
629
+ label: THREE.Sprite
630
+ state: 'idle' | 'running' | 'offline' | 'alarm'
631
+ telemetry: Record<string, number | string | boolean>
632
+ }) {
633
+ Object.assign(this, init)
634
+ }
635
+
636
+ /** 模型顶面世界高度缓存(HUD/callout 每帧读取;缩放变更时失效) */
637
+ topYCache: number | null = null
638
+ /** 当前产线光晕色(空 = 未分配) */
639
+ lineColor = ''
640
+ /** 产线换色回调(recreateDaqNode 装配;applyTwin 检测变化时重 tint) */
641
+ applyLine: ((color: string) => void) | null = null
642
+
643
+ /** 状态环颜色(数据驱动:状态变化/产线换色时调用,渲染循环不再每帧重刷)。
644
+ * 分配了产线的节点(数采)环随产线色 —— 状态生命感已由 LED 呼吸环表达;常规设备环 = 状态色。 */
645
+ updateRing(): void {
646
+ const mat = this.ring.material as THREE.MeshBasicMaterial
647
+ if (this.lineColor) mat.color.set(this.lineColor)
648
+ else mat.color.setHex(this.state === 'alarm' ? 0xff6b6b : this.state === 'offline' ? 0x8496a5 : this.state === 'running' ? 0x35e0a0 : 0xf6c453)
649
+ if (this.arc) this.arc.visible = this.state === 'running'
650
+ }
651
+
652
+ /** 与数据库讑生记录收敛(状态/遥测/名称/模型/产线光晕;宿主完成名牌与模型重挂) */
653
+ applyTwin(t: DeviceTwinSync): void {
654
+ if (t.state) this.state = t.state
655
+ if (t.telemetry) this.telemetry = { ...this.telemetry, ...t.telemetry }
656
+ if (t.name && t.name !== this.name) this.host.renameDeviceSprite(this, t.name)
657
+ if (t.modelRef && t.modelRef !== this.modelRef) this.host.swapDeviceModelSprite(this, t.modelRef)
658
+ if ((t.lineColor ?? '') !== this.lineColor) {
659
+ this.lineColor = t.lineColor ?? ''
660
+ this.applyLine?.(this.lineColor)
661
+ }
662
+ this.updateRing()
663
+ this.host.markDirty()
664
+ }
665
+
666
+ /** 记录当前 transform(供防抖落库快照) */
667
+ rememberTransform(): { posX: number, posZ: number, rotationY: number } {
668
+ return {
669
+ posX: Math.round(this.root.position.x * 10) / 10,
670
+ posZ: Math.round(this.root.position.z * 10) / 10,
671
+ rotationY: Math.round(THREE.MathUtils.radToDeg(this.root.rotation.y) * 10) / 10,
672
+ }
673
+ }
674
+ }
675
+
676
+ interface BehaviorState {
677
+ mode: BehaviorMode
678
+ roamTarget: { x: number, z: number } | null
679
+ targetId: string | null
680
+ waitUntil: number
681
+ /** 漫游到达后就地停顿至该时刻(idle 动画;营造走走停停的闲逛节奏,驱动 motion 事件) */
682
+ pauseUntil: number
683
+ action?: { kind: ActionKind, taskKind?: string, requireReply: boolean, text: string }
684
+ engaged: boolean
685
+ }
686
+
687
+ /** 信息接收器队列中的一条消息(按频道 FIFO 逐条消费) */
688
+ interface BubbleMsg {
689
+ /** 说话 Agent(null = 频道级/系统气泡) */
690
+ agentId: string | null
691
+ kind: TownBubbleKind
692
+ text: string
693
+ ttlMs: number
694
+ }
695
+
696
+ /** 每个实例化 Channel 的信息接收器:WS 实时信息经 handleTownEvent 入队,FIFO 逐条消费渲染到对应 Agent 头顶 */
697
+ interface ChannelMessageReceiver {
698
+ channelId: string
699
+ queue: BubbleMsg[]
700
+ current: BubbleMsg | null
701
+ currentUntil: number
702
+ }
703
+
704
+ /** 世界尺度/速度/身份色等纯常量与函数集中定义于 #shared/town-scene-math(与 2D/测试共用) */
705
+
706
+ /** 角色模型来源登记(registerModelsFromList) */
707
+ interface ModelInfo { id: string, file: string, name: string, kind?: string, hFactor?: number }
708
+
709
+ export class TownScene3D {
710
+ private renderer!: THREE.WebGLRenderer
711
+ /** 后处理管线:Render → Bloom(夜航辉光)→ Output(tone mapping + sRGB;曝光经 setExposure 仍实时生效) */
712
+ private composer!: EffectComposer
713
+ private camera!: THREE.PerspectiveCamera
714
+ /** 相机注视目标(拖拽平移它,zoom 微调距离) */
715
+ private camTarget = new THREE.Vector3(WORLD_CX, 20, WORLD_CZ)
716
+ /** 主方向光(阴影相机范围随 dolly 扩,大领地投影不消失) */
717
+ private keyLight!: THREE.DirectionalLight
718
+ private scene!: THREE.Scene
719
+ private ground!: THREE.Mesh
720
+ /** 穹顶天幕(随镜头平移,保证无限视野观感) */
721
+ private skyDome: THREE.Mesh | null = null
722
+ private blocks = new Map<string, Block3D>()
723
+ private agents = new Map<string, Agent3D>()
724
+ /** 数字孪生设备节点(twinId → node) */
725
+ private deviceNodes = new Map<string, DeviceNode>()
726
+ /** 数采节点顶端 LED 环(twinId → 环;渲染循环缓转呼吸) */
727
+ private daqLedRings = new Map<string, THREE.Mesh>()
728
+ /** 数采→设备 绑定链路(虚线贝塞尔 + 流动脉冲;syncDaqLinks 维护) */
729
+ private daqLinkGroup = new THREE.Group()
730
+ private daqLinks: Array<{ daqId: string, deviceId: string, line: THREE.Line, pulse: THREE.Mesh, curve: THREE.QuadraticBezierCurve3, pt: number, ptSig: string }> = []
731
+
732
+ // ===== 投影/拾取 scratch 单例(热路径零分配) =====
733
+ private static readonly _wDir = new THREE.Vector3()
734
+ private static readonly _wRel = new THREE.Vector3()
735
+ private static readonly _wVec = new THREE.Vector3()
736
+ private static readonly _wVec2 = new THREE.Vector2()
737
+ private static readonly _groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
738
+ private static readonly _raycaster = new THREE.Raycaster()
739
+ private daqLinkSig = ''
740
+ /** 期望链路(TownView 传入;设备节点晚到时由 syncDevices 末尾重仲裁) */
741
+ private daqLinksWanted: Array<{ daqId: string, deviceId: string }> = []
742
+ /** 薄膜 web(产线设备之间的半透明膜;按 X 序连接挤出→流延→MD→TD→收卷) */
743
+ private filmWebGroup = new THREE.Group()
744
+ private filmWebSig = ''
745
+ private filmWebMat: THREE.MeshStandardMaterial | null = null
746
+ /** 服务端已有但模型资产尚未注册的设备孪生,资产到达后补建节点。 */
747
+ private pendingDeviceTwins = new Map<string, DeviceTwinSync>()
748
+ /** 尚未取得服务端 ID 的本地设备节点(临时 ID → 创建请求初始状态)。 */
749
+ private pendingDeviceCreates = new Map<string, { name: string, modelRef: string, posX: number, posZ: number }>()
750
+ /** 用户在创建设备完成前删除的临时节点,创建完成后补偿删除服务端记录。 */
751
+ private cancelledDeviceCreates = new Set<string>()
752
+ /** 场景内可缩放目标(Agent 或设备) */
753
+ private scalables = new Map<string, ScaledTarget>()
754
+ /** 当前选中(kind:id) */
755
+ private selected: { kind: 'agent' | 'device', id: string } | null = null
756
+ /** 已登记模型(id → file) */
757
+ private modelsById = new Map<string, ModelInfo>()
758
+ /** GLTF 缓存(避免重复加载) */
759
+ private gltfCache = new Map<string, { file: string, scene: THREE.Group, height: number }>()
760
+ private gltfLoader = new GLTFLoader()
761
+ private el: HTMLDivElement
762
+ private raf = 0
763
+ private clock = new THREE.Clock()
764
+ private frameCount = 0
765
+ private fpsAccum = 0
766
+ /** 帧预算(ms):渲染节流上限;0 = 不限制。数据消费不走此门控(帧到达即入实时缓冲) */
767
+ private frameBudgetMs = 1000 / 40
768
+
769
+ /** 用户帧率选择(60/120/0=不限制):只影响渲染节流,与数据消费频率无关 */
770
+ setFpsCap(fps: number): void {
771
+ this.frameBudgetMs = fps > 0 ? 1000 / fps : 0
772
+ this.frameAcc = 0
773
+ }
774
+
775
+ private frameAcc = 0
776
+ /** 动态分辨率基准(初始 dpr):实测帧率过低时降,富余时回升 */
777
+ private baseDpr = Math.min(window.devicePixelRatio, 2)
778
+ private dirty = true
779
+ /** 频道旗缓存(任务状态) */
780
+ private flagBy = new Map<string, THREE.Mesh>()
781
+ private disposed = false
782
+ /** 当前相机缩放(滚轮;作用于 dolly 距离) */
783
+ private dolly = 1.0
784
+ /** 轨道相机状态机(Blender 规范):yaw 方位角 / pitch 仰角 / radius 半径。
785
+ * viewTarget 供预设平滑趋近;左键环绕直接改双值(即时跟手)。 */
786
+ private autoOrbit = false
787
+ private viewCur = { yaw: 0, pitch: 0.7, radius: 1178 }
788
+ private viewTarget = { yaw: 0, pitch: 0.7, radius: 1178 }
789
+ /** Blender 式变换手柄(选中设备;G 移动 / R 旋转 / S 缩放) */
790
+ private tControls: TransformControls | null = null
791
+ /** 舞台尺寸观察器 */
792
+ private resizeOb: ResizeObserver | null = null
793
+ /** 编辑 / 浏览模式(浏览只读:设备/角色不可拖,仅点选) */
794
+ private mode: TownScene3DMode = 'browse'
795
+ /** 正在拖曳的场景对象(编辑模式):设备 / 角色落点 / 频道整体 / 边界手柄 / Agent 活动范围 */
796
+ private pointerDrag:
797
+ | { kind: 'device', id: string }
798
+ | { kind: 'agent', id: string }
799
+ | { kind: 'channel', id: string, dx: number, dz: number }
800
+ | { kind: 'resize', id: string, handle: number }
801
+ | { kind: 'channelEdge', id: string, rx0: number, rz0: number, rd0: number }
802
+ | { kind: 'rangeDraw', id: string, x0: number, z0: number }
803
+ | { kind: 'agentRange', id: string, dx: number, dz: number }
804
+ | { kind: 'agentRangeResize', id: string, handle: number }
805
+ | null = null
806
+
807
+ /** 网格吸附(编辑拖拽落点对齐 16 单位网格) */
808
+ private snapEnabled = true
809
+ /** 选中高亮环(琥珀色动效环,跟随当前选中设备/角色;渲染循环驱动) */
810
+ private selRing: THREE.Mesh | null = null
811
+ /** 边界缩放手柄(编辑模式选中频道时显示;拖拽手柄调整 radiusX/radiusZ) */
812
+ private resizeHandles: Array<{ mesh: THREE.Mesh, cid: string, handle: number }> = []
813
+ /** Agent 活动范围缩放手柄(编辑模式选中带范围角色时显示;拖拽调整该 Agent 范围大小) */
814
+ private agentRangeHandles: Array<{ mesh: THREE.Mesh, agentId: string, handle: number }> = []
815
+ /** 框选绘制预览线框(rangeDraw 模式;松开后变为正式范围) */
816
+ private rangeDrawLine: THREE.LineLoop | null = null
817
+ /** 框选绘制目标 agent(非空 = 编辑模式正在为该角色拉动矩形框) */
818
+ private rangeDrawAgent: string | null = null
819
+ /** 框选绘制当前对角(供 endPointerDrag 生成范围) */
820
+ private rangeDrawPreviewState: { x0: number, z0: number, x1: number, z1: number } | null = null
821
+ /** setAgentRangeScene 中因范围收缩而位移的 home(面板提交时一并落库) */
822
+ private rangeHomeMoved: { agentId: string, x: number, z: number } | null = null
823
+ private lastActivity: { channelId: string, agentName: string, text: string, at?: number } | null = null
824
+ private recentActivity: Array<{ channelId: string, agentName: string, text: string, at?: number }> = []
825
+ private dbgBubbles: Array<{ text: string, at: number }> = []
826
+ /** 频道信息接收器(channelId → FIFO 队列;实时信息经 handleTownEvent 入队,渲染循环逐条消费) */
827
+ private receivers = new Map<string, ChannelMessageReceiver>()
828
+ readonly container: HTMLDivElement
829
+
830
+ /** 任务 ID → assignee 反查 */
831
+ resolveTaskAssignee: ((taskId: string) => string | null) | null = null
832
+ /** 数字孪生设备 API(由 TownView 注入 useDeviceTwins 适配器;拖 dev 模型进场景时创建设备) */
833
+ devices: {
834
+ create(input: { name: string, modelRef?: string, kind?: string, controls?: string[], posX?: number, posZ?: number, scale?: number }): Promise<{ id: string }>
835
+ update(id: string, patch: DeviceTransformPatch): Promise<unknown>
836
+ remove?(id: string): Promise<unknown>
837
+ control(id: string, command: string, args?: Record<string, unknown>): Promise<unknown>
838
+ } | null = null
839
+
840
+ /** 管理员布局:保存 Agent 落点(由 TownView 注入;经既有 channel_agents.config 持久化) */
841
+ agentApi: {
842
+ updateHome(agentId: string, channelId: string, x: number, z: number): Promise<unknown>
843
+ /** 保存 Agent 独立活动范围(经 config.range 持久化;null 清除回退频道边界) */
844
+ updateRange(agentId: string, channelId: string, range: AgentRangeLayout | null): Promise<unknown>
845
+ } | null = null
846
+
847
+ /** 频道布局持久化(由 TownView 注入;频道拖拽移动/边界拖拽调整后落库) */
848
+ channelApi: {
849
+ save(channelId: string, layout: ChannelLayout): Promise<unknown>
850
+ } | null = null
851
+
852
+ private readonly busHandlers = new Map<string, (e: unknown) => void>()
853
+
854
+ constructor(seed: TownEntityInput[], el: HTMLDivElement) {
855
+ this.el = el
856
+ this.container = el
857
+ this.initRenderer()
858
+ this.buildBlocks(seed)
859
+ }
860
+
861
+ // ================================================================
862
+ // 初始化(渲染器/相机/灯光/地面)
863
+ // ================================================================
864
+
865
+ private initRenderer(): void {
866
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' })
867
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
868
+ this.renderer.setSize(this.el.clientWidth || 1100, this.el.clientHeight || 700)
869
+ this.renderer.shadowMap.enabled = true
870
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap
871
+ // 阴影按需重绘:渲染循环里仅场景内容变化(dirty)或角色动画在跑时置 needsUpdate,
872
+ // 静态场景(总览/无动画)不再每帧全量重绘阴影贴图(2048² PCFSoft 是最大单项 GPU 开销)
873
+ this.renderer.shadowMap.autoUpdate = false
874
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace
875
+ // 电影级色调映射:高光滚降 + 中间调层次,GLB 材质不再"平板曝光"
876
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping
877
+ this.renderer.toneMappingExposure = 1.12
878
+ this.el.appendChild(this.renderer.domElement)
879
+ // 舞台尺寸跟随宿主(网格布局/抽屉/全屏切换都会改变宿主尺寸)
880
+ this.resizeOb = new ResizeObserver(() => {
881
+ const w = this.el.clientWidth
882
+ const h = this.el.clientHeight
883
+ if (w < 2 || h < 2) return
884
+ this.renderer.setSize(w, h)
885
+ this.composer.setSize(w, h)
886
+ this.camera.aspect = w / h
887
+ this.camera.updateProjectionMatrix()
888
+ this.dirty = true
889
+ })
890
+ this.resizeOb.observe(this.el)
891
+ // 选中:点击 Agent/设备 → 弹缩放/旋转滑杆(经 on('select') 通知 Vue);拖拽释放不触发
892
+ // 点击语义守卫:记录按下位置;松手位移大 = 相机平移手势 → 不触发点选(含信标)
893
+ let clickDownAt: { x: number, y: number } | null = null
894
+ this.renderer.domElement.addEventListener('pointerdown', (e: PointerEvent) => {
895
+ clickDownAt = e.button === 0 ? { x: e.clientX, y: e.clientY } : null
896
+ })
897
+ this.renderer.domElement.addEventListener('pointerup', (e: PointerEvent) => {
898
+ if (e.button !== 0 || this.pointerDrag || this.tControls?.dragging) return
899
+ // 平移手势(按下后移动超 8px)→ 不作点选
900
+ if (clickDownAt && Math.hypot(e.clientX - clickDownAt.x, e.clientY - clickDownAt.y) > 8) {
901
+ clickDownAt = null
902
+ return
903
+ }
904
+ clickDownAt = null
905
+ const w = this.screenToWorld(e.clientX, e.clientY)
906
+ // 频道信标优先(领队驻扎中心与信标重叠;中心 ±34 内点=信标,按住拖=Agent:
907
+ // 拖拽手势在 pointerup 时因 pointerDrag 存在提前返回,不会误触信标)
908
+ const beacon = this.pickBeacon(w.x, w.z)
909
+ if (beacon) {
910
+ this.setSelected(null)
911
+ this.selectChannel(beacon.cid)
912
+ this.focusTo(beacon.x, beacon.z)
913
+ return
914
+ }
915
+ const hit = this.pickAt(w.x, w.z)
916
+ if (hit) {
917
+ this.setSelected(hit)
918
+ // 点中角色/设备时清除频道选中(避免与边界面板混用)
919
+ if (this.selectedChannel) this.selectChannel(null)
920
+ return
921
+ }
922
+ // 未命中 agent/设备 → 尝试点选频道领地(打开边界编辑面板)
923
+ const cid = this.pickChannel(w.x, w.z)
924
+ this.setSelected(null)
925
+ if (cid && this.mode === 'edit') this.selectChannel(cid)
926
+ })
927
+ // 双击 Agent/设备:缓动聚焦(指挥官镜头;不打断选择语义)
928
+ this.renderer.domElement.addEventListener('dblclick', (e: MouseEvent) => {
929
+ if (this.pointerDrag) return
930
+ const w = this.screenToWorld(e.clientX, e.clientY)
931
+ const hit = this.pickAt(w.x, w.z)
932
+ if (hit?.kind === 'agent') {
933
+ const a = this.agents.get(hit.id)
934
+ if (a) this.focusTo(a.root.position.x, a.root.position.z)
935
+ }
936
+ else if (hit?.kind === 'device') {
937
+ const d = this.deviceNodes.get(hit.id)
938
+ if (d) this.focusTo(d.root.position.x, d.root.position.z)
939
+ }
940
+ else {
941
+ // 双击领地(非 Agent/设备落点)→ 对焦该 Channel 中心并选中
942
+ // (浏览模式:边界面板即刻可用调节范围;编辑模式:缩放手柄同时显示,可拖拽控制)
943
+ const cid = this.pickChannel(w.x, w.z)
944
+ const b = cid ? this.blocks.get(cid) : undefined
945
+ if (b) {
946
+ this.selectChannel(cid)
947
+ this.focusTo(b.x, b.z)
948
+ }
949
+ }
950
+ })
951
+
952
+ this.scene = new THREE.Scene()
953
+ this.scene.background = new THREE.Color(0x0a0f18)
954
+ // 大气纵深:雾把远处柔化进夜色,2.5D 场景立刻有工业孪生的空间感
955
+ this.scene.fog = new THREE.Fog(0x1b2836, 1400, 8800)
956
+ // PBR 环境光照:PMREM 室内环境 → 金属/粗糙 GLB 材质获得真实反射与 specular 生命;
957
+ // 低强度保留夜景工业气氛(只在材质细节里"呼吸",不提亮全场)
958
+ try {
959
+ const pmrem = new THREE.PMREMGenerator(this.renderer)
960
+ this.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture
961
+ this.scene.environmentIntensity = 0.42
962
+ pmrem.dispose()
963
+ }
964
+ catch { /* 环境贴图失败:退回纯灯光方案 */ }
965
+
966
+ // 穹顶渐变天幕(深空蓝黑 → 地平线工业暖灰;BackSide 大球,随镜头平移)
967
+ this.skyDome = this.makeSkyDome()
968
+ this.scene.add(this.skyDome)
969
+
970
+ // 边界缩放手柄(编辑模式选中频道时显示;4 个:椭圆轴点 / 矩形角点)
971
+ for (let i = 0; i < 4; i++) {
972
+ const h = new THREE.Mesh(
973
+ new THREE.TorusGeometry(16, 6, 8, 20),
974
+ new THREE.MeshBasicMaterial({ color: 0xf6c453, transparent: true, opacity: 0.95, depthTest: false }),
975
+ )
976
+ h.rotation.x = Math.PI / 2
977
+ h.position.y = 1.4
978
+ h.visible = false
979
+ this.scene.add(h)
980
+ this.resizeHandles.push({ mesh: h, cid: '', handle: i })
981
+ }
982
+ // Agent 活动范围缩放手柄(编辑模式选中带范围角色时显示;4 个:椭圆轴点 / 矩形角点)
983
+ for (let i = 0; i < 4; i++) {
984
+ const h = new THREE.Mesh(
985
+ new THREE.TorusGeometry(10, 4, 8, 16),
986
+ new THREE.MeshBasicMaterial({ color: 0x41c8f4, transparent: true, opacity: 0.95, depthTest: false }),
987
+ )
988
+ h.rotation.x = Math.PI / 2
989
+ h.position.y = 1.2
990
+ h.visible = false
991
+ this.scene.add(h)
992
+ this.agentRangeHandles.push({ mesh: h, agentId: '', handle: i })
993
+ }
994
+
995
+ // 选中高亮环:单位几何 + 每帧按目标缩放(琥珀 #f6c453,加性发光,慢转活性)
996
+ const selRing = new THREE.Mesh(
997
+ new THREE.RingGeometry(0.92, 1.0, 56),
998
+ new THREE.MeshBasicMaterial({ color: 0xf6c453, transparent: true, opacity: 0.85, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }),
999
+ )
1000
+ selRing.rotation.x = -Math.PI / 2
1001
+ selRing.position.y = 0.5
1002
+ selRing.visible = false
1003
+ selRing.renderOrder = 5
1004
+ this.scene.add(selRing)
1005
+ this.selRing = selRing
1006
+
1007
+ // 相机:斜俯视 2.5D(FOV 55 + 远距框景,整个园区一块入画)
1008
+ this.camera = new THREE.PerspectiveCamera(55, (this.el.clientWidth || 1100) / (this.el.clientHeight || 700), 1, 12000)
1009
+ this.camera.position.set(WORLD_CX, 760, WORLD_CZ + 900)
1010
+ this.camera.lookAt(WORLD_CX, 20, WORLD_CZ)
1011
+
1012
+ // Blender 式变换手柄:选中设备时出现(移动 G / 旋转 R / 缩放 S;UI 分段钮 + 键盘同源)
1013
+ const tc = new TransformControls(this.camera, this.renderer.domElement)
1014
+ tc.size = 0.85
1015
+ tc.setMode('translate')
1016
+ tc.showY = false
1017
+ tc.addEventListener('dragging-changed', (e) => {
1018
+ // 手柄拖拽期间压制场景点选/相机平移;松手后设备 transform 落库
1019
+ if (!e.value) {
1020
+ const sel = this.selected
1021
+ if (sel?.kind === 'device' && this.deviceNodes.has(sel.id)) this.persistDeviceTransform(sel.id)
1022
+ }
1023
+ })
1024
+ tc.addEventListener('objectChange', () => {
1025
+ const sel = this.selected
1026
+ if (!sel || sel.kind !== 'device') return
1027
+ const dev = this.deviceNodes.get(sel.id)
1028
+ if (!dev) return
1029
+ // 缩放转移:gizmo 对 root 的缩放倍率转移到模型 holder(免得环/铭牌跟着变大),root 复位
1030
+ if (tc.mode === 'scale' && Math.abs(dev.root.scale.x - 1) > 1e-4) {
1031
+ const reg = this.scalables.get(`device:${sel.id}`)
1032
+ const cur = reg?.userScale ?? 1
1033
+ this.setModelScale(sel.id, Math.min(50, Math.max(0.02, cur * dev.root.scale.x)), 'device')
1034
+ dev.root.scale.set(1, 1, 1)
1035
+ }
1036
+ this.dirty = true
1037
+ })
1038
+ this.tControls = tc
1039
+ this.scene.add(tc.getHelper())
1040
+
1041
+ // 灯光:为 ACES 配平(整体提亮补偿 filmic 滚降;normalBias 消除模型自阴影痤疮)
1042
+ this.scene.add(new THREE.AmbientLight(0xd0e4ee, 1.6))
1043
+ const hemi = new THREE.HemisphereLight(0x9fc7e8, 0x3a2f25, 0.7)
1044
+ this.scene.add(hemi)
1045
+ const key = new THREE.DirectionalLight(0xfff4e0, 3.2)
1046
+ key.position.set(WORLD_CX + 500, 900, WORLD_CZ + 300)
1047
+ key.castShadow = true
1048
+ this.keyLight = key
1049
+ // 高分辨率软影:2048 map + 大 radius(PCSS 观感),贴地阴影是真实感的核心来源
1050
+ key.shadow.mapSize.set(2048, 2048)
1051
+ key.shadow.camera.left = -1400
1052
+ key.shadow.camera.right = 1400
1053
+ key.shadow.camera.top = 2000
1054
+ key.shadow.camera.bottom = -2000
1055
+ key.shadow.normalBias = 2
1056
+ key.shadow.bias = -0.0002
1057
+ key.shadow.radius = 6
1058
+ this.scene.add(key)
1059
+ const fill = new THREE.DirectionalLight(0x88bbff, 0.75)
1060
+ fill.position.set(WORLD_CX - 1200, 900, WORLD_CZ - 800)
1061
+ this.scene.add(fill)
1062
+ // 设计稿光系补光:青色轮廓光(金属切边高光)+ 绿色低位补光(孪生绿氛围)
1063
+ const rim = new THREE.DirectionalLight(0x41c8f4, 1.4)
1064
+ rim.position.set(WORLD_CX - 1400, 620, WORLD_CZ - 1000)
1065
+ this.scene.add(rim)
1066
+ const green = new THREE.DirectionalLight(0x35e0a0, 0.4)
1067
+ green.position.set(WORLD_CX + 700, 380, WORLD_CZ + 900)
1068
+ this.scene.add(green)
1069
+
1070
+ // 地面(大平面,无限观感:7× 世界尺寸 + 高重复贴图)——夜航深蓝地坪;低反射让网格退为纹理
1071
+ const groundMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.62, metalness: 0.3, envMapIntensity: 0.42 })
1072
+ this.ground = new THREE.Mesh(new THREE.PlaneGeometry(WORLD_W * 16, WORLD_H * 16), groundMat)
1073
+ this.ground.rotation.x = -Math.PI / 2
1074
+ this.ground.receiveShadow = true
1075
+ this.scene.add(this.ground)
1076
+
1077
+ // 绑定链路层 + 薄膜 web 层(syncDaqLinks / 产线设备增删移动时重建)
1078
+ this.scene.add(this.daqLinkGroup, this.filmWebGroup)
1079
+
1080
+ // 后处理:RenderPass → UnrealBloom(夜航辉光只作用于 HDR 顶端:阈值 3.0 =
1081
+ // 本灯光系下漫反射亮面(2~2.5 线性)不起晕,只留指示灯/金属高光;LED 等指示件
1082
+ // 已按 ×4.5~6 提为 HDR)→ OutputPass(tone mapping + sRGB 收尾;setExposure
1083
+ // 经 OutputPass 透传仍实时生效)。MSAA 目标保住抗锯齿(EffectComposer 默认无 MSAA)。
1084
+ const bloomTarget = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType, samples: 4 })
1085
+ this.composer = new EffectComposer(this.renderer, bloomTarget)
1086
+ this.composer.setPixelRatio(this.renderer.getPixelRatio())
1087
+ this.composer.setSize(this.el.clientWidth || 1100, this.el.clientHeight || 700)
1088
+ this.composer.addPass(new RenderPass(this.scene, this.camera))
1089
+ this.composer.addPass(new UnrealBloomPass(new THREE.Vector2(this.el.clientWidth || 1100, this.el.clientHeight || 700), 0.32, 0.35, 3.0))
1090
+ this.composer.addPass(new OutputPass())
1091
+
1092
+ this.applyGroundTexture()
1093
+
1094
+ // E2E/性能探针钩子:真值验证用(渲染帧计数/质量档/pixelRatio),dispose 时清除
1095
+ ;(globalThis as { __townScene3d?: TownScene3D }).__townScene3d = this
1096
+ this.loop()
1097
+ }
1098
+
1099
+ /** 工业孪生地面贴图(程序化 Canvas:深色混凝土地基 + 分块拼缝 + 细网格导引线 + 噪点)。
1100
+ * 一次性生成并缓存;SVG 背景贴图仅作历史兼容,优先使用本贴图。 */
1101
+ private makeIndustrialGroundTexture(): THREE.CanvasTexture {
1102
+ const size = 1024
1103
+ const canvas = document.createElement('canvas')
1104
+ canvas.width = size
1105
+ canvas.height = size
1106
+ const ctx = canvas.getContext('2d')!
1107
+ // 深色混凝土地基(微渐变)+ 分块拼缝 + 细网格导引线 —— 夜航深蓝基调(设计稿 #131f36 系)
1108
+ const grad = ctx.createLinearGradient(0, 0, size, size)
1109
+ grad.addColorStop(0, '#151f36')
1110
+ grad.addColorStop(0.5, '#101a2d')
1111
+ grad.addColorStop(1, '#131c31')
1112
+ ctx.fillStyle = grad
1113
+ ctx.fillRect(0, 0, size, size)
1114
+ // 分块拼缝(工业板格 8×8;低调度 —— 地面是舞台,不是主角)
1115
+ ctx.strokeStyle = 'rgba(42,63,102,0.32)'
1116
+ ctx.lineWidth = 2
1117
+ for (let i = 0; i <= 8; i++) {
1118
+ const p = (i / 8) * size
1119
+ ctx.beginPath()
1120
+ ctx.moveTo(p, 0)
1121
+ ctx.lineTo(p, size)
1122
+ ctx.stroke()
1123
+ ctx.beginPath()
1124
+ ctx.moveTo(0, p)
1125
+ ctx.lineTo(size, p)
1126
+ ctx.stroke()
1127
+ }
1128
+ // 细网格导引线(设计稿 GridHelper 主色 0x2a3f66;压暗让设备读第一眼)
1129
+ ctx.strokeStyle = 'rgba(42,63,102,0.2)'
1130
+ ctx.lineWidth = 1
1131
+ for (let i = 0; i <= 32; i++) {
1132
+ const p = (i / 32) * size
1133
+ ctx.beginPath()
1134
+ ctx.moveTo(p, 0)
1135
+ ctx.lineTo(p, size)
1136
+ ctx.stroke()
1137
+ ctx.beginPath()
1138
+ ctx.moveTo(0, p)
1139
+ ctx.lineTo(size, p)
1140
+ ctx.stroke()
1141
+ }
1142
+ // 噪点(亚光粗糙感;伪随机但确定性)
1143
+ ctx.fillStyle = 'rgba(255,255,255,0.02)'
1144
+ for (let i = 0; i < 900; i++) {
1145
+ const x = (i * 733) % size
1146
+ const y = (i * 151) % size
1147
+ ctx.fillRect(x, y, 2, 2)
1148
+ }
1149
+ const tex = new THREE.CanvasTexture(canvas)
1150
+ tex.colorSpace = THREE.SRGBColorSpace
1151
+ tex.wrapS = THREE.RepeatWrapping
1152
+ tex.wrapT = THREE.RepeatWrapping
1153
+ tex.repeat.set(23, 17)
1154
+ tex.anisotropy = Math.min(8, this.renderer.capabilities.getMaxAnisotropy())
1155
+ return tex
1156
+ }
1157
+
1158
+ /** 穹顶天幕:垂直渐变(顶部深空 → 地平线工业暖灰)+ 顶半球星野 + 底部更亮一点,覆盖整球 */
1159
+ private makeSkyDome(): THREE.Mesh {
1160
+ const canvas = document.createElement('canvas')
1161
+ canvas.width = 1024
1162
+ canvas.height = 256
1163
+ const ctx = canvas.getContext('2d')!
1164
+ const grad = ctx.createLinearGradient(0, 0, 0, 256)
1165
+ grad.addColorStop(0, '#080d16') // 天顶:近黑夜空
1166
+ grad.addColorStop(0.40, '#121b27')
1167
+ grad.addColorStop(0.64, '#22303e') // 中段石墨蓝(提前起坡,渐变带更长)
1168
+ grad.addColorStop(0.84, '#3b4b5b') // 地平线:钢色微光(与雾同族)
1169
+ grad.addColorStop(1, '#2a3644') // 地平线下收暗(下半球)
1170
+ ctx.fillStyle = grad
1171
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
1172
+ // 星野(顶部 45%:确定性伪随机,数据青/冷白微点,越靠天顶越密 —— 夜航纵深,低调度不抢戏)
1173
+ let seed = 20260831
1174
+ const rnd = (): number => {
1175
+ seed = (seed * 1103515245 + 12345) % 2147483648
1176
+ return seed / 2147483648
1177
+ }
1178
+ for (let i = 0; i < 220; i++) {
1179
+ const x = rnd() * canvas.width
1180
+ const y = rnd() * rnd() * 118
1181
+ const r = rnd() < 0.88 ? 1 : 2
1182
+ const a = 0.12 + rnd() * 0.5
1183
+ ctx.fillStyle = rnd() < 0.24 ? `rgba(160,220,255,${a.toFixed(2)})` : `rgba(232,240,250,${a.toFixed(2)})`
1184
+ ctx.beginPath()
1185
+ ctx.arc(x, y, r, 0, Math.PI * 2)
1186
+ ctx.fill()
1187
+ }
1188
+ const tex = new THREE.CanvasTexture(canvas)
1189
+ tex.colorSpace = THREE.SRGBColorSpace
1190
+ const geo = new THREE.SphereGeometry(5600, 24, 16)
1191
+ const mat = new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide, fog: false, depthWrite: false })
1192
+ const dome = new THREE.Mesh(geo, mat)
1193
+ dome.position.set(WORLD_CX, 0, WORLD_CZ)
1194
+ dome.renderOrder = -10
1195
+ return dome
1196
+ }
1197
+
1198
+ /** 赛博小镇背景贴图 → 地面材质(优先程序化工业贴图;SVG 兼容保留) */
1199
+ private applyGroundTexture(): void {
1200
+ try {
1201
+ const mat = this.ground.material as THREE.MeshStandardMaterial
1202
+ // 工业孪生贴图为主(SVG 历史贴图只在程序化不可用时兜底)
1203
+ mat.map = this.makeIndustrialGroundTexture()
1204
+ mat.color.set(0xffffff)
1205
+ mat.roughness = 0.94
1206
+ mat.metalness = 0.06
1207
+ // 环境反射压低:沥青地面保持亚光,反射生命留给金属设备/角色模型
1208
+ mat.envMapIntensity = 0.28
1209
+ mat.needsUpdate = true
1210
+ this.dirty = true
1211
+ }
1212
+ catch { /* Canvas 不可用:保持纯色地面 */ }
1213
+ }
1214
+
1215
+ // ================================================================
1216
+ // 事件总线(与 2D 同构)
1217
+ // ================================================================
1218
+
1219
+ on<K extends keyof TownEventMap>(event: K, fn: (e: TownEventMap[K]) => void): () => void {
1220
+ this.busHandlers.set(event, fn as (e: unknown) => void)
1221
+ return () => this.busHandlers.delete(event)
1222
+ }
1223
+
1224
+ private emit<K extends keyof TownEventMap>(event: K, e: TownEventMap[K]): void {
1225
+ const fn = this.busHandlers.get(event)
1226
+ if (fn) fn(e)
1227
+ }
1228
+
1229
+ // ================================================================
1230
+ // 实体基线构建(布局驱动放置:只呈现「已放置」的频道;未放置不进场景)
1231
+ // ================================================================
1232
+
1233
+ /**
1234
+ * 用频道实体基线同步领地/角色。仅放置已保存布局的频道(布局来自 useSceneLayouts,
1235
+ * 经 applySceneLayouts 注入);未放置频道只出现在频道坞,不进 3D。
1236
+ */
1237
+ private buildBlocks(seeds: TownEntityInput[]): void {
1238
+ this.entityIndex.clear()
1239
+ for (const ch of seeds) {
1240
+ this.entityIndex.set(ch.channelId, ch)
1241
+ const layout = this.layouts.get(ch.channelId)
1242
+ if (!layout) continue
1243
+ this.placeChannel(ch, layout)
1244
+ }
1245
+ this.emit('blockCount', this.blocks.size)
1246
+ this.emit('agentCount', this.agents.size)
1247
+ }
1248
+
1249
+ /** 按布局放置一个频道领地(面向对象:Block3D 实例)+ 在其边界内铺放全部 Agent */
1250
+ private placeChannel(ch: TownEntityInput, rawLayout: ChannelLayout): void {
1251
+ const layout = normLayout(rawLayout)
1252
+ const color = channelColorNum(ch.channelId)
1253
+ const pad = this.makeBlock(ch.channelName, color, layout)
1254
+ const block = new Block3D({
1255
+ channelId: ch.channelId, name: ch.channelName,
1256
+ x: layout.x, z: layout.z,
1257
+ radiusX: layout.radiusX, radiusZ: layout.radiusZ,
1258
+ shape: layout.shape, rotationY: layout.rotationY,
1259
+ color,
1260
+ platform: pad.platform,
1261
+ padRing: pad.padRing,
1262
+ beacon: pad.beacon,
1263
+ boundary: makeBoundary(layout.shape, layout.radiusX, layout.radiusZ, color),
1264
+ label: this.makeLabel(ch.channelName, layout.x, 30, layout.z),
1265
+ })
1266
+ block.host = this
1267
+ block.boundary.position.set(layout.x, 0.3, layout.z)
1268
+ block.boundary.rotation.y = layout.rotationY * Math.PI / 180
1269
+ this.scene.add(block.boundary)
1270
+ this.blocks.set(ch.channelId, block)
1271
+ // 在边界内铺放该频道的全部 Agent(lead 居中心,worker 左右展开;钳在边界内)
1272
+ this.layoutAgentsInBlock(ch, block)
1273
+ // 面向对象聚合:把该频道全部成员挂到 Block3D.members(供整体移动/边界钳制批量处理)
1274
+ for (const a of this.agents.values()) {
1275
+ if (a.channelId === ch.channelId && !block.members.includes(a)) block.members.push(a)
1276
+ }
1277
+ }
1278
+
1279
+ /** 在频道领地里铺放全部 Agent(按边界内分布;客户自定 home 优先) */
1280
+ private layoutAgentsInBlock(ch: TownEntityInput, block: Block3D): void {
1281
+ const { x, z, radiusX, radiusZ } = block
1282
+ const inner = 0.6 // 内缩比例:agent 铺在边界内侧
1283
+ const n = Math.max(1, ch.agents.length)
1284
+ ch.agents.forEach((a, i) => {
1285
+ // 若该 agent 有 persisted home(且在边界附近)则用之;否则沿长轴均匀铺放
1286
+ let px: number
1287
+ let pz: number
1288
+ if (typeof a.homeX === 'number' && typeof a.homeZ === 'number') {
1289
+ px = a.homeX
1290
+ pz = a.homeZ
1291
+ }
1292
+ else {
1293
+ const t = n <= 1 ? 0 : (i / (n - 1)) * 2 - 1
1294
+ px = x + t * radiusX * inner * 0.8
1295
+ pz = z + (a.role === 'lead' ? 0 : (i % 2 === 0 ? -1 : 1) * radiusZ * inner * 0.4)
1296
+ }
1297
+ this.ensureAgent({ ...a, channelId: ch.channelId }, px, pz, block.color)
1298
+ })
1299
+ }
1300
+
1301
+ /** 领地底盘 = 按真实形状的平台(染色层) + 同形边框环(边界告示);所见即用户设置 */
1302
+ private makeBlock(name: string, color: number, layout: ChannelLayout): { platform: THREE.Mesh, padRing: THREE.Mesh, beacon: THREE.Group } {
1303
+ const rotY = layout.rotationY * Math.PI / 180
1304
+ // 平台:按真实半轴构建(与边框环同一构建路径,无单位几何、无 scale 传递 —— 杜绝缩放残留)
1305
+ const platform = new THREE.Mesh(
1306
+ this.makePadShapeGeometry(layout.shape, layout.radiusX, layout.radiusZ),
1307
+ new THREE.MeshStandardMaterial({
1308
+ color, transparent: true, opacity: 0.24, roughness: 0.85, metalness: 0.12,
1309
+ emissive: color, emissiveIntensity: 0.12, side: THREE.DoubleSide,
1310
+ }),
1311
+ )
1312
+ // 几何已烘焙放平(XY→XZ 无镜像),网格仅做 Y 旋转(与边界线同语义),不再有任何 scale
1313
+ platform.rotation.set(0, rotY, 0)
1314
+ platform.position.set(layout.x, 0.16, layout.z)
1315
+ platform.receiveShadow = false
1316
+ this.scene.add(platform)
1317
+ // 边框环:实际尺寸外形挖去内缩内形(恒定 13 单位线宽,随形状变化重建)
1318
+ const padRing = new THREE.Mesh(
1319
+ this.makePadRingGeometry(layout.shape, layout.radiusX, layout.radiusZ),
1320
+ new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.52, side: THREE.DoubleSide, depthWrite: false }),
1321
+ )
1322
+ padRing.rotation.set(0, rotY, 0)
1323
+ padRing.position.set(layout.x, 0.34, layout.z)
1324
+ this.scene.add(padRing)
1325
+ // 中央信标(HMI 定位销):底座 + 立杆 + 频道色菱形顶标(顶标随渲染循环缓转)
1326
+ const beacon = this.makeBeacon(color)
1327
+ beacon.position.set(layout.x, 0, layout.z)
1328
+ this.scene.add(beacon)
1329
+ void name
1330
+ return { platform, padRing, beacon }
1331
+ }
1332
+
1333
+ /** 频道中心信标(HMI 定位销):点击 → pickBeacon 定位中心 + 唤醒边界编辑 */
1334
+ private makeBeacon(color: number): THREE.Group {
1335
+ const g = new THREE.Group()
1336
+ const metal = new THREE.MeshStandardMaterial({ color: 0x2a3542, roughness: 0.45, metalness: 0.65 })
1337
+ const base = new THREE.Mesh(new THREE.CylinderGeometry(7, 9, 5, 12), metal)
1338
+ base.position.y = 2.5
1339
+ const pole = new THREE.Mesh(new THREE.CylinderGeometry(1.6, 1.6, 30, 8), metal)
1340
+ pole.position.y = 18
1341
+ const tip = new THREE.Mesh(new THREE.OctahedronGeometry(6.5), new THREE.MeshBasicMaterial({ color }))
1342
+ // HDR 亮度:信标顶标是 HMI 定位销的"灯",bloom 起晕(seleRing/边界保持 LDR 不起晕)
1343
+ ;(tip.material as THREE.MeshBasicMaterial).color.multiplyScalar(4.5)
1344
+ tip.position.y = 37
1345
+ g.add(base, pole, tip)
1346
+ return g
1347
+ }
1348
+
1349
+ /** 命中频道信标(中心 ±34 单位;点击信标 = 定位中心 + 唤醒边界编辑,任何模式) */
1350
+ private pickBeacon(x: number, z: number): { cid: string, x: number, z: number } | null {
1351
+ for (const b of this.blocks.values()) {
1352
+ if (Math.hypot(b.x - x, b.z - z) < 34) return { cid: b.channelId, x: b.x, z: b.z }
1353
+ }
1354
+ return null
1355
+ }
1356
+
1357
+ /** 领地平台面几何:按真实半轴的外形(椭圆/矩形);放平烘焙进顶点(XY→XZ,无镜像),
1358
+ * 网格仅需 rotation.y —— 与边界线同一旋转语义,任意朝向严格贴合 */
1359
+ private makePadShapeGeometry(shape: 'ellipse' | 'rect', radiusX: number, radiusZ: number): THREE.ShapeGeometry {
1360
+ const shp = new THREE.Shape()
1361
+ if (shape === 'rect') {
1362
+ shp.moveTo(-radiusX, -radiusZ)
1363
+ shp.lineTo(radiusX, -radiusZ)
1364
+ shp.lineTo(radiusX, radiusZ)
1365
+ shp.lineTo(-radiusX, radiusZ)
1366
+ shp.closePath()
1367
+ }
1368
+ else {
1369
+ shp.absellipse(0, 0, radiusX, radiusZ, 0, Math.PI * 2)
1370
+ }
1371
+ const geo = new THREE.ShapeGeometry(shp, shape === 'rect' ? 1 : 56)
1372
+ geo.rotateX(Math.PI / 2)
1373
+ return geo
1374
+ }
1375
+
1376
+ /** 领地边框几何:外形挖去内缩 ringW 的同形内形(椭圆/矩形;恒定世界线宽) */
1377
+ private makePadRingGeometry(shape: 'ellipse' | 'rect', radiusX: number, radiusZ: number, ringW = 13): THREE.ShapeGeometry {
1378
+ const irx = Math.max(4, radiusX - ringW)
1379
+ const irz = Math.max(4, radiusZ - ringW)
1380
+ const outer = new THREE.Shape()
1381
+ const inner = new THREE.Path()
1382
+ if (shape === 'rect') {
1383
+ outer.moveTo(-radiusX, -radiusZ)
1384
+ outer.lineTo(radiusX, -radiusZ)
1385
+ outer.lineTo(radiusX, radiusZ)
1386
+ outer.lineTo(-radiusX, radiusZ)
1387
+ outer.closePath()
1388
+ inner.moveTo(-irx, -irz)
1389
+ inner.lineTo(irx, -irz)
1390
+ inner.lineTo(irx, irz)
1391
+ inner.lineTo(-irx, irz)
1392
+ inner.closePath()
1393
+ }
1394
+ else {
1395
+ outer.absellipse(0, 0, radiusX, radiusZ, 0, Math.PI * 2)
1396
+ inner.absellipse(0, 0, irx, irz, 0, Math.PI * 2)
1397
+ }
1398
+ outer.holes.push(inner)
1399
+ const geo = new THREE.ShapeGeometry(outer, shape === 'rect' ? 1 : 56)
1400
+ geo.rotateX(Math.PI / 2)
1401
+ return geo
1402
+ }
1403
+
1404
+ /** 平台/边框贴形(半径或形状变化后):平台与边框几何都按真实尺寸重建;同步朝向 */
1405
+ private applyPadToBlock(b: Block3D): void {
1406
+ const rotY = b.rotationY * Math.PI / 180
1407
+ b.platform.rotation.set(0, rotY, 0)
1408
+ b.padRing.rotation.set(0, rotY, 0)
1409
+ const oldP = b.platform.geometry
1410
+ b.platform.geometry = this.makePadShapeGeometry(b.shape, b.radiusX, b.radiusZ)
1411
+ oldP.dispose()
1412
+ const old = b.padRing.geometry
1413
+ b.padRing.geometry = this.makePadRingGeometry(b.shape, b.radiusX, b.radiusZ)
1414
+ old.dispose()
1415
+ }
1416
+
1417
+ /** 文本 Sprite(名牌/名字)—— HMI 铭牌:深底 + 发丝描边 + 左缘数据条 + 等宽字。
1418
+ * accent: 左缘数据条与描边 tint 的身份色(Agent 铭牌传所属频道哈希色 → 归属一眼可辨)。 */
1419
+ private makeLabel(text: string, x: number, y: number, z: number, accent = '#41c8f4'): THREE.Sprite {
1420
+ const canvas = document.createElement('canvas')
1421
+ const ctx = canvas.getContext('2d')!
1422
+ const font = '600 21px "Geist Mono", Geist, "PingFang SC", monospace'
1423
+ ctx.font = font
1424
+ const padL = 26
1425
+ const padR = 14
1426
+ const w = Math.max(104, Math.ceil(ctx.measureText(text).width) + padL + padR)
1427
+ const h = 48
1428
+ canvas.width = w
1429
+ canvas.height = h
1430
+ ctx.font = font
1431
+ // 深色铭牌底 + 半透明度(不遮场景,只提字)
1432
+ ctx.fillStyle = 'rgba(10,14,20,0.8)'
1433
+ ctx.fillRect(0, 0, w, h)
1434
+ // 发丝描边(身份色轻 tint)+ 左缘数据条(身份色)
1435
+ ctx.strokeStyle = `${accent}66`
1436
+ ctx.lineWidth = 1
1437
+ ctx.strokeRect(0.5, 0.5, w - 1, h - 1)
1438
+ ctx.fillStyle = accent
1439
+ ctx.fillRect(0, 0, 3, h)
1440
+ ctx.fillStyle = '#dce7f0'
1441
+ ctx.textAlign = 'left'
1442
+ ctx.textBaseline = 'middle'
1443
+ ctx.fillText(text, padL, h / 2 + 1)
1444
+ const tex = new THREE.CanvasTexture(canvas)
1445
+ tex.colorSpace = THREE.SRGBColorSpace
1446
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }))
1447
+ sprite.scale.set(w / 6, h / 6, 1)
1448
+ sprite.position.set(x, y, z)
1449
+ this.scene.add(sprite)
1450
+ return sprite
1451
+ }
1452
+
1453
+ /** int 颜色 → CSS hex(铭牌身份色用) */
1454
+ private hexOf(color: number): string {
1455
+ return `#${color.toString(16).padStart(6, '0')}`
1456
+ }
1457
+
1458
+ // ================================================================
1459
+ // Agent(3D 角色:根 Group + 模型 + 同频道色环 + 名字)
1460
+ // ================================================================
1461
+
1462
+ private ensureAgent(a: TownEntityInput['agents'][number] & { channelId: string }, cx: number, cz: number, color: number): void {
1463
+ const key = a.agentId
1464
+ if (this.agents.has(key)) return
1465
+ // 管理员布局落点(来自 config.homeX/homeZ)优先;缺省按领地内排布;整体钳制在频道边界内
1466
+ const layout = this.blockLayout(a.channelId)
1467
+ let homeX = a.homeX ?? cx
1468
+ let homeZ = a.homeZ ?? cz
1469
+ if (layout) {
1470
+ const clamped = clampToBoundary(layout, homeX, homeZ, 20)
1471
+ homeX = clamped.x
1472
+ homeZ = clamped.z
1473
+ }
1474
+ // 管理员布局活动范围(来自 config.range;缺省 null = 沿用频道边界);收进频道边界
1475
+ const range: AgentRangeLayout | null = a.range ?? null
1476
+ const root = new THREE.Group()
1477
+ root.position.set(homeX, GROUND_Y, homeZ)
1478
+ const nameSprite = this.makeLabel(a.name, homeX, 48, homeZ, this.hexOf(color))
1479
+ // 默认模型(内置二次元角色 hero-anime-1「樱叶少女」;模型库换装可覆盖)
1480
+ const texKey = a.modelRef || 'hero-anime-1'
1481
+ const model = new THREE.Group()
1482
+ root.add(model)
1483
+ this.scene.add(root)
1484
+ const agent = new Agent3D({
1485
+ channelId: a.channelId,
1486
+ agentId: key,
1487
+ name: a.name,
1488
+ role: a.role,
1489
+ root,
1490
+ model,
1491
+ mixer: null,
1492
+ clips: [],
1493
+ colorNum: color,
1494
+ nameSprite,
1495
+ bubble: null,
1496
+ bubbleText: null,
1497
+ bubbleTimer: null,
1498
+ state: a.state,
1499
+ progress: a.currentTaskProgress ?? null,
1500
+ dragging: false,
1501
+ homeX,
1502
+ homeZ,
1503
+ range,
1504
+ rangeLine: null,
1505
+ textureKey: texKey,
1506
+ modelRef: a.modelRef ?? '',
1507
+ behavior: { mode: 'idle', roamTarget: null, targetId: null, waitUntil: 0, pauseUntil: 0, engaged: false },
1508
+ })
1509
+ agent.host = this
1510
+ agent.attachAura(color, a.role)
1511
+ this.agents.set(key, agent)
1512
+ // 载入模型(GLB);模型库注册先于/晚于挂载都能正确解析(内置二次元角色按 id 直取文件)
1513
+ const info = this.modelsById.get(texKey)
1514
+ ?? (texKey.startsWith('hero-anime-')
1515
+ ? { id: texKey, file: `/assets/game/character/${texKey}.glb`, name: '二次元角色' }
1516
+ : (this.modelsById.get('hero-3d') ?? { id: 'hero-3d', file: '/assets/game/character/hero-3d.glb', name: '标准员工模型' }))
1517
+ void this.mountModel(agent, info.file, info.name)
1518
+ agent.renderRangeLine()
1519
+ this.emit('agentCount', this.agents.size)
1520
+ }
1521
+
1522
+ /**
1523
+ * 绑定模型到 Agent(GLTFLoader 加载 → 归一化 scale/锚点贴地 → 若有动画则 mixer)。
1524
+ * 缺失/失败 → 回退内置 hero-3d,永不白屏。
1525
+ */
1526
+ private async mountModel(asp: Agent3D, file: string, name: string): Promise<void> {
1527
+ const cached = this.gltfCache.get(file)
1528
+ let loaded: THREE.Group
1529
+ let height: number
1530
+ if (cached) {
1531
+ loaded = cached.scene.clone(true)
1532
+ height = cached.height
1533
+ }
1534
+ else {
1535
+ try {
1536
+ const gltf = await this.loadGltf(file)
1537
+ loaded = gltf.scene
1538
+ const box = new THREE.Box3().setFromObject(loaded)
1539
+ height = Math.max(0.5, box.max.y - box.min.y)
1540
+ this.gltfCache.set(file, { file, scene: loaded, height })
1541
+ // 缓存动画 clip(供 mixer 状态切换;GLTFLoader 动画必须在解析层保留)
1542
+ this.agentAnimClips.set(file, gltf.animations)
1543
+ }
1544
+ catch {
1545
+ // 加载失败回退程序化「孪生机器人」(胶囊躯干 + 发光核心 + 天线),角色永不隐形
1546
+ loaded = this.makeFallbackBot()
1547
+ height = 1.4
1548
+ }
1549
+ }
1550
+ // 归一化 scale(使模型高度≈UNITS 世界单位)并贴地(模型大小自适应)
1551
+ loaded.position.y = 0
1552
+ loaded.scale.setScalar(UNITS / height)
1553
+ // 真实投影 + PBR 增强:模型网格 cast/receiveShadow(体积感) + 分级环境反射(角色质感)
1554
+ loaded.traverse((o) => {
1555
+ if ((o as THREE.Mesh).isMesh) o.castShadow = true
1556
+ })
1557
+ this.enhancePbrMaterials(loaded, 'character')
1558
+ // 清掉旧模型子节点与 mixer
1559
+ asp.model.clear()
1560
+ asp.model.add(loaded)
1561
+ // 客制化:注册可缩放目标并恢复用户缩放(套在 asp.model 上,作为自适应之上的倍率层)
1562
+ this.registerScalable('agent', asp.agentId, asp.model)
1563
+ asp.mixer = null
1564
+ asp.activeAction = null
1565
+ const clips = this.agentAnimClips.get(file) ?? []
1566
+ const firstClip = clips[0]
1567
+ if (firstClip) {
1568
+ asp.mixer = new THREE.AnimationMixer(loaded)
1569
+ asp.clips = clips
1570
+ const firstAction = asp.mixer.clipAction(firstClip)
1571
+ firstAction.play()
1572
+ asp.activeAction = firstAction
1573
+ }
1574
+ void name
1575
+ this.dirty = true
1576
+ }
1577
+
1578
+ private agentAnimClips = new Map<string, THREE.AnimationClip[]>()
1579
+
1580
+ private loadGltf(file: string): Promise<{ scene: THREE.Group, animations: THREE.AnimationClip[] }> {
1581
+ return new Promise((resolve, reject) => {
1582
+ this.gltfLoader.load(file, gltf => resolve({ scene: gltf.scene as THREE.Group, animations: gltf.animations ?? [] }), undefined, reject)
1583
+ })
1584
+ }
1585
+
1586
+ /**
1587
+ * 程序化「孪生机器人」兜底模型:GLB 缺失/加载失败时使用,保证 Agent 永远可见。
1588
+ * 结构:胶囊躯干 + 头部 + 发光核心胸灯 + 天线信号球 + 悬浮底盘;中性工业灰 + 青蓝核心。
1589
+ */
1590
+ private makeFallbackBot(): THREE.Group {
1591
+ const bot = new THREE.Group()
1592
+ const bodyMat = new THREE.MeshStandardMaterial({ color: 0x2a3542, roughness: 0.42, metalness: 0.62 })
1593
+ const darkMat = new THREE.MeshStandardMaterial({ color: 0x141b24, roughness: 0.6, metalness: 0.4 })
1594
+ const coreMat = new THREE.MeshBasicMaterial({ color: 0x41c8f4 })
1595
+ // 躯干(胶囊)
1596
+ const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.42, 0.62, 8, 16), bodyMat)
1597
+ body.position.y = 0.86
1598
+ // 头部
1599
+ const head = new THREE.Mesh(new THREE.SphereGeometry(0.32, 16, 12), bodyMat)
1600
+ head.position.y = 1.62
1601
+ // 面窗(发光条带,数字孪生眼)
1602
+ const visor = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.1, 0.12), coreMat)
1603
+ visor.position.set(0, 1.66, 0.27)
1604
+ // 发光核心胸灯
1605
+ const core = new THREE.Mesh(new THREE.SphereGeometry(0.14, 12, 8), coreMat)
1606
+ core.position.set(0, 1.1, 0.36)
1607
+ // 悬浮底盘
1608
+ const base = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.62, 0.26, 16), darkMat)
1609
+ base.position.y = 0.16
1610
+ // 天线 + 信号球
1611
+ const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.5, 6), darkMat)
1612
+ antenna.position.set(0, 2.04, 0)
1613
+ const signal = new THREE.Mesh(new THREE.SphereGeometry(0.08, 10, 8), coreMat)
1614
+ signal.position.set(0, 2.34, 0)
1615
+ bot.add(body, head, visor, core, base, antenna, signal)
1616
+ return bot
1617
+ }
1618
+
1619
+ /** 注册模型清单,并将晚到资产挂载到已存在的 Agent/设备。 */
1620
+ registerModelsFromList(list: Array<{ id: string, file: string, name: string, kind?: string, hFactor?: number }>): void {
1621
+ if (this.disposed) return
1622
+ const changed = new Set<string>()
1623
+ for (const model of list) {
1624
+ const previous = this.modelsById.get(model.id)
1625
+ if (!previous || previous.file !== model.file || previous.name !== model.name || previous.kind !== model.kind || previous.hFactor !== (model.hFactor ?? 1)) {
1626
+ this.modelsById.set(model.id, { ...model, hFactor: model.hFactor ?? 1 })
1627
+ changed.add(model.id)
1628
+ }
1629
+ }
1630
+ if (changed.size > 0) {
1631
+ for (const agent of this.agents.values()) {
1632
+ if (!agent.modelRef || !changed.has(agent.modelRef)) continue
1633
+ const model = this.modelsById.get(agent.modelRef)
1634
+ if (model) void this.mountModel(agent, model.file, model.name)
1635
+ }
1636
+ for (const device of this.deviceNodes.values()) {
1637
+ if (changed.has(device.modelRef)) this.swapDeviceModelSprite(device, device.modelRef)
1638
+ }
1639
+ }
1640
+ for (const twin of [...this.pendingDeviceTwins.values()]) {
1641
+ const isDaq = twin.kind === 'daq' || (twin.modelRef ?? '').startsWith('daq-')
1642
+ if (!isDaq && !this.modelsById.get(twin.modelRef)?.file) continue
1643
+ this.pendingDeviceTwins.delete(twin.id)
1644
+ this.recreateDeviceNode(twin)
1645
+ }
1646
+ }
1647
+
1648
+ // ================================================================
1649
+ // 布局注入 / 重建 / 聚焦 / 事件入口(公开面与 2D 镜像)
1650
+ // ================================================================
1651
+
1652
+ /**
1653
+ * 注入频道领地布局(来自 useSceneLayouts)。构建场景前必须调用;
1654
+ * 未放置的频道会在 rebuild 时被跳过(只停留在频道坞)。
1655
+ */
1656
+ applySceneLayouts(layouts: ChannelLayout[]): void {
1657
+ this.layouts.clear()
1658
+ for (const l of layouts) {
1659
+ const n = normLayout(l)
1660
+ this.layouts.set(n.channelId, n)
1661
+ }
1662
+ }
1663
+
1664
+ /** 频道是否已放入场景(供频道坞标记已放置/未放置) */
1665
+ hasChannel(channelId: string): boolean {
1666
+ return this.blocks.has(channelId)
1667
+ }
1668
+
1669
+ /** 频道当前布局(供边界约束;未放置返回空) */
1670
+ private blockLayout(channelId: string): ChannelLayout | null {
1671
+ const b = this.blocks.get(channelId)
1672
+ return b ? b.layout() : null
1673
+ }
1674
+
1675
+ /** 已放入场景的频道集(供频道坞/选中面板) */
1676
+ placedChannels(): string[] {
1677
+ return [...this.blocks.keys()]
1678
+ }
1679
+
1680
+ /** 频道当前布局(供边界编辑面板初始化) */
1681
+ getChannelLayout(channelId: string): ChannelLayout | null {
1682
+ const b = this.blocks.get(channelId)
1683
+ return b ? b.layout() : null
1684
+ }
1685
+
1686
+ // ================================================================
1687
+ // 控制器公开面(供实体 class 反向调用:场景图/脏标记/布局表/设备名牌与模型)
1688
+ // ================================================================
1689
+
1690
+ /** THREE 场景图(实体渲染线框/气泡时访问) */
1691
+ get threeScene(): THREE.Scene {
1692
+ return this.scene
1693
+ }
1694
+
1695
+ /** 标记场景需重渲染(滚动/交互后由实体行为调用) */
1696
+ markDirty(): void {
1697
+ this.dirty = true
1698
+ }
1699
+
1700
+ /** 记录频道当前布局到场景布局表(移动/缩放后由 Block3D 调用) */
1701
+ trackLayout(b: Block3D): void {
1702
+ this.layouts.set(b.channelId, b.normLayout())
1703
+ }
1704
+
1705
+ /** 设备改名:重建名牌 Sprite(由 DeviceNode.applyTwin 委托) */
1706
+ renameDeviceSprite(dev: DeviceNode, name: string): void {
1707
+ dev.name = name.trim()
1708
+ this.scene.remove(dev.label)
1709
+ dev.label = this.makeLabel(`⚙ ${dev.name}`, dev.root.position.x, 60, dev.root.position.z)
1710
+ this.dirty = true
1711
+ }
1712
+
1713
+ /** 设备换模型:按 modelRef 重挂 GLB 到 holder(由 DeviceNode.applyTwin 委托)。 */
1714
+ swapDeviceModelSprite(dev: DeviceNode, modelRef: string): void {
1715
+ const info = this.modelsById.get(modelRef)
1716
+ if (!info?.file) return
1717
+ if (dev.modelRef === modelRef && dev.holder.userData.modelFile === info.file) return
1718
+ dev.modelRef = modelRef
1719
+ dev.holder.clear()
1720
+ dev.holder.userData.modelFile = info.file
1721
+ void this.loadGltfToGroup(info.file, dev.holder, UNITS * 1.6)
1722
+ this.dirty = true
1723
+ }
1724
+
1725
+ /** 统一实例化入口:按数据库元数据(布局/实体/设备孪生)实例化并初始化场景内全部实例。 */
1726
+ hydrate(channels: TownEntityInput[], layouts: ChannelLayout[], devices: DeviceTwinSync[]): void {
1727
+ this.applySceneLayouts(layouts)
1728
+ this.rebuild(channels)
1729
+ this.syncDevices(devices)
1730
+ this.emit('blockCount', this.blocks.size)
1731
+ this.emit('agentCount', this.agents.size)
1732
+ }
1733
+
1734
+ rebuild(channels: TownEntityInput[]): void {
1735
+ this.resetAll()
1736
+ this.buildBlocks(channels)
1737
+ this.emit('blockCount', this.blocks.size)
1738
+ this.emit('agentCount', this.agents.size)
1739
+ }
1740
+
1741
+ focusChannel(channelId: string): void {
1742
+ const b = this.blocks.get(channelId)
1743
+ if (!b) return
1744
+ this.focusTo(b.x, b.z)
1745
+ }
1746
+
1747
+ // ================================================================
1748
+ // 频道放置(drop 入口)+ 活动边界编辑
1749
+ // ================================================================
1750
+
1751
+ /**
1752
+ * 频道拖入场景:在落点建领地 + 铺放其全部 Agent(钳在边界内)。
1753
+ * 频道 id 需已在实体基线中;返回落点世界坐标(供 HUD 提示)。
1754
+ */
1755
+ dropChannelOnWorld(x: number, z: number, channelId: string, channelName: string, agentCount: number): { x: number, z: number, channelId: string, name: string } {
1756
+ const existing = this.blocks.get(channelId)
1757
+ if (existing) {
1758
+ this.focusTo(existing.x, existing.z)
1759
+ return { x: Math.round(existing.x), z: Math.round(existing.z), channelId, name: existing.name }
1760
+ }
1761
+ // 缺省布局:以落点为中心、与 Agent 数匹配的边界
1762
+ const rx = Math.max(120, 110 + agentCount * 18)
1763
+ const rz = Math.max(80, 70 + agentCount * 14)
1764
+ const layout: ChannelLayout = { channelId, x, z, radiusX: rx, radiusZ: rz, shape: 'ellipse', rotationY: 0 }
1765
+ this.layouts.set(channelId, normLayout(layout))
1766
+ // 从实体基线取该频道 agents(由 TownView ensureChannelPresent 提前放置)
1767
+ const seed = this.entityIndex.get(channelId)
1768
+ if (seed) {
1769
+ this.placeChannel(seed, layout)
1770
+ }
1771
+ else {
1772
+ // 无实体基线(频道坞拖入,数据尚未到达):只建空领地占位
1773
+ const color = channelColorNum(channelId)
1774
+ const pad = this.makeBlock(channelName, color, layout)
1775
+ const block = new Block3D({
1776
+ channelId, name: channelName, x, z,
1777
+ radiusX: layout.radiusX, radiusZ: layout.radiusZ,
1778
+ shape: layout.shape, rotationY: layout.rotationY, color,
1779
+ platform: pad.platform,
1780
+ padRing: pad.padRing,
1781
+ beacon: pad.beacon,
1782
+ boundary: makeBoundary(layout.shape, layout.radiusX, layout.radiusZ, color),
1783
+ label: this.makeLabel(channelName, x, 30, z),
1784
+ })
1785
+ block.host = this
1786
+ block.boundary.position.set(x, 0.3, z)
1787
+ this.scene.add(block.boundary)
1788
+ this.blocks.set(channelId, block)
1789
+ }
1790
+ this.emit('blockCount', this.blocks.size)
1791
+ this.emit('agentCount', this.agents.size)
1792
+ return { x: Math.round(x), z: Math.round(z), channelId, name: channelName }
1793
+ }
1794
+
1795
+ /** 更新频道布局(编辑边界后本地即时生效;持久化由 TownView 经 useSceneLayouts.save 落库) */
1796
+ updateChannelLayout(channelId: string, patch: Partial<ChannelLayout>): void {
1797
+ const b = this.blocks.get(channelId)
1798
+ if (!b) return
1799
+ if (patch.x !== undefined) b.x = patch.x
1800
+ if (patch.z !== undefined) b.z = patch.z
1801
+ if (patch.radiusX !== undefined) b.radiusX = Math.max(60, patch.radiusX)
1802
+ if (patch.radiusZ !== undefined) b.radiusZ = Math.max(40, patch.radiusZ)
1803
+ if (patch.shape !== undefined) b.shape = patch.shape
1804
+ if (patch.rotationY !== undefined) b.rotationY = patch.rotationY
1805
+ const layout = normLayout({ channelId, x: b.x, z: b.z, radiusX: b.radiusX, radiusZ: b.radiusZ, shape: b.shape, rotationY: b.rotationY })
1806
+ this.layouts.set(channelId, layout)
1807
+ // 重建领地平台/边界/名牌
1808
+ this.scene.remove(b.boundary)
1809
+ this.applyPadToBlock(b)
1810
+ b.boundary = makeBoundary(layout.shape, layout.radiusX, layout.radiusZ, b.color)
1811
+ b.boundary.position.set(layout.x, 0.3, layout.z)
1812
+ b.boundary.rotation.y = layout.rotationY * Math.PI / 180
1813
+ this.scene.add(b.boundary)
1814
+ // 频道边界变化后把成员活动范围一并收进新边界(面板滑杆编辑路径)
1815
+ this.clampAgentsToBoundary(channelId)
1816
+ this.dirty = true
1817
+ }
1818
+
1819
+ /** 移除频道放置(从场景撤走领地及其 Agent) */
1820
+ removeChannel(channelId: string): void {
1821
+ const b = this.blocks.get(channelId)
1822
+ if (!b) return
1823
+ this.scene.remove(b.platform)
1824
+ this.scene.remove(b.padRing)
1825
+ this.scene.remove(b.beacon)
1826
+ if (b.boundary) this.scene.remove(b.boundary)
1827
+ this.scene.remove(b.label)
1828
+ for (const [aid, a] of [...this.agents.entries()]) {
1829
+ if (a.channelId === channelId) {
1830
+ this.scene.remove(a.root)
1831
+ if (a.nameSprite) this.scene.remove(a.nameSprite)
1832
+ if (a.bubble) this.scene.remove(a.bubble)
1833
+ if (a.rangeLine) this.scene.remove(a.rangeLine)
1834
+ this.disposeAgentAssets(a)
1835
+ this.agents.delete(aid)
1836
+ }
1837
+ }
1838
+ if (b.label) this.disposeCanvasTextures(b.label)
1839
+ if (this.rangeDrawAgent && this.agents.get(this.rangeDrawAgent) === undefined) this.cancelRangeDraw()
1840
+ this.blocks.delete(channelId)
1841
+ this.layouts.delete(channelId)
1842
+ this.receivers.delete(channelId)
1843
+ for (const hl of this.resizeHandles) hl.mesh.visible = false
1844
+ for (const hl of this.agentRangeHandles) hl.mesh.visible = false
1845
+ if (this.selectedChannel === channelId) {
1846
+ this.selectedChannel = null
1847
+ this.emit('selectChannel', null)
1848
+ }
1849
+ this.emit('blockCount', this.blocks.size)
1850
+ this.emit('agentCount', this.agents.size)
1851
+ this.dirty = true
1852
+ }
1853
+
1854
+ // ================================================================
1855
+ // 频道整体拖拽 / 边界手柄缩放(编辑模式;用户自定义布局)
1856
+ // ================================================================
1857
+
1858
+ /** 把频道地块整体平移(委托 Block3D.moveBy:平台/边界/名牌/成员落点与各自范围一并位移) */
1859
+ private applyBlockMove(b: Block3D, nx: number, nz: number): void {
1860
+ b.moveBy(nx - b.x, nz - b.z)
1861
+ this.refreshAgentRangeHandles()
1862
+ this.dirty = true
1863
+ }
1864
+
1865
+ /** 按当前 Block 字段重建领地几何(平台刻度/边界线框/朝向),供缩放与移动共用 */
1866
+ private applyLayoutToBlock(b: Block3D): void {
1867
+ const layout = { channelId: b.channelId, x: b.x, z: b.z, radiusX: b.radiusX, radiusZ: b.radiusZ, shape: b.shape, rotationY: b.rotationY }
1868
+ this.layouts.set(b.channelId, normLayout(layout))
1869
+ b.platform.position.set(b.x, 0.16, b.z)
1870
+ b.padRing.position.set(b.x, 0.34, b.z)
1871
+ this.applyPadToBlock(b)
1872
+ if (b.boundary) this.scene.remove(b.boundary)
1873
+ b.boundary = makeBoundary(b.shape, b.radiusX, b.radiusZ, b.color)
1874
+ b.boundary.position.set(b.x, 0.3, b.z)
1875
+ b.boundary.rotation.y = b.rotationY * Math.PI / 180
1876
+ this.scene.add(b.boundary)
1877
+ this.dirty = true
1878
+ }
1879
+
1880
+ /** 边界手柄本地坐标(椭圆:轴向四点;矩形:四角;radius 是半轴或半宽)。 */
1881
+ private boundaryHandlePoints(layout: { radiusX: number, radiusZ: number, shape: 'ellipse' | 'rect' }): Array<[number, number]> {
1882
+ if (layout.shape === 'rect') return [[layout.radiusX, layout.radiusZ], [-layout.radiusX, layout.radiusZ], [-layout.radiusX, -layout.radiusZ], [layout.radiusX, -layout.radiusZ]]
1883
+ return [[layout.radiusX, 0], [-layout.radiusX, 0], [0, layout.radiusZ], [0, -layout.radiusZ]]
1884
+ }
1885
+
1886
+ /** 刷新边界手柄位置/可见性(编辑模式选中频道时显示;其余隐藏) */
1887
+ private refreshChannelHandles(): void {
1888
+ for (const hl of this.resizeHandles) hl.mesh.visible = false
1889
+ if (this.mode !== 'edit' || !this.selectedChannel) return
1890
+ const b = this.blocks.get(this.selectedChannel)
1891
+ if (!b) return
1892
+ const rot = b.rotationY * Math.PI / 180
1893
+ const pts = this.boundaryHandlePoints(b)
1894
+ for (let i = 0; i < this.resizeHandles.length && i < pts.length; i++) {
1895
+ const [lx, lz] = pts[i]!
1896
+ const wx = b.x + lx * Math.cos(rot) - lz * Math.sin(rot)
1897
+ const wz = b.z + lx * Math.sin(rot) + lz * Math.cos(rot)
1898
+ this.resizeHandles[i]!.cid = b.channelId
1899
+ this.resizeHandles[i]!.mesh.position.set(wx, 1.4, wz)
1900
+ this.resizeHandles[i]!.mesh.visible = true
1901
+ }
1902
+ }
1903
+
1904
+ /** 命中边界手柄(仅编辑模式;返回频道 id + 手柄号) */
1905
+ private pickResizeHandle(x: number, z: number): { cid: string, handle: number } | null {
1906
+ if (this.mode !== 'edit') return null
1907
+ for (const hl of this.resizeHandles) {
1908
+ if (!hl.mesh.visible) continue
1909
+ if (Math.hypot(hl.mesh.position.x - x, hl.mesh.position.z - z) < 90) return { cid: hl.cid, handle: hl.handle }
1910
+ }
1911
+ return null
1912
+ }
1913
+
1914
+ /** 拖拽手柄 → 实时调整 radiusX/radiusZ(矩形:角点双轴;椭圆:对应轴向轴点)。 */
1915
+ private applyResize(b: Block3D, handle: number, wx: number, wz: number): void {
1916
+ const rad = -b.rotationY * Math.PI / 180
1917
+ const cos = Math.cos(rad)
1918
+ const sin = Math.sin(rad)
1919
+ const dx = wx - b.x
1920
+ const dz = wz - b.z
1921
+ const lx = dx * cos - dz * sin
1922
+ const lz = dx * sin + dz * cos
1923
+ if (b.shape === 'rect') {
1924
+ // radiusX/radiusZ 表示半宽:角点到中心的局部距离即新半宽(无上限,相机自动跟随取景)
1925
+ b.radiusX = Math.max(60, Math.abs(lx))
1926
+ b.radiusZ = Math.max(40, Math.abs(lz))
1927
+ }
1928
+ else {
1929
+ if (handle === 0 || handle === 1) b.radiusX = Math.max(60, Math.abs(lx))
1930
+ else b.radiusZ = Math.max(40, Math.abs(lz))
1931
+ }
1932
+ this.applyLayoutToBlock(b)
1933
+ this.refreshChannelHandles()
1934
+ this.autoFrameTo(b.radiusX, b.radiusZ)
1935
+ this.dirty = true
1936
+ }
1937
+
1938
+ /** 缩放/移动结束后把该频道成员落点与活动范围钳回新边界内(委托 Block3D) */
1939
+ private clampAgentsToBoundary(channelId: string): void {
1940
+ const b = this.blocks.get(channelId)
1941
+ if (!b) return
1942
+ b.clampMembersAndRanges()
1943
+ this.dirty = true
1944
+ }
1945
+
1946
+ /** 频道是否在场景内且被选中(供边界编辑面板) */
1947
+ getSelectedChannel(): string | null {
1948
+ return this.selectedChannel
1949
+ }
1950
+
1951
+ /** 点选频道(供频道坞/边界编辑面板打开) */
1952
+ selectChannel(channelId: string | null): void {
1953
+ this.selectedChannel = channelId
1954
+ this.emit('selectChannel', channelId)
1955
+ this.refreshChannelHandles()
1956
+ this.cancelRangeDraw()
1957
+ this.refreshAgentRangeHandles()
1958
+ // 选中频道边界高亮勾边(取消时恢复频道本色)
1959
+ for (const bb of this.blocks.values()) {
1960
+ const m = bb.boundary.material as THREE.LineBasicMaterial
1961
+ m.color.setHex(bb.channelId === channelId ? 0xf6c453 : bb.color)
1962
+ m.opacity = bb.channelId === channelId ? 1 : 0.75
1963
+ }
1964
+ // 拖拽中不聚焦(避免拖频道/手柄时相机被拽走导致落点错乱)
1965
+ if (channelId && !this.pointerDrag) this.focusTo(this.blocks.get(channelId)?.x ?? WORLD_CX, this.blocks.get(channelId)?.z ?? WORLD_CZ)
1966
+ }
1967
+
1968
+ /** 当前场景内全部频道布局(供「保存全部布局」/E2E) */
1969
+ getAllChannelLayouts(): ChannelLayout[] {
1970
+ return [...this.blocks.values()].map(b => ({
1971
+ channelId: b.channelId, x: b.x, z: b.z,
1972
+ radiusX: b.radiusX, radiusZ: b.radiusZ,
1973
+ shape: b.shape, rotationY: b.rotationY,
1974
+ }))
1975
+ }
1976
+
1977
+ handleTownEvent(e: AepEnvelope): void {
1978
+ if (e.type === 'channel.snapshot') return
1979
+ // 频道布局事件:本地已放同一频道则即时应用(他人编辑边界/移入场景 → 本端同步)
1980
+ if (e.type === 'scene.layout.saved') {
1981
+ const l = e.payload as ChannelLayout
1982
+ if (this.blocks.has(l.channelId)) {
1983
+ this.applySceneLayouts([...(this.layouts.values()), l])
1984
+ // 更新对应领地几何
1985
+ const cur = this.getChannelLayout(l.channelId)
1986
+ if (cur) this.updateChannelLayout(l.channelId, l)
1987
+ }
1988
+ return
1989
+ }
1990
+ if (e.type === 'scene.layout.removed') {
1991
+ const { channelId } = e.payload as { channelId: string }
1992
+ this.removeChannel(channelId)
1993
+ return
1994
+ }
1995
+ const intent = mapEnvelopeToIntent(e)
1996
+ if (!intent) return
1997
+ if (intent.agentId) {
1998
+ const asp = this.agents.get(intent.agentId)
1999
+ if (asp) {
2000
+ if (e.type === 'agent.status') {
2001
+ asp.state = (e.payload as { state: 'idle' | 'busy' | 'stopped' }).state
2002
+ if (asp.state !== 'busy') asp.progress = null
2003
+ }
2004
+ if (e.type === 'task.progress') asp.progress = (e.payload as { progress: number }).progress
2005
+ this.dirty = true
2006
+ }
2007
+ }
2008
+ if (intent.bubble) this.enqueueBubble(intent.bubble.channelId, intent.bubble.agentId, intent.bubble.kind, intent.bubble.text, intent.bubble.ttlMs)
2009
+ this.emitResonance(e)
2010
+ const action = parseActionFromEnvelope(e, { resolveTaskAssignee: this.resolveTaskAssignee ?? undefined })
2011
+ if (action) this.startBehavior(action)
2012
+ }
2013
+
2014
+ // ================================================================
2015
+ // 行为 FSM(3D x/z 平面,复刻 2D 逻辑)
2016
+ // ================================================================
2017
+
2018
+ private startBehavior(action: ActionContext): void {
2019
+ const from = this.agents.get(action.fromId)
2020
+ const to = this.agents.get(action.toId)
2021
+ if (!from || !to || from.dragging) return
2022
+ from.behavior.mode = 'approach'
2023
+ from.behavior.targetId = to.agentId
2024
+ from.behavior.action = action
2025
+ if (!to.dragging) {
2026
+ to.behavior.engaged = true
2027
+ to.behavior.roamTarget = null
2028
+ }
2029
+ this.emit('behavior', { agentName: from.name, action: this.behaviorActionLabel(action.kind), targetName: to.name })
2030
+ }
2031
+
2032
+ private behaviorActionLabel(kind: ActionKind): string {
2033
+ return kind === 'task' ? '下发任务' : '回复'
2034
+ }
2035
+
2036
+ /** 动画状态切换广播(数据驱动模型 → HUD/E2E 观察) */
2037
+ notifyMotion(asp: Agent3D): void {
2038
+ this.emit('motion', { agentName: asp.name, anim: asp.animState, at: Date.now() })
2039
+ }
2040
+
2041
+ /** 送达交接:文本入接收器(FIFO 气泡)+ 行为事件广播(公众面供 Agent3D 调用) */
2042
+ deliverBehavior(asp: Agent3D, target: Agent3D): void {
2043
+ const text = asp.behavior.action?.text ?? ''
2044
+ // 送达文本挂在说话者头顶(经接收器 FIFO,与对话消息同节奏)
2045
+ if (text) this.enqueueBubble(asp.channelId, asp.agentId, 'info', text, 2600)
2046
+ this.emit('behavior', { agentName: asp.name, action: this.behaviorActionLabel(asp.behavior.action?.kind ?? 'message'), targetName: target.name })
2047
+ }
2048
+
2049
+ /** 解除对方 engaged(交接/等待结束) */
2050
+ releaseEngaged(asp: Agent3D): void {
2051
+ const target = asp.behavior.targetId ? this.agents.get(asp.behavior.targetId) : undefined
2052
+ if (target) target.behavior.engaged = false
2053
+ }
2054
+
2055
+ /** 按 agentId 取场景内 Agent3D 实例(实体间互访服务) */
2056
+ getAgent(agentId: string): Agent3D | undefined {
2057
+ return this.agents.get(agentId)
2058
+ }
2059
+
2060
+ /** 频道当前布局(实体钳制服务;未放置返回 null) */
2061
+ blockLayoutOf(channelId: string): ChannelLayout | null {
2062
+ return this.blockLayout(channelId)
2063
+ }
2064
+
2065
+ private resolveFile(id: string): string {
2066
+ return this.modelsById.get(id)?.file ?? ''
2067
+ }
2068
+
2069
+ /** 相机注视目标(供频道坞首放落点/聚焦) */
2070
+ getCameraTarget(): { x: number, z: number } {
2071
+ return { x: Math.round(this.camTarget.x), z: Math.round(this.camTarget.z) }
2072
+ }
2073
+
2074
+ /** 为指定角色换装模型(选择器绑定;要求实体已在场景,否则仅记录) */
2075
+ swapAgentModel(agentId: string, modelRef: string): void {
2076
+ const asp = this.agents.get(agentId)
2077
+ if (!asp) return
2078
+ const info = this.modelsById.get(modelRef)
2079
+ const file = info?.file ?? '/assets/game/character/hero-3d.glb'
2080
+ asp.modelRef = modelRef
2081
+ asp.textureKey = modelRef
2082
+ void this.mountModel(asp, file, info?.name ?? modelRef)
2083
+ }
2084
+
2085
+ // ================================================================
2086
+ // 事件共鸣(说话/完成/错误/旗) —— 3D 圆环/光柱
2087
+ // ================================================================
2088
+
2089
+ private emitResonance(e: AepEnvelope): void {
2090
+ const b = this.blocks.get(e.channelId)
2091
+ const asp = e.agentId ? this.agents.get(e.agentId) : undefined
2092
+ const x = asp?.root.position.x ?? b?.x
2093
+ const z = asp?.root.position.z ?? b?.z
2094
+ if (x === undefined || z === undefined) return
2095
+ if (e.type === 'agent.message' || e.type === 'agent.status.message' || e.type === 'a2a.message') {
2096
+ this.pulseRing(x, z, asp ? 0x41c8f4 : (b?.color ?? 0x41c8f4))
2097
+ }
2098
+ else if (e.type === 'error' || (e.type === 'task.status' && ((e.payload as { state?: string }).state === 'failed' || (e.payload as { state?: string }).state === 'canceled'))) {
2099
+ this.pulseRing(x, z, 0xff6b6b)
2100
+ }
2101
+ else if (e.type === 'task.status' && (e.payload as { state?: string }).state === 'completed') {
2102
+ this.lightColumn(x, z, 0x35e0a0)
2103
+ }
2104
+ }
2105
+
2106
+ /** 事件共鸣扩散环(时间基:800ms 缓出扩散 + 淡出;帧率无关,结束即释放资源) */
2107
+ private pulseRing(x: number, z: number, color: number): void {
2108
+ const ring = new THREE.Mesh(new THREE.RingGeometry(10, 22, 24), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.8, side: THREE.DoubleSide, depthWrite: false }))
2109
+ ring.rotation.x = -Math.PI / 2
2110
+ ring.position.set(x, 0.4, z)
2111
+ this.scene.add(ring)
2112
+ const start = performance.now()
2113
+ const dur = 800
2114
+ const step = () => {
2115
+ const k = Math.min(1, (performance.now() - start) / dur)
2116
+ const e = 1 - Math.pow(1 - k, 3)
2117
+ ring.scale.setScalar(1 + e * 1.6)
2118
+ ;(ring.material as THREE.MeshBasicMaterial).opacity = 0.8 * (1 - e)
2119
+ if (k >= 1) {
2120
+ this.scene.remove(ring)
2121
+ ring.geometry.dispose()
2122
+ ;(ring.material as THREE.MeshBasicMaterial).dispose()
2123
+ return
2124
+ }
2125
+ this.rafAnims.push(step)
2126
+ }
2127
+ this.rafAnims.push(step)
2128
+ }
2129
+
2130
+ /** 交付光柱(时间基:底部锚定向上生长 + 淡出;几何上移锚定,修复旧零高度柱不可见的缺陷) */
2131
+ private lightColumn(x: number, z: number, color: number): void {
2132
+ const geo = new THREE.CylinderGeometry(6, 12, 120, 16)
2133
+ geo.translate(0, 60, 0)
2134
+ const beam = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.8, depthWrite: false }))
2135
+ beam.position.set(x, 0, z)
2136
+ beam.scale.y = 0.01
2137
+ this.scene.add(beam)
2138
+ const start = performance.now()
2139
+ const dur = 900
2140
+ const step = () => {
2141
+ const k = Math.min(1, (performance.now() - start) / dur)
2142
+ const e = 1 - Math.pow(1 - k, 2)
2143
+ beam.scale.y = 0.01 + e * 0.99
2144
+ ;(beam.material as THREE.MeshBasicMaterial).opacity = 0.8 * (1 - k)
2145
+ if (k >= 1) {
2146
+ this.scene.remove(beam)
2147
+ beam.geometry.dispose()
2148
+ ;(beam.material as THREE.MeshBasicMaterial).dispose()
2149
+ return
2150
+ }
2151
+ this.rafAnims.push(step)
2152
+ }
2153
+ this.rafAnims.push(step)
2154
+ }
2155
+
2156
+ private rafAnims: Array<() => void> = []
2157
+
2158
+ /** 频道领地布局(channelId → 放置);由 applySceneLayouts 注入,驱动 buildBlocks/边界约束 */
2159
+ private layouts = new Map<string, ChannelLayout>()
2160
+ /** 频道实体基线(channelId → seed;供 dropChannelOnWorld 即时铺放) */
2161
+ private entityIndex = new Map<string, TownEntityInput>()
2162
+ /** 当前选中频道(供边界编辑面板) */
2163
+ private selectedChannel: string | null = null
2164
+
2165
+ // ================================================================
2166
+ // 信息接收器 + 聊天气泡(每个实例化 Channel 一个接收器:FIFO 逐条消费,
2167
+ // WS 实时信息经 townBus → handleTownEvent 入队,渲染到对应 Agent 头顶,像真实对话)
2168
+ // ================================================================
2169
+
2170
+ /** 实时信息(讲话/交付/错误)入队到目标频道的信息接收器;系统指标即时更新,3D 展示按 FIFO 消费 */
2171
+ private enqueueBubble(channelId: string, agentId: string | undefined, kind: TownBubbleKind, text: string, ttlMs: number): void {
2172
+ // 事件流 / 最近活动 / 调试气泡即时更新(展示延迟只作用于 3D 气泡)
2173
+ this.dbgBubbles.push({ text, at: Date.now() })
2174
+ if (this.dbgBubbles.length > 30) this.dbgBubbles.splice(0, this.dbgBubbles.length - 30)
2175
+ const speaker = agentId ? this.agents.get(agentId)?.name : undefined
2176
+ this.lastActivity = { channelId, agentName: speaker ?? this.blocks.get(channelId)?.name ?? '系统', text, at: Date.now() }
2177
+ this.recentActivity.push(this.lastActivity)
2178
+ if (this.recentActivity.length > 30) this.recentActivity.splice(0, this.recentActivity.length - 30)
2179
+ this.emit('lastActivity', this.lastActivity)
2180
+ // 入队(FIFO;超长队列丢最旧,防止异常风暴撑爆内存)
2181
+ let rec = this.receivers.get(channelId)
2182
+ if (!rec) {
2183
+ rec = { channelId, queue: [], current: null, currentUntil: 0 }
2184
+ this.receivers.set(channelId, rec)
2185
+ }
2186
+ if (rec.queue.length >= 24) rec.queue.splice(0, rec.queue.length - 24)
2187
+ rec.queue.push({ agentId: agentId ?? null, kind, text, ttlMs })
2188
+ this.dirty = true
2189
+ }
2190
+
2191
+ /** 各频道接收器 FIFO 消费:当前条目展示期满 → 清理其气泡并取下一条实时渲染(渲染循环每帧调用)。
2192
+ * 队列积压时按 drainDisplayMs 压缩单条时长,让爆发期消息尽快追平。 */
2193
+ private drainReceivers(now: number): void {
2194
+ for (const rec of this.receivers.values()) {
2195
+ if (rec.current) {
2196
+ if (now < rec.currentUntil) continue
2197
+ this.clearReceiverBubble(rec.channelId, rec.current)
2198
+ rec.current = null
2199
+ }
2200
+ const msg = rec.queue.shift()
2201
+ if (!msg) continue
2202
+ rec.current = msg
2203
+ rec.currentUntil = now + drainDisplayMs(msg.text, rec.queue.length)
2204
+ this.renderReceiverBubble(rec.channelId, msg)
2205
+ }
2206
+ }
2207
+
2208
+ /** 清除接收器条目对应的场景气泡(不同说话人衔接时避免叠泡) */
2209
+ private clearReceiverBubble(channelId: string, msg: BubbleMsg): void {
2210
+ if (!msg.agentId) return
2211
+ const asp = this.agents.get(msg.agentId)
2212
+ if (asp?.bubble) {
2213
+ this.scene.remove(asp.bubble)
2214
+ asp.bubble = null
2215
+ asp.bubbleText = null
2216
+ if (asp.bubbleTimer) {
2217
+ clearTimeout(asp.bubbleTimer)
2218
+ asp.bubbleTimer = null
2219
+ }
2220
+ }
2221
+ }
2222
+
2223
+ /** 将接收器消息渲染为 2.5D 聊天气泡(说话 Agent 头顶;无对应用户则挂频道名牌上方) */
2224
+ private renderReceiverBubble(channelId: string, msg: BubbleMsg): void {
2225
+ const asp = msg.agentId ? this.agents.get(msg.agentId) : undefined
2226
+ const b = this.blocks.get(channelId)
2227
+ // 频道未放置且无对应用户 → 暂不渲染(队列保留,放置/用户出现后补显)
2228
+ if (!asp && !b) return
2229
+ const name = asp?.name ?? b?.name ?? '系统'
2230
+ const accent = asp
2231
+ ? asp.colorNum
2232
+ : (b?.color ?? 0x41c8f4)
2233
+ const text = (msg.kind === 'artifact' && !msg.text.startsWith('📦') ? `📦 ${msg.text}` : msg.text) || '…'
2234
+ const sprite = this.makeChatBubble(text, name, accent, msg.kind)
2235
+ const x = asp?.root.position.x ?? b!.x
2236
+ const z = asp?.root.position.z ?? b!.z
2237
+ // 放大气泡后锚点整体抬升(避免压到 48 处名牌)
2238
+ sprite.position.set(x, asp ? BUBBLE_Y + 22 + Math.max(0, asp.model.scale.y - 1) * 22 : 64, z)
2239
+ this.scene.add(sprite)
2240
+ if (!asp) {
2241
+ // 频道级(系统)气泡:展示期满自行移除
2242
+ window.setTimeout(() => {
2243
+ this.scene.remove(sprite)
2244
+ }, bubbleDisplayMs(msg.text) + 500)
2245
+ return
2246
+ }
2247
+ if (asp.bubble) {
2248
+ this.scene.remove(asp.bubble)
2249
+ this.disposeCanvasTextures(asp.bubble)
2250
+ asp.bubbleText = null
2251
+ }
2252
+ asp.bubble = sprite
2253
+ asp.bubbleText = text
2254
+ this.popInSprite(sprite)
2255
+ this.dirty = true
2256
+ }
2257
+
2258
+ /** 2.5D 聊天气泡:工业 HMI 风格 —— 头部身份条(等宽名牌 + 类型印章)+ 多行正文 + 尾角 */
2259
+ private makeChatBubble(text: string, name: string, accent: number, kind: TownBubbleKind): THREE.Sprite {
2260
+ const accentHex = `#${accent.toString(16).padStart(6, '0')}`
2261
+ const canvas = document.createElement('canvas')
2262
+ const ctx = canvas.getContext('2d')!
2263
+ // 超大字号会话气泡:正文 18px / 头名 13px / 最多 6 行 / 宽 ≤ 680px —— 实时信息一眼可读
2264
+ const bodyFont = '18px Geist, "PingFang SC", sans-serif'
2265
+ const headFont = '600 13px "Geist Mono", Geist, "PingFang SC", monospace'
2266
+ const tagFont = '600 10px "Geist Mono", Geist, monospace'
2267
+ const maxTextW = 470
2268
+ const padX = 18
2269
+ const padY = 12
2270
+ const nameH = 26
2271
+ const lineH = 24
2272
+ const tailH = 13
2273
+ ctx.font = bodyFont
2274
+ const lines = this.wrapBubbleLines(ctx, text.replace(/\s+/g, ' ').trim() || '…', maxTextW, 6)
2275
+ const textW = Math.max(...lines.map(l => ctx.measureText(l).width))
2276
+ const bw = Math.min(680, Math.max(140, Math.ceil(textW) + padX * 2))
2277
+ const bodyH = nameH + lines.length * lineH + padY * 2
2278
+ const bh = bodyH + tailH
2279
+ canvas.width = bw
2280
+ canvas.height = bh
2281
+ ctx.clearRect(0, 0, bw, bh)
2282
+ // 方角矩形 + 底部中央尾角(指向说话人;HMI 直角配方)
2283
+ const r = 4
2284
+ const cx = bw / 2
2285
+ const t = Math.min(24, bw * 0.32)
2286
+ const path = () => {
2287
+ ctx.beginPath()
2288
+ ctx.moveTo(r, 0)
2289
+ ctx.lineTo(bw - r, 0)
2290
+ ctx.quadraticCurveTo(bw, 0, bw, r)
2291
+ ctx.lineTo(bw, bodyH - r)
2292
+ ctx.quadraticCurveTo(bw, bodyH, bw - r, bodyH)
2293
+ ctx.lineTo(cx + t / 2, bodyH)
2294
+ ctx.lineTo(cx, bodyH + tailH)
2295
+ ctx.lineTo(cx - t / 2, bodyH)
2296
+ ctx.lineTo(r, bodyH)
2297
+ ctx.quadraticCurveTo(0, bodyH, 0, bodyH - r)
2298
+ ctx.lineTo(0, r)
2299
+ ctx.quadraticCurveTo(0, 0, r, 0)
2300
+ ctx.closePath()
2301
+ }
2302
+ // 柔和投影 → 深色面板底
2303
+ ctx.save()
2304
+ ctx.shadowColor = 'rgba(0,0,0,0.55)'
2305
+ ctx.shadowBlur = 14
2306
+ ctx.shadowOffsetY = 4
2307
+ path()
2308
+ ctx.fillStyle = 'rgba(14,18,27,0.94)'
2309
+ ctx.fill()
2310
+ ctx.restore()
2311
+ // 头部数据带(轻微抬升 + 发丝底边)
2312
+ ctx.save()
2313
+ path()
2314
+ ctx.clip()
2315
+ ctx.fillStyle = 'rgba(255,255,255,0.045)'
2316
+ ctx.fillRect(0, 0, bw, padY + nameH)
2317
+ ctx.restore()
2318
+ ctx.globalAlpha = 0.28
2319
+ ctx.fillStyle = '#fff'
2320
+ ctx.fillRect(padX, padY + 18, bw - padX * 2, 1)
2321
+ ctx.globalAlpha = 1
2322
+ // 发丝描边 + 左缘身份色数据条
2323
+ path()
2324
+ ctx.lineWidth = 1.2
2325
+ ctx.strokeStyle = 'rgba(140,170,195,0.4)'
2326
+ ctx.stroke()
2327
+ ctx.save()
2328
+ path()
2329
+ ctx.clip()
2330
+ ctx.fillStyle = accentHex
2331
+ ctx.fillRect(0, 0, 3, bodyH)
2332
+ ctx.restore()
2333
+ // 头牌:等宽名牌(身份色)+ 右侧类型印章(交付/异常)
2334
+ ctx.font = headFont
2335
+ ctx.fillStyle = accentHex
2336
+ ctx.textBaseline = 'middle'
2337
+ ctx.textAlign = 'left'
2338
+ ctx.fillText(name, padX, padY + 10)
2339
+ const tag = kind === 'artifact' ? 'ARTIFACT' : kind === 'error' ? 'FAULT' : ''
2340
+ if (tag) {
2341
+ const tw = ctx.measureText(tag).width + 12
2342
+ ctx.font = tagFont
2343
+ ctx.fillStyle = kind === 'error' ? '#ff8d80' : '#9fc3e8'
2344
+ ctx.strokeStyle = kind === 'error' ? 'rgba(255,141,128,0.55)' : 'rgba(159,195,232,0.45)'
2345
+ ctx.lineWidth = 1
2346
+ const tx = bw - padX - tw
2347
+ const ty = padY + 2
2348
+ const th = 16
2349
+ ctx.beginPath()
2350
+ ctx.rect(tx, ty, tw, th)
2351
+ ctx.fill()
2352
+ ctx.stroke()
2353
+ ctx.textAlign = 'center'
2354
+ ctx.fillText(tag, tx + tw / 2, ty + th / 2 + 0.5)
2355
+ }
2356
+ // 正文(错误微红)
2357
+ ctx.font = bodyFont
2358
+ ctx.fillStyle = kind === 'error' ? '#ff9d9d' : '#eef2fb'
2359
+ ctx.textAlign = 'left'
2360
+ lines.forEach((l, i) => ctx.fillText(l, padX, padY + nameH + 10 + i * lineH))
2361
+ // Sprite(纹理按 1/2.4 缩放到世界单位 —— 放大气泡后保持更大可见尺度)
2362
+ const tex = new THREE.CanvasTexture(canvas)
2363
+ tex.colorSpace = THREE.SRGBColorSpace
2364
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false, depthWrite: false }))
2365
+ const k = 2.4
2366
+ sprite.scale.set(bw / k, bh / k, 1)
2367
+ return sprite
2368
+ }
2369
+
2370
+ /** 气泡弹入动画:0.62 → 1.0 缓出(180ms,2.5D 手感) */
2371
+ private popInSprite(sprite: THREE.Sprite): void {
2372
+ const tx = sprite.scale.x
2373
+ const ty = sprite.scale.y
2374
+ sprite.scale.set(tx * 0.62, ty * 0.62, 1)
2375
+ const start = performance.now()
2376
+ const dur = 180
2377
+ const step = () => {
2378
+ if (this.disposed) return
2379
+ const t = Math.min(1, (performance.now() - start) / dur)
2380
+ const s = 0.62 + 0.38 * (1 - Math.pow(1 - t, 3))
2381
+ sprite.scale.set(tx * s, ty * s, 1)
2382
+ if (t < 1) this.rafAnims.push(step)
2383
+ }
2384
+ this.rafAnims.push(step)
2385
+ }
2386
+
2387
+ /** 按宽度逐字符换行(兼容中英混排);超 maxLines 行末加省略号 */
2388
+ private wrapBubbleLines(ctx: CanvasRenderingContext2D, text: string, maxW: number, maxLines: number): string[] {
2389
+ const lines: string[] = []
2390
+ let line = ''
2391
+ for (const ch of text) {
2392
+ if (ctx.measureText(line + ch).width > maxW && line) {
2393
+ lines.push(line)
2394
+ line = ch
2395
+ if (lines.length >= maxLines) {
2396
+ lines[maxLines - 1] = `${lines[maxLines - 1] ?? ''}…`
2397
+ return lines
2398
+ }
2399
+ }
2400
+ else {
2401
+ line += ch
2402
+ }
2403
+ }
2404
+ if (line) lines.push(line)
2405
+ if (lines.length === 0) lines.push('…')
2406
+ return lines
2407
+ }
2408
+
2409
+ // ================================================================
2410
+ // 相机 / 交互(拖拽平移、滚轮缩放、点选聚焦) —— 供 TownView 调用
2411
+ // ================================================================
2412
+
2413
+ /** 暴露 canvas 供 TownView 绑定 pointer 事件 */
2414
+ get canvas(): HTMLCanvasElement {
2415
+ return this.renderer.domElement
2416
+ }
2417
+
2418
+ /** 世界坐标 → 屏幕像素(数据标注/悬浮 callout 投影;返回 null = 在相机背后) */
2419
+ worldToScreen(x: number, y: number, z: number): { x: number, y: number } | null {
2420
+ const rect = this.renderer.domElement.getBoundingClientRect()
2421
+ // 相机背后的点 project() 会按负 w 翻转到错误的屏幕位置 → 先用前向点积剔除
2422
+ const dir = TownScene3D._wDir
2423
+ this.camera.getWorldDirection(dir)
2424
+ const rel = TownScene3D._wRel.set(x - this.camera.position.x, y - this.camera.position.y, z - this.camera.position.z)
2425
+ if (rel.dot(dir) <= 0.1) return null
2426
+ const v = TownScene3D._wVec.set(x, y, z).project(this.camera)
2427
+ if (v.z > 1) return null
2428
+ // 视锥外过远(NDC ±1.6)的点投影无意义(可能落到数千 px 外)→ 剔除;边缘 ±1 内保留供夹取
2429
+ if (Math.abs(v.x) > 1.6 || Math.abs(v.y) > 1.6) return null
2430
+ return {
2431
+ x: rect.left + (v.x + 1) / 2 * rect.width,
2432
+ y: rect.top + (1 - v.y) / 2 * rect.height,
2433
+ }
2434
+ }
2435
+
2436
+ /** 页面坐标 → 世界 xz(经 canvas rect + 相机射线打在 y=0 平面) */
2437
+ screenToWorld(clientX: number, clientY: number): { x: number, z: number } {
2438
+ const rect = this.renderer.domElement.getBoundingClientRect()
2439
+ const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1
2440
+ const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1
2441
+ const raycaster = TownScene3D._raycaster
2442
+ raycaster.setFromCamera(TownScene3D._wVec2.set(ndcX, ndcY), this.camera)
2443
+ const pt = TownScene3D._wVec
2444
+ raycaster.ray.intersectPlane(TownScene3D._groundPlane, pt)
2445
+ return pt ? { x: pt.x, z: pt.z } : { x: this.camera.position.x, z: this.camera.position.z }
2446
+ }
2447
+
2448
+ /** 平移相机(拖拽):移动 camTarget(相机位置由 loop 据 target+dolly 推导) */
2449
+ panBy(dxWorld: number, dzWorld: number): void {
2450
+ this.camTarget.x -= dxWorld
2451
+ this.camTarget.z -= dzWorld
2452
+ }
2453
+
2454
+ /** 自动取景目标 dolly(缩放拖拽期间按范围尺寸计算;渲染循环平滑跟随,拖拽结束清除) */
2455
+ private autoDolly: number | null = null
2456
+
2457
+ /** 范围半轴 → 容纳它的 dolly(基线几何:水平半幅 ≈ 613*aspect*d,地面纵深 ≈ 950*d) */
2458
+ private fitDollyFor(rx: number, rz: number): number {
2459
+ const aspect = this.camera.aspect || 1.6
2460
+ return Math.max(rx / (0.82 * 613 * aspect), rz / (0.82 * 950))
2461
+ }
2462
+
2463
+ /** 缩放过程中调用:范围超出当前视野 → 相机自动拉远(只放大不缩小,收敛于拖拽结束) */
2464
+ private autoFrameTo(rx: number, rz: number): void {
2465
+ const fit = this.fitDollyFor(rx, rz)
2466
+ if (fit > this.dolly) this.autoDolly = Math.min(40, fit * 1.18)
2467
+ }
2468
+
2469
+ /** 变换模式(Blender 规范):translate=移动 G / rotate=旋转 R / scale=缩放 S。
2470
+ * 轴约束:移动仅 XZ(模型贴地),旋转仅 Y(2.5D 朝向),缩放 XYZ。 */
2471
+ setTransformMode(mode: 'translate' | 'rotate' | 'scale'): void {
2472
+ if (!this.tControls) return
2473
+ this.tControls.setMode(mode)
2474
+ this.tControls.showX = mode !== 'rotate'
2475
+ this.tControls.showY = mode !== 'translate'
2476
+ this.tControls.showZ = mode !== 'rotate'
2477
+ }
2478
+
2479
+ /** 手柄是否悬停/拖拽中(调用方应让出相机平移与点选) */
2480
+ isGizmoBusy(): boolean {
2481
+ return !!this.tControls && (this.tControls.dragging || !!this.tControls.axis)
2482
+ }
2483
+
2484
+ /** 滚轮缩放(改变 dolly 距离) */
2485
+ /** 视角预设(设计稿 angle-chip):std 标准 / top 俯视 / front 前视 / side 侧视。
2486
+ * 轨道参数:yaw 方位 / pitch 仰角(rad) / radius 半径(再乘 dolly)。 */
2487
+ setViewPreset(p: 'std' | 'top' | 'front' | 'side'): void {
2488
+ const presets: Record<string, { yaw: number, pitch: number, radius: number }> = {
2489
+ std: { yaw: 0, pitch: 0.70, radius: 1178 },
2490
+ top: { yaw: 0, pitch: 1.51, radius: 2400 },
2491
+ front: { yaw: 0, pitch: 0.09, radius: 1510 },
2492
+ side: { yaw: Math.PI / 2, pitch: 0.33, radius: 1320 },
2493
+ }
2494
+ const t = presets[p] ?? presets.std!
2495
+ this.viewTarget = { ...t }
2496
+ // 预设切换走平滑趋近;环绕拖拽则是即时双写
2497
+ }
2498
+
2499
+ /** 左键环绕(设计稿 OrbitLite 语义):theta -= dx·0.0052(拖右=场景右转,内容跟手不反向);
2500
+ * phi -= dy·0.0052(拖上=压低视角看地平,拖下=抬升俯视),夹在仰角安全区间 */
2501
+ /** 环绕(设计稿 OrbitLite 1:1):theta -= dx·0.0052;仰角 += dy·0.0052
2502
+ * (设计稿 phi -= dy·0.0052,phi 为极角 → 仰角随 dy 增大:往下拖 = 相机升向俯视) */
2503
+ orbitBy(dxPx: number, dyPx: number): void {
2504
+ const yaw = this.viewCur.yaw - dxPx * 0.0052
2505
+ const pitch = Math.min(1.52, Math.max(0.06, this.viewCur.pitch + dyPx * 0.0052))
2506
+ this.viewCur = { yaw, pitch, radius: this.viewCur.radius }
2507
+ this.viewTarget = { ...this.viewCur }
2508
+ this.dirty = true
2509
+ }
2510
+
2511
+ /** 自动环绕(设计稿 tOrbit):渲染循环缓转 yaw;orbitBy 拖拽时暂停 */
2512
+ setAutoOrbit(on: boolean): void {
2513
+ this.autoOrbit = on
2514
+ }
2515
+
2516
+ getAutoOrbit(): boolean {
2517
+ return this.autoOrbit
2518
+ }
2519
+
2520
+ /** 滚轮缩放(改变 dolly 距离) */
2521
+
2522
+ /** 平移(设计稿 OrbitLite 1:1):target += (−right·dx + up·dy)·s。
2523
+ * right=(cosY,0,−sinY),up 地面分量 = −sinP·(sinY,cosY);panBy 内部取负 → 注视点
2524
+ * 拖右时沿 −right 移动 = 相机左扫 = 内容跟随光标(抓取语义,与设计稿逐项一致)。 */
2525
+ panByScreen(dxPx: number, dyPx: number): void {
2526
+ const r = this.viewCur.radius * this.dolly
2527
+ const k = r * 0.0011
2528
+ const sinY = Math.sin(this.viewCur.yaw)
2529
+ const cosY = Math.cos(this.viewCur.yaw)
2530
+ const sinP = Math.sin(this.viewCur.pitch)
2531
+ const dxw = (cosY * dxPx + sinP * sinY * dyPx) * k
2532
+ const dzw = (-sinY * dxPx + sinP * cosY * dyPx) * k
2533
+ this.panBy(dxw, dzw)
2534
+ }
2535
+
2536
+ /** 飞往预设视角(平滑;设计稿 flyTo) */
2537
+ flyToPreset(p: 'std' | 'top' | 'front' | 'side', _dur = 900): void {
2538
+ this.setViewPreset(p)
2539
+ }
2540
+
2541
+ /** 相机即时状态(导航地图/callout 距离显隐;世界坐标) */
2542
+ getCameraPose(): { pos: { x: number, y: number, z: number }, target: { x: number, z: number }, yaw: number, dolly: number } {
2543
+ return {
2544
+ pos: { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z },
2545
+ target: { x: this.camTarget.x, z: this.camTarget.z },
2546
+ yaw: this.viewCur.yaw,
2547
+ dolly: this.dolly,
2548
+ }
2549
+ }
2550
+
2551
+ /** 导航地图拖拽平移:世界位移直接作用于注视点与相机位置(设计稿 minimap drag) */
2552
+ panWorldBy(dxw: number, dzw: number): void {
2553
+ this.camTarget.x -= dxw
2554
+ this.camTarget.z -= dzw
2555
+ this.camera.position.x -= dxw
2556
+ this.camera.position.z -= dzw
2557
+ this.dirty = true
2558
+ }
2559
+
2560
+ /** 渲染曝光(场景控制坞"环境光照";0.2~2.2) */
2561
+ setExposure(v: number): void {
2562
+ this.renderer.toneMappingExposure = Math.min(2.2, Math.max(0.2, v))
2563
+ }
2564
+
2565
+ /** 领地染色浓度(平台透明度系数;0.05~1) */
2566
+ setTerritoryOpacity(v: number): void {
2567
+ const o = Math.min(1, Math.max(0.05, v))
2568
+ for (const b of this.blocks.values()) {
2569
+ ;(b.platform.material as THREE.MeshStandardMaterial).opacity = 0.24 * o
2570
+ }
2571
+ this.dirty = true
2572
+ }
2573
+
2574
+ /** 重置视角(注视世界中心,标准轨道,基准 dolly) */
2575
+ resetView(): void {
2576
+ this.dolly = 1
2577
+ this.autoDolly = null
2578
+ this.tweenCamTo(WORLD_CX, WORLD_CZ)
2579
+ this.setViewPreset('std')
2580
+ }
2581
+
2582
+ zoomBy(f: number): void {
2583
+ this.autoDolly = null
2584
+ this.dolly = Math.min(1000, Math.max(0.001, this.dolly + f))
2585
+ }
2586
+
2587
+ /** 缓动聚焦某世界点(移动 camTarget) */
2588
+ /** 缓动聚焦某世界点(移动 camTarget) */
2589
+ focusTo(x: number, z: number): void {
2590
+ this.tweenCamTo(x, z)
2591
+ }
2592
+
2593
+ private tweenCamTo(x: number, z: number): void {
2594
+ const start = performance.now()
2595
+ const sx = this.camTarget.x
2596
+ const sz = this.camTarget.z
2597
+ const dur = 600
2598
+ const step = () => {
2599
+ const t = Math.min(1, (performance.now() - start) / dur)
2600
+ const e = 1 - Math.pow(1 - t, 3)
2601
+ this.camTarget.x = sx + (x - sx) * e
2602
+ this.camTarget.z = sz + (z - sz) * e
2603
+ if (t < 1) this.rafAnims.push(step)
2604
+ }
2605
+ this.rafAnims.push(step)
2606
+ }
2607
+
2608
+ // ================================================================
2609
+ // 拖拽换装 / 生成居民(drop 入口)
2610
+ // ================================================================
2611
+
2612
+ dropModelOnWorld(x: number, z: number, assetId: string): { mode: 'rebind' | 'spawn', agentId?: string, textureKey: string, x: number, y: number } {
2613
+ const near = this.nearestAgent(x, z, 80)
2614
+ const model = this.modelsById.get(assetId)
2615
+ const texKey = model?.id ?? assetId
2616
+ // 数字孪生实体模型(kind=dev):拖入场景生成"设备节点" + 绑定 device twin
2617
+ if (model?.kind === 'dev') {
2618
+ const twinId = this.spawnDeviceNode(x, z, texKey, model.file, model.name)
2619
+ return { mode: 'spawn', agentId: twinId, textureKey: texKey, x: Math.round(x), y: Math.round(z) }
2620
+ }
2621
+ if (near) {
2622
+ this.swapTexture(near, texKey)
2623
+ this.enqueueBubble(near.channelId, near.agentId, 'info', `换装 → ${model?.name ?? assetId}`, 2200)
2624
+ return { mode: 'rebind', agentId: near.agentId, textureKey: texKey, x: Math.round(near.root.position.x), y: Math.round(near.root.position.z) }
2625
+ }
2626
+ // 落点生成居民
2627
+ const info = model ?? { id: assetId, file: '', name: assetId }
2628
+ this.spawnResident(x, z, texKey, info.name)
2629
+ return { mode: 'spawn', textureKey: texKey, x: Math.round(x), y: Math.round(z) }
2630
+ }
2631
+
2632
+ private nearestAgent(x: number, z: number, maxDist: number): Agent3D | undefined {
2633
+ let best: Agent3D | undefined
2634
+ let bestD = maxDist
2635
+ for (const a of this.agents.values()) {
2636
+ const d = Math.hypot(a.root.position.x - x, a.root.position.z - z)
2637
+ if (d < bestD) {
2638
+ best = a
2639
+ bestD = d
2640
+ }
2641
+ }
2642
+ return best
2643
+ }
2644
+
2645
+ private swapTexture(asp: Agent3D, texKey: string): void {
2646
+ const info = this.modelsById.get(texKey)
2647
+ const file = info?.file ?? this.resolveFile(texKey) ?? '/assets/game/character/hero-3d.glb'
2648
+ asp.modelRef = texKey
2649
+ asp.textureKey = texKey
2650
+ void this.mountModel(asp, file, info?.name ?? texKey)
2651
+ }
2652
+
2653
+ private spawnResident(x: number, z: number, texKey: string, name: string): void {
2654
+ const root = new THREE.Group()
2655
+ root.position.set(x, GROUND_Y, z)
2656
+ const model = new THREE.Group()
2657
+ root.add(model)
2658
+ const nameSprite = this.makeLabel(name, x, 48, z, this.hexOf(0xffe9c4))
2659
+ this.scene.add(root)
2660
+ const resident = new Agent3D({
2661
+ channelId: '', agentId: `resident-${Date.now().toString(36)}`, name,
2662
+ role: 'worker', root, model, mixer: null, clips: [], colorNum: 0xffe9c4, nameSprite,
2663
+ bubble: null, bubbleText: null, bubbleTimer: null, state: 'idle', progress: null,
2664
+ dragging: false, homeX: x, homeZ: z, range: null, rangeLine: null, textureKey: texKey, modelRef: texKey,
2665
+ behavior: { mode: 'idle', roamTarget: null, targetId: null, waitUntil: 0, pauseUntil: 0, engaged: false },
2666
+ })
2667
+ resident.host = this
2668
+ resident.attachAura(0xffe9c4, 'worker')
2669
+ this.agents.set(resident.agentId, resident)
2670
+ const info = this.modelsById.get(texKey)
2671
+ void this.mountModel(resident, info?.file ?? '/assets/game/character/hero-3d.glb', info?.name ?? name)
2672
+ }
2673
+
2674
+ /** 设备状态环 + 运行态动效弧(局部坐标一次定位随 root 移动 —— 修复曾被每帧写成世界坐标导致环 2× 漂移的缺陷)。
2675
+ * 颜色/显隐由 DeviceNode.updateRing 数据驱动,渲染循环只负责弧的缓转。 */
2676
+ private makeDeviceRing(): { ring: THREE.Mesh, arc: THREE.Mesh } {
2677
+ const ring = new THREE.Mesh(
2678
+ new THREE.RingGeometry(20, 26, 32),
2679
+ new THREE.MeshBasicMaterial({ color: 0xf6c453, transparent: true, opacity: 0.8, side: THREE.DoubleSide }),
2680
+ )
2681
+ ring.rotation.x = -Math.PI / 2
2682
+ ring.position.y = 0.3
2683
+ const arc = new THREE.Mesh(
2684
+ new THREE.RingGeometry(20, 26, 32, 1, 0, Math.PI * 1.5),
2685
+ new THREE.MeshBasicMaterial({ color: 0x35e0a0, transparent: true, opacity: 0.55, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }),
2686
+ )
2687
+ arc.rotation.x = -Math.PI / 2
2688
+ arc.position.y = 0.34
2689
+ arc.visible = false
2690
+ return { ring, arc }
2691
+ }
2692
+
2693
+ /**
2694
+ * 数字孪生设备节点:拖 dev 模型进场景生成。
2695
+ * - 3D:挂 device GLB + 设备名牌 + 状态环(telemetry 驱动颜色);
2696
+ * - 数据:经 devices.create 落一个 device twin,与 modelRef 绑定;
2697
+ * - state 驱动:alarm→红环,running→青环,offline→灰环,idle→亮环。
2698
+ */
2699
+ private spawnDeviceNode(x: number, z: number, texKey: string, file: string, name: string): string {
2700
+ const tempId = `dev-${Date.now().toString(36)}`
2701
+ const root = new THREE.Group()
2702
+ root.position.set(x, GROUND_Y, z)
2703
+ const { ring, arc } = this.makeDeviceRing()
2704
+ root.add(ring, arc)
2705
+ const holder = new THREE.Group()
2706
+ root.add(holder)
2707
+ holder.userData.modelFile = file
2708
+ const label = this.makeLabel(`⚙ ${name}`, x, 60, z)
2709
+ this.scene.add(root)
2710
+ const twin = new DeviceNode({ twinId: tempId, name, modelRef: texKey, root, holder, ring, label, state: 'idle', telemetry: {} })
2711
+ twin.host = this
2712
+ twin.arc = arc
2713
+ twin.updateRing()
2714
+ this.deviceNodes.set(tempId, twin)
2715
+ this.registerScalable('device', tempId, holder)
2716
+ void this.loadGltfToGroup(file, holder, UNITS * 1.6)
2717
+ const st = this.scalables.get(`device:${tempId}`)
2718
+ const createPromise = this.devices?.create({
2719
+ name, modelRef: texKey, kind: 'device', controls: ['power_on', 'power_off', 'set_speed'],
2720
+ posX: Math.round(x * 10) / 10,
2721
+ posZ: Math.round(z * 10) / 10,
2722
+ scale: st ? Math.round(st.userScale * 100) / 100 : 1,
2723
+ })
2724
+ if (!createPromise) return tempId
2725
+ this.pendingDeviceCreates.set(tempId, { name, modelRef: texKey, posX: x, posZ: z })
2726
+ void createPromise
2727
+ .then((created) => {
2728
+ const cancelled = this.cancelledDeviceCreates.delete(tempId)
2729
+ this.pendingDeviceCreates.delete(tempId)
2730
+ if (cancelled) {
2731
+ void this.devices?.remove?.(created.id).catch(() => {})
2732
+ return
2733
+ }
2734
+ if (this.disposed) return
2735
+ this.adoptDeviceNode(tempId, created.id)
2736
+ })
2737
+ .catch(() => {
2738
+ this.cancelledDeviceCreates.delete(tempId)
2739
+ this.pendingDeviceCreates.delete(tempId)
2740
+ })
2741
+ return tempId
2742
+ }
2743
+
2744
+ /** 将本地临时设备原子迁移到服务端 ID,避免后续更新/删除命中旧键。 */
2745
+ private adoptDeviceNode(tempId: string, realId: string): void {
2746
+ this.pendingDeviceCreates.delete(tempId)
2747
+ const node = this.deviceNodes.get(tempId)
2748
+ if (!node) return
2749
+ const duplicate = this.deviceNodes.get(realId)
2750
+ if (duplicate && duplicate !== node) {
2751
+ this.removeDeviceNode(tempId)
2752
+ if (this.selected?.kind === 'device' && this.selected.id === tempId) this.setSelected({ kind: 'device', id: realId })
2753
+ return
2754
+ }
2755
+ this.deviceNodes.delete(tempId)
2756
+ node.twinId = realId
2757
+ this.deviceNodes.set(realId, node)
2758
+ const scalable = this.scalables.get(`device:${tempId}`)
2759
+ this.scalables.delete(`device:${tempId}`)
2760
+ if (scalable) {
2761
+ scalable.id = realId
2762
+ this.scalables.set(`device:${realId}`, scalable)
2763
+ }
2764
+ const timer = this.pendingSaveTimers.get(tempId)
2765
+ this.pendingSaveTimers.delete(tempId)
2766
+ if (timer) this.pendingSaveTimers.set(realId, timer)
2767
+ if (this.pointerDrag?.kind === 'device' && this.pointerDrag.id === tempId) this.pointerDrag.id = realId
2768
+ if (this.selected?.kind === 'device' && this.selected.id === tempId) this.setSelected({ kind: 'device', id: realId })
2769
+ this.persistDeviceTransform(realId)
2770
+ }
2771
+
2772
+ /** 将 GLB 加载进 Group(按高度归一化 scale) */
2773
+ private async loadGltfToGroup(file: string, group: THREE.Group, targetH: number): Promise<void> {
2774
+ try {
2775
+ const gltf = await this.loadGltf(file)
2776
+ const box = new THREE.Box3().setFromObject(gltf.scene)
2777
+ const h = Math.max(0.5, box.max.y - box.min.y)
2778
+ const scale = targetH / h
2779
+ gltf.scene.scale.setScalar(scale)
2780
+ gltf.scene.position.y = 0
2781
+ // 真实投影 + PBR 材质增强(设备:金属机身材质反射拉满,像真实工业设备)
2782
+ gltf.scene.traverse((o) => {
2783
+ if ((o as THREE.Mesh).isMesh) o.castShadow = true
2784
+ })
2785
+ this.enhancePbrMaterials(gltf.scene, 'device')
2786
+ group.add(gltf.scene)
2787
+ }
2788
+ catch {
2789
+ // 加载失败:空 Group,静默(节点仍存在,只是无网格)
2790
+ }
2791
+ }
2792
+
2793
+ /**
2794
+ * 模型 PBR 材质增强(放入场景后的渲染优化;设备/角色分治)。
2795
+ * 根因修复:角色与产线设备 GLB 大量使用 KHR_materials_unlit(three 加载为
2796
+ * MeshBasicMaterial),不受灯光/阴影约束,夜景里像自发光贴片 —— 这既是
2797
+ * 「角色过亮」也是设备「假金属光泽」的来源。统一就地转标准材质接入光照
2798
+ * 体系,再分级调参:
2799
+ * - 角色:哑光 + 极轻环境反射(无清漆/自发光,保持原作贴图观感);
2800
+ * - 设备:LED 指示灯保持无光照语义并提 HDR(bloom 起晕,幂等防重);
2801
+ * 透光件微自发光 / 金属件适度镜面(0.85,克制)/ 烤漆件轻清漆。
2802
+ */
2803
+ private enhancePbrMaterials(root: THREE.Object3D, kind: 'device' | 'character'): void {
2804
+ root.traverse((o) => {
2805
+ const mesh = o as THREE.Mesh
2806
+ if (!mesh.isMesh) return
2807
+ mesh.receiveShadow = true
2808
+ const tune = (raw: THREE.Material): THREE.Material => {
2809
+ const b = raw as THREE.MeshBasicMaterial & { isMeshBasicMaterial?: boolean }
2810
+ let m: THREE.MeshStandardMaterial
2811
+ if (b?.isMeshBasicMaterial) {
2812
+ if (kind === 'device' && /led|lamp|light/i.test(b.name ?? '')) {
2813
+ // 设备指示灯:保持无光照语义;提为 HDR(bloom 起晕)。clone 共享同一材质,
2814
+ // userData 幂等护桥避免多实例挂载时倍率叠乘。
2815
+ const flag = (b.userData ?? (b.userData = {})) as { hdrBoost?: boolean }
2816
+ if (!flag.hdrBoost) {
2817
+ b.color.multiplyScalar(4.5)
2818
+ flag.hdrBoost = true
2819
+ }
2820
+ return b
2821
+ }
2822
+ m = new THREE.MeshStandardMaterial({
2823
+ map: b.map ?? null,
2824
+ // unlit 贴图按"全亮"绘制,直接接入 ~8 倍总光的灯光系会过曝成白炽;
2825
+ // 反照率按类型压暗:角色 0.4 / 设备 0.55(ACES 肩部回落到合理亮度)
2826
+ color: b.color.clone().multiplyScalar(kind === 'character' ? 0.38 : 0.5),
2827
+ transparent: b.transparent,
2828
+ opacity: b.opacity,
2829
+ alphaTest: b.alphaTest,
2830
+ side: b.side,
2831
+ roughness: kind === 'character' ? 0.92 : 0.6,
2832
+ metalness: kind === 'character' ? 0 : 0.25,
2833
+ })
2834
+ m.name = b.name ?? ''
2835
+ b.dispose()
2836
+ }
2837
+ else {
2838
+ const s = raw as THREE.MeshStandardMaterial
2839
+ if (!s || !('envMapIntensity' in s)) return raw
2840
+ m = s
2841
+ }
2842
+ if (kind === 'character') {
2843
+ // 角色:哑光 + 极轻环境反射(曾因 emissive 底光 + 清漆层整体过亮)
2844
+ m.envMapIntensity = 0.45
2845
+ if (m.metalness > 0.3) m.metalness = 0
2846
+ return m
2847
+ }
2848
+ // 设备分级
2849
+ if (m.transparent) {
2850
+ // 灯罩/屏幕/指示窗:轻微自发光,像通电的设备部件
2851
+ m.envMapIntensity = 0.42
2852
+ if (m.emissive) m.emissive.setScalar(Math.max(m.emissive.r, 0.06))
2853
+ return m
2854
+ }
2855
+ if (m.metalness >= 0.5) {
2856
+ m.envMapIntensity = 0.85 // 金属机身:适可而止的镜面(1.35 曾过亮)
2857
+ return m
2858
+ }
2859
+ // 涂装/塑料外壳 → 物理材质轻清漆层(已是物理材质只调参,避免 clone 共享材质重复替换)
2860
+ if ((m as unknown as { isMeshPhysicalMaterial?: boolean }).isMeshPhysicalMaterial) {
2861
+ const p = m as THREE.MeshPhysicalMaterial
2862
+ p.clearcoat = 0.35
2863
+ p.clearcoatRoughness = 0.4
2864
+ p.envMapIntensity = 0.7
2865
+ p.needsUpdate = true
2866
+ return p
2867
+ }
2868
+ const p = new THREE.MeshPhysicalMaterial({
2869
+ color: m.color,
2870
+ map: m.map,
2871
+ normalMap: m.normalMap,
2872
+ roughnessMap: m.roughnessMap,
2873
+ aoMap: m.aoMap,
2874
+ emissive: m.emissive,
2875
+ emissiveMap: m.emissiveMap,
2876
+ emissiveIntensity: m.emissiveIntensity,
2877
+ roughness: Math.min(m.roughness, 0.55),
2878
+ metalness: m.metalness,
2879
+ clearcoat: 0.35,
2880
+ clearcoatRoughness: 0.4,
2881
+ })
2882
+ p.name = m.name
2883
+ p.side = m.side
2884
+ m.dispose()
2885
+ return p
2886
+ }
2887
+ if (Array.isArray(mesh.material)) mesh.material = mesh.material.map(tune)
2888
+ else mesh.material = tune(mesh.material)
2889
+ })
2890
+ this.dirty = true
2891
+ }
2892
+
2893
+ // ================================================================
2894
+ // 调试 / HUD 数据
2895
+ // ================================================================
2896
+
2897
+ getDebugState(): {
2898
+ blocks: number
2899
+ agents: Array<{ agentId: string, name: string, role: string, channelId: string, state: string, progress: number | null, x: number, y: number, visible: boolean, draggable: boolean, auraColor: number, behavior: string, targetId: string | null, homeX: number, homeY: number, textureKey: string, modelRef: string, decorated: boolean, range: { x: number, z: number, radiusX: number, radiusZ: number, shape: string } | null, bubbleText: string | null, anim: string }>
2900
+ bubbles: Array<{ text: string, at: number }>
2901
+ activity: { channelId: string, agentName: string, text: string } | null
2902
+ player: { x: number, y: number }
2903
+ } {
2904
+ return {
2905
+ blocks: this.blocks.size,
2906
+ agents: [...this.agents.values()].map(a => ({
2907
+ agentId: a.agentId,
2908
+ name: a.name,
2909
+ role: a.role,
2910
+ channelId: a.channelId,
2911
+ state: a.state,
2912
+ progress: a.progress,
2913
+ x: Math.round(a.root.position.x),
2914
+ y: Math.round(a.root.position.z),
2915
+ visible: true,
2916
+ draggable: true,
2917
+ auraColor: a.colorNum,
2918
+ behavior: a.behavior.mode,
2919
+ targetId: a.behavior.targetId,
2920
+ homeX: Math.round(a.homeX),
2921
+ homeY: Math.round(a.homeZ),
2922
+ textureKey: a.textureKey,
2923
+ modelRef: a.modelRef,
2924
+ decorated: !a.channelId,
2925
+ range: a.range
2926
+ ? { x: Math.round(a.range.x), z: Math.round(a.range.z), radiusX: Math.round(a.range.radiusX), radiusZ: Math.round(a.range.radiusZ), shape: a.range.shape }
2927
+ : null,
2928
+ bubbleText: a.bubbleText,
2929
+ anim: a.animState,
2930
+ })),
2931
+ bubbles: this.dbgBubbles.slice(-8),
2932
+ activity: this.lastActivity,
2933
+ player: { x: Math.round(this.camera.position.x), y: Math.round(this.camera.position.z) },
2934
+ }
2935
+ }
2936
+
2937
+ getMinimapState(): {
2938
+ world: { w: number, h: number }
2939
+ blocks: Array<{ x: number, y: number, color: number, name: string, shape?: 'ellipse' | 'rect', rx?: number, rz?: number, rot?: number }>
2940
+ agents: Array<{ x: number, y: number, color: number, busy: boolean }>
2941
+ devices: Array<{ x: number, y: number, color: number, state: string }>
2942
+ player: { x: number, y: number }
2943
+ } {
2944
+ const stateColor = (state: 'idle' | 'running' | 'offline' | 'alarm'): number =>
2945
+ state === 'alarm' ? 0xff6b6b : state === 'offline' ? 0x8496a5 : state === 'running' ? 0x35e0a0 : 0xf6c453
2946
+ return {
2947
+ world: { w: WORLD_W, h: WORLD_H },
2948
+ // 领地含真实形状/半轴/朝向(归一化;镜头居中小地图按形状绘制)
2949
+ blocks: [...this.blocks.values()].map(b => ({
2950
+ x: b.x / WORLD_W,
2951
+ y: b.z / WORLD_H,
2952
+ color: b.color,
2953
+ name: b.name,
2954
+ shape: b.shape,
2955
+ rx: b.radiusX / WORLD_W,
2956
+ rz: b.radiusZ / WORLD_H,
2957
+ rot: b.rotationY,
2958
+ })),
2959
+ agents: [...this.agents.values()].map(a => ({ x: a.root.position.x / WORLD_W, y: a.root.position.z / WORLD_H, color: a.colorNum, busy: a.state === 'busy' })),
2960
+ devices: [...this.deviceNodes.values()].map(d => ({
2961
+ twinId: d.twinId,
2962
+ x: d.root.position.x / WORLD_W,
2963
+ y: d.root.position.z / WORLD_H,
2964
+ color: stateColor(d.state),
2965
+ state: d.state,
2966
+ daq: d.modelRef.startsWith('daq-') || d.modelRef.includes('daq'),
2967
+ bound: false,
2968
+ })),
2969
+ // 镜头 = camTarget(画面注视中心;小地图准星锚点)
2970
+ player: { x: this.camTarget.x / WORLD_W, y: this.camTarget.z / WORLD_H },
2971
+ }
2972
+ }
2973
+
2974
+ getRecentActivity(): Array<{ channelId: string, agentName: string, text: string }> {
2975
+ return [...this.recentActivity]
2976
+ }
2977
+
2978
+ /** 更新设备节点状态/遥测(由 useDeviceTwins 轮询或控制反馈驱动;数据驱动渲染状态环) */
2979
+ updateDeviceNode(twinId: string, state: DeviceNode['state'], telemetry?: Record<string, number | string | boolean>): void {
2980
+ const dev = this.deviceNodes.get(twinId)
2981
+ if (!dev) return
2982
+ if (state) {
2983
+ dev.state = state
2984
+ dev.updateRing()
2985
+ }
2986
+ if (telemetry) dev.telemetry = { ...dev.telemetry, ...telemetry }
2987
+ this.dirty = true
2988
+ }
2989
+
2990
+ /** 设备改名(重建名牌 Sprite + 落库由 TownView 走 devices.update) */
2991
+ renameDevice(id: string, name: string): void {
2992
+ const dev = this.deviceNodes.get(id)
2993
+ if (!dev || !name.trim() || name.trim() === dev.name) return
2994
+ this.renameDeviceSprite(dev, name)
2995
+ }
2996
+
2997
+ /** 设备换模型(按 modelRef 重挂 GLB 到 holder;持久化由 TownView 走 devices.update) */
2998
+ swapDeviceModel(id: string, modelRef: string): void {
2999
+ const dev = this.deviceNodes.get(id)
3000
+ if (!dev) return
3001
+ this.swapDeviceModelSprite(dev, modelRef)
3002
+ }
3003
+
3004
+ /** 设备当前名称(供属性面板初始化) */
3005
+ getDeviceName(id: string): string {
3006
+ return this.deviceNodes.get(id)?.name ?? ''
3007
+ }
3008
+
3009
+ /** 设备当前绑定模型 id(供模型下拉高亮) */
3010
+ getDeviceModelRef(id: string): string {
3011
+ return this.deviceNodes.get(id)?.modelRef ?? ''
3012
+ }
3013
+
3014
+ /** 删除设备实例:落库删除 + 移除场景节点(由 TownView 触发;失败仅移除本地节点)。 */
3015
+ async removeDevice(id: string): Promise<void> {
3016
+ const dev = this.deviceNodes.get(id)
3017
+ if (!dev) return
3018
+ if (this.pendingDeviceCreates.has(id)) {
3019
+ this.cancelledDeviceCreates.add(id)
3020
+ this.removeDeviceNode(id)
3021
+ return
3022
+ }
3023
+ try {
3024
+ await this.devices?.remove?.(id)
3025
+ }
3026
+ catch { /* 服务端失败仍移除本地节点(尽力同步) */ }
3027
+ this.removeDeviceNode(id)
3028
+ }
3029
+
3030
+ /** 设备节点列表(供 HUD/E2E;topY = 模型顶面世界高度,callout 锚定用) */
3031
+ getDeviceNodes(): Array<{ twinId: string, name: string, x: number, z: number, topY: number, state: string, telemetry: Record<string, number | string | boolean> }> {
3032
+ return [...this.deviceNodes.values()].map(d => ({
3033
+ twinId: d.twinId,
3034
+ name: d.name,
3035
+ x: Math.round(d.root.position.x),
3036
+ z: Math.round(d.root.position.z),
3037
+ topY: Math.round(this.deviceTopY(d)),
3038
+ state: d.state,
3039
+ telemetry: d.telemetry,
3040
+ }))
3041
+ }
3042
+
3043
+ /** 动作绑定:为某 Agent 指定动画 clip(按名称 idle/walk/work 或索引)。供 AssetLibrary 选择动作后调用。 */
3044
+ setAnimPref(agentId: string, clipName: string): void {
3045
+ const asp = this.agents.get(agentId)
3046
+ if (!asp || asp.clips.length === 0 || !asp.mixer) return
3047
+ // 解析:优先名称匹配,否则 idle→0/walk→1/work→末帧
3048
+ let idx = asp.clips.findIndex(c => c.name.toLowerCase().includes(clipName.toLowerCase()))
3049
+ if (idx < 0) idx = clipName === 'work' ? asp.clips.length - 1 : clipName === 'walk' ? Math.min(1, asp.clips.length - 1) : 0
3050
+ const clip = asp.clips[idx]
3051
+ if (!clip) return
3052
+ asp.mixer.stopAllAction()
3053
+ asp.mixer.clipAction(clip).reset().play()
3054
+ }
3055
+
3056
+ /** 角色当前绑定模型 id(供模型选择器高亮) */
3057
+ getAgentModel(agentId: string): string | null {
3058
+ const asp = this.agents.get(agentId)
3059
+ return asp?.modelRef ?? null
3060
+ }
3061
+
3062
+ /** 角色名字(供模型选择器标题) */
3063
+ getAgentName(agentId: string): string {
3064
+ return this.agents.get(agentId)?.name ?? agentId.slice(0, 8)
3065
+ }
3066
+
3067
+ /** 角色动画 clip 名列表(供 UI 展示可绑定动作;无则空) */
3068
+ getAgentClips(agentId: string): Array<{ name: string, duration: number }> {
3069
+ const asp = this.agents.get(agentId)
3070
+ if (!asp) return []
3071
+ return asp.clips.map(c => ({ name: c.name, duration: Math.round(c.duration * 10) / 10 }))
3072
+ }
3073
+
3074
+ // ================================================================
3075
+ // 选中 + 缩放(客制化:场景内点选 Agent/设备 → 弹滑杆调大小)
3076
+ // ================================================================
3077
+
3078
+ /** 以世界坐标 hit 一个 Agent 或设备节点(近似距离阈值;供点击选中) */
3079
+ private pickAt(x: number, z: number): { kind: 'agent' | 'device', id: string } | null {
3080
+ let best: { kind: 'agent' | 'device', id: string, d: number } | null = null
3081
+ for (const a of this.agents.values()) {
3082
+ const d = Math.hypot(a.root.position.x - x, a.root.position.z - z)
3083
+ if (d < 90 && (!best || d < best.d)) best = { kind: 'agent', id: a.agentId, d }
3084
+ }
3085
+ for (const dev of this.deviceNodes.values()) {
3086
+ const d = Math.hypot(dev.root.position.x - x, dev.root.position.z - z)
3087
+ if (d < 90 && (!best || d < best.d)) best = { kind: 'device', id: dev.twinId, d }
3088
+ }
3089
+ return best ? { kind: best.kind, id: best.id } : null
3090
+ }
3091
+
3092
+ /** 点选频道:返回包含该点的频道 id(边界内;供编辑模式打开边界编辑面板) */
3093
+ private pickChannel(x: number, z: number): string | null {
3094
+ for (const b of this.blocks.values()) {
3095
+ const layout: ChannelLayout = {
3096
+ channelId: b.channelId, x: b.x, z: b.z,
3097
+ radiusX: b.radiusX, radiusZ: b.radiusZ,
3098
+ shape: b.shape, rotationY: b.rotationY,
3099
+ }
3100
+ if (pointInBoundary(layout, x, z)) return b.channelId
3101
+ }
3102
+ return null
3103
+ }
3104
+
3105
+ /** 命中频道边界线(世界点落在某领地边界带 ±42 单位内 → 边界拖拽等比缩放)。 */
3106
+ private pickChannelEdge(x: number, z: number): { cid: string, rx0: number, rz0: number, rd0: number } | null {
3107
+ for (const b of this.blocks.values()) {
3108
+ const lxz = toLocal(b, x, z)
3109
+ const rd = Math.hypot(lxz.x / b.radiusX, lxz.z / b.radiusZ)
3110
+ const tol = 42 / Math.min(b.radiusX, b.radiusZ)
3111
+ if (Math.abs(rd - 1) <= tol) {
3112
+ return { cid: b.channelId, rx0: b.radiusX, rz0: b.radiusZ, rd0: rd }
3113
+ }
3114
+ }
3115
+ return null
3116
+ }
3117
+
3118
+ /** 设置选中(点击后由 Vue 弹缩放/旋转滑杆) */
3119
+ private setSelected(sel: { kind: 'agent' | 'device', id: string } | null): void {
3120
+ // 选中切换/取消 → 退出框选绘制(清理预览)
3121
+ if (!sel || sel.kind !== 'agent' || sel.id !== this.rangeDrawAgent) this.cancelRangeDraw()
3122
+ this.selected = sel
3123
+ // 变换手柄跟随选中设备(Blender 规范:选中即出手柄;运行模式只读 → 不出手柄)
3124
+ if (this.tControls) {
3125
+ if (sel?.kind === 'device' && this.mode === 'edit' && this.deviceNodes.has(sel.id)) this.tControls.attach(this.deviceNodes.get(sel.id)!.root)
3126
+ else this.tControls.detach()
3127
+ }
3128
+ this.refreshAgentRangeLines()
3129
+ this.refreshAgentRangeHandles()
3130
+ if (!sel) {
3131
+ this.emit('select', null)
3132
+ return
3133
+ }
3134
+ const st = this.scalables.get(`${sel.kind}:${sel.id}`)
3135
+ if (st) {
3136
+ this.emit('select', {
3137
+ kind: sel.kind,
3138
+ id: sel.id,
3139
+ scale: st.userScale,
3140
+ rotation: Math.round(this.getModelRotation(sel.id, sel.kind) * 10) / 10,
3141
+ })
3142
+ }
3143
+ }
3144
+
3145
+ /** 场景内缩放:为 Agent(id)或设备(id)设定用户缩放倍率(0.2~5;1=默认归一化) */
3146
+ setModelScale(id: string, scale: number, kind?: 'agent' | 'device'): void {
3147
+ const k = kind ?? (this.scalables.has(`device:${id}`) ? 'device' : 'agent')
3148
+ const key = `${k}:${id}`
3149
+ const st = this.scalables.get(key)
3150
+ if (!st) return
3151
+ st.userScale = scale
3152
+ // 施加于模型 holder(相对已归一化的默认尺寸)
3153
+ st.holder.scale.setScalar(st.userScale)
3154
+ if (k === 'device') {
3155
+ const dev = this.deviceNodes.get(id)
3156
+ if (dev) dev.topYCache = null
3157
+ }
3158
+ this.dirty = true
3159
+ }
3160
+
3161
+ /** 当前选中对象(供 Vue/滑杆初始化) */
3162
+ getSelectedScale(): { kind: 'agent' | 'device', id: string, scale: number, rotation: number } | null {
3163
+ if (!this.selected) return null
3164
+ const st = this.scalables.get(`${this.selected.kind}:${this.selected.id}`)
3165
+ if (!st) return null
3166
+ return {
3167
+ kind: this.selected.kind,
3168
+ id: this.selected.id,
3169
+ scale: st.userScale,
3170
+ rotation: Math.round(this.getModelRotation(this.selected.id, this.selected.kind) * 10) / 10,
3171
+ }
3172
+ }
3173
+
3174
+ /** 组装可缩放目标并恢复初始化缩放(initialScale 优先=服务端持久化;否则 localStorage) */
3175
+ private registerScalable(kind: 'agent' | 'device', id: string, holder: THREE.Group, initialScale?: number): void {
3176
+ const saved = initialScale !== undefined
3177
+ ? initialScale
3178
+ : (typeof localStorage !== 'undefined' ? Number(localStorage.getItem(`town.scale.${kind}:${id}`) ?? 1) : 1)
3179
+ const userScale = Number.isFinite(saved) && saved > 0 ? saved : 1
3180
+ this.scalables.set(`${kind}:${id}`, { kind, id, userScale, holder })
3181
+ holder.scale.setScalar(userScale)
3182
+ if (kind === 'device') {
3183
+ const dev = this.deviceNodes.get(id)
3184
+ if (dev) dev.topYCache = null
3185
+ }
3186
+ }
3187
+
3188
+ /** 持久化指定对象的缩放(滑杆松手时调用;设备同时落库,角色仅本地) */
3189
+ persistScale(kind: 'agent' | 'device', id: string): void {
3190
+ const st = this.scalables.get(`${kind}:${id}`)
3191
+ if (!st || typeof localStorage === 'undefined') return
3192
+ localStorage.setItem(`town.scale.${kind}:${id}`, String(st.userScale))
3193
+ if (kind === 'device') this.persistDeviceTransform(id)
3194
+ }
3195
+
3196
+ // ================================================================
3197
+ // 编辑/浏览模式 + 场景对象拖拽(设备↔地面,角色↔落点)
3198
+ // ================================================================
3199
+
3200
+ /** 模式切换:浏览(只读:相机+点选) / 编辑(可拖拽设备/调整角色落点/旋转/频道整体移动/边界手柄/Agent 活动范围) */
3201
+ setMode(mode: TownScene3DMode): void {
3202
+ if (this.mode === mode) return
3203
+ this.mode = mode
3204
+ if (mode === 'browse') {
3205
+ if (this.pointerDrag) this.endPointerDrag()
3206
+ this.cancelRangeDraw()
3207
+ this.setSelected(null)
3208
+ // 运行模式只读:收起变换手柄(拖拽已在 tryStartPointerDrag 门禁)
3209
+ this.tControls?.detach()
3210
+ }
3211
+ this.refreshChannelHandles()
3212
+ this.refreshAgentRangeHandles()
3213
+ // 编辑模式范围线框可见性(range 线框在编辑模式全部显示,浏览模式仅选中显示)
3214
+ for (const a of this.agents.values()) a.renderRangeLine()
3215
+ this.dirty = true
3216
+ }
3217
+
3218
+ getMode(): TownScene3DMode {
3219
+ return this.mode
3220
+ }
3221
+
3222
+ /** 网格吸附开关(编辑拖拽落点;默认开) */
3223
+ setSnap(enabled: boolean): void {
3224
+ this.snapEnabled = enabled
3225
+ }
3226
+
3227
+ getSnap(): boolean {
3228
+ return this.snapEnabled
3229
+ }
3230
+
3231
+ /**
3232
+ * 尝试开始场景拖拽(编辑模式;指针按下命中设备/角色 → 占用该手势)。
3233
+ * 返回 true 表示场景已接管指针(调用方应跳过相机平移)。
3234
+ */
3235
+ tryStartPointerDrag(clientX: number, clientY: number): boolean {
3236
+ if (this.mode !== 'edit' || this.isGizmoBusy()) return false
3237
+ const w = this.screenToWorld(clientX, clientY)
3238
+ // 1) Agent 活动范围手柄(编辑模式选中带范围角色时 → 拖拽调整该 Agent 范围大小)
3239
+ const ar = this.pickAgentRangeHandle(w.x, w.z)
3240
+ if (ar) {
3241
+ this.pointerDrag = { kind: 'agentRangeResize', id: ar.agentId, handle: ar.handle }
3242
+ this.emitSaveState('dirty')
3243
+ return true
3244
+ }
3245
+ // 2) 边界缩放手柄(编辑模式选中频道的 4 个手柄 → 调整频道范围大小)
3246
+ const h = this.pickResizeHandle(w.x, w.z)
3247
+ if (h) {
3248
+ this.pointerDrag = { kind: 'resize', id: h.cid, handle: h.handle }
3249
+ this.setSelected(null)
3250
+ this.emitSaveState('dirty')
3251
+ return true
3252
+ }
3253
+ // 3) 框选绘制:rangeDraw 模式激活 → 以按下点为框角拉动(优先于点选,便于从角色身上起手)
3254
+ if (this.rangeDrawAgent) {
3255
+ this.pointerDrag = { kind: 'rangeDraw', id: this.rangeDrawAgent, x0: w.x, z0: w.z }
3256
+ this.rangeDrawPreviewState = null
3257
+ return true
3258
+ }
3259
+ // 4) 设备 / 角色
3260
+ const hit = this.pickAt(w.x, w.z)
3261
+ if (hit) {
3262
+ if (hit.kind === 'device') {
3263
+ if (!this.deviceNodes.has(hit.id)) return false
3264
+ this.pointerDrag = hit
3265
+ this.setSelected(hit)
3266
+ this.emitSaveState('dirty')
3267
+ return true
3268
+ }
3269
+ // 角色:仅频道角色可调整落点(装饰居民不持久化、不可布局)
3270
+ const asp = this.agents.get(hit.id)
3271
+ if (!asp || !asp.channelId) return false
3272
+ if (asp.bubbleTimer) {
3273
+ clearTimeout(asp.bubbleTimer)
3274
+ asp.bubbleTimer = null
3275
+ }
3276
+ asp.dragging = true
3277
+ asp.behavior.mode = 'idle'
3278
+ asp.behavior.targetId = null
3279
+ this.pointerDrag = hit
3280
+ this.setSelected(hit)
3281
+ this.emitSaveState('dirty')
3282
+ return true
3283
+ }
3284
+ // 5) 拖某成员活动范围边界线 → 整框平移(频道整体移动之前;频道移动先排除已按范围接管)
3285
+ const rangeHit = this.hitAgentRangeBoundary(w.x, w.z)
3286
+ if (rangeHit && rangeHit.range) {
3287
+ this.pointerDrag = { kind: 'agentRange', id: rangeHit.agentId, dx: rangeHit.range.x - w.x, dz: rangeHit.range.z - w.z }
3288
+ this.setSelected({ kind: 'agent', id: rangeHit.agentId })
3289
+ this.emitSaveState('dirty')
3290
+ return true
3291
+ }
3292
+ // 6) 频道边界线拖拽:抓住边界附近 → 整体等比缩放(手柄之外,自由拉边调节范围)
3293
+ const edge = this.pickChannelEdge(w.x, w.z)
3294
+ if (edge) {
3295
+ this.pointerDrag = { kind: 'channelEdge', id: edge.cid, rx0: edge.rx0, rz0: edge.rz0, rd0: edge.rd0 }
3296
+ this.setSelected(null)
3297
+ this.selectChannel(edge.cid)
3298
+ this.emitSaveState('dirty')
3299
+ return true
3300
+ }
3301
+ // 7) 频道信标:非拖拽目标(点击定位语义交给 pointerup;避免编辑模式下点信标变成整体拖动)
3302
+ if (this.pickBeacon(w.x, w.z)) return false
3303
+ // 8) 频道领地整体拖拽:点中领地空白处 → 平移整个频道(平台/边界/名牌/成员落点)
3304
+ const cid = this.pickChannel(w.x, w.z)
3305
+ if (cid) {
3306
+ const b = this.blocks.get(cid)
3307
+ if (!b) return false
3308
+ this.pointerDrag = { kind: 'channel', id: cid, dx: b.x - w.x, dz: b.z - w.z }
3309
+ this.setSelected(null)
3310
+ this.selectChannel(cid)
3311
+ this.emitSaveState('dirty')
3312
+ return true
3313
+ }
3314
+ return false
3315
+ }
3316
+
3317
+ isPointerDragging(): boolean {
3318
+ return this.pointerDrag !== null
3319
+ }
3320
+
3321
+ private snapWorld(v: number): number {
3322
+ return this.snapEnabled ? Math.round(v / SNAP_SIZE) * SNAP_SIZE : Math.round(v * 10) / 10
3323
+ }
3324
+
3325
+ /** 拖拽中:对象跟随指针指向的 xz 平面(仅改内存;落库在 endPointerDrag) */
3326
+ movePointerDrag(clientX: number, clientY: number): void {
3327
+ if (!this.pointerDrag) return
3328
+ const w = this.screenToWorld(clientX, clientY)
3329
+ // 无边界画布:仅网格吸附,不钳制世界范围(模型可摆放到任意位置)
3330
+ const x = this.snapWorld(w.x)
3331
+ const z = this.snapWorld(w.z)
3332
+ const pd = this.pointerDrag
3333
+ if (pd.kind === 'device') {
3334
+ const dev = this.deviceNodes.get(pd.id)
3335
+ if (dev) {
3336
+ dev.root.position.x = x
3337
+ dev.root.position.z = z
3338
+ this.dirty = true
3339
+ }
3340
+ }
3341
+ else if (pd.kind === 'agent') {
3342
+ const asp = this.agents.get(pd.id)
3343
+ if (asp) {
3344
+ // 频道角色落点钳制在所属领地边界内(不可拖出频道)
3345
+ let px = x
3346
+ let pz = z
3347
+ const layout = this.blockLayout(asp.channelId)
3348
+ if (layout) {
3349
+ const clamped = clampToBoundary(layout, x, z, 20)
3350
+ px = clamped.x
3351
+ pz = clamped.z
3352
+ }
3353
+ asp.root.position.x = px
3354
+ asp.root.position.z = pz
3355
+ this.dirty = true
3356
+ }
3357
+ }
3358
+ else if (pd.kind === 'rangeDraw') {
3359
+ // 框选绘制:当前点为对角终点(钳入频道边界),实时更新预览矩形
3360
+ const asp = this.agents.get(pd.id)
3361
+ if (!asp) return
3362
+ const layout = this.blockLayout(asp.channelId)
3363
+ let cx = w.x
3364
+ let cz = w.z
3365
+ if (layout) {
3366
+ const clamped = clampToBoundary(layout, cx, cz, 12)
3367
+ cx = clamped.x
3368
+ cz = clamped.z
3369
+ }
3370
+ this.rangeDrawPreviewState = { x0: pd.x0, z0: pd.z0, x1: cx, z1: cz }
3371
+ this.updateRangeDrawPreview(pd.x0, pd.z0, cx, cz)
3372
+ this.dirty = true
3373
+ }
3374
+ else if (pd.kind === 'agentRange') {
3375
+ // 拖 Agent 活动范围边界 → 整框平移(钳入频道边界;范围线框/手柄跟随)
3376
+ const asp = this.agents.get(pd.id)
3377
+ if (!asp || !asp.range) return
3378
+ const nx = x + pd.dx
3379
+ const nz = z + pd.dz
3380
+ asp.range = { ...asp.range, x: nx, z: nz }
3381
+ asp.renderRangeLine()
3382
+ this.refreshAgentRangeHandles()
3383
+ this.dirty = true
3384
+ }
3385
+ else if (pd.kind === 'agentRangeResize') {
3386
+ // 拖 Agent 范围手柄 → 实时调整 radiusX/radiusZ(矩形角点双轴 / 椭圆轴向轴点)
3387
+ const asp = this.agents.get(pd.id)
3388
+ if (!asp || !asp.range) return
3389
+ asp.range = this.applyAgentRangeResize(asp.range, pd.handle, w.x, w.z)
3390
+ asp.renderRangeLine()
3391
+ this.refreshAgentRangeHandles()
3392
+ this.autoFrameTo(asp.range.radiusX, asp.range.radiusZ)
3393
+ this.dirty = true
3394
+ }
3395
+ else if (pd.kind === 'channel') {
3396
+ const b = this.blocks.get(pd.id)
3397
+ if (b) this.applyBlockMove(b, x + pd.dx, z + pd.dz)
3398
+ }
3399
+ else if (pd.kind === 'resize') {
3400
+ const b = this.blocks.get(pd.id)
3401
+ if (b) this.applyResize(b, pd.handle, w.x, w.z)
3402
+ }
3403
+ else if (pd.kind === 'channelEdge') {
3404
+ // 边界线等比缩放:指针沿"中心→边界"射线的归一化距离比例 → 双轴同倍率
3405
+ const b = this.blocks.get(pd.id)
3406
+ if (b) {
3407
+ const lxz = toLocal(b, x, z)
3408
+ const rd = Math.hypot(lxz.x / pd.rx0, lxz.z / pd.rz0)
3409
+ const factor = pd.rd0 > 0.05 ? rd / pd.rd0 : 1
3410
+ b.radiusX = Math.max(60, Math.round(pd.rx0 * factor))
3411
+ b.radiusZ = Math.max(40, Math.round(pd.rz0 * factor))
3412
+ this.applyLayoutToBlock(b)
3413
+ this.autoFrameTo(b.radiusX, b.radiusZ)
3414
+ }
3415
+ }
3416
+ }
3417
+
3418
+ /** 拖拽结束:设备 → 防抖落库;角色 → home 更新 + 持久化;频道 → 布局落库 */
3419
+ endPointerDrag(): void {
3420
+ if (!this.pointerDrag) return
3421
+ const pd = this.pointerDrag
3422
+ this.pointerDrag = null
3423
+ if (pd.kind === 'device') {
3424
+ const dev = this.deviceNodes.get(pd.id)
3425
+ if (dev) this.persistDeviceTransform(pd.id)
3426
+ }
3427
+ else if (pd.kind === 'agent') {
3428
+ const asp = this.agents.get(pd.id)
3429
+ if (asp) {
3430
+ asp.homeX = asp.root.position.x
3431
+ asp.homeZ = asp.root.position.z
3432
+ asp.dragging = false
3433
+ this.emitSaveState('saving')
3434
+ void this.agentApi?.updateHome(pd.id, asp.channelId, asp.homeX, asp.homeZ)
3435
+ .then(() => this.emitSaveState('saved', Date.now()))
3436
+ .catch(() => this.emitSaveState('error'))
3437
+ }
3438
+ }
3439
+ else if (pd.kind === 'rangeDraw') {
3440
+ // 框选结束:以拖框中点为中心、半宽为半径生成矩形范围(过小拖动视为取消)
3441
+ const asp = this.agents.get(pd.id)
3442
+ if (asp && this.rangeDrawPreviewState) {
3443
+ const d = this.rangeDrawPreviewState
3444
+ const range: AgentRangeLayout = {
3445
+ x: (d.x0 + d.x1) / 2,
3446
+ z: (d.z0 + d.z1) / 2,
3447
+ radiusX: Math.abs(d.x1 - d.x0) / 2,
3448
+ radiusZ: Math.abs(d.z1 - d.z0) / 2,
3449
+ shape: 'rect',
3450
+ rotationY: 0,
3451
+ }
3452
+ if (range.radiusX >= 40 && range.radiusZ >= 40) {
3453
+ asp.range = range
3454
+ asp.renderRangeLine()
3455
+ // 把角色落点收进新范围(home 若在范围外会导致回归/漫游卡死)
3456
+ const c = clampToAgentRange(asp.range, asp.homeX, asp.homeZ, 0)
3457
+ if (c.x !== asp.homeX || c.z !== asp.homeZ) {
3458
+ asp.homeX = c.x
3459
+ asp.homeZ = c.z
3460
+ asp.root.position.x = c.x
3461
+ asp.root.position.z = c.z
3462
+ void this.agentApi?.updateHome(pd.id, asp.channelId, c.x, c.z).catch(() => {})
3463
+ }
3464
+ this.emitSaveState('saving')
3465
+ void this.agentApi?.updateRange(pd.id, asp.channelId, asp.range)
3466
+ .then(() => this.emitSaveState('saved', Date.now()))
3467
+ .catch(() => this.emitSaveState('error'))
3468
+ }
3469
+ }
3470
+ this.cancelRangeDraw()
3471
+ this.refreshAgentRangeHandles()
3472
+ this.emitAgentRangeChanged(pd.id)
3473
+ }
3474
+ else if (pd.kind === 'agentRange' || pd.kind === 'agentRangeResize') {
3475
+ // 拖范围整框 / 拖范围手柄结束 → 落库 config.range
3476
+ const asp = this.agents.get(pd.id)
3477
+ if (asp && asp.range) {
3478
+ this.emitSaveState('saving')
3479
+ void this.agentApi?.updateRange(pd.id, asp.channelId, asp.range)
3480
+ .then(() => this.emitSaveState('saved', Date.now()))
3481
+ .catch(() => this.emitSaveState('error'))
3482
+ }
3483
+ this.refreshAgentRangeHandles()
3484
+ this.emitAgentRangeChanged(pd.id)
3485
+ }
3486
+ else {
3487
+ // channel / resize:把成员钳回新边界 + 布局落库 + 通知 Vue 刷新边界面板草稿
3488
+ const b = this.blocks.get(pd.id)
3489
+ if (b) {
3490
+ this.clampAgentsToBoundary(pd.id)
3491
+ this.refreshChannelHandles()
3492
+ const layout = this.getChannelLayout(pd.id)
3493
+ if (layout) {
3494
+ this.emitSaveState('saving')
3495
+ void this.channelApi?.save(pd.id, layout)
3496
+ .then(() => this.emitSaveState('saved', Date.now()))
3497
+ .catch(() => this.emitSaveState('error'))
3498
+ this.emit('channelResized', { channelId: pd.id, layout })
3499
+ }
3500
+ }
3501
+ }
3502
+ }
3503
+
3504
+ // ================================================================
3505
+ // Agent 独立活动范围(编辑模式:框选绘制 / 整框平移 / 手柄收缩扩张)
3506
+ // ================================================================
3507
+
3508
+ /** 活动范围线框可见性:编辑模式全部显示;浏览模式仅选中角色的范围显示 */
3509
+ private rangeLineVisible(asp: Agent3D): boolean {
3510
+ if (this.mode === 'edit') return true
3511
+ return this.selected?.kind === 'agent' && this.selected.id === asp.agentId
3512
+ }
3513
+
3514
+ /** 通知 Vue 对象面板刷新活动范围草稿 */
3515
+ private emitAgentRangeChanged(agentId: string): void {
3516
+ this.emit('agentRangeChanged', { agentId })
3517
+ }
3518
+
3519
+ /** 同步全部范围线框可见性(选中/模式切换后调用) */
3520
+ private refreshAgentRangeLines(): void {
3521
+ for (const a of this.agents.values()) {
3522
+ if (a.rangeLine) a.rangeLine.visible = this.rangeLineVisibleFor(a)
3523
+ }
3524
+ }
3525
+
3526
+ /** 活动范围线框可见性(公众面,供 Agent3D.renderRangeLine):编辑模式全部显示;浏览模式仅选中显示 */
3527
+ rangeLineVisibleFor(asp: Agent3D): boolean {
3528
+ if (this.mode === 'edit') return true
3529
+ return this.selected?.kind === 'agent' && this.selected.id === asp.agentId
3530
+ }
3531
+
3532
+ /** 刷新 Agent 活动范围手柄(编辑模式 + 选中带范围角色时显示;矩形四角 / 椭圆轴向四点) */
3533
+ private refreshAgentRangeHandles(): void {
3534
+ for (const hl of this.agentRangeHandles) hl.mesh.visible = false
3535
+ if (this.mode !== 'edit' || !this.selected || this.selected.kind !== 'agent') return
3536
+ const asp = this.agents.get(this.selected.id)
3537
+ if (!asp || !asp.range) return
3538
+ const rot = asp.range.rotationY * Math.PI / 180
3539
+ const pts = this.boundaryHandlePoints(asp.range)
3540
+ for (let i = 0; i < this.agentRangeHandles.length && i < pts.length; i++) {
3541
+ const [lx, lz] = pts[i]!
3542
+ const wx = asp.range.x + lx * Math.cos(rot) - lz * Math.sin(rot)
3543
+ const wz = asp.range.z + lx * Math.sin(rot) + lz * Math.cos(rot)
3544
+ this.agentRangeHandles[i]!.agentId = asp.agentId
3545
+ this.agentRangeHandles[i]!.mesh.position.set(wx, 1.2, wz)
3546
+ this.agentRangeHandles[i]!.mesh.visible = true
3547
+ }
3548
+ }
3549
+
3550
+ /** 命中选中 Agent 的活动范围手柄(编辑模式;返回 agentId + 手柄号) */
3551
+ private pickAgentRangeHandle(x: number, z: number): { agentId: string, handle: number } | null {
3552
+ if (this.mode !== 'edit') return null
3553
+ for (const hl of this.agentRangeHandles) {
3554
+ if (!hl.mesh.visible) continue
3555
+ if (Math.hypot(hl.mesh.position.x - x, hl.mesh.position.z - z) < 40) return { agentId: hl.agentId, handle: hl.handle }
3556
+ }
3557
+ return null
3558
+ }
3559
+
3560
+ /** 命中某成员活动范围边界线(供整框拖移;编辑模式;26 单位内视为命中) */
3561
+ private hitAgentRangeBoundary(x: number, z: number): Agent3D | undefined {
3562
+ if (this.mode !== 'edit') return undefined
3563
+ for (const a of this.agents.values()) {
3564
+ if (!a.range || !a.channelId) continue
3565
+ if (distToRangeBoundary(a.range, x, z) < 26) return a
3566
+ }
3567
+ return undefined
3568
+ }
3569
+
3570
+ /** 拖范围手柄 → 实时调整 radiusX/radiusZ(矩形:角点双轴;椭圆:对应轴向轴点),并收进频道边界 */
3571
+ private applyAgentRangeResize(range: AgentRangeLayout, handle: number, wx: number, wz: number): AgentRangeLayout {
3572
+ const rad = -range.rotationY * Math.PI / 180
3573
+ const cos = Math.cos(rad)
3574
+ const sin = Math.sin(rad)
3575
+ const dx = wx - range.x
3576
+ const dz = wz - range.z
3577
+ const lx = dx * cos - dz * sin
3578
+ const lz = dx * sin + dz * cos
3579
+ let next: AgentRangeLayout
3580
+ if (range.shape === 'rect') {
3581
+ next = { ...range, radiusX: Math.max(30, Math.abs(lx)), radiusZ: Math.max(30, Math.abs(lz)) }
3582
+ }
3583
+ else if (handle === 0 || handle === 1) {
3584
+ next = { ...range, radiusX: Math.max(30, Math.abs(lx)) }
3585
+ }
3586
+ else {
3587
+ next = { ...range, radiusZ: Math.max(30, Math.abs(lz)) }
3588
+ }
3589
+ return next
3590
+ }
3591
+
3592
+ /** 框选预览:以 (x0,z0)-(x1,z1) 为对角生成矩形线框(实时跟随指针) */
3593
+ private updateRangeDrawPreview(x0: number, z0: number, x1: number, z1: number): void {
3594
+ const lx = Math.min(x0, x1)
3595
+ const hx = Math.max(x0, x1)
3596
+ const lz = Math.min(z0, z1)
3597
+ const hz = Math.max(z0, z1)
3598
+ const cx = (lx + hx) / 2
3599
+ const cz = (lz + hz) / 2
3600
+ const rx = Math.max(8, (hx - lx) / 2)
3601
+ const rz = Math.max(8, (hz - lz) / 2)
3602
+ if (!this.rangeDrawLine) {
3603
+ const line = makeBoundary('rect', 1, 1, 0x41c8f4)
3604
+ ;(line.material as THREE.LineBasicMaterial).opacity = 0.95
3605
+ line.position.y = 0.5
3606
+ this.scene.add(line)
3607
+ this.rangeDrawLine = line
3608
+ }
3609
+ this.rangeDrawLine.position.set(cx, 0.5, cz)
3610
+ this.rangeDrawLine.rotation.y = 0
3611
+ const geo = new THREE.BufferGeometry().setFromPoints([
3612
+ new THREE.Vector3(-rx, 0, -rz), new THREE.Vector3(rx, 0, -rz),
3613
+ new THREE.Vector3(rx, 0, rz), new THREE.Vector3(-rx, 0, rz),
3614
+ ])
3615
+ this.rangeDrawLine.geometry.dispose()
3616
+ this.rangeDrawLine.geometry = geo
3617
+ }
3618
+
3619
+ /** 退出框选绘制模式(清理预览与状态) */
3620
+ cancelRangeDraw(): void {
3621
+ if (this.rangeDrawAgent === null && !this.rangeDrawLine) return
3622
+ this.rangeDrawAgent = null
3623
+ this.rangeDrawPreviewState = null
3624
+ if (this.rangeDrawLine) {
3625
+ this.scene.remove(this.rangeDrawLine)
3626
+ this.rangeDrawLine = null
3627
+ }
3628
+ this.dirty = true
3629
+ }
3630
+
3631
+ /** 进入「框选绘制」模式:为该角色拉动矩形框生成活动范围(编辑模式) */
3632
+ startRangeDraw(agentId: string): void {
3633
+ const asp = this.agents.get(agentId)
3634
+ if (!asp || !asp.channelId || this.mode !== 'edit') return
3635
+ this.setSelected({ kind: 'agent', id: agentId })
3636
+ this.cancelRangeDraw()
3637
+ this.rangeDrawAgent = agentId
3638
+ this.rangeDrawPreviewState = null
3639
+ this.refreshAgentRangeHandles()
3640
+ this.dirty = true
3641
+ }
3642
+
3643
+ /** 当前是否正在为某角色框选绘制 */
3644
+ isRangeDrawing(agentId?: string): boolean {
3645
+ if (!this.rangeDrawAgent) return false
3646
+ return agentId === undefined || this.rangeDrawAgent === agentId
3647
+ }
3648
+
3649
+ /** E2E/调试:强制某频道接收器跳过当前条目,立即消费下一条(FIFO 顺序断言用) */
3650
+ debugAdvanceReceiver(channelId: string): void {
3651
+ const rec = this.receivers.get(channelId)
3652
+ if (rec) rec.currentUntil = 0
3653
+ }
3654
+
3655
+ /** Agent 当前活动范围(供面板初始化;未设置返回 null) */
3656
+ getAgentRange(agentId: string): AgentRangeLayout | null {
3657
+ return this.agents.get(agentId)?.range ?? null
3658
+ }
3659
+
3660
+ /** 面板滑杆/形状即时调整:局部更新范围(无上限,信任用户设定) + 线框/手柄刷新;home 被迫位移时记档待提交 */
3661
+ setAgentRangeScene(agentId: string, patch: Partial<AgentRangeLayout>): void {
3662
+ const asp = this.agents.get(agentId)
3663
+ if (!asp) return
3664
+ if (!asp.range) {
3665
+ // 尚无范围:以落点为中心、频道半径 1/4 起手(面板直接给值时)
3666
+ asp.range = { x: asp.homeX, z: asp.homeZ, radiusX: 120, radiusZ: 90, shape: 'ellipse', rotationY: 0 }
3667
+ }
3668
+ const next = { ...asp.range, ...patch }
3669
+ asp.range = next
3670
+ // 范围收缩致 home 越界 → 落点收进范围(提交时随 updateRange 一并落库)
3671
+ const c = clampToAgentRange(asp.range, asp.homeX, asp.homeZ, 0)
3672
+ if (c.x !== asp.homeX || c.z !== asp.homeZ) {
3673
+ asp.homeX = c.x
3674
+ asp.homeZ = c.z
3675
+ asp.root.position.x = c.x
3676
+ asp.root.position.z = c.z
3677
+ this.rangeHomeMoved = { agentId, x: c.x, z: c.z }
3678
+ }
3679
+ asp.renderRangeLine()
3680
+ this.refreshAgentRangeHandles()
3681
+ this.dirty = true
3682
+ }
3683
+
3684
+ /** 面板提交:落库范围(updateRange);home 若被迫位移一并落库(updateHome) */
3685
+ commitAgentRange(agentId: string): void {
3686
+ const asp = this.agents.get(agentId)
3687
+ if (!asp) return
3688
+ if (this.rangeHomeMoved?.agentId === agentId) {
3689
+ const { x, z } = this.rangeHomeMoved
3690
+ this.rangeHomeMoved = null
3691
+ void this.agentApi?.updateHome(agentId, asp.channelId, x, z).catch(() => {})
3692
+ }
3693
+ this.emitSaveState('saving')
3694
+ void this.agentApi?.updateRange(agentId, asp.channelId, asp.range)
3695
+ .then(() => this.emitSaveState('saved', Date.now()))
3696
+ .catch(() => this.emitSaveState('error'))
3697
+ this.emitAgentRangeChanged(agentId)
3698
+ }
3699
+
3700
+ /** 清除角色活动范围(局部 + 落库 null;回退频道边界) */
3701
+ clearAgentRange(agentId: string): void {
3702
+ const asp = this.agents.get(agentId)
3703
+ if (!asp) return
3704
+ if (asp.rangeLine) {
3705
+ this.scene.remove(asp.rangeLine)
3706
+ asp.rangeLine = null
3707
+ }
3708
+ asp.range = null
3709
+ this.rangeHomeMoved = null
3710
+ this.refreshAgentRangeHandles()
3711
+ this.emitSaveState('saving')
3712
+ void this.agentApi?.updateRange(agentId, asp.channelId, null)
3713
+ .then(() => this.emitSaveState('saved', Date.now()))
3714
+ .catch(() => this.emitSaveState('error'))
3715
+ this.emitAgentRangeChanged(agentId)
3716
+ this.dirty = true
3717
+ }
3718
+
3719
+ /** 对象朝向(度;编辑模式旋转滑杆实时) */
3720
+ setModelRotation(id: string, deg: number, kind?: 'agent' | 'device'): void {
3721
+ const k = kind ?? (this.deviceNodes.has(id) ? 'device' : 'agent')
3722
+ if (k === 'device') {
3723
+ const dev = this.deviceNodes.get(id)
3724
+ if (!dev) return
3725
+ dev.root.rotation.y = deg * Math.PI / 180
3726
+ }
3727
+ else {
3728
+ const asp = this.agents.get(id)
3729
+ if (!asp) return
3730
+ asp.root.rotation.y = deg * Math.PI / 180
3731
+ }
3732
+ this.dirty = true
3733
+ }
3734
+
3735
+ getModelRotation(id: string, kind?: 'agent' | 'device'): number {
3736
+ const k = kind ?? (this.deviceNodes.has(id) ? 'device' : 'agent')
3737
+ if (k === 'device') {
3738
+ const dev = this.deviceNodes.get(id)
3739
+ return dev ? THREE.MathUtils.radToDeg(dev.root.rotation.y) : 0
3740
+ }
3741
+ const asp = this.agents.get(id)
3742
+ return asp ? THREE.MathUtils.radToDeg(asp.root.rotation.y) : 0
3743
+ }
3744
+
3745
+ // ================================================================
3746
+ // 场景保存状态(未保存/保存中/已保存/失败)+ 设备 transform 防抖落库
3747
+ // ================================================================
3748
+
3749
+ private emitSaveState(state: 'idle' | 'dirty' | 'saving' | 'saved' | 'error', at = Date.now()): void {
3750
+ this.emit('saveState', { state, at })
3751
+ }
3752
+
3753
+ /** 持久化设备 transform(位置/朝向/缩放一次写入;防抖 350ms,不逐帧写库) */
3754
+ persistDeviceTransform(id: string): void {
3755
+ const dev = this.deviceNodes.get(id)
3756
+ if (!dev || !this.devices?.update || this.pendingDeviceCreates.has(id)) return
3757
+ const st = this.scalables.get(`device:${id}`)
3758
+ const prev = this.pendingSaveTimers.get(id)
3759
+ if (prev) clearTimeout(prev)
3760
+ this.pendingSaveTimers.set(id, setTimeout(() => {
3761
+ this.pendingSaveTimers.delete(id)
3762
+ if (this.disposed || this.pendingDeviceCreates.has(id)) return
3763
+ this.emitSaveState('saving')
3764
+ void this.devices!.update(id, {
3765
+ posX: Math.round(dev.root.position.x * 10) / 10,
3766
+ posZ: Math.round(dev.root.position.z * 10) / 10,
3767
+ rotationY: Math.round(THREE.MathUtils.radToDeg(dev.root.rotation.y) * 10) / 10,
3768
+ scale: st ? Math.round(st.userScale * 100) / 100 : undefined,
3769
+ })
3770
+ .then(() => this.emitSaveState('saved', Date.now()))
3771
+ .catch(() => this.emitSaveState('error'))
3772
+ }, 350))
3773
+ this.emitSaveState('dirty')
3774
+ }
3775
+
3776
+ /** 保存全部设备节点(「保存布局」按钮:强制全量落库) */
3777
+ persistAllDevices(): void {
3778
+ for (const id of [...this.deviceNodes.keys()]) this.persistDeviceTransform(id)
3779
+ }
3780
+
3781
+ private pendingSaveTimers = new Map<string, ReturnType<typeof setTimeout>>()
3782
+
3783
+ // ================================================================
3784
+ // 设备场景实例同步(服务端设备孪生 → 本地节点;多客户端一致)
3785
+ // ================================================================
3786
+
3787
+ /**
3788
+ * 与设备孪生清单对齐:
3789
+ * - 服务端有落点且本地无节点 → 按保存的 transform 重建节点(刷新后恢复);
3790
+ * - 已存在 → 收敛状态/遥测,并在未拖拽时收敛 transform(他人编辑即时跟随);
3791
+ * - 服务端已删除 → 移除本地节点。
3792
+ * TownView 在 device.* 事件与轮询时调用;轮询兜底多客户端同步。
3793
+ */
3794
+ syncDevices(twins: DeviceTwinSync[]): void {
3795
+ const serverIds = new Set(twins.map(t => t.id))
3796
+ for (const id of [...this.deviceNodes.keys()]) {
3797
+ if (!serverIds.has(id) && !this.pendingDeviceCreates.has(id)) this.removeDeviceNode(id)
3798
+ }
3799
+ for (const t of twins) {
3800
+ let existing = this.deviceNodes.get(t.id)
3801
+ if (!existing) {
3802
+ const pending = [...this.pendingDeviceCreates.entries()].find(([, p]) =>
3803
+ p.name === t.name && p.modelRef === t.modelRef,
3804
+ )
3805
+ if (pending) {
3806
+ this.adoptDeviceNode(pending[0], t.id)
3807
+ existing = this.deviceNodes.get(t.id)
3808
+ }
3809
+ }
3810
+ if (!existing) {
3811
+ // 数采/智控节点走程序化路径(无 GLB 也可实例化);设备需模型可解析 + 已入场景
3812
+ const isDaq = t.kind === 'daq' || (t.modelRef ?? '').startsWith('daq-') || (t.modelRef ?? '').startsWith('dcw-')
3813
+ const resolvable = isDaq || !!this.resolveDeviceModel(t.modelRef)?.file
3814
+ if (resolvable && typeof t.posX === 'number' && typeof t.posZ === 'number') this.recreateDeviceNode(t)
3815
+ else this.pendingDeviceTwins.set(t.id, t)
3816
+ continue
3817
+ }
3818
+ this.pendingDeviceTwins.delete(t.id)
3819
+ existing.applyTwin(t)
3820
+ if (this.pointerDrag?.id !== t.id) {
3821
+ const moved = (typeof t.posX === 'number' && t.posX !== existing.root.position.x)
3822
+ || (typeof t.posZ === 'number' && t.posZ !== existing.root.position.z)
3823
+ if (typeof t.posX === 'number') existing.root.position.x = t.posX
3824
+ if (typeof t.posZ === 'number') existing.root.position.z = t.posZ
3825
+ if (typeof t.rotationY === 'number') existing.root.rotation.y = t.rotationY * Math.PI / 180
3826
+ const st = this.scalables.get(`device:${t.id}`)
3827
+ if (typeof t.scale === 'number' && st) {
3828
+ st.userScale = t.scale
3829
+ st.holder.scale.setScalar(t.scale)
3830
+ }
3831
+ if (moved) this.dirty = true
3832
+ }
3833
+ this.dirty = true
3834
+ }
3835
+ // 设备节点就位后重仲裁绑定链路(链路期望先于节点到达的时序兜底)
3836
+ if (this.daqLinksWanted.length) this.syncDaqLinks(this.daqLinksWanted)
3837
+ for (const id of [...this.pendingDeviceTwins.keys()]) {
3838
+ if (!serverIds.has(id)) this.pendingDeviceTwins.delete(id)
3839
+ }
3840
+ }
3841
+
3842
+ /** 按设备孪生记录重建场景节点(持久化恢复:pos/rotation/scale;模型缺失则跳过) */
3843
+ /** 设备模型解析:精确 id → `dev-folder-<ref>` 前缀 → `-<ref>` 后缀(兼容旧数据短 modelRef) */
3844
+ private resolveDeviceModel(ref: string): { id: string, file: string, hFactor: number } | null {
3845
+ const direct = this.modelsById.get(ref)
3846
+ if (direct?.file) return { id: direct.id, file: direct.file, hFactor: direct.hFactor ?? 1 }
3847
+ for (const [id, m] of this.modelsById) {
3848
+ if (m.kind !== 'dev' || !m.file) continue
3849
+ if (id === `dev-folder-${ref}` || id.endsWith(`-${ref}`)) return { id, file: m.file, hFactor: m.hFactor ?? 1 }
3850
+ }
3851
+ return null
3852
+ }
3853
+
3854
+ private recreateDeviceNode(t: DeviceTwinSync): void {
3855
+ // 数采/智控节点:程序化传感网格(基座 + 传感头 + 信号环),不依赖 GLB 资产
3856
+ // (modelRef 前缀兜底:旧数据 kind 落成 'device' 的数采实例同样走程序化路径)
3857
+ if (t.kind === 'daq' || (t.modelRef ?? '').startsWith('daq-') || (t.modelRef ?? '').startsWith('dcw-')) {
3858
+ this.recreateDaqNode(t)
3859
+ return
3860
+ }
3861
+ const model = this.resolveDeviceModel(t.modelRef)
3862
+ const file = model?.file ?? ''
3863
+ if (!file) return
3864
+ const x = typeof t.posX === 'number' ? t.posX : WORLD_CX
3865
+ const z = typeof t.posZ === 'number' ? t.posZ : WORLD_CZ
3866
+ const root = new THREE.Group()
3867
+ root.position.set(x, GROUND_Y, z)
3868
+ root.rotation.y = typeof t.rotationY === 'number' ? t.rotationY * Math.PI / 180 : 0
3869
+ const { ring, arc } = this.makeDeviceRing()
3870
+ root.add(ring, arc)
3871
+ const holder = new THREE.Group()
3872
+ root.add(holder)
3873
+ const label = this.makeLabel(`⚙ ${t.name}`, x, 60, z)
3874
+ holder.userData.modelFile = file
3875
+ this.scene.add(root)
3876
+ const twin = new DeviceNode({ twinId: t.id, name: t.name, modelRef: t.modelRef, root, holder, ring, label, state: t.state ?? 'idle', telemetry: t.telemetry ?? {} })
3877
+ twin.host = this
3878
+ twin.arc = arc
3879
+ twin.updateRing()
3880
+ this.deviceNodes.set(t.id, twin)
3881
+ // 服务端缩放优先(持久化恢复);缺省回退 localStorage
3882
+ this.registerScalable('device', t.id, holder, typeof t.scale === 'number' ? t.scale : undefined)
3883
+ // 高度系数:产线设备按设计稿比例错落(基准高 × 模型系数),未知模型维持 1
3884
+ void this.loadGltfToGroup(file, holder, UNITS * 1.6 * (model?.hFactor ?? 1))
3885
+ this.dirty = true
3886
+ }
3887
+
3888
+ /** 数采节点传感头(设计稿 DAQ 预制件 1:1 移植,×34 世界单位):
3889
+ * 立杆基座 + 顶端 LED 环 + 每模板独立传感形态 —— 温度计/压力表/张力辊/编码盘/相机/电参天线。 */
3890
+ private makeDaqMesh(modelRef: string): { group: THREE.Group, ledRing: THREE.Mesh } {
3891
+ const S = 34
3892
+ const g = new THREE.Group()
3893
+ const steel = new THREE.MeshStandardMaterial({ color: 0x9fb2c8, metalness: 0.85, roughness: 0.32 })
3894
+ const body = new THREE.MeshStandardMaterial({ color: 0x74869c, metalness: 0.35, roughness: 0.5 })
3895
+ const body2 = new THREE.MeshStandardMaterial({ color: 0x55647a, metalness: 0.4, roughness: 0.55 })
3896
+ const dark = new THREE.MeshStandardMaterial({ color: 0x2b3442, metalness: 0.7, roughness: 0.45 })
3897
+ const chrome = new THREE.MeshStandardMaterial({ color: 0xdfe8f2, metalness: 1, roughness: 0.14 })
3898
+ const copper = new THREE.MeshStandardMaterial({ color: 0xc57a45, metalness: 0.9, roughness: 0.35 })
3899
+ const glow = new THREE.MeshBasicMaterial({ color: 0x41c8f4 })
3900
+ glow.color.multiplyScalar(5)
3901
+ const B = (w: number, h: number, d: number, m: THREE.Material): THREE.Mesh => {
3902
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * S, h * S, d * S), m)
3903
+ mesh.castShadow = true
3904
+ return mesh
3905
+ }
3906
+ const Cyl = (rt: number, rb: number, h: number, m: THREE.Material, seg = 20): THREE.Mesh => {
3907
+ const mesh = new THREE.Mesh(new THREE.CylinderGeometry(rt * S, rb * S, h * S, seg), m)
3908
+ mesh.castShadow = true
3909
+ return mesh
3910
+ }
3911
+ const at = (m: THREE.Mesh, x: number, y: number, z: number): THREE.Mesh => {
3912
+ m.position.set(x * S, y * S, z * S)
3913
+ g.add(m)
3914
+ return m
3915
+ }
3916
+ // 立杆基座(设计稿 daqPole)
3917
+ at(Cyl(0.17, 0.22, 0.09, dark), 0, 0.045, 0)
3918
+ at(Cyl(0.028, 0.028, 0.95, steel, 10), 0, 0.55, 0)
3919
+ const ledRing = new THREE.Mesh(new THREE.TorusGeometry(0.09 * S, 0.02 * S, 10, 28), new THREE.MeshBasicMaterial({ color: 0x35e0a0 }))
3920
+ // HDR 亮度:LED 环(及其复用材质的 halo/iris/tip)提到阈值之上,"真发光"元件(bloom 起晕)
3921
+ ;(ledRing.material as THREE.MeshBasicMaterial).color.multiplyScalar(6)
3922
+ ledRing.rotation.x = Math.PI / 2
3923
+ g.add(ledRing)
3924
+ ledRing.position.y = 1.02 * S
3925
+ // 每模板传感头(modelRef = `daq-<tplId>`)
3926
+ if (modelRef.endsWith('temp-tc')) {
3927
+ at(B(0.24, 0.32, 0.24, body), 0, 1.2, 0)
3928
+ const probe = at(Cyl(0.014, 0.014, 0.4, steel, 8), 0.14, 0.85, 0)
3929
+ probe.rotation.z = -0.5
3930
+ }
3931
+ else if (modelRef.endsWith('pressure-tx')) {
3932
+ at(Cyl(0.13, 0.16, 0.3, steel), 0, 1.22, 0)
3933
+ at(Cyl(0.12, 0.12, 0.05, dark), 0, 1.4, 0)
3934
+ at(B(0.05, 0.05, 0.2, copper), 0, 1.1, 0.2)
3935
+ }
3936
+ else if (modelRef.endsWith('tension-cell')) {
3937
+ at(B(0.34, 0.2, 0.2, body), 0, 1.25, 0)
3938
+ for (const x of [-0.11, 0.11]) {
3939
+ const roll = at(Cyl(0.06, 0.06, 0.26, chrome), x, 1.25, 0)
3940
+ roll.rotation.x = Math.PI / 2
3941
+ }
3942
+ }
3943
+ else if (modelRef.endsWith('line-encoder')) {
3944
+ at(B(0.34, 0.05, 0.05, steel), 0.14, 1.28, 0)
3945
+ const disc = at(Cyl(0.16, 0.16, 0.05, dark), 0.32, 1.28, 0)
3946
+ disc.rotation.z = Math.PI / 2
3947
+ const halo = new THREE.Mesh(new THREE.TorusGeometry(0.1 * S, 0.015 * S, 10, 26), ledRing.material as THREE.Material)
3948
+ at(halo, 0.35, 1.28, 0)
3949
+ }
3950
+ else if (modelRef.endsWith('vision-cam')) {
3951
+ at(B(0.28, 0.2, 0.36, body2), 0, 1.3, 0)
3952
+ const lens = at(Cyl(0.07, 0.09, 0.14, dark), 0, 1.3, 0.24)
3953
+ lens.rotation.x = Math.PI / 2
3954
+ const iris = new THREE.Mesh(new THREE.TorusGeometry(0.075 * S, 0.012 * S, 10, 24), ledRing.material as THREE.Material)
3955
+ at(iris, 0, 1.3, 0.3)
3956
+ }
3957
+ else if (modelRef.endsWith('power-meter')) {
3958
+ at(B(0.32, 0.42, 0.18, body), 0, 1.3, 0)
3959
+ at(Cyl(0.012, 0.012, 0.42, steel, 8), 0.1, 1.7, 0)
3960
+ const tip = new THREE.Mesh(new THREE.SphereGeometry(0.028 * S, 10, 8), ledRing.material as THREE.Material)
3961
+ at(tip, 0.1, 1.92, 0)
3962
+ at(B(0.2, 0.06, 0.02, glow), 0, 1.42, 0.1)
3963
+ }
3964
+ return { group: g, ledRing }
3965
+ }
3966
+
3967
+ /** 数采/智控节点(程序化):模板分化传感网格 + **产线光晕**(同产线同色光环)+ 名牌 */
3968
+ private recreateDaqNode(t: DeviceTwinSync): void {
3969
+ const x = typeof t.posX === 'number' ? t.posX : WORLD_CX
3970
+ const z = typeof t.posZ === 'number' ? t.posZ : WORLD_CZ
3971
+ const root = new THREE.Group()
3972
+ root.position.set(x, GROUND_Y, z)
3973
+ root.rotation.y = typeof t.rotationY === 'number' ? t.rotationY * Math.PI / 180 : 0
3974
+ const { group: sensor, ledRing } = this.makeDaqMesh(t.modelRef)
3975
+ root.add(sensor)
3976
+ const sigRing = new THREE.Mesh(
3977
+ new THREE.RingGeometry(16, 21, 36),
3978
+ new THREE.MeshBasicMaterial({ color: 0x35e0a0, transparent: true, opacity: 0.5, side: THREE.DoubleSide, depthWrite: false }),
3979
+ )
3980
+ sigRing.rotation.x = -Math.PI / 2
3981
+ sigRing.position.y = 0.35
3982
+ root.add(sigRing)
3983
+ // 产线光晕:外圈加性光环(soft) + 内圈亮环;未分配 = 缺省绿
3984
+ const haloOuter = new THREE.Mesh(
3985
+ new THREE.RingGeometry(24, 34, 48),
3986
+ new THREE.MeshBasicMaterial({ color: 0x35e0a0, transparent: true, opacity: 0.16, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }),
3987
+ )
3988
+ haloOuter.rotation.x = -Math.PI / 2
3989
+ haloOuter.position.y = 0.28
3990
+ root.add(haloOuter)
3991
+ const accent = t.lineColor || '#35e0a0'
3992
+ const label = this.makeLabel(t.name, x, 62, z, accent)
3993
+ this.scene.add(root)
3994
+ const node = new DeviceNode({
3995
+ twinId: t.id, name: t.name, modelRef: t.modelRef,
3996
+ root, holder: root, ring: sigRing, label,
3997
+ state: t.state ?? 'running', telemetry: t.telemetry ?? {},
3998
+ })
3999
+ node.host = this
4000
+ node.lineColor = t.lineColor ?? ''
4001
+ // 换产线/换色:重 tint 光环 + LED 环 + 名牌(applyTwin 检测 lineColor 变化时触发)
4002
+ node.applyLine = (color: string) => {
4003
+ const c = new THREE.Color(color || '#35e0a0')
4004
+ ;(sigRing.material as THREE.MeshBasicMaterial).color.copy(c)
4005
+ ;(haloOuter.material as THREE.MeshBasicMaterial).color.copy(c)
4006
+ ;(ledRing.material as THREE.MeshBasicMaterial).color.copy(c).multiplyScalar(6) // 保持 HDR(LED 起晕)
4007
+ this.scene.remove(label)
4008
+ label.material.dispose()
4009
+ const fresh = this.makeLabel(node.name, root.position.x, 62, root.position.z, color || '#35e0a0')
4010
+ node.label = fresh
4011
+ this.scene.add(fresh)
4012
+ this.dirty = true
4013
+ }
4014
+ if (t.lineColor) node.applyLine(t.lineColor)
4015
+ node.updateRing()
4016
+ this.deviceNodes.set(t.id, node)
4017
+ this.daqLedRings.set(t.id, ledRing)
4018
+ this.registerScalable('device', t.id, root, typeof t.scale === 'number' ? t.scale : undefined)
4019
+ this.dirty = true
4020
+ }
4021
+
4022
+ /** 移除设备场景节点(服务端记录被删/本地重建清理) */
4023
+ private removeDeviceNode(id: string): void {
4024
+ const dev = this.deviceNodes.get(id)
4025
+ if (!dev) return
4026
+ this.scene.remove(dev.root)
4027
+ this.scene.remove(dev.label)
4028
+ this.disposeDeviceAssets(dev)
4029
+ if (this.selected?.kind === 'device' && this.selected.id === id) this.setSelected(null)
4030
+ this.scalables.delete(`device:${id}`)
4031
+ this.deviceNodes.delete(id)
4032
+ this.daqLedRings.delete(id)
4033
+ this.daqLinkSig = ''
4034
+ this.filmWebSig = ''
4035
+ this.dirty = true
4036
+ }
4037
+
4038
+ // ================================================================
4039
+ // 数采绑定链路 + 薄膜 web(设计稿 buildChanLine / rebuildWeb 移植)
4040
+ // ================================================================
4041
+
4042
+ /** 设备顶端世界高度(holder 包围盒;链路/膜 web 的挂点)。
4043
+ * 缓存优先:Box3.setFromObject 遍历整个 GLB 子树,原每帧每设备全量计算是 HUD 最大热点;
4044
+ * 设备贴地 y=0,高度只随缩放变化 → setModelScale/registerScalable 时失效即可。 */
4045
+ private deviceTopY(dev: DeviceNode): number {
4046
+ if (dev.topYCache != null) return dev.topYCache
4047
+ const box = new THREE.Box3().setFromObject(dev.holder)
4048
+ const y = Number.isFinite(box.max.y) ? Math.max(40, box.max.y) : 64
4049
+ dev.topYCache = y
4050
+ return y
4051
+ }
4052
+
4053
+ /** 同步数采→设备绑定链路(TownView 传入 [{daqId, deviceId}];端点移动时逐帧跟随重建) */
4054
+ syncDaqLinks(links: Array<{ daqId: string, deviceId: string }>): void {
4055
+ this.daqLinksWanted = links
4056
+ // 签名按"成功建链"的链路计算:节点未就绪被跳过的链路不计入 → 设备节点晚到时 syncDevices 末尾重仲裁补链
4057
+ const built = links.filter(l => this.deviceNodes.has(l.daqId) && this.deviceNodes.has(l.deviceId))
4058
+ const sig = built.map(l => `${l.daqId}>${l.deviceId}`).sort().join('|')
4059
+ if (sig === this.daqLinkSig) return
4060
+ this.daqLinkSig = sig
4061
+ for (const l of this.daqLinks) {
4062
+ this.daqLinkGroup.remove(l.line)
4063
+ this.daqLinkGroup.remove(l.pulse)
4064
+ l.line.geometry.dispose()
4065
+ }
4066
+ this.daqLinks = []
4067
+ for (const l of built) {
4068
+ const daq = this.deviceNodes.get(l.daqId)!
4069
+ const dev = this.deviceNodes.get(l.deviceId)!
4070
+ const a = new THREE.Vector3(daq.root.position.x, 42, daq.root.position.z)
4071
+ const b = new THREE.Vector3(dev.root.position.x, this.deviceTopY(dev) + 6, dev.root.position.z)
4072
+ const mid = a.clone().add(b).multiplyScalar(0.5)
4073
+ mid.y = Math.max(a.y, b.y) + 42
4074
+ const curve = new THREE.QuadraticBezierCurve3(a, mid, b)
4075
+ const geo = new THREE.BufferGeometry().setFromPoints(curve.getPoints(28))
4076
+ const line = new THREE.Line(geo, new THREE.LineDashedMaterial({ color: 0x35e0a0, transparent: true, opacity: 0.55, dashSize: 8, gapSize: 5 }))
4077
+ line.computeLineDistances()
4078
+ const pulse = new THREE.Mesh(new THREE.SphereGeometry(2.6, 10, 8), new THREE.MeshBasicMaterial({ color: 0x41c8f4 }))
4079
+ ;(pulse.material as THREE.MeshBasicMaterial).color.multiplyScalar(5) // HDR:链路数据脉冲起晕
4080
+ this.daqLinkGroup.add(line, pulse)
4081
+ this.daqLinks.push({ ...l, line, pulse, curve, pt: Math.random(), ptSig: '' })
4082
+ }
4083
+ this.dirty = true
4084
+ }
4085
+
4086
+ /** 端点跟随:任一端点位移超阈值 → 重建该链路曲线(拖拽设备/数采时虚线实时跟随) */
4087
+ /** 链路端点 scratch(逐帧跟随用;免每帧 3 次分配) */
4088
+ private static readonly _lkA = new THREE.Vector3()
4089
+ private static readonly _lkB = new THREE.Vector3()
4090
+ private static readonly _lkM = new THREE.Vector3()
4091
+
4092
+ private refreshDaqLinks(): void {
4093
+ for (const l of this.daqLinks) {
4094
+ const daq = this.deviceNodes.get(l.daqId)
4095
+ const dev = this.deviceNodes.get(l.deviceId)
4096
+ if (!daq || !dev) continue
4097
+ const topY = this.deviceTopY(dev) + 6
4098
+ const sig = `${Math.round(daq.root.position.x)},${Math.round(daq.root.position.z)},${Math.round(dev.root.position.x)},${Math.round(dev.root.position.z)},${Math.round(topY)}`
4099
+ if (sig === l.ptSig) continue // 端点未动:曲线/geometry 不重建(脉冲仍逐帧走)
4100
+ l.ptSig = sig
4101
+ const a = TownScene3D._lkA.set(daq.root.position.x, 42, daq.root.position.z)
4102
+ const b = TownScene3D._lkB.set(dev.root.position.x, topY, dev.root.position.z)
4103
+ const mid = TownScene3D._lkM.copy(a).add(b).multiplyScalar(0.5)
4104
+ mid.y = Math.max(a.y, b.y) + 42
4105
+ l.curve.v0.copy(a)
4106
+ l.curve.v1.copy(mid)
4107
+ l.curve.v2.copy(b)
4108
+ const pts = l.curve.getPoints(28)
4109
+ l.line.geometry.setFromPoints(pts)
4110
+ l.line.computeLineDistances()
4111
+ }
4112
+ }
4113
+
4114
+ /** 产线设备型号识别(modelRef 含产线关键字 → 薄膜 web 连线成员) */
4115
+ private isLineDevice(modelRef: string): boolean {
4116
+ const ref = modelRef.toLowerCase()
4117
+ return /extruder|caster|mdo|tdo|winder/.test(ref)
4118
+ }
4119
+
4120
+ /** 薄膜 web:按 X 序连接产线设备(挤出→流延→MD→TD→收卷),半透明膜面 —— 产线工艺连续性可视化 */
4121
+ private rebuildFilmWeb(): void {
4122
+ const list = [...this.deviceNodes.values()]
4123
+ .filter(d => this.isLineDevice(d.modelRef) && !d.modelRef.startsWith('daq-'))
4124
+ .sort((a, b) => a.root.position.x - b.root.position.x)
4125
+ const sig = list.map(d => `${d.twinId}:${Math.round(d.root.position.x)},${Math.round(d.root.position.z)}`).join('|')
4126
+ if (sig === this.filmWebSig) return
4127
+ this.filmWebSig = sig
4128
+ while (this.filmWebGroup.children.length) {
4129
+ const c = this.filmWebGroup.children[0] as THREE.Mesh
4130
+ this.filmWebGroup.remove(c)
4131
+ c.geometry.dispose()
4132
+ }
4133
+ if (!this.filmWebMat) {
4134
+ this.filmWebMat = new THREE.MeshStandardMaterial({
4135
+ color: 0xaad8ff, metalness: 0.1, roughness: 0.35, transparent: true, opacity: 0.28,
4136
+ emissive: 0x2b6b8f, emissiveIntensity: 0.25, side: THREE.DoubleSide, depthWrite: false,
4137
+ })
4138
+ }
4139
+ for (let i = 0; i < list.length - 1; i++) {
4140
+ const a = list[i]!.root.position
4141
+ const b = list[i + 1]!.root.position
4142
+ const len = Math.abs(b.x - a.x) - 110
4143
+ if (len < 30) continue
4144
+ const web = new THREE.Mesh(new THREE.BoxGeometry(len, 1.2, 46), this.filmWebMat)
4145
+ web.position.set((a.x + b.x) / 2, 48, (a.z + b.z) / 2)
4146
+ this.filmWebGroup.add(web)
4147
+ }
4148
+ this.dirty = true
4149
+ }
4150
+
4151
+ // ================================================================
4152
+ // 生命周期 / 渲染循环
4153
+ // ================================================================
4154
+
4155
+ private loop(): void {
4156
+ const animate = () => {
4157
+ if (this.disposed) return
4158
+ this.raf = requestAnimationFrame(animate)
4159
+ const rawDt = this.clock.getDelta()
4160
+ // 帧预算门控:数据消费与帧率解耦 —— WS 帧直写实时缓冲(消费层,不受此门控),
4161
+ // 渲染循环每帧只取「当前最新值」上屏(展示层);budget 0 = 不限制(用户可选 60/120/∞)。
4162
+ // 后台标签页 rAF 自动停摆;回前台 rawDt 巨大 → 钳制单步 ≤100ms 防动画跳变
4163
+ if (this.frameBudgetMs > 0) {
4164
+ this.frameAcc += rawDt * 1000
4165
+ if (this.frameAcc < this.frameBudgetMs - 0.5) return
4166
+ this.frameAcc = 0
4167
+ }
4168
+ const dt = Math.min(rawDt, 0.1)
4169
+ const t = this.clock.elapsedTime
4170
+ // 频道信息接收器:FIFO 逐条消费实时消息(每帧检查,展示期满取下一条)
4171
+ this.drainReceivers(performance.now())
4172
+ // 行为 FSM
4173
+ for (const asp of this.agents.values()) {
4174
+ if (asp.state !== 'stopped' && !asp.dragging) asp.update(dt)
4175
+ }
4176
+ // mixer 更新
4177
+ for (const asp of this.agents.values()) {
4178
+ if (asp.mixer) asp.mixer.update(dt)
4179
+ }
4180
+ // 频道信标缓转(顶标菱形旋转,中心定位销的生命感)
4181
+ for (const b of this.blocks.values()) b.beacon.rotation.y += dt * 0.4
4182
+ // 名字/气泡跟随 + 光环呼吸 + 逐帧动画(脉冲/光柱/缓动)
4183
+ for (const asp of this.agents.values()) {
4184
+ asp.nameSprite.position.set(asp.root.position.x, 48, asp.root.position.z)
4185
+ if (asp.bubble) {
4186
+ asp.bubble.position.set(asp.root.position.x, BUBBLE_Y + 22 + Math.max(0, asp.model.scale.y - 1) * 22, asp.root.position.z)
4187
+ }
4188
+ if (asp.auraMats.length) {
4189
+ const br = 0.8 + 0.2 * Math.sin(t * 2.2 + asp.auraPhase)
4190
+ for (const m of asp.auraMats) m.opacity = (m.userData.baseOp as number) * br
4191
+ }
4192
+ }
4193
+ // 设备节点:名牌跟随(状态环是 root 子节点自动跟随;颜色由 updateRing 在状态变化时刷新)+ 运行弧缓转
4194
+ for (const dev of this.deviceNodes.values()) {
4195
+ dev.label.position.set(dev.root.position.x, 60, dev.root.position.z)
4196
+ if (dev.arc?.visible) dev.arc.rotation.z += dt * 1.6
4197
+ }
4198
+ // 数采节点 LED 环:缓转 + 呼吸(绑定链路的信号生命感)
4199
+ for (const ring of this.daqLedRings.values()) {
4200
+ ring.rotation.z += dt * 1.2
4201
+ const s = 1 + Math.sin(t * 3.2) * 0.08
4202
+ ring.scale.setScalar(s)
4203
+ }
4204
+ // 绑定链路:端点位移时重建曲线(dirty 门控;脉冲仍逐帧行走)
4205
+ if (this.daqLinks.length) {
4206
+ if (this.dirty) this.refreshDaqLinks()
4207
+ for (const l of this.daqLinks) {
4208
+ l.pt = (l.pt + dt * 0.3) % 1
4209
+ l.pulse.position.copy(l.curve.getPoint(l.pt))
4210
+ }
4211
+ }
4212
+ // 薄膜 web:产线设备增删/移动时重建(dirty 门控;免每帧签名字符串分配)
4213
+ if (this.dirty) this.rebuildFilmWeb()
4214
+ // 选中高亮环:跟随选中目标(角色按用户缩放、设备按顶高定半径;慢转活性)
4215
+ if (this.selRing) {
4216
+ const sel = this.selected
4217
+ let sx = 0
4218
+ let sz = 0
4219
+ let sr = 0
4220
+ if (sel?.kind === 'agent') {
4221
+ const a = this.agents.get(sel.id)
4222
+ if (a) {
4223
+ sx = a.root.position.x
4224
+ sz = a.root.position.z
4225
+ sr = 46 * Math.max(0.6, a.model.scale.y)
4226
+ }
4227
+ }
4228
+ else if (sel?.kind === 'device') {
4229
+ const d = this.deviceNodes.get(sel.id)
4230
+ if (d) {
4231
+ sx = d.root.position.x
4232
+ sz = d.root.position.z
4233
+ sr = Math.max(34, this.deviceTopY(d) * 0.42)
4234
+ }
4235
+ }
4236
+ this.selRing.visible = sr > 0
4237
+ if (sr > 0) {
4238
+ this.selRing.position.set(sx, 0.5, sz)
4239
+ this.selRing.scale.setScalar(sr)
4240
+ this.selRing.rotation.z += dt * 0.9
4241
+ }
4242
+ }
4243
+ const anims = this.rafAnims
4244
+ this.rafAnims = []
4245
+ for (const f of anims) f()
4246
+ // 相机:围绕 camTarget 按 dolly 距离摆放(拖拽平移 camTarget,滚轮调 dolly,tween 平移 camTarget);
4247
+ // 基线 1250/760 + FOV55 = 全园区电影化 2.5D 框景;穹顶随镜头平移(无限视野观感)
4248
+ // 指数阻尼(帧率无关:1-e^(-λ·dt);60fps 下与旧每帧系数 0.12/0.08 等效,高刷屏不再加速)
4249
+ const kDolly = 1 - Math.exp(-dt * 7.2)
4250
+ if (this.autoDolly !== null) {
4251
+ this.dolly += (this.autoDolly - this.dolly) * kDolly
4252
+ if (Math.abs(this.autoDolly - this.dolly) < 0.01) this.autoDolly = null
4253
+ }
4254
+ const kView = 1 - Math.exp(-dt * 4.8)
4255
+ for (const k of ['yaw', 'pitch', 'radius'] as const) {
4256
+ this.viewCur[k] += (this.viewTarget[k] - this.viewCur[k]) * kView
4257
+ }
4258
+ // 自动环绕(设计稿 tOrbit):缓转方位角(dt 基 ≈ 24s/圈;指针按住场景时暂停)
4259
+ if (this.autoOrbit && !this.pointerDrag) {
4260
+ this.viewCur.yaw -= dt * 0.156
4261
+ this.viewTarget.yaw = this.viewCur.yaw
4262
+ }
4263
+ const r = this.viewCur.radius * this.dolly
4264
+ const cp = Math.cos(this.viewCur.pitch)
4265
+ this.camera.position.set(
4266
+ this.camTarget.x + r * cp * Math.sin(this.viewCur.yaw),
4267
+ 20 + r * Math.sin(this.viewCur.pitch),
4268
+ this.camTarget.z + r * cp * Math.cos(this.viewCur.yaw),
4269
+ )
4270
+ this.camera.lookAt(this.camTarget.x, 20, this.camTarget.z)
4271
+ // 无级缩放:近/远裁剪面随 dolly 伸缩(贴脸到星野全程不裁剪)
4272
+ const near = Math.max(0.05, 1.2 * this.dolly)
4273
+ const far = 16000 * this.dolly
4274
+ if (Math.abs(this.camera.near - near) > 0.005 || Math.abs(this.camera.far - far) > 1) {
4275
+ this.camera.near = near
4276
+ this.camera.far = far
4277
+ this.camera.updateProjectionMatrix()
4278
+ }
4279
+ if (this.skyDome) {
4280
+ this.skyDome.position.set(this.camTarget.x, 0, this.camTarget.z)
4281
+ // 穹顶随 dolly 缩放:高空拉远时相机始终在穹内,无硬地平线
4282
+ this.skyDome.scale.setScalar(Math.max(1, this.dolly * 1.3))
4283
+ }
4284
+ // 地面跟随镜头滑动(重复纹理 = 无限地面;高空不见地面边缘);
4285
+ // UV 偏移反向补偿平面位移 → 网格钉死世界坐标(否则纹理跟平面一起滑,平移时有"冰面漂移"感)
4286
+ this.ground.position.set(this.camTarget.x, 0, this.camTarget.z)
4287
+ const gmap = (this.ground.material as THREE.MeshStandardMaterial).map
4288
+ if (gmap) {
4289
+ gmap.offset.set(
4290
+ this.camTarget.x / ((WORLD_W * 16) / gmap.repeat.x),
4291
+ -this.camTarget.z / ((WORLD_H * 16) / gmap.repeat.y),
4292
+ )
4293
+ }
4294
+ // 雾距/阴影范围随 dolly 缩放:任意 zoom 层级下取景内容都不被雾吞、投影不消失
4295
+ const fog = this.scene.fog
4296
+ if (fog instanceof THREE.Fog) {
4297
+ fog.near = 1400 * this.dolly
4298
+ fog.far = 8800 * this.dolly
4299
+ }
4300
+ const sc = this.keyLight.shadow.camera
4301
+ const ext = 1600 * Math.max(1, this.dolly)
4302
+ if (Math.abs(sc.right - ext) > 1) {
4303
+ sc.left = -ext
4304
+ sc.right = ext
4305
+ sc.top = 2300 * Math.max(1, this.dolly)
4306
+ sc.bottom = -2300 * Math.max(1, this.dolly)
4307
+ sc.updateProjectionMatrix()
4308
+ this.renderer.shadowMap.needsUpdate = true
4309
+ }
4310
+ // 阴影按需:内容变化(dirty)或有动画角色(mixer 驱动蒙皮位移)才重绘阴影贴图
4311
+ let shadowAnimated = false
4312
+ for (const asp of this.agents.values()) {
4313
+ if (asp.mixer) {
4314
+ shadowAnimated = true
4315
+ break
4316
+ }
4317
+ }
4318
+ this.renderer.shadowMap.needsUpdate = this.dirty || shadowAnimated
4319
+ // 后处理管线出图(RenderPass → Bloom → OutputPass;替代直渲染)
4320
+ this.composer.render(dt)
4321
+ this.dirty = false
4322
+ // FPS
4323
+ this.frameCount += 1
4324
+ this.fpsAccum += dt * 1000
4325
+ if (this.fpsAccum >= 1000) {
4326
+ this.emit('fps', this.frameCount)
4327
+ this.adaptQuality(this.frameCount)
4328
+ this.frameCount = 0
4329
+ this.fpsAccum = 0
4330
+ }
4331
+ }
4332
+ animate()
4333
+ }
4334
+
4335
+ /** 质量阶梯:实测 fps 连续 2s < 17 → 降一档;连续 4s ≥ 27(帧预算附近)→ 升一档。
4336
+ * 档位 = dprScale × 阴影贴图 × Bloom 开关的组合(Bloom+2048² 阴影是两大单项开销,
4337
+ * 重活机器 11fps = 每帧 ~90ms 主线程阻塞)。统一阶梯,避免多套自适应互相打架。 */
4338
+ private static readonly Q_TIERS = [
4339
+ { scale: 1.0, shadow: 2048, bloom: true },
4340
+ { scale: 0.8, shadow: 2048, bloom: true },
4341
+ { scale: 0.65, shadow: 1024, bloom: true },
4342
+ { scale: 0.55, shadow: 1024, bloom: false },
4343
+ ] as const
4344
+
4345
+ private qTier = 0
4346
+
4347
+ private qLowStreak = 0
4348
+
4349
+ private qHighStreak = 0
4350
+
4351
+ /** 画质模式:auto = 质量阶梯自适应;manual 三档由用户固定(渲染配置不再自动变动) */
4352
+ private qualityMode: 'auto' | 'normal' | 'hd' | 'ultra' = 'auto'
4353
+
4354
+ /** 用户画质三档(超清 = WebGL 全配置:满 DPR/2048² 阴影/Bloom/最大各向异性) */
4355
+ private static readonly USER_QUALITY = {
4356
+ ultra: { scale: 1.0, shadow: 2048, bloom: true },
4357
+ hd: { scale: 0.8, shadow: 2048, bloom: true },
4358
+ normal: { scale: 0.6, shadow: 1024, bloom: true },
4359
+ } as const
4360
+
4361
+ setQualityMode(mode: 'auto' | 'normal' | 'hd' | 'ultra'): void {
4362
+ this.qualityMode = mode
4363
+ if (mode === 'auto') {
4364
+ this.applyQuality()
4365
+ return
4366
+ }
4367
+ const q = TownScene3D.USER_QUALITY[mode]
4368
+ this.applyPixelRatio(Math.max(0.5, this.baseDpr * q.scale))
4369
+ const key = this.keyLight
4370
+ if ((key.shadow.mapSize.x ?? 0) !== q.shadow) {
4371
+ key.shadow.mapSize.set(q.shadow, q.shadow)
4372
+ key.shadow.map?.dispose()
4373
+ key.shadow.map = null
4374
+ this.renderer.shadowMap.needsUpdate = true
4375
+ }
4376
+ const bloomPass = this.composer.passes.find(p => p instanceof UnrealBloomPass)
4377
+ if (bloomPass) bloomPass.enabled = q.bloom
4378
+ }
4379
+
4380
+ private adaptQuality(fps: number): void {
4381
+ if (this.qualityMode !== 'auto') return
4382
+ const maxTier = TownScene3D.Q_TIERS.length - 1
4383
+ if (fps < 17 && this.qTier < maxTier) {
4384
+ if (++this.qLowStreak >= 2) {
4385
+ this.qTier++
4386
+ this.applyQuality()
4387
+ this.qLowStreak = 0
4388
+ this.qHighStreak = 0
4389
+ }
4390
+ }
4391
+ else if (fps >= 27 && this.qTier > 0) {
4392
+ if (++this.qHighStreak >= 4) {
4393
+ this.qTier--
4394
+ this.applyQuality()
4395
+ this.qHighStreak = 0
4396
+ this.qLowStreak = 0
4397
+ }
4398
+ }
4399
+ else {
4400
+ this.qLowStreak = 0
4401
+ this.qHighStreak = 0
4402
+ }
4403
+ }
4404
+
4405
+ private applyQuality(): void {
4406
+ const tier = TownScene3D.Q_TIERS[this.qTier]!
4407
+ this.applyPixelRatio(Math.max(0.5, this.baseDpr * tier.scale))
4408
+ // 阴影贴图缩容:置空 map 让 three 按新尺寸重分配(渲染循环按需重绘阴影)
4409
+ const key = this.keyLight
4410
+ if ((key.shadow.mapSize.x ?? 0) !== tier.shadow) {
4411
+ key.shadow.mapSize.set(tier.shadow, tier.shadow)
4412
+ key.shadow.map?.dispose()
4413
+ key.shadow.map = null
4414
+ this.renderer.shadowMap.needsUpdate = true
4415
+ }
4416
+ // Bloom 开关:Pass.enabled=false 跳过该 pass(RenderPass→OutputPass 直通)
4417
+ const bloomPass = this.composer.passes.find(p => p instanceof UnrealBloomPass)
4418
+ if (bloomPass) bloomPass.enabled = tier.bloom
4419
+ }
4420
+
4421
+ private applyPixelRatio(next: number): void {
4422
+ this.renderer.setPixelRatio(next)
4423
+ this.composer.setPixelRatio(next)
4424
+ const w = this.el.clientWidth || 1100
4425
+ const h = this.el.clientHeight || 700
4426
+ this.renderer.setSize(w, h)
4427
+ this.composer.setSize(w, h)
4428
+ }
4429
+
4430
+ // ================================================================
4431
+ // 实例本地资源释放(防长会话 GPU 内存只涨不跌)
4432
+ // ================================================================
4433
+ // 注意:GLB 克隆(clone)与 gltfCache 原件**共享** geometry/material/纹理,
4434
+ // 其 GPU 资源归缓存原件管理 —— 释放路径绝不可碰 GLB 克隆内部,否则同模型
4435
+ // 其他活动实例会被连带摧毁。这里只释放每实例独占的资源:
4436
+ // 名牌/气泡的 CanvasTexture(材质 map)、状态环/链路等自建几何与材质。
4437
+
4438
+ /** 释放对象树内所有 Canvas 纹理(名牌/气泡 sprite 的独占资源) */
4439
+ private disposeCanvasTextures(root: THREE.Object3D | null): void {
4440
+ if (!root) return
4441
+ root.traverse((o) => {
4442
+ const mesh = o as THREE.Mesh
4443
+ const mat = mesh.material as THREE.Material | THREE.Material[] | undefined
4444
+ const mats = Array.isArray(mat) ? mat : mat ? [mat] : []
4445
+ for (const m of mats) {
4446
+ const std = m as THREE.Material & { map?: THREE.Texture | null }
4447
+ if (std.map && std.map.image instanceof HTMLCanvasElement) std.map.dispose()
4448
+ m.dispose()
4449
+ }
4450
+ })
4451
+ }
4452
+
4453
+ /** 释放单个设备节点的实例资源(状态环 + 运行弧 + LED 环 + 名牌) */
4454
+ private disposeDeviceAssets(dev: DeviceNode): void {
4455
+ dev.ring.geometry.dispose()
4456
+ const rmat = dev.ring.material as THREE.Material & { map?: THREE.Texture | null }
4457
+ rmat.dispose()
4458
+ if (dev.arc) {
4459
+ dev.arc.geometry.dispose()
4460
+ ;(dev.arc.material as THREE.Material).dispose()
4461
+ }
4462
+ this.disposeCanvasTextures(dev.label)
4463
+ }
4464
+
4465
+ /** 释放单个 agent 的实例资源(名牌/气泡纹理 + 光环 + 活动范围线几何;GLB 克隆不动) */
4466
+ private disposeAgentAssets(a: { nameSprite?: THREE.Sprite | null, bubble?: THREE.Sprite | null, rangeLine?: THREE.Line | null, aura?: THREE.Group | null }): void {
4467
+ this.disposeCanvasTextures(a.nameSprite ?? null)
4468
+ this.disposeCanvasTextures(a.bubble ?? null)
4469
+ a.rangeLine?.geometry.dispose()
4470
+ if (a.aura) {
4471
+ a.aura.traverse((o) => {
4472
+ const m = o as THREE.Mesh
4473
+ if (m.isMesh) {
4474
+ m.geometry.dispose()
4475
+ ;(m.material as THREE.Material).dispose()
4476
+ }
4477
+ })
4478
+ a.aura = null
4479
+ }
4480
+ }
4481
+
4482
+ /** 重置全部(rebuild 用) */
4483
+ private resetAll(): void {
4484
+ for (const a of this.agents.values()) {
4485
+ this.scene.remove(a.root)
4486
+ if (a.nameSprite) this.scene.remove(a.nameSprite)
4487
+ if (a.bubble) this.scene.remove(a.bubble)
4488
+ if (a.rangeLine) this.scene.remove(a.rangeLine)
4489
+ this.disposeAgentAssets(a)
4490
+ }
4491
+ for (const b of this.blocks.values()) {
4492
+ this.scene.remove(b.platform)
4493
+ this.scene.remove(b.padRing)
4494
+ this.scene.remove(b.beacon)
4495
+ this.scene.remove(b.boundary)
4496
+ this.scene.remove(b.label)
4497
+ this.disposeCanvasTextures(b.label)
4498
+ }
4499
+ for (const dev of this.deviceNodes.values()) {
4500
+ this.scene.remove(dev.root)
4501
+ this.scene.remove(dev.label)
4502
+ this.disposeDeviceAssets(dev)
4503
+ }
4504
+ this.deviceNodes.clear()
4505
+ this.scalables.clear()
4506
+ this.receivers.clear()
4507
+ this.pointerDrag = null
4508
+ this.cancelRangeDraw()
4509
+ this.selected = null
4510
+ this.selectedChannel = null
4511
+ for (const hl of this.resizeHandles) hl.mesh.visible = false
4512
+ for (const hl of this.agentRangeHandles) hl.mesh.visible = false
4513
+ this.agents.clear()
4514
+ this.blocks.clear()
4515
+ }
4516
+
4517
+ /** 销毁(卸载时由 TownView 调用) */
4518
+ dispose(): void {
4519
+ this.disposed = true
4520
+ cancelAnimationFrame(this.raf)
4521
+ delete (globalThis as { __townScene3d?: unknown }).__townScene3d
4522
+ for (const t of this.pendingSaveTimers.values()) clearTimeout(t)
4523
+ this.pendingSaveTimers.clear()
4524
+ this.receivers.clear()
4525
+ this.resizeOb?.disconnect()
4526
+ this.resizeOb = null
4527
+ this.tControls?.dispose()
4528
+ this.tControls = null
4529
+ this.composer?.dispose()
4530
+ this.renderer.dispose()
4531
+ if (this.renderer.domElement.parentElement === this.el) this.renderer.domElement.remove()
4532
+ }
4533
+ }