extends NPCController class_name NetworkNpc var npcId: String = "" var stateVersion: int = -1 var worldState: String = "idle" var publicIntention: String = "" var dailyGoal: String = "" var planSource: String = "fallback" var currentActivity: Dictionary = {} var movementState: String = "idle" var activeAction: Dictionary = {} var _actionStartedLocalMsec: int = 0 var _actionCompletesLocalMsec: int = 0 const DIRECTION_ROWS: Dictionary = {"down": 0, "up": 1, "right": 2, "left": 3} const WALK_ANIMATION_LENGTH: float = 0.8 const IDLE_ANIMATION_LENGTH: float = 1.2 const ACTIVITY_ANIMATION_LENGTH: float = 1.2 const COLLISION_MOTION_SAMPLE_DISTANCE: float = 8.0 const RESEARCHER_TEXTURE: Texture2D = preload("res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png") const MAYOR_TEXTURE: Texture2D = preload("res://assets/characters/npc_286_241.png") const CRAYFISH_TEXTURE: Texture2D = preload("res://assets/characters/crayfish_npc_256_256.png") const NIULAI_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png") const NIULAI_IDLE_DOWN_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png") const NIULAI_IDLE_UP_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png") const NIULAI_IDLE_RIGHT_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png") const NIULAI_IDLE_LEFT_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png") var lastDirection: String = "down" var visualScene: String = "" var spriteColumns: int = 8 var _lastBlockedActionId: String = "" var _baseSpritePosition: Vector2 = Vector2.ZERO var _niulaiIdleTexture: Texture2D var _baseSpriteScale: Vector2 = Vector2.ONE var _baseSpriteRotation: float = 0.0 func _ready() -> void: super._ready() var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call("connect_event", EventNames.NPC_CONVERSATION, _on_npc_conversation, self) if animation_player != null: animation_player.stop() var sharedLibrary := animation_player.get_animation_library("") if sharedLibrary != null: animation_player.remove_animation_library("") animation_player.add_animation_library("", sharedLibrary.duplicate(true)) _configure_visual("classic_whale") func _exit_tree() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call("disconnect_event", EventNames.NPC_CONVERSATION, _on_npc_conversation, self) func interact() -> void: # 第一次交互和后续交流都进入同一个居中对话框。 var chatUi := get_tree().root.find_child("ChatUI", true, false) if chatUi != null and chatUi.has_method("start_npc_whisper"): chatUi.call("start_npc_whisper", npcId, npcName, dialogue) func _on_npc_conversation(data: Dictionary) -> void: # 环境中的 NPC 对话只更新下次交互时的开场白,不再弹出世界气泡。 var linesValue: Variant = data.get("lines", []) if not (linesValue is Array): return for lineValue: Variant in (linesValue as Array): if not (lineValue is Dictionary): continue var line: Dictionary = lineValue if str(line.get("speaker_npc_id", "")) != npcId: continue var text := str(line.get("text", "")).strip_edges() if not text.is_empty(): dialogue = text func _process(delta: float) -> void: if activeAction.is_empty(): return var actionKind := str(activeAction.get("kind", "walk")) if actionKind == "transition": return var now := Time.get_ticks_msec() var duration: int = maxi(1, _actionCompletesLocalMsec - _actionStartedLocalMsec) var progress := clampf(float(now - _actionStartedLocalMsec) / float(duration), 0.0, 1.0) var from := Vector2(float(activeAction.get("from_x", global_position.x)), float(activeAction.get("from_y", global_position.y))) var to := Vector2(float(activeAction.get("to_x", global_position.x)), float(activeAction.get("to_y", global_position.y))) var reachedAuthoritativePosition := _move_to_authoritative_position(from.lerp(to, progress)) _update_world_sort_z() if progress >= 1.0: if reachedAuthoritativePosition and global_position.distance_to(to) <= 0.5: global_position = to activeAction = {} movementState = "idle" _play_idle_animation() func _play_idle_animation() -> void: if animation_player != null: if visualScene == "niulai_ambassador" and has_node("Sprite2D"): _set_niulai_idle_texture("down") animation_player.play("idle_down") else: animation_player.play("idle") func _play_activity_animation(activityKind: String = "") -> void: if animation_player == null: return if visualScene == "niulai_ambassador" and has_node("Sprite2D"): _set_niulai_idle_texture("down") animation_player.play("idle_down") return var normalized := activityKind.strip_edges().to_lower() var animationPrefix := "activity_work" if normalized == "socialize" or normalized == "share": animationPrefix = "activity_talk" var animationName := "%s_%s" % [animationPrefix, lastDirection] if animation_player.has_animation(animationName): animation_player.play(animationName) else: _play_idle_animation() func _set_niulai_idle_texture(direction: String) -> void: var idleTexture: Texture2D = NIULAI_IDLE_DOWN_TEXTURE match direction: "up": idleTexture = NIULAI_IDLE_UP_TEXTURE "right": idleTexture = NIULAI_IDLE_RIGHT_TEXTURE "left": idleTexture = NIULAI_IDLE_LEFT_TEXTURE $Sprite2D.texture = idleTexture $Sprite2D.hframes = 4 $Sprite2D.vframes = 1 func _configure_visual(sceneKey: String) -> void: if not has_node("Sprite2D"): return var normalized := sceneKey.strip_edges() if normalized.is_empty(): normalized = "classic_whale" if visualScene == normalized: return visualScene = normalized var sprite := $Sprite2D as Sprite2D # Network NPC sheets are authored for crisp 2D rendering. Set the filter on # the actual Sprite2D as well as the parent, since imported textures may # otherwise fall back to the renderer's linear sampler on Web. sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST sprite.texture_repeat = CanvasItem.TEXTURE_REPEAT_DISABLED match visualScene: "town_mayor": sprite.texture = MAYOR_TEXTURE spriteColumns = 4 sprite.position = Vector2.ZERO sprite.scale = Vector2(0.72, 0.72) # The mayor frames have transparent padding above the propeller, so the # name must follow the visible silhouette instead of the frame boundary. nameplateOffsetY = -52.0 _set_collision_size(Vector2(48, 24)) "dock_crayfish": sprite.texture = CRAYFISH_TEXTURE spriteColumns = 4 sprite.position = Vector2.ZERO sprite.scale = Vector2(0.65, 0.65) nameplateOffsetY = -66.0 _set_collision_size(Vector2(44, 22)) "niulai_ambassador": sprite.texture = NIULAI_TEXTURE spriteColumns = 4 # The generated sheet includes feet. Align the sprite's feet with the # network node origin used for sorting and collision. # Its source frames are 384x256 (larger than the 160x160 sheets used # by the other NPCs), so use a smaller display scale while preserving # the same ground anchor and a slightly broader physical footprint. sprite.position = Vector2(0, -45) sprite.scale = Vector2(0.4, 0.4) nameplateOffsetY = -105.0 _set_collision_size(Vector2(52, 24)) _: sprite.texture = RESEARCHER_TEXTURE spriteColumns = 8 sprite.position = Vector2(0, -29) sprite.scale = Vector2(0.5, 0.5) nameplateOffsetY = -72.0 _set_collision_size(Vector2(48, 24)) _baseSpritePosition = sprite.position _baseSpriteScale = sprite.scale _baseSpriteRotation = sprite.rotation sprite.hframes = spriteColumns sprite.vframes = 4 _configure_directional_animations() _play_idle_animation() _update_nameplate() func _set_collision_size(value: Vector2) -> void: var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D if collision != null and collision.shape is RectangleShape2D: collision.shape = collision.shape.duplicate() (collision.shape as RectangleShape2D).size = value func _direction_row(direction: String) -> int: if visualScene == "niulai_ambassador": return int({"down": 0, "up": 1, "right": 2, "left": 3}.get(direction, 0)) if spriteColumns == 4: return int({"down": 0, "right": 1, "up": 2, "left": 3}.get(direction, 0)) return int(DIRECTION_ROWS.get(direction, 0)) func _configure_directional_animations() -> void: if animation_player == null or not has_node("Sprite2D"): return var sprite := $Sprite2D as Sprite2D var library := animation_player.get_animation_library("") if library == null: library = AnimationLibrary.new() animation_player.add_animation_library("", library) if library.has_animation("idle"): library.remove_animation("idle") var idle := Animation.new() idle.resource_name = "idle" idle.length = IDLE_ANIMATION_LENGTH idle.loop_mode = Animation.LOOP_LINEAR var idleTrack := idle.add_track(Animation.TYPE_VALUE) idle.track_set_path(idleTrack, NodePath("Sprite2D:frame")) idle.value_track_set_update_mode(idleTrack, Animation.UPDATE_DISCRETE) var idleFrames: Array[int] = [0, 1, 0, 2, 0] if visualScene == "niulai_ambassador": sprite.texture = NIULAI_IDLE_DOWN_TEXTURE idleFrames = [0, 1, 2, 3, 2, 1, 0] if spriteColumns >= 8: idleFrames = [0, 2, 0, 4, 0] for frameIndex in range(idleFrames.size()): var frameTime := idle.length * float(frameIndex) / float(idleFrames.size() - 1) idle.track_insert_key(idleTrack, frameTime, idleFrames[frameIndex]) var idlePositionTrack := idle.add_track(Animation.TYPE_VALUE) idle.track_set_path(idlePositionTrack, NodePath("Sprite2D:position")) idle.track_insert_key(idlePositionTrack, 0.0, _baseSpritePosition) idle.track_insert_key(idlePositionTrack, idle.length * 0.25, _baseSpritePosition + Vector2(0.0, -0.75)) idle.track_insert_key(idlePositionTrack, idle.length * 0.5, _baseSpritePosition) idle.track_insert_key(idlePositionTrack, idle.length * 0.75, _baseSpritePosition + Vector2(0.0, -0.35)) idle.track_insert_key(idlePositionTrack, idle.length, _baseSpritePosition) library.add_animation("idle", idle) if visualScene == "niulai_ambassador": sprite.texture = NIULAI_TEXTURE sprite.hframes = 4 sprite.vframes = 4 for direction in DIRECTION_ROWS.keys(): var row := _direction_row(str(direction)) if visualScene == "niulai_ambassador": var idleTextureForDirection: Texture2D = NIULAI_IDLE_DOWN_TEXTURE match str(direction): "up": idleTextureForDirection = NIULAI_IDLE_UP_TEXTURE "right": idleTextureForDirection = NIULAI_IDLE_RIGHT_TEXTURE "left": idleTextureForDirection = NIULAI_IDLE_LEFT_TEXTURE sprite.texture = idleTextureForDirection sprite.hframes = 4 sprite.vframes = 1 var niulaiIdleName := "idle_%s" % direction if library.has_animation(niulaiIdleName): library.remove_animation(niulaiIdleName) var niulaiIdle := Animation.new() niulaiIdle.resource_name = niulaiIdleName niulaiIdle.length = IDLE_ANIMATION_LENGTH niulaiIdle.loop_mode = Animation.LOOP_LINEAR var niulaiIdleTrack := niulaiIdle.add_track(Animation.TYPE_VALUE) niulaiIdle.track_set_path(niulaiIdleTrack, NodePath("Sprite2D:frame")) niulaiIdle.value_track_set_update_mode(niulaiIdleTrack, Animation.UPDATE_DISCRETE) for niulaiFrameIndex in range(idleFrames.size()): var niulaiFrameTime := niulaiIdle.length * float(niulaiFrameIndex) / float(idleFrames.size() - 1) niulaiIdle.track_insert_key(niulaiIdleTrack, niulaiFrameTime, idleFrames[niulaiFrameIndex]) library.add_animation(niulaiIdleName, niulaiIdle) var niulaiWalkName := "walk_%s" % direction if library.has_animation(niulaiWalkName): library.remove_animation(niulaiWalkName) var niulaiWalk := Animation.new() niulaiWalk.resource_name = niulaiWalkName niulaiWalk.length = WALK_ANIMATION_LENGTH niulaiWalk.loop_mode = Animation.LOOP_LINEAR var niulaiWalkTrack := niulaiWalk.add_track(Animation.TYPE_VALUE) niulaiWalk.track_set_path(niulaiWalkTrack, NodePath("Sprite2D:frame")) niulaiWalk.value_track_set_update_mode(niulaiWalkTrack, Animation.UPDATE_DISCRETE) for column in range(4): niulaiWalk.track_insert_key( niulaiWalkTrack, float(column) * WALK_ANIMATION_LENGTH / 4.0, row * 4 + column, ) library.add_animation(niulaiWalkName, niulaiWalk) for staticPrefix in ["activity_work", "activity_talk"]: var staticName := "%s_%s" % [staticPrefix, direction] if library.has_animation(staticName): library.remove_animation(staticName) var staticAnimation := Animation.new() staticAnimation.resource_name = staticName staticAnimation.length = 1.0 staticAnimation.loop_mode = Animation.LOOP_LINEAR var staticTrack := staticAnimation.add_track(Animation.TYPE_VALUE) staticAnimation.track_set_path(staticTrack, NodePath("Sprite2D:frame")) staticAnimation.value_track_set_update_mode(staticTrack, Animation.UPDATE_DISCRETE) staticAnimation.track_insert_key(staticTrack, 0.0, row * spriteColumns) library.add_animation(staticName, staticAnimation) continue var walkName := "walk_%s" % direction if library.has_animation(walkName): library.remove_animation(walkName) var walk := Animation.new() walk.length = WALK_ANIMATION_LENGTH walk.loop_mode = Animation.LOOP_LINEAR var walkTrack := walk.add_track(Animation.TYPE_VALUE) walk.track_set_path(walkTrack, NodePath("Sprite2D:frame")) walk.value_track_set_update_mode(walkTrack, Animation.UPDATE_DISCRETE) for column in range(spriteColumns): walk.track_insert_key(walkTrack, float(column) * WALK_ANIMATION_LENGTH / float(spriteColumns), row * spriteColumns + column) library.add_animation(walkName, walk) # Activity animations deliberately reuse the canonical character frame. # Only the timing and a tiny pixel-scale body motion change, so every NPC # keeps the exact same face, outfit, proportions, and palette while working. for activityPrefix in ["activity_work", "activity_talk"]: var activityName := "%s_%s" % [activityPrefix, direction] if library.has_animation(activityName): library.remove_animation(activityName) var activity := Animation.new() activity.length = ACTIVITY_ANIMATION_LENGTH activity.loop_mode = Animation.LOOP_LINEAR var frameTrack := activity.add_track(Animation.TYPE_VALUE) activity.track_set_path(frameTrack, NodePath("Sprite2D:frame")) activity.value_track_set_update_mode(frameTrack, Animation.UPDATE_DISCRETE) var motionFrames: Array[int] = [0, 1, 0, 2, 0] if activityPrefix == "activity_talk": motionFrames = [0, 2, 1, 3, 0] if spriteColumns >= 8: motionFrames = [0, 2, 0, 4, 0] if activityPrefix == "activity_talk": motionFrames = [0, 4, 2, 6, 0] for frameIndex in range(motionFrames.size()): var frameTime := ACTIVITY_ANIMATION_LENGTH * float(frameIndex) / float(motionFrames.size() - 1) activity.track_insert_key(frameTrack, frameTime, row * spriteColumns + motionFrames[frameIndex]) var positionTrack := activity.add_track(Animation.TYPE_VALUE) activity.track_set_path(positionTrack, NodePath("Sprite2D:position")) var bobAmount := 1.0 if activityPrefix == "activity_work" else 1.5 activity.track_insert_key(positionTrack, 0.0, _baseSpritePosition) activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpritePosition + Vector2(0.0, -bobAmount)) activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpritePosition) activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpritePosition + Vector2(0.0, -bobAmount * 0.5)) activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpritePosition) var rotationTrack := activity.add_track(Animation.TYPE_VALUE) activity.track_set_path(rotationTrack, NodePath("Sprite2D:rotation")) var tiltAmount := 0.018 if activityPrefix == "activity_work" else 0.028 activity.track_insert_key(rotationTrack, 0.0, _baseSpriteRotation) activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpriteRotation - tiltAmount) activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpriteRotation) activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpriteRotation + tiltAmount * 0.6) activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpriteRotation) var scaleTrack := activity.add_track(Animation.TYPE_VALUE) activity.track_set_path(scaleTrack, NodePath("Sprite2D:scale")) var scaleAmount := 0.012 if activityPrefix == "activity_work" else 0.018 activity.track_insert_key(scaleTrack, 0.0, _baseSpriteScale) activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpriteScale * Vector2(1.0, 1.0 + scaleAmount)) activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpriteScale) activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpriteScale * Vector2(1.0, 1.0 + scaleAmount * 0.5)) activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpriteScale) library.add_animation(activityName, activity) func apply_snapshot(data: Dictionary) -> void: var incomingVersion := int(data.get("version", 0)) if stateVersion > incomingVersion: return var isInitialSnapshot := stateVersion < 0 npcId = str(data.get("npc_id", data.get("npcId", npcId))).strip_edges() _configure_visual(str(data.get("scene", "classic_whale"))) stateVersion = incomingVersion npcName = str(data.get("name", npcName)).strip_edges() worldState = str(data.get("state", "idle")).strip_edges() publicIntention = str(data.get("public_intention", data.get("publicIntention", ""))).strip_edges() dailyGoal = str(data.get("daily_goal", data.get("dailyGoal", ""))).strip_edges() planSource = str(data.get("plan_source", data.get("planSource", "fallback"))).strip_edges() var activityValue: Variant = data.get("current_activity", data.get("currentActivity", {})) currentActivity = activityValue if activityValue is Dictionary else {} dialogue = str(data.get("dialogue", _fallback_dialogue())).strip_edges() movementState = str(data.get("movement_state", data.get("movementState", "idle"))).strip_edges() var snapshotDirection := str(data.get("direction", lastDirection)).strip_edges().to_lower() if DIRECTION_ROWS.has(snapshotDirection): lastDirection = snapshotDirection showNameplate = true var snapshotPosition := Vector2(float(data.get("x", global_position.x)), float(data.get("y", global_position.y))) if isInitialSnapshot: _place_at_clear_position(snapshotPosition) else: _move_to_authoritative_position(snapshotPosition) _update_nameplate() _update_world_sort_z() var snapshotAction: Variant = data.get("active_action", data.get("activeAction", {})) if snapshotAction is Dictionary and not (snapshotAction as Dictionary).is_empty(): _apply_action(snapshotAction, int(data.get("server_now", 0))) else: activeAction = {} if _is_activity_state(): _play_activity_animation(str(currentActivity.get("activityKind", currentActivity.get("activity_kind", "")))) else: _play_idle_animation() func apply_action_started(data: Dictionary) -> void: _apply_action(data.get("action", {}), int(data.get("server_now", data.get("serverNow", 0)))) func _is_activity_state() -> bool: var normalizedState := worldState.strip_edges().to_lower() if normalizedState in ["working", "talking", "performing", "acting", "socializing", "sharing"]: return not currentActivity.is_empty() or normalizedState != "working" return false func apply_action_completed(data: Dictionary) -> void: var action: Variant = data.get("action", {}) if not (action is Dictionary): return var completed: Dictionary = action var actionKind := str(completed.get("kind", "walk")) var incomingVersion := int(completed.get("version", 0)) if incomingVersion < stateVersion: return stateVersion = incomingVersion if actionKind == "transition": visible = false else: var completedPosition := Vector2(float(completed.get("to_x", completed.get("toX", global_position.x))), float(completed.get("to_y", completed.get("toY", global_position.y)))) if not _move_to_authoritative_position(completedPosition): movementState = "idle" _play_idle_animation() return _update_world_sort_z() activeAction = {} movementState = "idle" _play_idle_animation() func _apply_action(value: Variant, serverNow: int) -> void: if not (value is Dictionary): return var incoming: Dictionary = value var incomingVersion := int(incoming.get("version", 0)) if incomingVersion < stateVersion: return stateVersion = incomingVersion activeAction = incoming visible = true var actionKind := str(incoming.get("kind", "walk")) movementState = "walk" if actionKind == "walk" else "idle" worldState = "travelling" if actionKind == "transition" else ("talking" if str(incoming.get("activity_kind", "")) == "socialize" else ("working" if actionKind == "perform" else "walking")) var dx := float(incoming.get("to_x", incoming.get("toX", 0.0))) - float(incoming.get("from_x", incoming.get("fromX", 0.0))) var dy := float(incoming.get("to_y", incoming.get("toY", 0.0))) - float(incoming.get("from_y", incoming.get("fromY", 0.0))) if actionKind == "walk": if absf(dx) > absf(dy): lastDirection = "right" if dx >= 0.0 else "left" else: lastDirection = "down" if dy >= 0.0 else "up" if animation_player != null and actionKind == "walk": if visualScene == "niulai_ambassador" and has_node("Sprite2D"): $Sprite2D.texture = NIULAI_TEXTURE $Sprite2D.hframes = 4 $Sprite2D.vframes = 4 animation_player.play("walk_%s" % lastDirection) elif animation_player != null and actionKind == "perform": _play_activity_animation(str(incoming.get("activity_kind", incoming.get("activityKind", "")))) elif animation_player != null: _play_idle_animation() var startedAt := int(incoming.get("started_at", incoming.get("startedAt", 0))) var completesAt := int(incoming.get("completes_at", incoming.get("completesAt", startedAt))) var offset := Time.get_ticks_msec() - serverNow if serverNow > 0 else 0 _actionStartedLocalMsec = startedAt + offset _actionCompletesLocalMsec = completesAt + offset if actionKind != "transition": var from := Vector2(float(incoming.get("from_x", incoming.get("fromX", global_position.x))), float(incoming.get("from_y", incoming.get("fromY", global_position.y)))) var to := Vector2(float(incoming.get("to_x", incoming.get("toX", from.x))), float(incoming.get("to_y", incoming.get("toY", from.y)))) var duration: int = maxi(1, _actionCompletesLocalMsec - _actionStartedLocalMsec) var progress := clampf(float(Time.get_ticks_msec() - _actionStartedLocalMsec) / float(duration), 0.0, 1.0) _move_to_authoritative_position(from.lerp(to, progress)) _update_world_sort_z() func _move_to_authoritative_position(target: Vector2) -> bool: # Network NPC positions are authoritative. Static map collisions are already # resolved by the server's route graph; blocking the replicated position on # the client makes every NPC freeze when a decorative collider is slightly # offset from a route point. Keep the shape for interaction, but never reject # an authoritative movement update locally. global_position = target return true func _place_at_clear_position(target: Vector2) -> bool: global_position = target return true func _has_static_collision_at(target: Vector2) -> bool: var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D if collision == null or collision.shape == null or not is_inside_tree(): return false var query := PhysicsShapeQueryParameters2D.new() query.shape = collision.shape var candidateTransform := collision.global_transform candidateTransform.origin += target - global_position query.transform = candidateTransform query.collision_mask = 1 query.collide_with_areas = false query.collide_with_bodies = true query.exclude = [get_rid()] for hit in get_world_2d().direct_space_state.intersect_shape(query, 16): if hit.get("collider") is StaticBody2D: return true return false func _report_blocked_route(position: Vector2) -> void: var actionId := str(activeAction.get("action_id", activeAction.get("actionId", "snapshot"))) if actionId == _lastBlockedActionId: return _lastBlockedActionId = actionId push_warning("NetworkNpc route blocked by static collision: npc=%s action=%s position=%s" % [npcId, actionId, position]) func _fallback_dialogue() -> String: if not publicIntention.is_empty(): return "你好,我是%s。%s。" % [npcName, publicIntention] return "你好,我是%s。" % npcName