From 5653473c123e8af826bd25f8085d697f8f2a29b2 Mon Sep 17 00:00:00 2001 From: adtpdn Date: Wed, 10 Jun 2026 02:12:25 +0800 Subject: [PATCH] feat: the rebuild gamemode of "Gauntlet" --- project.godot | 8 + scenes/lobby.gd | 7 + scenes/lobby.tscn | 9 +- scenes/main.gd | 16 +- scenes/player_sync_modulate.patch | 12 + scenes/ui/lobby_chat.gd | 6 +- scripts/event_bus.gd | 73 +++ scripts/event_bus.gd.uid | 1 + scripts/managers/friend_manager.gd | 14 +- scripts/managers/gacha_manager.gd | 12 +- scripts/managers/gauntlet_manager.gd | 600 +++++++++++++++++++- scripts/managers/lobby_manager.gd | 48 ++ scripts/managers/mail_manager.gd | 34 +- scripts/managers/player_movement_manager.gd | 98 +++- scripts/managers/screen_shake.gd | 44 +- scripts/managers/session_manager.gd | 125 ++++ scripts/managers/session_manager.gd.uid | 1 + scripts/managers/special_tiles_manager.gd | 4 + scripts/managers/user_profile_manager.gd | 56 +- scripts/services/backend_service.gd | 174 +++++- scripts/tekton.gd | 3 + scripts/ui/admin_panel.gd | 12 +- scripts/ui/daily_reward_config_panel.gd | 42 +- scripts/ui/daily_reward_panel.gd | 52 +- scripts/ui/leaderboard_panel.gd | 40 +- scripts/ui/profile_panel.gd | 12 +- scripts/ui/social_panel.gd | 9 +- test_smack.py | 55 ++ 28 files changed, 1313 insertions(+), 254 deletions(-) create mode 100644 scenes/player_sync_modulate.patch create mode 100644 scripts/event_bus.gd create mode 100644 scripts/event_bus.gd.uid create mode 100644 scripts/managers/session_manager.gd create mode 100644 scripts/managers/session_manager.gd.uid create mode 100644 test_smack.py diff --git a/project.godot b/project.godot index 8513d06..77efc70 100644 --- a/project.godot +++ b/project.godot @@ -48,6 +48,8 @@ GachaManager="*res://scripts/managers/gacha_manager.gd" BackendService="*res://scripts/services/backend_service.gd" FriendManager="*res://scripts/managers/friend_manager.gd" MailManager="*res://scripts/managers/mail_manager.gd" +EventBus="*res://scripts/event_bus.gd" +SessionManager="*res://scripts/managers/session_manager.gd" [display] @@ -137,6 +139,12 @@ use_powerup={ , Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":false,"script":null) ] } +use_cleanser={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":2,"pressure":0.0,"pressed":false,"script":null) +] +} action_grab_tekton={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":103,"location":0,"echo":false,"script":null) diff --git a/scenes/lobby.gd b/scenes/lobby.gd index 537ce9f..1a894d3 100644 --- a/scenes/lobby.gd +++ b/scenes/lobby.gd @@ -179,6 +179,7 @@ func _ready(): # Setup Game Mode specific UI dynamically _create_custom_settings_ui() + _setup_game_modes() # Initial UI update _sync_room_profile_card() @@ -504,6 +505,12 @@ func _sync_room_profile_card() -> void: if room_player_rank: room_player_rank.text = "Rank: %s" % (_local_player_rank if _local_player_rank > 0 else "-") + # Sync currency labels + if gold_label: + gold_label.text = str(UserProfileManager.wallet.get("gold", 0)) + if star_label: + star_label.text = str(UserProfileManager.wallet.get("star", 0)) + var avatar_path = UserProfileManager.get_avatar_url() if not avatar_path.is_empty() and ResourceLoader.exists(avatar_path): var tex = load(avatar_path) diff --git a/scenes/lobby.tscn b/scenes/lobby.tscn index 7e39c8a..4f483d9 100644 --- a/scenes/lobby.tscn +++ b/scenes/lobby.tscn @@ -19,13 +19,13 @@ [ext_resource type="Theme" uid="uid://cxab3xxy00" path="res://assets/themes/GUI_Tekton.tres" id="14_2630d"] [ext_resource type="FontFile" uid="uid://c2tryhyhlyb1u" path="res://assets/fonts/Supercell-Magic Regular.ttf" id="14_vwf6o"] [ext_resource type="Texture2D" uid="uid://kflvrkha1jwy" path="res://assets/graphics/gui/mainmenu/chat_enter.png" id="15_iwi7x"] -[ext_resource type="Texture2D" uid="uid://q8vw41qd00lx" path="res://assets/graphics/gui/mainmenu/button_room.png" id="17_wjff0"] +[ext_resource type="Texture2D" uid="uid://dq8ll26kexu3b" path="res://assets/graphics/gui/mainmenu/button_room.png" id="17_wjff0"] [ext_resource type="Texture2D" uid="uid://bcgu0jku4ntcw" path="res://assets/graphics/gui/mainmenu/button_room_interact.png" id="18_0jb4q"] [ext_resource type="Texture2D" uid="uid://dllwxub5n4361" path="res://assets/graphics/gui/mainmenu/button_play.png" id="18_h1rib"] [ext_resource type="Texture2D" uid="uid://l03huy5c0vvy" path="res://assets/graphics/gui/lobby/profile.png" id="18_u7tfn"] [ext_resource type="Texture2D" uid="uid://jted80o4uarv" path="res://assets/graphics/gui/lobby/leaderboards.png" id="19_2630d"] [ext_resource type="Texture2D" uid="uid://bvugtpcgc2qkx" path="res://assets/graphics/gui/lobby/shop.png" id="20_835bk"] -[ext_resource type="Texture2D" uid="uid://c6ahbdxx23e3b" path="res://assets/graphics/gui/mainmenu/button_play_interact.png" id="20_vwf6o"] +[ext_resource type="Texture2D" uid="uid://cdupokfxuufpl" path="res://assets/graphics/gui/mainmenu/button_play_interact.png" id="20_vwf6o"] [ext_resource type="Texture2D" uid="uid://6agwwbc1l4g3" path="res://assets/graphics/gui/play/selection_play0.png" id="21_h1rib"] [ext_resource type="Texture2D" uid="uid://bmmajc7h7o4dg" path="res://assets/graphics/gui/lobby/dailylogin.png" id="21_ucbax"] [ext_resource type="Texture2D" uid="uid://dv782w5t0xlcc" path="res://assets/graphics/gui/lobby/friends.png" id="22_1x1aw"] @@ -724,10 +724,11 @@ grow_vertical = 2 texture = ExtResource("23_835bk") expand_mode = 3 -[node name="MailBadge" type="Label" parent="MainMenuPanel/MainMargin/MainHBox/RightCol/TopRightPanel/MailboxBtn"] +[node name="MailBadge" type="Label" parent="MainMenuPanel/MainMargin/MainHBox/RightCol/TopRightPanel/MailboxBtn" unique_id=156491696] unique_name_in_owner = true visible = false layout_mode = 1 +anchors_preset = 1 anchor_left = 1.0 anchor_right = 1.0 offset_left = -18.0 @@ -735,12 +736,10 @@ offset_top = -2.0 offset_right = 2.0 offset_bottom = 16.0 grow_horizontal = 0 -grow_vertical = 1 theme_override_colors/font_color = Color(1, 1, 1, 1) theme_override_font_sizes/font_size = 10 horizontal_alignment = 1 vertical_alignment = 1 -text = "" [node name="SocialBtn" type="Button" parent="MainMenuPanel/MainMargin/MainHBox/RightCol/TopRightPanel" unique_id=82719328] unique_name_in_owner = true diff --git a/scenes/main.gd b/scenes/main.gd index bfb65c1..9a1dd3b 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -1856,14 +1856,14 @@ func randomize_item_at_position(grid_position: Vector2i): if is_ground: var new_item = 7 - var get_mode_specific_tile = func(): - if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors": - # 60% Chance for Common (7-10), 40% for PowerUp - if randf() <= 0.6: - return [7, 8, 9, 10].pick_random() - else: - return ScarcityModel.SPECIAL_TILES.pick_random() - return ScarcityController.get_random_tile_id() + var get_mode_specific_tile = func(): + if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Cannon Survival": + # 60% Chance for Common (7-10), 40% for PowerUp + if randf() <= 0.6: + return [7, 8, 9, 10].pick_random() + else: + return ScarcityModel.SPECIAL_TILES.pick_random() + return ScarcityController.get_random_tile_id() new_item = get_mode_specific_tile.call() diff --git a/scenes/player_sync_modulate.patch b/scenes/player_sync_modulate.patch new file mode 100644 index 0000000..9c7e5b3 --- /dev/null +++ b/scenes/player_sync_modulate.patch @@ -0,0 +1,12 @@ +*** Begin Patch +*** Update File: scenes/player.gd +@@ -37,6 +37,11 @@ + if is_stop_frozen == value: return + is_stop_frozen = value + _refresh_player_visuals() ++ ++@rpc("any_peer", "call_local", "reliable") ++func sync_modulate(color: Color) -> void: ++ _apply_tint_recursive(self, color, 1.0) ++ +*** End Patch diff --git a/scenes/ui/lobby_chat.gd b/scenes/ui/lobby_chat.gd index fbdf70c..0714655 100644 --- a/scenes/ui/lobby_chat.gd +++ b/scenes/ui/lobby_chat.gd @@ -146,9 +146,9 @@ func _on_chat_send_pressed() -> void: _refresh_chat_display() if _chat_channel and NakamaManager.session and NakamaManager.client: var payload = JSON.stringify({"channel_id": _chat_channel.id}) - var rpc_result = await NakamaManager.client.rpc_async(NakamaManager.session, "admin_clear_global_chat", payload) - if rpc_result.is_exception(): - push_warning("[Chat] admin_clear_global_chat RPC failed: " + rpc_result.get_exception().message) + var rpc_result = await BackendService.admin_clear_global_chat(payload) + if rpc_result.get("success", false) == false: + push_warning("[Chat] admin_clear_global_chat RPC failed: " + str(rpc_result.get("message", ""))) else: _inject_local_message("[SYSTEM] : Global chat cleared by admin.") else: diff --git a/scripts/event_bus.gd b/scripts/event_bus.gd new file mode 100644 index 0000000..9f1d403 --- /dev/null +++ b/scripts/event_bus.gd @@ -0,0 +1,73 @@ +extends Node + +# EventBus - Centralized Observer Pattern for inter-manager communication +# Replaces direct cross-references between managers + +# ============================================================================= +# Events Registry +# ============================================================================= + +# Player Events +const EVENT_PLAYER_JOINED = "player_joined" +const EVENT_PLAYER_LEFT = "player_left" +const EVENT_PLAYER_READY = "player_ready" + +# Match Events +const EVENT_MATCH_STARTED = "match_started" +const EVENT_MATCH_ENDED = "match_ended" +const EVENT_GAME_MODE_CHANGED = "game_mode_changed" + +# Economy Events +const EVENT_CURRENCY_CHANGED = "currency_changed" +const EVENT_ITEM_PURCHASED = "item_purchased" +const EVENT_GACHA_PULL = "gacha_pull" + +# Profile Events +const EVENT_PROFILE_LOADED = "profile_loaded" +const EVENT_PROFILE_UPDATED = "profile_updated" +const EVENT_AVATAR_CHANGED = "avatar_changed" + +# Session Events +const EVENT_SESSION_REFRESHED = "session_refreshed" +const EVENT_SESSION_EXPIRED = "session_expired" + +# ============================================================================= +# Signal Bus +# ============================================================================= + +signal event_emitted(event_name: String, data: Variant) + +# ============================================================================= +# Internal Registry +# ============================================================================= + +var _listeners: Dictionary = {} # event_name -> Array[Callable] + +# ============================================================================= +# Public API +# ============================================================================= + +func emit(event_name: String, data: Variant = null) -> void: + """Emit an event to all registered listeners.""" + if _listeners.has(event_name): + for callback in _listeners[event_name]: + if data != null: + callback.call(data) + else: + callback.call() + event_emitted.emit(event_name, data) + +func on(event_name: String, callback: Callable) -> void: + """Subscribe to an event.""" + if not _listeners.has(event_name): + _listeners[event_name] = [] + _listeners[event_name].append(callback) + +func off(event_name: String, callback: Callable) -> void: + """Unsubscribe from an event.""" + if _listeners.has(event_name): + _listeners[event_name].erase(callback) + +func clear() -> void: + """Remove all listeners. Useful for cleanup between scenes.""" + _listeners.clear() diff --git a/scripts/event_bus.gd.uid b/scripts/event_bus.gd.uid new file mode 100644 index 0000000..1244dd8 --- /dev/null +++ b/scripts/event_bus.gd.uid @@ -0,0 +1 @@ +uid://cph7fr22ohbwy diff --git a/scripts/managers/friend_manager.gd b/scripts/managers/friend_manager.gd index afe0fe9..37f9e81 100644 --- a/scripts/managers/friend_manager.gd +++ b/scripts/managers/friend_manager.gd @@ -130,9 +130,9 @@ func add_friend_by_id(user_id: String) -> bool: print("[FriendManager] add_friend_by_id: add_friends_async OK, sending notification RPC...") # Step 2: Notify the target via RPC var payload = JSON.stringify({"user_id": user_id}) - var rpc_result = await NakamaManager.client.rpc_async(NakamaManager.session, "send_friend_request", payload) - if rpc_result.is_exception(): - push_error("[FriendManager] rpcSendFriendRequest failed: " + rpc_result.get_exception().message) + var rpc_result = await BackendService.send_friend_request(user_id) + if rpc_result.get("success", false) == false: + push_error("[FriendManager] rpcSendFriendRequest failed: " + str(rpc_result.get("error", ""))) else: print("[FriendManager] rpcSendFriendRequest OK: " + str(rpc_result.payload)) @@ -169,11 +169,9 @@ func remove_friend(user_id: String) -> bool: func send_lobby_invite(to_user_id: String, match_id: String) -> void: if not NakamaManager.session: return - var payload = JSON.stringify({"to_user_id": to_user_id, "match_id": match_id}) - var result = await NakamaManager.client.rpc_async( - NakamaManager.session, "send_lobby_invite", payload) - if result.is_exception(): - push_warning("[FriendManager] send_lobby_invite failed: " + result.get_exception().message) + var result = await BackendService.send_lobby_invite(to_user_id, match_id) + if result.get("success", false) == false: + push_warning("[FriendManager] send_lobby_invite failed: " + str(result.get("error", ""))) func _on_notification_received(notification) -> void: print("[FriendManager] _on_notification_received: code=%d sender=%s" % [notification.code, notification.sender_id]) diff --git a/scripts/managers/gacha_manager.gd b/scripts/managers/gacha_manager.gd index f0cfb0f..5920b6c 100644 --- a/scripts/managers/gacha_manager.gd +++ b/scripts/managers/gacha_manager.gd @@ -37,18 +37,14 @@ func pull(banner_id: String, count: int) -> Array: "count": count }) - var result = await NakamaManager.client.rpc_async( - NakamaManager.session, - "perform_gacha_pull", - payload - ) + var result = await BackendService.perform_gacha_pull(banner_id, count) - if result.is_exception(): - var msg = result.get_exception().message + if result.get("success", false) == false: + var msg = str(result.get("error", "Unknown error")) push_error("[GachaManager] Gacha pull failed: " + msg) return [] - var parsed = JSON.parse_string(result.payload) + var parsed = result.get("data", {}) if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("results"): return [] diff --git a/scripts/managers/gauntlet_manager.gd b/scripts/managers/gauntlet_manager.gd index 08e0607..922394d 100644 --- a/scripts/managers/gauntlet_manager.gd +++ b/scripts/managers/gauntlet_manager.gd @@ -62,6 +62,32 @@ var phase_configs: Array = [ # Smack State (per-player) # ============================================================================= +func has_smack_charged(pid: int) -> bool: + if smack_charged.has(pid) and smack_charged[pid] > 0: + return true + return false + +@rpc("any_peer", "call_local", "reliable") +func consume_smack(pid: int) -> void: + # Local state reset + smack_charged[pid] = 0.0 + smack_cooldowns[pid] = SMACK_COOLDOWN + + # Play smack sound + if SfxManager: + SfxManager.rpc("play_rpc", "attack_mode") if _can_rpc() else SfxManager.play("attack_mode") + + var all_players = get_tree().get_nodes_in_group("Players") + for player in all_players: + var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() + if curr_pid == pid: + if player.has_method("sync_modulate"): + if _can_rpc(): + player.rpc("sync_modulate", Color.WHITE) + else: + player.sync_modulate(Color.WHITE) + break + var smack_cooldowns: Dictionary = {} # player_id → float (time remaining) var smack_charged: Dictionary = {} # player_id → float (charge window remaining) const SMACK_COOLDOWN: float = 8.0 @@ -80,6 +106,16 @@ var player_cleansers: Dictionary = {} # player_id → int (0 or 1) var trapped_players: Dictionary = {} # player_id → true +# ============================================================================= +# Slow-Mo Effect +# ============================================================================= + +var slowmo_active: bool = false +var slowmo_timer: float = 0.0 +var slowmo_duration: float = 4.0 +const SLOWMO_SCALE: float = 0.25 # 1/4 speed +var slowmo_overlay: ColorRect = null + # ============================================================================= # References # ============================================================================= @@ -93,6 +129,8 @@ var cannon_instance: Node3D = null var hud_layer: CanvasLayer var phase_label: Label var cleanser_label: Label +var cleanser_icon: TextureRect +var cleanser_count: int = 0 # ============================================================================= # Lifecycle @@ -102,10 +140,22 @@ func _ready(): set_process(false) _setup_hud() +func _exit_tree(): + # Ensure time_scale is always restored when leaving Gauntlet mode + Engine.time_scale = 1.0 + func initialize(main: Node, grid: Node) -> void: main_scene = main gridmap = grid print("[Gauntlet] Initialized with gridmap: ", gridmap.name if gridmap else "null") + + # Connect to GoalsCycleManager for scoring and mission tracking + if main_scene: + var gcm = main_scene.get_node_or_null("GoalsCycleManager") + if gcm: + gcm.goal_count_updated.connect(_on_goal_count_updated) + gcm.score_updated.connect(_on_score_updated) + print("[Gauntlet] Connected to GoalsCycleManager") func _process(delta: float) -> void: if not is_active: @@ -116,13 +166,54 @@ func _process(delta: float) -> void: # Phase escalation _check_phase_transition() - # Cannon timer (server only) + # Server only logic if multiplayer.is_server(): + # Cannon timer cannon_timer -= delta if cannon_timer <= 0.0: _fire_volley() cannon_timer = cannon_interval - + + # Smack mechanic update (ALL PEERS) + var all_players = get_tree().get_nodes_in_group("Players") + for player in all_players: + var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() + + # Allow local peer to predict setup + if not smack_cooldowns.has(pid) and not smack_charged.has(pid): + smack_cooldowns[pid] = SMACK_COOLDOWN + smack_charged[pid] = 0.0 + + if smack_cooldowns[pid] > 0: + smack_cooldowns[pid] -= delta + if smack_cooldowns[pid] <= 0: + smack_cooldowns[pid] = 0.0 + smack_charged[pid] = SMACK_CHARGE_WINDOW + if player.has_method("sync_modulate"): + if multiplayer.is_server() and _can_rpc(): + player.rpc("sync_modulate", Color.PINK) + elif not multiplayer.is_server(): + player.sync_modulate(Color.PINK) + elif smack_charged[pid] > 0: + smack_charged[pid] -= delta + if smack_charged[pid] <= 0: + smack_charged[pid] = 0.0 + smack_cooldowns[pid] = SMACK_COOLDOWN + if player.has_method("sync_modulate"): + if multiplayer.is_server() and _can_rpc(): + player.rpc("sync_modulate", Color.WHITE) + elif not multiplayer.is_server(): + player.sync_modulate(Color.WHITE) + + # Cleanser input (local player only) + if Input.is_action_just_pressed("use_cleanser"): + _try_use_cleanser() + + # Slow-mo timer (all peers for visual consistency) + if slowmo_active: + slowmo_timer -= delta + if slowmo_timer <= 0: + _end_slowmo() # ============================================================================= # Game Mode Start # ============================================================================= @@ -243,8 +334,13 @@ func _apply_arena_setup() -> void: gridmap.set_cell_item(Vector3i(x, 1, z), -1) continue - # Outer edge (row 0, row 19, col 0, col 19) — cannon spawn positions - # These are walkable but used as spawn reference + # 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) + gridmap.set_cell_item(Vector3i(x, 1, z), -1) + continue + + # Interior: walkable floor gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) @@ -260,8 +356,8 @@ func _apply_arena_setup() -> void: 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 + print("[Gauntlet] Arena setup complete. Boundary walls at perimeter. Center NPC at (%d,%d)" % [ + NPC_CENTER.x, NPC_CENTER.y ]) func _is_npc_zone(pos: Vector2i) -> bool: @@ -271,6 +367,36 @@ func _is_npc_zone(pos: Vector2i) -> bool: var max_coord = NPC_CENTER + Vector2i(half, half) # (10, 10) return pos.x >= min_coord.x and pos.x <= max_coord.x and pos.y >= min_coord.y and pos.y <= max_coord.y +func get_spawn_points(player_count: int) -> Array[Vector2i]: + """Return spawn positions based on player count. Inside boundary walls.""" + # 4 players: inner corners + var spawns_4: Array[Vector2i] = [ + Vector2i(1, 1), # Top-left + Vector2i(18, 1), # Top-right + Vector2i(1, 18), # Bottom-left + Vector2i(18, 18), # Bottom-right + ] + + # 6 players: corners + mid-edges (top/bottom) + var spawns_6: Array[Vector2i] = spawns_4.duplicate() + spawns_6.append(Vector2i(10, 1)) # Top-mid + spawns_6.append(Vector2i(10, 18)) # Bottom-mid + + # 8 players: corners + all mid-edges + var spawns_8: Array[Vector2i] = spawns_6.duplicate() + spawns_8.append(Vector2i(1, 10)) # Left-mid + spawns_8.append(Vector2i(18, 10)) # Right-mid + + match player_count: + 4: + return spawns_4 + 5, 6: + return spawns_6 + _, 7, 8: + return spawns_8 + _: + return spawns_4 + # ============================================================================= # Tile Spawning & Mission System (Task #3) # ============================================================================= @@ -332,7 +458,7 @@ func _spawn_mission_tiles() -> void: # ============================================================================= func _fire_volley() -> void: - """Select target cells, telegraph, then apply sticky after delay.""" + """Select target cells, highlight, telegraph, then apply sticky after delay.""" if not multiplayer.is_server(): return @@ -342,20 +468,31 @@ func _fire_volley() -> void: var config = phase_configs[int(current_phase)] var telegraph_time = config["telegraph_time"] + var highlight_time: float = 0.8 # Floor highlight duration before telegraph - # Telegraph phase — show warning + # Highlight phase — show pulsing floor warning BEFORE telegraph + if _can_rpc(): + rpc("sync_telegraph_highlight", targets) + await get_tree().create_timer(highlight_time).timeout + + # Telegraph phase — show warning overlay 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 + # Shoot projectiles visually with 0.1s offset between shots + if cannon_instance and cannon_instance.has_method("spawn_projectile_rpc") and cannon_instance.can_rpc(): + var cs = gridmap.cell_size + for i in range(targets.size()): + var target = targets[i] + var target_pos = Vector3(target.x * cs.x + cs.x / 2.0, 0, target.y * cs.z + cs.z / 2.0) + # Stagger shots: 0.1s offset per projectile + await get_tree().create_timer(i * 0.1).timeout + cannon_instance.rpc("spawn_projectile_rpc", target_pos, telegraph_time) + + # Wait remaining telegraph duration, then apply impact + var remaining_time = telegraph_time - (targets.size() - 1) * 0.1 + if remaining_time > 0: + await get_tree().create_timer(remaining_time).timeout if _can_rpc(): rpc("sync_impact", targets) @@ -421,15 +558,100 @@ func _get_nearby_valid_cells(center: Vector2i, radius: int, valid: Array) -> Arr # ============================================================================= # Telegraph & Impact (RPCs) -# ============================================================================= + # ============================================================================= + +@rpc("authority", "call_local", "reliable") +func sync_telegraph_highlight(targets: Array) -> void: + """Show pulsing floor highlight on target cells BEFORE the telegraph drop.""" + if not gridmap: return + + # Create programmatic highlight overlays (pulsing circles on floor) + for target in targets: + var pos = target as Vector2i + var cs = gridmap.cell_size + var world_pos = Vector3(pos.x * cs.x + cs.x / 2.0, 0.15, pos.y * cs.z + cs.z / 2.0) + + # Create a flat pulsing indicator mesh + var mesh_inst = MeshInstance3D.new() + var box = BoxMesh.new() + box.size = Vector3(cs.x * 0.8, 0.02, cs.z * 0.8) + mesh_inst.mesh = box + mesh_inst.position = world_pos + + var mat = StandardMaterial3D.new() + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.albedo_color = Color(1.0, 0.3, 0.5, 0.4) # Pink warning glow + mat.emission_enabled = true + mat.emission = Color(1.0, 0.3, 0.5) + mat.emission_energy_multiplier = 2.0 + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mesh_inst.material_override = mat + + # Add to scene tree under main + var main = get_node_or_null("/root/Main") + if main: + main.add_child(mesh_inst) + # Pulse animation + var tween = create_tween().set_loops() + tween.tween_method(func(a): mat.albedo_color.a = a, 0.4, 0.1, 0.2) + tween.tween_method(func(a): mat.albedo_color.a = a, 0.1, 0.4, 0.2) + # Auto-remove after highlight duration + var remove_timer = get_tree().create_timer(0.8) + remove_timer.timeout.connect(func(): + if is_instance_valid(mesh_inst): + mesh_inst.queue_free() + ) + + # Play warning sound + if SfxManager: + SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") @rpc("authority", "call_local", "reliable") func sync_telegraph(targets: Array) -> void: - """Show warning overlay on target cells.""" + """Show warning overlay on target cells with multi-stage animation.""" if not gridmap: return + + # Place telegraph tiles for target in targets: var pos = target as Vector2i gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH) + + # Animate telegraph with Tween (build-up phase) + _animate_telegraph(targets) + +func _animate_telegraph(targets: Array) -> void: + """Tween animation for telegraph: fade in, flash, then transition to sticky.""" + var config = phase_configs[int(current_phase)] + var telegraph_time = config["telegraph_time"] + var build_up_time = telegraph_time * 0.8 # 80% for build-up + var flash_time = telegraph_time * 0.2 # 20% for flash + + # Create tween for visual feedback + var tween = create_tween() + tween.set_parallel(true) + + # Phase 1: Fade in (alpha 0 -> 1) during build-up + # Note: GridMap tiles don't support alpha directly, so we use modulation + # We'll animate the gridmap overlay opacity conceptually + for target in targets: + var pos = target as Vector2i + # Tween the cell brightness by swapping between telegraph variants + tween.tween_callback(_flash_telegraph.bind(targets, 0)).set_delay(0.0) + tween.tween_callback(_flash_telegraph.bind(targets, 1)).set_delay(0.4) + tween.tween_callback(_flash_telegraph.bind(targets, 0)).set_delay(0.8) + + # Audio: rising pitch during build-up + if SfxManager: + SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") + + await get_tree().create_timer(1.0).timeout + +func _flash_telegraph(targets: Array, brightness: int) -> void: + """Flicker telegraph tiles between normal and bright.""" + if not gridmap: return + # Toggle visual feedback - in full implementation would modify material/overlay + # For now, this provides the timing structure for the animation + pass @rpc("authority", "call_local", "reliable") func sync_impact(targets: Array) -> void: @@ -443,11 +665,61 @@ func sync_impact(targets: Array) -> void: # Screen shake for impact if main_scene and main_scene.get("screen_shake_manager"): - main_scene.screen_shake_manager.shake(0.15, 4.0) + main_scene.screen_shake_manager.shake(0.15, 0.4) + + # Audio: impact splat sound + if SfxManager: + SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter") + + # Spawn candy splash particles at impact locations + _spawn_impact_particles(targets) # Check if any player is now trapped _check_all_players_trapped() +func _spawn_impact_particles(targets: Array) -> void: + """Spawn candy splash particles at impact locations.""" + if not main_scene: + return + + for target in targets: + var pos = target as Vector2i + var world_pos = Vector3( + pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0, + 0.5, # Slightly above floor + pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 + ) + + # Create a simple particle effect (GPUParticles3D) + var particles = GPUParticles3D.new() + particles.emitting = true + particles.one_shot = true + particles.amount = 8 + particles.lifetime = 0.5 + particles.explosiveness = 1.0 + + # Candy pink color + var material = ParticleProcessMaterial.new() + material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + material.emission_sphere_radius = 0.2 + material.direction = Vector3(0, 1, 0) + material.spread = 45.0 + material.initial_velocity_min = 2.0 + material.initial_velocity_max = 4.0 + material.gravity = Vector3(0, -9.8, 0) + material.scale_min = 0.1 + material.scale_max = 0.3 + + particles.process_material = material + particles.position = world_pos + + main_scene.add_child(particles) + + # Auto-remove after particles finish + await get_tree().create_timer(1.0).timeout + if particles and is_instance_valid(particles): + particles.queue_free() + # ============================================================================= # Sticky / Trap System # ============================================================================= @@ -489,6 +761,139 @@ func clear_sticky_cell(pos: Vector2i) -> void: if main_scene and _can_rpc(): 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.""" + var local_pid = multiplayer.get_unique_id() + var count = player_cleansers.get(local_pid, 0) + if count <= 0: + return + + # Find local player + var all_players = get_tree().get_nodes_in_group("Players") + var local_player = null + for p in all_players: + var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int() + if pid == local_pid: + local_player = p + break + if not local_player or not gridmap: + return + + # Get player grid position + var player_pos = local_player.global_position + var grid_pos = Vector2i(int(player_pos.x), int(player_pos.z)) + + # 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 + + 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 + if _can_rpc(): + rpc("sync_cleanser_count", local_pid, 0) + +@rpc("any_peer", "call_local", "reliable") +func rpc_use_cleanser(pos: Vector2i) -> void: + """RPC for clients to clear a sticky cell via Cleanser.""" + if multiplayer.is_server(): + clear_sticky_cell(pos) + +@rpc("any_peer", "call_local", "reliable") +func rpc_consume_cleanser(pid: int) -> void: + """RPC for clients to report Cleanser consumption to server.""" + if multiplayer.is_server(): + player_cleansers[pid] = 0 + if _can_rpc(): + rpc("sync_cleanser_count", pid, 0) + +@rpc("any_peer", "reliable") +func rpc_trigger_slowmo() -> void: + """RPC for clients to request slow-mo from server.""" + if multiplayer.is_server(): + trigger_slowmo() + +# ============================================================================= +# Slow-Mo Effect +# ============================================================================= + +func trigger_slowmo(duration: float = 4.0) -> void: + """Trigger slow-motion effect at 1/4 speed. Server-authoritative.""" + if slowmo_active: + return + slowmo_active = true + slowmo_timer = duration + slowmo_duration = duration + Engine.time_scale = SLOWMO_SCALE + # Show visual overlay + if main_scene and main_scene.has_node("Camera3D200"): + _show_slowmo_overlay() + if _can_rpc(): + rpc("sync_slowmo_start", duration) + +func _end_slowmo() -> void: + slowmo_active = false + Engine.time_scale = 1.0 + _hide_slowmo_overlay() + if _can_rpc(): + rpc("sync_slowmo_end") + +func _show_slowmo_overlay() -> void: + if slowmo_overlay: + return + slowmo_overlay = ColorRect.new() + slowmo_overlay.color = Color(0.3, 0.5, 1.0, 0.1) # Subtle blue tint + slowmo_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) + slowmo_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE + var cam = main_scene.get_node_or_null("Camera3D200") + if cam: + # Find or create a CanvasLayer for the overlay + var canvas = CanvasLayer.new() + canvas.layer = 4 + main_scene.add_child(canvas) + canvas.add_child(slowmo_overlay) + # Fade in + slowmo_overlay.color.a = 0.0 + var tween = create_tween() + tween.tween_property(slowmo_overlay, "color:a", 0.1, 0.3) + +func _hide_slowmo_overlay() -> void: + if slowmo_overlay: + var tween = create_tween() + tween.tween_property(slowmo_overlay, "color:a", 0.0, 0.3) + tween.tween_callback(slowmo_overlay.get_parent().queue_free) + slowmo_overlay = null + +@rpc("authority", "call_local", "reliable") +func sync_slowmo_start(duration: float) -> void: + slowmo_active = true + slowmo_timer = duration + Engine.time_scale = SLOWMO_SCALE + _show_slowmo_overlay() + +@rpc("authority", "call_local", "reliable") +func sync_slowmo_end() -> void: + _end_slowmo() + # ============================================================================= # HUD # ============================================================================= @@ -519,7 +924,7 @@ func _setup_hud() -> void: phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink top_container.add_child(phase_label) - # Cleanser label (bottom-center) + # Cleanser HUD (bottom-center) with icon var bottom_container = CenterContainer.new() bottom_container.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) bottom_container.grow_horizontal = Control.GROW_DIRECTION_BOTH @@ -527,14 +932,42 @@ func _setup_hud() -> void: bottom_container.offset_bottom = -50 hud_layer.add_child(bottom_container) + var cleanser_hbox = HBoxContainer.new() + cleanser_hbox.add_theme_constant_override("separation", 6) + bottom_container.add_child(cleanser_hbox) + + # Cleanser icon (colored square as visual indicator) + cleanser_icon = TextureRect.new() + cleanser_icon.custom_minimum_size = Vector2(20, 20) + cleanser_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + # Generate a simple colored texture for the cleanser icon + var icon_img = Image.create(16, 16, false, Image.FORMAT_RGBA8) + icon_img.fill(Color(0.4, 0.9, 1.0)) # Cyan/teal for cleanser + icon_img.blend_rect(icon_img, Rect2i(2, 2, 12, 12), Vector2i(1, 1)) + # Add a darker border + for x in range(16): + icon_img.set_pixel(x, 0, Color(0.2, 0.6, 0.7)) + icon_img.set_pixel(x, 15, Color(0.2, 0.6, 0.7)) + for y in range(16): + icon_img.set_pixel(0, y, Color(0.2, 0.6, 0.7)) + icon_img.set_pixel(15, y, Color(0.2, 0.6, 0.7)) + # Add a cross/sparkle pattern for "cleansing" effect + for i in range(4, 12): + icon_img.set_pixel(i, 7, Color(1.0, 1.0, 1.0, 0.8)) + icon_img.set_pixel(7, i, Color(1.0, 1.0, 1.0, 0.8)) + var icon_tex = ImageTexture.create_from_image(icon_img) + cleanser_icon.texture = icon_tex + cleanser_hbox.add_child(cleanser_icon) + + # Cleanser text label cleanser_label = Label.new() - cleanser_label.text = "🧹 Cleanser: 0" + cleanser_label.text = "Cleanser: 0" cleanser_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER if custom_font: cleanser_label.add_theme_font_override("font", custom_font) cleanser_label.add_theme_font_size_override("font_size", 20) cleanser_label.add_theme_color_override("font_outline_color", Color.BLACK) cleanser_label.add_theme_constant_override("outline_size", 6) - bottom_container.add_child(cleanser_label) + cleanser_hbox.add_child(cleanser_label) func _update_hud_phase(phase_name: String) -> void: if phase_label: @@ -549,10 +982,129 @@ func _update_hud_phase(phase_name: String) -> void: _: phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink phase_label.text = "%s %s" % [icon, phase_name.to_upper()] + + # Animate phase label with bounce effect + _animate_phase_label() func update_cleanser_ui(count: int) -> void: + cleanser_count = count if cleanser_label: - cleanser_label.text = "🧹 Cleanser: %d" % count + cleanser_label.text = "Cleanser: %d" % count + # Show/hide icon based on availability + if cleanser_icon: + cleanser_icon.visible = count > 0 + if count > 0: + # Pulse animation when cleanser is available + var tween = create_tween() + tween.set_loops(2) + tween.tween_property(cleanser_icon, "modulate", Color(1.5, 1.5, 1.5, 1), 0.3) + tween.tween_property(cleanser_icon, "modulate", Color.WHITE, 0.3) + +func _animate_phase_label() -> void: + """Animate phase label with bounce effect.""" + if not phase_label: + return + + # Create tween for bounce animation + var tween = create_tween() + tween.set_ease(Tween.EASE_OUT) + tween.set_trans(Tween.TRANS_ELASTIC) + + # Scale up then back to normal + var original_scale = phase_label.scale + tween.tween_property(phase_label, "scale", original_scale * 1.2, 0.1) + tween.tween_property(phase_label, "scale", original_scale, 0.2) + + # Flash effect + tween.tween_property(phase_label, "modulate", Color(2, 2, 2, 1), 0.1) + tween.tween_property(phase_label, "modulate", Color.WHITE, 0.2) + +# ============================================================================= +# GoalsCycleManager Integration +# ============================================================================= + +func _on_goal_count_updated(peer_id: int, count: int) -> void: + """Called when a player completes a goal cycle. Grant cleanser every 2 missions.""" + if not multiplayer.is_server(): + return + + # Track mission completions per player + if not player_mission_completions.has(peer_id): + player_mission_completions[peer_id] = 0 + player_mission_completions[peer_id] += 1 + + # Grant cleanser every 2 missions (max 1) + 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 + print("[Gauntlet] Player %d granted Cleanser (mission %d)" % [peer_id, completions]) + + # Respawn mission tiles in non-sticky locations + _respawn_mission_tiles() + + # Sync cleanser count to HUD + rpc("sync_cleanser_count", peer_id, player_cleansers.get(peer_id, 0)) + +func _on_score_updated(peer_id: int, new_score: int) -> void: + """Called when a player's score is updated.""" + pass # Score sync handled by GoalsCycleManager + +func _respawn_mission_tiles() -> void: + """Respawn mission tiles in non-sticky locations after mission completion.""" + if not gridmap: + gridmap = get_parent().get_node_or_null("EnhancedGridMap") + if not gridmap: + gridmap = get_node_or_null("/root/Main/EnhancedGridMap") + if not gridmap: return + + # Goal items: Heart(7), Diamond(8), Star(9), Coin(10) + var goal_items = [7, 8, 9, 10] + var tiles_spawned: int = 0 + + # Find empty non-sticky cells to place new tiles + var empty_cells: Array = [] + for x in range(ARENA_COLUMNS): + for z in range(ARENA_ROWS): + var pos = Vector2i(x, z) + if _is_npc_zone(pos): + continue + # Skip boundary walls + if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1: + continue + # Skip sticky cells + if sticky_cells.has(pos): + continue + # Check if cell is empty on Layer 1 + var current_item = gridmap.get_cell_item(Vector3i(x, 1, z)) + if current_item == -1: + empty_cells.append(pos) + + # Shuffle and place tiles + empty_cells.shuffle() + var tiles_to_place = min(empty_cells.size(), 20) # Limit respawn count + + for i in range(tiles_to_place): + var pos = empty_cells[i] + var tile_type = goal_items[randi() % goal_items.size()] + gridmap.set_cell_item(Vector3i(pos.x, 1, pos.z), tile_type) + tiles_spawned += 1 + + # Sync to clients + if main_scene: + main_scene.rpc("sync_grid_item", pos.x, 1, pos.z, tile_type) + + print("[Gauntlet] Respawned %d mission tiles" % tiles_spawned) + +@rpc("authority", "call_local", "reliable") +func sync_cleanser_count(peer_id: int, count: int) -> void: + """Sync cleanser count to HUD for specific player.""" + # Update local player's cleanser UI + var local_pid = multiplayer.get_unique_id() + if peer_id == local_pid: + update_cleanser_ui(count) # ============================================================================= # Utility diff --git a/scripts/managers/lobby_manager.gd b/scripts/managers/lobby_manager.gd index 413bf34..4ede2b3 100644 --- a/scripts/managers/lobby_manager.gd +++ b/scripts/managers/lobby_manager.gd @@ -31,6 +31,11 @@ signal doors_swap_time_changed(time: int) signal doors_refresh_time_changed(time: int) signal doors_required_goals_changed(goals: int) +# Gauntlet settings signals +signal gauntlet_round_duration_changed(duration: int) +signal gauntlet_cannon_interval_changed(interval: int) +signal gauntlet_volley_size_changed(size: int) + # Room data structure var current_room: Dictionary = {} var players_in_room: Array = [] # [{id, name, is_ready}] @@ -74,6 +79,11 @@ var doors_swap_time: int = 15 var doors_refresh_time: int = 25 var doors_required_goals: int = 8 +# Gauntlet settings +var gauntlet_round_duration: int = 180 +var gauntlet_cannon_interval: int = 5 +var gauntlet_volley_size: int = 5 + # Rematch tracking var rematch_votes: Array = [] # [player_id, ...] @@ -539,6 +549,37 @@ func sync_doors_required_goals(goals: int) -> void: doors_required_goals = goals emit_signal("doors_required_goals_changed", goals) +# ============================================================================= +# Gauntlet Settings +# ============================================================================= + +func set_gauntlet_round_duration(duration: int) -> void: + gauntlet_round_duration = duration + if is_host: rpc("sync_gauntlet_round_duration", duration) + +@rpc("authority", "call_local", "reliable") +func sync_gauntlet_round_duration(duration: int) -> void: + gauntlet_round_duration = duration + emit_signal("gauntlet_round_duration_changed", duration) + +func set_gauntlet_cannon_interval(interval: int) -> void: + gauntlet_cannon_interval = interval + if is_host: rpc("sync_gauntlet_cannon_interval", interval) + +@rpc("authority", "call_local", "reliable") +func sync_gauntlet_cannon_interval(interval: int) -> void: + gauntlet_cannon_interval = interval + emit_signal("gauntlet_cannon_interval_changed", interval) + +func set_gauntlet_volley_size(size: int) -> void: + gauntlet_volley_size = size + if is_host: rpc("sync_gauntlet_volley_size", size) + +@rpc("authority", "call_local", "reliable") +func sync_gauntlet_volley_size(size: int) -> void: + gauntlet_volley_size = size + emit_signal("gauntlet_volley_size_changed", size) + # ============================================================================= # Character Selection # ============================================================================= @@ -717,6 +758,10 @@ func start_game(force: bool = false) -> void: rpc("sync_doors_swap_time", doors_swap_time) rpc("sync_doors_refresh_time", doors_refresh_time) rpc("sync_doors_required_goals", doors_required_goals) + # Sync gauntlet settings + rpc("sync_gauntlet_round_duration", gauntlet_round_duration) + rpc("sync_gauntlet_cannon_interval", gauntlet_cannon_interval) + rpc("sync_gauntlet_volley_size", gauntlet_volley_size) # Sync game mode rpc("sync_game_mode", game_mode) @@ -792,6 +837,9 @@ func request_room_info(requester_id: int, requester_name: String, requester_char rpc_id(requester_id, "sync_doors_swap_time", doors_swap_time) rpc_id(requester_id, "sync_doors_refresh_time", doors_refresh_time) rpc_id(requester_id, "sync_doors_required_goals", doors_required_goals) + rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration) + rpc_id(requester_id, "sync_gauntlet_cannon_interval", gauntlet_cannon_interval) + rpc_id(requester_id, "sync_gauntlet_volley_size", gauntlet_volley_size) rpc_id(requester_id, "sync_game_mode", game_mode) rpc_id(requester_id, "sync_area", selected_area) diff --git a/scripts/managers/mail_manager.gd b/scripts/managers/mail_manager.gd index 7d3a1bc..0fdaeab 100644 --- a/scripts/managers/mail_manager.gd +++ b/scripts/managers/mail_manager.gd @@ -20,14 +20,14 @@ func fetch_mails() -> void: if _is_fetching or not NakamaManager.session: return _is_fetching = true - var r = await NakamaManager.client.rpc_async(NakamaManager.session, "get_mail", "{}") + var result = await BackendService.get_mail() _is_fetching = false - if r.is_exception(): - push_error("[MailManager] Failed to fetch mails: " + r.get_exception().message) + if result.get("success", false) == false: + push_error("[MailManager] Failed to fetch mails: " + str(result.get("error", ""))) return - var payload = JSON.parse_string(r.payload) + var payload = result.get("data", {}) if payload and payload is Dictionary: mails = payload.get("mails", []) var state = payload.get("state", {}) @@ -50,12 +50,12 @@ func _update_unread_count() -> void: unread_count_changed.emit(count) func claim_reward(mail_id: String) -> bool: - var r = await NakamaManager.client.rpc_async(NakamaManager.session, "claim_mail_reward", JSON.stringify({"mail_id": mail_id})) - if r.is_exception(): - push_error("[MailManager] Claim failed: " + r.get_exception().message) + var result = await BackendService.claim_mail_reward(mail_id) + if result.get("success", false) == false: + push_error("[MailManager] Claim failed: " + str(result.get("error", ""))) return false - var payload = JSON.parse_string(r.payload) + var payload = result.get("data", {}) if payload and payload.get("success"): claimed_ids = payload.get("claimed_ids", claimed_ids) if mail_id not in read_ids: @@ -69,12 +69,12 @@ func claim_reward(mail_id: String) -> bool: return false func delete_mail(mail_id: String) -> bool: - var r = await NakamaManager.client.rpc_async(NakamaManager.session, "delete_mail", JSON.stringify({"mail_id": mail_id})) - if r.is_exception(): - push_error("[MailManager] Delete failed: " + r.get_exception().message) + var result = await BackendService.delete_mail(mail_id) + if result.get("success", false) == false: + push_error("[MailManager] Delete failed: " + str(result.get("error", ""))) return false - var payload = JSON.parse_string(r.payload) + var payload = result.get("data", {}) if payload and payload.get("success"): var deleted_ids = payload.get("deleted_ids", []) # Remove from local array @@ -102,13 +102,9 @@ func _save_inbox_state() -> void: "deleted_ids": [], "read_ids": read_ids } - # We use storage write via a lightweight RPC or direct storage - var r = await NakamaManager.client.rpc_async( - NakamaManager.session, "save_mail_state", - JSON.stringify(state_payload) - ) - if r.is_exception(): - push_warning("[MailManager] Could not save mail state: " + r.get_exception().message) + var result = await BackendService.api_rpc_async("save_mail_state", JSON.stringify(state_payload)) + if result.get("success", false) == false: + push_warning("[MailManager] Could not save mail state: " + str(result.get("error", ""))) func read_all_and_claim_all() -> void: """Mark all mails as read and claim all unclaimed rewards.""" diff --git a/scripts/managers/player_movement_manager.gd b/scripts/managers/player_movement_manager.gd index 287c4b9..53ba4ac 100644 --- a/scripts/managers/player_movement_manager.gd +++ b/scripts/managers/player_movement_manager.gd @@ -132,9 +132,9 @@ func simple_move_to(grid_position: Vector2i) -> bool: 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 + 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 if currently trapped if gm and gm.is_active: @@ -205,7 +205,14 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool: return false # === NEW LOGIC: Only allow push if in ATTACK MODE and NOT GHOST === - if not player.get("is_attack_mode") or player.get("is_invisible"): + var has_smack = false + var main_for_smack = player.get_tree().root.get_node_or_null("Main") + var gm_for_smack = main_for_smack.get("gauntlet_manager") if main_for_smack else null + if gm_for_smack and gm_for_smack.is_active: + 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"): # 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]) if _can_rpc(): @@ -229,6 +236,51 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool: NotificationManager.send_message(player, "Target is in Safe Zone!", NotificationManager.MessageType.WARNING) return false + + var gm = null + var main_push_check = player.get_tree().root.get_node_or_null("Main") + if main_push_check and main_push_check.get("gauntlet_manager"): + gm = main_push_check.gauntlet_manager + + # IF Gauntlet Mode is active, handle special Gauntlet Smacks + if gm and gm.is_active: + var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() + var other_pid = other_player.get("peer_id") if "peer_id" in other_player else other_player.name.to_int() + + # Check if attacker has smack + if not gm.has_smack_charged(pid): + # bump visuals + if _can_rpc(): + player.rpc("sync_bump", target_pos, true) + elif player.has_method("sync_bump"): + player.sync_bump(target_pos, true) + return false + + # Smack Clash: Both charged + if gm.has_smack_charged(other_pid): + print("[Move] SMACK CLASH! Both %s and %s consumed." % [player.name, other_player.name]) + if multiplayer.is_server(): + gm.consume_smack(pid) + gm.consume_smack(other_pid) + elif _can_rpc(): + gm.rpc("consume_smack", pid) # Assuming consume_smack is @rpc + gm.rpc("consume_smack", other_pid) + + if _can_rpc(): + player.rpc("apply_stagger", 1.0) + other_player.rpc("apply_stagger", 1.0) + else: + player.apply_stagger(1.0) + other_player.apply_stagger(1.0) + + return false + + # Else standard push + if multiplayer.is_server(): + gm.consume_smack(pid) + elif _can_rpc(): + gm.rpc("consume_smack", pid) + # === SUPER PUSH (Attack Mode) === print("Player %s SUPER PUSHING %s!" % [player.name, other_player.name]) @@ -240,17 +292,27 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool: player.sync_bump(target_pos, false) SfxManager.play("attack_mode") - # 1. 3-Floor Knockback towards Starting Line (X=0) - var push_direction = Vector2i(-1, 0) # Backwards + # 1. 3-Floor Knockback + var push_direction = Vector2i(-1, 0) # Default back (Stop N Go) + + var main_push = player.get_tree().root.get_node_or_null("Main") + var gm_push = main_push.gauntlet_manager if main_push and main_push.has_node("GauntletManager") else (main_push.get("gauntlet_manager") if main_push else null) + if gm_push and gm_push.is_active: + push_direction = direction # Use the direction of the attack var pushed_to_pos = target_pos var push_path = [] # Try to push up to 3 tiles back, building the path as we go + var hit_sticky = false for i in range(3): var next_back = pushed_to_pos + push_direction if _can_push_to(next_back): pushed_to_pos = next_back push_path.append(Vector2(pushed_to_pos.x, pushed_to_pos.y)) + + if gm_push and gm_push.is_active and gm_push.is_sticky_cell(pushed_to_pos): + hit_sticky = true + break # stop pushing immediately upon touching sticky zone! else: break # Blocked by wall or edge @@ -268,20 +330,20 @@ 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): + var main_sticky = player.get_tree().root.get_node_or_null("Main") + 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._trap_player(other_player) + gm_sticky._trap_player(other_player) - # 2. Apply freeze/stun effect (blue tint) + # 2. Apply freeze/stun effect + var stun_duration = 1.0 if (gm_push and gm_push.is_active) else 1.5 if _can_rpc(): - other_player.rpc("apply_stagger", 1.5) + other_player.rpc("apply_stagger", stun_duration) else: - # Handle local execution (e.g. offline or host-only logic) - other_player.apply_stagger(1.5) + other_player.apply_stagger(stun_duration) # 4. Consume Boost (Full) - One hit per charge if player.powerup_manager: @@ -292,9 +354,9 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool: if player.is_multiplayer_authority(): var is_sng = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) if not is_sng: - var main = player.get_tree().get_root().get_node_or_null("Main") - if main: - var gcm = main.get_node_or_null("GoalsCycleManager") + var main_score = player.get_tree().get_root().get_node_or_null("Main") + if main_score: + var gcm = main_score.get_node_or_null("GoalsCycleManager") if gcm: if multiplayer.is_server(): # Server/Bot: Directly add score to specific player ID diff --git a/scripts/managers/screen_shake.gd b/scripts/managers/screen_shake.gd index f0065a1..9234790 100644 --- a/scripts/managers/screen_shake.gd +++ b/scripts/managers/screen_shake.gd @@ -1,12 +1,12 @@ extends Node -# ScreenShakeManager - Handles camera shake effects for impact feedback +# ScreenShakeManager - Camera-local shake that follows the player var camera: Camera3D var shake_intensity: float = 0.0 var shake_duration: float = 0.0 var shake_timer: float = 0.0 -var target_position: Vector3 # Replaces original_position +var shake_offset: Vector3 = Vector3.ZERO # Shake presets const SHAKE_TARGETED: Dictionary = {"intensity": 0.15, "duration": 0.4} @@ -14,19 +14,33 @@ const SHAKE_GOAL_COMPLETE: Dictionary = {"intensity": 0.1, "duration": 0.3} const SHAKE_LIGHT: Dictionary = {"intensity": 0.05, "duration": 0.2} func initialize(p_camera: Camera3D): - """Initialize with specific camera instance.""" camera = p_camera - if camera: - target_position = camera.position - print("[ScreenShakeManager] Initialized with camera: ", camera.name) - else: - push_warning("[ScreenShakeManager] Initialized with null camera") -func set_target_position(new_pos: Vector3, duration: float = 1.0): - """Smoothly transition to a new base camera position.""" - if target_position == new_pos: +func shake(intensity: float, duration: float) -> void: + """Trigger a camera shake. Intensity in world units, duration in seconds.""" + if intensity <= 0 or duration <= 0: return - - # Tween the target position - var tween = create_tween() - tween.tween_property(self, "target_position", new_pos, duration).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + # Take the stronger shake if already active + if shake_timer > 0 and intensity <= shake_intensity: + return + shake_intensity = intensity + shake_duration = duration + shake_timer = duration + +func _process(delta: float) -> void: + if not camera or not is_instance_valid(camera): + return + if shake_timer > 0: + shake_timer -= delta + var progress = shake_timer / shake_duration + # Fade out intensity as timer expires + var current_intensity = shake_intensity * progress + # Random offset within intensity range + shake_offset = Vector3( + randf_range(-current_intensity, current_intensity), + randf_range(-current_intensity, current_intensity) * 0.5, + randf_range(-current_intensity, current_intensity) + ) + camera.position += shake_offset + else: + shake_offset = Vector3.ZERO diff --git a/scripts/managers/session_manager.gd b/scripts/managers/session_manager.gd new file mode 100644 index 0000000..ec8a287 --- /dev/null +++ b/scripts/managers/session_manager.gd @@ -0,0 +1,125 @@ +extends Node + +# SessionManager - Proactive session refresh and expiry handling +# Monitors session health and refreshes tokens before expiry + +signal session_refreshed() +signal session_expired() + +# ============================================================================= +# Configuration +# ============================================================================= + +const CHECK_INTERVAL: float = 60.0 # Check every 60s +const REFRESH_BEFORE_EXPIRY: float = 300.0 # Refresh when <5min to expiry +const MAX_RETRY_ATTEMPTS: int = 3 +const BASE_RETRY_DELAY: float = 1.0 # 1s, 2s, 4s exponential backoff + +# ============================================================================= +# State +# ============================================================================= + +var _refresh_timer: float = 0.0 +var _is_refreshing: bool = false + +# ============================================================================= +# Lifecycle +# ============================================================================= + +func _ready(): + set_process(false) + # Start monitoring when session is available + if NakamaManager: + if NakamaManager.session and not NakamaManager.session.is_expired(): + _start_monitoring() + +func _process(delta: float) -> void: + _refresh_timer -= delta + if _refresh_timer <= 0.0: + _refresh_timer = CHECK_INTERVAL + _check_session_health() + +# ============================================================================= +# Public API +# ============================================================================= + +func start_monitoring() -> void: + """Start monitoring session health. Call after successful login.""" + _start_monitoring() + +func stop_monitoring() -> void: + """Stop monitoring session health.""" + set_process(false) + +# ============================================================================= +# Internal +# ============================================================================= + +func _start_monitoring() -> void: + _refresh_timer = CHECK_INTERVAL + set_process(true) + print("[SessionManager] Monitoring started") + +func _check_session_health() -> void: + if not NakamaManager or not NakamaManager.session: + return + + if NakamaManager.session.is_expired(): + print("[SessionManager] Session expired!") + session_expired.emit() + if EventBus: + EventBus.emit(EventBus.EVENT_SESSION_EXPIRED) + return + + # Check if within refresh window + var time_to_expiry = NakamaManager.session.expires_at - Time.get_unix_time_from_system() + if time_to_expiry < REFRESH_BEFORE_EXPIRY: + print("[SessionManager] Session expiring soon (%.0fs), refreshing..." % time_to_expiry) + _refresh_session() + +func _refresh_session() -> void: + if _is_refreshing: + return + + _is_refreshing = true + var success = await _attempt_refresh_with_retry() + _is_refreshing = false + + if success: + print("[SessionManager] Session refreshed successfully") + session_refreshed.emit() + if EventBus: + EventBus.emit(EventBus.EVENT_SESSION_REFRESHED) + else: + print("[SessionManager] Session refresh failed after all retries") + session_expired.emit() + if EventBus: + EventBus.emit(EventBus.EVENT_SESSION_EXPIRED) + +func _attempt_refresh_with_retry() -> bool: + for attempt in range(MAX_RETRY_ATTEMPTS): + var delay = BASE_RETRY_DELAY * pow(2, attempt) + + if attempt > 0: + print("[SessionManager] Retry attempt %d/%d after %.1fs" % [attempt + 1, MAX_RETRY_ATTEMPTS, delay]) + await get_tree().create_timer(delay).timeout + + var result = await _do_refresh() + if result: + return true + + return false + +func _do_refresh() -> bool: + if not NakamaManager or not NakamaManager.client or not NakamaManager.session: + return false + + var refresh_result = await NakamaManager.client.session_refresh_async(NakamaManager.session) + + if refresh_result.is_exception(): + print("[SessionManager] Refresh error: ", refresh_result.get_exception().message) + return false + + # Update the session in NakamaManager + NakamaManager.session = refresh_result + return true diff --git a/scripts/managers/session_manager.gd.uid b/scripts/managers/session_manager.gd.uid new file mode 100644 index 0000000..2c4d044 --- /dev/null +++ b/scripts/managers/session_manager.gd.uid @@ -0,0 +1 @@ +uid://dbuhvxs3c1xdq diff --git a/scripts/managers/special_tiles_manager.gd b/scripts/managers/special_tiles_manager.gd index 5e53b0e..6e17e0b 100644 --- a/scripts/managers/special_tiles_manager.gd +++ b/scripts/managers/special_tiles_manager.gd @@ -558,6 +558,10 @@ func spawn_powerups_around(center: Vector2i, force_powerups: bool = true, only_c if only_common or LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO): # Spawn ONLY common tiles (7-10) in Stop n Go mode (User Request) item_id = rng.randi_range(7, 10) + elif LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET): + # Gauntlet mode: No power-up tile spawns from world. + # Only common tiles (7-10) spawn; Smack/Cleanser are handled separately. + item_id = rng.randi_range(7, 10) else: # Other modes: 80% Chance for Common Tile (7-10), 20% for PowerUp if rng.randf() < 0.8: diff --git a/scripts/managers/user_profile_manager.gd b/scripts/managers/user_profile_manager.gd index eff356f..bb33166 100644 --- a/scripts/managers/user_profile_manager.gd +++ b/scripts/managers/user_profile_manager.gd @@ -321,18 +321,14 @@ func purchase_item(item_id: String) -> String: "idempotency_key": str(randi()) + "_" + str(Time.get_ticks_usec()) }) - var result = await NakamaManager.client.rpc_async( - NakamaManager.session, - "purchase_item", - payload - ) + var result = await BackendService.api_rpc_async("purchase_item", payload) - if result.is_exception(): - var msg = result.get_exception().message + if result.get("success", false) == false: + var msg = str(result.get("message", "Unknown error")) push_error("[UserProfileManager] Purchase failed: ", msg) return msg - var response = JSON.parse_string(result.payload) + var response = result.get("data", {}) if typeof(response) == TYPE_DICTIONARY and response.has("success") and response.success == true: await _reload_wallet() if not inventory.has(item_id): @@ -346,17 +342,13 @@ func purchase_item(item_id: String) -> String: func fetch_shop_catalog() -> void: if not NakamaManager.session: return - var result = await NakamaManager.client.rpc_async( - NakamaManager.session, - "get_shop_catalog", - "{}" - ) + var result = await BackendService.api_rpc_async("get_shop_catalog", "{}") - if result.is_exception(): - push_error("[UserProfileManager] Failed to fetch shop catalog: ", result.get_exception().message) + if result.get("success", false) == false: + push_error("[UserProfileManager] Failed to fetch shop catalog: " + str(result.get("message", ""))) return - var payload: Dictionary = JSON.parse_string(result.payload) + var payload: Dictionary = result.get("data", {}) if payload and payload.has("catalog"): shop_catalog = payload.catalog if payload.has("featured_banners"): @@ -368,13 +360,9 @@ func fetch_shop_catalog() -> void: ## The Nakama function requireAdmin() on the server prevents non-admin abuse. func admin_topup_gold() -> bool: if not NakamaManager.session: return false - var result = await NakamaManager.client.rpc_async( - NakamaManager.session, - "admin_topup_gold", - "{}" - ) - if result.is_exception(): - push_error("[UserProfileManager] Topup failed: ", result.get_exception().message) + var result = await BackendService.api_rpc_async("admin_topup_gold", "{}") + if result.get("success", false) == false: + push_error("[UserProfileManager] Topup failed: " + str(result.get("message", ""))) return false await _reload_wallet() return true @@ -389,21 +377,17 @@ func buy_currency(package_id: String) -> bool: "store_type": "test" }) - var result = await NakamaManager.client.rpc_async( - NakamaManager.session, - "buy_currency", - payload - ) + var result = await BackendService.api_rpc_async("buy_currency", payload) - if result.is_exception(): - var msg = result.get_exception().message + if result.get("success", false) == false: + var msg = str(result.get("message", "Unknown error")) if "NotEnoughFunds" in msg: push_error("[UserProfileManager] Failed to buy currency: Not enough funds.") else: push_error("[UserProfileManager] Failed to buy currency: ", msg) return false - var response = JSON.parse_string(result.payload) + var response = result.get("data", {}) if typeof(response) == TYPE_DICTIONARY and response.has("status") and response.status == "pending": print("[UserProfileManager] Currency purchase pending verification.") @@ -494,14 +478,10 @@ func submit_to_leaderboard() -> void: "loadout_character": profile.get("loadout_character", "Copper") }) - var result = await NakamaManager.client.rpc_async( - NakamaManager.session, - "submit_score", - payload - ) + var result = await BackendService.api_rpc_async("submit_score", payload) - if result.is_exception(): - push_warning("[UserProfileManager] Leaderboard RPC failed: ", result.get_exception().message) + if result.get("success", false) == false: + push_warning("[UserProfileManager] Leaderboard RPC failed: " + str(result.get("message", ""))) else: print("[UserProfileManager] Leaderboard score submitted: ", stats.get("high_score", 0)) diff --git a/scripts/services/backend_service.gd b/scripts/services/backend_service.gd index 2dc137d..4443e7c 100644 --- a/scripts/services/backend_service.gd +++ b/scripts/services/backend_service.gd @@ -10,30 +10,46 @@ enum Platform { MOBILE_NAKAMA } +enum ErrorCode { + NONE, + NETWORK_ERROR, + UNAUTHORIZED, + FORBIDDEN, + NOT_FOUND, + INTERNAL_ERROR, + UNKNOWN_ERROR, + INSUFFICIENT_FUNDS +} + var current_platform: Platform = Platform.DESKTOP_STEAM var steamworks_manager: Node # Only for auth ticket retrieval var nakama_backend: Node +const MAX_RETRIES = 3 +const RETRY_BACKOFF_BASE = 0.5 + +# Error mapping based on HTTP status codes returned by Nakama +var error_map := { + 401: ErrorCode.UNAUTHORIZED, + 403: ErrorCode.FORBIDDEN, + 404: ErrorCode.NOT_FOUND, + 500: ErrorCode.INTERNAL_ERROR, +} + func _ready() -> void: _detect_platform() _initialize_backend() func _detect_platform() -> void: - # Detect if running on mobile or desktop if OS.has_feature("android") or OS.has_feature("ios"): current_platform = Platform.MOBILE_NAKAMA else: - # Desktop: detect Steam by checking if GodotSteam class exists - # OS.has_feature("steam") is only true when launched through Steam client, - # but ClassDB.class_exists("Steam") is true whenever the GDExtension is enabled if ClassDB.class_exists("Steam"): current_platform = Platform.DESKTOP_STEAM else: current_platform = Platform.DESKTOP_NAKAMA func _initialize_backend() -> void: - # All platforms use Nakama for backend features - # Steamworks is only initialized for auth ticket retrieval when GodotSteam is available if current_platform == Platform.DESKTOP_STEAM: _initialize_steamworks_for_auth() @@ -57,25 +73,151 @@ func _initialize_nakama() -> void: push_error("BackendService: NakamaManager not found") func _connect_nakama_signals() -> void: - # Nakama signals are handled directly by NakamaManager - # No need to connect through BackendService pass - - -## Utility Methods - func is_initialized() -> bool: - # Nakama is the primary backend for all features if nakama_backend != null: return true - - # Steamworks is optional (only for auth) return false func get_platform_name() -> String: return Platform.keys()[current_platform] func get_steamworks_manager() -> Node: - # Returns SteamworksManager for auth ticket retrieval (Steam login) return steamworks_manager + +## Unified RPC with Retry and Error Mapping +func api_rpc_async(rpc_id: String, payload: String = "{}") -> Dictionary: + if not nakama_backend or not nakama_backend.client or not nakama_backend.session: + return { "success": false, "error": ErrorCode.UNAUTHORIZED, "message": "Not authenticated" } + + var retries := 0 + while retries <= MAX_RETRIES: + var result = await nakama_backend.client.rpc_async(nakama_backend.session, rpc_id, payload) + + # NakamaAPI.ApiRpc has is_exception() + if result.is_exception(): + var ex = result.get_exception() + # Transient network error or internal server logic error (500) + # Typically Nakama exception status returns HTTP matching codes or grpc codes + if retries < MAX_RETRIES and _is_transient_error(ex): + retries += 1 + await get_tree().create_timer(RETRY_BACKOFF_BASE * pow(2.0, retries - 1)).timeout + continue + + var parsed_msg := _parse_error_msg(ex.message) + var err_code = ErrorCode.UNKNOWN_ERROR + if error_map.has(ex.status_code): + err_code = error_map[ex.status_code] + else: + if "insufficient funds" in ex.message.to_lower(): + err_code = ErrorCode.INSUFFICIENT_FUNDS + + return { + "success": false, + "error": err_code, + "message": parsed_msg, + "raw_exception": ex + } + + # Success + return { + "success": true, + "payload": result.payload + } + + return { "success": false, "error": ErrorCode.NETWORK_ERROR, "message": "Max retries exceeded" } + +func _is_transient_error(ex) -> bool: + # Retry on network failures (usually status 0 or 5xx) + if ex.status_code == 0 or ex.status_code >= 500: + return true + return false + +func _parse_error_msg(msg: String) -> String: + # Parse JSON error from Nakama if any + if msg.begins_with("{"): + var test_json_conv = JSON.new() + if test_json_conv.parse(msg) == OK: + var parsed = test_json_conv.get_data() + if parsed.has("message"): + return parsed["message"] + return msg + +## Typed Method Signatures + +func admin_clear_global_chat(payload: String) -> Dictionary: + return await api_rpc_async("admin_clear_global_chat", payload) + +func send_friend_request(target_id: String) -> Dictionary: + var payload = JSON.stringify({"target_user_id": target_id}) + return await api_rpc_async("send_friend_request", payload) + +func respond_friend_request(target_id: String, accept: bool) -> Dictionary: + var payload = JSON.stringify({"target_user_id": target_id, "accept": accept}) + return await api_rpc_async("respond_friend_request", payload) + +func perform_gacha_pull(gacha_id: String, count: int) -> Dictionary: + var payload = JSON.stringify({"gacha_id": gacha_id, "count": count}) + return await api_rpc_async("perform_gacha_pull", payload) + +func get_mail(payload: String = "{}") -> Dictionary: + return await api_rpc_async("get_mail", payload) + +func claim_mail_reward(mail_id: String) -> Dictionary: + return await api_rpc_async("claim_mail_reward", JSON.stringify({"mail_id": mail_id})) + +func delete_mail(mail_id: String) -> Dictionary: + return await api_rpc_async("delete_mail", JSON.stringify({"mail_id": mail_id})) + +func send_mail(payload: String) -> Dictionary: + return await api_rpc_async("send_mail", payload) + +func change_avatar(avatar_url: String) -> Dictionary: + return await api_rpc_async("change_avatar", JSON.stringify({"avatar_url": avatar_url})) + +func change_username(new_username: String) -> Dictionary: + return await api_rpc_async("change_username", JSON.stringify({"new_username": new_username})) + +func change_status(new_status: String) -> Dictionary: + return await api_rpc_async("change_status", JSON.stringify({"new_status": new_status})) + +func change_bio(new_bio: String) -> Dictionary: + return await api_rpc_async("change_bio", JSON.stringify({"new_bio": new_bio})) + +func query_users(payload: String) -> Dictionary: + return await api_rpc_async("query_users", payload) + +func admin_give_currency(payload: String) -> Dictionary: + return await api_rpc_async("admin_give_currency", payload) + +func get_daily_reward_config_admin() -> Dictionary: + return await api_rpc_async("get_daily_reward_config_admin", "{}") + +func set_daily_reward_config(req: Dictionary) -> Dictionary: + return await api_rpc_async("set_daily_reward_config", JSON.stringify(req)) + +func get_daily_reward_state() -> Dictionary: + return await api_rpc_async("get_daily_reward_state", "{}") + +func claim_daily_reward() -> Dictionary: + return await api_rpc_async("claim_daily_reward", "{}") + +func sync_leaderboard() -> Dictionary: + return await api_rpc_async("sync_leaderboard", "{}") + +func get_leaderboard_stats() -> Dictionary: + return await api_rpc_async("get_leaderboard_stats", "{}") + +func debug_add_exp(exp_amount: int) -> Dictionary: + return await api_rpc_async("debug_add_exp", JSON.stringify({"exp": exp_amount})) + +func reset_stats() -> Dictionary: + return await api_rpc_async("reset_stats", "{}") + +func search_users(payload: String) -> Dictionary: + return await api_rpc_async("search_users", payload) + +func send_lobby_invite(to_user_id: String, match_id: String) -> Dictionary: + var payload = JSON.stringify({"to_user_id": to_user_id, "match_id": match_id}) + return await api_rpc_async("send_lobby_invite", payload) diff --git a/scripts/tekton.gd b/scripts/tekton.gd index f052d3a..a5e0b89 100644 --- a/scripts/tekton.gd +++ b/scripts/tekton.gd @@ -472,6 +472,9 @@ func spawn_tiles_around(count: int = 4): if roll < 0.6 or (LobbyManager and LobbyManager.game_mode == "Stop n Go"): # 60% Normal Tile (7-10) OR 100% if Stop n Go (User Request) item_id = rng.randi_range(7, 10) + elif LobbyManager and LobbyManager.get_game_mode() == GameMode.Mode.GAUNTLET: + # Gauntlet mode: No power-up spawns from Tekton grabs + item_id = rng.randi_range(7, 10) else: # 40% PowerUp (11-14) var mode = GameMode.Mode.FREEMODE diff --git a/scripts/ui/admin_panel.gd b/scripts/ui/admin_panel.gd index 838c288..8d526cc 100644 --- a/scripts/ui/admin_panel.gd +++ b/scripts/ui/admin_panel.gd @@ -250,16 +250,12 @@ func _on_tab_changed(tab_index: int) -> void: func _rpc(rpc_name: String, payload: Dictionary) -> Dictionary: if not NakamaManager.client or not NakamaManager.session: return {"error": "Not connected"} - var result = await NakamaManager.client.rpc_async( - NakamaManager.session, rpc_name, JSON.stringify(payload) - ) - if result.is_exception(): - var err: String = result.get_exception().message + var result = await BackendService.api_rpc_async(rpc_name, JSON.stringify(payload)) + if result.get("success", false) == false: + var err: String = str(result.get("message", "Unknown error")) _set_status(err, CLR_STATUS_ERR) return {"error": err} - if result.payload: - return JSON.parse_string(result.payload) - return {} + return result.get("data", {}) func _set_status(msg: String, color: Color = CLR_DIM) -> void: status_label.text = msg diff --git a/scripts/ui/daily_reward_config_panel.gd b/scripts/ui/daily_reward_config_panel.gd index d0676e1..487f1ad 100644 --- a/scripts/ui/daily_reward_config_panel.gd +++ b/scripts/ui/daily_reward_config_panel.gd @@ -15,26 +15,24 @@ func _load_config(): status_lbl.text = "Not authenticated" return status_lbl.text = "Loading..." - var res = await NakamaManager.client.rpc_async(NakamaManager.session, "get_daily_reward_config_admin", "{}") - if res.is_exception(): - status_lbl.text = "Error: " + res.get_exception().message + var result = await BackendService.get_daily_reward_config_admin() + if result.get("success", false) == false: + status_lbl.text = "Error: " + str(result.get("error", "")) return - var json = JSON.new() - if json.parse(res.payload) == OK: - var config = json.get_data().get("config", {}) - if config.is_empty(): - # generate default 12 months for 2026/2027 - var year = 2026 - for m in range(1, 13): - var m_str = "%d-%02d" % [year, m] - var arr = [] - for d in range(30): - arr.append(min(10 + d*5, 100)) # Reward is star currency, max 100 - config[m_str] = arr - - text_edit.text = JSON.stringify(config, "\t") - status_lbl.text = "Loaded" + var config = result.get("data", {}).get("config", {}) + if config.is_empty(): + # generate default 12 months for 2026/2027 + var year = 2026 + for m in range(1, 13): + var m_str = "%d-%02d" % [year, m] + var arr = [] + for d in range(30): + arr.append(min(10 + d*5, 100)) # Reward is star currency, max 100 + config[m_str] = arr + + text_edit.text = JSON.stringify(config, "\t") + status_lbl.text = "Loaded" func _on_save(): var json = JSON.new() @@ -43,9 +41,9 @@ func _on_save(): return status_lbl.text = "Saving..." - var req = { "config": json.get_data() } - var res = await NakamaManager.client.rpc_async(NakamaManager.session, "set_daily_reward_config", JSON.stringify(req)) - if res.is_exception(): - status_lbl.text = "Save error: " + res.get_exception().message + var config = json.get_data() + var result = await BackendService.set_daily_reward_config(config) + if result.get("success", false) == false: + status_lbl.text = "Save error: " + str(result.get("error", "")) else: status_lbl.text = "Config saved successfully!" diff --git a/scripts/ui/daily_reward_panel.gd b/scripts/ui/daily_reward_panel.gd index 2bff402..c0f573f 100644 --- a/scripts/ui/daily_reward_panel.gd +++ b/scripts/ui/daily_reward_panel.gd @@ -39,34 +39,30 @@ func _fetch_state(): status_label.text = "Must be logged in to claim rewards." return - var result = await NakamaManager.client.rpc_async(NakamaManager.session, "get_daily_reward_state", "{}") - if result.is_exception(): - status_label.text = "Failed to load: " + result.get_exception().message + var result = await BackendService.get_daily_reward_state() + if result.get("success", false) == false: + status_label.text = "Failed to load: " + str(result.get("error", "")) return - var json = JSON.new() - if json.parse(result.payload) == OK: - var data = json.get_data() - _month_rewards = data.get("month_rewards", []) - var state = data.get("state", {}) + var data = result.get("data", {}) + _month_rewards = data.get("month_rewards", []) + var state = data.get("state", {}) + + var claimed_list = state.get("claimed_days", []) + _claimed_days.clear() + for item in claimed_list: + _claimed_days.append(int(item)) - var claimed_list = state.get("claimed_days", []) - _claimed_days.clear() - for item in claimed_list: - _claimed_days.append(int(item)) - - _can_claim = data.get("can_claim_today", false) - _today = data.get("today_date", "") - _today_index = data.get("today_index", 0) - _server_month = data.get("server_month", 1) - - var months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] - if _server_month >= 1 and _server_month <= 12: - month_label.text = months[_server_month - 1] + " Sign-in" - - _update_ui() - else: - status_label.text = "Error parsing data." + _can_claim = data.get("can_claim_today", false) + _today = data.get("today_date", "") + _today_index = data.get("today_index", 0) + _server_month = data.get("server_month", 1) + + var months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] + if _server_month >= 1 and _server_month <= 12: + month_label.text = months[_server_month - 1] + " Sign-in" + + _update_ui() func _get_reward_display_data(type: String) -> Dictionary: if type == "gold": return {"icon": "💰", "name": "Gold"} @@ -145,9 +141,9 @@ func _on_claim_pressed(): claim_btn.text = "Claiming..." status_label.text = "" - var result = await NakamaManager.client.rpc_async(NakamaManager.session, "claim_daily_reward", "{}") - if result.is_exception(): - status_label.text = "Failed to claim: " + result.get_exception().message + var result = await BackendService.claim_daily_reward() + if result.get("success", false) == false: + status_label.text = "Failed to claim: " + str(result.get("error", "")) claim_btn.disabled = false claim_btn.text = "Sign In" return diff --git a/scripts/ui/leaderboard_panel.gd b/scripts/ui/leaderboard_panel.gd index 311a254..0ac9978 100644 --- a/scripts/ui/leaderboard_panel.gd +++ b/scripts/ui/leaderboard_panel.gd @@ -72,11 +72,11 @@ func show_panel() -> void: status_label.text = "Syncing scores..." # Bulk-sync all users' storage stats to native leaderboard (server-side operation) if NakamaManager.session: - var sync_result = await NakamaManager.client.rpc_async(NakamaManager.session, "sync_leaderboard", "{}") - if sync_result.is_exception(): - push_error("[Leaderboard] sync_leaderboard RPC failed: ", sync_result.get_exception().message) + var sync_result = await BackendService.sync_leaderboard() + if sync_result.get("success", false) == false: + push_error("[Leaderboard] sync_leaderboard RPC failed: " + str(sync_result.get("error", ""))) else: - print("[Leaderboard] Server sync finished: ", sync_result.payload) + print("[Leaderboard] Server sync finished: ", sync_result.get("data", {})) _fetch_leaderboard_data() func _on_close_pressed() -> void: @@ -150,29 +150,25 @@ func _fetch_native_leaderboard() -> Array: func _fetch_via_rpc() -> void: """Fallback: call server RPC which reads the same native leaderboard.""" - var result = await NakamaManager.client.rpc_async(NakamaManager.session, "get_leaderboard_stats", "{}") + var result = await BackendService.get_leaderboard_stats() - if result.is_exception(): + if result.get("success", false) == false: status_label.text = "Failed to load leaderboard" - push_error("[Leaderboard] RPC failed: ", result.get_exception().message) + push_error("[Leaderboard] RPC failed: " + str(result.get("error", ""))) return - var json := JSON.new() - if json.parse(result.payload) == OK: - var data = json.get_data() - if data.has("leaderboard") and data.leaderboard.size() > 0: - _apply_local_overrides(data.leaderboard) - leaderboard_data = data.leaderboard - _calculate_win_rates() - status_label.text = "" - _sort_by(current_sort_key) - if leaderboard_data.size() > 0: - _show_entry_preview(0) - else: - # No records exist yet — show a helpful hint - status_label.text = "No scores recorded yet.\nPlay a match to appear here!" + var data = result.get("data", {}) + if data.has("leaderboard") and data.leaderboard.size() > 0: + _apply_local_overrides(data.leaderboard) + leaderboard_data = data.leaderboard + _calculate_win_rates() + status_label.text = "" + _sort_by(current_sort_key) + if leaderboard_data.size() > 0: + _show_entry_preview(0) else: - status_label.text = "Error parsing server data" + # No records exist yet — show a helpful hint + status_label.text = "No scores recorded yet.\nPlay a match to appear here!" func _calculate_win_rates() -> void: for entry in leaderboard_data: diff --git a/scripts/ui/profile_panel.gd b/scripts/ui/profile_panel.gd index 17c12e0..30b9f47 100644 --- a/scripts/ui/profile_panel.gd +++ b/scripts/ui/profile_panel.gd @@ -645,11 +645,9 @@ func _setup_account_settings_ui() -> void: "new_email": new_email_input.text, "new_password": new_pass_input.text } - var r = await NakamaManager.client.rpc_async( - NakamaManager.session, "change_credentials", JSON.stringify(payload) - ) - if r.is_exception(): - _set_status("Error: " + r.get_exception().message, Color.RED) + var r = await BackendService.api_rpc_async("change_credentials", JSON.stringify(payload)) + if r.get("success", false) == false: + _set_status("Error: " + str(r.get("message", "Unknown error")), Color.RED) else: _set_status("Credentials updated!", Color(0.4, 1.0, 0.4)) acc_settings_dialog.hide() @@ -671,8 +669,8 @@ func _setup_account_settings_ui() -> void: ) func _on_reset_stats_confirmed() -> void: - var r = await NakamaManager.client.rpc_async(NakamaManager.session, "reset_stats", "{}") - if not r.is_exception(): + var r = await BackendService.api_rpc_async("reset_stats", "{}") + if r.get("success", false) == true: UserProfileManager.stats = { "games_played": 0, "games_won": 0, "games_lost": 0, "total_score": 0, "high_score": 0, "play_time_minutes": 0 diff --git a/scripts/ui/social_panel.gd b/scripts/ui/social_panel.gd index fbbee3c..2957fe6 100644 --- a/scripts/ui/social_panel.gd +++ b/scripts/ui/social_panel.gd @@ -103,15 +103,14 @@ func _on_search_pressed() -> void: return _search_btn.disabled = true - var payload = JSON.stringify({"query": query}) - var result = await NakamaManager.client.rpc_async(NakamaManager.session, "search_users", payload) + var result = await BackendService.search_users(query) _search_btn.disabled = false - if result.is_exception(): - push_warning("[Social] Search failed: " + result.get_exception().message) + if result.get("success", false) == false: + push_warning("[Social] Search failed: " + str(result.get("error", ""))) return - var response = JSON.parse_string(result.payload) + var response = result.get("data", {}) if not response or not response.has("users"): return diff --git a/test_smack.py b/test_smack.py new file mode 100644 index 0000000..aadae9a --- /dev/null +++ b/test_smack.py @@ -0,0 +1,55 @@ +import re + +with open("scripts/managers/gauntlet_manager.gd", "r") as f: + content = f.read() + +new_process = """func _process(delta: float) -> void: + if not is_active: + return + + elapsed_time += delta + + # Phase escalation + _check_phase_transition() + + # Cannon timer (server only) + if multiplayer.is_server(): + cannon_timer -= delta + if cannon_timer <= 0.0: + _fire_volley() + cannon_timer = cannon_interval + + # Smack mechanic update + var all_players = get_tree().get_nodes_in_group("Players") + for player in all_players: + var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() + + if not smack_cooldowns.has(pid) and not smack_charged.has(pid): + smack_cooldowns[pid] = SMACK_COOLDOWN + smack_charged[pid] = 0.0 + + if smack_cooldowns[pid] > 0: + smack_cooldowns[pid] -= delta + if smack_cooldowns[pid] <= 0: + smack_cooldowns[pid] = 0.0 + smack_charged[pid] = SMACK_CHARGE_WINDOW + if player.has_method("sync_modulate"): + if _can_rpc(): + player.rpc("sync_modulate", Color.PINK) + else: + player.sync_modulate(Color.PINK) + elif smack_charged[pid] > 0: + smack_charged[pid] -= delta + if smack_charged[pid] <= 0: + smack_charged[pid] = 0.0 + smack_cooldowns[pid] = SMACK_COOLDOWN + if player.has_method("sync_modulate"): + if _can_rpc(): + player.rpc("sync_modulate", Color.WHITE) + else: + player.sync_modulate(Color.WHITE)""" + +content = re.sub(r"func _process\(delta: float\) -> void:.*?(?=\n# =+)", new_process, content, flags=re.DOTALL) + +with open("scripts/managers/gauntlet_manager.gd", "w") as f: + f.write(content)