Start encounter feature

This commit is contained in:
2026-01-13 08:31:42 +01:00
parent 93ac9555e9
commit 7b1b09e0c1
36 changed files with 2201 additions and 0 deletions

270
scripts/board_manager.gd Normal file
View 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)

View File

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

View 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

View File

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

95
scripts/event_bus.gd Normal file
View 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
View File

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

39
scripts/grid_cell.gd Normal file
View 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
View File

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

141
scripts/tetromino.gd Normal file
View 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
View File

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

View 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

View File

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

View 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

View File

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

68
scripts/wave_config.gd Normal file
View 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

View File

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