527 lines
17 KiB
GDScript
527 lines
17 KiB
GDScript
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 = 0.6
|
|
|
|
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), or PowerUp Stand (15)
|
|
if original != new_id and not original in [1, 2, 3, 4, 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
|