init game files

This commit is contained in:
2026-01-24 16:29:11 -05:00
commit ea2028cf13
171 changed files with 191733 additions and 0 deletions

107
scripts/GameController.gd Normal file
View File

@@ -0,0 +1,107 @@
extends Node
## GameController - Top-level controller managing menu and game states
enum State {
MENU,
PLAYING,
PAUSED
}
var current_state: State = State.MENU
# Scene references
var main_menu: MainMenu = null
var game_scene: Node3D = null
var pause_menu: PauseMenu = null
# Preload the main game scene script
const MainScript = preload("res://scripts/Main.gd")
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
_show_main_menu()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed:
if event.keycode == KEY_ESCAPE:
match current_state:
State.PLAYING:
_show_pause_menu()
State.PAUSED:
_hide_pause_menu()
func _show_main_menu() -> void:
# Clean up any existing game
if game_scene:
game_scene.queue_free()
game_scene = null
if pause_menu:
pause_menu.queue_free()
pause_menu = null
# Create main menu
if not main_menu:
main_menu = MainMenu.new()
add_child(main_menu)
main_menu.start_game.connect(_on_start_game)
main_menu.visible = true
current_state = State.MENU
func _on_start_game() -> void:
# Hide menu
if main_menu:
main_menu.visible = false
# Create game scene
_start_new_game()
func _start_new_game() -> void:
# Make sure game isn't paused
get_tree().paused = false
# Clean up existing game
if game_scene:
game_scene.queue_free()
game_scene = null
# Reset GameManager state
if GameManager:
GameManager.is_game_active = false
GameManager.game_state = null
# Create new game scene
game_scene = Node3D.new()
game_scene.set_script(MainScript)
add_child(game_scene)
# Create pause menu
if not pause_menu:
pause_menu = PauseMenu.new()
add_child(pause_menu)
pause_menu.resume_game.connect(_on_resume_game)
pause_menu.restart_game.connect(_on_restart_game)
pause_menu.return_to_menu.connect(_on_return_to_menu)
current_state = State.PLAYING
func _show_pause_menu() -> void:
if pause_menu:
pause_menu.show_menu()
current_state = State.PAUSED
func _hide_pause_menu() -> void:
if pause_menu:
pause_menu.hide_menu()
current_state = State.PLAYING
func _on_resume_game() -> void:
current_state = State.PLAYING
func _on_restart_game() -> void:
_start_new_game()
func _on_return_to_menu() -> void:
_show_main_menu()

230
scripts/Main.gd Normal file
View File

@@ -0,0 +1,230 @@
extends Node3D
## Main - Root scene that coordinates the game
# Components
var table_setup: TableSetup
var game_ui: GameUI
var hand_display: HandDisplay
var hand_layer: CanvasLayer
# Player damage displays
var damage_displays: Array[DamageDisplay] = []
func _ready() -> void:
_setup_table()
_setup_ui()
_connect_signals()
# Start game when ready
if GameManager.is_initialized:
_start_game()
else:
GameManager.game_ready.connect(_start_game)
func _setup_table() -> void:
table_setup = TableSetup.new()
add_child(table_setup)
# Connect table signals
table_setup.card_clicked.connect(_on_table_card_clicked)
func _setup_ui() -> void:
# Main game UI overlay (has its own CanvasLayer)
game_ui = GameUI.new()
game_ui.layer = 10 # Base UI layer
add_child(game_ui)
# Hand display needs its own CanvasLayer to render on top of 3D
hand_layer = CanvasLayer.new()
hand_layer.layer = 11 # Above the game UI
add_child(hand_layer)
# Container for hand at bottom of screen - must fill the viewport
var hand_container = Control.new()
hand_layer.add_child(hand_container)
hand_container.set_anchors_preset(Control.PRESET_FULL_RECT)
hand_container.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Hand display positioned at bottom - use explicit positioning
hand_display = HandDisplay.new()
hand_container.add_child(hand_display)
# Position at bottom of screen with explicit coordinates
# We'll update position in _process or use a deferred call after container is sized
call_deferred("_position_hand_display")
hand_display.card_selected.connect(_on_hand_card_selected)
hand_display.card_hovered.connect(_on_hand_card_hovered)
hand_display.card_unhovered.connect(_on_hand_card_unhovered)
# Create damage displays (positioned by 3D camera overlay later)
for i in range(2):
var damage_display = DamageDisplay.new()
damage_displays.append(damage_display)
func _position_hand_display() -> void:
# Get viewport size and position hand at bottom
var viewport = get_viewport()
if viewport:
var vp_size = viewport.get_visible_rect().size
hand_display.position = Vector2(50, vp_size.y - 180)
hand_display.size = Vector2(vp_size.x - 100, 170)
func _connect_signals() -> void:
# GameManager signals
GameManager.game_started.connect(_on_game_started)
GameManager.game_ended.connect(_on_game_ended)
GameManager.turn_changed.connect(_on_turn_changed)
GameManager.phase_changed.connect(_on_phase_changed)
GameManager.damage_dealt.connect(_on_damage_dealt)
func _start_game() -> void:
GameManager.start_new_game()
# Force an update of visuals after a frame to ensure everything is ready
call_deferred("_force_initial_update")
func _force_initial_update() -> void:
_sync_visuals()
_update_hand_display()
_update_cp_display()
func _on_game_started() -> void:
_sync_visuals()
_update_hand_display()
func _on_game_ended(winner_name: String) -> void:
game_ui.show_message(winner_name + " wins the game!")
func _on_turn_changed(_player_name: String, _turn_number: int) -> void:
_sync_visuals()
_update_hand_display()
_update_cp_display()
func _on_phase_changed(_phase_name: String) -> void:
_update_playable_highlights()
_update_cp_display()
func _on_damage_dealt(player_name: String, _amount: int) -> void:
# Find player index
if GameManager.game_state:
for i in range(2):
var player = GameManager.game_state.get_player(i)
if player and player.player_name == player_name:
if i < damage_displays.size():
damage_displays[i].set_damage(player.damage_zone.get_count())
func _sync_visuals() -> void:
if GameManager.game_state and table_setup:
table_setup.sync_with_game_state(GameManager.game_state)
func _update_hand_display() -> void:
if not GameManager.game_state:
return
# Show current player's hand
var current_player = GameManager.get_current_player()
if current_player:
var cards = current_player.hand.get_cards()
hand_display.update_hand(cards)
func _update_cp_display() -> void:
if not GameManager.game_state:
return
var current_player = GameManager.get_current_player()
if current_player:
game_ui.update_cp_display(current_player.cp_pool)
func _update_playable_highlights() -> void:
if not GameManager.game_state:
return
var phase = GameManager.get_current_phase()
var player = GameManager.get_current_player()
if not player:
hand_display.clear_highlights()
return
match phase:
Enums.TurnPhase.MAIN_1, Enums.TurnPhase.MAIN_2:
# Highlight cards that can be played
hand_display.highlight_playable(func(card: CardInstance) -> bool:
return player.cp_pool.can_afford_card(card.card_data)
)
_:
hand_display.clear_highlights()
func _on_hand_card_selected(card: CardInstance) -> void:
print("Main: Card selected: ", card.card_data.name, " input_mode=", GameManager.input_mode)
var input_mode = GameManager.input_mode
match input_mode:
GameManager.InputMode.SELECT_CARD_TO_PLAY:
print("Main: Trying to play card")
GameManager.try_play_card(card)
_sync_visuals()
_update_hand_display()
_update_cp_display()
GameManager.InputMode.SELECT_CP_SOURCE:
# Discard for CP
print("Main: Discarding for CP")
GameManager.discard_card_for_cp(card)
_update_hand_display()
_update_cp_display()
_:
print("Main: Input mode not handled: ", input_mode)
func _on_hand_card_hovered(card: CardInstance) -> void:
game_ui.show_card_detail(card)
func _on_hand_card_unhovered() -> void:
game_ui.hide_card_detail()
func _on_table_card_clicked(card: CardInstance, zone_type: Enums.ZoneType, player_index: int) -> void:
var input_mode = GameManager.input_mode
match input_mode:
GameManager.InputMode.SELECT_CP_SOURCE:
# Check if it's a backup we can dull
if zone_type == Enums.ZoneType.FIELD_BACKUPS:
if player_index == GameManager.game_state.turn_manager.current_player_index:
GameManager.dull_backup_for_cp(card)
_sync_visuals()
_update_cp_display()
GameManager.InputMode.SELECT_ATTACKER:
# Select attacker
if zone_type == Enums.ZoneType.FIELD_FORWARDS:
if player_index == GameManager.game_state.turn_manager.current_player_index:
GameManager.declare_attack(card)
_sync_visuals()
GameManager.InputMode.SELECT_BLOCKER:
# Select blocker
if zone_type == Enums.ZoneType.FIELD_FORWARDS:
var opponent_index = 1 - GameManager.game_state.turn_manager.current_player_index
if player_index == opponent_index:
GameManager.declare_block(card)
_sync_visuals()
# Show card detail on any click
game_ui.show_card_detail(card)
func _input(event: InputEvent) -> void:
# Keyboard shortcuts
if event is InputEventKey and event.pressed:
match event.keycode:
KEY_SPACE:
# Pass priority / end phase
GameManager.pass_priority()
_sync_visuals()
_update_hand_display()
_update_cp_display()
KEY_ESCAPE:
# Cancel current selection
GameManager.clear_selection()
table_setup.clear_all_highlights()
hand_display.clear_highlights()

View File

@@ -0,0 +1,260 @@
extends Node
## CardDatabase - Singleton for managing card data
## Loads card definitions from JSON and provides lookup methods
const CARDS_PATH = "res://data/cards.json"
# Loaded card data
var _cards: Dictionary = {} # id -> CardData
var _cards_by_element: Dictionary = {} # Element -> Array[CardData]
var _cards_by_type: Dictionary = {} # CardType -> Array[CardData]
var _card_textures: Dictionary = {} # id -> Texture2D
# Signals
signal database_loaded
signal load_error(message: String)
func _ready() -> void:
_load_database()
func _load_database() -> void:
var file = FileAccess.open(CARDS_PATH, FileAccess.READ)
if not file:
push_error("Failed to open cards database: " + CARDS_PATH)
load_error.emit("Failed to open cards database")
return
var json_text = file.get_as_text()
file.close()
var json = JSON.new()
var error = json.parse(json_text)
if error != OK:
push_error("Failed to parse cards JSON: " + json.get_error_message())
load_error.emit("Failed to parse cards JSON")
return
var data = json.get_data()
if not data.has("cards"):
push_error("Cards database missing 'cards' array")
load_error.emit("Invalid database format")
return
# Initialize element and type dictionaries
for element in Enums.Element.values():
_cards_by_element[element] = []
for card_type in Enums.CardType.values():
_cards_by_type[card_type] = []
# Parse cards
for card_data in data["cards"]:
var card = _parse_card(card_data)
if card:
_cards[card.id] = card
# Index by element(s)
for element in card.elements:
_cards_by_element[element].append(card)
# Index by type
_cards_by_type[card.type].append(card)
print("CardDatabase: Loaded ", _cards.size(), " cards")
database_loaded.emit()
func _parse_card(data: Dictionary) -> CardData:
var card = CardData.new()
# Required fields
if not data.has("id") or not data.has("name") or not data.has("type") or not data.has("element") or not data.has("cost"):
push_error("Card missing required fields: " + str(data))
return null
card.id = data["id"]
card.name = data["name"]
card.type = Enums.card_type_from_string(data["type"])
card.cost = data["cost"]
# Parse element (can be string or array)
if data["element"] is Array:
for elem_str in data["element"]:
card.elements.append(Enums.element_from_string(elem_str))
else:
card.elements.append(Enums.element_from_string(data["element"]))
# Optional fields
card.power = data.get("power", 0) if data.get("power") != null else 0
card.job = data.get("job", "") if data.get("job") != null else ""
card.category = data.get("category", "") if data.get("category") != null else ""
card.is_generic = data.get("is_generic", false) if data.get("is_generic") != null else false
card.has_ex_burst = data.get("has_ex_burst", false) if data.get("has_ex_burst") != null else false
card.image_path = data.get("image", "") if data.get("image") != null else ""
# Parse abilities
if data.has("abilities"):
for ability_data in data["abilities"]:
var ability = _parse_ability(ability_data)
if ability:
card.abilities.append(ability)
return card
func _parse_ability(data: Dictionary) -> AbilityData:
var ability = AbilityData.new()
if not data.has("type") or not data.has("effect"):
push_error("Ability missing required fields")
return null
match data["type"].to_lower():
"field": ability.type = Enums.AbilityType.FIELD
"auto": ability.type = Enums.AbilityType.AUTO
"action": ability.type = Enums.AbilityType.ACTION
"special": ability.type = Enums.AbilityType.SPECIAL
ability.effect = data["effect"]
ability.name = data.get("name", "")
ability.trigger = data.get("trigger", "")
ability.is_ex_burst = data.get("is_ex_burst", false)
# Parse cost if present
if data.has("cost"):
ability.cost = _parse_cost(data["cost"])
return ability
func _parse_cost(data: Dictionary) -> CostData:
var cost = CostData.new()
cost.generic = data.get("generic", 0)
cost.fire = data.get("fire", 0)
cost.ice = data.get("ice", 0)
cost.wind = data.get("wind", 0)
cost.lightning = data.get("lightning", 0)
cost.water = data.get("water", 0)
cost.earth = data.get("earth", 0)
cost.light = data.get("light", 0)
cost.dark = data.get("dark", 0)
cost.requires_dull = data.get("dull", false)
cost.discard_count = data.get("discard", 0)
cost.specific_discard = data.get("specific_discard", "")
return cost
## Get a card by ID
func get_card(id: String) -> CardData:
return _cards.get(id)
## Get all cards
func get_all_cards() -> Array:
return _cards.values()
## Get cards by element
func get_cards_by_element(element: Enums.Element) -> Array:
return _cards_by_element.get(element, [])
## Get cards by type
func get_cards_by_type(card_type: Enums.CardType) -> Array:
return _cards_by_type.get(card_type, [])
## Get or load a card texture
func get_card_texture(card: CardData) -> Texture2D:
if card.id in _card_textures:
return _card_textures[card.id]
if card.image_path.is_empty():
return null
var texture_path = "res://assets/cards/" + card.image_path
if ResourceLoader.exists(texture_path):
var texture = load(texture_path)
_card_textures[card.id] = texture
return texture
return null
## Create a list of card IDs for a deck (for testing)
func create_test_deck(_player_index: int) -> Array[String]:
var deck: Array[String] = []
# Get all cards and create a 50-card deck
var all_cards = get_all_cards()
# Add 3 copies of each card until we have 50
for card in all_cards:
if deck.size() >= 50:
break
# Add up to 3 copies
for i in range(3):
if deck.size() >= 50:
break
deck.append(card.id)
# Fill remaining slots if needed
while deck.size() < 50 and all_cards.size() > 0:
deck.append(all_cards[0].id)
return deck
## Data Classes
class CardData:
var id: String = ""
var name: String = ""
var type: Enums.CardType = Enums.CardType.FORWARD
var elements: Array[Enums.Element] = []
var cost: int = 0
var power: int = 0
var job: String = ""
var category: String = ""
var is_generic: bool = false
var has_ex_burst: bool = false
var image_path: String = ""
var abilities: Array[AbilityData] = []
func get_primary_element() -> Enums.Element:
if elements.size() > 0:
return elements[0]
return Enums.Element.FIRE
func is_multi_element() -> bool:
return elements.size() > 1
class AbilityData:
var type: Enums.AbilityType = Enums.AbilityType.FIELD
var name: String = ""
var effect: String = ""
var trigger: String = ""
var is_ex_burst: bool = false
var cost: CostData = null
class CostData:
var generic: int = 0
var fire: int = 0
var ice: int = 0
var wind: int = 0
var lightning: int = 0
var water: int = 0
var earth: int = 0
var light: int = 0
var dark: int = 0
var requires_dull: bool = false
var discard_count: int = 0
var specific_discard: String = ""
func get_total_cp() -> int:
return generic + fire + ice + wind + lightning + water + earth + light + dark
func get_element_requirements() -> Dictionary:
var reqs = {}
if fire > 0: reqs[Enums.Element.FIRE] = fire
if ice > 0: reqs[Enums.Element.ICE] = ice
if wind > 0: reqs[Enums.Element.WIND] = wind
if lightning > 0: reqs[Enums.Element.LIGHTNING] = lightning
if water > 0: reqs[Enums.Element.WATER] = water
if earth > 0: reqs[Enums.Element.EARTH] = earth
if light > 0: reqs[Enums.Element.LIGHT] = light
if dark > 0: reqs[Enums.Element.DARK] = dark
return reqs

View File

@@ -0,0 +1,286 @@
extends Node
## GameManager - Main game coordinator singleton
## Bridges game state, visuals, and UI
signal game_ready
signal game_started
signal game_ended(winner_name: String)
signal turn_changed(player_name: String, turn_number: int)
signal phase_changed(phase_name: String)
signal card_played(card_data: Dictionary)
signal damage_dealt(player_name: String, amount: int)
signal message(text: String)
# Game state
var game_state: GameState = null
# State flags
var is_initialized: bool = false
var is_game_active: bool = false
# Current input mode
enum InputMode {
NONE,
SELECT_CARD_TO_PLAY,
SELECT_CP_SOURCE,
SELECT_ATTACKER,
SELECT_BLOCKER,
SELECT_TARGET
}
var input_mode: InputMode = InputMode.NONE
# Selection tracking
var selected_card: CardInstance = null
var pending_action: Callable = func(): pass
func _ready() -> void:
# Wait for CardDatabase to load
if CardDatabase.get_all_cards().size() == 0:
CardDatabase.database_loaded.connect(_on_database_loaded)
else:
_on_database_loaded()
func _on_database_loaded() -> void:
is_initialized = true
game_ready.emit()
print("GameManager: Ready")
## Start a new game
func start_new_game() -> void:
if not is_initialized:
push_error("GameManager not initialized")
return
# Create new game state
game_state = GameState.new()
# Connect signals
_connect_game_signals()
# Create test decks
var deck1 = CardDatabase.create_test_deck(0)
var deck2 = CardDatabase.create_test_deck(1)
# Setup and start
game_state.setup_game(deck1, deck2)
game_state.start_game()
is_game_active = true
game_started.emit()
message.emit("Game started!")
## Connect to game state signals
func _connect_game_signals() -> void:
game_state.game_ended.connect(_on_game_ended)
game_state.card_played.connect(_on_card_played)
game_state.damage_dealt.connect(_on_damage_dealt)
game_state.forward_broken.connect(_on_forward_broken)
game_state.attack_declared.connect(_on_attack_declared)
game_state.block_declared.connect(_on_block_declared)
game_state.combat_resolved.connect(_on_combat_resolved)
game_state.turn_manager.turn_changed.connect(_on_turn_changed)
game_state.turn_manager.phase_changed.connect(_on_phase_changed)
## Get current player
func get_current_player() -> Player:
if game_state:
return game_state.get_current_player()
return null
## Get opponent
func get_opponent() -> Player:
if game_state:
return game_state.get_opponent()
return null
## Check if it's a specific player's turn
func is_player_turn(player_index: int) -> bool:
if game_state:
return game_state.turn_manager.current_player_index == player_index
return false
## Get current phase
func get_current_phase() -> Enums.TurnPhase:
if game_state:
return game_state.turn_manager.current_phase
return Enums.TurnPhase.ACTIVE
## Try to play a card
func try_play_card(card: CardInstance) -> bool:
if not game_state or not is_game_active:
return false
var player_index = card.controller_index
if not is_player_turn(player_index):
message.emit("Not your turn!")
return false
if not game_state.turn_manager.is_main_phase():
message.emit("Can only play cards during Main Phase!")
return false
var player = game_state.get_player(player_index)
# Check if we can afford the card
if not player.cp_pool.can_afford_card(card.card_data):
message.emit("Not enough CP!")
# Enter CP generation mode
input_mode = InputMode.SELECT_CP_SOURCE
selected_card = card
return false
# Try to play
if game_state.play_card(player_index, card):
message.emit("Played " + card.get_display_name())
return true
else:
message.emit("Cannot play that card!")
return false
## Discard a card to generate CP
func discard_card_for_cp(card: CardInstance) -> bool:
if not game_state or not is_game_active:
return false
var player_index = card.owner_index
if game_state.discard_for_cp(player_index, card):
message.emit("Discarded " + card.get_display_name() + " for 2 CP")
_check_pending_action()
return true
return false
## Dull a backup to generate CP
func dull_backup_for_cp(card: CardInstance) -> bool:
if not game_state or not is_game_active:
return false
var player_index = card.controller_index
if game_state.dull_backup_for_cp(player_index, card):
message.emit("Dulled " + card.get_display_name() + " for 1 CP")
_check_pending_action()
return true
return false
## Check if we can now afford the pending card
func _check_pending_action() -> void:
if input_mode == InputMode.SELECT_CP_SOURCE and selected_card:
var player = game_state.get_player(selected_card.controller_index)
if player.cp_pool.can_afford_card(selected_card.card_data):
# Can now afford - try to play
try_play_card(selected_card)
clear_selection()
## Declare an attack
func declare_attack(card: CardInstance) -> bool:
if not game_state or not is_game_active:
return false
if game_state.declare_attack(card):
input_mode = InputMode.SELECT_BLOCKER
return true
return false
## Declare a block
func declare_block(card: CardInstance) -> bool:
if not game_state or not is_game_active:
return false
if game_state.declare_block(card):
game_state.resolve_combat()
input_mode = InputMode.SELECT_ATTACKER
return true
return false
## Skip blocking
func skip_block() -> bool:
if not game_state or not is_game_active:
return false
if game_state.skip_block():
game_state.resolve_combat()
input_mode = InputMode.SELECT_ATTACKER
return true
return false
## Pass priority / end current phase
func pass_priority() -> void:
if not game_state or not is_game_active:
return
var phase = game_state.turn_manager.current_phase
match phase:
Enums.TurnPhase.MAIN_1, Enums.TurnPhase.MAIN_2:
game_state.end_main_phase()
Enums.TurnPhase.ATTACK:
if game_state.turn_manager.attack_step == Enums.AttackStep.DECLARATION:
game_state.end_attack_phase()
elif game_state.turn_manager.attack_step == Enums.AttackStep.BLOCK_DECLARATION:
skip_block()
clear_selection()
## Clear current selection
func clear_selection() -> void:
input_mode = InputMode.NONE
selected_card = null
pending_action = func(): pass
## Signal handlers
func _on_game_ended(winner_index: int) -> void:
is_game_active = false
var winner_name = game_state.get_player(winner_index).player_name
game_ended.emit(winner_name)
message.emit(winner_name + " wins!")
func _on_card_played(card: CardInstance, _player_index: int) -> void:
card_played.emit({
"name": card.get_display_name(),
"type": Enums.card_type_to_string(card.card_data.type)
})
func _on_damage_dealt(player_index: int, amount: int, _cards: Array) -> void:
var player_name = game_state.get_player(player_index).player_name
damage_dealt.emit(player_name, amount)
message.emit(player_name + " takes " + str(amount) + " damage!")
func _on_forward_broken(card: CardInstance) -> void:
message.emit(card.get_display_name() + " was broken!")
func _on_attack_declared(attacker: CardInstance) -> void:
message.emit(attacker.get_display_name() + " attacks!")
func _on_block_declared(blocker: CardInstance) -> void:
message.emit(blocker.get_display_name() + " blocks!")
func _on_combat_resolved(_attacker: CardInstance, blocker: CardInstance) -> void:
if blocker == null:
message.emit("Attack hits!")
else:
message.emit("Combat resolved!")
func _on_turn_changed(player_index: int) -> void:
var player = game_state.get_player(player_index)
turn_changed.emit(player.player_name, game_state.turn_manager.turn_number)
message.emit(player.player_name + "'s turn")
func _on_phase_changed(phase: Enums.TurnPhase) -> void:
var phase_name = Enums.phase_to_string(phase)
phase_changed.emit(phase_name)
# Set appropriate input mode
match phase:
Enums.TurnPhase.MAIN_1, Enums.TurnPhase.MAIN_2:
input_mode = InputMode.SELECT_CARD_TO_PLAY
Enums.TurnPhase.ATTACK:
input_mode = InputMode.SELECT_ATTACKER
_:
input_mode = InputMode.NONE

178
scripts/game/CPPool.gd Normal file
View File

@@ -0,0 +1,178 @@
class_name CPPool
extends RefCounted
## CPPool - Tracks Crystal Points generated during a turn
# CP stored by element
var _cp: Dictionary = {}
func _init() -> void:
clear()
## Clear all CP (called at end of each action)
func clear() -> void:
for element in Enums.Element.values():
_cp[element] = 0
## Add CP of a specific element
func add_cp(element: Enums.Element, amount: int) -> void:
_cp[element] = _cp.get(element, 0) + amount
## Get CP of a specific element
func get_cp(element: Enums.Element) -> int:
return _cp.get(element, 0)
## Get total CP available
func get_total_cp() -> int:
var total = 0
for element in _cp:
total += _cp[element]
return total
## Check if we can afford a card cost
func can_afford_card(card_data: CardDatabase.CardData) -> bool:
if not card_data:
return false
var cost = card_data.cost
var elements = card_data.elements
# For Light/Dark cards, just need total CP
var is_light_dark = false
for element in elements:
if Enums.is_light_or_dark(element):
is_light_dark = true
break
if is_light_dark:
return get_total_cp() >= cost
# For multi-element cards, need at least 1 CP of each element
if elements.size() > 1:
for element in elements:
if get_cp(element) < 1:
return false
# Total must be at least the cost
return get_total_cp() >= cost
# For single element cards, need at least 1 CP of that element
var primary_element = elements[0] if elements.size() > 0 else Enums.Element.FIRE
if get_cp(primary_element) < 1:
return false
return get_total_cp() >= cost
## Spend CP to pay a card cost
## Returns true if successful, false if cannot afford
func spend_for_card(card_data: CardDatabase.CardData) -> bool:
if not can_afford_card(card_data):
return false
var cost = card_data.cost
var elements = card_data.elements
var remaining = cost
# For multi-element, spend 1 of each required element first
if elements.size() > 1:
for element in elements:
_cp[element] -= 1
remaining -= 1
# For single element (non-Light/Dark), spend at least 1 of that element
elif elements.size() == 1:
var element = elements[0]
if not Enums.is_light_or_dark(element):
_cp[element] -= 1
remaining -= 1
# Spend remaining from any element
while remaining > 0:
var spent = false
for element in _cp:
if _cp[element] > 0:
_cp[element] -= 1
remaining -= 1
spent = true
break
if not spent:
push_error("Failed to spend remaining CP")
return false
return true
## Check if we can afford an ability cost
func can_afford_ability(cost: CardDatabase.CostData) -> bool:
if not cost:
return true # No cost means free
# Check element-specific requirements
if cost.fire > 0 and get_cp(Enums.Element.FIRE) < cost.fire:
return false
if cost.ice > 0 and get_cp(Enums.Element.ICE) < cost.ice:
return false
if cost.wind > 0 and get_cp(Enums.Element.WIND) < cost.wind:
return false
if cost.lightning > 0 and get_cp(Enums.Element.LIGHTNING) < cost.lightning:
return false
if cost.water > 0 and get_cp(Enums.Element.WATER) < cost.water:
return false
if cost.earth > 0 and get_cp(Enums.Element.EARTH) < cost.earth:
return false
if cost.light > 0 and get_cp(Enums.Element.LIGHT) < cost.light:
return false
if cost.dark > 0 and get_cp(Enums.Element.DARK) < cost.dark:
return false
# Check total
return get_total_cp() >= cost.get_total_cp()
## Spend CP to pay an ability cost
func spend_for_ability(cost: CardDatabase.CostData) -> bool:
if not can_afford_ability(cost):
return false
# Spend element-specific CP
if cost.fire > 0:
_cp[Enums.Element.FIRE] -= cost.fire
if cost.ice > 0:
_cp[Enums.Element.ICE] -= cost.ice
if cost.wind > 0:
_cp[Enums.Element.WIND] -= cost.wind
if cost.lightning > 0:
_cp[Enums.Element.LIGHTNING] -= cost.lightning
if cost.water > 0:
_cp[Enums.Element.WATER] -= cost.water
if cost.earth > 0:
_cp[Enums.Element.EARTH] -= cost.earth
if cost.light > 0:
_cp[Enums.Element.LIGHT] -= cost.light
if cost.dark > 0:
_cp[Enums.Element.DARK] -= cost.dark
# Spend generic from any element
var generic_remaining = cost.generic
while generic_remaining > 0:
for element in _cp:
if _cp[element] > 0:
_cp[element] -= 1
generic_remaining -= 1
break
return true
## Get a display-friendly dictionary of current CP
func get_display_data() -> Dictionary:
var data = {}
for element in _cp:
if _cp[element] > 0:
data[Enums.element_to_string(element)] = _cp[element]
return data
func _to_string() -> String:
var parts = []
for element in _cp:
if _cp[element] > 0:
parts.append("%s: %d" % [Enums.element_to_string(element), _cp[element]])
if parts.size() == 0:
return "[CPPool: empty]"
return "[CPPool: " + ", ".join(parts) + "]"

View File

@@ -0,0 +1,193 @@
class_name CardInstance
extends RefCounted
## CardInstance - Runtime instance of a card in the game
## Represents a specific card in play with its current state
# Reference to card definition
var card_data: CardDatabase.CardData
# Unique instance ID
var instance_id: int = 0
# Current state
var state: Enums.CardState = Enums.CardState.ACTIVE
var current_power: int = 0
var damage_received: int = 0
# Owner and controller
var owner_index: int = 0 # Player who owns this card (0 or 1)
var controller_index: int = 0 # Player who currently controls this card
# Current zone
var zone_type: Enums.ZoneType = Enums.ZoneType.DECK
# Temporary effects (cleared at end of turn)
var power_modifiers: Array[int] = []
var temporary_abilities: Array = []
# Turn tracking
var turns_on_field: int = 0
var attacked_this_turn: bool = false
# Static counter for unique IDs
static var _next_id: int = 1
func _init(data: CardDatabase.CardData = null, owner: int = 0) -> void:
card_data = data
owner_index = owner
controller_index = owner
instance_id = _next_id
_next_id += 1
if data:
current_power = data.power
## Get the card's current power (base + modifiers)
func get_power() -> int:
var total = current_power
for mod in power_modifiers:
total += mod
return max(0, total)
## Check if this is a Forward
func is_forward() -> bool:
return card_data and card_data.type == Enums.CardType.FORWARD
## Check if this is a Backup
func is_backup() -> bool:
return card_data and card_data.type == Enums.CardType.BACKUP
## Check if this is a Summon
func is_summon() -> bool:
return card_data and card_data.type == Enums.CardType.SUMMON
## Check if the card is active (not dull)
func is_active() -> bool:
return state == Enums.CardState.ACTIVE
## Check if the card is dull
func is_dull() -> bool:
return state == Enums.CardState.DULL
## Dull this card
func dull() -> void:
state = Enums.CardState.DULL
## Activate this card
func activate() -> void:
state = Enums.CardState.ACTIVE
## Check if this card can attack
func can_attack() -> bool:
if not is_forward():
return false
if is_dull():
return false
if attacked_this_turn:
return false
# Must have been on field since start of turn (or have Haste)
if turns_on_field < 1 and not has_haste():
return false
return true
## Check if this card can block
func can_block() -> bool:
if not is_forward():
return false
if is_dull():
return false
return true
## Check if this card can use dull abilities
func can_use_dull_ability() -> bool:
# Must have been on field since start of turn (or have Haste)
# Monsters are exception
if card_data.type == Enums.CardType.MONSTER:
return true
if turns_on_field < 1 and not has_haste():
return false
return true
## Check if card has Haste (from abilities)
func has_haste() -> bool:
if not card_data:
return false
for ability in card_data.abilities:
if ability.type == Enums.AbilityType.FIELD:
if "haste" in ability.effect.to_lower():
return true
return false
## Check if card has Brave (from abilities)
func has_brave() -> bool:
if not card_data:
return false
for ability in card_data.abilities:
if ability.type == Enums.AbilityType.FIELD:
if "brave" in ability.effect.to_lower():
return true
return false
## Check if card has First Strike
func has_first_strike() -> bool:
if not card_data:
return false
for ability in card_data.abilities:
if ability.type == Enums.AbilityType.FIELD:
if "first strike" in ability.effect.to_lower():
return true
return false
## Get primary element
func get_element() -> Enums.Element:
if card_data:
return card_data.get_primary_element()
return Enums.Element.FIRE
## Get all elements
func get_elements() -> Array[Enums.Element]:
if card_data:
return card_data.elements
return []
## Check if card is Light or Dark element
func is_light_or_dark() -> bool:
for element in get_elements():
if Enums.is_light_or_dark(element):
return true
return false
## Apply damage to this Forward
func apply_damage(amount: int) -> bool:
if not is_forward():
return false
damage_received += amount
# Check if broken (damage >= power)
return damage_received >= get_power()
## Reset temporary effects at end of turn
func end_turn_cleanup() -> void:
power_modifiers.clear()
temporary_abilities.clear()
damage_received = 0
attacked_this_turn = false
## Called when card enters field
func entered_field() -> void:
turns_on_field = 0
attacked_this_turn = false
damage_received = 0
## Called at start of each turn
func start_turn() -> void:
turns_on_field += 1
## Get a display string for this card
func get_display_name() -> String:
if card_data:
return card_data.name
return "Unknown Card"
func _to_string() -> String:
return "[CardInstance: %s (%s)]" % [get_display_name(), instance_id]

141
scripts/game/Enums.gd Normal file
View File

@@ -0,0 +1,141 @@
class_name Enums
extends RefCounted
## Card Elements
enum Element {
FIRE,
ICE,
WIND,
LIGHTNING,
WATER,
EARTH,
LIGHT,
DARK
}
## Card Types
enum CardType {
FORWARD,
BACKUP,
SUMMON,
MONSTER
}
## Ability Types
enum AbilityType {
FIELD,
AUTO,
ACTION,
SPECIAL
}
## Game Phases
enum TurnPhase {
ACTIVE,
DRAW,
MAIN_1,
ATTACK,
MAIN_2,
END
}
## Attack Phase Steps
enum AttackStep {
PREPARATION,
DECLARATION,
BLOCK_DECLARATION,
DAMAGE_RESOLUTION
}
## Zone Types
enum ZoneType {
DECK,
HAND,
FIELD_FORWARDS,
FIELD_BACKUPS,
DAMAGE,
BREAK,
STACK,
REMOVED
}
## Card States
enum CardState {
ACTIVE,
DULL
}
## Helper functions for Element
static func element_from_string(s: String) -> Element:
match s.to_lower():
"fire": return Element.FIRE
"ice": return Element.ICE
"wind": return Element.WIND
"lightning": return Element.LIGHTNING
"water": return Element.WATER
"earth": return Element.EARTH
"light": return Element.LIGHT
"dark": return Element.DARK
push_error("Unknown element: " + s)
return Element.FIRE
static func element_to_string(e: Element) -> String:
match e:
Element.FIRE: return "Fire"
Element.ICE: return "Ice"
Element.WIND: return "Wind"
Element.LIGHTNING: return "Lightning"
Element.WATER: return "Water"
Element.EARTH: return "Earth"
Element.LIGHT: return "Light"
Element.DARK: return "Dark"
return "Unknown"
static func element_to_color(e: Element) -> Color:
match e:
Element.FIRE: return Color(0.9, 0.2, 0.2) # Red
Element.ICE: return Color(0.4, 0.8, 0.9) # Cyan
Element.WIND: return Color(0.3, 0.8, 0.3) # Green
Element.LIGHTNING: return Color(0.6, 0.3, 0.8) # Purple
Element.WATER: return Color(0.2, 0.4, 0.9) # Blue
Element.EARTH: return Color(0.8, 0.7, 0.3) # Yellow
Element.LIGHT: return Color(1.0, 1.0, 0.9) # White
Element.DARK: return Color(0.2, 0.1, 0.3) # Dark Purple
return Color.WHITE
## Helper functions for CardType
static func card_type_from_string(s: String) -> CardType:
match s.to_lower():
"forward": return CardType.FORWARD
"backup": return CardType.BACKUP
"summon": return CardType.SUMMON
"monster": return CardType.MONSTER
push_error("Unknown card type: " + s)
return CardType.FORWARD
static func card_type_to_string(t: CardType) -> String:
match t:
CardType.FORWARD: return "Forward"
CardType.BACKUP: return "Backup"
CardType.SUMMON: return "Summon"
CardType.MONSTER: return "Monster"
return "Unknown"
## Helper functions for TurnPhase
static func phase_to_string(p: TurnPhase) -> String:
match p:
TurnPhase.ACTIVE: return "Active Phase"
TurnPhase.DRAW: return "Draw Phase"
TurnPhase.MAIN_1: return "Main Phase 1"
TurnPhase.ATTACK: return "Attack Phase"
TurnPhase.MAIN_2: return "Main Phase 2"
TurnPhase.END: return "End Phase"
return "Unknown Phase"
## Check if element is Light or Dark (special rules apply)
static func is_light_or_dark(e: Element) -> bool:
return e == Element.LIGHT or e == Element.DARK
## Alias for element_to_color
static func get_element_color(e: Element) -> Color:
return element_to_color(e)

332
scripts/game/GameState.gd Normal file
View File

@@ -0,0 +1,332 @@
class_name GameState
extends RefCounted
## GameState - Central game state container and rules engine
signal game_started
signal game_ended(winner_index: int)
signal card_played(card: CardInstance, player_index: int)
signal card_moved(card: CardInstance, from_zone: Enums.ZoneType, to_zone: Enums.ZoneType)
signal damage_dealt(player_index: int, amount: int, cards: Array[CardInstance])
signal forward_broken(card: CardInstance)
signal attack_declared(attacker: CardInstance)
signal block_declared(blocker: CardInstance)
signal combat_resolved(attacker: CardInstance, blocker: CardInstance)
signal cp_generated(player_index: int, element: Enums.Element, amount: int)
# Players
var players: Array[Player] = []
# Turn management
var turn_manager: TurnManager
# Shared stack for abilities/summons
var stack: Zone
# Game state flags
var game_active: bool = false
var winner_index: int = -1
func _init() -> void:
turn_manager = TurnManager.new()
stack = Zone.new(Enums.ZoneType.STACK, -1)
# Connect turn manager signals
turn_manager.phase_changed.connect(_on_phase_changed)
turn_manager.turn_started.connect(_on_turn_started)
turn_manager.turn_ended.connect(_on_turn_ended)
## Initialize a new game with two players
func setup_game(deck1: Array[String], deck2: Array[String]) -> void:
# Create players
players.clear()
players.append(Player.new(0, "Player 1"))
players.append(Player.new(1, "Player 2"))
# Setup decks
players[0].setup_deck(deck1)
players[1].setup_deck(deck2)
game_active = false
winner_index = -1
## Start the game
func start_game(first_player: int = -1) -> void:
if first_player < 0:
first_player = randi() % 2
players[first_player].is_first_player = true
# Draw initial hands (5 cards each)
players[0].draw_cards(5)
players[1].draw_cards(5)
game_active = true
turn_manager.start_game(first_player)
game_started.emit()
## Get current player
func get_current_player() -> Player:
return players[turn_manager.current_player_index]
## Get opponent of current player
func get_opponent() -> Player:
return players[1 - turn_manager.current_player_index]
## Get player by index
func get_player(index: int) -> Player:
if index >= 0 and index < players.size():
return players[index]
return null
## Execute Active Phase
func execute_active_phase() -> void:
var player = get_current_player()
player.activate_all()
turn_manager.advance_phase()
## Execute Draw Phase
func execute_draw_phase() -> void:
var player = get_current_player()
var draw_count = turn_manager.get_draw_count()
var drawn = player.draw_cards(draw_count)
# Check for loss condition (can't draw)
if drawn.size() < draw_count and player.deck.is_empty():
_check_loss_conditions()
turn_manager.advance_phase()
## End Main Phase
func end_main_phase() -> void:
# Clear any unused CP
get_current_player().cp_pool.clear()
turn_manager.advance_phase()
## Play a card from hand
func play_card(player_index: int, card: CardInstance) -> bool:
if not game_active:
return false
if player_index != turn_manager.current_player_index:
return false
if not turn_manager.is_main_phase():
return false
var player = players[player_index]
# Validate and play
if player.play_card(card):
card_played.emit(card, player_index)
card_moved.emit(card, Enums.ZoneType.HAND,
Enums.ZoneType.FIELD_FORWARDS if card.is_forward() else Enums.ZoneType.FIELD_BACKUPS)
return true
return false
## Discard a card for CP
func discard_for_cp(player_index: int, card: CardInstance) -> bool:
if not game_active:
return false
var player = players[player_index]
var element = card.get_element()
if player.discard_for_cp(card):
cp_generated.emit(player_index, element, 2)
card_moved.emit(card, Enums.ZoneType.HAND, Enums.ZoneType.BREAK)
return true
return false
## Dull a backup for CP
func dull_backup_for_cp(player_index: int, card: CardInstance) -> bool:
if not game_active:
return false
var player = players[player_index]
var element = card.get_element()
if player.dull_backup_for_cp(card):
cp_generated.emit(player_index, element, 1)
return true
return false
## Start Attack Phase
func start_attack_phase() -> void:
turn_manager.start_attack_declaration()
## Declare an attack
func declare_attack(attacker: CardInstance) -> bool:
if not game_active:
return false
if not turn_manager.is_attack_phase():
return false
var player = get_current_player()
if not player.field_forwards.has_card(attacker):
return false
if not attacker.can_attack():
return false
# Dull the attacker (unless Brave)
if not attacker.has_brave():
attacker.dull()
attacker.attacked_this_turn = true
turn_manager.set_attacker(attacker)
attack_declared.emit(attacker)
return true
## Declare a block
func declare_block(blocker: CardInstance) -> bool:
if not game_active:
return false
if turn_manager.attack_step != Enums.AttackStep.BLOCK_DECLARATION:
return false
var opponent = get_opponent()
if blocker != null:
if not opponent.field_forwards.has_card(blocker):
return false
if not blocker.can_block():
return false
turn_manager.set_blocker(blocker)
if blocker:
block_declared.emit(blocker)
return true
## Skip blocking
func skip_block() -> bool:
return declare_block(null)
## Resolve combat damage
func resolve_combat() -> void:
if turn_manager.attack_step != Enums.AttackStep.DAMAGE_RESOLUTION:
return
var attacker = turn_manager.current_attacker
var blocker = turn_manager.current_blocker
if blocker == null:
# Unblocked - deal 1 damage to opponent
_deal_damage_to_player(1 - turn_manager.current_player_index, 1)
else:
# Blocked - exchange damage
var attacker_power = attacker.get_power()
var blocker_power = blocker.get_power()
# Apply damage
var attacker_broken = attacker.apply_damage(blocker_power)
var blocker_broken = blocker.apply_damage(attacker_power)
# Break destroyed forwards
if attacker_broken:
_break_forward(attacker, turn_manager.current_player_index)
if blocker_broken:
_break_forward(blocker, 1 - turn_manager.current_player_index)
combat_resolved.emit(attacker, blocker)
turn_manager.complete_attack()
## End Attack Phase (no more attacks)
func end_attack_phase() -> void:
turn_manager.end_attack_phase()
## Execute End Phase
func execute_end_phase() -> void:
var player = get_current_player()
# Discard to hand limit
var discarded = player.discard_to_hand_limit()
for card in discarded:
card_moved.emit(card, Enums.ZoneType.HAND, Enums.ZoneType.BREAK)
# Cleanup
player.end_turn_cleanup()
turn_manager.advance_phase()
## Deal damage to a player
func _deal_damage_to_player(player_index: int, amount: int) -> void:
var player = players[player_index]
var damage_cards = player.take_damage(amount)
damage_dealt.emit(player_index, amount, damage_cards)
# Check for EX Bursts (simplified - would need full implementation)
for card in damage_cards:
if card.card_data and card.card_data.has_ex_burst:
# TODO: Offer EX Burst choice to player
pass
_check_loss_conditions()
## Break a forward
func _break_forward(card: CardInstance, player_index: int) -> void:
var player = players[player_index]
if player.break_card(card):
forward_broken.emit(card)
card_moved.emit(card, Enums.ZoneType.FIELD_FORWARDS, Enums.ZoneType.BREAK)
## Check for game-ending conditions
func _check_loss_conditions() -> void:
for i in range(players.size()):
var player = players[i]
# Check damage
if player.has_lost():
_end_game(1 - i) # Other player wins
return
# Check deck out (can't draw when needed)
# This is checked during draw phase
## End the game
func _end_game(winner: int) -> void:
game_active = false
winner_index = winner
game_ended.emit(winner)
## Phase change handler
func _on_phase_changed(phase: Enums.TurnPhase) -> void:
match phase:
Enums.TurnPhase.ACTIVE:
execute_active_phase()
Enums.TurnPhase.DRAW:
execute_draw_phase()
Enums.TurnPhase.ATTACK:
start_attack_phase()
Enums.TurnPhase.END:
execute_end_phase()
## Turn started handler
func _on_turn_started(player_index: int, _turn_number: int) -> void:
players[player_index].start_turn()
## Turn ended handler
func _on_turn_ended(_player_index: int) -> void:
pass
func _to_string() -> String:
if not game_active:
return "[GameState: Inactive]"
return "[GameState: Turn %d, Phase: %s, Player %d]" % [
turn_manager.turn_number,
turn_manager.get_phase_string(),
turn_manager.current_player_index + 1
]

263
scripts/game/Player.gd Normal file
View File

@@ -0,0 +1,263 @@
class_name Player
extends RefCounted
## Player - Represents a player's game state
var player_index: int = 0
var player_name: String = "Player"
# Zones
var deck: Zone
var hand: Zone
var field_forwards: Zone
var field_backups: Zone
var damage_zone: Zone
var break_zone: Zone
# Resources
var cp_pool: CPPool
# Game state
var is_first_player: bool = false
var has_mulliganed: bool = false
# Constants
const MAX_HAND_SIZE: int = 5
const MAX_BACKUPS: int = 5
const DAMAGE_TO_LOSE: int = 7
func _init(index: int, name: String = "") -> void:
player_index = index
player_name = name if name != "" else "Player %d" % (index + 1)
# Initialize zones
deck = Zone.new(Enums.ZoneType.DECK, index)
hand = Zone.new(Enums.ZoneType.HAND, index)
field_forwards = Zone.new(Enums.ZoneType.FIELD_FORWARDS, index)
field_backups = Zone.new(Enums.ZoneType.FIELD_BACKUPS, index)
damage_zone = Zone.new(Enums.ZoneType.DAMAGE, index)
break_zone = Zone.new(Enums.ZoneType.BREAK, index)
# Initialize CP pool
cp_pool = CPPool.new()
## Setup the player's deck from a list of card IDs
func setup_deck(card_ids: Array[String]) -> void:
for card_id in card_ids:
var card_data = CardDatabase.get_card(card_id)
if card_data:
var card = CardInstance.new(card_data, player_index)
deck.add_card(card)
# Shuffle the deck
deck.shuffle()
## Draw cards from deck to hand
func draw_cards(count: int) -> Array[CardInstance]:
var drawn: Array[CardInstance] = []
for i in range(count):
if deck.is_empty():
break # Can't draw from empty deck
var card = deck.pop_top_card()
if card:
hand.add_card(card)
drawn.append(card)
return drawn
## Check if player can draw (deck not empty)
func can_draw() -> bool:
return not deck.is_empty()
## Get current damage count
func get_damage_count() -> int:
return damage_zone.get_count()
## Check if player has lost (7+ damage)
func has_lost() -> bool:
return get_damage_count() >= DAMAGE_TO_LOSE
## Take damage (move cards from deck to damage zone)
func take_damage(amount: int) -> Array[CardInstance]:
var damage_cards: Array[CardInstance] = []
for i in range(amount):
if deck.is_empty():
break
var card = deck.pop_top_card()
if card:
damage_zone.add_card(card)
damage_cards.append(card)
return damage_cards
## Discard a card from hand to generate CP
func discard_for_cp(card: CardInstance) -> bool:
if not hand.has_card(card):
return false
# Light/Dark cards cannot be discarded for CP
if card.is_light_or_dark():
return false
hand.remove_card(card)
break_zone.add_card(card)
# Generate 2 CP of the card's element
var element = card.get_element()
cp_pool.add_cp(element, 2)
return true
## Dull a backup to generate CP
func dull_backup_for_cp(card: CardInstance) -> bool:
if not field_backups.has_card(card):
return false
if not card.is_backup():
return false
if card.is_dull():
return false
card.dull()
# Generate 1 CP of the backup's element
var element = card.get_element()
cp_pool.add_cp(element, 1)
return true
## Play a card from hand to field
func play_card(card: CardInstance) -> bool:
if not hand.has_card(card):
return false
# Check if we can afford it
if not cp_pool.can_afford_card(card.card_data):
return false
# Check field restrictions
if card.is_backup():
if field_backups.get_count() >= MAX_BACKUPS:
return false
# Check unique name restriction
if not card.card_data.is_generic:
if card.is_forward() and field_forwards.has_card_with_name(card.card_data.name):
return false
if card.is_backup() and field_backups.has_card_with_name(card.card_data.name):
return false
# Check Light/Dark restriction
if card.is_light_or_dark():
if field_forwards.has_light_or_dark() or field_backups.has_light_or_dark():
return false
# Pay the cost
if not cp_pool.spend_for_card(card.card_data):
return false
# Move to field
hand.remove_card(card)
if card.is_forward():
field_forwards.add_card(card)
card.state = Enums.CardState.ACTIVE
elif card.is_backup():
field_backups.add_card(card)
card.state = Enums.CardState.DULL # Backups enter dull
card.entered_field()
return true
## Break a card (move from field to break zone)
func break_card(card: CardInstance) -> bool:
var removed = false
if field_forwards.has_card(card):
field_forwards.remove_card(card)
removed = true
elif field_backups.has_card(card):
field_backups.remove_card(card)
removed = true
if removed:
break_zone.add_card(card)
return true
return false
## Activate all dull cards (Active Phase)
func activate_all() -> void:
for card in field_forwards.get_dull_cards():
card.activate()
for card in field_backups.get_dull_cards():
card.activate()
## Discard down to hand size (End Phase)
func discard_to_hand_limit() -> Array[CardInstance]:
var discarded: Array[CardInstance] = []
while hand.get_count() > MAX_HAND_SIZE:
# For now, just discard the last card
# In actual game, player would choose
var card = hand.pop_top_card()
if card:
break_zone.add_card(card)
discarded.append(card)
return discarded
## End of turn cleanup
func end_turn_cleanup() -> void:
# Clear CP pool
cp_pool.clear()
# Reset temporary effects on field cards
for card in field_forwards.get_cards():
card.end_turn_cleanup()
for card in field_backups.get_cards():
card.end_turn_cleanup()
## Start of turn setup
func start_turn() -> void:
# Increment turn counter for field cards
for card in field_forwards.get_cards():
card.start_turn()
for card in field_backups.get_cards():
card.start_turn()
## Get all cards on field
func get_all_field_cards() -> Array[CardInstance]:
var cards: Array[CardInstance] = []
cards.append_array(field_forwards.get_cards())
cards.append_array(field_backups.get_cards())
return cards
## Get forwards that can attack
func get_attackable_forwards() -> Array[CardInstance]:
var attackers: Array[CardInstance] = []
for card in field_forwards.get_cards():
if card.can_attack():
attackers.append(card)
return attackers
## Get forwards that can block
func get_blockable_forwards() -> Array[CardInstance]:
var blockers: Array[CardInstance] = []
for card in field_forwards.get_cards():
if card.can_block():
blockers.append(card)
return blockers
func _to_string() -> String:
return "[Player: %s, Hand: %d, Forwards: %d, Backups: %d, Damage: %d]" % [
player_name,
hand.get_count(),
field_forwards.get_count(),
field_backups.get_count(),
damage_zone.get_count()
]

150
scripts/game/TurnManager.gd Normal file
View File

@@ -0,0 +1,150 @@
class_name TurnManager
extends RefCounted
## TurnManager - Handles turn and phase progression
signal phase_changed(phase: Enums.TurnPhase)
signal turn_changed(player_index: int)
signal turn_started(player_index: int, turn_number: int)
signal turn_ended(player_index: int)
var current_phase: Enums.TurnPhase = Enums.TurnPhase.ACTIVE
var current_player_index: int = 0
var turn_number: int = 0
var is_first_turn: bool = true
# Attack phase tracking
var attack_step: Enums.AttackStep = Enums.AttackStep.PREPARATION
var current_attacker: CardInstance = null
var current_blocker: CardInstance = null
func _init() -> void:
pass
## Start a new game
func start_game(first_player: int) -> void:
current_player_index = first_player
turn_number = 1
is_first_turn = true
current_phase = Enums.TurnPhase.ACTIVE
turn_started.emit(current_player_index, turn_number)
## Advance to the next phase
func advance_phase() -> Enums.TurnPhase:
var next_phase = _get_next_phase(current_phase)
if next_phase == Enums.TurnPhase.ACTIVE:
# Starting a new turn
_end_current_turn()
_start_new_turn()
else:
current_phase = next_phase
phase_changed.emit(current_phase)
return current_phase
## Get the next phase in sequence
func _get_next_phase(phase: Enums.TurnPhase) -> Enums.TurnPhase:
match phase:
Enums.TurnPhase.ACTIVE:
return Enums.TurnPhase.DRAW
Enums.TurnPhase.DRAW:
return Enums.TurnPhase.MAIN_1
Enums.TurnPhase.MAIN_1:
return Enums.TurnPhase.ATTACK
Enums.TurnPhase.ATTACK:
return Enums.TurnPhase.MAIN_2
Enums.TurnPhase.MAIN_2:
return Enums.TurnPhase.END
Enums.TurnPhase.END:
return Enums.TurnPhase.ACTIVE # Loop back to start new turn
return Enums.TurnPhase.ACTIVE
## End the current turn
func _end_current_turn() -> void:
turn_ended.emit(current_player_index)
## Start a new turn
func _start_new_turn() -> void:
# Switch to other player
current_player_index = 1 - current_player_index
turn_number += 1
is_first_turn = false
current_phase = Enums.TurnPhase.ACTIVE
_reset_attack_state()
turn_changed.emit(current_player_index)
turn_started.emit(current_player_index, turn_number)
phase_changed.emit(current_phase)
## Reset attack phase state
func _reset_attack_state() -> void:
attack_step = Enums.AttackStep.PREPARATION
current_attacker = null
current_blocker = null
## Check if we're in a main phase (can play cards)
func is_main_phase() -> bool:
return current_phase == Enums.TurnPhase.MAIN_1 or current_phase == Enums.TurnPhase.MAIN_2
## Check if we're in attack phase
func is_attack_phase() -> bool:
return current_phase == Enums.TurnPhase.ATTACK
## Get cards drawn this turn (2 normally, 1 on first turn for first player)
func get_draw_count() -> int:
if is_first_turn and current_player_index == 0:
return 1
return 2
## Attack phase state management
## Start attack declaration
func start_attack_declaration() -> void:
attack_step = Enums.AttackStep.DECLARATION
## Set the attacking forward
func set_attacker(attacker: CardInstance) -> void:
current_attacker = attacker
attack_step = Enums.AttackStep.BLOCK_DECLARATION
## Set the blocking forward (or null for no block)
func set_blocker(blocker: CardInstance) -> void:
current_blocker = blocker
attack_step = Enums.AttackStep.DAMAGE_RESOLUTION
## Complete the current attack
func complete_attack() -> void:
current_attacker = null
current_blocker = null
attack_step = Enums.AttackStep.DECLARATION
## End the attack phase (no more attacks)
func end_attack_phase() -> void:
_reset_attack_state()
advance_phase()
## Get current phase as string
func get_phase_string() -> String:
return Enums.phase_to_string(current_phase)
## Get current attack step as string
func get_attack_step_string() -> String:
match attack_step:
Enums.AttackStep.PREPARATION:
return "Preparation"
Enums.AttackStep.DECLARATION:
return "Declare Attacker"
Enums.AttackStep.BLOCK_DECLARATION:
return "Declare Blocker"
Enums.AttackStep.DAMAGE_RESOLUTION:
return "Damage Resolution"
return "Unknown"
func _to_string() -> String:
return "[TurnManager: Turn %d, Player %d, Phase: %s]" % [
turn_number,
current_player_index + 1,
get_phase_string()
]

158
scripts/game/Zone.gd Normal file
View File

@@ -0,0 +1,158 @@
class_name Zone
extends RefCounted
## Zone - Container for cards in a specific game area
var zone_type: Enums.ZoneType
var owner_index: int # Player who owns this zone (-1 for shared zones like stack)
var _cards: Array[CardInstance] = []
# Signals (these would need to be connected via the parent)
# In GDScript, RefCounted can't have signals, so we'll use callbacks
var on_card_added: Callable = func(_card): pass
var on_card_removed: Callable = func(_card): pass
func _init(type: Enums.ZoneType, owner: int = -1) -> void:
zone_type = type
owner_index = owner
## Add a card to this zone
func add_card(card: CardInstance, at_top: bool = true) -> void:
if card in _cards:
push_warning("Card already in zone: " + str(card))
return
card.zone_type = zone_type
if at_top:
_cards.append(card)
else:
_cards.insert(0, card)
on_card_added.call(card)
## Remove a card from this zone
func remove_card(card: CardInstance) -> bool:
var index = _cards.find(card)
if index >= 0:
_cards.remove_at(index)
on_card_removed.call(card)
return true
return false
## Get all cards in this zone
func get_cards() -> Array[CardInstance]:
return _cards.duplicate()
## Get card count
func get_count() -> int:
return _cards.size()
## Check if zone contains a card
func has_card(card: CardInstance) -> bool:
return card in _cards
## Get card at index
func get_card_at(index: int) -> CardInstance:
if index >= 0 and index < _cards.size():
return _cards[index]
return null
## Get top card (last added)
func get_top_card() -> CardInstance:
if _cards.size() > 0:
return _cards[_cards.size() - 1]
return null
## Remove and return top card
func pop_top_card() -> CardInstance:
if _cards.size() > 0:
var card = _cards.pop_back()
on_card_removed.call(card)
return card
return null
## Get bottom card (first added)
func get_bottom_card() -> CardInstance:
if _cards.size() > 0:
return _cards[0]
return null
## Check if zone is empty
func is_empty() -> bool:
return _cards.size() == 0
## Shuffle the zone (for decks)
func shuffle() -> void:
_cards.shuffle()
## Clear all cards from zone
func clear() -> Array[CardInstance]:
var removed = _cards.duplicate()
_cards.clear()
for card in removed:
on_card_removed.call(card)
return removed
## Get all Forwards in zone
func get_forwards() -> Array[CardInstance]:
var forwards: Array[CardInstance] = []
for card in _cards:
if card.is_forward():
forwards.append(card)
return forwards
## Get all Backups in zone
func get_backups() -> Array[CardInstance]:
var backups: Array[CardInstance] = []
for card in _cards:
if card.is_backup():
backups.append(card)
return backups
## Get all active cards
func get_active_cards() -> Array[CardInstance]:
var active: Array[CardInstance] = []
for card in _cards:
if card.is_active():
active.append(card)
return active
## Get all dull cards
func get_dull_cards() -> Array[CardInstance]:
var dull: Array[CardInstance] = []
for card in _cards:
if card.is_dull():
dull.append(card)
return dull
## Find cards by name
func find_cards_by_name(card_name: String) -> Array[CardInstance]:
var found: Array[CardInstance] = []
for card in _cards:
if card.card_data and card.card_data.name == card_name:
found.append(card)
return found
## Find cards by element
func find_cards_by_element(element: Enums.Element) -> Array[CardInstance]:
var found: Array[CardInstance] = []
for card in _cards:
if element in card.get_elements():
found.append(card)
return found
## Check if zone has a card with specific name (non-generic check)
func has_card_with_name(card_name: String) -> bool:
for card in _cards:
if card.card_data and card.card_data.name == card_name:
if not card.card_data.is_generic:
return true
return false
## Check if zone has any Light or Dark card
func has_light_or_dark() -> bool:
for card in _cards:
if card.is_light_or_dark():
return true
return false

View File

@@ -0,0 +1,67 @@
class_name DamageDisplay
extends Control
## DamageDisplay - Shows player damage counters
# Display settings
const MAX_DAMAGE: int = 7
const MARKER_SIZE: float = 30.0
const MARKER_SPACING: float = 5.0
# UI elements
var markers: Array[ColorRect] = []
var damage_label: Label
# Current damage
var current_damage: int = 0
func _ready() -> void:
_create_display()
func _create_display() -> void:
# Container for markers
var marker_container = HBoxContainer.new()
add_child(marker_container)
marker_container.add_theme_constant_override("separation", int(MARKER_SPACING))
# Create damage markers
for i in range(MAX_DAMAGE):
var marker = ColorRect.new()
marker.custom_minimum_size = Vector2(MARKER_SIZE, MARKER_SIZE)
marker.color = Color(0.3, 0.3, 0.3) # Empty state
marker_container.add_child(marker)
markers.append(marker)
# Damage count label
damage_label = Label.new()
damage_label.text = "0 / 7"
damage_label.position = Vector2(0, MARKER_SIZE + 5)
damage_label.add_theme_font_size_override("font_size", 14)
add_child(damage_label)
## Update damage display
func set_damage(amount: int) -> void:
current_damage = clampi(amount, 0, MAX_DAMAGE)
# Update markers
for i in range(MAX_DAMAGE):
if i < current_damage:
markers[i].color = Color(0.8, 0.2, 0.2) # Damage taken
else:
markers[i].color = Color(0.3, 0.3, 0.3) # Empty
# Update label
damage_label.text = str(current_damage) + " / " + str(MAX_DAMAGE)
# Flash on damage change
if current_damage > 0:
_flash_latest()
func _flash_latest() -> void:
if current_damage <= 0 or current_damage > MAX_DAMAGE:
return
var marker = markers[current_damage - 1]
var tween = create_tween()
tween.tween_property(marker, "color", Color(1.0, 0.5, 0.5), 0.1)
tween.tween_property(marker, "color", Color(0.8, 0.2, 0.2), 0.2)

336
scripts/ui/GameUI.gd Normal file
View File

@@ -0,0 +1,336 @@
class_name GameUI
extends CanvasLayer
## GameUI - Main UI overlay for game information and controls
signal end_phase_pressed
signal pass_priority_pressed
# UI Components
var turn_panel: PanelContainer
var phase_panel: PanelContainer
var cp_panel: PanelContainer
var message_panel: PanelContainer
var card_detail_panel: PanelContainer
var action_buttons: HBoxContainer
# Labels
var turn_label: Label
var phase_label: Label
var cp_label: Label
var message_label: Label
# Card detail labels
var detail_name_label: Label
var detail_type_label: Label
var detail_cost_label: Label
var detail_power_label: Label
var detail_element_label: Label
var detail_ability_label: Label
# Buttons
var end_phase_button: Button
var pass_button: Button
# Message queue
var message_queue: Array[String] = []
var message_timer: Timer
const MESSAGE_DISPLAY_TIME: float = 3.0
func _ready() -> void:
_create_ui()
_connect_signals()
func _create_ui() -> void:
# Root control that fills the screen
var root = Control.new()
add_child(root)
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
# === TOP BAR ===
var top_bar = HBoxContainer.new()
root.add_child(top_bar)
top_bar.set_anchors_preset(Control.PRESET_TOP_WIDE)
top_bar.offset_left = 10
top_bar.offset_right = -10
top_bar.offset_top = 10
top_bar.offset_bottom = 70
top_bar.add_theme_constant_override("separation", 20)
# Turn panel (top left)
turn_panel = _create_panel()
top_bar.add_child(turn_panel)
var turn_vbox = VBoxContainer.new()
turn_panel.add_child(turn_vbox)
var turn_header = Label.new()
turn_header.text = "Turn"
turn_header.add_theme_font_size_override("font_size", 12)
turn_vbox.add_child(turn_header)
turn_label = Label.new()
turn_label.text = "Player 1 - Turn 1"
turn_label.add_theme_font_size_override("font_size", 16)
turn_vbox.add_child(turn_label)
# Phase panel (top center-left)
phase_panel = _create_panel()
top_bar.add_child(phase_panel)
var phase_vbox = VBoxContainer.new()
phase_panel.add_child(phase_vbox)
var phase_header = Label.new()
phase_header.text = "Phase"
phase_header.add_theme_font_size_override("font_size", 12)
phase_vbox.add_child(phase_header)
phase_label = Label.new()
phase_label.text = "Active Phase"
phase_label.add_theme_font_size_override("font_size", 16)
phase_vbox.add_child(phase_label)
# CP panel
cp_panel = _create_panel()
top_bar.add_child(cp_panel)
var cp_vbox = VBoxContainer.new()
cp_panel.add_child(cp_vbox)
var cp_header = Label.new()
cp_header.text = "CP Pool"
cp_header.add_theme_font_size_override("font_size", 12)
cp_vbox.add_child(cp_header)
cp_label = Label.new()
cp_label.text = "0 CP"
cp_label.add_theme_font_size_override("font_size", 16)
cp_vbox.add_child(cp_label)
# Spacer to push buttons to the right
var spacer = Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top_bar.add_child(spacer)
# Action buttons in top bar (right side)
action_buttons = HBoxContainer.new()
top_bar.add_child(action_buttons)
action_buttons.add_theme_constant_override("separation", 10)
pass_button = Button.new()
pass_button.text = "Pass"
pass_button.custom_minimum_size = Vector2(80, 40)
action_buttons.add_child(pass_button)
end_phase_button = Button.new()
end_phase_button.text = "End Phase"
end_phase_button.custom_minimum_size = Vector2(100, 40)
action_buttons.add_child(end_phase_button)
# === MESSAGE PANEL (center, above hand) ===
message_panel = _create_panel()
root.add_child(message_panel)
message_panel.set_anchors_preset(Control.PRESET_CENTER)
message_panel.position = Vector2(-100, 200)
message_panel.custom_minimum_size = Vector2(200, 40)
message_label = Label.new()
message_label.text = ""
message_label.add_theme_font_size_override("font_size", 16)
message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message_panel.add_child(message_label)
message_panel.visible = false
# === CARD DETAIL PANEL (right side) ===
card_detail_panel = _create_panel()
root.add_child(card_detail_panel)
card_detail_panel.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
card_detail_panel.offset_left = -220
card_detail_panel.offset_right = -10
card_detail_panel.offset_top = -150
card_detail_panel.offset_bottom = 150
card_detail_panel.visible = false
var detail_vbox = VBoxContainer.new()
card_detail_panel.add_child(detail_vbox)
detail_vbox.add_theme_constant_override("separation", 5)
detail_name_label = Label.new()
detail_name_label.text = "Card Name"
detail_name_label.add_theme_font_size_override("font_size", 18)
detail_vbox.add_child(detail_name_label)
var separator = HSeparator.new()
detail_vbox.add_child(separator)
detail_type_label = Label.new()
detail_type_label.text = "Type: Forward"
detail_vbox.add_child(detail_type_label)
detail_element_label = Label.new()
detail_element_label.text = "Element: Fire"
detail_vbox.add_child(detail_element_label)
detail_cost_label = Label.new()
detail_cost_label.text = "Cost: 3"
detail_vbox.add_child(detail_cost_label)
detail_power_label = Label.new()
detail_power_label.text = "Power: 7000"
detail_vbox.add_child(detail_power_label)
var separator2 = HSeparator.new()
detail_vbox.add_child(separator2)
var ability_header = Label.new()
ability_header.text = "Abilities:"
ability_header.add_theme_font_size_override("font_size", 12)
detail_vbox.add_child(ability_header)
detail_ability_label = Label.new()
detail_ability_label.text = ""
detail_ability_label.autowrap_mode = TextServer.AUTOWRAP_WORD
detail_ability_label.custom_minimum_size.x = 180
detail_vbox.add_child(detail_ability_label)
# Message timer
message_timer = Timer.new()
add_child(message_timer)
message_timer.one_shot = true
message_timer.timeout.connect(_on_message_timer_timeout)
func _create_panel() -> PanelContainer:
var panel = PanelContainer.new()
# Create stylebox
var style = StyleBoxFlat.new()
style.bg_color = Color(0.1, 0.1, 0.15, 0.9)
style.border_color = Color(0.3, 0.3, 0.4)
style.set_border_width_all(2)
style.set_corner_radius_all(5)
style.set_content_margin_all(8)
panel.add_theme_stylebox_override("panel", style)
return panel
func _connect_signals() -> void:
end_phase_button.pressed.connect(_on_end_phase_pressed)
pass_button.pressed.connect(_on_pass_priority_pressed)
# Connect to GameManager signals
if GameManager:
GameManager.turn_changed.connect(_on_turn_changed)
GameManager.phase_changed.connect(_on_phase_changed)
GameManager.message.connect(show_message)
func _on_end_phase_pressed() -> void:
end_phase_pressed.emit()
if GameManager:
GameManager.pass_priority()
func _on_pass_priority_pressed() -> void:
pass_priority_pressed.emit()
if GameManager:
GameManager.pass_priority()
func _on_turn_changed(player_name: String, turn_number: int) -> void:
turn_label.text = player_name + " - Turn " + str(turn_number)
func _on_phase_changed(phase_name: String) -> void:
phase_label.text = phase_name
## Update CP display
func update_cp_display(cp_pool: CPPool) -> void:
if not cp_pool:
cp_label.text = "0 CP"
return
var total = cp_pool.get_total_cp()
var text = str(total) + " CP"
# Show element breakdown if any specific element CP
var elements_text = []
for element in Enums.Element.values():
var amount = cp_pool.get_cp(element)
if amount > 0:
var elem_name = Enums.element_to_string(element)
elements_text.append(elem_name + ":" + str(amount))
if elements_text.size() > 0:
text += "\n" + ", ".join(elements_text)
cp_label.text = text
## Show a message
func show_message(text: String) -> void:
message_queue.append(text)
if not message_timer.is_stopped():
return
_show_next_message()
func _show_next_message() -> void:
if message_queue.is_empty():
message_panel.visible = false
return
var text = message_queue.pop_front()
message_label.text = text
message_panel.visible = true
message_timer.start(MESSAGE_DISPLAY_TIME)
func _on_message_timer_timeout() -> void:
_show_next_message()
## Show card detail panel
func show_card_detail(card: CardInstance) -> void:
if not card or not card.card_data:
card_detail_panel.visible = false
return
var data = card.card_data
detail_name_label.text = data.name
detail_type_label.text = "Type: " + Enums.card_type_to_string(data.type)
detail_cost_label.text = "Cost: " + str(data.cost)
# Element
var element_strs = []
for elem in data.elements:
element_strs.append(Enums.element_to_string(elem))
detail_element_label.text = "Element: " + "/".join(element_strs)
# Power
if data.type == Enums.CardType.FORWARD or data.power > 0:
detail_power_label.text = "Power: " + str(card.get_power())
detail_power_label.visible = true
else:
detail_power_label.visible = false
# Abilities
var ability_text = ""
for ability in data.abilities:
if ability_text != "":
ability_text += "\n\n"
var type_str = ""
match ability.type:
Enums.AbilityType.FIELD: type_str = "[Field]"
Enums.AbilityType.AUTO: type_str = "[Auto]"
Enums.AbilityType.ACTION: type_str = "[Action]"
Enums.AbilityType.SPECIAL: type_str = "[Special]"
ability_text += type_str
if ability.name != "":
ability_text += " " + ability.name
ability_text += "\n" + ability.effect
detail_ability_label.text = ability_text if ability_text != "" else "No abilities"
card_detail_panel.visible = true
## Hide card detail panel
func hide_card_detail() -> void:
card_detail_panel.visible = false
## Update button states based on game phase
func update_button_states(can_end_phase: bool, can_pass: bool) -> void:
end_phase_button.disabled = not can_end_phase
pass_button.disabled = not can_pass

238
scripts/ui/HandDisplay.gd Normal file
View File

@@ -0,0 +1,238 @@
class_name HandDisplay
extends Control
## HandDisplay - Shows the current player's hand in a fan layout
signal card_selected(card_instance: CardInstance)
signal card_hovered(card_instance: CardInstance)
signal card_unhovered
# Hand card visuals
var hand_cards: Array[Control] = []
var card_instances: Array[CardInstance] = []
# Layout settings
const CARD_WIDTH: float = 100.0
const CARD_HEIGHT: float = 140.0
const CARD_OVERLAP: float = 70.0
const FAN_ANGLE: float = 2.0 # Degrees per card from center
const HOVER_LIFT: float = 25.0
# Current hover state
var hovered_card_index: int = -1
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
# Connect to resized signal to re-layout when size changes
resized.connect(_on_resized)
func _on_resized() -> void:
_layout_cards()
## Update hand display with cards
func update_hand(cards: Array) -> void:
# Clear existing
for card_ui in hand_cards:
card_ui.queue_free()
hand_cards.clear()
card_instances.clear()
# Store card instances
for card in cards:
if card is CardInstance:
card_instances.append(card)
# Create card UI elements
_create_card_visuals()
# Defer layout to ensure size is set
call_deferred("_layout_cards")
func _create_card_visuals() -> void:
for i in range(card_instances.size()):
var card = card_instances[i]
var card_ui = _create_card_ui(card, i)
add_child(card_ui)
hand_cards.append(card_ui)
func _create_card_ui(card: CardInstance, index: int) -> Control:
# Use Panel for better input handling
var container = Panel.new()
container.custom_minimum_size = Vector2(CARD_WIDTH, CARD_HEIGHT)
container.size = Vector2(CARD_WIDTH, CARD_HEIGHT)
container.mouse_filter = Control.MOUSE_FILTER_STOP
# Style the panel with element color
var style = StyleBoxFlat.new()
style.bg_color = _get_element_color(card.card_data.get_primary_element())
style.border_color = Color.BLACK
style.set_border_width_all(2)
container.add_theme_stylebox_override("panel", style)
# Card background (keep for highlighting)
var bg = ColorRect.new()
bg.size = Vector2(CARD_WIDTH, CARD_HEIGHT)
bg.color = _get_element_color(card.card_data.get_primary_element())
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE # Let parent handle input
container.add_child(bg)
# Card name
var name_label = Label.new()
name_label.text = card.card_data.name
name_label.position = Vector2(5, 5)
name_label.size = Vector2(CARD_WIDTH - 10, 50)
name_label.add_theme_font_size_override("font_size", 11)
name_label.add_theme_color_override("font_color", Color.BLACK)
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(name_label)
# Cost indicator (top right)
var cost_bg = ColorRect.new()
cost_bg.size = Vector2(28, 28)
cost_bg.position = Vector2(CARD_WIDTH - 32, 4)
cost_bg.color = Color(0.2, 0.2, 0.2)
cost_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(cost_bg)
var cost_label = Label.new()
cost_label.text = str(card.card_data.cost)
cost_label.position = Vector2(CARD_WIDTH - 32, 4)
cost_label.size = Vector2(28, 28)
cost_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
cost_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
cost_label.add_theme_font_size_override("font_size", 16)
cost_label.add_theme_color_override("font_color", Color.WHITE)
cost_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(cost_label)
# Type indicator (bottom left)
var type_label = Label.new()
type_label.text = Enums.card_type_to_string(card.card_data.type)
type_label.position = Vector2(5, CARD_HEIGHT - 22)
type_label.add_theme_font_size_override("font_size", 10)
type_label.add_theme_color_override("font_color", Color.BLACK)
type_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(type_label)
# Power for forwards (bottom right)
if card.card_data.type == Enums.CardType.FORWARD:
var power_bg = ColorRect.new()
power_bg.size = Vector2(45, 20)
power_bg.position = Vector2(CARD_WIDTH - 50, CARD_HEIGHT - 24)
power_bg.color = Color(0.3, 0.1, 0.1, 0.8)
power_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(power_bg)
var power_label = Label.new()
power_label.text = str(card.card_data.power)
power_label.position = Vector2(CARD_WIDTH - 50, CARD_HEIGHT - 24)
power_label.size = Vector2(45, 20)
power_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
power_label.add_theme_font_size_override("font_size", 12)
power_label.add_theme_color_override("font_color", Color.WHITE)
power_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(power_label)
# Connect signals
container.gui_input.connect(_on_card_gui_input.bind(index))
container.mouse_entered.connect(_on_card_mouse_entered.bind(index))
container.mouse_exited.connect(_on_card_mouse_exited.bind(index))
return container
func _get_element_color(element: Enums.Element) -> Color:
var color = Enums.get_element_color(element)
# Lighten for card background
return color.lightened(0.4)
func _layout_cards() -> void:
var card_count = hand_cards.size()
if card_count == 0:
return
# Get actual display width - use size if available, otherwise viewport
var display_width = size.x
if display_width <= 0:
var viewport = get_viewport()
if viewport:
display_width = viewport.get_visible_rect().size.x - 100 # Leave margins
if display_width <= 0:
display_width = 1820 # Fallback
# Calculate total width needed
var total_width = CARD_WIDTH + (card_count - 1) * CARD_OVERLAP
var start_x = (display_width - total_width) / 2.0
# Position each card
for i in range(card_count):
var card_ui = hand_cards[i]
# X position with overlap
var x = start_x + i * CARD_OVERLAP
# Y position (near top of container, with hover lift)
var y = 10.0
if i == hovered_card_index:
y -= HOVER_LIFT
# Rotation based on position in fan
var center_offset = i - (card_count - 1) / 2.0
var angle = center_offset * FAN_ANGLE
card_ui.position = Vector2(x, y)
card_ui.rotation = deg_to_rad(angle)
card_ui.z_index = i
if i == hovered_card_index:
card_ui.z_index = 100
func _on_card_gui_input(event: InputEvent, index: int) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
print("HandDisplay: Card clicked, index=", index)
if index >= 0 and index < card_instances.size():
print("HandDisplay: Emitting card_selected for ", card_instances[index].card_data.name)
card_selected.emit(card_instances[index])
func _on_card_mouse_entered(index: int) -> void:
hovered_card_index = index
_layout_cards()
if index >= 0 and index < card_instances.size():
card_hovered.emit(card_instances[index])
func _on_card_mouse_exited(index: int) -> void:
if hovered_card_index == index:
hovered_card_index = -1
_layout_cards()
card_unhovered.emit()
## Highlight playable cards
func highlight_playable(predicate: Callable) -> void:
for i in range(hand_cards.size()):
var card_ui = hand_cards[i]
var card = card_instances[i]
var bg = card_ui.get_child(0) as ColorRect
if bg:
if predicate.call(card):
# Add glow effect - brighter
bg.color = _get_element_color(card.card_data.get_primary_element()).lightened(0.2)
else:
# Dim non-playable
bg.color = _get_element_color(card.card_data.get_primary_element()).darkened(0.4)
## Clear all highlights
func clear_highlights() -> void:
for i in range(hand_cards.size()):
var card_ui = hand_cards[i]
var card = card_instances[i]
var bg = card_ui.get_child(0) as ColorRect
if bg:
bg.color = _get_element_color(card.card_data.get_primary_element())
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
_layout_cards()

94
scripts/ui/MainMenu.gd Normal file
View File

@@ -0,0 +1,94 @@
class_name MainMenu
extends CanvasLayer
## MainMenu - Main menu screen
signal start_game
signal quit_game
# UI Components
var title_label: Label
var start_button: Button
var options_button: Button
var quit_button: Button
var version_label: Label
func _ready() -> void:
_create_menu()
func _create_menu() -> void:
# Background
var bg = ColorRect.new()
add_child(bg)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.color = Color(0.08, 0.08, 0.12)
# Center container
var center = CenterContainer.new()
add_child(center)
center.set_anchors_preset(Control.PRESET_FULL_RECT)
var vbox = VBoxContainer.new()
center.add_child(vbox)
vbox.add_theme_constant_override("separation", 20)
# Title
title_label = Label.new()
title_label.text = "FF-TCG Digital"
title_label.add_theme_font_size_override("font_size", 48)
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(title_label)
# Subtitle
var subtitle = Label.new()
subtitle.text = "Final Fantasy Trading Card Game"
subtitle.add_theme_font_size_override("font_size", 18)
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
subtitle.add_theme_color_override("font_color", Color(0.6, 0.6, 0.7))
vbox.add_child(subtitle)
# Spacer
var spacer = Control.new()
spacer.custom_minimum_size = Vector2(0, 40)
vbox.add_child(spacer)
# Start button
start_button = _create_menu_button("Start Game")
vbox.add_child(start_button)
start_button.pressed.connect(_on_start_pressed)
# Options button (placeholder)
options_button = _create_menu_button("Options")
options_button.disabled = true
vbox.add_child(options_button)
# Quit button
quit_button = _create_menu_button("Quit")
vbox.add_child(quit_button)
quit_button.pressed.connect(_on_quit_pressed)
# Version label at bottom
version_label = Label.new()
version_label.text = "v0.1.0 - Development Build"
version_label.add_theme_font_size_override("font_size", 12)
version_label.add_theme_color_override("font_color", Color(0.4, 0.4, 0.5))
add_child(version_label)
version_label.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
version_label.offset_left = -200
version_label.offset_top = -30
version_label.offset_right = -10
version_label.offset_bottom = -10
func _create_menu_button(text: String) -> Button:
var button = Button.new()
button.text = text
button.custom_minimum_size = Vector2(200, 50)
button.add_theme_font_size_override("font_size", 20)
return button
func _on_start_pressed() -> void:
start_game.emit()
func _on_quit_pressed() -> void:
quit_game.emit()
get_tree().quit()

111
scripts/ui/PauseMenu.gd Normal file
View File

@@ -0,0 +1,111 @@
class_name PauseMenu
extends CanvasLayer
## PauseMenu - In-game pause/escape menu
signal resume_game
signal restart_game
signal return_to_menu
# UI Components
var panel: PanelContainer
var resume_button: Button
var restart_button: Button
var menu_button: Button
var menu_is_visible: bool = false
func _ready() -> void:
_create_menu()
hide_menu()
func _create_menu() -> void:
# Semi-transparent background
var bg = ColorRect.new()
add_child(bg)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.color = Color(0, 0, 0, 0.7)
bg.mouse_filter = Control.MOUSE_FILTER_STOP
# Center container
var center = CenterContainer.new()
add_child(center)
center.set_anchors_preset(Control.PRESET_FULL_RECT)
# Panel
panel = PanelContainer.new()
center.add_child(panel)
var style = StyleBoxFlat.new()
style.bg_color = Color(0.15, 0.15, 0.2)
style.border_color = Color(0.3, 0.3, 0.4)
style.set_border_width_all(2)
style.set_corner_radius_all(10)
style.set_content_margin_all(30)
panel.add_theme_stylebox_override("panel", style)
var vbox = VBoxContainer.new()
panel.add_child(vbox)
vbox.add_theme_constant_override("separation", 15)
# Title
var title = Label.new()
title.text = "Game Paused"
title.add_theme_font_size_override("font_size", 28)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(title)
# Spacer
var spacer = Control.new()
spacer.custom_minimum_size = Vector2(0, 10)
vbox.add_child(spacer)
# Resume button
resume_button = _create_menu_button("Resume")
vbox.add_child(resume_button)
resume_button.pressed.connect(_on_resume_pressed)
# Restart button
restart_button = _create_menu_button("Restart Game")
vbox.add_child(restart_button)
restart_button.pressed.connect(_on_restart_pressed)
# Return to menu button
menu_button = _create_menu_button("Main Menu")
vbox.add_child(menu_button)
menu_button.pressed.connect(_on_menu_pressed)
func _create_menu_button(text: String) -> Button:
var button = Button.new()
button.text = text
button.custom_minimum_size = Vector2(180, 45)
button.add_theme_font_size_override("font_size", 18)
return button
func show_menu() -> void:
visible = true
menu_is_visible = true
get_tree().paused = true
func hide_menu() -> void:
visible = false
menu_is_visible = false
get_tree().paused = false
func toggle_menu() -> void:
if menu_is_visible:
hide_menu()
else:
show_menu()
func _on_resume_pressed() -> void:
hide_menu()
resume_game.emit()
func _on_restart_pressed() -> void:
hide_menu()
restart_game.emit()
func _on_menu_pressed() -> void:
hide_menu()
return_to_menu.emit()

View File

@@ -0,0 +1,193 @@
class_name CardVisual
extends Node3D
## CardVisual - 3D visual representation of a card
signal clicked(card_visual: CardVisual)
signal hovered(card_visual: CardVisual)
signal unhovered(card_visual: CardVisual)
# Card dimensions (standard card ratio ~2.5:3.5)
const CARD_WIDTH: float = 0.63
const CARD_HEIGHT: float = 0.88
const CARD_THICKNESS: float = 0.02
# Associated card instance
var card_instance: CardInstance = null
# Visual state
var is_highlighted: bool = false
var is_selected: bool = false
var is_hovered: bool = false
# Animation
var target_position: Vector3 = Vector3.ZERO
var target_rotation: Vector3 = Vector3.ZERO
var move_speed: float = 10.0
var is_animating: bool = false
# Components
var mesh_instance: MeshInstance3D
var collision_shape: CollisionShape3D
var static_body: StaticBody3D
var material: StandardMaterial3D
# Colors
var normal_color: Color = Color.WHITE
var highlight_color: Color = Color(1.2, 1.2, 0.8)
var selected_color: Color = Color(0.8, 1.2, 0.8)
var dull_tint: Color = Color(0.7, 0.7, 0.7)
func _ready() -> void:
_create_card_mesh()
_setup_collision()
func _create_card_mesh() -> void:
mesh_instance = MeshInstance3D.new()
add_child(mesh_instance)
# Create box mesh for card
var box = BoxMesh.new()
box.size = Vector3(CARD_WIDTH, CARD_THICKNESS, CARD_HEIGHT)
mesh_instance.mesh = box
# Create material
material = StandardMaterial3D.new()
material.albedo_color = Color.WHITE
mesh_instance.material_override = material
func _setup_collision() -> void:
static_body = StaticBody3D.new()
add_child(static_body)
collision_shape = CollisionShape3D.new()
var shape = BoxShape3D.new()
shape.size = Vector3(CARD_WIDTH, CARD_THICKNESS * 2, CARD_HEIGHT)
collision_shape.shape = shape
static_body.add_child(collision_shape)
# Connect input
static_body.input_event.connect(_on_input_event)
static_body.mouse_entered.connect(_on_mouse_entered)
static_body.mouse_exited.connect(_on_mouse_exited)
func _process(delta: float) -> void:
# Animate position
if is_animating:
position = position.lerp(target_position, move_speed * delta)
rotation = rotation.lerp(target_rotation, move_speed * delta)
if position.distance_to(target_position) < 0.01 and rotation.distance_to(target_rotation) < 0.01:
position = target_position
rotation = target_rotation
is_animating = false
# Update dull visual
_update_visual_state()
## Initialize with a card instance
func setup(card: CardInstance) -> void:
card_instance = card
_load_card_texture()
_update_visual_state()
## Load the card's texture
func _load_card_texture() -> void:
if not card_instance or not card_instance.card_data:
return
var texture = CardDatabase.get_card_texture(card_instance.card_data)
if texture:
material.albedo_texture = texture
else:
# Use element color as fallback
var element_color = Enums.element_to_color(card_instance.get_element())
material.albedo_color = element_color
## Update visual state based on card state
func _update_visual_state() -> void:
if not material:
return
var color = normal_color
if is_selected:
color = selected_color
elif is_highlighted:
color = highlight_color
# Apply dull tint
if card_instance and card_instance.is_dull():
color = color * dull_tint
material.albedo_color = color
## Set card as highlighted
func set_highlighted(highlighted: bool) -> void:
is_highlighted = highlighted
_update_visual_state()
## Set card as selected
func set_selected(selected: bool) -> void:
is_selected = selected
_update_visual_state()
## Move card to position with animation
func move_to(pos: Vector3, rot: Vector3 = Vector3.ZERO) -> void:
target_position = pos
target_rotation = rot
is_animating = true
## Move card instantly
func set_position_instant(pos: Vector3, rot: Vector3 = Vector3.ZERO) -> void:
position = pos
rotation = rot
target_position = pos
target_rotation = rot
is_animating = false
## Set dull rotation (90 degrees)
func set_dull_visual(is_dull: bool) -> void:
if is_dull:
target_rotation.y = deg_to_rad(90)
else:
target_rotation.y = 0
is_animating = true
## Input handlers
func _on_input_event(_camera: Node, event: InputEvent, _position: Vector3, _normal: Vector3, _shape_idx: int) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
clicked.emit(self)
func _on_mouse_entered() -> void:
is_hovered = true
hovered.emit(self)
# Slight raise on hover
if not is_animating:
target_position.y = position.y + 0.1
is_animating = true
func _on_mouse_exited() -> void:
is_hovered = false
unhovered.emit(self)
# Return to normal height
if not is_animating and card_instance:
target_position.y = position.y - 0.1
is_animating = true
## Get display info for UI
func get_card_info() -> Dictionary:
if not card_instance or not card_instance.card_data:
return {}
var data = card_instance.card_data
return {
"name": data.name,
"type": Enums.card_type_to_string(data.type),
"element": Enums.element_to_string(data.get_primary_element()),
"cost": data.cost,
"power": data.power if data.type == Enums.CardType.FORWARD else 0,
"job": data.job,
"is_dull": card_instance.is_dull()
}

View File

@@ -0,0 +1,53 @@
class_name TableCamera
extends Camera3D
## TableCamera - Isometric camera for the game table
# Camera settings
@export var camera_distance: float = 18.0
@export var camera_angle: float = 55.0 # Degrees from horizontal (higher = more top-down)
@export var camera_height_offset: float = 0.0 # Offset to look slightly above center
# Smooth movement
@export var smooth_speed: float = 5.0
var target_position: Vector3 = Vector3.ZERO
func _ready() -> void:
_setup_isometric_view()
func _setup_isometric_view() -> void:
# Set perspective projection
projection = PROJECTION_PERSPECTIVE
fov = 50.0
# Calculate position - camera is positioned behind Player 1's side
var angle_rad = deg_to_rad(camera_angle)
# Height based on angle
var height = camera_distance * sin(angle_rad)
# Distance along Z axis (positive Z is toward Player 1)
var z_offset = camera_distance * cos(angle_rad)
# Position camera behind Player 1 (positive Z), looking toward center/opponent
position = Vector3(0, height, z_offset)
# Look at center of table
look_at(Vector3(0, camera_height_offset, 0), Vector3.UP)
target_position = position
func _process(delta: float) -> void:
# Smooth camera movement if needed
if position.distance_to(target_position) > 0.01:
position = position.lerp(target_position, smooth_speed * delta)
look_at(Vector3(0, camera_height_offset, 0), Vector3.UP)
## Set camera to look at a specific point
func focus_on(point: Vector3) -> void:
var angle_rad = deg_to_rad(camera_angle)
target_position = point + Vector3(0, camera_distance * sin(angle_rad),
camera_distance * cos(angle_rad))
## Reset to default position
func reset_position() -> void:
_setup_isometric_view()

View File

@@ -0,0 +1,258 @@
class_name TableSetup
extends Node3D
## TableSetup - Sets up the 3D game table with all zones
signal card_clicked(card_instance: CardInstance, zone_type: Enums.ZoneType, player_index: int)
# Zone visuals for each player
var player_zones: Array[Dictionary] = [{}, {}]
# Deck indicators (simple card-back representation)
var deck_indicators: Array[MeshInstance3D] = [null, null]
var deck_count_labels: Array[Label3D] = [null, null]
# Table dimensions
const TABLE_WIDTH: float = 16.0
const TABLE_DEPTH: float = 12.0
# Zone positions (relative to table center)
const ZONE_POSITIONS = {
"deck": Vector3(5.5, 0.1, 3.5),
"damage": Vector3(3.5, 0.1, 3.5),
"backups": Vector3(0.0, 0.1, 3.5),
"break": Vector3(-3.5, 0.1, 3.5),
"forwards": Vector3(0.0, 0.1, 1.5),
"hand": Vector3(0.0, 0.5, 5.5)
}
# Components
var table_mesh: MeshInstance3D
var camera: TableCamera
func _ready() -> void:
_create_table()
_create_camera()
_create_lighting()
_create_zones()
_create_deck_indicators()
func _create_table() -> void:
table_mesh = MeshInstance3D.new()
add_child(table_mesh)
var plane = PlaneMesh.new()
plane.size = Vector2(TABLE_WIDTH, TABLE_DEPTH)
table_mesh.mesh = plane
# Create table material
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.15, 0.2, 0.15) # Dark green felt
# Try to load playmat texture
if ResourceLoader.exists("res://assets/table/playmat.webp"):
var texture = load("res://assets/table/playmat.webp")
if texture:
mat.albedo_texture = texture
table_mesh.material_override = mat
# PlaneMesh in Godot 4 is already horizontal (facing up), no rotation needed
func _create_camera() -> void:
camera = TableCamera.new()
add_child(camera)
camera.current = true
func _create_lighting() -> void:
# Main directional light
var dir_light = DirectionalLight3D.new()
add_child(dir_light)
dir_light.position = Vector3(5, 10, 5)
dir_light.rotation = Vector3(deg_to_rad(-45), deg_to_rad(45), 0)
dir_light.light_energy = 0.8
dir_light.shadow_enabled = true
# Ambient light
var env = WorldEnvironment.new()
add_child(env)
var environment = Environment.new()
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.ambient_light_color = Color(0.3, 0.3, 0.35)
environment.ambient_light_energy = 0.5
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color(0.1, 0.1, 0.15)
env.environment = environment
func _create_zones() -> void:
# Create zones for both players
for player_idx in range(2):
var flip = 1 if player_idx == 0 else -1
var rot = 0 if player_idx == 0 else 180
# Field - Forwards
var forwards_zone = _create_zone(
Enums.ZoneType.FIELD_FORWARDS, player_idx,
ZONE_POSITIONS["forwards"] * Vector3(1, 1, flip), rot
)
player_zones[player_idx]["forwards"] = forwards_zone
# Field - Backups
var backups_zone = _create_zone(
Enums.ZoneType.FIELD_BACKUPS, player_idx,
ZONE_POSITIONS["backups"] * Vector3(1, 1, flip), rot
)
player_zones[player_idx]["backups"] = backups_zone
# Damage Zone
var damage_zone = _create_zone(
Enums.ZoneType.DAMAGE, player_idx,
ZONE_POSITIONS["damage"] * Vector3(flip, 1, flip), rot
)
player_zones[player_idx]["damage"] = damage_zone
# Break Zone
var break_zone = _create_zone(
Enums.ZoneType.BREAK, player_idx,
ZONE_POSITIONS["break"] * Vector3(flip, 1, flip), rot
)
player_zones[player_idx]["break"] = break_zone
func _create_deck_indicators() -> void:
# Create simple card-back boxes for deck representation
for player_idx in range(2):
var flip = 1 if player_idx == 0 else -1
var pos = ZONE_POSITIONS["deck"] * Vector3(flip, 1, flip)
# Card back mesh (simple colored box)
var deck_mesh = MeshInstance3D.new()
add_child(deck_mesh)
var box = BoxMesh.new()
box.size = Vector3(0.63, 0.3, 0.88) # Card size with thickness for deck
deck_mesh.mesh = box
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.15, 0.1, 0.3) # Dark blue for card back
deck_mesh.material_override = mat
deck_mesh.position = pos + Vector3(0, 0.15, 0) # Raise above table
deck_indicators[player_idx] = deck_mesh
# Card count label
var label = Label3D.new()
add_child(label)
label.text = "50"
label.font_size = 64
label.position = pos + Vector3(0, 0.35, 0)
label.rotation.x = deg_to_rad(-90) # Face up
if player_idx == 1:
label.rotation.z = deg_to_rad(180) # Flip for opponent
deck_count_labels[player_idx] = label
func _create_zone(zone_type: Enums.ZoneType, player_idx: int, pos: Vector3, rot: float) -> ZoneVisual:
var zone = ZoneVisual.new()
add_child(zone)
zone.zone_type = zone_type
zone.player_index = player_idx
zone.zone_position = pos
zone.zone_rotation = rot
# Configure based on zone type
match zone_type:
Enums.ZoneType.FIELD_FORWARDS, Enums.ZoneType.FIELD_BACKUPS:
zone.card_spacing = 0.8
Enums.ZoneType.DAMAGE, Enums.ZoneType.BREAK:
zone.stack_offset = 0.02
zone.card_clicked.connect(_on_zone_card_clicked.bind(zone_type, player_idx))
return zone
## Get a zone visual
func get_zone(player_idx: int, zone_name: String) -> ZoneVisual:
if player_idx >= 0 and player_idx < player_zones.size():
return player_zones[player_idx].get(zone_name)
return null
## Sync visual state with game state
func sync_with_game_state(game_state: GameState) -> void:
for player_idx in range(2):
var player = game_state.get_player(player_idx)
if not player:
continue
# Sync forwards
_sync_zone(player_zones[player_idx]["forwards"], player.field_forwards)
# Sync backups
_sync_zone(player_zones[player_idx]["backups"], player.field_backups)
# Update deck count (don't sync individual cards)
_update_deck_indicator(player_idx, player.deck.get_count())
# Sync damage
_sync_zone(player_zones[player_idx]["damage"], player.damage_zone)
# Sync break zone
_sync_zone(player_zones[player_idx]["break"], player.break_zone)
## Update deck indicator
func _update_deck_indicator(player_idx: int, count: int) -> void:
if deck_count_labels[player_idx]:
deck_count_labels[player_idx].text = str(count)
# Scale deck thickness based on count
if deck_indicators[player_idx]:
var thickness = max(0.05, count * 0.006) # Min thickness, scale with cards
var mesh = deck_indicators[player_idx].mesh as BoxMesh
if mesh:
mesh.size.y = thickness
deck_indicators[player_idx].position.y = 0.1 + thickness / 2
func _sync_zone(visual_zone: ZoneVisual, game_zone: Zone) -> void:
if not visual_zone or not game_zone:
return
# Get current visual cards
var visual_instances = {}
for cv in visual_zone.card_visuals:
if cv.card_instance:
visual_instances[cv.card_instance.instance_id] = cv
# Get game zone cards
var game_cards = game_zone.get_cards()
# Add missing cards
for card in game_cards:
if not visual_instances.has(card.instance_id):
visual_zone.add_card(card)
# Remove cards no longer in zone
var game_ids = {}
for card in game_cards:
game_ids[card.instance_id] = true
for instance_id in visual_instances:
if not game_ids.has(instance_id):
visual_zone.remove_card(visual_instances[instance_id])
## Card click handler
func _on_zone_card_clicked(card_visual: CardVisual, zone_type: Enums.ZoneType, player_idx: int) -> void:
if card_visual.card_instance:
card_clicked.emit(card_visual.card_instance, zone_type, player_idx)
## Highlight cards in a zone based on a condition
func highlight_zone_cards(player_idx: int, zone_name: String, predicate: Callable) -> void:
var zone = get_zone(player_idx, zone_name)
if zone:
zone.highlight_interactive(predicate)
## Clear all highlights
func clear_all_highlights() -> void:
for player_idx in range(2):
for zone_name in player_zones[player_idx]:
var zone = player_zones[player_idx][zone_name]
if zone:
zone.clear_highlights()

View File

@@ -0,0 +1,184 @@
class_name ZoneVisual
extends Node3D
## ZoneVisual - Visual representation of a card zone
signal card_clicked(card_visual: CardVisual)
# Zone configuration
@export var zone_type: Enums.ZoneType = Enums.ZoneType.HAND
@export var player_index: int = 0
# Layout settings
@export var card_spacing: float = 0.7
@export var stack_offset: float = 0.02 # Vertical offset for stacked cards
@export var max_visible_cards: int = 10
@export var fan_angle: float = 5.0 # Degrees for hand fan
# Position settings
@export var zone_position: Vector3 = Vector3.ZERO
@export var zone_rotation: float = 0.0 # Y rotation in degrees
# Card visuals in this zone
var card_visuals: Array[CardVisual] = []
# Zone indicator (optional visual for empty zones)
var zone_indicator: MeshInstance3D = null
func _ready() -> void:
position = zone_position
rotation.y = deg_to_rad(zone_rotation)
_create_zone_indicator()
func _create_zone_indicator() -> void:
zone_indicator = MeshInstance3D.new()
add_child(zone_indicator)
var plane = PlaneMesh.new()
plane.size = Vector2(CardVisual.CARD_WIDTH * 1.1, CardVisual.CARD_HEIGHT * 1.1)
zone_indicator.mesh = plane
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.2, 0.2, 0.3, 0.3)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
zone_indicator.material_override = mat
zone_indicator.rotation.x = deg_to_rad(-90) # Lay flat
zone_indicator.visible = true
## Add a card to this zone
func add_card(card_instance: CardInstance) -> CardVisual:
var card_visual = CardVisual.new()
add_child(card_visual)
card_visual.setup(card_instance)
# Connect signals
card_visual.clicked.connect(_on_card_clicked)
card_visuals.append(card_visual)
_arrange_cards()
return card_visual
## Remove a card from this zone
func remove_card(card_visual: CardVisual) -> void:
var index = card_visuals.find(card_visual)
if index >= 0:
card_visuals.remove_at(index)
card_visual.queue_free()
_arrange_cards()
## Remove card by instance
func remove_card_instance(card_instance: CardInstance) -> CardVisual:
for card_visual in card_visuals:
if card_visual.card_instance == card_instance:
remove_card(card_visual)
return card_visual
return null
## Find card visual by instance
func find_card_visual(card_instance: CardInstance) -> CardVisual:
for card_visual in card_visuals:
if card_visual.card_instance == card_instance:
return card_visual
return null
## Arrange cards based on zone type
func _arrange_cards() -> void:
match zone_type:
Enums.ZoneType.HAND:
_arrange_hand()
Enums.ZoneType.DECK, Enums.ZoneType.DAMAGE, Enums.ZoneType.BREAK:
_arrange_stack()
Enums.ZoneType.FIELD_FORWARDS, Enums.ZoneType.FIELD_BACKUPS:
_arrange_field()
_:
_arrange_row()
# Update zone indicator visibility
zone_indicator.visible = card_visuals.size() == 0
## Arrange as a fan (for hand)
func _arrange_hand() -> void:
var count = card_visuals.size()
if count == 0:
return
var total_width = (count - 1) * card_spacing
var start_x = -total_width / 2
for i in range(count):
var card = card_visuals[i]
var x = start_x + i * card_spacing
var angle = (i - (count - 1) / 2.0) * fan_angle
card.move_to(
Vector3(x, i * 0.01, 0), # Slight y offset for overlap
Vector3(0, 0, deg_to_rad(-angle))
)
## Arrange as a stack (for deck, damage, break zone)
func _arrange_stack() -> void:
for i in range(card_visuals.size()):
var card = card_visuals[i]
card.move_to(Vector3(0, i * stack_offset, 0))
## Arrange in a row (for field zones)
func _arrange_field() -> void:
var count = card_visuals.size()
if count == 0:
return
var total_width = (count - 1) * card_spacing
var start_x = -total_width / 2
for i in range(count):
var card = card_visuals[i]
var x = start_x + i * card_spacing
# Apply dull rotation if card is dull
var rot_y = 0.0
if card.card_instance and card.card_instance.is_dull():
rot_y = deg_to_rad(90)
card.move_to(Vector3(x, 0, 0), Vector3(0, rot_y, 0))
## Arrange in a simple row
func _arrange_row() -> void:
var count = card_visuals.size()
if count == 0:
return
var total_width = (count - 1) * card_spacing
var start_x = -total_width / 2
for i in range(count):
var card = card_visuals[i]
card.move_to(Vector3(start_x + i * card_spacing, 0, 0))
## Clear all cards
func clear() -> void:
for card_visual in card_visuals:
card_visual.queue_free()
card_visuals.clear()
zone_indicator.visible = true
## Get card count
func get_card_count() -> int:
return card_visuals.size()
## Highlight all cards that can be interacted with
func highlight_interactive(predicate: Callable) -> void:
for card_visual in card_visuals:
var can_interact = predicate.call(card_visual.card_instance)
card_visual.set_highlighted(can_interact)
## Clear all highlights
func clear_highlights() -> void:
for card_visual in card_visuals:
card_visual.set_highlighted(false)
card_visual.set_selected(false)
## Card click handler
func _on_card_clicked(card_visual: CardVisual) -> void:
card_clicked.emit(card_visual)