feat: update

This commit is contained in:
2026-06-11 18:28:25 +08:00
parent 8520f9db3c
commit 8a2fb36a98
65 changed files with 4407 additions and 572 deletions
+22 -6
View File
@@ -442,28 +442,44 @@ func logout() -> void:
# =============================================================================
func _connect_socket() -> bool:
if not NakamaManager.session:
push_error("[AuthManager] Socket connection failed: no Nakama session")
return false
if NakamaManager.socket and NakamaManager.socket.is_connected_to_host():
if not multiplayer.has_multiplayer_peer() and NakamaManager.bridge:
multiplayer.set_multiplayer_peer(NakamaManager.bridge.multiplayer_peer)
NakamaManager.connected_to_nakama.emit()
return true
if NakamaManager.socket:
NakamaManager.socket.close()
NakamaManager.socket = null
NakamaManager.socket = Nakama.create_socket_from(NakamaManager.client)
var result = await NakamaManager.socket.connect_async(NakamaManager.session)
if result.is_exception():
push_error("[AuthManager] Socket connection failed: " + result.get_exception().message)
var exception = result.get_exception()
var error_message = "Socket connection failed"
if exception and not exception.message.is_empty():
error_message = exception.message
elif exception and exception.status_code >= 0:
error_message = "Socket connection failed with error code %s" % exception.status_code
push_error("[AuthManager] " + error_message)
NakamaManager.socket.close()
NakamaManager.socket = null
return false
# Initialize multiplayer bridge
NakamaManager.bridge = NakamaMultiplayerBridge.new(NakamaManager.socket)
NakamaManager.bridge.match_joined.connect(NakamaManager._on_bridge_match_joined)
NakamaManager.bridge.match_join_error.connect(NakamaManager._on_bridge_match_join_error)
multiplayer.set_multiplayer_peer(NakamaManager.bridge.multiplayer_peer)
# Notify other systems that Nakama socket is ready
NakamaManager.connected_to_nakama.emit()
return true
func _load_user_profile() -> void:
+81 -32
View File
@@ -99,6 +99,10 @@ const SMACK_CHARGE_WINDOW: float = 3.0
var player_mission_completions: Dictionary = {} # player_id → int
var player_cleansers: Dictionary = {} # player_id → int (0 or 1)
var cleanser_active: Dictionary = {} # player_id → true when immunity active
var cleanser_cells_left: Dictionary = {} # player_id → int (cells remaining)
const CLEANSER_MAX_CELLS: int = 5
const CLEANSER_ACTIVATION_DELAY: float = 0.3
# =============================================================================
# Trapped Players
@@ -131,6 +135,7 @@ var phase_label: Label
var cleanser_label: Label
var cleanser_icon: TextureRect
var cleanser_count: int = 0
var slowmo_label: Label
var _gauntlet_hud_scene: PackedScene = preload("res://scenes/gauntlet_hud.tscn")
# =============================================================================
@@ -763,13 +768,13 @@ func clear_sticky_cell(pos: Vector2i) -> void:
main_scene.rpc("sync_grid_item", pos.x, 2, pos.y, -1)
func _try_use_cleanser() -> void:
"""Local player attempts to use Cleanser on adjacent sticky cells."""
"""Local player attempts to activate Cleanser for 5-cell sticky immunity."""
var local_pid = multiplayer.get_unique_id()
var count = player_cleansers.get(local_pid, 0)
if count <= 0:
return
# Find local player
# Block activation during stun
var all_players = get_tree().get_nodes_in_group("Players")
var local_player = null
for p in all_players:
@@ -777,41 +782,75 @@ func _try_use_cleanser() -> void:
if pid == local_pid:
local_player = p
break
if not local_player or not gridmap:
if not local_player:
return
if local_player.get("is_frozen") or local_player.get("is_stop_frozen"):
return
# Already active
if cleanser_active.has(local_pid):
return
# Get player grid position
var player_pos = local_player.global_position
var grid_pos = Vector2i(int(player_pos.x), int(player_pos.z))
# 0.3s activation delay
await get_tree().create_timer(CLEANSER_ACTIVATION_DELAY).timeout
# Clear sticky cells in 3x3 area around player
var cleared_any = false
for dx in range(-1, 2):
for dz in range(-1, 2):
var check_pos = grid_pos + Vector2i(dx, dz)
if sticky_cells.has(check_pos):
if multiplayer.is_server():
clear_sticky_cell(check_pos)
else:
rpc("rpc_use_cleanser", check_pos)
cleared_any = true
# Re-validate after delay
if not is_instance_valid(local_player):
return
if local_player.get("is_frozen") or local_player.get("is_stop_frozen"):
return
if cleared_any:
# Consume cleanser
player_cleansers[local_pid] = 0
update_cleanser_ui(0)
# Trigger slow-mo for dramatic effect
if multiplayer.is_server():
trigger_slowmo()
else:
rpc("rpc_trigger_slowmo")
# Notify server if we're a client
if not multiplayer.is_server() and _can_rpc():
rpc("rpc_consume_cleanser", local_pid)
elif multiplayer.is_server():
# Sync to all clients
# 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)
# 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)
# Trigger slow-mo for dramatic effect
if multiplayer.is_server():
trigger_slowmo()
else:
rpc("rpc_trigger_slowmo")
NotificationManager.send_message(local_player, "Cleanser Active! (5 cells)", NotificationManager.MessageType.POWERUP)
func deactivate_cleanser(player_id: int) -> void:
"""Deactivate cleanser immunity for a player."""
cleanser_active.erase(player_id)
cleanser_cells_left.erase(player_id)
func is_cleanser_active(player_id: int) -> bool:
"""Check if a player has active cleanser immunity."""
return cleanser_active.has(player_id)
func use_cleanser_cell(player_id: int) -> bool:
"""Use one cleanser cell. Returns true if still active, false if exhausted."""
if not cleanser_active.has(player_id):
return false
cleanser_cells_left[player_id] -= 1
if cleanser_cells_left[player_id] <= 0:
deactivate_cleanser(player_id)
return false
return true
@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", local_pid, 0)
rpc("sync_cleanser_count", pid, 0)
@rpc("any_peer", "call_local", "reliable")
func rpc_use_cleanser(pos: Vector2i) -> void:
@@ -848,6 +887,9 @@ func trigger_slowmo(duration: float = 4.0) -> void:
# Show visual overlay
if main_scene and main_scene.has_node("Camera3D200"):
_show_slowmo_overlay()
# Show slow-mo HUD label
if slowmo_label:
slowmo_label.visible = true
if _can_rpc():
rpc("sync_slowmo_start", duration)
@@ -855,6 +897,9 @@ func _end_slowmo() -> void:
slowmo_active = false
Engine.time_scale = 1.0
_hide_slowmo_overlay()
# Hide slow-mo HUD label
if slowmo_label:
slowmo_label.visible = false
if _can_rpc():
rpc("sync_slowmo_end")
@@ -890,6 +935,8 @@ func sync_slowmo_start(duration: float) -> void:
slowmo_timer = duration
Engine.time_scale = SLOWMO_SCALE
_show_slowmo_overlay()
if slowmo_label:
slowmo_label.visible = true
@rpc("authority", "call_local", "reliable")
func sync_slowmo_end() -> void:
@@ -907,6 +954,7 @@ func _setup_hud() -> void:
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")
slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel")
_generate_cleanser_icon()
func _generate_cleanser_icon() -> void:
@@ -995,6 +1043,7 @@ func _on_goal_count_updated(peer_id: int, count: int) -> void:
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])
# Respawn mission tiles in non-sticky locations
+25 -1
View File
@@ -725,16 +725,40 @@ func set_game_mode(mode: String) -> void:
rpc("sync_game_mode", mode)
_update_available_areas(mode)
# Only force switch the area if the selected area is NOT valid for this mode
if selected_area not in available_areas:
set_area(available_areas[0])
else:
# Important: even if the area is technically in the list, if they just clicked Free Mode
# we should default them to Free Mode Area if they were on Stop n Go Area before.
if mode == "Free Mode" and "Free Mode Area" in available_areas:
set_area("Free Mode Area")
elif mode == "Stop n Go" and "Stop n Go Area" in available_areas:
set_area("Stop n Go Area")
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
set_area("Tekton Doors Area")
elif mode == "Gauntlet" and "Candy Pump Arena" in available_areas:
set_area("Candy Pump Arena")
@rpc("authority", "call_local", "reliable")
func sync_game_mode(mode: String) -> void:
"""Sync game mode selection from host to clients."""
game_mode = mode
_update_available_areas(mode)
if selected_area not in available_areas:
# Try to smart-match the client's local area to the mode as well so their UI matches
if mode == "Free Mode" and "Free Mode Area" in available_areas:
selected_area = "Free Mode Area"
elif mode == "Stop n Go" and "Stop n Go Area" in available_areas:
selected_area = "Stop n Go Area"
elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas:
selected_area = "Tekton Doors Area"
elif mode == "Gauntlet" and "Candy Pump Arena" in available_areas:
selected_area = "Candy Pump Arena"
elif selected_area not in available_areas:
selected_area = available_areas[0]
emit_signal("game_mode_changed", mode)
func start_game(force: bool = false) -> void:
+27 -13
View File
@@ -143,9 +143,9 @@ func simple_move_to(grid_position: Vector2i) -> bool:
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"):
# Check for Tekton interaction (Charged Strike Mode)
# If moving into a Tekton's space while Charged, trigger knock
if player.get("is_charged_strike"):
# Find Tekton at grid_position
var tektons = player.get_tree().get_nodes_in_group("Tektons")
for t in tektons:
@@ -154,12 +154,19 @@ 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 moving into a sticky cell, trigger trap (unless cleanser active)
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)
var pid = player.get("peer_id") if "peer_id" in player else -1
if pid != -1 and gm.is_cleanser_active(pid):
# Cleanser immunity: clear sticky cell, use one cell, don't trap
gm.clear_sticky_cell(grid_position)
gm.use_cleanser_cell(pid)
print("[Move] Cleanser cleared sticky cell at %s (%d cells left)" % [grid_position, gm.cleanser_cells_left.get(pid, 0)])
else:
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)
@@ -212,9 +219,9 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
var att_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
has_smack = gm_for_smack.has_smack_charged(att_pid)
if (not player.get("is_attack_mode") and not has_smack) or player.get("is_invisible"):
if (not player.get("is_charged_strike") and not has_smack) or player.get("is_invisible"):
# Standard bumping effect (Visual only)
print("[Move] Push blocked: Not in attack mode or is Ghost (%s trying to push %s)" % [player.name, other_player.name])
print("[Move] Push blocked: Not charged or is Ghost (%s trying to push %s)" % [player.name, other_player.name])
if _can_rpc():
player.rpc("sync_bump", target_pos, true) # Soft bump
elif player.has_method("sync_bump"):
@@ -334,9 +341,16 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
if main_sticky and main_sticky.get("gauntlet_manager"):
var gm_sticky = main_sticky.gauntlet_manager
if gm_sticky.is_active and gm_sticky.is_sticky_cell(pushed_to_pos):
print("[Move] Player pushed into sticky cell at %s" % pushed_to_pos)
if multiplayer.is_server() or other_player.is_multiplayer_authority():
gm_sticky._trap_player(other_player)
var push_pid = other_player.get("peer_id") if "peer_id" in other_player else -1
if push_pid != -1 and gm_sticky.is_cleanser_active(push_pid):
# Cleanser immunity: clear sticky cell, use one cell
gm_sticky.clear_sticky_cell(pushed_to_pos)
gm_sticky.use_cleanser_cell(push_pid)
print("[Move] Cleanser cleared push-into-sticky at %s" % pushed_to_pos)
else:
print("[Move] Player pushed into sticky cell at %s" % pushed_to_pos)
if multiplayer.is_server() or other_player.is_multiplayer_authority():
gm_sticky._trap_player(other_player)
# 2. Apply freeze/stun effect
var stun_duration = 1.0 if (gm_push and gm_push.is_active) else 1.5
+7 -7
View File
@@ -159,18 +159,18 @@ func use_special_effect() -> bool:
# Restriction: Cannot use attack mode while carrying a Tekton or in Ghost mode
if player.is_carrying_tekton:
NotificationManager.send_message(player, "Cannot enter Attack Mode while carrying a Tekton!", NotificationManager.MessageType.WARNING)
NotificationManager.send_message(player, "Cannot use Charged Strike while carrying a Tekton!", NotificationManager.MessageType.WARNING)
return false
if player.get("is_invisible"):
NotificationManager.send_message(player, "Cannot enter Attack Mode while in Ghost mode!", NotificationManager.MessageType.WARNING)
NotificationManager.send_message(player, "Cannot use Charged Strike while in Ghost mode!", NotificationManager.MessageType.WARNING)
return false
# Enable Attack Mode explicitly
player.is_attack_mode = true
# Do NOT consume boost here. Boost acts as "fuel" for the attacks.
# Notification check handled by caller or signal
return true
if player.has_method("enter_charged_strike"):
player.enter_charged_strike()
return true
return false
func consume_boost(amount: float):
"""Consume a specific amount of boost."""
+12 -1
View File
@@ -289,7 +289,9 @@ func get_action_display(action_key: String) -> String:
"grab": "ctrl_grab",
"use_powerup": "ctrl_use_powerup",
"tekton_grab": "ctrl_tekton_grab",
"action_grab_tekton": "ctrl_tekton_grab",
"attack_mode": "ctrl_attack_mode",
"action_knock_tekton": "ctrl_attack_mode",
}
if ctrl_key_map.has(action_key):
return get_controller_binding_text(ctrl_key_map[action_key])
@@ -315,7 +317,16 @@ func is_controller_button_used(button_index: int) -> String:
return ""
func get_control_keycode(action_name: String) -> int:
return settings.controls.get(action_name, -1)
# Map friendly names to their internal settings.controls keys
var mapped_name = action_name
if action_name == "tekton_grab":
mapped_name = "action_grab_tekton"
elif action_name == "attack_mode":
mapped_name = "action_knock_tekton"
elif action_name == "grab":
mapped_name = "action_grab"
return settings.controls.get(mapped_name, -1)
func get_control_text(action_name: String) -> String:
var code = get_control_keycode(action_name)
+3 -3
View File
@@ -354,9 +354,9 @@ func _run_tutorial() -> void:
if spawned_bot.get("is_knock_mode") == true:
break
# Fallback: if player's attack mode deactivated early despite having timer left, they likely successfully rammed
var t = local_player.get("attack_mode_timer")
if local_player.get("is_attack_mode") == false and t != null and t > 0.0 and t < 3.0:
# Fallback: if player's charged strike deactivated early despite having timer left, they likely successfully rammed
var t = local_player.get("charged_strike_timer")
if local_player.get("is_charged_strike") == false and t != null and t > 0.0 and t < 3.0:
break
await get_tree().process_frame
+10 -2
View File
@@ -123,8 +123,16 @@ func connect_to_nakama_async(email: String = "", password: String = "") -> bool:
return false
elif socket_result.is_exception():
var err = socket_result.get_exception()
printerr("[NakamaManager] Socket Error: %s (Code: %s)" % [err.message, err.status_code])
emit_signal("connection_failed", err.message)
var err_msg = "Socket connection failed"
if err and not err.message.is_empty():
err_msg = err.message
elif err and err.status_code >= 0:
err_msg = "Socket connection failed with code %s" % err.status_code
printerr("[NakamaManager] Socket Error: %s (Code: %s)" % [err_msg, err.status_code if err else -1])
emit_signal("connection_failed", err_msg)
if socket:
socket.close()
socket = null
return false
# 3. Initialize Multiplayer Bridge
+16 -1
View File
@@ -414,7 +414,16 @@ func spawn_tiles_around(count: int = 4):
# FIX 1: Make tekton look/rotate toward a random spawning direction
if not is_carried and not is_thrown:
var random_angle = rng.randf_range(0, TAU)
rotation.y = random_angle
# If it's a static turret, make it face the target tile it's about to spawn instead
if is_static_turret:
# We don't have a specific target yet, but we can pick an average direction
# Or just let it throw randomly like the others. Wait, the user wants:
# "static tekton, should facing toward where they're going to thrown the tiles"
# We'll calculate rotation inside the spawning loop for static turrets.
pass
else:
rotation.y = random_angle
# Play throw animation
if not is_carried and not is_thrown:
@@ -439,6 +448,12 @@ func spawn_tiles_around(count: int = 4):
var pos = current_position + Vector2i(x, y)
# For static turret, update rotation to face the exact tile being thrown
if is_static_turret and not is_carried and not is_thrown:
var throw_dir = Vector3(x, 0, y).normalized()
if throw_dir.length_squared() > 0.01:
rotation.y = atan2(throw_dir.x, throw_dir.z)
# Don't overwrite the Tekton's own cell? Or do?
# Maybe avoid center.
if x == 0 and y == 0: continue