feat: Implement core game managers, player movement logic, and initial UI scenes.

This commit is contained in:
2025-12-27 05:45:57 +08:00
parent 6870016ba6
commit c5e9d073fa
23 changed files with 1456 additions and 97 deletions
+11 -3
View File
@@ -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
+19
View File
@@ -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
# =============================================================================
@@ -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()
+22 -4
View File
@@ -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:
+21 -1
View File
@@ -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)
+72
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bg43ds58dip7u
+12
View File
@@ -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!")
+367
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://b54tfa0n6kogi
+30 -3
View File
@@ -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"):
+151
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://djiml4sh61dc1