chore: initial scaffold
This commit is contained in:
commit
3b13ae7c23
53 changed files with 2618 additions and 0 deletions
20
.forgejo/ISSUE_TEMPLATE/bug.md
Normal file
20
.forgejo/ISSUE_TEMPLATE/bug.md
Normal 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):
|
||||||
14
.forgejo/ISSUE_TEMPLATE/data.md
Normal file
14
.forgejo/ISSUE_TEMPLATE/data.md
Normal 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
|
||||||
18
.forgejo/ISSUE_TEMPLATE/feature.md
Normal file
18
.forgejo/ISSUE_TEMPLATE/feature.md
Normal 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 -->
|
||||||
19
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
19
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal 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
62
.forgejo/workflows/ci.yml
Normal 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
13
.gitignore
vendored
Normal 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
247
FORGEJO_WORKFLOW.md
Normal 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
157
GDD.md
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
# Squadron TD Clone — Game Design Document
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A 1–8 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 (2–4):** 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 **8–12 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 | 1–3 |
|
||||||
|
| 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 | 1–31 |
|
||||||
|
| 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 1–10: economy building phase, lighter creeps.
|
||||||
|
- Waves 11–20: increasing pressure.
|
||||||
|
- Waves 21–30: 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 1–5 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
137
PROJECT_STRUCTURE.md
Normal 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
57
data/sends.json
Normal 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
50
data/towers/ancient.json
Normal 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
50
data/towers/fortress.json
Normal 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
82
data/towers/ghost.json
Normal 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
50
data/towers/phantom.json
Normal 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
124
data/waves.json
Normal 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
45
project.godot
Normal 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
|
||||||
26
scenes/gameplay/Creep.tscn
Normal file
26
scenes/gameplay/Creep.tscn
Normal 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
|
||||||
20
scenes/gameplay/Fighter.tscn
Normal file
20
scenes/gameplay/Fighter.tscn
Normal 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
12
scenes/gameplay/Lane.tscn
Normal 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)
|
||||||
27
scenes/gameplay/SecuritySystem.tscn
Normal file
27
scenes/gameplay/SecuritySystem.tscn
Normal 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
|
||||||
16
scenes/gameplay/Tower.tscn
Normal file
16
scenes/gameplay/Tower.tscn
Normal 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
|
||||||
19
scenes/gameplay/TowerSlot.tscn
Normal file
19
scenes/gameplay/TowerSlot.tscn
Normal 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
27
scenes/main/GameLoop.tscn
Normal 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
6
scenes/main/Main.tscn
Normal 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
28
scenes/ui/BuildPanel.tscn
Normal 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
37
scenes/ui/GameOver.tscn
Normal 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
57
scenes/ui/HUD.tscn
Normal 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
30
scenes/ui/RaceSelect.tscn
Normal 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"
|
||||||
68
scripts/autoload/Economy.gd
Normal file
68
scripts/autoload/Economy.gd
Normal 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)
|
||||||
31
scripts/autoload/EventBus.gd
Normal file
31
scripts/autoload/EventBus.gd
Normal 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()
|
||||||
23
scripts/autoload/GameState.gd
Normal file
23
scripts/autoload/GameState.gd
Normal 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", "")
|
||||||
68
scripts/autoload/WaveManager.gd
Normal file
68
scripts/autoload/WaveManager.gd
Normal 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
|
||||||
28
scripts/data/DataLoader.gd
Normal file
28
scripts/data/DataLoader.gd
Normal 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
90
scripts/gameplay/Creep.gd
Normal 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
|
||||||
43
scripts/gameplay/Fighter.gd
Normal file
43
scripts/gameplay/Fighter.gd
Normal 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
|
||||||
62
scripts/gameplay/GameLoop.gd
Normal file
62
scripts/gameplay/GameLoop.gd
Normal 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
164
scripts/gameplay/Lane.gd
Normal 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)
|
||||||
27
scripts/gameplay/SecuritySystem.gd
Normal file
27
scripts/gameplay/SecuritySystem.gd
Normal 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
49
scripts/gameplay/Tower.gd
Normal 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
|
||||||
59
scripts/gameplay/TowerSlot.gd
Normal file
59
scripts/gameplay/TowerSlot.gd
Normal 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
6
scripts/main/Main.gd
Normal 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
47
scripts/ui/BuildPanel.gd
Normal 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
30
scripts/ui/GameOver.gd
Normal 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
52
scripts/ui/HUD.gd
Normal 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
42
scripts/ui/RaceSelect.gd
Normal 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)
|
||||||
22
scripts/utils/DamageCalc.gd
Normal file
22
scripts/utils/DamageCalc.gd
Normal 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
20
tests/test_damage_calc.gd
Normal 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
27
tests/test_economy.gd
Normal 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
24
tests/test_wave_data.gd
Normal 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)
|
||||||
18
tools/BRANCH_PROTECTION.md
Normal file
18
tools/BRANCH_PROTECTION.md
Normal 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
101
tools/forgejo_setup.py
Normal 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
29
tools/init_repo.ps1
Normal 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
38
tools/init_repo.sh
Normal 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"
|
||||||
Loading…
Add table
Reference in a new issue