Compare commits

3 Commits

Author SHA1 Message Date
e9689ac3cd Add tetromino movement 2026-01-14 13:52:57 +01:00
6982ded2a1 Select/hover tetramino with Area3D signal 2026-01-13 22:43:51 +01:00
7b1b09e0c1 Start encounter feature 2026-01-13 08:31:42 +01:00
38 changed files with 2275 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
---
name: godot-dev
description: Develops Godot 4.5+ games with GDScript. Creates scenes, scripts, resources, and nodes. Use when working on Godot projects, writing GDScript, or building game features.
---
# Godot 4.5+ Development Skill
## Capabilities
- Create and modify GDScript files with proper Godot 4.5 syntax
- Design scene hierarchies and node structures
- Implement common game patterns (state machines, singletons, signals)
- Debug and fix GDScript errors
- Create custom resources and exported properties
## GDScript Quick Reference
### Script Template
```gdscript
class_name MyClass
extends Node
signal my_signal(value: int)
@export var speed: float = 100.0
@onready var sprite: Sprite2D = $Sprite2D
var _private_var: int = 0
func _ready() -> void:
pass
func _process(delta: float) -> void:
pass
func _physics_process(delta: float) -> void:
pass
```
### Common Node Types
- **Node2D/Node3D**: Base spatial nodes
- **CharacterBody2D/3D**: Player/enemy controllers
- **RigidBody2D/3D**: Physics objects
- **Area2D/3D**: Triggers and detection zones
- **Control**: UI elements (Button, Label, Panel, etc.)
- **AudioStreamPlayer**: Sound effects and music
### Signal Patterns
```gdscript
# Declare
signal health_changed(new_value: int)
# Emit
health_changed.emit(current_health)
# Connect in code
other_node.health_changed.connect(_on_health_changed)
# Await
await get_tree().create_timer(1.0).timeout
var result = await some_signal
```
### Input Handling
```gdscript
func _input(event: InputEvent) -> void:
if event.is_action_pressed("jump"):
jump()
func _process(delta: float) -> void:
var direction = Input.get_vector("left", "right", "up", "down")
velocity = direction * speed
```
### Resource Creation
```gdscript
class_name ItemData
extends Resource
@export var name: String = ""
@export var icon: Texture2D
@export var value: int = 0
@export_multiline var description: String = ""
```
### State Machine Pattern
```gdscript
enum State { IDLE, WALK, JUMP, ATTACK }
var current_state: State = State.IDLE
func _physics_process(delta: float) -> void:
match current_state:
State.IDLE:
_handle_idle(delta)
State.WALK:
_handle_walk(delta)
State.JUMP:
_handle_jump(delta)
```
### Tween Animations
```gdscript
func fade_out() -> void:
var tween = create_tween()
tween.tween_property(self, "modulate:a", 0.0, 0.5)
await tween.finished
queue_free()
```
## Scene Structure Best Practices
- Root node named after the scene purpose (Player, Enemy, MainMenu)
- Group related nodes under organizational Node2D/Node3D
- Use `%UniqueNodeName` syntax for unique node access
- Prefer composition over inheritance
## Debugging Commands
```bash
# Validate project
godot --headless --quit
# Run with verbose logging
godot --verbose
# Run specific scene
godot --path . res://scenes/test.tscn
```
## Common Fixes
- **Null reference**: Check `@onready` nodes exist, use `is_instance_valid()`
- **Signal not found**: Ensure signal is declared before connecting
- **Type errors**: Add explicit type hints, check return types
- **Physics issues**: Verify collision layers/masks are set correctly

139
AGENTS.md Normal file
View File

@@ -0,0 +1,139 @@
# Godot 4.5.1 Development - Agent Configuration
## Project Info
- **Engine**: Godot 4.5.1
- **Language**: GDScript (primary), C# (optional)
- **Project Root**: Contains `project.godot`
## Commands
```bash
# Run project
godot --path .
# Run headless (for validation/testing)
godot --headless --quit --script res://path/to/script.gd
# Export project
godot --headless --export-release "preset_name" output_path
# Validate/check project
godot --headless --quit
# Run specific scene
godot --path . res://scenes/main.tscn
# Generate documentation
godot --doctool docs/ --gdscript-docs res://
```
## Code Style
### GDScript Conventions
- Use `snake_case` for variables, functions, signals
- Use `PascalCase` for class names and node types
- Use `SCREAMING_SNAKE_CASE` for constants
- Prefix private members with `_`
- Use type hints: `var speed: float = 100.0`
- Use `@export` for editor-exposed variables
- Use `@onready` for node references
### File Naming
- Scripts: `snake_case.gd` (e.g., `player_controller.gd`)
- Scenes: `snake_case.tscn` (e.g., `main_menu.tscn`)
- Resources: `snake_case.tres` (e.g., `player_stats.tres`)
### Project Structure
```
res://
├── scenes/ # .tscn scene files
├── scripts/ # .gd script files
├── assets/
│ ├── sprites/
│ ├── audio/
│ └── fonts/
├── resources/ # .tres resource files
├── autoload/ # Singleton scripts
└── addons/ # Third-party plugins
```
## Godot 4.5 Specifics
- Use `@export` instead of `export`
- Use `@onready` instead of `onready`
- Use `super()` instead of `.call()`
- Signals: `signal_name.emit()` instead of `emit_signal("signal_name")`
- Await: `await signal_name` instead of `yield`
- String formatting: `"Value: %s" % value` or `"Value: {val}".format({"val": value})`
## Testing
- Use GUT (Godot Unit Test) for unit testing if available
- Test scripts in `res://tests/`
- Run tests: `godot --headless --script res://addons/gut/gut_cmdln.gd`
## Common Patterns
### Singleton/Autoload
```gdscript
# In Project Settings > AutoLoad
extends Node
var game_state: Dictionary = {}
func _ready() -> void:
pass
```
### Signal Pattern
```gdscript
signal health_changed(new_health: int)
func take_damage(amount: int) -> void:
health -= amount
health_changed.emit(health)
```
### Resource Pattern
```gdscript
class_name WeaponData extends Resource
@export var damage: int = 10
@export var fire_rate: float = 0.5
```
## MCP Server (Recommended)
Install [Coding-Solo/godot-mcp](https://github.com/Coding-Solo/godot-mcp) for:
- Launch editor programmatically
- Run projects and capture debug output
- Scene management (create/modify scenes)
- UID management (Godot 4.4+)
### Setup
```bash
git clone https://github.com/Coding-Solo/godot-mcp
cd godot-mcp
npm install
npm run build
```
### Configuration (add to MCP settings)
```json
{
"godot": {
"command": "node",
"args": ["/path/to/godot-mcp/build/index.js"],
"env": {
"GODOT_PATH": "/path/to/godot"
}
}
}
```
## Documentation MCP (Optional)
For up-to-date Godot docs access:
```json
{
"godot-docs": {
"url": "https://doc-mcp.fly.dev/mcp/"
}
}
```

119
TETROMINO_SELECTOR.md Normal file
View File

@@ -0,0 +1,119 @@
# Tetromino Selector System
## Overview
Adds interactive tetromino selection and repositioning during gameplay. Players can select already-
placed tetrominoes, move them, rotate them, and place them in new positions.
## Components
### 1. TetrominoSelector (scripts/tetromino_selector.gd)
Main controller for tetromino selection logic.
**Features:**
- Raycast-based tetromino detection on left-click from camera to world
- Real-time position tracking with mouse movement
- Ghost mode visualization (semi-transparent)
- Placement validation using board manager
- Rotation with spacebar (45° increments)
- Cancellation with right-click or ESC key
**Signals:**
- `tetromino_selected(tetromino: Tetromino)` - Fired when a tetromino is selected
- `tetromino_deselected(tetromino: Tetromino)` - Fired when selection is cancelled
- `selection_placed(tetromino: Tetromino)` - Fired when tetromino is placed
**Controls:**
- **Left Click** - Select tetromino or place selected tetromino
- **Right Click / ESC** - Cancel selection
- **Spacebar** - Rotate selected tetromino 45°
- **Mouse Movement** - Move ghost tetromino (updates position on Y=0 plane)
### 2. Tetromino (scripts/tetromino.gd) - Updated
Added ghost mode support to tetromino script.
**New Methods:**
- `set_ghost_mode(enabled: bool)` - Enables/disables ghost appearance
- When enabled: tetromino becomes 50% transparent with original color
- When disabled: tetromino returns to original appearance
**New Variables:**
- `_is_ghost: bool` - Tracks if tetromino is in ghost mode
- `_original_color: Color` - Stores original color for restoration
### 3. Board Scene (scenes/board/board.tscn) - Updated
Added TetrominoSelector node to board hierarchy.
**Changes:**
- Added TetrominoSelector as child of Board node
- Selector has access to BoardManager3D parent
- Integrated into existing board scene structure
### 4. Tetromino Scene (scenes/board/tetromino.tscn) - Updated
Added collision detection for raycast selection.
**Changes:**
- Added SelectionArea (Area3D) node
- Added CollisionShape3D with BoxShape3D for raycast detection
- Enables click detection on tetromino mesh
## Workflow
1. **Selection Phase**
- Player left-clicks on a placed tetromino
- Selector raycasts from camera through mouse position
- If tetromino hit, it enters ghost mode (semi-transparent)
- Signals emit `tetromino_selected`
2. **Movement Phase**
- Player moves mouse to new position
- Ghost tetromino tracks mouse position in real-time
- Position snaps to grid coordinates
- Clamped to valid board bounds
3. **Rotation Phase**
- Player presses spacebar to rotate ghost 45°
- Rotation applied around Y-axis
- Updates visual representation
4. **Placement Phase**
- Player left-clicks to place
- Selector validates placement using BoardManager3D._can_place_tetromino()
- If valid:
- Updates board manager occupied cells
- Tetromino exits ghost mode
- Signals emit `selection_placed`
- If invalid:
- Tetromino snaps back to original position
- Reverts to original rotation
5. **Cancellation**
- Player right-clicks or presses ESC
- Tetromino reverts to original position and rotation
- Tetromino exits ghost mode
- Signals emit `tetromino_deselected`
## Integration Points
### Board Manager
- Uses `_can_place_tetromino()` for validation
- Modifies `_occupied_cells` dictionary directly
- Respects grid bounds and collision rules
### Tetromino Script
- Calls `set_ghost_mode()` for visual feedback
- Tracks grid_position and rotation_quat
- Provides `get_grid_cells()` for collision checking
### Input System
- Listens to input events (_input callback)
- Handles mouse clicks, movement, and keyboard input
- Calls `get_tree().root.set_input_as_handled()` to consume inputs
## Notes
- Ghost mode requires material initialization to display transparency
- Position clamping prevents out-of-bounds placement attempts
- Rotation validation happens before placement
- Only one tetromino can be selected at a time
- Original position/rotation saved for cancellation

BIN
assets/models/MSH_1x1.res Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/models/MSH_1x3.res Normal file

Binary file not shown.

Binary file not shown.

101
assets/tetra-tactics.md Normal file
View File

@@ -0,0 +1,101 @@
# Tetra Tactics
## Summary
Tetra Tactics is a strategic roguelike tower-defense.
The player defends their position by building modular structures (tetraminos) and
**exploiting their sinergies** while waves of enemies fall from above.
## Experience
**Primary Feeling**: The satisfaction of strategic planning combined with the
thrill of narrowly avoiding disaster ("just barely made it"). The player should
feel **clever** and **resourceful**.
A single encounter has a duration of ~10 minutes, or in general a short duration.
Depth emerges from the interaction between shape, positioning, timing, and build
choices.
## Inspiration
- Ball Pit
- 9 Kings
- Drop Duchy
- The King Is Watching
- Vampire Survivor
## Design Pillars
- **Exploiting Sinergies**: breaking the game is fun
- **Modular Depth**: simple tetromino rules combine in unexpected ways;
complexity emerges naturally from interaction
- **Consequence & Clarity**: threats are telegraphed clearly; every placement
has visible impact; failure is immediate and understandable
- **Momentum & Escalation**: each successful wave grows your toolkit and unlocks
new synergies; pressure mounts steadily without overwhelming
## Encounter Loop
1. **Telegraph**: Warning indicators show where next wave enemies will fall.
2. **Build & Adapt**:
- Draft new shapes (randomly offered).
- Place/Rotate units on persistent board.
- **Trigger Synergies**: Adjacency bonuses light up (Visual Feedback).
3. **Resolution**:
- Enemies fall/attack.
- Towers fire.
- **Player repositions shapes in real-time** to dodge incoming fire.
- **Player can active a the main tower skill**
4. **Outcome**:
- **Defense Breached**: Lose Health (Punishment).
- **Defense Holds**: Gain Gold/XP.
5. **Evolution**: Buy upgrades or Mutate blocks (Synergy scaling). Threats
escalate automatically.
6. **Repeat** until Death or Victory.
## Progression & Motivation
### Encounter Goals
- Encounters consist of a **variable number of waves** (RNG-driven difficulty/length)
- Some encounters feature **mini-bosses** mid-run
- The **final encounter of a scenario** is a **boss fight**
### Player Motivation Arc
- **Moment-to-Moment**: Hit synergies, exploit combinations, survive threats ("feeling clever")
- **Single Run**: Build scaling through gold; adapt to random shapes; defeat
mini-bosses and scenario bosses
- **Long-Term (Meta)**: Unlock new block types and permanent perks across runs;
expand synergy possibilities; enable new strategies in future runs
### Permanent Progression
- **Unlockable Blocks**: New tetromino types unlock over time, creating fresh
synergy combinations
- **Unlockable Perks**: Passive bonuses that persist across runs, increasing
player power and enabling riskier strategies
- Progression feeds back into motivation: "What can I break with this new block?"
## Core Mechanics
- **Tetromino Placement & Rotation**: Spatial puzzle element; player chooses
positioning and orientation on grid
- **Adjacency Synergies**: Shapes trigger bonuses when placed next to
complementary pieces; core depth driver
- **Persistent Board State**: Board carries between waves; placement decisions
compound and escalate over time
- **Shape Drafting (Random)**: Player selects from randomly offered tetrominos
or mutations; forces adaptive strategy
- **Real-Time Repositioning**: During enemy attack, player moves shapes to dodge
and optimize defense in slow motion
- **Encounter Market**: Shapes or Upgrades purchased with gold; modify base
stats and effects;
- **Resource Economy**: Gold collection from successful waves → purchases upgrades/mutations;
paces progression
- **Automatic Wave Escalation**: Threats increase autonomously each wave;
creates growing pressure
- **Enemy Telegraph**: Visible threat indicators before waves;
enables defensive planning
- **Mini-Bosses & Bosses**: Special encounters with unique attacks that twist
standard wave mechanics
- **Perk System**: Permanent unlocks that affect all game state (damage, costs,
synergy bonuses, etc.); unlock through meta-progression

View File

@@ -14,6 +14,45 @@ config/name="tetra-tactics"
config/features=PackedStringArray("4.5", "GL Compatibility") config/features=PackedStringArray("4.5", "GL Compatibility")
config/icon="res://icon.svg" config/icon="res://icon.svg"
[autoload]
EventBus="*res://scenes/event_bus.tscn"
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
[input]
movement={
"deadzone": 0.21,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":65,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
rotate={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
confirm={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
]
}
[layer_names]
3d_physics/layer_1="ground"
3d_physics/layer_2="tetrominos"
3d_physics/layer_3="enemies"
3d_physics/layer_4="projectiles"
3d_physics/layer_5="selection"
[rendering] [rendering]
renderer/rendering_method="gl_compatibility" renderer/rendering_method="gl_compatibility"

View File

@@ -0,0 +1,9 @@
[gd_resource type="Resource" script_class="TetrominoDefinition" load_steps=3 format=3 uid="uid://curn7voye0ewx"]
[ext_resource type="Script" uid="uid://p168a1urs4im" path="res://scripts/tetromino_definition.gd" id="1_suu2q"]
[ext_resource type="ArrayMesh" uid="uid://dev1jigsm4i4e" path="res://assets/models/MSH_1x2_Orizzontale.res" id="1_tot0g"]
[resource]
script = ExtResource("1_suu2q")
mesh = ExtResource("1_tot0g")
metadata/_custom_type_script = "uid://p168a1urs4im"

View File

@@ -0,0 +1,8 @@
[gd_resource type="Resource" script_class="TetrominoDefinition" load_steps=2 format=3 uid="uid://cot0k1cs02ds3"]
[ext_resource type="Script" uid="uid://p168a1urs4im" path="res://scripts/tetromino_definition.gd" id="1_s1xhx"]
[resource]
script = ExtResource("1_s1xhx")
shape_type = &"O"
metadata/_custom_type_script = "uid://p168a1urs4im"

View File

@@ -0,0 +1,9 @@
[gd_resource type="Resource" script_class="TetrominoDefinition" load_steps=3 format=3 uid="uid://cqgsjnof4dly0"]
[ext_resource type="Script" uid="uid://p168a1urs4im" path="res://scripts/tetromino_definition.gd" id="1_0geyk"]
[ext_resource type="ArrayMesh" uid="uid://5jb2cluw5746" path="res://assets/models/MSH_T_Corta.res" id="1_il264"]
[resource]
script = ExtResource("1_0geyk")
mesh = ExtResource("1_il264")
metadata/_custom_type_script = "uid://p168a1urs4im"

View File

@@ -0,0 +1,7 @@
[gd_resource type="Resource" script_class="WaveConfig" load_steps=2 format=3 uid="uid://ccwgq3vxplge1"]
[ext_resource type="Script" uid="uid://c0xlr04kjhr22" path="res://scripts/wave_config.gd" id="1_vphdf"]
[resource]
script = ExtResource("1_vphdf")
metadata/_custom_type_script = "uid://c0xlr04kjhr22"

View File

@@ -0,0 +1,7 @@
[gd_resource type="Resource" script_class="WaveConfig" load_steps=2 format=3 uid="uid://bau2lnnbf88ih"]
[ext_resource type="Script" uid="uid://c0xlr04kjhr22" path="res://scripts/wave_config.gd" id="1_sn1sd"]
[resource]
script = ExtResource("1_sn1sd")
metadata/_custom_type_script = "uid://c0xlr04kjhr22"

View File

@@ -0,0 +1,7 @@
[gd_resource type="Resource" script_class="WaveConfig" load_steps=2 format=3 uid="uid://owybnsnvwjbf"]
[ext_resource type="Script" uid="uid://c0xlr04kjhr22" path="res://scripts/wave_config.gd" id="1_g8gee"]
[resource]
script = ExtResource("1_g8gee")
metadata/_custom_type_script = "uid://c0xlr04kjhr22"

32
scenes/board/board.tscn Normal file
View File

@@ -0,0 +1,32 @@
[gd_scene load_steps=6 format=3 uid="uid://j3vihw63lw7q"]
[ext_resource type="Script" uid="uid://bvxt8mmibr1uj" path="res://scripts/board_manager.gd" id="1_jnyf0"]
[ext_resource type="PackedScene" uid="uid://btdnshtrnejt" path="res://scenes/board/tetromino.tscn" id="2_jnyf0"]
[ext_resource type="Script" uid="uid://bv7xi75mklk7d" path="res://scripts/tetromino_selector.gd" id="3_jnyf0"]
[ext_resource type="Resource" uid="uid://cqgsjnof4dly0" path="res://resources/data/tetrominos/t_piece.tres" id="3_q07he"]
[sub_resource type="PlaneMesh" id="PlaneMesh_s78fa"]
size = Vector2(1, 1)
[node name="Board" type="Node3D"]
script = ExtResource("1_jnyf0")
[node name="GridVisuals" type="Node3D" parent="."]
[node name="GridPlane" type="Node3D" parent="GridVisuals"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="GridVisuals/GridPlane"]
transform = Transform3D(8, 0, 0, 0, 4, 0, 0, 0, 14, 0, 0, 0)
mesh = SubResource("PlaneMesh_s78fa")
[node name="GridLines" type="Node3D" parent="GridVisuals"]
[node name="MultiMeshInstance3D" type="MultiMeshInstance3D" parent="GridVisuals/GridLines"]
[node name="TetrominoContainer" type="Node3D" parent="."]
[node name="Tetromino" parent="TetrominoContainer" instance=ExtResource("2_jnyf0")]
resource = ExtResource("3_q07he")
[node name="TetrominoSelector" type="Node3D" parent="."]
script = ExtResource("3_jnyf0")

View File

@@ -0,0 +1,23 @@
[gd_scene load_steps=3 format=3 uid="uid://j3vihw63lw7q"]
[ext_resource type="Script" uid="uid://bvxt8mmibr1uj" path="res://scripts/board_manager.gd" id="1_jnyf0"]
[sub_resource type="PlaneMesh" id="PlaneMesh_s78fa"]
size = Vector2(1, 1)
[node name="Board" type="Node3D"]
script = ExtResource("1_jnyf0")
[node name="GridVisuals" type="Node3D" parent="."]
[node name="GridPlane" type="Node3D" parent="GridVisuals"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="GridVisuals/GridPlane"]
transform = Transform3D(8, 0, 0, 0, 4, 0, 0, 0, 14, 0, 0, 0)
mesh = SubResource("PlaneMesh_s78fa")
[node name="GridLines" type="Node3D" parent="GridVisuals"]
[node name="MultiMeshInstance3D" type="MultiMeshInstance3D" parent="GridVisuals/GridLines"]
[node name="TetrominoContainer" type="Node3D" parent="."]

View File

@@ -0,0 +1,29 @@
[gd_scene load_steps=5 format=3 uid="uid://btdnshtrnejt"]
[ext_resource type="Script" uid="uid://c8041v2usigk4" path="res://scripts/tetromino.gd" id="1_hprdj"]
[ext_resource type="Resource" uid="uid://curn7voye0ewx" path="res://resources/data/tetrominos/i_piece.tres" id="2_f3wyc"]
[ext_resource type="ArrayMesh" uid="uid://cp721yqfdga1n" path="res://assets/models/MSH_T_Corta.res" id="3_f3wyc"]
[sub_resource type="BoxShape3D" id="BoxShape3D_f3wyc"]
[node name="Tetromino" type="Node3D"]
script = ExtResource("1_hprdj")
resource = ExtResource("2_f3wyc")
[node name="Visuals" type="Node3D" parent="."]
[node name="MeshInstance3D" type="MeshInstance3D" parent="Visuals"]
layers = 2
mesh = ExtResource("3_f3wyc")
skeleton = NodePath("../..")
[node name="SelectionArea" type="Area3D" parent="."]
collision_layer = 16
collision_mask = 0
[node name="CollisionShape3D" type="CollisionShape3D" parent="SelectionArea"]
shape = SubResource("BoxShape3D_f3wyc")
[connection signal="input_event" from="SelectionArea" to="." method="_on_selection_area_input_event"]
[connection signal="mouse_entered" from="SelectionArea" to="." method="_on_selection_area_mouse_entered"]
[connection signal="mouse_exited" from="SelectionArea" to="." method="_on_selection_area_mouse_exited"]

19
scenes/encounter.tscn Normal file
View File

@@ -0,0 +1,19 @@
[gd_scene load_steps=3 format=3 uid="uid://di7iohkgtosmm"]
[ext_resource type="Script" uid="uid://dv2k6b3b3tfa6" path="res://scripts/encounter_manager.gd" id="1_lp88p"]
[ext_resource type="PackedScene" uid="uid://j3vihw63lw7q" path="res://scenes/board/board.tscn" id="2_eu4s7"]
[node name="Encounter" type="Node3D"]
script = ExtResource("1_lp88p")
[node name="Board" parent="." instance=ExtResource("2_eu4s7")]
[node name="Camera3D" type="Camera3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0.64278764, 0.76604444, 0, -0.76604444, 0.64278764, 0, 7.431855, 7.5)
fov = 60.0
near = 0.1
far = 1000.0
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.90507954, 0.3257545, -0.27334046, 0, 0.64278764, 0.76604444, 0.42524227, -0.6933311, 0.58177394, 7.1817727, 10, 5)
shadow_enabled = true

6
scenes/event_bus.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://ibl8sdj1wp6t"]
[ext_resource type="Script" uid="uid://cbus31k0tae6i" path="res://scripts/event_bus.gd" id="1_7xt3l"]
[node name="Node" type="Node"]
script = ExtResource("1_7xt3l")

285
scripts/board_manager.gd Normal file
View File

@@ -0,0 +1,285 @@
## 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
var selected_tetromino: Tetromino
# Ray casting for placement validation
var _space_state: PhysicsDirectSpaceState3D
func _ready() -> void:
_space_state = get_world_3d().direct_space_state
_initialize_grid()
# Connect to every tetrominos already present on board
var curr_tetrominos = $TetrominoContainer.get_children();
for t in curr_tetrominos:
t.selected.connect(_on_tetromino_selected)
t.deselected.connect(_on_tetromino_deselected)
func _on_tetromino_selected(tetromino: Tetromino):
print("Tetromino selected")
selected_tetromino = tetromino
func _on_tetromino_deselected(tetromino: Tetromino):
print("Tetromino deselected")
selected_tetromino = null
## 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, rot: Quaternion = Quaternion.IDENTITY) -> bool:
# Validate placement
if not _can_place_tetromino(tetromino, grid_position, rot):
placement_invalid.emit("Invalid placement: overlapping or out of bounds")
return false
# Update tetromino state
tetromino.grid_position = grid_position
tetromino.rotation_quat = rot
tetromino.global_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.global_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.global_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.global_position
for other in _tetrominoes:
if other == tetromino:
continue
var distance = world_pos.distance_to(other.global_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, rot: Quaternion) -> bool:
# Get the cells this tetromino would occupy
var cells = tetromino.get_grid_cells_with_rotation(grid_position, rot)
# 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):
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) -> 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,490 @@
## 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
#
@onready var camera: Camera3D = $Camera3D
# 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_node_or_null("Board")
#_combat_system = get_node_or_null("CombatSystem")
#_synergy_system = get_node_or_null("SynergySystem")
#_enemy_spawner = get_node_or_null("EnemySpawner")
#_ui_manager = 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 _input(event: InputEvent) -> void:
if _board_manager.selected_tetromino and event is InputEventMouseMotion:
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
var new_grid_pos = _board_manager.world_to_grid(world_hit)
_board_manager.move_tetromino(_board_manager.selected_tetromino, new_grid_pos)
func _on_tetromino_selected(tetromino: Tetromino):
print("Tetromino selected")
_board_manager.selected_tetromino = tetromino
func _on_tetromino_deselected(tetromino: Tetromino):
print("Tetromino deselected")
_board_manager.selected_tetromino = null
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

162
scripts/tetromino.gd Normal file
View File

@@ -0,0 +1,162 @@
## 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
signal hovered(tetromino: Tetromino)
signal unhovered(tetromino: Tetromino)
signal selected(tetromino: Tetromino)
signal deselected(tetromino: Tetromino)
# Data
@export var resource: TetrominoDefinition
# Visual
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 = Color.RED
_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)
$SelectionArea/CollisionShape3D.shape = _mesh_instance.mesh.create_trimesh_shape()
_grid_cells = resource.grid_cells
## 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, rot: Quaternion) -> PackedVector3Array:
var result = PackedVector3Array()
# Apply rotation to relative grid cells
for cell in _grid_cells:
var rotated = rot * 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()
]
## Callback used to handle tetromino selection and deselection
func _on_selection_area_input_event(camera: Node, event: InputEvent, event_position: Vector3, normal: Vector3, shape_idx: int) -> void:
if event is InputEventMouseButton:
if event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
selected.emit(self)
elif event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
deselected.emit(self)
## Callback used to handle tetromino hovering
func _on_selection_area_mouse_entered() -> void:
print("Tetromino hovered")
set_highlighted(true)
hovered.emit(self)
## Callback used to handle tetromino unhovering
func _on_selection_area_mouse_exited() -> void:
print("Tetromino unhovered")
set_highlighted(false)
unhovered.emit(self)

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

101
tetra-tactics.md Normal file
View File

@@ -0,0 +1,101 @@
# Tetra Tactics
## Summary
Tetra Tactics is a strategic roguelike tower-defense.
The player defends their position by building modular structures (tetraminos) and
**exploiting their sinergies** while waves of enemies fall from above.
## Experience
**Primary Feeling**: The satisfaction of strategic planning combined with the
thrill of narrowly avoiding disaster ("just barely made it"). The player should
feel **clever** and **resourceful**.
A single encounter has a duration of ~10 minutes, or in general a short duration.
Depth emerges from the interaction between shape, positioning, timing, and build
choices.
## Inspiration
- Ball Pit
- 9 Kings
- Drop Duchy
- The King Is Watching
- Vampire Survivor
## Design Pillars
- **Exploiting Sinergies**: breaking the game is fun
- **Modular Depth**: simple tetromino rules combine in unexpected ways;
complexity emerges naturally from interaction
- **Consequence & Clarity**: threats are telegraphed clearly; every placement
has visible impact; failure is immediate and understandable
- **Momentum & Escalation**: each successful wave grows your toolkit and unlocks
new synergies; pressure mounts steadily without overwhelming
## Encounter Loop
1. **Telegraph**: Warning indicators show where next wave enemies will fall.
2. **Build & Adapt**:
- Draft new shapes (randomly offered).
- Place/Rotate units on persistent board.
- **Trigger Synergies**: Adjacency bonuses light up (Visual Feedback).
3. **Resolution**:
- Enemies fall/attack.
- Towers fire.
- **Player repositions shapes in real-time** to dodge incoming fire.
- **Player can active a the main tower skill**
4. **Outcome**:
- **Defense Breached**: Lose Health (Punishment).
- **Defense Holds**: Gain Gold/XP.
5. **Evolution**: Buy upgrades or Mutate blocks (Synergy scaling). Threats
escalate automatically.
6. **Repeat** until Death or Victory.
## Progression & Motivation
### Encounter Goals
- Encounters consist of a **variable number of waves** (RNG-driven difficulty/length)
- Some encounters feature **mini-bosses** mid-run
- The **final encounter of a scenario** is a **boss fight**
### Player Motivation Arc
- **Moment-to-Moment**: Hit synergies, exploit combinations, survive threats ("feeling clever")
- **Single Run**: Build scaling through gold; adapt to random shapes; defeat
mini-bosses and scenario bosses
- **Long-Term (Meta)**: Unlock new block types and permanent perks across runs;
expand synergy possibilities; enable new strategies in future runs
### Permanent Progression
- **Unlockable Blocks**: New tetromino types unlock over time, creating fresh
synergy combinations
- **Unlockable Perks**: Passive bonuses that persist across runs, increasing
player power and enabling riskier strategies
- Progression feeds back into motivation: "What can I break with this new block?"
## Core Mechanics
- **Tetromino Placement & Rotation**: Spatial puzzle element; player chooses
positioning and orientation on grid
- **Adjacency Synergies**: Shapes trigger bonuses when placed next to
complementary pieces; core depth driver
- **Persistent Board State**: Board carries between waves; placement decisions
compound and escalate over time
- **Shape Drafting (Random)**: Player selects from randomly offered tetrominos
or mutations; forces adaptive strategy
- **Real-Time Repositioning**: During enemy attack, player moves shapes to dodge
and optimize defense in slow motion
- **Encounter Market**: Shapes or Upgrades purchased with gold; modify base
stats and effects;
- **Resource Economy**: Gold collection from successful waves → purchases upgrades/mutations;
paces progression
- **Automatic Wave Escalation**: Threats increase autonomously each wave;
creates growing pressure
- **Enemy Telegraph**: Visible threat indicators before waves;
enables defensive planning
- **Mini-Bosses & Bosses**: Special encounters with unique attacks that twist
standard wave mechanics
- **Perk System**: Permanent unlocks that affect all game state (damage, costs,
synergy bonuses, etc.); unlock through meta-progression