first commit
This commit is contained in:
96
godot_client/scripts/character/character_card.gd
Normal file
96
godot_client/scripts/character/character_card.gd
Normal file
@@ -0,0 +1,96 @@
|
||||
extends PanelContainer
|
||||
class_name CharacterCard
|
||||
## Character Card Component
|
||||
##
|
||||
## Displays a single character's summary information with select/delete actions.
|
||||
|
||||
#region Signals
|
||||
signal selected(character_id: String)
|
||||
signal delete_requested(character_id: String)
|
||||
#endregion
|
||||
|
||||
#region Node References
|
||||
@onready var name_label: Label = $MarginContainer/HBoxContainer/InfoVBox/NameLabel
|
||||
@onready var class_label: Label = $MarginContainer/HBoxContainer/InfoVBox/ClassLabel
|
||||
@onready var level_label: Label = $MarginContainer/HBoxContainer/InfoVBox/LevelLabel
|
||||
@onready var gold_label: Label = $MarginContainer/HBoxContainer/InfoVBox/GoldLabel
|
||||
@onready var location_label: Label = $MarginContainer/HBoxContainer/InfoVBox/LocationLabel
|
||||
@onready var select_button: Button = $MarginContainer/HBoxContainer/ActionVBox/SelectButton
|
||||
@onready var delete_button: Button = $MarginContainer/HBoxContainer/ActionVBox/DeleteButton
|
||||
#endregion
|
||||
|
||||
#region Private Variables
|
||||
var _character_id: String = ""
|
||||
var _character_data: Dictionary = {}
|
||||
var _pending_data: Dictionary = {} # Store data if set before _ready
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
func _ready() -> void:
|
||||
#_apply_style()
|
||||
select_button.pressed.connect(_on_select_pressed)
|
||||
delete_button.pressed.connect(_on_delete_pressed)
|
||||
|
||||
# Apply pending data if set before _ready
|
||||
if not _pending_data.is_empty():
|
||||
_apply_character_data(_pending_data)
|
||||
_pending_data = {}
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
func set_character_data(data: Dictionary) -> void:
|
||||
"""Set character data and update display."""
|
||||
if not is_node_ready():
|
||||
# Store for later if called before _ready
|
||||
_pending_data = data
|
||||
return
|
||||
|
||||
_apply_character_data(data)
|
||||
|
||||
|
||||
func _apply_character_data(data: Dictionary) -> void:
|
||||
"""Actually apply the character data to UI elements."""
|
||||
_character_data = data
|
||||
_character_id = data.get("character_id", "")
|
||||
|
||||
name_label.text = data.get("name", "Unknown")
|
||||
class_label.text = data.get("class_name", "Unknown Class")
|
||||
level_label.text = "Level %d" % data.get("level", 1)
|
||||
gold_label.text = "%d Gold" % data.get("gold", 0)
|
||||
location_label.text = _format_location(data.get("current_location", ""))
|
||||
|
||||
|
||||
func get_character_id() -> String:
|
||||
return _character_id
|
||||
#endregion
|
||||
|
||||
#region Private Methods
|
||||
#func _apply_style() -> void:
|
||||
#"""Apply card styling."""
|
||||
#var style = StyleBoxFlat.new()
|
||||
#style.bg_color = ThemeColors.BACKGROUND_CARD
|
||||
#style.border_width_all = 1
|
||||
#style.border_color = ThemeColors.BORDER_DEFAULT
|
||||
#style.corner_radius_all = 8
|
||||
#style.content_margin_left = 0
|
||||
#style.content_margin_right = 0
|
||||
#style.content_margin_top = 0
|
||||
#style.content_margin_bottom = 0
|
||||
#add_theme_stylebox_override("panel", style)
|
||||
|
||||
|
||||
func _format_location(location_id: String) -> String:
|
||||
"""Convert location_id to display name."""
|
||||
if location_id.is_empty():
|
||||
return "Unknown"
|
||||
return location_id.replace("_", " ").capitalize()
|
||||
#endregion
|
||||
|
||||
#region Signal Handlers
|
||||
func _on_select_pressed() -> void:
|
||||
selected.emit(_character_id)
|
||||
|
||||
|
||||
func _on_delete_pressed() -> void:
|
||||
delete_requested.emit(_character_id)
|
||||
#endregion
|
||||
1
godot_client/scripts/character/character_card.gd.uid
Normal file
1
godot_client/scripts/character/character_card.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://sxgrib8ck0wx
|
||||
200
godot_client/scripts/character/character_list.gd
Normal file
200
godot_client/scripts/character/character_list.gd
Normal file
@@ -0,0 +1,200 @@
|
||||
extends Control
|
||||
## Character List Screen
|
||||
##
|
||||
## Displays user's characters and allows selection or creation.
|
||||
## Fetches data from GET /api/v1/characters endpoint.
|
||||
|
||||
#region Signals
|
||||
signal character_selected(character_id: String)
|
||||
signal create_requested
|
||||
#endregion
|
||||
|
||||
#region Node References
|
||||
@onready var character_container: VBoxContainer = $VBoxContainer/CharacterScrollContainer/CharacterVBox
|
||||
@onready var loading_indicator: CenterContainer = $VBoxContainer/LoadingIndicator
|
||||
@onready var error_container: MarginContainer = $VBoxContainer/ErrorContainer
|
||||
@onready var error_label: Label = $VBoxContainer/ErrorContainer/ErrorLabel
|
||||
@onready var empty_state: CenterContainer = $EmptyState
|
||||
@onready var tier_label: Label = $VBoxContainer/Header/TierLabel
|
||||
@onready var create_button: Button = $VBoxContainer/ActionContainer/CreateButton
|
||||
@onready var refresh_button: Button = $VBoxContainer/ActionContainer/RefreshButton
|
||||
@onready var scroll_container: ScrollContainer = $VBoxContainer/CharacterScrollContainer
|
||||
@onready var empty_create_button: Button = $EmptyState/VBoxContainer/CreateFirstButton
|
||||
#endregion
|
||||
|
||||
#region Service References
|
||||
@onready var http_client: Node = get_node("/root/HTTPClient")
|
||||
@onready var state_manager: Node = get_node("/root/StateManager")
|
||||
#endregion
|
||||
|
||||
#region Private Variables
|
||||
var _characters: Array = []
|
||||
var _is_loading: bool = false
|
||||
var _tier: String = "free"
|
||||
var _limit: int = 1
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
func _ready() -> void:
|
||||
print("[CharacterList] Initialized")
|
||||
_connect_signals()
|
||||
_hide_error()
|
||||
empty_state.visible = false
|
||||
load_characters()
|
||||
|
||||
|
||||
func _connect_signals() -> void:
|
||||
create_button.pressed.connect(_on_create_button_pressed)
|
||||
refresh_button.pressed.connect(_on_refresh_button_pressed)
|
||||
empty_create_button.pressed.connect(_on_create_button_pressed)
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
func load_characters() -> void:
|
||||
"""Fetch characters from API."""
|
||||
if _is_loading:
|
||||
return
|
||||
|
||||
print("[CharacterList] Loading characters...")
|
||||
_set_loading(true)
|
||||
_hide_error()
|
||||
|
||||
http_client.http_get(
|
||||
"/api/v1/characters",
|
||||
_on_characters_loaded,
|
||||
_on_characters_error
|
||||
)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
"""Refresh character list."""
|
||||
load_characters()
|
||||
#endregion
|
||||
|
||||
#region Private Methods
|
||||
func _set_loading(loading: bool) -> void:
|
||||
_is_loading = loading
|
||||
loading_indicator.visible = loading
|
||||
scroll_container.visible = not loading
|
||||
refresh_button.disabled = loading
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
error_label.text = message
|
||||
error_container.visible = true
|
||||
|
||||
|
||||
func _hide_error() -> void:
|
||||
error_container.visible = false
|
||||
|
||||
|
||||
func _update_ui() -> void:
|
||||
# Update tier info
|
||||
tier_label.text = "%s: %d/%d" % [_tier.capitalize(), _characters.size(), _limit]
|
||||
|
||||
# Enable/disable create button based on limit
|
||||
create_button.disabled = _characters.size() >= _limit
|
||||
|
||||
# Show empty state or character list
|
||||
if _characters.is_empty():
|
||||
empty_state.visible = true
|
||||
scroll_container.visible = false
|
||||
else:
|
||||
empty_state.visible = false
|
||||
scroll_container.visible = true
|
||||
|
||||
|
||||
func _populate_character_list() -> void:
|
||||
# Clear existing cards
|
||||
for child in character_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
# Create card for each character
|
||||
var card_scene = preload("res://scenes/character/character_card.tscn")
|
||||
|
||||
for char_data in _characters:
|
||||
var card = card_scene.instantiate()
|
||||
card.set_character_data(char_data)
|
||||
card.selected.connect(_on_character_card_selected)
|
||||
card.delete_requested.connect(_on_character_delete_requested)
|
||||
character_container.add_child(card)
|
||||
|
||||
print("[CharacterList] Populated %d character cards" % _characters.size())
|
||||
#endregion
|
||||
|
||||
#region Signal Handlers
|
||||
func _on_characters_loaded(response: APIResponse) -> void:
|
||||
_set_loading(false)
|
||||
|
||||
if not response.is_success():
|
||||
_on_characters_error(response)
|
||||
return
|
||||
|
||||
var result = response.result
|
||||
_characters = result.get("characters", [])
|
||||
_tier = result.get("tier", "free")
|
||||
_limit = result.get("limit", 1)
|
||||
|
||||
print("[CharacterList] Loaded %d characters (tier=%s, limit=%d)" % [_characters.size(), _tier, _limit])
|
||||
|
||||
_populate_character_list()
|
||||
_update_ui()
|
||||
|
||||
|
||||
func _on_characters_error(response: APIResponse) -> void:
|
||||
_set_loading(false)
|
||||
|
||||
var message = response.get_error_message()
|
||||
if message.is_empty():
|
||||
message = "Failed to load characters"
|
||||
|
||||
if response.status == 401:
|
||||
print("[CharacterList] Session expired, redirecting to login")
|
||||
get_tree().change_scene_to_file("res://scenes/auth/login.tscn")
|
||||
return
|
||||
|
||||
print("[CharacterList] Error: %s (status=%d)" % [message, response.status])
|
||||
_show_error(message)
|
||||
|
||||
|
||||
func _on_character_card_selected(character_id: String) -> void:
|
||||
print("[CharacterList] Character selected: %s" % character_id)
|
||||
state_manager.select_character(character_id)
|
||||
character_selected.emit(character_id)
|
||||
# TODO: Navigate to main game scene
|
||||
# get_tree().change_scene_to_file("res://scenes/game/main_game.tscn")
|
||||
|
||||
|
||||
func _on_character_delete_requested(character_id: String) -> void:
|
||||
print("[CharacterList] Delete requested for: %s" % character_id)
|
||||
# TODO: Show confirmation dialog, then call DELETE endpoint
|
||||
# For now, directly delete
|
||||
_delete_character(character_id)
|
||||
|
||||
|
||||
func _delete_character(character_id: String) -> void:
|
||||
"""Delete a character via API."""
|
||||
http_client.http_delete(
|
||||
"/api/v1/characters/%s" % character_id,
|
||||
func(response: APIResponse):
|
||||
if response.is_success():
|
||||
print("[CharacterList] Character deleted: %s" % character_id)
|
||||
load_characters() # Refresh list
|
||||
else:
|
||||
_show_error(response.get_error_message()),
|
||||
func(response: APIResponse):
|
||||
_show_error(response.get_error_message())
|
||||
)
|
||||
|
||||
|
||||
func _on_create_button_pressed() -> void:
|
||||
print("[CharacterList] Create button pressed")
|
||||
create_requested.emit()
|
||||
# TODO: Navigate to character creation wizard
|
||||
# get_tree().change_scene_to_file("res://scenes/character/character_create.tscn")
|
||||
|
||||
|
||||
func _on_refresh_button_pressed() -> void:
|
||||
print("[CharacterList] Refresh button pressed")
|
||||
refresh()
|
||||
#endregion
|
||||
1
godot_client/scripts/character/character_list.gd.uid
Normal file
1
godot_client/scripts/character/character_list.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://x4mt6jwbywsl
|
||||
202
godot_client/scripts/components/card.gd
Normal file
202
godot_client/scripts/components/card.gd
Normal file
@@ -0,0 +1,202 @@
|
||||
extends PanelContainer
|
||||
class_name Card
|
||||
## Card Component
|
||||
##
|
||||
## Styled container for content with optional header and footer.
|
||||
## Mimics the card design from the web UI.
|
||||
##
|
||||
## Structure:
|
||||
## Card (PanelContainer)
|
||||
## └─ VBoxContainer
|
||||
## ├─ Header (optional)
|
||||
## ├─ Body (content)
|
||||
## └─ Footer (optional)
|
||||
##
|
||||
## Usage:
|
||||
## var card = Card.new()
|
||||
## card.set_header("Character Details")
|
||||
## card.add_content(my_content_node)
|
||||
## card.set_footer_buttons(["Save", "Cancel"])
|
||||
|
||||
signal header_action_pressed(action: String)
|
||||
signal footer_button_pressed(button_text: String)
|
||||
|
||||
# Export variables
|
||||
@export var header_text: String = ""
|
||||
@export var show_header: bool = false
|
||||
@export var show_footer: bool = false
|
||||
@export var card_style: StyleVariant = StyleVariant.DEFAULT
|
||||
|
||||
# Style variants
|
||||
enum StyleVariant {
|
||||
DEFAULT, # Standard card
|
||||
HIGHLIGHTED, # Gold border highlight
|
||||
SUBTLE # Minimal border
|
||||
}
|
||||
|
||||
# Internal nodes
|
||||
var _container: VBoxContainer = null
|
||||
var _header_container: HBoxContainer = null
|
||||
var _header_label: Label = null
|
||||
var _body_container: MarginContainer = null
|
||||
var _body_content: VBoxContainer = null
|
||||
var _footer_container: HBoxContainer = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_structure()
|
||||
_apply_style()
|
||||
|
||||
if not header_text.is_empty():
|
||||
set_header(header_text)
|
||||
|
||||
|
||||
## Setup card structure
|
||||
func _setup_structure() -> void:
|
||||
# Main container
|
||||
_container = VBoxContainer.new()
|
||||
add_child(_container)
|
||||
|
||||
# Header
|
||||
_header_container = HBoxContainer.new()
|
||||
_header_container.visible = show_header
|
||||
|
||||
_header_label = Label.new()
|
||||
_header_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
# TODO: Set heading font when theme is set up
|
||||
_header_container.add_child(_header_label)
|
||||
|
||||
_container.add_child(_header_container)
|
||||
|
||||
# Add separator after header
|
||||
var separator = HSeparator.new()
|
||||
separator.visible = show_header
|
||||
_container.add_child(separator)
|
||||
|
||||
# Body
|
||||
_body_container = MarginContainer.new()
|
||||
_body_container.add_theme_constant_override("margin_left", 16)
|
||||
_body_container.add_theme_constant_override("margin_right", 16)
|
||||
_body_container.add_theme_constant_override("margin_top", 16)
|
||||
_body_container.add_theme_constant_override("margin_bottom", 16)
|
||||
|
||||
_body_content = VBoxContainer.new()
|
||||
_body_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_body_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
|
||||
_body_container.add_child(_body_content)
|
||||
_container.add_child(_body_container)
|
||||
|
||||
# Footer
|
||||
var footer_separator = HSeparator.new()
|
||||
footer_separator.visible = show_footer
|
||||
_container.add_child(footer_separator)
|
||||
|
||||
_footer_container = HBoxContainer.new()
|
||||
_footer_container.visible = show_footer
|
||||
_footer_container.alignment = BoxContainer.ALIGNMENT_END
|
||||
_container.add_child(_footer_container)
|
||||
|
||||
|
||||
## Apply card styling
|
||||
func _apply_style() -> void:
|
||||
var style = StyleBoxFlat.new()
|
||||
|
||||
match card_style:
|
||||
StyleVariant.DEFAULT:
|
||||
style.bg_color = ThemeColors.BACKGROUND_CARD
|
||||
style.border_width_all = 1
|
||||
style.border_color = ThemeColors.BORDER_DEFAULT
|
||||
style.corner_radius_all = 8
|
||||
|
||||
StyleVariant.HIGHLIGHTED:
|
||||
style.bg_color = ThemeColors.BACKGROUND_CARD
|
||||
style.border_width_all = 2
|
||||
style.border_color = ThemeColors.GOLD_ACCENT
|
||||
style.corner_radius_all = 8
|
||||
style.shadow_color = ThemeColors.SHADOW
|
||||
style.shadow_size = 4
|
||||
|
||||
StyleVariant.SUBTLE:
|
||||
style.bg_color = ThemeColors.BACKGROUND_CARD
|
||||
style.border_width_all = 1
|
||||
style.border_color = ThemeColors.DIVIDER
|
||||
style.corner_radius_all = 4
|
||||
|
||||
add_theme_stylebox_override("panel", style)
|
||||
|
||||
|
||||
## Set header text and show header
|
||||
func set_header(text: String) -> void:
|
||||
header_text = text
|
||||
_header_label.text = text
|
||||
_header_container.visible = true
|
||||
show_header = true
|
||||
|
||||
# Show separator
|
||||
if _container.get_child_count() > 1:
|
||||
_container.get_child(1).visible = true
|
||||
|
||||
|
||||
## Hide header
|
||||
func hide_header() -> void:
|
||||
_header_container.visible = false
|
||||
show_header = false
|
||||
|
||||
# Hide separator
|
||||
if _container.get_child_count() > 1:
|
||||
_container.get_child(1).visible = false
|
||||
|
||||
|
||||
## Add content to card body
|
||||
func add_content(node: Node) -> void:
|
||||
_body_content.add_child(node)
|
||||
|
||||
|
||||
## Clear all content from body
|
||||
func clear_content() -> void:
|
||||
for child in _body_content.get_children():
|
||||
child.queue_free()
|
||||
|
||||
|
||||
## Set footer buttons
|
||||
func set_footer_buttons(button_labels: Array[String]) -> void:
|
||||
# Clear existing buttons
|
||||
for child in _footer_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
# Add new buttons
|
||||
for label in button_labels:
|
||||
var btn = Button.new()
|
||||
btn.text = label
|
||||
btn.pressed.connect(_on_footer_button_pressed.bind(label))
|
||||
_footer_container.add_child(btn)
|
||||
|
||||
# Show footer
|
||||
_footer_container.visible = true
|
||||
show_footer = true
|
||||
|
||||
# Show separator
|
||||
if _container.get_child_count() > 2:
|
||||
_container.get_child(_container.get_child_count() - 2).visible = true
|
||||
|
||||
|
||||
## Hide footer
|
||||
func hide_footer() -> void:
|
||||
_footer_container.visible = false
|
||||
show_footer = false
|
||||
|
||||
# Hide separator
|
||||
if _container.get_child_count() > 2:
|
||||
_container.get_child(_container.get_child_count() - 2).visible = false
|
||||
|
||||
|
||||
## Set card style variant
|
||||
func set_style_variant(variant: StyleVariant) -> void:
|
||||
card_style = variant
|
||||
_apply_style()
|
||||
|
||||
|
||||
## Internal: Handle footer button press
|
||||
func _on_footer_button_pressed(button_text: String) -> void:
|
||||
footer_button_pressed.emit(button_text)
|
||||
1
godot_client/scripts/components/card.gd.uid
Normal file
1
godot_client/scripts/components/card.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dq4fplw7kw5yu
|
||||
280
godot_client/scripts/components/form_field.gd
Normal file
280
godot_client/scripts/components/form_field.gd
Normal file
@@ -0,0 +1,280 @@
|
||||
extends VBoxContainer
|
||||
class_name FormField
|
||||
## Form Field Component
|
||||
##
|
||||
## Combines label, input field, and error message display.
|
||||
## Supports validation and different input types.
|
||||
##
|
||||
## Structure:
|
||||
## VBoxContainer
|
||||
## ├─ Label (field label)
|
||||
## ├─ LineEdit (input field)
|
||||
## └─ Label (error message, hidden by default)
|
||||
##
|
||||
## Usage:
|
||||
## var field = FormField.new()
|
||||
## field.label_text = "Email"
|
||||
## field.placeholder = "Enter your email"
|
||||
## field.input_type = FormField.InputType.EMAIL
|
||||
## field.value_changed.connect(_on_email_changed)
|
||||
|
||||
signal value_changed(new_value: String)
|
||||
signal validation_changed(is_valid: bool)
|
||||
|
||||
# Input types
|
||||
enum InputType {
|
||||
TEXT, # Plain text
|
||||
EMAIL, # Email validation
|
||||
PASSWORD, # Password (hidden)
|
||||
NUMBER, # Numeric only
|
||||
PHONE, # Phone number
|
||||
}
|
||||
|
||||
# Export variables
|
||||
@export var label_text: String = ""
|
||||
@export var placeholder: String = ""
|
||||
@export var input_type: InputType = InputType.TEXT
|
||||
@export var required: bool = false
|
||||
@export var min_length: int = 0
|
||||
@export var max_length: int = 0
|
||||
|
||||
# Internal nodes
|
||||
var _label: Label = null
|
||||
var _input: LineEdit = null
|
||||
var _error_label: Label = null
|
||||
|
||||
# State
|
||||
var _is_valid: bool = true
|
||||
var _error_message: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_structure()
|
||||
_apply_styling()
|
||||
|
||||
|
||||
## Setup form field structure
|
||||
func _setup_structure() -> void:
|
||||
# Label
|
||||
_label = Label.new()
|
||||
_label.text = label_text
|
||||
add_child(_label)
|
||||
|
||||
# Spacing
|
||||
var spacer1 = Control.new()
|
||||
spacer1.custom_minimum_size = Vector2(0, 4)
|
||||
add_child(spacer1)
|
||||
|
||||
# Input field
|
||||
_input = LineEdit.new()
|
||||
_input.placeholder_text = placeholder
|
||||
_input.text_changed.connect(_on_text_changed)
|
||||
_input.text_submitted.connect(_on_text_submitted)
|
||||
|
||||
# Set input type
|
||||
match input_type:
|
||||
InputType.PASSWORD:
|
||||
_input.secret = true
|
||||
InputType.NUMBER:
|
||||
# TODO: Add number-only filter
|
||||
pass
|
||||
_:
|
||||
pass
|
||||
|
||||
# Set max length if specified
|
||||
if max_length > 0:
|
||||
_input.max_length = max_length
|
||||
|
||||
add_child(_input)
|
||||
|
||||
# Spacing
|
||||
var spacer2 = Control.new()
|
||||
spacer2.custom_minimum_size = Vector2(0, 2)
|
||||
add_child(spacer2)
|
||||
|
||||
# Error label
|
||||
_error_label = Label.new()
|
||||
_error_label.visible = false
|
||||
add_child(_error_label)
|
||||
|
||||
|
||||
## Apply styling
|
||||
func _apply_styling() -> void:
|
||||
# Label styling
|
||||
_label.add_theme_color_override("font_color", ThemeColors.TEXT_PRIMARY)
|
||||
|
||||
# Input styling
|
||||
var normal_style = StyleBoxFlat.new()
|
||||
normal_style.bg_color = ThemeColors.BACKGROUND_SECONDARY
|
||||
normal_style.border_width_all = 2
|
||||
normal_style.border_color = ThemeColors.BORDER_DEFAULT
|
||||
normal_style.corner_radius_all = 4
|
||||
normal_style.content_margin_left = 12
|
||||
normal_style.content_margin_right = 12
|
||||
normal_style.content_margin_top = 8
|
||||
normal_style.content_margin_bottom = 8
|
||||
_input.add_theme_stylebox_override("normal", normal_style)
|
||||
|
||||
var focus_style = normal_style.duplicate()
|
||||
focus_style.border_color = ThemeColors.GOLD_ACCENT
|
||||
_input.add_theme_stylebox_override("focus", focus_style)
|
||||
|
||||
_input.add_theme_color_override("font_color", ThemeColors.TEXT_PRIMARY)
|
||||
_input.add_theme_color_override("font_placeholder_color", ThemeColors.TEXT_SECONDARY)
|
||||
_input.add_theme_color_override("caret_color", ThemeColors.GOLD_ACCENT)
|
||||
|
||||
# Error label styling
|
||||
_error_label.add_theme_color_override("font_color", ThemeColors.ERROR)
|
||||
# TODO: Add smaller font size when theme is set up
|
||||
|
||||
|
||||
## Get current value
|
||||
func get_value() -> String:
|
||||
return _input.text if _input else ""
|
||||
|
||||
|
||||
## Set value
|
||||
func set_value(value: String) -> void:
|
||||
if _input:
|
||||
_input.text = value
|
||||
_validate()
|
||||
|
||||
|
||||
## Set label text
|
||||
func set_label(text: String) -> void:
|
||||
label_text = text
|
||||
if _label:
|
||||
_label.text = text
|
||||
|
||||
|
||||
## Set placeholder text
|
||||
func set_placeholder(text: String) -> void:
|
||||
placeholder = text
|
||||
if _input:
|
||||
_input.placeholder_text = text
|
||||
|
||||
|
||||
## Show error message
|
||||
func show_error(message: String) -> void:
|
||||
_error_message = message
|
||||
_is_valid = false
|
||||
|
||||
if _error_label:
|
||||
_error_label.text = message
|
||||
_error_label.visible = true
|
||||
|
||||
# Change input border to error color
|
||||
if _input:
|
||||
var error_style = StyleBoxFlat.new()
|
||||
error_style.bg_color = ThemeColors.BACKGROUND_SECONDARY
|
||||
error_style.border_width_all = 2
|
||||
error_style.border_color = ThemeColors.ERROR
|
||||
error_style.corner_radius_all = 4
|
||||
error_style.content_margin_left = 12
|
||||
error_style.content_margin_right = 12
|
||||
error_style.content_margin_top = 8
|
||||
error_style.content_margin_bottom = 8
|
||||
_input.add_theme_stylebox_override("normal", error_style)
|
||||
|
||||
validation_changed.emit(false)
|
||||
|
||||
|
||||
## Clear error
|
||||
func clear_error() -> void:
|
||||
_error_message = ""
|
||||
_is_valid = true
|
||||
|
||||
if _error_label:
|
||||
_error_label.visible = false
|
||||
|
||||
# Restore normal input styling
|
||||
_apply_styling()
|
||||
|
||||
validation_changed.emit(true)
|
||||
|
||||
|
||||
## Check if field is valid
|
||||
func is_valid() -> bool:
|
||||
return _is_valid
|
||||
|
||||
|
||||
## Focus the input field
|
||||
func focus_input() -> void:
|
||||
if _input:
|
||||
_input.grab_focus()
|
||||
|
||||
|
||||
## Validate the field
|
||||
func validate() -> bool:
|
||||
return _validate()
|
||||
|
||||
|
||||
## Internal: Validate input
|
||||
func _validate() -> bool:
|
||||
var value = get_value()
|
||||
|
||||
# Required check
|
||||
if required and value.is_empty():
|
||||
show_error("This field is required")
|
||||
return false
|
||||
|
||||
# Min length check
|
||||
if min_length > 0 and value.length() < min_length:
|
||||
show_error("Must be at least %d characters" % min_length)
|
||||
return false
|
||||
|
||||
# Max length check (redundant with LineEdit.max_length, but kept for consistency)
|
||||
if max_length > 0 and value.length() > max_length:
|
||||
show_error("Must be no more than %d characters" % max_length)
|
||||
return false
|
||||
|
||||
# Type-specific validation
|
||||
match input_type:
|
||||
InputType.EMAIL:
|
||||
if not _is_valid_email(value) and not value.is_empty():
|
||||
show_error("Invalid email address")
|
||||
return false
|
||||
|
||||
InputType.NUMBER:
|
||||
if not value.is_valid_int() and not value.is_empty():
|
||||
show_error("Must be a number")
|
||||
return false
|
||||
|
||||
InputType.PHONE:
|
||||
if not _is_valid_phone(value) and not value.is_empty():
|
||||
show_error("Invalid phone number")
|
||||
return false
|
||||
|
||||
# All validations passed
|
||||
clear_error()
|
||||
return true
|
||||
|
||||
|
||||
## Internal: Email validation
|
||||
func _is_valid_email(email: String) -> bool:
|
||||
# Basic email regex validation
|
||||
var regex = RegEx.new()
|
||||
regex.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
|
||||
return regex.search(email) != null
|
||||
|
||||
|
||||
## Internal: Phone validation
|
||||
func _is_valid_phone(phone: String) -> bool:
|
||||
# Basic phone validation (digits, spaces, dashes, parentheses)
|
||||
var regex = RegEx.new()
|
||||
regex.compile("^[0-9\\s\\-\\(\\)\\+]+$")
|
||||
return regex.search(phone) != null and phone.length() >= 10
|
||||
|
||||
|
||||
## Internal: Handle text change
|
||||
func _on_text_changed(new_text: String) -> void:
|
||||
# Clear error when user starts typing
|
||||
if not _error_message.is_empty():
|
||||
clear_error()
|
||||
|
||||
value_changed.emit(new_text)
|
||||
|
||||
|
||||
## Internal: Handle text submission (Enter key)
|
||||
func _on_text_submitted(new_text: String) -> void:
|
||||
_validate()
|
||||
1
godot_client/scripts/components/form_field.gd.uid
Normal file
1
godot_client/scripts/components/form_field.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dvx6m2ahutlgm
|
||||
171
godot_client/scripts/main.gd
Normal file
171
godot_client/scripts/main.gd
Normal file
@@ -0,0 +1,171 @@
|
||||
extends Control
|
||||
## Main Scene
|
||||
##
|
||||
## Entry point for the application.
|
||||
## Displays loading screen with logged in player info.
|
||||
## Provides "Play Now" and "Logout" buttons for authenticated users.
|
||||
|
||||
# Scene paths
|
||||
const SCENE_LOGIN = "res://scenes/auth/login.tscn"
|
||||
const SCENE_CHARACTER_LIST = "res://scenes/character/character_list.tscn"
|
||||
|
||||
# UI node references (from scene)
|
||||
@onready var background_panel: Panel = $BackgroundPanel
|
||||
@onready var title_label: Label = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/TitleLabel
|
||||
@onready var welcome_label: Label = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/WelcomeLabel
|
||||
@onready var status_label: Label = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/StatusLabel
|
||||
@onready var button_container: HBoxContainer = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/ButtonContainer
|
||||
@onready var play_now_button: Button = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/ButtonContainer/PlayNowButton
|
||||
@onready var logout_button: Button = $CenterContainer/PanelContainer/MarginContainer/VBoxContainer/ButtonContainer/LogoutButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
print("=== Code of Conquest Starting ===")
|
||||
print("Godot Version: ", Engine.get_version_info())
|
||||
print("Platform: ", OS.get_name())
|
||||
|
||||
# Connect button signals
|
||||
play_now_button.pressed.connect(_on_play_now_pressed)
|
||||
logout_button.pressed.connect(_on_logout_pressed)
|
||||
|
||||
# Initially hide buttons (will show after auth check)
|
||||
button_container.visible = false
|
||||
|
||||
# Wait a frame for services to initialize
|
||||
await get_tree().process_frame
|
||||
|
||||
# Check authentication status
|
||||
_check_authentication()
|
||||
|
||||
|
||||
|
||||
## Check if user is authenticated
|
||||
func _check_authentication() -> void:
|
||||
if StateManager.is_authenticated():
|
||||
print("[Main] User is authenticated")
|
||||
|
||||
# Get user info and display
|
||||
var user_data = StateManager.get_current_user()
|
||||
var username = user_data.get("email", user_data.get("name", "Player"))
|
||||
welcome_label.text = "Welcome, %s" % username
|
||||
|
||||
status_label.text = "Validating session..."
|
||||
|
||||
# Validate token with backend
|
||||
_validate_token()
|
||||
else:
|
||||
print("[Main] User not authenticated")
|
||||
welcome_label.text = "Not authenticated"
|
||||
status_label.text = "Please log in to continue"
|
||||
|
||||
# Wait a moment then navigate to login
|
||||
await get_tree().create_timer(1.5).timeout
|
||||
_navigate_to_login()
|
||||
|
||||
|
||||
## Validate auth token with backend
|
||||
func _validate_token() -> void:
|
||||
# TODO: Replace with actual validation endpoint
|
||||
# For now, assume token is valid and show buttons
|
||||
|
||||
print("[Main] Token validation not yet implemented")
|
||||
print("[Main] Assuming valid, showing play options...")
|
||||
|
||||
# Uncomment when endpoint exists:
|
||||
# HTTPClient.http_get("/api/v1/auth/validate", _on_token_valid, _on_token_invalid)
|
||||
|
||||
# For now, just show buttons (remove this when validation is implemented)
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
_show_play_options()
|
||||
|
||||
|
||||
## Show play/logout buttons
|
||||
func _show_play_options() -> void:
|
||||
status_label.text = "Ready to play!"
|
||||
button_container.visible = true
|
||||
|
||||
|
||||
## Handle valid token
|
||||
func _on_token_valid(response: APIResponse) -> void:
|
||||
if response.is_success():
|
||||
print("[Main] Token is valid")
|
||||
_show_play_options()
|
||||
else:
|
||||
print("[Main] Token validation failed: ", response.get_error_message())
|
||||
_handle_invalid_token()
|
||||
|
||||
|
||||
## Handle invalid token
|
||||
#func _on_token_invalid(response: APIResponse) -> void:
|
||||
#print("[Main] Token invalid or network error")
|
||||
#_handle_invalid_token()
|
||||
|
||||
|
||||
## Clear session and go to login
|
||||
func _handle_invalid_token() -> void:
|
||||
status_label.text = "Session expired"
|
||||
welcome_label.text = "Please log in again"
|
||||
button_container.visible = false
|
||||
|
||||
StateManager.clear_user_session()
|
||||
|
||||
await get_tree().create_timer(1.5).timeout
|
||||
_navigate_to_login()
|
||||
|
||||
|
||||
## Handle "Play Now" button press
|
||||
func _on_play_now_pressed() -> void:
|
||||
print("[Main] Play Now button pressed")
|
||||
|
||||
# Disable buttons during navigation
|
||||
button_container.visible = false
|
||||
status_label.text = "Loading characters..."
|
||||
|
||||
# Navigate to character list
|
||||
_navigate_to_character_list()
|
||||
|
||||
|
||||
## Handle "Logout" button press
|
||||
func _on_logout_pressed() -> void:
|
||||
print("[Main] Logout button pressed")
|
||||
|
||||
# Disable buttons
|
||||
button_container.visible = false
|
||||
status_label.text = "Logging out..."
|
||||
welcome_label.text = "Goodbye!"
|
||||
|
||||
# Clear session
|
||||
StateManager.clear_user_session()
|
||||
|
||||
# Wait a moment then navigate to login
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
_navigate_to_login()
|
||||
|
||||
|
||||
## Navigate to login scene
|
||||
func _navigate_to_login() -> void:
|
||||
print("[Main] Navigating to login...")
|
||||
|
||||
# Check if scene exists
|
||||
if not FileAccess.file_exists(SCENE_LOGIN):
|
||||
print("[Main] ERROR: Login scene not found at ", SCENE_LOGIN)
|
||||
status_label.text = "Login scene not yet created.\nSee Phase 2 in GETTING_STARTED.md"
|
||||
return
|
||||
|
||||
# Navigate
|
||||
get_tree().change_scene_to_file(SCENE_LOGIN)
|
||||
|
||||
|
||||
## Navigate to character list
|
||||
func _navigate_to_character_list() -> void:
|
||||
print("[Main] Navigating to character list...")
|
||||
|
||||
# Check if scene exists
|
||||
if not FileAccess.file_exists(SCENE_CHARACTER_LIST):
|
||||
print("[Main] ERROR: Character list scene not found at ", SCENE_CHARACTER_LIST)
|
||||
status_label.text = "Character list not yet created.\nSee Phase 3 in GETTING_STARTED.md"
|
||||
button_container.visible = true # Re-show buttons
|
||||
return
|
||||
|
||||
# Navigate
|
||||
get_tree().change_scene_to_file(SCENE_CHARACTER_LIST)
|
||||
1
godot_client/scripts/main.gd.uid
Normal file
1
godot_client/scripts/main.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d100mupmyal5
|
||||
56
godot_client/scripts/models/api_response.gd
Normal file
56
godot_client/scripts/models/api_response.gd
Normal file
@@ -0,0 +1,56 @@
|
||||
extends RefCounted
|
||||
class_name APIResponse
|
||||
## API Response Model
|
||||
##
|
||||
## Represents a response from the Flask backend API.
|
||||
## Matches the backend response format:
|
||||
## {
|
||||
## "app": "Code of Conquest",
|
||||
## "version": "0.1.0",
|
||||
## "status": 200,
|
||||
## "timestamp": "2025-11-16T...",
|
||||
## "result": {...},
|
||||
## "error": {...},
|
||||
## "meta": {...}
|
||||
## }
|
||||
|
||||
var app: String
|
||||
var version: String
|
||||
var status: int
|
||||
var timestamp: String
|
||||
var result: Variant
|
||||
var error: Dictionary
|
||||
var meta: Dictionary
|
||||
var raw_response: String
|
||||
|
||||
|
||||
func _init(json_data: Dictionary) -> void:
|
||||
app = json_data.get("app", "")
|
||||
version = json_data.get("version", "")
|
||||
status = json_data.get("status", 0)
|
||||
timestamp = json_data.get("timestamp", "")
|
||||
result = json_data.get("result", null)
|
||||
|
||||
# Handle Dictionary fields that might be null
|
||||
var error_data = json_data.get("error", null)
|
||||
error = error_data if error_data != null else {}
|
||||
|
||||
var meta_data = json_data.get("meta", null)
|
||||
meta = meta_data if meta_data != null else {}
|
||||
|
||||
|
||||
## Check if the request was successful (2xx status)
|
||||
func is_success() -> bool:
|
||||
return status >= 200 and status < 300
|
||||
|
||||
|
||||
## Check if there's an error
|
||||
func has_error() -> bool:
|
||||
return not error.is_empty() or status >= 400
|
||||
|
||||
|
||||
## Get error message if present
|
||||
func get_error_message() -> String:
|
||||
if error.has("message"):
|
||||
return error["message"]
|
||||
return ""
|
||||
1
godot_client/scripts/models/api_response.gd.uid
Normal file
1
godot_client/scripts/models/api_response.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cwwx1jcnpafur
|
||||
364
godot_client/scripts/services/http_client.gd
Normal file
364
godot_client/scripts/services/http_client.gd
Normal file
@@ -0,0 +1,364 @@
|
||||
extends Node
|
||||
## HTTPClient Service
|
||||
##
|
||||
## Singleton service for all HTTP communication with the Flask backend.
|
||||
## Handles authentication, JSON parsing, error handling, and provides
|
||||
## a convenient interface for making API requests.
|
||||
##
|
||||
## Usage:
|
||||
## HTTPClient.http_get("/api/v1/characters", _on_characters_loaded)
|
||||
## HTTPClient.http_post("/api/v1/auth/login", {"email": "...", "password": "..."}, _on_login_complete)
|
||||
|
||||
# API Configuration
|
||||
const API_TIMEOUT := 30.0 # Seconds
|
||||
|
||||
# HTTP Methods
|
||||
enum Method {
|
||||
GET,
|
||||
POST,
|
||||
PUT,
|
||||
DELETE,
|
||||
PATCH
|
||||
}
|
||||
|
||||
# Internal state
|
||||
var _auth_token: String = ""
|
||||
var _session_cookie: String = "" # Session cookie for authentication
|
||||
#var _request_queue: Array[Dictionary] = []
|
||||
var _active_requests: Dictionary = {} # request_id -> HTTPRequest node
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
print("[HTTPClient] Service initialized. API Base URL: %s" % Settings.get_api_url())
|
||||
|
||||
|
||||
## Make a GET request
|
||||
##
|
||||
## @param endpoint: API endpoint (e.g., "/api/v1/characters")
|
||||
## @param callback: Function to call with APIResponse when complete
|
||||
## @param error_callback: Optional function to call on error
|
||||
func http_get(endpoint: String, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
_make_request(Method.GET, endpoint, {}, callback, error_callback)
|
||||
|
||||
|
||||
## Make a POST request
|
||||
##
|
||||
## @param endpoint: API endpoint
|
||||
## @param data: Dictionary to send as JSON body
|
||||
## @param callback: Function to call with APIResponse when complete
|
||||
## @param error_callback: Optional function to call on error
|
||||
func http_post(endpoint: String, data: Dictionary, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
_make_request(Method.POST, endpoint, data, callback, error_callback)
|
||||
|
||||
|
||||
## Make a PUT request
|
||||
##
|
||||
## @param endpoint: API endpoint
|
||||
## @param data: Dictionary to send as JSON body
|
||||
## @param callback: Function to call with APIResponse when complete
|
||||
## @param error_callback: Optional function to call on error
|
||||
func http_put(endpoint: String, data: Dictionary, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
_make_request(Method.PUT, endpoint, data, callback, error_callback)
|
||||
|
||||
|
||||
## Make a DELETE request
|
||||
##
|
||||
## @param endpoint: API endpoint
|
||||
## @param callback: Function to call with APIResponse when complete
|
||||
## @param error_callback: Optional function to call on error
|
||||
func http_delete(endpoint: String, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
_make_request(Method.DELETE, endpoint, {}, callback, error_callback)
|
||||
|
||||
|
||||
## Make a PATCH request
|
||||
##
|
||||
## @param endpoint: API endpoint
|
||||
## @param data: Dictionary to send as JSON body
|
||||
## @param callback: Function to call with APIResponse when complete
|
||||
## @param error_callback: Optional function to call on error
|
||||
func http_patch(endpoint: String, data: Dictionary, callback: Callable, error_callback: Callable = Callable()) -> void:
|
||||
_make_request(Method.PATCH, endpoint, data, callback, error_callback)
|
||||
|
||||
|
||||
## Set the authentication token for subsequent requests
|
||||
##
|
||||
## @param token: JWT token from login/registration
|
||||
func set_auth_token(token: String) -> void:
|
||||
_auth_token = token
|
||||
print("[HTTPClient] Auth token set")
|
||||
|
||||
|
||||
## Clear the authentication token (logout)
|
||||
func clear_auth_token() -> void:
|
||||
_auth_token = ""
|
||||
print("[HTTPClient] Auth token cleared")
|
||||
|
||||
|
||||
## Get current auth token
|
||||
func get_auth_token() -> String:
|
||||
return _auth_token
|
||||
|
||||
|
||||
## Set the session cookie for subsequent requests
|
||||
##
|
||||
## @param cookie: Session cookie value (e.g., "coc_session=xxx")
|
||||
func set_session_cookie(cookie: String) -> void:
|
||||
_session_cookie = cookie
|
||||
print("[HTTPClient] Session cookie set")
|
||||
|
||||
|
||||
## Clear the session cookie (logout)
|
||||
func clear_session_cookie() -> void:
|
||||
_session_cookie = ""
|
||||
print("[HTTPClient] Session cookie cleared")
|
||||
|
||||
|
||||
## Get current session cookie
|
||||
func get_session_cookie() -> String:
|
||||
return _session_cookie
|
||||
|
||||
|
||||
## Check if authenticated
|
||||
func is_authenticated() -> bool:
|
||||
return not _auth_token.is_empty() or not _session_cookie.is_empty()
|
||||
|
||||
|
||||
## Internal: Make HTTP request
|
||||
func _make_request(
|
||||
method: Method,
|
||||
endpoint: String,
|
||||
data: Dictionary,
|
||||
callback: Callable,
|
||||
error_callback: Callable
|
||||
) -> void:
|
||||
# Create HTTPRequest node
|
||||
var http_request := HTTPRequest.new()
|
||||
add_child(http_request)
|
||||
|
||||
# Generate request ID for tracking
|
||||
var request_id := "%s_%d" % [endpoint.get_file(), Time.get_ticks_msec()]
|
||||
_active_requests[request_id] = http_request
|
||||
|
||||
# Build full URL
|
||||
var url := Settings.get_api_url() + endpoint
|
||||
|
||||
# Build headers
|
||||
var headers := _build_headers()
|
||||
|
||||
# Connect completion signal
|
||||
http_request.request_completed.connect(
|
||||
_on_request_completed.bind(request_id, callback, error_callback)
|
||||
)
|
||||
|
||||
# Set timeout
|
||||
http_request.timeout = API_TIMEOUT
|
||||
|
||||
# Make request based on method
|
||||
var method_int := _method_to_int(method)
|
||||
var body := ""
|
||||
|
||||
if method in [Method.POST, Method.PUT, Method.PATCH]:
|
||||
body = JSON.stringify(data)
|
||||
|
||||
print("[HTTPClient] %s %s" % [_method_to_string(method), url])
|
||||
if not body.is_empty():
|
||||
print("[HTTPClient] Body: %s" % body)
|
||||
|
||||
var error := http_request.request(url, headers, method_int, body)
|
||||
|
||||
if error != OK:
|
||||
push_error("[HTTPClient] Failed to initiate request: %s" % error)
|
||||
_cleanup_request(request_id)
|
||||
if error_callback.is_valid():
|
||||
error_callback.call(_create_error_response("Failed to initiate request", 0))
|
||||
|
||||
|
||||
## Internal: Build request headers
|
||||
func _build_headers() -> PackedStringArray:
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Accept: application/json"
|
||||
])
|
||||
|
||||
# Add auth token if present (for JWT auth)
|
||||
if not _auth_token.is_empty():
|
||||
headers.append("Authorization: Bearer %s" % _auth_token)
|
||||
|
||||
# Add session cookie if present (for cookie-based auth)
|
||||
if not _session_cookie.is_empty():
|
||||
headers.append("Cookie: %s" % _session_cookie)
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
## Internal: Handle request completion
|
||||
func _on_request_completed(
|
||||
result: int,
|
||||
response_code: int,
|
||||
headers: PackedStringArray,
|
||||
body: PackedByteArray,
|
||||
request_id: String,
|
||||
callback: Callable,
|
||||
error_callback: Callable
|
||||
) -> void:
|
||||
print("[HTTPClient] Request completed: %s (status=%d)" % [request_id, response_code])
|
||||
|
||||
# Extract Set-Cookie header if present
|
||||
_extract_session_cookie(headers)
|
||||
|
||||
# Parse response body
|
||||
var body_string := body.get_string_from_utf8()
|
||||
|
||||
# Handle network errors
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
push_error("[HTTPClient] Network error: %s" % _result_to_string(result))
|
||||
_cleanup_request(request_id)
|
||||
|
||||
if error_callback.is_valid():
|
||||
error_callback.call(_create_error_response(_result_to_string(result), response_code))
|
||||
return
|
||||
|
||||
# Parse JSON response
|
||||
var json := JSON.new()
|
||||
var parse_error := json.parse(body_string)
|
||||
|
||||
if parse_error != OK:
|
||||
push_error("[HTTPClient] Failed to parse JSON: %s" % body_string)
|
||||
_cleanup_request(request_id)
|
||||
|
||||
if error_callback.is_valid():
|
||||
error_callback.call(_create_error_response("Failed to parse JSON response", response_code))
|
||||
return
|
||||
|
||||
# Create APIResponse
|
||||
var api_response: APIResponse = APIResponse.new(json.data)
|
||||
api_response.raw_response = body_string
|
||||
|
||||
# Check for errors
|
||||
if api_response.has_error():
|
||||
print("[HTTPClient] API error: %s" % api_response.get_error_message())
|
||||
|
||||
if error_callback.is_valid():
|
||||
error_callback.call(api_response)
|
||||
elif callback.is_valid():
|
||||
# Call regular callback even with error if no error callback
|
||||
callback.call(api_response)
|
||||
else:
|
||||
print("[HTTPClient] Success: %s" % request_id)
|
||||
|
||||
if callback.is_valid():
|
||||
callback.call(api_response)
|
||||
|
||||
_cleanup_request(request_id)
|
||||
|
||||
|
||||
## Internal: Extract session cookie from response headers
|
||||
func _extract_session_cookie(headers: PackedStringArray) -> void:
|
||||
for header in headers:
|
||||
# Look for Set-Cookie header
|
||||
if header.begins_with("Set-Cookie:") or header.begins_with("set-cookie:"):
|
||||
# Extract cookie value
|
||||
var cookie_string := header.substr(11).strip_edges() # Remove "Set-Cookie:"
|
||||
|
||||
# Look for coc_session cookie
|
||||
if cookie_string.begins_with("coc_session="):
|
||||
# Extract just the cookie name=value part (before semicolon)
|
||||
var cookie_parts := cookie_string.split(";")
|
||||
if cookie_parts.size() > 0:
|
||||
_session_cookie = cookie_parts[0].strip_edges()
|
||||
print("[HTTPClient] Session cookie extracted: %s" % _session_cookie)
|
||||
return
|
||||
|
||||
|
||||
## Internal: Cleanup request resources
|
||||
func _cleanup_request(request_id: String) -> void:
|
||||
if _active_requests.has(request_id):
|
||||
var http_request: HTTPRequest = _active_requests[request_id]
|
||||
http_request.queue_free()
|
||||
_active_requests.erase(request_id)
|
||||
|
||||
|
||||
## Internal: Create error response
|
||||
func _create_error_response(message: String, status_code: int) -> APIResponse:
|
||||
var error_data := {
|
||||
"app": "Code of Conquest",
|
||||
"version": "0.1.0",
|
||||
"status": status_code if status_code > 0 else 500,
|
||||
"timestamp": Time.get_datetime_string_from_system(),
|
||||
"result": null,
|
||||
"error": {
|
||||
"message": message,
|
||||
"code": "NETWORK_ERROR"
|
||||
},
|
||||
"meta": {}
|
||||
}
|
||||
return APIResponse.new(error_data)
|
||||
|
||||
|
||||
## Internal: Convert Method enum to HTTPClient constant
|
||||
func _method_to_int(method: Method) -> int:
|
||||
match method:
|
||||
Method.GET:
|
||||
return HTTPClient.METHOD_GET
|
||||
Method.POST:
|
||||
return HTTPClient.METHOD_POST
|
||||
Method.PUT:
|
||||
return HTTPClient.METHOD_PUT
|
||||
Method.DELETE:
|
||||
return HTTPClient.METHOD_DELETE
|
||||
Method.PATCH:
|
||||
return HTTPClient.METHOD_PATCH
|
||||
_:
|
||||
return HTTPClient.METHOD_GET
|
||||
|
||||
|
||||
## Internal: Convert Method enum to string
|
||||
func _method_to_string(method: Method) -> String:
|
||||
match method:
|
||||
Method.GET:
|
||||
return "GET"
|
||||
Method.POST:
|
||||
return "POST"
|
||||
Method.PUT:
|
||||
return "PUT"
|
||||
Method.DELETE:
|
||||
return "DELETE"
|
||||
Method.PATCH:
|
||||
return "PATCH"
|
||||
_:
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
## Internal: Convert HTTPRequest result to string
|
||||
func _result_to_string(result: int) -> String:
|
||||
match result:
|
||||
HTTPRequest.RESULT_SUCCESS:
|
||||
return "Success"
|
||||
HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH:
|
||||
return "Chunked body size mismatch"
|
||||
HTTPRequest.RESULT_CANT_CONNECT:
|
||||
return "Can't connect to server"
|
||||
HTTPRequest.RESULT_CANT_RESOLVE:
|
||||
return "Can't resolve hostname"
|
||||
HTTPRequest.RESULT_CONNECTION_ERROR:
|
||||
return "Connection error"
|
||||
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
|
||||
return "TLS handshake error"
|
||||
HTTPRequest.RESULT_NO_RESPONSE:
|
||||
return "No response from server"
|
||||
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
|
||||
return "Body size limit exceeded"
|
||||
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
|
||||
return "Body decompression failed"
|
||||
HTTPRequest.RESULT_REQUEST_FAILED:
|
||||
return "Request failed"
|
||||
HTTPRequest.RESULT_DOWNLOAD_FILE_CANT_OPEN:
|
||||
return "Can't open download file"
|
||||
HTTPRequest.RESULT_DOWNLOAD_FILE_WRITE_ERROR:
|
||||
return "Download file write error"
|
||||
HTTPRequest.RESULT_REDIRECT_LIMIT_REACHED:
|
||||
return "Redirect limit reached"
|
||||
HTTPRequest.RESULT_TIMEOUT:
|
||||
return "Request timeout"
|
||||
_:
|
||||
return "Unknown error (%d)" % result
|
||||
1
godot_client/scripts/services/http_client.gd.uid
Normal file
1
godot_client/scripts/services/http_client.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://du1woo6w2kr3b
|
||||
204
godot_client/scripts/services/settings.gd
Normal file
204
godot_client/scripts/services/settings.gd
Normal file
@@ -0,0 +1,204 @@
|
||||
extends Node
|
||||
## Settings Service
|
||||
##
|
||||
## Singleton service for application configuration.
|
||||
## Stores URLs, feature flags, and other runtime settings.
|
||||
##
|
||||
## Usage:
|
||||
## Settings.get_api_url()
|
||||
## Settings.get_web_url()
|
||||
## Settings.set_environment("production")
|
||||
|
||||
# Environment types
|
||||
enum Env {
|
||||
DEVELOPMENT,
|
||||
STAGING,
|
||||
PRODUCTION
|
||||
}
|
||||
|
||||
# Current environment
|
||||
var _current_environment: int = Env.DEVELOPMENT
|
||||
|
||||
# URL Configuration
|
||||
var _api_urls := {
|
||||
Env.DEVELOPMENT: "http://localhost:5000",
|
||||
Env.STAGING: "https://staging-api.codeofconquest.com",
|
||||
Env.PRODUCTION: "https://api.codeofconquest.com"
|
||||
}
|
||||
|
||||
var _web_urls := {
|
||||
Env.DEVELOPMENT: "http://localhost:8000", # Flask serves web pages in dev
|
||||
Env.STAGING: "https://staging.codeofconquest.com",
|
||||
Env.PRODUCTION: "https://www.codeofconquest.com"
|
||||
}
|
||||
|
||||
# Feature flags
|
||||
var _features := {
|
||||
"enable_debug_logging": true,
|
||||
"enable_analytics": false,
|
||||
"enable_multiplayer": false,
|
||||
"max_characters_per_user": 5
|
||||
}
|
||||
|
||||
# User preferences (persisted)
|
||||
var _preferences := {
|
||||
"remember_login": true,
|
||||
"auto_save": true,
|
||||
"sound_enabled": true,
|
||||
"music_enabled": true,
|
||||
"sound_volume": 0.8,
|
||||
"music_volume": 0.6
|
||||
}
|
||||
|
||||
# Save file configuration
|
||||
const SETTINGS_FILE_PATH := "user://settings.save"
|
||||
const SETTINGS_VERSION := 1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_load_settings()
|
||||
_detect_environment()
|
||||
print("[Settings] Service initialized")
|
||||
print("[Settings] Environment: %s" % _environment_to_string(_current_environment))
|
||||
print("[Settings] API URL: %s" % get_api_url())
|
||||
print("[Settings] Web URL: %s" % get_web_url())
|
||||
|
||||
|
||||
## Get current API base URL
|
||||
func get_api_url() -> String:
|
||||
return _api_urls.get(_current_environment, _api_urls[Env.DEVELOPMENT])
|
||||
|
||||
|
||||
## Get current web frontend base URL
|
||||
func get_web_url() -> String:
|
||||
return _web_urls.get(_current_environment, _web_urls[Env.DEVELOPMENT])
|
||||
|
||||
|
||||
## Get current environment
|
||||
func get_environment() -> int:
|
||||
return _current_environment
|
||||
|
||||
|
||||
## Set environment (for testing/switching)
|
||||
func set_environment(env: int) -> void:
|
||||
_current_environment = env
|
||||
print("[Settings] Environment changed to: %s" % _environment_to_string(env))
|
||||
print("[Settings] API URL: %s" % get_api_url())
|
||||
print("[Settings] Web URL: %s" % get_web_url())
|
||||
|
||||
|
||||
## Check if a feature is enabled
|
||||
func is_feature_enabled(feature_name: String) -> bool:
|
||||
return _features.get(feature_name, false)
|
||||
|
||||
|
||||
## Get feature value
|
||||
func get_feature(feature_name: String, default: Variant = null) -> Variant:
|
||||
return _features.get(feature_name, default)
|
||||
|
||||
|
||||
## Set feature flag (for testing/debugging)
|
||||
func set_feature(feature_name: String, value: Variant) -> void:
|
||||
_features[feature_name] = value
|
||||
print("[Settings] Feature updated: %s = %s" % [feature_name, value])
|
||||
|
||||
|
||||
## Get user preference
|
||||
func get_preference(key: String, default: Variant = null) -> Variant:
|
||||
return _preferences.get(key, default)
|
||||
|
||||
|
||||
## Set user preference
|
||||
func set_preference(key: String, value: Variant) -> void:
|
||||
_preferences[key] = value
|
||||
print("[Settings] Preference updated: %s = %s" % [key, value])
|
||||
_save_settings()
|
||||
|
||||
|
||||
## Get all preferences
|
||||
func get_all_preferences() -> Dictionary:
|
||||
return _preferences.duplicate()
|
||||
|
||||
|
||||
## Auto-detect environment based on OS and build flags
|
||||
func _detect_environment() -> void:
|
||||
# Check for --production command line argument
|
||||
var args := OS.get_cmdline_args()
|
||||
if "--production" in args:
|
||||
_current_environment = Env.PRODUCTION
|
||||
return
|
||||
|
||||
if "--staging" in args:
|
||||
_current_environment = Env.STAGING
|
||||
return
|
||||
|
||||
# Default to development
|
||||
_current_environment = Env.DEVELOPMENT
|
||||
|
||||
|
||||
## Save settings to disk
|
||||
func _save_settings() -> void:
|
||||
var save_data := {
|
||||
"version": SETTINGS_VERSION,
|
||||
"preferences": _preferences,
|
||||
"timestamp": Time.get_unix_time_from_system()
|
||||
}
|
||||
|
||||
var file := FileAccess.open(SETTINGS_FILE_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("[Settings] Failed to open settings file for writing: %s" % FileAccess.get_open_error())
|
||||
return
|
||||
|
||||
file.store_string(JSON.stringify(save_data))
|
||||
file.close()
|
||||
|
||||
print("[Settings] Settings saved to %s" % SETTINGS_FILE_PATH)
|
||||
|
||||
|
||||
## Load settings from disk
|
||||
func _load_settings() -> void:
|
||||
if not FileAccess.file_exists(SETTINGS_FILE_PATH):
|
||||
print("[Settings] No settings file found, using defaults")
|
||||
return
|
||||
|
||||
var file := FileAccess.open(SETTINGS_FILE_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("[Settings] Failed to open settings file for reading: %s" % FileAccess.get_open_error())
|
||||
return
|
||||
|
||||
var json_string := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var json := JSON.new()
|
||||
var parse_error := json.parse(json_string)
|
||||
|
||||
if parse_error != OK:
|
||||
push_error("[Settings] Failed to parse settings file")
|
||||
return
|
||||
|
||||
var save_data: Dictionary = json.data
|
||||
|
||||
# Check version
|
||||
if save_data.get("version", 0) != SETTINGS_VERSION:
|
||||
print("[Settings] Settings file version mismatch, using defaults")
|
||||
return
|
||||
|
||||
# Restore preferences
|
||||
var saved_prefs = save_data.get("preferences", {})
|
||||
for key in saved_prefs:
|
||||
_preferences[key] = saved_prefs[key]
|
||||
|
||||
print("[Settings] Settings loaded from %s" % SETTINGS_FILE_PATH)
|
||||
|
||||
|
||||
## Convert environment enum to string
|
||||
func _environment_to_string(env: int) -> String:
|
||||
match env:
|
||||
Env.DEVELOPMENT:
|
||||
return "Development"
|
||||
Env.STAGING:
|
||||
return "Staging"
|
||||
Env.PRODUCTION:
|
||||
return "Production"
|
||||
_:
|
||||
return "Unknown"
|
||||
1
godot_client/scripts/services/settings.gd.uid
Normal file
1
godot_client/scripts/services/settings.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://3s8i3b6v5mde
|
||||
422
godot_client/scripts/services/state_manager.gd
Normal file
422
godot_client/scripts/services/state_manager.gd
Normal file
@@ -0,0 +1,422 @@
|
||||
extends Node
|
||||
## StateManager Service
|
||||
##
|
||||
## Singleton service for managing global application state.
|
||||
## Handles user session, character data, wizard state, navigation, and persistence.
|
||||
##
|
||||
## Usage:
|
||||
## StateManager.set_user_session(user_data)
|
||||
## var user = StateManager.get_current_user()
|
||||
## StateManager.save_state() # Persist to local storage
|
||||
|
||||
# Signals for state changes
|
||||
signal user_logged_in(user_data: Dictionary)
|
||||
signal user_logged_out()
|
||||
signal character_selected(character_id: String)
|
||||
signal character_created(character_data: Dictionary)
|
||||
signal character_deleted(character_id: String)
|
||||
signal characters_updated(characters: Array)
|
||||
|
||||
# Save file configuration
|
||||
const SAVE_FILE_PATH := "user://coc_state.save"
|
||||
const SAVE_VERSION := 1
|
||||
|
||||
# User session state
|
||||
var _current_user: Dictionary = {}
|
||||
var _auth_token: String = ""
|
||||
var _is_authenticated: bool = false
|
||||
|
||||
# Character state
|
||||
var _characters: Array[Dictionary] = []
|
||||
var _selected_character_id: String = ""
|
||||
var _character_limits: Dictionary = {}
|
||||
|
||||
# Character creation wizard state
|
||||
var _wizard_state: Dictionary = {
|
||||
"step": 0, # Current step (0-3)
|
||||
"selected_origin": null, # Selected origin data
|
||||
"selected_class": null, # Selected class data
|
||||
"character_name": "",
|
||||
"character_customization": {}
|
||||
}
|
||||
|
||||
# Navigation state
|
||||
var _current_scene: String = ""
|
||||
var _scene_history: Array[String] = []
|
||||
|
||||
# Settings (deprecated - use Settings service for preferences)
|
||||
# Kept for backward compatibility with save files
|
||||
var _settings: Dictionary = {
|
||||
"remember_login": true,
|
||||
"auto_save": true
|
||||
}
|
||||
|
||||
# Reference to HTTPClient singleton (available after _ready)
|
||||
@onready var http_client: Node = get_node("/root/HTTPClient")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
print("[StateManager] Service initialized")
|
||||
_load_state()
|
||||
|
||||
|
||||
## Set user session data (after login/registration)
|
||||
func set_user_session(user_data: Dictionary, token: String = "") -> void:
|
||||
_current_user = user_data
|
||||
_auth_token = token
|
||||
_is_authenticated = true
|
||||
|
||||
# Update HTTPClient with token (if using JWT auth)
|
||||
if not token.is_empty():
|
||||
http_client.set_auth_token(token)
|
||||
|
||||
print("[StateManager] User session set: %s" % user_data.get("email", "unknown"))
|
||||
|
||||
# Emit signal
|
||||
user_logged_in.emit(user_data)
|
||||
|
||||
# Auto-save if enabled
|
||||
if _settings.get("auto_save", true):
|
||||
save_state()
|
||||
|
||||
|
||||
## Clear user session (logout)
|
||||
func clear_user_session() -> void:
|
||||
_current_user = {}
|
||||
_auth_token = ""
|
||||
_is_authenticated = false
|
||||
_characters = []
|
||||
_selected_character_id = ""
|
||||
|
||||
# Clear HTTPClient token and cookie
|
||||
http_client.clear_auth_token()
|
||||
http_client.clear_session_cookie()
|
||||
|
||||
print("[StateManager] User session cleared")
|
||||
|
||||
# Emit signal
|
||||
user_logged_out.emit()
|
||||
|
||||
# Clear saved state
|
||||
_clear_save_file()
|
||||
|
||||
|
||||
## Get current user data
|
||||
func get_current_user() -> Dictionary:
|
||||
return _current_user
|
||||
|
||||
|
||||
## Get current auth token
|
||||
func get_auth_token() -> String:
|
||||
return _auth_token
|
||||
|
||||
|
||||
## Check if user is authenticated
|
||||
func is_authenticated() -> bool:
|
||||
# Check both token and cookie-based auth
|
||||
return _is_authenticated and (not _auth_token.is_empty() or http_client.is_authenticated())
|
||||
|
||||
|
||||
## Set characters list
|
||||
func set_characters(characters: Array) -> void:
|
||||
_characters.clear()
|
||||
for character in characters:
|
||||
_characters.append(character)
|
||||
|
||||
print("[StateManager] Characters updated: %d total" % _characters.size())
|
||||
|
||||
# Emit signal
|
||||
characters_updated.emit(_characters)
|
||||
|
||||
# Auto-save if enabled
|
||||
if _settings.get("auto_save", true):
|
||||
save_state()
|
||||
|
||||
|
||||
## Get all characters
|
||||
func get_characters() -> Array[Dictionary]:
|
||||
return _characters
|
||||
|
||||
|
||||
## Add character to list
|
||||
func add_character(character_data: Dictionary) -> void:
|
||||
_characters.append(character_data)
|
||||
|
||||
print("[StateManager] Character added: %s" % character_data.get("name", "unknown"))
|
||||
|
||||
# Emit signal
|
||||
character_created.emit(character_data)
|
||||
|
||||
# Auto-save if enabled
|
||||
if _settings.get("auto_save", true):
|
||||
save_state()
|
||||
|
||||
|
||||
## Remove character from list
|
||||
func remove_character(character_id: String) -> void:
|
||||
for i in range(_characters.size()):
|
||||
if _characters[i].get("id") == character_id:
|
||||
_characters.remove_at(i)
|
||||
print("[StateManager] Character removed: %s" % character_id)
|
||||
|
||||
# Clear selection if this was selected
|
||||
if _selected_character_id == character_id:
|
||||
_selected_character_id = ""
|
||||
|
||||
# Emit signal
|
||||
character_deleted.emit(character_id)
|
||||
|
||||
# Auto-save if enabled
|
||||
if _settings.get("auto_save", true):
|
||||
save_state()
|
||||
return
|
||||
|
||||
|
||||
## Get character by ID
|
||||
func get_character(character_id: String) -> Dictionary:
|
||||
for character in _characters:
|
||||
if character.get("id") == character_id:
|
||||
return character
|
||||
return {}
|
||||
|
||||
|
||||
## Set selected character
|
||||
func select_character(character_id: String) -> void:
|
||||
_selected_character_id = character_id
|
||||
print("[StateManager] Character selected: %s" % character_id)
|
||||
|
||||
# Emit signal
|
||||
character_selected.emit(character_id)
|
||||
|
||||
|
||||
## Get selected character
|
||||
func get_selected_character() -> Dictionary:
|
||||
return get_character(_selected_character_id)
|
||||
|
||||
|
||||
## Get selected character ID
|
||||
func get_selected_character_id() -> String:
|
||||
return _selected_character_id
|
||||
|
||||
|
||||
## Set character limits
|
||||
func set_character_limits(limits: Dictionary) -> void:
|
||||
_character_limits = limits
|
||||
|
||||
|
||||
## Get character limits
|
||||
func get_character_limits() -> Dictionary:
|
||||
return _character_limits
|
||||
|
||||
|
||||
## Character Creation Wizard State Management
|
||||
|
||||
## Reset wizard state
|
||||
func reset_wizard() -> void:
|
||||
_wizard_state = {
|
||||
"step": 0,
|
||||
"selected_origin": null,
|
||||
"selected_class": null,
|
||||
"character_name": "",
|
||||
"character_customization": {}
|
||||
}
|
||||
print("[StateManager] Wizard state reset")
|
||||
|
||||
|
||||
## Set wizard step
|
||||
func set_wizard_step(step: int) -> void:
|
||||
_wizard_state["step"] = step
|
||||
|
||||
|
||||
## Get current wizard step
|
||||
func get_wizard_step() -> int:
|
||||
return _wizard_state.get("step", 0)
|
||||
|
||||
|
||||
## Set selected origin
|
||||
func set_wizard_origin(origin_data: Dictionary) -> void:
|
||||
_wizard_state["selected_origin"] = origin_data
|
||||
|
||||
|
||||
## Get selected origin
|
||||
func get_wizard_origin() -> Dictionary:
|
||||
return _wizard_state.get("selected_origin", {})
|
||||
|
||||
|
||||
## Set selected class
|
||||
func set_wizard_class(class_data: Dictionary) -> void:
|
||||
_wizard_state["selected_class"] = class_data
|
||||
|
||||
|
||||
## Get selected class
|
||||
func get_wizard_class() -> Dictionary:
|
||||
return _wizard_state.get("selected_class", {})
|
||||
|
||||
|
||||
## Set character name
|
||||
func set_wizard_name(char_name: String) -> void:
|
||||
_wizard_state["character_name"] = char_name
|
||||
|
||||
|
||||
## Get character name
|
||||
func get_wizard_name() -> String:
|
||||
return _wizard_state.get("character_name", "")
|
||||
|
||||
|
||||
## Set character customization
|
||||
func set_wizard_customization(customization: Dictionary) -> void:
|
||||
_wizard_state["character_customization"] = customization
|
||||
|
||||
|
||||
## Get character customization
|
||||
func get_wizard_customization() -> Dictionary:
|
||||
return _wizard_state.get("character_customization", {})
|
||||
|
||||
|
||||
## Get complete wizard state
|
||||
func get_wizard_state() -> Dictionary:
|
||||
return _wizard_state
|
||||
|
||||
|
||||
## Navigation State Management
|
||||
|
||||
## Set current scene
|
||||
func set_current_scene(scene_path: String) -> void:
|
||||
if not _current_scene.is_empty():
|
||||
_scene_history.append(_current_scene)
|
||||
|
||||
_current_scene = scene_path
|
||||
print("[StateManager] Scene changed: %s" % scene_path)
|
||||
|
||||
|
||||
## Get current scene
|
||||
func get_current_scene() -> String:
|
||||
return _current_scene
|
||||
|
||||
|
||||
## Navigate back
|
||||
func navigate_back() -> String:
|
||||
if _scene_history.is_empty():
|
||||
return ""
|
||||
|
||||
var previous_scene: String = _scene_history.pop_back()
|
||||
_current_scene = previous_scene
|
||||
return previous_scene
|
||||
|
||||
|
||||
## Can navigate back
|
||||
func can_navigate_back() -> bool:
|
||||
return not _scene_history.is_empty()
|
||||
|
||||
|
||||
## Settings Management
|
||||
|
||||
## Get setting value
|
||||
func get_setting(key: String, default: Variant = null) -> Variant:
|
||||
return _settings.get(key, default)
|
||||
|
||||
|
||||
## Set setting value
|
||||
func set_setting(key: String, value: Variant) -> void:
|
||||
_settings[key] = value
|
||||
print("[StateManager] Setting updated: %s = %s" % [key, value])
|
||||
|
||||
# Auto-save if enabled
|
||||
if _settings.get("auto_save", true):
|
||||
save_state()
|
||||
|
||||
|
||||
## Get all settings
|
||||
func get_settings() -> Dictionary:
|
||||
return _settings
|
||||
|
||||
|
||||
## Persistence (Save/Load)
|
||||
|
||||
## Save state to disk
|
||||
func save_state() -> void:
|
||||
var save_data := {
|
||||
"version": SAVE_VERSION,
|
||||
"user": _current_user,
|
||||
"auth_token": _auth_token if _settings.get("remember_login", true) else "",
|
||||
"session_cookie": http_client.get_session_cookie() if _settings.get("remember_login", true) else "",
|
||||
"is_authenticated": _is_authenticated if _settings.get("remember_login", true) else false,
|
||||
"characters": _characters,
|
||||
"selected_character_id": _selected_character_id,
|
||||
"character_limits": _character_limits,
|
||||
"settings": _settings,
|
||||
"timestamp": Time.get_unix_time_from_system()
|
||||
}
|
||||
|
||||
var file := FileAccess.open(SAVE_FILE_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("[StateManager] Failed to open save file for writing: %s" % FileAccess.get_open_error())
|
||||
return
|
||||
|
||||
file.store_string(JSON.stringify(save_data))
|
||||
file.close()
|
||||
|
||||
print("[StateManager] State saved to %s" % SAVE_FILE_PATH)
|
||||
|
||||
|
||||
## Load state from disk
|
||||
func _load_state() -> void:
|
||||
if not FileAccess.file_exists(SAVE_FILE_PATH):
|
||||
print("[StateManager] No save file found, starting fresh")
|
||||
return
|
||||
|
||||
var file := FileAccess.open(SAVE_FILE_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("[StateManager] Failed to open save file for reading: %s" % FileAccess.get_open_error())
|
||||
return
|
||||
|
||||
var json_string := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var json := JSON.new()
|
||||
var parse_error := json.parse(json_string)
|
||||
|
||||
if parse_error != OK:
|
||||
push_error("[StateManager] Failed to parse save file")
|
||||
return
|
||||
|
||||
var save_data: Dictionary = json.data
|
||||
|
||||
# Check version
|
||||
if save_data.get("version", 0) != SAVE_VERSION:
|
||||
print("[StateManager] Save file version mismatch, ignoring")
|
||||
return
|
||||
|
||||
# Restore state
|
||||
_current_user = save_data.get("user", {})
|
||||
_auth_token = save_data.get("auth_token", "")
|
||||
_is_authenticated = save_data.get("is_authenticated", false)
|
||||
_characters = []
|
||||
|
||||
# Convert characters array
|
||||
var chars_data = save_data.get("characters", [])
|
||||
for character in chars_data:
|
||||
_characters.append(character)
|
||||
|
||||
_selected_character_id = save_data.get("selected_character_id", "")
|
||||
_character_limits = save_data.get("character_limits", {})
|
||||
_settings = save_data.get("settings", _settings)
|
||||
|
||||
# Update HTTPClient with token if authenticated
|
||||
if _is_authenticated and not _auth_token.is_empty():
|
||||
http_client.set_auth_token(_auth_token)
|
||||
|
||||
# Restore session cookie if present
|
||||
var session_cookie = save_data.get("session_cookie", "")
|
||||
if _is_authenticated and not session_cookie.is_empty():
|
||||
http_client.set_session_cookie(session_cookie)
|
||||
|
||||
print("[StateManager] State loaded from %s" % SAVE_FILE_PATH)
|
||||
print("[StateManager] Authenticated: %s, Characters: %d" % [_is_authenticated, _characters.size()])
|
||||
|
||||
|
||||
## Clear save file
|
||||
func _clear_save_file() -> void:
|
||||
if FileAccess.file_exists(SAVE_FILE_PATH):
|
||||
DirAccess.remove_absolute(SAVE_FILE_PATH)
|
||||
print("[StateManager] Save file cleared")
|
||||
1
godot_client/scripts/services/state_manager.gd.uid
Normal file
1
godot_client/scripts/services/state_manager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b28q3ngeah5sf
|
||||
25
godot_client/scripts/test_services.gd
Normal file
25
godot_client/scripts/test_services.gd
Normal file
@@ -0,0 +1,25 @@
|
||||
extends Button
|
||||
|
||||
@onready var test_button = $"."
|
||||
|
||||
func _ready():
|
||||
test_button.pressed.connect(_on_test_clicked)
|
||||
|
||||
func _on_test_clicked():
|
||||
print("Testing HTTPClient...")
|
||||
|
||||
# Test API call (replace with real endpoint)
|
||||
# Note: Can use HTTPClient directly in _ready() or later
|
||||
#HTTPClient.http_get("/api/v1/health", _on_api_success, _on_api_error)
|
||||
|
||||
# Alternative (always works):
|
||||
get_node("/root/HTTPClient").http_get("/api/v1/health", _on_api_success, _on_api_error)
|
||||
|
||||
func _on_api_success(response):
|
||||
print("API Success!")
|
||||
print("Status: ", response.status)
|
||||
print("Result: ", response.result)
|
||||
|
||||
func _on_api_error(response):
|
||||
print("API Error!")
|
||||
print("Error: ", response.get_error_message())
|
||||
1
godot_client/scripts/test_services.gd.uid
Normal file
1
godot_client/scripts/test_services.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c1uv0pqjtun0r
|
||||
121
godot_client/scripts/utils/theme_colors.gd
Normal file
121
godot_client/scripts/utils/theme_colors.gd
Normal file
@@ -0,0 +1,121 @@
|
||||
extends Object
|
||||
class_name ThemeColors
|
||||
## Theme Colors
|
||||
##
|
||||
## Defines the color palette for Code of Conquest.
|
||||
## Based on the original web UI color scheme:
|
||||
## - Dark slate gray backgrounds
|
||||
## - Gold accents and highlights
|
||||
## - RPG/fantasy aesthetic
|
||||
##
|
||||
## Usage:
|
||||
## var bg_color = ThemeColors.BACKGROUND_PRIMARY
|
||||
## button.add_theme_color_override("font_color", ThemeColors.GOLD_ACCENT)
|
||||
|
||||
# Primary Background Colors
|
||||
const BACKGROUND_PRIMARY := Color("#1a1a2e") # Very dark blue-gray
|
||||
const BACKGROUND_SECONDARY := Color("#16213e") # Slightly lighter blue-gray
|
||||
const BACKGROUND_TERTIARY := Color("#0f3460") # Accent background
|
||||
const BACKGROUND_CARD := Color("#1e1e2f") # Card backgrounds
|
||||
|
||||
# Text Colors
|
||||
const TEXT_PRIMARY := Color("#e4e4e7") # Light gray (main text)
|
||||
const TEXT_SECONDARY := Color("#a1a1aa") # Medium gray (secondary text)
|
||||
const TEXT_DISABLED := Color("#71717a") # Dark gray (disabled)
|
||||
const TEXT_INVERTED := Color("#1a1a2e") # Dark text on light bg
|
||||
|
||||
# Accent Colors
|
||||
const GOLD_ACCENT := Color("#d4af37") # Primary gold accent
|
||||
const GOLD_LIGHT := Color("#f4d03f") # Lighter gold
|
||||
const GOLD_DARK := Color("#b8930a") # Darker gold
|
||||
|
||||
# Status Colors
|
||||
const SUCCESS := Color("#10b981") # Green (success messages)
|
||||
const ERROR := Color("#ef4444") # Red (errors)
|
||||
const WARNING := Color("#f59e0b") # Orange (warnings)
|
||||
const INFO := Color("#3b82f6") # Blue (info messages)
|
||||
|
||||
# HP/Mana/Resource Colors
|
||||
const HP_COLOR := Color("#dc2626") # Red for HP bars
|
||||
const MANA_COLOR := Color("#3b82f6") # Blue for mana bars
|
||||
const STAMINA_COLOR := Color("#10b981") # Green for stamina
|
||||
|
||||
# Rarity Colors (for items, etc.)
|
||||
const RARITY_COMMON := Color("#9ca3af") # Gray
|
||||
const RARITY_UNCOMMON := Color("#10b981") # Green
|
||||
const RARITY_RARE := Color("#3b82f6") # Blue
|
||||
const RARITY_EPIC := Color("#a855f7") # Purple
|
||||
const RARITY_LEGENDARY := Color("#f59e0b") # Orange/gold
|
||||
|
||||
# Border and Divider Colors
|
||||
const BORDER_DEFAULT := Color("#3f3f46") # Default borders
|
||||
const BORDER_ACCENT := Color("#d4af37") # Gold borders
|
||||
const DIVIDER := Color("#27272a") # Subtle dividers
|
||||
|
||||
# Interactive States
|
||||
const HOVER := Color("#d4af37", 0.1) # Gold overlay on hover
|
||||
const PRESSED := Color("#d4af37", 0.2) # Gold overlay on press
|
||||
const FOCUSED := Color("#d4af37", 0.15) # Gold overlay on focus
|
||||
const SELECTED := Color("#d4af37", 0.25) # Gold overlay on selection
|
||||
|
||||
# Overlay Colors
|
||||
const OVERLAY_DARK := Color("#000000", 0.5) # Dark overlay
|
||||
const OVERLAY_LIGHT := Color("#ffffff", 0.1) # Light overlay
|
||||
|
||||
# Shadow Colors
|
||||
const SHADOW := Color("#000000", 0.3) # Default shadow
|
||||
const SHADOW_STRONG := Color("#000000", 0.5) # Strong shadow
|
||||
|
||||
|
||||
## Get a color with modified alpha
|
||||
static func with_alpha(color: Color, alpha: float) -> Color:
|
||||
var new_color := color
|
||||
new_color.a = alpha
|
||||
return new_color
|
||||
|
||||
|
||||
## Lighten a color by a percentage
|
||||
static func lighten(color: Color, amount: float) -> Color:
|
||||
return color.lightened(amount)
|
||||
|
||||
|
||||
## Darken a color by a percentage
|
||||
static func darken(color: Color, amount: float) -> Color:
|
||||
return color.darkened(amount)
|
||||
|
||||
|
||||
## Mix two colors
|
||||
static func mix(color1: Color, color2: Color, weight: float = 0.5) -> Color:
|
||||
return color1.lerp(color2, weight)
|
||||
|
||||
|
||||
## Get rarity color by tier
|
||||
static func get_rarity_color(tier: int) -> Color:
|
||||
match tier:
|
||||
1:
|
||||
return RARITY_COMMON
|
||||
2:
|
||||
return RARITY_UNCOMMON
|
||||
3:
|
||||
return RARITY_RARE
|
||||
4:
|
||||
return RARITY_EPIC
|
||||
5:
|
||||
return RARITY_LEGENDARY
|
||||
_:
|
||||
return RARITY_COMMON
|
||||
|
||||
|
||||
## Get status color by type
|
||||
static func get_status_color(status: String) -> Color:
|
||||
match status.to_lower():
|
||||
"success", "ok", "complete":
|
||||
return SUCCESS
|
||||
"error", "fail", "failed":
|
||||
return ERROR
|
||||
"warning", "warn":
|
||||
return WARNING
|
||||
"info", "information":
|
||||
return INFO
|
||||
_:
|
||||
return TEXT_SECONDARY
|
||||
1
godot_client/scripts/utils/theme_colors.gd.uid
Normal file
1
godot_client/scripts/utils/theme_colors.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bbbe8eaq2ltgm
|
||||
Reference in New Issue
Block a user