extends Node # SpecialTilesManager - Handles special effects triggered by holo tile pickups # Holo tile indices (11-14) trigger special effects const HOLO_TILES = [11, 12, 13, 14] enum SpecialEffect { BURN_TILES, # Remove 3x3 pattern tiles on random opponent SPAWN_TILES, # Spawn 3x3 pattern tiles around activating player FREEZE_PLAYER, # Freeze random opponent for 3 seconds BLOCK_FLOOR, # Make nearby tile non-walkable for 9 seconds INVISIBLE_MODE # Speed boost + auto-grab + shield for 6 seconds } # Random shape patterns for 3x3 area (relative offsets from center) const PATTERNS = { "T": [Vector2i(0, -1), Vector2i(-1, 0), Vector2i(0, 0), Vector2i(1, 0)], "L": [Vector2i(0, -1), Vector2i(0, 0), Vector2i(0, 1), Vector2i(1, 1)], "I_H": [Vector2i(-1, 0), Vector2i(0, 0), Vector2i(1, 0)], "I_V": [Vector2i(0, -1), Vector2i(0, 0), Vector2i(0, 1)], "PLUS": [Vector2i(0, -1), Vector2i(-1, 0), Vector2i(0, 0), Vector2i(1, 0), Vector2i(0, 1)], "CORNER": [Vector2i(-1, -1), Vector2i(0, -1), Vector2i(-1, 0), Vector2i(0, 0)], "FULL": [Vector2i(-1, -1), Vector2i(0, -1), Vector2i(1, -1), Vector2i(-1, 0), Vector2i(0, 0), Vector2i(1, 0), Vector2i(-1, 1), Vector2i(0, 1)], "DOT3": [Vector2i(-1, 0), Vector2i(0, 0), Vector2i(1, 0)] } var player: Node3D var enhanced_gridmap: Node var rng: RandomNumberGenerator # Effect durations const FREEZE_DURATION = 3.0 const BLOCK_DURATION = 9.0 const INVISIBLE_DURATION = 6.0 # Active effect tracking var blocked_tiles: Array[Dictionary] = [] # {position: Vector3i, original_item: int, timer: float} var invisible_timer: float = 0.0 func initialize(p_player: Node3D, p_gridmap: Node): player = p_player enhanced_gridmap = p_gridmap rng = RandomNumberGenerator.new() rng.randomize() # ============================================================================= # Check if item is a holo tile # ============================================================================= func is_holo_tile(item_id: int) -> bool: return item_id in HOLO_TILES # ============================================================================= # Trigger random special effect # ============================================================================= func trigger_random_effect(): var effect = rng.randi() % SpecialEffect.size() print("[SpecialTiles] Player %s triggered effect: %s" % [player.name, SpecialEffect.keys()[effect]]) match effect: SpecialEffect.BURN_TILES: _execute_burn_tiles() SpecialEffect.SPAWN_TILES: _execute_spawn_tiles() SpecialEffect.FREEZE_PLAYER: _execute_freeze_player() SpecialEffect.BLOCK_FLOOR: _execute_block_floor() SpecialEffect.INVISIBLE_MODE: _execute_invisible_mode() # Sync effect to all clients if player.is_multiplayer_authority(): rpc("sync_effect_triggered", effect) @rpc("any_peer", "call_local", "reliable") func sync_effect_triggered(effect: int): print("[SpecialTiles] Synced effect %s for player %s" % [SpecialEffect.keys()[effect], player.name]) # ============================================================================= # Effect Implementations # ============================================================================= func _execute_burn_tiles(): # NEW LOGIC: Put back random target tiles from their playerboard to their position nearest # Find random opponent var opponent = _get_random_opponent() if not opponent: print("[SpecialTiles] No opponent found for BURN_TILES") return # Get opponent's playerboard items var board_indices = [] for i in range(opponent.playerboard.size()): if opponent.playerboard[i] != -1: board_indices.append(i) if board_indices.is_empty(): return # Nothing to burn # Pick random 1x (3x3 equivalent = ~3-4 tiles) or 2x amount # Let's say we burn 3 to 6 tiles var burn_count = rng.randi_range(3, 6) board_indices.shuffle() var tiles_burned = 0 # Get valid empty spots near opponent to dump tiles var empty_spots = _get_empty_neighbors_recursive(opponent.current_position, 2) empty_spots.shuffle() for i in range(min(burn_count, board_indices.size())): var slot_idx = board_indices[i] var item = opponent.playerboard[slot_idx] # Remove from opponent board opponent.playerboard[slot_idx] = -1 # Determine where to put it var target_pos = Vector3i.ZERO var target_item = item if not empty_spots.is_empty(): # Place on empty spot var pos_2d = empty_spots.pop_back() target_pos = Vector3i(pos_2d.x, 1, pos_2d.y) else: # No empty spots? "Replace it with new one" at a random nearby non-empty spot? # Or just find ANY nearby spot and overwrite var neighbors = enhanced_gridmap.get_neighbors(opponent.current_position, 1) if not neighbors.is_empty(): var rand_n = neighbors[rng.randi() % neighbors.size()] target_pos = Vector3i(rand_n.position.x, 1, rand_n.position.y) # If we are overwriting or essentially "spawning" a new one to replace it target_item = rng.randi_range(7, 10) # As per request "replace it with new one" if floor not empty if target_pos != Vector3i.ZERO: if player.is_multiplayer_authority(): var main = player.get_tree().get_root().get_node_or_null("Main") if main: main.rpc("sync_grid_item", target_pos.x, target_pos.y, target_pos.z, target_item) # Sync opponent board change main.rpc("sync_playerboard", opponent.name.to_int(), opponent.playerboard) tiles_burned += 1 if tiles_burned > 0: # Trigger screen shake if opponent.is_multiplayer_authority(): opponent.rpc("trigger_screen_shake", "targeted") else: opponent.rpc_id(opponent.get_multiplayer_authority(), "trigger_screen_shake", "targeted") print("[SpecialTiles] BURN_TILES: Knocked %d tiles from %s" % [tiles_burned, opponent.name]) player.rpc("display_message", "Knocked tiles from %s!" % opponent.display_name) opponent.rpc("display_message", "%s knocked tiles out of your bag!" % player.display_name) func _execute_spawn_tiles(): # NEW LOGIC: Spawn more in neighbor space (radius 2) var radius = 2 var candidates = [] for x in range(-radius, radius + 1): for y in range(-radius, radius + 1): if x == 0 and y == 0: continue var pos = player.current_position + Vector2i(x, y) if enhanced_gridmap.is_position_valid(pos): var cell = Vector3i(pos.x, 1, pos.y) if enhanced_gridmap.get_cell_item(cell) == -1: candidates.append(cell) var spawn_count = rng.randi_range(3, 8) # Spawn a bunch candidates.shuffle() var actual_spawned = 0 for i in range(min(spawn_count, candidates.size())): var cell = candidates[i] var new_tile = rng.randi_range(7, 10) if player.is_multiplayer_authority(): var main = player.get_tree().get_root().get_node_or_null("Main") if main: main.rpc("sync_grid_item", cell.x, cell.y, cell.z, new_tile) actual_spawned += 1 print("[SpecialTiles] SPAWN_TILES: Spawned %d tiles around %s" % [actual_spawned, player.name]) player.rpc("display_message", "Spawned tiles nearby!") func _execute_freeze_player(): # Find random opponent var opponent = _get_random_opponent() if not opponent: print("[SpecialTiles] No opponent found for FREEZE_PLAYER") return # Freeze the opponent if opponent.has_method("apply_freeze"): opponent.apply_freeze(FREEZE_DURATION) else: # Fallback: directly set frozen state opponent.set("is_frozen", true) _create_unfreeze_timer(opponent, FREEZE_DURATION) # Trigger screen shake on the frozen opponent if opponent.is_multiplayer_authority(): opponent.rpc("trigger_screen_shake", "targeted") else: opponent.rpc_id(opponent.get_multiplayer_authority(), "trigger_screen_shake", "targeted") print("[SpecialTiles] FREEZE_PLAYER: Froze %s for %ds" % [opponent.name, FREEZE_DURATION]) player.rpc("display_message", "Froze %s!" % opponent.display_name) opponent.rpc("display_message", "%s froze you!" % player.display_name) # Visual effect: Ice Blue # Use RPC to sync visual effect to everyone (call_local handles our screen) if opponent.has_method("sync_modulate"): opponent.rpc("sync_modulate", Color(0.5, 0.8, 1.0)) # Standard players sync via network transform but modulation might not sync automatically unless handled. # Let's hope basic property sync or local effect handles it enough for now, # but ideally we should RPC a visual update method on the player. # Checking player.gd again, there isn't a sync_modulate. # We can just set it locally and rely on the RPCs below for syncing the EFFECT STATUS, # but we should probably RPC the color change to be sure everyone sees it. # Actually, since we don't have a generic sync_proeprty, we will just set it locally on the authority # and rely on the target itself to perhaps propogate it? No, that won't work traversing network. # We need a way to tell clients "Painter this player blue". # The simplest safe way without modifying Player.gd extensively is to rpc a method call if available, # or just set it on the proxy if we are the server. # But special_tiles is running on the player who TRIGGERED it. # If I am client A, targeting client B. I am authority of ME. B is authority of B. # I can't set properties on B directly and expect them to sync. # I must RPC B to freeze himself. # The _execute_freeze_player logic calls opponent.apply_freeze or sets is_frozen. # If opponent has authority, they will run their own logic? # Wait, special_tiles_manager runs on the client who picked up the tile? # "if player.is_multiplayer_authority(): rpc(...)" implies we are the authority of the player who picked it up. # We find an opponent (which is a proxy version on our machine). # We call methods on that proxy. # "opponent.rpc(...)" sends a message to the authority of that opponent. # So we should validly call an RPC on opponent to change color. # But Player.gd doesn't have "set_modulate_rpc". # Use "set" works locally. # We need to add visual sync support to Player.gd or just rely on what we have. # Given constraints, I'll add the modulate locally and maybe the opponent-side logic should handle it? # _create_unfreeze_timer runs on OUR machine mostly? No, "await player.get_tree()..." # If we are A, targeting B. # We call opponent.apply_freeze(). If B has that method, good. # If B lacks it, we set is_frozen on B's proxy and run a timer on A's machine? # That only freezes B on A's screen if logic relies on is_frozen? # Actually, `opponent.rpc("display_message", ...)` works. # Let's add a `sync_visual_effect` to Player.gd if needed, or just standard property setting if supported. # For now, I will just set it and see if I can add a dedicated RPC in Player.gd in the next step if this is insufficient, # OR better: I'll use `opponent.rpc("sync_modulate", ...)` and add that method to Player.gd in a separate tool call. # For this tool call, I'll update the text and set local modulate. func _create_unfreeze_timer(target_player: Node3D, duration: float): if not is_instance_valid(player) or not is_instance_valid(target_player): return await player.get_tree().create_timer(duration).timeout if is_instance_valid(target_player): target_player.set("is_frozen", false) # Reset visuals if target_player.has_method("sync_modulate"): target_player.rpc("sync_modulate", Color.WHITE) target_player.rpc("display_message", "Unfrozen!") func _execute_block_floor(): # NEW LOGIC: Block 3 to 9 tiles in a line (Horizontal/Vertical/Diagonal) # Find valid start neighbor var neighbors = enhanced_gridmap.get_neighbors(player.current_position, 0) var valid_neighbors = neighbors.filter(func(n): return n.is_walkable) if valid_neighbors.is_empty(): return var start_neighbor = valid_neighbors[rng.randi() % valid_neighbors.size()] var start_pos = start_neighbor.position # Random direction: H, V, D1, D2 var directions = [ Vector2i(1, 0), Vector2i(-1, 0), # Horizontal Vector2i(0, 1), Vector2i(0, -1), # Vertical Vector2i(1, 1), Vector2i(-1, -1), # Diagonal Vector2i(1, -1), Vector2i(-1, 1) ] var dir = directions[rng.randi() % directions.size()] var count = rng.randi_range(3, 9) var valid_block_count = 0 for i in range(count): var target_pos_2d = start_pos + (dir * i) # Check if valid grid position if not enhanced_gridmap.is_position_valid(target_pos_2d): break # Stop if we hit edge of map var block_pos = Vector3i(target_pos_2d.x, 0, target_pos_2d.y) var original_item = enhanced_gridmap.get_cell_item(block_pos) # Make tile non-walkable var blocked_item = 4 if enhanced_gridmap.non_walkable_items.size() > 0: blocked_item = enhanced_gridmap.non_walkable_items[0] if player.is_multiplayer_authority(): var main = player.get_tree().get_root().get_node_or_null("Main") if main: main.rpc("sync_grid_item", block_pos.x, block_pos.y, block_pos.z, blocked_item) blocked_tiles.append({ "position": block_pos, "original_item": original_item, "timer": BLOCK_DURATION }) valid_block_count += 1 if valid_block_count > 0: enhanced_gridmap.initialize_astar() print("[SpecialTiles] BLOCK_FLOOR: Blocked line of %d tiles" % valid_block_count) player.rpc("display_message", "Blocked a wall of tiles!") func _execute_invisible_mode(): # Set invisible mode on player # NEW LOGIC: Also enables auto-grab in _process if player.has_method("apply_invisible_mode"): player.apply_invisible_mode(INVISIBLE_DURATION) else: player.set("is_invisible", true) player.set("original_movement_range", player.movement_range) player.movement_range = player.movement_range + 2 invisible_timer = INVISIBLE_DURATION print("[SpecialTiles] INVISIBLE_MODE: Activated") player.rpc("display_message", "Invisible Mode Active!") func _process(delta): _update_blocked_tiles(delta) _update_invisible_timer(delta) func _update_invisible_timer(delta: float): if invisible_timer > 0: invisible_timer -= delta if invisible_timer <= 0: invisible_timer = 0 if is_instance_valid(player): player.set("is_invisible", false) if player.get("original_movement_range"): player.movement_range = player.original_movement_range player.rpc("display_message", "Invisible mode ended!") # ============================================================================= # Helper Functions # ============================================================================= func _get_random_opponent() -> Node3D: var all_players = player.get_tree().get_nodes_in_group("Players") var opponents = all_players.filter(func(p): return p != player) if opponents.is_empty(): return null return opponents[rng.randi() % opponents.size()] func _get_empty_neighbors_recursive(center: Vector2i, radius: int) -> Array[Vector2i]: var result: Array[Vector2i] = [] 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): if enhanced_gridmap.get_cell_item(Vector3i(pos.x, 1, pos.y)) == -1: result.append(pos) return result func _update_blocked_tiles(delta: float): var tiles_to_restore: Array[int] = [] for i in range(blocked_tiles.size()): blocked_tiles[i].timer -= delta if blocked_tiles[i].timer <= 0: tiles_to_restore.append(i) # Restore tiles in reverse order to maintain indices tiles_to_restore.reverse() for idx in tiles_to_restore: var tile_data = blocked_tiles[idx] if player.is_multiplayer_authority(): var main = player.get_tree().get_root().get_node_or_null("Main") if main: main.rpc("sync_grid_item", tile_data.position.x, tile_data.position.y, tile_data.position.z, tile_data.original_item) blocked_tiles.remove_at(idx) if tiles_to_restore.size() > 0: enhanced_gridmap.initialize_astar() # ============================================================================= # Shield Check (for Invisible Mode) # ============================================================================= func check_shield_and_cancel_effect() -> bool: """Returns true if player has shield (invisible mode) and cancels the incoming effect.""" if player.get("is_invisible"): player.set("is_invisible", false) invisible_timer = 0 # Cancel timer if player.get("original_movement_range"): player.movement_range = player.original_movement_range player.rpc("display_message", "Shield blocked an attack!") return true return false