From 7b1b09e0c154a306416a9385faadabeacf1cb99b Mon Sep 17 00:00:00 2001 From: Michele Rossi Date: Tue, 13 Jan 2026 08:31:42 +0100 Subject: [PATCH] Start encounter feature --- .agents/skills/godot-dev/SKILL.md | 131 +++++++ AGENTS.md | 139 ++++++++ TETROMINO_SELECTOR.md | 116 ++++++ assets/models/MSH_1x1.res | Bin 0 -> 4894 bytes assets/models/MSH_1x2_Orizzontale.res | Bin 0 -> 5770 bytes assets/models/MSH_1x2_Verticale.res | Bin 0 -> 5768 bytes assets/models/MSH_1x3.res | Bin 0 -> 6622 bytes assets/tetra-tactics.md | 101 ++++++ project.godot | 39 +++ resources/data/tetrominos/i_piece.tres | 7 + resources/data/tetrominos/o_piece.tres | 8 + resources/data/waves/wave_1.tres | 7 + resources/data/waves/wave_2.tres | 7 + resources/data/waves/wave_3.tres | 7 + scenes/board/board.tscn | 30 ++ scenes/board/board.tscn916401494.tmp | 23 ++ scenes/board/tetromino.tscn | 32 ++ scenes/encounter.tscn | 19 + scenes/event_bus.tscn | 6 + scripts/board_manager.gd | 270 ++++++++++++++ scripts/board_manager.gd.uid | 1 + scripts/encounter_manager.gd | 465 +++++++++++++++++++++++++ scripts/encounter_manager.gd.uid | 1 + scripts/event_bus.gd | 95 +++++ scripts/event_bus.gd.uid | 1 + scripts/grid_cell.gd | 39 +++ scripts/grid_cell.gd.uid | 1 + scripts/tetromino.gd | 141 ++++++++ scripts/tetromino.gd.uid | 1 + scripts/tetromino_definition.gd | 123 +++++++ scripts/tetromino_definition.gd.uid | 1 + scripts/tetromino_selector.gd | 219 ++++++++++++ scripts/tetromino_selector.gd.uid | 1 + scripts/wave_config.gd | 68 ++++ scripts/wave_config.gd.uid | 1 + tetra-tactics.md | 101 ++++++ 36 files changed, 2201 insertions(+) create mode 100644 .agents/skills/godot-dev/SKILL.md create mode 100644 AGENTS.md create mode 100644 TETROMINO_SELECTOR.md create mode 100644 assets/models/MSH_1x1.res create mode 100644 assets/models/MSH_1x2_Orizzontale.res create mode 100644 assets/models/MSH_1x2_Verticale.res create mode 100644 assets/models/MSH_1x3.res create mode 100644 assets/tetra-tactics.md create mode 100644 resources/data/tetrominos/i_piece.tres create mode 100644 resources/data/tetrominos/o_piece.tres create mode 100644 resources/data/waves/wave_1.tres create mode 100644 resources/data/waves/wave_2.tres create mode 100644 resources/data/waves/wave_3.tres create mode 100644 scenes/board/board.tscn create mode 100644 scenes/board/board.tscn916401494.tmp create mode 100644 scenes/board/tetromino.tscn create mode 100644 scenes/encounter.tscn create mode 100644 scenes/event_bus.tscn create mode 100644 scripts/board_manager.gd create mode 100644 scripts/board_manager.gd.uid create mode 100644 scripts/encounter_manager.gd create mode 100644 scripts/encounter_manager.gd.uid create mode 100644 scripts/event_bus.gd create mode 100644 scripts/event_bus.gd.uid create mode 100644 scripts/grid_cell.gd create mode 100644 scripts/grid_cell.gd.uid create mode 100644 scripts/tetromino.gd create mode 100644 scripts/tetromino.gd.uid create mode 100644 scripts/tetromino_definition.gd create mode 100644 scripts/tetromino_definition.gd.uid create mode 100644 scripts/tetromino_selector.gd create mode 100644 scripts/tetromino_selector.gd.uid create mode 100644 scripts/wave_config.gd create mode 100644 scripts/wave_config.gd.uid create mode 100644 tetra-tactics.md diff --git a/.agents/skills/godot-dev/SKILL.md b/.agents/skills/godot-dev/SKILL.md new file mode 100644 index 0000000..27fe1fc --- /dev/null +++ b/.agents/skills/godot-dev/SKILL.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9581149 --- /dev/null +++ b/AGENTS.md @@ -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/" + } +} +``` diff --git a/TETROMINO_SELECTOR.md b/TETROMINO_SELECTOR.md new file mode 100644 index 0000000..92832a5 --- /dev/null +++ b/TETROMINO_SELECTOR.md @@ -0,0 +1,116 @@ +# 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 +- Ghost mode visualization (semi-transparent) +- Real-time position tracking with mouse movement +- Rotation with spacebar (45° increments) +- Placement validation using board manager +- 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 diff --git a/assets/models/MSH_1x1.res b/assets/models/MSH_1x1.res new file mode 100644 index 0000000000000000000000000000000000000000..eba61f59dd2abb2b650c1f58640ec65d0621e327 GIT binary patch literal 4894 zcmcgwOOG5i5cY;mfbdR$@DLEhLx@Y11tG+N4T1w4a!GDnjHlZ@V{K2naeMZGkTwE- z0sn<_IB?>`4UQc6AL0AT?e5;0fgq4@%WM0qa+O`KDtq?U?OPwKzU-nsk9q<1BI-vW zn3J2fTcDrH186?K_RG&d{Pf$i8T%*gw_th>HQ3ItLSc373sdXpbywKNs>i3a#w@LR zQjn&!LASyA;Npp_sjjX#JMG-L>Qbo5grEBJC27%I5)vB|jh_1EJV?sh`m(q0xE74{-bGC`P zcJoD4k4`9+pQ{%q<3WZmih9*K-w658hG>s<;cFi>m6}eQdS*+np26%hWY_5~%x7Yk z#FOT!C&1YQ5EfU#suwmOU3s31NO*day6nnIJq&zlqp53Gs7sT9&dXO%P0_6F`7NL> z7N%)zEyoo6YQAVJ4wX`ekCeQ79G4x3p|VcvBJ$fjE+lFEh?B~uqf>WFNp*xOJ+vt?6Ou0}ve!pu;xtu-kK^UWG;Vcj9_E8NchDBT;W&qsxC zO~D5P<<4AP&%6o!tAN+$2pup1B-`=fGY|equ4#5~d;1k)pGK|Ru`PAyzDk*#H*xEc zZxck0$xsYQ4iln70);8y5S?5eocWb5m|K!s-V|p5dLy|if2ch!_~7uCA=epz>7@<+ z*dc&VbY;phf_VUIBv?(NJYCPY%5{qk)f%s-xfPG({jU8g)~ zDJk)tkdeHZxNyaRIQWC47Knu?o0I&(`-0om3*MgGvrbV7XKJnHQ?!#Oie|bt1x7re zDUa-fQC_UN=$G1<*-V_M_eV%b4`@D~UqzL-?+4ee-G-G)IOa_Qhj*ra=M(+@q3hmN zf6$>&c#@fUc#G@Vd&h|F@2C-FJI|M)ey3EYv@Qy_wW&-!1b7FPp3hCuN4TEbX{Xoa zD9-gv%(`^!?>~EcKUbyh?9=3YiJ-JSN+@)J+rzYD(1!+xGCc;=yQI{ffTXEx!Y@{-9Q-oiYEGLbkMjZEA= zu7#8__Q<+c$V8`c_`!s5%e(wk*miIz=R`5hxrbRO6a+H>gb;wRq@z6@!h-kcP+?5-`)}>J(bP&R4I@k|1a8mqMx%*`3L1x{`M_q zo#MlKx3_edt>MTNN+P2Wukqk!t7yj{`Vp{FSND~=fy$OP`|-oE&A9669Fv~gjNbuv z6*P`X&+UNsdN?LMw}90bm~#V_?H$ysla@I7b4$A7{Jy=h&+W1Pui0i7Dlgz;K6wZ^ z>>|EL(0dfe@HoCF(0dZ8;u5~g=sk`9X*`SX3VK{D^Y&k58~Im6e{aei$a{w+@ARLi TqHi#ZE|71ooSJUoKVyFa3~Dk! literal 0 HcmV?d00001 diff --git a/assets/models/MSH_1x2_Orizzontale.res b/assets/models/MSH_1x2_Orizzontale.res new file mode 100644 index 0000000000000000000000000000000000000000..ff7ff064983ae16ea8ed3a027b034ca0fc3cc872 GIT binary patch literal 5770 zcmc&&Pmd%w758j5yUT78l0dSXkc0_@5R$+$jl_Wivj`61utM7X0FJxL)oo36x#X&z zKP#je0cV8x3W!g@2iSZIj$9(H962t(-*dUj)7|VwL<%l>{QP^*&(HSr^Xqx=@WC(D zP_CkV9`zdPP1Ik6V9xH_ZV5V;=g|Dq{q6HV{_7u$gng0r_Yk^+8f@p+p|HC4g{gJ# zbywKNs+V$JV^&tZEJ#z@pxfYlaQ&Is)Y#Krx2EgVi;(DpX}Z<~+Z1Q&7Jzwen^LcQ zX+^qN*LCbsUuoNZsmowaBF0%8uOr;st``+qms500Q@UmmM-V7oRjoTaspwjZc}$4N z>mt$_Sfb5p?rhWR+AWs7dLiRfexbgV^+z4PDC%|Rd?V~*8+v=H3t#)7IjQNispq!z z>NSkM#CDzT!eTD_5@q5z^>s)N0F?FXVAY#jkghz>MKpYKo4e}DN_`FZ%J!zNU7=ph zIy#xJUdhok+Z#JTT`W!0*jkP$`1NAhSR5*~jy;_e(A+I_)WA9I%5~f|Z5!@^hZP(;%}zfVpbn01 zfSU99FfGX zX_&C)E}olg)5J@5?@~6L32o@s^KKm~gg?{wh;hkrcvUueU61&-=$CS9x0pw+iGgRA z+GNf9JJd?-T)7@=#Klhry!T8=2}bJK8q?g*)+iBIJzH+>Jz_Kudv1!O=rzf1`pJlH z$RtbXJ;7GN6n!2l7(X&NI?9G@Qo)#=$qw-7hztcKBBYx_fkgcXtxR_$rVS$5V-=q# z8Mr>ywK+pr;`1~}4lzGN2Iu3l*T)C+BSaZqLiT)Bb+#7;?DheH&NL>7A74T=p106S z=w%on@5G0?%*MniW|yXzU7li=iPH^nRVQR?irLl_+_topm8)?fBw!{e*w&hO2}>7W z)?f?kj`3dMb@nIm-H0@k3g4Q72?O=cU0u(;2}4%EXLEv%7y#nlG4WXhe|+%NY0nI^@z6-qD0n}jiiJLy+Z)SR=^=Tc|17skuDfp;%O0-x#1l+z970c^0aYjVru^Ngolw^&fEkv%P}cqHF< z?bm(n8f+@p$qSIi#R(*<8j_Y6G-Tct0#Fi6IZe(srxTm&=z3&pxFDjA9-V-yHj7T4 z-Gmf=#pF&bOc-vTWxf}a#)QV8#5^G(X)*oM6-VOW*U2q#7kXKo#1Fm~T&`a7_2im$ zyp?dK)@r7rounw5;o21F@qls}SqE=`m(#Fi^;zWZ#LPJcTnLNLXD!K2^?%sWf zT`J+2_YEB0nffQc()DS7uvDMZp-^~|NqEe~wg0J|&D0+_Bg$@>>Ugy{Q2*xcqHtN; zlc^hk_fYBiToiqT_B@O$J&>cgG6*s1(y{;i>kki8RqEo9Th2@bT+YDElV){N!^a~c!m*^Upt9`YoNnOn75t@j zS8!gWZlI;iEqs)`C4H`d)Gf3$l^YOEEhHp!Q$9f@k5zs#lGy3S7hrG%%WjwBP}<9u zPJ931C;H%YrhgOMqes5MFCM$)lH5MZZ@A>`|Jfx!i#Ea~E)H||$=z_GbD7cg^0DF+ zxjgg&qZ_g$dK@eA15UY2X1ogd*jz^F$>vdKE-!R0liN1Y<6Im1bDcs{U((Mu$q#fc zv$a>7G8mi7j5a`S#B&+YxeR-pFWU2zwG1+&PWDrn5qi=y+W$CWq8~KNL{D=?olnm3 zJ$dr@DJ|H--`x@=KbGzGTq&@i|G%_PWPXl5=O2`F`8%{2b&e0~*xd>m?c=z|JNi^d ze0MAG|IdB~gqOAXcMk7xm;hu zFQ9WDGVGI|+7SmtKKtaS7Vwv}pU1=c6}_;0W0?x^4dp9(Ro=s!^6K5di}xaazP$v0 zc>!tIE69LfMH=@qUZ>a8>-cVix})Aea`Yy0?mOx&^)?cwcaYn>rS_3!eh1%oLA|HG zho9>2tM7yQK>a{{i0>m%AFCg#AK|+Tii>{Q3oHNj8*)_1H<{zHq#gU>&+qRriWzIl M64P`10RM&l4-!5{LI3~& literal 0 HcmV?d00001 diff --git a/assets/models/MSH_1x2_Verticale.res b/assets/models/MSH_1x2_Verticale.res new file mode 100644 index 0000000000000000000000000000000000000000..b66cd6deba9edb0b98651ae3b7c8727c972d9a67 GIT binary patch literal 5768 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 literal 0 HcmV?d00001 diff --git a/assets/tetra-tactics.md b/assets/tetra-tactics.md new file mode 100644 index 0000000..29f8ea1 --- /dev/null +++ b/assets/tetra-tactics.md @@ -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 diff --git a/project.godot b/project.godot index 22d8dc1..7813107 100644 --- a/project.godot +++ b/project.godot @@ -14,6 +14,45 @@ config/name="tetra-tactics" config/features=PackedStringArray("4.5", "GL Compatibility") 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] renderer/rendering_method="gl_compatibility" diff --git a/resources/data/tetrominos/i_piece.tres b/resources/data/tetrominos/i_piece.tres new file mode 100644 index 0000000..762a80f --- /dev/null +++ b/resources/data/tetrominos/i_piece.tres @@ -0,0 +1,7 @@ +[gd_resource type="Resource" script_class="TetrominoDefinition" load_steps=2 format=3 uid="uid://curn7voye0ewx"] + +[ext_resource type="Script" uid="uid://p168a1urs4im" path="res://scripts/tetromino_definition.gd" id="1_suu2q"] + +[resource] +script = ExtResource("1_suu2q") +metadata/_custom_type_script = "uid://p168a1urs4im" diff --git a/resources/data/tetrominos/o_piece.tres b/resources/data/tetrominos/o_piece.tres new file mode 100644 index 0000000..e418388 --- /dev/null +++ b/resources/data/tetrominos/o_piece.tres @@ -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" diff --git a/resources/data/waves/wave_1.tres b/resources/data/waves/wave_1.tres new file mode 100644 index 0000000..f303169 --- /dev/null +++ b/resources/data/waves/wave_1.tres @@ -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" diff --git a/resources/data/waves/wave_2.tres b/resources/data/waves/wave_2.tres new file mode 100644 index 0000000..58e2744 --- /dev/null +++ b/resources/data/waves/wave_2.tres @@ -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" diff --git a/resources/data/waves/wave_3.tres b/resources/data/waves/wave_3.tres new file mode 100644 index 0000000..3b81e65 --- /dev/null +++ b/resources/data/waves/wave_3.tres @@ -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" diff --git a/scenes/board/board.tscn b/scenes/board/board.tscn new file mode 100644 index 0000000..a1cb571 --- /dev/null +++ b/scenes/board/board.tscn @@ -0,0 +1,30 @@ +[gd_scene load_steps=5 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"] + +[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")] + +[node name="TetrominoSelector" type="Node3D" parent="."] +script = ExtResource("3_jnyf0") diff --git a/scenes/board/board.tscn916401494.tmp b/scenes/board/board.tscn916401494.tmp new file mode 100644 index 0000000..5a7e179 --- /dev/null +++ b/scenes/board/board.tscn916401494.tmp @@ -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="."] diff --git a/scenes/board/tetromino.tscn b/scenes/board/tetromino.tscn new file mode 100644 index 0000000..4e06dbd --- /dev/null +++ b/scenes/board/tetromino.tscn @@ -0,0 +1,32 @@ +[gd_scene load_steps=6 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://e1oj3t6audj3" path="res://assets/models/MSH_1x3.res" id="3_f3wyc"] + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_f3wyc"] +albedo_color = Color(1, 0, 0, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_f3wyc"] +size = Vector3(3.0031738, 1, 1) + +[node name="Tetromino" type="Node3D"] +script = ExtResource("1_hprdj") +resource = ExtResource("2_f3wyc") +mesh_color = Color(1, 0, 0, 1) + +[node name="Visuals" type="Node3D" parent="."] + +[node name="MeshInstance3D" type="MeshInstance3D" parent="Visuals"] +layers = 2 +material_override = SubResource("StandardMaterial3D_f3wyc") +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"] +transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.6541168e-10, 0.49949223, -0.0037841797) +shape = SubResource("BoxShape3D_f3wyc") diff --git a/scenes/encounter.tscn b/scenes/encounter.tscn new file mode 100644 index 0000000..7849bb0 --- /dev/null +++ b/scenes/encounter.tscn @@ -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 diff --git a/scenes/event_bus.tscn b/scenes/event_bus.tscn new file mode 100644 index 0000000..ce6bcc7 --- /dev/null +++ b/scenes/event_bus.tscn @@ -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") diff --git a/scripts/board_manager.gd b/scripts/board_manager.gd new file mode 100644 index 0000000..54aee4e --- /dev/null +++ b/scripts/board_manager.gd @@ -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) diff --git a/scripts/board_manager.gd.uid b/scripts/board_manager.gd.uid new file mode 100644 index 0000000..d6305fa --- /dev/null +++ b/scripts/board_manager.gd.uid @@ -0,0 +1 @@ +uid://bvxt8mmibr1uj diff --git a/scripts/encounter_manager.gd b/scripts/encounter_manager.gd new file mode 100644 index 0000000..081eba7 --- /dev/null +++ b/scripts/encounter_manager.gd @@ -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 diff --git a/scripts/encounter_manager.gd.uid b/scripts/encounter_manager.gd.uid new file mode 100644 index 0000000..06854f3 --- /dev/null +++ b/scripts/encounter_manager.gd.uid @@ -0,0 +1 @@ +uid://dv2k6b3b3tfa6 diff --git a/scripts/event_bus.gd b/scripts/event_bus.gd new file mode 100644 index 0000000..93130ea --- /dev/null +++ b/scripts/event_bus.gd @@ -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 diff --git a/scripts/event_bus.gd.uid b/scripts/event_bus.gd.uid new file mode 100644 index 0000000..c676fa9 --- /dev/null +++ b/scripts/event_bus.gd.uid @@ -0,0 +1 @@ +uid://cbus31k0tae6i diff --git a/scripts/grid_cell.gd b/scripts/grid_cell.gd new file mode 100644 index 0000000..42d560a --- /dev/null +++ b/scripts/grid_cell.gd @@ -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" + ] diff --git a/scripts/grid_cell.gd.uid b/scripts/grid_cell.gd.uid new file mode 100644 index 0000000..c3e481c --- /dev/null +++ b/scripts/grid_cell.gd.uid @@ -0,0 +1 @@ +uid://dygdtu1vjr1rt diff --git a/scripts/tetromino.gd b/scripts/tetromino.gd new file mode 100644 index 0000000..4cc1b1a --- /dev/null +++ b/scripts/tetromino.gd @@ -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() + ] diff --git a/scripts/tetromino.gd.uid b/scripts/tetromino.gd.uid new file mode 100644 index 0000000..936347f --- /dev/null +++ b/scripts/tetromino.gd.uid @@ -0,0 +1 @@ +uid://c8041v2usigk4 diff --git a/scripts/tetromino_definition.gd b/scripts/tetromino_definition.gd new file mode 100644 index 0000000..87b5925 --- /dev/null +++ b/scripts/tetromino_definition.gd @@ -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 diff --git a/scripts/tetromino_definition.gd.uid b/scripts/tetromino_definition.gd.uid new file mode 100644 index 0000000..53a2059 --- /dev/null +++ b/scripts/tetromino_definition.gd.uid @@ -0,0 +1 @@ +uid://p168a1urs4im diff --git a/scripts/tetromino_selector.gd b/scripts/tetromino_selector.gd new file mode 100644 index 0000000..75ccb92 --- /dev/null +++ b/scripts/tetromino_selector.gd @@ -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 diff --git a/scripts/tetromino_selector.gd.uid b/scripts/tetromino_selector.gd.uid new file mode 100644 index 0000000..d090e7d --- /dev/null +++ b/scripts/tetromino_selector.gd.uid @@ -0,0 +1 @@ +uid://bv7xi75mklk7d diff --git a/scripts/wave_config.gd b/scripts/wave_config.gd new file mode 100644 index 0000000..4eaa748 --- /dev/null +++ b/scripts/wave_config.gd @@ -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 diff --git a/scripts/wave_config.gd.uid b/scripts/wave_config.gd.uid new file mode 100644 index 0000000..2a1a24e --- /dev/null +++ b/scripts/wave_config.gd.uid @@ -0,0 +1 @@ +uid://c0xlr04kjhr22 diff --git a/tetra-tactics.md b/tetra-tactics.md new file mode 100644 index 0000000..29f8ea1 --- /dev/null +++ b/tetra-tactics.md @@ -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