first commit

This commit is contained in:
2025-11-24 23:10:55 -06:00
commit 8315fa51c9
279 changed files with 74600 additions and 0 deletions

View 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