first commit
This commit is contained in:
44
godot_client/.gitignore
vendored
Normal file
44
godot_client/.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# Godot 4+ specific ignores
|
||||
.godot/
|
||||
.import/
|
||||
export.cfg
|
||||
export_presets.cfg
|
||||
|
||||
# Godot-specific files to ignore
|
||||
*.translation
|
||||
*.import
|
||||
|
||||
# Build artifacts
|
||||
builds/
|
||||
exports/
|
||||
*.apk
|
||||
*.ipa
|
||||
*.exe
|
||||
*.dmg
|
||||
*.zip
|
||||
*.pck
|
||||
|
||||
# Logs and databases
|
||||
*.log
|
||||
*.sqlite
|
||||
|
||||
# OS-specific
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Editor-specific
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
*.cache
|
||||
558
godot_client/CLAUDE.md
Normal file
558
godot_client/CLAUDE.md
Normal file
@@ -0,0 +1,558 @@
|
||||
# CLAUDE.md - Godot Client
|
||||
|
||||
## Service Overview
|
||||
**Godot Client** for Code of Conquest - Native cross-platform game client using Godot 4.5 + GDScript.
|
||||
|
||||
**Tech Stack:** Godot 4.5 + GDScript + HTTPRequest
|
||||
**Platforms:** Desktop (Windows, macOS, Linux), Mobile (Android, iOS), Web (HTML5)
|
||||
**Location:** `/godot_client`
|
||||
|
||||
---
|
||||
|
||||
## Architecture Role
|
||||
|
||||
This Godot client is a **thin UI layer** that makes HTTP requests to the API backend:
|
||||
- ✅ Render game UI (scenes, components)
|
||||
- ✅ Handle user input
|
||||
- ✅ Make HTTP requests to API backend
|
||||
- ✅ Display API responses
|
||||
- ✅ Client-side animations and effects
|
||||
- ✅ Local state management (UI state only)
|
||||
|
||||
**What this service does NOT do:**
|
||||
- ❌ No business logic (all in `/api`)
|
||||
- ❌ No game mechanics calculations (use API)
|
||||
- ❌ No direct database access (use API)
|
||||
- ❌ No AI calls (use API)
|
||||
- ❌ No authoritative game state (API is source of truth)
|
||||
|
||||
**Communication:**
|
||||
```
|
||||
Godot Client → HTTP Request → API Backend
|
||||
↑ ↓
|
||||
←───── HTTP Response ────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation Index
|
||||
|
||||
**Godot Client Documentation:**
|
||||
- **[README.md](README.md)** - Setup and usage guide
|
||||
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** - Client architecture overview
|
||||
- **[docs/GETTING_STARTED.md](docs/GETTING_STARTED.md)** - Quickstart guide
|
||||
- **[docs/EXPORT.md](docs/EXPORT.md)** - Platform export instructions
|
||||
- **[docs/THEME_SETUP.md](docs/THEME_SETUP.md)** - UI theming guide
|
||||
- **[docs/MULTIPLAYER.md](docs/MULTIPLAYER.md)** - Multiplayer client implementation
|
||||
- **[docs/README.md](docs/README.md)** - Scene documentation workflow
|
||||
|
||||
**Project-Wide Documentation:**
|
||||
- **[../docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md)** - System architecture overview
|
||||
- **[../api/docs/API_REFERENCE.md](../api/docs/API_REFERENCE.md)** - API endpoints to call
|
||||
- **[../docs/WEB_VS_CLIENT_SYSTEMS.md](../docs/WEB_VS_CLIENT_SYSTEMS.md)** - Feature distribution between frontends
|
||||
|
||||
---
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
godot_client/
|
||||
├── project.godot # Godot project configuration
|
||||
├── scenes/ # Godot scene files (.tscn)
|
||||
│ ├── main.tscn # Entry point
|
||||
│ ├── auth/ # Authentication scenes
|
||||
│ ├── character/ # Character scenes
|
||||
│ ├── combat/ # Combat scenes
|
||||
│ └── components/ # Reusable UI components
|
||||
├── scripts/ # GDScript code
|
||||
│ ├── services/ # Singleton autoloads
|
||||
│ ├── models/ # Data models
|
||||
│ ├── components/ # Component scripts
|
||||
│ └── utils/ # Helper utilities
|
||||
└── assets/ # Game assets
|
||||
├── fonts/ # Fonts
|
||||
├── themes/ # UI themes
|
||||
└── ui/ # UI assets
|
||||
```
|
||||
|
||||
### GDScript Coding Standards
|
||||
|
||||
**Style & Structure**
|
||||
- Follow GDScript style guide (snake_case for functions/variables)
|
||||
- Use PascalCase for class names
|
||||
- Always include doc comments for classes and functions
|
||||
- Prefer explicit types over dynamic typing
|
||||
- Group related code with region comments
|
||||
- **Do NOT apply styles/themes in code** - All visual styling (StyleBoxFlat, colors, fonts, margins) should be done in the Godot editor, not programmatically in GDScript
|
||||
|
||||
**Example:**
|
||||
```gdscript
|
||||
class_name CharacterCard
|
||||
extends Control
|
||||
## A UI component that displays character information.
|
||||
##
|
||||
## This card shows the character's name, level, class, and stats.
|
||||
## It makes an HTTP request to fetch character data from the API.
|
||||
|
||||
#region Exports
|
||||
@export var character_id: String = ""
|
||||
@export var auto_load: bool = true
|
||||
#endregion
|
||||
|
||||
#region Private Variables
|
||||
var _character_data: Dictionary = {}
|
||||
var _is_loading: bool = false
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
func _ready() -> void:
|
||||
"""Initialize the character card."""
|
||||
if auto_load and character_id != "":
|
||||
load_character()
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
"""Update card UI each frame."""
|
||||
pass
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
func load_character() -> void:
|
||||
"""Load character data from API."""
|
||||
if _is_loading:
|
||||
return
|
||||
|
||||
_is_loading = true
|
||||
|
||||
var url = Settings.api_base_url + "/api/v1/characters/" + character_id
|
||||
HTTPClient.get_request(url, _on_character_loaded, _on_character_error)
|
||||
|
||||
func refresh() -> void:
|
||||
"""Refresh character data."""
|
||||
load_character()
|
||||
#endregion
|
||||
|
||||
#region Private Methods
|
||||
func _update_ui() -> void:
|
||||
"""Update UI elements with character data."""
|
||||
$Name.text = _character_data.get("name", "Unknown")
|
||||
$Level.text = "Level " + str(_character_data.get("level", 1))
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
"""Display error message to user."""
|
||||
$ErrorLabel.text = message
|
||||
$ErrorLabel.visible = true
|
||||
#endregion
|
||||
|
||||
#region Signal Handlers
|
||||
func _on_character_loaded(response: Dictionary) -> void:
|
||||
"""Handle successful character data load."""
|
||||
_is_loading = false
|
||||
|
||||
if response.has("result"):
|
||||
_character_data = response.result
|
||||
_update_ui()
|
||||
|
||||
func _on_character_error(error: String) -> void:
|
||||
"""Handle character data load error."""
|
||||
_is_loading = false
|
||||
_show_error("Failed to load character: " + error)
|
||||
|
||||
func _on_button_pressed() -> void:
|
||||
"""Handle button click."""
|
||||
refresh()
|
||||
#endregion
|
||||
```
|
||||
|
||||
**Code Organization:**
|
||||
1. Class documentation
|
||||
2. Exports (inspector variables)
|
||||
3. Signals
|
||||
4. Public constants
|
||||
5. Private variables
|
||||
6. Lifecycle methods (_ready, _process, etc.)
|
||||
7. Public methods
|
||||
8. Private methods
|
||||
9. Signal handlers
|
||||
|
||||
### Service Singletons (Autoloads)
|
||||
|
||||
**Settings Service (`scripts/services/settings.gd`):**
|
||||
```gdscript
|
||||
extends Node
|
||||
## Global settings and configuration management.
|
||||
|
||||
var api_base_url: String = "http://localhost:5000"
|
||||
var environment: String = "development"
|
||||
var version: String = "0.1.0"
|
||||
|
||||
func _ready() -> void:
|
||||
load_settings()
|
||||
|
||||
func load_settings() -> void:
|
||||
"""Load settings from file or environment."""
|
||||
# Load from settings file or use defaults
|
||||
if FileAccess.file_exists("user://settings.json"):
|
||||
var file = FileAccess.open("user://settings.json", FileAccess.READ)
|
||||
var json = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
|
||||
if json:
|
||||
api_base_url = json.get("api_base_url", api_base_url)
|
||||
environment = json.get("environment", environment)
|
||||
|
||||
func save_settings() -> void:
|
||||
"""Save settings to file."""
|
||||
var data = {
|
||||
"api_base_url": api_base_url,
|
||||
"environment": environment
|
||||
}
|
||||
|
||||
var file = FileAccess.open("user://settings.json", FileAccess.WRITE)
|
||||
file.store_string(JSON.stringify(data, "\t"))
|
||||
file.close()
|
||||
```
|
||||
|
||||
**HTTP Client Service (`scripts/services/http_client.gd`):**
|
||||
```gdscript
|
||||
extends Node
|
||||
## Centralized HTTP client for API communication.
|
||||
|
||||
signal request_completed(response: Dictionary)
|
||||
signal request_failed(error: String)
|
||||
|
||||
var _http_requests: Dictionary = {}
|
||||
|
||||
func get_request(url: String, on_success: Callable, on_error: Callable = Callable()) -> void:
|
||||
"""Make a GET request to the API."""
|
||||
_make_request(url, HTTPClient.METHOD_GET, {}, on_success, on_error)
|
||||
|
||||
func post_request(url: String, data: Dictionary, on_success: Callable, on_error: Callable = Callable()) -> void:
|
||||
"""Make a POST request to the API."""
|
||||
_make_request(url, HTTPClient.METHOD_POST, data, on_success, on_error)
|
||||
|
||||
func _make_request(url: String, method: int, data: Dictionary, on_success: Callable, on_error: Callable) -> void:
|
||||
"""Internal method to make HTTP requests."""
|
||||
var http_request = HTTPRequest.new()
|
||||
add_child(http_request)
|
||||
|
||||
# Store callbacks
|
||||
var request_id = str(http_request.get_instance_id())
|
||||
_http_requests[request_id] = {
|
||||
"on_success": on_success,
|
||||
"on_error": on_error,
|
||||
"node": http_request
|
||||
}
|
||||
|
||||
# Connect signals
|
||||
http_request.request_completed.connect(_on_request_completed.bind(request_id))
|
||||
|
||||
# Make request
|
||||
var headers = ["Content-Type: application/json"]
|
||||
var body = JSON.stringify(data) if method != HTTPClient.METHOD_GET else ""
|
||||
|
||||
var error = http_request.request(url, headers, method, body)
|
||||
|
||||
if error != OK:
|
||||
_handle_error(request_id, "Request failed: " + str(error))
|
||||
|
||||
func _on_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray, request_id: String) -> void:
|
||||
"""Handle HTTP request completion."""
|
||||
var request_data = _http_requests.get(request_id)
|
||||
if not request_data:
|
||||
return
|
||||
|
||||
# Parse response
|
||||
var json = JSON.parse_string(body.get_string_from_utf8())
|
||||
|
||||
# Check for errors
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code >= 400:
|
||||
var error_message = json.get("error", {}).get("message", "Request failed") if json else "Request failed"
|
||||
_handle_error(request_id, error_message)
|
||||
return
|
||||
|
||||
# Success
|
||||
if request_data.on_success.is_valid():
|
||||
request_data.on_success.call(json)
|
||||
|
||||
# Cleanup
|
||||
_cleanup_request(request_id)
|
||||
|
||||
func _handle_error(request_id: String, error: String) -> void:
|
||||
"""Handle request error."""
|
||||
var request_data = _http_requests.get(request_id)
|
||||
if request_data and request_data.on_error.is_valid():
|
||||
request_data.on_error.call(error)
|
||||
|
||||
_cleanup_request(request_id)
|
||||
|
||||
func _cleanup_request(request_id: String) -> void:
|
||||
"""Clean up completed request."""
|
||||
var request_data = _http_requests.get(request_id)
|
||||
if request_data:
|
||||
request_data.node.queue_free()
|
||||
_http_requests.erase(request_id)
|
||||
```
|
||||
|
||||
### Scene Organization
|
||||
|
||||
**Scene Structure:**
|
||||
```
|
||||
Main Scene (main.tscn)
|
||||
├── UI Layer (CanvasLayer)
|
||||
│ ├── MainMenu
|
||||
│ ├── LoadingScreen
|
||||
│ └── ErrorPopup
|
||||
├── Game Layer (Node2D/Node3D)
|
||||
│ ├── Character
|
||||
│ ├── World
|
||||
│ └── Combat
|
||||
└── Services (Node)
|
||||
├── AudioManager
|
||||
└── InputManager
|
||||
```
|
||||
|
||||
**Scene Best Practices:**
|
||||
- Keep scenes focused (single responsibility)
|
||||
- Use scenes as components (reusable UI elements)
|
||||
- Attach scripts to scene root nodes
|
||||
- Use groups for easy node access (`node.add_to_group("enemies")`)
|
||||
- Prefer signals over direct node references
|
||||
|
||||
### UI Theming
|
||||
|
||||
**Theme Structure:**
|
||||
```gdscript
|
||||
# Create programmatically or use theme resource
|
||||
var theme = Theme.new()
|
||||
|
||||
# Colors
|
||||
theme.set_color("font_color", "Label", Color.WHITE)
|
||||
theme.set_color("font_color", "Button", Color("#e5e7eb"))
|
||||
|
||||
# Fonts
|
||||
var title_font = load("res://assets/fonts/Cinzel-Bold.ttf")
|
||||
theme.set_font("font", "TitleLabel", title_font)
|
||||
|
||||
# Stylebox
|
||||
var button_style = StyleBoxFlat.new()
|
||||
button_style.bg_color = Color("#8b5cf6")
|
||||
button_style.corner_radius_top_left = 8
|
||||
button_style.corner_radius_top_right = 8
|
||||
button_style.corner_radius_bottom_left = 8
|
||||
button_style.corner_radius_bottom_right = 8
|
||||
theme.set_stylebox("normal", "Button", button_style)
|
||||
```
|
||||
|
||||
### API Communication Patterns
|
||||
|
||||
**Simple GET Request:**
|
||||
```gdscript
|
||||
func load_characters() -> void:
|
||||
var url = Settings.api_base_url + "/api/v1/characters"
|
||||
HTTPClient.get_request(url, _on_characters_loaded, _on_characters_error)
|
||||
|
||||
func _on_characters_loaded(response: Dictionary) -> void:
|
||||
var characters = response.get("result", [])
|
||||
# Update UI with characters
|
||||
|
||||
func _on_characters_error(error: String) -> void:
|
||||
print("Error loading characters: ", error)
|
||||
```
|
||||
|
||||
**POST Request with Data:**
|
||||
```gdscript
|
||||
func create_character(name: String, class_id: String, origin_id: String) -> void:
|
||||
var url = Settings.api_base_url + "/api/v1/characters"
|
||||
var data = {
|
||||
"name": name,
|
||||
"class_id": class_id,
|
||||
"origin_id": origin_id
|
||||
}
|
||||
|
||||
HTTPClient.post_request(url, data, _on_character_created, _on_create_error)
|
||||
|
||||
func _on_character_created(response: Dictionary) -> void:
|
||||
var character = response.get("result")
|
||||
print("Character created: ", character.name)
|
||||
# Navigate to character screen
|
||||
|
||||
func _on_create_error(error: String) -> void:
|
||||
show_error_popup("Failed to create character: " + error)
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
**StateManager Service (`scripts/services/state_manager.gd`):**
|
||||
```gdscript
|
||||
extends Node
|
||||
## Global game state management.
|
||||
|
||||
signal state_changed(key: String, value: Variant)
|
||||
|
||||
var _state: Dictionary = {}
|
||||
|
||||
func set_value(key: String, value: Variant) -> void:
|
||||
"""Set a state value."""
|
||||
_state[key] = value
|
||||
state_changed.emit(key, value)
|
||||
|
||||
func get_value(key: String, default: Variant = null) -> Variant:
|
||||
"""Get a state value."""
|
||||
return _state.get(key, default)
|
||||
|
||||
func has_value(key: String) -> bool:
|
||||
"""Check if state has a key."""
|
||||
return _state.has(key)
|
||||
|
||||
func clear() -> void:
|
||||
"""Clear all state."""
|
||||
_state.clear()
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```gdscript
|
||||
# Set current character
|
||||
StateManager.set_value("current_character_id", "char_123")
|
||||
|
||||
# Get current character
|
||||
var char_id = StateManager.get_value("current_character_id")
|
||||
|
||||
# Listen for changes
|
||||
StateManager.state_changed.connect(_on_state_changed)
|
||||
|
||||
func _on_state_changed(key: String, value: Variant) -> void:
|
||||
if key == "current_character_id":
|
||||
load_character(value)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Godot Workflow
|
||||
|
||||
### Project Setup (Autoloads)
|
||||
|
||||
In Project Settings → Autoload, add these singletons:
|
||||
|
||||
1. **Settings** - `scripts/services/settings.gd`
|
||||
2. **HTTPClient** - `scripts/services/http_client.gd`
|
||||
3. **StateManager** - `scripts/services/state_manager.gd`
|
||||
|
||||
### Creating a New Scene
|
||||
|
||||
1. **Plan the scene** - Sketch layout, define purpose
|
||||
2. **Create scene file** - `scenes/feature/scene_name.tscn`
|
||||
3. **Add nodes** - Build UI hierarchy
|
||||
4. **Attach script** - `scripts/feature/scene_name.gd`
|
||||
5. **Connect signals** - Wire up interactions
|
||||
6. **Test in editor** - F6 to test scene
|
||||
7. **Integrate** - Add to main scene or navigation
|
||||
|
||||
### Testing
|
||||
|
||||
**In Editor:**
|
||||
- F5 - Run project
|
||||
- F6 - Run current scene
|
||||
- Use Debugger panel for breakpoints
|
||||
- Monitor output for print statements
|
||||
|
||||
**Export Testing:**
|
||||
- Test on target platforms (Desktop, Mobile, Web)
|
||||
- Check API connectivity
|
||||
- Verify UI scaling
|
||||
|
||||
---
|
||||
|
||||
## Export Configuration
|
||||
|
||||
### Desktop Export
|
||||
|
||||
**Windows:**
|
||||
```
|
||||
Platform: Windows Desktop
|
||||
Architecture: x86_64
|
||||
Export Template: Release
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```
|
||||
Platform: macOS
|
||||
Architecture: Universal (arm64 + x86_64)
|
||||
Export Template: Release
|
||||
Code Sign: Required for distribution
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```
|
||||
Platform: Linux/X11
|
||||
Architecture: x86_64
|
||||
Export Template: Release
|
||||
```
|
||||
|
||||
### Mobile Export
|
||||
|
||||
**Android:**
|
||||
```
|
||||
Platform: Android
|
||||
Architecture: arm64-v8a
|
||||
Min SDK: 21
|
||||
Target SDK: 33
|
||||
```
|
||||
|
||||
**iOS:**
|
||||
```
|
||||
Platform: iOS
|
||||
Architecture: arm64
|
||||
Deployment Target: iOS 12.0
|
||||
Bundle ID: com.codeofconquest.game
|
||||
```
|
||||
|
||||
### Web Export
|
||||
|
||||
```
|
||||
Platform: Web
|
||||
Export Type: HTML5
|
||||
Progressive Web App: Yes
|
||||
```
|
||||
|
||||
See [EXPORT.md](EXPORT.md) for detailed instructions.
|
||||
|
||||
---
|
||||
|
||||
## Git Standards
|
||||
|
||||
**Commit Messages:**
|
||||
- Use conventional commit format: `feat:`, `fix:`, `style:`, `asset:`, etc.
|
||||
- Examples:
|
||||
- `feat(godot): add character list scene`
|
||||
- `fix(http): handle API timeout errors`
|
||||
- `style(ui): update character card layout`
|
||||
- `asset(fonts): add Cinzel font family`
|
||||
|
||||
**Branch Strategy:**
|
||||
- Branch off `dev` for features
|
||||
- Merge back to `dev` for testing
|
||||
- Promote to `master` for production
|
||||
|
||||
---
|
||||
|
||||
## Notes for Claude Code
|
||||
|
||||
When working on the Godot client:
|
||||
|
||||
1. **Thin client only** - No business logic, just UI and user interaction
|
||||
2. **Always call API** - Use HTTPClient service for all data operations
|
||||
3. **Handle errors gracefully** - Show user-friendly error popups
|
||||
4. **Keep scenes modular** - Create reusable component scenes
|
||||
5. **Use signals** - Prefer signals over direct node references
|
||||
6. **Test on target platforms** - Verify exports work correctly
|
||||
7. **Performance matters** - Optimize for mobile (low draw calls, minimize allocations)
|
||||
8. **Follow Godot conventions** - Use GDScript idioms, not Python/JavaScript patterns
|
||||
|
||||
**Remember:**
|
||||
- This is a thin client - all logic lives in the API backend
|
||||
- The API serves multiple frontends (this Godot client and web UI)
|
||||
- Godot handles rendering and input - API handles game state
|
||||
- Keep it responsive - show loading indicators during API calls
|
||||
- Mobile-first design - optimize for touch and smaller screens
|
||||
220
godot_client/README.md
Normal file
220
godot_client/README.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Code of Conquest - Godot Client
|
||||
|
||||
Native desktop, mobile, and web client for Code of Conquest, built with Godot 4.
|
||||
|
||||
## Overview
|
||||
|
||||
This is the frontend client that connects to the Flask backend API (`/app`) to provide a native game experience across all platforms.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
godot_client/
|
||||
├── project.godot # Main project configuration
|
||||
├── scenes/ # Scene files (.tscn)
|
||||
│ ├── auth/ # Authentication screens
|
||||
│ ├── character/ # Character management screens
|
||||
│ ├── combat/ # Combat interface
|
||||
│ └── world/ # World exploration
|
||||
├── scripts/ # GDScript files
|
||||
│ ├── services/ # Singleton services (HTTP, State)
|
||||
│ ├── models/ # Data models matching backend
|
||||
│ └── utils/ # Helper functions
|
||||
└── assets/ # Resources
|
||||
├── fonts/ # Custom fonts (Cinzel, Lato)
|
||||
├── themes/ # UI themes
|
||||
└── ui/ # UI assets (icons, images)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Godot Engine 4.5**: Download from [godotengine.org](https://godotengine.org/)
|
||||
- **Flask Backend**: Must be running (see `/app` directory)
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install Godot 4.5**
|
||||
```bash
|
||||
# Download from official site or use package manager
|
||||
# For Ubuntu/Debian:
|
||||
sudo snap install godot-4
|
||||
```
|
||||
|
||||
2. **Open Project**
|
||||
- Launch Godot
|
||||
- Click "Import"
|
||||
- Navigate to `godot_client/project.godot`
|
||||
- Click "Import & Edit"
|
||||
|
||||
3. **Configure Backend URL**
|
||||
- Edit `scripts/services/http_client.gd`
|
||||
- Set `API_BASE_URL` to your Flask backend (default: `http://localhost:5000`)
|
||||
|
||||
4. **Install Fonts** (if needed)
|
||||
- Download Cinzel and Lato fonts
|
||||
- Place in `assets/fonts/`
|
||||
- Reimport in Godot
|
||||
|
||||
## Running the Game
|
||||
|
||||
### From Godot Editor
|
||||
1. Open project in Godot
|
||||
2. Press F5 (or click Play button)
|
||||
3. Game launches in development mode
|
||||
|
||||
### Exported Builds
|
||||
See "Export" section below
|
||||
|
||||
## Development
|
||||
|
||||
### Backend Connection
|
||||
|
||||
The game communicates with the Flask backend via REST API:
|
||||
- Base URL configured in `HTTPClient` singleton
|
||||
- All API calls go through `HTTPClient.request()` method
|
||||
- Authentication token stored in `StateManager`
|
||||
|
||||
### State Management
|
||||
|
||||
- `StateManager` singleton handles global app state
|
||||
- User session, character data, wizard state
|
||||
- Persists to local storage between sessions
|
||||
|
||||
### Scene Flow
|
||||
|
||||
```
|
||||
Main Scene (initial load)
|
||||
↓
|
||||
Login/Register (if not authenticated)
|
||||
↓
|
||||
Character List (main menu)
|
||||
↓
|
||||
Character Creation Wizard (if creating new)
|
||||
↓
|
||||
Game World / Combat
|
||||
```
|
||||
|
||||
## Export
|
||||
|
||||
### Desktop (Windows, Mac, Linux)
|
||||
|
||||
1. In Godot: Project → Export
|
||||
2. Select platform template
|
||||
3. Export project
|
||||
4. Distribute executable + pck file
|
||||
|
||||
### Mobile (iOS, Android)
|
||||
|
||||
**Android:**
|
||||
1. Install Android SDK
|
||||
2. Configure export template
|
||||
3. Set keystore for signing
|
||||
4. Export APK/AAB
|
||||
|
||||
**iOS:**
|
||||
1. Requires macOS + Xcode
|
||||
2. Configure provisioning profile
|
||||
3. Export Xcode project
|
||||
4. Build in Xcode
|
||||
|
||||
### Web (HTML5/WebAssembly)
|
||||
|
||||
1. Export to HTML5
|
||||
2. Serve via HTTP server (HTTPS recommended)
|
||||
3. Can deploy to static hosting (Netlify, Vercel, etc.)
|
||||
|
||||
**Note:** Web builds have limitations (no threading, some GDNative features disabled)
|
||||
|
||||
## Testing
|
||||
|
||||
### Local Development
|
||||
- Run Flask backend on `localhost:5000`
|
||||
- Run Godot client (F5 in editor)
|
||||
- Test API integration
|
||||
|
||||
### Platform-Specific Testing
|
||||
- **Desktop**: Test on Windows, Mac, Linux builds
|
||||
- **Mobile**: Test on real devices (Android/iOS)
|
||||
- **Web**: Test in multiple browsers (Chrome, Firefox, Safari)
|
||||
|
||||
## API Integration
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. User enters credentials in login screen
|
||||
2. Client calls `POST /api/v1/auth/login`
|
||||
3. Backend returns JWT token
|
||||
4. Token stored in `StateManager`
|
||||
5. Token sent in `Authorization` header for protected endpoints
|
||||
|
||||
### Character Management
|
||||
|
||||
- Create character: `POST /api/v1/characters`
|
||||
- List characters: `GET /api/v1/characters`
|
||||
- Get character: `GET /api/v1/characters/<id>`
|
||||
- Delete character: `DELETE /api/v1/characters/<id>`
|
||||
|
||||
All endpoints expect/return JSON matching backend data models.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Services (Singletons)
|
||||
|
||||
**HTTPClient** (`scripts/services/http_client.gd`):
|
||||
- Wraps Godot's HTTPRequest node
|
||||
- Handles JSON serialization/deserialization
|
||||
- Manages auth tokens
|
||||
- Error handling and retries
|
||||
|
||||
**StateManager** (`scripts/services/state_manager.gd`):
|
||||
- Global application state
|
||||
- User session management
|
||||
- Character creation wizard state
|
||||
- Navigation/routing state
|
||||
- Persistence to local storage
|
||||
|
||||
### Data Models
|
||||
|
||||
GDScript classes in `scripts/models/` mirror Python dataclasses:
|
||||
- `Character.gd`
|
||||
- `CharacterClass.gd`
|
||||
- `Origin.gd`
|
||||
- `Skill.gd`
|
||||
- etc.
|
||||
|
||||
### UI Components
|
||||
|
||||
Reusable scenes in `scenes/components/`:
|
||||
- `Button.tscn` - Styled button
|
||||
- `Card.tscn` - Card container
|
||||
- `FormField.tscn` - Input field with label
|
||||
- `ProgressIndicator.tscn` - Wizard progress
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to connect to backend"
|
||||
- Ensure Flask backend is running
|
||||
- Check `API_BASE_URL` in `http_client.gd`
|
||||
- Verify CORS headers if running on web
|
||||
|
||||
### "Authentication failed"
|
||||
- Check Flask backend logs
|
||||
- Verify token storage in StateManager
|
||||
- Clear local storage and re-login
|
||||
|
||||
### Export issues
|
||||
- Ensure export templates are installed
|
||||
- Check platform-specific requirements
|
||||
- Review Godot export documentation
|
||||
|
||||
## Contributing
|
||||
|
||||
Follow the same standards as the main project (see `/CLAUDE.md`):
|
||||
- Type hints on all functions
|
||||
- Docstrings for public methods
|
||||
- Clear variable names
|
||||
- Comments explaining complex logic
|
||||
|
||||
## License
|
||||
|
||||
Same as main project.
|
||||
BIN
godot_client/assets/fonts/Cinzel-VariableFont_wght.ttf
Normal file
BIN
godot_client/assets/fonts/Cinzel-VariableFont_wght.ttf
Normal file
Binary file not shown.
8
godot_client/assets/fonts/Cinzel_Heading_Large.tres
Normal file
8
godot_client/assets/fonts/Cinzel_Heading_Large.tres
Normal file
@@ -0,0 +1,8 @@
|
||||
[gd_resource type="FontVariation" load_steps=2 format=3 uid="uid://b802dpstbtb3k"]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://bjj527hh2w0mv" path="res://assets/fonts/Cinzel-VariableFont_wght.ttf" id="1_l2pup"]
|
||||
|
||||
[resource]
|
||||
fallbacks = Array[Font]([null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null])
|
||||
base_font = ExtResource("1_l2pup")
|
||||
variation_embolden = 1.0
|
||||
8
godot_client/assets/fonts/Cinzel_Heading_Medium.tres
Normal file
8
godot_client/assets/fonts/Cinzel_Heading_Medium.tres
Normal file
@@ -0,0 +1,8 @@
|
||||
[gd_resource type="FontVariation" load_steps=2 format=3 uid="uid://b5lkawnx1dr0u"]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://bjj527hh2w0mv" path="res://assets/fonts/Cinzel-VariableFont_wght.ttf" id="1_d0s2x"]
|
||||
|
||||
[resource]
|
||||
fallbacks = Array[Font]([null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null])
|
||||
base_font = ExtResource("1_d0s2x")
|
||||
variation_embolden = 0.5
|
||||
7
godot_client/assets/fonts/Cinzel_Heading_Small.tres
Normal file
7
godot_client/assets/fonts/Cinzel_Heading_Small.tres
Normal file
@@ -0,0 +1,7 @@
|
||||
[gd_resource type="FontVariation" load_steps=2 format=3 uid="uid://c6gh0dkh0hip1"]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://bjj527hh2w0mv" path="res://assets/fonts/Cinzel-VariableFont_wght.ttf" id="1_et6f3"]
|
||||
|
||||
[resource]
|
||||
fallbacks = Array[Font]([null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null])
|
||||
base_font = ExtResource("1_et6f3")
|
||||
BIN
godot_client/assets/fonts/Lato-Black.ttf
Normal file
BIN
godot_client/assets/fonts/Lato-Black.ttf
Normal file
Binary file not shown.
BIN
godot_client/assets/fonts/Lato-BlackItalic.ttf
Normal file
BIN
godot_client/assets/fonts/Lato-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
godot_client/assets/fonts/Lato-Bold.ttf
Normal file
BIN
godot_client/assets/fonts/Lato-Bold.ttf
Normal file
Binary file not shown.
BIN
godot_client/assets/fonts/Lato-BoldItalic.ttf
Normal file
BIN
godot_client/assets/fonts/Lato-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
godot_client/assets/fonts/Lato-Italic.ttf
Normal file
BIN
godot_client/assets/fonts/Lato-Italic.ttf
Normal file
Binary file not shown.
BIN
godot_client/assets/fonts/Lato-Light.ttf
Normal file
BIN
godot_client/assets/fonts/Lato-Light.ttf
Normal file
Binary file not shown.
BIN
godot_client/assets/fonts/Lato-LightItalic.ttf
Normal file
BIN
godot_client/assets/fonts/Lato-LightItalic.ttf
Normal file
Binary file not shown.
BIN
godot_client/assets/fonts/Lato-Regular.ttf
Normal file
BIN
godot_client/assets/fonts/Lato-Regular.ttf
Normal file
Binary file not shown.
BIN
godot_client/assets/fonts/Lato-Thin.ttf
Normal file
BIN
godot_client/assets/fonts/Lato-Thin.ttf
Normal file
Binary file not shown.
BIN
godot_client/assets/fonts/Lato-ThinItalic.ttf
Normal file
BIN
godot_client/assets/fonts/Lato-ThinItalic.ttf
Normal file
Binary file not shown.
7
godot_client/assets/fonts/Lato_Body.tres
Normal file
7
godot_client/assets/fonts/Lato_Body.tres
Normal file
@@ -0,0 +1,7 @@
|
||||
[gd_resource type="FontVariation" load_steps=2 format=3 uid="uid://djittchmc47cm"]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://ceh47x1000ffl" path="res://assets/fonts/Lato-Regular.ttf" id="1_kryr6"]
|
||||
|
||||
[resource]
|
||||
fallbacks = Array[Font]([null, null, null, null, null, null, null, null, null, null, null, null, null, null])
|
||||
base_font = ExtResource("1_kryr6")
|
||||
8
godot_client/assets/fonts/Lato_Body_Bold.tres
Normal file
8
godot_client/assets/fonts/Lato_Body_Bold.tres
Normal file
@@ -0,0 +1,8 @@
|
||||
[gd_resource type="FontVariation" load_steps=2 format=3 uid="uid://dg2afkncquebg"]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://ceh47x1000ffl" path="res://assets/fonts/Lato-Regular.ttf" id="1_divye"]
|
||||
|
||||
[resource]
|
||||
fallbacks = Array[Font]([null, null, null, null, null, null, null, null, null, null, null, null, null, null])
|
||||
base_font = ExtResource("1_divye")
|
||||
variation_embolden = 1.0
|
||||
BIN
godot_client/assets/fonts/MedievalSharp-Regular.ttf
Normal file
BIN
godot_client/assets/fonts/MedievalSharp-Regular.ttf
Normal file
Binary file not shown.
67
godot_client/assets/fonts/README.md
Normal file
67
godot_client/assets/fonts/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Fonts
|
||||
|
||||
This directory contains the fonts used in Code of Conquest.
|
||||
|
||||
## Required Fonts
|
||||
|
||||
### Cinzel (Headings)
|
||||
- **Source**: [Google Fonts - Cinzel](https://fonts.google.com/specimen/Cinzel)
|
||||
- **Weights Needed**:
|
||||
- Regular (400)
|
||||
- Medium (500)
|
||||
- SemiBold (600)
|
||||
- Bold (700)
|
||||
- **License**: SIL Open Font License
|
||||
- **Usage**: Headings, titles, important labels
|
||||
|
||||
### Lato (Body Text)
|
||||
- **Source**: [Google Fonts - Lato](https://fonts.google.com/specimen/Lato)
|
||||
- **Weights Needed**:
|
||||
- Regular (400)
|
||||
- Bold (700)
|
||||
- Italic (400 italic)
|
||||
- **License**: SIL Open Font License
|
||||
- **Usage**: Body text, UI labels, descriptions
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download fonts from Google Fonts (links above)
|
||||
2. Extract .ttf files
|
||||
3. Copy to this directory:
|
||||
```
|
||||
assets/fonts/
|
||||
├── Cinzel-Regular.ttf
|
||||
├── Cinzel-Medium.ttf
|
||||
├── Cinzel-SemiBold.ttf
|
||||
├── Cinzel-Bold.ttf
|
||||
├── Lato-Regular.ttf
|
||||
├── Lato-Bold.ttf
|
||||
└── Lato-Italic.ttf
|
||||
```
|
||||
4. Restart Godot or click "Reimport" in FileSystem panel
|
||||
|
||||
## Font Sizes
|
||||
|
||||
### Headings (Cinzel)
|
||||
- **H1**: 32px, Bold
|
||||
- **H2**: 24px, SemiBold
|
||||
- **H3**: 18px, Medium
|
||||
- **H4**: 16px, Regular
|
||||
|
||||
### Body Text (Lato)
|
||||
- **Normal**: 14px, Regular
|
||||
- **Small**: 12px, Regular
|
||||
- **Emphasis**: 14px, Bold
|
||||
|
||||
## License
|
||||
|
||||
Both fonts are licensed under the SIL Open Font License 1.1, which allows:
|
||||
- Use in commercial and non-commercial projects
|
||||
- Redistribution (with original license)
|
||||
- Modification
|
||||
|
||||
See individual font licenses for full terms.
|
||||
|
||||
## Fallback
|
||||
|
||||
If fonts are not installed, Godot will fall back to the default font. The game will still function, but visual appearance will differ from intended design.
|
||||
BIN
godot_client/assets/images/main_menu
Normal file
BIN
godot_client/assets/images/main_menu
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
114
godot_client/assets/themes/main_theme.tres
Normal file
114
godot_client/assets/themes/main_theme.tres
Normal file
@@ -0,0 +1,114 @@
|
||||
[gd_resource type="Theme" load_steps=12 format=3 uid="uid://bviqieumdiccr"]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://ceh47x1000ffl" path="res://assets/fonts/Lato-Regular.ttf" id="1_3f3va"]
|
||||
[ext_resource type="FontFile" uid="uid://bou2y1sefpmvm" path="res://assets/fonts/MedievalSharp-Regular.ttf" id="1_lxams"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lxams"]
|
||||
bg_color = Color(0.101960786, 0.101960786, 0.18039216, 1)
|
||||
border_color = Color(0.15294118, 0.15294118, 0.16470589, 1)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_vhku8"]
|
||||
bg_color = Color(0.08627451, 0.12941177, 0.24313726, 1)
|
||||
border_color = Color(0.83137256, 0.6862745, 0.21568628, 1)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gvtt4"]
|
||||
bg_color = Color(0.11764706, 0.11764706, 0.18431373, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.24705882, 0.24705882, 0.27450982, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_y0ixg"]
|
||||
content_margin_left = 4.0
|
||||
content_margin_top = 4.0
|
||||
content_margin_right = 4.0
|
||||
content_margin_bottom = 4.0
|
||||
bg_color = Color(1, 1, 1, 0.75)
|
||||
draw_center = false
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
corner_radius_top_left = 3
|
||||
corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_detail = 5
|
||||
expand_margin_left = 2.0
|
||||
expand_margin_top = 2.0
|
||||
expand_margin_right = 2.0
|
||||
expand_margin_bottom = 2.0
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_etupa"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_axncs"]
|
||||
bg_color = Color(0.08627451, 0.12941177, 0.24313726, 1)
|
||||
border_color = Color(0.83137256, 0.6862745, 0.21568628, 1)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4fp8c"]
|
||||
bg_color = Color(0.08627451, 0.12941177, 0.24313726, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.24705882, 0.24705882, 0.27450982, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0f6i3"]
|
||||
bg_color = Color(0.11764706, 0.11764706, 0.18431373, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.15294118, 0.15294118, 0.16470589, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_etupa"]
|
||||
bg_color = Color(0, 0, 0, 0.5254902)
|
||||
border_width_left = 3
|
||||
border_width_top = 3
|
||||
border_width_right = 3
|
||||
border_width_bottom = 3
|
||||
border_color = Color(0.83137256, 0.6862745, 0.21568628, 0.5019608)
|
||||
border_blend = true
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("1_3f3va")
|
||||
default_font_size = 14
|
||||
Button/colors/font_color = Color(0.89411765, 0.89411765, 0.90588236, 1)
|
||||
Button/colors/font_disabled_color = Color(0.44313726, 0.44313726, 0.47843137, 1)
|
||||
Button/colors/font_hover_color = Color(0.95686275, 0.8156863, 0.24705882, 1)
|
||||
Button/colors/font_pressed_color = Color(0.83137256, 0.6862745, 0.21568628, 1)
|
||||
Button/styles/disabled = SubResource("StyleBoxFlat_lxams")
|
||||
Button/styles/hover = SubResource("StyleBoxFlat_vhku8")
|
||||
Button/styles/normal = SubResource("StyleBoxFlat_gvtt4")
|
||||
Label/colors/font_color = Color(0.89411765, 0.89411765, 0.90588236, 1)
|
||||
Label/colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
Label/colors/font_shadow_color = Color(0, 0, 0, 0)
|
||||
Label/constants/line_spacing = 3
|
||||
Label/constants/outline_size = 0
|
||||
Label/constants/shadow_offset_x = 1
|
||||
Label/constants/shadow_offset_y = 1
|
||||
Label/constants/shadow_outline_size = 1
|
||||
Label/fonts/font = ExtResource("1_lxams")
|
||||
Label/styles/focus = SubResource("StyleBoxFlat_y0ixg")
|
||||
Label/styles/normal = SubResource("StyleBoxEmpty_etupa")
|
||||
LineEdit/colors/caret_color = Color(0.83137256, 0.6862745, 0.21568628, 1)
|
||||
LineEdit/colors/font_color = Color(0.89411765, 0.89411765, 0.90588236, 1)
|
||||
LineEdit/colors/font_placeholder_color = Color(0.6313726, 0.6313726, 0.6666667, 1)
|
||||
LineEdit/colors/selection_color = Color(0.83137256, 0.6862745, 0.21568628, 1)
|
||||
LineEdit/styles/focus = SubResource("StyleBoxFlat_axncs")
|
||||
LineEdit/styles/normal = SubResource("StyleBoxFlat_4fp8c")
|
||||
Panel/styles/panel = SubResource("StyleBoxFlat_0f6i3")
|
||||
PanelContainer/styles/panel = SubResource("StyleBoxFlat_etupa")
|
||||
BIN
godot_client/assets/ui/icon.png
Normal file
BIN
godot_client/assets/ui/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
godot_client/assets/ui/main_menu.png
Normal file
BIN
godot_client/assets/ui/main_menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
767
godot_client/docs/ARCHITECTURE.md
Normal file
767
godot_client/docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,767 @@
|
||||
# Godot Client Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The Godot client is a native frontend for Code of Conquest that connects to the Flask backend via REST API. It provides a consistent experience across desktop, mobile, and web platforms.
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Godot Client │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ UI Layer (Scenes) │ │
|
||||
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
|
||||
│ │ │ Auth │ │ Char │ │ Combat │ │ World │ │ │
|
||||
│ │ │ Screens│ │ Mgmt │ │ UI │ │ UI │ │ │
|
||||
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ Component Layer │ │
|
||||
│ │ ┌─────────┐ ┌──────┐ ┌──────────┐ ┌──────┐ │ │
|
||||
│ │ │ Buttons │ │ Cards│ │FormFields│ │ ... │ │ │
|
||||
│ │ └─────────┘ └──────┘ └──────────┘ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ Service Layer (Singletons) │ │
|
||||
│ │ ┌──────────────┐ ┌────────────────┐ │ │
|
||||
│ │ │ HTTPClient │ │ StateManager │ │ │
|
||||
│ │ │ - API calls │ │ - Session │ │ │
|
||||
│ │ │ - Auth token │ │ - Characters │ │ │
|
||||
│ │ │ - Error │ │ - Wizard state │ │ │
|
||||
│ │ │ handling │ │ - Persistence │ │ │
|
||||
│ │ └──────────────┘ └────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ Data Models (GDScript) │ │
|
||||
│ │ Character, CharacterClass, Origin, Skill, etc. │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
HTTP/JSON REST API
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Flask Backend │
|
||||
│ (Existing REST API) │
|
||||
│ /api/v1/auth/*, /api/v1/characters/* │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Layers
|
||||
|
||||
### 1. UI Layer (Scenes)
|
||||
|
||||
**Location**: `scenes/`
|
||||
|
||||
Individual screens and pages of the application.
|
||||
|
||||
**Responsibilities**:
|
||||
- Render UI elements
|
||||
- Handle user input
|
||||
- Display data from StateManager
|
||||
- Trigger API calls via HTTPClient
|
||||
- Navigate between scenes
|
||||
|
||||
**Examples**:
|
||||
- `scenes/auth/login.tscn` - Login screen
|
||||
- `scenes/character/character_list.tscn` - Character list
|
||||
- `scenes/character/create_wizard_step1.tscn` - Character creation step 1
|
||||
|
||||
**Communication**:
|
||||
- Uses components from Component Layer
|
||||
- Reads/writes to StateManager
|
||||
- Calls HTTPClient for API requests
|
||||
- Emits signals for navigation
|
||||
|
||||
### 2. Component Layer
|
||||
|
||||
**Location**: `scenes/components/`, `scripts/components/`
|
||||
|
||||
Reusable UI components shared across scenes.
|
||||
|
||||
**Responsibilities**:
|
||||
- Provide consistent UI elements
|
||||
- Encapsulate common patterns
|
||||
- Handle component-specific logic
|
||||
- Emit signals for parent scenes
|
||||
|
||||
**Examples**:
|
||||
- `CustomButton` - Themed button with variants
|
||||
- `Card` - Container with header/footer
|
||||
- `FormField` - Input field with validation
|
||||
|
||||
**Communication**:
|
||||
- Receives props from parent scenes
|
||||
- Emits signals to parent scenes
|
||||
- Uses ThemeColors for styling
|
||||
- Independent of business logic
|
||||
|
||||
### 3. Service Layer (Singletons)
|
||||
|
||||
**Location**: `scripts/services/`
|
||||
|
||||
Global services accessible from anywhere in the app.
|
||||
|
||||
**Responsibilities**:
|
||||
- Manage API communication
|
||||
- Store global state
|
||||
- Handle persistence
|
||||
- Provide utilities
|
||||
|
||||
**Singletons**:
|
||||
|
||||
#### HTTPClient
|
||||
- Base URL configuration
|
||||
- Authentication token management
|
||||
- JSON request/response handling
|
||||
- Error handling and retries
|
||||
- Timeout management
|
||||
|
||||
**API**:
|
||||
```gdscript
|
||||
HTTPClient.http_get(endpoint, callback, error_callback)
|
||||
HTTPClient.http_post(endpoint, data, callback, error_callback)
|
||||
HTTPClient.http_put(endpoint, data, callback, error_callback)
|
||||
HTTPClient.http_delete(endpoint, callback, error_callback)
|
||||
HTTPClient.http_patch(endpoint, data, callback, error_callback)
|
||||
HTTPClient.set_auth_token(token)
|
||||
```
|
||||
|
||||
#### StateManager
|
||||
- User session state
|
||||
- Character data cache
|
||||
- Wizard state (character creation)
|
||||
- Navigation history
|
||||
- Settings and preferences
|
||||
- Save/load to local storage
|
||||
|
||||
**API**:
|
||||
```gdscript
|
||||
StateManager.set_user_session(user_data, token)
|
||||
StateManager.get_characters()
|
||||
StateManager.set_wizard_origin(origin_data)
|
||||
StateManager.save_state()
|
||||
```
|
||||
|
||||
**Communication**:
|
||||
- HTTPClient → Backend API (HTTP)
|
||||
- StateManager → Local storage (FileAccess)
|
||||
- Services → UI via signals
|
||||
|
||||
**Accessing Autoloads**:
|
||||
|
||||
Autoload singletons can be accessed in three ways:
|
||||
|
||||
1. **Direct access** (simple, works in `_ready()` and later):
|
||||
```gdscript
|
||||
func _ready():
|
||||
HTTPClient.http_get("/api/v1/characters", _on_success)
|
||||
```
|
||||
|
||||
2. **Get node explicitly** (verbose but always works):
|
||||
```gdscript
|
||||
func _on_button_clicked():
|
||||
get_node("/root/HTTPClient").http_get("/api/v1/characters", _on_success)
|
||||
```
|
||||
|
||||
3. **Cached reference with @onready** (best for multiple uses):
|
||||
```gdscript
|
||||
@onready var http_client = get_node("/root/HTTPClient")
|
||||
@onready var state_manager = get_node("/root/StateManager")
|
||||
|
||||
func _ready():
|
||||
http_client.http_get("/api/v1/characters", _on_success)
|
||||
```
|
||||
|
||||
**Important**: When one autoload needs to reference another (like StateManager calling HTTPClient), use `@onready` to avoid script parsing errors. Direct access (`HTTPClient.method()`) only works during runtime, not during script compilation.
|
||||
|
||||
### 4. Data Models
|
||||
|
||||
**Location**: `scripts/models/`
|
||||
|
||||
GDScript classes that mirror backend data structures.
|
||||
|
||||
**Responsibilities**:
|
||||
- Define typed data structures
|
||||
- Validate data
|
||||
- Serialize/deserialize JSON
|
||||
- Provide helper methods
|
||||
|
||||
**Examples**:
|
||||
```gdscript
|
||||
class_name Character
|
||||
|
||||
var id: String
|
||||
var name: String
|
||||
var origin_id: String
|
||||
var class_id: String
|
||||
var level: int
|
||||
var hp: int
|
||||
var max_hp: int
|
||||
var gold: int
|
||||
var skills: Array[Skill]
|
||||
|
||||
static func from_json(data: Dictionary) -> Character:
|
||||
# Parse JSON to Character object
|
||||
pass
|
||||
```
|
||||
|
||||
**Communication**:
|
||||
- Created from API responses
|
||||
- Stored in StateManager
|
||||
- Displayed in UI scenes
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Example: User Login
|
||||
|
||||
```
|
||||
1. User enters credentials in LoginScene
|
||||
↓
|
||||
2. LoginScene calls HTTPClient.http_post("/api/v1/auth/login", {...})
|
||||
↓
|
||||
3. HTTPClient sends HTTP request to Flask backend
|
||||
↓
|
||||
4. Backend validates and returns JWT token + user data
|
||||
↓
|
||||
5. HTTPClient receives response, creates APIResponse object
|
||||
↓
|
||||
6. LoginScene receives APIResponse via callback
|
||||
↓
|
||||
7. LoginScene calls StateManager.set_user_session(user_data, token)
|
||||
↓
|
||||
8. StateManager stores session and emits user_logged_in signal
|
||||
↓
|
||||
9. StateManager saves state to local storage
|
||||
↓
|
||||
10. LoginScene navigates to CharacterListScene
|
||||
```
|
||||
|
||||
### Example: Load Characters
|
||||
|
||||
```
|
||||
1. CharacterListScene calls HTTPClient.http_get("/api/v1/characters")
|
||||
↓
|
||||
2. HTTPClient includes auth token in headers
|
||||
↓
|
||||
3. Backend returns array of character data
|
||||
↓
|
||||
4. CharacterListScene receives response
|
||||
↓
|
||||
5. CharacterListScene calls StateManager.set_characters(characters)
|
||||
↓
|
||||
6. StateManager emits characters_updated signal
|
||||
↓
|
||||
7. CharacterListScene updates UI with character cards
|
||||
```
|
||||
|
||||
### Example: Character Creation Wizard
|
||||
|
||||
```
|
||||
1. User selects origin in WizardStep1Scene
|
||||
↓
|
||||
2. Scene calls StateManager.set_wizard_origin(origin_data)
|
||||
↓
|
||||
3. Scene navigates to WizardStep2Scene
|
||||
↓
|
||||
4. User selects class in WizardStep2Scene
|
||||
↓
|
||||
5. Scene calls StateManager.set_wizard_class(class_data)
|
||||
↓
|
||||
6. User continues through steps 3 and 4
|
||||
↓
|
||||
7. WizardStep4Scene (confirmation) calls HTTPClient.http_post(
|
||||
"/api/v1/characters",
|
||||
{
|
||||
"origin_id": StateManager.get_wizard_origin().id,
|
||||
"class_id": StateManager.get_wizard_class().id,
|
||||
"name": StateManager.get_wizard_name()
|
||||
}
|
||||
)
|
||||
↓
|
||||
8. Backend creates character and returns character data
|
||||
↓
|
||||
9. Scene calls StateManager.add_character(character_data)
|
||||
↓
|
||||
10. Scene calls StateManager.reset_wizard()
|
||||
↓
|
||||
11. Scene navigates back to CharacterListScene
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
### Scene Management
|
||||
|
||||
Godot's built-in scene tree is used for navigation:
|
||||
|
||||
```gdscript
|
||||
# Navigate to another scene
|
||||
get_tree().change_scene_to_file("res://scenes/character/character_list.tscn")
|
||||
|
||||
# Or use PackedScene for preloading
|
||||
var next_scene = preload("res://scenes/auth/login.tscn")
|
||||
get_tree().change_scene_to_packed(next_scene)
|
||||
```
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
```
|
||||
App Start
|
||||
↓
|
||||
Main Scene (checks auth)
|
||||
├─ Authenticated? → CharacterListScene
|
||||
└─ Not authenticated? → LoginScene
|
||||
↓
|
||||
Login successful → CharacterListScene
|
||||
↓
|
||||
Create Character → WizardStep1Scene
|
||||
↓
|
||||
Step 2 → WizardStep2Scene
|
||||
↓
|
||||
Step 3 → WizardStep3Scene
|
||||
↓
|
||||
Step 4 → WizardStep4Scene
|
||||
↓
|
||||
Complete → CharacterListScene
|
||||
↓
|
||||
Select Character → CharacterDetailScene
|
||||
↓
|
||||
Start Game → GameWorldScene
|
||||
↓
|
||||
Enter Combat → CombatScene
|
||||
```
|
||||
|
||||
### Navigation Helper
|
||||
|
||||
Consider creating a navigation manager:
|
||||
|
||||
```gdscript
|
||||
# scripts/services/navigation_manager.gd
|
||||
extends Node
|
||||
|
||||
const SCENE_LOGIN = "res://scenes/auth/login.tscn"
|
||||
const SCENE_CHARACTER_LIST = "res://scenes/character/character_list.tscn"
|
||||
const SCENE_WIZARD_STEP_1 = "res://scenes/character/create_wizard_step1.tscn"
|
||||
# ... etc
|
||||
|
||||
func navigate_to(scene_path: String) -> void:
|
||||
StateManager.set_current_scene(scene_path)
|
||||
get_tree().change_scene_to_file(scene_path)
|
||||
|
||||
func navigate_to_login() -> void:
|
||||
navigate_to(SCENE_LOGIN)
|
||||
|
||||
func navigate_to_character_list() -> void:
|
||||
navigate_to(SCENE_CHARACTER_LIST)
|
||||
|
||||
# etc.
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Initial Load
|
||||
|
||||
```gdscript
|
||||
# main.gd (attached to main.tscn, first scene loaded)
|
||||
extends Control
|
||||
|
||||
func _ready() -> void:
|
||||
# StateManager auto-loads from local storage in its _ready()
|
||||
# Check if user is authenticated
|
||||
if StateManager.is_authenticated():
|
||||
# Validate token by making a test API call
|
||||
HTTPClient.http_get("/api/v1/auth/validate", _on_token_validated, _on_token_invalid)
|
||||
else:
|
||||
# Go to login
|
||||
NavigationManager.navigate_to_login()
|
||||
|
||||
func _on_token_validated(response: HTTPClient.APIResponse) -> void:
|
||||
if response.is_success():
|
||||
# Token is valid, go to character list
|
||||
NavigationManager.navigate_to_character_list()
|
||||
else:
|
||||
# Token expired, clear and go to login
|
||||
StateManager.clear_user_session()
|
||||
NavigationManager.navigate_to_login()
|
||||
|
||||
func _on_token_invalid(response: HTTPClient.APIResponse) -> void:
|
||||
# Network error or token invalid
|
||||
StateManager.clear_user_session()
|
||||
NavigationManager.navigate_to_login()
|
||||
```
|
||||
|
||||
### Login
|
||||
|
||||
```gdscript
|
||||
# scenes/auth/login.gd
|
||||
extends Control
|
||||
|
||||
@onready var email_field: FormField = $EmailField
|
||||
@onready var password_field: FormField = $PasswordField
|
||||
@onready var login_button: CustomButton = $LoginButton
|
||||
@onready var error_label: Label = $ErrorLabel
|
||||
|
||||
func _on_login_button_clicked() -> void:
|
||||
# Validate fields
|
||||
if not email_field.validate() or not password_field.validate():
|
||||
return
|
||||
|
||||
# Show loading state
|
||||
login_button.set_loading(true)
|
||||
error_label.visible = false
|
||||
|
||||
# Make API call
|
||||
var credentials = {
|
||||
"email": email_field.get_value(),
|
||||
"password": password_field.get_value()
|
||||
}
|
||||
|
||||
HTTPClient.http_post("/api/v1/auth/login", credentials, _on_login_success, _on_login_error)
|
||||
|
||||
func _on_login_success(response: APIResponse) -> void:
|
||||
login_button.set_loading(false)
|
||||
|
||||
if response.is_success():
|
||||
# Extract user data and token from response
|
||||
var user_data = response.result.get("user", {})
|
||||
var token = response.result.get("token", "")
|
||||
|
||||
# Store in StateManager
|
||||
StateManager.set_user_session(user_data, token)
|
||||
|
||||
# Navigate to character list
|
||||
NavigationManager.navigate_to_character_list()
|
||||
else:
|
||||
# Show error
|
||||
error_label.text = response.get_error_message()
|
||||
error_label.visible = true
|
||||
|
||||
func _on_login_error(response: APIResponse) -> void:
|
||||
login_button.set_loading(false)
|
||||
error_label.text = "Failed to connect. Please try again."
|
||||
error_label.visible = true
|
||||
```
|
||||
|
||||
### Logout
|
||||
|
||||
```gdscript
|
||||
func _on_logout_button_clicked() -> void:
|
||||
# Call logout endpoint (optional, for server-side cleanup)
|
||||
HTTPClient.http_post("/api/v1/auth/logout", {}, _on_logout_complete)
|
||||
|
||||
func _on_logout_complete(response: APIResponse) -> void:
|
||||
# Clear local session regardless of API response
|
||||
StateManager.clear_user_session()
|
||||
|
||||
# Navigate to login
|
||||
NavigationManager.navigate_to_login()
|
||||
```
|
||||
|
||||
## State Persistence
|
||||
|
||||
### What Gets Saved
|
||||
|
||||
StateManager saves to `user://coc_state.save` (platform-specific location):
|
||||
|
||||
- User session data
|
||||
- Auth token (if "remember me")
|
||||
- Character list cache
|
||||
- Selected character ID
|
||||
- Character limits
|
||||
- Settings/preferences
|
||||
- Timestamp of last save
|
||||
|
||||
### Save Triggers
|
||||
|
||||
State is saved automatically:
|
||||
- On login
|
||||
- On logout (clears save)
|
||||
- On character list update
|
||||
- On character selection
|
||||
- On settings change
|
||||
- On app exit (if auto-save enabled)
|
||||
|
||||
Manual save:
|
||||
```gdscript
|
||||
StateManager.save_state()
|
||||
```
|
||||
|
||||
### Load on Startup
|
||||
|
||||
StateManager automatically loads on `_ready()`:
|
||||
- Restores user session if valid
|
||||
- Restores character list
|
||||
- Sets HTTPClient auth token
|
||||
|
||||
## Error Handling
|
||||
|
||||
### API Errors
|
||||
|
||||
```gdscript
|
||||
HTTPClient.http_get("/api/v1/characters", _on_success, _on_error)
|
||||
|
||||
func _on_success(response: APIResponse) -> void:
|
||||
if response.is_success():
|
||||
# Handle success
|
||||
var characters = response.result
|
||||
else:
|
||||
# API returned error (4xx, 5xx)
|
||||
_show_error(response.get_error_message())
|
||||
|
||||
func _on_error(response: APIResponse) -> void:
|
||||
# Network error or JSON parse error
|
||||
_show_error("Failed to connect to server")
|
||||
```
|
||||
|
||||
### Network Errors
|
||||
|
||||
HTTPClient handles:
|
||||
- Connection timeout (30s default)
|
||||
- DNS resolution failure
|
||||
- TLS/SSL errors
|
||||
- No response from server
|
||||
- Invalid JSON responses
|
||||
|
||||
All errors return an `APIResponse` with:
|
||||
- `status`: HTTP status code (or 500 for network errors)
|
||||
- `error.message`: Human-readable error message
|
||||
- `error.code`: Machine-readable error code
|
||||
|
||||
### User-Facing Errors
|
||||
|
||||
Show errors to users:
|
||||
|
||||
```gdscript
|
||||
# Toast notification (create a notification system)
|
||||
NotificationManager.show_error("Failed to load characters")
|
||||
|
||||
# Inline error label
|
||||
error_label.text = response.get_error_message()
|
||||
error_label.visible = true
|
||||
|
||||
# Error dialog
|
||||
var dialog = AcceptDialog.new()
|
||||
dialog.dialog_text = "An error occurred. Please try again."
|
||||
dialog.popup_centered()
|
||||
```
|
||||
|
||||
## Platform Considerations
|
||||
|
||||
### Mobile
|
||||
|
||||
**Touch Input**:
|
||||
- Larger tap targets (minimum 44x44 dp)
|
||||
- No hover states
|
||||
- Swipe gestures for navigation
|
||||
|
||||
**Screen Sizes**:
|
||||
- Responsive layouts
|
||||
- Safe areas (notches, rounded corners)
|
||||
- Portrait and landscape orientations
|
||||
|
||||
**Performance**:
|
||||
- Limit draw calls
|
||||
- Use compressed textures
|
||||
- Reduce particle effects
|
||||
|
||||
**Networking**:
|
||||
- Handle intermittent connectivity
|
||||
- Show loading indicators
|
||||
- Cache data locally
|
||||
|
||||
### Desktop
|
||||
|
||||
**Input**:
|
||||
- Keyboard shortcuts
|
||||
- Mouse hover effects
|
||||
- Scroll wheel support
|
||||
|
||||
**Window Management**:
|
||||
- Resizable windows
|
||||
- Fullscreen mode
|
||||
- Multiple monitor support
|
||||
|
||||
### Web
|
||||
|
||||
**Limitations**:
|
||||
- No threading (slower)
|
||||
- No file system access (use IndexedDB)
|
||||
- Larger download size
|
||||
- CORS restrictions
|
||||
|
||||
**Requirements**:
|
||||
- HTTPS for SharedArrayBuffer
|
||||
- Proper MIME types
|
||||
- COOP/COEP headers
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Storage
|
||||
|
||||
**Desktop/Mobile**: Tokens stored in encrypted local storage
|
||||
**Web**: Tokens stored in localStorage (less secure, consider sessionStorage)
|
||||
|
||||
### HTTPS Only
|
||||
|
||||
Always use HTTPS in production for API calls.
|
||||
|
||||
### Token Expiration
|
||||
|
||||
Handle expired tokens:
|
||||
```gdscript
|
||||
func _on_api_error(response: HTTPClient.APIResponse) -> void:
|
||||
if response.status == 401: # Unauthorized
|
||||
# Token expired, logout
|
||||
StateManager.clear_user_session()
|
||||
NavigationManager.navigate_to_login()
|
||||
```
|
||||
|
||||
### Input Validation
|
||||
|
||||
Always validate user input:
|
||||
- FormField components have built-in validation
|
||||
- Re-validate on backend (never trust client)
|
||||
- Sanitize before displaying (prevent XSS if user-generated content)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Test individual components:
|
||||
- HTTPClient request/response handling
|
||||
- StateManager save/load
|
||||
- Data model serialization
|
||||
- Component validation logic
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test scene flows:
|
||||
- Login → Character List
|
||||
- Character Creation wizard
|
||||
- Character selection → Game
|
||||
|
||||
### Platform Tests
|
||||
|
||||
Test on each platform:
|
||||
- Desktop: Windows, Mac, Linux
|
||||
- Mobile: Android, iOS
|
||||
- Web: Chrome, Firefox, Safari
|
||||
|
||||
### API Mocking
|
||||
|
||||
For offline testing, create mock responses:
|
||||
|
||||
```gdscript
|
||||
# scripts/services/mock_http_client.gd
|
||||
extends Node
|
||||
|
||||
# Same API as HTTPClient but returns mock data
|
||||
func get(endpoint: String, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
match endpoint:
|
||||
"/api/v1/characters":
|
||||
callback.call(_mock_character_list_response())
|
||||
_:
|
||||
error_callback.call(_mock_error_response())
|
||||
|
||||
func _mock_character_list_response() -> HTTPClient.APIResponse:
|
||||
var mock_data = {
|
||||
"app": "Code of Conquest",
|
||||
"version": "0.1.0",
|
||||
"status": 200,
|
||||
"timestamp": Time.get_datetime_string_from_system(),
|
||||
"result": [
|
||||
{"id": "1", "name": "Warrior", "level": 5, "hp": 100, "max_hp": 100},
|
||||
{"id": "2", "name": "Mage", "level": 3, "hp": 60, "max_hp": 60}
|
||||
],
|
||||
"error": {}
|
||||
}
|
||||
return HTTPClient.APIResponse.new(mock_data)
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Minimize API Calls
|
||||
|
||||
- Cache data in StateManager
|
||||
- Only refetch when necessary
|
||||
- Use pagination for large lists
|
||||
|
||||
### Optimize Rendering
|
||||
|
||||
- Use object pooling for lists
|
||||
- Minimize StyleBox allocations
|
||||
- Use TextureRect instead of Sprite when possible
|
||||
- Reduce shadow/glow effects on mobile
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
Load scenes only when needed:
|
||||
```gdscript
|
||||
# Preload for instant transition
|
||||
const CharacterList = preload("res://scenes/character/character_list.tscn")
|
||||
|
||||
# Or load on-demand
|
||||
var scene = load("res://scenes/character/character_list.tscn")
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Logging
|
||||
|
||||
```gdscript
|
||||
# Add to HTTPClient, StateManager, etc.
|
||||
print("[HTTPClient] GET /api/v1/characters")
|
||||
print("[StateManager] User logged in: ", user_data.get("email"))
|
||||
```
|
||||
|
||||
### Remote Debugging
|
||||
|
||||
For mobile:
|
||||
1. Enable remote debug in Godot
|
||||
2. Connect device via USB
|
||||
3. Run with "Remote Debug" option
|
||||
|
||||
### Network Inspector
|
||||
|
||||
Monitor network traffic:
|
||||
- Desktop: Use browser dev tools for Godot Web export
|
||||
- Mobile: Use Charles Proxy or similar
|
||||
- Check request/response bodies, headers, timing
|
||||
|
||||
## Next Steps (Phase 2)
|
||||
|
||||
After Phase 1 (Foundation) is complete:
|
||||
|
||||
1. **Implement Auth Screens** (Week 2)
|
||||
- Login, Register, Password Reset
|
||||
- Email verification
|
||||
- Form validation
|
||||
|
||||
2. **Implement Character Management** (Weeks 3-4)
|
||||
- Character creation wizard
|
||||
- Character list
|
||||
- Character detail
|
||||
- Delete character
|
||||
|
||||
3. **Platform Optimization** (Week 5)
|
||||
- Mobile touch controls
|
||||
- Desktop keyboard shortcuts
|
||||
- Web export configuration
|
||||
|
||||
4. **Future Features** (Week 6+)
|
||||
- Combat UI
|
||||
- World exploration
|
||||
- Quest system
|
||||
- Multiplayer
|
||||
|
||||
## Resources
|
||||
|
||||
- [Godot Documentation](https://docs.godotengine.org/)
|
||||
- [GDScript Style Guide](https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_styleguide.html)
|
||||
- [Godot UI Tutorials](https://docs.godotengine.org/en/stable/tutorials/ui/index.html)
|
||||
- [HTTP Request](https://docs.godotengine.org/en/stable/classes/class_httprequest.html)
|
||||
- [FileAccess](https://docs.godotengine.org/en/stable/classes/class_fileaccess.html)
|
||||
413
godot_client/docs/EXPORT.md
Normal file
413
godot_client/docs/EXPORT.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# Export Configuration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Code of Conquest targets multiple platforms:
|
||||
- Desktop: Windows, macOS, Linux
|
||||
- Mobile: Android, iOS
|
||||
- Web: HTML5/WebAssembly
|
||||
|
||||
Each platform requires specific configuration in Godot's export system.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. Install Export Templates
|
||||
|
||||
In Godot Editor:
|
||||
1. Editor → Manage Export Templates
|
||||
2. Click "Download and Install"
|
||||
3. Wait for templates to download (~300-500 MB)
|
||||
|
||||
Alternatively, download manually from [Godot downloads](https://godotengine.org/download).
|
||||
|
||||
### 2. Platform-Specific Requirements
|
||||
|
||||
#### Windows
|
||||
- **Template**: Included by default
|
||||
- **Optional**: Custom icon (.ico file)
|
||||
- **Optional**: Code signing certificate for production
|
||||
|
||||
#### macOS
|
||||
- **Template**: Included by default
|
||||
- **Requirements** (for distribution):
|
||||
- macOS machine for final build
|
||||
- Apple Developer account ($99/year)
|
||||
- Code signing certificate
|
||||
- Notarization for Gatekeeper
|
||||
|
||||
#### Linux
|
||||
- **Template**: Included by default
|
||||
- **Note**: Export on Linux for best results
|
||||
- **Distribution**: AppImage, Flatpak, or raw binary
|
||||
|
||||
#### Android
|
||||
- **Android SDK**: API level 33+ (Android 13)
|
||||
- Download via Android Studio or command-line tools
|
||||
- Set path in Godot: Editor → Editor Settings → Export → Android → SDK Path
|
||||
|
||||
- **Java JDK**: Version 17 or later
|
||||
|
||||
- **Keystore for Signing**:
|
||||
```bash
|
||||
keytool -genkey -v -keystore code_of_conquest.keystore \
|
||||
-alias coc_key -keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
|
||||
- **Package Name**: `com.codeofconquest.game`
|
||||
|
||||
#### iOS
|
||||
- **Requirements**:
|
||||
- macOS with Xcode installed
|
||||
- Apple Developer account ($99/year)
|
||||
- Provisioning profile
|
||||
- Code signing certificate
|
||||
|
||||
- **Bundle ID**: `com.codeofconquest.game`
|
||||
|
||||
- **Export Process**:
|
||||
1. Export Xcode project from Godot
|
||||
2. Open in Xcode
|
||||
3. Configure signing
|
||||
4. Build for device or App Store
|
||||
|
||||
#### Web (HTML5)
|
||||
- **Template**: Included by default
|
||||
- **Server Requirements**:
|
||||
- HTTPS (for SharedArrayBuffer support)
|
||||
- CORS headers for cross-origin resources
|
||||
- Proper MIME types for .wasm files
|
||||
|
||||
- **PWA Configuration** (optional):
|
||||
- Manifest file for installability
|
||||
- Service worker for offline support
|
||||
- Icons in multiple sizes
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
### Step 1: Open Export Window
|
||||
|
||||
In Godot:
|
||||
1. Project → Export
|
||||
2. Click "Add..." to add a new preset
|
||||
|
||||
### Step 2: Configure Each Platform
|
||||
|
||||
#### Windows Export
|
||||
|
||||
1. Add → Windows Desktop
|
||||
2. Configure:
|
||||
- **Executable Name**: `CodeOfConquest.exe`
|
||||
- **Export Path**: `builds/windows/CodeOfConquest.exe`
|
||||
- **Icon**: `assets/ui/icon.ico` (optional)
|
||||
- **Embed PCK**: `true` (for single-file distribution)
|
||||
|
||||
3. Features:
|
||||
- 64-bit: Recommended for modern systems
|
||||
- 32-bit: Optional for older systems
|
||||
|
||||
#### Linux Export
|
||||
|
||||
1. Add → Linux/X11
|
||||
2. Configure:
|
||||
- **Executable Name**: `CodeOfConquest.x86_64`
|
||||
- **Export Path**: `builds/linux/CodeOfConquest.x86_64`
|
||||
- **Embed PCK**: `true`
|
||||
|
||||
3. After export, set executable permissions:
|
||||
```bash
|
||||
chmod +x CodeOfConquest.x86_64
|
||||
```
|
||||
|
||||
#### macOS Export
|
||||
|
||||
1. Add → macOS
|
||||
2. Configure:
|
||||
- **App Name**: `Code of Conquest`
|
||||
- **Bundle ID**: `com.codeofconquest.game`
|
||||
- **Export Path**: `builds/macos/CodeOfConquest.dmg`
|
||||
- **Icon**: `assets/ui/icon.icns` (optional)
|
||||
|
||||
3. For distribution:
|
||||
- Set code signing identity
|
||||
- Enable hardened runtime
|
||||
- Enable notarization
|
||||
|
||||
#### Android Export
|
||||
|
||||
1. Add → Android
|
||||
2. Configure:
|
||||
- **Package Name**: `com.codeofconquest.game`
|
||||
- **Version Code**: `1` (increment for each release)
|
||||
- **Version Name**: `0.1.0`
|
||||
- **Min SDK**: `21` (Android 5.0)
|
||||
- **Target SDK**: `33` (Android 13)
|
||||
|
||||
3. Keystore:
|
||||
- **Debug Keystore**: Auto-generated (for testing)
|
||||
- **Release Keystore**: Path to your `.keystore` file
|
||||
- **Release User**: Keystore alias (e.g., `coc_key`)
|
||||
- **Release Password**: Your keystore password
|
||||
|
||||
4. Permissions (add in export preset):
|
||||
- `INTERNET` - Required for API calls
|
||||
- `ACCESS_NETWORK_STATE` - Check connectivity
|
||||
|
||||
5. Screen Orientation:
|
||||
- Landscape or Portrait (choose based on game design)
|
||||
|
||||
6. Graphics:
|
||||
- Renderer: GL Compatibility (best mobile support)
|
||||
|
||||
7. Export:
|
||||
- **APK**: For direct installation/testing
|
||||
- **AAB** (Android App Bundle): For Google Play Store
|
||||
|
||||
#### iOS Export
|
||||
|
||||
1. Add → iOS
|
||||
2. Configure:
|
||||
- **Bundle ID**: `com.codeofconquest.game`
|
||||
- **Version**: `0.1.0`
|
||||
- **Icon**: Multiple sizes required (see Xcode)
|
||||
- **Orientation**: Landscape or Portrait
|
||||
|
||||
3. Provisioning:
|
||||
- **Team ID**: From Apple Developer account
|
||||
- **Provisioning Profile**: Development or Distribution
|
||||
|
||||
4. Export:
|
||||
- Export as Xcode project
|
||||
- Open in Xcode for final build
|
||||
|
||||
5. Capabilities (configure in Xcode):
|
||||
- Network requests
|
||||
- Push notifications (if needed later)
|
||||
|
||||
#### Web Export
|
||||
|
||||
1. Add → HTML5
|
||||
2. Configure:
|
||||
- **Export Path**: `builds/web/index.html`
|
||||
- **Export Type**: Regular or PWA
|
||||
- **Head Include**: Custom HTML header (optional)
|
||||
|
||||
3. Features:
|
||||
- **Thread Support**: Enable for better performance (requires COOP/COEP headers)
|
||||
- **SharedArrayBuffer**: Requires HTTPS + specific headers
|
||||
|
||||
4. Server Configuration:
|
||||
- Must serve with proper MIME types:
|
||||
- `.wasm`: `application/wasm`
|
||||
- `.pck`: `application/octet-stream`
|
||||
|
||||
5. CORS Headers (for threaded builds):
|
||||
```
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
```
|
||||
|
||||
6. PWA Manifest (optional):
|
||||
```json
|
||||
{
|
||||
"name": "Code of Conquest",
|
||||
"short_name": "CoC",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a2e",
|
||||
"theme_color": "#d4af37",
|
||||
"icons": [...]
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Exports
|
||||
|
||||
### Desktop Testing
|
||||
|
||||
1. Export to local directory
|
||||
2. Run executable directly
|
||||
3. Test with backend running on localhost
|
||||
4. Verify:
|
||||
- Window sizing and fullscreen
|
||||
- Keyboard/mouse input
|
||||
- API connectivity
|
||||
- Save/load functionality
|
||||
|
||||
### Mobile Testing
|
||||
|
||||
#### Android
|
||||
1. Enable USB debugging on device
|
||||
2. Connect via USB
|
||||
3. Use `adb install` to install APK:
|
||||
```bash
|
||||
adb install builds/android/CodeOfConquest.apk
|
||||
```
|
||||
4. Or export to device directly from Godot (Debug → Deploy)
|
||||
|
||||
5. Verify:
|
||||
- Touch input responsiveness
|
||||
- Screen orientation
|
||||
- Battery usage
|
||||
- Network connectivity
|
||||
- Permissions granted
|
||||
|
||||
#### iOS
|
||||
1. Configure development provisioning
|
||||
2. Export Xcode project
|
||||
3. Open in Xcode
|
||||
4. Build to connected device
|
||||
5. Verify same as Android
|
||||
|
||||
### Web Testing
|
||||
|
||||
1. Export to directory
|
||||
2. Serve locally:
|
||||
```bash
|
||||
python3 -m http.server 8000
|
||||
```
|
||||
3. Open `http://localhost:8000` in browser
|
||||
4. Verify:
|
||||
- Loading performance
|
||||
- Browser compatibility (Chrome, Firefox, Safari)
|
||||
- WebGL rendering
|
||||
- Network requests
|
||||
- Mobile browser (responsive)
|
||||
|
||||
## Distribution
|
||||
|
||||
### Desktop
|
||||
|
||||
**Windows**:
|
||||
- Distribute .exe directly
|
||||
- Or create installer (e.g., Inno Setup, NSIS)
|
||||
- Upload to Steam, itch.io, etc.
|
||||
|
||||
**macOS**:
|
||||
- Create .dmg for distribution
|
||||
- Notarize for Gatekeeper
|
||||
- Upload to Mac App Store or direct download
|
||||
|
||||
**Linux**:
|
||||
- Raw binary
|
||||
- AppImage (self-contained)
|
||||
- Flatpak (Flathub distribution)
|
||||
- Snap package
|
||||
|
||||
### Mobile
|
||||
|
||||
**Android**:
|
||||
- Google Play Store (requires AAB)
|
||||
- Amazon Appstore
|
||||
- F-Droid (open source)
|
||||
- Direct APK download (sideloading)
|
||||
|
||||
**iOS**:
|
||||
- App Store (requires App Store Connect)
|
||||
- TestFlight (beta testing)
|
||||
- Enterprise distribution (if applicable)
|
||||
|
||||
### Web
|
||||
|
||||
**Hosting Options**:
|
||||
- Static hosting: Netlify, Vercel, GitHub Pages
|
||||
- CDN: Cloudflare, AWS S3 + CloudFront
|
||||
- Game platforms: itch.io (supports HTML5)
|
||||
|
||||
**Deployment**:
|
||||
1. Export web build
|
||||
2. Upload all files (index.html, .wasm, .pck, etc.)
|
||||
3. Configure COOP/COEP headers if using threads
|
||||
4. Test on production URL
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### General
|
||||
- Use compressed textures (ETC2 for mobile, S3TC for desktop)
|
||||
- Minimize draw calls
|
||||
- Use LOD (Level of Detail) for 3D
|
||||
- Profile with Godot profiler
|
||||
|
||||
### Mobile Specific
|
||||
- Reduce resolution for lower-end devices
|
||||
- Limit particle effects
|
||||
- Use simpler shaders
|
||||
- Test on low-end devices
|
||||
- Monitor battery usage
|
||||
|
||||
### Web Specific
|
||||
- Optimize asset sizes (compress images, audio)
|
||||
- Use progressive loading for large assets
|
||||
- Minimize WASM binary size
|
||||
- Cache assets with service worker
|
||||
|
||||
## Platform-Specific Considerations
|
||||
|
||||
### Windows
|
||||
- Antivirus may flag unsigned executables
|
||||
- Consider code signing for production
|
||||
- Test on Windows 10 and 11
|
||||
|
||||
### macOS
|
||||
- Gatekeeper will block unsigned apps
|
||||
- Notarization required for distribution
|
||||
- Test on both Intel and Apple Silicon (M1/M2)
|
||||
|
||||
### Linux
|
||||
- Provide both 32-bit and 64-bit builds
|
||||
- Test on multiple distros (Ubuntu, Fedora, Arch)
|
||||
- Include library dependencies or use static linking
|
||||
|
||||
### Android
|
||||
- Target latest API level for Play Store
|
||||
- Test on multiple screen sizes and densities
|
||||
- Handle back button properly
|
||||
- Support safe areas (notches, rounded corners)
|
||||
|
||||
### iOS
|
||||
- Follow Apple Human Interface Guidelines
|
||||
- Support safe areas
|
||||
- Handle app lifecycle (background/foreground)
|
||||
- Test on multiple devices (iPhone, iPad)
|
||||
|
||||
### Web
|
||||
- Fallback for browsers without WebGL
|
||||
- Loading screen for asset downloads
|
||||
- Handle browser resize events
|
||||
- Support both desktop and mobile browsers
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Missing export template"
|
||||
→ Download templates via Editor → Manage Export Templates
|
||||
|
||||
### "Android SDK not found"
|
||||
→ Set SDK path in Editor Settings → Export → Android
|
||||
|
||||
### "Code signing failed" (macOS/iOS)
|
||||
→ Verify certificate and provisioning profile
|
||||
|
||||
### "Web build won't load"
|
||||
→ Check browser console, verify MIME types, check CORS
|
||||
|
||||
### "App crashes on mobile"
|
||||
→ Check device logs (adb logcat for Android, Xcode console for iOS)
|
||||
|
||||
## Resources
|
||||
|
||||
- [Godot Export Documentation](https://docs.godotengine.org/en/stable/tutorials/export/)
|
||||
- [Android Publishing Guide](https://developer.android.com/studio/publish)
|
||||
- [iOS App Distribution](https://developer.apple.com/documentation/xcode/distributing-your-app-for-beta-testing-and-releases)
|
||||
- [PWA Documentation](https://web.dev/progressive-web-apps/)
|
||||
|
||||
## Automation (Future)
|
||||
|
||||
Consider automating builds with CI/CD:
|
||||
- GitHub Actions
|
||||
- GitLab CI
|
||||
- Jenkins
|
||||
|
||||
Example workflow:
|
||||
1. Commit to `main` branch
|
||||
2. Trigger automated export for all platforms
|
||||
3. Run automated tests
|
||||
4. Upload builds to distribution channels
|
||||
5. Tag release with version number
|
||||
381
godot_client/docs/GETTING_STARTED.md
Normal file
381
godot_client/docs/GETTING_STARTED.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Getting Started with Godot Client
|
||||
|
||||
## Phase 1 Complete! ✓
|
||||
|
||||
The foundation for the Godot client is now in place. Here's what's been created:
|
||||
|
||||
### What's Built
|
||||
|
||||
✅ **Project Structure**: Complete directory organization
|
||||
✅ **Core Services**: HTTPClient and StateManager singletons
|
||||
✅ **Theme System**: Color palette and styling utilities
|
||||
✅ **UI Components**: CustomButton, Card, FormField
|
||||
✅ **Documentation**: Architecture, themes, components, export
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
godot_client/
|
||||
├── project.godot # Main project file
|
||||
├── README.md # Project overview
|
||||
├── ARCHITECTURE.md # Architecture documentation
|
||||
├── THEME_SETUP.md # Theme and font setup guide
|
||||
├── EXPORT.md # Export configuration guide
|
||||
├── GETTING_STARTED.md # This file
|
||||
│
|
||||
├── scenes/
|
||||
│ ├── auth/ # Authentication screens (TODO)
|
||||
│ ├── character/ # Character management (TODO)
|
||||
│ ├── combat/ # Combat UI (TODO)
|
||||
│ ├── world/ # World exploration (TODO)
|
||||
│ └── components/ # Reusable UI components
|
||||
│ └── README.md # Component documentation
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── services/
|
||||
│ │ ├── http_client.gd # ✓ API communication service
|
||||
│ │ └── state_manager.gd # ✓ Global state management
|
||||
│ ├── models/ # Data models (TODO)
|
||||
│ ├── utils/
|
||||
│ │ └── theme_colors.gd # ✓ Color palette
|
||||
│ └── components/
|
||||
│ ├── custom_button.gd # ✓ Custom button component
|
||||
│ ├── card.gd # ✓ Card container component
|
||||
│ └── form_field.gd # ✓ Form input component
|
||||
│
|
||||
└── assets/
|
||||
├── fonts/ # Fonts (TODO: download)
|
||||
│ └── README.md # Font installation guide
|
||||
├── themes/ # Theme resources (TODO: create in editor)
|
||||
└── ui/ # UI assets (icons, etc.)
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
### 1. Open Project in Godot
|
||||
|
||||
1. **Download Godot 4.5** from [godotengine.org](https://godotengine.org/)
|
||||
|
||||
2. **Launch Godot and import project**:
|
||||
- Click "Import"
|
||||
- Navigate to `godot_client/project.godot`
|
||||
- Click "Import & Edit"
|
||||
|
||||
3. **Install export templates** (for exporting to platforms):
|
||||
- Editor → Manage Export Templates
|
||||
- Download and Install
|
||||
|
||||
### 2. Set Up Fonts
|
||||
|
||||
1. **Download fonts**:
|
||||
- [Cinzel](https://fonts.google.com/specimen/Cinzel) - for headings
|
||||
- [Lato](https://fonts.google.com/specimen/Lato) - for body text
|
||||
|
||||
2. **Copy to project**:
|
||||
- Extract .ttf files
|
||||
- Copy to `godot_client/assets/fonts/`
|
||||
- Files needed:
|
||||
- Cinzel-Regular.ttf, Cinzel-Medium.ttf, Cinzel-SemiBold.ttf, Cinzel-Bold.ttf
|
||||
- Lato-Regular.ttf, Lato-Bold.ttf, Lato-Italic.ttf
|
||||
|
||||
3. **Reimport in Godot** (automatic, or click "Reimport" in FileSystem panel)
|
||||
|
||||
See `THEME_SETUP.md` for details.
|
||||
|
||||
### 3. Create Main Theme
|
||||
|
||||
1. **Create theme resource**:
|
||||
- Right-click `assets/themes/` in Godot FileSystem
|
||||
- New Resource → Theme
|
||||
- Save as `main_theme.tres`
|
||||
|
||||
2. **Configure theme** (see `THEME_SETUP.md`):
|
||||
- Set default font to Lato-Regular.ttf
|
||||
- Configure button colors and styleboxes
|
||||
- Configure panel styleboxes
|
||||
- Configure input field styles
|
||||
|
||||
3. **Set project theme**:
|
||||
- Project → Project Settings → GUI → Theme
|
||||
- Set Custom Theme to `res://assets/themes/main_theme.tres`
|
||||
|
||||
### 4. Configure Backend URL
|
||||
|
||||
Edit `scripts/services/http_client.gd`:
|
||||
|
||||
```gdscript
|
||||
# Line 16
|
||||
const API_BASE_URL := "http://localhost:5000" # Change if needed
|
||||
```
|
||||
|
||||
For production, you might use:
|
||||
- `https://api.codeofconquest.com`
|
||||
- Or make it configurable via settings
|
||||
|
||||
### 5. Create Main Scene
|
||||
|
||||
Create `scenes/main.tscn`:
|
||||
|
||||
1. **Scene → New Scene**
|
||||
2. **Root node**: Control
|
||||
3. **Add script**: `scripts/main.gd`
|
||||
|
||||
**main.gd**:
|
||||
```gdscript
|
||||
extends Control
|
||||
|
||||
func _ready() -> void:
|
||||
print("Code of Conquest starting...")
|
||||
|
||||
# Check if authenticated
|
||||
if StateManager.is_authenticated():
|
||||
print("User authenticated, loading character list...")
|
||||
# TODO: Navigate to character list
|
||||
else:
|
||||
print("Not authenticated, showing login...")
|
||||
# TODO: Navigate to login
|
||||
```
|
||||
|
||||
4. **Set as main scene**:
|
||||
- Project → Project Settings → Application → Run
|
||||
- Set Main Scene to `res://scenes/main.tscn`
|
||||
|
||||
### 6. Test Services
|
||||
|
||||
Create a test scene to verify services work:
|
||||
|
||||
**scenes/test_services.tscn**:
|
||||
- Root: Control
|
||||
- Add: Button ("Test API")
|
||||
|
||||
**test_services.gd**:
|
||||
```gdscript
|
||||
extends Control
|
||||
|
||||
@onready var test_button = $TestButton
|
||||
|
||||
func _ready():
|
||||
test_button.pressed.connect(_on_test_clicked)
|
||||
|
||||
func _on_test_clicked():
|
||||
print("Testing HTTPClient...")
|
||||
|
||||
# Test API call (replace with real endpoint)
|
||||
# Note: Can use HTTPClient directly in _ready() or later
|
||||
HTTPClient.http_get("/api/v1/health", _on_api_success, _on_api_error)
|
||||
|
||||
# Alternative (always works):
|
||||
# get_node("/root/HTTPClient").http_get("/api/v1/health", _on_api_success, _on_api_error)
|
||||
|
||||
func _on_api_success(response):
|
||||
print("API Success!")
|
||||
print("Status: ", response.status)
|
||||
print("Result: ", response.result)
|
||||
|
||||
func _on_api_error(response):
|
||||
print("API Error!")
|
||||
print("Error: ", response.get_error_message())
|
||||
```
|
||||
|
||||
**Note**: If you get "Static function not found" errors, use `get_node("/root/HTTPClient")` instead of direct `HTTPClient` access.
|
||||
|
||||
Run this scene (F6) and click the button to test API connectivity.
|
||||
|
||||
## Phase 2: Authentication Screens
|
||||
|
||||
Once the foundation is set up, proceed with Phase 2 (Week 2):
|
||||
|
||||
### Auth Screens to Build
|
||||
|
||||
1. **Login Screen** (`scenes/auth/login.tscn`)
|
||||
- Email and password fields (using FormField)
|
||||
- Login button (using CustomButton)
|
||||
- Links to register and forgot password
|
||||
|
||||
2. **Register Screen** (`scenes/auth/register.tscn`)
|
||||
- Email, password, confirm password
|
||||
- Validation
|
||||
- Register button
|
||||
|
||||
3. **Forgot Password** (`scenes/auth/forgot_password.tscn`)
|
||||
- Email field
|
||||
- Send reset link button
|
||||
|
||||
4. **Reset Password** (`scenes/auth/reset_password.tscn`)
|
||||
- New password and confirm
|
||||
- Reset button
|
||||
|
||||
5. **Email Verification** (`scenes/auth/verify_email.tscn`)
|
||||
- Verification status display
|
||||
|
||||
### Example: Login Screen
|
||||
|
||||
1. **Create scene**: `scenes/auth/login.tscn`
|
||||
2. **Root**: Control node
|
||||
3. **Add**:
|
||||
- Card container (center of screen)
|
||||
- FormField for email
|
||||
- FormField for password
|
||||
- CustomButton for login
|
||||
- Label for errors
|
||||
|
||||
4. **Attach script**: `scenes/auth/login.gd`
|
||||
|
||||
See `ARCHITECTURE.md` for complete login implementation example.
|
||||
|
||||
## Helpful Resources
|
||||
|
||||
### Documentation Created
|
||||
|
||||
- **README.md** - Project overview, setup, and development guide
|
||||
- **ARCHITECTURE.md** - Complete architecture documentation with examples
|
||||
- **THEME_SETUP.md** - Theme creation and font setup
|
||||
- **EXPORT.md** - Platform export configuration
|
||||
- **scenes/components/README.md** - UI component guide
|
||||
|
||||
### External Resources
|
||||
|
||||
- [Godot Documentation](https://docs.godotengine.org/en/stable/)
|
||||
- [GDScript Reference](https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/index.html)
|
||||
- [Godot UI Tutorials](https://docs.godotengine.org/en/stable/tutorials/ui/index.html)
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "Autoload not found" error
|
||||
|
||||
**Problem**: HTTPClient or StateManager not found
|
||||
**Solution**: Check `project.godot` has these lines in `[autoload]` section:
|
||||
```
|
||||
HTTPClient="*res://scripts/services/http_client.gd"
|
||||
StateManager="*res://scripts/services/state_manager.gd"
|
||||
```
|
||||
|
||||
### "Class ThemeColors not found"
|
||||
|
||||
**Problem**: ThemeColors class not recognized
|
||||
**Solution**:
|
||||
1. Check file exists at `scripts/utils/theme_colors.gd`
|
||||
2. Restart Godot (to refresh script cache)
|
||||
3. Ensure `class_name ThemeColors` is at top of file
|
||||
|
||||
### API calls failing
|
||||
|
||||
**Problem**: HTTPClient can't connect to Flask backend
|
||||
**Solution**:
|
||||
1. Ensure Flask backend is running on `localhost:5000`
|
||||
2. Check `API_BASE_URL` in `http_client.gd`
|
||||
3. Check Flask CORS settings if running on different port/domain
|
||||
|
||||
### Fonts not showing
|
||||
|
||||
**Problem**: Custom fonts not displaying
|
||||
**Solution**:
|
||||
1. Ensure .ttf files are in `assets/fonts/`
|
||||
2. Check font imports (click font in FileSystem)
|
||||
3. Verify theme is set in Project Settings
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Typical Development Session
|
||||
|
||||
1. **Start Flask backend**:
|
||||
```bash
|
||||
cd /home/ptarrant/repos/coc
|
||||
source venv/bin/activate
|
||||
python -m flask run
|
||||
```
|
||||
|
||||
2. **Open Godot project**:
|
||||
- Launch Godot
|
||||
- Open `godot_client`
|
||||
|
||||
3. **Work on scene**:
|
||||
- Edit scene in Godot editor
|
||||
- Attach/edit scripts
|
||||
- Test with F6 (run scene) or F5 (run project)
|
||||
|
||||
4. **Debug**:
|
||||
- Check Output panel for print statements
|
||||
- Check Debugger panel for errors
|
||||
- Use Godot debugger (breakpoints, step through)
|
||||
|
||||
5. **Test on device**:
|
||||
- Export to platform (see `EXPORT.md`)
|
||||
- Test on real device
|
||||
|
||||
### Git Workflow
|
||||
|
||||
```bash
|
||||
# Create feature branch
|
||||
git checkout -b feature/login-screen
|
||||
|
||||
# Work on feature
|
||||
# (edit files in Godot)
|
||||
|
||||
# Commit changes
|
||||
git add godot_client/
|
||||
git commit -m "feat: implement login screen UI"
|
||||
|
||||
# Merge to dev
|
||||
git checkout dev
|
||||
git merge feature/login-screen
|
||||
|
||||
# When ready for production
|
||||
git checkout master
|
||||
git merge dev
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
Before each release:
|
||||
|
||||
- [ ] Login/logout works
|
||||
- [ ] Character creation works
|
||||
- [ ] Character list displays correctly
|
||||
- [ ] API errors are handled gracefully
|
||||
- [ ] Network errors show user-friendly messages
|
||||
- [ ] State persists after app restart
|
||||
- [ ] Works on Windows
|
||||
- [ ] Works on macOS
|
||||
- [ ] Works on Linux
|
||||
- [ ] Works on Android
|
||||
- [ ] Works on iOS
|
||||
- [ ] Works in web browser
|
||||
|
||||
### Performance Checklist
|
||||
|
||||
- [ ] App starts in < 3 seconds
|
||||
- [ ] API calls complete in < 5 seconds
|
||||
- [ ] UI is responsive (no lag)
|
||||
- [ ] Memory usage is reasonable
|
||||
- [ ] Battery usage is acceptable (mobile)
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
- **Phase 1: Foundation** - ✅ Complete
|
||||
- **Phase 2: Authentication** (Week 2) - Ready to start
|
||||
- **Phase 3: Character Management** (Weeks 3-4) - Documented
|
||||
- **Phase 4: Platform Optimization** (Week 5) - Planned
|
||||
- **Phase 5: Future Features** (Week 6+) - Ongoing
|
||||
|
||||
With full-time focus, you should have a working app with auth and character management in 4-5 weeks.
|
||||
|
||||
## Questions?
|
||||
|
||||
- Check documentation in this directory
|
||||
- Read Godot documentation
|
||||
- Check Flask backend API documentation in `/docs`
|
||||
|
||||
## Ready to Start?
|
||||
|
||||
1. ✅ Open project in Godot
|
||||
2. ✅ Download and install fonts
|
||||
3. ✅ Create main theme
|
||||
4. ✅ Configure backend URL
|
||||
5. ✅ Create main scene
|
||||
6. ✅ Test services
|
||||
7. 🚀 Start building auth screens!
|
||||
|
||||
Good luck with the migration! The foundation is solid, and you're ready to build out the UI.
|
||||
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
|
||||
103
godot_client/docs/README.md
Normal file
103
godot_client/docs/README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Scene Documentation
|
||||
|
||||
This directory contains specifications for building Godot scenes.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Read the Scene Spec
|
||||
Each scene has a detailed specification document in `scenes/` that includes:
|
||||
- Visual mockup
|
||||
- Complete node hierarchy
|
||||
- Exact node names (important!)
|
||||
- Step-by-step build instructions
|
||||
- Colors and styling
|
||||
- What the script will handle
|
||||
|
||||
### 2. Build in Godot Editor
|
||||
Follow the spec to create the scene visually:
|
||||
- Add nodes in the specified hierarchy
|
||||
- Name them **exactly** as documented
|
||||
- Set properties as specified
|
||||
- Apply colors and styling
|
||||
|
||||
### 3. Save the Scene
|
||||
Save to the location specified in the doc (e.g., `scenes/auth/login.tscn`)
|
||||
|
||||
### 4. Notify
|
||||
Let Claude know the scene is ready
|
||||
|
||||
### 5. Script Creation
|
||||
Claude will:
|
||||
- Create the `.gd` script file
|
||||
- Add all the logic and API integration
|
||||
- Attach it to your scene
|
||||
|
||||
## Available Specs
|
||||
|
||||
### Scenes
|
||||
- [Login Screen](scenes/login_screen.md) - User authentication
|
||||
|
||||
### Components
|
||||
- CustomButton - See `scripts/components/custom_button.gd`
|
||||
- Card - See `scripts/components/card.gd`
|
||||
- FormField - See `scripts/components/form_field.gd`
|
||||
|
||||
## Color Reference
|
||||
|
||||
Quick reference for the theme colors:
|
||||
|
||||
```
|
||||
Backgrounds:
|
||||
Primary: #1a1a2e (very dark blue-gray)
|
||||
Secondary: #16213e (slightly lighter)
|
||||
Card: #1e1e2f (card backgrounds)
|
||||
|
||||
Text:
|
||||
Primary: #e4e4e7 (light gray)
|
||||
Secondary: #a1a1aa (medium gray)
|
||||
Disabled: #71717a (dark gray)
|
||||
|
||||
Accent:
|
||||
Gold: #d4af37 (primary)
|
||||
Gold Light: #f4d03f (hover)
|
||||
Gold Dark: #b8930a (pressed)
|
||||
|
||||
Status:
|
||||
Error: #ef4444 (red)
|
||||
Success: #10b981 (green)
|
||||
Warning: #f59e0b (orange)
|
||||
|
||||
Borders:
|
||||
Default: #3f3f46
|
||||
Accent: #d4af37 (gold)
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
### Node Naming
|
||||
- Use exact names from the spec (case-sensitive!)
|
||||
- Names must match so the script can find them
|
||||
- Example: `EmailInput` not `email_input`
|
||||
|
||||
### Styling
|
||||
- Use StyleBoxFlat for custom backgrounds
|
||||
- Set theme overrides in Inspector
|
||||
- Colors are specified as hex codes
|
||||
|
||||
### Layout
|
||||
- Use Container nodes for automatic layout
|
||||
- Set Custom Minimum Size for spacing/sizing
|
||||
- Use Control nodes for spacers
|
||||
|
||||
### Testing
|
||||
- Run the scene (F6) to preview
|
||||
- Check for errors in debugger
|
||||
- Verify all nodes are named correctly
|
||||
|
||||
## Questions?
|
||||
|
||||
Check the spec document for details. Each spec includes:
|
||||
- What nodes to create
|
||||
- What properties to set
|
||||
- Visual reference
|
||||
- Step-by-step instructions
|
||||
361
godot_client/docs/THEME_SETUP.md
Normal file
361
godot_client/docs/THEME_SETUP.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Theme Setup Guide
|
||||
|
||||
This guide explains how to set up the Code of Conquest theme in Godot, including fonts, colors, and UI styling.
|
||||
|
||||
## Overview
|
||||
|
||||
The theme recreates the web UI's RPG/fantasy aesthetic:
|
||||
- **Color Palette**: Dark slate gray backgrounds with gold accents
|
||||
- **Fonts**: Cinzel (headings), Lato (body text)
|
||||
- **Style**: Medieval/fantasy with ornate borders and rich colors
|
||||
|
||||
## Color Palette
|
||||
|
||||
All colors are defined in `scripts/utils/theme_colors.gd`:
|
||||
|
||||
### Backgrounds
|
||||
- Primary: `#1a1a2e` - Very dark blue-gray
|
||||
- Secondary: `#16213e` - Slightly lighter
|
||||
- Card: `#1e1e2f` - Card backgrounds
|
||||
|
||||
### Text
|
||||
- Primary: `#e4e4e7` - Light gray (main text)
|
||||
- Secondary: `#a1a1aa` - Medium gray (secondary text)
|
||||
- Disabled: `#71717a` - Dark gray (disabled)
|
||||
|
||||
### Accents
|
||||
- Gold: `#d4af37` - Primary accent color
|
||||
- Gold Light: `#f4d03f`
|
||||
- Gold Dark: `#b8930a`
|
||||
|
||||
### Status
|
||||
- Success: `#10b981` - Green
|
||||
- Error: `#ef4444` - Red
|
||||
- Warning: `#f59e0b` - Orange
|
||||
- Info: `#3b82f6` - Blue
|
||||
|
||||
## Font Setup
|
||||
|
||||
### 1. Download Fonts
|
||||
|
||||
**Cinzel** (for headings):
|
||||
- Download from [Google Fonts](https://fonts.google.com/specimen/Cinzel)
|
||||
- Download all weights (Regular, Medium, SemiBold, Bold)
|
||||
|
||||
**Lato** (for body text):
|
||||
- Download from [Google Fonts](https://fonts.google.com/specimen/Lato)
|
||||
- Download Regular, Bold, and Italic
|
||||
|
||||
### 2. Install Fonts in Project
|
||||
|
||||
1. Extract downloaded font files (.ttf or .otf)
|
||||
2. Copy to `godot_client/assets/fonts/`:
|
||||
```
|
||||
assets/fonts/
|
||||
├── Cinzel-Regular.ttf
|
||||
├── Cinzel-Medium.ttf
|
||||
├── Cinzel-SemiBold.ttf
|
||||
├── Cinzel-Bold.ttf
|
||||
├── Lato-Regular.ttf
|
||||
├── Lato-Bold.ttf
|
||||
└── Lato-Italic.ttf
|
||||
```
|
||||
|
||||
3. In Godot, the fonts will auto-import
|
||||
4. Check import settings (select font → Import tab):
|
||||
- Antialiasing: Enabled
|
||||
- Hinting: Full
|
||||
- Subpixel Positioning: Auto
|
||||
|
||||
### 3. Create Font Resources
|
||||
|
||||
For each font file:
|
||||
|
||||
1. Right-click font in FileSystem
|
||||
2. Select "New Resource"
|
||||
3. Choose "FontVariation" (or use font directly)
|
||||
4. Configure size and other properties
|
||||
5. Save as `.tres` resource
|
||||
|
||||
Example font resources to create:
|
||||
- `Cinzel_Heading_Large.tres` (Cinzel-Bold, 32px)
|
||||
- `Cinzel_Heading_Medium.tres` (Cinzel-SemiBold, 24px)
|
||||
- `Cinzel_Heading_Small.tres` (Cinzel-Medium, 18px)
|
||||
- `Lato_Body.tres` (Lato-Regular, 14px)
|
||||
- `Lato_Body_Bold.tres` (Lato-Bold, 14px)
|
||||
|
||||
## Theme Resource Setup
|
||||
|
||||
### 1. Create Main Theme
|
||||
|
||||
1. In Godot: Right-click `assets/themes/` → New Resource
|
||||
2. Select "Theme"
|
||||
3. Save as `main_theme.tres`
|
||||
|
||||
### 2. Configure Default Font
|
||||
|
||||
1. Select `main_theme.tres`
|
||||
2. In Inspector → Theme → Default Font:
|
||||
- Set to `Lato-Regular.ttf`
|
||||
3. Default Font Size: `14`
|
||||
|
||||
### 3. Configure Colors
|
||||
|
||||
For each Control type (Button, Label, LineEdit, etc.):
|
||||
|
||||
1. In Theme editor, expand control type
|
||||
2. Add color overrides:
|
||||
|
||||
**Button**:
|
||||
- `font_color`: `#e4e4e7` (TEXT_PRIMARY)
|
||||
- `font_hover_color`: `#f4d03f` (GOLD_LIGHT)
|
||||
- `font_pressed_color`: `#d4af37` (GOLD_ACCENT)
|
||||
- `font_disabled_color`: `#71717a` (TEXT_DISABLED)
|
||||
|
||||
**Label**:
|
||||
- `font_color`: `#e4e4e7` (TEXT_PRIMARY)
|
||||
|
||||
**LineEdit**:
|
||||
- `font_color`: `#e4e4e7` (TEXT_PRIMARY)
|
||||
- `font_placeholder_color`: `#a1a1aa` (TEXT_SECONDARY)
|
||||
- `caret_color`: `#d4af37` (GOLD_ACCENT)
|
||||
- `selection_color`: `#d4af37` (GOLD_ACCENT with alpha)
|
||||
|
||||
### 4. Configure StyleBoxes
|
||||
|
||||
Create StyleBoxFlat resources for backgrounds and borders:
|
||||
|
||||
**Button Normal**:
|
||||
1. Create New StyleBoxFlat
|
||||
2. Settings:
|
||||
- Background Color: `#1e1e2f` (BACKGROUND_CARD)
|
||||
- Border Width: `2` (all sides)
|
||||
- Border Color: `#3f3f46` (BORDER_DEFAULT)
|
||||
- Corner Radius: `4` (all corners)
|
||||
|
||||
**Button Hover**:
|
||||
- Background Color: `#16213e` (BACKGROUND_SECONDARY)
|
||||
- Border Color: `#d4af37` (GOLD_ACCENT)
|
||||
|
||||
**Button Pressed**:
|
||||
- Background Color: `#0f3460` (BACKGROUND_TERTIARY)
|
||||
- Border Color: `#d4af37` (GOLD_ACCENT)
|
||||
|
||||
**Button Disabled**:
|
||||
- Background Color: `#1a1a2e` (BACKGROUND_PRIMARY)
|
||||
- Border Color: `#27272a` (DIVIDER)
|
||||
|
||||
**Panel**:
|
||||
- Background Color: `#1e1e2f` (BACKGROUND_CARD)
|
||||
- Border Width: `1`
|
||||
- Border Color: `#3f3f46` (BORDER_DEFAULT)
|
||||
- Corner Radius: `8`
|
||||
|
||||
**LineEdit Normal**:
|
||||
- Background Color: `#16213e` (BACKGROUND_SECONDARY)
|
||||
- Border Width: `2`
|
||||
- Border Color: `#3f3f46` (BORDER_DEFAULT)
|
||||
- Corner Radius: `4`
|
||||
|
||||
**LineEdit Focus**:
|
||||
- Border Color: `#d4af37` (GOLD_ACCENT)
|
||||
|
||||
### 5. Set Theme in Project
|
||||
|
||||
1. Project → Project Settings → GUI → Theme
|
||||
2. Set Custom Theme to `res://assets/themes/main_theme.tres`
|
||||
|
||||
Or set per-scene by selecting root Control node and setting Theme property.
|
||||
|
||||
## Using the Theme
|
||||
|
||||
### In Scenes
|
||||
|
||||
Most UI elements will automatically use the theme. For custom styling:
|
||||
|
||||
```gdscript
|
||||
# Access theme colors
|
||||
var label = Label.new()
|
||||
label.add_theme_color_override("font_color", ThemeColors.GOLD_ACCENT)
|
||||
|
||||
# Access theme fonts
|
||||
var heading = Label.new()
|
||||
heading.add_theme_font_override("font", preload("res://assets/fonts/Cinzel_Heading_Large.tres"))
|
||||
|
||||
# Access theme styleboxes
|
||||
var panel = Panel.new()
|
||||
var stylebox = get_theme_stylebox("panel", "Panel").duplicate()
|
||||
stylebox.bg_color = ThemeColors.BACKGROUND_TERTIARY
|
||||
panel.add_theme_stylebox_override("panel", stylebox)
|
||||
```
|
||||
|
||||
### Creating Custom Styles
|
||||
|
||||
```gdscript
|
||||
# Create a card-style panel
|
||||
var stylebox = StyleBoxFlat.new()
|
||||
stylebox.bg_color = ThemeColors.BACKGROUND_CARD
|
||||
stylebox.border_width_all = 2
|
||||
stylebox.border_color = ThemeColors.BORDER_DEFAULT
|
||||
stylebox.corner_radius_all = 8
|
||||
stylebox.shadow_color = ThemeColors.SHADOW
|
||||
stylebox.shadow_size = 4
|
||||
|
||||
panel.add_theme_stylebox_override("panel", stylebox)
|
||||
```
|
||||
|
||||
### Reusable Component Scenes
|
||||
|
||||
Create reusable UI components in `scenes/components/`:
|
||||
|
||||
**Card.tscn**:
|
||||
- PanelContainer with card styling
|
||||
- Margins for content padding
|
||||
- Optional header and footer sections
|
||||
|
||||
**CustomButton.tscn**:
|
||||
- Button with custom styling
|
||||
- Hover effects
|
||||
- Icon support
|
||||
|
||||
**FormField.tscn**:
|
||||
- Label + LineEdit combo
|
||||
- Validation error display
|
||||
- Help text support
|
||||
|
||||
## Theme Variations
|
||||
|
||||
### Headings
|
||||
|
||||
Create different heading styles:
|
||||
|
||||
```gdscript
|
||||
# H1 - Large heading
|
||||
var h1 = Label.new()
|
||||
h1.add_theme_font_override("font", preload("res://assets/fonts/Cinzel_Heading_Large.tres"))
|
||||
h1.add_theme_color_override("font_color", ThemeColors.GOLD_ACCENT)
|
||||
|
||||
# H2 - Medium heading
|
||||
var h2 = Label.new()
|
||||
h2.add_theme_font_override("font", preload("res://assets/fonts/Cinzel_Heading_Medium.tres"))
|
||||
h2.add_theme_color_override("font_color", ThemeColors.TEXT_PRIMARY)
|
||||
|
||||
# H3 - Small heading
|
||||
var h3 = Label.new()
|
||||
h3.add_theme_font_override("font", preload("res://assets/fonts/Cinzel_Heading_Small.tres"))
|
||||
h3.add_theme_color_override("font_color", ThemeColors.TEXT_PRIMARY)
|
||||
```
|
||||
|
||||
### Status Messages
|
||||
|
||||
```gdscript
|
||||
# Success message
|
||||
var success_label = Label.new()
|
||||
success_label.add_theme_color_override("font_color", ThemeColors.SUCCESS)
|
||||
|
||||
# Error message
|
||||
var error_label = Label.new()
|
||||
error_label.add_theme_color_override("font_color", ThemeColors.ERROR)
|
||||
```
|
||||
|
||||
### Progress Bars
|
||||
|
||||
```gdscript
|
||||
# HP Bar
|
||||
var hp_bar = ProgressBar.new()
|
||||
hp_bar.add_theme_color_override("fill_color", ThemeColors.HP_COLOR)
|
||||
|
||||
# Mana Bar
|
||||
var mana_bar = ProgressBar.new()
|
||||
mana_bar.add_theme_color_override("fill_color", ThemeColors.MANA_COLOR)
|
||||
```
|
||||
|
||||
## Responsive Scaling
|
||||
|
||||
### DPI Scaling
|
||||
|
||||
Godot handles DPI scaling automatically. For custom scaling:
|
||||
|
||||
```gdscript
|
||||
# Get DPI scale
|
||||
var scale = DisplayServer.screen_get_scale()
|
||||
|
||||
# Adjust font size
|
||||
var font_size = 14 * scale
|
||||
label.add_theme_font_size_override("font_size", int(font_size))
|
||||
```
|
||||
|
||||
### Mobile Adjustments
|
||||
|
||||
```gdscript
|
||||
# Detect platform
|
||||
var is_mobile = OS.get_name() in ["Android", "iOS"]
|
||||
|
||||
if is_mobile:
|
||||
# Larger touch targets
|
||||
button.custom_minimum_size = Vector2(60, 60)
|
||||
# Larger fonts
|
||||
label.add_theme_font_size_override("font_size", 16)
|
||||
else:
|
||||
# Smaller desktop sizes
|
||||
button.custom_minimum_size = Vector2(40, 40)
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
```
|
||||
|
||||
## Testing the Theme
|
||||
|
||||
### Visual Consistency
|
||||
|
||||
Create a theme test scene with all UI elements:
|
||||
- Buttons (normal, hover, pressed, disabled)
|
||||
- Labels (headings, body, secondary)
|
||||
- Input fields (normal, focused, error)
|
||||
- Panels and cards
|
||||
- Progress bars
|
||||
- Lists and grids
|
||||
|
||||
### Cross-Platform
|
||||
|
||||
Test theme on:
|
||||
- Desktop (Windows/Mac/Linux) - Check with different DPI settings
|
||||
- Mobile (Android/iOS) - Check on different screen sizes
|
||||
- Web - Check in different browsers
|
||||
|
||||
## Advanced: Dynamic Theming
|
||||
|
||||
For future dark/light mode support:
|
||||
|
||||
```gdscript
|
||||
# Theme manager (future)
|
||||
class ThemeManager:
|
||||
static var current_theme = "dark"
|
||||
|
||||
static func switch_theme(theme_name: String):
|
||||
match theme_name:
|
||||
"dark":
|
||||
# Load dark theme
|
||||
get_tree().root.theme = preload("res://assets/themes/dark_theme.tres")
|
||||
"light":
|
||||
# Load light theme
|
||||
get_tree().root.theme = preload("res://assets/themes/light_theme.tres")
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Godot Theme Documentation](https://docs.godotengine.org/en/stable/tutorials/ui/gui_using_theme_editor.html)
|
||||
- [Google Fonts](https://fonts.google.com/)
|
||||
- [Color Palette Tool](https://coolors.co/)
|
||||
|
||||
## Quick Start Checklist
|
||||
|
||||
- [ ] Download Cinzel and Lato fonts
|
||||
- [ ] Copy fonts to `assets/fonts/`
|
||||
- [ ] Create `main_theme.tres` in `assets/themes/`
|
||||
- [ ] Set default font to Lato-Regular
|
||||
- [ ] Configure Button colors and styleboxes
|
||||
- [ ] Configure Panel styleboxes
|
||||
- [ ] Configure LineEdit colors and styleboxes
|
||||
- [ ] Create heading font variations
|
||||
- [ ] Set project theme in settings
|
||||
- [ ] Test in a sample scene
|
||||
- [ ] Create reusable component scenes
|
||||
452
godot_client/docs/scene_char_list.md
Normal file
452
godot_client/docs/scene_char_list.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# Character List Scene - Implementation Plan
|
||||
|
||||
**Status:** Draft - Pending Approval
|
||||
**Date:** November 20, 2025
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the implementation plan for the Character List scene in the Godot client. This scene displays all characters owned by the authenticated user and allows them to select a character to play or create a new one.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoint Reference
|
||||
|
||||
**Endpoint:** `GET /api/v1/characters`
|
||||
**Authentication:** Required
|
||||
**Source:** `api/app/api/characters.py:140-221`
|
||||
|
||||
### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"characters": [
|
||||
{
|
||||
"character_id": "char_001",
|
||||
"name": "Thorin Ironheart",
|
||||
"class": "vanguard",
|
||||
"class_name": "Vanguard",
|
||||
"level": 5,
|
||||
"experience": 250,
|
||||
"gold": 1000,
|
||||
"current_location": "town_square",
|
||||
"origin": "soul_revenant"
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"tier": "free",
|
||||
"limit": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scene Architecture
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
godot_client/
|
||||
├── scenes/
|
||||
│ └── character/
|
||||
│ ├── character_list.tscn # Main scene
|
||||
│ └── character_card.tscn # Reusable card component
|
||||
└── scripts/
|
||||
└── character/
|
||||
├── character_list.gd # List controller
|
||||
└── character_card.gd # Card component script
|
||||
```
|
||||
|
||||
### Scene Hierarchy
|
||||
|
||||
```
|
||||
CharacterList (Control)
|
||||
├── VBoxContainer
|
||||
│ ├── Header (HBoxContainer)
|
||||
│ │ ├── TitleLabel ("Your Characters")
|
||||
│ │ └── TierInfo (Label - "Free: 1/1")
|
||||
│ ├── LoadingIndicator (CenterContainer)
|
||||
│ │ └── ProgressIndicator
|
||||
│ ├── ErrorContainer (MarginContainer)
|
||||
│ │ └── ErrorLabel
|
||||
│ ├── CharacterScrollContainer (ScrollContainer)
|
||||
│ │ └── CharacterGrid (GridContainer or VBoxContainer)
|
||||
│ │ └── [CharacterCard instances]
|
||||
│ └── ActionContainer (HBoxContainer)
|
||||
│ ├── CreateButton ("Create New Character")
|
||||
│ └── RefreshButton
|
||||
└── EmptyState (CenterContainer)
|
||||
└── EmptyStateCard
|
||||
├── Icon
|
||||
├── MessageLabel ("No characters yet")
|
||||
└── CreateFirstButton
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Scene Setup
|
||||
|
||||
1. **Create character_list.tscn**
|
||||
- Root node: Control (full rect)
|
||||
- Add VBoxContainer with margins
|
||||
- Set up header with title and tier info
|
||||
- Add ScrollContainer for character list
|
||||
- Add action buttons at bottom
|
||||
|
||||
2. **Create character_card.tscn** (reusable component)
|
||||
- Use existing Card component as base
|
||||
- Layout: horizontal with left info, right actions
|
||||
- Display: name, class icon, level, gold, location
|
||||
- Buttons: Select, Delete
|
||||
|
||||
### Phase 2: Script Implementation
|
||||
|
||||
#### character_list.gd
|
||||
|
||||
```gdscript
|
||||
extends Control
|
||||
## Character List Screen
|
||||
##
|
||||
## Displays user's characters and allows selection or creation.
|
||||
|
||||
#region Signals
|
||||
signal character_selected(character_id: String)
|
||||
signal create_requested
|
||||
#endregion
|
||||
|
||||
#region Node References
|
||||
@onready var character_grid: Container = $VBoxContainer/CharacterScrollContainer/CharacterGrid
|
||||
@onready var loading_indicator: Control = $VBoxContainer/LoadingIndicator
|
||||
@onready var error_container: Control = $VBoxContainer/ErrorContainer
|
||||
@onready var error_label: Label = $VBoxContainer/ErrorContainer/ErrorLabel
|
||||
@onready var empty_state: Control = $EmptyState
|
||||
@onready var tier_label: Label = $VBoxContainer/Header/TierInfo
|
||||
@onready var create_button: Button = $VBoxContainer/ActionContainer/CreateButton
|
||||
@onready var refresh_button: Button = $VBoxContainer/ActionContainer/RefreshButton
|
||||
#endregion
|
||||
|
||||
#region Service References
|
||||
@onready var http_client: Node = get_node("/root/HTTPClient")
|
||||
@onready var state_manager: Node = get_node("/root/StateManager")
|
||||
#endregion
|
||||
|
||||
#region Private Variables
|
||||
var _characters: Array = []
|
||||
var _is_loading: bool = false
|
||||
var _tier: String = "free"
|
||||
var _limit: int = 1
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
func _ready() -> void:
|
||||
_connect_signals()
|
||||
load_characters()
|
||||
|
||||
func _connect_signals() -> void:
|
||||
create_button.pressed.connect(_on_create_button_pressed)
|
||||
refresh_button.pressed.connect(_on_refresh_button_pressed)
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
func load_characters() -> void:
|
||||
"""Fetch characters from API."""
|
||||
if _is_loading:
|
||||
return
|
||||
|
||||
_set_loading(true)
|
||||
_hide_error()
|
||||
|
||||
http_client.http_get(
|
||||
"/api/v1/characters",
|
||||
_on_characters_loaded,
|
||||
_on_characters_error
|
||||
)
|
||||
|
||||
func refresh() -> void:
|
||||
"""Refresh character list."""
|
||||
load_characters()
|
||||
#endregion
|
||||
|
||||
#region Private Methods
|
||||
func _set_loading(loading: bool) -> void:
|
||||
_is_loading = loading
|
||||
loading_indicator.visible = loading
|
||||
character_grid.visible = not loading
|
||||
refresh_button.disabled = loading
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
error_label.text = message
|
||||
error_container.visible = true
|
||||
|
||||
func _hide_error() -> void:
|
||||
error_container.visible = false
|
||||
|
||||
func _update_ui() -> void:
|
||||
# Update tier info
|
||||
tier_label.text = "%s: %d/%d" % [_tier.capitalize(), _characters.size(), _limit]
|
||||
|
||||
# Enable/disable create button based on limit
|
||||
create_button.disabled = _characters.size() >= _limit
|
||||
|
||||
# Show empty state or character list
|
||||
if _characters.is_empty():
|
||||
empty_state.visible = true
|
||||
character_grid.visible = false
|
||||
else:
|
||||
empty_state.visible = false
|
||||
character_grid.visible = true
|
||||
|
||||
func _populate_character_list() -> void:
|
||||
# Clear existing cards
|
||||
for child in character_grid.get_children():
|
||||
child.queue_free()
|
||||
|
||||
# Create card for each character
|
||||
var card_scene = preload("res://scenes/character/character_card.tscn")
|
||||
|
||||
for char_data in _characters:
|
||||
var card = card_scene.instantiate()
|
||||
card.set_character_data(char_data)
|
||||
card.selected.connect(_on_character_card_selected)
|
||||
card.delete_requested.connect(_on_character_delete_requested)
|
||||
character_grid.add_child(card)
|
||||
#endregion
|
||||
|
||||
#region Signal Handlers
|
||||
func _on_characters_loaded(response: APIResponse) -> void:
|
||||
_set_loading(false)
|
||||
|
||||
if not response.is_success():
|
||||
_on_characters_error(response)
|
||||
return
|
||||
|
||||
var result = response.result
|
||||
_characters = result.get("characters", [])
|
||||
_tier = result.get("tier", "free")
|
||||
_limit = result.get("limit", 1)
|
||||
|
||||
_populate_character_list()
|
||||
_update_ui()
|
||||
|
||||
func _on_characters_error(response: APIResponse) -> void:
|
||||
_set_loading(false)
|
||||
|
||||
var message = response.get_error_message()
|
||||
if message.is_empty():
|
||||
message = "Failed to load characters"
|
||||
|
||||
if response.status == 401:
|
||||
# Redirect to login
|
||||
get_tree().change_scene_to_file("res://scenes/auth/login.tscn")
|
||||
return
|
||||
|
||||
_show_error(message)
|
||||
|
||||
func _on_character_card_selected(character_id: String) -> void:
|
||||
state_manager.set_value("current_character_id", character_id)
|
||||
character_selected.emit(character_id)
|
||||
# Navigate to main game or character detail
|
||||
# get_tree().change_scene_to_file("res://scenes/game/main_game.tscn")
|
||||
|
||||
func _on_character_delete_requested(character_id: String) -> void:
|
||||
# Show confirmation dialog
|
||||
# Then call DELETE endpoint
|
||||
pass
|
||||
|
||||
func _on_create_button_pressed() -> void:
|
||||
create_requested.emit()
|
||||
# Navigate to character creation wizard
|
||||
# get_tree().change_scene_to_file("res://scenes/character/character_create.tscn")
|
||||
|
||||
func _on_refresh_button_pressed() -> void:
|
||||
refresh()
|
||||
#endregion
|
||||
```
|
||||
|
||||
#### character_card.gd
|
||||
|
||||
```gdscript
|
||||
extends Control
|
||||
class_name CharacterCard
|
||||
## Character Card Component
|
||||
##
|
||||
## Displays a single character's summary information.
|
||||
|
||||
#region Signals
|
||||
signal selected(character_id: String)
|
||||
signal delete_requested(character_id: String)
|
||||
#endregion
|
||||
|
||||
#region Node References
|
||||
@onready var name_label: Label = $Card/HBox/InfoVBox/NameLabel
|
||||
@onready var class_label: Label = $Card/HBox/InfoVBox/ClassLabel
|
||||
@onready var level_label: Label = $Card/HBox/InfoVBox/LevelLabel
|
||||
@onready var gold_label: Label = $Card/HBox/InfoVBox/GoldLabel
|
||||
@onready var location_label: Label = $Card/HBox/InfoVBox/LocationLabel
|
||||
@onready var select_button: Button = $Card/HBox/ActionVBox/SelectButton
|
||||
@onready var delete_button: Button = $Card/HBox/ActionVBox/DeleteButton
|
||||
#endregion
|
||||
|
||||
#region Private Variables
|
||||
var _character_id: String = ""
|
||||
var _character_data: Dictionary = {}
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
func _ready() -> void:
|
||||
select_button.pressed.connect(_on_select_pressed)
|
||||
delete_button.pressed.connect(_on_delete_pressed)
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
func set_character_data(data: Dictionary) -> void:
|
||||
"""Set character data and update display."""
|
||||
_character_data = data
|
||||
_character_id = data.get("character_id", "")
|
||||
|
||||
name_label.text = data.get("name", "Unknown")
|
||||
class_label.text = data.get("class_name", "Unknown Class")
|
||||
level_label.text = "Level %d" % data.get("level", 1)
|
||||
gold_label.text = "%d Gold" % data.get("gold", 0)
|
||||
location_label.text = _format_location(data.get("current_location", ""))
|
||||
|
||||
func get_character_id() -> String:
|
||||
return _character_id
|
||||
#endregion
|
||||
|
||||
#region Private Methods
|
||||
func _format_location(location_id: String) -> String:
|
||||
# Convert location_id to display name
|
||||
return location_id.replace("_", " ").capitalize()
|
||||
#endregion
|
||||
|
||||
#region Signal Handlers
|
||||
func _on_select_pressed() -> void:
|
||||
selected.emit(_character_id)
|
||||
|
||||
func _on_delete_pressed() -> void:
|
||||
delete_requested.emit(_character_id)
|
||||
#endregion
|
||||
```
|
||||
|
||||
### Phase 3: UI Polish
|
||||
|
||||
1. **Styling**
|
||||
- Use Card component for character cards
|
||||
- Apply theme colors from ThemeColors
|
||||
- Add class icons (placeholder or loaded)
|
||||
- Add hover/focus states
|
||||
|
||||
2. **Animations**
|
||||
- Fade in cards on load
|
||||
- Button press feedback
|
||||
- Loading spinner animation
|
||||
|
||||
3. **Responsive Layout**
|
||||
- GridContainer for desktop (2-3 columns)
|
||||
- VBoxContainer for mobile (single column)
|
||||
- Handle different screen sizes
|
||||
|
||||
### Phase 4: Integration
|
||||
|
||||
1. **Navigation Flow**
|
||||
- Login success → Character List
|
||||
- Character selected → Main Game
|
||||
- Create button → Character Creation Wizard
|
||||
- Empty state create → Character Creation Wizard
|
||||
|
||||
2. **State Management**
|
||||
- Store selected character_id in StateManager
|
||||
- Handle session expiration (401 errors)
|
||||
|
||||
3. **Delete Confirmation**
|
||||
- Add confirmation dialog component
|
||||
- Call DELETE /api/v1/characters/{id}
|
||||
- Refresh list on success
|
||||
|
||||
---
|
||||
|
||||
## UI Mockup
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Your Characters Free: 2/3 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Thorin Ironheart [Select] │ │
|
||||
│ │ Vanguard • Level 5 │ │
|
||||
│ │ 1,000 Gold │ │
|
||||
│ │ Town Square [Delete] │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Elara Shadowstep [Select] │ │
|
||||
│ │ Assassin • Level 3 │ │
|
||||
│ │ 500 Gold │ │
|
||||
│ │ Dark Forest [Delete] │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [Create New Character] [Refresh] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error Type | User Message | Action |
|
||||
|------------|--------------|--------|
|
||||
| 401 Unauthorized | "Session expired" | Redirect to login |
|
||||
| 500 Server Error | "Server error. Try again." | Show retry button |
|
||||
| Network Error | "Cannot connect to server" | Show retry button |
|
||||
| Empty Response | "No characters found" | Show empty state |
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Characters load on scene ready
|
||||
- [ ] Loading indicator shows during fetch
|
||||
- [ ] Error message displays on API error
|
||||
- [ ] 401 redirects to login
|
||||
- [ ] Empty state shows when no characters
|
||||
- [ ] Tier info updates correctly
|
||||
- [ ] Create button disabled when at limit
|
||||
- [ ] Select button navigates correctly
|
||||
- [ ] Delete shows confirmation
|
||||
- [ ] Delete refreshes list on success
|
||||
- [ ] Refresh button reloads list
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Existing:** HTTPClient, StateManager, APIResponse, Card component
|
||||
- **New:** CharacterCard component, ConfirmDialog (for delete)
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
- Phase 1 (Scene Setup): 1-2 hours
|
||||
- Phase 2 (Scripts): 2-3 hours
|
||||
- Phase 3 (UI Polish): 1-2 hours
|
||||
- Phase 4 (Integration): 1-2 hours
|
||||
|
||||
**Total:** 5-9 hours
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Consider caching character list in StateManager for faster navigation
|
||||
- Add pull-to-refresh for mobile
|
||||
- Consider adding character preview image in future
|
||||
- Location names should come from a mapping (not raw IDs)
|
||||
53
godot_client/export_presets_template.cfg
Normal file
53
godot_client/export_presets_template.cfg
Normal file
@@ -0,0 +1,53 @@
|
||||
# Export Presets Template for Code of Conquest
|
||||
#
|
||||
# This file serves as a template. Open the project in Godot and configure
|
||||
# export presets via Project → Export for each target platform.
|
||||
#
|
||||
# Required setup per platform:
|
||||
#
|
||||
# WINDOWS:
|
||||
# - Install Windows export template
|
||||
# - Configure executable icon (optional)
|
||||
#
|
||||
# LINUX:
|
||||
# - Install Linux export template
|
||||
# - Configure executable permissions
|
||||
#
|
||||
# MACOS:
|
||||
# - Install macOS export template
|
||||
# - Configure app bundle settings
|
||||
# - Set code signing identity (for distribution)
|
||||
#
|
||||
# ANDROID:
|
||||
# - Install Android SDK (API level 33+)
|
||||
# - Install Android export template
|
||||
# - Create keystore for signing:
|
||||
# keytool -genkey -v -keystore code_of_conquest.keystore \
|
||||
# -alias coc_key -keyalg RSA -keysize 2048 -validity 10000
|
||||
# - Configure keystore path and passwords in export preset
|
||||
# - Set package name: com.codeofconquest.game
|
||||
#
|
||||
# IOS:
|
||||
# - Requires macOS with Xcode installed
|
||||
# - Install iOS export template
|
||||
# - Configure provisioning profile
|
||||
# - Set bundle identifier: com.codeofconquest.game
|
||||
# - Configure code signing
|
||||
#
|
||||
# WEB (HTML5):
|
||||
# - Install Web export template
|
||||
# - Configure export settings:
|
||||
# - HTML shell template (optional custom)
|
||||
# - Enable SharedArrayBuffer (requires HTTPS + CORS headers)
|
||||
# - Configure PWA settings if desired
|
||||
#
|
||||
# STEPS:
|
||||
# 1. Open Godot project
|
||||
# 2. Go to Editor → Manage Export Templates → Download and Install
|
||||
# 3. Go to Project → Export
|
||||
# 4. Click "Add..." and select each platform
|
||||
# 5. Configure platform-specific settings
|
||||
# 6. Test export by exporting to local directory
|
||||
#
|
||||
# Once configured, Godot will generate export_presets.cfg automatically.
|
||||
# That file is gitignored to avoid committing signing credentials.
|
||||
60
godot_client/project.godot
Normal file
60
godot_client/project.godot
Normal file
@@ -0,0 +1,60 @@
|
||||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
;
|
||||
; Format:
|
||||
; [section] ; section goes between []
|
||||
; param=value ; assign values to parameters
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Code of Conquest"
|
||||
config/description="AI-powered D&D style RPG"
|
||||
config/version="0.1.0"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.5", "GL Compatibility")
|
||||
config/icon="res://assets/ui/icon.png"
|
||||
|
||||
[autoload]
|
||||
|
||||
Settings="*res://scripts/services/settings.gd"
|
||||
HTTPClient="*res://scripts/services/http_client.gd"
|
||||
StateManager="*res://scripts/services/state_manager.gd"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=720
|
||||
window/size/mode=2
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/aspect="expand"
|
||||
|
||||
[gui]
|
||||
|
||||
theme/custom="uid://bviqieumdiccr"
|
||||
theme/custom_font="res://assets/fonts/Lato-Regular.ttf"
|
||||
|
||||
[input]
|
||||
|
||||
ui_accept={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194310,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":true,"script":null)
|
||||
]
|
||||
}
|
||||
ui_cancel={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":true,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
textures/vram_compression/import_etc2_astc=true
|
||||
209
godot_client/scenes/auth/login.gd
Normal file
209
godot_client/scenes/auth/login.gd
Normal file
@@ -0,0 +1,209 @@
|
||||
extends Control
|
||||
## Login Screen Script
|
||||
##
|
||||
## Handles user authentication via the backend API.
|
||||
## Validates input, displays errors, and navigates to character list on success.
|
||||
|
||||
# Node references
|
||||
@onready var email_input: LineEdit = $CenterContainer/LoginCard/MainContainer/ContentVBox/EmailInput
|
||||
@onready var password_input: LineEdit = $CenterContainer/LoginCard/MainContainer/ContentVBox/PasswordInput
|
||||
@onready var login_button: Button = $CenterContainer/LoginCard/MainContainer/ContentVBox/LoginButton
|
||||
@onready var error_label: Label = $CenterContainer/LoginCard/MainContainer/ContentVBox/ErrorLabel
|
||||
@onready var remember_checkbox: CheckBox = $CenterContainer/LoginCard/MainContainer/ContentVBox/RememberCheckBox
|
||||
@onready var register_link: Button = $CenterContainer/LoginCard/MainContainer/ContentVBox/BottomLinksVBox/RegisterLink
|
||||
@onready var forgot_password_link: Button = $CenterContainer/LoginCard/MainContainer/ContentVBox/BottomLinksVBox/ForgotPasswordLink
|
||||
|
||||
# Service references
|
||||
@onready var http_client: Node = get_node("/root/HTTPClient")
|
||||
@onready var state_manager: Node = get_node("/root/StateManager")
|
||||
|
||||
# Internal state
|
||||
var _is_loading: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
print("[LoginScreen] Initialized")
|
||||
|
||||
# Connect button signals
|
||||
login_button.pressed.connect(_on_login_button_pressed)
|
||||
register_link.pressed.connect(_on_register_link_pressed)
|
||||
forgot_password_link.pressed.connect(_on_forgot_password_link_pressed)
|
||||
|
||||
# Connect Enter key to submit
|
||||
email_input.text_submitted.connect(_on_input_submitted)
|
||||
password_input.text_submitted.connect(_on_input_submitted)
|
||||
|
||||
# Hide error label by default
|
||||
error_label.visible = false
|
||||
|
||||
# Load saved remember setting
|
||||
remember_checkbox.button_pressed = state_manager.get_setting("remember_login", true)
|
||||
|
||||
|
||||
## Handle login button press
|
||||
func _on_login_button_pressed() -> void:
|
||||
# Prevent double-click
|
||||
if _is_loading:
|
||||
return
|
||||
|
||||
# Get input values
|
||||
var email := email_input.text.strip_edges()
|
||||
var password := password_input.text
|
||||
|
||||
# Validate inputs
|
||||
var validation_error := _validate_inputs(email, password)
|
||||
if not validation_error.is_empty():
|
||||
_show_error(validation_error)
|
||||
return
|
||||
|
||||
# Start login process
|
||||
_start_login(email, password)
|
||||
|
||||
|
||||
## Handle Enter key press in input fields
|
||||
func _on_input_submitted(_text: String = "") -> void:
|
||||
_on_login_button_pressed()
|
||||
|
||||
|
||||
## Validate email and password
|
||||
func _validate_inputs(email: String, password: String) -> String:
|
||||
# Check if email is empty
|
||||
if email.is_empty():
|
||||
return "Please enter your email address"
|
||||
|
||||
# Check if password is empty
|
||||
if password.is_empty():
|
||||
return "Please enter your password"
|
||||
|
||||
# Basic email format validation (contains @ and .)
|
||||
if not email.contains("@") or not email.contains("."):
|
||||
return "Please enter a valid email address"
|
||||
|
||||
# Check minimum password length
|
||||
if password.length() < 6:
|
||||
return "Password must be at least 6 characters"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
## Start login process
|
||||
func _start_login(email: String, password: String) -> void:
|
||||
_is_loading = true
|
||||
_hide_error()
|
||||
|
||||
# Disable button and show loading state
|
||||
login_button.disabled = true
|
||||
login_button.text = "LOGGING IN..."
|
||||
|
||||
# Build request payload
|
||||
var payload := {
|
||||
"email": email,
|
||||
"password": password,
|
||||
"remember_me": remember_checkbox.button_pressed
|
||||
}
|
||||
|
||||
print("[LoginScreen] Attempting login for: %s (remember_me=%s)" % [email, remember_checkbox.button_pressed])
|
||||
|
||||
# Make API request
|
||||
http_client.http_post(
|
||||
"/api/v1/auth/login",
|
||||
payload,
|
||||
_on_login_success,
|
||||
_on_login_error
|
||||
)
|
||||
|
||||
|
||||
## Handle successful login response
|
||||
func _on_login_success(response: APIResponse) -> void:
|
||||
_is_loading = false
|
||||
|
||||
# Re-enable button
|
||||
login_button.disabled = false
|
||||
login_button.text = "LOGIN"
|
||||
|
||||
# Check if response is actually successful
|
||||
if not response.is_success():
|
||||
_on_login_error(response)
|
||||
return
|
||||
|
||||
print("[LoginScreen] Login successful")
|
||||
|
||||
# Extract user data from response
|
||||
# Note: Authentication is cookie-based, so no token in response
|
||||
var result: Dictionary = response.result if response.result is Dictionary else {}
|
||||
var user_data: Dictionary = result.get("user", {})
|
||||
|
||||
if user_data.is_empty():
|
||||
_show_error("Invalid response from server")
|
||||
return
|
||||
|
||||
# Update remember setting
|
||||
state_manager.set_setting("remember_login", remember_checkbox.button_pressed)
|
||||
|
||||
# Save session to StateManager (cookie is already set in HTTPClient)
|
||||
state_manager.set_user_session(user_data)
|
||||
|
||||
print("[LoginScreen] User authenticated: %s" % user_data.get("email", "unknown"))
|
||||
print("[LoginScreen] Session cookie stored, ready for authenticated requests")
|
||||
|
||||
# Navigate back to main screen (which will show authenticated UI)
|
||||
print("[LoginScreen] Navigating to main screen...")
|
||||
get_tree().change_scene_to_file("res://scenes/main.tscn")
|
||||
|
||||
|
||||
## Handle login error
|
||||
func _on_login_error(response: APIResponse) -> void:
|
||||
_is_loading = false
|
||||
|
||||
# Re-enable button
|
||||
login_button.disabled = false
|
||||
login_button.text = "LOGIN"
|
||||
|
||||
# Get error message
|
||||
var error_message := response.get_error_message()
|
||||
|
||||
# Show user-friendly error
|
||||
if error_message.is_empty():
|
||||
error_message = "Login failed. Please try again."
|
||||
|
||||
# Handle specific error cases
|
||||
if response.status == 401:
|
||||
error_message = "Invalid email or password"
|
||||
elif response.status == 0 or response.status >= 500:
|
||||
error_message = "Cannot connect to server. Please check your connection."
|
||||
|
||||
print("[LoginScreen] Login error: %s (status=%d)" % [error_message, response.status])
|
||||
_show_error(error_message)
|
||||
|
||||
|
||||
## Show error message
|
||||
func _show_error(message: String) -> void:
|
||||
error_label.text = message
|
||||
error_label.visible = true
|
||||
|
||||
|
||||
## Hide error message
|
||||
func _hide_error() -> void:
|
||||
error_label.visible = false
|
||||
|
||||
|
||||
## Handle register link press
|
||||
func _on_register_link_pressed() -> void:
|
||||
print("[LoginScreen] Opening registration page in browser")
|
||||
var register_url := Settings.get_web_url() + "/auth/register"
|
||||
var error := OS.shell_open(register_url)
|
||||
|
||||
if error != OK:
|
||||
push_error("[LoginScreen] Failed to open browser: %s" % error)
|
||||
_show_error("Could not open registration page")
|
||||
|
||||
|
||||
## Handle forgot password link press
|
||||
func _on_forgot_password_link_pressed() -> void:
|
||||
print("[LoginScreen] Opening forgot password page in browser")
|
||||
var forgot_url := Settings.get_web_url() + "/auth/forgot-password"
|
||||
var error := OS.shell_open(forgot_url)
|
||||
|
||||
if error != OK:
|
||||
push_error("[LoginScreen] Failed to open browser: %s" % error)
|
||||
_show_error("Could not open password reset page")
|
||||
1
godot_client/scenes/auth/login.gd.uid
Normal file
1
godot_client/scenes/auth/login.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://260dpbodarqy
|
||||
241
godot_client/scenes/auth/login.tscn
Normal file
241
godot_client/scenes/auth/login.tscn
Normal file
@@ -0,0 +1,241 @@
|
||||
[gd_scene load_steps=10 format=3 uid="uid://pon554b5gdnu"]
|
||||
|
||||
[ext_resource type="Theme" uid="uid://bviqieumdiccr" path="res://assets/themes/main_theme.tres" id="1_8wugm"]
|
||||
[ext_resource type="Script" uid="uid://260dpbodarqy" path="res://scenes/auth/login.gd" id="1_lg6fp"]
|
||||
[ext_resource type="Texture2D" uid="uid://cja8kui47qb3d" path="res://assets/ui/main_menu.png" id="3_c4dse"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"]
|
||||
bg_color = Color(0, 0, 0, 0.9882353)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.247059, 0.247059, 0.278431, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_input_normal"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.0862745, 0.129412, 0.243137, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.247059, 0.247059, 0.278431, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_input_focus"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.0862745, 0.129412, 0.243137, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.831373, 0.686275, 0.215686, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_normal"]
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 16.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.831373, 0.686275, 0.215686, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.721569, 0.576471, 0.0392157, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_pressed"]
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 16.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.721569, 0.576471, 0.0392157, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.721569, 0.576471, 0.0392157, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"]
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 16.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.956863, 0.815686, 0.247059, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.721569, 0.576471, 0.0392157, 1)
|
||||
corner_radius_top_left = 4
|
||||
corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[node name="Login" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme = ExtResource("1_8wugm")
|
||||
script = ExtResource("1_lg6fp")
|
||||
|
||||
[node name="TextureRect" type="TextureRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
texture = ExtResource("3_c4dse")
|
||||
expand_mode = 2
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme = ExtResource("1_8wugm")
|
||||
|
||||
[node name="LoginCard" type="PanelContainer" parent="CenterContainer"]
|
||||
custom_minimum_size = Vector2(400, 0)
|
||||
layout_mode = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_card")
|
||||
|
||||
[node name="MainContainer" type="MarginContainer" parent="CenterContainer/LoginCard"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 32
|
||||
theme_override_constants/margin_top = 32
|
||||
theme_override_constants/margin_right = 32
|
||||
theme_override_constants/margin_bottom = 32
|
||||
|
||||
[node name="ContentVBox" type="VBoxContainer" parent="CenterContainer/LoginCard/MainContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.831373, 0.686275, 0.215686, 1)
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Welcome Back"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Spacer1" type="Control" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
custom_minimum_size = Vector2(0, 20)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="EmailLabel" type="Label" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.894118, 0.894118, 0.905882, 1)
|
||||
text = "Email"
|
||||
|
||||
[node name="EmailInput" type="LineEdit" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.894118, 0.894118, 0.905882, 1)
|
||||
theme_override_colors/font_placeholder_color = Color(0.631373, 0.631373, 0.666667, 1)
|
||||
theme_override_colors/caret_color = Color(0.831373, 0.686275, 0.215686, 1)
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_input_normal")
|
||||
theme_override_styles/focus = SubResource("StyleBoxFlat_input_focus")
|
||||
placeholder_text = "Enter your email"
|
||||
|
||||
[node name="Spacer2" type="Control" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
custom_minimum_size = Vector2(0, 12)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="PasswordLabel" type="Label" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.894118, 0.894118, 0.905882, 1)
|
||||
text = "Password"
|
||||
|
||||
[node name="PasswordInput" type="LineEdit" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
custom_minimum_size = Vector2(0, 40)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.894118, 0.894118, 0.905882, 1)
|
||||
theme_override_colors/font_placeholder_color = Color(0.631373, 0.631373, 0.666667, 1)
|
||||
theme_override_colors/caret_color = Color(0.831373, 0.686275, 0.215686, 1)
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_input_normal")
|
||||
theme_override_styles/focus = SubResource("StyleBoxFlat_input_focus")
|
||||
placeholder_text = "Enter your password"
|
||||
secret = true
|
||||
|
||||
[node name="Spacer3" type="Control" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
custom_minimum_size = Vector2(0, 12)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="RememberCheckBox" type="CheckBox" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.894118, 0.894118, 0.905882, 1)
|
||||
text = "Remember me"
|
||||
|
||||
[node name="Spacer4" type="Control" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
custom_minimum_size = Vector2(0, 20)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ErrorLabel" type="Label" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
visible = false
|
||||
custom_minimum_size = Vector2(300, 0)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.937255, 0.266667, 0.266667, 1)
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="LoginButton" type="Button" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.101961, 0.101961, 0.180392, 1)
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover")
|
||||
text = "LOGIN"
|
||||
|
||||
[node name="Spacer5" type="Control" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="BottomLinksVBox" type="VBoxContainer" parent="CenterContainer/LoginCard/MainContainer/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
alignment = 1
|
||||
|
||||
[node name="RegisterLink" type="Button" parent="CenterContainer/LoginCard/MainContainer/ContentVBox/BottomLinksVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.631373, 0.631373, 0.666667, 1)
|
||||
theme_override_colors/font_hover_color = Color(0.831373, 0.686275, 0.215686, 1)
|
||||
text = "Don't have an account? Register"
|
||||
flat = true
|
||||
|
||||
[node name="ForgotPasswordLink" type="Button" parent="CenterContainer/LoginCard/MainContainer/ContentVBox/BottomLinksVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.631373, 0.631373, 0.666667, 1)
|
||||
theme_override_colors/font_hover_color = Color(0.831373, 0.686275, 0.215686, 1)
|
||||
text = "Forgot password?"
|
||||
flat = true
|
||||
65
godot_client/scenes/character/character_card.tscn
Normal file
65
godot_client/scenes/character/character_card.tscn
Normal file
@@ -0,0 +1,65 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://dqx8k3h5nwc2r"]
|
||||
|
||||
[ext_resource type="Theme" uid="uid://bviqieumdiccr" path="res://assets/themes/main_theme.tres" id="1_80ja4"]
|
||||
[ext_resource type="Script" uid="uid://sxgrib8ck0wx" path="res://scripts/character/character_card.gd" id="1_card"]
|
||||
|
||||
[node name="CharacterCard" type="PanelContainer"]
|
||||
custom_minimum_size = Vector2(0, 120)
|
||||
size_flags_horizontal = 3
|
||||
theme = ExtResource("1_80ja4")
|
||||
script = ExtResource("1_card")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="InfoVBox" type="VBoxContainer" parent="MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="NameLabel" type="Label" parent="MarginContainer/HBoxContainer/InfoVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "Character Name"
|
||||
|
||||
[node name="ClassLabel" type="Label" parent="MarginContainer/HBoxContainer/InfoVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 14
|
||||
text = "Class Name"
|
||||
|
||||
[node name="LevelLabel" type="Label" parent="MarginContainer/HBoxContainer/InfoVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 12
|
||||
text = "Level 1"
|
||||
|
||||
[node name="GoldLabel" type="Label" parent="MarginContainer/HBoxContainer/InfoVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 12
|
||||
text = "0 Gold"
|
||||
|
||||
[node name="LocationLabel" type="Label" parent="MarginContainer/HBoxContainer/InfoVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 12
|
||||
text = "Unknown Location"
|
||||
|
||||
[node name="ActionVBox" type="VBoxContainer" parent="MarginContainer/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
alignment = 1
|
||||
|
||||
[node name="SelectButton" type="Button" parent="MarginContainer/HBoxContainer/ActionVBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Select"
|
||||
|
||||
[node name="DeleteButton" type="Button" parent="MarginContainer/HBoxContainer/ActionVBox"]
|
||||
custom_minimum_size = Vector2(100, 36)
|
||||
layout_mode = 2
|
||||
text = "Delete"
|
||||
156
godot_client/scenes/character/character_list.tscn
Normal file
156
godot_client/scenes/character/character_list.tscn
Normal file
@@ -0,0 +1,156 @@
|
||||
[gd_scene load_steps=5 format=3 uid="uid://csqelun8tcd7y"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://x4mt6jwbywsl" path="res://scripts/character/character_list.gd" id="1_list"]
|
||||
[ext_resource type="Texture2D" uid="uid://cja8kui47qb3d" path="res://assets/ui/main_menu.png" id="2_arrgh"]
|
||||
[ext_resource type="Theme" uid="uid://bviqieumdiccr" path="res://assets/themes/main_theme.tres" id="3_pfk5o"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_mvluj"]
|
||||
bg_color = Color(0, 0, 0, 0.69411767)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.83137256, 0.6862745, 0.21568628, 1)
|
||||
|
||||
[node name="CharacterList" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_list")
|
||||
|
||||
[node name="TextureRect" type="TextureRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
texture = ExtResource("2_arrgh")
|
||||
expand_mode = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBoxContainer/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Your Characters"
|
||||
|
||||
[node name="TierLabel" type="Label" parent="VBoxContainer/Header"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "Free: 0/1"
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="LoadingIndicator" type="CenterContainer" parent="VBoxContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/LoadingIndicator"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ProgressBar" type="ProgressBar" parent="VBoxContainer/LoadingIndicator/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(200, 0)
|
||||
layout_mode = 2
|
||||
max_value = 0.0
|
||||
indeterminate = true
|
||||
editor_preview_indeterminate = false
|
||||
|
||||
[node name="Label" type="Label" parent="VBoxContainer/LoadingIndicator/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Loading characters..."
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="CharacterScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="CharacterVBox" type="VBoxContainer" parent="VBoxContainer/CharacterScrollContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="ErrorContainer" type="MarginContainer" parent="VBoxContainer"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme = ExtResource("3_pfk5o")
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 8
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 8
|
||||
|
||||
[node name="ErrorLabel" type="Label" parent="VBoxContainer/ErrorContainer"]
|
||||
custom_minimum_size = Vector2(200, 20)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.9, 0.3, 0.3, 1)
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_mvluj")
|
||||
text = "Error message here"
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="ActionContainer" type="HBoxContainer" parent="VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
alignment = 1
|
||||
|
||||
[node name="CreateButton" type="Button" parent="VBoxContainer/ActionContainer"]
|
||||
custom_minimum_size = Vector2(180, 44)
|
||||
layout_mode = 2
|
||||
text = "Create Character"
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBoxContainer/ActionContainer"]
|
||||
custom_minimum_size = Vector2(100, 44)
|
||||
layout_mode = 2
|
||||
text = "Refresh"
|
||||
|
||||
[node name="EmptyState" type="CenterContainer" parent="."]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="EmptyState"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="IconLabel" type="Label" parent="EmptyState/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 48
|
||||
text = "⚔"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="MessageLabel" type="Label" parent="EmptyState/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "No characters yet"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="SubMessageLabel" type="Label" parent="EmptyState/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Create your first character to begin your adventure!"
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="CreateFirstButton" type="Button" parent="EmptyState/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(180, 44)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 4
|
||||
text = "Create Character"
|
||||
335
godot_client/scenes/components/README.md
Normal file
335
godot_client/scenes/components/README.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# Reusable UI Components
|
||||
|
||||
This directory contains reusable UI components for Code of Conquest.
|
||||
|
||||
## Available Components
|
||||
|
||||
### CustomButton
|
||||
**Script**: `scripts/components/custom_button.gd`
|
||||
|
||||
Enhanced button with multiple visual variants and features.
|
||||
|
||||
**Features**:
|
||||
- Multiple variants (Primary, Secondary, Danger, Success, Ghost)
|
||||
- Icon support (left or right position)
|
||||
- Loading state
|
||||
- Hover effects
|
||||
- Themed styling
|
||||
|
||||
**Usage**:
|
||||
```gdscript
|
||||
var btn = CustomButton.new()
|
||||
btn.text = "Login"
|
||||
btn.set_variant(CustomButton.Variant.PRIMARY)
|
||||
btn.button_clicked.connect(_on_login_clicked)
|
||||
add_child(btn)
|
||||
```
|
||||
|
||||
**Variants**:
|
||||
- `PRIMARY` - Gold accent button for main actions
|
||||
- `SECONDARY` - Standard button
|
||||
- `DANGER` - Red button for destructive actions (delete, etc.)
|
||||
- `SUCCESS` - Green button for positive actions
|
||||
- `GHOST` - Transparent/subtle button
|
||||
|
||||
### Card
|
||||
**Script**: `scripts/components/card.gd`
|
||||
|
||||
Container with optional header and footer, styled like the web UI cards.
|
||||
|
||||
**Features**:
|
||||
- Optional header with text
|
||||
- Body content area
|
||||
- Optional footer with buttons
|
||||
- Multiple style variants
|
||||
- Automatic sizing
|
||||
|
||||
**Usage**:
|
||||
```gdscript
|
||||
var card = Card.new()
|
||||
card.set_header("Character Details")
|
||||
|
||||
var content = Label.new()
|
||||
content.text = "Character information goes here"
|
||||
card.add_content(content)
|
||||
|
||||
card.set_footer_buttons(["Save", "Cancel"])
|
||||
card.footer_button_pressed.connect(_on_card_button_pressed)
|
||||
|
||||
add_child(card)
|
||||
```
|
||||
|
||||
**Style Variants**:
|
||||
- `DEFAULT` - Standard card with subtle border
|
||||
- `HIGHLIGHTED` - Gold border with shadow
|
||||
- `SUBTLE` - Minimal border
|
||||
|
||||
### FormField
|
||||
**Script**: `scripts/components/form_field.gd`
|
||||
|
||||
Form input field with label, validation, and error display.
|
||||
|
||||
**Features**:
|
||||
- Label + input + error message
|
||||
- Multiple input types (text, email, password, number, phone)
|
||||
- Built-in validation
|
||||
- Required field support
|
||||
- Min/max length validation
|
||||
- Error state styling
|
||||
|
||||
**Usage**:
|
||||
```gdscript
|
||||
var email_field = FormField.new()
|
||||
email_field.set_label("Email")
|
||||
email_field.set_placeholder("Enter your email")
|
||||
email_field.input_type = FormField.InputType.EMAIL
|
||||
email_field.required = true
|
||||
email_field.value_changed.connect(_on_email_changed)
|
||||
|
||||
add_child(email_field)
|
||||
|
||||
# Later, validate
|
||||
if email_field.validate():
|
||||
var email = email_field.get_value()
|
||||
# Process email
|
||||
```
|
||||
|
||||
**Input Types**:
|
||||
- `TEXT` - Plain text
|
||||
- `EMAIL` - Email validation
|
||||
- `PASSWORD` - Hidden password
|
||||
- `NUMBER` - Numeric only
|
||||
- `PHONE` - Phone number format
|
||||
|
||||
## Creating New Components
|
||||
|
||||
### 1. Create Script
|
||||
|
||||
Create a new GDScript file in `scripts/components/`:
|
||||
|
||||
```gdscript
|
||||
extends Control # or appropriate base class
|
||||
class_name MyComponent
|
||||
|
||||
## MyComponent
|
||||
##
|
||||
## Brief description of what this component does
|
||||
##
|
||||
## Usage:
|
||||
## var comp = MyComponent.new()
|
||||
## comp.some_property = "value"
|
||||
|
||||
signal some_signal(data: String)
|
||||
|
||||
@export var some_property: String = ""
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_structure()
|
||||
_apply_styling()
|
||||
|
||||
func _setup_structure() -> void:
|
||||
# Build node hierarchy
|
||||
pass
|
||||
|
||||
func _apply_styling() -> void:
|
||||
# Apply theme colors and styleboxes
|
||||
pass
|
||||
```
|
||||
|
||||
### 2. Use Theme Colors
|
||||
|
||||
Always use `ThemeColors` constants:
|
||||
|
||||
```gdscript
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = ThemeColors.BACKGROUND_CARD
|
||||
style.border_color = ThemeColors.BORDER_DEFAULT
|
||||
```
|
||||
|
||||
### 3. Make It Configurable
|
||||
|
||||
Use `@export` variables for Godot Inspector:
|
||||
|
||||
```gdscript
|
||||
@export var title: String = ""
|
||||
@export_enum("Small", "Medium", "Large") var size: String = "Medium"
|
||||
@export var show_icon: bool = true
|
||||
```
|
||||
|
||||
### 4. Emit Signals
|
||||
|
||||
For user interactions:
|
||||
|
||||
```gdscript
|
||||
signal item_selected(item_id: String)
|
||||
signal value_changed(new_value: Variant)
|
||||
|
||||
func _on_internal_action():
|
||||
item_selected.emit("some_id")
|
||||
```
|
||||
|
||||
### 5. Document Usage
|
||||
|
||||
Include usage examples in docstring:
|
||||
|
||||
```gdscript
|
||||
## MyComponent
|
||||
##
|
||||
## Detailed description of the component.
|
||||
##
|
||||
## Usage:
|
||||
## var comp = MyComponent.new()
|
||||
## comp.title = "My Title"
|
||||
## comp.item_selected.connect(_on_item_selected)
|
||||
## add_child(comp)
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Separation of Structure and Style
|
||||
|
||||
```gdscript
|
||||
func _ready() -> void:
|
||||
_setup_structure() # Build node hierarchy
|
||||
_apply_styling() # Apply theme
|
||||
|
||||
func _setup_structure() -> void:
|
||||
# Create child nodes
|
||||
# Set up hierarchy
|
||||
# Connect signals
|
||||
pass
|
||||
|
||||
func _apply_styling() -> void:
|
||||
# Apply StyleBoxes
|
||||
# Set colors
|
||||
# Set fonts
|
||||
pass
|
||||
```
|
||||
|
||||
### Responsive Design
|
||||
|
||||
```gdscript
|
||||
func _apply_styling() -> void:
|
||||
# Check platform
|
||||
var is_mobile = OS.get_name() in ["Android", "iOS"]
|
||||
|
||||
if is_mobile:
|
||||
# Larger touch targets
|
||||
custom_minimum_size = Vector2(60, 60)
|
||||
else:
|
||||
# Desktop sizing
|
||||
custom_minimum_size = Vector2(40, 40)
|
||||
```
|
||||
|
||||
### Validation Pattern
|
||||
|
||||
```gdscript
|
||||
func validate() -> bool:
|
||||
var is_valid = true
|
||||
|
||||
# Check conditions
|
||||
if some_condition:
|
||||
show_error("Error message")
|
||||
is_valid = false
|
||||
|
||||
if is_valid:
|
||||
clear_error()
|
||||
|
||||
return is_valid
|
||||
```
|
||||
|
||||
## Testing Components
|
||||
|
||||
Create a test scene `test_components.tscn`:
|
||||
|
||||
1. Add each component
|
||||
2. Test all variants
|
||||
3. Test all states (normal, hover, pressed, disabled)
|
||||
4. Test on different screen sizes
|
||||
5. Test on different platforms
|
||||
|
||||
Example test scene structure:
|
||||
```
|
||||
Control (root)
|
||||
├─ VBoxContainer
|
||||
│ ├─ Label ("Buttons")
|
||||
│ ├─ HBoxContainer
|
||||
│ │ ├─ CustomButton (Primary)
|
||||
│ │ ├─ CustomButton (Secondary)
|
||||
│ │ ├─ CustomButton (Danger)
|
||||
│ │ └─ CustomButton (Ghost)
|
||||
│ ├─ Label ("Cards")
|
||||
│ ├─ HBoxContainer
|
||||
│ │ ├─ Card (Default)
|
||||
│ │ ├─ Card (Highlighted)
|
||||
│ │ └─ Card (Subtle)
|
||||
│ └─ Label ("Form Fields")
|
||||
│ ├─ FormField (Text)
|
||||
│ ├─ FormField (Email)
|
||||
│ └─ FormField (Password)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use ThemeColors** - Never hardcode colors
|
||||
2. **Make components reusable** - Avoid scene-specific logic
|
||||
3. **Use signals for communication** - Don't couple components
|
||||
4. **Document everything** - Docstrings + usage examples
|
||||
5. **Test on all platforms** - Desktop, mobile, web
|
||||
6. **Follow naming conventions** - PascalCase for class names
|
||||
7. **Export important properties** - Make them editable in Inspector
|
||||
8. **Validate inputs** - Check types and ranges
|
||||
9. **Handle edge cases** - Empty strings, null values, etc.
|
||||
10. **Keep it simple** - One component, one responsibility
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### ❌ Don't hardcode colors
|
||||
```gdscript
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = Color("#1a1a2e") # BAD
|
||||
```
|
||||
|
||||
### ✅ Use ThemeColors
|
||||
```gdscript
|
||||
var style = StyleBoxFlat.new()
|
||||
style.bg_color = ThemeColors.BACKGROUND_PRIMARY # GOOD
|
||||
```
|
||||
|
||||
### ❌ Don't create nodes in every frame
|
||||
```gdscript
|
||||
func _process(delta):
|
||||
var button = Button.new() # BAD - leaks memory
|
||||
add_child(button)
|
||||
```
|
||||
|
||||
### ✅ Create once in _ready
|
||||
```gdscript
|
||||
var button: Button
|
||||
|
||||
func _ready():
|
||||
button = Button.new()
|
||||
add_child(button)
|
||||
```
|
||||
|
||||
### ❌ Don't couple components
|
||||
```gdscript
|
||||
# In a button component
|
||||
func _on_pressed():
|
||||
get_parent().get_node("SomeOtherNode").do_something() # BAD
|
||||
```
|
||||
|
||||
### ✅ Use signals
|
||||
```gdscript
|
||||
signal action_requested
|
||||
|
||||
func _on_pressed():
|
||||
action_requested.emit() # GOOD
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Godot UI Documentation](https://docs.godotengine.org/en/stable/tutorials/ui/index.html)
|
||||
- [Control Nodes](https://docs.godotengine.org/en/stable/classes/class_control.html)
|
||||
- [Themes](https://docs.godotengine.org/en/stable/tutorials/ui/gui_using_theme_editor.html)
|
||||
- [StyleBox](https://docs.godotengine.org/en/stable/classes/class_stylebox.html)
|
||||
183
godot_client/scenes/main.tscn
Normal file
183
godot_client/scenes/main.tscn
Normal file
@@ -0,0 +1,183 @@
|
||||
[gd_scene load_steps=5 format=3 uid="uid://b8qxqvw3n2k4r"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://d100mupmyal5" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Theme" uid="uid://bviqieumdiccr" path="res://assets/themes/main_theme.tres" id="1_sugp2"]
|
||||
[ext_resource type="Texture2D" uid="uid://cja8kui47qb3d" path="res://assets/ui/main_menu.png" id="3_sugp2"]
|
||||
|
||||
[sub_resource type="GDScript" id="GDScript_0wfyh"]
|
||||
script/source = "extends Button
|
||||
class_name CustomButton
|
||||
## Custom Button Component
|
||||
##
|
||||
## Enhanced button with icon support, loading state, and themed styling.
|
||||
##
|
||||
## Features:
|
||||
## - Optional icon (left or right)
|
||||
## - Loading spinner state
|
||||
## - Hover effects
|
||||
## - Disabled state management
|
||||
## - Multiple visual variants
|
||||
##
|
||||
## Usage:
|
||||
## var btn = CustomButton.new()
|
||||
## btn.text = \"Login\"
|
||||
## btn.variant = CustomButton.Variant.PRIMARY
|
||||
## btn.set_loading(true)
|
||||
|
||||
signal button_clicked()
|
||||
|
||||
# Button variants
|
||||
enum Variant {
|
||||
PRIMARY, # Gold accent button (main actions)
|
||||
SECONDARY, # Standard button
|
||||
DANGER, # Red button (destructive actions)
|
||||
SUCCESS, # Green button (positive actions)
|
||||
GHOST # Transparent button (subtle actions)
|
||||
}
|
||||
|
||||
# Icon position
|
||||
enum IconPosition {
|
||||
LEFT,
|
||||
RIGHT,
|
||||
NONE
|
||||
}
|
||||
|
||||
# Export variables (editable in Godot Inspector)
|
||||
@export var variant: Variant = Variant.SECONDARY
|
||||
@export var icon_texture: Texture2D = null
|
||||
@export var icon_position: IconPosition = IconPosition.LEFT
|
||||
@export var show_loading: bool = false
|
||||
|
||||
# Internal nodes (set up in _ready)
|
||||
#var _icon: TextureRect = null
|
||||
#var _label: Label = null
|
||||
#var _spinner: TextureRect = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Apply theme based on variant
|
||||
|
||||
|
||||
# Connect press signal
|
||||
pressed.connect(_on_button_pressed)
|
||||
|
||||
|
||||
|
||||
## Set loading state
|
||||
func set_loading(loading: bool) -> void:
|
||||
show_loading = loading
|
||||
disabled = loading
|
||||
_update_loading_state()
|
||||
|
||||
|
||||
|
||||
## Internal: Update loading spinner
|
||||
func _update_loading_state() -> void:
|
||||
# TODO: Implement loading spinner when scene is set up
|
||||
if show_loading:
|
||||
text = \"Loading...\"
|
||||
else:
|
||||
# Restore original text
|
||||
pass
|
||||
|
||||
|
||||
## Internal: Handle button press
|
||||
func _on_button_pressed() -> void:
|
||||
if not disabled and not show_loading:
|
||||
button_clicked.emit()
|
||||
"
|
||||
|
||||
[node name="Main" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme = ExtResource("1_sugp2")
|
||||
script = ExtResource("1_main")
|
||||
|
||||
[node name="BackgroundPanel" type="Panel" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="TextureRect" type="TextureRect" parent="BackgroundPanel"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
texture = ExtResource("3_sugp2")
|
||||
expand_mode = 4
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="CenterContainer/PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme = ExtResource("1_sugp2")
|
||||
theme_override_constants/margin_left = 15
|
||||
theme_override_constants/margin_top = 15
|
||||
theme_override_constants/margin_right = 15
|
||||
theme_override_constants/margin_bottom = 15
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CenterContainer/PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Code of Conquest"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Spacer1" type="Control" parent="CenterContainer/PanelContainer/MarginContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 20)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="WelcomeLabel" type="Label" parent="CenterContainer/PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Welcome, Player"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="CenterContainer/PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Loading..."
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Spacer2" type="Control" parent="CenterContainer/PanelContainer/MarginContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 30)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ButtonContainer" type="HBoxContainer" parent="CenterContainer/PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 16
|
||||
alignment = 1
|
||||
|
||||
[node name="PlayNowButton" type="Button" parent="CenterContainer/PanelContainer/MarginContainer/VBoxContainer/ButtonContainer"]
|
||||
custom_minimum_size = Vector2(150, 0)
|
||||
layout_mode = 2
|
||||
text = "Play Now"
|
||||
script = SubResource("GDScript_0wfyh")
|
||||
|
||||
[node name="LogoutButton" type="Button" parent="CenterContainer/PanelContainer/MarginContainer/VBoxContainer/ButtonContainer"]
|
||||
custom_minimum_size = Vector2(150, 0)
|
||||
layout_mode = 2
|
||||
text = "Logout"
|
||||
script = SubResource("GDScript_0wfyh")
|
||||
21
godot_client/scenes/test_services.tscn
Normal file
21
godot_client/scenes/test_services.tscn
Normal file
@@ -0,0 +1,21 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://b1gbhqqbu5aij"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://c1uv0pqjtun0r" path="res://scripts/test_services.gd" id="1_slw0k"]
|
||||
|
||||
[node name="Control" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
layout_mode = 0
|
||||
offset_right = 55.0
|
||||
offset_bottom = 40.0
|
||||
|
||||
[node name="Button" type="Button" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
text = "Test API"
|
||||
script = ExtResource("1_slw0k")
|
||||
96
godot_client/scripts/character/character_card.gd
Normal file
96
godot_client/scripts/character/character_card.gd
Normal file
@@ -0,0 +1,96 @@
|
||||
extends PanelContainer
|
||||
class_name CharacterCard
|
||||
## Character Card Component
|
||||
##
|
||||
## Displays a single character's summary information with select/delete actions.
|
||||
|
||||
#region Signals
|
||||
signal selected(character_id: String)
|
||||
signal delete_requested(character_id: String)
|
||||
#endregion
|
||||
|
||||
#region Node References
|
||||
@onready var name_label: Label = $MarginContainer/HBoxContainer/InfoVBox/NameLabel
|
||||
@onready var class_label: Label = $MarginContainer/HBoxContainer/InfoVBox/ClassLabel
|
||||
@onready var level_label: Label = $MarginContainer/HBoxContainer/InfoVBox/LevelLabel
|
||||
@onready var gold_label: Label = $MarginContainer/HBoxContainer/InfoVBox/GoldLabel
|
||||
@onready var location_label: Label = $MarginContainer/HBoxContainer/InfoVBox/LocationLabel
|
||||
@onready var select_button: Button = $MarginContainer/HBoxContainer/ActionVBox/SelectButton
|
||||
@onready var delete_button: Button = $MarginContainer/HBoxContainer/ActionVBox/DeleteButton
|
||||
#endregion
|
||||
|
||||
#region Private Variables
|
||||
var _character_id: String = ""
|
||||
var _character_data: Dictionary = {}
|
||||
var _pending_data: Dictionary = {} # Store data if set before _ready
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
func _ready() -> void:
|
||||
#_apply_style()
|
||||
select_button.pressed.connect(_on_select_pressed)
|
||||
delete_button.pressed.connect(_on_delete_pressed)
|
||||
|
||||
# Apply pending data if set before _ready
|
||||
if not _pending_data.is_empty():
|
||||
_apply_character_data(_pending_data)
|
||||
_pending_data = {}
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
func set_character_data(data: Dictionary) -> void:
|
||||
"""Set character data and update display."""
|
||||
if not is_node_ready():
|
||||
# Store for later if called before _ready
|
||||
_pending_data = data
|
||||
return
|
||||
|
||||
_apply_character_data(data)
|
||||
|
||||
|
||||
func _apply_character_data(data: Dictionary) -> void:
|
||||
"""Actually apply the character data to UI elements."""
|
||||
_character_data = data
|
||||
_character_id = data.get("character_id", "")
|
||||
|
||||
name_label.text = data.get("name", "Unknown")
|
||||
class_label.text = data.get("class_name", "Unknown Class")
|
||||
level_label.text = "Level %d" % data.get("level", 1)
|
||||
gold_label.text = "%d Gold" % data.get("gold", 0)
|
||||
location_label.text = _format_location(data.get("current_location", ""))
|
||||
|
||||
|
||||
func get_character_id() -> String:
|
||||
return _character_id
|
||||
#endregion
|
||||
|
||||
#region Private Methods
|
||||
#func _apply_style() -> void:
|
||||
#"""Apply card styling."""
|
||||
#var style = StyleBoxFlat.new()
|
||||
#style.bg_color = ThemeColors.BACKGROUND_CARD
|
||||
#style.border_width_all = 1
|
||||
#style.border_color = ThemeColors.BORDER_DEFAULT
|
||||
#style.corner_radius_all = 8
|
||||
#style.content_margin_left = 0
|
||||
#style.content_margin_right = 0
|
||||
#style.content_margin_top = 0
|
||||
#style.content_margin_bottom = 0
|
||||
#add_theme_stylebox_override("panel", style)
|
||||
|
||||
|
||||
func _format_location(location_id: String) -> String:
|
||||
"""Convert location_id to display name."""
|
||||
if location_id.is_empty():
|
||||
return "Unknown"
|
||||
return location_id.replace("_", " ").capitalize()
|
||||
#endregion
|
||||
|
||||
#region Signal Handlers
|
||||
func _on_select_pressed() -> void:
|
||||
selected.emit(_character_id)
|
||||
|
||||
|
||||
func _on_delete_pressed() -> void:
|
||||
delete_requested.emit(_character_id)
|
||||
#endregion
|
||||
1
godot_client/scripts/character/character_card.gd.uid
Normal file
1
godot_client/scripts/character/character_card.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://sxgrib8ck0wx
|
||||
200
godot_client/scripts/character/character_list.gd
Normal file
200
godot_client/scripts/character/character_list.gd
Normal file
@@ -0,0 +1,200 @@
|
||||
extends Control
|
||||
## Character List Screen
|
||||
##
|
||||
## Displays user's characters and allows selection or creation.
|
||||
## Fetches data from GET /api/v1/characters endpoint.
|
||||
|
||||
#region Signals
|
||||
signal character_selected(character_id: String)
|
||||
signal create_requested
|
||||
#endregion
|
||||
|
||||
#region Node References
|
||||
@onready var character_container: VBoxContainer = $VBoxContainer/CharacterScrollContainer/CharacterVBox
|
||||
@onready var loading_indicator: CenterContainer = $VBoxContainer/LoadingIndicator
|
||||
@onready var error_container: MarginContainer = $VBoxContainer/ErrorContainer
|
||||
@onready var error_label: Label = $VBoxContainer/ErrorContainer/ErrorLabel
|
||||
@onready var empty_state: CenterContainer = $EmptyState
|
||||
@onready var tier_label: Label = $VBoxContainer/Header/TierLabel
|
||||
@onready var create_button: Button = $VBoxContainer/ActionContainer/CreateButton
|
||||
@onready var refresh_button: Button = $VBoxContainer/ActionContainer/RefreshButton
|
||||
@onready var scroll_container: ScrollContainer = $VBoxContainer/CharacterScrollContainer
|
||||
@onready var empty_create_button: Button = $EmptyState/VBoxContainer/CreateFirstButton
|
||||
#endregion
|
||||
|
||||
#region Service References
|
||||
@onready var http_client: Node = get_node("/root/HTTPClient")
|
||||
@onready var state_manager: Node = get_node("/root/StateManager")
|
||||
#endregion
|
||||
|
||||
#region Private Variables
|
||||
var _characters: Array = []
|
||||
var _is_loading: bool = false
|
||||
var _tier: String = "free"
|
||||
var _limit: int = 1
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
func _ready() -> void:
|
||||
print("[CharacterList] Initialized")
|
||||
_connect_signals()
|
||||
_hide_error()
|
||||
empty_state.visible = false
|
||||
load_characters()
|
||||
|
||||
|
||||
func _connect_signals() -> void:
|
||||
create_button.pressed.connect(_on_create_button_pressed)
|
||||
refresh_button.pressed.connect(_on_refresh_button_pressed)
|
||||
empty_create_button.pressed.connect(_on_create_button_pressed)
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
func load_characters() -> void:
|
||||
"""Fetch characters from API."""
|
||||
if _is_loading:
|
||||
return
|
||||
|
||||
print("[CharacterList] Loading characters...")
|
||||
_set_loading(true)
|
||||
_hide_error()
|
||||
|
||||
http_client.http_get(
|
||||
"/api/v1/characters",
|
||||
_on_characters_loaded,
|
||||
_on_characters_error
|
||||
)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
"""Refresh character list."""
|
||||
load_characters()
|
||||
#endregion
|
||||
|
||||
#region Private Methods
|
||||
func _set_loading(loading: bool) -> void:
|
||||
_is_loading = loading
|
||||
loading_indicator.visible = loading
|
||||
scroll_container.visible = not loading
|
||||
refresh_button.disabled = loading
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
error_label.text = message
|
||||
error_container.visible = true
|
||||
|
||||
|
||||
func _hide_error() -> void:
|
||||
error_container.visible = false
|
||||
|
||||
|
||||
func _update_ui() -> void:
|
||||
# Update tier info
|
||||
tier_label.text = "%s: %d/%d" % [_tier.capitalize(), _characters.size(), _limit]
|
||||
|
||||
# Enable/disable create button based on limit
|
||||
create_button.disabled = _characters.size() >= _limit
|
||||
|
||||
# Show empty state or character list
|
||||
if _characters.is_empty():
|
||||
empty_state.visible = true
|
||||
scroll_container.visible = false
|
||||
else:
|
||||
empty_state.visible = false
|
||||
scroll_container.visible = true
|
||||
|
||||
|
||||
func _populate_character_list() -> void:
|
||||
# Clear existing cards
|
||||
for child in character_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
# Create card for each character
|
||||
var card_scene = preload("res://scenes/character/character_card.tscn")
|
||||
|
||||
for char_data in _characters:
|
||||
var card = card_scene.instantiate()
|
||||
card.set_character_data(char_data)
|
||||
card.selected.connect(_on_character_card_selected)
|
||||
card.delete_requested.connect(_on_character_delete_requested)
|
||||
character_container.add_child(card)
|
||||
|
||||
print("[CharacterList] Populated %d character cards" % _characters.size())
|
||||
#endregion
|
||||
|
||||
#region Signal Handlers
|
||||
func _on_characters_loaded(response: APIResponse) -> void:
|
||||
_set_loading(false)
|
||||
|
||||
if not response.is_success():
|
||||
_on_characters_error(response)
|
||||
return
|
||||
|
||||
var result = response.result
|
||||
_characters = result.get("characters", [])
|
||||
_tier = result.get("tier", "free")
|
||||
_limit = result.get("limit", 1)
|
||||
|
||||
print("[CharacterList] Loaded %d characters (tier=%s, limit=%d)" % [_characters.size(), _tier, _limit])
|
||||
|
||||
_populate_character_list()
|
||||
_update_ui()
|
||||
|
||||
|
||||
func _on_characters_error(response: APIResponse) -> void:
|
||||
_set_loading(false)
|
||||
|
||||
var message = response.get_error_message()
|
||||
if message.is_empty():
|
||||
message = "Failed to load characters"
|
||||
|
||||
if response.status == 401:
|
||||
print("[CharacterList] Session expired, redirecting to login")
|
||||
get_tree().change_scene_to_file("res://scenes/auth/login.tscn")
|
||||
return
|
||||
|
||||
print("[CharacterList] Error: %s (status=%d)" % [message, response.status])
|
||||
_show_error(message)
|
||||
|
||||
|
||||
func _on_character_card_selected(character_id: String) -> void:
|
||||
print("[CharacterList] Character selected: %s" % character_id)
|
||||
state_manager.select_character(character_id)
|
||||
character_selected.emit(character_id)
|
||||
# TODO: Navigate to main game scene
|
||||
# get_tree().change_scene_to_file("res://scenes/game/main_game.tscn")
|
||||
|
||||
|
||||
func _on_character_delete_requested(character_id: String) -> void:
|
||||
print("[CharacterList] Delete requested for: %s" % character_id)
|
||||
# TODO: Show confirmation dialog, then call DELETE endpoint
|
||||
# For now, directly delete
|
||||
_delete_character(character_id)
|
||||
|
||||
|
||||
func _delete_character(character_id: String) -> void:
|
||||
"""Delete a character via API."""
|
||||
http_client.http_delete(
|
||||
"/api/v1/characters/%s" % character_id,
|
||||
func(response: APIResponse):
|
||||
if response.is_success():
|
||||
print("[CharacterList] Character deleted: %s" % character_id)
|
||||
load_characters() # Refresh list
|
||||
else:
|
||||
_show_error(response.get_error_message()),
|
||||
func(response: APIResponse):
|
||||
_show_error(response.get_error_message())
|
||||
)
|
||||
|
||||
|
||||
func _on_create_button_pressed() -> void:
|
||||
print("[CharacterList] Create button pressed")
|
||||
create_requested.emit()
|
||||
# TODO: Navigate to character creation wizard
|
||||
# get_tree().change_scene_to_file("res://scenes/character/character_create.tscn")
|
||||
|
||||
|
||||
func _on_refresh_button_pressed() -> void:
|
||||
print("[CharacterList] Refresh button pressed")
|
||||
refresh()
|
||||
#endregion
|
||||
1
godot_client/scripts/character/character_list.gd.uid
Normal file
1
godot_client/scripts/character/character_list.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://x4mt6jwbywsl
|
||||
202
godot_client/scripts/components/card.gd
Normal file
202
godot_client/scripts/components/card.gd
Normal file
@@ -0,0 +1,202 @@
|
||||
extends PanelContainer
|
||||
class_name Card
|
||||
## Card Component
|
||||
##
|
||||
## Styled container for content with optional header and footer.
|
||||
## Mimics the card design from the web UI.
|
||||
##
|
||||
## Structure:
|
||||
## Card (PanelContainer)
|
||||
## └─ VBoxContainer
|
||||
## ├─ Header (optional)
|
||||
## ├─ Body (content)
|
||||
## └─ Footer (optional)
|
||||
##
|
||||
## Usage:
|
||||
## var card = Card.new()
|
||||
## card.set_header("Character Details")
|
||||
## card.add_content(my_content_node)
|
||||
## card.set_footer_buttons(["Save", "Cancel"])
|
||||
|
||||
signal header_action_pressed(action: String)
|
||||
signal footer_button_pressed(button_text: String)
|
||||
|
||||
# Export variables
|
||||
@export var header_text: String = ""
|
||||
@export var show_header: bool = false
|
||||
@export var show_footer: bool = false
|
||||
@export var card_style: StyleVariant = StyleVariant.DEFAULT
|
||||
|
||||
# Style variants
|
||||
enum StyleVariant {
|
||||
DEFAULT, # Standard card
|
||||
HIGHLIGHTED, # Gold border highlight
|
||||
SUBTLE # Minimal border
|
||||
}
|
||||
|
||||
# Internal nodes
|
||||
var _container: VBoxContainer = null
|
||||
var _header_container: HBoxContainer = null
|
||||
var _header_label: Label = null
|
||||
var _body_container: MarginContainer = null
|
||||
var _body_content: VBoxContainer = null
|
||||
var _footer_container: HBoxContainer = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_structure()
|
||||
_apply_style()
|
||||
|
||||
if not header_text.is_empty():
|
||||
set_header(header_text)
|
||||
|
||||
|
||||
## Setup card structure
|
||||
func _setup_structure() -> void:
|
||||
# Main container
|
||||
_container = VBoxContainer.new()
|
||||
add_child(_container)
|
||||
|
||||
# Header
|
||||
_header_container = HBoxContainer.new()
|
||||
_header_container.visible = show_header
|
||||
|
||||
_header_label = Label.new()
|
||||
_header_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
# TODO: Set heading font when theme is set up
|
||||
_header_container.add_child(_header_label)
|
||||
|
||||
_container.add_child(_header_container)
|
||||
|
||||
# Add separator after header
|
||||
var separator = HSeparator.new()
|
||||
separator.visible = show_header
|
||||
_container.add_child(separator)
|
||||
|
||||
# Body
|
||||
_body_container = MarginContainer.new()
|
||||
_body_container.add_theme_constant_override("margin_left", 16)
|
||||
_body_container.add_theme_constant_override("margin_right", 16)
|
||||
_body_container.add_theme_constant_override("margin_top", 16)
|
||||
_body_container.add_theme_constant_override("margin_bottom", 16)
|
||||
|
||||
_body_content = VBoxContainer.new()
|
||||
_body_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_body_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
|
||||
_body_container.add_child(_body_content)
|
||||
_container.add_child(_body_container)
|
||||
|
||||
# Footer
|
||||
var footer_separator = HSeparator.new()
|
||||
footer_separator.visible = show_footer
|
||||
_container.add_child(footer_separator)
|
||||
|
||||
_footer_container = HBoxContainer.new()
|
||||
_footer_container.visible = show_footer
|
||||
_footer_container.alignment = BoxContainer.ALIGNMENT_END
|
||||
_container.add_child(_footer_container)
|
||||
|
||||
|
||||
## Apply card styling
|
||||
func _apply_style() -> void:
|
||||
var style = StyleBoxFlat.new()
|
||||
|
||||
match card_style:
|
||||
StyleVariant.DEFAULT:
|
||||
style.bg_color = ThemeColors.BACKGROUND_CARD
|
||||
style.border_width_all = 1
|
||||
style.border_color = ThemeColors.BORDER_DEFAULT
|
||||
style.corner_radius_all = 8
|
||||
|
||||
StyleVariant.HIGHLIGHTED:
|
||||
style.bg_color = ThemeColors.BACKGROUND_CARD
|
||||
style.border_width_all = 2
|
||||
style.border_color = ThemeColors.GOLD_ACCENT
|
||||
style.corner_radius_all = 8
|
||||
style.shadow_color = ThemeColors.SHADOW
|
||||
style.shadow_size = 4
|
||||
|
||||
StyleVariant.SUBTLE:
|
||||
style.bg_color = ThemeColors.BACKGROUND_CARD
|
||||
style.border_width_all = 1
|
||||
style.border_color = ThemeColors.DIVIDER
|
||||
style.corner_radius_all = 4
|
||||
|
||||
add_theme_stylebox_override("panel", style)
|
||||
|
||||
|
||||
## Set header text and show header
|
||||
func set_header(text: String) -> void:
|
||||
header_text = text
|
||||
_header_label.text = text
|
||||
_header_container.visible = true
|
||||
show_header = true
|
||||
|
||||
# Show separator
|
||||
if _container.get_child_count() > 1:
|
||||
_container.get_child(1).visible = true
|
||||
|
||||
|
||||
## Hide header
|
||||
func hide_header() -> void:
|
||||
_header_container.visible = false
|
||||
show_header = false
|
||||
|
||||
# Hide separator
|
||||
if _container.get_child_count() > 1:
|
||||
_container.get_child(1).visible = false
|
||||
|
||||
|
||||
## Add content to card body
|
||||
func add_content(node: Node) -> void:
|
||||
_body_content.add_child(node)
|
||||
|
||||
|
||||
## Clear all content from body
|
||||
func clear_content() -> void:
|
||||
for child in _body_content.get_children():
|
||||
child.queue_free()
|
||||
|
||||
|
||||
## Set footer buttons
|
||||
func set_footer_buttons(button_labels: Array[String]) -> void:
|
||||
# Clear existing buttons
|
||||
for child in _footer_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
# Add new buttons
|
||||
for label in button_labels:
|
||||
var btn = Button.new()
|
||||
btn.text = label
|
||||
btn.pressed.connect(_on_footer_button_pressed.bind(label))
|
||||
_footer_container.add_child(btn)
|
||||
|
||||
# Show footer
|
||||
_footer_container.visible = true
|
||||
show_footer = true
|
||||
|
||||
# Show separator
|
||||
if _container.get_child_count() > 2:
|
||||
_container.get_child(_container.get_child_count() - 2).visible = true
|
||||
|
||||
|
||||
## Hide footer
|
||||
func hide_footer() -> void:
|
||||
_footer_container.visible = false
|
||||
show_footer = false
|
||||
|
||||
# Hide separator
|
||||
if _container.get_child_count() > 2:
|
||||
_container.get_child(_container.get_child_count() - 2).visible = false
|
||||
|
||||
|
||||
## Set card style variant
|
||||
func set_style_variant(variant: StyleVariant) -> void:
|
||||
card_style = variant
|
||||
_apply_style()
|
||||
|
||||
|
||||
## Internal: Handle footer button press
|
||||
func _on_footer_button_pressed(button_text: String) -> void:
|
||||
footer_button_pressed.emit(button_text)
|
||||
1
godot_client/scripts/components/card.gd.uid
Normal file
1
godot_client/scripts/components/card.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dq4fplw7kw5yu
|
||||
280
godot_client/scripts/components/form_field.gd
Normal file
280
godot_client/scripts/components/form_field.gd
Normal file
@@ -0,0 +1,280 @@
|
||||
extends VBoxContainer
|
||||
class_name FormField
|
||||
## Form Field Component
|
||||
##
|
||||
## Combines label, input field, and error message display.
|
||||
## Supports validation and different input types.
|
||||
##
|
||||
## Structure:
|
||||
## VBoxContainer
|
||||
## ├─ Label (field label)
|
||||
## ├─ LineEdit (input field)
|
||||
## └─ Label (error message, hidden by default)
|
||||
##
|
||||
## Usage:
|
||||
## var field = FormField.new()
|
||||
## field.label_text = "Email"
|
||||
## field.placeholder = "Enter your email"
|
||||
## field.input_type = FormField.InputType.EMAIL
|
||||
## field.value_changed.connect(_on_email_changed)
|
||||
|
||||
signal value_changed(new_value: String)
|
||||
signal validation_changed(is_valid: bool)
|
||||
|
||||
# Input types
|
||||
enum InputType {
|
||||
TEXT, # Plain text
|
||||
EMAIL, # Email validation
|
||||
PASSWORD, # Password (hidden)
|
||||
NUMBER, # Numeric only
|
||||
PHONE, # Phone number
|
||||
}
|
||||
|
||||
# Export variables
|
||||
@export var label_text: String = ""
|
||||
@export var placeholder: String = ""
|
||||
@export var input_type: InputType = InputType.TEXT
|
||||
@export var required: bool = false
|
||||
@export var min_length: int = 0
|
||||
@export var max_length: int = 0
|
||||
|
||||
# Internal nodes
|
||||
var _label: Label = null
|
||||
var _input: LineEdit = null
|
||||
var _error_label: Label = null
|
||||
|
||||
# State
|
||||
var _is_valid: bool = true
|
||||
var _error_message: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_structure()
|
||||
_apply_styling()
|
||||
|
||||
|
||||
## Setup form field structure
|
||||
func _setup_structure() -> void:
|
||||
# Label
|
||||
_label = Label.new()
|
||||
_label.text = label_text
|
||||
add_child(_label)
|
||||
|
||||
# Spacing
|
||||
var spacer1 = Control.new()
|
||||
spacer1.custom_minimum_size = Vector2(0, 4)
|
||||
add_child(spacer1)
|
||||
|
||||
# Input field
|
||||
_input = LineEdit.new()
|
||||
_input.placeholder_text = placeholder
|
||||
_input.text_changed.connect(_on_text_changed)
|
||||
_input.text_submitted.connect(_on_text_submitted)
|
||||
|
||||
# Set input type
|
||||
match input_type:
|
||||
InputType.PASSWORD:
|
||||
_input.secret = true
|
||||
InputType.NUMBER:
|
||||
# TODO: Add number-only filter
|
||||
pass
|
||||
_:
|
||||
pass
|
||||
|
||||
# Set max length if specified
|
||||
if max_length > 0:
|
||||
_input.max_length = max_length
|
||||
|
||||
add_child(_input)
|
||||
|
||||
# Spacing
|
||||
var spacer2 = Control.new()
|
||||
spacer2.custom_minimum_size = Vector2(0, 2)
|
||||
add_child(spacer2)
|
||||
|
||||
# Error label
|
||||
_error_label = Label.new()
|
||||
_error_label.visible = false
|
||||
add_child(_error_label)
|
||||
|
||||
|
||||
## Apply styling
|
||||
func _apply_styling() -> void:
|
||||
# Label styling
|
||||
_label.add_theme_color_override("font_color", ThemeColors.TEXT_PRIMARY)
|
||||
|
||||
# Input styling
|
||||
var normal_style = StyleBoxFlat.new()
|
||||
normal_style.bg_color = ThemeColors.BACKGROUND_SECONDARY
|
||||
normal_style.border_width_all = 2
|
||||
normal_style.border_color = ThemeColors.BORDER_DEFAULT
|
||||
normal_style.corner_radius_all = 4
|
||||
normal_style.content_margin_left = 12
|
||||
normal_style.content_margin_right = 12
|
||||
normal_style.content_margin_top = 8
|
||||
normal_style.content_margin_bottom = 8
|
||||
_input.add_theme_stylebox_override("normal", normal_style)
|
||||
|
||||
var focus_style = normal_style.duplicate()
|
||||
focus_style.border_color = ThemeColors.GOLD_ACCENT
|
||||
_input.add_theme_stylebox_override("focus", focus_style)
|
||||
|
||||
_input.add_theme_color_override("font_color", ThemeColors.TEXT_PRIMARY)
|
||||
_input.add_theme_color_override("font_placeholder_color", ThemeColors.TEXT_SECONDARY)
|
||||
_input.add_theme_color_override("caret_color", ThemeColors.GOLD_ACCENT)
|
||||
|
||||
# Error label styling
|
||||
_error_label.add_theme_color_override("font_color", ThemeColors.ERROR)
|
||||
# TODO: Add smaller font size when theme is set up
|
||||
|
||||
|
||||
## Get current value
|
||||
func get_value() -> String:
|
||||
return _input.text if _input else ""
|
||||
|
||||
|
||||
## Set value
|
||||
func set_value(value: String) -> void:
|
||||
if _input:
|
||||
_input.text = value
|
||||
_validate()
|
||||
|
||||
|
||||
## Set label text
|
||||
func set_label(text: String) -> void:
|
||||
label_text = text
|
||||
if _label:
|
||||
_label.text = text
|
||||
|
||||
|
||||
## Set placeholder text
|
||||
func set_placeholder(text: String) -> void:
|
||||
placeholder = text
|
||||
if _input:
|
||||
_input.placeholder_text = text
|
||||
|
||||
|
||||
## Show error message
|
||||
func show_error(message: String) -> void:
|
||||
_error_message = message
|
||||
_is_valid = false
|
||||
|
||||
if _error_label:
|
||||
_error_label.text = message
|
||||
_error_label.visible = true
|
||||
|
||||
# Change input border to error color
|
||||
if _input:
|
||||
var error_style = StyleBoxFlat.new()
|
||||
error_style.bg_color = ThemeColors.BACKGROUND_SECONDARY
|
||||
error_style.border_width_all = 2
|
||||
error_style.border_color = ThemeColors.ERROR
|
||||
error_style.corner_radius_all = 4
|
||||
error_style.content_margin_left = 12
|
||||
error_style.content_margin_right = 12
|
||||
error_style.content_margin_top = 8
|
||||
error_style.content_margin_bottom = 8
|
||||
_input.add_theme_stylebox_override("normal", error_style)
|
||||
|
||||
validation_changed.emit(false)
|
||||
|
||||
|
||||
## Clear error
|
||||
func clear_error() -> void:
|
||||
_error_message = ""
|
||||
_is_valid = true
|
||||
|
||||
if _error_label:
|
||||
_error_label.visible = false
|
||||
|
||||
# Restore normal input styling
|
||||
_apply_styling()
|
||||
|
||||
validation_changed.emit(true)
|
||||
|
||||
|
||||
## Check if field is valid
|
||||
func is_valid() -> bool:
|
||||
return _is_valid
|
||||
|
||||
|
||||
## Focus the input field
|
||||
func focus_input() -> void:
|
||||
if _input:
|
||||
_input.grab_focus()
|
||||
|
||||
|
||||
## Validate the field
|
||||
func validate() -> bool:
|
||||
return _validate()
|
||||
|
||||
|
||||
## Internal: Validate input
|
||||
func _validate() -> bool:
|
||||
var value = get_value()
|
||||
|
||||
# Required check
|
||||
if required and value.is_empty():
|
||||
show_error("This field is required")
|
||||
return false
|
||||
|
||||
# Min length check
|
||||
if min_length > 0 and value.length() < min_length:
|
||||
show_error("Must be at least %d characters" % min_length)
|
||||
return false
|
||||
|
||||
# Max length check (redundant with LineEdit.max_length, but kept for consistency)
|
||||
if max_length > 0 and value.length() > max_length:
|
||||
show_error("Must be no more than %d characters" % max_length)
|
||||
return false
|
||||
|
||||
# Type-specific validation
|
||||
match input_type:
|
||||
InputType.EMAIL:
|
||||
if not _is_valid_email(value) and not value.is_empty():
|
||||
show_error("Invalid email address")
|
||||
return false
|
||||
|
||||
InputType.NUMBER:
|
||||
if not value.is_valid_int() and not value.is_empty():
|
||||
show_error("Must be a number")
|
||||
return false
|
||||
|
||||
InputType.PHONE:
|
||||
if not _is_valid_phone(value) and not value.is_empty():
|
||||
show_error("Invalid phone number")
|
||||
return false
|
||||
|
||||
# All validations passed
|
||||
clear_error()
|
||||
return true
|
||||
|
||||
|
||||
## Internal: Email validation
|
||||
func _is_valid_email(email: String) -> bool:
|
||||
# Basic email regex validation
|
||||
var regex = RegEx.new()
|
||||
regex.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
|
||||
return regex.search(email) != null
|
||||
|
||||
|
||||
## Internal: Phone validation
|
||||
func _is_valid_phone(phone: String) -> bool:
|
||||
# Basic phone validation (digits, spaces, dashes, parentheses)
|
||||
var regex = RegEx.new()
|
||||
regex.compile("^[0-9\\s\\-\\(\\)\\+]+$")
|
||||
return regex.search(phone) != null and phone.length() >= 10
|
||||
|
||||
|
||||
## Internal: Handle text change
|
||||
func _on_text_changed(new_text: String) -> void:
|
||||
# Clear error when user starts typing
|
||||
if not _error_message.is_empty():
|
||||
clear_error()
|
||||
|
||||
value_changed.emit(new_text)
|
||||
|
||||
|
||||
## Internal: Handle text submission (Enter key)
|
||||
func _on_text_submitted(new_text: String) -> void:
|
||||
_validate()
|
||||
1
godot_client/scripts/components/form_field.gd.uid
Normal file
1
godot_client/scripts/components/form_field.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dvx6m2ahutlgm
|
||||
171
godot_client/scripts/main.gd
Normal file
171
godot_client/scripts/main.gd
Normal file
@@ -0,0 +1,171 @@
|
||||
extends Control
|
||||
## Main Scene
|
||||
##
|
||||
## Entry point for the application.
|
||||
## Displays loading screen with logged in player info.
|
||||
## Provides "Play Now" and "Logout" buttons for authenticated users.
|
||||
|
||||
# Scene paths
|
||||
const SCENE_LOGIN = "res://scenes/auth/login.tscn"
|
||||
const SCENE_CHARACTER_LIST = "res://scenes/character/character_list.tscn"
|
||||
|
||||
# UI node references (from scene)
|
||||
@onready var background_panel: Panel = $BackgroundPanel
|
||||
@onready var title_label: Label = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/TitleLabel
|
||||
@onready var welcome_label: Label = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/WelcomeLabel
|
||||
@onready var status_label: Label = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/StatusLabel
|
||||
@onready var button_container: HBoxContainer = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/ButtonContainer
|
||||
@onready var play_now_button: Button = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/ButtonContainer/PlayNowButton
|
||||
@onready var logout_button: Button = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/ButtonContainer/LogoutButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
print("=== Code of Conquest Starting ===")
|
||||
print("Godot Version: ", Engine.get_version_info())
|
||||
print("Platform: ", OS.get_name())
|
||||
|
||||
# Connect button signals
|
||||
play_now_button.pressed.connect(_on_play_now_pressed)
|
||||
logout_button.pressed.connect(_on_logout_pressed)
|
||||
|
||||
# Initially hide buttons (will show after auth check)
|
||||
button_container.visible = false
|
||||
|
||||
# Wait a frame for services to initialize
|
||||
await get_tree().process_frame
|
||||
|
||||
# Check authentication status
|
||||
_check_authentication()
|
||||
|
||||
|
||||
|
||||
## Check if user is authenticated
|
||||
func _check_authentication() -> void:
|
||||
if StateManager.is_authenticated():
|
||||
print("[Main] User is authenticated")
|
||||
|
||||
# Get user info and display
|
||||
var user_data = StateManager.get_current_user()
|
||||
var username = user_data.get("email", user_data.get("name", "Player"))
|
||||
welcome_label.text = "Welcome, %s" % username
|
||||
|
||||
status_label.text = "Validating session..."
|
||||
|
||||
# Validate token with backend
|
||||
_validate_token()
|
||||
else:
|
||||
print("[Main] User not authenticated")
|
||||
welcome_label.text = "Not authenticated"
|
||||
status_label.text = "Please log in to continue"
|
||||
|
||||
# Wait a moment then navigate to login
|
||||
await get_tree().create_timer(1.5).timeout
|
||||
_navigate_to_login()
|
||||
|
||||
|
||||
## Validate auth token with backend
|
||||
func _validate_token() -> void:
|
||||
# TODO: Replace with actual validation endpoint
|
||||
# For now, assume token is valid and show buttons
|
||||
|
||||
print("[Main] Token validation not yet implemented")
|
||||
print("[Main] Assuming valid, showing play options...")
|
||||
|
||||
# Uncomment when endpoint exists:
|
||||
# HTTPClient.http_get("/api/v1/auth/validate", _on_token_valid, _on_token_invalid)
|
||||
|
||||
# For now, just show buttons (remove this when validation is implemented)
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
_show_play_options()
|
||||
|
||||
|
||||
## Show play/logout buttons
|
||||
func _show_play_options() -> void:
|
||||
status_label.text = "Ready to play!"
|
||||
button_container.visible = true
|
||||
|
||||
|
||||
## Handle valid token
|
||||
func _on_token_valid(response: APIResponse) -> void:
|
||||
if response.is_success():
|
||||
print("[Main] Token is valid")
|
||||
_show_play_options()
|
||||
else:
|
||||
print("[Main] Token validation failed: ", response.get_error_message())
|
||||
_handle_invalid_token()
|
||||
|
||||
|
||||
## Handle invalid token
|
||||
#func _on_token_invalid(response: APIResponse) -> void:
|
||||
#print("[Main] Token invalid or network error")
|
||||
#_handle_invalid_token()
|
||||
|
||||
|
||||
## Clear session and go to login
|
||||
func _handle_invalid_token() -> void:
|
||||
status_label.text = "Session expired"
|
||||
welcome_label.text = "Please log in again"
|
||||
button_container.visible = false
|
||||
|
||||
StateManager.clear_user_session()
|
||||
|
||||
await get_tree().create_timer(1.5).timeout
|
||||
_navigate_to_login()
|
||||
|
||||
|
||||
## Handle "Play Now" button press
|
||||
func _on_play_now_pressed() -> void:
|
||||
print("[Main] Play Now button pressed")
|
||||
|
||||
# Disable buttons during navigation
|
||||
button_container.visible = false
|
||||
status_label.text = "Loading characters..."
|
||||
|
||||
# Navigate to character list
|
||||
_navigate_to_character_list()
|
||||
|
||||
|
||||
## Handle "Logout" button press
|
||||
func _on_logout_pressed() -> void:
|
||||
print("[Main] Logout button pressed")
|
||||
|
||||
# Disable buttons
|
||||
button_container.visible = false
|
||||
status_label.text = "Logging out..."
|
||||
welcome_label.text = "Goodbye!"
|
||||
|
||||
# Clear session
|
||||
StateManager.clear_user_session()
|
||||
|
||||
# Wait a moment then navigate to login
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
_navigate_to_login()
|
||||
|
||||
|
||||
## Navigate to login scene
|
||||
func _navigate_to_login() -> void:
|
||||
print("[Main] Navigating to login...")
|
||||
|
||||
# Check if scene exists
|
||||
if not FileAccess.file_exists(SCENE_LOGIN):
|
||||
print("[Main] ERROR: Login scene not found at ", SCENE_LOGIN)
|
||||
status_label.text = "Login scene not yet created.\nSee Phase 2 in GETTING_STARTED.md"
|
||||
return
|
||||
|
||||
# Navigate
|
||||
get_tree().change_scene_to_file(SCENE_LOGIN)
|
||||
|
||||
|
||||
## Navigate to character list
|
||||
func _navigate_to_character_list() -> void:
|
||||
print("[Main] Navigating to character list...")
|
||||
|
||||
# Check if scene exists
|
||||
if not FileAccess.file_exists(SCENE_CHARACTER_LIST):
|
||||
print("[Main] ERROR: Character list scene not found at ", SCENE_CHARACTER_LIST)
|
||||
status_label.text = "Character list not yet created.\nSee Phase 3 in GETTING_STARTED.md"
|
||||
button_container.visible = true # Re-show buttons
|
||||
return
|
||||
|
||||
# Navigate
|
||||
get_tree().change_scene_to_file(SCENE_CHARACTER_LIST)
|
||||
1
godot_client/scripts/main.gd.uid
Normal file
1
godot_client/scripts/main.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d100mupmyal5
|
||||
56
godot_client/scripts/models/api_response.gd
Normal file
56
godot_client/scripts/models/api_response.gd
Normal file
@@ -0,0 +1,56 @@
|
||||
extends RefCounted
|
||||
class_name APIResponse
|
||||
## API Response Model
|
||||
##
|
||||
## Represents a response from the Flask backend API.
|
||||
## Matches the backend response format:
|
||||
## {
|
||||
## "app": "Code of Conquest",
|
||||
## "version": "0.1.0",
|
||||
## "status": 200,
|
||||
## "timestamp": "2025-11-16T...",
|
||||
## "result": {...},
|
||||
## "error": {...},
|
||||
## "meta": {...}
|
||||
## }
|
||||
|
||||
var app: String
|
||||
var version: String
|
||||
var status: int
|
||||
var timestamp: String
|
||||
var result: Variant
|
||||
var error: Dictionary
|
||||
var meta: Dictionary
|
||||
var raw_response: String
|
||||
|
||||
|
||||
func _init(json_data: Dictionary) -> void:
|
||||
app = json_data.get("app", "")
|
||||
version = json_data.get("version", "")
|
||||
status = json_data.get("status", 0)
|
||||
timestamp = json_data.get("timestamp", "")
|
||||
result = json_data.get("result", null)
|
||||
|
||||
# Handle Dictionary fields that might be null
|
||||
var error_data = json_data.get("error", null)
|
||||
error = error_data if error_data != null else {}
|
||||
|
||||
var meta_data = json_data.get("meta", null)
|
||||
meta = meta_data if meta_data != null else {}
|
||||
|
||||
|
||||
## Check if the request was successful (2xx status)
|
||||
func is_success() -> bool:
|
||||
return status >= 200 and status < 300
|
||||
|
||||
|
||||
## Check if there's an error
|
||||
func has_error() -> bool:
|
||||
return not error.is_empty() or status >= 400
|
||||
|
||||
|
||||
## Get error message if present
|
||||
func get_error_message() -> String:
|
||||
if error.has("message"):
|
||||
return error["message"]
|
||||
return ""
|
||||
1
godot_client/scripts/models/api_response.gd.uid
Normal file
1
godot_client/scripts/models/api_response.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cwwx1jcnpafur
|
||||
364
godot_client/scripts/services/http_client.gd
Normal file
364
godot_client/scripts/services/http_client.gd
Normal file
@@ -0,0 +1,364 @@
|
||||
extends Node
|
||||
## HTTPClient Service
|
||||
##
|
||||
## Singleton service for all HTTP communication with the Flask backend.
|
||||
## Handles authentication, JSON parsing, error handling, and provides
|
||||
## a convenient interface for making API requests.
|
||||
##
|
||||
## Usage:
|
||||
## HTTPClient.http_get("/api/v1/characters", _on_characters_loaded)
|
||||
## HTTPClient.http_post("/api/v1/auth/login", {"email": "...", "password": "..."}, _on_login_complete)
|
||||
|
||||
# API Configuration
|
||||
const API_TIMEOUT := 30.0 # Seconds
|
||||
|
||||
# HTTP Methods
|
||||
enum Method {
|
||||
GET,
|
||||
POST,
|
||||
PUT,
|
||||
DELETE,
|
||||
PATCH
|
||||
}
|
||||
|
||||
# Internal state
|
||||
var _auth_token: String = ""
|
||||
var _session_cookie: String = "" # Session cookie for authentication
|
||||
#var _request_queue: Array[Dictionary] = []
|
||||
var _active_requests: Dictionary = {} # request_id -> HTTPRequest node
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
print("[HTTPClient] Service initialized. API Base URL: %s" % Settings.get_api_url())
|
||||
|
||||
|
||||
## Make a GET request
|
||||
##
|
||||
## @param endpoint: API endpoint (e.g., "/api/v1/characters")
|
||||
## @param callback: Function to call with APIResponse when complete
|
||||
## @param error_callback: Optional function to call on error
|
||||
func http_get(endpoint: String, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
_make_request(Method.GET, endpoint, {}, callback, error_callback)
|
||||
|
||||
|
||||
## Make a POST request
|
||||
##
|
||||
## @param endpoint: API endpoint
|
||||
## @param data: Dictionary to send as JSON body
|
||||
## @param callback: Function to call with APIResponse when complete
|
||||
## @param error_callback: Optional function to call on error
|
||||
func http_post(endpoint: String, data: Dictionary, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
_make_request(Method.POST, endpoint, data, callback, error_callback)
|
||||
|
||||
|
||||
## Make a PUT request
|
||||
##
|
||||
## @param endpoint: API endpoint
|
||||
## @param data: Dictionary to send as JSON body
|
||||
## @param callback: Function to call with APIResponse when complete
|
||||
## @param error_callback: Optional function to call on error
|
||||
func http_put(endpoint: String, data: Dictionary, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
_make_request(Method.PUT, endpoint, data, callback, error_callback)
|
||||
|
||||
|
||||
## Make a DELETE request
|
||||
##
|
||||
## @param endpoint: API endpoint
|
||||
## @param callback: Function to call with APIResponse when complete
|
||||
## @param error_callback: Optional function to call on error
|
||||
func http_delete(endpoint: String, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
_make_request(Method.DELETE, endpoint, {}, callback, error_callback)
|
||||
|
||||
|
||||
## Make a PATCH request
|
||||
##
|
||||
## @param endpoint: API endpoint
|
||||
## @param data: Dictionary to send as JSON body
|
||||
## @param callback: Function to call with APIResponse when complete
|
||||
## @param error_callback: Optional function to call on error
|
||||
func http_patch(endpoint: String, data: Dictionary, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
_make_request(Method.PATCH, endpoint, data, callback, error_callback)
|
||||
|
||||
|
||||
## Set the authentication token for subsequent requests
|
||||
##
|
||||
## @param token: JWT token from login/registration
|
||||
func set_auth_token(token: String) -> void:
|
||||
_auth_token = token
|
||||
print("[HTTPClient] Auth token set")
|
||||
|
||||
|
||||
## Clear the authentication token (logout)
|
||||
func clear_auth_token() -> void:
|
||||
_auth_token = ""
|
||||
print("[HTTPClient] Auth token cleared")
|
||||
|
||||
|
||||
## Get current auth token
|
||||
func get_auth_token() -> String:
|
||||
return _auth_token
|
||||
|
||||
|
||||
## Set the session cookie for subsequent requests
|
||||
##
|
||||
## @param cookie: Session cookie value (e.g., "coc_session=xxx")
|
||||
func set_session_cookie(cookie: String) -> void:
|
||||
_session_cookie = cookie
|
||||
print("[HTTPClient] Session cookie set")
|
||||
|
||||
|
||||
## Clear the session cookie (logout)
|
||||
func clear_session_cookie() -> void:
|
||||
_session_cookie = ""
|
||||
print("[HTTPClient] Session cookie cleared")
|
||||
|
||||
|
||||
## Get current session cookie
|
||||
func get_session_cookie() -> String:
|
||||
return _session_cookie
|
||||
|
||||
|
||||
## Check if authenticated
|
||||
func is_authenticated() -> bool:
|
||||
return not _auth_token.is_empty() or not _session_cookie.is_empty()
|
||||
|
||||
|
||||
## Internal: Make HTTP request
|
||||
func _make_request(
|
||||
method: Method,
|
||||
endpoint: String,
|
||||
data: Dictionary,
|
||||
callback: Callable,
|
||||
error_callback: Callable
|
||||
) -> void:
|
||||
# Create HTTPRequest node
|
||||
var http_request := HTTPRequest.new()
|
||||
add_child(http_request)
|
||||
|
||||
# Generate request ID for tracking
|
||||
var request_id := "%s_%d" % [endpoint.get_file(), Time.get_ticks_msec()]
|
||||
_active_requests[request_id] = http_request
|
||||
|
||||
# Build full URL
|
||||
var url := Settings.get_api_url() + endpoint
|
||||
|
||||
# Build headers
|
||||
var headers := _build_headers()
|
||||
|
||||
# Connect completion signal
|
||||
http_request.request_completed.connect(
|
||||
_on_request_completed.bind(request_id, callback, error_callback)
|
||||
)
|
||||
|
||||
# Set timeout
|
||||
http_request.timeout = API_TIMEOUT
|
||||
|
||||
# Make request based on method
|
||||
var method_int := _method_to_int(method)
|
||||
var body := ""
|
||||
|
||||
if method in [Method.POST, Method.PUT, Method.PATCH]:
|
||||
body = JSON.stringify(data)
|
||||
|
||||
print("[HTTPClient] %s %s" % [_method_to_string(method), url])
|
||||
if not body.is_empty():
|
||||
print("[HTTPClient] Body: %s" % body)
|
||||
|
||||
var error := http_request.request(url, headers, method_int, body)
|
||||
|
||||
if error != OK:
|
||||
push_error("[HTTPClient] Failed to initiate request: %s" % error)
|
||||
_cleanup_request(request_id)
|
||||
if error_callback.is_valid():
|
||||
error_callback.call(_create_error_response("Failed to initiate request", 0))
|
||||
|
||||
|
||||
## Internal: Build request headers
|
||||
func _build_headers() -> PackedStringArray:
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Accept: application/json"
|
||||
])
|
||||
|
||||
# Add auth token if present (for JWT auth)
|
||||
if not _auth_token.is_empty():
|
||||
headers.append("Authorization: Bearer %s" % _auth_token)
|
||||
|
||||
# Add session cookie if present (for cookie-based auth)
|
||||
if not _session_cookie.is_empty():
|
||||
headers.append("Cookie: %s" % _session_cookie)
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
## Internal: Handle request completion
|
||||
func _on_request_completed(
|
||||
result: int,
|
||||
response_code: int,
|
||||
headers: PackedStringArray,
|
||||
body: PackedByteArray,
|
||||
request_id: String,
|
||||
callback: Callable,
|
||||
error_callback: Callable
|
||||
) -> void:
|
||||
print("[HTTPClient] Request completed: %s (status=%d)" % [request_id, response_code])
|
||||
|
||||
# Extract Set-Cookie header if present
|
||||
_extract_session_cookie(headers)
|
||||
|
||||
# Parse response body
|
||||
var body_string := body.get_string_from_utf8()
|
||||
|
||||
# Handle network errors
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
push_error("[HTTPClient] Network error: %s" % _result_to_string(result))
|
||||
_cleanup_request(request_id)
|
||||
|
||||
if error_callback.is_valid():
|
||||
error_callback.call(_create_error_response(_result_to_string(result), response_code))
|
||||
return
|
||||
|
||||
# Parse JSON response
|
||||
var json := JSON.new()
|
||||
var parse_error := json.parse(body_string)
|
||||
|
||||
if parse_error != OK:
|
||||
push_error("[HTTPClient] Failed to parse JSON: %s" % body_string)
|
||||
_cleanup_request(request_id)
|
||||
|
||||
if error_callback.is_valid():
|
||||
error_callback.call(_create_error_response("Failed to parse JSON response", response_code))
|
||||
return
|
||||
|
||||
# Create APIResponse
|
||||
var api_response: APIResponse = APIResponse.new(json.data)
|
||||
api_response.raw_response = body_string
|
||||
|
||||
# Check for errors
|
||||
if api_response.has_error():
|
||||
print("[HTTPClient] API error: %s" % api_response.get_error_message())
|
||||
|
||||
if error_callback.is_valid():
|
||||
error_callback.call(api_response)
|
||||
elif callback.is_valid():
|
||||
# Call regular callback even with error if no error callback
|
||||
callback.call(api_response)
|
||||
else:
|
||||
print("[HTTPClient] Success: %s" % request_id)
|
||||
|
||||
if callback.is_valid():
|
||||
callback.call(api_response)
|
||||
|
||||
_cleanup_request(request_id)
|
||||
|
||||
|
||||
## Internal: Extract session cookie from response headers
|
||||
func _extract_session_cookie(headers: PackedStringArray) -> void:
|
||||
for header in headers:
|
||||
# Look for Set-Cookie header
|
||||
if header.begins_with("Set-Cookie:") or header.begins_with("set-cookie:"):
|
||||
# Extract cookie value
|
||||
var cookie_string := header.substr(11).strip_edges() # Remove "Set-Cookie:"
|
||||
|
||||
# Look for coc_session cookie
|
||||
if cookie_string.begins_with("coc_session="):
|
||||
# Extract just the cookie name=value part (before semicolon)
|
||||
var cookie_parts := cookie_string.split(";")
|
||||
if cookie_parts.size() > 0:
|
||||
_session_cookie = cookie_parts[0].strip_edges()
|
||||
print("[HTTPClient] Session cookie extracted: %s" % _session_cookie)
|
||||
return
|
||||
|
||||
|
||||
## Internal: Cleanup request resources
|
||||
func _cleanup_request(request_id: String) -> void:
|
||||
if _active_requests.has(request_id):
|
||||
var http_request: HTTPRequest = _active_requests[request_id]
|
||||
http_request.queue_free()
|
||||
_active_requests.erase(request_id)
|
||||
|
||||
|
||||
## Internal: Create error response
|
||||
func _create_error_response(message: String, status_code: int) -> APIResponse:
|
||||
var error_data := {
|
||||
"app": "Code of Conquest",
|
||||
"version": "0.1.0",
|
||||
"status": status_code if status_code > 0 else 500,
|
||||
"timestamp": Time.get_datetime_string_from_system(),
|
||||
"result": null,
|
||||
"error": {
|
||||
"message": message,
|
||||
"code": "NETWORK_ERROR"
|
||||
},
|
||||
"meta": {}
|
||||
}
|
||||
return APIResponse.new(error_data)
|
||||
|
||||
|
||||
## Internal: Convert Method enum to HTTPClient constant
|
||||
func _method_to_int(method: Method) -> int:
|
||||
match method:
|
||||
Method.GET:
|
||||
return HTTPClient.METHOD_GET
|
||||
Method.POST:
|
||||
return HTTPClient.METHOD_POST
|
||||
Method.PUT:
|
||||
return HTTPClient.METHOD_PUT
|
||||
Method.DELETE:
|
||||
return HTTPClient.METHOD_DELETE
|
||||
Method.PATCH:
|
||||
return HTTPClient.METHOD_PATCH
|
||||
_:
|
||||
return HTTPClient.METHOD_GET
|
||||
|
||||
|
||||
## Internal: Convert Method enum to string
|
||||
func _method_to_string(method: Method) -> String:
|
||||
match method:
|
||||
Method.GET:
|
||||
return "GET"
|
||||
Method.POST:
|
||||
return "POST"
|
||||
Method.PUT:
|
||||
return "PUT"
|
||||
Method.DELETE:
|
||||
return "DELETE"
|
||||
Method.PATCH:
|
||||
return "PATCH"
|
||||
_:
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
## Internal: Convert HTTPRequest result to string
|
||||
func _result_to_string(result: int) -> String:
|
||||
match result:
|
||||
HTTPRequest.RESULT_SUCCESS:
|
||||
return "Success"
|
||||
HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH:
|
||||
return "Chunked body size mismatch"
|
||||
HTTPRequest.RESULT_CANT_CONNECT:
|
||||
return "Can't connect to server"
|
||||
HTTPRequest.RESULT_CANT_RESOLVE:
|
||||
return "Can't resolve hostname"
|
||||
HTTPRequest.RESULT_CONNECTION_ERROR:
|
||||
return "Connection error"
|
||||
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
|
||||
return "TLS handshake error"
|
||||
HTTPRequest.RESULT_NO_RESPONSE:
|
||||
return "No response from server"
|
||||
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
|
||||
return "Body size limit exceeded"
|
||||
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
|
||||
return "Body decompression failed"
|
||||
HTTPRequest.RESULT_REQUEST_FAILED:
|
||||
return "Request failed"
|
||||
HTTPRequest.RESULT_DOWNLOAD_FILE_CANT_OPEN:
|
||||
return "Can't open download file"
|
||||
HTTPRequest.RESULT_DOWNLOAD_FILE_WRITE_ERROR:
|
||||
return "Download file write error"
|
||||
HTTPRequest.RESULT_REDIRECT_LIMIT_REACHED:
|
||||
return "Redirect limit reached"
|
||||
HTTPRequest.RESULT_TIMEOUT:
|
||||
return "Request timeout"
|
||||
_:
|
||||
return "Unknown error (%d)" % result
|
||||
1
godot_client/scripts/services/http_client.gd.uid
Normal file
1
godot_client/scripts/services/http_client.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://du1woo6w2kr3b
|
||||
204
godot_client/scripts/services/settings.gd
Normal file
204
godot_client/scripts/services/settings.gd
Normal file
@@ -0,0 +1,204 @@
|
||||
extends Node
|
||||
## Settings Service
|
||||
##
|
||||
## Singleton service for application configuration.
|
||||
## Stores URLs, feature flags, and other runtime settings.
|
||||
##
|
||||
## Usage:
|
||||
## Settings.get_api_url()
|
||||
## Settings.get_web_url()
|
||||
## Settings.set_environment("production")
|
||||
|
||||
# Environment types
|
||||
enum Env {
|
||||
DEVELOPMENT,
|
||||
STAGING,
|
||||
PRODUCTION
|
||||
}
|
||||
|
||||
# Current environment
|
||||
var _current_environment: int = Env.DEVELOPMENT
|
||||
|
||||
# URL Configuration
|
||||
var _api_urls := {
|
||||
Env.DEVELOPMENT: "http://localhost:5000",
|
||||
Env.STAGING: "https://staging-api.codeofconquest.com",
|
||||
Env.PRODUCTION: "https://api.codeofconquest.com"
|
||||
}
|
||||
|
||||
var _web_urls := {
|
||||
Env.DEVELOPMENT: "http://localhost:8000", # Flask serves web pages in dev
|
||||
Env.STAGING: "https://staging.codeofconquest.com",
|
||||
Env.PRODUCTION: "https://www.codeofconquest.com"
|
||||
}
|
||||
|
||||
# Feature flags
|
||||
var _features := {
|
||||
"enable_debug_logging": true,
|
||||
"enable_analytics": false,
|
||||
"enable_multiplayer": false,
|
||||
"max_characters_per_user": 5
|
||||
}
|
||||
|
||||
# User preferences (persisted)
|
||||
var _preferences := {
|
||||
"remember_login": true,
|
||||
"auto_save": true,
|
||||
"sound_enabled": true,
|
||||
"music_enabled": true,
|
||||
"sound_volume": 0.8,
|
||||
"music_volume": 0.6
|
||||
}
|
||||
|
||||
# Save file configuration
|
||||
const SETTINGS_FILE_PATH := "user://settings.save"
|
||||
const SETTINGS_VERSION := 1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_load_settings()
|
||||
_detect_environment()
|
||||
print("[Settings] Service initialized")
|
||||
print("[Settings] Environment: %s" % _environment_to_string(_current_environment))
|
||||
print("[Settings] API URL: %s" % get_api_url())
|
||||
print("[Settings] Web URL: %s" % get_web_url())
|
||||
|
||||
|
||||
## Get current API base URL
|
||||
func get_api_url() -> String:
|
||||
return _api_urls.get(_current_environment, _api_urls[Env.DEVELOPMENT])
|
||||
|
||||
|
||||
## Get current web frontend base URL
|
||||
func get_web_url() -> String:
|
||||
return _web_urls.get(_current_environment, _web_urls[Env.DEVELOPMENT])
|
||||
|
||||
|
||||
## Get current environment
|
||||
func get_environment() -> int:
|
||||
return _current_environment
|
||||
|
||||
|
||||
## Set environment (for testing/switching)
|
||||
func set_environment(env: int) -> void:
|
||||
_current_environment = env
|
||||
print("[Settings] Environment changed to: %s" % _environment_to_string(env))
|
||||
print("[Settings] API URL: %s" % get_api_url())
|
||||
print("[Settings] Web URL: %s" % get_web_url())
|
||||
|
||||
|
||||
## Check if a feature is enabled
|
||||
func is_feature_enabled(feature_name: String) -> bool:
|
||||
return _features.get(feature_name, false)
|
||||
|
||||
|
||||
## Get feature value
|
||||
func get_feature(feature_name: String, default: Variant = null) -> Variant:
|
||||
return _features.get(feature_name, default)
|
||||
|
||||
|
||||
## Set feature flag (for testing/debugging)
|
||||
func set_feature(feature_name: String, value: Variant) -> void:
|
||||
_features[feature_name] = value
|
||||
print("[Settings] Feature updated: %s = %s" % [feature_name, value])
|
||||
|
||||
|
||||
## Get user preference
|
||||
func get_preference(key: String, default: Variant = null) -> Variant:
|
||||
return _preferences.get(key, default)
|
||||
|
||||
|
||||
## Set user preference
|
||||
func set_preference(key: String, value: Variant) -> void:
|
||||
_preferences[key] = value
|
||||
print("[Settings] Preference updated: %s = %s" % [key, value])
|
||||
_save_settings()
|
||||
|
||||
|
||||
## Get all preferences
|
||||
func get_all_preferences() -> Dictionary:
|
||||
return _preferences.duplicate()
|
||||
|
||||
|
||||
## Auto-detect environment based on OS and build flags
|
||||
func _detect_environment() -> void:
|
||||
# Check for --production command line argument
|
||||
var args := OS.get_cmdline_args()
|
||||
if "--production" in args:
|
||||
_current_environment = Env.PRODUCTION
|
||||
return
|
||||
|
||||
if "--staging" in args:
|
||||
_current_environment = Env.STAGING
|
||||
return
|
||||
|
||||
# Default to development
|
||||
_current_environment = Env.DEVELOPMENT
|
||||
|
||||
|
||||
## Save settings to disk
|
||||
func _save_settings() -> void:
|
||||
var save_data := {
|
||||
"version": SETTINGS_VERSION,
|
||||
"preferences": _preferences,
|
||||
"timestamp": Time.get_unix_time_from_system()
|
||||
}
|
||||
|
||||
var file := FileAccess.open(SETTINGS_FILE_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("[Settings] Failed to open settings file for writing: %s" % FileAccess.get_open_error())
|
||||
return
|
||||
|
||||
file.store_string(JSON.stringify(save_data))
|
||||
file.close()
|
||||
|
||||
print("[Settings] Settings saved to %s" % SETTINGS_FILE_PATH)
|
||||
|
||||
|
||||
## Load settings from disk
|
||||
func _load_settings() -> void:
|
||||
if not FileAccess.file_exists(SETTINGS_FILE_PATH):
|
||||
print("[Settings] No settings file found, using defaults")
|
||||
return
|
||||
|
||||
var file := FileAccess.open(SETTINGS_FILE_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("[Settings] Failed to open settings file for reading: %s" % FileAccess.get_open_error())
|
||||
return
|
||||
|
||||
var json_string := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var json := JSON.new()
|
||||
var parse_error := json.parse(json_string)
|
||||
|
||||
if parse_error != OK:
|
||||
push_error("[Settings] Failed to parse settings file")
|
||||
return
|
||||
|
||||
var save_data: Dictionary = json.data
|
||||
|
||||
# Check version
|
||||
if save_data.get("version", 0) != SETTINGS_VERSION:
|
||||
print("[Settings] Settings file version mismatch, using defaults")
|
||||
return
|
||||
|
||||
# Restore preferences
|
||||
var saved_prefs = save_data.get("preferences", {})
|
||||
for key in saved_prefs:
|
||||
_preferences[key] = saved_prefs[key]
|
||||
|
||||
print("[Settings] Settings loaded from %s" % SETTINGS_FILE_PATH)
|
||||
|
||||
|
||||
## Convert environment enum to string
|
||||
func _environment_to_string(env: int) -> String:
|
||||
match env:
|
||||
Env.DEVELOPMENT:
|
||||
return "Development"
|
||||
Env.STAGING:
|
||||
return "Staging"
|
||||
Env.PRODUCTION:
|
||||
return "Production"
|
||||
_:
|
||||
return "Unknown"
|
||||
1
godot_client/scripts/services/settings.gd.uid
Normal file
1
godot_client/scripts/services/settings.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://3s8i3b6v5mde
|
||||
422
godot_client/scripts/services/state_manager.gd
Normal file
422
godot_client/scripts/services/state_manager.gd
Normal file
@@ -0,0 +1,422 @@
|
||||
extends Node
|
||||
## StateManager Service
|
||||
##
|
||||
## Singleton service for managing global application state.
|
||||
## Handles user session, character data, wizard state, navigation, and persistence.
|
||||
##
|
||||
## Usage:
|
||||
## StateManager.set_user_session(user_data)
|
||||
## var user = StateManager.get_current_user()
|
||||
## StateManager.save_state() # Persist to local storage
|
||||
|
||||
# Signals for state changes
|
||||
signal user_logged_in(user_data: Dictionary)
|
||||
signal user_logged_out()
|
||||
signal character_selected(character_id: String)
|
||||
signal character_created(character_data: Dictionary)
|
||||
signal character_deleted(character_id: String)
|
||||
signal characters_updated(characters: Array)
|
||||
|
||||
# Save file configuration
|
||||
const SAVE_FILE_PATH := "user://coc_state.save"
|
||||
const SAVE_VERSION := 1
|
||||
|
||||
# User session state
|
||||
var _current_user: Dictionary = {}
|
||||
var _auth_token: String = ""
|
||||
var _is_authenticated: bool = false
|
||||
|
||||
# Character state
|
||||
var _characters: Array[Dictionary] = []
|
||||
var _selected_character_id: String = ""
|
||||
var _character_limits: Dictionary = {}
|
||||
|
||||
# Character creation wizard state
|
||||
var _wizard_state: Dictionary = {
|
||||
"step": 0, # Current step (0-3)
|
||||
"selected_origin": null, # Selected origin data
|
||||
"selected_class": null, # Selected class data
|
||||
"character_name": "",
|
||||
"character_customization": {}
|
||||
}
|
||||
|
||||
# Navigation state
|
||||
var _current_scene: String = ""
|
||||
var _scene_history: Array[String] = []
|
||||
|
||||
# Settings (deprecated - use Settings service for preferences)
|
||||
# Kept for backward compatibility with save files
|
||||
var _settings: Dictionary = {
|
||||
"remember_login": true,
|
||||
"auto_save": true
|
||||
}
|
||||
|
||||
# Reference to HTTPClient singleton (available after _ready)
|
||||
@onready var http_client: Node = get_node("/root/HTTPClient")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
print("[StateManager] Service initialized")
|
||||
_load_state()
|
||||
|
||||
|
||||
## Set user session data (after login/registration)
|
||||
func set_user_session(user_data: Dictionary, token: String = "") -> void:
|
||||
_current_user = user_data
|
||||
_auth_token = token
|
||||
_is_authenticated = true
|
||||
|
||||
# Update HTTPClient with token (if using JWT auth)
|
||||
if not token.is_empty():
|
||||
http_client.set_auth_token(token)
|
||||
|
||||
print("[StateManager] User session set: %s" % user_data.get("email", "unknown"))
|
||||
|
||||
# Emit signal
|
||||
user_logged_in.emit(user_data)
|
||||
|
||||
# Auto-save if enabled
|
||||
if _settings.get("auto_save", true):
|
||||
save_state()
|
||||
|
||||
|
||||
## Clear user session (logout)
|
||||
func clear_user_session() -> void:
|
||||
_current_user = {}
|
||||
_auth_token = ""
|
||||
_is_authenticated = false
|
||||
_characters = []
|
||||
_selected_character_id = ""
|
||||
|
||||
# Clear HTTPClient token and cookie
|
||||
http_client.clear_auth_token()
|
||||
http_client.clear_session_cookie()
|
||||
|
||||
print("[StateManager] User session cleared")
|
||||
|
||||
# Emit signal
|
||||
user_logged_out.emit()
|
||||
|
||||
# Clear saved state
|
||||
_clear_save_file()
|
||||
|
||||
|
||||
## Get current user data
|
||||
func get_current_user() -> Dictionary:
|
||||
return _current_user
|
||||
|
||||
|
||||
## Get current auth token
|
||||
func get_auth_token() -> String:
|
||||
return _auth_token
|
||||
|
||||
|
||||
## Check if user is authenticated
|
||||
func is_authenticated() -> bool:
|
||||
# Check both token and cookie-based auth
|
||||
return _is_authenticated and (not _auth_token.is_empty() or http_client.is_authenticated())
|
||||
|
||||
|
||||
## Set characters list
|
||||
func set_characters(characters: Array) -> void:
|
||||
_characters.clear()
|
||||
for character in characters:
|
||||
_characters.append(character)
|
||||
|
||||
print("[StateManager] Characters updated: %d total" % _characters.size())
|
||||
|
||||
# Emit signal
|
||||
characters_updated.emit(_characters)
|
||||
|
||||
# Auto-save if enabled
|
||||
if _settings.get("auto_save", true):
|
||||
save_state()
|
||||
|
||||
|
||||
## Get all characters
|
||||
func get_characters() -> Array[Dictionary]:
|
||||
return _characters
|
||||
|
||||
|
||||
## Add character to list
|
||||
func add_character(character_data: Dictionary) -> void:
|
||||
_characters.append(character_data)
|
||||
|
||||
print("[StateManager] Character added: %s" % character_data.get("name", "unknown"))
|
||||
|
||||
# Emit signal
|
||||
character_created.emit(character_data)
|
||||
|
||||
# Auto-save if enabled
|
||||
if _settings.get("auto_save", true):
|
||||
save_state()
|
||||
|
||||
|
||||
## Remove character from list
|
||||
func remove_character(character_id: String) -> void:
|
||||
for i in range(_characters.size()):
|
||||
if _characters[i].get("id") == character_id:
|
||||
_characters.remove_at(i)
|
||||
print("[StateManager] Character removed: %s" % character_id)
|
||||
|
||||
# Clear selection if this was selected
|
||||
if _selected_character_id == character_id:
|
||||
_selected_character_id = ""
|
||||
|
||||
# Emit signal
|
||||
character_deleted.emit(character_id)
|
||||
|
||||
# Auto-save if enabled
|
||||
if _settings.get("auto_save", true):
|
||||
save_state()
|
||||
return
|
||||
|
||||
|
||||
## Get character by ID
|
||||
func get_character(character_id: String) -> Dictionary:
|
||||
for character in _characters:
|
||||
if character.get("id") == character_id:
|
||||
return character
|
||||
return {}
|
||||
|
||||
|
||||
## Set selected character
|
||||
func select_character(character_id: String) -> void:
|
||||
_selected_character_id = character_id
|
||||
print("[StateManager] Character selected: %s" % character_id)
|
||||
|
||||
# Emit signal
|
||||
character_selected.emit(character_id)
|
||||
|
||||
|
||||
## Get selected character
|
||||
func get_selected_character() -> Dictionary:
|
||||
return get_character(_selected_character_id)
|
||||
|
||||
|
||||
## Get selected character ID
|
||||
func get_selected_character_id() -> String:
|
||||
return _selected_character_id
|
||||
|
||||
|
||||
## Set character limits
|
||||
func set_character_limits(limits: Dictionary) -> void:
|
||||
_character_limits = limits
|
||||
|
||||
|
||||
## Get character limits
|
||||
func get_character_limits() -> Dictionary:
|
||||
return _character_limits
|
||||
|
||||
|
||||
## Character Creation Wizard State Management
|
||||
|
||||
## Reset wizard state
|
||||
func reset_wizard() -> void:
|
||||
_wizard_state = {
|
||||
"step": 0,
|
||||
"selected_origin": null,
|
||||
"selected_class": null,
|
||||
"character_name": "",
|
||||
"character_customization": {}
|
||||
}
|
||||
print("[StateManager] Wizard state reset")
|
||||
|
||||
|
||||
## Set wizard step
|
||||
func set_wizard_step(step: int) -> void:
|
||||
_wizard_state["step"] = step
|
||||
|
||||
|
||||
## Get current wizard step
|
||||
func get_wizard_step() -> int:
|
||||
return _wizard_state.get("step", 0)
|
||||
|
||||
|
||||
## Set selected origin
|
||||
func set_wizard_origin(origin_data: Dictionary) -> void:
|
||||
_wizard_state["selected_origin"] = origin_data
|
||||
|
||||
|
||||
## Get selected origin
|
||||
func get_wizard_origin() -> Dictionary:
|
||||
return _wizard_state.get("selected_origin", {})
|
||||
|
||||
|
||||
## Set selected class
|
||||
func set_wizard_class(class_data: Dictionary) -> void:
|
||||
_wizard_state["selected_class"] = class_data
|
||||
|
||||
|
||||
## Get selected class
|
||||
func get_wizard_class() -> Dictionary:
|
||||
return _wizard_state.get("selected_class", {})
|
||||
|
||||
|
||||
## Set character name
|
||||
func set_wizard_name(char_name: String) -> void:
|
||||
_wizard_state["character_name"] = char_name
|
||||
|
||||
|
||||
## Get character name
|
||||
func get_wizard_name() -> String:
|
||||
return _wizard_state.get("character_name", "")
|
||||
|
||||
|
||||
## Set character customization
|
||||
func set_wizard_customization(customization: Dictionary) -> void:
|
||||
_wizard_state["character_customization"] = customization
|
||||
|
||||
|
||||
## Get character customization
|
||||
func get_wizard_customization() -> Dictionary:
|
||||
return _wizard_state.get("character_customization", {})
|
||||
|
||||
|
||||
## Get complete wizard state
|
||||
func get_wizard_state() -> Dictionary:
|
||||
return _wizard_state
|
||||
|
||||
|
||||
## Navigation State Management
|
||||
|
||||
## Set current scene
|
||||
func set_current_scene(scene_path: String) -> void:
|
||||
if not _current_scene.is_empty():
|
||||
_scene_history.append(_current_scene)
|
||||
|
||||
_current_scene = scene_path
|
||||
print("[StateManager] Scene changed: %s" % scene_path)
|
||||
|
||||
|
||||
## Get current scene
|
||||
func get_current_scene() -> String:
|
||||
return _current_scene
|
||||
|
||||
|
||||
## Navigate back
|
||||
func navigate_back() -> String:
|
||||
if _scene_history.is_empty():
|
||||
return ""
|
||||
|
||||
var previous_scene: String = _scene_history.pop_back()
|
||||
_current_scene = previous_scene
|
||||
return previous_scene
|
||||
|
||||
|
||||
## Can navigate back
|
||||
func can_navigate_back() -> bool:
|
||||
return not _scene_history.is_empty()
|
||||
|
||||
|
||||
## Settings Management
|
||||
|
||||
## Get setting value
|
||||
func get_setting(key: String, default: Variant = null) -> Variant:
|
||||
return _settings.get(key, default)
|
||||
|
||||
|
||||
## Set setting value
|
||||
func set_setting(key: String, value: Variant) -> void:
|
||||
_settings[key] = value
|
||||
print("[StateManager] Setting updated: %s = %s" % [key, value])
|
||||
|
||||
# Auto-save if enabled
|
||||
if _settings.get("auto_save", true):
|
||||
save_state()
|
||||
|
||||
|
||||
## Get all settings
|
||||
func get_settings() -> Dictionary:
|
||||
return _settings
|
||||
|
||||
|
||||
## Persistence (Save/Load)
|
||||
|
||||
## Save state to disk
|
||||
func save_state() -> void:
|
||||
var save_data := {
|
||||
"version": SAVE_VERSION,
|
||||
"user": _current_user,
|
||||
"auth_token": _auth_token if _settings.get("remember_login", true) else "",
|
||||
"session_cookie": http_client.get_session_cookie() if _settings.get("remember_login", true) else "",
|
||||
"is_authenticated": _is_authenticated if _settings.get("remember_login", true) else false,
|
||||
"characters": _characters,
|
||||
"selected_character_id": _selected_character_id,
|
||||
"character_limits": _character_limits,
|
||||
"settings": _settings,
|
||||
"timestamp": Time.get_unix_time_from_system()
|
||||
}
|
||||
|
||||
var file := FileAccess.open(SAVE_FILE_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("[StateManager] Failed to open save file for writing: %s" % FileAccess.get_open_error())
|
||||
return
|
||||
|
||||
file.store_string(JSON.stringify(save_data))
|
||||
file.close()
|
||||
|
||||
print("[StateManager] State saved to %s" % SAVE_FILE_PATH)
|
||||
|
||||
|
||||
## Load state from disk
|
||||
func _load_state() -> void:
|
||||
if not FileAccess.file_exists(SAVE_FILE_PATH):
|
||||
print("[StateManager] No save file found, starting fresh")
|
||||
return
|
||||
|
||||
var file := FileAccess.open(SAVE_FILE_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("[StateManager] Failed to open save file for reading: %s" % FileAccess.get_open_error())
|
||||
return
|
||||
|
||||
var json_string := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var json := JSON.new()
|
||||
var parse_error := json.parse(json_string)
|
||||
|
||||
if parse_error != OK:
|
||||
push_error("[StateManager] Failed to parse save file")
|
||||
return
|
||||
|
||||
var save_data: Dictionary = json.data
|
||||
|
||||
# Check version
|
||||
if save_data.get("version", 0) != SAVE_VERSION:
|
||||
print("[StateManager] Save file version mismatch, ignoring")
|
||||
return
|
||||
|
||||
# Restore state
|
||||
_current_user = save_data.get("user", {})
|
||||
_auth_token = save_data.get("auth_token", "")
|
||||
_is_authenticated = save_data.get("is_authenticated", false)
|
||||
_characters = []
|
||||
|
||||
# Convert characters array
|
||||
var chars_data = save_data.get("characters", [])
|
||||
for character in chars_data:
|
||||
_characters.append(character)
|
||||
|
||||
_selected_character_id = save_data.get("selected_character_id", "")
|
||||
_character_limits = save_data.get("character_limits", {})
|
||||
_settings = save_data.get("settings", _settings)
|
||||
|
||||
# Update HTTPClient with token if authenticated
|
||||
if _is_authenticated and not _auth_token.is_empty():
|
||||
http_client.set_auth_token(_auth_token)
|
||||
|
||||
# Restore session cookie if present
|
||||
var session_cookie = save_data.get("session_cookie", "")
|
||||
if _is_authenticated and not session_cookie.is_empty():
|
||||
http_client.set_session_cookie(session_cookie)
|
||||
|
||||
print("[StateManager] State loaded from %s" % SAVE_FILE_PATH)
|
||||
print("[StateManager] Authenticated: %s, Characters: %d" % [_is_authenticated, _characters.size()])
|
||||
|
||||
|
||||
## Clear save file
|
||||
func _clear_save_file() -> void:
|
||||
if FileAccess.file_exists(SAVE_FILE_PATH):
|
||||
DirAccess.remove_absolute(SAVE_FILE_PATH)
|
||||
print("[StateManager] Save file cleared")
|
||||
1
godot_client/scripts/services/state_manager.gd.uid
Normal file
1
godot_client/scripts/services/state_manager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b28q3ngeah5sf
|
||||
25
godot_client/scripts/test_services.gd
Normal file
25
godot_client/scripts/test_services.gd
Normal file
@@ -0,0 +1,25 @@
|
||||
extends Button
|
||||
|
||||
@onready var test_button = $"."
|
||||
|
||||
func _ready():
|
||||
test_button.pressed.connect(_on_test_clicked)
|
||||
|
||||
func _on_test_clicked():
|
||||
print("Testing HTTPClient...")
|
||||
|
||||
# Test API call (replace with real endpoint)
|
||||
# Note: Can use HTTPClient directly in _ready() or later
|
||||
#HTTPClient.http_get("/api/v1/health", _on_api_success, _on_api_error)
|
||||
|
||||
# Alternative (always works):
|
||||
get_node("/root/HTTPClient").http_get("/api/v1/health", _on_api_success, _on_api_error)
|
||||
|
||||
func _on_api_success(response):
|
||||
print("API Success!")
|
||||
print("Status: ", response.status)
|
||||
print("Result: ", response.result)
|
||||
|
||||
func _on_api_error(response):
|
||||
print("API Error!")
|
||||
print("Error: ", response.get_error_message())
|
||||
1
godot_client/scripts/test_services.gd.uid
Normal file
1
godot_client/scripts/test_services.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c1uv0pqjtun0r
|
||||
121
godot_client/scripts/utils/theme_colors.gd
Normal file
121
godot_client/scripts/utils/theme_colors.gd
Normal file
@@ -0,0 +1,121 @@
|
||||
extends Object
|
||||
class_name ThemeColors
|
||||
## Theme Colors
|
||||
##
|
||||
## Defines the color palette for Code of Conquest.
|
||||
## Based on the original web UI color scheme:
|
||||
## - Dark slate gray backgrounds
|
||||
## - Gold accents and highlights
|
||||
## - RPG/fantasy aesthetic
|
||||
##
|
||||
## Usage:
|
||||
## var bg_color = ThemeColors.BACKGROUND_PRIMARY
|
||||
## button.add_theme_color_override("font_color", ThemeColors.GOLD_ACCENT)
|
||||
|
||||
# Primary Background Colors
|
||||
const BACKGROUND_PRIMARY := Color("#1a1a2e") # Very dark blue-gray
|
||||
const BACKGROUND_SECONDARY := Color("#16213e") # Slightly lighter blue-gray
|
||||
const BACKGROUND_TERTIARY := Color("#0f3460") # Accent background
|
||||
const BACKGROUND_CARD := Color("#1e1e2f") # Card backgrounds
|
||||
|
||||
# Text Colors
|
||||
const TEXT_PRIMARY := Color("#e4e4e7") # Light gray (main text)
|
||||
const TEXT_SECONDARY := Color("#a1a1aa") # Medium gray (secondary text)
|
||||
const TEXT_DISABLED := Color("#71717a") # Dark gray (disabled)
|
||||
const TEXT_INVERTED := Color("#1a1a2e") # Dark text on light bg
|
||||
|
||||
# Accent Colors
|
||||
const GOLD_ACCENT := Color("#d4af37") # Primary gold accent
|
||||
const GOLD_LIGHT := Color("#f4d03f") # Lighter gold
|
||||
const GOLD_DARK := Color("#b8930a") # Darker gold
|
||||
|
||||
# Status Colors
|
||||
const SUCCESS := Color("#10b981") # Green (success messages)
|
||||
const ERROR := Color("#ef4444") # Red (errors)
|
||||
const WARNING := Color("#f59e0b") # Orange (warnings)
|
||||
const INFO := Color("#3b82f6") # Blue (info messages)
|
||||
|
||||
# HP/Mana/Resource Colors
|
||||
const HP_COLOR := Color("#dc2626") # Red for HP bars
|
||||
const MANA_COLOR := Color("#3b82f6") # Blue for mana bars
|
||||
const STAMINA_COLOR := Color("#10b981") # Green for stamina
|
||||
|
||||
# Rarity Colors (for items, etc.)
|
||||
const RARITY_COMMON := Color("#9ca3af") # Gray
|
||||
const RARITY_UNCOMMON := Color("#10b981") # Green
|
||||
const RARITY_RARE := Color("#3b82f6") # Blue
|
||||
const RARITY_EPIC := Color("#a855f7") # Purple
|
||||
const RARITY_LEGENDARY := Color("#f59e0b") # Orange/gold
|
||||
|
||||
# Border and Divider Colors
|
||||
const BORDER_DEFAULT := Color("#3f3f46") # Default borders
|
||||
const BORDER_ACCENT := Color("#d4af37") # Gold borders
|
||||
const DIVIDER := Color("#27272a") # Subtle dividers
|
||||
|
||||
# Interactive States
|
||||
const HOVER := Color("#d4af37", 0.1) # Gold overlay on hover
|
||||
const PRESSED := Color("#d4af37", 0.2) # Gold overlay on press
|
||||
const FOCUSED := Color("#d4af37", 0.15) # Gold overlay on focus
|
||||
const SELECTED := Color("#d4af37", 0.25) # Gold overlay on selection
|
||||
|
||||
# Overlay Colors
|
||||
const OVERLAY_DARK := Color("#000000", 0.5) # Dark overlay
|
||||
const OVERLAY_LIGHT := Color("#ffffff", 0.1) # Light overlay
|
||||
|
||||
# Shadow Colors
|
||||
const SHADOW := Color("#000000", 0.3) # Default shadow
|
||||
const SHADOW_STRONG := Color("#000000", 0.5) # Strong shadow
|
||||
|
||||
|
||||
## Get a color with modified alpha
|
||||
static func with_alpha(color: Color, alpha: float) -> Color:
|
||||
var new_color := color
|
||||
new_color.a = alpha
|
||||
return new_color
|
||||
|
||||
|
||||
## Lighten a color by a percentage
|
||||
static func lighten(color: Color, amount: float) -> Color:
|
||||
return color.lightened(amount)
|
||||
|
||||
|
||||
## Darken a color by a percentage
|
||||
static func darken(color: Color, amount: float) -> Color:
|
||||
return color.darkened(amount)
|
||||
|
||||
|
||||
## Mix two colors
|
||||
static func mix(color1: Color, color2: Color, weight: float = 0.5) -> Color:
|
||||
return color1.lerp(color2, weight)
|
||||
|
||||
|
||||
## Get rarity color by tier
|
||||
static func get_rarity_color(tier: int) -> Color:
|
||||
match tier:
|
||||
1:
|
||||
return RARITY_COMMON
|
||||
2:
|
||||
return RARITY_UNCOMMON
|
||||
3:
|
||||
return RARITY_RARE
|
||||
4:
|
||||
return RARITY_EPIC
|
||||
5:
|
||||
return RARITY_LEGENDARY
|
||||
_:
|
||||
return RARITY_COMMON
|
||||
|
||||
|
||||
## Get status color by type
|
||||
static func get_status_color(status: String) -> Color:
|
||||
match status.to_lower():
|
||||
"success", "ok", "complete":
|
||||
return SUCCESS
|
||||
"error", "fail", "failed":
|
||||
return ERROR
|
||||
"warning", "warn":
|
||||
return WARNING
|
||||
"info", "information":
|
||||
return INFO
|
||||
_:
|
||||
return TEXT_SECONDARY
|
||||
1
godot_client/scripts/utils/theme_colors.gd.uid
Normal file
1
godot_client/scripts/utils/theme_colors.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bbbe8eaq2ltgm
|
||||
Reference in New Issue
Block a user