diff --git a/CHANGELOG_DRAFT.md b/CHANGELOG_DRAFT.md index 81c8479..6696018 100644 --- a/CHANGELOG_DRAFT.md +++ b/CHANGELOG_DRAFT.md @@ -152,7 +152,17 @@ - Fragment Craft system — collect drops to craft exclusive skins - Fixed boot screen stuck on "Checking versions..." -## [2.4.1] — $(date +"%Y-%m-%d") +## [2.4.2] — 2026-07-03 +- Replaced the **Cleanser** mechanic in Gauntlet mode with a **Ghost powerup** sticky-bypass system. +- Ghost (Invisible Mode) now lets players walk through sticky candy tiles in Gauntlet — no more hard block. +- Players earn a Ghost powerup every 2 completed missions in Gauntlet (replaces Cleanser charge grants). +- Ghost powerup tiles now spawn naturally on the Gauntlet arena (15% chance alongside common tiles). +- Removed Cleanser HUD elements (icon, label, charge counter) from the Gauntlet overlay. +- Removed `use_cleanser` input action — Ghost uses the existing powerup activation keybind. +- Bots now activate Ghost powerup when boxed in by sticky tiles instead of using Cleanser. +- Players pushed into sticky tiles while in Ghost mode are no longer slowed. + +## [2.4.1] — 2026-06-28 - Fixed Gauntlet map layout to remove red unpassable barrier blocks and center blocks. They are now standard walkable floors but act as hard blockers in physics so players cannot pass them. - Fixed Gauntlet mode to prevent powerups or sticky bubbles from spawning on boundary tiles or under the central cannon. - Center Candy Cannon now shoots actual projectiles that fly towards sticky cells and leave a VFX trail behind them. diff --git a/assets/data/version.json b/assets/data/version.json index 9cec39b..fa448ef 100644 --- a/assets/data/version.json +++ b/assets/data/version.json @@ -1,7 +1,22 @@ { - "latest_version": "2.4.1", + "latest_version": "2.4.2", "minimum_app_version": "2.1.0", "releases": [ + { + "version": "2.4.2", + "date": "2026-07-03", + "pck_url": "https://raw.githubusercontent.com/adtpdn/tekton-updates/main/latest/patch.pck", + "pck_size": 0, + "changelog": [ + "Replaced Cleanser mechanic in Gauntlet with Ghost powerup sticky-bypass system.", + "Ghost (Invisible Mode) now lets players walk through sticky candy tiles in Gauntlet.", + "Players earn a Ghost powerup every 2 completed missions in Gauntlet.", + "Ghost powerup tiles now spawn naturally on the Gauntlet arena (15% chance).", + "Removed Cleanser HUD elements from Gauntlet overlay.", + "Bots now activate Ghost powerup when boxed in by sticky tiles.", + "Players pushed into sticky tiles while in Ghost mode are no longer slowed." + ] + }, { "version": "2.4.1", "date": "2026-06-28", diff --git a/export_presets.cfg b/export_presets.cfg index 0dbdd51..95990e0 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -1,14 +1,20 @@ +[runnable_presets] + +"Windows Desktop"="Windows Desktop" +Android="Android" +macOS="macOS" +Linux="Linux/X11" + [preset.0] name="Windows Desktop" platform="Windows Desktop" -runnable=true dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/tekton_armageddon_v2.4.1.exe" +export_path="build/windows/tekton_armageddon_v2.4.2.exe" patches=PackedStringArray() patch_delta_encoding=false patch_delta_compression_level_zstd=19 @@ -42,8 +48,8 @@ application/modify_resources=false application/icon="" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="2.4.1" -application/product_version="2.4.1" +application/file_version="2.4.2" +application/product_version="2.4.2" application/company_name="DanchieGo" application/product_name="Tekton Armageddon" application/file_description="" @@ -74,13 +80,12 @@ Remove-Item -Recurse -Force '{temp_dir}'" name="Android" platform="Android" -runnable=true dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/tekton-dash-armageddon-v.2.4.1.apk" +export_path="build/tekton-dash-armageddon-v.2.4.2.apk" patches=PackedStringArray() patch_delta_encoding=false patch_delta_compression_level_zstd=19 @@ -111,7 +116,7 @@ architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false version/code=3 -version/name="2.4.1" +version/name="2.4.2" package/unique_name="com.danchiego.$genname" package/name="Tekton Dash Armageddon" package/signed=true @@ -136,11 +141,12 @@ screen/support_normal=true screen/support_large=true screen/support_xlarge=true screen/background_color=Color(0, 0, 0, 1) +splash_screen/disable_godot_boot_splash=false +splash_screen/icon="" +splash_screen/branding_image="" +splash_screen/background_color=Color(0, 0, 0, 1) user_data_backup/allow=false command_line/extra_args="" -apk_expansion/enable=false -apk_expansion/SALT="" -apk_expansion/public_key="" permissions/custom_permissions=PackedStringArray() permissions/access_checkin_properties=false permissions/access_coarse_location=false @@ -295,18 +301,20 @@ permissions/write_sms=false permissions/write_social_stream=false permissions/write_sync_settings=false permissions/write_user_dictionary=false +apk_expansion/enable=false +apk_expansion/SALT="" +apk_expansion/public_key="" [preset.2] name="macOS" platform="macOS" -runnable=true dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/tekton_armageddon_v2.4.1.zip" +export_path="build/tekton_armageddon_v2.4.2.zip" patches=PackedStringArray() patch_delta_encoding=false patch_delta_compression_level_zstd=19 @@ -565,8 +573,8 @@ codesign/digest_algorithm=1 codesign/identity_type=0 application/modify_resources=false application/console_wrapper_icon="" -application/file_version="2.4.1" -application/product_version="2.4.1" +application/file_version="2.4.2" +application/product_version="2.4.2" application/company_name="DanchieGo" application/product_name="Tekton Armageddon" application/file_description="" @@ -576,13 +584,12 @@ application/trademarks="" name="Linux/X11" platform="Linux" -runnable=true dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/tekton_armageddon_v2.4.1.x86_64" +export_path="build/linux/tekton_armageddon_v2.4.22.x86_64" patches=PackedStringArray() patch_delta_encoding=false patch_delta_compression_level_zstd=19 diff --git a/project.godot b/project.godot index f04477c..50df99b 100644 --- a/project.godot +++ b/project.godot @@ -17,7 +17,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true config/name="Tekton Dash Armageddon" config/version="2.4.1" run/main_scene="res://scenes/ui/boot_screen.tscn" -config/features=PackedStringArray("4.6", "Forward Plus") +config/features=PackedStringArray("4.7", "Forward Plus") boot_splash/bg_color=Color(0.16470589, 0.6745098, 0.9372549, 1) boot_splash/stretch_mode=0 boot_splash/image="uid://b10e6kr508642" @@ -27,7 +27,7 @@ boot_splash/minimum_display_time=2 [autoload] GameUpdateManager="*res://scripts/managers/game_update_manager.gd" -Nakama="*res://addons/com.heroiclabs.nakama/Nakama.gd" +Nakama="*uid://bueyqhhvxe0tx" NakamaManager="*res://scripts/nakama_manager.gd" AuthManager="*res://scripts/managers/auth_manager.gd" LobbyManager="*res://scripts/managers/lobby_manager.gd" @@ -37,7 +37,7 @@ TurnManager="*res://scripts/managers/turn_manager.gd" GoalManager="*res://scripts/managers/goal_manager.gd" PlayerManager="*res://scripts/managers/player_manager.gd" GoalsCycleManager="*res://scripts/managers/goals_cycle_manager.gd" -Satori="*res://addons/com.heroiclabs.nakama/Satori.gd" +Satori="*uid://b8vev00s34b7" SettingsManager="*res://scripts/managers/settings_manager.gd" SfxManager="*res://scripts/managers/sfx_manager.gd" NameGenerator="*res://scripts/generators/name_generator.gd" @@ -67,8 +67,11 @@ enabled=PackedStringArray("res://addons/com.heroiclabs.nakama/plugin.cfg", "res: [file_customization] folder_colors={ +"res://addons/": "pink", "res://assets/": "purple", -"res://scenes/": "green" +"res://scenes/": "green", +"res://scripts/": "green", +"res://server/": "blue" } [input] @@ -140,12 +143,6 @@ use_powerup={ , Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":false,"script":null) ] } -use_cleanser={ -"deadzone": 0.2, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) -, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":2,"pressure":0.0,"pressed":false,"script":null) -] -} action_grab_tekton={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":103,"location":0,"echo":false,"script":null) diff --git a/scenes/gauntlet_hud.tscn b/scenes/gauntlet_hud.tscn index a50d1b6..a80c02a 100644 --- a/scenes/gauntlet_hud.tscn +++ b/scenes/gauntlet_hud.tscn @@ -47,22 +47,3 @@ theme_override_constants/outline_size = 6 theme_override_fonts/font = ExtResource("1_font") text = "🍬 OPEN ARENA" horizontal_alignment = 1 - -[node name="CleanserHBox" type="HBoxContainer" parent="BottomContainer/VBoxContainer"] -layout_mode = 2 -theme_override_constants/separation = 6 -alignment = 1 - -[node name="CleanserIcon" type="TextureRect" parent="BottomContainer/VBoxContainer/CleanserHBox"] -layout_mode = 2 -custom_minimum_size = Vector2(20, 20) -stretch_mode = 5 - -[node name="CleanserLabel" type="Label" parent="BottomContainer/VBoxContainer/CleanserHBox"] -layout_mode = 2 -theme_override_font_sizes/font_size = 20 -theme_override_colors/font_outline_color = Color(0, 0, 0, 1) -theme_override_constants/outline_size = 6 -theme_override_fonts/font = ExtResource("1_font") -text = "[E] Cleanser (0)" -horizontal_alignment = 1 diff --git a/scripts/bot_controller.gd b/scripts/bot_controller.gd index e5880a4..d1b8512 100644 --- a/scripts/bot_controller.gd +++ b/scripts/bot_controller.gd @@ -210,9 +210,9 @@ func _run_ai_tick(): print("[BotController] Action Taken: Attack Pursuit") return - # Priority 0.5: Gauntlet (#075) — burn Cleanser if boxed in - if await _try_activate_cleanser(): - print("[BotController] Action Taken: Cleanser (trapped)") + # Priority 0.5: Gauntlet (#075) — use Ghost powerup if boxed in + if await _try_activate_ghost(): + print("[BotController] Action Taken: Ghost (trapped)") return # Priority 1: Tekton Management (Grab Tekton if full boost, or spawn if carrying) @@ -260,27 +260,24 @@ func _run_ai_tick(): return # ============================================================================= -# Gauntlet (#075) — Cleanser + Sticky Avoidance wiring +# Gauntlet (#075) — Ghost Powerup + Sticky Avoidance wiring # ============================================================================= -func _try_activate_cleanser() -> bool: - """Activate Cleanser when the planner reports imminent danger. +func _try_activate_ghost() -> bool: + """Activate Ghost powerup when the planner reports imminent danger. - Server-authoritative RPC; we only request it. Returns true if the request - was sent successfully (not a guarantee it landed on a sticky cell).""" + Uses the existing SpecialTilesManager to activate the held ghost powerup. + Returns true if activation was triggered.""" if not strategic_planner or not strategic_planner.is_gauntlet_mode(): return false - if not strategic_planner.should_activate_cleanser_now(): + if not strategic_planner.should_activate_ghost_now(): return false - var gm = strategic_planner._get_gauntlet_manager() - if not gm: + var stm = actor.get_node_or_null("SpecialTilesManager") + if not stm: return false - var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int() - if pid == null or pid < 0: - return false - if gm.has_method("rpc_activate_cleanser"): - gm.rpc_activate_cleanser(pid) - print("[BotController] %s requested Cleanser activation (trapped)" % actor.name) + if stm.has_method("activate_effect"): + stm.activate_effect(stm.SpecialEffect.INVISIBLE_MODE) + print("[BotController] %s activated Ghost powerup (trapped)" % actor.name) return true return false @@ -292,14 +289,14 @@ func _on_step_onto_unsafe() -> bool: var here = actor.current_position if "current_position" in actor else Vector2i(-1, -1) if here == Vector2i(-1, -1): return false - # Post-move guard: if we somehow landed on a sticky without cleanser active, - # burn Cleanser to clear ourselves out next tick. + # Post-move guard: if we somehow landed on a sticky without ghost active, + # burn Ghost powerup to phase through next tick. if strategic_planner.is_gauntlet_mode() and strategic_planner._is_overlay_unsafe(here): - if not strategic_planner._is_bot_cleanser_active(): + if not strategic_planner._is_bot_ghost_active(): var gm = strategic_planner._get_gauntlet_manager() if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(here): - print("[BotController] %s stepped onto sticky at %s — burning Cleanser" % [actor.name, here]) - return _try_activate_cleanser() + print("[BotController] %s stepped onto sticky at %s — activating Ghost" % [actor.name, here]) + return _try_activate_ghost() return false # ============================================================================= diff --git a/scripts/bot_strategic_planner.gd b/scripts/bot_strategic_planner.gd index b68c209..8658f01 100644 --- a/scripts/bot_strategic_planner.gd +++ b/scripts/bot_strategic_planner.gd @@ -17,7 +17,7 @@ const GOAL_TILES = [7, 8, 9, 10] # Heart, Diamond, Star, Coin const HOLO_TILES = [11, 12, 13, 14] # Power-up holo tiles # Gauntlet overlay layer (v2 ground-growth model — sticky/telegraph on layer 2). -# Bots must avoid these cells or burn a Cleanser charge to cross. +# Bots must avoid these cells or use Ghost mode to cross. const GAUNTLET_OVERLAY_LAYER: int = 2 const TILE_STICKY: int = 17 const TILE_TELEGRAPH: int = 18 @@ -61,23 +61,16 @@ func _get_gauntlet_manager() -> Node: return gm2 return null -func _bot_has_cleanser_charge() -> bool: - var gm = _get_gauntlet_manager() - if not gm or not "player_cleansers" in gm: +func _bot_has_ghost_powerup() -> bool: + """Check if the bot has a ghost powerup in its SpecialTilesManager inventory.""" + var stm = actor.get_node_or_null("SpecialTilesManager") + if not stm: return false - var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int() - if pid == null or pid < 0: - return false - return gm.player_cleansers.get(pid, 0) > 0 + return stm.inventory.get(stm.SpecialEffect.INVISIBLE_MODE, false) -func _is_bot_cleanser_active() -> bool: - var gm = _get_gauntlet_manager() - if not gm: - return false - var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int() - if pid == null or pid < 0: - return false - return gm.is_cleanser_active(pid) +func _is_bot_ghost_active() -> bool: + """Check if the bot is currently in ghost (invisible) mode.""" + return actor.get("is_invisible") == true func _is_overlay_unsafe(pos: Vector2i) -> bool: """True if the cell carries a sticky or telegraphed overlay on layer 2.""" @@ -88,10 +81,10 @@ func _is_overlay_unsafe(pos: Vector2i) -> bool: func _is_cell_unsafe_in_gauntlet(pos: Vector2i) -> bool: """Cell is unsafe in Gauntlet if it's sticky/telegraphed — unless the bot's - Cleanser is active (grants temporary immunity).""" + Ghost mode is active (grants sticky bypass).""" if not is_gauntlet_mode(): return false - if _is_bot_cleanser_active(): + if _is_bot_ghost_active(): return false var gm = _get_gauntlet_manager() if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(pos): @@ -106,13 +99,13 @@ func _count_unsafe_neighbors(pos: Vector2i) -> int: count += 1 return count -func should_activate_cleanser_now() -> bool: - """True if the bot is boxed in / about to be sealed and should burn Cleanser.""" +func should_activate_ghost_now() -> bool: + """True if the bot is boxed in / about to be sealed and should use Ghost powerup.""" if not is_gauntlet_mode(): return false - if not _bot_has_cleanser_charge(): + if not _bot_has_ghost_powerup(): return false - if _is_bot_cleanser_active(): + if _is_bot_ghost_active(): return false var here = actor.current_position if actor and "current_position" in actor else Vector2i(-1, -1) if here == Vector2i(-1, -1): @@ -623,7 +616,7 @@ func _is_valid_move_target(pos: Vector2i, ignore_players: bool = false) -> bool: # Gauntlet mode (#075): reject cells that are sticky or telegraphed — # stepping onto them either traps the bot or strands it within 1s. # Safety applies even when ignore_players is true (a sticky cell is unsafe - # regardless of whether another player is on it). Cleanser-active bots are + # regardless of whether another player is on it). Ghost-active bots are # exempt via the helper. if _is_cell_unsafe_in_gauntlet(pos): return false diff --git a/scripts/managers/gauntlet_manager.gd b/scripts/managers/gauntlet_manager.gd index ccd1ac7..834be54 100644 --- a/scripts/managers/gauntlet_manager.gd +++ b/scripts/managers/gauntlet_manager.gd @@ -7,7 +7,7 @@ class_name GauntletManager signal phase_changed(phase_index: int, phase_name: String) signal growth_tick(cells: Array) signal player_trapped(player_id: int) -signal cleanser_granted(player_id: int) +signal ghost_granted(player_id: int) # ============================================================================= # Constants @@ -31,10 +31,9 @@ enum CellState { STICKY, # Covered in sticky candy, blocks + traps BUBBLE_GROWING, # Candy bubble growing, not yet exploded BLOCKED, # NPC zone or permanent obstacle - CLEANSED, # Recently cleaned by Cleanser (temp protection) } -# Cells temporarily protected after a Cleanser pass (Vector2i -> time remaining). +# Cells temporarily protected after Ghost-clearing (not used — kept for compat). var cleansed_cells: Dictionary = {} const CLEANSED_PROTECTION_TIME: float = 5.0 @@ -146,15 +145,10 @@ const SMACK_COOLDOWN: float = 8.0 const SMACK_CHARGE_WINDOW: float = 3.0 # ============================================================================= -# Cleanser Tracking +# Ghost Reward Tracking (replaces Cleanser) # ============================================================================= var player_mission_completions: Dictionary = {} # player_id → int -var player_cleansers: Dictionary = {} # player_id → int (0 or 1) -var cleanser_active: Dictionary = {} # player_id → true when immunity active -var cleanser_cells_left: Dictionary = {} # player_id → int (cells remaining) -const CLEANSER_MAX_CELLS: int = 5 -const CLEANSER_ACTIVATION_DELAY: float = 0.3 # ============================================================================= # Trapped Players @@ -189,9 +183,6 @@ var pump_instance: Node3D = null # HUD var hud_layer: CanvasLayer var phase_label: Label -var cleanser_label: Label -var cleanser_icon: TextureRect -var cleanser_count: int = 0 var slowmo_label: Label var _gauntlet_hud_scene: PackedScene = preload("res://scenes/gauntlet_hud.tscn") @@ -284,10 +275,6 @@ func _process(delta: float) -> void: elif not multiplayer.is_server(): player.sync_modulate(Color.WHITE) - # Cleanser input (local player only) - if Input.is_action_just_pressed("use_cleanser"): - _try_use_cleanser() - # Slow-mo timer (all peers for visual consistency) if slowmo_active: slowmo_timer -= delta @@ -1016,56 +1003,6 @@ func _spawn_impact_particles(targets: Array) -> void: if particles and is_instance_valid(particles): particles.queue_free() -# ============================================================================= -func _spawn_cleanser_particles(pos: Vector2i) -> void: - """Spawn bright cleansing particles when sticky is cleared.""" - if not main_scene or not gridmap: - return - - var world_pos = Vector3( - pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0, - 0.5, - pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 - ) - - var particles = GPUParticles3D.new() - particles.emitting = true - particles.one_shot = true - particles.amount = 12 - particles.lifetime = 0.6 - particles.explosiveness = 0.9 - - var material = ParticleProcessMaterial.new() - material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE - material.emission_sphere_radius = 0.3 - material.direction = Vector3(0, 1, 0) - material.spread = 180.0 - material.initial_velocity_min = 3.0 - material.initial_velocity_max = 5.0 - material.gravity = Vector3(0, -5.0, 0) - material.scale_min = 0.05 - material.scale_max = 0.15 - - var mesh = SphereMesh.new() - mesh.radius = 0.2 - mesh.height = 0.4 - var spatial_mat = StandardMaterial3D.new() - spatial_mat.albedo_color = Color(0.2, 1.0, 1.0) # Cyan/Blue for cleanser - spatial_mat.emission_enabled = true - spatial_mat.emission = Color(0.2, 1.0, 1.0) - spatial_mat.emission_energy_multiplier = 3.0 - mesh.material = spatial_mat - particles.draw_pass_1 = mesh - - particles.process_material = material - particles.position = world_pos - - main_scene.add_child(particles) - - await get_tree().create_timer(1.2).timeout - if particles and is_instance_valid(particles): - particles.queue_free() - # ============================================================================= # Sticky / Trap System @@ -1085,7 +1022,7 @@ func cell_state(pos: Vector2i) -> CellState: if is_sticky_cell(pos): return CellState.STICKY if cleansed_cells.has(pos): - return CellState.CLEANSED + return CellState.BLOCKED # Protected from regrowth temporarily if telegraphed_cells.has(pos): return CellState.TELEGRAPHED if bubble_cells.has(pos): @@ -1383,11 +1320,11 @@ func _calculate_bubble_score(pos: Vector2i, player_cells: Array = []) -> float: return score func _bubble_score_camping(pos: Vector2i) -> float: - """Reward targeting campers. +40 >5s, +60 >8s, +80 >10s-with-cleanser.""" + """Reward targeting campers. +40 >5s, +60 >8s, +80 >10s-with-ghost.""" var t := _camp_time_for_region(_region_of(pos)) if t > 10.0: - # Stronger only if a nearby player actually holds a cleanser. - if _any_cleanser_holder_near(pos): + # Stronger only if a nearby player is in ghost mode. + if _any_ghost_player_near(pos): return 80.0 return 60.0 elif t > 8.0: @@ -1455,13 +1392,10 @@ func _bubble_blast_cells(center: Vector2i) -> Array: func _bubble_footprint(center: Vector2i) -> Array: return _bubble_blast_cells(center) -func _any_cleanser_holder_near(pos: Vector2i) -> bool: - """True if a player holding a Cleanser charge is within the camping region.""" +func _any_ghost_player_near(pos: Vector2i) -> bool: + """True if a player in ghost mode is within the camping region.""" for player in get_tree().get_nodes_in_group("Players"): - var pid = player.get("peer_id") if "peer_id" in player else -1 - if pid == -1: - continue - if player_cleansers.get(pid, 0) <= 0: + if not player.get("is_invisible"): continue if "current_position" in player and player.current_position != null: if _region_of(player.current_position) == _region_of(pos): @@ -1557,7 +1491,7 @@ func _explode_bubble(center: Vector2i, cells: Array) -> void: if "current_position" in player and player.current_position != null: if blast.has(player.current_position): var pid = player.get("peer_id") if "peer_id" in player else -1 - if pid != -1 and is_cleanser_active(pid): + if pid != -1 and player.get("is_invisible"): continue apply_sticky_slow(player) @@ -1664,8 +1598,8 @@ func _check_all_players_trapped() -> void: var pos = player.current_position if player.get("current_position") else Vector2i(-1, -1) if is_sticky_cell(pos): var pid = player.get("peer_id") if "peer_id" in player else -1 - if pid != -1 and is_cleanser_active(pid): - continue # cleansing players are immune to the slow + if pid != -1 and player.get("is_invisible"): + continue # ghost players are immune to the slow apply_sticky_slow(player) func apply_sticky_slow(player: Node) -> void: @@ -1697,25 +1631,22 @@ func _trap_player(player: Node) -> void: NotificationManager.send_message(player, "Stuck in Candy!", NotificationManager.MessageType.WARNING) func clear_sticky_cell(pos: Vector2i) -> void: - """Used by Cleanser power-up to remove a sticky cell.""" + """Remove a sticky cell (used when ghost player walks through).""" if _can_rpc(): if multiplayer.is_server(): rpc("sync_clear_sticky_cell", pos) else: sync_clear_sticky_cell(pos) # Predictive local clear - rpc("rpc_use_cleanser", pos) else: sync_clear_sticky_cell(pos) @rpc("authority", "call_local", "reliable") func sync_clear_sticky_cell(pos: Vector2i) -> void: sticky_cells.erase(pos) - mark_cleansed(pos) # temporary regrowth protection (v2) + mark_cleansed(pos) # temporary regrowth protection if gridmap: gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1) - # Play VFX and SFX - _spawn_cleanser_particles(pos) if SfxManager: SfxManager.play("pick_up_power_tile") @@ -1723,142 +1654,6 @@ func sync_clear_sticky_cell(pos: Vector2i) -> void: if main_scene and main_scene.has_method("sync_grid_item"): main_scene.sync_grid_item(pos.x, 2, pos.y, -1) -func _try_use_cleanser() -> void: - """Local player attempts to activate Cleanser for 5-cell sticky immunity.""" - var local_pid = multiplayer.get_unique_id() - var count = player_cleansers.get(local_pid, 0) - if count <= 0: - return - - # Block activation during stun - var all_players = get_tree().get_nodes_in_group("Players") - var local_player = null - for p in all_players: - var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int() - if pid == local_pid: - local_player = p - break - if not local_player: - return - if local_player.get("is_frozen") or local_player.get("is_stop_frozen"): - return - # 0.3s activation delay - await get_tree().create_timer(CLEANSER_ACTIVATION_DELAY).timeout - - # Re-validate after delay - if not is_instance_valid(local_player): - return - if local_player.get("is_frozen") or local_player.get("is_stop_frozen"): - return - - # Consume cleanser from inventory (only if client, host relies on rpc) - if not multiplayer.is_server(): - cleanser_active[local_pid] = true - cleanser_cells_left[local_pid] = CLEANSER_MAX_CELLS - player_cleansers[local_pid] = max(0, player_cleansers[local_pid] - 1) - update_cleanser_ui(player_cleansers[local_pid]) - - NotificationManager.send_message(local_player, "Cleanser Used! (5 charges)", NotificationManager.MessageType.POWERUP) - - # Sync to server/clients - if not multiplayer.is_server() and _can_rpc(): - rpc("rpc_activate_cleanser", local_pid) - elif multiplayer.is_server(): - # Call RPC logic directly for host (it will set active/cells_left/consume) - rpc_activate_cleanser(local_pid) - -@rpc("any_peer", "call_local", "reliable") -func deactivate_cleanser(player_id: int) -> void: - """Deactivate cleanser immunity for a player.""" - cleanser_active.erase(player_id) - cleanser_cells_left.erase(player_id) - -func is_cleanser_active(player_id: int) -> bool: - """Check if a player has active cleanser immunity.""" - return cleanser_active.has(player_id) - -func use_cleanser_cell(player_id: int) -> bool: - """Use one cleanser cell. Returns true if still active, false if exhausted.""" - if not cleanser_active.has(player_id): - return false - cleanser_cells_left[player_id] -= 1 - if cleanser_cells_left[player_id] <= 0: - if _can_rpc(): - rpc("deactivate_cleanser", player_id) - else: - deactivate_cleanser(player_id) - return false - return true - -func notify_movement_stopped(player_id: int, pos: Vector2i) -> void: - """Called from PlayerMovementManager when a move chain settles. - Previously deactivated cleanser here, but now immunity persists - until charges run out to allow repeated use across safe gaps.""" - pass - -@rpc("any_peer", "call_local", "reliable") -func rpc_activate_cleanser(pid: int) -> void: - """RPC for clients to activate cleanser on server.""" - if multiplayer.is_server(): - # Verify they actually have a cleanser charge (prevents spam/cheats) - if player_cleansers.get(pid, 0) <= 0: - return - - # Always apply the state and AoE, since this is the server authority - cleanser_active[pid] = true - cleanser_cells_left[pid] = CLEANSER_MAX_CELLS - player_cleansers[pid] = max(0, player_cleansers[pid] - 1) - if _can_rpc(): - rpc("sync_cleanser_count", pid, player_cleansers[pid]) - - # NEW: Clear 3x3 area around player - var all_players = get_tree().get_nodes_in_group("Players") - var target_player = null - for p in all_players: - var target_pid = p.get("peer_id") if "peer_id" in p else p.name.to_int() - if target_pid == pid: - target_player = p - break - - if gridmap and is_instance_valid(target_player): - var map_pos = gridmap.local_to_map(target_player.global_position) - var center_pos = Vector2i(map_pos.x, map_pos.z) - - # 3x3 neighborhood - for dx in range(-1, 2): - for dz in range(-1, 2): - var check_pos = center_pos + Vector2i(dx, dz) - if is_sticky_cell(check_pos): - clear_sticky_cell(check_pos) - - # Remove slow effect for any player in the cleansed area - for p in all_players: - if is_instance_valid(p) and p.has_method("remove_slow_effect"): - if gridmap: - var p_map_pos = gridmap.local_to_map(p.global_position) - var p_cell_pos = Vector2i(p_map_pos.x, p_map_pos.z) - if abs(p_cell_pos.x - center_pos.x) <= 1 and abs(p_cell_pos.y - center_pos.y) <= 1: - if _can_rpc(): - p.rpc("remove_slow_effect") - else: - p.remove_slow_effect() - - print("[Cleanser] Server cleared 3x3 area around %s for player %d" % [center_pos, pid]) - -@rpc("any_peer", "call_local", "reliable") -func rpc_use_cleanser(pos: Vector2i) -> void: - """RPC for clients to clear a sticky cell via Cleanser.""" - if multiplayer.is_server(): - clear_sticky_cell(pos) - -@rpc("any_peer", "call_local", "reliable") -func rpc_consume_cleanser(pid: int) -> void: - """RPC for clients to report Cleanser consumption to server.""" - if multiplayer.is_server(): - player_cleansers[pid] = 0 - if _can_rpc(): - rpc("sync_cleanser_count", pid, 0) - @rpc("any_peer", "reliable") func rpc_trigger_slowmo() -> void: """RPC for clients to request slow-mo from server.""" @@ -1945,25 +1740,7 @@ func _setup_hud() -> void: hud_layer.visible = false add_child(hud_layer) phase_label = hud_layer.get_node("BottomContainer/VBoxContainer/PhaseLabel") - cleanser_icon = hud_layer.get_node("BottomContainer/VBoxContainer/CleanserHBox/CleanserIcon") - cleanser_label = hud_layer.get_node("BottomContainer/VBoxContainer/CleanserHBox/CleanserLabel") slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel") - _generate_cleanser_icon() - -func _generate_cleanser_icon() -> void: - var icon_img = Image.create(16, 16, false, Image.FORMAT_RGBA8) - icon_img.fill(Color(0.4, 0.9, 1.0)) - icon_img.blend_rect(icon_img, Rect2i(2, 2, 12, 12), Vector2i(1, 1)) - for x in range(16): - icon_img.set_pixel(x, 0, Color(0.2, 0.6, 0.7)) - icon_img.set_pixel(x, 15, Color(0.2, 0.6, 0.7)) - for y in range(16): - icon_img.set_pixel(0, y, Color(0.2, 0.6, 0.7)) - icon_img.set_pixel(15, y, Color(0.2, 0.6, 0.7)) - for i in range(4, 12): - icon_img.set_pixel(i, 7, Color(1.0, 1.0, 1.0, 0.8)) - icon_img.set_pixel(7, i, Color(1.0, 1.0, 1.0, 0.8)) - cleanser_icon.texture = ImageTexture.create_from_image(icon_img) func _update_hud_phase(phase_name: String) -> void: if phase_label: @@ -1982,20 +1759,6 @@ func _update_hud_phase(phase_name: String) -> void: # Animate phase label with bounce effect _animate_phase_label() -func update_cleanser_ui(count: int) -> void: - cleanser_count = count - if cleanser_label: - cleanser_label.text = "[E] Cleanser (%d)" % count - # Show/hide icon based on availability - if cleanser_icon: - cleanser_icon.visible = count > 0 - if count > 0: - # Pulse animation when cleanser is available - var tween = create_tween() - tween.set_loops(2) - tween.tween_property(cleanser_icon, "modulate", Color(1.5, 1.5, 1.5, 1), 0.3) - tween.tween_property(cleanser_icon, "modulate", Color.WHITE, 0.3) - func _animate_phase_label() -> void: """Animate phase label with bounce effect.""" if not phase_label: @@ -2020,7 +1783,7 @@ func _animate_phase_label() -> void: # ============================================================================= func _on_goal_count_updated(peer_id: int, count: int) -> void: - """Called when a player completes a goal cycle. Grant cleanser every 2 missions.""" + """Called when a player completes a goal cycle. Grant ghost powerup every 2 missions.""" if not multiplayer.is_server(): return @@ -2029,34 +1792,29 @@ func _on_goal_count_updated(peer_id: int, count: int) -> void: player_mission_completions[peer_id] = 0 player_mission_completions[peer_id] += 1 - # Grant cleanser every 2 missions + # Grant ghost powerup every 2 missions var completions = player_mission_completions[peer_id] if completions % 2 == 0: - if not player_cleansers.has(peer_id): - player_cleansers[peer_id] = 0 - - # Allow stacking cleanser charges instead of capping at 1 - player_cleansers[peer_id] += 1 - emit_signal("cleanser_granted", peer_id) - print("[Gauntlet] Player %d granted Cleanser (Total: %d) (mission %d)" % [peer_id, player_cleansers[peer_id], completions]) - - # Sync cleanser count to HUD - rpc("sync_cleanser_count", peer_id, player_cleansers.get(peer_id, 0)) + _grant_ghost_powerup(peer_id) + +func _grant_ghost_powerup(peer_id: int) -> void: + """Grant the ghost (invisible mode) powerup to a player.""" + var all_players = get_tree().get_nodes_in_group("Players") + for p in all_players: + var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int() + if pid == peer_id: + var stm = p.get_node_or_null("SpecialTilesManager") + if stm and stm.has_method("add_powerup_from_item"): + stm.add_powerup_from_item(14) # 14 = Ghost / INVISIBLE_MODE + emit_signal("ghost_granted", peer_id) + print("[Gauntlet] Player %d granted Ghost powerup (mission %d)" % [peer_id, player_mission_completions[peer_id]]) + NotificationManager.send_message(p, "Ghost Power Earned!", NotificationManager.MessageType.POWERUP) + break func _on_score_updated(peer_id: int, new_score: int) -> void: """Called when a player's score is updated.""" pass # Score sync handled by GoalsCycleManager - - -@rpc("authority", "call_local", "reliable") -func sync_cleanser_count(peer_id: int, count: int) -> void: - """Sync cleanser count to HUD for specific player.""" - # Update local player's cleanser UI - var local_pid = multiplayer.get_unique_id() - if peer_id == local_pid: - update_cleanser_ui(count) - # ============================================================================= # Utility # ============================================================================= diff --git a/scripts/managers/player_movement_manager.gd b/scripts/managers/player_movement_manager.gd index f78b875..cc3910b 100644 --- a/scripts/managers/player_movement_manager.gd +++ b/scripts/managers/player_movement_manager.gd @@ -155,15 +155,12 @@ func simple_move_to(grid_position: Vector2i) -> bool: player.knock_tekton() return false # Don't move into the tile, just knock - # If moving into a sticky cell: slow the player (unless cleanser active, - # which clears the cell instead). Sticky no longer hard-traps. + # If moving into a sticky cell: block movement unless player is in ghost + # mode (is_invisible), which lets them bypass sticky tiles in gauntlet. if gm and gm.is_active and gm.is_sticky_cell(grid_position): - var pid = player.get("peer_id") if "peer_id" in player else -1 - if pid != -1 and gm.is_cleanser_active(pid): - # Cleanser immunity: clear sticky cell, use one cell, don't slow - gm.clear_sticky_cell(grid_position) - gm.use_cleanser_cell(pid) - print("[Move] Cleanser cleared sticky cell at %s (%d cells left)" % [grid_position, gm.cleanser_cells_left.get(pid, 0)]) + if player.get("is_invisible"): + # Ghost mode: walk through sticky tile freely + print("[Move] Ghost mode bypassed sticky cell at %s" % grid_position) else: print("[Move] Failed: Blocked by Gauntlet Sticky cell at %s" % grid_position) return false @@ -341,12 +338,9 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool: if main_sticky and main_sticky.get("gauntlet_manager"): var gm_sticky = main_sticky.gauntlet_manager if gm_sticky.is_active and gm_sticky.is_sticky_cell(pushed_to_pos): - var push_pid = other_player.get("peer_id") if "peer_id" in other_player else -1 - if push_pid != -1 and gm_sticky.is_cleanser_active(push_pid): - # Cleanser immunity: clear sticky cell, use one cell - gm_sticky.clear_sticky_cell(pushed_to_pos) - gm_sticky.use_cleanser_cell(push_pid) - print("[Move] Cleanser cleared push-into-sticky at %s" % pushed_to_pos) + if other_player.get("is_invisible"): + # Ghost mode: pushed player bypasses sticky + print("[Move] Ghost mode bypassed push-into-sticky at %s" % pushed_to_pos) else: print("[Move] Player pushed into sticky cell at %s — slowed" % pushed_to_pos) if multiplayer.is_server() or other_player.is_multiplayer_authority(): @@ -406,17 +400,6 @@ func _on_movement_finished(): emit_signal("movement_finished") else: current_move_direction = Vector2i.ZERO - # Gauntlet (#072): a Cleanser ends early once the player rests on a safe - # cell. Gated on gm.is_active so other game modes are never affected. - var gm = null - var main_node = player.get_tree().root.get_node_or_null("Main") - if main_node and main_node.get("gauntlet_manager"): - gm = main_node.gauntlet_manager - if gm and gm.is_active and player.get("current_position") != null: - var mpid = player.get("peer_id") if "peer_id" in player else -1 - if mpid != -1 and gm.is_cleanser_active(mpid): - if multiplayer.is_server() or player.is_multiplayer_authority(): - gm.notify_movement_stopped(mpid, player.current_position) emit_signal("movement_finished") func move_to_clicked_position(grid_position: Vector2i) -> bool: diff --git a/scripts/managers/special_tiles_manager.gd b/scripts/managers/special_tiles_manager.gd index d0ef011..e580327 100644 --- a/scripts/managers/special_tiles_manager.gd +++ b/scripts/managers/special_tiles_manager.gd @@ -547,9 +547,11 @@ func spawn_powerups_around(center: Vector2i, force_powerups: bool = true, only_c # Spawn ONLY common tiles (7-10) in Stop n Go mode (User Request) item_id = rng.randi_range(7, 10) elif LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET): - # Gauntlet mode: No power-up tile spawns from world. - # Only common tiles (7-10) spawn; Smack/Cleanser are handled separately. - item_id = rng.randi_range(7, 10) + # Gauntlet mode: mostly common tiles, but ghost (14) can spawn too. + if rng.randf() < 0.85: + item_id = rng.randi_range(7, 10) + else: + item_id = 14 # Ghost powerup only else: # Other modes: 80% Chance for Common Tile (7-10), 20% for PowerUp if rng.randf() < 0.8: