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.
- package/.AgentWorkShop/prompts/host-tools.json +631 -0
- package/.AgentWorkShop/prompts/lead-supervise.md +42 -0
- package/.AgentWorkShop/prompts/mode-goal.md +26 -0
- package/.AgentWorkShop/prompts/mode-loop.md +10 -0
- package/.AgentWorkShop/prompts/mode-pipeline.md +16 -0
- package/.AgentWorkShop/prompts/peer-lead-roster.md +3 -0
- package/.AgentWorkShop/prompts/peer-message.md +14 -0
- package/.AgentWorkShop/prompts/peer-reply-optional.md +2 -0
- package/.AgentWorkShop/prompts/peer-reply-required.md +8 -0
- package/.AgentWorkShop/prompts/scenario-default.md +11 -0
- package/.AgentWorkShop/prompts/system-manual.md +18 -0
- package/.AgentWorkShop/prompts/team-roster.md +9 -0
- package/.AgentWorkShop/prompts/worker-workflow.md +19 -0
- package/.env.example +34 -0
- package/README-zh.md +424 -0
- package/README.md +424 -0
- package/app/.mimosa/hook-state/sess_05189059-e42d-4188-8911-4f7baabe009b.json +1 -0
- package/app/.mimosa/hook-state/sess_05189059-e42d-4188-8911-4f7baabe009b.mtg0vz68-49792-75b0709555.baseline/1459e6269990a5d229b9228c8b2f62ffaa7d350068380ae8b875fee0db45cca6.source +180 -0
- package/app/.mimosa/hook-state/sess_05189059-e42d-4188-8911-4f7baabe009b.mtg0vz68-49792-75b0709555.baseline/c2991e909c70c5c12b90a407c7adcf63384c51668a2b334e6e047fa675c268fe.source +305 -0
- package/app/.mimosa/hook-state/sess_05189059-e42d-4188-8911-4f7baabe009b.mtg0vz68-49792-75b0709555.baseline/e09129039d1e27e3fac24becc9bdafd2d1a7c8049bff24173df007be865f0165.source +264 -0
- package/app/.mimosa/hook-status/sess_05189059-e42d-4188-8911-4f7baabe009b-28ef7c75ea.json +14 -0
- package/app/app.vue +139 -0
- package/app/assets/css/main.css +1257 -0
- package/app/components/AppHeader.vue +675 -0
- package/app/components/AppSidebar.vue +286 -0
- package/app/components/AwChart.vue +44 -0
- package/app/components/AwLogo.vue +75 -0
- package/app/components/workshop/A2aRpcDebugger.vue +237 -0
- package/app/components/workshop/AgentInspectorDrawer.vue +424 -0
- package/app/components/workshop/AgentLanesView.vue +947 -0
- package/app/components/workshop/ArtifactCard.vue +120 -0
- package/app/components/workshop/AssetLibrary.vue +450 -0
- package/app/components/workshop/ChannelSessionList.vue +593 -0
- package/app/components/workshop/CommandPalette.vue +270 -0
- package/app/components/workshop/Composer.vue +731 -0
- package/app/components/workshop/DeviceTwinPanel.vue +607 -0
- package/app/components/workshop/EventBlock.vue +602 -0
- package/app/components/workshop/FileSelectorModal.vue +188 -0
- package/app/components/workshop/InspectorPanel.vue +265 -0
- package/app/components/workshop/LiveAgentBar.vue +362 -0
- package/app/components/workshop/MemoryPanel.vue +364 -0
- package/app/components/workshop/MentionHoverCard.vue +155 -0
- package/app/components/workshop/MultiChannelView.vue +189 -0
- package/app/components/workshop/PaneSplitter.vue +142 -0
- package/app/components/workshop/StatsPanel.vue +168 -0
- package/app/components/workshop/TaskBoardView.vue +636 -0
- package/app/components/workshop/TaskInspectorDrawer.vue +390 -0
- package/app/components/workshop/TranscriptTimeline.vue +545 -0
- package/app/components/workshop/blocks/ClusterArtifact.vue +77 -0
- package/app/components/workshop/blocks/ClusterError.vue +85 -0
- package/app/components/workshop/blocks/ClusterLife.vue +87 -0
- package/app/components/workshop/blocks/ClusterMember.vue +93 -0
- package/app/components/workshop/blocks/ClusterMemory.vue +84 -0
- package/app/components/workshop/blocks/ClusterOther.vue +67 -0
- package/app/components/workshop/blocks/ClusterRoute.vue +359 -0
- package/app/components/workshop/blocks/ClusterStatus.vue +147 -0
- package/app/components/workshop/blocks/ClusterStream.vue +325 -0
- package/app/components/workshop/blocks/ClusterTask.vue +151 -0
- package/app/components/workshop/blocks/ClusterTool.vue +158 -0
- package/app/components/workshop/lanes/LaneBlocks.vue +149 -0
- package/app/components/workshop/terminal/OmpTerminalPanel.vue +1010 -0
- package/app/components/workshop/town/ModelPreview3D.vue +276 -0
- package/app/components/workshop/town/TownScene.ts +1355 -0
- package/app/components/workshop/town/TownScene3D.ts +4533 -0
- package/app/components/workshop/town/TownView.vue +7071 -0
- package/app/composables/useCodeCopy.ts +30 -0
- package/app/composables/useHttp.ts +8 -0
- package/app/composables/useRouteMeta.ts +50 -0
- package/app/composables/useSiteConfig.ts +21 -0
- package/app/composables/workshop/apiClient.ts +95 -0
- package/app/composables/workshop/useCharacterAssets.ts +292 -0
- package/app/composables/workshop/useClusteredBlocks.ts +150 -0
- package/app/composables/workshop/useComposerBus.ts +16 -0
- package/app/composables/workshop/useDaqStream.ts +379 -0
- package/app/composables/workshop/useDcwStream.ts +300 -0
- package/app/composables/workshop/useDeviceTwins.ts +147 -0
- package/app/composables/workshop/useEventBlocks.ts +662 -0
- package/app/composables/workshop/useLocalTime.ts +33 -0
- package/app/composables/workshop/useOpsLog.ts +164 -0
- package/app/composables/workshop/useSceneLayouts.ts +166 -0
- package/app/composables/workshop/useTownBus.ts +47 -0
- package/app/composables/workshop/useVisibleInterval.ts +42 -0
- package/app/composables/workshop/useWorkshopApi.ts +224 -0
- package/app/composables/workshop/useWorkshopWs.ts +113 -0
- package/app/config/index.ts +66 -0
- package/app/config/schema.ts +68 -0
- package/app/error.vue +76 -0
- package/app/layouts/default.vue +100 -0
- package/app/layouts/town.vue +16 -0
- package/app/pages/daq/[id].vue +796 -0
- package/app/pages/daq/index.vue +2577 -0
- package/app/pages/dcw/[id].vue +2259 -0
- package/app/pages/dcw/index.vue +1079 -0
- package/app/pages/index.vue +830 -0
- package/app/pages/logs/index.vue +621 -0
- package/app/pages/monitor.vue +665 -0
- package/app/pages/settings.vue +757 -0
- package/app/pages/tokens.vue +610 -0
- package/app/pages/town.vue +145 -0
- package/app/pages/users.vue +257 -0
- package/app/pages/workshop/agents.vue +382 -0
- package/app/pages/workshop/channel-templates.vue +456 -0
- package/app/pages/workshop/index.vue +604 -0
- package/app/pages/workshop/teams.vue +448 -0
- package/app/pages/workshop/w/[wsId].vue +516 -0
- package/app/plugins/http.ts +71 -0
- package/app/plugins/locale-restore.client.ts +13 -0
- package/app/plugins/runtime-config.client.ts +65 -0
- package/app/plugins/session-restore.client.ts +24 -0
- package/app/plugins/theme.client.ts +15 -0
- package/app/stores/app.ts +32 -0
- package/app/stores/route-trail.ts +45 -0
- package/app/stores/runtime-config.ts +212 -0
- package/app/stores/workshop/connection.ts +184 -0
- package/app/stores/workshop/entities.ts +350 -0
- package/app/stores/workshop/events.ts +292 -0
- package/app/stores/workshop/user.ts +176 -0
- package/app/stores/workshop/workspaces.ts +133 -0
- package/bin/aw.mjs +13 -0
- package/cli/aw.mjs +184 -0
- package/cli/commands/build.mjs +33 -0
- package/cli/commands/config.mjs +157 -0
- package/cli/commands/dev.mjs +74 -0
- package/cli/commands/doctor.mjs +120 -0
- package/cli/commands/home.mjs +75 -0
- package/cli/commands/init.mjs +150 -0
- package/cli/commands/register.mjs +123 -0
- package/cli/commands/start.mjs +106 -0
- package/cli/commands/status.mjs +67 -0
- package/cli/commands/update.mjs +116 -0
- package/cli/commands/version.mjs +32 -0
- package/cli/core/args.mjs +95 -0
- package/cli/core/context.mjs +264 -0
- package/cli/core/errors.mjs +18 -0
- package/cli/core/help.mjs +68 -0
- package/cli/core/logger.mjs +40 -0
- package/cli/core/registry.mjs +111 -0
- package/config.yml +70 -0
- package/docker-compose.yml +34 -0
- package/docs/cli.md +220 -0
- package/i18n/dicts/_en.json +1097 -0
- package/i18n/dicts/_en2.json +300 -0
- package/i18n/dicts/_en3.json +102 -0
- package/i18n/dicts/_en4.json +115 -0
- package/i18n/dicts/_en5.json +33 -0
- package/i18n/dicts/a2aRpcDebugger.json +10 -0
- package/i18n/dicts/agentInspectorDrawer.json +25 -0
- package/i18n/dicts/agentLanesView.json +57 -0
- package/i18n/dicts/agents.json +27 -0
- package/i18n/dicts/appHeader.json +7 -0
- package/i18n/dicts/appSidebar.json +1 -0
- package/i18n/dicts/artifactCard.json +6 -0
- package/i18n/dicts/assetLibrary.json +18 -0
- package/i18n/dicts/awChart.json +1 -0
- package/i18n/dicts/awLogo.json +1 -0
- package/i18n/dicts/channelSessionList.json +45 -0
- package/i18n/dicts/clusterArtifact.json +4 -0
- package/i18n/dicts/clusterError.json +3 -0
- package/i18n/dicts/clusterLife.json +5 -0
- package/i18n/dicts/clusterMember.json +3 -0
- package/i18n/dicts/clusterMemory.json +3 -0
- package/i18n/dicts/clusterOther.json +3 -0
- package/i18n/dicts/clusterRoute.json +15 -0
- package/i18n/dicts/clusterStatus.json +3 -0
- package/i18n/dicts/clusterStream.json +3 -0
- package/i18n/dicts/clusterTask.json +10 -0
- package/i18n/dicts/clusterTool.json +3 -0
- package/i18n/dicts/commandPalette.json +16 -0
- package/i18n/dicts/composer.json +38 -0
- package/i18n/dicts/ctpl.json +32 -0
- package/i18n/dicts/daq.json +157 -0
- package/i18n/dicts/daqDetail.json +55 -0
- package/i18n/dicts/dcw.json +68 -0
- package/i18n/dicts/dcwDetail.json +199 -0
- package/i18n/dicts/deviceTwinPanel.json +12 -0
- package/i18n/dicts/eventBlock.json +8 -0
- package/i18n/dicts/fileSelectorModal.json +6 -0
- package/i18n/dicts/inspectorPanel.json +12 -0
- package/i18n/dicts/laneBlocks.json +6 -0
- package/i18n/dicts/liveAgentBar.json +8 -0
- package/i18n/dicts/logs.json +39 -0
- package/i18n/dicts/memoryPanel.json +20 -0
- package/i18n/dicts/mentionHoverCard.json +4 -0
- package/i18n/dicts/modelPreview3D.json +1 -0
- package/i18n/dicts/monitor.json +5 -0
- package/i18n/dicts/multiChannelView.json +13 -0
- package/i18n/dicts/ompTerminalPanel.json +7 -0
- package/i18n/dicts/paneSplitter.json +3 -0
- package/i18n/dicts/settings.json +1 -0
- package/i18n/dicts/statsPanel.json +6 -0
- package/i18n/dicts/taskBoardView.json +22 -0
- package/i18n/dicts/taskInspectorDrawer.json +19 -0
- package/i18n/dicts/teams.json +33 -0
- package/i18n/dicts/tokens.json +30 -0
- package/i18n/dicts/town.json +7 -0
- package/i18n/dicts/townView.json +209 -0
- package/i18n/dicts/transcriptTimeline.json +31 -0
- package/i18n/dicts/users.json +15 -0
- package/i18n/dicts/wsHome.json +33 -0
- package/i18n/dicts/wsView.json +15 -0
- package/i18n/locales/en.ts +1737 -0
- package/i18n/locales/zh-CN.ts +1736 -0
- package/nuxt.config.ts +209 -0
- package/package.json +123 -0
- package/public/assets/game/character/bot.png +0 -0
- package/public/assets/game/character/device-3d.glb +0 -0
- package/public/assets/game/character/hero-3d.glb +0 -0
- package/public/assets/game/character/hero-anime-1.glb +0 -0
- package/public/assets/game/character/hero-anime-2.glb +0 -0
- package/public/assets/game/character/hero-anime-3.glb +0 -0
- package/public/assets/game/character/hero-anime-4.glb +0 -0
- package/public/assets/game/character/knight.png +0 -0
- package/public/assets/game/character/mage.png +0 -0
- package/public/assets/game/coin.png +0 -0
- package/public/assets/game/devices/agv.glb +0 -0
- package/public/assets/game/devices/caster.glb +0 -0
- package/public/assets/game/devices/device-console.glb +0 -0
- package/public/assets/game/devices/device-robot-arm.glb +0 -0
- package/public/assets/game/devices/device-scanner.glb +0 -0
- package/public/assets/game/devices/extruder.glb +0 -0
- package/public/assets/game/devices/mdo.glb +0 -0
- package/public/assets/game/devices/power-cabinet.glb +0 -0
- package/public/assets/game/devices/pump.glb +0 -0
- package/public/assets/game/devices/tdo.glb +0 -0
- package/public/assets/game/devices/thickness-scanner.glb +0 -0
- package/public/assets/game/devices/winder.glb +0 -0
- package/public/assets/game/npc/boltnu-sheet.png +0 -0
- package/public/assets/game/npc/fluttaflap-sheet.png +0 -0
- package/public/assets/game/npc/rockitten-sheet.png +0 -0
- package/public/assets/game/npc/tux-sheet.png +0 -0
- package/public/assets/game/player/misa-back-walk.000.png +0 -0
- package/public/assets/game/player/misa-back-walk.001.png +0 -0
- package/public/assets/game/player/misa-back-walk.002.png +0 -0
- package/public/assets/game/player/misa-back-walk.003.png +0 -0
- package/public/assets/game/player/misa-back.png +0 -0
- package/public/assets/game/player/misa-front-walk.000.png +0 -0
- package/public/assets/game/player/misa-front-walk.001.png +0 -0
- package/public/assets/game/player/misa-front-walk.002.png +0 -0
- package/public/assets/game/player/misa-front-walk.003.png +0 -0
- package/public/assets/game/player/misa-front.png +0 -0
- package/public/assets/game/player/misa-left-walk.000.png +0 -0
- package/public/assets/game/player/misa-left-walk.001.png +0 -0
- package/public/assets/game/player/misa-left-walk.002.png +0 -0
- package/public/assets/game/player/misa-left-walk.003.png +0 -0
- package/public/assets/game/player/misa-left.png +0 -0
- package/public/assets/game/player/misa-right-walk.000.png +0 -0
- package/public/assets/game/player/misa-right-walk.001.png +0 -0
- package/public/assets/game/player/misa-right-walk.002.png +0 -0
- package/public/assets/game/player/misa-right-walk.003.png +0 -0
- package/public/assets/game/player/misa-right.png +0 -0
- package/public/assets/game/player-sheet.png +0 -0
- package/public/assets/game/tuxemon-town.json +1 -0
- package/public/assets/game/tuxmon-sample-32px-extruded.png +0 -0
- package/public/assets/game/wuwa/scene-map.png +0 -0
- package/public/assets/game/wuwa/world-far.png +0 -0
- package/public/assets/game/wuwa/world-middle.png +0 -0
- package/public/assets/game/wuwa/world-near.png +0 -0
- package/public/assets/game/wuwa/wu-aura.png +0 -0
- package/public/assets/game/wuwa/wu-lead.png +0 -0
- package/public/assets/game/wuwa/wu-ring.png +0 -0
- package/public/assets/game/wuwa/wu-slash.png +0 -0
- package/public/assets/game/wuwa/wu-worker-0.png +0 -0
- package/public/assets/game/wuwa/wu-worker-1.png +0 -0
- package/public/assets/game/wuwa/wu-worker-2.png +0 -0
- package/public/favicon.svg +14 -0
- package/public/scene/background/cyber-town-background.svg +110 -0
- package/scripts/_daq-realtime-probe.mjs +68 -0
- package/scripts/_daq-warn-probe.mjs +50 -0
- package/scripts/_dbg-agent-tools-audit.mjs +154 -0
- package/scripts/_dbg-agent-workflow-audit.mjs +177 -0
- package/scripts/_dbg-anim-verify.mjs +81 -0
- package/scripts/_dbg-audit-all-pages.mjs +43 -0
- package/scripts/_dbg-autoframe.mjs +103 -0
- package/scripts/_dbg-beacon-final.mjs +181 -0
- package/scripts/_dbg-bind-bidi-e2e.mjs +239 -0
- package/scripts/_dbg-callout-near.mjs +108 -0
- package/scripts/_dbg-chan-focus.mjs +49 -0
- package/scripts/_dbg-cleanup.mjs +31 -0
- package/scripts/_dbg-closedloop-ui-shot.mjs +61 -0
- package/scripts/_dbg-console-audit.mjs +61 -0
- package/scripts/_dbg-console-probe.mjs +23 -0
- package/scripts/_dbg-context-inject-audit.mjs +231 -0
- package/scripts/_dbg-crash-recovery.mjs +143 -0
- package/scripts/_dbg-crop-shot.mjs +16 -0
- package/scripts/_dbg-daq-acc-probe.mjs +24 -0
- package/scripts/_dbg-daq-attrib.mjs +34 -0
- package/scripts/_dbg-daq-bind-audit.mjs +119 -0
- package/scripts/_dbg-daq-cadence.mjs +104 -0
- package/scripts/_dbg-daq-debug.mjs +22 -0
- package/scripts/_dbg-daq-diag.mjs +33 -0
- package/scripts/_dbg-daq-e2e.mjs +171 -0
- package/scripts/_dbg-daq-film.mjs +136 -0
- package/scripts/_dbg-daq-filter-e2e.mjs +109 -0
- package/scripts/_dbg-daq-filter-e2e2.mjs +62 -0
- package/scripts/_dbg-daq-final-regression.mjs +78 -0
- package/scripts/_dbg-daq-full-check.mjs +179 -0
- package/scripts/_dbg-daq-line-note-verify.mjs +61 -0
- package/scripts/_dbg-daq-linebanner-audit.mjs +107 -0
- package/scripts/_dbg-daq-pages-shot.mjs +37 -0
- package/scripts/_dbg-daq-perf.mjs +61 -0
- package/scripts/_dbg-daq-probe.mjs +27 -0
- package/scripts/_dbg-daq-recipe-e2e.mjs +30 -0
- package/scripts/_dbg-daq-redesign-shot.mjs +88 -0
- package/scripts/_dbg-daq-rows-probe.mjs +26 -0
- package/scripts/_dbg-daq-rowstate-e2e.mjs +152 -0
- package/scripts/_dbg-daq-rowstate-shot.mjs +45 -0
- package/scripts/_dbg-daq-rowstate-shot2.mjs +38 -0
- package/scripts/_dbg-daq-tpl.mjs +144 -0
- package/scripts/_dbg-dash-console-check.mjs +49 -0
- package/scripts/_dbg-dash-deep-probe.mjs +61 -0
- package/scripts/_dbg-dash-hitpoint.mjs +44 -0
- package/scripts/_dbg-dash-live-shot.mjs +36 -0
- package/scripts/_dbg-dash-shot.mjs +21 -0
- package/scripts/_dbg-dashboard-shot.mjs +35 -0
- package/scripts/_dbg-dcw-audit.mjs +126 -0
- package/scripts/_dbg-dcw-card-e2e.mjs +108 -0
- package/scripts/_dbg-dcw-channel-ui.mjs +144 -0
- package/scripts/_dbg-dcw-examples.mjs +81 -0
- package/scripts/_dbg-dcw-light-shot.mjs +19 -0
- package/scripts/_dbg-dcw-line-audit.mjs +107 -0
- package/scripts/_dbg-dcw-pause-tpl-audit.mjs +161 -0
- package/scripts/_dbg-dcw-pause-ui.mjs +175 -0
- package/scripts/_dbg-dcw-scene.mjs +72 -0
- package/scripts/_dbg-dcw-shot2.mjs +14 -0
- package/scripts/_dbg-dcw-twin-e2e.mjs +192 -0
- package/scripts/_dbg-dt-interact.mjs +193 -0
- package/scripts/_dbg-error-probe.mjs +54 -0
- package/scripts/_dbg-filter-light-verify.mjs +54 -0
- package/scripts/_dbg-final-acceptance-2.mjs +72 -0
- package/scripts/_dbg-final-acceptance.mjs +192 -0
- package/scripts/_dbg-final-agent-e2e.mjs +151 -0
- package/scripts/_dbg-final-full-e2e.mjs +218 -0
- package/scripts/_dbg-final-line-e2e.mjs +173 -0
- package/scripts/_dbg-final-t6-hitl.mjs +38 -0
- package/scripts/_dbg-focus-range.mjs +115 -0
- package/scripts/_dbg-fresh-scene-e2e.mjs +125 -0
- package/scripts/_dbg-full-feature-e2e.mjs +219 -0
- package/scripts/_dbg-fullchain-e2e.mjs +178 -0
- package/scripts/_dbg-gate-debug.mjs +37 -0
- package/scripts/_dbg-gif-encode.mjs +111 -0
- package/scripts/_dbg-gizmo.mjs +95 -0
- package/scripts/_dbg-glass-probe.mjs +57 -0
- package/scripts/_dbg-glass-shot.mjs +50 -0
- package/scripts/_dbg-glass-sweep.mjs +25 -0
- package/scripts/_dbg-glb-probe.mjs +43 -0
- package/scripts/_dbg-hardening-probe.mjs +90 -0
- package/scripts/_dbg-hero-btn-probe.mjs +21 -0
- package/scripts/_dbg-hidden-check.mjs +21 -0
- package/scripts/_dbg-hitl-timeout-e2e.mjs +93 -0
- package/scripts/_dbg-hmi-audit.mjs +164 -0
- package/scripts/_dbg-home-repro.mjs +24 -0
- package/scripts/_dbg-home-stack.mjs +13 -0
- package/scripts/_dbg-huashu-survey.mjs +31 -0
- package/scripts/_dbg-huashu-survey2.mjs +35 -0
- package/scripts/_dbg-huashu-verify.mjs +25 -0
- package/scripts/_dbg-huashu-verify2.mjs +26 -0
- package/scripts/_dbg-hydration-detail.mjs +18 -0
- package/scripts/_dbg-i18n-debug.mjs +27 -0
- package/scripts/_dbg-i18n-debug2.mjs +26 -0
- package/scripts/_dbg-i18n-final.mjs +42 -0
- package/scripts/_dbg-i18n-scan.mjs +38 -0
- package/scripts/_dbg-infra-lifecycle.mjs +44 -0
- package/scripts/_dbg-line-aura-shots.mjs +54 -0
- package/scripts/_dbg-line-control-e2e.mjs +120 -0
- package/scripts/_dbg-line-gate-audit.mjs +108 -0
- package/scripts/_dbg-line-live-e2e.mjs +144 -0
- package/scripts/_dbg-line-scenario-e2e.mjs +256 -0
- package/scripts/_dbg-minimal-omp.mjs +77 -0
- package/scripts/_dbg-mode-nav.mjs +173 -0
- package/scripts/_dbg-mqtt-chain-e2e.mjs +90 -0
- package/scripts/_dbg-nav-rpg.mjs +84 -0
- package/scripts/_dbg-nav-shot.mjs +28 -0
- package/scripts/_dbg-nav.mjs +27 -0
- package/scripts/_dbg-opslog-e2e.mjs +121 -0
- package/scripts/_dbg-opslog-shots.mjs +62 -0
- package/scripts/_dbg-opt-team-e2e.mjs +133 -0
- package/scripts/_dbg-optimization-e2e.mjs +94 -0
- package/scripts/_dbg-orbit.mjs +58 -0
- package/scripts/_dbg-panesplitter-fix-check.mjs +62 -0
- package/scripts/_dbg-phase-e3-monitor.mjs +25 -0
- package/scripts/_dbg-pop-clamp-verify.mjs +77 -0
- package/scripts/_dbg-preset-e2e.mjs +116 -0
- package/scripts/_dbg-preview-console.mjs +28 -0
- package/scripts/_dbg-preview-minrepro.mjs +156 -0
- package/scripts/_dbg-preview-pixels.mjs +93 -0
- package/scripts/_dbg-quality-pages-e2e.mjs +84 -0
- package/scripts/_dbg-query-cancel.ts +12 -0
- package/scripts/_dbg-rail-tree-e2e.mjs +183 -0
- package/scripts/_dbg-real-driver-e2e.mjs +55 -0
- package/scripts/_dbg-redesign-shots.mjs +34 -0
- package/scripts/_dbg-restart-persist.mjs +41 -0
- package/scripts/_dbg-rollback-mgr-audit.mjs +169 -0
- package/scripts/_dbg-rpg.mjs +71 -0
- package/scripts/_dbg-sc001-dispatch.mjs +63 -0
- package/scripts/_dbg-scenario-final-shot.mjs +24 -0
- package/scripts/_dbg-scenario-phase-e2.mjs +75 -0
- package/scripts/_dbg-scenario-phase-e3.mjs +96 -0
- package/scripts/_dbg-scenario-phase1.mjs +90 -0
- package/scripts/_dbg-scenario-phase2.mjs +105 -0
- package/scripts/_dbg-scenario-phase3-monitor.mjs +48 -0
- package/scripts/_dbg-scenario-phase3.mjs +111 -0
- package/scripts/_dbg-scene-polish-shot.mjs +118 -0
- package/scripts/_dbg-selring-probe.mjs +47 -0
- package/scripts/_dbg-sideinfo.mjs +101 -0
- package/scripts/_dbg-splitter.mjs +88 -0
- package/scripts/_dbg-team-repro.mjs +76 -0
- package/scripts/_dbg-town-alarm-e2e.mjs +40 -0
- package/scripts/_dbg-town-beauty-shots.mjs +54 -0
- package/scripts/_dbg-town-daq-shot.mjs +26 -0
- package/scripts/_dbg-town-full-e2e.mjs +104 -0
- package/scripts/_dbg-town-gate.mjs +44 -0
- package/scripts/_dbg-town-ins-probe.mjs +37 -0
- package/scripts/_dbg-town-perf.mjs +50 -0
- package/scripts/_dbg-town-polish-shots.mjs +82 -0
- package/scripts/_dbg-town-realtime.mjs +42 -0
- package/scripts/_dbg-town-shot3.mjs +17 -0
- package/scripts/_dbg-town-truth.mjs +37 -0
- package/scripts/_dbg-town-truth2.mjs +27 -0
- package/scripts/_dbg-transform-audit.mjs +98 -0
- package/scripts/_dbg-trend-expand-test.mjs +38 -0
- package/scripts/_dbg-twindaq-focus.mjs +81 -0
- package/scripts/_dbg-ui-polish-shot.mjs +43 -0
- package/scripts/_dbg-ui-polish-verify1.mjs +92 -0
- package/scripts/_dbg-ui-polish-verify2.mjs +50 -0
- package/scripts/_dbg-ui-polish-verify3.mjs +51 -0
- package/scripts/_dbg-ui-polish-verify4.mjs +50 -0
- package/scripts/_dbg-ui-shot.mjs +26 -0
- package/scripts/_dbg-unify-shot.mjs +43 -0
- package/scripts/_dbg-uplink-probe.mjs +36 -0
- package/scripts/_dbg-wizard-preview-shot.mjs +58 -0
- package/scripts/_dbg-wizard-ui-e2e.mjs +93 -0
- package/scripts/_dbg-ws-probe.mjs +86 -0
- package/scripts/_dbg-ws-shot2.mjs +23 -0
- package/scripts/_dbg-wsdot-sanity.mjs +25 -0
- package/scripts/_dev-stack.mjs +46 -0
- package/scripts/_http-driver-probe.mjs +30 -0
- package/scripts/_lib-dcw-line.mjs +24 -0
- package/scripts/_mqtt-driver-probe.mjs +30 -0
- package/scripts/_newdriver-rest-probe.mjs +100 -0
- package/scripts/_node-state-probe.mjs +5 -0
- package/scripts/_opcua-driver-probe.mjs +31 -0
- package/scripts/_rtu-debug.mjs +41 -0
- package/scripts/_rtu-direct.mjs +6 -0
- package/scripts/_rtu-driver-probe.mjs +53 -0
- package/scripts/_rtu-mini-slave.mjs +63 -0
- package/scripts/_townview_style.css +1185 -0
- package/scripts/_townview_template.html +1215 -0
- package/scripts/_wizard-shots.mjs +90 -0
- package/scripts/acceptance-suite.py +207 -0
- package/scripts/api-live-e2e.mjs +467 -0
- package/scripts/build-anime-models.mjs +339 -0
- package/scripts/build-character-models.mjs +175 -0
- package/scripts/build-film-devices.mjs +227 -0
- package/scripts/build-model-3d.mjs +173 -0
- package/scripts/build-player-sheet.mjs +58 -0
- package/scripts/build-world-map.mjs +188 -0
- package/scripts/build-wuwa-assets.mjs +345 -0
- package/scripts/collab-live-test.py +177 -0
- package/scripts/debug-mcp-http.ts +64 -0
- package/scripts/demo-lead-worker.ts +256 -0
- package/scripts/demo-modes.ts +415 -0
- package/scripts/demo-omp-channel.ts +135 -0
- package/scripts/design-preflight.mjs +223 -0
- package/scripts/design-verify-templates.mjs +110 -0
- package/scripts/design-verify.mjs +170 -0
- package/scripts/dev-guard.mjs +74 -0
- package/scripts/dev-modbus-simulator.mjs +55 -0
- package/scripts/dev-opcua-simulator.mjs +57 -0
- package/scripts/dev-protocol-simulators.mjs +145 -0
- package/scripts/dev-window.cmd +6 -0
- package/scripts/device-line-restore.mjs +34 -0
- package/scripts/diag-lead-status.ts +101 -0
- package/scripts/e2e-a2a-steer-live.mjs +193 -0
- package/scripts/e2e-agent-channel-task.ts +151 -0
- package/scripts/e2e-agent-team.mjs +168 -0
- package/scripts/e2e-auth-matrix.mjs +119 -0
- package/scripts/e2e-channel-isolation.ts +325 -0
- package/scripts/e2e-dual-drive-comm.ts +259 -0
- package/scripts/e2e-lead-team-mgmt.ts +217 -0
- package/scripts/e2e-memory-persistence.ts +320 -0
- package/scripts/e2e-memory-system.ts +332 -0
- package/scripts/e2e-omp-modes.ts +274 -0
- package/scripts/e2e-omp-realtime.ts +258 -0
- package/scripts/e2e-omp-workspace.ts +180 -0
- package/scripts/e2e-parallel-execution.mjs +151 -0
- package/scripts/e2e-rest-memory.mjs +251 -0
- package/scripts/e2e-rest-robustness.mjs +177 -0
- package/scripts/e2e-resume-crash.mjs +254 -0
- package/scripts/e2e-tab-close.mjs +136 -0
- package/scripts/e2e-task-queue.ts +277 -0
- package/scripts/e2e-template-isolation.mjs +267 -0
- package/scripts/e2e-token-ui.mjs +149 -0
- package/scripts/final-soup-run.py +192 -0
- package/scripts/final-soup-watch.py +83 -0
- package/scripts/home-bootstrap.mjs +117 -0
- package/scripts/i18n-codemod.mjs +173 -0
- package/scripts/i18n-fix-en.py +28 -0
- package/scripts/i18n-fix-en2.py +31 -0
- package/scripts/i18n-merge.mjs +67 -0
- package/scripts/omp-real-test.py +328 -0
- package/scripts/smoke-optimization-verify.ts +72 -0
- package/scripts/start.mjs +60 -0
- package/scripts/test-a2a-relay.mjs +222 -0
- package/scripts/test-a2a-types.ts +296 -0
- package/scripts/test-agent-brain.ts +135 -0
- package/scripts/test-agent-multi-channel.ts +160 -0
- package/scripts/test-agent-runtime.ts +348 -0
- package/scripts/test-api-client.ts +108 -0
- package/scripts/test-api-suite.mjs +271 -0
- package/scripts/test-block-consumption.mjs +194 -0
- package/scripts/test-channel-routing.ts +301 -0
- package/scripts/test-cmd-render.mjs +218 -0
- package/scripts/test-collab-e2e.mjs +136 -0
- package/scripts/test-collab-omp.mjs +192 -0
- package/scripts/test-dedup-logic.ts +132 -0
- package/scripts/test-dual-drive.ts +227 -0
- package/scripts/test-e2e-collaboration.ts +262 -0
- package/scripts/test-e2e-monitor.ts +192 -0
- package/scripts/test-event-history.ts +112 -0
- package/scripts/test-event-persistence.mjs +169 -0
- package/scripts/test-exec-modes.ts +316 -0
- package/scripts/test-fifo-realtime.mjs +193 -0
- package/scripts/test-final-e2e.mjs +290 -0
- package/scripts/test-full-system.ts +439 -0
- package/scripts/test-game-session.ts +160 -0
- package/scripts/test-goal-summary.ts +202 -0
- package/scripts/test-harness-liveness.ts +109 -0
- package/scripts/test-harness-terminal.ts +164 -0
- package/scripts/test-header-trail.mjs +95 -0
- package/scripts/test-hitl.ts +360 -0
- package/scripts/test-koda-restyle.mjs +105 -0
- package/scripts/test-lane-history-live.mjs +103 -0
- package/scripts/test-lane-raw.ts +67 -0
- package/scripts/test-lanes-history-render.mjs +138 -0
- package/scripts/test-loop-omp.mjs +154 -0
- package/scripts/test-mcp-tools.ts +285 -0
- package/scripts/test-mdlite.ts +53 -0
- package/scripts/test-memory-dynamic.ts +99 -0
- package/scripts/test-memory-maintenance.ts +117 -0
- package/scripts/test-memory-vector.ts +154 -0
- package/scripts/test-memory.ts +431 -0
- package/scripts/test-mock-collab.mjs +226 -0
- package/scripts/test-modern-render.mjs +174 -0
- package/scripts/test-omp-terminal.mjs +187 -0
- package/scripts/test-orchestration.ts +425 -0
- package/scripts/test-persistence-lazy.ts +323 -0
- package/scripts/test-prompt-system.mjs +181 -0
- package/scripts/test-prompts-loader.ts +56 -0
- package/scripts/test-protocol.ts +156 -0
- package/scripts/test-real-scenario.mjs +290 -0
- package/scripts/test-route-reason.mjs +111 -0
- package/scripts/test-scheduler-loop.ts +469 -0
- package/scripts/test-stream-render.ts +37 -0
- package/scripts/test-task-drawer.mjs +204 -0
- package/scripts/test-task-engine.ts +433 -0
- package/scripts/test-task-invariants.mjs +169 -0
- package/scripts/test-terminal-e2e.mjs +264 -0
- package/scripts/test-town-3d.mjs +121 -0
- package/scripts/test-town-drag.mjs +218 -0
- package/scripts/test-town-layout.mjs +200 -0
- package/scripts/test-town-page.mjs +136 -0
- package/scripts/test-town-scene-math.ts +169 -0
- package/scripts/test-town-twin.mjs +121 -0
- package/scripts/test-town-upload.mjs +89 -0
- package/scripts/test-town-user-flow.mjs +619 -0
- package/scripts/test-town-world.mjs +76 -0
- package/scripts/test-town-wuwa.mjs +198 -0
- package/scripts/test-user-isolation.mjs +193 -0
- package/scripts/test-workshop-db.ts +280 -0
- package/scripts/test-workshop-entries.ts +370 -0
- package/scripts/test-workspace-api.ts +329 -0
- package/scripts/test-ws-aep.mjs +187 -0
- package/scripts/test-ws-protocol.ts +134 -0
- package/scripts/test-ws-stress.mjs +208 -0
- package/scripts/town-demo-seed.mjs +75 -0
- package/scripts/town-scene-clear.mjs +56 -0
- package/scripts/ui-screenshot.mjs +128 -0
- package/scripts/verify-a2a-live.mjs +124 -0
- package/scripts/verify-exec-modes-report.ts +248 -0
- package/scripts/verify-game.mjs +477 -0
- package/scripts/verify-lead-mail.ts +188 -0
- package/scripts/verify-page-smoke.mjs +87 -0
- package/scripts/verify-ws-events.mjs +123 -0
- package/scripts/ws-test.mjs +176 -0
- package/scripts/ws-test.py +4 -0
- package/server/api/health.get.ts +32 -0
- package/server/api/mcp/workshop.ts +78 -0
- package/server/api/metrics.ts +75 -0
- package/server/api/system/config.get.ts +12 -0
- package/server/api/system/monitor/terminal/ws.ts +177 -0
- package/server/api/system/monitor/terminate.post.ts +36 -0
- package/server/api/system/monitor.get.ts +13 -0
- package/server/api/system/settings/events.get.ts +28 -0
- package/server/api/system/settings/index.get.ts +14 -0
- package/server/api/system/settings/index.patch.ts +22 -0
- package/server/api/system/settings/reset.post.ts +12 -0
- package/server/api/users/[id].delete.ts +11 -0
- package/server/api/users/[id].get.ts +11 -0
- package/server/api/users/[id].put.ts +14 -0
- package/server/api/users/index.get.ts +13 -0
- package/server/api/users/index.post.ts +13 -0
- package/server/api/users/login.post.ts +11 -0
- package/server/api/users/logout.post.ts +9 -0
- package/server/api/users/me.get.ts +9 -0
- package/server/api/users/register.post.ts +11 -0
- package/server/api/users/tokens/[id]/token.get.ts +11 -0
- package/server/api/users/tokens/[id].delete.ts +11 -0
- package/server/api/users/tokens/[id].patch.ts +14 -0
- package/server/api/users/tokens/index.get.ts +9 -0
- package/server/api/users/tokens/index.post.ts +13 -0
- package/server/api/workshop/[...path].ts +13 -0
- package/server/api/workshop/a2a/[agentId]/card/index.get.ts +75 -0
- package/server/api/workshop/a2a/[agentId]/rpc/index.post.ts +429 -0
- package/server/api/workshop/a2a/send.post.ts +28 -0
- package/server/api/workshop/agent-tools/approvals/[id]/decide.post.ts +21 -0
- package/server/api/workshop/agent-tools/approvals/index.get.ts +18 -0
- package/server/api/workshop/agent-tools/bindings/[id].delete.ts +21 -0
- package/server/api/workshop/agent-tools/bindings/[id].patch.ts +14 -0
- package/server/api/workshop/agent-tools/bindings/index.get.ts +14 -0
- package/server/api/workshop/agent-tools/bindings/index.post.ts +20 -0
- package/server/api/workshop/agent-tools/invoke.post.ts +34 -0
- package/server/api/workshop/agents/[id].delete.ts +20 -0
- package/server/api/workshop/agents/[id].get.ts +19 -0
- package/server/api/workshop/agents/[id].patch.ts +30 -0
- package/server/api/workshop/agents/index.get.ts +13 -0
- package/server/api/workshop/agents/index.post.ts +29 -0
- package/server/api/workshop/agents/subscribe.post.ts +21 -0
- package/server/api/workshop/approvals/[id]/decide.post.ts +30 -0
- package/server/api/workshop/approvals/index.get.ts +22 -0
- package/server/api/workshop/assets/character/[id].delete.ts +33 -0
- package/server/api/workshop/assets/character/index.get.ts +17 -0
- package/server/api/workshop/assets/character/index.post.ts +87 -0
- package/server/api/workshop/assets/devices/[id].delete.ts +43 -0
- package/server/api/workshop/assets/devices/index.get.ts +103 -0
- package/server/api/workshop/assets/devices/index.post.ts +53 -0
- package/server/api/workshop/audit.get.ts +43 -0
- package/server/api/workshop/caller.ts +141 -0
- package/server/api/workshop/channel-templates/[id]/instantiate.post.ts +31 -0
- package/server/api/workshop/channel-templates/[id].delete.ts +20 -0
- package/server/api/workshop/channel-templates/[id].get.ts +19 -0
- package/server/api/workshop/channel-templates/[id].patch.ts +48 -0
- package/server/api/workshop/channel-templates/from-channel.post.ts +31 -0
- package/server/api/workshop/channel-templates/index.get.ts +13 -0
- package/server/api/workshop/channel-templates/index.post.ts +61 -0
- package/server/api/workshop/channels/[id]/activate.post.ts +19 -0
- package/server/api/workshop/channels/[id]/agents/[agentId]/memories/[memoryId].delete.ts +30 -0
- package/server/api/workshop/channels/[id]/agents/[agentId]/memories/index.get.ts +29 -0
- package/server/api/workshop/channels/[id]/agents/[agentId]/memories/index.post.ts +48 -0
- package/server/api/workshop/channels/[id]/agents/[agentId]/memories/search.post.ts +43 -0
- package/server/api/workshop/channels/[id]/agents/[agentId]/model.patch.ts +39 -0
- package/server/api/workshop/channels/[id]/agents/[agentId]/position.patch.ts +33 -0
- package/server/api/workshop/channels/[id]/agents/[agentId]/range.patch.ts +54 -0
- package/server/api/workshop/channels/[id]/agents/[agentId]/stop.post.ts +25 -0
- package/server/api/workshop/channels/[id]/agents/[agentId].delete.ts +23 -0
- package/server/api/workshop/channels/[id]/agents/[agentId].patch.ts +28 -0
- package/server/api/workshop/channels/[id]/agents/index.get.ts +27 -0
- package/server/api/workshop/channels/[id]/agents/index.post.ts +61 -0
- package/server/api/workshop/channels/[id]/events.get.ts +55 -0
- package/server/api/workshop/channels/[id]/index.get.ts +18 -0
- package/server/api/workshop/channels/[id]/index.patch.ts +30 -0
- package/server/api/workshop/channels/[id]/memories/[memoryId].delete.ts +29 -0
- package/server/api/workshop/channels/[id]/memories/index.get.ts +28 -0
- package/server/api/workshop/channels/[id]/memories/index.post.ts +42 -0
- package/server/api/workshop/channels/[id]/messages/index.get.ts +56 -0
- package/server/api/workshop/channels/[id]/messages/index.post.ts +75 -0
- package/server/api/workshop/channels/[id]/queue.get.ts +25 -0
- package/server/api/workshop/channels/[id]/tasks/index.get.ts +24 -0
- package/server/api/workshop/channels/[id]/tasks/index.post.ts +79 -0
- package/server/api/workshop/channels/[id]/terminals.get.ts +21 -0
- package/server/api/workshop/channels/[id].delete.ts +18 -0
- package/server/api/workshop/channels.get.ts +11 -0
- package/server/api/workshop/channels.post.ts +48 -0
- package/server/api/workshop/daq/[id]/bind.post.ts +19 -0
- package/server/api/workshop/daq/[id]/samples.get.ts +22 -0
- package/server/api/workshop/daq/[id]/test.post.ts +17 -0
- package/server/api/workshop/daq/[id].delete.ts +30 -0
- package/server/api/workshop/daq/[id].patch.ts +31 -0
- package/server/api/workshop/daq/alarms/[id]/ack.post.ts +15 -0
- package/server/api/workshop/daq/alarms.get.ts +16 -0
- package/server/api/workshop/daq/controller.post.ts +52 -0
- package/server/api/workshop/daq/index.get.ts +40 -0
- package/server/api/workshop/daq/index.post.ts +33 -0
- package/server/api/workshop/daq/infra/reconnect.post.ts +17 -0
- package/server/api/workshop/daq/infra.get.ts +11 -0
- package/server/api/workshop/daq/templates/[key].delete.ts +17 -0
- package/server/api/workshop/daq/templates/[key].patch.ts +18 -0
- package/server/api/workshop/daq/templates/index.post.ts +17 -0
- package/server/api/workshop/daq/test-driver.post.ts +23 -0
- package/server/api/workshop/dcw/[id]/bind.post.ts +17 -0
- package/server/api/workshop/dcw/[id]/param-ledger.get.ts +15 -0
- package/server/api/workshop/dcw/[id]/test.post.ts +15 -0
- package/server/api/workshop/dcw/[id]/write.post.ts +19 -0
- package/server/api/workshop/dcw/[id].delete.ts +30 -0
- package/server/api/workshop/dcw/[id].patch.ts +18 -0
- package/server/api/workshop/dcw/controller.post.ts +43 -0
- package/server/api/workshop/dcw/index.get.ts +25 -0
- package/server/api/workshop/dcw/index.post.ts +20 -0
- package/server/api/workshop/dcw/journal/index.get.ts +21 -0
- package/server/api/workshop/dcw/journal/node/[nodeId]/rollback.post.ts +22 -0
- package/server/api/workshop/dcw/line/query.get.ts +31 -0
- package/server/api/workshop/dcw/lines/[id]/start.post.ts +35 -0
- package/server/api/workshop/dcw/lines/[id]/stop.post.ts +34 -0
- package/server/api/workshop/dcw/lines/[id].delete.ts +35 -0
- package/server/api/workshop/dcw/lines/[id].patch.ts +15 -0
- package/server/api/workshop/dcw/lines/index.get.ts +14 -0
- package/server/api/workshop/dcw/lines/index.post.ts +31 -0
- package/server/api/workshop/dcw/optimizations/[id]/judge.post.ts +24 -0
- package/server/api/workshop/dcw/optimizations/[id]/rollback.post.ts +29 -0
- package/server/api/workshop/dcw/optimizations/[id]/series.get.ts +18 -0
- package/server/api/workshop/dcw/optimizations/[id].get.ts +17 -0
- package/server/api/workshop/dcw/optimizations/index.get.ts +23 -0
- package/server/api/workshop/dcw/products/[id].delete.ts +16 -0
- package/server/api/workshop/dcw/products/[id].patch.ts +17 -0
- package/server/api/workshop/dcw/products/index.post.ts +16 -0
- package/server/api/workshop/dcw/recipes/[id]/apply.post.ts +40 -0
- package/server/api/workshop/dcw/recipes/[id]/mark-good.post.ts +21 -0
- package/server/api/workshop/dcw/recipes/[id]/rollback-good.post.ts +19 -0
- package/server/api/workshop/dcw/recipes/[id].delete.ts +14 -0
- package/server/api/workshop/dcw/recipes/[id].patch.ts +16 -0
- package/server/api/workshop/dcw/recipes/index.get.ts +12 -0
- package/server/api/workshop/dcw/recipes/index.post.ts +15 -0
- package/server/api/workshop/dcw/runs/[id]/close.post.ts +15 -0
- package/server/api/workshop/dcw/runs/[id]/data.get.ts +15 -0
- package/server/api/workshop/dcw/runs/[id]/rollback.post.ts +20 -0
- package/server/api/workshop/dcw/runs/index.get.ts +11 -0
- package/server/api/workshop/dcw/templates/[key].delete.ts +14 -0
- package/server/api/workshop/dcw/templates/[key].patch.ts +16 -0
- package/server/api/workshop/dcw/templates/index.post.ts +15 -0
- package/server/api/workshop/dcw/test-driver.post.ts +16 -0
- package/server/api/workshop/device-twins/[id]/control.post.ts +21 -0
- package/server/api/workshop/device-twins/[id]/telemetry.post.ts +21 -0
- package/server/api/workshop/device-twins/[id].delete.ts +25 -0
- package/server/api/workshop/device-twins/[id].patch.ts +29 -0
- package/server/api/workshop/device-twins/index.get.ts +16 -0
- package/server/api/workshop/device-twins/index.post.ts +32 -0
- package/server/api/workshop/fs/dirs.get.ts +40 -0
- package/server/api/workshop/mailbox/all.get.ts +19 -0
- package/server/api/workshop/mailbox.get.ts +14 -0
- package/server/api/workshop/memories/maintenance.post.ts +15 -0
- package/server/api/workshop/ops-logs/index.get.ts +30 -0
- package/server/api/workshop/ops-logs/index.post.ts +39 -0
- package/server/api/workshop/runtime.get.ts +11 -0
- package/server/api/workshop/scene/layouts/[channelId].delete.ts +23 -0
- package/server/api/workshop/scene/layouts/[channelId].put.ts +31 -0
- package/server/api/workshop/scene/layouts.get.ts +13 -0
- package/server/api/workshop/tasks/[id]/cancel/index.post.ts +34 -0
- package/server/api/workshop/tasks/[id]/complete.post.ts +32 -0
- package/server/api/workshop/tasks/[id]/dispatch.post.ts +30 -0
- package/server/api/workshop/tasks/[id]/report.post.ts +38 -0
- package/server/api/workshop/tasks/[id]/retry.post.ts +33 -0
- package/server/api/workshop/tasks/[id].get.ts +32 -0
- package/server/api/workshop/tasks.get.ts +13 -0
- package/server/api/workshop/teams/[id]/deploy.post.ts +40 -0
- package/server/api/workshop/teams/[id]/members/[agentId].delete.ts +19 -0
- package/server/api/workshop/teams/[id]/members/index.get.ts +19 -0
- package/server/api/workshop/teams/[id]/members/index.post.ts +32 -0
- package/server/api/workshop/teams/[id].delete.ts +20 -0
- package/server/api/workshop/teams/[id].get.ts +19 -0
- package/server/api/workshop/teams/[id].patch.ts +32 -0
- package/server/api/workshop/teams/index.get.ts +13 -0
- package/server/api/workshop/teams/index.post.ts +27 -0
- package/server/api/workshop/users/me.get.ts +23 -0
- package/server/api/workshop/users/register.post.ts +33 -0
- package/server/api/workshop/workspaces/[id]/channel-templates/[tplId].post.ts +31 -0
- package/server/api/workshop/workspaces/[id]/channels/[channelId].delete.ts +15 -0
- package/server/api/workshop/workspaces/[id]/channels/[channelId].post.ts +15 -0
- package/server/api/workshop/workspaces/[id]/index.delete.ts +14 -0
- package/server/api/workshop/workspaces/index.get.ts +11 -0
- package/server/api/workshop/workspaces/index.post.ts +17 -0
- package/server/api/workshop/ws.ts +694 -0
- package/server/data/agent-node-bindings.json +554 -0
- package/server/data/daq-templates.json +16 -0
- package/server/data/daqs.json +2680 -0
- package/server/data/dcw-lines.json +919 -0
- package/server/data/dcw-products.json +835 -0
- package/server/data/dcw-recipes.json +1650 -0
- package/server/data/dcw-rollback.json +9339 -0
- package/server/data/dcw-runs.json +4633 -0
- package/server/data/dcw-templates.json +26 -0
- package/server/data/dcw-writes.json +9770 -0
- package/server/data/dcws.json +1736 -0
- package/server/data/device-twins.json +133 -0
- package/server/data/line-runs.json +152 -0
- package/server/data/scene-layouts.json +200 -0
- package/server/mcp/workshop-server.ts +510 -0
- package/server/middleware/request-log.ts +15 -0
- package/server/plugins/backup.ts +85 -0
- package/server/plugins/daq.ts +65 -0
- package/server/plugins/dev-stability-guard.ts +42 -0
- package/server/plugins/security.ts +29 -0
- package/server/plugins/system-config.ts +38 -0
- package/server/plugins/workshop.ts +145 -0
- package/server/repositories/user.repository.ts +391 -0
- package/server/schemas/user.schema.ts +67 -0
- package/server/services/system-config.ts +356 -0
- package/server/services/user.service.ts +186 -0
- package/server/services/workshop/agents/adapters/omp-rpc-client.ts +539 -0
- package/server/services/workshop/agents/adapters/to-a2a.ts +40 -0
- package/server/services/workshop/agents/agent-interface.ts +219 -0
- package/server/services/workshop/agents/claude-agent.ts +47 -0
- package/server/services/workshop/agents/factory.ts +24 -0
- package/server/services/workshop/agents/harness-process.ts +158 -0
- package/server/services/workshop/agents/harness-terminal.ts +545 -0
- package/server/services/workshop/agents/industrial-context.ts +222 -0
- package/server/services/workshop/agents/industrial-tools.ts +362 -0
- package/server/services/workshop/agents/mock-agent.ts +396 -0
- package/server/services/workshop/agents/node-bindings.repo.ts +165 -0
- package/server/services/workshop/agents/omp-agent.ts +1598 -0
- package/server/services/workshop/agents/tool-approvals.ts +174 -0
- package/server/services/workshop/assets/character-asset.repo.ts +120 -0
- package/server/services/workshop/assets/device-twin.repo.ts +236 -0
- package/server/services/workshop/daq/alarm-notify.ts +100 -0
- package/server/services/workshop/daq/bus/index.ts +46 -0
- package/server/services/workshop/daq/bus/inproc.adapter.ts +62 -0
- package/server/services/workshop/daq/bus/mqtt.adapter.ts +112 -0
- package/server/services/workshop/daq/bus/queue-port.ts +36 -0
- package/server/services/workshop/daq/daq-controller.ts +778 -0
- package/server/services/workshop/daq/daq-node.repo.ts +96 -0
- package/server/services/workshop/daq/daq-node.ts +247 -0
- package/server/services/workshop/daq/daq-runtime.ts +168 -0
- package/server/services/workshop/daq/daq-templates.ts +157 -0
- package/server/services/workshop/daq/drivers.ts +771 -0
- package/server/services/workshop/daq/host-bindings.ts +71 -0
- package/server/services/workshop/daq/host-ports.ts +64 -0
- package/server/services/workshop/daq/infra.ts +202 -0
- package/server/services/workshop/daq/storage/index.ts +96 -0
- package/server/services/workshop/daq/storage/sqlite.adapter.ts +205 -0
- package/server/services/workshop/daq/storage/timescale.adapter.ts +185 -0
- package/server/services/workshop/daq/storage/tsdb-port.ts +74 -0
- package/server/services/workshop/db/agent.repo.ts +105 -0
- package/server/services/workshop/db/channel-agent.repo.ts +119 -0
- package/server/services/workshop/db/channel-event.repo.ts +154 -0
- package/server/services/workshop/db/channel-template.repo.ts +113 -0
- package/server/services/workshop/db/channel.repo.ts +101 -0
- package/server/services/workshop/db/database.ts +934 -0
- package/server/services/workshop/db/memory.repo.ts +249 -0
- package/server/services/workshop/db/message.repo.ts +149 -0
- package/server/services/workshop/db/ops.repo.ts +323 -0
- package/server/services/workshop/db/schema.sql +143 -0
- package/server/services/workshop/db/subscription.repo.ts +62 -0
- package/server/services/workshop/db/task.repo.ts +175 -0
- package/server/services/workshop/db/team-member.repo.ts +70 -0
- package/server/services/workshop/db/team.repo.ts +109 -0
- package/server/services/workshop/db/user.repo.ts +100 -0
- package/server/services/workshop/dcw/dcw-controller.ts +841 -0
- package/server/services/workshop/dcw/dcw-line.repo.ts +86 -0
- package/server/services/workshop/dcw/dcw-node.repo.ts +89 -0
- package/server/services/workshop/dcw/dcw-node.ts +200 -0
- package/server/services/workshop/dcw/dcw-product.repo.ts +95 -0
- package/server/services/workshop/dcw/dcw-recipe.repo.ts +349 -0
- package/server/services/workshop/dcw/dcw-runtime.ts +82 -0
- package/server/services/workshop/dcw/dcw-templates.ts +138 -0
- package/server/services/workshop/dcw/drivers.ts +523 -0
- package/server/services/workshop/dcw/line-run.ts +101 -0
- package/server/services/workshop/dcw/recipe-rollback-manager.ts +702 -0
- package/server/services/workshop/dcw/recipe-rollback.repo.ts +197 -0
- package/server/services/workshop/logger.ts +58 -0
- package/server/services/workshop/ops/ops.ts +78 -0
- package/server/services/workshop/prompts/loader.ts +112 -0
- package/server/services/workshop/runtime/agent-runtime.ts +698 -0
- package/server/services/workshop/runtime/channel-runtime.ts +202 -0
- package/server/services/workshop/runtime/embedding-provider.ts +79 -0
- package/server/services/workshop/runtime/execution-mode.ts +215 -0
- package/server/services/workshop/runtime/mailbox.ts +148 -0
- package/server/services/workshop/runtime/manager.ts +2543 -0
- package/server/services/workshop/runtime/memory.ts +439 -0
- package/server/services/workshop/runtime/monitor.ts +194 -0
- package/server/services/workshop/runtime/scheduler-loop.ts +773 -0
- package/server/services/workshop/runtime/task-engine.ts +574 -0
- package/server/services/workshop/scene/scene-layout.repo.ts +112 -0
- package/server/services/workshop/scene-events.ts +63 -0
- package/server/services/workshop/types/a2a.ts +61 -0
- package/server/services/workshop/types/task.ts +85 -0
- package/server/types/user.ts +53 -0
- package/server/utils/approval-gate.ts +67 -0
- package/server/utils/auth.ts +13 -0
- package/server/utils/config.ts +40 -0
- package/server/utils/errors.ts +24 -0
- package/server/utils/response.ts +49 -0
- package/server/utils/validate.ts +19 -0
- package/shared/config/engine.d.mts +41 -0
- package/shared/config/engine.mjs +291 -0
- package/shared/config/home.d.mts +48 -0
- package/shared/config/home.mjs +146 -0
- package/shared/config/schema.json +30 -0
- package/shared/daq-protocol.ts +361 -0
- package/shared/dcw-protocol.ts +576 -0
- package/shared/terminal-protocol.ts +70 -0
- package/shared/town-anim.ts +56 -0
- package/shared/town-behavior.ts +136 -0
- package/shared/town-protocol.ts +110 -0
- package/shared/town-scene-math.ts +283 -0
- package/shared/workshop-protocol.ts +199 -0
- package/tsconfig.json +10 -0
- 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
|
+
}
|