6a08487c4c
Creates the full buy/economy subsystem for a tactical FPS: - EconomyManager (server/scripts/economy/economy_manager.gd): per-player money tracking with starting 00, kill/round/objective rewards, spending validation, escalating loss bonuses, and money_changed signal. - BuyMenuHandler (server/scripts/economy/buy_menu_handler.gd): server-side RPC validation of purchase requests — checks weapon validity, buy-zone membership (scans BuyZone nodes in scene tree), affordability, then deducts money and grants weapon via WeaponServer.give_weapon(). - BuyMenu (client/characters/weapon/buy_menu.gd): client-side Control UI with styled panel, money display, weapon grid (name/cost/stats), numeric key (1-4) and mouse-click purchase, server-driven affordability, and auto-close after purchase. - GameServer integration: creates EconomyManager + BuyMenuHandler in _ready(), registers players for economy on spawn, awards kill rewards via DamageProcessor.player_killed signal, and unregisters on despawn. Economy rules: win=+,250, loss=,900 (+00/streak, cap ,900), kill=+00, bomb_plant/defuse=+00. Weapon costs: pistol=00, smg=,200, rifle=,700, shotgun=,900.
446 lines
16 KiB
GDScript
446 lines
16 KiB
GDScript
## 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()
|