first commit

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

44
godot_client/.gitignore vendored Normal file
View 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
View 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
View 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.

Binary file not shown.

View 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

View 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

View 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")

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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")

View 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

Binary file not shown.

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View 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")

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View 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
View 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

View 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.

View File

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

103
godot_client/docs/README.md Normal file
View 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

View 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

View 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)

View 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.

View 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

View 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")

View File

@@ -0,0 +1 @@
uid://260dpbodarqy

View 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

View 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"

View 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"

View 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)

View 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")

View 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")

View 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

View File

@@ -0,0 +1 @@
uid://sxgrib8ck0wx

View 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

View File

@@ -0,0 +1 @@
uid://x4mt6jwbywsl

View 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)

View File

@@ -0,0 +1 @@
uid://dq4fplw7kw5yu

View 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()

View File

@@ -0,0 +1 @@
uid://dvx6m2ahutlgm

View 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)

View File

@@ -0,0 +1 @@
uid://d100mupmyal5

View 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 ""

View File

@@ -0,0 +1 @@
uid://cwwx1jcnpafur

View 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

View File

@@ -0,0 +1 @@
uid://du1woo6w2kr3b

View 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"

View File

@@ -0,0 +1 @@
uid://3s8i3b6v5mde

View 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")

View File

@@ -0,0 +1 @@
uid://b28q3ngeah5sf

View 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())

View File

@@ -0,0 +1 @@
uid://c1uv0pqjtun0r

View 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

View File

@@ -0,0 +1 @@
uid://bbbe8eaq2ltgm