Files
tekton/scripts/tekton.gd
T

529 lines
18 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 = 1.0 # Static Tekton sits higher on the seat
else:
floor_y += 0.3 # Roaming Tekton sits slightly above the floor surface
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.6, target_pos.y + 0.5) + Vector3.UP * 5.0
var to = Vector3(target_pos.x + 0.5, 0.6, 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 + 0.3 # Add the same 0.3 offset while moving
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