first commit
This commit is contained in:
823
godot_client/docs/MULTIPLAYER.md
Normal file
823
godot_client/docs/MULTIPLAYER.md
Normal file
@@ -0,0 +1,823 @@
|
||||
# Multiplayer System - Godot Client
|
||||
|
||||
**Status:** Planned
|
||||
**Phase:** 6 (Multiplayer Sessions)
|
||||
**Last Updated:** November 18, 2025
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Godot Client provides a rich, native multiplayer experience with realtime synchronization, interactive combat UI, and smooth animations for multiplayer sessions.
|
||||
|
||||
**Client Responsibilities:**
|
||||
- Render multiplayer session creation UI
|
||||
- Display lobby with player list and ready status
|
||||
- Show active session UI (timer, party status, combat animations)
|
||||
- Handle realtime updates via WebSocket (Appwrite Realtime or custom)
|
||||
- Submit player actions to API backend via HTTP
|
||||
- Display session completion and rewards with animations
|
||||
|
||||
**Technical Stack:**
|
||||
- **Engine**: Godot 4.5
|
||||
- **Networking**: HTTP requests to API + WebSocket for realtime
|
||||
- **UI**: Godot Control nodes (responsive layouts)
|
||||
- **Animations**: AnimationPlayer, Tweens
|
||||
|
||||
---
|
||||
|
||||
## Scene Structure
|
||||
|
||||
### Multiplayer Scenes Hierarchy
|
||||
|
||||
```
|
||||
scenes/multiplayer/
|
||||
├── MultiplayerMenu.tscn # Entry point (create/join session)
|
||||
├── SessionCreate.tscn # Session creation form
|
||||
├── Lobby.tscn # Lobby screen with party list
|
||||
├── ActiveSession.tscn # Main session scene
|
||||
│ ├── Timer.tscn # Session timer component
|
||||
│ ├── PartyStatus.tscn # Party member HP/status display
|
||||
│ ├── NarrativePanel.tscn # Narrative/conversation display
|
||||
│ └── CombatUI.tscn # Combat interface
|
||||
│ ├── TurnOrder.tscn # Turn order display
|
||||
│ ├── ActionButtons.tscn # Combat action buttons
|
||||
│ └── TargetSelector.tscn # Enemy target selection
|
||||
└── SessionComplete.tscn # Completion/rewards screen
|
||||
```
|
||||
|
||||
### Component Scenes
|
||||
|
||||
```
|
||||
scenes/components/multiplayer/
|
||||
├── PartyMemberCard.tscn # Single party member display
|
||||
├── InviteLinkCopy.tscn # Invite link copy widget
|
||||
├── ReadyToggle.tscn # Ready status toggle button
|
||||
├── CombatantCard.tscn # Combat turn order entry
|
||||
└── RewardDisplay.tscn # Reward summary component
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GDScript Implementation
|
||||
|
||||
### Multiplayer Manager (Singleton)
|
||||
|
||||
**Script:** `scripts/singletons/MultiplayerManager.gd`
|
||||
|
||||
```gdscript
|
||||
extends Node
|
||||
|
||||
# Signals for multiplayer events
|
||||
signal session_created(session_id: String)
|
||||
signal player_joined(member: Dictionary)
|
||||
signal player_ready_changed(user_id: String, is_ready: bool)
|
||||
signal session_started()
|
||||
signal combat_started(encounter: Dictionary)
|
||||
signal turn_changed(current_turn: int)
|
||||
signal session_completed(rewards: Dictionary)
|
||||
signal session_expired()
|
||||
|
||||
# Current session data
|
||||
var current_session: Dictionary = {}
|
||||
var is_host: bool = false
|
||||
var my_character_id: String = ""
|
||||
|
||||
# API configuration
|
||||
var api_base_url: String = "http://localhost:5000"
|
||||
|
||||
# WebSocket for realtime updates
|
||||
var ws_client: WebSocketPeer
|
||||
var is_connected: bool = false
|
||||
|
||||
func _ready():
|
||||
# Initialize WebSocket
|
||||
ws_client = WebSocketPeer.new()
|
||||
|
||||
func create_session(max_players: int, difficulty: String) -> void:
|
||||
"""Create a new multiplayer session."""
|
||||
|
||||
var body = {
|
||||
"max_players": max_players,
|
||||
"difficulty": difficulty,
|
||||
"tier_required": "premium"
|
||||
}
|
||||
|
||||
var response = await APIClient.post("/api/v1/sessions/multiplayer/create", body)
|
||||
|
||||
if response.status == 200:
|
||||
current_session = response.result
|
||||
is_host = true
|
||||
emit_signal("session_created", current_session.session_id)
|
||||
|
||||
# Connect to realtime updates
|
||||
connect_realtime(current_session.session_id)
|
||||
else:
|
||||
push_error("Failed to create session: " + str(response.error))
|
||||
|
||||
func join_session(invite_code: String, character_id: String) -> void:
|
||||
"""Join a multiplayer session via invite code."""
|
||||
|
||||
# First, get session info
|
||||
var info_response = await APIClient.get("/api/v1/sessions/multiplayer/join/" + invite_code)
|
||||
|
||||
if info_response.status != 200:
|
||||
push_error("Invalid invite code")
|
||||
return
|
||||
|
||||
# Join with selected character
|
||||
var join_body = {"character_id": character_id}
|
||||
var join_response = await APIClient.post("/api/v1/sessions/multiplayer/join/" + invite_code, join_body)
|
||||
|
||||
if join_response.status == 200:
|
||||
current_session = join_response.result
|
||||
my_character_id = character_id
|
||||
is_host = false
|
||||
|
||||
# Connect to realtime updates
|
||||
connect_realtime(current_session.session_id)
|
||||
|
||||
emit_signal("player_joined", {"character_id": character_id})
|
||||
else:
|
||||
push_error("Failed to join session: " + str(join_response.error))
|
||||
|
||||
func connect_realtime(session_id: String) -> void:
|
||||
"""Connect to Appwrite Realtime for session updates."""
|
||||
|
||||
# Use Appwrite SDK or custom WebSocket
|
||||
# For Appwrite Realtime, use their JavaScript SDK bridged via JavaScriptBridge
|
||||
# Or implement custom WebSocket to backend realtime endpoint
|
||||
|
||||
var ws_url = "wss://cloud.appwrite.io/v1/realtime?project=" + Config.appwrite_project_id
|
||||
var channels = ["databases." + Config.appwrite_database_id + ".collections.multiplayer_sessions.documents." + session_id]
|
||||
|
||||
# Connect WebSocket
|
||||
var err = ws_client.connect_to_url(ws_url)
|
||||
if err != OK:
|
||||
push_error("Failed to connect WebSocket: " + str(err))
|
||||
return
|
||||
|
||||
is_connected = true
|
||||
|
||||
func _process(_delta):
|
||||
"""Process WebSocket messages."""
|
||||
|
||||
if not is_connected:
|
||||
return
|
||||
|
||||
ws_client.poll()
|
||||
|
||||
var state = ws_client.get_ready_state()
|
||||
if state == WebSocketPeer.STATE_OPEN:
|
||||
while ws_client.get_available_packet_count():
|
||||
var packet = ws_client.get_packet()
|
||||
var message = packet.get_string_from_utf8()
|
||||
handle_realtime_message(JSON.parse_string(message))
|
||||
|
||||
elif state == WebSocketPeer.STATE_CLOSED:
|
||||
is_connected = false
|
||||
push_warning("WebSocket disconnected")
|
||||
|
||||
func handle_realtime_message(data: Dictionary) -> void:
|
||||
"""Handle realtime event from backend."""
|
||||
|
||||
var event_type = data.get("events", [])[0] if data.has("events") else ""
|
||||
var payload = data.get("payload", {})
|
||||
|
||||
match event_type:
|
||||
"databases.*.collections.*.documents.*.update":
|
||||
# Session updated
|
||||
current_session = payload
|
||||
|
||||
# Check for status changes
|
||||
if payload.status == "active":
|
||||
emit_signal("session_started")
|
||||
elif payload.status == "completed":
|
||||
emit_signal("session_completed", payload.campaign.rewards)
|
||||
elif payload.status == "expired":
|
||||
emit_signal("session_expired")
|
||||
|
||||
# Check for combat updates
|
||||
if payload.has("combat_encounter") and payload.combat_encounter != null:
|
||||
emit_signal("combat_started", payload.combat_encounter)
|
||||
emit_signal("turn_changed", payload.combat_encounter.current_turn_index)
|
||||
|
||||
func toggle_ready() -> void:
|
||||
"""Toggle ready status in lobby."""
|
||||
|
||||
var response = await APIClient.post("/api/v1/sessions/multiplayer/" + current_session.session_id + "/ready", {})
|
||||
|
||||
if response.status != 200:
|
||||
push_error("Failed to toggle ready status")
|
||||
|
||||
func start_session() -> void:
|
||||
"""Start the session (host only)."""
|
||||
|
||||
if not is_host:
|
||||
push_error("Only host can start session")
|
||||
return
|
||||
|
||||
var response = await APIClient.post("/api/v1/sessions/multiplayer/" + current_session.session_id + "/start", {})
|
||||
|
||||
if response.status != 200:
|
||||
push_error("Failed to start session: " + str(response.error))
|
||||
|
||||
func take_combat_action(action_type: String, ability_id: String = "", target_id: String = "") -> void:
|
||||
"""Submit combat action to backend."""
|
||||
|
||||
var body = {
|
||||
"action_type": action_type,
|
||||
"ability_id": ability_id,
|
||||
"target_id": target_id
|
||||
}
|
||||
|
||||
var response = await APIClient.post("/api/v1/sessions/multiplayer/" + current_session.session_id + "/combat/action", body)
|
||||
|
||||
if response.status != 200:
|
||||
push_error("Failed to take action: " + str(response.error))
|
||||
|
||||
func leave_session() -> void:
|
||||
"""Leave the current session."""
|
||||
|
||||
var response = await APIClient.post("/api/v1/sessions/multiplayer/" + current_session.session_id + "/leave", {})
|
||||
|
||||
# Disconnect WebSocket
|
||||
if is_connected:
|
||||
ws_client.close()
|
||||
is_connected = false
|
||||
|
||||
current_session = {}
|
||||
is_host = false
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```gdscript
|
||||
# Autoload as singleton: Project Settings > Autoload > MultiplayerManager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scene Implementation
|
||||
|
||||
### Session Create Scene
|
||||
|
||||
**Scene:** `scenes/multiplayer/SessionCreate.tscn`
|
||||
|
||||
**Script:** `scripts/multiplayer/session_create.gd`
|
||||
|
||||
```gdscript
|
||||
extends Control
|
||||
|
||||
@onready var party_size_group: ButtonGroup = $VBoxContainer/PartySize/ButtonGroup
|
||||
@onready var difficulty_group: ButtonGroup = $VBoxContainer/Difficulty/ButtonGroup
|
||||
@onready var create_button: Button = $VBoxContainer/CreateButton
|
||||
|
||||
func _ready():
|
||||
create_button.pressed.connect(_on_create_pressed)
|
||||
|
||||
func _on_create_pressed():
|
||||
var max_players = int(party_size_group.get_pressed_button().text.split(" ")[0])
|
||||
var difficulty = difficulty_group.get_pressed_button().text.to_lower()
|
||||
|
||||
# Disable button to prevent double-click
|
||||
create_button.disabled = true
|
||||
|
||||
# Create session via MultiplayerManager
|
||||
await MultiplayerManager.create_session(max_players, difficulty)
|
||||
|
||||
# Navigate to lobby
|
||||
get_tree().change_scene_to_file("res://scenes/multiplayer/Lobby.tscn")
|
||||
```
|
||||
|
||||
**UI Layout:**
|
||||
```
|
||||
VBoxContainer
|
||||
├── Label "Create Multiplayer Session"
|
||||
├── HBoxContainer (Party Size)
|
||||
│ ├── RadioButton "2 Players"
|
||||
│ ├── RadioButton "3 Players"
|
||||
│ └── RadioButton "4 Players"
|
||||
├── HBoxContainer (Difficulty)
|
||||
│ ├── RadioButton "Easy"
|
||||
│ ├── RadioButton "Medium"
|
||||
│ ├── RadioButton "Hard"
|
||||
│ └── RadioButton "Deadly"
|
||||
└── Button "Create Session"
|
||||
```
|
||||
|
||||
### Lobby Scene
|
||||
|
||||
**Scene:** `scenes/multiplayer/Lobby.tscn`
|
||||
|
||||
**Script:** `scripts/multiplayer/lobby.gd`
|
||||
|
||||
```gdscript
|
||||
extends Control
|
||||
|
||||
@onready var invite_code_label: Label = $VBoxContainer/InviteSection/CodeLabel
|
||||
@onready var invite_link_input: LineEdit = $VBoxContainer/InviteSection/LinkInput
|
||||
@onready var copy_button: Button = $VBoxContainer/InviteSection/CopyButton
|
||||
@onready var party_list: VBoxContainer = $VBoxContainer/PartyList
|
||||
@onready var ready_button: Button = $VBoxContainer/Actions/ReadyButton
|
||||
@onready var start_button: Button = $VBoxContainer/Actions/StartButton
|
||||
@onready var leave_button: Button = $VBoxContainer/Actions/LeaveButton
|
||||
|
||||
# Party member card scene
|
||||
var party_member_card_scene = preload("res://scenes/components/multiplayer/PartyMemberCard.tscn")
|
||||
|
||||
func _ready():
|
||||
# Connect signals
|
||||
MultiplayerManager.player_joined.connect(_on_player_joined)
|
||||
MultiplayerManager.player_ready_changed.connect(_on_player_ready_changed)
|
||||
MultiplayerManager.session_started.connect(_on_session_started)
|
||||
|
||||
# Setup UI
|
||||
var session = MultiplayerManager.current_session
|
||||
invite_code_label.text = "Session Code: " + session.invite_code
|
||||
invite_link_input.text = "https://codeofconquest.com/join/" + session.invite_code
|
||||
|
||||
copy_button.pressed.connect(_on_copy_pressed)
|
||||
ready_button.pressed.connect(_on_ready_pressed)
|
||||
start_button.pressed.connect(_on_start_pressed)
|
||||
leave_button.pressed.connect(_on_leave_pressed)
|
||||
|
||||
# Show/hide start button based on host status
|
||||
start_button.visible = MultiplayerManager.is_host
|
||||
ready_button.visible = !MultiplayerManager.is_host
|
||||
|
||||
# Populate party list
|
||||
refresh_party_list()
|
||||
|
||||
func refresh_party_list():
|
||||
"""Refresh the party member list."""
|
||||
|
||||
# Clear existing cards
|
||||
for child in party_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var session = MultiplayerManager.current_session
|
||||
var party_members = session.get("party_members", [])
|
||||
|
||||
# Add party member cards
|
||||
for member in party_members:
|
||||
var card = party_member_card_scene.instantiate()
|
||||
card.set_member_data(member)
|
||||
party_list.add_child(card)
|
||||
|
||||
# Add empty slots
|
||||
var max_players = session.get("max_players", 4)
|
||||
for i in range(max_players - party_members.size()):
|
||||
var card = party_member_card_scene.instantiate()
|
||||
card.set_empty_slot()
|
||||
party_list.add_child(card)
|
||||
|
||||
# Check if all ready
|
||||
check_all_ready()
|
||||
|
||||
func check_all_ready():
|
||||
"""Check if all players are ready and enable/disable start button."""
|
||||
|
||||
if not MultiplayerManager.is_host:
|
||||
return
|
||||
|
||||
var session = MultiplayerManager.current_session
|
||||
var party_members = session.get("party_members", [])
|
||||
|
||||
var all_ready = true
|
||||
for member in party_members:
|
||||
if not member.is_ready:
|
||||
all_ready = false
|
||||
break
|
||||
|
||||
start_button.disabled = !all_ready
|
||||
|
||||
func _on_player_joined(member: Dictionary):
|
||||
refresh_party_list()
|
||||
|
||||
func _on_player_ready_changed(user_id: String, is_ready: bool):
|
||||
refresh_party_list()
|
||||
|
||||
func _on_session_started():
|
||||
# Navigate to active session
|
||||
get_tree().change_scene_to_file("res://scenes/multiplayer/ActiveSession.tscn")
|
||||
|
||||
func _on_copy_pressed():
|
||||
DisplayServer.clipboard_set(invite_link_input.text)
|
||||
# Show toast notification
|
||||
show_toast("Invite link copied!")
|
||||
|
||||
func _on_ready_pressed():
|
||||
await MultiplayerManager.toggle_ready()
|
||||
|
||||
func _on_start_pressed():
|
||||
start_button.disabled = true
|
||||
await MultiplayerManager.start_session()
|
||||
|
||||
func _on_leave_pressed():
|
||||
await MultiplayerManager.leave_session()
|
||||
get_tree().change_scene_to_file("res://scenes/MainMenu.tscn")
|
||||
|
||||
func show_toast(message: String):
|
||||
# Implement toast notification (simple Label with animation)
|
||||
var toast = Label.new()
|
||||
toast.text = message
|
||||
toast.position = Vector2(400, 50)
|
||||
add_child(toast)
|
||||
|
||||
# Fade out animation
|
||||
var tween = create_tween()
|
||||
tween.tween_property(toast, "modulate:a", 0.0, 2.0)
|
||||
tween.tween_callback(toast.queue_free)
|
||||
```
|
||||
|
||||
### Party Member Card Component
|
||||
|
||||
**Scene:** `scenes/components/multiplayer/PartyMemberCard.tscn`
|
||||
|
||||
**Script:** `scripts/components/party_member_card.gd`
|
||||
|
||||
```gdscript
|
||||
extends PanelContainer
|
||||
|
||||
@onready var crown_icon: TextureRect = $HBoxContainer/CrownIcon
|
||||
@onready var username_label: Label = $HBoxContainer/UsernameLabel
|
||||
@onready var character_label: Label = $HBoxContainer/CharacterLabel
|
||||
@onready var ready_status: Label = $HBoxContainer/ReadyStatus
|
||||
|
||||
func set_member_data(member: Dictionary):
|
||||
"""Populate card with member data."""
|
||||
|
||||
crown_icon.visible = member.is_host
|
||||
username_label.text = member.username + (" (Host)" if member.is_host else "")
|
||||
character_label.text = member.character_snapshot.name + " - " + member.character_snapshot.player_class.name + " Lvl " + str(member.character_snapshot.level)
|
||||
|
||||
if member.is_ready:
|
||||
ready_status.text = "✅ Ready"
|
||||
ready_status.modulate = Color.GREEN
|
||||
else:
|
||||
ready_status.text = "⏳ Not Ready"
|
||||
ready_status.modulate = Color.YELLOW
|
||||
|
||||
func set_empty_slot():
|
||||
"""Display as empty slot."""
|
||||
|
||||
crown_icon.visible = false
|
||||
username_label.text = "[Waiting for player...]"
|
||||
character_label.text = ""
|
||||
ready_status.text = ""
|
||||
```
|
||||
|
||||
### Active Session Scene
|
||||
|
||||
**Scene:** `scenes/multiplayer/ActiveSession.tscn`
|
||||
|
||||
**Script:** `scripts/multiplayer/active_session.gd`
|
||||
|
||||
```gdscript
|
||||
extends Control
|
||||
|
||||
@onready var campaign_title: Label = $Header/TitleLabel
|
||||
@onready var timer_label: Label = $Header/TimerLabel
|
||||
@onready var progress_bar: ProgressBar = $ProgressSection/ProgressBar
|
||||
@onready var party_status: HBoxContainer = $PartyStatus
|
||||
@onready var narrative_panel: RichTextLabel = $NarrativePanel/TextLabel
|
||||
@onready var combat_ui: Control = $CombatUI
|
||||
|
||||
var time_remaining: int = 7200 # 2 hours in seconds
|
||||
|
||||
func _ready():
|
||||
# Connect signals
|
||||
MultiplayerManager.combat_started.connect(_on_combat_started)
|
||||
MultiplayerManager.turn_changed.connect(_on_turn_changed)
|
||||
MultiplayerManager.session_completed.connect(_on_session_completed)
|
||||
MultiplayerManager.session_expired.connect(_on_session_expired)
|
||||
|
||||
# Setup UI
|
||||
var session = MultiplayerManager.current_session
|
||||
campaign_title.text = session.campaign.title
|
||||
time_remaining = session.time_remaining_seconds
|
||||
|
||||
update_progress_bar()
|
||||
update_party_status()
|
||||
update_narrative()
|
||||
|
||||
# Start timer countdown
|
||||
var timer = Timer.new()
|
||||
timer.wait_time = 1.0
|
||||
timer.timeout.connect(_on_timer_tick)
|
||||
add_child(timer)
|
||||
timer.start()
|
||||
|
||||
func _on_timer_tick():
|
||||
"""Update timer every second."""
|
||||
|
||||
time_remaining -= 1
|
||||
|
||||
if time_remaining <= 0:
|
||||
timer_label.text = "⏱️ Time's Up!"
|
||||
return
|
||||
|
||||
var hours = time_remaining / 3600
|
||||
var minutes = (time_remaining % 3600) / 60
|
||||
var seconds = time_remaining % 60
|
||||
|
||||
timer_label.text = "⏱️ %d:%02d:%02d Remaining" % [hours, minutes, seconds]
|
||||
|
||||
# Warnings
|
||||
if time_remaining == 600:
|
||||
show_warning("⚠️ 10 minutes remaining!")
|
||||
elif time_remaining == 300:
|
||||
show_warning("⚠️ 5 minutes remaining!")
|
||||
elif time_remaining == 60:
|
||||
show_warning("🚨 1 minute remaining!")
|
||||
|
||||
func update_progress_bar():
|
||||
var session = MultiplayerManager.current_session
|
||||
var total_encounters = session.campaign.encounters.size()
|
||||
var current = session.current_encounter_index
|
||||
|
||||
progress_bar.max_value = total_encounters
|
||||
progress_bar.value = current
|
||||
|
||||
func update_party_status():
|
||||
# Clear existing party status
|
||||
for child in party_status.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var session = MultiplayerManager.current_session
|
||||
for member in session.party_members:
|
||||
var status_label = Label.new()
|
||||
var char = member.character_snapshot
|
||||
status_label.text = "%s (HP: %d/%d)" % [char.name, char.current_hp, char.max_hp]
|
||||
|
||||
if not member.is_connected:
|
||||
status_label.text += " ⚠️ Disconnected"
|
||||
|
||||
party_status.add_child(status_label)
|
||||
|
||||
func update_narrative():
|
||||
var session = MultiplayerManager.current_session
|
||||
narrative_panel.clear()
|
||||
|
||||
for entry in session.conversation_history:
|
||||
narrative_panel.append_text("[b]%s:[/b] %s\n\n" % [entry.role.capitalize(), entry.content])
|
||||
|
||||
func _on_combat_started(encounter: Dictionary):
|
||||
combat_ui.visible = true
|
||||
combat_ui.setup_combat(encounter)
|
||||
|
||||
func _on_turn_changed(current_turn: int):
|
||||
combat_ui.update_turn_order(current_turn)
|
||||
|
||||
func _on_session_completed(rewards: Dictionary):
|
||||
get_tree().change_scene_to_file("res://scenes/multiplayer/SessionComplete.tscn")
|
||||
|
||||
func _on_session_expired():
|
||||
show_warning("Session time limit reached. Redirecting...")
|
||||
await get_tree().create_timer(3.0).timeout
|
||||
get_tree().change_scene_to_file("res://scenes/multiplayer/SessionComplete.tscn")
|
||||
|
||||
func show_warning(message: String):
|
||||
# Display warning notification (AcceptDialog or custom popup)
|
||||
var dialog = AcceptDialog.new()
|
||||
dialog.dialog_text = message
|
||||
add_child(dialog)
|
||||
dialog.popup_centered()
|
||||
```
|
||||
|
||||
### Combat UI Component
|
||||
|
||||
**Scene:** `scenes/multiplayer/CombatUI.tscn`
|
||||
|
||||
**Script:** `scripts/multiplayer/combat_ui.gd`
|
||||
|
||||
```gdscript
|
||||
extends Control
|
||||
|
||||
@onready var turn_order_list: VBoxContainer = $TurnOrder/List
|
||||
@onready var action_buttons: HBoxContainer = $Actions/Buttons
|
||||
@onready var attack_button: Button = $Actions/Buttons/AttackButton
|
||||
@onready var ability_dropdown: OptionButton = $Actions/Buttons/AbilityDropdown
|
||||
@onready var defend_button: Button = $Actions/Buttons/DefendButton
|
||||
|
||||
var is_my_turn: bool = false
|
||||
var current_encounter: Dictionary = {}
|
||||
|
||||
func setup_combat(encounter: Dictionary):
|
||||
"""Setup combat UI for new encounter."""
|
||||
|
||||
current_encounter = encounter
|
||||
populate_turn_order(encounter)
|
||||
setup_action_buttons()
|
||||
|
||||
func populate_turn_order(encounter: Dictionary):
|
||||
"""Display turn order list."""
|
||||
|
||||
# Clear existing
|
||||
for child in turn_order_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var turn_order = encounter.turn_order
|
||||
var current_turn = encounter.current_turn_index
|
||||
|
||||
for i in range(turn_order.size()):
|
||||
var combatant_id = turn_order[i]
|
||||
var label = Label.new()
|
||||
label.text = get_combatant_name(combatant_id)
|
||||
|
||||
# Highlight current turn
|
||||
if i == current_turn:
|
||||
label.modulate = Color.YELLOW
|
||||
label.text = "▶ " + label.text
|
||||
|
||||
turn_order_list.add_child(label)
|
||||
|
||||
# Check if it's my turn
|
||||
check_if_my_turn(current_turn, turn_order)
|
||||
|
||||
func update_turn_order(current_turn: int):
|
||||
"""Update turn order highlighting."""
|
||||
|
||||
var labels = turn_order_list.get_children()
|
||||
for i in range(labels.size()):
|
||||
var label = labels[i]
|
||||
if i == current_turn:
|
||||
label.modulate = Color.YELLOW
|
||||
label.text = "▶ " + get_combatant_name(current_encounter.turn_order[i])
|
||||
else:
|
||||
label.modulate = Color.WHITE
|
||||
label.text = get_combatant_name(current_encounter.turn_order[i])
|
||||
|
||||
# Check if it's my turn
|
||||
check_if_my_turn(current_turn, current_encounter.turn_order)
|
||||
|
||||
func check_if_my_turn(current_turn: int, turn_order: Array):
|
||||
"""Check if current combatant is controlled by this player."""
|
||||
|
||||
var current_combatant_id = turn_order[current_turn]
|
||||
|
||||
# Check if this combatant belongs to me
|
||||
is_my_turn = is_my_combatant(current_combatant_id)
|
||||
|
||||
# Enable/disable action buttons
|
||||
action_buttons.visible = is_my_turn
|
||||
|
||||
func is_my_combatant(combatant_id: String) -> bool:
|
||||
"""Check if combatant belongs to current player."""
|
||||
|
||||
# Logic to determine if this combatant_id matches my character
|
||||
return combatant_id == MultiplayerManager.my_character_id
|
||||
|
||||
func setup_action_buttons():
|
||||
"""Setup combat action button handlers."""
|
||||
|
||||
attack_button.pressed.connect(_on_attack_pressed)
|
||||
defend_button.pressed.connect(_on_defend_pressed)
|
||||
|
||||
# Populate ability dropdown
|
||||
# (Load character abilities from MultiplayerManager.current_session)
|
||||
|
||||
func _on_attack_pressed():
|
||||
# Show target selection
|
||||
show_target_selection("attack")
|
||||
|
||||
func _on_defend_pressed():
|
||||
await MultiplayerManager.take_combat_action("defend")
|
||||
|
||||
func show_target_selection(action_type: String):
|
||||
"""Show enemy target selection UI."""
|
||||
|
||||
# Display list of enemies, allow player to select target
|
||||
# For simplicity, just take first enemy
|
||||
var enemies = get_enemies_from_encounter()
|
||||
if enemies.size() > 0:
|
||||
await MultiplayerManager.take_combat_action(action_type, "", enemies[0].combatant_id)
|
||||
|
||||
func get_combatant_name(combatant_id: String) -> String:
|
||||
# Lookup combatant name from encounter data
|
||||
return combatant_id # Placeholder
|
||||
|
||||
func get_enemies_from_encounter() -> Array:
|
||||
# Extract enemy combatants from encounter
|
||||
return [] # Placeholder
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Integration
|
||||
|
||||
### Appwrite Realtime via GDScript
|
||||
|
||||
Godot doesn't natively support Appwrite SDK, so we'll use WebSocketPeer:
|
||||
|
||||
**Custom WebSocket Client:**
|
||||
|
||||
```gdscript
|
||||
# In MultiplayerManager.gd
|
||||
|
||||
func connect_realtime(session_id: String) -> void:
|
||||
var project_id = Config.appwrite_project_id
|
||||
var db_id = Config.appwrite_database_id
|
||||
|
||||
# Construct Appwrite Realtime WebSocket URL
|
||||
var ws_url = "wss://cloud.appwrite.io/v1/realtime?project=" + project_id
|
||||
|
||||
ws_client = WebSocketPeer.new()
|
||||
var err = ws_client.connect_to_url(ws_url)
|
||||
|
||||
if err != OK:
|
||||
push_error("WebSocket connection failed: " + str(err))
|
||||
return
|
||||
|
||||
is_connected = true
|
||||
|
||||
# After connection, subscribe to channels
|
||||
await get_tree().create_timer(1.0).timeout # Wait for connection
|
||||
subscribe_to_channels(session_id)
|
||||
|
||||
func subscribe_to_channels(session_id: String):
|
||||
"""Send subscribe message to Appwrite Realtime."""
|
||||
|
||||
var subscribe_message = {
|
||||
"type": "subscribe",
|
||||
"channels": [
|
||||
"databases." + Config.appwrite_database_id + ".collections.multiplayer_sessions.documents." + session_id
|
||||
]
|
||||
}
|
||||
|
||||
ws_client.send_text(JSON.stringify(subscribe_message))
|
||||
```
|
||||
|
||||
**Alternative:** Use HTTP polling as fallback if WebSocket fails.
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Manual Testing Tasks (Godot Client)
|
||||
|
||||
- [ ] Session creation UI works correctly
|
||||
- [ ] Invite link copies to clipboard
|
||||
- [ ] Lobby updates when players join
|
||||
- [ ] Ready status toggle works
|
||||
- [ ] Host can start session when all ready
|
||||
- [ ] Timer displays and counts down correctly
|
||||
- [ ] Party HP updates during combat
|
||||
- [ ] Combat action buttons submit correctly
|
||||
- [ ] Turn order highlights current player
|
||||
- [ ] Realtime updates work (test with web client simultaneously)
|
||||
- [ ] Session completion screen displays rewards
|
||||
- [ ] Disconnection handling shows warnings
|
||||
- [ ] UI is responsive on different screen sizes
|
||||
- [ ] Animations play smoothly
|
||||
|
||||
---
|
||||
|
||||
## Animation Guidelines
|
||||
|
||||
### Combat Animations
|
||||
|
||||
**Attack Animation:**
|
||||
```gdscript
|
||||
func play_attack_animation(attacker_id: String, target_id: String):
|
||||
var attacker_sprite = get_combatant_sprite(attacker_id)
|
||||
var target_sprite = get_combatant_sprite(target_id)
|
||||
|
||||
var tween = create_tween()
|
||||
# Move attacker forward
|
||||
tween.tween_property(attacker_sprite, "position:x", target_sprite.position.x - 50, 0.3)
|
||||
# Flash target red (damage)
|
||||
tween.parallel().tween_property(target_sprite, "modulate", Color.RED, 0.1)
|
||||
tween.tween_property(target_sprite, "modulate", Color.WHITE, 0.1)
|
||||
# Move attacker back
|
||||
tween.tween_property(attacker_sprite, "position:x", attacker_sprite.position.x, 0.3)
|
||||
```
|
||||
|
||||
**Damage Number Popup:**
|
||||
```gdscript
|
||||
func show_damage_number(damage: int, position: Vector2):
|
||||
var label = Label.new()
|
||||
label.text = "-" + str(damage)
|
||||
label.position = position
|
||||
label.modulate = Color.RED
|
||||
add_child(label)
|
||||
|
||||
var tween = create_tween()
|
||||
tween.tween_property(label, "position:y", position.y - 50, 1.0)
|
||||
tween.parallel().tween_property(label, "modulate:a", 0.0, 1.0)
|
||||
tween.tween_callback(label.queue_free)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[/api/docs/MULTIPLAYER.md](../../api/docs/MULTIPLAYER.md)** - Backend API endpoints and business logic
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)** - Godot client architecture
|
||||
- **[GETTING_STARTED.md](GETTING_STARTED.md)** - Setup guide
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0 (Microservices Split)
|
||||
**Created:** November 18, 2025
|
||||
**Last Updated:** November 18, 2025
|
||||
Reference in New Issue
Block a user