Start encounter feature
This commit is contained in:
270
scripts/board_manager.gd
Normal file
270
scripts/board_manager.gd
Normal file
@@ -0,0 +1,270 @@
|
||||
## Manages the 3D game board state, tetromino placement, and grid operations.
|
||||
##
|
||||
## Provides 3D grid representation, tetromino placement, collision detection,
|
||||
## rotation logic, and real-time re positioning during combat.
|
||||
class_name BoardManager3D
|
||||
extends Node3D
|
||||
|
||||
# Signals
|
||||
signal tetromino_placed(tetromino: Tetromino)
|
||||
signal tetromino_moved(tetromino: Tetromino, old_position: Vector3, new_position: Vector3)
|
||||
signal tetromino_rotated(tetromino: Tetromino, rotation: Quaternion)
|
||||
signal placement_invalid(reason: String)
|
||||
|
||||
# Grid configuration
|
||||
@export var grid_size: Vector3i = Vector3i(8, 5, 8) # Width (X), Height (Y), Depth (Z)
|
||||
@export var cell_size: float = 1.0
|
||||
@export var placement_plane_y: float = 0.0 # Y-position where tetrominoes are placed
|
||||
|
||||
# Internal state
|
||||
var _grid: Dictionary = {} # Key: Vector3i, Value: GridCell3D
|
||||
var _tetrominoes: Array[Tetromino] = []
|
||||
var _occupied_cells: Dictionary = {} # Key: Vector3i, Value: Tetromino3D reference
|
||||
|
||||
# Ray casting for placement validation
|
||||
var _space_state: PhysicsDirectSpaceState3D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_space_state = get_world_3d().direct_space_state
|
||||
_initialize_grid()
|
||||
|
||||
|
||||
## Initializes the 3D grid structure with empty cells.
|
||||
func _initialize_grid() -> void:
|
||||
for x in range(grid_size.x):
|
||||
for y in range(grid_size.y):
|
||||
for z in range(grid_size.z):
|
||||
var cell_pos = Vector3i(x, y, z)
|
||||
var cell = GridCell.new()
|
||||
cell.position = cell_pos
|
||||
_grid[cell_pos] = cell
|
||||
|
||||
|
||||
## Converts world position to grid coordinates.
|
||||
func world_to_grid(world_pos: Vector3) -> Vector3i:
|
||||
var local_pos = world_pos - (Vector3(grid_size) * cell_size / 2.0)
|
||||
return Vector3i(
|
||||
int(local_pos.x / cell_size),
|
||||
int(local_pos.y / cell_size),
|
||||
int(local_pos.z / cell_size)
|
||||
)
|
||||
|
||||
|
||||
## Converts grid coordinates to world position.
|
||||
func grid_to_world(grid_pos: Vector3i) -> Vector3:
|
||||
var center_offset = Vector3(grid_size) * cell_size / 2.0
|
||||
return Vector3(grid_pos) * cell_size + center_offset
|
||||
|
||||
|
||||
## Checks if a grid position is within bounds.
|
||||
func is_within_bounds(grid_pos: Vector3i) -> bool:
|
||||
return (grid_pos.x >= 0 and grid_pos.x < grid_size.x and
|
||||
grid_pos.y >= 0 and grid_pos.y < grid_size.y and
|
||||
grid_pos.z >= 0 and grid_pos.z < grid_size.z)
|
||||
|
||||
|
||||
## Checks if a grid cell is empty and unoccupied.
|
||||
func is_cell_empty(grid_pos: Vector3i) -> bool:
|
||||
if not is_within_bounds(grid_pos):
|
||||
return false
|
||||
return grid_pos not in _occupied_cells
|
||||
|
||||
|
||||
## Attempts to place a tetromino at the given grid position with optional rotation.
|
||||
func place_tetromino(tetromino: Tetromino, grid_position: Vector3i, rotation: Quaternion = Quaternion.IDENTITY) -> bool:
|
||||
# Validate placement
|
||||
if not _can_place_tetromino(tetromino, grid_position, rotation):
|
||||
placement_invalid.emit("Invalid placement: overlapping or out of bounds")
|
||||
return false
|
||||
|
||||
# Update tetromino state
|
||||
tetromino.grid_position = grid_position
|
||||
tetromino.rotation_quat = rotation
|
||||
tetromino.world_position = grid_to_world(grid_position)
|
||||
|
||||
# Mark cells as occupied
|
||||
var cells = tetromino.get_grid_cells(grid_position)
|
||||
for cell_pos in cells:
|
||||
_occupied_cells[cell_pos] = tetromino
|
||||
if cell_pos in _grid:
|
||||
_grid[cell_pos].owner = tetromino
|
||||
|
||||
_tetrominoes.append(tetromino)
|
||||
tetromino_placed.emit(tetromino)
|
||||
return true
|
||||
|
||||
|
||||
## Moves a tetromino to a new position (used during combat).
|
||||
func move_tetromino(tetromino: Tetromino, new_grid_position: Vector3i) -> bool:
|
||||
if tetromino not in _tetrominoes:
|
||||
return false
|
||||
|
||||
# Clear old occupation
|
||||
var old_cells = tetromino.get_grid_cells(tetromino.grid_position)
|
||||
for cell_pos in old_cells:
|
||||
_occupied_cells.erase(cell_pos)
|
||||
if cell_pos in _grid:
|
||||
_grid[cell_pos].owner = null
|
||||
|
||||
# Validate new position
|
||||
if not _can_place_tetromino(tetromino, new_grid_position, tetromino.rotation_quat):
|
||||
# Restore old occupation
|
||||
for cell_pos in old_cells:
|
||||
_occupied_cells[cell_pos] = tetromino
|
||||
if cell_pos in _grid:
|
||||
_grid[cell_pos].owner = tetromino
|
||||
placement_invalid.emit("Cannot move to target position")
|
||||
return false
|
||||
|
||||
# Update position
|
||||
var old_position = tetromino.grid_position
|
||||
tetromino.grid_position = new_grid_position
|
||||
tetromino.world_position = grid_to_world(new_grid_position)
|
||||
|
||||
# Mark cells as occupied
|
||||
var new_cells = tetromino.get_grid_cells(new_grid_position)
|
||||
for cell_pos in new_cells:
|
||||
_occupied_cells[cell_pos] = tetromino
|
||||
if cell_pos in _grid:
|
||||
_grid[cell_pos].owner = tetromino
|
||||
|
||||
tetromino_moved.emit(tetromino, grid_to_world(old_position), tetromino.world_position)
|
||||
return true
|
||||
|
||||
|
||||
## Rotates a tetromino around the Y-axis (XZ plane rotation).
|
||||
func rotate_tetromino_y(tetromino: Tetromino, angle: float) -> bool:
|
||||
if tetromino not in _tetrominoes:
|
||||
return false
|
||||
|
||||
var new_rotation = tetromino.rotation_quat * Quaternion.from_euler(Vector3(0, angle, 0))
|
||||
|
||||
# Validate rotation doesn't cause overlap
|
||||
if not _can_place_tetromino(tetromino, tetromino.grid_position, new_rotation):
|
||||
placement_invalid.emit("Cannot rotate: would overlap or go out of bounds")
|
||||
return false
|
||||
|
||||
# Clear old occupation
|
||||
var old_cells = tetromino.get_grid_cells(tetromino.grid_position)
|
||||
for cell_pos in old_cells:
|
||||
_occupied_cells.erase(cell_pos)
|
||||
if cell_pos in _grid:
|
||||
_grid[cell_pos].owner = null
|
||||
|
||||
# Update rotation
|
||||
tetromino.rotation_quat = new_rotation
|
||||
|
||||
# Mark cells as occupied with new rotation
|
||||
var new_cells = tetromino.get_grid_cells(tetromino.grid_position)
|
||||
for cell_pos in new_cells:
|
||||
_occupied_cells[cell_pos] = tetromino
|
||||
if cell_pos in _grid:
|
||||
_grid[cell_pos].owner = tetromino
|
||||
|
||||
tetromino_rotated.emit(tetromino, new_rotation)
|
||||
return true
|
||||
|
||||
|
||||
## Removes a tetromino from the board (e.g., destroyed during combat).
|
||||
func remove_tetromino(tetromino: Tetromino) -> void:
|
||||
if tetromino not in _tetrominoes:
|
||||
return
|
||||
|
||||
# Clear occupied cells
|
||||
var cells = tetromino.get_grid_cells(tetromino.grid_position)
|
||||
for cell_pos in cells:
|
||||
_occupied_cells.erase(cell_pos)
|
||||
if cell_pos in _grid:
|
||||
_grid[cell_pos].owner = null
|
||||
|
||||
_tetrominoes.erase(tetromino)
|
||||
|
||||
|
||||
## Gets all placed tetrominoes.
|
||||
func get_tetrominoes() -> Array[Tetromino]:
|
||||
return _tetrominoes.duplicate()
|
||||
|
||||
|
||||
## Gets the tetromino at a specific grid position, if any.
|
||||
func get_tetromino_at(grid_pos: Vector3i) -> Tetromino:
|
||||
return _occupied_cells.get(grid_pos)
|
||||
|
||||
|
||||
## Gets all adjacent tetrominoes within synergy radius.
|
||||
func get_adjacent_tetrominoes(tetromino: Tetromino, synergy_radius: float = 2.5) -> Array[Tetromino]:
|
||||
var adjacent: Array[Tetromino] = []
|
||||
var world_pos = tetromino.world_position
|
||||
|
||||
for other in _tetrominoes:
|
||||
if other == tetromino:
|
||||
continue
|
||||
|
||||
var distance = world_pos.distance_to(other.world_position)
|
||||
if distance <= synergy_radius:
|
||||
adjacent.append(other)
|
||||
|
||||
return adjacent
|
||||
|
||||
|
||||
## Validates whether a tetromino can be placed at the given position with rotation.
|
||||
func _can_place_tetromino(tetromino: Tetromino, grid_position: Vector3i, rotation: Quaternion) -> bool:
|
||||
# Get the cells this tetromino would occupy
|
||||
var cells = tetromino.get_grid_cells_with_rotation(grid_position, rotation)
|
||||
|
||||
# Check all cells are within bounds and empty
|
||||
for cell_pos in cells:
|
||||
if not is_within_bounds(cell_pos):
|
||||
return false
|
||||
|
||||
# Check if occupied by another tetromino
|
||||
if cell_pos in _occupied_cells and _occupied_cells[cell_pos] != tetromino:
|
||||
return false
|
||||
|
||||
# Raycast check for physical obstacles
|
||||
if not _validate_placement_raycast(tetromino, grid_position, rotation):
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Performs raycast validation for tetromino placement.
|
||||
## Checks for physical obstacles using raycasting.
|
||||
func _validate_placement_raycast(tetromino: Tetromino, grid_position: Vector3i, rotation: Quaternion) -> bool:
|
||||
if not _space_state:
|
||||
return true
|
||||
|
||||
var world_pos = grid_to_world(grid_position)
|
||||
|
||||
# Cast a ray downward from above the placement position
|
||||
var query = PhysicsRayQueryParameters3D.create(
|
||||
world_pos + Vector3.UP * 10.0,
|
||||
world_pos
|
||||
)
|
||||
query.exclude = [tetromino]
|
||||
|
||||
var result = _space_state.intersect_ray(query)
|
||||
|
||||
# If ray hits something other than the grid plane, placement is invalid
|
||||
if result and result.get("collider") and result.collider != self:
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Gets a string representation of the grid for debugging.
|
||||
func get_grid_state() -> String:
|
||||
var state = "Grid State (X x Y x Z = %d x %d x %d):\n" % [grid_size.x, grid_size.y, grid_size.z]
|
||||
state += "Occupied cells: %d\n" % _occupied_cells.size()
|
||||
state += "Placed tetrominoes: %d\n" % _tetrominoes.size()
|
||||
|
||||
for tetromino in _tetrominoes:
|
||||
state += " - %s at %v\n" % [tetromino.shape_type, tetromino.grid_position]
|
||||
|
||||
return state
|
||||
|
||||
|
||||
## Clears the board (removes all tetrominoes).
|
||||
func clear_board() -> void:
|
||||
for tetromino in _tetrominoes.duplicate():
|
||||
remove_tetromino(tetromino)
|
||||
1
scripts/board_manager.gd.uid
Normal file
1
scripts/board_manager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bvxt8mmibr1uj
|
||||
465
scripts/encounter_manager.gd
Normal file
465
scripts/encounter_manager.gd
Normal file
@@ -0,0 +1,465 @@
|
||||
## Core game state machine and encounter loop for Tetra Tactics 3D.
|
||||
##
|
||||
## Manages the encounter flow: DRAFT → PLACEMENT → TELEGRAPH → COMBAT → RESOLUTION → ESCALATION.
|
||||
## Orchestrates turn transitions, wave progression, and win/loss conditions.
|
||||
class_name EncounterManager
|
||||
extends Node
|
||||
|
||||
## Game state enumeration
|
||||
enum State {
|
||||
DRAFT, ## Draft random tetrominoes for placement
|
||||
PLACEMENT, ## Player places/rotates shapes in 3D board
|
||||
TELEGRAPH, ## Show enemy spawn points and fall paths (pre-combat warning)
|
||||
COMBAT, ## Enemies fall from above; towers track and fire projectiles
|
||||
RESOLUTION, ## Damage calculation, collect rewards, wave outcome
|
||||
ESCALATION ## Difficulty increase, preview next wave
|
||||
}
|
||||
|
||||
## Signal emitted when state changes
|
||||
signal state_changed(new_state: State, old_state: State)
|
||||
|
||||
## Signal emitted when wave starts/progresses
|
||||
signal wave_started(wave_number: int)
|
||||
signal wave_completed(wave_number: int)
|
||||
signal wave_failed
|
||||
|
||||
## Signal emitted for player feedback
|
||||
signal gold_changed(amount: int)
|
||||
signal health_changed(amount: int)
|
||||
|
||||
# Game flow configuration
|
||||
@export var waves: Array[Resource] = [] ## Array of WaveConfig resources
|
||||
@export var starting_health: int = 100
|
||||
@export var starting_gold: int = 500
|
||||
|
||||
# State management
|
||||
var _current_state: State = State.DRAFT
|
||||
var _previous_state: State
|
||||
var _state_timer: float = 0.0
|
||||
|
||||
# Game progress
|
||||
var _current_wave: int = 0
|
||||
var _total_waves: int = 0
|
||||
var _current_health: int
|
||||
var _current_gold: int
|
||||
|
||||
# References to systems and managers
|
||||
var _event_bus: Node
|
||||
var _board_manager: Node ## BoardManager
|
||||
var _combat_system: Node ## CombatSystem
|
||||
var _synergy_system: Node ## SynergySystem
|
||||
var _enemy_spawner: Node ## EnemySpawner
|
||||
var _ui_manager: Node
|
||||
|
||||
# Draft phase data
|
||||
var _available_tetrominoes: Array[Resource] = [] ## TetrominoDefinition resources
|
||||
var _drafted_tetromino: Resource ## Currently selected tetromino
|
||||
|
||||
# Combat phase state
|
||||
var _combat_in_progress: bool = false
|
||||
var _enemies_remaining: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_total_waves = waves.size()
|
||||
_current_health = starting_health
|
||||
_current_gold = starting_gold
|
||||
|
||||
# Get references to autoload singletons and scene nodes
|
||||
_event_bus = get_tree().root.get_child(0).get_node_or_null("EventBus")
|
||||
if not _event_bus:
|
||||
_event_bus = get_node("/root/EventBus") if "/root/EventBus" in get_tree().root else null
|
||||
|
||||
# Find board manager in scene
|
||||
_board_manager = get_parent().get_node_or_null("Board")
|
||||
_combat_system = get_parent().get_node_or_null("CombatSystem")
|
||||
_synergy_system = get_parent().get_node_or_null("SynergySystem")
|
||||
_enemy_spawner = get_parent().get_node_or_null("EnemySpawner")
|
||||
_ui_manager = get_parent().get_node_or_null("HUD")
|
||||
|
||||
# Connect to signal events if event bus exists
|
||||
if _event_bus:
|
||||
if _event_bus.has_signal("enemy_died"):
|
||||
_event_bus.enemy_died.connect(_on_enemy_died)
|
||||
if _event_bus.has_signal("tower_damaged"):
|
||||
_event_bus.tower_damaged.connect(_on_tower_damaged)
|
||||
|
||||
# Start first wave's draft phase
|
||||
_current_wave = 0
|
||||
_transition_to_state(State.DRAFT)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_state_timer += delta
|
||||
|
||||
match _current_state:
|
||||
State.DRAFT:
|
||||
_update_draft(delta)
|
||||
State.PLACEMENT:
|
||||
_update_placement(delta)
|
||||
State.TELEGRAPH:
|
||||
_update_telegraph(delta)
|
||||
State.COMBAT:
|
||||
_update_combat(delta)
|
||||
State.RESOLUTION:
|
||||
_update_resolution(delta)
|
||||
State.ESCALATION:
|
||||
_update_escalation(delta)
|
||||
|
||||
|
||||
## Transition to a new state and emit signal
|
||||
func _transition_to_state(new_state: State) -> void:
|
||||
if new_state == _current_state:
|
||||
return
|
||||
|
||||
_previous_state = _current_state
|
||||
_current_state = new_state
|
||||
_state_timer = 0.0
|
||||
|
||||
state_changed.emit(new_state, _previous_state)
|
||||
|
||||
# Broadcast to event bus if available
|
||||
if _event_bus and _event_bus.has_signal("state_changed"):
|
||||
_event_bus.state_changed.emit(new_state, _previous_state)
|
||||
|
||||
# Execute state entry logic
|
||||
match new_state:
|
||||
State.DRAFT:
|
||||
_enter_draft()
|
||||
State.PLACEMENT:
|
||||
_enter_placement()
|
||||
State.TELEGRAPH:
|
||||
_enter_telegraph()
|
||||
State.COMBAT:
|
||||
_enter_combat()
|
||||
State.RESOLUTION:
|
||||
_enter_resolution()
|
||||
State.ESCALATION:
|
||||
_enter_escalation()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DRAFT PHASE: Present 3 random tetrominoes for player selection
|
||||
# ============================================================================
|
||||
|
||||
func _enter_draft() -> void:
|
||||
print("Entering DRAFT phase for wave %d" % (_current_wave + 1))
|
||||
|
||||
# Load tetromino definitions from resources
|
||||
_available_tetrominoes.clear()
|
||||
_load_tetromino_definitions()
|
||||
|
||||
# Shuffle and select 3 random tetrominoes for player choice
|
||||
_available_tetrominoes.shuffle()
|
||||
if _available_tetrominoes.size() > 3:
|
||||
_available_tetrominoes = _available_tetrominoes.slice(0, 3)
|
||||
|
||||
# Signal UI to show draft carousel
|
||||
if _ui_manager:
|
||||
_ui_manager.show_draft_carousel(_available_tetrominoes)
|
||||
|
||||
if _event_bus and _event_bus.has_signal("draft_started"):
|
||||
_event_bus.draft_started.emit(_available_tetrominoes)
|
||||
|
||||
|
||||
func _update_draft(delta: float) -> void:
|
||||
# Wait for player selection via input or UI callback
|
||||
# Once tetromino is selected, move to PLACEMENT phase
|
||||
pass
|
||||
|
||||
|
||||
## Called when player selects a tetromino from the draft carousel
|
||||
func select_tetromino(tetromino: Resource) -> void:
|
||||
if _current_state != State.DRAFT:
|
||||
return
|
||||
|
||||
_drafted_tetromino = tetromino
|
||||
print("Selected tetromino: %s" % tetromino.shape_type)
|
||||
|
||||
_transition_to_state(State.PLACEMENT)
|
||||
|
||||
|
||||
## Load tetromino definitions from resource files
|
||||
func _load_tetromino_definitions() -> void:
|
||||
# Load from resource directory (placeholder path)
|
||||
var tetromino_dir = "res://resources/data/tetrominoes/"
|
||||
var dir = DirAccess.open(tetromino_dir)
|
||||
|
||||
if dir:
|
||||
dir.list_dir_begin()
|
||||
var file_name = dir.get_next()
|
||||
|
||||
while file_name != "":
|
||||
if file_name.ends_with(".tres"):
|
||||
var resource_path = tetromino_dir + file_name
|
||||
var tetromino_def = load(resource_path)
|
||||
if tetromino_def is Resource:
|
||||
_available_tetrominoes.append(tetromino_def)
|
||||
file_name = dir.get_next()
|
||||
else:
|
||||
# Fallback: create sample tetrominoes if resources unavailable
|
||||
_create_sample_tetrominoes()
|
||||
|
||||
|
||||
## Create sample tetrominoes if resource files not found
|
||||
func _create_sample_tetrominoes() -> void:
|
||||
var shapes = ["I", "O", "T", "S", "Z", "L", "J"]
|
||||
for shape in shapes:
|
||||
var tetromino_def = TetrominoDefinition.new()
|
||||
tetromino_def.shape_type = StringName(shape)
|
||||
tetromino_def.base_damage = 10 + randi() % 10
|
||||
tetromino_def.fire_rate = 1.0 + randf_range(0.0, 0.5)
|
||||
tetromino_def.synergy_tags = ["fire", "ice", "light"].slice(0, randi() % 2 + 1)
|
||||
_available_tetrominoes.append(tetromino_def)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PLACEMENT PHASE: Player places/rotates tetrominoes in 3D board
|
||||
# ============================================================================
|
||||
|
||||
func _enter_placement() -> void:
|
||||
print("Entering PLACEMENT phase")
|
||||
|
||||
if _board_manager:
|
||||
_board_manager.clear_preview()
|
||||
_board_manager.set_placement_mode(true, _drafted_tetromino)
|
||||
|
||||
if _ui_manager:
|
||||
_ui_manager.show_placement_instructions()
|
||||
|
||||
if _event_bus and _event_bus.has_signal("placement_started"):
|
||||
_event_bus.placement_started.emit(_drafted_tetromino)
|
||||
|
||||
|
||||
func _update_placement(delta: float) -> void:
|
||||
# Wait for player confirmation (Space key)
|
||||
if Input.is_action_just_pressed("ui_select"):
|
||||
_on_placement_confirmed()
|
||||
|
||||
|
||||
## Called when player confirms tetromino placement
|
||||
func _on_placement_confirmed() -> void:
|
||||
if _current_state != State.PLACEMENT:
|
||||
return
|
||||
|
||||
if _board_manager and _board_manager.place_tetromino(_drafted_tetromino):
|
||||
print("Tetromino placed successfully")
|
||||
_transition_to_state(State.TELEGRAPH)
|
||||
else:
|
||||
print("Invalid placement - cannot place tetromino")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TELEGRAPH PHASE: Show enemy spawn points and fall paths
|
||||
# ============================================================================
|
||||
|
||||
func _enter_telegraph() -> void:
|
||||
print("Entering TELEGRAPH phase")
|
||||
|
||||
# Show visual indicators of where enemies will spawn and fall
|
||||
if _enemy_spawner:
|
||||
_enemy_spawner.telegraph_wave(waves[_current_wave])
|
||||
|
||||
if _ui_manager:
|
||||
_ui_manager.show_telegraph_warning()
|
||||
|
||||
if _event_bus and _event_bus.has_signal("telegraph_started"):
|
||||
_event_bus.telegraph_started.emit(waves[_current_wave])
|
||||
|
||||
|
||||
func _update_telegraph(delta: float) -> void:
|
||||
# Telegraph phase lasts 2-3 seconds, then transitions to combat
|
||||
if _state_timer >= 2.5:
|
||||
_transition_to_state(State.COMBAT)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COMBAT PHASE: Enemies fall; towers track and fire
|
||||
# ============================================================================
|
||||
|
||||
func _enter_combat() -> void:
|
||||
print("Entering COMBAT phase for wave %d" % (_current_wave + 1))
|
||||
|
||||
_combat_in_progress = true
|
||||
|
||||
# Spawn enemies for current wave
|
||||
var wave_config: Resource = waves[_current_wave]
|
||||
if _enemy_spawner:
|
||||
_enemies_remaining = _enemy_spawner.spawn_wave(wave_config)
|
||||
|
||||
# Enable tower combat systems
|
||||
if _combat_system:
|
||||
_combat_system.enable_combat(true)
|
||||
if _synergy_system:
|
||||
_synergy_system.recalculate_synergies()
|
||||
if _board_manager:
|
||||
_board_manager.set_placement_mode(false) # Disable placement, allow repositioning
|
||||
|
||||
wave_started.emit(_current_wave + 1)
|
||||
|
||||
if _event_bus and _event_bus.has_signal("combat_started"):
|
||||
_event_bus.combat_started.emit(_current_wave + 1)
|
||||
|
||||
|
||||
func _update_combat(delta: float) -> void:
|
||||
# Combat continues until all enemies are defeated or health reaches 0
|
||||
if _enemies_remaining <= 0:
|
||||
_transition_to_state(State.RESOLUTION)
|
||||
elif _current_health <= 0:
|
||||
wave_failed.emit()
|
||||
_transition_to_state(State.RESOLUTION)
|
||||
|
||||
|
||||
## Called when an enemy is defeated
|
||||
func _on_enemy_died() -> void:
|
||||
_enemies_remaining = max(0, _enemies_remaining - 1)
|
||||
|
||||
if _enemies_remaining == 0 and _current_state == State.COMBAT:
|
||||
_transition_to_state(State.RESOLUTION)
|
||||
|
||||
|
||||
## Called when a tower takes damage from enemy impact
|
||||
func _on_tower_damaged(amount: int) -> void:
|
||||
modify_health(-amount)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RESOLUTION PHASE: Damage calculation, collect rewards
|
||||
# ============================================================================
|
||||
|
||||
func _enter_resolution() -> void:
|
||||
print("Entering RESOLUTION phase")
|
||||
|
||||
_combat_in_progress = false
|
||||
|
||||
# Disable combat systems
|
||||
if _combat_system:
|
||||
_combat_system.enable_combat(false)
|
||||
if _board_manager:
|
||||
_board_manager.set_placement_mode(false)
|
||||
|
||||
# Calculate and award gold for wave completion
|
||||
if _enemies_remaining == 0:
|
||||
var gold_reward = 100 + (_current_wave * 50)
|
||||
modify_gold(gold_reward)
|
||||
wave_completed.emit(_current_wave + 1)
|
||||
#print("Wave %d completed! Earned %d gold" % (_current_wave + 1, gold_reward))
|
||||
else:
|
||||
print("Wave %d failed - enemies escaped" % (_current_wave + 1))
|
||||
|
||||
if _ui_manager:
|
||||
_ui_manager.show_resolution_summary(_enemies_remaining == 0)
|
||||
|
||||
|
||||
func _update_resolution(delta: float) -> void:
|
||||
# Resolution phase lasts 2 seconds, then transitions to escalation or game over
|
||||
if _state_timer >= 2.0:
|
||||
if _current_health <= 0:
|
||||
print("Game Over - Health depleted")
|
||||
_end_game(false)
|
||||
else:
|
||||
_transition_to_state(State.ESCALATION)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ESCALATION PHASE: Difficulty increase, preview next wave
|
||||
# ============================================================================
|
||||
|
||||
func _enter_escalation() -> void:
|
||||
print("Entering ESCALATION phase")
|
||||
|
||||
_current_wave += 1
|
||||
|
||||
if _current_wave >= _total_waves:
|
||||
print("All waves completed - Victory!")
|
||||
_end_game(true)
|
||||
return
|
||||
|
||||
# Prepare next wave
|
||||
if _ui_manager:
|
||||
_ui_manager.show_escalation_preview(_current_wave + 1, waves[_current_wave])
|
||||
|
||||
if _event_bus and _event_bus.has_signal("escalation_started"):
|
||||
_event_bus.escalation_started.emit(_current_wave + 1)
|
||||
|
||||
|
||||
func _update_escalation(delta: float) -> void:
|
||||
# Escalation phase lasts 2-3 seconds, then returns to draft
|
||||
if _state_timer >= 2.5:
|
||||
# Clear previous tetrominoes from board (optional: allow stacking)
|
||||
# For now, board persists across waves
|
||||
_transition_to_state(State.DRAFT)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GAME END & UTILITIES
|
||||
# ============================================================================
|
||||
|
||||
## Called when game ends (victory or defeat)
|
||||
func _end_game(victory: bool) -> void:
|
||||
if victory:
|
||||
print("VICTORY - Campaign completed!")
|
||||
if _event_bus and _event_bus.has_signal("game_won"):
|
||||
_event_bus.game_won.emit()
|
||||
else:
|
||||
print("DEFEAT - Health depleted")
|
||||
if _event_bus and _event_bus.has_signal("game_lost"):
|
||||
_event_bus.game_lost.emit()
|
||||
|
||||
if _ui_manager:
|
||||
_ui_manager.show_game_over(victory)
|
||||
|
||||
# Pause game or transition to menu
|
||||
get_tree().paused = true
|
||||
|
||||
|
||||
## Modify player health (positive to heal, negative to damage)
|
||||
func modify_health(amount: int) -> void:
|
||||
var old_health = _current_health
|
||||
_current_health = clampi(_current_health + amount, 0, starting_health)
|
||||
|
||||
if _current_health != old_health:
|
||||
health_changed.emit(_current_health)
|
||||
if _event_bus and _event_bus.has_signal("health_changed"):
|
||||
_event_bus.health_changed.emit(_current_health)
|
||||
|
||||
|
||||
## Modify player gold (positive to gain, negative to spend)
|
||||
func modify_gold(amount: int) -> void:
|
||||
var old_gold = _current_gold
|
||||
_current_gold = maxi(_current_gold + amount, 0)
|
||||
|
||||
if _current_gold != old_gold:
|
||||
gold_changed.emit(_current_gold)
|
||||
if _event_bus and _event_bus.has_signal("gold_changed"):
|
||||
_event_bus.gold_changed.emit(_current_gold)
|
||||
|
||||
|
||||
## Get current game state
|
||||
func get_current_state() -> State:
|
||||
return _current_state
|
||||
|
||||
|
||||
## Get current wave number (1-indexed)
|
||||
func get_current_wave() -> int:
|
||||
return _current_wave + 1
|
||||
|
||||
|
||||
## Get total wave count
|
||||
func get_total_waves() -> int:
|
||||
return _total_waves
|
||||
|
||||
|
||||
## Get current health
|
||||
func get_health() -> int:
|
||||
return _current_health
|
||||
|
||||
|
||||
## Get current gold
|
||||
func get_gold() -> int:
|
||||
return _current_gold
|
||||
|
||||
|
||||
## Check if combat is active
|
||||
func is_combat_active() -> bool:
|
||||
return _combat_in_progress
|
||||
1
scripts/encounter_manager.gd.uid
Normal file
1
scripts/encounter_manager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dv2k6b3b3tfa6
|
||||
95
scripts/event_bus.gd
Normal file
95
scripts/event_bus.gd
Normal file
@@ -0,0 +1,95 @@
|
||||
## Global event bus for signal-based communication across systems.
|
||||
##
|
||||
## Centralizes game events to decouple systems and simplify event handling.
|
||||
## Register as autoload singleton: Project Settings → Autoload → EventBus
|
||||
extends Node
|
||||
|
||||
# ============================================================================
|
||||
# Game State Events
|
||||
# ============================================================================
|
||||
|
||||
## Emitted when game state changes
|
||||
signal state_changed(new_state: int, old_state: int)
|
||||
|
||||
## Emitted when draft phase begins
|
||||
signal draft_started(available_tetrominoes: Array[Resource])
|
||||
|
||||
## Emitted when placement phase begins
|
||||
signal placement_started(tetromino: Resource)
|
||||
|
||||
## Emitted when telegraph phase begins
|
||||
signal telegraph_started(wave_config: Resource)
|
||||
|
||||
## Emitted when combat phase begins
|
||||
signal combat_started(wave_number: int)
|
||||
|
||||
## Emitted when escalation phase begins
|
||||
signal escalation_started(wave_number: int)
|
||||
|
||||
# ============================================================================
|
||||
# Wave & Enemy Events
|
||||
# ============================================================================
|
||||
|
||||
## Emitted when enemy spawns
|
||||
signal enemy_spawned(enemy: Node3D)
|
||||
|
||||
## Emitted when enemy is defeated
|
||||
signal enemy_died(enemy: Node3D)
|
||||
|
||||
## Emitted when all enemies in wave are defeated
|
||||
signal wave_completed(wave_number: int)
|
||||
|
||||
## Emitted when wave fails (enemies reach board/health depleted)
|
||||
signal wave_failed(wave_number: int)
|
||||
|
||||
# ============================================================================
|
||||
# Combat Events
|
||||
# ============================================================================
|
||||
|
||||
## Emitted when tower fires projectile
|
||||
signal tower_fired(tower: Node3D, target: Node3D)
|
||||
|
||||
## Emitted when projectile hits enemy
|
||||
signal projectile_hit(projectile: Node3D, enemy: Node3D, damage: int)
|
||||
|
||||
## Emitted when tower takes damage from enemy
|
||||
signal tower_damaged(tower: Node3D, damage: int)
|
||||
|
||||
## Emitted when synergy is activated
|
||||
signal synergy_activated(towers: Array[Node3D], bonus_type: String, bonus_value: float)
|
||||
|
||||
# ============================================================================
|
||||
# Player Resource Events
|
||||
# ============================================================================
|
||||
|
||||
## Emitted when player gold changes
|
||||
signal gold_changed(amount: int)
|
||||
|
||||
## Emitted when player health changes
|
||||
signal health_changed(amount: int)
|
||||
|
||||
## Emitted when tower is placed
|
||||
signal tower_placed(tower: Node3D, position: Vector3)
|
||||
|
||||
## Emitted when tower is moved/repositioned
|
||||
signal tower_moved(tower: Node3D, new_position: Vector3, old_position: Vector3)
|
||||
|
||||
# ============================================================================
|
||||
# Game End Events
|
||||
# ============================================================================
|
||||
|
||||
## Emitted when player wins the encounter
|
||||
signal game_won
|
||||
|
||||
## Emitted when player loses the encounter
|
||||
signal game_lost
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
# Ensure this is a singleton
|
||||
if get_tree().root.get_child_count() > 1:
|
||||
for i in range(get_tree().root.get_child_count()):
|
||||
var child = get_tree().root.get_child(i)
|
||||
if child is EventBus and child != self:
|
||||
queue_free()
|
||||
return
|
||||
1
scripts/event_bus.gd.uid
Normal file
1
scripts/event_bus.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cbus31k0tae6i
|
||||
39
scripts/grid_cell.gd
Normal file
39
scripts/grid_cell.gd
Normal file
@@ -0,0 +1,39 @@
|
||||
## Represents a single 3D grid cell on the game board.
|
||||
##
|
||||
## It's a data structure for occupancy state, owner tetromino, bounds, and
|
||||
## visual feedback.
|
||||
class_name GridCell
|
||||
extends Object
|
||||
|
||||
# Properties
|
||||
var position: Vector3i # Grid coordinates
|
||||
var owner: Tetromino = null # Tetromino occupying this cell
|
||||
var bounds: AABB = AABB() # 3D axis-aligned bounding box
|
||||
|
||||
# Visual state
|
||||
var is_highlighted: bool = false
|
||||
var is_valid_placement: bool = true
|
||||
|
||||
|
||||
## Checks if the cell is currently empty.
|
||||
func is_empty() -> bool:
|
||||
return owner == null
|
||||
|
||||
|
||||
## Marks the cell as occupied by a tetromino.
|
||||
func occupy(tetromino: Tetromino) -> void:
|
||||
owner = tetromino
|
||||
|
||||
|
||||
## Marks the cell as empty.
|
||||
func clear() -> void:
|
||||
owner = null
|
||||
|
||||
|
||||
## Returns a string representation of the cell state.
|
||||
func _to_string() -> String:
|
||||
return "GridCell3D[pos=%v, occupied=%s, valid=%s]" % [
|
||||
position,
|
||||
"yes" if owner else "no",
|
||||
"yes" if is_valid_placement else "no"
|
||||
]
|
||||
1
scripts/grid_cell.gd.uid
Normal file
1
scripts/grid_cell.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dygdtu1vjr1rt
|
||||
141
scripts/tetromino.gd
Normal file
141
scripts/tetromino.gd
Normal file
@@ -0,0 +1,141 @@
|
||||
## Represents a 3D tetromino (tower block) with shape, position, and rotation.
|
||||
##
|
||||
## Manages 3D grid cell data, mesh instances, position/rotation state,
|
||||
## synergy detection, and material effects.
|
||||
class_name Tetromino
|
||||
extends Node3D
|
||||
|
||||
# Data
|
||||
@export var resource: TetrominoDefinition
|
||||
|
||||
# Visual
|
||||
@export var mesh_color: Color = Color.WHITE
|
||||
var _material: StandardMaterial3D
|
||||
var _is_ghost: bool = false
|
||||
var _original_color: Color = Color.WHITE
|
||||
|
||||
# Runtime
|
||||
@onready var base_damage: int = resource.base_damage
|
||||
@onready var fire_rate: float = resource.fire_rate
|
||||
@onready var synergy_radius: float = resource.synergy_radius
|
||||
@onready var synergy_tags: PackedStringArray = resource.synergy_tags
|
||||
@onready var _mesh_instance: MeshInstance3D = get_node("Visuals/MeshInstance3D")
|
||||
|
||||
# Grid state
|
||||
var grid_position: Vector3i = Vector3i.ZERO
|
||||
var rotation_quat: Quaternion = Quaternion.IDENTITY
|
||||
var _grid_cells: PackedVector3Array = [] # Relative cell coordinates
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
#_initialize_material()
|
||||
#_set_mesh_representation()
|
||||
pass
|
||||
|
||||
|
||||
## Initializes the material for visual representation.
|
||||
func _initialize_material() -> void:
|
||||
_material = StandardMaterial3D.new()
|
||||
_material.albedo_color = mesh_color
|
||||
_material.metallic = 0.3
|
||||
_material.roughness = 0.7
|
||||
|
||||
|
||||
## Creates a simple mesh representation based on shape type.
|
||||
func _set_mesh_representation() -> void:
|
||||
_mesh_instance.mesh = resource.mesh
|
||||
#_mesh_instance.set_surface_override_material(0, _material)
|
||||
_grid_cells = resource.grid_cells
|
||||
|
||||
|
||||
## Creates a simple box mesh for tetromino visualization.
|
||||
func _create_box_mesh(width: float, height: float, depth: float) -> BoxMesh:
|
||||
var box = BoxMesh.new()
|
||||
box.size = Vector3(width, height, depth)
|
||||
return box
|
||||
|
||||
|
||||
## Gets the grid cells occupied by this tetromino at its current position.
|
||||
func get_grid_cells(at_position: Vector3i = grid_position) -> PackedVector3Array:
|
||||
var result = PackedVector3Array()
|
||||
for cell in _grid_cells:
|
||||
result.append(at_position + Vector3i(cell.x, cell.y, cell.z))
|
||||
return result
|
||||
|
||||
|
||||
## Gets grid cells with a specific rotation applied.
|
||||
func get_grid_cells_with_rotation(at_position: Vector3i, rotation: Quaternion) -> PackedVector3Array:
|
||||
var result = PackedVector3Array()
|
||||
|
||||
# Apply rotation to relative grid cells
|
||||
for cell in _grid_cells:
|
||||
var rotated = rotation * Vector3(cell)
|
||||
var rotated_int = Vector3i(rotated.round())
|
||||
result.append(at_position + rotated_int)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Gets the bounds (AABB) of this tetromino.
|
||||
func get_bounds() -> AABB:
|
||||
var min_cell = Vector3i.ZERO
|
||||
var max_cell = Vector3i.ZERO
|
||||
|
||||
for cell in _grid_cells:
|
||||
min_cell = min_cell.min(cell)
|
||||
max_cell = max_cell.max(cell)
|
||||
|
||||
var size = Vector3(max_cell - min_cell) + Vector3.ONE
|
||||
var pos = grid_position + min_cell
|
||||
|
||||
return AABB(Vector3(pos), size)
|
||||
|
||||
|
||||
## Sets the color/material of the tetromino.
|
||||
func set_color(color: Color) -> void:
|
||||
mesh_color = color
|
||||
_original_color = color
|
||||
if _material:
|
||||
_material.albedo_color = color
|
||||
|
||||
|
||||
## Enables/disables ghost mode (semi-transparent, movable state).
|
||||
func set_ghost_mode(enabled: bool) -> void:
|
||||
_is_ghost = enabled
|
||||
|
||||
if enabled:
|
||||
# Create material if needed
|
||||
if not _material:
|
||||
_initialize_material()
|
||||
|
||||
# Make semi-transparent
|
||||
var ghost_color = _original_color
|
||||
ghost_color.a = 0.5
|
||||
_material.albedo_color = ghost_color
|
||||
_mesh_instance.set_surface_override_material(0, _material)
|
||||
else:
|
||||
# Restore original appearance
|
||||
if _material:
|
||||
_material.albedo_color = _original_color
|
||||
_material.albedo_color.a = 1.0
|
||||
_mesh_instance.set_surface_override_material(0, _material)
|
||||
|
||||
|
||||
## Enables/disables highlight for visual feedback.
|
||||
func set_highlighted(enabled: bool) -> void:
|
||||
if _material:
|
||||
_material.emission_enabled = enabled
|
||||
if enabled:
|
||||
_material.emission = mesh_color
|
||||
_material.emission_energy_multiplier = 2.0
|
||||
else:
|
||||
_material.emission_energy_multiplier = 0.0
|
||||
|
||||
|
||||
## Returns string representation for debugging.
|
||||
func _to_string() -> String:
|
||||
return "Tetromino3D[type=%s, pos=%v, cells=%d]" % [
|
||||
resource.shape_type,
|
||||
grid_position,
|
||||
_grid_cells.size()
|
||||
]
|
||||
1
scripts/tetromino.gd.uid
Normal file
1
scripts/tetromino.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c8041v2usigk4
|
||||
123
scripts/tetromino_definition.gd
Normal file
123
scripts/tetromino_definition.gd
Normal file
@@ -0,0 +1,123 @@
|
||||
## Data class for tetromino shape definitions in 3D space.
|
||||
##
|
||||
## Stores shape geometry, combat stats, synergy tags, and cost information.
|
||||
## Designed to be saved as a Resource (.tres file) for easy editor configuration.
|
||||
class_name TetrominoDefinition
|
||||
extends Resource
|
||||
|
||||
enum ShapeType { I, O, T, S, Z, L, J }
|
||||
|
||||
## Shape type identifier
|
||||
@export var shape_type: ShapeType = ShapeType.I
|
||||
|
||||
## Base damage output per shot
|
||||
@export var base_damage: int = 10
|
||||
|
||||
## Fire rate in shots per second
|
||||
@export var fire_rate: float = 1.0
|
||||
|
||||
## Gold cost to place this tetromino
|
||||
@export var base_cost: int = 50
|
||||
|
||||
## Synergy tags for combination effects (e.g., ["fire", "ice"])
|
||||
@export var synergy_tags: PackedStringArray = []
|
||||
|
||||
## 3D mesh color/tint for visual distinction
|
||||
@export var mesh_color: Color = Color.WHITE
|
||||
|
||||
## Pre-built 3D mesh for this tetromino
|
||||
@export var mesh: Mesh
|
||||
|
||||
## Detection radius for 3D adjacency synergies
|
||||
@export var synergy_radius: float = 2.5
|
||||
|
||||
## 3D grid cells relative to anchor point
|
||||
var grid_cells: PackedVector3Array = []
|
||||
|
||||
func _init(p_shape_type: ShapeType = ShapeType.I) -> void:
|
||||
shape_type = p_shape_type
|
||||
_initialize_default_shape()
|
||||
|
||||
|
||||
## Initialize default grid cells based on shape type (Tetris standard)
|
||||
func _initialize_default_shape() -> void:
|
||||
match shape_type:
|
||||
ShapeType.I:
|
||||
# I-piece: 4 in a row
|
||||
grid_cells = [Vector3i.ZERO, Vector3i.RIGHT, Vector3i.RIGHT * 2, Vector3i.RIGHT * 3]
|
||||
mesh_color = Color.CYAN
|
||||
base_damage = 8
|
||||
fire_rate = 0.8
|
||||
|
||||
ShapeType.O:
|
||||
# O-piece: 2x2 square
|
||||
grid_cells = [Vector3i.ZERO, Vector3i.RIGHT, Vector3i.FORWARD, Vector3i.RIGHT + Vector3i.FORWARD]
|
||||
mesh_color = Color.YELLOW
|
||||
base_damage = 12
|
||||
fire_rate = 1.2
|
||||
|
||||
ShapeType.T:
|
||||
# T-piece: T-shape
|
||||
grid_cells = [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.FORWARD]
|
||||
mesh_color = Color.MAGENTA
|
||||
base_damage = 10
|
||||
fire_rate = 1.0
|
||||
|
||||
ShapeType.S:
|
||||
# S-piece: Zigzag
|
||||
grid_cells = [Vector3i.ZERO, Vector3i.RIGHT, Vector3i.FORWARD, Vector3i.LEFT + Vector3i.FORWARD]
|
||||
mesh_color = Color.GREEN
|
||||
base_damage = 9
|
||||
fire_rate = 0.9
|
||||
|
||||
ShapeType.Z:
|
||||
# Z-piece: Reverse zigzag
|
||||
grid_cells = [Vector3i.ZERO, Vector3i.LEFT, Vector3i.FORWARD, Vector3i.RIGHT + Vector3i.FORWARD]
|
||||
mesh_color = Color.RED
|
||||
base_damage = 9
|
||||
fire_rate = 0.9
|
||||
|
||||
ShapeType.L:
|
||||
# L-piece: L-shape
|
||||
grid_cells = [Vector3i.ZERO, Vector3i.LEFT, Vector3i.FORWARD, Vector3i.LEFT + Vector3i.FORWARD]
|
||||
mesh_color = Color.ORANGE
|
||||
base_damage = 11
|
||||
fire_rate = 1.1
|
||||
|
||||
ShapeType.J:
|
||||
# J-piece: Reverse L-shape
|
||||
grid_cells = [Vector3i.ZERO, Vector3i.RIGHT, Vector3i.FORWARD, Vector3i.RIGHT + Vector3i.FORWARD]
|
||||
mesh_color = Color.BLUE
|
||||
base_damage = 11
|
||||
fire_rate = 1.1
|
||||
|
||||
|
||||
## Get the display name of this tetromino
|
||||
func get_display_name() -> String:
|
||||
match shape_type:
|
||||
ShapeType.I:
|
||||
return "I"
|
||||
ShapeType.O:
|
||||
return "O"
|
||||
ShapeType.T:
|
||||
return "T"
|
||||
ShapeType.S:
|
||||
return "S"
|
||||
ShapeType.Z:
|
||||
return "Z"
|
||||
ShapeType.L:
|
||||
return "L"
|
||||
ShapeType.J:
|
||||
return "J"
|
||||
|
||||
return "Invalid"
|
||||
|
||||
|
||||
## Get all synergy tags as a formatted string
|
||||
func get_synergy_string() -> String:
|
||||
return ", ".join(synergy_tags)
|
||||
|
||||
|
||||
## Check if this tetromino has a specific synergy tag
|
||||
func has_synergy_tag(tag: StringName) -> bool:
|
||||
return tag in synergy_tags
|
||||
1
scripts/tetromino_definition.gd.uid
Normal file
1
scripts/tetromino_definition.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://p168a1urs4im
|
||||
219
scripts/tetromino_selector.gd
Normal file
219
scripts/tetromino_selector.gd
Normal file
@@ -0,0 +1,219 @@
|
||||
## Manages tetromino selection, ghosting, and repositioning.
|
||||
##
|
||||
## Allows players to:
|
||||
## 1. Click an already-placed tetromino to select it
|
||||
## 2. Tetromino becomes "ghost" (semi-transparent, movable)
|
||||
## 3. Move and rotate the ghost tetromino
|
||||
## 4. Click to place it in the new position
|
||||
class_name TetrominoSelector
|
||||
extends Node3D
|
||||
|
||||
# Signals
|
||||
signal tetromino_selected(tetromino: Tetromino)
|
||||
signal tetromino_deselected(tetromino: Tetromino)
|
||||
signal selection_placed(tetromino: Tetromino)
|
||||
|
||||
# References
|
||||
@onready var board_manager: BoardManager3D = get_parent()
|
||||
@onready var camera: Camera3D = get_viewport().get_camera_3d()
|
||||
|
||||
# Selection state
|
||||
var _selected_tetromino: Tetromino = null
|
||||
var _ghost_position: Vector3i = Vector3i.ZERO
|
||||
var _ghost_rotation: Quaternion = Quaternion.IDENTITY
|
||||
var _original_position: Vector3i = Vector3i.ZERO
|
||||
var _original_rotation: Quaternion = Quaternion.IDENTITY
|
||||
|
||||
# Rotation speed (radians per frame)
|
||||
var rotation_speed: float = PI / 4.0 # 45 degrees
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Input is handled in _input()
|
||||
pass
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
if event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_handle_left_click()
|
||||
elif event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
_handle_right_click()
|
||||
|
||||
if _selected_tetromino and event is InputEventMouseMotion:
|
||||
_update_ghost_position()
|
||||
|
||||
if _selected_tetromino:
|
||||
if event.is_action_pressed("ui_select"): # Spacebar
|
||||
_rotate_ghost()
|
||||
elif event.is_action_pressed("ui_cancel"): # ESC
|
||||
_cancel_selection()
|
||||
|
||||
|
||||
## Handles left mouse click for selection or placement.
|
||||
func _handle_left_click() -> void:
|
||||
if _selected_tetromino:
|
||||
# Try to place the ghost tetromino
|
||||
_place_selection()
|
||||
return
|
||||
|
||||
# Try to select a tetromino
|
||||
var tetromino = _raycast_tetromino()
|
||||
if tetromino:
|
||||
_select_tetromino(tetromino)
|
||||
|
||||
|
||||
## Handles right mouse click to deselect.
|
||||
func _handle_right_click() -> void:
|
||||
if _selected_tetromino:
|
||||
_cancel_selection()
|
||||
|
||||
|
||||
## Raycasts from mouse position to find a tetromino.
|
||||
func _raycast_tetromino() -> Tetromino:
|
||||
if not camera:
|
||||
return null
|
||||
|
||||
var mouse_pos = get_viewport().get_mouse_position()
|
||||
var from = camera.project_ray_origin(mouse_pos)
|
||||
var to = from + camera.project_ray_normal(mouse_pos) * 10000.0
|
||||
|
||||
var space_state = get_world_3d().direct_space_state
|
||||
var collision_mask = 0xFFFFFFFF # Hit all layers
|
||||
var query = PhysicsRayQueryParameters3D.create(from, to, collision_mask)
|
||||
query.collide_with_areas = true
|
||||
|
||||
var result = space_state.intersect_ray(query)
|
||||
|
||||
if result and result.get("collider"):
|
||||
var collider = result["collider"]
|
||||
# Walk up the node tree to find a Tetromino
|
||||
var node = collider
|
||||
while node:
|
||||
if node is Tetromino:
|
||||
return node
|
||||
node = node.get_parent()
|
||||
else:
|
||||
print_debug("Raycast hit nothing")
|
||||
|
||||
return null
|
||||
|
||||
|
||||
## Selects a tetromino and converts it to ghost mode.
|
||||
func _select_tetromino(tetromino: Tetromino) -> void:
|
||||
if _selected_tetromino:
|
||||
_cancel_selection()
|
||||
|
||||
_selected_tetromino = tetromino
|
||||
_original_position = tetromino.grid_position
|
||||
_original_rotation = tetromino.rotation_quat
|
||||
_ghost_position = _original_position
|
||||
_ghost_rotation = _original_rotation
|
||||
|
||||
# Enable ghost mode
|
||||
tetromino.set_ghost_mode(true)
|
||||
|
||||
tetromino_selected.emit(tetromino)
|
||||
get_tree().root.set_input_as_handled()
|
||||
|
||||
|
||||
## Updates ghost position based on mouse position.
|
||||
func _update_ghost_position() -> void:
|
||||
if not _selected_tetromino or not camera:
|
||||
return
|
||||
|
||||
var mouse_pos = get_viewport().get_mouse_position()
|
||||
var ray_origin = camera.project_ray_origin(mouse_pos)
|
||||
var ray_direction = camera.project_ray_normal(mouse_pos)
|
||||
|
||||
# Cast ray to find ground position (Y = 0 plane)
|
||||
var t = -ray_origin.y / ray_direction.y if ray_direction.y != 0 else 0.0
|
||||
var world_hit = ray_origin + ray_direction * t
|
||||
|
||||
# Convert to grid position
|
||||
_ghost_position = board_manager.world_to_grid(world_hit)
|
||||
|
||||
# Clamp to valid grid bounds
|
||||
_ghost_position = Vector3i(
|
||||
clampi(_ghost_position.x, 0, board_manager.grid_size.x - 1),
|
||||
clampi(_ghost_position.y, 0, board_manager.grid_size.y - 1),
|
||||
clampi(_ghost_position.z, 0, board_manager.grid_size.z - 1)
|
||||
)
|
||||
|
||||
# Update visual position of ghost
|
||||
_selected_tetromino.grid_position = _ghost_position
|
||||
_selected_tetromino.global_position = board_manager.grid_to_world(_ghost_position)
|
||||
|
||||
|
||||
## Rotates the ghost tetromino.
|
||||
func _rotate_ghost() -> void:
|
||||
if not _selected_tetromino:
|
||||
return
|
||||
|
||||
_ghost_rotation = _ghost_rotation * Quaternion.from_euler(Vector3(0, rotation_speed, 0))
|
||||
_selected_tetromino.rotation_quat = _ghost_rotation
|
||||
|
||||
get_tree().root.set_input_as_handled()
|
||||
|
||||
|
||||
## Places the ghost tetromino at its current position.
|
||||
func _place_selection() -> void:
|
||||
if not _selected_tetromino:
|
||||
return
|
||||
|
||||
var tetromino = _selected_tetromino
|
||||
|
||||
# Validate placement
|
||||
if not board_manager._can_place_tetromino(tetromino, _ghost_position, _ghost_rotation):
|
||||
# Placement invalid, snap back to original position
|
||||
tetromino.grid_position = _original_position
|
||||
tetromino.rotation_quat = _original_rotation
|
||||
tetromino.world_position = board_manager.grid_to_world(_original_position)
|
||||
return
|
||||
|
||||
# Update board manager state
|
||||
# Remove from old position
|
||||
var old_cells = tetromino.get_grid_cells(_original_position)
|
||||
for cell_pos in old_cells:
|
||||
board_manager._occupied_cells.erase(cell_pos)
|
||||
|
||||
# Place at new position
|
||||
tetromino.grid_position = _ghost_position
|
||||
tetromino.rotation_quat = _ghost_rotation
|
||||
tetromino.global_position = board_manager.grid_to_world(_ghost_position)
|
||||
|
||||
var new_cells = tetromino.get_grid_cells(_ghost_position)
|
||||
for cell_pos in new_cells:
|
||||
board_manager._occupied_cells[cell_pos] = tetromino
|
||||
|
||||
# Disable ghost mode
|
||||
tetromino.set_ghost_mode(false)
|
||||
|
||||
_selected_tetromino = null
|
||||
selection_placed.emit(tetromino)
|
||||
get_tree().root.set_input_as_handled()
|
||||
|
||||
|
||||
## Cancels current selection and reverts tetromino to original state.
|
||||
func _cancel_selection() -> void:
|
||||
if not _selected_tetromino:
|
||||
return
|
||||
|
||||
var tetromino = _selected_tetromino
|
||||
|
||||
# Restore original position and rotation
|
||||
tetromino.grid_position = _original_position
|
||||
tetromino.rotation_quat = _original_rotation
|
||||
tetromino.world_position = board_manager.grid_to_world(_original_position)
|
||||
|
||||
# Disable ghost mode
|
||||
tetromino.set_ghost_mode(false)
|
||||
|
||||
tetromino_deselected.emit(tetromino)
|
||||
_selected_tetromino = null
|
||||
get_tree().root.set_input_as_handled()
|
||||
|
||||
|
||||
## Returns the currently selected tetromino, if any.
|
||||
func get_selected_tetromino() -> Tetromino:
|
||||
return _selected_tetromino
|
||||
1
scripts/tetromino_selector.gd.uid
Normal file
1
scripts/tetromino_selector.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bv7xi75mklk7d
|
||||
68
scripts/wave_config.gd
Normal file
68
scripts/wave_config.gd
Normal file
@@ -0,0 +1,68 @@
|
||||
## Configuration data for enemy waves.
|
||||
##
|
||||
## Defines spawn count, timing, enemy stats, and difficulty modifiers for each wave.
|
||||
## Designed to be saved as a Resource (.tres file) for easy encounter design.
|
||||
class_name WaveConfig
|
||||
extends Resource
|
||||
|
||||
## Wave number (1-indexed)
|
||||
@export var wave_number: int = 1
|
||||
|
||||
## Number of enemies to spawn in this wave
|
||||
@export var enemy_count: int = 3
|
||||
|
||||
## Interval between enemy spawns in seconds
|
||||
@export var spawn_interval: float = 1.0
|
||||
|
||||
## Enemy falling speed (units per second on Y-axis)
|
||||
@export var enemy_speed: float = 5.0
|
||||
|
||||
## Base health per enemy
|
||||
@export var enemy_health: int = 10
|
||||
|
||||
## Global difficulty multiplier for this wave (scales damage, health, count)
|
||||
@export var difficulty_multiplier: float = 1.0
|
||||
|
||||
## Optional: Enemy type/archetype for variety (placeholder)
|
||||
@export var enemy_type: StringName = &"basic"
|
||||
|
||||
## Optional: Special modifiers for the wave
|
||||
@export var wave_modifiers: PackedStringArray = []
|
||||
|
||||
|
||||
func _init(p_wave_number: int = 1) -> void:
|
||||
wave_number = p_wave_number
|
||||
_scale_difficulty()
|
||||
|
||||
|
||||
## Auto-scale difficulty based on wave number
|
||||
func _scale_difficulty() -> void:
|
||||
difficulty_multiplier = 1.0 + (wave_number - 1) * 0.2
|
||||
enemy_count = max(1, int(3 + (wave_number - 1) * 0.5))
|
||||
enemy_health = int(10 * difficulty_multiplier)
|
||||
enemy_speed = 5.0 + (wave_number - 1) * 0.5
|
||||
|
||||
|
||||
## Get adjusted enemy count based on difficulty
|
||||
func get_enemy_count() -> int:
|
||||
return int(enemy_count * difficulty_multiplier)
|
||||
|
||||
|
||||
## Get adjusted enemy health based on difficulty
|
||||
func get_enemy_health() -> int:
|
||||
return int(enemy_health * difficulty_multiplier)
|
||||
|
||||
|
||||
## Get wave description for UI display
|
||||
func get_wave_description() -> String:
|
||||
return "Wave %d: %d enemies (Speed: %.1f, Health: %d)" % [
|
||||
wave_number,
|
||||
get_enemy_count(),
|
||||
enemy_speed,
|
||||
get_enemy_health()
|
||||
]
|
||||
|
||||
|
||||
## Check if wave has a specific modifier
|
||||
func has_modifier(modifier_name: StringName) -> bool:
|
||||
return modifier_name in wave_modifiers
|
||||
1
scripts/wave_config.gd.uid
Normal file
1
scripts/wave_config.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c0xlr04kjhr22
|
||||
Reference in New Issue
Block a user