import os from pathlib import Path from direct.actor.Actor import Actor from direct.task.TaskManagerGlobal import taskMgr from panda3d.core import NodePath, PartSubset class _BoundAnimationProxy: """Actor-compatible wrapper backed by auto-bound AnimControls.""" def __init__(self, node, controls, owns_node=False): self._node = node self._controls = controls self._owns_node = owns_node def isEmpty(self): return self._node.isEmpty() def show(self): self._node.show() def hide(self): self._node.hide() def setPos(self, *args): self._node.setPos(*args) def setHpr(self, *args): self._node.setHpr(*args) def setScale(self, *args): self._node.setScale(*args) def setTransform(self, *args): self._node.setTransform(*args) def getTransform(self, *args): return self._node.getTransform(*args) def reparentTo(self, *args): self._node.reparentTo(*args) def getAnimNames(self): return list(self._controls.keys()) def getAnimControl(self, anim_name): return self._controls.get(anim_name) def play(self, anim_name): control = self.getAnimControl(anim_name) if control: try: control.stop() except Exception: pass try: control.pose(0) except Exception: pass control.play() def loop(self, anim_name): control = self.getAnimControl(anim_name) if control: # AnimControl.loop 需要 restart 参数 try: control.stop() except Exception: pass try: control.pose(0) except Exception: pass control.loop(True) def stop(self): for control in self._controls.values(): try: control.stop() except Exception: pass def setPlayRate(self, speed, anim_name): control = self.getAnimControl(anim_name) if control: control.setPlayRate(speed) def cleanup(self): if self._owns_node: try: if not self._node.isEmpty(): self._node.removeNode() except Exception: pass def removeNode(self): if self._owns_node: try: if not self._node.isEmpty(): self._node.removeNode() except Exception: pass class AnimationTools: """Animation and actor helper methods extracted from main world.""" def __init__(self, app): self.app = app def __getattr__(self, name): return getattr(self.app, name) def __setattr__(self, name, value): if name == "app" or name in self.__dict__ or hasattr(type(self), name): object.__setattr__(self, name, value) else: setattr(self.app, name, value) def _is_scene_root_node(self, node): try: if not node or node.isEmpty(): return False name = node.getName() return name in ("render", "render2d", "aspect2d", "pixel2d") except Exception: return False def _get_owner_parent_node(self, owner_model): """获取动画对象的挂接父节点,确保不是空父节点。""" try: if owner_model and not owner_model.isEmpty(): parent = owner_model.getParent() if parent and not parent.isEmpty(): return parent except Exception: pass return self.render def _node_has_geom(self, node): try: if not node or node.isEmpty(): return False return node.findAllMatches("**/+GeomNode").getNumPaths() > 0 except Exception: return False def _node_has_animation_nodes(self, node): try: if not node or node.isEmpty(): return False return ( node.findAllMatches("**/+Character").getNumPaths() > 0 or node.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 ) except Exception: return False def _prefer_owner_with_visible_geometry(self, origin_model, owner_model): """ 优先选择“带可见几何体”的动画 owner,避免绑定到纯骨骼节点导致播放时不可见。 """ try: if self._node_has_geom(owner_model): return owner_model candidates = [] def _add_candidate(node, bonus=0): if not node or node.isEmpty() or self._is_scene_root_node(node): return for existing, _ in candidates: try: if existing == node: return except Exception: continue candidates.append((node, bonus)) _add_candidate(owner_model, 100) _add_candidate(origin_model, 90) # origin/owner 祖先链中优先找可见几何体节点 for seed, base_bonus in ((origin_model, 80), (owner_model, 70)): current = seed for depth in range(48): if not current or current.isEmpty() or self._is_scene_root_node(current): break _add_candidate(current, base_bonus - depth) parent = current.getParent() if not parent or parent.isEmpty() or parent == current: break current = parent # 从 scene_manager.models 中补充候选(含弱关联兜底) scene_manager = getattr(self, "scene_manager", None) models = getattr(scene_manager, "models", None) if scene_manager else None if models: origin_name = "" try: origin_name = (origin_model.getName() or "").strip().lower() except Exception: origin_name = "" related_models = [] weak_name_match_models = [] anim_geom_models = [] for model in list(models): try: if not model or model.isEmpty() or self._is_scene_root_node(model): continue related = ( model == origin_model or model == owner_model or model.isAncestorOf(origin_model) or origin_model.isAncestorOf(model) or model.isAncestorOf(owner_model) or owner_model.isAncestorOf(model) ) if related: related_models.append(model) model_name = (model.getName() or "").strip().lower() if origin_name and model_name == origin_name: weak_name_match_models.append(model) if self._node_has_geom(model) and self._node_has_animation_nodes(model): anim_geom_models.append(model) except Exception: continue for m in related_models: _add_candidate(m, 75) for m in weak_name_match_models: _add_candidate(m, 60) if len(anim_geom_models) == 1: _add_candidate(anim_geom_models[0], 85) best = owner_model best_score = -10**9 for node, bonus in candidates: try: has_geom = self._node_has_geom(node) has_anim = self._node_has_animation_nodes(node) has_path = ( (node.hasTag("model_path") and bool(node.getTag("model_path"))) or (node.hasTag("saved_model_path") and bool(node.getTag("saved_model_path"))) or (node.hasTag("original_path") and bool(node.getTag("original_path"))) ) score = bonus score += 220 if has_anim and has_geom else 0 score += 90 if has_geom else 0 score += 35 if has_anim else 0 score += 15 if has_path else 0 if score > best_score: best_score = score best = node except Exception: continue # 把路径标签补齐到最终 owner,避免后续回退到“格式未知/纯内存” try: if best and best != owner_model: for tag_name in ("model_path", "original_path", "saved_model_path", "file"): if best.hasTag(tag_name) and best.getTag(tag_name): continue if owner_model.hasTag(tag_name) and owner_model.getTag(tag_name): best.setTag(tag_name, owner_model.getTag(tag_name)) except Exception: pass return best or owner_model except Exception: return owner_model def _find_scene_model_owner(self, node): """优先从 scene_manager.models 反查真实模型根节点。""" try: scene_manager = getattr(self, "scene_manager", None) models = getattr(scene_manager, "models", None) if not models or not node or node.isEmpty(): return None best = None best_score = -1 related_found = False for model in list(models): try: if not model or model.isEmpty() or self._is_scene_root_node(model): continue # 必须与当前节点存在祖先关系 relation = 0 if model == node: relation = 3 elif model.isAncestorOf(node): relation = 2 elif node.isAncestorOf(model): relation = 1 if relation == 0: continue related_found = True has_model_path = model.hasTag("model_path") and bool(model.getTag("model_path")) has_saved_path = model.hasTag("saved_model_path") and bool(model.getTag("saved_model_path")) has_anim = ( model.findAllMatches("**/+Character").getNumPaths() > 0 or model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 ) has_geom = model.findAllMatches("**/+GeomNode").getNumPaths() > 0 is_model_root = model.hasTag("is_model_root") and model.getTag("is_model_root") == "1" is_imported_root = model.hasTag("tree_item_type") and model.getTag("tree_item_type") == "IMPORTED_MODEL_NODE" score = 0 score += relation * 100 score += 60 if has_model_path else 0 score += 40 if has_saved_path else 0 score += 30 if has_anim else 0 score += 20 if has_geom else 0 score += 10 if is_model_root else 0 score += 8 if is_imported_root else 0 if score > best_score: best_score = score best = model except Exception: continue if best: return best # 关系反查失败时的兜底:同名优先(常见于场景重建后层级丢失) node_name = "" try: node_name = (node.getName() or "").strip().lower() except Exception: node_name = "" if not related_found and node_name: fallback_best = None fallback_score = -1 for model in list(models): try: if not model or model.isEmpty() or self._is_scene_root_node(model): continue model_name = (model.getName() or "").strip().lower() if model_name != node_name: continue has_model_path = model.hasTag("model_path") and bool(model.getTag("model_path")) has_saved_path = model.hasTag("saved_model_path") and bool(model.getTag("saved_model_path")) has_anim = ( model.findAllMatches("**/+Character").getNumPaths() > 0 or model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 ) has_geom = model.findAllMatches("**/+GeomNode").getNumPaths() > 0 score = 0 score += 80 if has_anim and has_geom else 0 score += 40 if has_model_path else 0 score += 25 if has_saved_path else 0 if score > fallback_score: fallback_score = score fallback_best = model except Exception: continue if fallback_best: return fallback_best # 单模型场景兜底 valid_models = [] for model in list(models): try: if model and (not model.isEmpty()) and (not self._is_scene_root_node(model)): valid_models.append(model) except Exception: continue if len(valid_models) == 1: return valid_models[0] # 若场景中仅有一个“动画+几何体”模型,也直接使用它 anim_models = [] for model in valid_models: try: has_anim = ( model.findAllMatches("**/+Character").getNumPaths() > 0 or model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 ) has_geom = model.findAllMatches("**/+GeomNode").getNumPaths() > 0 if has_anim and has_geom: anim_models.append(model) except Exception: continue if len(anim_models) == 1: return anim_models[0] return None except Exception: return None def _recover_model_path_from_tags(self, node): """从常见标签恢复模型路径,尽量避免退化到纯内存 autoBind。""" try: project_root = Path(__file__).resolve().parents[2] search_roots = [ project_root, project_root / "Resources", project_root / "Resources" / "models", ] raw_candidates = [] if node and not node.isEmpty(): for tag_name in ("model_path", "original_path", "saved_model_path", "file"): try: if node.hasTag(tag_name): val = node.getTag(tag_name) if val: raw_candidates.append(val) except Exception: continue # 父链继续补齐 walker = node.getParent() if (node and not node.isEmpty()) else None for _ in range(64): if not walker or walker.isEmpty() or self._is_scene_root_node(walker): break for tag_name in ("model_path", "original_path", "saved_model_path", "file"): try: if walker.hasTag(tag_name): val = walker.getTag(tag_name) if val: raw_candidates.append(val) except Exception: continue parent = walker.getParent() if not parent or parent.isEmpty() or parent == walker: break walker = parent # scene_manager.models 补齐 model_owner = self._find_scene_model_owner(node) if model_owner and not model_owner.isEmpty(): for tag_name in ("model_path", "original_path", "saved_model_path", "file"): try: if model_owner.hasTag(tag_name): val = model_owner.getTag(tag_name) if val: raw_candidates.append(val) except Exception: continue # 全局兜底:当当前节点标签全丢失时,从 scene_manager.models 收集唯一可用路径 scene_manager = getattr(self, "scene_manager", None) models = getattr(scene_manager, "models", None) if scene_manager else None if models: scene_paths = [] best_related_path = "" best_related_score = -1 for model in list(models): try: if not model or model.isEmpty() or self._is_scene_root_node(model): continue relation = 0 try: if model == node: relation = 3 elif model.isAncestorOf(node): relation = 2 elif node.isAncestorOf(model): relation = 1 except Exception: relation = 0 has_anim = self._node_has_animation_nodes(model) has_geom = self._node_has_geom(model) candidate_path = "" for tag_name in ("model_path", "original_path", "saved_model_path", "file"): if model.hasTag(tag_name): val = model.getTag(tag_name) if val: scene_paths.append(val) if (not candidate_path) and tag_name in ("model_path", "original_path", "saved_model_path"): candidate_path = val if relation > 0 and candidate_path: score = relation * 100 score += 40 if has_anim else 0 score += 25 if has_geom else 0 try: if os.path.exists(candidate_path) or os.path.exists(os.path.normpath(candidate_path)): score += 35 except Exception: pass if score > best_related_score: best_related_score = score best_related_path = candidate_path except Exception: continue # 去重 uniq_scene_paths = [] seen_scene = set() for p in scene_paths: try: key = os.path.normcase(os.path.normpath(p)) except Exception: key = p if key in seen_scene: continue seen_scene.add(key) uniq_scene_paths.append(p) # 仅在唯一时采用,避免误绑到别的模型 if len(uniq_scene_paths) == 1: raw_candidates.append(uniq_scene_paths[0]) elif best_related_path: raw_candidates.append(best_related_path) # 1) 直接存在的路径优先 for c in raw_candidates: try: if os.path.exists(c): return c norm = os.path.normpath(c) if os.path.exists(norm): return norm except Exception: continue # 2) 基于文件名在项目目录中补查 filenames = [] for c in raw_candidates: try: name = os.path.basename(c) if name and "." in name: filenames.append(name) except Exception: continue tried = set() for filename in filenames: for root in search_roots: candidate = str(root / filename) key = os.path.normcase(os.path.normpath(candidate)) if key in tried: continue tried.add(key) if os.path.exists(candidate): return candidate return "" except Exception: return "" def _resolve_animation_owner_model(self, node): """向上查找动画所属的模型根节点,避免选中子节点时绑定失败。""" scene_owner = self._find_scene_model_owner(node) if scene_owner and not scene_owner.isEmpty(): try: # 同步路径标签,避免后续格式识别为“未知” if scene_owner.hasTag("model_path") and scene_owner.getTag("model_path"): if (not node.hasTag("model_path")) or (not node.getTag("model_path")): node.setTag("model_path", scene_owner.getTag("model_path")) if scene_owner.hasTag("original_path") and scene_owner.getTag("original_path"): if (not node.hasTag("original_path")) or (not node.getTag("original_path")): node.setTag("original_path", scene_owner.getTag("original_path")) except Exception: pass return scene_owner try: current = node max_depth = 64 chain = [] for _ in range(max_depth): if not current or current.isEmpty(): break # 绝不把场景根节点当作动画 owner,否则 hide() 会把全场景隐藏 if self._is_scene_root_node(current): break chain.append(current) parent = current.getParent() if not parent or parent.isEmpty() or parent == current: break current = parent # 标签缺失/层级复杂时:按可见播放优先级打分选 owner(动画+几何体最高) if chain: path_source = None original_path_source = None def score_candidate(c): try: has_character = c.findAllMatches("**/+Character").getNumPaths() > 0 has_bundle = c.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 has_anim = has_character or has_bundle has_geom = c.findAllMatches("**/+GeomNode").getNumPaths() > 0 except Exception: has_anim = False has_geom = False has_model_path = c.hasTag("model_path") and bool(c.getTag("model_path")) is_model_root = c.hasTag("is_model_root") and c.getTag("is_model_root") == "1" is_imported_root = c.hasTag("tree_item_type") and c.getTag("tree_item_type") == "IMPORTED_MODEL_NODE" # 分数越高越优先:保证可见播放 > 仅有骨骼 > 空节点 score = 0 score += 100 if has_anim and has_geom else 0 score += 60 if has_model_path and has_geom else 0 score += 40 if has_anim else 0 score += 20 if has_geom else 0 score += 15 if has_model_path else 0 score += 10 if is_model_root else 0 score += 8 if is_imported_root else 0 return score best = chain[0] best_score = -1 for candidate in chain: try: if not path_source and candidate.hasTag("model_path") and candidate.getTag("model_path"): path_source = candidate if not original_path_source and candidate.hasTag("original_path") and candidate.getTag("original_path"): original_path_source = candidate except Exception: pass s = score_candidate(candidate) if s > best_score: best_score = s best = candidate # 把路径标签补到最终 owner,避免后续格式识别为“未知” try: if path_source and ((not best.hasTag("model_path")) or (not best.getTag("model_path"))): best.setTag("model_path", path_source.getTag("model_path")) if original_path_source and ((not best.hasTag("original_path")) or (not best.getTag("original_path"))): best.setTag("original_path", original_path_source.getTag("original_path")) if best.hasTag("saved_model_path") and ((not best.hasTag("model_path")) or (not best.getTag("model_path"))): best.setTag("model_path", best.getTag("saved_model_path")) except Exception: pass return best except Exception: pass try: if node and not node.isEmpty() and not self._is_scene_root_node(node): return node except Exception: pass return node def _getActor(self, origin_model): """ 获取或创建模型的Actor,用于动画控制 复用Qt版本经过验证的实现方式 """ owner_model = self._resolve_animation_owner_model(origin_model) owner_model = self._prefer_owner_with_visible_geometry(origin_model, owner_model) # owner 节点缺失路径标签时,尝试从当前节点及其祖先继承路径,避免只能走内存分支 try: needs_path = (not owner_model.hasTag("model_path")) or (not owner_model.getTag("model_path")) if needs_path: walker = origin_model for _ in range(64): if not walker or walker.isEmpty() or self._is_scene_root_node(walker): break if walker.hasTag("model_path") and walker.getTag("model_path"): owner_model.setTag("model_path", walker.getTag("model_path")) if walker.hasTag("original_path") and walker.getTag("original_path"): owner_model.setTag("original_path", walker.getTag("original_path")) break if walker.hasTag("saved_model_path") and walker.getTag("saved_model_path"): owner_model.setTag("model_path", walker.getTag("saved_model_path")) break parent = walker.getParent() if not parent or parent.isEmpty() or parent == walker: break walker = parent # 仍缺路径时,尝试更激进的标签恢复 if (not owner_model.hasTag("model_path")) or (not owner_model.getTag("model_path")): recovered = self._recover_model_path_from_tags(owner_model) if recovered: owner_model.setTag("model_path", recovered) if (not owner_model.hasTag("original_path")) or (not owner_model.getTag("original_path")): owner_model.setTag("original_path", recovered) except Exception: pass def _cleanup_actor(actor): try: if actor is None: return if hasattr(actor, "cleanup"): actor.cleanup() if hasattr(actor, "removeNode"): actor.removeNode() except Exception: pass def _try_create_actor_from_source(source, source_desc): try: actor = Actor(source) # 无论是否已检测到动画名,都显式绑定一次,避免“有名字但无可播放控制” try: actor.bindAllAnims(allowAsyncBind=False) except Exception: pass anims = actor.getAnimNames() print(f"[Actor加载] {source_desc} 检测到动画: {anims}") if not anims: _cleanup_actor(actor) return None # 确认至少有一个可用的 AnimControl playable = False for anim_name in anims: try: control = actor.getAnimControl(anim_name) if control and control.getNumFrames() > 1: playable = True break except Exception: continue if not playable: print(f"[Actor加载] {source_desc} 动画控制无效,尝试其他加载路径") _cleanup_actor(actor) return None # 无可见几何体的 Actor 会导致“播放后什么都看不到” has_geom = False try: has_geom = actor.findAllMatches("**/+GeomNode").getNumPaths() > 0 except Exception: has_geom = False if not has_geom: print(f"[Actor加载] {source_desc} 无可见几何体,尝试其他加载路径") _cleanup_actor(actor) return None actor.reparentTo(self._get_owner_parent_node(owner_model)) actor.hide() return actor except Exception as e: print(f"[Actor加载] {source_desc} 失败: {e}") return None def _try_create_autobind_proxy(model_np, source_desc, owns_node=False): try: from panda3d.core import AnimControlCollection, autoBind controls = {} def collect_controls(np): if not np or np.isEmpty(): return try: acc = AnimControlCollection() autoBind(np.node(), acc, ~0) for i in range(acc.getNumAnims()): name = acc.getAnimName(i) or f"anim_{i}" control = acc.getAnim(i) if not control: continue if name in controls: try: old_frames = controls[name].getNumFrames() except Exception: old_frames = -1 try: new_frames = control.getNumFrames() except Exception: new_frames = -1 # 重名时保留帧数更多的控制,避免默认选到“看起来无效”的同名动画 if new_frames >= old_frames: controls[name] = control else: controls[name] = control except Exception: pass # 先在根节点尝试,再补扫 Character 节点 collect_controls(model_np) character_nodes = model_np.findAllMatches("**/+Character") for i in range(character_nodes.getNumPaths()): collect_controls(character_nodes.getPath(i)) # autoBind 无结果时,尝试手动把 AnimBundle 绑定到 Character 的 PartBundle if not controls and character_nodes.getNumPaths() > 0: anim_bundle_nodes = model_np.findAllMatches("**/+AnimBundleNode") subset = PartSubset() for ci in range(character_nodes.getNumPaths()): try: character = character_nodes.getPath(ci).node() for bi in range(character.getNumBundles()): part_bundle = character.getBundle(bi) for ai in range(anim_bundle_nodes.getNumPaths()): try: anim_bundle_node = anim_bundle_nodes.getPath(ai).node() anim_bundle = anim_bundle_node.getBundle() if not anim_bundle: continue anim_name = anim_bundle.getName() or anim_bundle_node.getName() or f"anim_{ai}" control = part_bundle.bindAnim(anim_bundle, -1, subset) if not control: continue if anim_name in controls: try: old_frames = controls[anim_name].getNumFrames() except Exception: old_frames = -1 try: new_frames = control.getNumFrames() except Exception: new_frames = -1 if new_frames >= old_frames: controls[anim_name] = control else: controls[anim_name] = control except Exception: continue except Exception: continue if not controls: return None # 仅骨骼无几何体时会出现“能触发播放但场景不可见” try: has_geom = model_np.findAllMatches("**/+GeomNode").getNumPaths() > 0 except Exception: has_geom = False if not has_geom: print(f"[Actor加载] {source_desc} autoBind 检测到动画但无可见几何体,尝试其他节点") return None print(f"[Actor加载] {source_desc} autoBind 检测到动画: {list(controls.keys())}") return _BoundAnimationProxy(model_np, controls, owns_node=owns_node) except Exception as e: print(f"[Actor加载] {source_desc} autoBind 失败: {e}") return None def _try_create_actor_via_gltf_path(path): """针对部分 GLB/GLTF 文件,使用 gltf 插件强制加载动画后再创建 Actor。""" lower_path = str(path).lower() if not (lower_path.endswith(".glb") or lower_path.endswith(".gltf")): return None model_np = None succeeded = False try: import gltf from panda3d.core import Filename panda_path = Filename.from_os_specific(path).get_fullpath() os_path = Filename(panda_path).to_os_specific() settings = gltf.GltfSettings(skip_animations=False) model_root = gltf.load_model(os_path, settings) if not model_root: return None model_np = NodePath(model_root) actor = _try_create_actor_from_source(model_np, f"GLTF专用加载({path})") if actor: succeeded = True return actor proxy = _try_create_autobind_proxy(model_np, f"GLTF专用加载({path})", owns_node=True) if proxy: proxy.reparentTo(self._get_owner_parent_node(owner_model)) proxy.hide() succeeded = True return proxy except Exception as e: print(f"[Actor加载] GLTF专用加载失败 ({path}): {e}") finally: # 仅在失败且本地临时节点仍存在时清理,避免泄漏 try: if (not succeeded) and model_np and not model_np.isEmpty(): model_np.removeNode() except Exception: pass return None # 检查缓存(无效缓存自动清理,避免“无动画”结果被永久缓存) cached_actor = self._actor_cache.get(owner_model) if cached_actor: # 若已获得模型路径,优先重建真实 Actor,避免长期停留在 autoBind 代理导致“能触发但不可见” try: owner_has_path = owner_model.hasTag("model_path") and bool(owner_model.getTag("model_path")) except Exception: owner_has_path = False if owner_has_path and isinstance(cached_actor, _BoundAnimationProxy): _cleanup_actor(cached_actor) self._actor_cache.pop(owner_model, None) cached_actor = None if cached_actor: try: has_geom = False if isinstance(cached_actor, _BoundAnimationProxy): has_geom = self._node_has_geom(getattr(cached_actor, "_node", None)) else: try: has_geom = cached_actor.findAllMatches("**/+GeomNode").getNumPaths() > 0 except Exception: has_geom = False if (not cached_actor.isEmpty()) and cached_actor.getAnimNames() and has_geom: return cached_actor except Exception: pass _cleanup_actor(cached_actor) self._actor_cache.pop(owner_model, None) # 先检测模型树中的动画结构,并写入标签供属性面板快速判断 try: character_nodes = owner_model.findAllMatches("**/+Character") anim_bundle_nodes = owner_model.findAllMatches("**/+AnimBundleNode") has_animation_nodes = ( character_nodes.getNumPaths() > 0 or anim_bundle_nodes.getNumPaths() > 0 ) owner_model.setTag("has_animations", "true" if has_animation_nodes else "false") if has_animation_nodes: owner_model.setTag("can_create_actor_from_memory", "true") except Exception: has_animation_nodes = False def _try_memory_fallback(): def _collect_autobind_source_candidates(): candidates = [] def add_candidate(node): try: if not node or node.isEmpty() or self._is_scene_root_node(node): return for existing in candidates: if existing == node: return candidates.append(node) except Exception: return add_candidate(owner_model) add_candidate(origin_model) add_candidate(self._prefer_owner_with_visible_geometry(origin_model, owner_model)) add_candidate(self._find_scene_model_owner(origin_model)) add_candidate(self._find_scene_model_owner(owner_model)) # 祖先链补齐 for seed in (origin_model, owner_model): current = seed for _ in range(48): if not current or current.isEmpty() or self._is_scene_root_node(current): break add_candidate(current) parent = current.getParent() if not parent or parent.isEmpty() or parent == current: break current = parent # scene_manager.models 补齐候选 scene_manager = getattr(self, "scene_manager", None) models = getattr(scene_manager, "models", None) if scene_manager else None if models: for model in list(models): add_candidate(model) def score(node): try: has_geom = self._node_has_geom(node) has_anim = self._node_has_animation_nodes(node) has_path = ( (node.hasTag("model_path") and bool(node.getTag("model_path"))) or (node.hasTag("saved_model_path") and bool(node.getTag("saved_model_path"))) or (node.hasTag("original_path") and bool(node.getTag("original_path"))) ) related = False try: related = ( node == owner_model or node == origin_model or node.isAncestorOf(origin_model) or origin_model.isAncestorOf(node) or node.isAncestorOf(owner_model) or owner_model.isAncestorOf(node) ) except Exception: related = False s = 0 s += 220 if has_geom and has_anim else 0 s += 120 if has_geom else 0 s += 45 if has_anim else 0 s += 35 if has_path else 0 s += 40 if node == owner_model else 0 s += 30 if node == origin_model else 0 s += 25 if related else 0 return s except Exception: return -1 candidates.sort(key=score, reverse=True) return candidates can_create_from_memory = False if owner_model.hasTag("can_create_actor_from_memory"): can_create_from_memory = owner_model.getTag("can_create_actor_from_memory").lower() == "true" if not can_create_from_memory and has_animation_nodes: can_create_from_memory = True if can_create_from_memory: # 不能直接 Actor(owner_model);会污染当前场景节点,导致播放后模型消失/选择失效。 # 先用副本创建真实 Actor,只有失败时才退回 autoBind 代理。 clone_parent = self._get_owner_parent_node(owner_model) clone_np = None try: clone_np = owner_model.copyTo(clone_parent) clone_np.setName(f"{owner_model.getName()}__anim_runtime") mem_actor = _try_create_actor_from_source(clone_np, "内存模型副本") if mem_actor: self._actor_cache[owner_model] = mem_actor owner_model.setTag("has_animations", "true") return mem_actor except Exception as e: print(f"[Actor加载] 创建内存模型副本失败: {e}") finally: # _try_create_actor_from_source 失败时,清理临时副本 try: if clone_np and not clone_np.isEmpty() and owner_model not in self._actor_cache: clone_np.removeNode() except Exception: pass # Actor 副本失败后,从多个候选节点中选择“带几何体”的 autoBind 源 for source_node in _collect_autobind_source_candidates(): mem_proxy = _try_create_autobind_proxy( source_node, f"内存模型({source_node.getName()})", owns_node=False ) if mem_proxy: self._actor_cache[owner_model] = mem_proxy owner_model.setTag("has_animations", "true") if source_node != owner_model: print(f"[Actor加载] 使用可见节点作为动画源: {owner_model.getName()} -> {source_node.getName()}") return mem_proxy return None # 始终优先尝试从文件路径加载,因为底层 gltf 插件只有在加载文件时才能抽取完整的动画名称。 print(f"[Actor加载调试] 传入的 origin_model: {origin_model.getName() if origin_model else 'None'}") print(f"[Actor加载调试] origin_model tags: {origin_model.getTags() if origin_model else 'None'}") print(f"[Actor加载调试] 解析出的 owner_model: {owner_model.getName() if owner_model else 'None'}") try: print(f"[Actor加载调试] owner_model tags: {owner_model.getTags() if owner_model else 'None'}") print(f"[Actor加载调试] owner_model path tag: {owner_model.getTag('model_path') if owner_model.hasTag('model_path') else 'MISSING'}") except Exception as e: print(f"[Actor加载调试] 获取 tags 异常: {e}") filepath = owner_model.getTag("model_path") if owner_model.hasTag("model_path") else "" if not filepath and owner_model.hasTag("original_path"): filepath = owner_model.getTag("original_path") print(f"[Actor加载调试] 获取到的 filepath: '{filepath}'") if not filepath: print(f"[Actor加载调试] filepath为空,触发 _try_memory_fallback()") return _try_memory_fallback() # 针对Actor加载,必须使用Panda3D规范的Unix风格路径,否则Windows绝对路径会导致加载彻底崩溃并返回空节点 panda_specific_path = "" try: from panda3d.core import Filename panda_specific_path = Filename.from_os_specific(filepath).get_fullpath() except: panda_specific_path = filepath.replace('\\', '/') candidate_paths = [panda_specific_path, filepath, os.path.normpath(filepath)] # 处理 /d/... 这类路径在 Windows 上无法直接访问的问题 (补充兜底OS路径) if filepath.startswith("/") and os.name == "nt": parts = filepath.split("/") if len(parts) > 2 and len(parts[1]) == 1: win_path = f"{parts[1].upper()}:\\{os.path.join(*parts[2:])}" if parts[2:] else f"{parts[1].upper()}:\\" candidate_paths.append(win_path) candidate_paths.append(os.path.normpath(win_path)) # 尝试 Panda3D 路径标准化 try: from scene import util candidate_paths.append(util.normalize_model_path(filepath)) except Exception: pass # 在 Resources/models 中按文件名兜底查找 filename = os.path.basename(filepath) if filename: candidate_paths.append(str(Path(__file__).resolve().parents[2] / "Resources" / "models" / filename)) candidate_paths.append(str(Path(__file__).resolve().parents[2] / "Resources" / filename)) # 去重并优先使用真实存在的路径 (同时确保panda专属路径排在第一位尝试) unique_paths = [] seen = set() for p in candidate_paths: if not p: continue key = os.path.normcase(os.path.normpath(p)) if key in seen: continue seen.add(key) unique_paths.append(p) # 过滤时注意如果是以 / 开头的 Panda 路径,os.path.exists 可能判断为 False,所以要额外豁免 panda_specific_path 保底加载 existing_paths = [p for p in unique_paths if os.path.exists(p) or p == panda_specific_path] load_paths = existing_paths + [p for p in unique_paths if p not in existing_paths] for p in load_paths: print(f"[Actor加载验证] 正在尝试通过路径读取骨骼和动画文件: {p}") actor = _try_create_actor_from_source(p, f"文件路径({p})") if actor: owner_model.setTag("model_path", p) owner_model.setTag("has_animations", "true") self._actor_cache[owner_model] = actor return actor # 标准 Actor 路径失败时,针对 GLTF/GLB 走插件加载兜底 actor = _try_create_actor_via_gltf_path(p) if actor: owner_model.setTag("model_path", p) owner_model.setTag("has_animations", "true") self._actor_cache[owner_model] = actor return actor # 路径 Actor 失败后,再尝试把文件作为普通模型加载并 autoBind try: loaded_model = self.loader.loadModel(p) if loaded_model and not loaded_model.isEmpty(): proxy = _try_create_autobind_proxy(loaded_model, f"文件路径({p})", owns_node=True) if proxy: loaded_model.reparentTo(self.render) loaded_model.hide() owner_model.setTag("model_path", p) owner_model.setTag("has_animations", "true") self._actor_cache[owner_model] = proxy return proxy loaded_model.removeNode() except Exception: pass # 所有创建路径失败时由内存加载进行兜底 return _try_memory_fallback() def _getModelFormat(self, origin_model): """获取模型格式信息""" filepath = origin_model.getTag("model_path") if origin_model.hasTag("model_path") else "" if not filepath and origin_model.hasTag("original_path"): filepath = origin_model.getTag("original_path") if not filepath and origin_model.hasTag("saved_model_path"): filepath = origin_model.getTag("saved_model_path") if not filepath and origin_model.hasTag("file"): filepath = origin_model.getTag("file") original_path = origin_model.getTag("original_path") converted_from = origin_model.getTag("converted_from") if filepath: ext = filepath.lower().split('.')[-1] format_name = ext.upper() # 如果是转换后的文件,显示转换信息 if converted_from and original_path: original_ext = converted_from.upper() format_name = f"{format_name} (从{original_ext}转换)" return format_name return "未知" def _processAnimationNames(self, origin_model, anim_names): """处理和分析动画名称,返回 [(显示名称, 原始名称), ...]""" format_info = self._getModelFormat(origin_model) processed = [] print(f"[动画分析] 格式: {format_info}, 原始动画名称: {anim_names}") for name in anim_names: display_name = name original_name = name format_upper = format_info.upper() if format_upper.startswith("GLB") or format_upper.startswith("GLTF"): # GLB 格式通常有真实的动画名称 if "|" in name: # 处理类似 'Armature|mixamo.com|Layer0' 的名称 parts = name.split("|") if "mixamo" in name.lower(): # Mixamo 动画 display_name = f"Mixamo_{parts[-1]}" if len(parts) > 1 else name elif len(parts) > 2: # 其他复杂命名 display_name = f"{parts[0]}_{parts[-1]}" else: display_name = parts[-1] elif ":" in name: # 一些导入器会产生前缀:动作名 display_name = name.split(":")[-1] elif "/" in name: display_name = name.split("/")[-1] elif format_upper.startswith("FBX"): # FBX 格式可能需要特殊处理 if self._isLikelyBoneGroup(name): # 检查是否是骨骼组而非动画 print(f"[警告] '{name}' 可能不是真正的动画序列,而是骨骼组") display_name = f"⚠️ {name} (可能非动画)" else: display_name = name elif format_upper.startswith("EGG") or format_upper.startswith("BAM"): # 原生格式通常命名规范 display_name = name processed.append((display_name, original_name)) print(f"[动画分析] {original_name} → {display_name}") return processed def _isLikelyBoneGroup(self, name): """判断动画名称是否更像骨骼组而不是动画序列""" bone_indicators = ['joints', 'bones', 'skeleton', 'surface', 'mesh', 'beta', 'rig'] name_lower = name.lower() # 如果包含这些关键词,可能是骨骼组 for indicator in bone_indicators: if indicator in name_lower: return True # 如果名称太简单(少于3个字符),可能不是动画 if len(name) < 3: return True return False def _analyzeAnimationQuality(self, actor, anim_names, format_info): """分析动画质量和类型(优化版本,减少详细分析以提高性能)""" try: valid_anims = 0 # 简化分析:只检查动画是否存在,不详细分析帧数 for anim_name in anim_names: try: control = actor.getAnimControl(anim_name) if control and control.getNumFrames() > 1: valid_anims += 1 except Exception: # 忽略单个动画的分析错误,继续处理其他动画 continue if valid_anims == 0: return "⚠️ 无有效动画" elif valid_anims < len(anim_names): return f"⚠️ {valid_anims}/{len(anim_names)} 个有效" else: return f"✓ {valid_anims} 个动画" except Exception as e: # 简化错误处理 return "分析异常" def _resolve_selected_animation_name(self, origin_model, actor, owner_model=None): """返回可播放的动画名;当当前选择无效时自动回退到首个动画并回写标签。""" try: anim_names = actor.getAnimNames() if actor else [] except Exception: anim_names = [] if not anim_names: return None current_anim = origin_model.getPythonTag("selected_animation") if owner_model and owner_model != origin_model and current_anim not in anim_names: current_anim = owner_model.getPythonTag("selected_animation") if current_anim not in anim_names: current_anim = anim_names[0] try: origin_model.setPythonTag("selected_animation", current_anim) if owner_model and owner_model != origin_model: owner_model.setPythonTag("selected_animation", current_anim) except Exception: pass return current_anim def _should_swap_visibility_for_actor(self, owner_model, actor): """ 是否需要“隐藏原模型/显示Actor”的切换。 当 actor 直接绑定到 owner_model 本体时,不应切换可见性,否则会导致选中节点瞬间丢失。 """ if self._is_scene_root_node(owner_model): # 绝不隐藏场景根节点 return False try: if isinstance(actor, _BoundAnimationProxy): # 非 owns_node 代理直接绑定场景节点,不能做“隐藏原模型/显示Actor”切换 if not getattr(actor, "_owns_node", False): return False return getattr(actor, "_node", None) != owner_model except Exception: pass try: return actor != owner_model except Exception: return True def _playAnimation(self, origin_model): """播放动画""" try: if hasattr(self, "render") and not self.render.isEmpty() and self.render.isHidden(): self.render.show() except Exception: pass owner_model = self._resolve_animation_owner_model(origin_model) actor = self._getActor(owner_model) if not actor: return # 若仍在“无路径的内存代理”,播放前强制再尝试一次路径恢复并重建真实 Actor if isinstance(actor, _BoundAnimationProxy): owner_has_path = owner_model.hasTag("model_path") and bool(owner_model.getTag("model_path")) if not owner_has_path: recovered_path = self._recover_model_path_from_tags(owner_model) or self._recover_model_path_from_tags(origin_model) if recovered_path: try: owner_model.setTag("model_path", recovered_path) if (not owner_model.hasTag("original_path")) or (not owner_model.getTag("original_path")): owner_model.setTag("original_path", recovered_path) except Exception: pass self._clear_animation_cache(owner_model) actor = self._getActor(owner_model) if not actor: return is_scene_bound_proxy = isinstance(actor, _BoundAnimationProxy) and (not getattr(actor, "_owns_node", False)) original_world_pos = owner_model.getPos(self.render) original_world_hpr = owner_model.getHpr(self.render) original_world_scale = owner_model.getScale(self.render) if not is_scene_bound_proxy: actor.setPos(owner_model.getPos()) actor.setHpr(owner_model.getHpr()) actor.setScale(owner_model.getScale()) if self._should_swap_visibility_for_actor(owner_model, actor): owner_model.hide() else: owner_model.show() actor.show() task_name = f"maintain_anim_pos_{id(actor)}" # 维持 Actor 的世界变换,避免父节点层级差异导致位置抖动/漂移 def maintainWorldPosition(task): try: if not actor.isEmpty() and not owner_model.isEmpty(): actor.setPos(self.render, original_world_pos) actor.setHpr(self.render, original_world_hpr) actor.setScale(self.render, original_world_scale) return task.cont else: return task.done except: return task.done taskMgr.remove(task_name) if not is_scene_bound_proxy: taskMgr.add(maintainWorldPosition, task_name) # 获取当前选中的动画 current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model) if current_anim: try: actor.stop() except Exception: pass actor.play(current_anim) print(f"『动画播放』:{current_anim}") def _pauseAnimation(self, origin_model): """暂停动画""" owner_model = self._resolve_animation_owner_model(origin_model) actor = self._getActor(owner_model) if not actor: return if isinstance(actor, _BoundAnimationProxy): owner_has_path = owner_model.hasTag("model_path") and bool(owner_model.getTag("model_path")) if not owner_has_path: recovered_path = self._recover_model_path_from_tags(owner_model) or self._recover_model_path_from_tags(origin_model) if recovered_path: try: owner_model.setTag("model_path", recovered_path) if (not owner_model.hasTag("original_path")) or (not owner_model.getTag("original_path")): owner_model.setTag("original_path", recovered_path) except Exception: pass self._clear_animation_cache(owner_model) actor = self._getActor(owner_model) if not actor: return is_scene_bound_proxy = isinstance(actor, _BoundAnimationProxy) and (not getattr(actor, "_owns_node", False)) if not is_scene_bound_proxy: actor.setPos(self.render, owner_model.getPos(self.render)) actor.setHpr(self.render, owner_model.getHpr(self.render)) actor.setScale(self.render, owner_model.getScale(self.render)) # 隐藏原始模型,显示Actor if self._should_swap_visibility_for_actor(owner_model, actor): owner_model.hide() else: owner_model.show() actor.show() # 停止动画(保持当前姿势) actor.stop() print("『动画』暂停") def _stopAnimation(self, origin_model): """停止动画""" try: if hasattr(self, "render") and not self.render.isEmpty() and self.render.isHidden(): self.render.show() except Exception: pass owner_model = self._resolve_animation_owner_model(origin_model) actor = self._getActor(owner_model) if not actor: return # 停止动画 actor.stop() # 获取当前选中的动画 current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model) if current_anim and actor.getAnimControl(current_anim): actor.getAnimControl(current_anim).pose(0) # 隐藏Actor,显示原始模型 if self._should_swap_visibility_for_actor(owner_model, actor): actor.hide() owner_model.show() # 移除位置维护任务 taskMgr.remove(f"maintain_anim_pos_{id(actor)}") print("『动画』停止切换至原始模型") def _loopAnimation(self, origin_model): """循环播放动画""" try: if hasattr(self, "render") and not self.render.isEmpty() and self.render.isHidden(): self.render.show() except Exception: pass owner_model = self._resolve_animation_owner_model(origin_model) actor = self._getActor(owner_model) if not actor: return is_scene_bound_proxy = isinstance(actor, _BoundAnimationProxy) and (not getattr(actor, "_owns_node", False)) original_world_pos = owner_model.getPos(self.render) original_world_hpr = owner_model.getHpr(self.render) original_world_scale = owner_model.getScale(self.render) if not is_scene_bound_proxy: actor.setPos(owner_model.getPos()) actor.setHpr(owner_model.getHpr()) actor.setScale(owner_model.getScale()) if self._should_swap_visibility_for_actor(owner_model, actor): owner_model.hide() else: owner_model.show() actor.show() task_name = f"maintain_anim_pos_{id(actor)}" def maintainWorldPosition(task): try: if not actor.isEmpty() and not owner_model.isEmpty(): actor.setPos(self.render, original_world_pos) actor.setHpr(self.render, original_world_hpr) actor.setScale(self.render, original_world_scale) return task.cont else: return task.done except: return task.done taskMgr.remove(task_name) if not is_scene_bound_proxy: taskMgr.add(maintainWorldPosition, task_name) # 获取当前选中的动画 current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model) if current_anim: try: actor.stop() except Exception: pass actor.loop(current_anim) print(f"[动画] 循环: {current_anim}") def _setAnimationSpeed(self, origin_model, speed): """设置动画播放速度""" owner_model = self._resolve_animation_owner_model(origin_model) actor = self._getActor(owner_model) if not actor: return # 获取当前选中的动画 current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model) if current_anim: actor.setPlayRate(speed, current_anim) print(f"[动画] 速度设为: {speed} ({current_anim})") else: # 兜底:尝试所有动画 anim_names = actor.getAnimNames() for anim_name in anim_names: actor.setPlayRate(speed, anim_name) print(f"[动画] 速度设为: {speed} (所有动画)") def _clear_animation_cache(self, node): """清除节点的动画缓存,当模型发生变化时调用""" owner_node = self._resolve_animation_owner_model(node) node.setPythonTag("cached_anim_info", None) node.setPythonTag("cached_processed_names", None) node.setPythonTag("animation", None) # 同时清除动画检测结果 # 如果Actor在缓存中,也需要清理 if owner_node in self._actor_cache: actor = self._actor_cache[owner_node] try: # 清理相关任务 taskMgr.remove(f"maintain_anim_pos_{id(actor)}") # 清理Actor if not actor.isEmpty(): actor.cleanup() actor.removeNode() except Exception as e: print(f"清理Actor缓存失败: {e}") finally: del self._actor_cache[owner_node] print(f"[缓存清理] 清除节点 {owner_node.getName()} 的动画缓存")