t_p2_buy: add buy menu and economy system
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.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
## 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
|
||||
Reference in New Issue
Block a user