Files
tekton/scripts/tekton.gd
T

305 lines
9.5 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 = 0.4 # Seconds per step
var enhanced_gridmap: Node
var is_moving: bool = false
var is_carried: 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: 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)
position = Vector3(current_position.x + 0.2, floor_y, current_position.y + 0.2)
func move_to(target_pos: Vector2i):
if is_moving or is_carried: return
# Validate
if not enhanced_gridmap.is_position_valid(target_pos):
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
tween = create_tween()
tween.tween_property(self , "position", target_world_pos, movement_speed).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
tween.tween_callback(func():
current_position = target_pos
is_moving = false
emit_signal("movement_finished")
)
if is_multiplayer_authority():
rpc("sync_movement", target_pos)
@rpc("any_peer", "call_remote", "reliable")
func sync_movement(target_pos: Vector2i):
# Clients execute the move visually
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."""
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):
is_carried = state
carrier = p_carrier
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:
update_visual_position()
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
var mesh_cache: Array[MeshInstance3D] = []
var original_scales: Array[Vector3] = []
func _ready():
# Cache meshes and their initial scales
# We wait a frame to ensure all children are ready and transforms applied
await get_tree().process_frame
var meshes = find_children("*", "MeshInstance3D", true)
for mesh in meshes:
mesh_cache.append(mesh)
original_scales.append(mesh.scale)
func _flash_damage():
# If cache empty (e.g. called before ready), try to populate or just skip custom scaling
if mesh_cache.is_empty():
return
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 * 1.2, 0.1)
t.tween_property(mesh, "scale", base_scale, 0.1)
var is_recovering: bool = false # True when shrunk/waiting
@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."""
# Prevent double-triggering (re-entrancy)
if is_recovering:
return
is_recovering = true
print("[Tekton] Landed/Knocked! Shrinking and waiting... (Intensity: %.1f)" % intensity)
_flash_damage() # Add visual flash too? Why not.
# 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
# We'll just define the user's specific vector as fallback target for the sphere
# But better to rely on cache.
pass
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.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 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 enhanced_gridmap.is_position_valid(pos):
var cell_3d = Vector3i(pos.x, 0, pos.y)
var original = enhanced_gridmap.get_cell_item(cell_3d)
# Only change if not already the new ID (avoid redundant updates or overriding existing freeze)
if original != new_id:
# 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
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
if enhanced_gridmap.is_position_valid(pos):
# 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:
# 60% Normal Tile (7-10)
item_id = rng.randi_range(7, 10)
elif roll < 0.9:
# 30% PowerUp (11-14)
item_id = rng.randi_range(11, 14)
else:
# 10% Obstacle/Trap (optional)
item_id = -1 # Clear?
if item_id != -1:
var main = get_tree().get_root().get_node_or_null("Main")
if main:
main.rpc("sync_grid_item", pos.x, 1, pos.y, item_id)
spawned += 1