chore: initial scaffold

This commit is contained in:
Xanarch 2026-06-03 21:53:16 -04:00
commit 3b13ae7c23
53 changed files with 2618 additions and 0 deletions

View file

@ -0,0 +1,20 @@
---
name: Bug Report
about: Something isn't working correctly
labels: "type: fix"
---
## Describe the Bug
## Steps to Reproduce
1.
2.
## Expected Behavior
## Actual Behavior
## Environment
- Godot version:
- OS:
- Build (main/develop/branch):

View file

@ -0,0 +1,14 @@
---
name: Balance / Data Change
about: Tower stats, wave definitions, economy values
labels: "type: data"
---
## What to Change
<!-- Which JSON file(s), which values -->
## Current Values
## Proposed Values
## Reasoning

View file

@ -0,0 +1,18 @@
---
name: Feature Request
about: Propose a new gameplay feature
labels: "type: feature"
---
## Summary
<!-- One-sentence description -->
## Motivation
<!-- Why is this needed? What problem does it solve? -->
## Acceptance Criteria
- [ ]
- [ ]
## Notes
<!-- Mockups, references, edge cases -->

View file

@ -0,0 +1,19 @@
## Summary
<!-- What does this PR do? -->
## Related Issue
Closes #
## Type of Change
- [ ] Feature
- [ ] Bug fix
- [ ] Balance/data change
- [ ] Chore/refactor
## Testing
- [ ] Ran GUT tests locally
- [ ] Manually tested in Godot editor
- [ ] JSON data validated
## Screenshots / Video
<!-- Optional — paste gameplay clips for UI/visual changes -->

62
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,62 @@
name: CI
on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]
jobs:
validate-data:
name: Validate JSON data files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check JSON syntax
run: |
find data -name "*.json" | while read f; do
python3 -m json.tool "$f" > /dev/null && echo "OK: $f" || { echo "FAIL: $f"; exit 1; }
done
unit-tests:
name: Run GUT tests
runs-on: ubuntu-latest
needs: validate-data
steps:
- uses: actions/checkout@v4
- name: Install Godot 4
run: |
wget -q https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip
unzip -q Godot_v4.3-stable_linux.x86_64.zip
chmod +x Godot_v4.3-stable_linux.x86_64
sudo mv Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot
- name: Run GUT tests
run: |
godot --headless --path . -s addons/gut/gut_cmdln.gd \
-gdir=res://tests/ -gexit
export-web:
name: Export HTML5 build
runs-on: ubuntu-latest
needs: unit-tests
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Install Godot + export templates
run: |
wget -q https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip
unzip -q Godot_v4.3-stable_linux.x86_64.zip
sudo mv Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot
mkdir -p ~/.local/share/godot/export_templates/4.3.stable
wget -q https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_export_templates.tpz
unzip -q Godot_v4.3-stable_export_templates.tpz -d /tmp/templates
mv /tmp/templates/templates/* ~/.local/share/godot/export_templates/4.3.stable/
- name: Export HTML5
run: |
mkdir -p build/web
godot --headless --export-release "Web" build/web/index.html
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: web-build
path: build/web/

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
# Godot 4 generated files
.godot/
*.translation
# Export presets are machine-specific
export_presets.cfg
# Build output
build/
# OS
.DS_Store
Thumbs.db

247
FORGEJO_WORKFLOW.md Normal file
View file

@ -0,0 +1,247 @@
# Forgejo Workflow — Squadron TD Clone
## Branch Strategy
```
main ← stable, always playable
develop ← integration branch; PRs merge here first
feature/* ← new features (e.g. feature/ghost-race-towers)
fix/* ← bug fixes (e.g. fix/fighter-targeting)
data/* ← balance/data-only changes (e.g. data/wave-12-rebalance)
chore/* ← tooling, CI, docs (e.g. chore/update-gitignore)
```
**Rules:**
- `main` only receives merges from `develop` via a release PR.
- `develop` requires at least 1 review before merge.
- Feature branches are deleted after merge.
- Direct pushes to `main` and `develop` are protected.
---
## Labels
| Label | Color | Purpose |
|---|---|---|
| `type: feature` | #0075ca | New gameplay feature |
| `type: fix` | #d73a4a | Bug fix |
| `type: data` | #e4e669 | Balance/data change only |
| `type: chore` | #cccccc | Tooling, CI, refactor |
| `type: docs` | #0052cc | Documentation |
| `scope: towers` | #b60205 | Tower/fighter systems |
| `scope: economy` | #fbca04 | Mineral/gas/income systems |
| `scope: waves` | #006b75 | Wave definitions or spawning |
| `scope: ui` | #e99695 | HUD, menus, build panel |
| `scope: ai` | #c5def5 | Fighter or creep AI |
| `scope: multiplayer` | #bfd4f2 | Networking |
| `priority: high` | #b60205 | Blocking or critical |
| `priority: low` | #eeeeee | Nice to have |
| `status: blocked` | #e4e669 | Waiting on something else |
| `good first issue` | #7057ff | Approachable for new contributors |
---
## Milestones
| Milestone | Goal |
|---|---|
| v0.1 — Prototype | Single lane, 1 race, 10 waves, no economy |
| v0.2 — Economy | Workers, minerals, gas, send system |
| v0.3 — Full Waves | All 31 waves with data-driven definitions |
| v0.4 — All Races | 4 builder races with full tower trees |
| v0.5 — Multiplayer | 2-player co-op over local network |
| v1.0 — Release | Polished, tested, exportable build |
---
## Issue Templates
Create these as files in `.forgejo/ISSUE_TEMPLATE/` in your repo.
### `feature.md`
```markdown
---
name: Feature Request
about: Propose a new gameplay feature
labels: "type: feature"
---
## Summary
<!-- One-sentence description -->
## Motivation
<!-- Why is this needed? What problem does it solve? -->
## Acceptance Criteria
- [ ]
- [ ]
## Notes
<!-- Mockups, references, edge cases -->
```
### `bug.md`
```markdown
---
name: Bug Report
about: Something isn't working correctly
labels: "type: fix"
---
## Describe the Bug
## Steps to Reproduce
1.
2.
## Expected Behavior
## Actual Behavior
## Environment
- Godot version:
- OS:
- Build (main/develop/branch):
```
### `data.md`
```markdown
---
name: Balance / Data Change
about: Tower stats, wave definitions, economy values
labels: "type: data"
---
## What to Change
<!-- Which JSON file(s), which values -->
## Current Values
## Proposed Values
## Reasoning
```
---
## CI Pipeline (Forgejo Actions)
Create `.forgejo/workflows/ci.yml`:
```yaml
name: CI
on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]
jobs:
validate-data:
name: Validate JSON data files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check JSON syntax
run: |
for f in data/**/*.json; do
python3 -m json.tool "$f" > /dev/null && echo "OK: $f" || exit 1
done
unit-tests:
name: Run GUT tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Godot 4
run: |
wget -q https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip
unzip -q Godot_v4.3-stable_linux.x86_64.zip
chmod +x Godot_v4.3-stable_linux.x86_64
sudo mv Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot
- name: Run GUT tests
run: |
godot --headless --path . -s addons/gut/gut_cmdln.gd \
-gdir=res://tests/ -gexit
export-web:
name: Export HTML5 build
runs-on: ubuntu-latest
needs: [validate-data, unit-tests]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Install Godot + export templates
run: |
wget -q https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip
unzip -q Godot_v4.3-stable_linux.x86_64.zip
sudo mv Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot
mkdir -p ~/.local/share/godot/export_templates/4.3.stable
wget -q https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_export_templates.tpz
unzip -q Godot_v4.3-stable_export_templates.tpz -d /tmp/templates
mv /tmp/templates/templates/* ~/.local/share/godot/export_templates/4.3.stable/
- name: Export HTML5
run: |
mkdir -p build/web
godot --headless --export-release "Web" build/web/index.html
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: web-build
path: build/web/
```
---
## PR Template
Create `.forgejo/PULL_REQUEST_TEMPLATE.md`:
```markdown
## Summary
<!-- What does this PR do? -->
## Related Issue
Closes #
## Type of Change
- [ ] Feature
- [ ] Bug fix
- [ ] Balance/data change
- [ ] Chore/refactor
## Testing
- [ ] Ran GUT tests locally
- [ ] Manually tested in Godot editor
- [ ] JSON data validated
## Screenshots / Video
<!-- Optional — paste gameplay clips for UI/visual changes -->
```
---
## Recommended Forgejo Settings
- **Default branch:** `develop`
- **Branch protection on `main`:** require PR + 1 review + CI pass
- **Branch protection on `develop`:** require CI pass
- **Auto-delete head branch after merge:** enabled
- **Squash merges:** enabled for `feature/*` and `fix/*`; merge commits for release PRs to `main`
---
## Typical Feature Workflow
```
1. Create issue with "type: feature" + relevant scope label
2. Branch: git checkout -b feature/<slug> develop
3. Work → commit with conventional commits:
feat(towers): add Ghost tier-2 upgrade
fix(economy): correct income calculation on send
data(waves): adjust wave 15 creep count
4. Open PR → link issue → CI runs
5. Review + merge to develop (squash)
6. When milestone complete → PR develop → main (merge commit)
```

157
GDD.md Normal file
View file

@ -0,0 +1,157 @@
# Squadron TD Clone — Game Design Document
## Overview
A 18 player cooperative/competitive tower defense game inspired by Squadron Tower Defense (SC2 Arcade). Players choose a **builder race**, place towers that transform into fighters during combat, manage a worker economy, and optionally send bonus enemies at opponents. Survive 31 waves to win.
---
## Core Loop
```
Pick builder race → Place towers → Wave combat → Collect income → Send enemies → Repeat
```
Each round:
1. **Build phase** — players spend minerals to place/upgrade towers.
2. **Combat phase** — towers transform into fighter units and engage the wave automatically.
3. **Income phase** — mineral income is paid out based on cumulative sends and upgrades.
---
## Player Count & Teams
- **Solo (1):** one lane, self-sends only.
- **Co-op (24):** shared team security system, each player holds their own lane.
- **PvP (4v4):** two teams; players send enemies across to opponents.
---
## Lanes & Security System
- Each player defends a **lane** of creep pathing.
- All lanes converge on the **Security System** (shared HP pool per team).
- If the Security System reaches 0 HP, the team loses.
---
## Builder Races
Each race has a unique tower roster, stats profile, and upgrade tree. All races are balanced around different timing windows.
| Race | Profile | Strengths |
|---|---|---|
| Ghost | High damage, low HP (glass cannon) | Early & late game |
| Fortress | Low damage, high HP + buffs/debuffs | Early & mid game |
| Phantom | Evasive, mixed melee/ranged | Mid & late game |
| Ancient | Slow, extremely high damage AoE | Late game |
Each race has roughly **812 towers** in a **tier tree**:
- Tier 1: base units (cheap, unlocked immediately)
- Tier 2: upgrades from Tier 1 (requires minerals + sometimes gas)
- Tier 3: capstone units (high cost, powerful)
Upgrading a tower **in place** morphs the sprite and updates stats.
---
## Towers / Fighters
Towers placed during build phase transform into **fighter units** during combat. Fighters are fully AI-controlled.
### Tower Data Fields
| Field | Type | Notes |
|---|---|---|
| id | String | Unique identifier |
| race | String | Builder race |
| tier | Int | 13 |
| cost_minerals | Int | Build cost |
| cost_gas | Int | Upgrade cost (0 for tier 1) |
| hp | Int | Fighter HP |
| armor_type | Enum | Light / Medium / Heavy / Unarmored |
| attack_type | Enum | Normal / Explosive / Concussive / Chaos |
| damage | Int | Base damage per hit |
| attack_speed | Float | Attacks per second |
| range | Float | Attack range in tiles |
| move_speed | Float | Fighter move speed |
| upgrades_to | String? | Next tier tower ID |
| strong_vs_waves | [Int] | Wave numbers this unit excels at |
### Armor/Attack Type Matrix (damage multipliers)
| | Normal | Explosive | Concussive | Chaos |
|---|---|---|---|---|
| Light | 100% | 75% | 150% | 100% |
| Medium | 100% | 100% | 75% | 100% |
| Heavy | 100% | 150% | 50% | 100% |
| Unarmored | 100% | 75% | 100% | 100% |
---
## Economy
### Minerals
- Passive income per round (base ~50, slight scaling).
- Bonus income earned cumulatively by **sending** units or upgrading the Security System.
- Spent on: building/upgrading towers.
### Gas
- Generated by **worker units** each round.
- Spent on: sends, Security System upgrades, supply cap raises.
### Workers
- Each player starts with a few workers.
- Build more workers early — aim for ~20 by wave 25.
- Workers cost minerals; balance worker vs. tower investment.
### Sends
- Spend gas to send bonus creeps into an opponent's lane (or self in solo).
- Each send type adds a permanent per-round income bonus.
- Send income is cumulative — early sending snowballs economy.
---
## Waves
31 total waves. Each wave definition:
| Field | Notes |
|---|---|
| wave_number | 131 |
| creep_type | Enum: Light, Armored, Mixed, Boss |
| creep_armor | Armor type |
| creep_count | Number of units |
| creep_hp | Base HP |
| creep_speed | Move speed |
| bounty | Minerals per kill |
- Waves 110: economy building phase, lighter creeps.
- Waves 1120: increasing pressure.
- Waves 2130: heavy creeps, economy should be near max.
- Wave 31: boss/final wave.
---
## Security System
- Shared team HP pool (e.g. 20 HP per player slot).
- Creeps reaching the exit drain 15 HP depending on type.
- Can be upgraded for gas (increases max HP and/or armor).
---
## Victory / Defeat
- **Win:** Survive all 31 waves with Security System HP > 0.
- **Lose:** Security System reaches 0 HP.
- Scored by: waves survived, sends sent, Security System HP remaining.
---
## Stretch Features (post-MVP)
- Chaos Mode (random race each round)
- Spectator mode
- Replay system
- Persistent leaderboard

137
PROJECT_STRUCTURE.md Normal file
View file

@ -0,0 +1,137 @@
# Godot 4 Project Structure — Squadron TD Clone
## Top-Level Layout
```
squadron-td/
├── project.godot
├── export_presets.cfg
├── .gitignore
├── CHANGELOG.md
├── assets/ # Raw art & audio (imported by Godot)
│ ├── sprites/
│ │ ├── towers/ # One folder per race
│ │ │ ├── ghost/
│ │ │ ├── fortress/
│ │ │ ├── phantom/
│ │ │ └── ancient/
│ │ ├── creeps/
│ │ ├── ui/
│ │ └── environment/
│ ├── audio/
│ │ ├── sfx/
│ │ └── music/
│ └── fonts/
├── data/ # Game balance data (JSON or GDScript constants)
│ ├── towers/ # One .json per race, defines all tower entries
│ │ ├── ghost.json
│ │ ├── fortress.json
│ │ ├── phantom.json
│ │ └── ancient.json
│ ├── waves.json # All 31 wave definitions
│ └── sends.json # Send unit definitions + income values
├── scenes/ # Godot .tscn scene files
│ ├── main/
│ │ ├── Main.tscn # Root scene, bootstraps game
│ │ └── GameLoop.tscn # Phase state machine
│ ├── ui/
│ │ ├── HUD.tscn # In-game HUD (minerals, gas, wave counter)
│ │ ├── BuildPanel.tscn # Tower build/upgrade UI
│ │ ├── RaceSelect.tscn # Builder race selection screen
│ │ └── GameOver.tscn
│ ├── gameplay/
│ │ ├── Lane.tscn # One player's lane (tilemap + path)
│ │ ├── SecuritySystem.tscn
│ │ ├── TowerSlot.tscn # Placeable cell on the lane grid
│ │ ├── Tower.tscn # Base tower scene (morphs to fighter)
│ │ ├── Fighter.tscn # Fighter unit (AI-controlled during combat)
│ │ ├── Creep.tscn # Enemy unit following path
│ │ └── Worker.tscn # Economy worker
│ └── effects/
│ ├── Projectile.tscn
│ └── HitEffect.tscn
├── scripts/ # GDScript source files
│ ├── autoload/ # Singletons (add to Project > Autoload)
│ │ ├── GameState.gd # Global game state (phase, wave number, etc.)
│ │ ├── Economy.gd # Mineral/gas tracking and income calculation
│ │ ├── WaveManager.gd # Wave sequencing and creep spawning
│ │ └── EventBus.gd # Global signal bus (decoupled communication)
│ ├── gameplay/
│ │ ├── Lane.gd
│ │ ├── TowerSlot.gd
│ │ ├── Tower.gd # Data-driven; loads stats from data/towers/
│ │ ├── Fighter.gd # AI: seek nearest creep, attack
│ │ ├── Creep.gd # Path follow, HP, armor type
│ │ ├── Worker.gd
│ │ └── SecuritySystem.gd
│ ├── ui/
│ │ ├── HUD.gd
│ │ ├── BuildPanel.gd
│ │ ├── RaceSelect.gd
│ │ └── GameOver.gd
│ ├── data/
│ │ ├── TowerData.gd # Resource class for tower definitions
│ │ ├── WaveData.gd # Resource class for wave definitions
│ │ └── DataLoader.gd # Loads and validates JSON data files
│ └── utils/
│ ├── DamageCalc.gd # Armor/attack type damage matrix
│ └── PathUtils.gd # Pathfinding helpers
└── tests/ # GUT (Godot Unit Testing) test files
├── test_damage_calc.gd
├── test_economy.gd
└── test_wave_data.gd
```
---
## Key Architectural Decisions
### Data-Driven Towers
Tower stats live in `data/towers/<race>.json`. The `Tower.gd` script reads these at runtime — adding a new tower means editing JSON, not code.
### Phase State Machine
`GameLoop.tscn` drives a state machine with three states: `BUILD`, `COMBAT`, `INCOME`. Transitions are signaled via `EventBus`.
### Autoload Singletons
| Singleton | Responsibility |
|---|---|
| GameState | Current phase, wave number, player list |
| Economy | Per-player minerals/gas, income tracking |
| WaveManager | Spawning creeps, tracking wave completion |
| EventBus | Decoupled signals (wave_started, creep_died, etc.) |
### Fighter AI
During `COMBAT` phase, towers emit their fighter node (or morph in place). `Fighter.gd` uses a simple priority: find nearest creep in range → attack. No pathfinding needed for fighters — they engage from position.
### Multiplayer (stretch)
Use Godot 4's built-in `MultiplayerAPI` (ENet or WebRTC). `GameState` and `Economy` will need `@rpc` annotations. Design scripts to be authority-aware from the start.
---
## Godot Project Settings to Configure
- **Main Scene:** `scenes/main/Main.tscn`
- **Autoloads:** GameState, Economy, WaveManager, EventBus
- **Input Map:** `build_mode`, `cancel`, `next_wave`, `send_unit`
- **Rendering:** 2D, pixel art mode if using pixel sprites (`snap 2D transforms`)
- **Physics Layers:** Layer 1 = Creeps, Layer 2 = Fighters, Layer 3 = Projectiles
---
## .gitignore (Godot 4)
```gitignore
# Godot 4 generated files
.godot/
*.translation
export_presets.cfg # machine-specific, keep out of repo unless needed
# OS
.DS_Store
Thumbs.db
```

57
data/sends.json Normal file
View file

@ -0,0 +1,57 @@
[
{
"id": "send_light_squad",
"label": "Light Squad",
"cost_gas": 50,
"income_bonus": 10,
"creep_armor": 0,
"creep_count": 5,
"creep_hp": 200,
"creep_speed": 100.0,
"hp_damage": 1
},
{
"id": "send_armored_squad",
"label": "Armored Squad",
"cost_gas": 100,
"income_bonus": 20,
"creep_armor": 2,
"creep_count": 3,
"creep_hp": 500,
"creep_speed": 75.0,
"hp_damage": 2
},
{
"id": "send_swarm",
"label": "Swarm",
"cost_gas": 75,
"income_bonus": 15,
"creep_armor": 0,
"creep_count": 12,
"creep_hp": 120,
"creep_speed": 130.0,
"hp_damage": 1
},
{
"id": "send_heavy_assault",
"label": "Heavy Assault",
"cost_gas": 200,
"income_bonus": 40,
"creep_armor": 2,
"creep_count": 5,
"creep_hp": 900,
"creep_speed": 65.0,
"hp_damage": 3
},
{
"id": "send_titan",
"label": "Titan",
"cost_gas": 500,
"income_bonus": 100,
"creep_armor": 2,
"creep_count": 1,
"creep_hp": 8000,
"creep_speed": 45.0,
"hp_damage": 8
}
]

50
data/towers/ancient.json Normal file
View file

@ -0,0 +1,50 @@
[
{
"id": "ancient_sentinel",
"race": "ancient",
"tier": 1,
"cost_minerals": 120,
"cost_gas": 0,
"hp": 500,
"armor_type": 2,
"attack_type": 1,
"damage": 30,
"attack_speed": 0.4,
"range": 3.5,
"move_speed": 80.0,
"upgrades_to": "ancient_colossus",
"strong_vs_waves": [4, 7, 10]
},
{
"id": "ancient_colossus",
"race": "ancient",
"tier": 2,
"cost_minerals": 250,
"cost_gas": 60,
"hp": 1000,
"armor_type": 2,
"attack_type": 1,
"damage": 75,
"attack_speed": 0.35,
"range": 4.5,
"move_speed": 70.0,
"upgrades_to": "ancient_titan",
"strong_vs_waves": [13, 17, 20, 22]
},
{
"id": "ancient_titan",
"race": "ancient",
"tier": 3,
"cost_minerals": 500,
"cost_gas": 150,
"hp": 2200,
"armor_type": 2,
"attack_type": 3,
"damage": 200,
"attack_speed": 0.3,
"range": 5.5,
"move_speed": 60.0,
"upgrades_to": "",
"strong_vs_waves": [25, 27, 29, 30, 31]
}
]

50
data/towers/fortress.json Normal file
View file

@ -0,0 +1,50 @@
[
{
"id": "fortress_guardian",
"race": "fortress",
"tier": 1,
"cost_minerals": 100,
"cost_gas": 0,
"hp": 350,
"armor_type": 2,
"attack_type": 0,
"damage": 12,
"attack_speed": 0.8,
"range": 2.5,
"move_speed": 120.0,
"upgrades_to": "fortress_bastion",
"strong_vs_waves": [1, 2, 3, 5]
},
{
"id": "fortress_bastion",
"race": "fortress",
"tier": 2,
"cost_minerals": 200,
"cost_gas": 40,
"hp": 700,
"armor_type": 2,
"attack_type": 0,
"damage": 28,
"attack_speed": 0.7,
"range": 3.0,
"move_speed": 110.0,
"upgrades_to": "fortress_citadel",
"strong_vs_waves": [7, 10, 13, 16]
},
{
"id": "fortress_citadel",
"race": "fortress",
"tier": 3,
"cost_minerals": 400,
"cost_gas": 100,
"hp": 1400,
"armor_type": 2,
"attack_type": 0,
"damage": 55,
"attack_speed": 0.6,
"range": 3.5,
"move_speed": 100.0,
"upgrades_to": "",
"strong_vs_waves": [20, 22, 25, 29]
}
]

82
data/towers/ghost.json Normal file
View file

@ -0,0 +1,82 @@
[
{
"id": "ghost_rifleman",
"race": "ghost",
"tier": 1,
"cost_minerals": 75,
"cost_gas": 0,
"hp": 120,
"armor_type": 0,
"attack_type": 1,
"damage": 18,
"attack_speed": 1.2,
"range": 4.5,
"move_speed": 200.0,
"upgrades_to": "ghost_sniper",
"strong_vs_waves": [1, 2, 3, 6]
},
{
"id": "ghost_sniper",
"race": "ghost",
"tier": 2,
"cost_minerals": 150,
"cost_gas": 25,
"hp": 180,
"armor_type": 0,
"attack_type": 1,
"damage": 42,
"attack_speed": 0.7,
"range": 7.0,
"move_speed": 190.0,
"upgrades_to": "ghost_phantom_operative",
"strong_vs_waves": [4, 7, 10, 13]
},
{
"id": "ghost_phantom_operative",
"race": "ghost",
"tier": 3,
"cost_minerals": 300,
"cost_gas": 75,
"hp": 250,
"armor_type": 0,
"attack_type": 2,
"damage": 90,
"attack_speed": 0.8,
"range": 8.0,
"move_speed": 210.0,
"upgrades_to": "",
"strong_vs_waves": [20, 25, 28, 31]
},
{
"id": "ghost_medic",
"race": "ghost",
"tier": 1,
"cost_minerals": 80,
"cost_gas": 0,
"hp": 150,
"armor_type": 0,
"attack_type": 0,
"damage": 8,
"attack_speed": 1.5,
"range": 3.0,
"move_speed": 180.0,
"upgrades_to": "ghost_field_surgeon",
"strong_vs_waves": [1, 2, 5, 8]
},
{
"id": "ghost_field_surgeon",
"race": "ghost",
"tier": 2,
"cost_minerals": 175,
"cost_gas": 30,
"hp": 200,
"armor_type": 0,
"attack_type": 0,
"damage": 20,
"attack_speed": 1.2,
"range": 4.0,
"move_speed": 175.0,
"upgrades_to": "",
"strong_vs_waves": [9, 11, 14, 18]
}
]

50
data/towers/phantom.json Normal file
View file

@ -0,0 +1,50 @@
[
{
"id": "phantom_scout",
"race": "phantom",
"tier": 1,
"cost_minerals": 85,
"cost_gas": 0,
"hp": 160,
"armor_type": 0,
"attack_type": 2,
"damage": 22,
"attack_speed": 1.4,
"range": 5.0,
"move_speed": 230.0,
"upgrades_to": "phantom_specter",
"strong_vs_waves": [2, 3, 6, 8]
},
{
"id": "phantom_specter",
"race": "phantom",
"tier": 2,
"cost_minerals": 180,
"cost_gas": 35,
"hp": 240,
"armor_type": 0,
"attack_type": 2,
"damage": 50,
"attack_speed": 1.2,
"range": 6.0,
"move_speed": 240.0,
"upgrades_to": "phantom_wraith",
"strong_vs_waves": [9, 11, 15, 18]
},
{
"id": "phantom_wraith",
"race": "phantom",
"tier": 3,
"cost_minerals": 350,
"cost_gas": 90,
"hp": 320,
"armor_type": 0,
"attack_type": 3,
"damage": 110,
"attack_speed": 1.0,
"range": 7.0,
"move_speed": 260.0,
"upgrades_to": "",
"strong_vs_waves": [23, 26, 28, 31]
}
]

124
data/waves.json Normal file
View file

@ -0,0 +1,124 @@
[
{
"wave_number": 1,
"label": "Light Infantry",
"creep_armor": 0,
"creep_count": 10,
"creep_hp": 80,
"creep_speed": 90.0,
"bounty": 5,
"hp_damage": 1
},
{
"wave_number": 2,
"label": "Light Infantry+",
"creep_armor": 0,
"creep_count": 12,
"creep_hp": 100,
"creep_speed": 95.0,
"bounty": 5,
"hp_damage": 1
},
{
"wave_number": 3,
"label": "Medium Scouts",
"creep_armor": 1,
"creep_count": 10,
"creep_hp": 150,
"creep_speed": 85.0,
"bounty": 7,
"hp_damage": 1
},
{
"wave_number": 4,
"label": "Heavy Tanks",
"creep_armor": 2,
"creep_count": 6,
"creep_hp": 300,
"creep_speed": 70.0,
"bounty": 10,
"hp_damage": 2
},
{
"wave_number": 5,
"label": "Mixed Wave",
"creep_armor": 0,
"creep_count": 15,
"creep_hp": 120,
"creep_speed": 100.0,
"bounty": 6,
"hp_damage": 1
},
{
"wave_number": 6,
"label": "Unarmored Swarm",
"creep_armor": 3,
"creep_count": 20,
"creep_hp": 90,
"creep_speed": 110.0,
"bounty": 4,
"hp_damage": 1
},
{
"wave_number": 7,
"label": "Heavy Assault",
"creep_armor": 2,
"creep_count": 8,
"creep_hp": 400,
"creep_speed": 65.0,
"bounty": 12,
"hp_damage": 2
},
{
"wave_number": 8,
"label": "Light Speeders",
"creep_armor": 0,
"creep_count": 18,
"creep_hp": 160,
"creep_speed": 130.0,
"bounty": 7,
"hp_damage": 1
},
{
"wave_number": 9,
"label": "Medium Armored",
"creep_armor": 1,
"creep_count": 12,
"creep_hp": 250,
"creep_speed": 90.0,
"bounty": 9,
"hp_damage": 1
},
{
"wave_number": 10,
"label": "Mini-Boss",
"creep_armor": 2,
"creep_count": 3,
"creep_hp": 1200,
"creep_speed": 55.0,
"bounty": 40,
"hp_damage": 4
},
{
"wave_number": 11, "label": "Wave 11", "creep_armor": 0, "creep_count": 20, "creep_hp": 300, "creep_speed": 100.0, "bounty": 8, "hp_damage": 1 },
{ "wave_number": 12, "label": "Wave 12", "creep_armor": 1, "creep_count": 16, "creep_hp": 400, "creep_speed": 95.0, "bounty": 10, "hp_damage": 2 },
{ "wave_number": 13, "label": "Wave 13", "creep_armor": 2, "creep_count": 10, "creep_hp": 600, "creep_speed": 80.0, "bounty": 14, "hp_damage": 2 },
{ "wave_number": 14, "label": "Wave 14", "creep_armor": 3, "creep_count": 25, "creep_hp": 200, "creep_speed": 115.0, "bounty": 6, "hp_damage": 1 },
{ "wave_number": 15, "label": "Wave 15", "creep_armor": 0, "creep_count": 22, "creep_hp": 450, "creep_speed": 105.0, "bounty": 11, "hp_damage": 2 },
{ "wave_number": 16, "label": "Wave 16", "creep_armor": 1, "creep_count": 18, "creep_hp": 550, "creep_speed": 100.0, "bounty": 12, "hp_damage": 2 },
{ "wave_number": 17, "label": "Wave 17", "creep_armor": 2, "creep_count": 12, "creep_hp": 800, "creep_speed": 85.0, "bounty": 16, "hp_damage": 2 },
{ "wave_number": 18, "label": "Wave 18", "creep_armor": 0, "creep_count": 28, "creep_hp": 380, "creep_speed": 110.0, "bounty": 9, "hp_damage": 1 },
{ "wave_number": 19, "label": "Wave 19", "creep_armor": 2, "creep_count": 14, "creep_hp": 900, "creep_speed": 80.0, "bounty": 18, "hp_damage": 3 },
{ "wave_number": 20, "label": "Mid-Boss", "creep_armor": 2, "creep_count": 2, "creep_hp": 5000, "creep_speed": 50.0, "bounty": 100, "hp_damage": 5 },
{ "wave_number": 21, "label": "Wave 21", "creep_armor": 1, "creep_count": 25, "creep_hp": 700, "creep_speed": 110.0, "bounty": 14, "hp_damage": 2 },
{ "wave_number": 22, "label": "Wave 22", "creep_armor": 2, "creep_count": 16, "creep_hp": 1100, "creep_speed": 90.0, "bounty": 20, "hp_damage": 3 },
{ "wave_number": 23, "label": "Wave 23", "creep_armor": 0, "creep_count": 30, "creep_hp": 600, "creep_speed": 120.0, "bounty": 12, "hp_damage": 2 },
{ "wave_number": 24, "label": "Wave 24", "creep_armor": 3, "creep_count": 35, "creep_hp": 400, "creep_speed": 125.0, "bounty": 10, "hp_damage": 1 },
{ "wave_number": 25, "label": "Wave 25", "creep_armor": 2, "creep_count": 20, "creep_hp": 1400, "creep_speed": 85.0, "bounty": 22, "hp_damage": 3 },
{ "wave_number": 26, "label": "Wave 26", "creep_armor": 1, "creep_count": 28, "creep_hp": 1000, "creep_speed": 100.0, "bounty": 18, "hp_damage": 2 },
{ "wave_number": 27, "label": "Wave 27", "creep_armor": 2, "creep_count": 22, "creep_hp": 1600, "creep_speed": 90.0, "bounty": 25, "hp_damage": 3 },
{ "wave_number": 28, "label": "Wave 28", "creep_armor": 0, "creep_count": 35, "creep_hp": 900, "creep_speed": 115.0, "bounty": 16, "hp_damage": 2 },
{ "wave_number": 29, "label": "Wave 29", "creep_armor": 2, "creep_count": 25, "creep_hp": 2000, "creep_speed": 85.0, "bounty": 30, "hp_damage": 4 },
{ "wave_number": 30, "label": "Pre-Boss", "creep_armor": 2, "creep_count": 30, "creep_hp": 1800, "creep_speed": 95.0, "bounty": 28, "hp_damage": 3 },
{ "wave_number": 31, "label": "FINAL BOSS", "creep_armor": 2, "creep_count": 1, "creep_hp": 50000, "creep_speed": 40.0, "bounty": 500, "hp_damage": 20 }
]

45
project.godot Normal file
View file

@ -0,0 +1,45 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; but this file provides the initial bootstrap.
config_version=5
[application]
config/name="Squadron TD Clone"
config/description="A Squadron Tower Defense clone built in Godot 4"
run/main_scene="res://scenes/main/Main.tscn"
config/features=PackedStringArray("4.3", "Forward Plus")
config/icon="res://assets/sprites/ui/icon.png"
[autoload]
GameState="*res://scripts/autoload/GameState.gd"
Economy="*res://scripts/autoload/Economy.gd"
WaveManager="*res://scripts/autoload/WaveManager.gd"
EventBus="*res://scripts/autoload/EventBus.gd"
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
window/stretch/mode="canvas_items"
[input]
build_mode={
"deadzone": 0.5,
"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":66,"key_label":0,"unicode":98,"location":0,"echo":false,"script":null)]
}
cancel={
"deadzone": 0.5,
"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":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]
}
next_wave={
"deadzone": 0.5,
"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)]
}
[rendering]
textures/canvas_textures/default_texture_filter=0

View file

@ -0,0 +1,26 @@
[gd_scene load_steps=3 format=3 uid="uid://creep"]
[ext_resource type="Script" path="res://scripts/gameplay/Creep.gd" id="1"]
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_1"]
radius = 12.0
height = 24.0
[node name="Creep" type="CharacterBody2D"]
script = ExtResource("1")
[node name="Sprite2D" type="Sprite2D" parent="."]
scale = Vector2(0.5, 0.5)
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CapsuleShape2D_1")
[node name="HealthBar" type="ProgressBar" parent="."]
offset_left = -20.0
offset_top = -30.0
offset_right = 20.0
offset_bottom = -20.0
min_value = 0.0
max_value = 100.0
value = 100.0
show_percentage = false

View file

@ -0,0 +1,20 @@
[gd_scene load_steps=3 format=3 uid="uid://fighter"]
[ext_resource type="Script" path="res://scripts/gameplay/Fighter.gd" id="1"]
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_1"]
radius = 10.0
height = 20.0
[node name="Fighter" type="CharacterBody2D"]
script = ExtResource("1")
[node name="Sprite2D" type="Sprite2D" parent="."]
scale = Vector2(0.5, 0.5)
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CapsuleShape2D_1")
[node name="AttackRange" type="Area2D" parent="."]
[node name="RangeShape" type="CollisionShape2D" parent="AttackRange"]

12
scenes/gameplay/Lane.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3 uid="uid://lane"]
[ext_resource type="Script" path="res://scripts/gameplay/Lane.gd" id="1"]
[node name="Lane" type="Node2D"]
script = ExtResource("1")
player_id = 0
slot_columns = 4
slot_rows = 8
tile_size = 64.0
slot_color = Color(0.18, 0.22, 0.28, 0.85)
path_color = Color(0.35, 0.30, 0.20, 1.0)

View file

@ -0,0 +1,27 @@
[gd_scene load_steps=2 format=3 uid="uid://securitysystem"]
[ext_resource type="Script" path="res://scripts/gameplay/SecuritySystem.gd" id="1"]
[node name="SecuritySystem" type="Node2D"]
script = ExtResource("1")
max_hp = 20
[node name="Sprite2D" type="Sprite2D" parent="."]
[node name="HPBar" type="ProgressBar" parent="."]
offset_left = -60.0
offset_top = -50.0
offset_right = 60.0
offset_bottom = -34.0
min_value = 0.0
max_value = 20.0
value = 20.0
show_percentage = false
[node name="Label" type="Label" parent="."]
offset_left = -60.0
offset_top = -70.0
offset_right = 60.0
offset_bottom = -52.0
text = "SECURITY SYSTEM"
horizontal_alignment = 1

View file

@ -0,0 +1,16 @@
[gd_scene load_steps=2 format=3 uid="uid://tower"]
[ext_resource type="Script" path="res://scripts/gameplay/Tower.gd" id="1"]
[node name="Tower" type="Node2D"]
script = ExtResource("1")
[node name="Sprite2D" type="Sprite2D" parent="."]
scale = Vector2(0.5, 0.5)
[node name="Label" type="Label" parent="."]
offset_left = -24.0
offset_top = 20.0
offset_right = 24.0
offset_bottom = 36.0
horizontal_alignment = 1

View file

@ -0,0 +1,19 @@
[gd_scene load_steps=3 format=3 uid="uid://towerslot"]
[ext_resource type="Script" path="res://scripts/gameplay/TowerSlot.gd" id="1"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_1"]
size = Vector2(60.0, 60.0)
[node name="TowerSlot" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("RectangleShape2D_1")
[node name="SlotVisual" type="ColorRect" parent="."]
offset_left = -30.0
offset_top = -30.0
offset_right = 30.0
offset_bottom = 30.0
color = Color(0.2, 0.2, 0.2, 0.5)

27
scenes/main/GameLoop.tscn Normal file
View file

@ -0,0 +1,27 @@
[gd_scene load_steps=7 format=3 uid="uid://gameloop"]
[ext_resource type="Script" path="res://scripts/gameplay/GameLoop.gd" id="1"]
[ext_resource type="PackedScene" uid="uid://raceselect" path="res://scenes/ui/RaceSelect.tscn" id="2"]
[ext_resource type="PackedScene" uid="uid://hud" path="res://scenes/ui/HUD.tscn" id="3"]
[ext_resource type="PackedScene" uid="uid://buildpanel" path="res://scenes/ui/BuildPanel.tscn" id="4"]
[ext_resource type="PackedScene" uid="uid://lane" path="res://scenes/gameplay/Lane.tscn" id="5"]
[ext_resource type="PackedScene" uid="uid://securitysystem" path="res://scenes/gameplay/SecuritySystem.tscn" id="6"]
[ext_resource type="PackedScene" uid="uid://gameover" path="res://scenes/ui/GameOver.tscn" id="7"]
[node name="GameLoop" type="Node"]
script = ExtResource("1")
[node name="RaceSelect" parent="." instance=ExtResource("2")]
[node name="HUD" parent="." instance=ExtResource("3")]
[node name="BuildPanel" parent="." instance=ExtResource("4")]
[node name="Lane" parent="." instance=ExtResource("5")]
position = Vector2(280.0, 70.0)
player_id = 0
[node name="SecuritySystem" parent="." instance=ExtResource("6")]
position = Vector2(640.0, 600.0)
[node name="GameOver" parent="." instance=ExtResource("7")]

6
scenes/main/Main.tscn Normal file
View file

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

28
scenes/ui/BuildPanel.tscn Normal file
View file

@ -0,0 +1,28 @@
[gd_scene load_steps=2 format=3 uid="uid://buildpanel"]
[ext_resource type="Script" path="res://scripts/ui/BuildPanel.gd" id="1"]
[node name="BuildPanel" type="PanelContainer"]
offset_left = 0.0
offset_top = 60.0
offset_right = 260.0
offset_bottom = 500.0
visible = false
script = ExtResource("1")
[node name="VBox" type="VBoxContainer" parent="."]
offset_right = 260.0
offset_bottom = 440.0
[node name="RaceLabel" type="Label" parent="VBox"]
text = "Race"
horizontal_alignment = 1
[node name="ScrollContainer" type="ScrollContainer" parent="VBox"]
custom_minimum_size = Vector2(0, 300)
[node name="TowerList" type="VBoxContainer" parent="VBox/ScrollContainer"]
size_flags_horizontal = 3
[node name="CloseButton" type="Button" parent="VBox"]
text = "Close"

37
scenes/ui/GameOver.tscn Normal file
View file

@ -0,0 +1,37 @@
[gd_scene load_steps=2 format=3 uid="uid://gameover"]
[ext_resource type="Script" path="res://scripts/ui/GameOver.gd" id="1"]
[node name="GameOver" type="Control"]
anchor_right = 1.0
anchor_bottom = 1.0
visible = false
script = ExtResource("1")
[node name="Background" type="ColorRect" parent="."]
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0, 0, 0, 0.7)
[node name="VBox" type="VBoxContainer" parent="."]
offset_left = 390.0
offset_top = 250.0
offset_right = 890.0
offset_bottom = 470.0
alignment = 1
[node name="Title" type="Label" parent="VBox"]
text = "GAME OVER"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 36
[node name="WaveReached" type="Label" parent="VBox"]
text = ""
horizontal_alignment = 1
theme_override_font_sizes/font_size = 20
[node name="RestartButton" type="Button" parent="VBox"]
text = "Play Again"
[node name="QuitButton" type="Button" parent="VBox"]
text = "Quit"

57
scenes/ui/HUD.tscn Normal file
View file

@ -0,0 +1,57 @@
[gd_scene load_steps=2 format=3 uid="uid://hud"]
[ext_resource type="Script" path="res://scripts/ui/HUD.gd" id="1"]
[node name="HUD" type="CanvasLayer"]
script = ExtResource("1")
[node name="Panel" type="Panel" parent="."]
offset_right = 1280.0
offset_bottom = 60.0
[node name="MineralsLabel" type="Label" parent="."]
offset_left = 16.0
offset_top = 8.0
offset_right = 200.0
offset_bottom = 36.0
text = "Minerals: 0"
[node name="GasLabel" type="Label" parent="."]
offset_left = 220.0
offset_top = 8.0
offset_right = 380.0
offset_bottom = 36.0
text = "Gas: 0"
[node name="WaveLabel" type="Label" parent="."]
offset_left = 540.0
offset_top = 8.0
offset_right = 740.0
offset_bottom = 36.0
horizontal_alignment = 1
text = "Wave 0 / 31"
[node name="PhaseLabel" type="Label" parent="."]
offset_left = 760.0
offset_top = 8.0
offset_right = 960.0
offset_bottom = 36.0
horizontal_alignment = 1
text = "LOBBY"
[node name="SSBar" type="ProgressBar" parent="."]
offset_left = 980.0
offset_top = 10.0
offset_right = 1180.0
offset_bottom = 30.0
min_value = 0.0
max_value = 20.0
value = 20.0
show_percentage = false
[node name="NextWaveButton" type="Button" parent="."]
offset_left = 1190.0
offset_top = 8.0
offset_right = 1270.0
offset_bottom = 50.0
text = "SEND"

30
scenes/ui/RaceSelect.tscn Normal file
View file

@ -0,0 +1,30 @@
[gd_scene load_steps=2 format=3 uid="uid://raceselect"]
[ext_resource type="Script" path="res://scripts/ui/RaceSelect.gd" id="1"]
[node name="RaceSelect" type="Control"]
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1")
[node name="VBox" type="VBoxContainer" parent="."]
offset_left = 340.0
offset_top = 200.0
offset_right = 940.0
offset_bottom = 520.0
[node name="Title" type="Label" parent="VBox"]
text = "Choose Your Builder Race"
horizontal_alignment = 1
theme_override_font_sizes/font_size = 28
[node name="ButtonRow" type="HBoxContainer" parent="VBox"]
alignment = 1
[node name="Description" type="Label" parent="VBox"]
custom_minimum_size = Vector2(0, 60)
horizontal_alignment = 1
autowrap_mode = 3
[node name="ConfirmButton" type="Button" parent="VBox"]
text = "Confirm"

View file

@ -0,0 +1,68 @@
## Economy.gd
## Manages minerals, gas, workers, and income for all players.
extends Node
const BASE_MINERAL_INCOME: int = 50
const GAS_PER_WORKER_PER_ROUND: int = 10
# Per-player economy state
var _minerals: Dictionary = {} # player_id -> int
var _gas: Dictionary = {} # player_id -> int
var _workers: Dictionary = {} # player_id -> int
var _income: Dictionary = {} # player_id -> int (cumulative bonus income)
func init_player(player_id: int, starting_minerals: int = 200, starting_workers: int = 3) -> void:
_minerals[player_id] = starting_minerals
_gas[player_id] = 0
_workers[player_id] = starting_workers
_income[player_id] = 0
## Called at end of each round to pay out income and gas.
func process_income_phase() -> void:
for player_id in _minerals.keys():
var mineral_gain = BASE_MINERAL_INCOME + _income[player_id]
_minerals[player_id] += mineral_gain
EventBus.minerals_changed.emit(player_id, _minerals[player_id])
EventBus.income_earned.emit(player_id, mineral_gain)
var gas_gain = _workers[player_id] * GAS_PER_WORKER_PER_ROUND
_gas[player_id] += gas_gain
EventBus.gas_changed.emit(player_id, _gas[player_id])
## Spend minerals. Returns false if insufficient funds.
func spend_minerals(player_id: int, amount: int) -> bool:
if _minerals.get(player_id, 0) < amount:
return false
_minerals[player_id] -= amount
EventBus.minerals_changed.emit(player_id, _minerals[player_id])
return true
## Spend gas. Returns false if insufficient funds.
func spend_gas(player_id: int, amount: int) -> bool:
if _gas.get(player_id, 0) < amount:
return false
_gas[player_id] -= amount
EventBus.gas_changed.emit(player_id, _gas[player_id])
return true
## Add a permanent income bonus (from sends or upgrades).
func add_income(player_id: int, bonus: int) -> void:
_income[player_id] = _income.get(player_id, 0) + bonus
func add_worker(player_id: int, cost_minerals: int) -> bool:
if not spend_minerals(player_id, cost_minerals):
return false
_workers[player_id] = _workers.get(player_id, 0) + 1
return true
func get_minerals(player_id: int) -> int:
return _minerals.get(player_id, 0)
func get_gas(player_id: int) -> int:
return _gas.get(player_id, 0)
func get_workers(player_id: int) -> int:
return _workers.get(player_id, 0)
func get_income(player_id: int) -> int:
return BASE_MINERAL_INCOME + _income.get(player_id, 0)

View file

@ -0,0 +1,31 @@
## EventBus.gd
## Global signal bus for decoupled communication between systems.
## Usage: EventBus.wave_started.emit(wave_number)
extends Node
# Phase signals
signal phase_changed(new_phase: GameState.Phase)
# Wave signals
signal wave_started(wave_number: int)
signal wave_completed(wave_number: int)
signal creep_died(creep: Node, bounty: int)
signal creep_reached_exit(creep: Node, hp_damage: int)
# Economy signals
signal minerals_changed(player_id: int, amount: int)
signal gas_changed(player_id: int, amount: int)
signal income_earned(player_id: int, amount: int)
signal send_executed(player_id: int, send_id: String)
# Tower signals
signal tower_placed(player_id: int, tower_id: String, slot: Node)
signal tower_upgraded(player_id: int, tower: Node, new_tower_id: String)
# Security system signals
signal security_system_damaged(damage: int, hp_remaining: int)
signal security_system_destroyed()
# UI → GameLoop signals
signal slot_clicked_in_lane(player_id: int, slot: Node)
signal next_wave_requested()

View file

@ -0,0 +1,23 @@
## GameState.gd
## Tracks global game state: current phase, wave number, player registrations.
extends Node
enum Phase { LOBBY, BUILD, COMBAT, INCOME, GAME_OVER }
var current_phase: Phase = Phase.LOBBY
var wave_number: int = 0
var players: Dictionary = {} # player_id -> { race, lane_node }
var game_over: bool = false
func register_player(player_id: int, race: String, lane_node: Node) -> void:
players[player_id] = { "race": race, "lane": lane_node }
func set_phase(new_phase: Phase) -> void:
current_phase = new_phase
EventBus.phase_changed.emit(new_phase)
func advance_wave() -> void:
wave_number += 1
func get_player_race(player_id: int) -> String:
return players.get(player_id, {}).get("race", "")

View file

@ -0,0 +1,68 @@
## WaveManager.gd
## Loads wave definitions and orchestrates creep spawning along the lane path.
extends Node
const WAVE_DATA_PATH := "res://data/waves.json"
const TOTAL_WAVES := 31
var _waves: Array = []
var _active_creeps: int = 0
func _ready() -> void:
_load_waves()
EventBus.creep_died.connect(_on_creep_removed)
EventBus.creep_reached_exit.connect(_on_creep_removed)
func _load_waves() -> void:
_waves = DataLoader.load_waves()
if _waves.is_empty():
push_error("WaveManager: waves.json failed to load or is empty")
func start_wave(wave_number: int, lane: Node) -> void:
if wave_number < 1 or wave_number > _waves.size():
push_error("WaveManager: invalid wave number %d" % wave_number)
return
var wave_data: Dictionary = _waves[wave_number - 1]
GameState.advance_wave()
GameState.set_phase(GameState.Phase.COMBAT)
EventBus.wave_started.emit(wave_number)
var count: int = wave_data.get("creep_count", 1)
_active_creeps += count
var path_pts: PackedVector2Array = lane.get_path_points() if lane.has_method("get_path_points") else PackedVector2Array()
# Spawn creeps spaced 0.5 s apart
for i in range(count):
await get_tree().create_timer(0.5 * i).timeout
_spawn_creep(wave_data, lane, path_pts)
func _spawn_creep(wave_data: Dictionary, lane: Node, path_pts: PackedVector2Array) -> void:
var creep_scene := load("res://scenes/gameplay/Creep.tscn") as PackedScene
if not creep_scene:
push_error("WaveManager: Creep.tscn not found")
_active_creeps -= 1
return
var creep: Node = creep_scene.instantiate()
lane.add_child(creep)
creep.setup(wave_data)
if path_pts.size() > 0:
# Convert lane-local path to global coords for the creep
var global_pts := PackedVector2Array()
for pt in path_pts:
global_pts.append(lane.to_global(pt))
creep.assign_path(global_pts)
func _on_creep_removed(_creep: Node, _value) -> void:
_active_creeps = max(0, _active_creeps - 1)
if _active_creeps == 0:
_wave_complete()
func _wave_complete() -> void:
var wn := GameState.wave_number
EventBus.wave_completed.emit(wn)
Economy.process_income_phase()
if wn < TOTAL_WAVES:
GameState.set_phase(GameState.Phase.BUILD)
# If wave 31, GameOver.gd listens to wave_completed and handles victory

View file

@ -0,0 +1,28 @@
## DataLoader.gd
## Utility for loading and caching JSON data files.
class_name DataLoader
static func load_json(path: String) -> Variant:
var file = FileAccess.open(path, FileAccess.READ)
if not file:
push_error("DataLoader: cannot open " + path)
return null
var json = JSON.new()
var err = json.parse(file.get_as_text())
file.close()
if err != OK:
push_error("DataLoader: parse error in " + path + " at line " + str(json.get_error_line()))
return null
return json.data
static func load_towers(race: String) -> Array:
var data = load_json("res://data/towers/" + race + ".json")
return data if data is Array else []
static func load_waves() -> Array:
var data = load_json("res://data/waves.json")
return data if data is Array else []
static func load_sends() -> Array:
var data = load_json("res://data/sends.json")
return data if data is Array else []

90
scripts/gameplay/Creep.gd Normal file
View file

@ -0,0 +1,90 @@
## Creep.gd
## Enemy unit that follows the lane path and damages the Security System on exit.
## Call assign_path() immediately after adding to the scene tree.
class_name Creep
extends Node2D
var _max_hp: int = 100
var _hp: int = 100
var _speed: float = 100.0
var _armor_type: int = DamageCalc.ArmorType.LIGHT
var _bounty: int = 5
var _hp_damage: int = 1
# Baked path points in global space
var _path: PackedVector2Array = []
var _path_index: int = 0
var _dead: bool = false
func _ready() -> void:
add_to_group("creeps")
func setup(wave_data: Dictionary) -> void:
_max_hp = wave_data.get("creep_hp", 100)
_hp = _max_hp
_speed = wave_data.get("creep_speed", 100.0)
_bounty = wave_data.get("bounty", 5)
_hp_damage = wave_data.get("hp_damage", 1)
_armor_type = wave_data.get("creep_armor", DamageCalc.ArmorType.LIGHT)
## Assign the world-space baked path from the Lane's Path2D.
func assign_path(world_points: PackedVector2Array) -> void:
_path = world_points
_path_index = 0
if _path.size() > 0:
global_position = _path[0]
func get_armor_type() -> int:
return _armor_type
func get_hp_fraction() -> float:
return float(_hp) / float(_max_hp)
func take_damage(amount: int) -> void:
if _dead:
return
_hp -= amount
_update_health_bar()
if _hp <= 0:
_die()
func _process(delta: float) -> void:
if _dead or _path.size() == 0:
return
if _path_index >= _path.size():
return
var target: Vector2 = _path[_path_index]
var dir: Vector2 = (target - global_position).normalized()
global_position += dir * _speed * delta
# Advance to next waypoint when close enough
if global_position.distance_to(target) < 3.0:
_path_index += 1
if _path_index >= _path.size():
_reach_exit()
func _die() -> void:
if _dead:
return
_dead = true
remove_from_group("creeps")
EventBus.creep_died.emit(self, _bounty)
# Award bounty to all players
for pid in Economy._minerals.keys():
Economy._minerals[pid] += _bounty
EventBus.minerals_changed.emit(pid, Economy._minerals[pid])
queue_free()
func _reach_exit() -> void:
if _dead:
return
_dead = true
remove_from_group("creeps")
EventBus.creep_reached_exit.emit(self, _hp_damage)
queue_free()
func _update_health_bar() -> void:
var bar := get_node_or_null("HealthBar") as ProgressBar
if bar:
bar.value = get_hp_fraction() * 100.0

View file

@ -0,0 +1,43 @@
## Fighter.gd
## AI-controlled unit that attacks creeps during combat phase.
class_name Fighter
extends CharacterBody2D
var _data: Dictionary = {}
var _target: Node = null
var _attack_cooldown: float = 0.0
func setup(tower_data: Dictionary) -> void:
_data = tower_data
func _process(delta: float) -> void:
if GameState.current_phase != GameState.Phase.COMBAT:
return
_attack_cooldown -= delta
_find_target()
if _target and _attack_cooldown <= 0.0:
_attack()
func _find_target() -> void:
if _target and is_instance_valid(_target) and not _target.is_queued_for_deletion():
return # keep current target
# Find nearest creep in range
var range_px: float = _data.get("range", 150.0) * 32.0 # tiles to pixels
var nearest: Node = null
var nearest_dist: float = INF
for creep in get_tree().get_nodes_in_group("creeps"):
var d = global_position.distance_to(creep.global_position)
if d <= range_px and d < nearest_dist:
nearest = creep
nearest_dist = d
_target = nearest
func _attack() -> void:
if not _target or not is_instance_valid(_target):
return
var atk_type = _data.get("attack_type", DamageCalc.AttackType.NORMAL)
var arm_type = _target.get_armor_type() if _target.has_method("get_armor_type") else DamageCalc.ArmorType.LIGHT
var damage = DamageCalc.calculate(_data.get("damage", 10), atk_type, arm_type)
_target.take_damage(damage)
var speed: float = _data.get("attack_speed", 1.0)
_attack_cooldown = 1.0 / speed

View file

@ -0,0 +1,62 @@
## GameLoop.gd
## Orchestrates the phase state machine and wires up the lane + UI for solo play.
extends Node
const PLAYER_ID = 0
const STARTING_MINERALS = 200
const STARTING_WORKERS = 3
@onready var race_select: Control = $RaceSelect
@onready var hud: CanvasLayer = $HUD
@onready var build_panel: PanelContainer = $BuildPanel
@onready var lane: Node2D = $Lane
@onready var security_system: Node2D = $SecuritySystem
@onready var game_over: Control = $GameOver
func _ready() -> void:
hud.hide()
build_panel.hide()
race_select.race_selected.connect(_on_race_selected)
EventBus.phase_changed.connect(_on_phase_changed)
# Wire slot clicks to build panel
EventBus.connect("slot_clicked_in_lane", _on_slot_clicked)
EventBus.connect("next_wave_requested", _on_next_wave_requested)
func _on_race_selected(race: String) -> void:
GameState.register_player(PLAYER_ID, race, lane)
Economy.init_player(PLAYER_ID, STARTING_MINERALS, STARTING_WORKERS)
race_select.hide()
hud.show()
GameState.set_phase(GameState.Phase.BUILD)
func _on_phase_changed(new_phase: GameState.Phase) -> void:
pass # HUD and other nodes react via their own connections
func _on_slot_clicked(player_id: int, slot: Node) -> void:
if GameState.current_phase != GameState.Phase.BUILD:
return
if slot.is_empty():
build_panel.open_for_slot(slot, player_id)
elif slot.get_tower().can_upgrade():
_try_upgrade(slot, player_id)
func _try_upgrade(slot: Node, player_id: int) -> void:
var tower = slot.get_tower()
var upgrade_id = tower.get_upgrade_id()
var race = GameState.get_player_race(player_id)
var defs = DataLoader.load_towers(race)
for d in defs:
if d.get("id") == upgrade_id:
var cost = d.get("cost_minerals", 0) + d.get("cost_gas", 0)
if Economy.spend_minerals(player_id, d.get("cost_minerals", 0)) and \
Economy.spend_gas(player_id, d.get("cost_gas", 0)):
slot.upgrade_tower(d)
return
func _on_next_wave_requested() -> void:
if GameState.current_phase != GameState.Phase.BUILD:
return
var next = GameState.wave_number + 1
if next > 31:
return
WaveManager.start_wave(next, lane)

164
scripts/gameplay/Lane.gd Normal file
View file

@ -0,0 +1,164 @@
## Lane.gd
## Manages a player's lane: a TileMap background, a grid of TowerSlots,
## and the creep Path2D that winds through the play area.
##
## Lane layout (64px tiles, 4 cols wide × 8 rows tall build area):
##
## col: 0 1 2 3 [path col 4]
## row 0 [ ] [ ] [ ] [ ] ↓
## row 1 [ ] [ ] [ ] [ ] ↓
## row 2 [ ] [ ] [ ] [ ] ←←
## row 3 [ ] [ ] [ ] [ ] ↑
## row 4 [ ] [ ] [ ] [ ] ↑
## row 5 [ ] [ ] [ ] [ ] →→
## row 6 [ ] [ ] [ ] [ ] ↓
## row 7 [ ] [ ] [ ] [ ] ↓ → EXIT
##
## The path enters from the top-right, snakes left/right, exits bottom-right.
## Creeps follow the Path2D curve; towers build on the coloured cells.
class_name Lane
extends Node2D
@export var player_id: int = 0
@export var slot_columns: int = 4
@export var slot_rows: int = 8
@export var tile_size: float = 64.0
# Colour used to tint each slot cell (overridden in editor per player)
@export var slot_color: Color = Color(0.18, 0.22, 0.28, 0.85)
@export var path_color: Color = Color(0.35, 0.30, 0.20, 1.0)
var _slots: Array[Node] = []
# Path points in local space. The snake runs down the right side of the
# build grid, then doubles back. Adjust these to reshape the lane.
const PATH_POINTS: Array = [
Vector2(288, -32), # entry: above top of lane (col 4.5, off-screen)
Vector2(288, 32), # row 0 right edge
Vector2(288, 96), # row 1
Vector2(288, 160), # row 2 — turn left
Vector2( 32, 160), # row 2 left edge — turn up
Vector2( 32, 96), # row 1 left
Vector2( 32, 32), # row 0 left — turn right (second pass)
Vector2(288, 32), # NOTE: this pass is a separate sweep for zigzag lanes
# Simplified single-snake for MVP — straight entry, one hairpin, exit:
]
# Cleaner single-hairpin path used at runtime
func _build_path_points() -> PackedVector2Array:
var pts := PackedVector2Array()
var w := slot_columns * tile_size # 256
var h := slot_rows * tile_size # 512
var mid := tile_size * 0.5 # 32 — centre of path corridor
# Entry: top-right corridor (one tile to the right of the build grid)
pts.append(Vector2(w + mid, -tile_size)) # off-screen top
pts.append(Vector2(w + mid, h * 0.25)) # quarter down
# Hairpin: sweep left across the top of the grid
pts.append(Vector2(w + mid, h * 0.375))
pts.append(Vector2(-mid, h * 0.375)) # left gutter
# Come back up then sweep across mid
pts.append(Vector2(-mid, h * 0.5))
pts.append(Vector2(w + mid, h * 0.5))
# Second hairpin lower
pts.append(Vector2(w + mid, h * 0.625))
pts.append(Vector2(-mid, h * 0.625))
# Exit: bottom-right
pts.append(Vector2(-mid, h * 0.875))
pts.append(Vector2(w + mid, h * 0.875))
pts.append(Vector2(w + mid, h + tile_size)) # off-screen bottom
return pts
func _ready() -> void:
_draw_background()
_build_slots()
_build_path()
## Draws the lane background using plain ColorRect nodes (no TileSet required).
## Replace with a real TileMap once you have art assets.
func _draw_background() -> void:
var w := slot_columns * tile_size
var h := slot_rows * tile_size
var corridor := tile_size # width of the path corridor on each side
# Main play field
var field := ColorRect.new()
field.size = Vector2(w, h)
field.color = Color(0.12, 0.14, 0.16)
add_child(field)
# Left path gutter
var left_gutter := ColorRect.new()
left_gutter.size = Vector2(corridor, h)
left_gutter.position = Vector2(-corridor, 0)
left_gutter.color = path_color
add_child(left_gutter)
# Right path corridor
var right_corridor := ColorRect.new()
right_corridor.size = Vector2(corridor, h)
right_corridor.position = Vector2(w, 0)
right_corridor.color = path_color
add_child(right_corridor)
# Hairpin cross-corridors
var hairpin_rows := [0.375, 0.5, 0.625]
for frac in hairpin_rows:
var bar := ColorRect.new()
bar.size = Vector2(w + corridor * 2, corridor)
bar.position = Vector2(-corridor, h * frac - corridor * 0.5)
bar.color = path_color
add_child(bar)
# Grid lines over the build area
for col in range(slot_columns + 1):
var line := ColorRect.new()
line.size = Vector2(1, h)
line.position = Vector2(col * tile_size, 0)
line.color = Color(1, 1, 1, 0.06)
add_child(line)
for row in range(slot_rows + 1):
var line := ColorRect.new()
line.size = Vector2(w, 1)
line.position = Vector2(0, row * tile_size)
line.color = Color(1, 1, 1, 0.06)
add_child(line)
func _build_slots() -> void:
var slot_scene := load("res://scenes/gameplay/TowerSlot.tscn") as PackedScene
if not slot_scene:
push_error("Lane: TowerSlot.tscn not found")
return
for row in range(slot_rows):
for col in range(slot_columns):
var slot = slot_scene.instantiate()
slot.player_id = player_id
slot.position = Vector2(col * tile_size + tile_size * 0.5,
row * tile_size + tile_size * 0.5)
slot.slot_clicked.connect(_on_slot_clicked)
add_child(slot)
_slots.append(slot)
func _build_path() -> void:
var path := Path2D.new()
path.name = "CreepPath"
var curve := Curve2D.new()
for pt in _build_path_points():
curve.add_point(pt)
path.curve = curve
add_child(path)
func get_path_points() -> PackedVector2Array:
var path := get_node_or_null("CreepPath") as Path2D
if path:
return path.curve.get_baked_points()
return PackedVector2Array()
func _on_slot_clicked(slot: Node) -> void:
EventBus.slot_clicked_in_lane.emit(player_id, slot)

View file

@ -0,0 +1,27 @@
## SecuritySystem.gd
## The shared objective the team must protect.
class_name SecuritySystem
extends Node2D
@export var max_hp: int = 20
var _hp: int = 20
func _ready() -> void:
_hp = max_hp
EventBus.creep_reached_exit.connect(_on_creep_reached_exit)
func _on_creep_reached_exit(_creep: Node, hp_damage: int) -> void:
take_damage(hp_damage)
func take_damage(amount: int) -> void:
_hp = max(0, _hp - amount)
EventBus.security_system_damaged.emit(amount, _hp)
if _hp <= 0:
EventBus.security_system_destroyed.emit()
GameState.set_phase(GameState.Phase.GAME_OVER)
func get_hp() -> int:
return _hp
func get_max_hp() -> int:
return max_hp

49
scripts/gameplay/Tower.gd Normal file
View file

@ -0,0 +1,49 @@
## Tower.gd
## Represents a placed tower during the build phase.
## During combat phase, spawns/morphs into a Fighter.
class_name Tower
extends Node2D
@export var tower_id: String = ""
@export var player_id: int = 0
var _data: Dictionary = {}
var _fighter: Node = null
func _ready() -> void:
EventBus.phase_changed.connect(_on_phase_changed)
func setup(tower_data: Dictionary, pid: int) -> void:
_data = tower_data
tower_id = tower_data.get("id", "")
player_id = pid
func get_data() -> Dictionary:
return _data
func can_upgrade() -> bool:
return _data.has("upgrades_to") and _data["upgrades_to"] != ""
func get_upgrade_id() -> String:
return _data.get("upgrades_to", "")
func _on_phase_changed(new_phase: GameState.Phase) -> void:
match new_phase:
GameState.Phase.COMBAT:
_spawn_fighter()
GameState.Phase.BUILD:
_recall_fighter()
func _spawn_fighter() -> void:
var fighter_scene = load("res://scenes/gameplay/Fighter.tscn") as PackedScene
if not fighter_scene:
return
_fighter = fighter_scene.instantiate()
_fighter.setup(_data)
get_parent().add_child(_fighter)
_fighter.global_position = global_position
func _recall_fighter() -> void:
if _fighter and is_instance_valid(_fighter):
_fighter.queue_free()
_fighter = null

View file

@ -0,0 +1,59 @@
## TowerSlot.gd
## A cell on the lane grid where a tower can be placed.
## Uses Area2D input_event for click detection.
class_name TowerSlot
extends Area2D
var player_id: int = 0
var _tower: Node = null
signal slot_clicked(slot: TowerSlot)
func _ready() -> void:
input_pickable = true
input_event.connect(_on_input_event)
# Tint the visual based on state
_refresh_visual()
func _on_input_event(_viewport, event: InputEvent, _shape_idx: int) -> void:
if event is InputEventMouseButton \
and event.pressed \
and event.button_index == MOUSE_BUTTON_LEFT:
slot_clicked.emit(self)
func is_empty() -> bool:
return _tower == null
func place_tower(tower_data: Dictionary, pid: int) -> bool:
if not is_empty():
return false
var tower_scene := load("res://scenes/gameplay/Tower.tscn") as PackedScene
if not tower_scene:
push_error("TowerSlot: Tower.tscn not found")
return false
_tower = tower_scene.instantiate()
_tower.setup(tower_data, pid)
add_child(_tower)
_refresh_visual()
EventBus.tower_placed.emit(pid, tower_data.get("id", ""), self)
return true
func upgrade_tower(new_tower_data: Dictionary) -> bool:
if is_empty() or not _tower.can_upgrade():
return false
_tower.setup(new_tower_data, _tower.player_id)
_refresh_visual()
EventBus.tower_upgraded.emit(_tower.player_id, _tower, new_tower_data.get("id", ""))
return true
func get_tower() -> Node:
return _tower
func _refresh_visual() -> void:
var vis := get_node_or_null("SlotVisual") as ColorRect
if not vis:
return
if is_empty():
vis.color = Color(0.2, 0.2, 0.2, 0.5)
else:
vis.color = Color(0.1, 0.35, 0.15, 0.7)

6
scripts/main/Main.gd Normal file
View file

@ -0,0 +1,6 @@
## Main.gd
## Root scene entry point. Boots into the game loop.
extends Node
func _ready() -> void:
get_tree().change_scene_to_file("res://scenes/main/GameLoop.tscn")

47
scripts/ui/BuildPanel.gd Normal file
View file

@ -0,0 +1,47 @@
## BuildPanel.gd
## Displays available towers for the selected player's race and handles placement.
extends PanelContainer
@onready var tower_list: VBoxContainer = $ScrollContainer/TowerList
@onready var race_label: Label = $RaceLabel
@onready var close_btn: Button = $CloseButton
var _player_id: int = 0
var _selected_slot: Node = null
var _tower_defs: Array = []
func _ready() -> void:
close_btn.pressed.connect(hide)
EventBus.phase_changed.connect(_on_phase_changed)
func open_for_slot(slot: Node, player_id: int) -> void:
_selected_slot = slot
_player_id = player_id
var race = GameState.get_player_race(player_id)
race_label.text = race.capitalize()
_tower_defs = DataLoader.load_towers(race)
_populate_list()
show()
func _populate_list() -> void:
for child in tower_list.get_children():
child.queue_free()
for tower in _tower_defs:
var btn := Button.new()
var cost = tower.get("cost_minerals", 0)
btn.text = "%s [%dM]" % [tower.get("id", "?").replace("_", " ").capitalize(), cost]
btn.disabled = Economy.get_minerals(_player_id) < cost
btn.pressed.connect(_on_tower_selected.bind(tower))
tower_list.add_child(btn)
func _on_tower_selected(tower_data: Dictionary) -> void:
if _selected_slot == null:
return
var cost = tower_data.get("cost_minerals", 0)
if Economy.spend_minerals(_player_id, cost):
_selected_slot.place_tower(tower_data, _player_id)
hide()
func _on_phase_changed(new_phase: GameState.Phase) -> void:
if new_phase != GameState.Phase.BUILD:
hide()

30
scripts/ui/GameOver.gd Normal file
View file

@ -0,0 +1,30 @@
## GameOver.gd
## Displayed when the Security System is destroyed or all 31 waves are survived.
extends Control
@onready var title_label: Label = $VBox/Title
@onready var wave_label: Label = $VBox/WaveReached
@onready var restart_btn: Button = $VBox/RestartButton
@onready var quit_btn: Button = $VBox/QuitButton
func _ready() -> void:
hide()
EventBus.security_system_destroyed.connect(_on_defeat)
EventBus.wave_completed.connect(_on_wave_completed)
restart_btn.pressed.connect(_on_restart)
quit_btn.pressed.connect(get_tree().quit)
func _on_defeat() -> void:
title_label.text = "SECURITY SYSTEM DESTROYED"
wave_label.text = "Survived %d / 31 waves" % GameState.wave_number
show()
func _on_wave_completed(wave_number: int) -> void:
if wave_number >= 31:
title_label.text = "VICTORY"
wave_label.text = "All 31 waves survived!"
GameState.set_phase(GameState.Phase.GAME_OVER)
show()
func _on_restart() -> void:
get_tree().reload_current_scene()

52
scripts/ui/HUD.gd Normal file
View file

@ -0,0 +1,52 @@
## HUD.gd
## In-game heads-up display: minerals, gas, wave counter, Security System HP.
extends CanvasLayer
@onready var minerals_label: Label = $MineralsLabel
@onready var gas_label: Label = $GasLabel
@onready var wave_label: Label = $WaveLabel
@onready var ss_bar: ProgressBar = $SSBar
@onready var phase_label: Label = $PhaseLabel
@onready var next_wave_btn: Button = $NextWaveButton
const PLAYER_ID = 0 # local player
func _ready() -> void:
EventBus.minerals_changed.connect(_on_minerals_changed)
EventBus.gas_changed.connect(_on_gas_changed)
EventBus.wave_started.connect(_on_wave_started)
EventBus.security_system_damaged.connect(_on_ss_damaged)
EventBus.phase_changed.connect(_on_phase_changed)
next_wave_btn.pressed.connect(_on_next_wave_pressed)
func _on_minerals_changed(player_id: int, amount: int) -> void:
if player_id == PLAYER_ID:
minerals_label.text = "Minerals: %d" % amount
func _on_gas_changed(player_id: int, amount: int) -> void:
if player_id == PLAYER_ID:
gas_label.text = "Gas: %d" % amount
func _on_wave_started(wave_number: int) -> void:
wave_label.text = "Wave %d / 31" % wave_number
next_wave_btn.disabled = true
func _on_ss_damaged(_damage: int, hp_remaining: int) -> void:
ss_bar.value = hp_remaining
func _on_phase_changed(new_phase: GameState.Phase) -> void:
match new_phase:
GameState.Phase.BUILD:
phase_label.text = "BUILD PHASE"
next_wave_btn.disabled = false
GameState.Phase.COMBAT:
phase_label.text = "COMBAT"
next_wave_btn.disabled = true
GameState.Phase.INCOME:
phase_label.text = "INCOME"
GameState.Phase.GAME_OVER:
phase_label.text = "GAME OVER"
next_wave_btn.disabled = true
func _on_next_wave_pressed() -> void:
EventBus.emit_signal("next_wave_requested")

42
scripts/ui/RaceSelect.gd Normal file
View file

@ -0,0 +1,42 @@
## RaceSelect.gd
## Race selection screen shown at game start.
extends Control
signal race_selected(race: String)
const RACES = ["ghost", "fortress", "phantom", "ancient"]
@onready var btn_container: HBoxContainer = $VBox/ButtonRow
@onready var description_label: Label = $VBox/Description
@onready var confirm_btn: Button = $VBox/ConfirmButton
const DESCRIPTIONS = {
"ghost": "High damage, low HP. Glass cannon. Strong early and late game.",
"fortress": "Low damage, high HP. Buffs and debuffs. Strong early and mid game.",
"phantom": "Evasive and fast. Mixed melee/ranged. Strong mid and late game.",
"ancient": "Slow, massive AoE damage. Dominant late game.",
}
var _selected_race: String = ""
func _ready() -> void:
confirm_btn.disabled = true
confirm_btn.pressed.connect(_on_confirm)
for race in RACES:
var btn := Button.new()
btn.text = race.capitalize()
btn.toggle_mode = true
btn.pressed.connect(_on_race_btn_pressed.bind(btn, race))
btn_container.add_child(btn)
func _on_race_btn_pressed(btn: Button, race: String) -> void:
for child in btn_container.get_children():
child.button_pressed = false
btn.button_pressed = true
_selected_race = race
description_label.text = DESCRIPTIONS.get(race, "")
confirm_btn.disabled = false
func _on_confirm() -> void:
if _selected_race != "":
race_selected.emit(_selected_race)

View file

@ -0,0 +1,22 @@
## DamageCalc.gd
## Applies armor/attack type damage multipliers per the Squadron TD matrix.
class_name DamageCalc
enum AttackType { NORMAL, EXPLOSIVE, CONCUSSIVE, CHAOS }
enum ArmorType { LIGHT, MEDIUM, HEAVY, UNARMORED }
# [attack_type][armor_type] = multiplier
const MATRIX: Array = [
# NORMAL
[1.00, 1.00, 1.00, 1.00],
# EXPLOSIVE
[0.75, 1.00, 1.50, 0.75],
# CONCUSSIVE
[1.50, 0.75, 0.50, 1.00],
# CHAOS
[1.00, 1.00, 1.00, 1.00],
]
static func calculate(base_damage: int, attack_type: AttackType, armor_type: ArmorType) -> int:
var multiplier: float = MATRIX[attack_type][armor_type]
return int(base_damage * multiplier)

20
tests/test_damage_calc.gd Normal file
View file

@ -0,0 +1,20 @@
extends GutTest
func test_normal_vs_light():
assert_eq(DamageCalc.calculate(100, DamageCalc.AttackType.NORMAL, DamageCalc.ArmorType.LIGHT), 100)
func test_explosive_vs_heavy():
assert_eq(DamageCalc.calculate(100, DamageCalc.AttackType.EXPLOSIVE, DamageCalc.ArmorType.HEAVY), 150)
func test_concussive_vs_light():
assert_eq(DamageCalc.calculate(100, DamageCalc.AttackType.CONCUSSIVE, DamageCalc.ArmorType.LIGHT), 150)
func test_concussive_vs_heavy():
assert_eq(DamageCalc.calculate(100, DamageCalc.AttackType.CONCUSSIVE, DamageCalc.ArmorType.HEAVY), 50)
func test_explosive_vs_light():
assert_eq(DamageCalc.calculate(100, DamageCalc.AttackType.EXPLOSIVE, DamageCalc.ArmorType.LIGHT), 75)
func test_chaos_always_full():
assert_eq(DamageCalc.calculate(100, DamageCalc.AttackType.CHAOS, DamageCalc.ArmorType.HEAVY), 100)
assert_eq(DamageCalc.calculate(100, DamageCalc.AttackType.CHAOS, DamageCalc.ArmorType.LIGHT), 100)

27
tests/test_economy.gd Normal file
View file

@ -0,0 +1,27 @@
extends GutTest
func before_each():
Economy.init_player(0, 200, 3)
func test_initial_minerals():
assert_eq(Economy.get_minerals(0), 200)
func test_spend_minerals_success():
var ok = Economy.spend_minerals(0, 75)
assert_true(ok)
assert_eq(Economy.get_minerals(0), 125)
func test_spend_minerals_insufficient():
var ok = Economy.spend_minerals(0, 999)
assert_false(ok)
assert_eq(Economy.get_minerals(0), 200)
func test_income_accumulates():
Economy.add_income(0, 20)
Economy.add_income(0, 10)
assert_eq(Economy.get_income(0), 80) # 50 base + 30 bonus
func test_income_phase_pays_out():
var before = Economy.get_minerals(0)
Economy.process_income_phase()
assert_gt(Economy.get_minerals(0), before)

24
tests/test_wave_data.gd Normal file
View file

@ -0,0 +1,24 @@
extends GutTest
var waves: Array = []
func before_all():
waves = DataLoader.load_waves()
func test_has_31_waves():
assert_eq(waves.size(), 31)
func test_wave_numbers_sequential():
for i in range(waves.size()):
assert_eq(waves[i].get("wave_number"), i + 1)
func test_all_waves_have_required_fields():
for wave in waves:
assert_true(wave.has("creep_hp"), "Missing creep_hp in wave %d" % wave.get("wave_number"))
assert_true(wave.has("creep_count"), "Missing creep_count in wave %d" % wave.get("wave_number"))
assert_true(wave.has("bounty"), "Missing bounty in wave %d" % wave.get("wave_number"))
func test_final_wave_is_boss():
var final = waves[30]
assert_eq(final.get("wave_number"), 31)
assert_gt(final.get("creep_hp"), 10000)

View file

@ -0,0 +1,18 @@
# Branch Protection Setup
Forgejo doesn't support branch protection via API in all versions.
Set these manually at: **Repo → Settings → Branches → Add Rule**
## `main`
- [x] Protect this branch
- [x] Require pull request before merging
- [x] Required approvals: 1
- [x] Require status checks to pass: `validate-data`, `unit-tests`
- [x] Dismiss stale reviews on new commits
- [ ] Allow force push: NO
## `develop`
- [x] Protect this branch
- [x] Require status checks to pass: `validate-data`
- [ ] Required approvals: 0 (optional — enable for teams)
- [ ] Allow force push: NO

101
tools/forgejo_setup.py Normal file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
forgejo_setup.py
----------------
Creates labels and milestones on your Forgejo repo via the API.
Usage:
python3 tools/forgejo_setup.py \
--url https://your-forgejo.example.com \
--token YOUR_API_TOKEN \
--owner YOUR_USERNAME \
--repo squadron-td
Get a token: Forgejo Settings Applications Generate Token (needs repo scope).
"""
import argparse
import sys
import urllib.request
import urllib.error
import json
def api(base_url, token, method, path, body=None):
url = f"{base_url}/api/v1{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"token {token}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
# 422 = already exists — safe to ignore
if e.code == 422:
print(f" (already exists, skipping)")
return None
print(f" ERROR {e.code}: {body}")
return None
LABELS = [
{"name": "type: feature", "color": "#0075ca"},
{"name": "type: fix", "color": "#d73a4a"},
{"name": "type: data", "color": "#e4e669"},
{"name": "type: chore", "color": "#cccccc"},
{"name": "type: docs", "color": "#0052cc"},
{"name": "scope: towers", "color": "#b60205"},
{"name": "scope: economy", "color": "#fbca04"},
{"name": "scope: waves", "color": "#006b75"},
{"name": "scope: ui", "color": "#e99695"},
{"name": "scope: ai", "color": "#c5def5"},
{"name": "scope: multiplayer","color": "#bfd4f2"},
{"name": "priority: high", "color": "#b60205"},
{"name": "priority: low", "color": "#eeeeee"},
{"name": "status: blocked", "color": "#e4e669"},
{"name": "good first issue", "color": "#7057ff"},
]
MILESTONES = [
{"title": "v0.1 — Prototype", "description": "Single lane, 1 race, 10 waves, no economy"},
{"title": "v0.2 — Economy", "description": "Workers, minerals, gas, send system"},
{"title": "v0.3 — Full Waves", "description": "All 31 waves with data-driven definitions"},
{"title": "v0.4 — All Races", "description": "4 builder races with full tower trees"},
{"title": "v0.5 — Multiplayer", "description": "2-player co-op over local network"},
{"title": "v1.0 — Release", "description": "Polished, tested, exportable build"},
]
def main():
p = argparse.ArgumentParser()
p.add_argument("--url", required=True, help="Forgejo base URL (no trailing slash)")
p.add_argument("--token", required=True, help="API token")
p.add_argument("--owner", required=True, help="Repo owner (username or org)")
p.add_argument("--repo", required=True, help="Repo name")
args = p.parse_args()
repo_path = f"/repos/{args.owner}/{args.repo}"
print("=== Creating labels ===")
for label in LABELS:
print(f" {label['name']} ...", end=" ")
result = api(args.url, args.token, "POST", f"{repo_path}/labels", label)
if result:
print(f"created (id={result['id']})")
print("\n=== Creating milestones ===")
for ms in MILESTONES:
print(f" {ms['title']} ...", end=" ")
result = api(args.url, args.token, "POST", f"{repo_path}/milestones", ms)
if result:
print(f"created (id={result['id']})")
print("\n=== Setting default branch to 'develop' ===")
result = api(args.url, args.token, "PATCH", f"{repo_path}",
{"default_branch": "develop"})
if result:
print(f" Default branch set to: {result.get('default_branch')}")
print("\nDone! Visit your repo to verify.")
if __name__ == "__main__":
main()

29
tools/init_repo.ps1 Normal file
View file

@ -0,0 +1,29 @@
param(
[Parameter(Mandatory)][string]$ForgejoUrl,
[Parameter(Mandatory)][string]$User,
[Parameter(Mandatory)][string]$Repo
)
$Remote = $ForgejoUrl + '/' + $User + '/' + $Repo + '.git'
Write-Host '==> Initialising git repo' -ForegroundColor Cyan
git init
git add .
git commit -m 'chore: initial scaffold'
Write-Host '==> Pushing main branch' -ForegroundColor Cyan
git branch -M main
git remote add origin $Remote 2>$null
if ($LASTEXITCODE -ne 0) { git remote set-url origin $Remote }
git push -u origin main
Write-Host '==> Creating develop branch' -ForegroundColor Cyan
git checkout -b develop
git push -u origin develop
Write-Host ''
Write-Host 'Done! Next: create labels and milestones:' -ForegroundColor Green
Write-Host (' python tools\forgejo_setup.py --url ' + $ForgejoUrl + ' --token YOUR_TOKEN --owner ' + $User + ' --repo ' + $Repo)
Write-Host ''
Write-Host 'Then set branch protection at:' -ForegroundColor Yellow
Write-Host (' ' + $ForgejoUrl + '/' + $User + '/' + $Repo + '/settings/branches')

38
tools/init_repo.sh Normal file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# init_repo.sh
# Initialises the local git repo, sets up branch structure, and pushes to Forgejo.
#
# Usage:
# chmod +x tools/init_repo.sh
# FORGEJO_URL=https://your-forgejo.example.com \
# FORGEJO_USER=yourname \
# FORGEJO_REPO=squadron-td \
# bash tools/init_repo.sh
set -e
FORGEJO_URL="${FORGEJO_URL:?Set FORGEJO_URL}"
FORGEJO_USER="${FORGEJO_USER:?Set FORGEJO_USER}"
FORGEJO_REPO="${FORGEJO_REPO:?Set FORGEJO_REPO}"
REMOTE="$FORGEJO_URL/$FORGEJO_USER/$FORGEJO_REPO.git"
echo "==> Initialising git repo"
git init
git add .
git commit -m "chore: initial scaffold"
echo "==> Creating and pushing main branch"
git branch -M main
git remote add origin "$REMOTE" 2>/dev/null || git remote set-url origin "$REMOTE"
git push -u origin main
echo "==> Creating develop branch"
git checkout -b develop
git push -u origin develop
echo ""
echo "Done. Now run:"
echo " python3 tools/forgejo_setup.py --url $FORGEJO_URL --token TOKEN --owner $FORGEJO_USER --repo $FORGEJO_REPO"
echo ""
echo "Then configure branch protection in:"
echo " $FORGEJO_URL/$FORGEJO_USER/$FORGEJO_REPO/settings/branches"