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.
210 lines
8.4 KiB
GDScript
210 lines
8.4 KiB
GDScript
## BuyMenuHandler — Server-side buy request validation and processing.
|
|
##
|
|
## Receives buy_request RPCs from clients, validates against buy-zone,
|
|
## phase, and money constraints, then processes valid purchases.
|
|
##
|
|
## Each purchase deducts the weapon cost from the player's balance
|
|
## and calls WeaponServer.give_weapon() to grant the item.
|
|
##
|
|
## Usage (add as child of GameServer):
|
|
## var bmh = BuyMenuHandler.new()
|
|
## add_child(bmh)
|
|
## bmh.initialise(economy_manager, weapon_server, team_manager)
|
|
##
|
|
## RPC protocol:
|
|
## Client → Server: buy_request(weapon_id)
|
|
## Server → Client: _on_buy_confirmed(weapon_id, cost)
|
|
## Server → Client: _on_buy_denied(reason)
|
|
##
|
|
## Signals:
|
|
## purchase_made(player_id, weapon_id, cost)
|
|
## purchase_denied(player_id, weapon_id, reason)
|
|
##
|
|
class_name BuyMenuHandler
|
|
extends Node
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
## Emitted when a purchase is successfully processed.
|
|
signal purchase_made(player_id: int, weapon_id: String, cost: int)
|
|
|
|
## Emitted when a purchase is denied (invalid weapon, insufficient funds,
|
|
## not in buy zone, etc.).
|
|
signal purchase_denied(player_id: int, weapon_id: String, reason: String)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dependencies (injected via initialise())
|
|
# ---------------------------------------------------------------------------
|
|
var economy_manager: EconomyManager = null
|
|
var weapon_server: WeaponServer = null
|
|
var team_manager: TeamManager = null
|
|
|
|
## Cached list of BuyZone nodes found in the current scene.
|
|
var _buy_zones: Array[BuyZone] = []
|
|
|
|
## If true, the buy-zone check is skipped (for admin / dev builds).
|
|
var bypass_zone_check: bool = false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Initialise with required subsystem references.
|
|
## Must be called after the node is added to the scene tree.
|
|
func initialise(em: EconomyManager, ws: WeaponServer, tm: TeamManager) -> void:
|
|
economy_manager = em
|
|
weapon_server = ws
|
|
team_manager = tm
|
|
_refresh_buy_zones()
|
|
|
|
## Scan the scene tree for all BuyZone instances and cache them.
|
|
## Call this after every map load to pick up the new zone layout.
|
|
func _refresh_buy_zones() -> void:
|
|
_buy_zones.clear()
|
|
if not is_inside_tree():
|
|
return
|
|
var all_nodes: Array[Node] = get_tree().root.find_children("*", "BuyZone", true, false)
|
|
for node in all_nodes:
|
|
if node is BuyZone:
|
|
_buy_zones.append(node)
|
|
print("[BuyMenuHandler] Cached %d buy zone(s)" % _buy_zones.size())
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Buy-zone queries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Returns true if the player is inside any buy zone that allows their team.
|
|
func is_player_in_buyzone(player_id: int) -> bool:
|
|
if bypass_zone_check:
|
|
return true
|
|
if _buy_zones.is_empty():
|
|
_refresh_buy_zones()
|
|
if _buy_zones.is_empty():
|
|
# No buy zones means players can buy anywhere (dev fallback)
|
|
return true
|
|
|
|
var player_team: TeamData.Team = TeamData.Team.SPECTATOR
|
|
if team_manager:
|
|
player_team = team_manager.get_player_team(player_id)
|
|
|
|
for bz in _buy_zones:
|
|
if bz.is_in_buyzone(player_id) and bz.is_team_allowed(player_team):
|
|
return true
|
|
return false
|
|
|
|
## Return the list of cached BuyZone nodes.
|
|
func get_buy_zones() -> Array[BuyZone]:
|
|
return _buy_zones.duplicate()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RPC — Client → Server: Buy request
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Client calls this RPC to attempt a weapon purchase.
|
|
## The server validates the request and responds with
|
|
## _on_buy_confirmed or _on_buy_denied.
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func buy_request(weapon_id: String) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
|
|
var player_id: int = multiplayer.get_remote_sender_id()
|
|
_process_buy_request(player_id, weapon_id)
|
|
|
|
## Process a buy request (also callable directly from server-side code).
|
|
func _process_buy_request(player_id: int, weapon_id: String) -> void:
|
|
# ── 1. Validate weapon exists ──
|
|
var weapon_data: WeaponData = WeaponDefinitions.get_weapon(weapon_id)
|
|
if weapon_data == null:
|
|
purchase_denied.emit(player_id, weapon_id, "unknown_weapon")
|
|
_send_buy_denied(player_id, "Unknown weapon: \"%s\"" % weapon_id)
|
|
return
|
|
|
|
# ── 2. Validate buy-zone membership ──
|
|
if not is_player_in_buyzone(player_id):
|
|
purchase_denied.emit(player_id, weapon_id, "not_in_buy_zone")
|
|
_send_buy_denied(player_id, "You must be in a buy zone to purchase equipment")
|
|
return
|
|
|
|
# ── 3. Validate weapon is purchasable and affordable ──
|
|
var cost: int = EconomyManager.get_weapon_cost(weapon_id)
|
|
if cost < 0:
|
|
purchase_denied.emit(player_id, weapon_id, "not_purchasable")
|
|
_send_buy_denied(player_id, "This weapon cannot be purchased")
|
|
return
|
|
|
|
if economy_manager == null or not economy_manager.can_afford(player_id, cost):
|
|
purchase_denied.emit(player_id, weapon_id, "insufficient_funds")
|
|
var balance: int = economy_manager.get_money(player_id) if economy_manager else 0
|
|
_send_buy_denied(player_id, "Insufficient funds — need $%d, have $%d" % [cost, balance])
|
|
return
|
|
|
|
# ── 4. Deduct money ──
|
|
if not economy_manager.spend_money(player_id, cost):
|
|
purchase_denied.emit(player_id, weapon_id, "transaction_failed")
|
|
_send_buy_denied(player_id, "Transaction failed")
|
|
return
|
|
|
|
# ── 5. Grant the weapon ──
|
|
if weapon_server:
|
|
weapon_server.give_weapon(player_id, weapon_id)
|
|
|
|
# ── 6. Confirm purchase ──
|
|
purchase_made.emit(player_id, weapon_id, cost)
|
|
_send_buy_confirmed(player_id, weapon_id, cost)
|
|
print("[BuyMenuHandler] Player %d purchased '%s' for $%d" % [player_id, weapon_id, cost])
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RPC — Server → Client: Purchase result
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Send a purchase confirmation to the requesting client only.
|
|
func _send_buy_confirmed(player_id: int, weapon_id: String, cost: int) -> void:
|
|
rpc_id(player_id, "_on_buy_confirmed", weapon_id, cost)
|
|
|
|
## Server-to-client RPC: purchase succeeded.
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _on_buy_confirmed(weapon_id: String, cost: int) -> void:
|
|
# Handled on the client side by buy_menu.gd
|
|
pass
|
|
|
|
## Send a purchase denial to the requesting client only.
|
|
func _send_buy_denied(player_id: int, reason: String) -> void:
|
|
rpc_id(player_id, "_on_buy_denied", reason)
|
|
|
|
## Server-to-client RPC: purchase denied with a human-readable reason.
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _on_buy_denied(reason: String) -> void:
|
|
# Handled on the client side by buy_menu.gd
|
|
pass
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API — Query helpers (for UI / RCON)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Return a list of weapon info dictionaries the player can afford.
|
|
## Each entry: {weapon_id, display_name, cost, damage, fire_rate, mag_size}
|
|
func get_affordable_weapons(player_id: int) -> Array[Dictionary]:
|
|
var affordable: Array[Dictionary] = []
|
|
var money: int = economy_manager.get_money(player_id) if economy_manager else 0
|
|
|
|
for wid in EconomyManager.WEAPON_COSTS.keys():
|
|
var cost: int = EconomyManager.WEAPON_COSTS[wid]
|
|
if money >= cost:
|
|
var data: WeaponData = WeaponDefinitions.get_weapon(wid)
|
|
affordable.append({
|
|
"weapon_id": wid,
|
|
"display_name": data.display_name if data else wid,
|
|
"cost": cost,
|
|
"damage": data.damage if data else 0.0,
|
|
"fire_rate": data.fire_rate if data else 0.0,
|
|
"mag_size": data.mag_size if data else 0,
|
|
})
|
|
|
|
return affordable
|
|
|
|
## Return the player's current money balance.
|
|
func get_player_money(player_id: int) -> int:
|
|
return economy_manager.get_money(player_id) if economy_manager else 0
|