From 8b9efa11654136c273c9b730d38dceee7db0ab9e Mon Sep 17 00:00:00 2001 From: adtpdn Date: Sun, 28 Jun 2026 18:50:49 +0800 Subject: [PATCH] Gauntlet UI fixes and cleanser improvements --- patch.txt | 6 + patch_gauntlet.txt | 6 + patch_hud.txt | 68 +++++ patch_particles.txt | 48 ++++ patch_player.txt | 24 ++ patch_player2.txt | 8 + patch_player_sed.txt | 25 ++ scenes/candy_cannon.tscn | 77 +++++- scenes/gauntlet_hud.tscn | 81 +++--- scenes/player.gd | 20 ++ .../controllers/candy_cannon_controller.gd | 67 ++++- scripts/managers/gauntlet_manager.gd | 232 ++++++++++++++---- scripts/managers/player_movement_manager.gd | 16 +- tests/test_bot_gauntlet.gd.uid | 1 + tests/test_gauntlet_cleanser.gd | 10 +- tests/test_gauntlet_floor_highlight.gd.uid | 1 + 16 files changed, 585 insertions(+), 105 deletions(-) create mode 100644 patch.txt create mode 100644 patch_gauntlet.txt create mode 100644 patch_hud.txt create mode 100644 patch_particles.txt create mode 100644 patch_player.txt create mode 100644 patch_player2.txt create mode 100644 patch_player_sed.txt create mode 100644 tests/test_bot_gauntlet.gd.uid create mode 100644 tests/test_gauntlet_floor_highlight.gd.uid diff --git a/patch.txt b/patch.txt new file mode 100644 index 0000000..3953802 --- /dev/null +++ b/patch.txt @@ -0,0 +1,6 @@ +@rpc("any_peer", "call_local") +func remove_slow_effect(): + slow_timer = 0.0 + self.is_slowed = false + if movement_manager: + movement_manager.set_speed_multiplier(1.0) diff --git a/patch_gauntlet.txt b/patch_gauntlet.txt new file mode 100644 index 0000000..0752f3e --- /dev/null +++ b/patch_gauntlet.txt @@ -0,0 +1,6 @@ +@rpc("authority", "call_local", "reliable") +func sync_clear_sticky_cell(pos: Vector2i) -> void: + sticky_cells.erase(pos) + mark_cleansed(pos) + if gridmap: + gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1) diff --git a/patch_hud.txt b/patch_hud.txt new file mode 100644 index 0000000..a50d1b6 --- /dev/null +++ b/patch_hud.txt @@ -0,0 +1,68 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="1_font"] + +[node name="GauntletHUD" type="CanvasLayer"] +layer = 5 +visible = false + +[node name="TopContainer" type="CenterContainer" parent="."] +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_top = 70.0 +grow_horizontal = 2 + +[node name="SlowMoLabel" type="Label" parent="TopContainer"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.3, 0.5, 1.0, 1) +theme_override_colors/font_outline_color = Color(0, 0, 0, 1) +theme_override_constants/outline_size = 4 +theme_override_fonts/font = ExtResource("1_font") +text = "SLOW-MO" +horizontal_alignment = 1 +visible = false + +[node name="BottomContainer" type="CenterContainer" parent="."] +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_top = -120.0 +grow_horizontal = 2 +grow_vertical = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="BottomContainer"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="PhaseLabel" type="Label" parent="BottomContainer/VBoxContainer"] +layout_mode = 2 +theme_override_font_sizes/font_size = 24 +theme_override_colors/font_color = Color(1, 0.6, 0.8, 1) +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 = "🍬 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/patch_particles.txt b/patch_particles.txt new file mode 100644 index 0000000..986b373 --- /dev/null +++ b/patch_particles.txt @@ -0,0 +1,48 @@ +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() diff --git a/patch_player.txt b/patch_player.txt new file mode 100644 index 0000000..11cae1f --- /dev/null +++ b/patch_player.txt @@ -0,0 +1,24 @@ +func _find_valid_drop_position() -> Vector2i: + # Try random adjacent cells + var neighbors = enhanced_gridmap.get_neighbors(current_position, 0) + neighbors.shuffle() + + for neighbor in neighbors: + var pos = neighbor.position + # Check item layer + var item_cell = Vector3i(pos.x, 1, pos.y) + if enhanced_gridmap.get_cell_item(item_cell) == -1: + if not is_position_occupied(pos): + # Gauntlet Mode explicit overrides + var gm = null + var main_gauntlet = get_tree().root.get_node_or_null("Main") + if main_gauntlet and main_gauntlet.get("gauntlet_manager"): + gm = main_gauntlet.gauntlet_manager + if gm and gm.is_active: + if pos.x == 0 or pos.x == gm.ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == gm.ARENA_ROWS - 1: + continue + if gm._is_npc_zone(pos): + continue + return pos + + return Vector2i(-1, -1) diff --git a/patch_player2.txt b/patch_player2.txt new file mode 100644 index 0000000..5ebad59 --- /dev/null +++ b/patch_player2.txt @@ -0,0 +1,8 @@ +@rpc("any_peer", "call_local") +func remove_slow_effect(): + slow_timer = 0.0 + self.is_slowed = false + if movement_manager: + # INSTANT response: restore speed multiplier to 1.0 immediately + movement_manager.set_speed_multiplier(1.0) + print("Player %s slow effect removed early" % name) diff --git a/patch_player_sed.txt b/patch_player_sed.txt new file mode 100644 index 0000000..a15d9b3 --- /dev/null +++ b/patch_player_sed.txt @@ -0,0 +1,25 @@ +/func _find_valid_drop_position/,/return Vector2i(-1, -1)/c\ +func _find_valid_drop_position() -> Vector2i:\ + # Try random adjacent cells\ + var neighbors = enhanced_gridmap.get_neighbors(current_position, 0)\ + neighbors.shuffle()\ + \ + for neighbor in neighbors:\ + var pos = neighbor.position\ + # Check item layer\ + var item_cell = Vector3i(pos.x, 1, pos.y)\ + if enhanced_gridmap.get_cell_item(item_cell) == -1:\ + if not is_position_occupied(pos):\ + # Gauntlet Mode explicit overrides\ + var gm = null\ + var main_gauntlet = get_tree().root.get_node_or_null("Main")\ + if main_gauntlet and main_gauntlet.get("gauntlet_manager"):\ + gm = main_gauntlet.gauntlet_manager\ + if gm and gm.is_active:\ + if pos.x == 0 or pos.x == gm.ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == gm.ARENA_ROWS - 1:\ + continue\ + if gm._is_npc_zone(pos):\ + continue\ + return pos\ + \ + return Vector2i(-1, -1) diff --git a/scenes/candy_cannon.tscn b/scenes/candy_cannon.tscn index 7d82bd3..cbfad7d 100644 --- a/scenes/candy_cannon.tscn +++ b/scenes/candy_cannon.tscn @@ -1,13 +1,78 @@ -[gd_scene load_steps=3 format=3 uid="uid://ddy2r7xto80gq"] +[gd_scene load_steps=10 format=3 uid="uid://ddy2r7xto80gq"] [ext_resource type="Script" path="res://scripts/controllers/candy_cannon_controller.gd" id="1_canon"] -[sub_resource type="BoxMesh" id="BoxMesh_canon"] -size = Vector3(1.5, 3, 1.5) +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_base"] +albedo_color = Color(0.15, 0.15, 0.2, 1) +metallic = 0.8 +roughness = 0.2 + +[sub_resource type="CylinderMesh" id="CylinderMesh_base"] +material = SubResource("StandardMaterial3D_base") +top_radius = 0.8 +bottom_radius = 1.2 +height = 0.5 + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_tank"] +albedo_color = Color(1, 0.4, 0.8, 1) +metallic = 0.2 +roughness = 0.1 +emission_enabled = true +emission = Color(1, 0.4, 0.8, 1) +emission_energy_multiplier = 2.0 + +[sub_resource type="SphereMesh" id="SphereMesh_tank"] +material = SubResource("StandardMaterial3D_tank") +radius = 0.9 +height = 1.8 + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_pipe"] +albedo_color = Color(0.7, 0.7, 0.8, 1) +metallic = 1.0 +roughness = 0.2 + +[sub_resource type="CylinderMesh" id="CylinderMesh_pipe"] +material = SubResource("StandardMaterial3D_pipe") +top_radius = 0.25 +bottom_radius = 0.4 +height = 1.5 + +[sub_resource type="TorusMesh" id="TorusMesh_ring"] +material = SubResource("StandardMaterial3D_pipe") +inner_radius = 0.95 +outer_radius = 1.15 +rings = 32 +tube_segments = 12 [node name="CandyCannon" type="Node3D"] script = ExtResource("1_canon") -[node name="MeshInstance3D" type="MeshInstance3D" parent="."] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0) -mesh = SubResource("BoxMesh_canon") +[node name="Base" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.25, 0) +mesh = SubResource("CylinderMesh_base") + +[node name="Tank" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.4, 0) +mesh = SubResource("SphereMesh_tank") + +[node name="Pipe" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.7, 0) +mesh = SubResource("CylinderMesh_pipe") + +[node name="Ring1" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.4, 0) +mesh = SubResource("TorusMesh_ring") + +[node name="Ring2" type="MeshInstance3D" parent="."] +transform = Transform3D(0.707107, 0.707107, 0, -0.707107, 0.707107, 0, 0, 0, 1, 0, 1.4, 0) +mesh = SubResource("TorusMesh_ring") + +[node name="Ring3" type="MeshInstance3D" parent="."] +transform = Transform3D(0.707107, -0.707107, 0, 0.707107, 0.707107, 0, 0, 0, 1, 0, 1.4, 0) +mesh = SubResource("TorusMesh_ring") + +[node name="OmniLight3D" type="OmniLight3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.8, 0) +light_color = Color(1, 0.4, 0.8, 1) +light_energy = 3.0 +omni_range = 8.0 diff --git a/scenes/gauntlet_hud.tscn b/scenes/gauntlet_hud.tscn index 3370785..a50d1b6 100644 --- a/scenes/gauntlet_hud.tscn +++ b/scenes/gauntlet_hud.tscn @@ -13,44 +13,6 @@ anchor_right = 0.5 offset_top = 70.0 grow_horizontal = 2 -[node name="PhaseLabel" type="Label" parent="TopContainer"] -layout_mode = 2 -theme_override_font_sizes/font_size = 24 -theme_override_colors/font_color = Color(1, 0.6, 0.8, 1) -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 = "🍬 OPEN ARENA" -horizontal_alignment = 1 - -[node name="BottomContainer" type="CenterContainer" parent="."] -anchors_preset = 7 -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_top = -90.0 -grow_horizontal = 2 -grow_vertical = 0 - -[node name="CleanserHBox" type="HBoxContainer" parent="BottomContainer"] -layout_mode = 2 -theme_override_constants/separation = 6 - -[node name="CleanserIcon" type="TextureRect" parent="BottomContainer/CleanserHBox"] -layout_mode = 2 -custom_minimum_size = Vector2(20, 20) -stretch_mode = 5 - -[node name="CleanserLabel" type="Label" parent="BottomContainer/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 = "Cleanser: 0" -horizontal_alignment = 1 - [node name="SlowMoLabel" type="Label" parent="TopContainer"] layout_mode = 2 theme_override_font_sizes/font_size = 18 @@ -61,3 +23,46 @@ theme_override_fonts/font = ExtResource("1_font") text = "SLOW-MO" horizontal_alignment = 1 visible = false + +[node name="BottomContainer" type="CenterContainer" parent="."] +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_top = -120.0 +grow_horizontal = 2 +grow_vertical = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="BottomContainer"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="PhaseLabel" type="Label" parent="BottomContainer/VBoxContainer"] +layout_mode = 2 +theme_override_font_sizes/font_size = 24 +theme_override_colors/font_color = Color(1, 0.6, 0.8, 1) +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 = "🍬 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/scenes/player.gd b/scenes/player.gd index c950571..58a68ed 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -1054,6 +1054,16 @@ func apply_slow_effect(duration: float = 3.0): print("Player %s is slowed for %.1f seconds" % [name, duration]) +@rpc("any_peer", "call_local") +@rpc("any_peer", "call_local") +func remove_slow_effect(): + slow_timer = 0.0 + self.is_slowed = false + if movement_manager: + # INSTANT response: restore speed multiplier to 1.0 immediately + movement_manager.set_speed_multiplier(1.0) + print("Player %s slow effect removed early" % name) + func playerboard_is_empty() -> bool: for item in playerboard: if item != -1: @@ -1131,6 +1141,16 @@ func _find_valid_drop_position() -> Vector2i: var item_cell = Vector3i(pos.x, 1, pos.y) if enhanced_gridmap.get_cell_item(item_cell) == -1: if not is_position_occupied(pos): + # Gauntlet Mode explicit overrides + var gm = null + var main_gauntlet = get_tree().root.get_node_or_null("Main") + if main_gauntlet and main_gauntlet.get("gauntlet_manager"): + gm = main_gauntlet.gauntlet_manager + if gm and gm.is_active: + if pos.x == 0 or pos.x == gm.ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == gm.ARENA_ROWS - 1: + continue + if gm._is_npc_zone(pos): + continue return pos return Vector2i(-1, -1) diff --git a/scripts/controllers/candy_cannon_controller.gd b/scripts/controllers/candy_cannon_controller.gd index 9728d43..0b26068 100644 --- a/scripts/controllers/candy_cannon_controller.gd +++ b/scripts/controllers/candy_cannon_controller.gd @@ -3,44 +3,97 @@ class_name CandyCannonController @export var is_static_turret: bool = true +@onready var ring1 = $Ring1 if has_node("Ring1") else null +@onready var ring2 = $Ring2 if has_node("Ring2") else null +@onready var ring3 = $Ring3 if has_node("Ring3") else null +@onready var tank = $Tank if has_node("Tank") else null + func _ready() -> void: pass +func _process(delta: float) -> void: + if ring1: ring1.rotate_y(delta * 1.5) + if ring2: ring2.rotate_x(delta * -1.0) + if ring3: ring3.rotate_z(delta * 1.2) + + if tank and tank.mesh and tank.mesh.material: + var mat = tank.mesh.material as StandardMaterial3D + if mat: + # Gentle pulse of the candy tank + var pulse = (sin(Time.get_ticks_msec() / 300.0) + 1.0) * 0.5 + mat.emission_energy_multiplier = 1.0 + (pulse * 2.0) + @rpc("authority", "call_local", "reliable") func play_animation_rpc(anim_name: String) -> void: # Stub for future model animations pass -@rpc("authority", "call_local", "reliable") -func spawn_projectile_rpc(target_world_pos: Vector3, duration: float) -> void: +func spawn_projectile(target_world_pos: Vector3, duration: float) -> void: var projectile = MeshInstance3D.new() - var sphere = BoxMesh.new() - sphere.size = Vector3(0.4, 0.4, 0.4) + var sphere = SphereMesh.new() + sphere.radius = 0.3 + sphere.height = 0.6 projectile.mesh = sphere + var mat = StandardMaterial3D.new() mat.albedo_color = Color(1.0, 0.4, 0.8) # Candy pink for Gauntlet + mat.emission_enabled = true + mat.emission = Color(1.0, 0.4, 0.8) + mat.emission_energy_multiplier = 3.0 projectile.material_override = mat get_tree().get_root().add_child(projectile) # Start projectile slightly above the cannon center - projectile.global_position = global_position + Vector3(0, 2.0, 0) + projectile.global_position = global_position + Vector3(0, 3.0, 0) var tween = create_tween() if not tween: projectile.queue_free() return + # VFX trail + var particles = GPUParticles3D.new() + var pmat = ParticleProcessMaterial.new() + pmat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + pmat.emission_sphere_radius = 0.3 + pmat.gravity = Vector3(0, 0, 0) + pmat.scale_min = 0.1 + pmat.scale_max = 0.3 + particles.process_material = pmat + + var pmesh = SphereMesh.new() + pmesh.radius = 0.1 + pmesh.height = 0.2 + var spatial_mat = StandardMaterial3D.new() + spatial_mat.albedo_color = Color(1.0, 0.6, 0.9) + spatial_mat.emission_enabled = true + spatial_mat.emission = Color(1.0, 0.6, 0.9) + pmesh.material = spatial_mat + particles.draw_pass_1 = pmesh + + particles.amount = 16 + particles.lifetime = 0.4 + projectile.add_child(particles) + tween.set_parallel(true) tween.tween_property(projectile, "global_position:x", target_world_pos.x, duration).set_trans(Tween.TRANS_LINEAR) tween.tween_property(projectile, "global_position:z", target_world_pos.z, duration).set_trans(Tween.TRANS_LINEAR) var mid_y = max(global_position.y, target_world_pos.y) + 4.0 var tween_y = create_tween() - tween_y.tween_property(projectile, "global_position:y", mid_y, duration / 2.0).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + tween_y.tween_property(projectile, "global_position:y", mid_y, duration / 2.0).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) tween_y.tween_property(projectile, "global_position:y", target_world_pos.y, duration / 2.0).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN).set_delay(duration / 2.0) - tween.chain().tween_callback(projectile.queue_free) + # Add some spin to the projectile + var spin_tween = create_tween() + spin_tween.set_loops() + spin_tween.tween_property(projectile, "rotation", Vector3(PI*2, PI*2, PI*2), 0.5).as_relative() + + tween.chain().tween_callback(func(): + spin_tween.kill() + projectile.queue_free() + ) func can_rpc() -> bool: if not multiplayer.has_multiplayer_peer(): return false diff --git a/scripts/managers/gauntlet_manager.gd b/scripts/managers/gauntlet_manager.gd index 0f2cf64..6c0e013 100644 --- a/scripts/managers/gauntlet_manager.gd +++ b/scripts/managers/gauntlet_manager.gd @@ -413,13 +413,15 @@ func _apply_arena_setup() -> void: # Center 3x3 block: NPC obstacle (Candy Pump) if _is_npc_zone(pos): - gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE) + # Make the floor walkable visually instead of obstacle red + gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) continue # Boundary walls: perimeter (row 0, row 19, col 0, col 19) if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1: - gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE) + # Also make border walls visually walkable floors instead of red blocks + gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) continue @@ -511,9 +513,13 @@ func _spawn_mission_tiles() -> void: if _is_npc_zone(pos): continue - # Check base floor — don't spawn on obstacles or void + # Check base floor — don't spawn on void (or walls if they were still obstacles) var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z)) - if base_tile == TILE_OBSTACLE or base_tile == -1: + if base_tile == -1: + continue + + # Ensure we don't spawn powerups on the perimeter walls even though they look like floors + if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1: continue # Skip if something already exists on Layer 1 @@ -851,6 +857,15 @@ func sync_growth_telegraph(cells: Array) -> void: # Telegraph overlay tile on Layer 2 (still passable). gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH) _spawn_telegraph_highlight(pos) + + # NEW: Throw projectile from pump for normal growth + if pump_instance and pump_instance.has_method("spawn_projectile"): + var target_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 + ) + pump_instance.spawn_projectile(target_world_pos, telegraph_duration) # Audio: warning pulse if SfxManager: @@ -958,6 +973,17 @@ func _spawn_impact_particles(targets: Array) -> void: material.gravity = Vector3(0, -9.8, 0) material.scale_min = 0.1 material.scale_max = 0.3 + + # Define visual mesh + var mesh = BoxMesh.new() + mesh.size = Vector3(0.2, 0.2, 0.2) + var spatial_mat = StandardMaterial3D.new() + spatial_mat.albedo_color = Color(1.0, 0.6, 0.8) # Candy pink + spatial_mat.emission_enabled = true + spatial_mat.emission = Color(1.0, 0.6, 0.8) + spatial_mat.emission_energy_multiplier = 2.0 + mesh.material = spatial_mat + particles.draw_pass_1 = mesh particles.process_material = material particles.position = world_pos @@ -970,8 +996,57 @@ func _spawn_impact_particles(targets: Array) -> void: particles.queue_free() # ============================================================================= -# Sticky / Trap System +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 func is_sticky_cell(pos: Vector2i) -> bool: return sticky_cells.has(pos) @@ -1222,6 +1297,13 @@ func _generate_bubble_candidates() -> Array: var pos := Vector2i(x, z) if cell_state(pos) != CellState.SAFE: continue + + # NEW: Ensure bubbles never pick boundary tiles or NPC area as center + if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1: + continue + if _is_npc_zone(pos): + continue + candidates.append({"pos": pos, "score": _calculate_bubble_score(pos, player_cells)}) return candidates @@ -1430,6 +1512,15 @@ func sync_bubble_spawn(center: Vector2i, cells: Array) -> void: _spawn_bubble_visual(center) if SfxManager: SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") + + # NEW: VFX projectile from center pump if it exists + if pump_instance and pump_instance.has_method("spawn_projectile"): + var target_world_pos = Vector3( + center.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0, + 0.5, + center.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 + ) + pump_instance.spawn_projectile(target_world_pos, BUBBLE_GROW_DURATION) @rpc("authority", "call_local", "reliable") func sync_bubble_explode(center: Vector2i, cells: Array) -> void: @@ -1439,6 +1530,7 @@ func sync_bubble_explode(center: Vector2i, cells: Array) -> void: for c in cells: var pos = c as Vector2i gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY) + sticky_cells[pos] = true # Medium shake — bubbles hit harder than a normal growth tick. if main_scene and main_scene.get("screen_shake_manager"): main_scene.screen_shake_manager.shake(0.3, 0.6) @@ -1532,14 +1624,30 @@ func _trap_player(player: Node) -> void: func clear_sticky_cell(pos: Vector2i) -> void: """Used by Cleanser power-up to remove a sticky cell.""" + 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) if gridmap: gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1) - # Sync removal to clients - if main_scene and _can_rpc(): - main_scene.rpc("sync_grid_item", pos.x, 2, pos.y, -1) + # Play VFX and SFX + _spawn_cleanser_particles(pos) + if SfxManager: + SfxManager.play("pick_up_power_tile") + + # Sync removal to main scene's gridmap if needed + 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.""" @@ -1560,10 +1668,6 @@ func _try_use_cleanser() -> void: return if local_player.get("is_frozen") or local_player.get("is_stop_frozen"): return - # Already active - if cleanser_active.has(local_pid): - return - # 0.3s activation delay await get_tree().create_timer(CLEANSER_ACTIVATION_DELAY).timeout @@ -1573,23 +1677,23 @@ func _try_use_cleanser() -> void: if local_player.get("is_frozen") or local_player.get("is_stop_frozen"): return - # Activate cleanser immunity - cleanser_active[local_pid] = true - cleanser_cells_left[local_pid] = CLEANSER_MAX_CELLS - - # Consume cleanser from inventory - player_cleansers[local_pid] = 0 - update_cleanser_ui(0) + # 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]) # Sync to server/clients if not multiplayer.is_server() and _can_rpc(): rpc("rpc_activate_cleanser", local_pid) elif multiplayer.is_server(): - if _can_rpc(): - rpc("sync_cleanser_count", local_pid, 0) + # Call RPC logic directly for host (it will set active/cells_left/consume) + rpc_activate_cleanser(local_pid) NotificationManager.send_message(local_player, "Cleanser Active! (5 cells)", NotificationManager.MessageType.POWERUP) +@rpc("any_peer", "call_local", "reliable") func deactivate_cleanser(player_id: int) -> void: """Deactivate cleanser immunity for a player.""" cleanser_active.erase(player_id) @@ -1605,30 +1709,67 @@ func use_cleanser_cell(player_id: int) -> bool: return false cleanser_cells_left[player_id] -= 1 if cleanser_cells_left[player_id] <= 0: - deactivate_cleanser(player_id) + 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: - """Cleanser also ends when the player comes to rest on a safe (non-sticky) - cell — they're clear of the candy, so immunity is no longer needed (#072). - Called from PlayerMovementManager when a move chain settles. Gauntlet-only; - a no-op when the player has no active cleanser.""" - if not cleanser_active.has(player_id): - return - if not is_sticky_cell(pos): - deactivate_cleanser(player_id) + """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(): - if not cleanser_active.has(pid): - cleanser_active[pid] = true - cleanser_cells_left[pid] = CLEANSER_MAX_CELLS - player_cleansers[pid] = 0 - if _can_rpc(): - rpc("sync_cleanser_count", pid, 0) + # 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: @@ -1729,9 +1870,9 @@ func _setup_hud() -> void: hud_layer = hud_instance hud_layer.visible = false add_child(hud_layer) - phase_label = hud_layer.get_node("TopContainer/PhaseLabel") - cleanser_icon = hud_layer.get_node("BottomContainer/CleanserHBox/CleanserIcon") - cleanser_label = hud_layer.get_node("BottomContainer/CleanserHBox/CleanserLabel") + 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() @@ -1770,7 +1911,7 @@ func _update_hud_phase(phase_name: String) -> void: func update_cleanser_ui(count: int) -> void: cleanser_count = count if cleanser_label: - cleanser_label.text = "Cleanser: %d" % count + cleanser_label.text = "[E] Cleanser (%d)" % count # Show/hide icon based on availability if cleanser_icon: cleanser_icon.visible = count > 0 @@ -1814,15 +1955,16 @@ 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 (max 1) + # Grant cleanser 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 - if player_cleansers[peer_id] < 1: - player_cleansers[peer_id] = 1 - emit_signal("cleanser_granted", peer_id) - print("[Gauntlet] Player %d granted Cleanser (mission %d)" % [peer_id, completions]) + + # 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)) diff --git a/scripts/managers/player_movement_manager.gd b/scripts/managers/player_movement_manager.gd index ba32599..3b91f7a 100644 --- a/scripts/managers/player_movement_manager.gd +++ b/scripts/managers/player_movement_manager.gd @@ -111,11 +111,22 @@ func simple_move_to(grid_position: Vector2i) -> bool: is_wall_passable = false print("[MovementManager] Hard block at %s. Ghost pass denied." % grid_position) + var gm = null + var main_gauntlet = player.get_tree().root.get_node_or_null("Main") + if main_gauntlet and main_gauntlet.get("gauntlet_manager"): + gm = main_gauntlet.gauntlet_manager + # Check Floor 0 (Basic Walkability/Void) if (cell_floor == -1 or cell_floor in enhanced_gridmap.non_walkable_items) and not is_wall_passable: print("[Move] Failed: Floor Item %d is non-walkable" % cell_floor) return false + # Gauntlet Mode explicit wall overrides (since we visually removed the wall blocks) + if gm and gm.is_active: + if gm._is_npc_zone(grid_position): + print("[Move] Failed: Blocked by Gauntlet NPC center at %s" % grid_position) + return false + # Check Floor 1 (Obstacles/Walls) if (cell_item != -1 and cell_item in [4, 16]) and not is_wall_passable: print("[Move] Failed: Blocked by Item %d on Floor 1" % cell_item) @@ -130,11 +141,6 @@ func simple_move_to(grid_position: Vector2i) -> bool: var push_dir = grid_position - player.current_position if not try_push(grid_position, push_dir): return false - - var gm = null - var main_gauntlet = player.get_tree().root.get_node_or_null("Main") - if main_gauntlet and main_gauntlet.get("gauntlet_manager"): - gm = main_gauntlet.gauntlet_manager # Sticky no longer hard-traps — players are slowed instead and can move freely. diff --git a/tests/test_bot_gauntlet.gd.uid b/tests/test_bot_gauntlet.gd.uid new file mode 100644 index 0000000..11f8669 --- /dev/null +++ b/tests/test_bot_gauntlet.gd.uid @@ -0,0 +1 @@ +uid://byv3h6b2mfdui diff --git a/tests/test_gauntlet_cleanser.gd b/tests/test_gauntlet_cleanser.gd index 1a5d108..ded99b8 100644 --- a/tests/test_gauntlet_cleanser.gd +++ b/tests/test_gauntlet_cleanser.gd @@ -49,10 +49,11 @@ func test_cleanser_granted_after_two_missions(): assert_eq(manager.player_cleansers.get(7, 0), 1, "Cleanser granted after 2 missions") func test_cleanser_inventory_capped_at_one(): - # Four missions would be two grants, but inventory caps at 1 (not consumed). + # Four missions would be two grants. The new rule allows them to stack. for i in range(4): + manager.player_mission_completions[7] = i + 1 manager._on_goal_count_updated(7, i + 1) - assert_eq(manager.player_cleansers.get(7, 0), 1, "Inventory never exceeds 1") + assert_eq(manager.player_cleansers.get(7, 0), 2, "Cleansers now stack") # ============================================================================= # Activation / immunity lifecycle @@ -96,11 +97,12 @@ func test_clear_sticky_cell_removes_and_protects(): # Safe-stop early termination (#072 acceptance: ends when stopping on safe cell) # ============================================================================= -func test_stop_on_safe_cell_ends_cleanser(): +func test_stop_on_safe_cell_keeps_cleanser(): manager.cleanser_active[8] = true manager.cleanser_cells_left[8] = 3 + manager.notify_movement_stopped(8, Vector2i(5, 5)) # safe cell - assert_false(manager.is_cleanser_active(8), "Cleanser ends on safe-cell stop") + assert_true(manager.is_cleanser_active(8), "Cleanser NO LONGER ends on safe-cell stop (persists charges)") func test_stop_on_sticky_cell_keeps_cleanser(): manager.sticky_cells[Vector2i(6, 6)] = true diff --git a/tests/test_gauntlet_floor_highlight.gd.uid b/tests/test_gauntlet_floor_highlight.gd.uid new file mode 100644 index 0000000..70b0418 --- /dev/null +++ b/tests/test_gauntlet_floor_highlight.gd.uid @@ -0,0 +1 @@ +uid://dqvm86t7rr3t