extends Node3D # Tekton - Roaming NPC # Moves around the grid and spawns tiles when attacked. signal movement_finished @export var current_position: Vector2i = Vector2i(0, 0) @export var health: int = 3 @export var movement_speed: float = 2.0 # Seconds per step (Slower walk) @export var is_static_turret: bool = false # If true, cannot be grabbed, thrown, or knocked var enhanced_gridmap: Node var is_moving: bool = false var is_carried: bool = false var is_thrown: bool = false var carrier: Node3D = null var tween: Tween const SIDE_OFFSET = 0.35 # Distance from center func initialize(start_pos: Vector2i, p_gridmap: Node): current_position = start_pos enhanced_gridmap = p_gridmap # Grounded and Side-positioned # We use a consistent side offset (e.g. North-West corner of the tile) update_visual_position() @rpc("any_peer", "call_local", "reliable") func sync_position(pos: Vector2i): current_position = pos update_visual_position() func update_visual_position(): if is_carried or is_thrown: return # Align with floor height (matching player's typical grounded height) var floor_y = 0.05 if enhanced_gridmap and "cell_size" in enhanced_gridmap: floor_y = enhanced_gridmap.cell_size.y # Side offset: place it near the edge # Using NW corner (+0.2, +0.2) instead of center (+0.5, +0.5) var offset = 0.2 if is_static_turret: offset = 0.5 floor_y = 1.0 position = Vector3(current_position.x + offset, floor_y, current_position.y + offset) func move_to(target_pos: Vector2i): if is_moving or is_carried or is_thrown: return # Validate # Validate Grid if not enhanced_gridmap.is_position_valid(target_pos): return # Validate Physics (Block on Stands) var space_state = get_world_3d().direct_space_state # Tekton is slightly offset, so query center of its current and target tiles # Just checking TARGET center is usually enough to stop entering a blocked tile # RAY HEIGHT: 0.3 to hit the 0.6m tall stand var from = Vector3(target_pos.x + 0.5, 0.3, target_pos.y + 0.5) + Vector3.UP * 5.0 var to = Vector3(target_pos.x + 0.5, 0.3, target_pos.y + 0.5) + Vector3.DOWN * 5.0 # Raycast VERTICALLY at target to see if it's occupied by a Stand # Actually, vertical ray from high up is fine IF it goes low enough. # But let's check intersection at body height to be sure. from = Vector3(target_pos.x + 0.5, 5.0, target_pos.y + 0.5) to = Vector3(target_pos.x + 0.5, 0.1, target_pos.y + 0.5) # Go almost to floor var query = PhysicsRayQueryParameters3D.create(from, to) query.collide_with_areas = false query.collide_with_bodies = true var result = space_state.intersect_ray(query) if result: # If we hit a StaticTektonStand (or any other static blocking body) if result.collider != self: # print("Tekton movement blocked by: ", result.collider.name) return is_moving = true var floor_y = 0.05 if enhanced_gridmap and "cell_size" in enhanced_gridmap: floor_y = enhanced_gridmap.cell_size.y var target_world_pos = Vector3(target_pos.x + 0.2, floor_y, target_pos.y + 0.2) # Rotation var dir = target_world_pos - position if dir.length_squared() > 0.01: var target_rot = atan2(dir.x, dir.z) rotation.y = target_rot play_animation("tekton_move") tween = create_tween() tween.tween_property(self , "position", target_world_pos, movement_speed).set_trans(Tween.TRANS_LINEAR).set_ease(Tween.EASE_IN_OUT) tween.tween_callback(func(): current_position = target_pos is_moving = false play_animation("tekton_idle") emit_signal("movement_finished") ) if is_multiplayer_authority() and can_rpc(): rpc("sync_movement", target_pos) func can_rpc() -> bool: return multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED @rpc("any_peer", "call_remote", "reliable") func sync_movement(target_pos: Vector2i): move_to(target_pos) # --- COMBAT / INTERACTION --- func on_hit(attacker: Node = null, intensity: float = 1.0): """Called when hit by a player attack or knock. Intensity: 0.5 for throw, 1.0+ for knock.""" if is_static_turret: return print("[Tekton] Hit by %s! Intensity: %.1f" % [attacker.name if attacker else "Unknown", intensity]) # Visual Reaction (Flash red) _flash_damage() # Spawn Tiles if is_multiplayer_authority(): var tile_count = int(8 * intensity) # Base 8 tiles for 1.0 intensity spawn_tiles_around(tile_count) @rpc("any_peer", "call_local") func set_carried(state: bool, p_carrier: Node3D = null): if is_static_turret: return is_carried = state carrier = p_carrier if is_carried: play_animation("tekton_idle") is_moving = false if tween: tween.kill() # Disable/Enable controller var controller = get_node_or_null("TektonController") if controller: controller.set_physics_process(not state) if controller.get("timer"): if state: controller.timer.stop() else: controller.call("_start_timer") if not state and not is_thrown: update_visual_position() @rpc("any_peer", "call_local") func set_thrown(state: bool): if is_static_turret: return is_thrown = state if is_thrown: # If we are thrown, we are not carried anymore, but we must stay disabled if is_carried: set_carried(false) # This will re-enable controller, so we must disable it again below var controller = get_node_or_null("TektonController") if controller: controller.set_physics_process(false) if controller.get("timer"): controller.timer.stop() # play_animation("RESET") # Optional: ensure no animation plays? Or just idle. # User said "don't play ANY animation". # If we play nothing, it freezes on last frame. # Maybe just don't call anything new, and let previous (idle) stick? # Or stop? var anim_player = get_node_or_null("Visuals/tekton/Armature/AnimationPlayer") if not anim_player: anim_player = find_child("AnimationPlayer", true, false) if anim_player: anim_player.stop() func _process(delta): if is_carried and is_instance_valid(carrier): # Carry on head: offset Y by approx carrier height (e.g. 1.25) global_position = carrier.global_position + Vector3(0, 1.5, 0) rotation = carrier.rotation _update_prompt_label() var mesh_cache: Array[MeshInstance3D] = [] var original_scales: Array[Vector3] = [] var prompt_label: Label3D @onready var SettingsManager = get_node_or_null("/root/SettingsManager") func _update_prompt_label(): if not prompt_label: return if is_static_turret or is_carried or is_thrown or is_recovering: prompt_label.visible = false return var authority_player = null var players = get_tree().get_nodes_in_group("Players") for p in players: if p.name == str(multiplayer.get_unique_id()): authority_player = p break if not authority_player: prompt_label.visible = false return # Check distance var player_pos = Vector2(authority_player.current_position.x, authority_player.current_position.y) var tekton_pos = Vector2(current_position.x, current_position.y) if player_pos.distance_to(tekton_pos) > 1.5: prompt_label.visible = false return # Check power bar var pw_mgr = authority_player.get_node_or_null("PowerUpManager") if pw_mgr and pw_mgr.current_boost >= (pw_mgr.MAX_BOOST - 1): prompt_label.visible = true else: prompt_label.visible = false func _ready(): # Cache meshes and their initial scales _cache_meshes(self) prompt_label = Label3D.new() var shortcut_text = "G" if SettingsManager and SettingsManager.has_method("get_control_text"): shortcut_text = SettingsManager.get_control_text("tekton_grab") prompt_label.text = "[ " + str(shortcut_text) + " ]" prompt_label.font_size = 64 prompt_label.outline_size = 12 prompt_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED prompt_label.no_depth_test = true prompt_label.position = Vector3(0, 1.8, 0) prompt_label.modulate = Color(1.0, 0.9, 0.0) # Yellow text prompt_label.visible = false add_child(prompt_label) func _cache_meshes(node: Node): if node is MeshInstance3D: mesh_cache.append(node) original_scales.append(node.scale) for child in node.get_children(): _cache_meshes(child) func _flash_damage(): if mesh_cache.is_empty(): return var tween_flash = create_tween() for i in range(mesh_cache.size()): var mesh = mesh_cache[i] var original_scale = original_scales[i] # Pop effect tween_flash.parallel().tween_property(mesh, "scale", original_scale * 1.3, 0.1).set_trans(Tween.TRANS_BOUNCE).set_ease(Tween.EASE_OUT) tween_flash.parallel().tween_property(mesh, "scale", original_scale, 0.2).set_delay(0.1) var is_recovering: bool = false # True when shrunk/waiting var recovery_timer: Timer @rpc("any_peer", "call_local", "reliable") func on_thrown_landing(attacker: Node = null, intensity: float = 1.0): """Called when Tekton lands after being thrown or knocked.""" is_thrown = false # Prevent double-triggering (re-entrancy) if is_recovering: return is_recovering = true print("[Tekton] Landed/Knocked! Shrinking and waiting... (Intensity: %.1f)" % intensity) # _flash_damage() # Disabled to prevent tween conflict with shrinking # Disable movement/interaction logic temporarily var controller = get_node_or_null("TektonController") if controller and controller.get("timer"): controller.timer.stop() # Visual Shrink # Use cached meshes if available, else find them (but can't restore accurately if not cached) if mesh_cache.is_empty(): # Fallback if _ready hasn't run or failed pass for i in range(mesh_cache.size()): var mesh = mesh_cache[i] if is_instance_valid(mesh): var base_scale = original_scales[i] # Kill any existing tweens on this mesh to stop flash_damage or previous effects var t = create_tween() t.tween_property(mesh, "scale", base_scale * 0.5, 0.2).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK) # Spawn tiles (as requested "tekton will spawn a tiles around that floor also") if is_multiplayer_authority(): spawn_tiles_around(int(8 * intensity)) # Floor Freeze (Visual/Instant - Run on all clients locally) temporarily_change_floor(current_position, 1, 6, 3.0) # Wait 3.0 seconds await get_tree().create_timer(3.0).timeout # Grow back for i in range(mesh_cache.size()): var mesh = mesh_cache[i] if is_instance_valid(mesh): var base_scale = original_scales[i] var t = create_tween() t.tween_property(mesh, "scale", base_scale, 0.2).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK) is_recovering = false # Resume AI if controller and controller.has_method("_start_timer"): if is_multiplayer_authority() and not is_carried: controller._start_timer() func temporarily_change_floor(center: Vector2i, radius: int, new_id: int, duration: float): if not enhanced_gridmap: return # Run locally on all clients to ensure instant feedback without network delay var changed_cells = {} # pos: original_id for x in range(-radius, radius + 1): for y in range(-radius, radius + 1): var pos = center + Vector2i(x, y) if _is_position_blocked_by_stand(pos): continue if enhanced_gridmap.is_position_valid(pos): var cell_3d = Vector3i(pos.x, 0, pos.y) var original = enhanced_gridmap.get_cell_item(cell_3d) # PROTECTED FLOOR CHECK: avoid overwriting Start (1), Safe (2), Finish (3), Wall (4), Freeze (5), or PowerUp Stand (15) if original != new_id and not original in [1, 2, 3, 4, 5, 15]: # PRE-FIX: If we capture a "Hole" (Void or Pickup 7-14) here, we must record it as Floor (0) # so that we restore a valid floor later. if original == -1 or (original >= 7 and original <= 14): print("[Tekton] Warning: Captured floor at %s was ID %d (Invalid/Pickup). Recording as FLOOR (0)." % [pos, original]) original = 0 changed_cells[pos] = original # Set locally immediately enhanced_gridmap.set_cell_item(cell_3d, new_id) print("[Tekton] Applying Floor ID %d at %s (Replacing ID %d)" % [new_id, pos, original]) await get_tree().create_timer(duration).timeout # Restore locally var restored_count = 0 for pos in changed_cells: var original = changed_cells[pos] var current_cell = Vector3i(pos.x, 0, pos.y) var current = enhanced_gridmap.get_cell_item(current_cell) # Only restore if it hasn't been changed to something else in meantime if current == new_id: # Fallback: Fix "Hole" bugs where original was Void (-1) or a Pickup (7-14) # Pickups (7-14) on Floor Layer (0) cause holes because they lack floor geometry. # If we find a pickup here, we MUST restore to Floor (0) instead of the Pickup. if original == -1 or (original >= 7 and original <= 14): print("[Tekton] Warning: Restoring floor at %s was ID %d (Invalid/Pickup). Forcing to FLOOR (0)." % [pos, original]) original = 0 enhanced_gridmap.set_cell_item(current_cell, original) restored_count += 1 print("[Tekton] Restored %d/%d frozen floors at %s" % [restored_count, changed_cells.size(), center]) func spawn_tiles_around(count: int = 4): """Spawns a mix of normal and special tiles in a radius.""" if not enhanced_gridmap: return # Play throw animation if not is_carried and not is_thrown: play_animation("tekton_throw_tile") # Queue idle after animation finishes (approx 1.0s) get_tree().create_timer(1.0).timeout.connect(func(): if not is_moving and not is_carried and not is_thrown: play_animation("tekton_idle") ) var radius = 2 var rng = RandomNumberGenerator.new() rng.randomize() print("[Tekton] Spawning %d tiles around %s" % [count, current_position]) var spawned = 0 var attempts = 0 while spawned < count and attempts < 25: attempts += 1 var x = rng.randi_range(-radius, radius) var y = rng.randi_range(-radius, radius) var pos = current_position + Vector2i(x, y) # Don't overwrite the Tekton's own cell? Or do? # Maybe avoid center. if x == 0 and y == 0: continue # PHYSICS CHECK: Block spawning on Static Stands if _is_position_blocked_by_stand(pos): continue # EXTRA CHECK: Do not spawn tiles on walls (ID 4), empty void (ID -1), or static powerup spawns (ID 15) var floor_0_item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) # Stop n Go: Don't overwrite static powerup spawns if LobbyManager and LobbyManager.game_mode == "Stop n Go" and floor_0_item == 15: continue if floor_0_item in [4, -1]: continue # 50% chance to spawn something if rng.randf() > 0.5: continue # Determine Type var item_id: int var roll = rng.randf() if roll < 0.6 or (LobbyManager and LobbyManager.game_mode == "Stop n Go"): # 60% Normal Tile (7-10) OR 100% if Stop n Go (User Request) item_id = rng.randi_range(7, 10) else: # 40% PowerUp (11-14) var mode = GameMode.Mode.FREEMODE if LobbyManager: mode = LobbyManager.get_game_mode() if mode == GameMode.Mode.TEKTON_DOORS: item_id = [11, 14].pick_random() else: item_id = rng.randi_range(11, 14) if item_id != -1: var main = get_tree().get_root().get_node_or_null("Main") if main and can_rpc(): main.rpc("sync_grid_item", pos.x, 1, pos.y, item_id) spawned += 1 @rpc("call_local", "reliable") func play_animation_rpc(anim_name: String): play_animation(anim_name) @rpc("call_local", "reliable") func spawn_projectile_rpc(target_pos: Vector3, duration: float): var projectile = MeshInstance3D.new() var box = BoxMesh.new() box.size = Vector3(0.4, 0.1, 0.4) projectile.mesh = box var mat = StandardMaterial3D.new() mat.albedo_color = Color(1, 0.5, 0) # Orange projectile.material_override = mat get_parent().add_child(projectile) projectile.global_position = global_position + Vector3(0, 2.0, 0) var tween = create_tween() tween.set_parallel(true) tween.tween_property(projectile, "global_position:x", target_pos.x, duration).set_trans(Tween.TRANS_LINEAR) tween.tween_property(projectile, "global_position:z", target_pos.z, duration).set_trans(Tween.TRANS_LINEAR) var mid_y = max(global_position.y, target_pos.y) + 3.0 var tween_y = create_tween() tween_y.tween_property(projectile, "global_position:y", mid_y, duration / 2).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) tween_y.tween_property(projectile, "global_position:y", target_pos.y, duration / 2).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN).set_delay(duration / 2) tween.chain().tween_callback(projectile.queue_free) func play_animation(anim_name: String): # Try specific user path first var anim_player = get_node_or_null("Visuals/tekton/Armature/AnimationPlayer") # If not found, try finding recursive if not anim_player: anim_player = find_child("AnimationPlayer", true, false) if anim_player and anim_player.has_animation(anim_name): anim_player.play(anim_name) # print("[Tekton] Playing animation: %s" % anim_name) else: if not anim_player: print("[Tekton] AnimationPlayer NOT FOUND! Scene tree:") print_tree_pretty() else: print("[Tekton] Animation '%s' NOT FOUND in AnimationPlayer!" % anim_name) func _is_position_blocked_by_stand(target_pos: Vector2i) -> bool: # Raycast check for Static Tekton Stand (Layer 1, Body) # Check CENTER of tile (x+0.5) at LOW height (0.3) var from = Vector3(target_pos.x + 0.5, 0.3, target_pos.y + 0.5) + Vector3.UP * 2.0 var to = Vector3(target_pos.x + 0.5, 0.3, target_pos.y + 0.5) + Vector3.DOWN * 2.0 # We perform a vertical raycast through the potential stand volume var query = PhysicsRayQueryParameters3D.create(from, to) query.collide_with_areas = false query.collide_with_bodies = true var space_state = get_world_3d().direct_space_state var result = space_state.intersect_ray(query) if result: # If we hit something not ourselves/player, assume it's a stand/blocker if result.collider != self: return true return false