## BuyMenu — Client-side buy menu UI for weapon and equipment purchases. ## ## Displays a grid of purchasable weapons with name, cost, and key stats. ## Highlights weapons the player can afford, shows current money at the top, ## and supports both keyboard (numeric keys) and mouse-click selection. ## ## Opens during the PREP (buy) phase when the player is inside a buy zone. ## Automatically closes when the buy phase ends or the player leaves the zone. ## ## Usage (add as child of the player's UI root or HUD): ## var buy_menu = preload("res://client/characters/weapon/buy_menu.gd").new() ## add_child(buy_menu) ## ## # Show it ## buy_menu.open() ## buy_menu.populate(affordable_weapons_list, current_money) ## ## Signals: ## buy_menu_opened() ## buy_menu_closed() ## purchase_completed(weapon_id) ## purchase_failed(reason: String) ## class_name BuyMenu extends Control # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted when the buy menu opens. signal buy_menu_opened() ## Emitted when the buy menu closes. signal buy_menu_closed() ## Emitted when a purchase is confirmed by the server. signal purchase_completed(weapon_id: String) ## Emitted when a purchase is denied by the server. signal purchase_failed(reason: String) # --------------------------------------------------------------------------- # Exports — Layout # --------------------------------------------------------------------------- ## Background panel colour. @export var panel_color: Color = Color(0.1, 0.1, 0.15, 0.92) ## Accent colour for headers and affordable items. @export var accent_color: Color = Color(0.2, 0.7, 0.3, 1.0) ## Colour for items the player cannot afford. @export var unaffordable_color: Color = Color(0.5, 0.5, 0.5, 1.0) ## Text colour. @export var text_color: Color = Color(0.95, 0.95, 0.95, 1.0) ## Key binding for opening the buy menu (default: B). @export var open_key: Key = KEY_B # --------------------------------------------------------------------------- # Internal state # --------------------------------------------------------------------------- ## Reference to the BuyMenuHandler on the server (for sending RPCs). ## Injected by the UI manager or found at runtime. var buy_menu_handler_path: NodePath = NodePath() var _is_open: bool = false var _weapon_buttons: Array[Button] = [] var _populated: bool = false # --------------------------------------------------------------------------- # UI Nodes (built in _build_ui) # --------------------------------------------------------------------------- var _panel: Panel = null var _money_label: Label = null var _title_label: Label = null var _weapon_container: Container = null var _close_button: Button = null var _status_label: Label = null # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: _build_ui() visible = false # Listen for server purchase confirmations / denials # The BuyMenuHandler sends RPCs that land on this node. # We connect to them via the multiplayer API — they're named to match # the RPC names defined in BuyMenuHandler. # Connect input for toggle key set_process_input(true) func _input(event: InputEvent) -> void: if event.is_action_pressed("buy_menu") or ( event is InputEventKey and event.keycode == open_key and event.pressed and not event.echo ): if _is_open: close() else: open() accept_event() # --------------------------------------------------------------------------- # UI Construction # --------------------------------------------------------------------------- func _build_ui() -> void: # Main panel — centred, sized to fit content _panel = Panel.new() _panel.anchor_left = 0.15 _panel.anchor_right = 0.85 _panel.anchor_top = 0.1 _panel.anchor_bottom = 0.9 _panel.mouse_filter = Control.MOUSE_FILTER_PASS add_child(_panel) # Panel background style var style := StyleBoxFlat.new() style.bg_color = panel_color style.corner_radius_top_left = 8 style.corner_radius_top_right = 8 style.corner_radius_bottom_left = 8 style.corner_radius_bottom_right = 8 _panel.add_theme_stylebox_override("panel", style) # Vertical layout inside the panel var vbox := VBoxContainer.new() vbox.anchor_left = 0.0 vbox.anchor_right = 1.0 vbox.anchor_top = 0.0 vbox.anchor_bottom = 1.0 vbox.add_theme_constant_override("separation", 12) _panel.add_child(vbox) # ── Title ── _title_label = Label.new() _title_label.text = "BUY MENU" _title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _title_label.add_theme_font_size_override("font_size", 24) _title_label.add_theme_color_override("font_color", accent_color) vbox.add_child(_title_label) # ── Money display ── _money_label = Label.new() _money_label.text = "$0" _money_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _money_label.add_theme_font_size_override("font_size", 20) _money_label.add_theme_color_override("font_color", text_color) vbox.add_child(_money_label) # ── Weapon grid ── _weapon_container = GridContainer.new() _weapon_container.columns = 2 _weapon_container.add_theme_constant_override("h_separation", 16) _weapon_container.add_theme_constant_override("v_separation", 8) _weapon_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL _weapon_container.size_flags_vertical = Control.SIZE_EXPAND_FILL vbox.add_child(_weapon_container) # ── Status / feedback line ── _status_label = Label.new() _status_label.text = "" _status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _status_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2, 1.0)) _status_label.add_theme_font_size_override("font_size", 14) vbox.add_child(_status_label) # ── Close hint ── var close_hint := Label.new() close_hint.text = "Press B or ESC to close | Click or press 1-4 to buy" close_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER close_hint.add_theme_font_size_override("font_size", 11) close_hint.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6, 1.0)) vbox.add_child(close_hint) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- ## Open and populate the buy menu. func open() -> void: if _is_open: return _is_open = true visible = true _populated = false _clear_weapons() _status_label.text = "" # Request affordable weapons from the server via an RPC call # (The server pushes the data back via RPC response.) _request_menu_data() buy_menu_opened.emit() ## Close the buy menu. func close() -> void: if not _is_open: return _is_open = false visible = false buy_menu_closed.emit() ## Returns true if the menu is currently open. func is_open() -> bool: return _is_open ## Update the displayed money amount. func set_money(amount: int) -> void: _money_label.text = "$%d" % amount ## Populate the weapon grid with purchasable items. ## `weapons` is an Array[Dictionary] with keys: ## weapon_id, display_name, cost, damage, fire_rate, mag_size ## `current_money` is the player's balance. func populate(weapons: Array[Dictionary], current_money: int) -> void: _clear_weapons() _populated = false set_money(current_money) var index: int = 1 for w in weapons: var affordable: bool = (current_money >= w.get("cost", 0)) _add_weapon_button(index, w, affordable) index += 1 _populated = true ## Show a temporary status message in the menu. func show_status(message: String, duration: float = 3.0) -> void: _status_label.text = message if duration > 0: get_tree().create_timer(duration).timeout.connect(func(): if is_instance_valid(_status_label) and _status_label.text == message: _status_label.text = "" ) # --------------------------------------------------------------------------- # Internal: weapon button creation # --------------------------------------------------------------------------- func _add_weapon_button(index: int, data: Dictionary, affordable: bool) -> void: var weapon_id: String = data.get("weapon_id", "") var display_name: String = data.get("display_name", weapon_id) var cost: int = data.get("cost", 0) var damage: float = data.get("damage", 0.0) var fire_rate: float = data.get("fire_rate", 0.0) var mag_size: int = data.get("mag_size", 0) # Build the weapon card as a styled Button var btn := Button.new() btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER btn.custom_minimum_size = Vector2(240, 80) # Text: name + cost on first line, stats on second var stats_line: String = "%.0f dmg | %.1f rps | %d rds" % [damage, fire_rate, mag_size] btn.text = "[%d] %s — $%d\n%s" % [index, display_name, cost, stats_line] btn.flat = false btn.tooltip_text = "%s — $%d\nDamage: %.0f\nFire Rate: %.1f Hz\nMagazine: %d rounds" % [ display_name, cost, damage, fire_rate, mag_size ] # Style var fg_color: Color = accent_color if affordable else unaffordable_color btn.add_theme_color_override("font_color", fg_color) btn.add_theme_color_override("font_hover_color", text_color) btn.add_theme_color_override("font_pressed_color", Color.WHITE) btn.add_theme_font_size_override("font_size", 14) if not affordable: btn.disabled = true btn.pressed.connect(_on_weapon_button_pressed.bind(weapon_id, cost, btn)) _weapon_container.add_child(btn) _weapon_buttons.append(btn) func _clear_weapons() -> void: for child in _weapon_buttons: if is_instance_valid(child): child.queue_free() _weapon_buttons.clear() _populated = false # --------------------------------------------------------------------------- # Internal: purchase flow # --------------------------------------------------------------------------- func _on_weapon_button_pressed(weapon_id: String, cost: int, _button: Button) -> void: _request_purchase(weapon_id) ## Send a purchase request RPC to the server. func _request_purchase(weapon_id: String) -> void: _status_label.text = "Purchasing %s..." % weapon_id # Find the BuyMenuHandler and call its buy_request RPC. # The handler is typically a child of GameServer. if multiplayer.is_server(): # Listen server / standalone — call directly _call_buy_handler(weapon_id) else: # Dedicated client — send RPC _call_buy_handler_rpc(weapon_id) func _call_buy_handler(_weapon_id: String) -> void: # On a listen server, the handler is local var handler: BuyMenuHandler = _find_buy_handler() if handler: handler.buy_request.rpc(_weapon_id) func _call_buy_handler_rpc(weapon_id: String) -> void: # Find the handler path and send the RPC var handler_path: NodePath = buy_menu_handler_path if handler_path.is_empty(): # Try to find it dynamically handler_path = "/root/GameServer/BuyMenuHandler" var node: Node = get_node_or_null(handler_path) if not handler_path.is_empty() else null if node and node.has_method(&"buy_request"): node.buy_request.rpc(weapon_id) else: # Fallback: call directly on the handler node path rpc_id(1, "_remote_buy_request", weapon_id) ## Fallback: send buy request to server's handler. @rpc("any_peer", "call_remote", "reliable") func _remote_buy_request(weapon_id: String) -> void: if not multiplayer.is_server(): return var handler: BuyMenuHandler = _find_buy_handler() if handler: var sender: int = multiplayer.get_remote_sender_id() handler.call_deferred("_process_buy_request", sender, weapon_id) func _find_buy_handler() -> BuyMenuHandler: # Try common locations var game_server: Node = get_node_or_null("/root/GameServer") if game_server: for child in game_server.get_children(): if child is BuyMenuHandler: return child return null # --------------------------------------------------------------------------- # RPC handlers — called by BuyMenuHandler (server → client) # --------------------------------------------------------------------------- ## Called by the server when a purchase succeeds. @rpc("authority", "call_remote", "reliable") func _on_buy_confirmed(weapon_id: String, cost: int) -> void: show_status("Purchased %s for $%d" % [weapon_id, cost], 2.0) purchase_completed.emit(weapon_id) # Close after a brief delay so the player sees the confirmation get_tree().create_timer(0.6).timeout.connect(close) ## Called by the server when a purchase is denied. @rpc("authority", "call_remote", "reliable") func _on_buy_denied(reason: String) -> void: show_status("Purchase failed: %s" % reason, 3.0) purchase_failed.emit(reason) # --------------------------------------------------------------------------- # Request affordable weapon data from the server # --------------------------------------------------------------------------- func _request_menu_data() -> void: # For now, the client populates from the weapon definitions directly, # filtering by what the server says is affordable. # In a more advanced version, the server sends the curated list. # # Since we don't know the player's money on the client, we send an # RPC to the server to get the affordable list. But for simplicity, # we populate all weapons and let the server deny if unaffordable. # # The server pushes the affordable list back via get_affordable_weapons(). # For now, just show all weapons — the server will reject unaffordable ones. # Try to fetch from server if multiplayer.is_server(): _populate_from_local(player_id()) else: _request_affordable_weapons.rpc_id(1) ## Get the player's peer ID. func player_id() -> int: return multiplayer.get_unique_id() ## Client → Server: request affordable weapon list. @rpc("any_peer", "call_remote", "reliable") func _request_affordable_weapons() -> void: if not multiplayer.is_server(): return var sender: int = multiplayer.get_remote_sender_id() var handler: BuyMenuHandler = _find_buy_handler() if handler: var weapons: Array[Dictionary] = handler.get_affordable_weapons(sender) var money: int = handler.get_player_money(sender) _send_menu_data.rpc_id(sender, weapons, money) ## Server → Client: send affordable weapon list and money. @rpc("authority", "call_remote", "reliable") func _send_menu_data(weapons: Array[Dictionary], current_money: int) -> void: populate(weapons, current_money) func _populate_from_local(pid: int) -> void: var handler: BuyMenuHandler = _find_buy_handler() if handler: var weapons: Array[Dictionary] = handler.get_affordable_weapons(pid) var money: int = handler.get_player_money(pid) populate(weapons, money) # --------------------------------------------------------------------------- # Input helpers # --------------------------------------------------------------------------- func _unhandled_key_input(event: InputEventKey) -> void: if not _is_open or not _populated: return # Numeric keys 1-4 to buy var key: Key = event.keycode var index: int = -1 match key: KEY_1, KEY_KP_1: index = 0 KEY_2, KEY_KP_2: index = 1 KEY_3, KEY_KP_3: index = 2 KEY_4, KEY_KP_4: index = 3 if index >= 0 and index < _weapon_buttons.size(): var btn: Button = _weapon_buttons[index] if btn and not btn.disabled and event.pressed: btn.emit_signal("pressed") accept_event() # ESC to close if event.keycode == KEY_ESCAPE and event.pressed: close() accept_event()