824 lines
26 KiB
Markdown
824 lines
26 KiB
Markdown
# 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
|