feat: implement Candy Cannon mechanics, CI/CD pipelines, and version 2.3.7 updates
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
extends Node3D
|
||||
class_name CandyCannonController
|
||||
|
||||
@export var is_static_turret: bool = true
|
||||
|
||||
func _ready() -> void:
|
||||
pass
|
||||
|
||||
@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:
|
||||
var projectile = MeshInstance3D.new()
|
||||
var sphere = BoxMesh.new()
|
||||
sphere.size = Vector3(0.4, 0.4, 0.4)
|
||||
projectile.mesh = sphere
|
||||
var mat = StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(1.0, 0.4, 0.8) # Candy pink for Gauntlet
|
||||
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)
|
||||
|
||||
var tween = create_tween()
|
||||
if not tween:
|
||||
projectile.queue_free()
|
||||
return
|
||||
|
||||
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", 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)
|
||||
|
||||
func can_rpc() -> bool:
|
||||
if not multiplayer.has_multiplayer_peer(): return false
|
||||
return multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
|
||||
@@ -86,6 +86,8 @@ var trapped_players: Dictionary = {} # player_id → true
|
||||
|
||||
var main_scene: Node = null
|
||||
var gridmap: Node = null
|
||||
var candy_cannon_scene: PackedScene = preload("res://scenes/candy_cannon.tscn")
|
||||
var cannon_instance: Node3D = null
|
||||
|
||||
# HUD
|
||||
var hud_layer: CanvasLayer
|
||||
@@ -250,6 +252,14 @@ func _apply_arena_setup() -> void:
|
||||
gridmap.update_grid_data()
|
||||
gridmap.initialize_astar()
|
||||
|
||||
if not cannon_instance and main_scene:
|
||||
cannon_instance = candy_cannon_scene.instantiate()
|
||||
cannon_instance.name = "CandyCannon"
|
||||
var cx = NPC_CENTER.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0
|
||||
var cz = NPC_CENTER.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||||
cannon_instance.position = Vector3(cx, 0, cz)
|
||||
main_scene.add_child(cannon_instance)
|
||||
|
||||
print("[Gauntlet] Arena setup complete. Center NPC at (%d,%d), size %dx%d" % [
|
||||
NPC_CENTER.x, NPC_CENTER.y, NPC_SIZE, NPC_SIZE
|
||||
])
|
||||
@@ -336,6 +346,13 @@ func _fire_volley() -> void:
|
||||
# Telegraph phase — show warning
|
||||
if _can_rpc():
|
||||
rpc("sync_telegraph", targets)
|
||||
|
||||
# Shoot projectiles visually
|
||||
if cannon_instance and cannon_instance.has_method("spawn_projectile_rpc") and cannon_instance.can_rpc():
|
||||
var cs = gridmap.cell_size
|
||||
for target in targets:
|
||||
var target_pos = Vector3(target.x * cs.x + cs.x / 2.0, 0, target.y * cs.z + cs.z / 2.0)
|
||||
cannon_instance.rpc("spawn_projectile_rpc", target_pos, telegraph_time)
|
||||
|
||||
# Wait telegraph duration, then apply impact
|
||||
await get_tree().create_timer(telegraph_time).timeout
|
||||
@@ -443,24 +460,34 @@ func _check_all_players_trapped() -> void:
|
||||
var all_players = get_tree().get_nodes_in_group("Players")
|
||||
for player in all_players:
|
||||
var pos = player.current_position if player.get("current_position") else Vector2i(-1, -1)
|
||||
if is_sticky_cell(pos) and not trapped_players.has(player.get("peer_id", -1)):
|
||||
if is_sticky_cell(pos) and not trapped_players.has(player.get("peer_id") if "peer_id" in player else -1):
|
||||
_trap_player(player)
|
||||
|
||||
func _trap_player(player: Node) -> void:
|
||||
var pid = player.get("peer_id", -1)
|
||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if pid == -1: return
|
||||
trapped_players[pid] = true
|
||||
print("[Gauntlet] Player %d TRAPPED at %s" % [pid, str(player.current_position)])
|
||||
emit_signal("player_trapped", pid)
|
||||
|
||||
# TODO: Apply movement lockout, score penalty, visual feedback
|
||||
# For now, just mark as trapped — will be expanded in Task #4
|
||||
# Apply visual feedback and notify
|
||||
if player.has_method("apply_stagger"):
|
||||
if _can_rpc():
|
||||
player.rpc("apply_stagger", 999.0) # Basically infinite until cleansed
|
||||
else:
|
||||
player.apply_stagger(999.0)
|
||||
|
||||
NotificationManager.send_message(player, "Stuck in Candy!", NotificationManager.MessageType.WARNING)
|
||||
|
||||
func clear_sticky_cell(pos: Vector2i) -> void:
|
||||
"""Used by Cleanser power-up to remove a sticky cell."""
|
||||
sticky_cells.erase(pos)
|
||||
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)
|
||||
|
||||
# =============================================================================
|
||||
# HUD
|
||||
|
||||
@@ -131,6 +131,18 @@ func simple_move_to(grid_position: Vector2i) -> bool:
|
||||
if not try_push(grid_position, push_dir):
|
||||
return false
|
||||
|
||||
var gm = null
|
||||
var main = player.get_tree().root.get_node_or_null("Main")
|
||||
if main and main.get("gauntlet_manager"):
|
||||
gm = main.gauntlet_manager
|
||||
|
||||
# Check if currently trapped
|
||||
if gm and gm.is_active:
|
||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||
if pid != -1 and gm.trapped_players.has(pid):
|
||||
print("[Move] Failed: Player is trapped in a sticky cell")
|
||||
return false
|
||||
|
||||
# Check for Tekton interaction (Knock Mode)
|
||||
# If moving into a Tekton's space while in Knock Mode, trigger knock
|
||||
if player.get("is_knock_mode"):
|
||||
@@ -142,6 +154,12 @@ func simple_move_to(grid_position: Vector2i) -> bool:
|
||||
player.knock_tekton()
|
||||
return false # Don't move into the tile, just knock
|
||||
|
||||
# If moving into a sticky cell, trigger trap
|
||||
if gm and gm.is_active and gm.is_sticky_cell(grid_position):
|
||||
print("[Move] Player stepping into sticky cell at %s" % grid_position)
|
||||
movement_queue.clear()
|
||||
if player.is_multiplayer_authority() or multiplayer.is_server():
|
||||
gm._trap_player(player)
|
||||
|
||||
rotate_towards_target(grid_position)
|
||||
|
||||
@@ -249,6 +267,15 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
|
||||
|
||||
other_player.target_position = pushed_to_pos # Logical update
|
||||
|
||||
# Check if landing spot is sticky
|
||||
var main = player.get_tree().root.get_node_or_null("Main")
|
||||
if main and main.get("gauntlet_manager"):
|
||||
var gm = main.gauntlet_manager
|
||||
if gm.is_active and gm.is_sticky_cell(pushed_to_pos):
|
||||
print("[Move] Player pushed into sticky cell at %s" % pushed_to_pos)
|
||||
if multiplayer.is_server() or other_player.is_multiplayer_authority():
|
||||
gm._trap_player(other_player)
|
||||
|
||||
# 2. Apply freeze/stun effect (blue tint)
|
||||
if _can_rpc():
|
||||
other_player.rpc("apply_stagger", 1.5)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
extends RefCounted
|
||||
class_name ModeConfig
|
||||
|
||||
# ModeConfig - Schema-driven validation for game mode settings
|
||||
# Task [036]: Consolidates duplicated/inconsistent option toggles
|
||||
|
||||
# Schema definition for all mode-specific settings
|
||||
const SCHEMA = {
|
||||
"Freemode": {
|
||||
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
|
||||
"randomize_spawn": {"type": TYPE_BOOL, "default": false},
|
||||
"enable_cycle_timer": {"type": TYPE_BOOL, "default": false},
|
||||
"scarcity_mode": {"type": TYPE_STRING, "default": "Normal", "allowed": ["Normal", "Aggressive", "Chaos"]}
|
||||
},
|
||||
"Stop n Go": {
|
||||
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
|
||||
"sng_go_duration": {"type": TYPE_INT, "default": 20, "min": 10, "max": 60},
|
||||
"sng_stop_duration": {"type": TYPE_INT, "default": 4, "min": 2, "max": 10},
|
||||
"sng_required_goals": {"type": TYPE_INT, "default": 8, "min": 3, "max": 20}
|
||||
},
|
||||
"Tekton Doors": {
|
||||
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
|
||||
"doors_swap_time": {"type": TYPE_INT, "default": 15, "min": 10, "max": 30},
|
||||
"doors_refresh_time": {"type": TYPE_INT, "default": 25, "min": 15, "max": 40},
|
||||
"doors_required_goals": {"type": TYPE_INT, "default": 8, "min": 5, "max": 12}
|
||||
},
|
||||
"Candy Cannon Survival": {
|
||||
"match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600},
|
||||
"gauntlet_cannon_interval": {"type": TYPE_FLOAT, "default": 5.0, "min": 2.0, "max": 10.0},
|
||||
"gauntlet_volley_size": {"type": TYPE_INT, "default": 5, "min": 3, "max": 15}
|
||||
}
|
||||
}
|
||||
|
||||
# Get default config for a mode
|
||||
static func get_defaults(mode: String) -> Dictionary:
|
||||
if not SCHEMA.has(mode):
|
||||
push_error("ModeConfig: Unknown mode '%s'" % mode)
|
||||
return {}
|
||||
|
||||
var defaults = {}
|
||||
for key in SCHEMA[mode]:
|
||||
defaults[key] = SCHEMA[mode][key]["default"]
|
||||
return defaults
|
||||
|
||||
# Validate a single setting
|
||||
static func validate_setting(mode: String, key: String, value: Variant) -> Dictionary:
|
||||
if not SCHEMA.has(mode):
|
||||
return {"valid": false, "error": "Unknown mode: %s" % mode}
|
||||
|
||||
if not SCHEMA[mode].has(key):
|
||||
return {"valid": false, "error": "Unknown setting '%s' for mode '%s'" % [key, mode]}
|
||||
|
||||
var schema = SCHEMA[mode][key]
|
||||
|
||||
# Type check
|
||||
if typeof(value) != schema["type"]:
|
||||
return {"valid": false, "error": "Setting '%s' expects type %s, got %s" % [key, schema["type"], typeof(value)]}
|
||||
|
||||
# Range check for numbers
|
||||
if schema["type"] == TYPE_INT or schema["type"] == TYPE_FLOAT:
|
||||
if schema.has("min") and value < schema["min"]:
|
||||
return {"valid": false, "error": "Setting '%s' must be >= %s" % [key, schema["min"]]}
|
||||
if schema.has("max") and value > schema["max"]:
|
||||
return {"valid": false, "error": "Setting '%s' must be <= %s" % [key, schema["max"]]}
|
||||
|
||||
# Allowed values check for strings
|
||||
if schema["type"] == TYPE_STRING and schema.has("allowed"):
|
||||
if value not in schema["allowed"]:
|
||||
return {"valid": false, "error": "Setting '%s' must be one of %s" % [key, schema["allowed"]]}
|
||||
|
||||
return {"valid": true}
|
||||
|
||||
# Validate entire config for a mode
|
||||
static func validate_config(mode: String, config: Dictionary) -> Dictionary:
|
||||
if not SCHEMA.has(mode):
|
||||
return {"valid": false, "error": "Unknown mode: %s" % mode}
|
||||
|
||||
var errors = []
|
||||
|
||||
for key in config:
|
||||
var result = validate_setting(mode, key, config[key])
|
||||
if not result["valid"]:
|
||||
errors.append(result["error"])
|
||||
|
||||
if errors.is_empty():
|
||||
return {"valid": true}
|
||||
else:
|
||||
return {"valid": false, "errors": errors}
|
||||
|
||||
# Get all settings for a mode
|
||||
static func get_mode_settings(mode: String) -> Array:
|
||||
if not SCHEMA.has(mode):
|
||||
return []
|
||||
return SCHEMA[mode].keys()
|
||||
|
||||
# Get schema for a specific setting
|
||||
static func get_setting_schema(mode: String, key: String) -> Dictionary:
|
||||
if not SCHEMA.has(mode) or not SCHEMA[mode].has(key):
|
||||
return {}
|
||||
return SCHEMA[mode][key]
|
||||
|
||||
# Check if a mode has a specific setting
|
||||
static func has_setting(mode: String, key: String) -> bool:
|
||||
return SCHEMA.has(mode) and SCHEMA[mode].has(key)
|
||||
|
||||
# Get all supported modes
|
||||
static func get_supported_modes() -> Array:
|
||||
return SCHEMA.keys()
|
||||
Reference in New Issue
Block a user