Gauntlet UI fixes and cleanser improvements
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
+43
-38
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://byv3h6b2mfdui
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqvm86t7rr3t
|
||||
Reference in New Issue
Block a user