From 3b13ae7c2363d529465ace2265656024fdfad19e Mon Sep 17 00:00:00 2001 From: Xanarch Date: Wed, 3 Jun 2026 21:53:16 -0400 Subject: [PATCH] chore: initial scaffold --- .forgejo/ISSUE_TEMPLATE/bug.md | 20 +++ .forgejo/ISSUE_TEMPLATE/data.md | 14 ++ .forgejo/ISSUE_TEMPLATE/feature.md | 18 ++ .forgejo/PULL_REQUEST_TEMPLATE.md | 19 +++ .forgejo/workflows/ci.yml | 62 +++++++ .gitignore | 13 ++ FORGEJO_WORKFLOW.md | 247 ++++++++++++++++++++++++++++ GDD.md | 157 ++++++++++++++++++ PROJECT_STRUCTURE.md | 137 +++++++++++++++ data/sends.json | 57 +++++++ data/towers/ancient.json | 50 ++++++ data/towers/fortress.json | 50 ++++++ data/towers/ghost.json | 82 +++++++++ data/towers/phantom.json | 50 ++++++ data/waves.json | 124 ++++++++++++++ project.godot | 45 +++++ scenes/gameplay/Creep.tscn | 26 +++ scenes/gameplay/Fighter.tscn | 20 +++ scenes/gameplay/Lane.tscn | 12 ++ scenes/gameplay/SecuritySystem.tscn | 27 +++ scenes/gameplay/Tower.tscn | 16 ++ scenes/gameplay/TowerSlot.tscn | 19 +++ scenes/main/GameLoop.tscn | 27 +++ scenes/main/Main.tscn | 6 + scenes/ui/BuildPanel.tscn | 28 ++++ scenes/ui/GameOver.tscn | 37 +++++ scenes/ui/HUD.tscn | 57 +++++++ scenes/ui/RaceSelect.tscn | 30 ++++ scripts/autoload/Economy.gd | 68 ++++++++ scripts/autoload/EventBus.gd | 31 ++++ scripts/autoload/GameState.gd | 23 +++ scripts/autoload/WaveManager.gd | 68 ++++++++ scripts/data/DataLoader.gd | 28 ++++ scripts/gameplay/Creep.gd | 90 ++++++++++ scripts/gameplay/Fighter.gd | 43 +++++ scripts/gameplay/GameLoop.gd | 62 +++++++ scripts/gameplay/Lane.gd | 164 ++++++++++++++++++ scripts/gameplay/SecuritySystem.gd | 27 +++ scripts/gameplay/Tower.gd | 49 ++++++ scripts/gameplay/TowerSlot.gd | 59 +++++++ scripts/main/Main.gd | 6 + scripts/ui/BuildPanel.gd | 47 ++++++ scripts/ui/GameOver.gd | 30 ++++ scripts/ui/HUD.gd | 52 ++++++ scripts/ui/RaceSelect.gd | 42 +++++ scripts/utils/DamageCalc.gd | 22 +++ tests/test_damage_calc.gd | 20 +++ tests/test_economy.gd | 27 +++ tests/test_wave_data.gd | 24 +++ tools/BRANCH_PROTECTION.md | 18 ++ tools/forgejo_setup.py | 101 ++++++++++++ tools/init_repo.ps1 | 29 ++++ tools/init_repo.sh | 38 +++++ 53 files changed, 2618 insertions(+) create mode 100644 .forgejo/ISSUE_TEMPLATE/bug.md create mode 100644 .forgejo/ISSUE_TEMPLATE/data.md create mode 100644 .forgejo/ISSUE_TEMPLATE/feature.md create mode 100644 .forgejo/PULL_REQUEST_TEMPLATE.md create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .gitignore create mode 100644 FORGEJO_WORKFLOW.md create mode 100644 GDD.md create mode 100644 PROJECT_STRUCTURE.md create mode 100644 data/sends.json create mode 100644 data/towers/ancient.json create mode 100644 data/towers/fortress.json create mode 100644 data/towers/ghost.json create mode 100644 data/towers/phantom.json create mode 100644 data/waves.json create mode 100644 project.godot create mode 100644 scenes/gameplay/Creep.tscn create mode 100644 scenes/gameplay/Fighter.tscn create mode 100644 scenes/gameplay/Lane.tscn create mode 100644 scenes/gameplay/SecuritySystem.tscn create mode 100644 scenes/gameplay/Tower.tscn create mode 100644 scenes/gameplay/TowerSlot.tscn create mode 100644 scenes/main/GameLoop.tscn create mode 100644 scenes/main/Main.tscn create mode 100644 scenes/ui/BuildPanel.tscn create mode 100644 scenes/ui/GameOver.tscn create mode 100644 scenes/ui/HUD.tscn create mode 100644 scenes/ui/RaceSelect.tscn create mode 100644 scripts/autoload/Economy.gd create mode 100644 scripts/autoload/EventBus.gd create mode 100644 scripts/autoload/GameState.gd create mode 100644 scripts/autoload/WaveManager.gd create mode 100644 scripts/data/DataLoader.gd create mode 100644 scripts/gameplay/Creep.gd create mode 100644 scripts/gameplay/Fighter.gd create mode 100644 scripts/gameplay/GameLoop.gd create mode 100644 scripts/gameplay/Lane.gd create mode 100644 scripts/gameplay/SecuritySystem.gd create mode 100644 scripts/gameplay/Tower.gd create mode 100644 scripts/gameplay/TowerSlot.gd create mode 100644 scripts/main/Main.gd create mode 100644 scripts/ui/BuildPanel.gd create mode 100644 scripts/ui/GameOver.gd create mode 100644 scripts/ui/HUD.gd create mode 100644 scripts/ui/RaceSelect.gd create mode 100644 scripts/utils/DamageCalc.gd create mode 100644 tests/test_damage_calc.gd create mode 100644 tests/test_economy.gd create mode 100644 tests/test_wave_data.gd create mode 100644 tools/BRANCH_PROTECTION.md create mode 100644 tools/forgejo_setup.py create mode 100644 tools/init_repo.ps1 create mode 100644 tools/init_repo.sh diff --git a/.forgejo/ISSUE_TEMPLATE/bug.md b/.forgejo/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..08c9e20 --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/bug.md @@ -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): diff --git a/.forgejo/ISSUE_TEMPLATE/data.md b/.forgejo/ISSUE_TEMPLATE/data.md new file mode 100644 index 0000000..87ac93c --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/data.md @@ -0,0 +1,14 @@ +--- +name: Balance / Data Change +about: Tower stats, wave definitions, economy values +labels: "type: data" +--- + +## What to Change + + +## Current Values + +## Proposed Values + +## Reasoning diff --git a/.forgejo/ISSUE_TEMPLATE/feature.md b/.forgejo/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000..d3c6678 --- /dev/null +++ b/.forgejo/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,18 @@ +--- +name: Feature Request +about: Propose a new gameplay feature +labels: "type: feature" +--- + +## Summary + + +## Motivation + + +## Acceptance Criteria +- [ ] +- [ ] + +## Notes + diff --git a/.forgejo/PULL_REQUEST_TEMPLATE.md b/.forgejo/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1ca43ac --- /dev/null +++ b/.forgejo/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Summary + + +## 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 + diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..949f961 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b11a4f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/FORGEJO_WORKFLOW.md b/FORGEJO_WORKFLOW.md new file mode 100644 index 0000000..49d0992 --- /dev/null +++ b/FORGEJO_WORKFLOW.md @@ -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 + + +## Motivation + + +## Acceptance Criteria +- [ ] +- [ ] + +## Notes + +``` + +### `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 + + +## 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 + + +## 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 + +``` + +--- + +## 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/ 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) +``` diff --git a/GDD.md b/GDD.md new file mode 100644 index 0000000..d619b0d --- /dev/null +++ b/GDD.md @@ -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 diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..a097341 --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -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/.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 +``` diff --git a/data/sends.json b/data/sends.json new file mode 100644 index 0000000..f08c73d --- /dev/null +++ b/data/sends.json @@ -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 + } +] diff --git a/data/towers/ancient.json b/data/towers/ancient.json new file mode 100644 index 0000000..6550858 --- /dev/null +++ b/data/towers/ancient.json @@ -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] + } +] diff --git a/data/towers/fortress.json b/data/towers/fortress.json new file mode 100644 index 0000000..db76dc4 --- /dev/null +++ b/data/towers/fortress.json @@ -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] + } +] diff --git a/data/towers/ghost.json b/data/towers/ghost.json new file mode 100644 index 0000000..f010e5e --- /dev/null +++ b/data/towers/ghost.json @@ -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] + } +] diff --git a/data/towers/phantom.json b/data/towers/phantom.json new file mode 100644 index 0000000..9f80833 --- /dev/null +++ b/data/towers/phantom.json @@ -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] + } +] diff --git a/data/waves.json b/data/waves.json new file mode 100644 index 0000000..cbdc028 --- /dev/null +++ b/data/waves.json @@ -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 } +] diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..8215b29 --- /dev/null +++ b/project.godot @@ -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 diff --git a/scenes/gameplay/Creep.tscn b/scenes/gameplay/Creep.tscn new file mode 100644 index 0000000..fd75978 --- /dev/null +++ b/scenes/gameplay/Creep.tscn @@ -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 diff --git a/scenes/gameplay/Fighter.tscn b/scenes/gameplay/Fighter.tscn new file mode 100644 index 0000000..40c3644 --- /dev/null +++ b/scenes/gameplay/Fighter.tscn @@ -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"] diff --git a/scenes/gameplay/Lane.tscn b/scenes/gameplay/Lane.tscn new file mode 100644 index 0000000..3239029 --- /dev/null +++ b/scenes/gameplay/Lane.tscn @@ -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) diff --git a/scenes/gameplay/SecuritySystem.tscn b/scenes/gameplay/SecuritySystem.tscn new file mode 100644 index 0000000..0309148 --- /dev/null +++ b/scenes/gameplay/SecuritySystem.tscn @@ -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 diff --git a/scenes/gameplay/Tower.tscn b/scenes/gameplay/Tower.tscn new file mode 100644 index 0000000..06eb99c --- /dev/null +++ b/scenes/gameplay/Tower.tscn @@ -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 diff --git a/scenes/gameplay/TowerSlot.tscn b/scenes/gameplay/TowerSlot.tscn new file mode 100644 index 0000000..eba1e55 --- /dev/null +++ b/scenes/gameplay/TowerSlot.tscn @@ -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) diff --git a/scenes/main/GameLoop.tscn b/scenes/main/GameLoop.tscn new file mode 100644 index 0000000..6b402a5 --- /dev/null +++ b/scenes/main/GameLoop.tscn @@ -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")] diff --git a/scenes/main/Main.tscn b/scenes/main/Main.tscn new file mode 100644 index 0000000..e8ecaca --- /dev/null +++ b/scenes/main/Main.tscn @@ -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") diff --git a/scenes/ui/BuildPanel.tscn b/scenes/ui/BuildPanel.tscn new file mode 100644 index 0000000..f82df53 --- /dev/null +++ b/scenes/ui/BuildPanel.tscn @@ -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" diff --git a/scenes/ui/GameOver.tscn b/scenes/ui/GameOver.tscn new file mode 100644 index 0000000..2f7b935 --- /dev/null +++ b/scenes/ui/GameOver.tscn @@ -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" diff --git a/scenes/ui/HUD.tscn b/scenes/ui/HUD.tscn new file mode 100644 index 0000000..d0fac9c --- /dev/null +++ b/scenes/ui/HUD.tscn @@ -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" diff --git a/scenes/ui/RaceSelect.tscn b/scenes/ui/RaceSelect.tscn new file mode 100644 index 0000000..b4a0d32 --- /dev/null +++ b/scenes/ui/RaceSelect.tscn @@ -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" diff --git a/scripts/autoload/Economy.gd b/scripts/autoload/Economy.gd new file mode 100644 index 0000000..b31668b --- /dev/null +++ b/scripts/autoload/Economy.gd @@ -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) diff --git a/scripts/autoload/EventBus.gd b/scripts/autoload/EventBus.gd new file mode 100644 index 0000000..0156eb8 --- /dev/null +++ b/scripts/autoload/EventBus.gd @@ -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() diff --git a/scripts/autoload/GameState.gd b/scripts/autoload/GameState.gd new file mode 100644 index 0000000..cb72b31 --- /dev/null +++ b/scripts/autoload/GameState.gd @@ -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", "") diff --git a/scripts/autoload/WaveManager.gd b/scripts/autoload/WaveManager.gd new file mode 100644 index 0000000..edb4cfd --- /dev/null +++ b/scripts/autoload/WaveManager.gd @@ -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 diff --git a/scripts/data/DataLoader.gd b/scripts/data/DataLoader.gd new file mode 100644 index 0000000..4b59634 --- /dev/null +++ b/scripts/data/DataLoader.gd @@ -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 [] diff --git a/scripts/gameplay/Creep.gd b/scripts/gameplay/Creep.gd new file mode 100644 index 0000000..7c78fe3 --- /dev/null +++ b/scripts/gameplay/Creep.gd @@ -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 diff --git a/scripts/gameplay/Fighter.gd b/scripts/gameplay/Fighter.gd new file mode 100644 index 0000000..953f738 --- /dev/null +++ b/scripts/gameplay/Fighter.gd @@ -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 diff --git a/scripts/gameplay/GameLoop.gd b/scripts/gameplay/GameLoop.gd new file mode 100644 index 0000000..5f962a3 --- /dev/null +++ b/scripts/gameplay/GameLoop.gd @@ -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) diff --git a/scripts/gameplay/Lane.gd b/scripts/gameplay/Lane.gd new file mode 100644 index 0000000..2aa4336 --- /dev/null +++ b/scripts/gameplay/Lane.gd @@ -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) diff --git a/scripts/gameplay/SecuritySystem.gd b/scripts/gameplay/SecuritySystem.gd new file mode 100644 index 0000000..d87186f --- /dev/null +++ b/scripts/gameplay/SecuritySystem.gd @@ -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 diff --git a/scripts/gameplay/Tower.gd b/scripts/gameplay/Tower.gd new file mode 100644 index 0000000..a13c0ea --- /dev/null +++ b/scripts/gameplay/Tower.gd @@ -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 diff --git a/scripts/gameplay/TowerSlot.gd b/scripts/gameplay/TowerSlot.gd new file mode 100644 index 0000000..e52af37 --- /dev/null +++ b/scripts/gameplay/TowerSlot.gd @@ -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) diff --git a/scripts/main/Main.gd b/scripts/main/Main.gd new file mode 100644 index 0000000..a506208 --- /dev/null +++ b/scripts/main/Main.gd @@ -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") diff --git a/scripts/ui/BuildPanel.gd b/scripts/ui/BuildPanel.gd new file mode 100644 index 0000000..f83bcbd --- /dev/null +++ b/scripts/ui/BuildPanel.gd @@ -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() diff --git a/scripts/ui/GameOver.gd b/scripts/ui/GameOver.gd new file mode 100644 index 0000000..39430b6 --- /dev/null +++ b/scripts/ui/GameOver.gd @@ -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() diff --git a/scripts/ui/HUD.gd b/scripts/ui/HUD.gd new file mode 100644 index 0000000..f5c7ea6 --- /dev/null +++ b/scripts/ui/HUD.gd @@ -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") diff --git a/scripts/ui/RaceSelect.gd b/scripts/ui/RaceSelect.gd new file mode 100644 index 0000000..9e0c26b --- /dev/null +++ b/scripts/ui/RaceSelect.gd @@ -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) diff --git a/scripts/utils/DamageCalc.gd b/scripts/utils/DamageCalc.gd new file mode 100644 index 0000000..1038fc1 --- /dev/null +++ b/scripts/utils/DamageCalc.gd @@ -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) diff --git a/tests/test_damage_calc.gd b/tests/test_damage_calc.gd new file mode 100644 index 0000000..82bd0ab --- /dev/null +++ b/tests/test_damage_calc.gd @@ -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) diff --git a/tests/test_economy.gd b/tests/test_economy.gd new file mode 100644 index 0000000..12604e7 --- /dev/null +++ b/tests/test_economy.gd @@ -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) diff --git a/tests/test_wave_data.gd b/tests/test_wave_data.gd new file mode 100644 index 0000000..cc885a0 --- /dev/null +++ b/tests/test_wave_data.gd @@ -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) diff --git a/tools/BRANCH_PROTECTION.md b/tools/BRANCH_PROTECTION.md new file mode 100644 index 0000000..c38d95a --- /dev/null +++ b/tools/BRANCH_PROTECTION.md @@ -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 diff --git a/tools/forgejo_setup.py b/tools/forgejo_setup.py new file mode 100644 index 0000000..d23510f --- /dev/null +++ b/tools/forgejo_setup.py @@ -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() diff --git a/tools/init_repo.ps1 b/tools/init_repo.ps1 new file mode 100644 index 0000000..62599f6 --- /dev/null +++ b/tools/init_repo.ps1 @@ -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') diff --git a/tools/init_repo.sh b/tools/init_repo.sh new file mode 100644 index 0000000..3edce63 --- /dev/null +++ b/tools/init_repo.sh @@ -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"