diff --git a/_daily_basis/report_2025-12-21.md b/_daily_basis/report_2025-12-21.md new file mode 100644 index 0000000..5bfcb31 --- /dev/null +++ b/_daily_basis/report_2025-12-21.md @@ -0,0 +1,53 @@ +[ ADT's Daily Report - 2025-12-21 ] + +Updated the `tekton-enet` ( Armageddon Multiplayer ) on branch `launcher` + +you can test also, if theres any bug, please report it to me, also try build the project on android to see if theres any bug on touch controls + +**General Fixes** + +✅ **Fixed "Invalid packet received" Errors** - The error `Failed to get path from RPC: Main/1` was caused by `player.rpc()` calls trying to find the same node path on clients. Fixed by routing all sync calls through `main.gd` RPCs which look up players by ID. + +✅ **sync_playerboard RPC Fix** - Updated `main.gd` `sync_playerboard(player_id, new_playerboard)` to actually update `player.playerboard` data, not just UI. + +✅ **sync_player_goals RPC Fix** - Changed `goals_cycle_manager.gd` `regenerate_goals_for_player()` to use `main_scene.rpc("sync_player_goals", peer_id, int_goals)` instead of `player.rpc("sync_goals")`. + +**Randomized Spawn System Fixes** + +✅ **Spawn Rollback Fix** - Restructured `_setup_host_game()` in `main.gd` to add all players synchronously first, then call `_assign_random_spawn_positions()` BEFORE the 0.3s await. This ensures spawn positions are set before player `_ready()` runs position initialization. + +✅ **Client-Side Spawn Init** - Modified `player.gd` (lines 200, 212) to check `LobbyManager.get_randomize_spawn()` before running position initialization. When random spawn is enabled, clients skip position init entirely and wait for host RPC. + +**RPC Node Path Fixes** + +✅ **Fixed "Invalid packet received" Errors** - The error `Failed to get path from RPC: Main/1` was caused by `player.rpc()` calls trying to find the same node path on clients. Fixed by routing all sync calls through `main.gd` RPCs which look up players by ID. + +✅ **sync_playerboard RPC Fix** - Updated `main.gd` `sync_playerboard(player_id, new_playerboard)` to actually update `player.playerboard` data, not just UI. + +✅ **sync_player_goals RPC Fix** - Changed `goals_cycle_manager.gd` `regenerate_goals_for_player()` to use `main_scene.rpc("sync_player_goals", peer_id, int_goals)` instead of `player.rpc("sync_goals")`. + +**Goal Completion Sync** + +✅ **Client Goal Completion Detection** - Added `_check_goal_completion()` call to server-side `_execute_grab()` in `playerboard_manager.gd`. This was the missing piece - client grabs were validated by server but goal check was never called server-side, so client goal completions weren't triggering regeneration. + +**Special Ability Cooldown** + +✅ **4-Second Cooldown for F Key** - Added `SPECIAL_COOLDOWN = 4.0` constant, `special_cooldown_timer` variable, and `_process()` to tick down cooldown in `powerup_manager.gd`. Shows "Special on cooldown! (X.Xs)" message if trying to use too soon. + +**Animation** + +✅ **AnimationTimeline** - added animation for player movement +✅ **AnimationTimeline** - added animation for player special ability +✅ **AnimationTimeline** - added animation for pickup, put + +**Character selection** +✅ **Character selection** - added character selection for player + +**Message Display Improvements** + +✅ **Player Names in Messages** - Updated `add_message_to_bar()` in `main.gd` to display messages in format: `[PlayerName] message` (e.g., "⚡ [Player1] Power-up bar filled!"). + +**Touch Controls** + +✅ **Settings Button Functionality** - Implemented `_on_settings_pressed()` in `touch_controls.gd` to open `SettingsPanel` from main.tscn when pressed. + diff --git a/_daily_basis/report_2025-12-23.md b/_daily_basis/report_2025-12-23.md new file mode 100644 index 0000000..684e121 --- /dev/null +++ b/_daily_basis/report_2025-12-23.md @@ -0,0 +1,25 @@ +[ ADT's Daily Report - 2025-12-23 ] + +Updated the `tekton-enet` ( Armageddon Multiplayer ) on branch `launcher` + +**Touch Controls Enhancement** + +✅ **Touch Button Settings** - Added `touch_buttons_enabled` and `joystick_enabled` toggles to `touch_controls.gd`. Settings now persist to `user://touch_controls_settings.cfg` using ConfigFile. + +✅ **Position Mapping API** - Added public methods `set_touch_buttons_enabled()`, `set_joystick_enabled()`, `set_button_position()`, and `get_settings()` for controlling touch UI programmatically. + +**Player Collision Fix** + +✅ **Prevent Player-to-Player Collision** - Modified `player.tscn` CharacterBody3D to use `collision_layer = 2`. Players now only collide with world objects, not each other, preventing desync issues when walking into same space. + +**Profile Access Improvement** + +✅ **Profile Button on All Screens** - Added PROFILE button to MainMenuPanel and RoomListPanel in `lobby.tscn`. Connected buttons to `_on_profile_btn_pressed()` in `lobby.gd`. + +**Multiplayer Disconnect Fix** + +✅ **Proper Nakama Match Cleanup** - Added `_cleanup_multiplayer()` function in `main.gd` that calls `NakamaManager.bridge.leave()`, clears `current_match_id`, and resets multiplayer peer. Called from both `_on_quit_match_pressed()` and `_on_back_to_menu_pressed()`. + +**Bug Fixes** + +✅ **Game Over Panel Fix** - Fixed duplicate parent error in `_show_game_over_panel()` where BackToMenuBtn was being added to two parents. Button now correctly added only to centered HBoxContainer. diff --git a/_daily_basis/report_2025-12-27.md b/_daily_basis/report_2025-12-27.md new file mode 100644 index 0000000..5da28a8 --- /dev/null +++ b/_daily_basis/report_2025-12-27.md @@ -0,0 +1,27 @@ +[ ADT's Daily Report - 2025-12-27 ] + +Updated the `tekton-enet` ( Armageddon Multiplayer ) on branch `launcher` + +**Screen Shake Implementation** + +✅ **Dependency Injection** - Refactored `screen_shake.gd` to accept camera instance via `initialize()` method instead of searching scene tree. Updated `main.gd` to inject `$Camera3D` on creation. + +**Touch Controls Visibility Fix** + +✅ **Unified Toggle Logic** - Fixed `main.gd` `_on_joystick_toggled()` to call `set_joystick_enabled()` instead of directly setting visibility, ensuring all touch controls hide/show together. + +✅ **Anchor Offset Fix** - Fixed touch buttons being invisible by switching from `.position` to proper `.offset_left/top/right/bottom` for anchored controls. Buttons now correctly appear in bottom-right corner. + +✅ **Layer Z-Order** - Reduced `CanvasLayer.layer` from 100 to 10 so pause menu renders above touch controls. + +✅ **Settings Panel Sync** - Updated `_on_settings_pressed()` in `main.gd` to sync JoystickToggle and sliders with current `TouchControlsManager` state when settings panel opens. + +**Special Tile Effects** + +✅ **Pickup Animation** - Added pulse and flash effect to playerboard slots when special tiles (Heart, Diamond, Star, Coin) are collected. Implemented in `ui_manager.gd` via `_pulse_slot_effect()`. + +✅ **State Tracking** - Added `_previous_playerboard_state` to detect newly placed tiles and trigger effects only on changes. + +**Playerboard Display Fix** + +✅ **Modulate Logic** - Fixed `update_playerboard_ui()` in `ui_manager.gd` where broken if/else structure caused non-center slots to inherit wrong modulate values. Now center slots show goals dimmed, collected tiles bright, and non-center slots always at full brightness. diff --git a/project.godot b/project.godot index 0533cba..0c60023 100644 --- a/project.godot +++ b/project.godot @@ -39,6 +39,10 @@ window/size/window_width_override=1280 window/size/window_height_override=720 window/stretch/mode="viewport" +[editor] + +run/main_run_args="--touch" + [editor_plugins] enabled=PackedStringArray("res://addons/enhanced_gridmap/plugin.cfg", "res://addons/com.heroiclabs.nakama/plugin.cfg") diff --git a/scenes/lobby.gd b/scenes/lobby.gd index 8840619..69035a5 100644 --- a/scenes/lobby.gd +++ b/scenes/lobby.gd @@ -5,6 +5,7 @@ extends Control @onready var player_name_input = $MainMenuPanel/VBoxContainer/InputSection/PlayerNameInput @onready var create_room_btn = $MainMenuPanel/VBoxContainer/ButtonSection/CreateRoomBtn @onready var browse_rooms_btn = $MainMenuPanel/VBoxContainer/ButtonSection/BrowseRoomsBtn +@onready var main_menu_profile_btn = $MainMenuPanel/VBoxContainer/ButtonSection/ProfileBtn # UI References - Room List @onready var room_list_panel = $RoomListPanel @@ -13,6 +14,7 @@ extends Control @onready var refresh_btn = $RoomListPanel/VBoxContainer/ButtonContainer/RefreshBtn @onready var join_btn = $RoomListPanel/VBoxContainer/ButtonContainer/JoinBtn @onready var back_btn = $RoomListPanel/VBoxContainer/ButtonContainer/BackBtn +@onready var room_list_profile_btn = $RoomListPanel/VBoxContainer/ButtonContainer/ProfileBtn # UI References - Lobby Panel @onready var lobby_panel = $LobbyPanel @@ -28,6 +30,8 @@ extends Control @onready var copy_id_btn = $LobbyPanel/TopBar/MatchIdContainer/CopyIdBtn @onready var duration_option = $LobbyPanel/TopBar/SettingsSection/DurationOption @onready var duration_text_label = $LobbyPanel/TopBar/SettingsSection/DurationTextLabel +@onready var random_spawn_check = $LobbyPanel/TopBar/SettingsSection/RandomSpawnCheck +@onready var random_spawn_label = $LobbyPanel/TopBar/SettingsSection/RandomSpawnLabel # UI References - Player Slots @onready var players_container = $LobbyPanel/PlayersContainer @@ -77,17 +81,22 @@ func _ready(): # Connect button signals - Main Menu create_room_btn.pressed.connect(_on_create_room_pressed) browse_rooms_btn.pressed.connect(_on_browse_rooms_pressed) + if main_menu_profile_btn: + main_menu_profile_btn.pressed.connect(_on_profile_btn_pressed) # Connect button signals - Room List refresh_btn.pressed.connect(_on_refresh_pressed) join_btn.pressed.connect(_on_join_pressed) back_btn.pressed.connect(_on_back_pressed) + if room_list_profile_btn: + room_list_profile_btn.pressed.connect(_on_profile_btn_pressed) # Connect button signals - Lobby profile_btn.pressed.connect(_on_profile_btn_pressed) logout_btn.pressed.connect(_on_logout_pressed) copy_id_btn.pressed.connect(_on_copy_id_pressed) duration_option.item_selected.connect(_on_duration_selected) + random_spawn_check.toggled.connect(_on_random_spawn_toggled) area_left_btn.pressed.connect(func(): LobbyManager.cycle_area(-1)) area_right_btn.pressed.connect(func(): LobbyManager.cycle_area(1)) leave_btn.pressed.connect(_on_leave_pressed) @@ -104,6 +113,7 @@ func _ready(): LobbyManager.all_players_ready.connect(_on_all_players_ready) LobbyManager.game_starting.connect(_on_game_starting) LobbyManager.match_duration_changed.connect(_on_match_duration_changed) + LobbyManager.randomize_spawn_changed.connect(_on_randomize_spawn_changed) LobbyManager.character_changed.connect(_on_character_changed) LobbyManager.area_changed.connect(_on_area_changed) LobbyManager.player_list_changed.connect(_update_player_slots) @@ -236,6 +246,15 @@ func _on_duration_selected(index: int) -> void: if index >= 0 and index < durations.size(): LobbyManager.set_match_duration(durations[index]) +func _on_random_spawn_toggled(enabled: bool) -> void: + if not LobbyManager.is_host: + return + LobbyManager.set_randomize_spawn(enabled) + +func _update_random_spawn_label(enabled: bool) -> void: + if random_spawn_label: + random_spawn_label.text = "Random ✓" if enabled else "Random ✗" + func _on_profile_btn_pressed() -> void: if not profile_panel_instance: var profile_panel_scene := load("res://scenes/ui/profile_panel.tscn") @@ -289,6 +308,12 @@ func _on_room_joined(room_data: Dictionary) -> void: if not is_host: _update_duration_text_label(LobbyManager.get_match_duration()) + # Random spawn: host sees checkbox, clients see label + random_spawn_check.visible = is_host + random_spawn_label.visible = not is_host + random_spawn_check.button_pressed = LobbyManager.get_randomize_spawn() + _update_random_spawn_label(LobbyManager.get_randomize_spawn()) + # Area selector: only host can interact area_left_btn.disabled = not is_host area_right_btn.disabled = not is_host @@ -329,6 +354,10 @@ func _on_match_duration_changed(duration_seconds: int) -> void: if not LobbyManager.is_host: _update_duration_text_label(duration_seconds) +func _on_randomize_spawn_changed(enabled: bool) -> void: + if not LobbyManager.is_host: + _update_random_spawn_label(enabled) + func _on_character_changed(_player_id: int, _character_name: String) -> void: _update_player_slots() diff --git a/scenes/lobby.tscn b/scenes/lobby.tscn index 28cbf7b..f0d22e1 100644 --- a/scenes/lobby.tscn +++ b/scenes/lobby.tscn @@ -94,6 +94,12 @@ layout_mode = 2 theme_override_font_sizes/font_size = 16 text = "BROWSE ROOMS" +[node name="ProfileBtn" type="Button" parent="MainMenuPanel/VBoxContainer/ButtonSection"] +custom_minimum_size = Vector2(0, 36) +layout_mode = 2 +theme_override_font_sizes/font_size = 14 +text = "PROFILE" + [node name="RoomListPanel" type="PanelContainer" parent="."] visible = false layout_mode = 1 @@ -165,6 +171,11 @@ custom_minimum_size = Vector2(110, 44) layout_mode = 2 text = "BACK" +[node name="ProfileBtn" type="Button" parent="RoomListPanel/VBoxContainer/ButtonContainer"] +custom_minimum_size = Vector2(80, 44) +layout_mode = 2 +text = "PROFILE" + [node name="LobbyPanel" type="Control" parent="."] visible = false layout_mode = 1 @@ -268,6 +279,23 @@ theme_override_colors/font_color = Color(0.647, 0.996, 0.224, 1) theme_override_font_sizes/font_size = 11 text = "3 min" +[node name="SpawnSpacer" type="Control" parent="LobbyPanel/TopBar/SettingsSection"] +custom_minimum_size = Vector2(15, 0) +layout_mode = 2 + +[node name="RandomSpawnCheck" type="CheckButton" parent="LobbyPanel/TopBar/SettingsSection"] +layout_mode = 2 +theme_override_font_sizes/font_size = 11 +button_pressed = true +text = "Random Spawn" + +[node name="RandomSpawnLabel" type="Label" parent="LobbyPanel/TopBar/SettingsSection"] +visible = false +layout_mode = 2 +theme_override_colors/font_color = Color(0.647, 0.996, 0.224, 1) +theme_override_font_sizes/font_size = 11 +text = "Random ✓" + [node name="HostBanner" type="PanelContainer" parent="LobbyPanel"] layout_mode = 1 anchors_preset = 5 diff --git a/scenes/main.gd b/scenes/main.gd index 14c4c54..c551924 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -8,6 +8,8 @@ extends Node3D var ui_manager var obstacle_manager var goals_cycle_manager +var screen_shake_manager +var touch_controls # Minimal local state var _connection_check_timer: float = 0.0 @@ -30,7 +32,8 @@ func _ready(): ui_manager.setup_leaderboard_ui(self) ui_manager.setup_powerup_bar_ui(self) _setup_obstacle_ui() - _setup_global_match_timer_ui() + # GlobalMatchTimer is now static in main.tscn - no setup needed + # NetworkPanel is visible during gameplay # Auto-start game if coming from lobby (already connected to match) if NakamaManager.is_connected_to_nakama() and multiplayer.get_unique_id() != 0: @@ -56,6 +59,18 @@ func _init_managers(): add_child(goals_cycle_manager) goals_cycle_manager.initialize(self) + # Screen shake manager for impact feedback + screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new() + screen_shake_manager.name = "ScreenShakeManager" + add_child(screen_shake_manager) + screen_shake_manager.initialize($Camera3D) + + # Touch controls for mobile + touch_controls = load("res://scripts/managers/touch_controls.gd").new() + touch_controls.name = "TouchControls" + add_child(touch_controls) + touch_controls.initialize(self) + # Connect signals for UI updates goals_cycle_manager.timer_updated.connect(_on_timer_updated) goals_cycle_manager.score_updated.connect(_on_score_updated) @@ -103,7 +118,11 @@ func add_message_to_bar(player_name: String, message: String, type: int = Messag icon = "💬 " color = Color(0.9, 0.9, 0.9) # Light gray - label.text = "%s%s" % [icon, message] + # Include player name in message if provided + if player_name and player_name != "": + label.text = "%s[%s] %s" % [icon, player_name, message] + else: + label.text = "%s%s" % [icon, message] label.add_theme_color_override("font_color", color) # Add shadow for better visibility @@ -248,14 +267,20 @@ func _process(delta): # ============================================================================= func _on_match_joined(match_id: String): - $NetworkPanel/NetworkInfo/UniquePeerID.text = str(multiplayer.get_unique_id()) - - if multiplayer.is_server(): - $NetworkPanel/NetworkInfo/NetworkSideDisplay.text = "Server (Match: %s)" % match_id - _setup_host_game() + var network_panel = get_node_or_null("PauseMenu/Panel/NetworkPanel") + if network_panel: + network_panel.get_node("NetworkInfo/UniquePeerID").text = str(multiplayer.get_unique_id()) + if multiplayer.is_server(): + network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Server (Match: %s)" % match_id + _setup_host_game() + else: + network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Client" + _setup_client_game() else: - $NetworkPanel/NetworkInfo/NetworkSideDisplay.text = "Client" - _setup_client_game() + if multiplayer.is_server(): + _setup_host_game() + else: + _setup_client_game() # ============================================================================= # Game Setup @@ -273,9 +298,24 @@ func _setup_host_game(): GameStateManager.add_player(player_id) GameStateManager.local_player_character = player_character ui_manager.set_local_player(player_character) + if touch_controls: + touch_controls.set_player(player_character) - # Wait for player to be fully ready (player.gd has 0.1s await in _ready before managers init) - await get_tree().create_timer(0.2).timeout + # Spawn client players that joined via lobby (need to add them first) + var lobby_players = LobbyManager.get_players() + for lobby_player in lobby_players: + var peer_id = lobby_player.get("id", 0) + if peer_id != 1 and peer_id != 0: # Skip host (1) and invalid (0) + print("Spawning lobby player: ", peer_id) + _spawn_lobby_client_sync(peer_id) + + # IMMEDIATELY assign random spawn positions before any player _ready() completes + # Player _ready() has 0.1s await, so we assign before that completes + if LobbyManager.get_randomize_spawn(): + _assign_random_spawn_positions() + + # Wait for players to be fully ready (player.gd has 0.1s await in _ready before managers init) + await get_tree().create_timer(0.3).timeout # Set host goals - get goals directly from GoalManager var host_goals = GoalManager.get_goals_for_player(0) @@ -290,14 +330,17 @@ func _setup_host_game(): _update_player_goals_ui(0, host_goals) ui_manager.update_playerboard_ui() - # Spawn client players that joined via lobby - var lobby_players = LobbyManager.get_players() + # Set goals for lobby client players + var player_index = 1 for lobby_player in lobby_players: var peer_id = lobby_player.get("id", 0) - if peer_id != 1 and peer_id != 0: # Skip host (1) and invalid (0) - print("Spawning lobby player: ", peer_id) - await get_tree().create_timer(0.3).timeout - _spawn_lobby_client(peer_id) + if peer_id != 1 and peer_id != 0: + var client_player = get_node_or_null(str(peer_id)) + if client_player and player_index < GoalManager.preset_goals.size(): + var client_goals = GoalManager.preset_goals[player_index].duplicate() + client_player.goals = client_goals + call_deferred("_deferred_set_player_goals", peer_id, client_goals) + player_index += 1 # Add bots (only if no lobby players connected) if GameStateManager.enable_bots and lobby_players.size() <= 1: @@ -306,8 +349,8 @@ func _setup_host_game(): _start_game() -func _spawn_lobby_client(peer_id: int): - """Spawn a client player that was in the lobby.""" +func _spawn_lobby_client_sync(peer_id: int): + """Spawn a client player synchronously (no await).""" if has_node(str(peer_id)): return @@ -319,13 +362,7 @@ func _spawn_lobby_client(peer_id: int): # Tell all clients to create this player rpc("add_newly_connected_player_character", peer_id) - # Wait for player to be ready then assign goals - await get_tree().create_timer(0.3).timeout - var player_index = GameStateManager.players.find(peer_id) - if player_index >= 0 and player_index < GoalManager.preset_goals.size(): - var player_goals = GoalManager.preset_goals[player_index].duplicate() - player_character.goals = player_goals - call_deferred("_deferred_set_player_goals", peer_id, player_goals) + # Goals will be assigned after players are ready in _setup_host_game func _setup_client_game(): """Setup client when transitioning from lobby.""" @@ -340,6 +377,8 @@ func _setup_client_game(): GameStateManager.add_player(my_id) GameStateManager.local_player_character = player_character ui_manager.set_local_player(player_character) + if touch_controls: + touch_controls.set_player(player_character) ui_manager.update_button_states() print("Created local player for client: ", my_id) @@ -349,19 +388,24 @@ func _setup_client_game(): func _auto_start_from_lobby(): """Called when main.tscn is loaded from lobby - game is already connected.""" - $NetworkPanel/NetworkInfo/UniquePeerID.text = str(multiplayer.get_unique_id()) - # Get match ID from LobbyManager var match_id = LobbyManager.current_room.get("match_id", "") var short_id = match_id.substr(0, 8) if match_id.length() > 8 else match_id + # Update NetworkPanel in PauseMenu (if exists) + var network_panel = get_node_or_null("PauseMenu/Panel/NetworkPanel") + if network_panel: + network_panel.get_node("NetworkInfo/UniquePeerID").text = str(multiplayer.get_unique_id()) + if multiplayer.is_server(): + network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Host (Match: %s)" % short_id + else: + network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Client (Match: %s)" % short_id + if multiplayer.is_server(): print("Auto-starting as HOST - Match: ", short_id) - $NetworkPanel/NetworkInfo/NetworkSideDisplay.text = "Host (Match: %s)" % short_id _setup_host_game() else: print("Auto-starting as CLIENT - Match: ", short_id) - $NetworkPanel/NetworkInfo/NetworkSideDisplay.text = "Client (Match: %s)" % short_id _setup_client_game() func _start_game(): @@ -383,6 +427,34 @@ func _start_game(): var all_players = get_tree().get_nodes_in_group("Players") ui_manager.initialize_leaderboard_with_players(all_players) +func _assign_random_spawn_positions(): + """Assign random unique spawn positions to all players.""" + var spawn_locations = [ + Vector2i(0, 0), Vector2i(0, 1), Vector2i(0, 2), Vector2i(0, 3), + Vector2i(0, 4), Vector2i(0, 5), Vector2i(0, 6), Vector2i(0, 7), + Vector2i(0, 8), Vector2i(0, 9), Vector2i(0, 10), Vector2i(0, 11) + ] + + # Shuffle spawn locations + var shuffled_spawns = spawn_locations.duplicate() + shuffled_spawns.shuffle() + + # Get all players + var all_players = get_tree().get_nodes_in_group("Players") + + # Assign positions + var spawn_index = 0 + for player in all_players: + if spawn_index >= shuffled_spawns.size(): + break + var spawn_pos = shuffled_spawns[spawn_index] + # Set position and sync to all clients + player.current_position = spawn_pos + player.position = player.grid_to_world(spawn_pos) + player.spawn_point_selected = true + player.rpc("set_spawn_position", spawn_pos) + spawn_index += 1 + # ============================================================================= # Player Management # ============================================================================= @@ -428,6 +500,8 @@ func add_player_character(peer_id: int): if peer_id == multiplayer.get_unique_id(): GameStateManager.local_player_character = player_character ui_manager.set_local_player(player_character) + if touch_controls: + touch_controls.set_player(player_character) ui_manager.update_button_states() ui_manager.update_playerboard_ui() @@ -617,6 +691,12 @@ func _update_goals_ui_for_player(player_id: int, goals: Array): @rpc("any_peer", "call_local") func sync_playerboard(player_id: int, new_playerboard: Array): + # Find the player and update their playerboard + var player = get_node_or_null(str(player_id)) + if player: + player.playerboard = new_playerboard.duplicate() + + # Update UI for local player if player_id == multiplayer.get_unique_id() and GameStateManager.local_player_character: ui_manager.update_playerboard_ui() update_all_players_boards() @@ -1009,9 +1089,8 @@ func _show_game_over_panel(): back_btn.custom_minimum_size = Vector2(300, 60) back_btn.add_theme_font_size_override("font_size", 20) back_btn.pressed.connect(_on_back_to_menu_pressed) - inner_vbox.add_child(back_btn) - # Center the button + # Center the button in a container var btn_container = HBoxContainer.new() btn_container.alignment = BoxContainer.ALIGNMENT_CENTER btn_container.add_child(back_btn) @@ -1026,11 +1105,28 @@ func _on_back_to_menu_pressed(): # Clean up game state GameStateManager.end_game() LobbyManager.reset() + # Properly disconnect from Nakama match + _cleanup_multiplayer() # Go back to lobby if get_tree(): get_tree().change_scene_to_file("res://scenes/lobby.tscn") +func _cleanup_multiplayer(): + """Properly leave Nakama match and cleanup multiplayer state.""" + print("[Main] Cleaning up multiplayer connection...") + + # Leave the Nakama match through the bridge + if NakamaManager.bridge: + NakamaManager.bridge.leave() + + # Clear the current match ID + NakamaManager.current_match_id = "" + + # Reset multiplayer peer to disconnect cleanly + if multiplayer.get_multiplayer_peer(): + multiplayer.set_multiplayer_peer(null) + func _deferred_init_leaderboard(): """Initialize leaderboard after a delay to ensure all players are loaded.""" # Longer delay ensures players are synced @@ -1151,3 +1247,74 @@ func _get_ordinal(n: int) -> String: 3: return "3rd" 4: return "4th" _: return str(n) + "th" + +# ============================================================================= +# Pause Menu & Settings +# ============================================================================= + +func _input(event): + if event.is_action_pressed("ui_cancel"): + _toggle_pause_menu() + +func _toggle_pause_menu(): + var pause_menu = get_node_or_null("PauseMenu") + if pause_menu: + pause_menu.visible = not pause_menu.visible + get_tree().paused = pause_menu.visible + +func _on_resume_pressed(): + var pause_menu = get_node_or_null("PauseMenu") + if pause_menu: + pause_menu.visible = false + get_tree().paused = false + +func _on_settings_pressed(): + var pause_menu = get_node_or_null("PauseMenu") + var settings_panel = get_node_or_null("SettingsPanel") + if pause_menu: + pause_menu.visible = false + if settings_panel: + settings_panel.visible = true + + # Sync settings UI with current state + var joystick_toggle = settings_panel.get_node_or_null("Panel/VBox/JoystickToggle") + if joystick_toggle and touch_controls: + joystick_toggle.set_pressed_no_signal(touch_controls.joystick_enabled) + + var size_slider = settings_panel.get_node_or_null("Panel/VBox/ButtonSizeRow/ButtonSizeSlider") + if size_slider and touch_controls: + size_slider.set_value_no_signal(touch_controls.button_size) + + var opacity_slider = settings_panel.get_node_or_null("Panel/VBox/OpacityRow/OpacitySlider") + if opacity_slider and touch_controls: + opacity_slider.set_value_no_signal(touch_controls.button_opacity) + +func _on_quit_match_pressed(): + get_tree().paused = false + # Properly disconnect from Nakama match + _cleanup_multiplayer() + # Return to lobby or main menu + get_tree().change_scene_to_file("res://scenes/lobby.tscn") + +func _on_settings_back_pressed(): + var pause_menu = get_node_or_null("PauseMenu") + var settings_panel = get_node_or_null("SettingsPanel") + if settings_panel: + settings_panel.visible = false + if pause_menu: + pause_menu.visible = true + +func _on_button_size_changed(value: float): + if touch_controls: + touch_controls.button_size = value + touch_controls._save_settings() + +func _on_opacity_changed(value: float): + if touch_controls: + touch_controls.button_opacity = value + touch_controls._save_settings() + +func _on_joystick_toggled(enabled: bool): + if touch_controls: + touch_controls.set_joystick_enabled(enabled) + touch_controls._save_settings() diff --git a/scenes/main.tscn b/scenes/main.tscn index d1c506a..7039cc5 100644 --- a/scenes/main.tscn +++ b/scenes/main.tscn @@ -76,39 +76,6 @@ current = true fov = 35.5 size = 23.0 -[node name="NetworkPanel" type="Panel" parent="."] -anchors_preset = 5 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = -185.0 -offset_top = 25.0 -offset_right = 185.0 -offset_bottom = 78.0 -grow_horizontal = 2 -theme_override_styles/panel = ExtResource("5_dvx6y") - -[node name="NetworkInfo" type="HBoxContainer" parent="NetworkPanel"] -layout_mode = 1 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 -theme_override_constants/separation = 50 -alignment = 1 - -[node name="NetworkSideDisplay" type="Label" parent="NetworkPanel/NetworkInfo"] -layout_mode = 2 -text = "Network Side" -horizontal_alignment = 1 -vertical_alignment = 1 - -[node name="UniquePeerID" type="Label" parent="NetworkPanel/NetworkInfo"] -layout_mode = 2 -text = "Unique Peer ID" -horizontal_alignment = 1 -vertical_alignment = 1 - [node name="PlayerboardPanel" type="PanelContainer" parent="."] anchors_preset = 4 anchor_top = 0.5 @@ -9375,9 +9342,9 @@ anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 offset_left = -200.0 -offset_top = -87.0 +offset_top = -52.0 offset_right = 200.0 -offset_bottom = -53.0 +offset_bottom = -18.0 grow_horizontal = 2 grow_vertical = 0 theme_override_styles/panel = ExtResource("5_dvx6y") @@ -9397,11 +9364,13 @@ theme_override_constants/separation = 4 anchors_preset = 1 anchor_left = 1.0 anchor_right = 1.0 -offset_left = -294.0 +offset_left = -211.0 offset_top = 15.0 offset_right = -18.0 -offset_bottom = 215.0 +offset_bottom = 166.0 grow_horizontal = 0 +size_flags_horizontal = 3 +size_flags_vertical = 3 [node name="MarginContainer" type="MarginContainer" parent="LeaderboardPanel"] layout_mode = 2 @@ -9518,8 +9487,8 @@ horizontal_alignment = 2 [node name="GoalsTimer" type="PanelContainer" parent="."] offset_left = 20.0 offset_top = 20.0 -offset_right = 120.0 -offset_bottom = 90.0 +offset_right = 97.0 +offset_bottom = 97.0 [node name="VBox" type="VBoxContainer" parent="GoalsTimer"] layout_mode = 2 @@ -9537,6 +9506,207 @@ theme_override_font_sizes/font_size = 12 text = "seconds" horizontal_alignment = 1 +[node name="GlobalMatchTimer" type="PanelContainer" parent="."] +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -80.0 +offset_top = 24.0 +offset_right = 80.0 +offset_bottom = 74.0 +grow_horizontal = 2 + +[node name="VBox" type="VBoxContainer" parent="GlobalMatchTimer"] +layout_mode = 2 +alignment = 1 + +[node name="TimerLabel" type="Label" parent="GlobalMatchTimer/VBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "3:00" +horizontal_alignment = 1 + +[node name="PauseMenu" type="CanvasLayer" parent="."] +process_mode = 3 +layer = 10 +visible = false + +[node name="Background" type="ColorRect" parent="PauseMenu"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 0.7) + +[node name="Panel" type="PanelContainer" parent="PauseMenu"] +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -150.0 +offset_top = -150.0 +offset_right = 150.0 +offset_bottom = 150.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="NetworkPanel" type="Panel" parent="PauseMenu/Panel"] +layout_mode = 2 +theme_override_styles/panel = ExtResource("5_dvx6y") + +[node name="NetworkInfo" type="HBoxContainer" parent="PauseMenu/Panel/NetworkPanel"] +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -135.0 +offset_right = 135.0 +offset_bottom = 23.0 +grow_horizontal = 2 +theme_override_constants/separation = 50 +alignment = 1 + +[node name="NetworkSideDisplay" type="Label" parent="PauseMenu/Panel/NetworkPanel/NetworkInfo"] +layout_mode = 2 +text = "Network Side" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="UniquePeerID" type="Label" parent="PauseMenu/Panel/NetworkPanel/NetworkInfo"] +layout_mode = 2 +text = "Unique Peer ID" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="VBox" type="VBoxContainer" parent="PauseMenu/Panel"] +layout_mode = 2 +theme_override_constants/separation = 15 +alignment = 1 + +[node name="Title" type="Label" parent="PauseMenu/Panel/VBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 24 +text = "PAUSED" +horizontal_alignment = 1 + +[node name="Spacer" type="Control" parent="PauseMenu/Panel/VBox"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 + +[node name="ResumeBtn" type="Button" parent="PauseMenu/Panel/VBox"] +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +text = "Resume" + +[node name="SettingsBtn" type="Button" parent="PauseMenu/Panel/VBox"] +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +text = "Settings" + +[node name="QuitBtn" type="Button" parent="PauseMenu/Panel/VBox"] +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +text = "Quit Match" + +[node name="SettingsPanel" type="CanvasLayer" parent="."] +process_mode = 3 +layer = 11 +visible = false + +[node name="Background" type="ColorRect" parent="SettingsPanel"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 0.8) + +[node name="Panel" type="PanelContainer" parent="SettingsPanel"] +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -200.0 +offset_top = -200.0 +offset_right = 200.0 +offset_bottom = 200.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBox" type="VBoxContainer" parent="SettingsPanel/Panel"] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="Title" type="Label" parent="SettingsPanel/Panel/VBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "Settings" +horizontal_alignment = 1 + +[node name="Spacer" type="Control" parent="SettingsPanel/Panel/VBox"] +custom_minimum_size = Vector2(0, 10) +layout_mode = 2 + +[node name="TouchHeader" type="Label" parent="SettingsPanel/Panel/VBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Touch Controls" + +[node name="ButtonSizeRow" type="HBoxContainer" parent="SettingsPanel/Panel/VBox"] +layout_mode = 2 + +[node name="Label" type="Label" parent="SettingsPanel/Panel/VBox/ButtonSizeRow"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Button Size" + +[node name="ButtonSizeSlider" type="HSlider" parent="SettingsPanel/Panel/VBox/ButtonSizeRow"] +custom_minimum_size = Vector2(150, 0) +layout_mode = 2 +min_value = 50.0 +max_value = 120.0 +value = 70.0 + +[node name="OpacityRow" type="HBoxContainer" parent="SettingsPanel/Panel/VBox"] +layout_mode = 2 + +[node name="Label" type="Label" parent="SettingsPanel/Panel/VBox/OpacityRow"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Button Opacity" + +[node name="OpacitySlider" type="HSlider" parent="SettingsPanel/Panel/VBox/OpacityRow"] +custom_minimum_size = Vector2(150, 0) +layout_mode = 2 +max_value = 1.0 +step = 0.1 +value = 0.7 + +[node name="JoystickToggle" type="CheckButton" parent="SettingsPanel/Panel/VBox"] +layout_mode = 2 +button_pressed = true +text = "Enable Virtual Joystick" + +[node name="Spacer2" type="Control" parent="SettingsPanel/Panel/VBox"] +custom_minimum_size = Vector2(0, 15) +layout_mode = 2 + +[node name="BackBtn" type="Button" parent="SettingsPanel/Panel/VBox"] +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 +text = "Back" + [connection signal="pressed" from="Menu/Host" to="." method="_on_host_pressed"] [connection signal="pressed" from="Menu/Join" to="." method="_on_join_pressed"] [connection signal="text_submitted" from="MessageInput" to="." method="_on_message_input_text_submitted"] +[connection signal="pressed" from="PauseMenu/Panel/VBox/ResumeBtn" to="." method="_on_resume_pressed"] +[connection signal="pressed" from="PauseMenu/Panel/VBox/SettingsBtn" to="." method="_on_settings_pressed"] +[connection signal="pressed" from="PauseMenu/Panel/VBox/QuitBtn" to="." method="_on_quit_match_pressed"] +[connection signal="value_changed" from="SettingsPanel/Panel/VBox/ButtonSizeRow/ButtonSizeSlider" to="." method="_on_button_size_changed"] +[connection signal="value_changed" from="SettingsPanel/Panel/VBox/OpacityRow/OpacitySlider" to="." method="_on_opacity_changed"] +[connection signal="toggled" from="SettingsPanel/Panel/VBox/JoystickToggle" to="." method="_on_joystick_toggled"] +[connection signal="pressed" from="SettingsPanel/Panel/VBox/BackBtn" to="." method="_on_settings_back_pressed"] diff --git a/scenes/player.gd b/scenes/player.gd index 56f354a..a2564c1 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -78,6 +78,17 @@ var spawn_point_selected = false # Action for hilighter var highlighted_spawn_points = [] +var _is_highlighting: bool = false + +# Character selection and animation +@onready var anim_player: AnimationPlayer = $AnimationPlayer +@onready var character_bob: Node3D = $Bob +@onready var character_masbro: Node3D = $Masbro +@onready var character_gatot: Node3D = $Gatot +@onready var character_oldpop: Node3D = $Oldpop + +var selected_character: String = "Masbro" # Default character (matches tscn default visibility) +const AVAILABLE_CHARACTERS: Array[String] = ["Bob", "Masbro", "Gatot", "Oldpop"] @export var movement_range: int = 1: @@ -124,6 +135,9 @@ func _ready(): _init_managers() + # Initialize character selection from LobbyManager + _setup_character() + # Early setup for bots if is_bot == true or is_in_group("Bots"): # Initialize behavior tree for bots @@ -162,11 +176,13 @@ func _ready(): enhanced_gridmap.initialize_astar() enhanced_gridmap.set_diagonal_movement(use_diagonal_movement) - # Request current spawn positions before highlighting - request_spawn_positions_update() - - highlight_available_spawn_points() - # Remove this line as goals are now managed by the host + # Skip manual spawn selection if random spawn is enabled + # Host will assign positions via RPC + if not LobbyManager.get_randomize_spawn(): + # Request current spawn positions before highlighting + request_spawn_positions_update() + highlight_available_spawn_points() + # Remove this line as goals are now managed by the host #append_random_goals() playerboard.resize(25) @@ -181,21 +197,26 @@ func _ready(): if enhanced_gridmap: enhanced_gridmap.initialize_astar() enhanced_gridmap.set_diagonal_movement(use_diagonal_movement) - current_position = find_valid_starting_position() - update_player_position(current_position) + # Only set position if not using random spawn (host will assign via RPC) + # AND not already assigned + if not LobbyManager.get_randomize_spawn() and not spawn_point_selected: + current_position = find_valid_starting_position() + update_player_position(current_position) #append_random_goals() playerboard.resize(25) playerboard.fill(-1) - # Ensure proper initial positioning - global_position = Vector3( - current_position.x * cell_size.x + cell_size.x * 0.5, - 1.0, - current_position.y * cell_size.z + cell_size.z * 0.5 - ) - if is_multiplayer_authority(): - rpc("sync_position", current_position) + # Ensure proper initial positioning (only if NOT using random spawn and not already positioned) + # When random spawn is enabled, the host assigns positions via set_spawn_position RPC + if not LobbyManager.get_randomize_spawn() and not spawn_point_selected: + global_position = Vector3( + current_position.x * cell_size.x + cell_size.x * 0.5, + 1.0, + current_position.y * cell_size.z + cell_size.z * 0.5 + ) + if is_multiplayer_authority(): + rpc("sync_position", current_position) func _init_managers(): movement_manager = load("res://scripts/managers/player_movement_manager.gd").new() @@ -233,6 +254,125 @@ func _init_managers(): add_child(powerup_manager) powerup_manager.initialize(self, enhanced_gridmap) +# ============================================================================= +# Character Selection +# ============================================================================= + +func set_character(character_name: String) -> void: + """Show only the selected character model and hide others. Updates AnimationPlayer root.""" + if character_name not in AVAILABLE_CHARACTERS: + push_warning("Invalid character name: %s" % character_name) + return + + selected_character = character_name + + # Hide all character models + if character_bob: character_bob.visible = false + if character_masbro: character_masbro.visible = false + if character_gatot: character_gatot.visible = false + if character_oldpop: character_oldpop.visible = false + + # Show selected character and update AnimationPlayer root + var active_character: Node3D = null + match character_name: + "Bob": + if character_bob: + character_bob.visible = true + active_character = character_bob + "Masbro": + if character_masbro: + character_masbro.visible = true + active_character = character_masbro + "Gatot": + if character_gatot: + character_gatot.visible = true + active_character = character_gatot + "Oldpop": + if character_oldpop: + character_oldpop.visible = true + active_character = character_oldpop + + # Update AnimationPlayer's root node to point to active character + if anim_player and active_character: + anim_player.root_node = anim_player.get_path_to(active_character) + # Start with idle animation + play_idle_animation() + +@rpc("any_peer", "call_local", "reliable") +func sync_character(character_name: String) -> void: + """Sync character selection across all clients.""" + set_character(character_name) + +func _setup_character() -> void: + """Initialize character based on LobbyManager selection or defaults.""" + var character_name = "Masbro" # Default + var player_authority_id = get_multiplayer_authority() + + # Look up character from LobbyManager for this player (works for all players) + if LobbyManager: + var players = LobbyManager.get_players() + for player_data in players: + if player_data.get("id") == player_authority_id: + character_name = player_data.get("character", "Masbro") + break + + set_character(character_name) + + # If this is our local player, also sync to other clients for late joiners + if is_multiplayer_authority(): + rpc("sync_character", character_name) + +# ============================================================================= +# Animation Functions +# ============================================================================= + +# Animation speed multiplier for fast-paced gameplay +const ANIMATION_SPEED: float = 2.0 + +func play_walk_animation() -> void: + """Play walking animation at increased speed.""" + if anim_player and anim_player.has_animation("animation-pack/walk_forward"): + anim_player.play("animation-pack/walk_forward", -1, ANIMATION_SPEED) + +func play_pickup_animation() -> void: + """Play pickup/grab tile animation at increased speed.""" + if anim_player and anim_player.has_animation("animation-pack/take_tile_1"): + anim_player.play("animation-pack/take_tile_1", -1, ANIMATION_SPEED) + +func play_put_animation() -> void: + """Play put/drop tile animation at increased speed.""" + if anim_player and anim_player.has_animation("animation-pack/drop_tile_1"): + anim_player.play("animation-pack/drop_tile_1", -1, ANIMATION_SPEED) + +func play_special_animation() -> void: + """Play special ability animation (backflip) at increased speed.""" + if anim_player and anim_player.has_animation("animation-pack/backflip_1"): + anim_player.play("animation-pack/backflip_1", -1, ANIMATION_SPEED * 1.5) + +func play_idle_animation() -> void: + """Play idle animation at normal speed.""" + if anim_player and anim_player.has_animation("animation-pack/idle"): + anim_player.play("animation-pack/idle") + +# ============================================================================= +# Screen Shake +# ============================================================================= + +@rpc("any_peer", "call_local", "reliable") +func trigger_screen_shake(shake_type: String) -> void: + """Trigger screen shake effect. Called via RPC when targeted or completing goals.""" + var main = get_tree().get_root().get_node_or_null("Main") + if main: + var screen_shake_manager = main.get_node_or_null("ScreenShakeManager") + if screen_shake_manager: + match shake_type: + "targeted": + screen_shake_manager.shake_targeted() + "goal": + screen_shake_manager.shake_goal_complete() + _: + screen_shake_manager.shake_light() + # Add function to check if position is at finish line func is_at_finish_line() -> bool: return race_manager.is_at_finish_line() @@ -1121,6 +1261,20 @@ func sync_position(pos: Vector2i): current_position.y * cell_size.z + cell_size.z * 0.5 ) + cell_offset +@rpc("any_peer", "call_local", "reliable") +func set_spawn_position(pos: Vector2i): + """Set spawn position - used by random spawn system.""" + current_position = pos + spawn_point_selected = true + # Clear any spawn highlights + clear_spawn_highlights() + # Update visual position + global_position = Vector3( + current_position.x * cell_size.x + cell_size.x * 0.5, + cell_size.y, + current_position.y * cell_size.z + cell_size.z * 0.5 + ) + cell_offset + func highlight_valid_obstacle_cells(): action_manager.highlight_valid_obstacle_cells() diff --git a/scenes/player.tscn b/scenes/player.tscn index 4933738..4912af3 100644 --- a/scenes/player.tscn +++ b/scenes/player.tscn @@ -34,6 +34,7 @@ spacing_glyph = 5 [node name="CharacterBody3D" type="CharacterBody3D"] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.5, 0) +collision_layer = 2 script = ExtResource("1_qecr4") cell_size = Vector3(1, 1, 1) use_diagonal_movement = true diff --git a/scripts/managers/goals_cycle_manager.gd b/scripts/managers/goals_cycle_manager.gd index 1ed9aa8..db08ef5 100644 --- a/scripts/managers/goals_cycle_manager.gd +++ b/scripts/managers/goals_cycle_manager.gd @@ -205,7 +205,9 @@ func on_goal_completed(player: Node, time_remaining: float): # Clear playerboard tiles (they convert to powerup bar reward) player.playerboard.fill(-1) - player.rpc("sync_playerboard", player.playerboard) + # Use main scene's RPC which properly looks up player by ID on each client + if main_scene: + main_scene.rpc("sync_playerboard", peer_id, player.playerboard) # Regenerate goals for this player regenerate_goals_for_player(player) @@ -250,7 +252,9 @@ func _process_cycle_end_for_all_players(): # Clear playerboard player.playerboard.fill(-1) - player.rpc("sync_playerboard", player.playerboard) + # Use main scene's RPC which properly looks up player by ID + if main_scene: + main_scene.rpc("sync_playerboard", peer_id, player.playerboard) # Generate new goals regenerate_goals_for_player(player) @@ -291,7 +295,11 @@ func regenerate_goals_for_player(player: Node): int_goals.append(g) player.goals = int_goals - player.rpc("sync_goals", int_goals) + + # Use main scene's RPC which properly looks up player by ID on each client + var peer_id = player.get_multiplayer_authority() + if main_scene: + main_scene.rpc("sync_player_goals", peer_id, int_goals) # ============================================================================= # Tile Randomization diff --git a/scripts/managers/lobby_manager.gd b/scripts/managers/lobby_manager.gd index 4c1f205..8dc2419 100644 --- a/scripts/managers/lobby_manager.gd +++ b/scripts/managers/lobby_manager.gd @@ -12,6 +12,7 @@ signal ready_state_changed(player_id: int, is_ready: bool) signal all_players_ready() signal game_starting() signal match_duration_changed(duration_seconds: int) +signal randomize_spawn_changed(enabled: bool) signal character_changed(player_id: int, character_name: String) signal area_changed(area_name: String) signal player_list_changed() @@ -26,6 +27,9 @@ var local_player_name: String = "Player" # Match duration in seconds (configurable in lobby by host) var match_duration: int = 180 # Default 3 minutes +# Randomize spawn locations (configurable in lobby by host) +var randomize_spawn: bool = true # Default enabled + # Character and area selection var available_characters: Array[String] = ["Bob", "Gatot", "Masbro", "Oldpop"] var available_areas: Array[String] = ["Desert", "Forest", "City", "Factory"] @@ -161,6 +165,21 @@ func sync_match_duration(duration_seconds: int) -> void: func get_match_duration() -> int: return match_duration +func set_randomize_spawn(enabled: bool) -> void: + """Host sets randomize spawn. Syncs to all clients.""" + randomize_spawn = enabled + if is_host: + rpc("sync_randomize_spawn", enabled) + +@rpc("authority", "call_local", "reliable") +func sync_randomize_spawn(enabled: bool) -> void: + """Sync randomize spawn from host to clients.""" + randomize_spawn = enabled + emit_signal("randomize_spawn_changed", enabled) + +func get_randomize_spawn() -> bool: + return randomize_spawn + # ============================================================================= # Character Selection # ============================================================================= diff --git a/scripts/managers/player_movement_manager.gd b/scripts/managers/player_movement_manager.gd index 238d4f5..9e1cfb6 100644 --- a/scripts/managers/player_movement_manager.gd +++ b/scripts/managers/player_movement_manager.gd @@ -69,6 +69,10 @@ func simple_move_to(grid_position: Vector2i) -> bool: # All checks passed, perform move rotate_towards_target(grid_position) + + # Play walk animation + if player.has_method("play_walk_animation"): + player.play_walk_animation() var path = [Vector2(player.current_position.x, player.current_position.y), Vector2(grid_position.x, grid_position.y)] path.pop_front() diff --git a/scripts/managers/playerboard_manager.gd b/scripts/managers/playerboard_manager.gd index ee6cd65..2606322 100644 --- a/scripts/managers/playerboard_manager.gd +++ b/scripts/managers/playerboard_manager.gd @@ -54,6 +54,10 @@ func grab_item(grid_position: Vector2i) -> bool: if not player.is_multiplayer_authority(): return false + # Play pickup animation + if player.has_method("play_pickup_animation"): + player.play_pickup_animation() + # === Optimistic Local Update (immediate visual feedback) === # Apply changes locally first, server will validate/sync enhanced_gridmap.set_cell_item(cell, -1) # Remove item visually immediately @@ -82,7 +86,9 @@ func grab_item(grid_position: Vector2i) -> bool: if multiplayer.is_server(): # HOST/SERVER: Broadcast to all clients main.rpc("sync_grid_item", cell.x, cell.y, cell.z, -1) - player.rpc("sync_playerboard", player.playerboard) + # Use main's RPC which properly looks up player by ID on each client + var peer_id = player.get_multiplayer_authority() + main.rpc("sync_playerboard", peer_id, player.playerboard) player.has_performed_action = true player.consume_action_points(1) player.rpc("force_action_state_none") @@ -138,13 +144,17 @@ func _execute_grab(grid_pos: Vector2i, cell: Vector3i, item_id: int): player.playerboard[target_slot] = item_id # 3c. Broadcast the new playerboard state to all clients - player.rpc("sync_playerboard", player.playerboard) + var peer_id = player.get_multiplayer_authority() + main.rpc("sync_playerboard", peer_id, player.playerboard) - # 3d. Consume action points + # 3d. Check if goal is completed (SERVER-SIDE - this triggers goal regeneration for clients!) + _check_goal_completion() + + # 3e. Consume action points player.has_performed_action = true player.consume_action_points(1) - # 3e. Reset the UI for the player who acted + # 3f. Reset the UI for the player who acted player.rpc("force_action_state_none") return true @@ -306,6 +316,10 @@ func auto_put_item() -> bool: var cell = Vector3i(target_pos.x, 1, target_pos.y) if player.is_multiplayer_authority(): + # Play put animation + if player.has_method("play_put_animation"): + player.play_put_animation() + # === Optimistic Local Update (immediate visual feedback) === enhanced_gridmap.set_cell_item(cell, item) # Add item to grid visually immediately player.playerboard[put_slot] = -1 # Remove from playerboard immediately @@ -627,6 +641,10 @@ func _check_goal_completion(): if powerup_manager: powerup_manager.add_goal_completion_reward() + # Trigger screen shake for goal completion + if player.is_multiplayer_authority() and player.has_method("trigger_screen_shake"): + player.trigger_screen_shake("goal") + # Notify GoalsCycleManager for scoring var main = player.get_tree().get_root().get_node_or_null("Main") if main: diff --git a/scripts/managers/powerup_manager.gd b/scripts/managers/powerup_manager.gd index edef68f..921a9a2 100644 --- a/scripts/managers/powerup_manager.gd +++ b/scripts/managers/powerup_manager.gd @@ -6,6 +6,7 @@ const MAX_POINTS: int = 12 const POINTS_PER_BAR: int = 4 const MAX_BARS: int = 4 const HOLO_PICKUPS_PER_BAR: int = 4 +const SPECIAL_COOLDOWN: float = 4.0 # 4 second cooldown var player: Node3D var enhanced_gridmap: Node @@ -13,6 +14,7 @@ var enhanced_gridmap: Node # Power-up state var current_points: int = 0 var holo_pickup_count: int = 0 +var special_cooldown_timer: float = 0.0 # Current cooldown remaining signal points_changed(current: int, max_points: int) signal bar_filled() @@ -21,6 +23,12 @@ signal effect_used() func initialize(p_player: Node3D, p_gridmap: Node): player = p_player enhanced_gridmap = p_gridmap + set_process(true) + +func _process(delta): + # Update cooldown timer + if special_cooldown_timer > 0: + special_cooldown_timer -= delta # ============================================================================= # Holo Tile Pickup @@ -79,17 +87,29 @@ func use_special_effect(): player.rpc("display_message", "Not enough power-up!", 3) return false + # Check cooldown + if special_cooldown_timer > 0: + player.rpc("display_message", "Special on cooldown! (%.1fs)" % special_cooldown_timer, 3) + return false + # Consume 1 bar current_points -= POINTS_PER_BAR emit_signal("effect_used") emit_signal("points_changed", current_points, MAX_POINTS) + # Start cooldown + special_cooldown_timer = SPECIAL_COOLDOWN + + # Play special animation (backflip) + if player.has_method("play_special_animation"): + player.play_special_animation() + # Trigger random special effect via SpecialTilesManager var special_tiles_manager = player.get_node_or_null("SpecialTilesManager") if special_tiles_manager: special_tiles_manager.trigger_random_effect() - print("[PowerUp] Player %s used special effect! Remaining: %d/%d points" % [player.name, current_points, MAX_POINTS]) + print("[PowerUp] Player %s used special effect! Remaining: %d/%d points, Cooldown: %.1fs" % [player.name, current_points, MAX_POINTS, SPECIAL_COOLDOWN]) if player.is_multiplayer_authority(): rpc("sync_points", current_points) diff --git a/scripts/managers/screen_shake.gd b/scripts/managers/screen_shake.gd new file mode 100644 index 0000000..18850a6 --- /dev/null +++ b/scripts/managers/screen_shake.gd @@ -0,0 +1,72 @@ +extends Node + +# ScreenShakeManager - Handles camera shake effects for impact feedback + +var camera: Camera3D +var shake_intensity: float = 0.0 +var shake_duration: float = 0.0 +var shake_timer: float = 0.0 +var original_position: Vector3 + +# Shake presets +const SHAKE_TARGETED: Dictionary = {"intensity": 0.15, "duration": 0.4} +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: + original_position = camera.position + print("[ScreenShakeManager] Initialized with camera: ", camera.name) + else: + push_warning("[ScreenShakeManager] Initialized with null camera") + +func _process(delta): + if shake_timer > 0: + shake_timer -= delta + if camera: + var shake_offset = Vector3( + randf_range(-shake_intensity, shake_intensity), + randf_range(-shake_intensity, shake_intensity), + randf_range(-shake_intensity * 0.5, shake_intensity * 0.5) + ) + camera.position = original_position + shake_offset + + if shake_timer <= 0: + _reset_camera() + +func _reset_camera(): + if camera: + camera.position = original_position + shake_timer = 0.0 + shake_intensity = 0.0 + +func shake(intensity: float, duration: float): + """Trigger camera shake with given intensity and duration.""" + if not camera: + push_warning("Screen shake requested but no camera assigned!") + return + + if camera: + # If already shaking, reset camera first to get true original position + if shake_timer > 0: + camera.position = original_position + else: + original_position = camera.position + + shake_intensity = intensity + shake_duration = duration + shake_timer = duration + +func shake_targeted(): + """Called when local player is targeted by opponent's powerup.""" + shake(SHAKE_TARGETED.intensity, SHAKE_TARGETED.duration) + +func shake_goal_complete(): + """Called when local player completes a goal.""" + shake(SHAKE_GOAL_COMPLETE.intensity, SHAKE_GOAL_COMPLETE.duration) + +func shake_light(): + """Light shake for minor events.""" + shake(SHAKE_LIGHT.intensity, SHAKE_LIGHT.duration) diff --git a/scripts/managers/screen_shake.gd.uid b/scripts/managers/screen_shake.gd.uid new file mode 100644 index 0000000..9b5ba0e --- /dev/null +++ b/scripts/managers/screen_shake.gd.uid @@ -0,0 +1 @@ +uid://bg43ds58dip7u diff --git a/scripts/managers/special_tiles_manager.gd b/scripts/managers/special_tiles_manager.gd index a1005f8..ead1e71 100644 --- a/scripts/managers/special_tiles_manager.gd +++ b/scripts/managers/special_tiles_manager.gd @@ -139,6 +139,12 @@ func _execute_burn_tiles(): if main: main.rpc("sync_grid_item", cell.x, cell.y, cell.z, -1) + # Trigger screen shake on the targeted opponent + if opponent.is_multiplayer_authority(): + opponent.rpc("trigger_screen_shake", "targeted") + else: + opponent.rpc_id(opponent.get_multiplayer_authority(), "trigger_screen_shake", "targeted") + print("[SpecialTiles] BURN_TILES: Removed %d tiles around %s" % [positions.size(), opponent.name]) player.rpc("display_message", "Burned tiles near opponent!") @@ -177,6 +183,12 @@ func _execute_freeze_player(): opponent.set("is_frozen", true) _create_unfreeze_timer(opponent, FREEZE_DURATION) + # Trigger screen shake on the frozen opponent + if opponent.is_multiplayer_authority(): + opponent.rpc("trigger_screen_shake", "targeted") + else: + opponent.rpc_id(opponent.get_multiplayer_authority(), "trigger_screen_shake", "targeted") + print("[SpecialTiles] FREEZE_PLAYER: Froze %s for %ds" % [opponent.name, FREEZE_DURATION]) player.rpc("display_message", "Froze an opponent!") opponent.rpc("display_message", "You are frozen!") diff --git a/scripts/managers/touch_controls.gd b/scripts/managers/touch_controls.gd new file mode 100644 index 0000000..b56afcc --- /dev/null +++ b/scripts/managers/touch_controls.gd @@ -0,0 +1,367 @@ +extends CanvasLayer + +# TouchControlsManager - Manages mobile touch controls including virtual joystick and action buttons + +signal grab_pressed +signal put_pressed +signal special_pressed + +# Touch control nodes +var virtual_joystick: Control +var grab_button: Button +var put_button: Button +var special_button: Button +var settings_button: Button + +# Settings - persisted to config file +const CONFIG_PATH = "user://touch_controls_settings.cfg" +var button_size: float = 70.0 +var button_opacity: float = 0.7 +var joystick_enabled: bool = true +var touch_buttons_enabled: bool = true # Master toggle for action buttons (grab, put, special) +var joystick_position: Vector2 = Vector2(120, -120) # Relative to bottom-left +var button_positions: Dictionary = { + "grab": Vector2(-200, -240), # Relative to bottom-right + "put": Vector2(-120, -160), + "special": Vector2(-200, -80) +} +var button_scale: float = 1.0 + +# Reference to main scene and player +var main_scene: Node3D +var local_player: Node3D + +func initialize(p_main: Node3D): + main_scene = p_main + _create_touch_ui() + _load_settings() + +func set_player(p_player: Node3D): + local_player = p_player + +func _create_touch_ui(): + print("[TouchControls] Creating touch UI...") + # Use layer 10 - above regular UI but below pause menu + layer = 10 + + # Create main container + var container = Control.new() + container.name = "TouchControls" + container.set_anchors_preset(Control.PRESET_FULL_RECT) + container.mouse_filter = Control.MOUSE_FILTER_PASS # Pass input to children + add_child(container) + + # Create virtual joystick (bottom-left) + var joystick_script = load("res://scripts/ui/virtual_joystick.gd") + virtual_joystick = Control.new() + virtual_joystick.set_script(joystick_script) + virtual_joystick.name = "VirtualJoystick" + virtual_joystick.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) + + # Use standard size from joystick script defaults (radius 60 -> size 160) + var joy_size = Vector2(160, 160) + virtual_joystick.custom_minimum_size = joy_size + virtual_joystick.size = joy_size + + # Position relative to Bottom-Left anchor + # joystick_position (120, -120) interpreted as margin from anchor + # x=120 (right from left edge), y=-120 (up from bottom edge - implies bottom margin) + # We want the *center* or *bottom-left* corner? + # Assuming (120, -120) is top-left corner of the control relative to anchor? + # Let's align bottom-left corner of control to (120, -120) from screen bottom-left + # Screen Bottom-Left is (0, 1) in normalized anchors. + # offset_left = 120 + # offset_bottom = -120 (120px up from bottom) + # offset_top = -120 - 160 = -280 + # offset_right = 120 + 160 = 280 + + virtual_joystick.offset_left = 120 + virtual_joystick.offset_top = -280 + virtual_joystick.offset_right = 280 + virtual_joystick.offset_bottom = -120 + + virtual_joystick.direction_changed.connect(_on_joystick_direction) + container.add_child(virtual_joystick) + + # Create action buttons (bottom-right) + grab_button = _create_action_button("Grab", "👋", button_positions.grab) + put_button = _create_action_button("Put", "📦", button_positions.put) + special_button = _create_action_button("Special", "⚡", button_positions.special) + + container.add_child(grab_button) + container.add_child(put_button) + container.add_child(special_button) + + # Create settings button (top-right corner) + settings_button = Button.new() + settings_button.name = "SettingsBtn" + settings_button.text = "⚙" + settings_button.set_anchors_preset(Control.PRESET_TOP_RIGHT) + settings_button.offset_left = -70 # Use offsets instead of position for anchored controls + settings_button.offset_right = -20 + settings_button.offset_top = 70 + settings_button.offset_bottom = 120 + settings_button.custom_minimum_size = Vector2(50, 50) + settings_button.mouse_filter = Control.MOUSE_FILTER_STOP # Ensure it receives input + settings_button.pressed.connect(_on_settings_pressed) + _style_button(settings_button, 0.5) + container.add_child(settings_button) + + # Always visible now - controlled by settings toggle + # Can be hidden via settings if user doesn't want touch controls on desktop + visible = true + +func _create_action_button(button_name: String, icon: String, pos: Vector2) -> Button: + var btn = Button.new() + btn.name = button_name + "Btn" + btn.text = icon + btn.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) + # Use offsets strictly for anchored positioning + # pos.x and pos.y are negative offsets from bottom-right (e.g. -200, -240) + btn.offset_left = pos.x + btn.offset_top = pos.y + btn.offset_right = pos.x + button_size + btn.offset_bottom = pos.y + button_size + + btn.custom_minimum_size = Vector2(button_size, button_size) + btn.pivot_offset = Vector2(button_size / 2, button_size / 2) # Center pivot for scaling + + # Connect signals + btn.button_down.connect(func(): _on_button_pressed(button_name)) + btn.button_up.connect(func(): _on_button_released(button_name)) + + _style_button(btn, button_opacity) + return btn + +func _style_button(btn: Button, opacity: float): + var style = StyleBoxFlat.new() + style.bg_color = Color(0.2, 0.2, 0.25, opacity) + style.border_width_left = 2 + style.border_width_top = 2 + style.border_width_right = 2 + style.border_width_bottom = 2 + style.border_color = Color(0.647, 0.996, 0.224, 0.8) + style.corner_radius_top_left = 15 + style.corner_radius_top_right = 15 + style.corner_radius_bottom_right = 15 + style.corner_radius_bottom_left = 15 + btn.add_theme_stylebox_override("normal", style) + + var pressed_style = style.duplicate() + pressed_style.bg_color = Color(0.4, 0.8, 0.4, opacity + 0.2) + btn.add_theme_stylebox_override("pressed", pressed_style) + + var hover_style = style.duplicate() + hover_style.bg_color = Color(0.3, 0.3, 0.35, opacity) + btn.add_theme_stylebox_override("hover", hover_style) + + btn.add_theme_font_size_override("font_size", 28) + +func _on_joystick_direction(direction: Vector2i): + if local_player and local_player.has_method("simple_move_to"): + var target_pos = local_player.current_position + direction + local_player.movement_manager.simple_move_to(target_pos) + +func _on_button_pressed(button_name: String): + if not local_player: + return + + # Visual feedback - scale up + var btn: Button + match button_name: + "Grab": btn = grab_button + "Put": btn = put_button + "Special": btn = special_button + + if btn: + var tween = create_tween() + tween.tween_property(btn, "scale", Vector2(1.15, 1.15), 0.1) + + # Trigger action + match button_name: + "Grab": + emit_signal("grab_pressed") + if local_player.has_method("grab_item"): + local_player.grab_item(local_player.current_position) + "Put": + emit_signal("put_pressed") + if local_player.has_method("auto_put_item"): + local_player.auto_put_item() + "Special": + emit_signal("special_pressed") + var powerup_mgr = local_player.get_node_or_null("PowerUpManager") + if powerup_mgr and powerup_mgr.has_method("use_special_effect"): + powerup_mgr.use_special_effect() + +func _on_button_released(button_name: String): + var btn: Button + match button_name: + "Grab": btn = grab_button + "Put": btn = put_button + "Special": btn = special_button + + if btn: + var tween = create_tween() + tween.tween_property(btn, "scale", Vector2(1.0, 1.0), 0.1) + +func _on_settings_pressed(): + # Open settings panel in main scene + if main_scene: + var settings_panel = main_scene.get_node_or_null("SettingsPanel") + if settings_panel: + settings_panel.visible = true + print("[TouchControls] Opening settings panel") + else: + print("[TouchControls] SettingsPanel not found in main scene") + +func _is_touch_device() -> bool: + # Check if running on mobile + return OS.has_feature("android") or OS.has_feature("ios") or OS.has_feature("web_android") or OS.has_feature("web_ios") + +func _load_settings(): + """Load touch control settings from config file.""" + var config = ConfigFile.new() + var err = config.load(CONFIG_PATH) + if err != OK: + print("[TouchControls] No saved settings found, using defaults") + return + + # Load settings values + button_size = config.get_value("touch_controls", "button_size", 70.0) + button_opacity = config.get_value("touch_controls", "button_opacity", 0.7) + button_scale = config.get_value("touch_controls", "button_scale", 1.0) + joystick_enabled = config.get_value("touch_controls", "joystick_enabled", true) + touch_buttons_enabled = config.get_value("touch_controls", "touch_buttons_enabled", true) + + # Load button positions + var grab_pos = config.get_value("touch_controls", "grab_position", Vector2(-200, -240)) + var put_pos = config.get_value("touch_controls", "put_position", Vector2(-120, -160)) + var special_pos = config.get_value("touch_controls", "special_position", Vector2(-200, -80)) + button_positions = {"grab": grab_pos, "put": put_pos, "special": special_pos} + + # Apply loaded settings + _apply_settings() + print("[TouchControls] Settings loaded") + +func _save_settings(): + """Save touch control settings to config file.""" + var config = ConfigFile.new() + + config.set_value("touch_controls", "button_size", button_size) + config.set_value("touch_controls", "button_opacity", button_opacity) + config.set_value("touch_controls", "button_scale", button_scale) + config.set_value("touch_controls", "joystick_enabled", joystick_enabled) + config.set_value("touch_controls", "touch_buttons_enabled", touch_buttons_enabled) + config.set_value("touch_controls", "grab_position", button_positions.grab) + config.set_value("touch_controls", "put_position", button_positions.put) + config.set_value("touch_controls", "special_position", button_positions.special) + + var err = config.save(CONFIG_PATH) + if err != OK: + push_error("[TouchControls] Failed to save settings: %s" % err) + else: + print("[TouchControls] Settings saved") + +func _apply_settings(): + """Apply current settings to UI elements.""" + # Apply joystick visibility + if virtual_joystick: + virtual_joystick.visible = joystick_enabled + + # Apply touch buttons visibility - dependent on master joystick_enabled switch + # If joystick is disabled, ALL touch controls are hidden + # Note: We ignore touch_buttons_enabled here to ensure "Enable Virtual Joystick" shows EVERYTHING as requested + var buttons_visible = joystick_enabled + + print("[TouchControls] Applying settings: JoystickEnabled=", joystick_enabled, " ButtonsVisible=", buttons_visible) + + if grab_button: + grab_button.visible = buttons_visible + grab_button.scale = Vector2(button_scale, button_scale) + # Use offsets for anchored controls, not position + grab_button.offset_left = button_positions.grab.x + grab_button.offset_top = button_positions.grab.y + grab_button.offset_right = button_positions.grab.x + button_size + grab_button.offset_bottom = button_positions.grab.y + button_size + + if put_button: + put_button.visible = buttons_visible + put_button.scale = Vector2(button_scale, button_scale) + put_button.offset_left = button_positions.put.x + put_button.offset_top = button_positions.put.y + put_button.offset_right = button_positions.put.x + button_size + put_button.offset_bottom = button_positions.put.y + button_size + + if special_button: + special_button.visible = buttons_visible + special_button.scale = Vector2(button_scale, button_scale) + special_button.offset_left = button_positions.special.x + special_button.offset_top = button_positions.special.y + special_button.offset_right = button_positions.special.x + button_size + special_button.offset_bottom = button_positions.special.y + button_size + + # Force layer update + visible = true + +# ============================================================================= +# Public Settings API +# ============================================================================= + +func set_touch_buttons_enabled(enabled: bool): + """Enable or disable all action buttons (grab, put, special).""" + touch_buttons_enabled = enabled + _apply_settings() + +func set_joystick_enabled(enabled: bool): + """Enable or disable the virtual joystick (and all touch controls).""" + joystick_enabled = enabled + _apply_settings() + +func set_button_scale(p_scale: float): + """Set scale for all action buttons.""" + button_scale = p_scale + _apply_settings() + +func set_button_position(button_name: String, new_position: Vector2): + """Update position of a specific button.""" + button_positions[button_name] = new_position + match button_name: + "grab": + if grab_button: + grab_button.offset_left = new_position.x + grab_button.offset_top = new_position.y + grab_button.offset_right = new_position.x + button_size + grab_button.offset_bottom = new_position.y + button_size + "put": + if put_button: + put_button.offset_left = new_position.x + put_button.offset_top = new_position.y + put_button.offset_right = new_position.x + button_size + put_button.offset_bottom = new_position.y + button_size + "special": + if special_button: + special_button.offset_left = new_position.x + special_button.offset_top = new_position.y + special_button.offset_right = new_position.x + button_size + special_button.offset_bottom = new_position.y + button_size + +func get_button_positions() -> Dictionary: + """Get current button positions for settings UI.""" + return button_positions.duplicate() + +func get_settings() -> Dictionary: + """Get all current settings as a dictionary.""" + return { + "button_size": button_size, + "button_opacity": button_opacity, + "button_scale": button_scale, + "joystick_enabled": joystick_enabled, + "touch_buttons_enabled": touch_buttons_enabled, + "button_positions": button_positions.duplicate() + } + +func show_controls(): + visible = true + +func hide_controls(): + visible = false diff --git a/scripts/managers/touch_controls.gd.uid b/scripts/managers/touch_controls.gd.uid new file mode 100644 index 0000000..0180c4e --- /dev/null +++ b/scripts/managers/touch_controls.gd.uid @@ -0,0 +1 @@ +uid://b54tfa0n6kogi diff --git a/scripts/managers/ui_manager.gd b/scripts/managers/ui_manager.gd index cdd0427..ce911d1 100644 --- a/scripts/managers/ui_manager.gd +++ b/scripts/managers/ui_manager.gd @@ -20,6 +20,7 @@ var arrange_button var playerboard_ui var local_player_character +var _previous_playerboard_state: Array = [] enum ActionState { NONE, @@ -122,9 +123,6 @@ func update_playerboard_ui(): if center_index != -1 and center_index < goals.size(): var goal_value = goals[center_index] - # Check if player has collected this tile - var _has_tile = item == goal_value - if item != -1: # Player has a tile in this slot - show it at full brightness match item: @@ -150,6 +148,35 @@ func update_playerboard_ui(): 8: slot.texture = item_tex[2] 9: slot.texture = item_tex[3] 10: slot.texture = item_tex[4] + # Non-center slots always full brightness + slot.modulate = Color.WHITE + + # Check for new special tile placement to trigger effect + if i < _previous_playerboard_state.size(): + var prev_item = _previous_playerboard_state[i] + # If slot was empty or different, and now has a special tile (7-10) + if item != prev_item and item >= 7 and item <= 10: + _pulse_slot_effect(slot) + + # Update cache + _previous_playerboard_state = local_player_character.playerboard.duplicate() + +func _pulse_slot_effect(slot: Control): + """Visual feedback when a special tile is placed.""" + var tween = create_tween() + + # Reset scale first to be safe + slot.scale = Vector2.ONE + slot.pivot_offset = slot.size / 2 # Center pivot + + # Pop effect + tween.tween_property(slot, "scale", Vector2(1.4, 1.4), 0.15).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + tween.tween_property(slot, "scale", Vector2(1.0, 1.0), 0.2).set_trans(Tween.TRANS_BOUNCE).set_ease(Tween.EASE_OUT) + + # Flash effect + var original_modulate = slot.modulate + slot.modulate = Color(1.5, 1.5, 1.5) # Overbright + tween.parallel().tween_property(slot, "modulate", original_modulate, 0.3) func update_button_states(): if not local_player_character or local_player_character.is_in_group("Bots"): diff --git a/scripts/ui/virtual_joystick.gd b/scripts/ui/virtual_joystick.gd new file mode 100644 index 0000000..ee7d497 --- /dev/null +++ b/scripts/ui/virtual_joystick.gd @@ -0,0 +1,151 @@ +extends Control + +# VirtualJoystick - Touch joystick for mobile movement control +# Provides 8-directional movement input + +signal direction_changed(direction: Vector2i) +signal joystick_released + +@export var dead_zone: float = 0.2 +@export var clamp_zone: float = 0.8 +@export var joystick_radius: float = 60.0 +@export var knob_radius: float = 25.0 +@export var repeat_delay: float = 0.3 # Initial delay before repeat +@export var repeat_rate: float = 0.15 # Repeat rate for continuous movement + +var base_color: Color = Color(1, 1, 1, 0.4) +var knob_color: Color = Color(1, 1, 1, 0.7) +var pressed_color: Color = Color(0.4, 0.9, 0.4, 0.8) + +var is_pressed: bool = false +var touch_index: int = -1 +var center_position: Vector2 +var current_direction: Vector2 = Vector2.ZERO +var last_grid_direction: Vector2i = Vector2i.ZERO + +var _repeat_timer: float = 0.0 +var _initial_repeat: bool = true + +func _ready(): + # Set minimum size for touch target + custom_minimum_size = Vector2(joystick_radius * 2 + 40, joystick_radius * 2 + 40) + center_position = size / 2 + + # Enable touch input + mouse_filter = Control.MOUSE_FILTER_STOP + set_process(true) + +func _draw(): + # Draw base circle + var base_circle_color = pressed_color if is_pressed else base_color + draw_circle(center_position, joystick_radius, base_circle_color) + draw_arc(center_position, joystick_radius, 0, TAU, 64, Color.WHITE, 2.0) + + # Draw knob + var knob_pos = center_position + current_direction * joystick_radius * clamp_zone + var knob_circle_color = pressed_color if is_pressed else knob_color + draw_circle(knob_pos, knob_radius, knob_circle_color) + draw_arc(knob_pos, knob_radius, 0, TAU, 32, Color.WHITE, 1.5) + + # Draw direction indicators (8 directions) + for i in range(8): + var angle = i * TAU / 8 + var line_start = center_position + Vector2.from_angle(angle) * (joystick_radius * 0.6) + var line_end = center_position + Vector2.from_angle(angle) * (joystick_radius * 0.9) + draw_line(line_start, line_end, Color(1, 1, 1, 0.3), 2.0) + +func _process(delta: float): + # Handle continuous movement while holding joystick + if is_pressed and last_grid_direction != Vector2i.ZERO: + _repeat_timer -= delta + if _repeat_timer <= 0: + emit_signal("direction_changed", last_grid_direction) + _repeat_timer = repeat_rate + _initial_repeat = false + +func _gui_input(event: InputEvent): + if event is InputEventScreenTouch: + if event.pressed: + _start_touch(event.index, event.position) + elif event.index == touch_index: + _end_touch() + + elif event is InputEventScreenDrag: + if event.index == touch_index: + _update_touch(event.position) + + # Mouse support for testing + elif event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_LEFT: + if event.pressed: + _start_touch(0, event.position) + else: + _end_touch() + + elif event is InputEventMouseMotion: + if is_pressed and touch_index == 0: + _update_touch(event.position) + +func _start_touch(index: int, pos: Vector2): + is_pressed = true + touch_index = index + _repeat_timer = repeat_delay # Use longer initial delay + _initial_repeat = true + _update_touch(pos) + queue_redraw() + +func _end_touch(): + is_pressed = false + touch_index = -1 + current_direction = Vector2.ZERO + last_grid_direction = Vector2i.ZERO + _repeat_timer = 0.0 + _initial_repeat = true + emit_signal("joystick_released") + queue_redraw() + +func _update_touch(pos: Vector2): + var diff = pos - center_position + var distance = diff.length() + + if distance > 0: + current_direction = diff.normalized() * clampf(distance / joystick_radius, 0, clamp_zone) + else: + current_direction = Vector2.ZERO + + # Convert to 8-directional grid movement + var grid_dir = _get_grid_direction(current_direction) + if grid_dir != last_grid_direction: + last_grid_direction = grid_dir + if grid_dir != Vector2i.ZERO: + emit_signal("direction_changed", grid_dir) + + queue_redraw() + +func _get_grid_direction(dir: Vector2) -> Vector2i: + if dir.length() < dead_zone: + return Vector2i.ZERO + + # Determine 8-directional output + var angle = dir.angle() + + # Divide circle into 8 sectors (each 45 degrees) + var sector = int(round(angle / (TAU / 8))) % 8 + if sector < 0: + sector += 8 + + match sector: + 0: return Vector2i(1, 0) # East + 1: return Vector2i(1, 1) # Southeast + 2: return Vector2i(0, 1) # South + 3: return Vector2i(-1, 1) # Southwest + 4: return Vector2i(-1, 0) # West + 5: return Vector2i(-1, -1) # Northwest + 6: return Vector2i(0, -1) # North + 7: return Vector2i(1, -1) # Northeast + + return Vector2i.ZERO + +func get_direction() -> Vector2i: + """Get the current grid direction for polling.""" + return last_grid_direction diff --git a/scripts/ui/virtual_joystick.gd.uid b/scripts/ui/virtual_joystick.gd.uid new file mode 100644 index 0000000..4b837d3 --- /dev/null +++ b/scripts/ui/virtual_joystick.gd.uid @@ -0,0 +1 @@ +uid://djiml4sh61dc1