Gauntlet UI fixes and cleanser improvements

This commit is contained in:
2026-06-28 18:50:49 +08:00
parent f0ba6c2b54
commit 8b9efa1165
16 changed files with 585 additions and 105 deletions
+6
View File
@@ -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)
+6
View File
@@ -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)
+68
View File
@@ -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
+48
View File
@@ -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()
+24
View File
@@ -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)
+8
View File
@@ -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)
+25
View File
@@ -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)
+71 -6
View File
@@ -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"] [ext_resource type="Script" path="res://scripts/controllers/candy_cannon_controller.gd" id="1_canon"]
[sub_resource type="BoxMesh" id="BoxMesh_canon"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_base"]
size = Vector3(1.5, 3, 1.5) 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"] [node name="CandyCannon" type="Node3D"]
script = ExtResource("1_canon") script = ExtResource("1_canon")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."] [node name="Base" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.25, 0)
mesh = SubResource("BoxMesh_canon") 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
+43 -38
View File
@@ -13,44 +13,6 @@ anchor_right = 0.5
offset_top = 70.0 offset_top = 70.0
grow_horizontal = 2 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"] [node name="SlowMoLabel" type="Label" parent="TopContainer"]
layout_mode = 2 layout_mode = 2
theme_override_font_sizes/font_size = 18 theme_override_font_sizes/font_size = 18
@@ -61,3 +23,46 @@ theme_override_fonts/font = ExtResource("1_font")
text = "SLOW-MO" text = "SLOW-MO"
horizontal_alignment = 1 horizontal_alignment = 1
visible = false 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
+20
View File
@@ -1054,6 +1054,16 @@ func apply_slow_effect(duration: float = 3.0):
print("Player %s is slowed for %.1f seconds" % [name, duration]) 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: func playerboard_is_empty() -> bool:
for item in playerboard: for item in playerboard:
if item != -1: if item != -1:
@@ -1131,6 +1141,16 @@ func _find_valid_drop_position() -> Vector2i:
var item_cell = Vector3i(pos.x, 1, pos.y) var item_cell = Vector3i(pos.x, 1, pos.y)
if enhanced_gridmap.get_cell_item(item_cell) == -1: if enhanced_gridmap.get_cell_item(item_cell) == -1:
if not is_position_occupied(pos): 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 pos
return Vector2i(-1, -1) return Vector2i(-1, -1)
+60 -7
View File
@@ -3,44 +3,97 @@ class_name CandyCannonController
@export var is_static_turret: bool = true @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: func _ready() -> void:
pass 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") @rpc("authority", "call_local", "reliable")
func play_animation_rpc(anim_name: String) -> void: func play_animation_rpc(anim_name: String) -> void:
# Stub for future model animations # Stub for future model animations
pass pass
@rpc("authority", "call_local", "reliable") func spawn_projectile(target_world_pos: Vector3, duration: float) -> void:
func spawn_projectile_rpc(target_world_pos: Vector3, duration: float) -> void:
var projectile = MeshInstance3D.new() var projectile = MeshInstance3D.new()
var sphere = BoxMesh.new() var sphere = SphereMesh.new()
sphere.size = Vector3(0.4, 0.4, 0.4) sphere.radius = 0.3
sphere.height = 0.6
projectile.mesh = sphere projectile.mesh = sphere
var mat = StandardMaterial3D.new() var mat = StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.4, 0.8) # Candy pink for Gauntlet 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 projectile.material_override = mat
get_tree().get_root().add_child(projectile) get_tree().get_root().add_child(projectile)
# Start projectile slightly above the cannon center # 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() var tween = create_tween()
if not tween: if not tween:
projectile.queue_free() projectile.queue_free()
return 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.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: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) 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 mid_y = max(global_position.y, target_world_pos.y) + 4.0
var tween_y = create_tween() 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_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: func can_rpc() -> bool:
if not multiplayer.has_multiplayer_peer(): return false if not multiplayer.has_multiplayer_peer(): return false
+180 -38
View File
@@ -413,13 +413,15 @@ func _apply_arena_setup() -> void:
# Center 3x3 block: NPC obstacle (Candy Pump) # Center 3x3 block: NPC obstacle (Candy Pump)
if _is_npc_zone(pos): 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) gridmap.set_cell_item(Vector3i(x, 1, z), -1)
continue continue
# Boundary walls: perimeter (row 0, row 19, col 0, col 19) # 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: 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) gridmap.set_cell_item(Vector3i(x, 1, z), -1)
continue continue
@@ -511,9 +513,13 @@ func _spawn_mission_tiles() -> void:
if _is_npc_zone(pos): if _is_npc_zone(pos):
continue 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)) 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 continue
# Skip if something already exists on Layer 1 # Skip if something already exists on Layer 1
@@ -852,6 +858,15 @@ func sync_growth_telegraph(cells: Array) -> void:
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH) gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
_spawn_telegraph_highlight(pos) _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 # Audio: warning pulse
if SfxManager: if SfxManager:
SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile")
@@ -959,6 +974,17 @@ func _spawn_impact_particles(targets: Array) -> void:
material.scale_min = 0.1 material.scale_min = 0.1
material.scale_max = 0.3 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.process_material = material
particles.position = world_pos particles.position = world_pos
@@ -970,8 +996,57 @@ func _spawn_impact_particles(targets: Array) -> void:
particles.queue_free() 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: func is_sticky_cell(pos: Vector2i) -> bool:
return sticky_cells.has(pos) return sticky_cells.has(pos)
@@ -1222,6 +1297,13 @@ func _generate_bubble_candidates() -> Array:
var pos := Vector2i(x, z) var pos := Vector2i(x, z)
if cell_state(pos) != CellState.SAFE: if cell_state(pos) != CellState.SAFE:
continue 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)}) candidates.append({"pos": pos, "score": _calculate_bubble_score(pos, player_cells)})
return candidates return candidates
@@ -1431,6 +1513,15 @@ func sync_bubble_spawn(center: Vector2i, cells: Array) -> void:
if SfxManager: if SfxManager:
SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") 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") @rpc("authority", "call_local", "reliable")
func sync_bubble_explode(center: Vector2i, cells: Array) -> void: func sync_bubble_explode(center: Vector2i, cells: Array) -> void:
"""Apply the 3x3 sticky overlay + explosion VFX on all clients.""" """Apply the 3x3 sticky overlay + explosion VFX on all clients."""
@@ -1439,6 +1530,7 @@ func sync_bubble_explode(center: Vector2i, cells: Array) -> void:
for c in cells: for c in cells:
var pos = c as Vector2i var pos = c as Vector2i
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY) 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. # Medium shake — bubbles hit harder than a normal growth tick.
if main_scene and main_scene.get("screen_shake_manager"): if main_scene and main_scene.get("screen_shake_manager"):
main_scene.screen_shake_manager.shake(0.3, 0.6) 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: func clear_sticky_cell(pos: Vector2i) -> void:
"""Used by Cleanser power-up to remove a sticky cell.""" """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) sticky_cells.erase(pos)
mark_cleansed(pos) # temporary regrowth protection (v2) mark_cleansed(pos) # temporary regrowth protection (v2)
if gridmap: if gridmap:
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1) gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1)
# Sync removal to clients # Play VFX and SFX
if main_scene and _can_rpc(): _spawn_cleanser_particles(pos)
main_scene.rpc("sync_grid_item", pos.x, 2, pos.y, -1) 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: func _try_use_cleanser() -> void:
"""Local player attempts to activate Cleanser for 5-cell sticky immunity.""" """Local player attempts to activate Cleanser for 5-cell sticky immunity."""
@@ -1560,10 +1668,6 @@ func _try_use_cleanser() -> void:
return return
if local_player.get("is_frozen") or local_player.get("is_stop_frozen"): if local_player.get("is_frozen") or local_player.get("is_stop_frozen"):
return return
# Already active
if cleanser_active.has(local_pid):
return
# 0.3s activation delay # 0.3s activation delay
await get_tree().create_timer(CLEANSER_ACTIVATION_DELAY).timeout 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"): if local_player.get("is_frozen") or local_player.get("is_stop_frozen"):
return return
# Activate cleanser immunity # Consume cleanser from inventory (only if client, host relies on rpc)
if not multiplayer.is_server():
cleanser_active[local_pid] = true cleanser_active[local_pid] = true
cleanser_cells_left[local_pid] = CLEANSER_MAX_CELLS cleanser_cells_left[local_pid] = CLEANSER_MAX_CELLS
player_cleansers[local_pid] = max(0, player_cleansers[local_pid] - 1)
# Consume cleanser from inventory update_cleanser_ui(player_cleansers[local_pid])
player_cleansers[local_pid] = 0
update_cleanser_ui(0)
# Sync to server/clients # Sync to server/clients
if not multiplayer.is_server() and _can_rpc(): if not multiplayer.is_server() and _can_rpc():
rpc("rpc_activate_cleanser", local_pid) rpc("rpc_activate_cleanser", local_pid)
elif multiplayer.is_server(): elif multiplayer.is_server():
if _can_rpc(): # Call RPC logic directly for host (it will set active/cells_left/consume)
rpc("sync_cleanser_count", local_pid, 0) rpc_activate_cleanser(local_pid)
NotificationManager.send_message(local_player, "Cleanser Active! (5 cells)", NotificationManager.MessageType.POWERUP) 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: func deactivate_cleanser(player_id: int) -> void:
"""Deactivate cleanser immunity for a player.""" """Deactivate cleanser immunity for a player."""
cleanser_active.erase(player_id) cleanser_active.erase(player_id)
@@ -1605,30 +1709,67 @@ func use_cleanser_cell(player_id: int) -> bool:
return false return false
cleanser_cells_left[player_id] -= 1 cleanser_cells_left[player_id] -= 1
if cleanser_cells_left[player_id] <= 0: if cleanser_cells_left[player_id] <= 0:
if _can_rpc():
rpc("deactivate_cleanser", player_id)
else:
deactivate_cleanser(player_id) deactivate_cleanser(player_id)
return false return false
return true return true
func notify_movement_stopped(player_id: int, pos: Vector2i) -> void: func notify_movement_stopped(player_id: int, pos: Vector2i) -> void:
"""Cleanser also ends when the player comes to rest on a safe (non-sticky) """Called from PlayerMovementManager when a move chain settles.
cell they're clear of the candy, so immunity is no longer needed (#072). Previously deactivated cleanser here, but now immunity persists
Called from PlayerMovementManager when a move chain settles. Gauntlet-only; until charges run out to allow repeated use across safe gaps."""
a no-op when the player has no active cleanser.""" pass
if not cleanser_active.has(player_id):
return
if not is_sticky_cell(pos):
deactivate_cleanser(player_id)
@rpc("any_peer", "call_local", "reliable") @rpc("any_peer", "call_local", "reliable")
func rpc_activate_cleanser(pid: int) -> void: func rpc_activate_cleanser(pid: int) -> void:
"""RPC for clients to activate cleanser on server.""" """RPC for clients to activate cleanser on server."""
if multiplayer.is_server(): if multiplayer.is_server():
if not cleanser_active.has(pid): # 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_active[pid] = true
cleanser_cells_left[pid] = CLEANSER_MAX_CELLS cleanser_cells_left[pid] = CLEANSER_MAX_CELLS
player_cleansers[pid] = 0 player_cleansers[pid] = max(0, player_cleansers[pid] - 1)
if _can_rpc(): if _can_rpc():
rpc("sync_cleanser_count", pid, 0) 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") @rpc("any_peer", "call_local", "reliable")
func rpc_use_cleanser(pos: Vector2i) -> void: func rpc_use_cleanser(pos: Vector2i) -> void:
@@ -1729,9 +1870,9 @@ func _setup_hud() -> void:
hud_layer = hud_instance hud_layer = hud_instance
hud_layer.visible = false hud_layer.visible = false
add_child(hud_layer) add_child(hud_layer)
phase_label = hud_layer.get_node("TopContainer/PhaseLabel") phase_label = hud_layer.get_node("BottomContainer/VBoxContainer/PhaseLabel")
cleanser_icon = hud_layer.get_node("BottomContainer/CleanserHBox/CleanserIcon") cleanser_icon = hud_layer.get_node("BottomContainer/VBoxContainer/CleanserHBox/CleanserIcon")
cleanser_label = hud_layer.get_node("BottomContainer/CleanserHBox/CleanserLabel") cleanser_label = hud_layer.get_node("BottomContainer/VBoxContainer/CleanserHBox/CleanserLabel")
slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel") slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel")
_generate_cleanser_icon() _generate_cleanser_icon()
@@ -1770,7 +1911,7 @@ func _update_hud_phase(phase_name: String) -> void:
func update_cleanser_ui(count: int) -> void: func update_cleanser_ui(count: int) -> void:
cleanser_count = count cleanser_count = count
if cleanser_label: if cleanser_label:
cleanser_label.text = "Cleanser: %d" % count cleanser_label.text = "[E] Cleanser (%d)" % count
# Show/hide icon based on availability # Show/hide icon based on availability
if cleanser_icon: if cleanser_icon:
cleanser_icon.visible = count > 0 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] = 0
player_mission_completions[peer_id] += 1 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] var completions = player_mission_completions[peer_id]
if completions % 2 == 0: if completions % 2 == 0:
if not player_cleansers.has(peer_id): if not player_cleansers.has(peer_id):
player_cleansers[peer_id] = 0 player_cleansers[peer_id] = 0
if player_cleansers[peer_id] < 1:
player_cleansers[peer_id] = 1 # Allow stacking cleanser charges instead of capping at 1
player_cleansers[peer_id] += 1
emit_signal("cleanser_granted", peer_id) emit_signal("cleanser_granted", peer_id)
print("[Gauntlet] Player %d granted Cleanser (mission %d)" % [peer_id, completions]) print("[Gauntlet] Player %d granted Cleanser (Total: %d) (mission %d)" % [peer_id, player_cleansers[peer_id], completions])
# Sync cleanser count to HUD # Sync cleanser count to HUD
rpc("sync_cleanser_count", peer_id, player_cleansers.get(peer_id, 0)) rpc("sync_cleanser_count", peer_id, player_cleansers.get(peer_id, 0))
+11 -5
View File
@@ -111,11 +111,22 @@ func simple_move_to(grid_position: Vector2i) -> bool:
is_wall_passable = false is_wall_passable = false
print("[MovementManager] Hard block at %s. Ghost pass denied." % grid_position) 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) # Check Floor 0 (Basic Walkability/Void)
if (cell_floor == -1 or cell_floor in enhanced_gridmap.non_walkable_items) and not is_wall_passable: 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) print("[Move] Failed: Floor Item %d is non-walkable" % cell_floor)
return false 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) # Check Floor 1 (Obstacles/Walls)
if (cell_item != -1 and cell_item in [4, 16]) and not is_wall_passable: 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) print("[Move] Failed: Blocked by Item %d on Floor 1" % cell_item)
@@ -131,11 +142,6 @@ func simple_move_to(grid_position: Vector2i) -> bool:
if not try_push(grid_position, push_dir): if not try_push(grid_position, push_dir):
return false 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. # Sticky no longer hard-traps — players are slowed instead and can move freely.
# Check for Tekton interaction (Charged Strike Mode) # Check for Tekton interaction (Charged Strike Mode)
+1
View File
@@ -0,0 +1 @@
uid://byv3h6b2mfdui
+6 -4
View File
@@ -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") assert_eq(manager.player_cleansers.get(7, 0), 1, "Cleanser granted after 2 missions")
func test_cleanser_inventory_capped_at_one(): 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): for i in range(4):
manager.player_mission_completions[7] = i + 1
manager._on_goal_count_updated(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 # 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) # 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_active[8] = true
manager.cleanser_cells_left[8] = 3 manager.cleanser_cells_left[8] = 3
manager.notify_movement_stopped(8, Vector2i(5, 5)) # safe cell 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(): func test_stop_on_sticky_cell_keeps_cleanser():
manager.sticky_cells[Vector2i(6, 6)] = true manager.sticky_cells[Vector2i(6, 6)] = true
@@ -0,0 +1 @@
uid://dqvm86t7rr3t