Initial commit — Station 0 vertical slice in progress
Includes full design documentation (ARCHITECTURE, DESIGN_NOTES, CONTENT_SPEC, PRODUCTION_NOTES) and active Godot 4 / GDScript codebase. Core systems built: player, floor generation, room system, enemy base, body part upgrades, run manager, save system, HUD, EventBus.
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
# Godot 4 — standard ignores
|
||||
.godot/
|
||||
*.uid
|
||||
|
||||
# Export / build output
|
||||
exports/
|
||||
*.exe
|
||||
*.x86_64
|
||||
*.dmg
|
||||
*.app
|
||||
|
||||
# OS junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor metadata
|
||||
.vscode/
|
||||
*.suo
|
||||
*.user
|
||||
|
||||
# Local config (not for version control)
|
||||
*.cfg
|
||||
!project.godot
|
||||
+600
@@ -0,0 +1,600 @@
|
||||
# Game Architecture Plan
|
||||
*Working title: Station 0 — roguelike + card game hybrid*
|
||||
|
||||
---
|
||||
|
||||
## Concept Summary
|
||||
You play as a robot aboard the last surviving human monitoring station, orbiting a planet that humanity once inhabited. Thousands of years after humanity's extinction — caused by their own damage to the planet — most robots have become corrupted. You survive, but with significant memory card corruption: you have your functions but not your history. Each run takes you deeper into the station. Permanent body part upgrades are truly permanent — never lost on death. A full card game (auto-battler format with hybrid card types) runs as a standalone minigame in the hub's cafeteria, played against other surviving robots you rescue and bring back. The hub world IS the main menu — no traditional start screen.
|
||||
|
||||
---
|
||||
|
||||
## Narrative Foundation
|
||||
|
||||
**Setting:** A space station built by humanity to monitor the vital signs of a planet they inhabited and ultimately destroyed. The station was the first of its kind, and is now the last surviving one. Humans worked alongside their robots here — the last joint effort before humanity disappeared entirely.
|
||||
|
||||
**Timeframe:** Thousands of years have passed since humanity went extinct. The station has been running in degraded state for so long that most robots are corrupted beyond recovery. A handful remain functional with their memory cards intact.
|
||||
|
||||
**The Player:** A surviving robot with partial memory card corruption. Not hostile — just amnesiac. They have their physical functions and basic operational knowledge, but their history and context are gone. This is the player's natural state for learning the world alongside the player.
|
||||
|
||||
**The Mystery:** The player uncovers what happened to the planet and to humanity through: data floor lore cards (fragmented archival records), NPC robots with intact memories, and card flavor text across all series. The answer is WALL-E in emotional structure — humans damaged what they loved until it couldn't recover — but expressed through the lens of fantastical alien ecology and robot grief.
|
||||
|
||||
**The Card Game (in-universe):** After humanity ended, the surviving robots developed an affinity for the life forms recorded in the station's archives. They used the data humans collected to create trading cards representing the species that lived on the planet — a game born from grief and preservation instinct. Each station zone has its own card series tied to the ecological region it monitored.
|
||||
|
||||
---
|
||||
|
||||
## Engine
|
||||
**Godot 4** — GDScript
|
||||
- Handles 2D (top-down/isometric) and 3D natively in one project
|
||||
- Built-in networking for online card game
|
||||
- Scene-based architecture maps cleanly to room/run/hub structure
|
||||
|
||||
---
|
||||
|
||||
## Project Folder Structure
|
||||
|
||||
```
|
||||
/project
|
||||
/scenes
|
||||
/hub/ — Hub world (multi-room, abandoned station feel)
|
||||
/control_room/
|
||||
/armory/
|
||||
/cafeteria/ — Card game played here
|
||||
/shop/
|
||||
/trophy_room/
|
||||
/staging_area/ — Upgrades + entrance to station
|
||||
/run/ — Dungeon run scenes
|
||||
/rooms/ — Room types (combat, item, shop, boss, secret, card_pack, lore, npc)
|
||||
/floors/ — Floor-level containers
|
||||
/card_game/ — Card game minigame
|
||||
/ui/ — Shared UI (HUD, minimap, menus, lore viewer)
|
||||
/player/ — Player scene + modular body parts
|
||||
/enemies/ — Enemy scenes
|
||||
/npcs/ — Companion NPC scenes
|
||||
/effects/ — Visual effects, transitions
|
||||
/3d_moments/ — 3D challenge/puzzle/fight scenes
|
||||
|
||||
/scripts
|
||||
/player/
|
||||
/enemies/
|
||||
/npcs/
|
||||
/rooms/
|
||||
/cards/
|
||||
/upgrades/
|
||||
/network/
|
||||
/save/
|
||||
/lore/
|
||||
|
||||
/resources
|
||||
/items/ — RunItem resources
|
||||
/cards/ — Card resources
|
||||
/upgrades/ — BodyPart resources
|
||||
/enemies/ — EnemyDefinition resources
|
||||
/npcs/ — NPCDefinition resources
|
||||
/lore/ — LoreTape resources
|
||||
/cosmetics/ — Cosmetic resources
|
||||
|
||||
/assets
|
||||
/sprites/
|
||||
/audio/
|
||||
/shaders/
|
||||
/fonts/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Singletons (Autoloads)
|
||||
|
||||
| Singleton | Responsibility |
|
||||
|-----------|---------------|
|
||||
| `GameManager` | Global state, scene transitions, session flow |
|
||||
| `RunManager` | Active run state (floor, room, items/NPCs this run) |
|
||||
| `SaveManager` | Read/write persistent data to disk |
|
||||
| `UpgradeManager` | Tracks equipped body parts and all previously acquired ones |
|
||||
| `CardCollection` | Player's full card collection and decks |
|
||||
| `NPCManager` | Tracks all NPC states (in hub, on run with player, lost) |
|
||||
| `LoreManager` | Tracks discovered lore tapes, syncs to player's "cloud" |
|
||||
| `EventBus` | Decoupled signal hub — all cross-system events |
|
||||
| `NetworkManager` | Multiplayer connection, matchmaking (Phase 9) |
|
||||
|
||||
---
|
||||
|
||||
## Core Systems
|
||||
|
||||
### 1. Player System
|
||||
|
||||
**Robot Designation: Adaptive Maintenance Unit**
|
||||
|
||||
The player is an adaptive maintenance robot — a class designed specifically to swap tool components in and out for different repair and monitoring tasks. This is diegetically why the modular body part system exists: it's not a gameplay abstraction, it's what the robot was built to do.
|
||||
|
||||
This also means the majority of corrupted enemies the player encounters are the same model — other maintenance units that have degraded beyond recovery. The player is fighting what they could become. The modular body part system works both ways: enemy maintenance robots visually reflect their degraded state through their parts, and the player can read a corrupted robot's loadout the same way they read their own.
|
||||
|
||||
Body parts found in the station are maintenance tool attachments that have been sitting dormant for thousands of years. Finding and equipping them is the player doing their job.
|
||||
|
||||
**Modular Robot Body**
|
||||
- 5 equipment slots: `Head`, `Torso`, `LeftArm`, `RightArm`, `Legs`
|
||||
- Each slot is a child Node2D with a Sprite2D — swapping a part changes the sprite
|
||||
- `BodyPart` resource defines: slot, sprite(s), stat modifiers, optional special ability
|
||||
- Default "base maintenance chassis" parts equipped at game start — worn, functional, generic
|
||||
|
||||
**Player Stats (calculated dynamically)**
|
||||
```
|
||||
base_stats + sum(equipped_part_modifiers) + sum(run_item_modifiers)
|
||||
```
|
||||
- Health, Speed, MoveControl, Damage, FireRate, Range, Luck
|
||||
|
||||
**Combat system** — BOI-style; large variety of items that modify combat in diverse ways.
|
||||
Developed fully in Phase 6 once core loop exists.
|
||||
|
||||
---
|
||||
|
||||
### 2. Death & Persistence
|
||||
|
||||
**On death, you LOSE:**
|
||||
- Any run items found during that run
|
||||
- Any card packs found during that run
|
||||
- Any scrap tokens — both those found in the run AND any you brought from the hub
|
||||
- Any buff items you brought into the run
|
||||
- Any NPCs who were on an escort run with you (they get "lost" — must be found again in future runs)
|
||||
|
||||
**On death, you KEEP:**
|
||||
- All permanent body part upgrades (always — no exceptions)
|
||||
- All lore discovered (tapes synced to archive, never lost)
|
||||
- Everything in the hub: card collection, cosmetics, hub token balance, NPCs who stayed behind
|
||||
|
||||
**Early exit — the alternative to death:**
|
||||
At any point during a run, the player can choose to exit early and return to the hub. On early exit, you keep everything you found during the run — items, card packs, tokens — exactly as if you had completed it successfully. You simply don't get whatever was deeper in. This is a meaningful strategic choice, not a fallback.
|
||||
|
||||
**Player agency:** Before a run, the player decides: which NPCs to bring (risk losing them on escort runs), how many tokens to withdraw from the hub bank (lost on death, spent in run shops/vending machines), and which buff items to carry in. The early exit option means the decision space continues throughout the run, not just at the start.
|
||||
|
||||
**Implication:** The risk/reward tension is active the entire time you're in a run. Body parts are true permanent progression. Everything else is a live bet on your own survival.
|
||||
|
||||
---
|
||||
|
||||
### 3. NPC / Companion System
|
||||
|
||||
This is a major system that ties the run, hub, card game, and lore together.
|
||||
|
||||
**NPC States:**
|
||||
```
|
||||
undiscovered → found (in run) → saved (in hub) → lost (died on a run with player)
|
||||
↑_______________________________________↓
|
||||
```
|
||||
|
||||
**During runs:**
|
||||
- NPCs are found in specific room types or hidden areas
|
||||
- Player can choose to bring them along on the run
|
||||
- They provide passive buffs or active abilities during the run
|
||||
- If the player dies with them, they are "lost" — their hub slot becomes empty and they must be found again (may appear in a different location next time)
|
||||
|
||||
**In the hub (cafeteria):**
|
||||
- Saved NPCs become card game opponents
|
||||
- Each has their own deck and difficulty
|
||||
- They also deliver lore through dialogue
|
||||
|
||||
**NPC classes and abilities**
|
||||
NPCs have their own fixed class and abilities — the player cannot modify them. Each NPC's class reflects their original station job and zone. Their combat ability (when they join on escort runs) and their card deck are expressions of who they were built to be, not something the player customizes.
|
||||
|
||||
**NPC development progression**
|
||||
Each NPC has a development track that advances through interactions: card matches played against them, dialogue conversations, and quests completed. Development gates what the NPC will offer the player over time:
|
||||
|
||||
1. **Early (newly saved):** Settles into the hub. Basic dialogue. Will play card matches.
|
||||
2. **Developing:** Opens up lore. Gives fetch quests (player goes solo, retrieves something from a run).
|
||||
3. **Trusted:** Offers escort quests — the NPC joins the player on a run to reach a specific location.
|
||||
4. **Established:** Deeper lore unlocks. May offer new card series, unlock new hub areas, or open permanent new options.
|
||||
|
||||
**Quest types**
|
||||
- **Fetch quests:** NPC stays in the hub. Player retrieves a specific item or reaches a specific location in a run. Reward: lore, new cards, advancement on NPC development track.
|
||||
- **Escort quests:** NPC joins the player on a run as a companion. Player must reach a destination with the NPC alive. Reward: unlocks new cards, new areas of the hub, or major lore reveals. High risk — if the player or NPC dies, the NPC is lost.
|
||||
|
||||
**Lost state during escort quests**
|
||||
If the player dies OR the NPC is defeated during an escort run, the NPC enters the "lost" state:
|
||||
- Their hub slot becomes empty
|
||||
- They are re-seeded into future runs (different floor, possibly different location than originally found)
|
||||
- When found again: all their data persists — same deck, same lore, same development progress, same dialogue memory
|
||||
- The NPC remembers what happened. Their dialogue reflects it.
|
||||
|
||||
**The Compass — consumable item**
|
||||
A rare run item. When used, it activates a homing signal that allows a lost NPC to navigate back to the hub on their own — without the player needing to escort them again. Consumed on use. Finding one feels meaningful because its value is entirely social: it's not a combat item, it's a rescue tool.
|
||||
|
||||
**NPC persistence:**
|
||||
- `NPCManager` tracks: name, state, hub location, deck, lore lines, development stage, quest state
|
||||
- "Lost" NPCs are re-seeded into runs (not necessarily same floor or location)
|
||||
- All NPC data persists through lost state — nothing resets on being lost and found again
|
||||
|
||||
---
|
||||
|
||||
### 4. Hub World
|
||||
|
||||
A multi-room physical space. Feels like an abandoned station — dark, dusty, only partially functional. The player navigates between rooms on foot.
|
||||
|
||||
| Room | Purpose |
|
||||
|------|---------|
|
||||
| **Control Room** | Central hub area, lore terminals with tapes, overview of station status |
|
||||
| **Armory** | Browse and equip body parts, view part stats/trade-offs |
|
||||
| **Cafeteria** | Card game tables, NPC opponents sit here when saved |
|
||||
| **Shop** | Buy cosmetics, card packs with accumulated currency |
|
||||
| **Trophy Room** | Collectibles and cosmetics unlocked during runs displayed here |
|
||||
| **Staging Area** | Final room before run entrance — equip/adjust loadout, talk to NPCs going with you |
|
||||
| **Rogue Robot Compendium** | Terminal in hub (Control Room or Trophy Room) displaying all logged corrupted robots encountered |
|
||||
| **Card Compendium** | Hub terminal displaying all cards ever seen or collected, organized by zone series |
|
||||
|
||||
The hub evolves as the player progresses:
|
||||
- More lore terminals light up
|
||||
- NPCs populate the cafeteria
|
||||
- Trophy room fills in
|
||||
- Certain rooms may unlock new areas as story progresses
|
||||
|
||||
---
|
||||
|
||||
### 5. Economy — Scrap Tokens
|
||||
|
||||
**Single unified currency.** Scrap tokens move freely between runs and the hub — there is no split currency. The same token you find in a run is the one you spend in the hub shop, and vice versa. This creates meaningful choices at every level of play.
|
||||
|
||||
Visually: old machine-stamped transit tokens — brass, worn, inscribed. Remnants of the station's original infrastructure repurposed by the surviving robots as an informal economy.
|
||||
|
||||
**Earning tokens**
|
||||
- **In runs:** dropped by enemies, found in rooms, found in destructible objects
|
||||
- **In the hub:** NPC quest rewards (fetch and escort completion), card match wins against NPCs
|
||||
|
||||
**Spending tokens**
|
||||
- **In runs:** vending machines and run shops (see below)
|
||||
- **In the hub:** hub shop (cosmetics, card packs, curated permanent items)
|
||||
|
||||
**The Hub Deposit Machine**
|
||||
A physical terminal in the hub — the station's original resource dispensary, repurposed as a token bank. Visually distinct and always in the same location. Mechanics:
|
||||
- Deposit tokens when returning from a run (or any time in the hub)
|
||||
- Withdraw any amount freely before or during your time in the hub — no penalty, no bombing required
|
||||
- Your total token balance is always visible on the machine
|
||||
|
||||
This is the connective tissue of the economy. Tokens you earn anywhere feed into the same pool. The machine makes that tangible.
|
||||
|
||||
**Taking tokens into runs**
|
||||
Before entering a run, the player can withdraw tokens from the machine to bring with them:
|
||||
- Useful if there's a specific shop item they're saving for, or if they want to buy multiple card packs
|
||||
- Tokens brought into a run are lost on death — this is a deliberate risk/reward choice
|
||||
- The player always knows their hub balance before deciding how much to risk
|
||||
|
||||
**Early run exit**
|
||||
The player can choose to abandon a run at any point and return to the hub, keeping:
|
||||
- All tokens collected during the run
|
||||
- All items and card packs found during the run (same as a successful run completion)
|
||||
- Any body parts found (permanent — always kept)
|
||||
|
||||
This creates a push-your-luck layer: every moment in a run is a live decision between cashing out now or pushing deeper. A player who has found great loot and a lot of tokens has real incentive to exit early. A player who has found nothing has incentive to push further.
|
||||
|
||||
**In-run economy — two tiers**
|
||||
|
||||
| Type | Location | Quality | Stock | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| **Vending machines** | Scattered throughout floors | Lower quality, random | Large, randomized per machine | Impulse buys, consumables, zone card pulls |
|
||||
| **Run shops** | Dedicated shop rooms (guaranteed per floor) | Higher quality, curated | Limited (3–5 items), refreshes rarely | Planned purchases, rare items, card packs |
|
||||
|
||||
*Vending machines:* Old dispensary units, still partially functional. Stock is randomized per machine per run — some may be broken, some have zone-specific or unusual inventory. Cheap, disposable, accessible.
|
||||
|
||||
*Run shops:* Automated retail rooms. No NPC present — just a terminal and a small curated selection. Higher cost, higher quality. Similar in feel to BOI shops, but with the station's aesthetic: terminal interface, items behind glass panels, purchased via token input.
|
||||
|
||||
**Token flow summary**
|
||||
```
|
||||
Hub bank ←→ Run (bring tokens in, bring tokens back if you survive or exit early)
|
||||
Run drops → Hub bank (deposited on return)
|
||||
Hub activities (card matches, NPC quests) → Hub bank
|
||||
Hub bank → Hub shop (spend on cosmetics, packs, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Run System
|
||||
|
||||
**Visual perspective — three modes**
|
||||
- **Primary: top-down 2D** — the default for most rooms and floors
|
||||
- **Secondary: isometric 2D** — specific rooms use isometric perspective for visual depth and theming; transitions between top-down and isometric rooms are handled per-room at the scene level
|
||||
- **Tertiary: 3D** — challenge rooms and full floor replacements (see Section 7)
|
||||
|
||||
All three can exist in a single run. The game never commits to only one look.
|
||||
|
||||
**Level generation — hybrid BOI + Spelunky style**
|
||||
- Grid-based room layout (like BOI)
|
||||
- Rooms have procedurally generated content within them (like Spelunky)
|
||||
- Each floor generates fresh every run — fully random
|
||||
- Guaranteed room types per floor: at least 1 item room, 1 shop, 1 boss room
|
||||
- Special rooms (lore, NPC, card pack, secret) placed by weighted RNG
|
||||
- Isometric rooms are flagged as a room variant — any room type can be isometric
|
||||
|
||||
**Floor structure — Station Zones**
|
||||
|
||||
Each zone is a distinct section of the station with its own ecological monitoring assignment. The zone determines: enemy type, environmental aesthetic, NPC robot personality/job, and card series found there.
|
||||
|
||||
| Zone | Station Role | Creature Theme | Card Series |
|
||||
|---|---|---|---|
|
||||
| **Botanical** | Tracked surface flora and terrestrial ecosystems | Earth, plant, and land creatures | Verdant Series |
|
||||
| **Aquatic** | Deep-water and ocean system monitoring | Ocean, river, deep-sea creatures | Tidal Series |
|
||||
| **Atmospheric** | Sky, weather, and aerial ecosystem monitoring | Sky, wind, and aerial creatures | Aether Series |
|
||||
| **Mineral** | Geological activity and subterranean monitoring | Rock, crystal, and cave creatures | Stratum Series |
|
||||
| **Arctic** | Polar region and ice ecosystem monitoring | Ice, tundra, and cold-climate creatures | Frost Series |
|
||||
| **Volcanic** | Thermal vent and magma zone monitoring | Fire, heat, and deep-earth creatures | Ember Series |
|
||||
| **Data** | Archival and records systems | All series (weighted random) + Legendary fragments | Archive Series (fragments) |
|
||||
|
||||
**Data Floors** are rare and special:
|
||||
- Access weighted random cards from ALL zone series
|
||||
- Contain fragmented Archive cards — pieces of lore explaining what happened to the planet and humanity
|
||||
- Feature stronger/legendary species not found in standard zone packs
|
||||
- May contain terminals with deeper lore than standard rooms
|
||||
- No NPC trainer is assigned to Data floors — they are discovery floors, not social ones
|
||||
|
||||
**Zone floor order — randomized with spawning rules**
|
||||
|
||||
Zones do not follow a fixed linear progression. Which zone appears next is determined by weighted RNG, making each run feel like exploring a different section of the station. The station's zones are not stacked floors — they're different wings and departments, and the player is navigating between them.
|
||||
|
||||
Confirmed rules:
|
||||
- Zone order is random per run
|
||||
- No two identical zones back-to-back
|
||||
- Data floors cannot appear on the first floor of a run
|
||||
- Data floors become more likely the deeper the run goes
|
||||
|
||||
Spawning rules TBD (to be determined through playtesting):
|
||||
- Whether certain zones are weighted toward earlier or later floors
|
||||
- Whether there are zone adjacency rules (e.g. Volcanic never adjacent to Aquatic)
|
||||
- Whether zone appearance is capped per run (e.g. at most 2 Botanical floors per run)
|
||||
|
||||
**Floor transition**
|
||||
- When moving to a new floor, there is a chance the entire next floor is replaced by a 3D section (challenge floor)
|
||||
- Otherwise, normal 2D floor generation continues
|
||||
|
||||
**Early run exit**
|
||||
The player can exit a run at any time from any cleared room by accessing the staging return terminal (or equivalent). On exit:
|
||||
- All tokens, items, and card packs found during the run are kept
|
||||
- Body parts are always kept regardless
|
||||
- The run is abandoned — no further progress that run
|
||||
- This is a deliberate push-your-luck decision, not a failsafe
|
||||
|
||||
**Room lifecycle**
|
||||
1. Player enters → enemies spawn (procedurally placed)
|
||||
2. Doors lock while enemies are alive
|
||||
3. All enemies dead → doors open, drops appear
|
||||
4. Room flagged cleared (no re-spawn on re-entry)
|
||||
|
||||
---
|
||||
|
||||
### 6. Minimap System
|
||||
|
||||
BOI-style minimap with 3 zoom stages, toggled by a key.
|
||||
|
||||
| Stage | Detail |
|
||||
|-------|--------|
|
||||
| Stage 1 (small) | Dots only — shows room positions, current room highlighted |
|
||||
| Stage 2 (medium) | Room shapes visible, door connections shown |
|
||||
| Stage 3 (large) | Full map with room type icons (boss skull, item star, shop bag, etc.) |
|
||||
|
||||
Icons on the map are only shown for rooms already visited (fog of war).
|
||||
Special items (Compass equivalent) can reveal boss room; others reveal all rooms or secret rooms.
|
||||
|
||||
---
|
||||
|
||||
### 7. 3D Moment System
|
||||
|
||||
Two types of 3D content:
|
||||
|
||||
**Type A — Secret rooms within a floor**
|
||||
- Hidden in 2D floors, found by exploring or by special item/ability
|
||||
- Each is a self-contained 3D challenge: platforming, puzzle, or big fight
|
||||
- Fundamentally different gameplay from 2D rooms
|
||||
- Reward is significant (rare part, legendary card, lore tape)
|
||||
|
||||
**Type B — Full 3D floor replacement**
|
||||
- When transitioning between floor sections, RNG can replace an entire floor with a 3D section
|
||||
- The whole floor plays out in 3D (multiple connected 3D challenges/areas)
|
||||
- Acts like a "bonus world" — higher difficulty, higher reward
|
||||
- More frequent in deeper floors (Floors 7+)
|
||||
|
||||
**Technical implementation**
|
||||
- `ViewManager` handles transitions: fade out, swap scene, fade in, remap controls
|
||||
- 3D scenes are Godot 3D sub-scenes, loaded independently
|
||||
- Player stats carry over but are translated to 3D equivalents
|
||||
- On completion, transition back to 2D (next floor or back to cleared room)
|
||||
|
||||
---
|
||||
|
||||
### 8. Lore System
|
||||
|
||||
**Delivery methods:**
|
||||
1. **Terminals with tapes** — found in control room (hub) and lore rooms in runs. Watch the tape → it syncs to the player's archive (accessible from hub terminal AND pause menu lore log)
|
||||
2. **NPCs** — saved companions deliver lore through dialogue in the hub cafeteria; gated by development stage
|
||||
3. **Card flavor text** — all cards have lore in their flavor text; Archive/Fragmented cards are the primary story delivery mechanism
|
||||
4. **Rogue Robot Compendium** — enemy log. Every corrupted robot encountered in the station gets an entry: original designation, assigned zone, their role, and what happened to them. Tracked in the hub. Accessible from pause menu.
|
||||
5. **Card Compendium** — full collection log. Every card ever seen or acquired, organized by zone series. Includes flavor text, artwork, and series notes. Tracked in the hub. Accessible from pause menu.
|
||||
6. **Environmental** — readable notes, station signage, wall markings (secondary, ambient method)
|
||||
|
||||
**Access:** All discovered lore is accessible in two places:
|
||||
- **In-hub terminals** (Control Room / Trophy Room) — the primary experience, feels like an event
|
||||
- **Pause menu archive tab** — always available, for reference during play
|
||||
|
||||
**LoreManager** tracks:
|
||||
- Which tapes discovered/watched
|
||||
- Which NPC lore lines triggered
|
||||
- Which Compendium entries unlocked (robot encounters + card acquisitions)
|
||||
- Lore is never lost — persists through all deaths
|
||||
|
||||
---
|
||||
|
||||
### 9. Card Game System (Full Minigame — Phase 7+)
|
||||
|
||||
**Format: Deck-based TCG with Auto-battler Combat Resolution**
|
||||
|
||||
A traditional deck-based card game where the combat phase resolves automatically. No in-match shop — the strategic layer lives in deck construction (before the match) and hand/board management (during). Closer to a TCG than Hearthstone Battlegrounds, with auto-battler combat as the payoff.
|
||||
|
||||
**Match structure**
|
||||
- Each player starts with a full deck and draws an opening hand of ~10 cards
|
||||
- **Prep phase**: Players play cards from hand to build their board — creatures placed, items/upgrades attached, support cards set, arena cards committed, trainer cards used for utility actions
|
||||
- **Combat phase**: Boards auto-resolve — creatures fight by priority/left-to-right
|
||||
- Cards played move to discard pile. Players draw more next prep phase.
|
||||
- When deck is empty → reshuffle discard pile and continue
|
||||
- First player to win [TBD] combat rounds wins the match — match length scales with NPC tier
|
||||
|
||||
**The meta layer:** Trainer cards are the engine of a deck. They let you draw more, search for specific cards, recover discarded pieces, or disrupt the opponent's hand. The strategic ceiling of deck construction is: how consistently can you access what you need, when you need it?
|
||||
|
||||
**Match win condition**
|
||||
- Base: first player to win 3 combat rounds wins the match
|
||||
- Certain rare cards can modify this rule mid-match (see Protocol cards below)
|
||||
- This means match length is not fixed — it's a live variable that both players can influence through their decks
|
||||
|
||||
**Card Types**
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| **Creature** | Core auto-battle units. Represent species from the planet. Have Attack, Health, and a passive trait or triggered ability. |
|
||||
| **Item / Upgrade** | Attach to a specific creature. Modifies its stats or grants a new behavior in combat. Equipment analogue. |
|
||||
| **Trainer** | One-time prep phase action. Draw extra cards, search deck, disrupt opponent's hand, recover a creature. |
|
||||
| **Support** | Persistent passive that stays on your side for multiple rounds. Auras, structures, environmental benefits. |
|
||||
| **Arena** | Battlefield modifier. Changes the rules of combat for the round. Weather, terrain, gravity, lighting effects. |
|
||||
| **Protocol** | Rare. Changes the fundamental rules of the match itself. Win condition modifiers, turn structure changes, scoring alterations. |
|
||||
|
||||
**Protocol cards — match rule modifiers**
|
||||
Protocol cards are the rarest card type. They alter the match's win condition or structural rules rather than affecting the battlefield directly. Examples:
|
||||
- *Extended Session*: "+1 win required for both players this match" (extends the match)
|
||||
- *Sudden Resolution*: "Next combat round is worth 2 wins" (compresses the match)
|
||||
- *Deadlock Protocol*: "Tied combat rounds now count as wins for the leading player"
|
||||
|
||||
These are Epic or Legendary rarity and are found primarily in Data floors and as boss rewards. Because they are rare, match length variance increases naturally as players acquire more cards over time — the challenge scales with collection depth rather than a preset difficulty mode. Building a deck around Protocol manipulation is a distinct high-skill strategy.
|
||||
|
||||
**Card resource — per card**
|
||||
- Name, artwork, zone series, rarity (Common/Rare/Epic/Legendary/Fragmented)
|
||||
- Type (Creature/Item/Upgrade/Trainer/Support/Arena)
|
||||
- Stat block (creatures only: Attack, Health, trait)
|
||||
- Effect text
|
||||
- Flavor text — lore-connected, written from the perspective of a robot who has studied the data
|
||||
|
||||
**Fragmented / Archive cards**
|
||||
- Found only in Data floors and deep lore rooms
|
||||
- Represent partial archival records — they may depict extinct species, lost habitats, or pieces of the planet's final years
|
||||
- Flavor text is the story delivery mechanism — each fragment tells a piece of what happened
|
||||
- Mechanically powerful (Legendary tier) but also lore-complete entries in the archive log
|
||||
|
||||
**Zone series**
|
||||
- Each zone has its own card series with creatures matching that ecological theme
|
||||
- Players build decks around zone affinities or mix across zones
|
||||
- Saved NPC robots have decks built from their zone's series — their deck is an expression of who they are and what they monitored
|
||||
|
||||
**Opponents**
|
||||
- NPCs saved and brought to hub sit in cafeteria and can be challenged
|
||||
- Each NPC has a unique deck built from their zone series + personality
|
||||
- AI difficulty scales with NPC tier and floor depth they were found on
|
||||
- NPC dialogue before/after matches delivers lore (intact memory cards = they remember everything)
|
||||
|
||||
**In-match card flow**
|
||||
- Draw from personal deck (built and brought to the match)
|
||||
- Cards played → discard pile
|
||||
- Deck emptied → shuffle discard → new draw pile (no interruption to match)
|
||||
- Trainer cards are the primary way to accelerate access: draw extra cards, search deck, recover from discard
|
||||
|
||||
**Card acquisition (outside matches)**
|
||||
- Zone-specific packs found in runs (survive to bring them back)
|
||||
- Packs bought in hub shop
|
||||
- Rare/Legendary cards as boss or deep-floor rewards
|
||||
- Archive fragments exclusively from Data floors and lore rooms
|
||||
|
||||
**Online PvP (Phase 9 — deferred)**
|
||||
- NetworkManager handles connection and matchmaking
|
||||
- Trading via hub trading board
|
||||
|
||||
---
|
||||
|
||||
### 10. Save System
|
||||
|
||||
**SaveManager** persists to disk:
|
||||
- Body parts equipped (survive runs) and those in armory/hub
|
||||
- Card collection and deck configs
|
||||
- Cosmetics in trophy room
|
||||
- NPC states (in hub, lost, undiscovered)
|
||||
- Lore tapes discovered (cloud log)
|
||||
- Currency in hub shop
|
||||
- Run history / stats
|
||||
|
||||
**NOT saved:**
|
||||
- Mid-run state (no mid-run saves — death is final for that run)
|
||||
- Items/parts/packs found during an in-progress run (only saved on successful return to hub)
|
||||
|
||||
---
|
||||
|
||||
### 11. EventBus (Signal Hub)
|
||||
|
||||
Key signals:
|
||||
```gdscript
|
||||
signal player_died
|
||||
signal run_started
|
||||
signal run_ended(reached_hub: bool)
|
||||
signal room_cleared(room_id)
|
||||
signal item_collected(item: RunItem)
|
||||
signal card_pack_found(pack: CardPack)
|
||||
signal body_part_found(part: BodyPart)
|
||||
signal body_part_equipped(part: BodyPart)
|
||||
signal floor_cleared(floor_number: int)
|
||||
signal npc_found(npc: NPCDefinition)
|
||||
signal npc_lost(npc: NPCDefinition)
|
||||
signal npc_saved(npc: NPCDefinition)
|
||||
signal lore_discovered(lore_id: String)
|
||||
signal card_game_started(opponent)
|
||||
signal card_game_ended(winner)
|
||||
signal view_switching_to_3d(scene_path: String)
|
||||
signal view_switching_to_2d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Order
|
||||
|
||||
| Phase | Milestone |
|
||||
|-------|----------|
|
||||
| 1 | Player: movement, shooting placeholder, modular body (stat system) |
|
||||
| 2 | Single test room: enemies, basic combat, drops |
|
||||
| 3 | Room generation: multi-room floors, door system, room types |
|
||||
| 4 | Hub world: all 6 rooms, physical navigation |
|
||||
| 5 | Permanent upgrade system: body parts found in runs, survive to keep |
|
||||
| 6 | Full run loop: floors, boss rooms, death, return to hub with rewards |
|
||||
| 7 | Combat depth: BOI-style item variety, status effects, projectile behaviors |
|
||||
| 8 | NPC system: find in runs, bring to hub, card opponents, lore dialogue |
|
||||
| 9 | Card game: rules engine, deck builder, AI opponents |
|
||||
| 10 | Lore system: terminals, tapes, cloud log, NPC lore, card flavor |
|
||||
| 11 | 3D moment rooms: secret challenges + floor replacement type |
|
||||
| 12 | Minimap: 3-stage BOI style |
|
||||
| 13 | Online card game: networking, matchmaking, trading |
|
||||
| 14 | Cosmetics, trophy room, polish, audio |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions / Still To Decide
|
||||
|
||||
- [x] Game title — **Station 0.** The first station built (Station Zero = prototype, origin). Last surviving. Zero humans remaining. Player starts at zero — no memory, no context. Clean as a logo, doesn't over-explain.
|
||||
- [x] Player robot's designation — **Adaptive Maintenance Unit. Designed to swap tool-part components. Explains modular body system diegetically. Memory card corrupted (amnesia), not hostile. Most enemies are corrupted maintenance units of the same class.**
|
||||
- [x] What caused the incident — **humanity destroyed the planet through neglect/overuse (WALL-E structure). Station was built to monitor the planet's vital signs. Robots outlived their creators by thousands of years.**
|
||||
- [x] Card game theme — **in-universe game built by robots using human archival data. Cards represent species that lived on the planet. Born from grief and preservation instinct.**
|
||||
- [x] Top-down vs isometric — **primary top-down, isometric rooms as variants, 3D for challenges**
|
||||
- [x] What do NPCs look like — **other robots, each designed for their zone's job (botanical NPC looks like a gardening/analysis robot, mineral NPC looks like a geological survey robot, etc.)**
|
||||
- [x] Card game shop — **no shop. Deck-based draw with discard pile reshuffle. Trainer cards are the access engine. Strategic layer is in deck construction, not in-match economy.**
|
||||
- [x] Zone floor order — **randomized per run with spawning rules. No fixed progression. Station zones are different wings, not stacked floors. Rules TBD through playtesting.**
|
||||
- [x] Hub permanent currency — **single unified currency (scrap tokens). Found in runs, earned in hub (NPC quests, card matches). Stored in hub deposit machine. Freely withdrawn into runs. Lost on death. Spent in both in-run economy (vending machines, run shops) and hub shop. Early run exit lets player cash out and keep everything found so far.**
|
||||
- [x] NPC role during runs — **quest-gated. Fixed classes the player cannot modify. Fetch quests (solo) and escort quests (NPC joins run). Escort: if player or NPC dies, NPC gets lost but all data persists. Compass consumable (rare, one-time use) lets a lost NPC find their own way home.**
|
||||
- [x] Lore archive access — **both: hub terminals (Control Room / Trophy Room) for the full experience, and pause menu archive tab for reference during play.**
|
||||
- [x] Match win condition — **base: first to 3 wins. Protocol cards (rare) can modify this mid-match. Challenge scales with card collection depth, not preset modes.**
|
||||
- [x] Visual style scope — **FRLG remake aesthetic applies to the whole game. Top-down world, hub, card game UI, and creature art all share the same visual language.**
|
||||
- [x] Zone spawning weight rules — **fully random. Data floors at lower spawn rate. Adjacency and weight rules TBD through playtesting.**
|
||||
- [x] Run currency — **scrap tokens. Old machine-stamped transit tokens. Found in runs, spent at vending machines (temporary buffs, cards, consumables), lost on death.**
|
||||
|
||||
---
|
||||
|
||||
## Performance Notes
|
||||
|
||||
Identified risks and mitigation strategies.
|
||||
|
||||
| Area | Risk | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| 2D↔3D transitions | Hitch/freeze if 3D scene loads synchronously | **High** | Use `ResourceLoader.load_threaded_request()` to background-load 3D scenes well before the transition fires. Design this in from Phase 11, not as an afterthought. |
|
||||
| NetworkManager autoload | Startup delay + wasted memory in all non-online phases | **Medium** | Don't connect/initialize in `_ready()`. Make it fully lazy — only activate when the card game requests a connection. |
|
||||
| Card game AI | Frame stall if AI decision runs on main thread | **Medium** | Run AI logic in a `Thread` or via coroutine. Never block the main thread for card evaluation. |
|
||||
| Procedural room/floor generation | Possible hitch on floor entry if generated synchronously | **Low–Medium** | Generate the next floor in the background while the player is still clearing the current one. |
|
||||
| Hub world sub-rooms | Hitch on room transition if scenes load on demand | **Low** | Pre-load adjacent hub rooms or load the full hub as one scene with visibility toggling. |
|
||||
|
||||
**Non-issues (look complex, aren't):**
|
||||
- Modular body (5 Sprite2Ds) — trivially cheap
|
||||
- Dynamic stat recalculation — fine if event-driven, not per-frame
|
||||
- Save/load — disk I/O on explicit events only
|
||||
- Card collection size — even 500+ Resource entries is negligible in memory
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
# Station 0 — Content Spec (Vertical Slice)
|
||||
|
||||
Last updated: 2026-03-03
|
||||
Scope: Vertical slice only. All content here is confirmed for VS unless marked **[OPTIONAL]**.
|
||||
|
||||
---
|
||||
|
||||
## Enemies
|
||||
|
||||
All enemies are corrupted Adaptive Maintenance Units — same chassis class as the player, visually degraded. Their corruption manifests in their function, not their form.
|
||||
|
||||
---
|
||||
|
||||
### DRIFTER
|
||||
**Corruption type**: Locomotion
|
||||
**Movement**: Pathfinding broken. Moves in slow arcs and wide curves. Never charges in a straight line.
|
||||
**Attack**: Contact damage only. No projectiles.
|
||||
**Threat level**: Low-Medium
|
||||
**Design role**: Individually harmless. Disruptive in groups — fills the room with unpredictable bodies, cuts off escape routes. Forces players to unlearn "dodge in a straight line" habits.
|
||||
|
||||
---
|
||||
|
||||
### REPEATER
|
||||
**Corruption type**: Task-loop
|
||||
**Movement**: Slow. Drifts toward last known player position, stops, then executes its loop.
|
||||
**Attack**: Fires a burst of 3 projectiles in a fixed direction, rotates ~20° clockwise, fires again, repeats indefinitely. Does not track the player — it is completing a stuck subroutine.
|
||||
**Threat level**: Medium
|
||||
**Design role**: Pattern recognition. First enemy that teaches players to observe before moving. Dangerous in confined rooms or when combined with other threats.
|
||||
|
||||
---
|
||||
|
||||
### ANCHOR
|
||||
**Corruption type**: Structural integrity
|
||||
**Movement**: Pathfinds to the nearest wall or corner and locks in. Does not move once anchored.
|
||||
**Knockback**: Immune to knockback while anchored.
|
||||
**Attack**: Fires slow homing projectiles at the player until destroyed. Projectiles are individually easy to outrun but accumulate.
|
||||
**Threat level**: High
|
||||
**Design role**: Changes room geometry. Turns safe corners into bad positions. Forces aggressive play. Punishes passivity.
|
||||
|
||||
---
|
||||
|
||||
## Boss: THE SUPERVISOR
|
||||
|
||||
A larger maintenance unit whose supervisor protocol has fully overridden all other functions. It does not perceive the player as an enemy — it perceives them as a malfunctioning unit and is attempting to decommission them.
|
||||
|
||||
**Room**: Large. No environmental hazards. Preceded by a charging station in the hallway (full HP restore before entry).
|
||||
|
||||
### Phase 1 (100%–55% HP)
|
||||
- Patrols a fixed rectangular path around the room.
|
||||
- When the player enters a forward sensor cone, fires a sweeping calibration beam (slow AoE line that leaves a brief floor hazard — clears within ~1 second, nothing lingering).
|
||||
- Freely damageable from behind and sides.
|
||||
- No adds. Room feels almost too easy — players get overconfident.
|
||||
|
||||
### Phase 2 (55%–20% HP)
|
||||
- Patrol pattern collapses. Supervisor begins tracking the player directly.
|
||||
- Moves faster. Calibration beam fires more frequently at tighter arcs.
|
||||
- Spawns one Drifter from a maintenance hatch.
|
||||
- Visual state change: one arm hanging, sparking.
|
||||
|
||||
### Climax (below 20% HP)
|
||||
- Supervisor freezes mid-room. Broadcasts a distress signal (visual + audio cue).
|
||||
- Frozen for ~2 seconds. Not invulnerable — can be killed during the freeze.
|
||||
- No lore triggered on death.
|
||||
- On death: guaranteed body part drop, then powers down.
|
||||
|
||||
**Emotional intent**: The freeze is not a mechanic — it's a beat. Players will kill it without thinking, then maybe feel it a second later. WALL-E tone.
|
||||
|
||||
### Post-boss
|
||||
- Body part drop (guaranteed).
|
||||
- Explicit early exit prompt offered.
|
||||
- Hub door opens.
|
||||
|
||||
---
|
||||
|
||||
## Run Items
|
||||
|
||||
8 items for VS. Populate item rooms and shops. BOI-style: diverse effects, some synergies, not just stat buffs.
|
||||
|
||||
| Item | Effect |
|
||||
|---|---|
|
||||
| **Coolant Leak** | Leaves a slick trail for 1s when you take damage. Enemies that touch it are slowed for 2s. |
|
||||
| **Overclock Module** | Fire rate +40%. Every 3rd shot deals 0 damage. |
|
||||
| **Scrap Magnet** | Scrap tokens auto-collect within 3 tiles. |
|
||||
| **Memory Spike** | First projectile fired in each room deals 3× damage. |
|
||||
| **Rust Coat** | Reduce all incoming damage by 1 (min 1). |
|
||||
| **Static Discharge** | On taking damage, emit a short-range AoE burst (1 tile radius). |
|
||||
| **Fragmented Map** | Each time you clear a combat room, one unexplored room on the floor map is revealed. |
|
||||
| **Patch Kit** | Consumed on pickup: restore 2 HP immediately. |
|
||||
|
||||
### Synergies worth noting
|
||||
- **Coolant Leak + Static Discharge**: Taking damage becomes room control.
|
||||
- **Memory Spike + Overclock Module**: Active tension — sacrificing your free first shot on dead 3rd shots.
|
||||
- **Rust Coat + Lightweight Frame** (body part): Glass cannon with a floor on incoming damage.
|
||||
|
||||
---
|
||||
|
||||
## Body Parts
|
||||
|
||||
8 non-default upgrades across 4 active slots for VS. All slots have a default (baseline, no stat change) that is never listed as a pickup.
|
||||
|
||||
Trade-offs are required — no pure upgrades.
|
||||
|
||||
### HEAD
|
||||
| Part | Effect |
|
||||
|---|---|
|
||||
| **Wide-Angle Lens** | Vision radius +30%. Projectile speed -20%. |
|
||||
| **Targeting Spike** | Projectile speed +40%, range +25%. Field of view -20%. |
|
||||
|
||||
### TORSO
|
||||
| Part | Effect |
|
||||
|---|---|
|
||||
| **Reinforced Chassis** | Max HP +2. Move speed -10%. |
|
||||
| **Lightweight Frame** | Move speed +20%. Max HP -1. |
|
||||
|
||||
### LEFT ARM (utility/shield slot)
|
||||
| Part | Effect |
|
||||
|---|---|
|
||||
| **Scatter Emitter** | Fires 3-way spread instead of single shot. Each projectile deals 60% damage. |
|
||||
| **Shield Projector** | Active block on cooldown (3s): absorbs one hit. No offensive change. |
|
||||
|
||||
### RIGHT ARM (damage slot)
|
||||
| Part | Effect |
|
||||
|---|---|
|
||||
| **Heavy Emitter** | Damage +60%. Fire rate -40%. |
|
||||
| **Rapid Emitter** | Fire rate +50%. Damage -30%. |
|
||||
|
||||
### LEGS **[OPTIONAL — include if bandwidth allows]**
|
||||
| Part | Effect |
|
||||
|---|---|
|
||||
| **Hydraulic Boosters** | Move speed +15%. Deceleration takes longer. Brief i-frames on dash through enemies. |
|
||||
|
||||
---
|
||||
|
||||
## Room Content Spec
|
||||
|
||||
### Room types
|
||||
|
||||
**Combat rooms**
|
||||
Doors lock on entry. Unlock when all enemies are defeated.
|
||||
|
||||
| Floor depth | Enemy count | Composition |
|
||||
|---|---|---|
|
||||
| Floor 1, rooms 1–3 | 1–2 | Drifters only |
|
||||
| Floor 1, rooms 4+ | 2–3 | Drifters + 1 Repeater |
|
||||
| Floor 2 | 2–4 | Mix of all three types |
|
||||
| Floor 3 (pre-boss) | 2–3 | Include Anchor; rooms tighter |
|
||||
|
||||
Enemy count capped by room size — small rooms get -1 enemy.
|
||||
|
||||
**Item rooms**
|
||||
One item on a pedestal. No enemies. Safe. 20% chance to also contain a lore terminal (not wired to any lore content for VS — terminal present, content placeholder).
|
||||
|
||||
**Shop rooms**
|
||||
Unmanned. No NPC keeper. Sells 2 items + 1 consumable at fixed scrap prices. No haggling. Purely transactional — all NPC personality lives in the hub.
|
||||
|
||||
**Boss room**
|
||||
See Boss section above.
|
||||
|
||||
---
|
||||
|
||||
### Environmental Hazards
|
||||
|
||||
Both hazard types included in VS.
|
||||
|
||||
**Oil slick**
|
||||
- Slippery surface. Reduces player movement control (momentum-based sliding).
|
||||
- Enemies unaffected.
|
||||
- Appears as puddles in combat rooms.
|
||||
|
||||
**Exposed wiring**
|
||||
- Deals 1 HP on contact.
|
||||
- Blocks movement paths — functions as impassable terrain with a damage border.
|
||||
- Appears as sparking floor sections along walls.
|
||||
|
||||
---
|
||||
|
||||
### Run arc (VS target)
|
||||
|
||||
| Floor | Room count | Beat |
|
||||
|---|---|---|
|
||||
| Floor 1 | 4–5 rooms | Learn the rhythm. One item. Maybe find the shop. First Repeater feels like a puzzle. |
|
||||
| Floor 2 | 5–6 rooms | Mixed threats. Find a body part. Build takes shape. One tense near-death moment. |
|
||||
| Floor 3 | 2 rooms + boss | Charging station. Supervisor fight. The freeze. Kill it. Hub door opens. |
|
||||
|
||||
**Target run time**: 20–30 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (not resolved, revisit post-VS)
|
||||
|
||||
- Zone-specific enemy variants — do hazard types change per zone, or stay consistent?
|
||||
- Mini-boss rooms — add for post-VS (e.g., named Anchor variant + Drifters in a sealed room)?
|
||||
- Lore terminal content — terminals exist in VS as placeholders; content pass comes later.
|
||||
- Hydraulic Boosters — confirm in or out based on animation budget before VS build locks.
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# Design Notes — Standing Out in the Roguelike Market
|
||||
|
||||
---
|
||||
|
||||
## The Core Differentiator: The NPC-Card-Lore Triangle
|
||||
|
||||
This is the thing no other game does, and it needs to be the centerpiece of everything:
|
||||
|
||||
- You find a survivor in a run. You risk bringing them along. They die. You mourn them.
|
||||
- You find them again in a future run — different floor, maybe a different disposition. Did they remember?
|
||||
- You bring them home. They sit in your cafeteria. Their card deck *is their personality*. Their dialogue tells you what they know about what happened.
|
||||
|
||||
No other game does this. Not Hades, not Slay the Spire, not BOI. The card game being the relationship layer — where you play against characters you rescued — is the emotional hook.
|
||||
|
||||
**This only works if the NPCs feel like real characters**, not systems. They need:
|
||||
- Distinct voices and personalities
|
||||
- Dialogue that reacts to being lost and found again
|
||||
- Responses to run outcomes ("you almost didn't make it back")
|
||||
- Their deck reflecting what they know and who they are
|
||||
|
||||
The Hades lesson: the loop is only meaningful if the people waiting at home are real.
|
||||
|
||||
---
|
||||
|
||||
## The Mystery Has a Shape Now
|
||||
|
||||
**What happened:** Humanity damaged and ultimately destroyed the planet they lived on — through accumulated neglect, overuse, and a failure to change course in time. The station was built not in triumph but in guilt: a vigil. Humans and robots worked together here, watching the vital signs of a dying world, until the humans were gone too.
|
||||
|
||||
The emotional structure is WALL-E: not a villain, not an attack, just the slow weight of what people do when they love something and take it for granted until it's too late.
|
||||
|
||||
**Why this works for the mystery:**
|
||||
- The player doesn't know any of this at the start (memory corruption)
|
||||
- The lore delivery is archaeological — fragments, not explanations
|
||||
- Each Archive card is a piece of a picture the player assembles themselves
|
||||
- NPC robots with intact memories are unreliable narrators in an interesting way: they remember everything but have had thousands of years to process it alone, and each has a different relationship to what happened
|
||||
- The creatures on the cards are beautiful and clearly once-living — the tragedy is present in the game's most visible layer before the player even reads a lore entry
|
||||
|
||||
**What the reveal feels like:** Not a twist. A confirmation of what the player already suspects, delivered with weight. Inscryption's lesson applies: the systems point at the answer before it's spoken. A player who finishes the game should feel like they understood it before the last lore card told them.
|
||||
|
||||
---
|
||||
|
||||
## The Body Part System Has Untapped Potential
|
||||
|
||||
Currently parts are stat modifiers + sprite swaps. What would make them memorable:
|
||||
|
||||
**Parts that are recognizable.**
|
||||
If you find an arm in the wreckage and a lore tape later names the robot it belonged to, that part now has weight. You're not just wearing an upgrade — you're carrying a piece of someone.
|
||||
|
||||
**Parts with personality or drawbacks.**
|
||||
The best BOI items have costs or consequences. Pure stat buffs aren't memorable. Examples:
|
||||
- A head that gives massive range but narrows vision
|
||||
- A torso that boosts speed but makes you louder (enemy detection range increases)
|
||||
- An arm that overclocks fire rate but generates heat (cooldown penalty)
|
||||
|
||||
**Enemies using the same modular system.**
|
||||
The player is an adaptive maintenance unit — and so are most of the corrupted robots they fight. This is the horror of it: you're fighting yourself, or what you could have become. Corrupted maintenance units should visibly reflect their degraded state through their parts. Players read an enemy's loadout the same way they read their own. Salvaging parts from defeated enemies ties combat directly to progression and reinforces that you're cannibalizing a graveyard of former colleagues.
|
||||
|
||||
---
|
||||
|
||||
## The Hub Needs to Feel Lonely Before It Feels Alive
|
||||
|
||||
The emotional impact of bringing NPCs home is proportional to how empty the hub feels without them.
|
||||
|
||||
Early game:
|
||||
- Most terminals offline
|
||||
- Flickering lights
|
||||
- Distant mechanical sounds, no voices
|
||||
- Cafeteria completely empty
|
||||
|
||||
As you progress:
|
||||
- Lights come on in populated areas
|
||||
- NPCs animate the spaces they inhabit
|
||||
- The cafeteria becomes a place you want to return to
|
||||
|
||||
That contrast — from isolated to inhabited — is the emotional arc of the whole game.
|
||||
|
||||
---
|
||||
|
||||
## Visual Identity — Reference Locked: FireRed/LeafGreen Remake Aesthetic
|
||||
|
||||
**The reference:** The remade FireRed/LeafGreen games for Switch/Switch 2. Clean, vibrant, readable. Bold outlines, warm palette, expressive sprites without excess detail. Animations that are economical but feel alive. UI that is simple and trustworthy.
|
||||
|
||||
**What this means in practice:**
|
||||
- Top-down world uses clean sprite work — nothing gritty or hyper-detailed
|
||||
- Characters (robots) are readable at small sizes but have personality at full scale
|
||||
- The card game UI should feel like a natural extension of this aesthetic — bright, clean, legible
|
||||
- The planet's creatures (on cards) should feel like they belong in this visual language: fantastical and expressive, not photorealistic
|
||||
|
||||
**Creature aesthetic — Vivisteria reference:**
|
||||
Creatures are earth-adjacent but not from Earth. Think bioluminescent, organic, beautiful — the kind of design that reads as "alive" even in still card art. Pixar's Elementals (specifically the Vivisteria) is the target emotional register: something that looks like it belongs in nature but is unmistakably from somewhere else. Not Pokemon-mechanical (type matchup grids) but Pokemon-adjacent in the sense that each creature has a clear identity, a silhouette you can read, and a feeling you could attach to.
|
||||
|
||||
The creatures carry the emotional weight of the game in a visual layer: they're on every card, they're beautiful, and they're all extinct or endangered. Players should feel that before they read a word of lore.
|
||||
|
||||
**Robot visual identity:**
|
||||
- Modular body system = genuinely unique appearance per run
|
||||
- Part visual language communicates origin zone: Botanical parts look organic/green, Mineral parts look crystalline, Volcanic parts look heat-scarred, etc.
|
||||
- Corrupted enemies should look like recognizable robots in degraded state — you can read what they used to be
|
||||
- NPC robots are clearly zone-matched: the Botanical robot looks like a caretaker, the Mineral robot looks like a surveyor, etc.
|
||||
|
||||
**The robot aesthetic is crowded** (Roboquest, etc.). The zone-matched visual language for parts and NPCs, combined with the FRLG aesthetic, is what creates distinction: this doesn't look like a shooter-robot game, it looks like a world where robots became curators.
|
||||
|
||||
---
|
||||
|
||||
## The Card Game as Emotional Core
|
||||
|
||||
The card game isn't a side activity. It's the robots' way of holding on.
|
||||
|
||||
After humanity ended, surviving robots had access to everything humans recorded — every species catalogued, every habitat surveyed, every ecosystem studied. They couldn't bring any of it back. They couldn't fix what happened. What they could do was build something that kept it alive in a different form: a game where the creatures of a dead planet are remembered as powerful, beautiful, worth caring about.
|
||||
|
||||
Every card in the game is an act of preservation. The robots who built these series were grieving. The player inherits that grief without knowing it, and discovers it through play.
|
||||
|
||||
**This is why the card game has to be good.** It needs to stand on its own as a system worth engaging with — not because the lore forces you to, but because you want to. The emotional layer only lands if the mechanical layer already has you.
|
||||
|
||||
---
|
||||
|
||||
## The Pitch in One Line
|
||||
|
||||
**Station 0**
|
||||
> *"You're the last robot. The others are out there. Bring them home. Then play cards and uncover what happened."*
|
||||
|
||||
That's a game with a clear identity. Lead with this.
|
||||
|
||||
**On the title:** Station 0 is the first monitoring station ever built by humanity — the original vigil. It is also the last one standing. "Zero" carries three meanings simultaneously: the station's designation as the first of its kind, the count of humans remaining, and the player's starting state — no memory, no context, nothing but function. It doesn't explain itself. That's the right kind of title for a game about piecing things together.
|
||||
|
||||
---
|
||||
|
||||
## Scope Recommendation for v1.0
|
||||
|
||||
Cut 3D moments and online PvP from the initial release scope. Ship:
|
||||
- Full roguelike loop (Phases 1–7)
|
||||
- NPC system (Phase 8)
|
||||
- Card game with AI opponents (Phase 9)
|
||||
- Lore system (Phase 10)
|
||||
|
||||
3D moments and online PvP are features. The NPC-card-lore triangle is the soul. Get the soul right first.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Production Notes — Backing, Team & Funding
|
||||
|
||||
---
|
||||
|
||||
## Solo Dev Reality
|
||||
|
||||
Possible, but 5–8 years to do it properly. The game has four disciplines that each need to be excellent:
|
||||
|
||||
- Systems programming (roguelike, card rules engine, networking)
|
||||
- Art (2D sprites, isometric assets, 3D assets, UI, animations)
|
||||
- Writing (NPC dialogue, lore tapes, card flavor text)
|
||||
- Audio (music, SFX)
|
||||
|
||||
The NPC dialogue and lore delivery are the core differentiator — weak writing will kill the game regardless of how good the systems are.
|
||||
|
||||
---
|
||||
|
||||
## Minimum Viable Team
|
||||
|
||||
**3–4 people | 2–3 years | ~$150k–$300k**
|
||||
|
||||
| Role | Priority | Why |
|
||||
|---|---|---|
|
||||
| Artist | Critical | Visual identity makes or breaks the Steam page |
|
||||
| Writer | Critical | NPC dialogue and lore are the soul of this game |
|
||||
| Second programmer | High | Card AI, networking, and roguelike systems in parallel is a lot |
|
||||
| Composer/audio | Medium | Can be contracted per-milestone rather than full-time |
|
||||
|
||||
This gets to Phases 1–10 (full roguelike + card game with AI + lore + NPCs) without online PvP.
|
||||
|
||||
---
|
||||
|
||||
## Comfortable Team
|
||||
|
||||
**5–6 people | 2 years | ~$400k–$700k**
|
||||
|
||||
Adds dedicated QA and a proper audio lead. QA matters more than it sounds for a game this interconnected — a card game with AI opponents, online play, and a complex NPC state machine will generate serious edge-case bugs.
|
||||
|
||||
---
|
||||
|
||||
## Funding Paths
|
||||
|
||||
### Grants (No Equity — Best First Step)
|
||||
- **Epic MegaGrants** — up to $500k, no strings attached
|
||||
- **National/regional funds** — UK Games Fund, Canada Media Fund, Screen Australia, etc. (depending on location)
|
||||
- Apply for these before approaching publishers. The architecture doc is unusually well-structured for a grant application.
|
||||
|
||||
### Publisher (Marketing + Porting Support)
|
||||
Good fits for this game's tone and scope:
|
||||
|
||||
| Publisher | Why |
|
||||
|---|---|
|
||||
| Raw Fury | Narrative-heavy indie, strong aesthetic fit |
|
||||
| Fellow Traveller | Story-driven games specifically |
|
||||
| Devolver Digital | If the tone goes darker or weirder |
|
||||
| Humble Games | Mid-tier indie, less creative interference |
|
||||
|
||||
Publishers typically fund $200k–$800k against a revenue share (usually 70/30 or 80/20 after recoup). You give up some control but gain QA, marketing, and often a console port — which can double lifetime revenue.
|
||||
|
||||
### Avoid
|
||||
Equity/investment funding unless building toward a live service model. A premium indie doesn't need that pressure.
|
||||
|
||||
---
|
||||
|
||||
## Online PvP — Separate Budget Item
|
||||
|
||||
Online card PvP is **not a one-time development cost**. It requires:
|
||||
- Server hosting (ongoing monthly)
|
||||
- Anti-cheat
|
||||
- Balance patches and moderation
|
||||
- Customer support for trade disputes
|
||||
|
||||
This is a live service commitment. Treat it as a separate business decision, not just a phase on the build list.
|
||||
|
||||
---
|
||||
|
||||
## Priority Order
|
||||
|
||||
1. **Find an artist.** Without a strong visual identity you can't pitch, can't get funding, can't build an audience.
|
||||
2. **Apply for grants** — the concept and architecture are already in good shape for an application.
|
||||
3. **Build to Phase 6** (full run loop) before approaching publishers. A playable vertical slice is worth 10x more than a document.
|
||||
4. **Decide on online PvP scope** before any publisher conversation — it changes the funding ask significantly.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Station 0
|
||||
|
||||
> *You're the last robot. The others are out there. Bring them home. Then play cards and uncover what happened.*
|
||||
|
||||
Station 0 is a roguelike + card game hybrid built in Godot 4. You play as an amnesiac maintenance robot on the last surviving human monitoring station — orbiting a planet that humanity destroyed through neglect and then abandoned entirely. Thousands of years have passed. Most robots are corrupted. A few survive with their memories intact.
|
||||
|
||||
Your job is to find them.
|
||||
|
||||
---
|
||||
|
||||
## What makes this different
|
||||
|
||||
Most roguelikes give you a hub to return to. Station 0 gives you a reason to care about it.
|
||||
|
||||
**The NPC-Card-Lore triangle:**
|
||||
- You find a surviving robot during a run and risk bringing them along
|
||||
- If they make it back, they settle into the hub's cafeteria
|
||||
- Their card deck is their personality — built from the ecological zone they were designed to monitor
|
||||
- Playing cards against them is how you learn what they remember about what happened
|
||||
- Their dialogue reacts to being lost, found again, and brought home
|
||||
|
||||
No other game does this. The card game is the relationship layer. The lore is delivered through play, not cutscenes.
|
||||
|
||||
---
|
||||
|
||||
## The two game modes
|
||||
|
||||
### Roguelike
|
||||
BOI-style top-down combat across six ecological zones of the station (Botanical, Aquatic, Atmospheric, Mineral, Arctic, Volcanic) plus rare Data floors. The player robot has a modular body — five equipment slots (Head, Torso, Left Arm, Right Arm, Legs) that accept maintenance tool attachments found throughout the station. Permanent upgrades survive death. Everything else is a live bet on your own survival.
|
||||
|
||||
Key mechanic: **early exit**. At any point in a run, the player can abandon and return to the hub, keeping everything found so far. The risk/reward tension is active the entire run, not just at the start.
|
||||
|
||||
### Card Game
|
||||
A deck-based TCG with auto-battler combat resolution, played in the hub cafeteria against rescued NPC robots. No in-match shop — the strategic layer lives in deck construction and hand management. Cards represent species that once lived on the planet. Each zone has its own card series. The robots who built these cards were grieving. The player inherits that grief without knowing it.
|
||||
|
||||
---
|
||||
|
||||
## Emotional foundation
|
||||
|
||||
The narrative structure is WALL-E: no villain, no attack, just the slow weight of what happens when something loved is taken for granted until it's too late. The station was built not in triumph but in guilt — a vigil. The creatures on the cards are beautiful and clearly once-living. Players feel the loss before they read a word of lore.
|
||||
|
||||
The surviving robots have intact memory cards. They remember everything. They've had thousands of years to process it alone. Each one has a different relationship to what happened. Bringing them home — and playing cards with them — is how the player assembles the picture.
|
||||
|
||||
---
|
||||
|
||||
## Current status
|
||||
|
||||
Active development. Vertical slice in progress (Godot 4 / GDScript).
|
||||
|
||||
**Built:**
|
||||
- Player movement, combat, iframes, death loop
|
||||
- Modular body part system with stat modifiers
|
||||
- Procedural floor generation (random walk + BFS boss placement)
|
||||
- Room system with door locking, enemy spawning, floor transitions
|
||||
- Run manager (death vs. early exit correctly split)
|
||||
- Hub world foundation
|
||||
- Save system (body parts, credits, NPC states, lore — all persistent)
|
||||
- HUD (procedurally drawn hearts, credit counter)
|
||||
- EventBus signal architecture
|
||||
|
||||
**In progress:**
|
||||
- Enemy variants (Drifter, Repeater, Anchor — distinct corrupted behaviors)
|
||||
- The Supervisor boss (multi-phase encounter)
|
||||
- Run item system (8 items with synergies)
|
||||
- Environmental hazards
|
||||
- Hub staging area and deposit machine
|
||||
|
||||
---
|
||||
|
||||
## Design documentation
|
||||
|
||||
Full design documentation is in this repository:
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — complete systems and technical design
|
||||
- [DESIGN_NOTES.md](DESIGN_NOTES.md) — tone, differentiation, emotional vision
|
||||
- [CONTENT_SPEC.md](CONTENT_SPEC.md) — enemies, boss, items, body parts, room content
|
||||
- [PRODUCTION_NOTES.md](PRODUCTION_NOTES.md) — team structure and funding paths
|
||||
|
||||
---
|
||||
|
||||
## Engine
|
||||
|
||||
**Godot 4** — GDScript
|
||||
No third-party dependencies. Scene-based architecture maps cleanly to the room/run/hub structure.
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
Solo developer. United States.
|
||||
Grant inquiries and collaboration: [add contact]
|
||||
@@ -0,0 +1,9 @@
|
||||
[gd_resource type="Resource" script_class="BodyPartData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/upgrades/body_part_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
slot = "head"
|
||||
display_name = "Sensor Array"
|
||||
mod_range = 40.0
|
||||
@@ -0,0 +1,9 @@
|
||||
[gd_resource type="Resource" script_class="BodyPartData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/upgrades/body_part_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
slot = "left_arm"
|
||||
display_name = "Rapid-Fire Module"
|
||||
mod_fire_rate = 1.0
|
||||
@@ -0,0 +1,9 @@
|
||||
[gd_resource type="Resource" script_class="BodyPartData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/upgrades/body_part_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
slot = "legs"
|
||||
display_name = "Servo Boost"
|
||||
mod_speed = 50.0
|
||||
@@ -0,0 +1,9 @@
|
||||
[gd_resource type="Resource" script_class="BodyPartData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/upgrades/body_part_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
slot = "right_arm"
|
||||
display_name = "Cannon Arm"
|
||||
mod_damage = 1.5
|
||||
@@ -0,0 +1,9 @@
|
||||
[gd_resource type="Resource" script_class="BodyPartData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/upgrades/body_part_data.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
slot = "torso"
|
||||
display_name = "Reinforced Plating"
|
||||
mod_max_health = 2.0
|
||||
@@ -0,0 +1,11 @@
|
||||
[gd_resource type="Resource" script_class="RunItem" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/items/run_item.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
display_name = "Overclock Chip"
|
||||
description = "+1.0 fire rate"
|
||||
effect = 0
|
||||
stat_key = "fire_rate"
|
||||
stat_delta = 1.0
|
||||
@@ -0,0 +1,12 @@
|
||||
[gd_resource type="Resource" script_class="RunItem" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/items/run_item.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
display_name = "Plating Shard"
|
||||
description = "+2 max health, restore 2 HP"
|
||||
effect = 0
|
||||
stat_key = "max_health"
|
||||
stat_delta = 2.0
|
||||
heal_on_pickup = 2.0
|
||||
@@ -0,0 +1,11 @@
|
||||
[gd_resource type="Resource" script_class="RunItem" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/items/run_item.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
display_name = "Range Booster"
|
||||
description = "+200 projectile range"
|
||||
effect = 0
|
||||
stat_key = "range"
|
||||
stat_delta = 200.0
|
||||
@@ -0,0 +1,9 @@
|
||||
[gd_resource type="Resource" script_class="RunItem" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/items/run_item.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
display_name = "Ricochet Module"
|
||||
description = "Projectiles bounce off walls once"
|
||||
effect = 1
|
||||
@@ -0,0 +1,11 @@
|
||||
[gd_resource type="Resource" script_class="RunItem" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/items/run_item.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
display_name = "Scatter Core"
|
||||
description = "Fire 3 projectiles in a spread"
|
||||
effect = 2
|
||||
scatter_count = 3
|
||||
scatter_spread_deg = 20.0
|
||||
@@ -0,0 +1,11 @@
|
||||
[gd_resource type="Resource" script_class="RunItem" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/items/run_item.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
display_name = "Volatile Round"
|
||||
description = "Projectiles explode on hit"
|
||||
effect = 3
|
||||
explosion_radius = 64.0
|
||||
explosion_damage = 2.5
|
||||
@@ -0,0 +1,43 @@
|
||||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
;
|
||||
; Format:
|
||||
; [section] ; section goes between []
|
||||
; param=value ; assign values to parameters
|
||||
|
||||
config_version=5
|
||||
|
||||
[animation]
|
||||
|
||||
compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Station"
|
||||
run/main_scene="res://scenes/hub/hub.tscn"
|
||||
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||
config/icon="res://assets/icon.svg"
|
||||
|
||||
[autoload]
|
||||
|
||||
EventBus="*res://scripts/event_bus.gd"
|
||||
GameManager="*res://scripts/game_manager.gd"
|
||||
RunManager="*res://scripts/run_manager.gd"
|
||||
SaveManager="*res://scripts/save_manager.gd"
|
||||
UpgradeManager="*res://scripts/upgrades/upgrade_manager.gd"
|
||||
CardCollection="*res://scripts/cards/card_collection.gd"
|
||||
NPCManager="*res://scripts/npcs/npc_manager.gd"
|
||||
LoreManager="*res://scripts/lore/lore_manager.gd"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
window/stretch/mode="canvas_items"
|
||||
|
||||
[rendering]
|
||||
|
||||
textures/canvas_textures/default_texture_filter=0
|
||||
2d/snap/snap_2d_transforms_to_pixel=true
|
||||
2d/snap/snap_2d_vertices_to_pixel=true
|
||||
@@ -0,0 +1,34 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/enemies/anchor.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 13.0
|
||||
|
||||
[sub_resource type="CircleShape2D" id="2"]
|
||||
radius = 15.0
|
||||
|
||||
[node name="Anchor" type="CharacterBody2D"]
|
||||
collision_layer = 8
|
||||
collision_mask = 1
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Collision" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
|
||||
[node name="Visual" type="Node2D" parent="."]
|
||||
|
||||
[node name="Body" type="Polygon2D" parent="Visual"]
|
||||
polygon = PackedVector2Array(0, -16, 14, 0, 0, 16, -14, 0)
|
||||
color = Color(0.1, 0.45, 0.28, 1)
|
||||
|
||||
[node name="Eye" type="Polygon2D" parent="Visual"]
|
||||
polygon = PackedVector2Array(-3, -7, 3, -7, 3, -2, -3, -2)
|
||||
color = Color(0.3, 0.95, 0.6, 1)
|
||||
|
||||
[node name="ContactArea" type="Area2D" parent="."]
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
|
||||
[node name="ContactCollision" type="CollisionShape2D" parent="ContactArea"]
|
||||
shape = SubResource("2")
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/enemies/currency_orb.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 8.0
|
||||
|
||||
[node name="CurrencyOrb" type="Area2D"]
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Collision" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
@@ -0,0 +1,34 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/enemies/drifter.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 10.0
|
||||
|
||||
[sub_resource type="CircleShape2D" id="2"]
|
||||
radius = 12.0
|
||||
|
||||
[node name="Drifter" type="CharacterBody2D"]
|
||||
collision_layer = 8
|
||||
collision_mask = 1
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Collision" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
|
||||
[node name="Visual" type="Node2D" parent="."]
|
||||
|
||||
[node name="Body" type="Polygon2D" parent="Visual"]
|
||||
polygon = PackedVector2Array(0, -14, 12, 0, 0, 14, -12, 0)
|
||||
color = Color(0.65, 0.12, 0.42, 1)
|
||||
|
||||
[node name="Eye" type="Polygon2D" parent="Visual"]
|
||||
polygon = PackedVector2Array(-3, -6, 3, -6, 3, -2, -3, -2)
|
||||
color = Color(0.9, 0.5, 0.8, 1)
|
||||
|
||||
[node name="ContactArea" type="Area2D" parent="."]
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
|
||||
[node name="ContactCollision" type="CollisionShape2D" parent="ContactArea"]
|
||||
shape = SubResource("2")
|
||||
@@ -0,0 +1,34 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/enemies/enemy_base.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 10.0
|
||||
|
||||
[sub_resource type="CircleShape2D" id="2"]
|
||||
radius = 12.0
|
||||
|
||||
[node name="Enemy" type="CharacterBody2D"]
|
||||
collision_layer = 8
|
||||
collision_mask = 1
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Collision" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
|
||||
[node name="Visual" type="Node2D" parent="."]
|
||||
|
||||
[node name="Body" type="Polygon2D" parent="Visual"]
|
||||
polygon = PackedVector2Array(0, -14, 12, 0, 0, 14, -12, 0)
|
||||
color = Color(0.75, 0.15, 0.15, 1)
|
||||
|
||||
[node name="Eye" type="Polygon2D" parent="Visual"]
|
||||
polygon = PackedVector2Array(-3, -6, 3, -6, 3, -2, -3, -2)
|
||||
color = Color(1.0, 0.7, 0.0, 1)
|
||||
|
||||
[node name="ContactArea" type="Area2D" parent="."]
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
|
||||
[node name="ContactCollision" type="CollisionShape2D" parent="ContactArea"]
|
||||
shape = SubResource("2")
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/enemies/enemy_projectile.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 4.0
|
||||
|
||||
[node name="EnemyProjectile" type="Area2D"]
|
||||
collision_layer = 0
|
||||
collision_mask = 3
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
@@ -0,0 +1,34 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/enemies/repeater.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 10.0
|
||||
|
||||
[sub_resource type="CircleShape2D" id="2"]
|
||||
radius = 12.0
|
||||
|
||||
[node name="Repeater" type="CharacterBody2D"]
|
||||
collision_layer = 8
|
||||
collision_mask = 1
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Collision" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
|
||||
[node name="Visual" type="Node2D" parent="."]
|
||||
|
||||
[node name="Body" type="Polygon2D" parent="Visual"]
|
||||
polygon = PackedVector2Array(0, -14, 12, 0, 0, 14, -12, 0)
|
||||
color = Color(0.8, 0.35, 0.05, 1)
|
||||
|
||||
[node name="Eye" type="Polygon2D" parent="Visual"]
|
||||
polygon = PackedVector2Array(-3, -6, 3, -6, 3, -2, -3, -2)
|
||||
color = Color(1.0, 0.85, 0.3, 1)
|
||||
|
||||
[node name="ContactArea" type="Area2D" parent="."]
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
|
||||
[node name="ContactCollision" type="CollisionShape2D" parent="ContactArea"]
|
||||
shape = SubResource("2")
|
||||
@@ -0,0 +1,34 @@
|
||||
[gd_scene load_steps=5 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/enemies/supervisor.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 22.0
|
||||
|
||||
[sub_resource type="CircleShape2D" id="2"]
|
||||
radius = 26.0
|
||||
|
||||
[node name="Supervisor" type="CharacterBody2D"]
|
||||
collision_layer = 8
|
||||
collision_mask = 1
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Collision" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
|
||||
[node name="Visual" type="Node2D" parent="."]
|
||||
|
||||
[node name="Body" type="Polygon2D" parent="Visual"]
|
||||
polygon = PackedVector2Array(22, 0, 11, -19, -11, -19, -22, 0, -11, 19, 11, 19)
|
||||
color = Color(0.2, 0.25, 0.45, 1)
|
||||
|
||||
[node name="Eye" type="Polygon2D" parent="Visual"]
|
||||
polygon = PackedVector2Array(12, -5, 22, -5, 22, 5, 12, 5)
|
||||
color = Color(1.0, 0.7, 0.1, 1)
|
||||
|
||||
[node name="ContactArea" type="Area2D" parent="."]
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
|
||||
[node name="ContactCollision" type="CollisionShape2D" parent="ContactArea"]
|
||||
shape = SubResource("2")
|
||||
@@ -0,0 +1,20 @@
|
||||
[gd_scene load_steps=5 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/hub/hub.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/player/player.tscn" id="2"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/hud.tscn" id="3"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/armory_ui.tscn" id="4"]
|
||||
|
||||
[node name="Hub" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Rooms" type="Node2D" parent="."]
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
zoom = Vector2(2, 2)
|
||||
|
||||
[node name="Player" parent="." instance=ExtResource("2")]
|
||||
|
||||
[node name="HUD" parent="." instance=ExtResource("3")]
|
||||
|
||||
[node name="ArmoryUI" parent="." instance=ExtResource("4")]
|
||||
@@ -0,0 +1,16 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/hub/hub_room.gd" id="1"]
|
||||
|
||||
[node name="HubRoom" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Floor" type="Polygon2D" parent="."]
|
||||
polygon = PackedVector2Array(-480, -270, 480, -270, 480, 270, -480, 270)
|
||||
color = Color(0.14, 0.14, 0.20, 1)
|
||||
|
||||
[node name="Walls" type="Node2D" parent="."]
|
||||
|
||||
[node name="DoorTriggers" type="Node2D" parent="."]
|
||||
|
||||
[node name="Contents" type="Node2D" parent="."]
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/items/run_item_pickup.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 20.0
|
||||
|
||||
[node name="RunItemPickup" type="Area2D"]
|
||||
script = ExtResource("1")
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
@@ -0,0 +1,61 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/player/player.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://scripts/player/player_stats.gd" id="2"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 10.0
|
||||
|
||||
[node name="Player" type="CharacterBody2D"]
|
||||
collision_layer = 2
|
||||
collision_mask = 1
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Collision" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
|
||||
[node name="Body" type="Node2D" parent="."]
|
||||
|
||||
[node name="SlotLegs" type="Node2D" parent="Body"]
|
||||
|
||||
[node name="Placeholder" type="Polygon2D" parent="Body/SlotLegs"]
|
||||
polygon = PackedVector2Array(-8, 4, 8, 4, 8, 14, -8, 14)
|
||||
color = Color(0.3, 0.35, 0.45, 1)
|
||||
|
||||
[node name="SlotTorso" type="Node2D" parent="Body"]
|
||||
|
||||
[node name="Placeholder" type="Polygon2D" parent="Body/SlotTorso"]
|
||||
polygon = PackedVector2Array(-10, -12, 10, -12, 10, 10, -10, 10)
|
||||
color = Color(0.4, 0.55, 0.75, 1)
|
||||
|
||||
[node name="SlotLeftArm" type="Node2D" parent="Body"]
|
||||
|
||||
[node name="Placeholder" type="Polygon2D" parent="Body/SlotLeftArm"]
|
||||
polygon = PackedVector2Array(-16, -8, -10, -8, -10, 6, -16, 6)
|
||||
color = Color(0.35, 0.5, 0.7, 1)
|
||||
|
||||
[node name="SlotRightArm" type="Node2D" parent="Body"]
|
||||
|
||||
[node name="Placeholder" type="Polygon2D" parent="Body/SlotRightArm"]
|
||||
polygon = PackedVector2Array(10, -8, 16, -8, 16, 6, 10, 6)
|
||||
color = Color(0.35, 0.5, 0.7, 1)
|
||||
|
||||
[node name="SlotHead" type="Node2D" parent="Body"]
|
||||
|
||||
[node name="Placeholder" type="Polygon2D" parent="Body/SlotHead"]
|
||||
polygon = PackedVector2Array(-8, -22, 8, -22, 8, -12, -8, -12)
|
||||
color = Color(0.5, 0.65, 0.85, 1)
|
||||
|
||||
[node name="EyeLeft" type="Polygon2D" parent="Body/SlotHead"]
|
||||
polygon = PackedVector2Array(-5, -20, -2, -20, -2, -17, -5, -17)
|
||||
color = Color(1, 0.9, 0.3, 1)
|
||||
|
||||
[node name="EyeRight" type="Polygon2D" parent="Body/SlotHead"]
|
||||
polygon = PackedVector2Array(2, -20, 5, -20, 5, -17, 2, -17)
|
||||
color = Color(1, 0.9, 0.3, 1)
|
||||
|
||||
[node name="ShootOrigin" type="Marker2D" parent="."]
|
||||
position = Vector2(16, 0)
|
||||
|
||||
[node name="Stats" type="Node" parent="."]
|
||||
script = ExtResource("2")
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/player/projectile.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 5.0
|
||||
|
||||
[node name="Projectile" type="Area2D"]
|
||||
collision_layer = 4
|
||||
collision_mask = 9
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Collision" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
@@ -0,0 +1,17 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/rooms/floor.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/player/player.tscn" id="2"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/hud.tscn" id="3"]
|
||||
|
||||
[node name="Floor" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Rooms" type="Node2D" parent="."]
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
zoom = Vector2(2, 2)
|
||||
|
||||
[node name="Player" parent="." instance=ExtResource("2")]
|
||||
|
||||
[node name="HUD" parent="." instance=ExtResource("3")]
|
||||
@@ -0,0 +1,16 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/rooms/room.gd" id="1"]
|
||||
|
||||
[node name="Room" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Floor" type="Polygon2D" parent="."]
|
||||
polygon = PackedVector2Array(-480, -270, 480, -270, 480, 270, -480, 270)
|
||||
color = Color(0.12, 0.12, 0.18, 1)
|
||||
|
||||
[node name="Walls" type="Node2D" parent="."]
|
||||
|
||||
[node name="DoorTriggers" type="Node2D" parent="."]
|
||||
|
||||
[node name="Contents" type="Node2D" parent="."]
|
||||
@@ -0,0 +1,30 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="PackedScene" path="res://scenes/player/player.tscn" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/enemies/enemy_basic.tscn" id="2"]
|
||||
|
||||
[node name="TestRoom" type="Node2D"]
|
||||
|
||||
[node name="Floor" type="Polygon2D" parent="."]
|
||||
polygon = PackedVector2Array(-480, -270, 480, -270, 480, 270, -480, 270)
|
||||
color = Color(0.12, 0.12, 0.18, 1)
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
zoom = Vector2(2, 2)
|
||||
|
||||
[node name="Player" parent="." instance=ExtResource("1")]
|
||||
|
||||
[node name="Enemy1" parent="." instance=ExtResource("2")]
|
||||
position = Vector2(-200, -130)
|
||||
|
||||
[node name="Enemy2" parent="." instance=ExtResource("2")]
|
||||
position = Vector2(200, -130)
|
||||
|
||||
[node name="Enemy3" parent="." instance=ExtResource("2")]
|
||||
position = Vector2(-200, 130)
|
||||
|
||||
[node name="Enemy4" parent="." instance=ExtResource("2")]
|
||||
position = Vector2(200, 130)
|
||||
|
||||
[node name="Enemy5" parent="." instance=ExtResource("2")]
|
||||
position = Vector2(0, -200)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/ui/armory_ui.gd" id="1"]
|
||||
|
||||
[node name="ArmoryUI" type="CanvasLayer"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/ui/hud.gd" id="1"]
|
||||
|
||||
[node name="HUD" type="CanvasLayer"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/upgrades/body_part_pickup.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 16.0
|
||||
|
||||
[node name="BodyPartPickup" type="Area2D"]
|
||||
script = ExtResource("1")
|
||||
collision_layer = 0
|
||||
collision_mask = 2
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
|
||||
[node name="Visual" type="Polygon2D" parent="."]
|
||||
color = Color(0.2, 0.9, 0.85, 1)
|
||||
polygon = PackedVector2Array(12, 0, 6, 10.392305, -6, 10.392305, -12, 0, -6, -10.392305, 6, -10.392305)
|
||||
@@ -0,0 +1,33 @@
|
||||
extends Node
|
||||
|
||||
# card_id -> count owned
|
||||
var collection: Dictionary = {}
|
||||
|
||||
# Array of { name: String, card_ids: Array[String] }
|
||||
var decks: Array[Dictionary] = []
|
||||
|
||||
func add_card(card_id: String, count: int = 1) -> void:
|
||||
collection[card_id] = collection.get(card_id, 0) + count
|
||||
|
||||
func get_card_count(card_id: String) -> int:
|
||||
return collection.get(card_id, 0)
|
||||
|
||||
func open_pack(pack: Resource) -> void:
|
||||
# Pack resource defines its card_ids array
|
||||
if pack == null or not pack.get("card_ids"):
|
||||
return
|
||||
for card_id in pack.card_ids:
|
||||
add_card(card_id)
|
||||
|
||||
func create_deck(deck_name: String) -> void:
|
||||
decks.append({ "name": deck_name, "card_ids": [] })
|
||||
|
||||
func get_save_data() -> Dictionary:
|
||||
return {
|
||||
"collection": collection.duplicate(),
|
||||
"decks": decks.duplicate(true),
|
||||
}
|
||||
|
||||
func load_save_data(data: Dictionary) -> void:
|
||||
collection = data.get("collection", {})
|
||||
decks = data.get("decks", [])
|
||||
@@ -0,0 +1,136 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ANCHOR — Structural integrity corruption
|
||||
# Pathfinds to nearest wall, locks in. Fires slow homing projectiles.
|
||||
# Does not move once anchored.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const ENEMY_PROJECTILE = preload("res://scenes/enemies/enemy_projectile.tscn")
|
||||
|
||||
enum State { SEEKING, ANCHORED }
|
||||
|
||||
var max_health: float = 40.0
|
||||
var current_health: float = 40.0
|
||||
var speed: float = 95.0
|
||||
var contact_damage: float = 0.5
|
||||
var contact_cooldown: float = 0.8
|
||||
|
||||
const SHOT_COOLDOWN := 2.2
|
||||
# Room interior half-extents (960×540 room, 32px walls, 16px gap buffer)
|
||||
const INNER_HALF_X := 432.0
|
||||
const INNER_HALF_Y := 222.0
|
||||
|
||||
var _player: Node2D = null
|
||||
var _state: State = State.SEEKING
|
||||
var _target_wall: Vector2
|
||||
var _shot_timer: float = 1.0 # initial fire delay
|
||||
var _contact_timer: float = 0.0
|
||||
var _flash_timer: float = 0.0
|
||||
|
||||
@onready var visual: Node2D = $Visual
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
$ContactArea.body_entered.connect(_on_contact_entered)
|
||||
_compute_wall_target()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Find the nearest wall face and set it as the target
|
||||
# ---------------------------------------------------------------------------
|
||||
func _compute_wall_target() -> void:
|
||||
var room_center: Vector2 = (get_parent() as Node2D).global_position
|
||||
var lp: Vector2 = global_position - room_center
|
||||
|
||||
var d_left: float = lp.x - (-INNER_HALF_X)
|
||||
var d_right: float = INNER_HALF_X - lp.x
|
||||
var d_up: float = lp.y - (-INNER_HALF_Y)
|
||||
var d_down: float = INNER_HALF_Y - lp.y
|
||||
var md: float = minf(minf(d_left, d_right), minf(d_up, d_down))
|
||||
|
||||
if md == d_left:
|
||||
_target_wall = room_center + Vector2(-INNER_HALF_X, lp.y)
|
||||
elif md == d_right:
|
||||
_target_wall = room_center + Vector2( INNER_HALF_X, lp.y)
|
||||
elif md == d_up:
|
||||
_target_wall = room_center + Vector2(lp.x, -INNER_HALF_Y)
|
||||
else:
|
||||
_target_wall = room_center + Vector2(lp.x, INNER_HALF_Y)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_find_player()
|
||||
_tick_contact(delta)
|
||||
_tick_flash(delta)
|
||||
match _state:
|
||||
State.SEEKING:
|
||||
_do_seek()
|
||||
State.ANCHORED:
|
||||
_do_anchored(delta)
|
||||
move_and_slide()
|
||||
|
||||
func _find_player() -> void:
|
||||
if _player == null or not is_instance_valid(_player):
|
||||
_player = get_tree().get_first_node_in_group("player")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Move toward the nearest wall, then lock in
|
||||
# ---------------------------------------------------------------------------
|
||||
func _do_seek() -> void:
|
||||
var diff := _target_wall - global_position
|
||||
if diff.length() < 12.0:
|
||||
velocity = Vector2.ZERO
|
||||
_state = State.ANCHORED
|
||||
else:
|
||||
velocity = diff.normalized() * speed
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stationary — fire slow homing projectiles at the player
|
||||
# ---------------------------------------------------------------------------
|
||||
func _do_anchored(delta: float) -> void:
|
||||
velocity = Vector2.ZERO
|
||||
_shot_timer -= delta
|
||||
if _shot_timer <= 0.0 and _player != null:
|
||||
_fire_at_player()
|
||||
_shot_timer = SHOT_COOLDOWN
|
||||
|
||||
func _fire_at_player() -> void:
|
||||
var dir := (_player.global_position - global_position).normalized()
|
||||
var proj = ENEMY_PROJECTILE.instantiate()
|
||||
proj.position = (get_parent() as Node2D).to_local(global_position)
|
||||
proj.direction = dir
|
||||
proj.speed = 120.0
|
||||
proj.damage = 1.0
|
||||
proj.max_range = 720.0
|
||||
proj.homing = true
|
||||
proj.homing_target = _player
|
||||
get_parent().call_deferred("add_child", proj)
|
||||
|
||||
func take_damage(amount: float) -> void:
|
||||
current_health -= amount
|
||||
_flash_timer = 0.1
|
||||
if current_health <= 0.0:
|
||||
_die()
|
||||
|
||||
func _die() -> void:
|
||||
_spawn_drop()
|
||||
queue_free()
|
||||
|
||||
func _spawn_drop() -> void:
|
||||
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
|
||||
orb.position = (get_parent() as Node2D).to_local(global_position)
|
||||
get_parent().call_deferred("add_child", orb)
|
||||
|
||||
func _on_contact_entered(body: Node) -> void:
|
||||
if body.is_in_group("player") and _contact_timer == 0.0:
|
||||
body.take_damage(contact_damage)
|
||||
_contact_timer = contact_cooldown
|
||||
|
||||
func _tick_contact(delta: float) -> void:
|
||||
_contact_timer = maxf(_contact_timer - delta, 0.0)
|
||||
|
||||
func _tick_flash(delta: float) -> void:
|
||||
if _flash_timer > 0.0:
|
||||
_flash_timer -= delta
|
||||
visual.modulate = Color(2.5, 2.5, 2.5)
|
||||
else:
|
||||
visual.modulate = Color(1.0, 1.0, 1.0)
|
||||
@@ -0,0 +1,17 @@
|
||||
extends Area2D
|
||||
|
||||
var value: int = 1
|
||||
|
||||
func _ready() -> void:
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
# Placeholder visual — gold circle
|
||||
func _draw() -> void:
|
||||
draw_circle(Vector2.ZERO, 5.0, Color(1.0, 0.8, 0.1))
|
||||
draw_arc(Vector2.ZERO, 5.0, 0, TAU, 16, Color(1.0, 1.0, 0.4), 1.0)
|
||||
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if body.is_in_group("player"):
|
||||
UpgradeManager.hub_credits += value
|
||||
EventBus.credits_changed.emit(UpgradeManager.hub_credits)
|
||||
queue_free()
|
||||
@@ -0,0 +1,79 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DRIFTER — Locomotion corruption
|
||||
# Moves in slow arcs and wide curves. Never charges straight. Contact only.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var max_health: float = 16.0
|
||||
var current_health: float = 16.0
|
||||
var speed: float = 80.0
|
||||
var contact_damage: float = 1.0
|
||||
var contact_cooldown: float = 0.8
|
||||
|
||||
var _player: Node2D = null
|
||||
var _contact_timer: float = 0.0
|
||||
var _flash_timer: float = 0.0
|
||||
var _wobble_time: float = 0.0
|
||||
var _wobble_phase: float = 0.0 # random start so groups don't sync
|
||||
|
||||
@onready var visual: Node2D = $Visual
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
$ContactArea.body_entered.connect(_on_contact_entered)
|
||||
_wobble_phase = randf() * TAU
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_find_player()
|
||||
_tick_contact(delta)
|
||||
_tick_flash(delta)
|
||||
_do_arc_chase(delta)
|
||||
move_and_slide()
|
||||
|
||||
func _find_player() -> void:
|
||||
if _player == null or not is_instance_valid(_player):
|
||||
_player = get_tree().get_first_node_in_group("player")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arcing movement — sinusoidal perpendicular drift applied to toward-player dir
|
||||
# ---------------------------------------------------------------------------
|
||||
func _do_arc_chase(delta: float) -> void:
|
||||
if _player == null:
|
||||
velocity = Vector2.ZERO
|
||||
return
|
||||
var toward := (_player.global_position - global_position).normalized()
|
||||
var perp := toward.rotated(PI * 0.5)
|
||||
_wobble_time += delta
|
||||
var wobble := sin(_wobble_time * 1.8 + _wobble_phase) * 0.75
|
||||
velocity = (toward + perp * wobble).normalized() * speed
|
||||
|
||||
func take_damage(amount: float) -> void:
|
||||
current_health -= amount
|
||||
_flash_timer = 0.1
|
||||
if current_health <= 0.0:
|
||||
_die()
|
||||
|
||||
func _die() -> void:
|
||||
_spawn_drop()
|
||||
queue_free()
|
||||
|
||||
func _spawn_drop() -> void:
|
||||
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
|
||||
orb.position = (get_parent() as Node2D).to_local(global_position)
|
||||
get_parent().call_deferred("add_child", orb)
|
||||
|
||||
func _on_contact_entered(body: Node) -> void:
|
||||
if body.is_in_group("player") and _contact_timer == 0.0:
|
||||
body.take_damage(contact_damage)
|
||||
_contact_timer = contact_cooldown
|
||||
|
||||
func _tick_contact(delta: float) -> void:
|
||||
_contact_timer = maxf(_contact_timer - delta, 0.0)
|
||||
|
||||
func _tick_flash(delta: float) -> void:
|
||||
if _flash_timer > 0.0:
|
||||
_flash_timer -= delta
|
||||
visual.modulate = Color(2.5, 2.5, 2.5)
|
||||
else:
|
||||
visual.modulate = Color(1.0, 1.0, 1.0)
|
||||
@@ -0,0 +1,76 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stats — override in derived enemy types
|
||||
# ---------------------------------------------------------------------------
|
||||
var max_health: float = 10.0
|
||||
var current_health: float = 10.0
|
||||
var speed: float = 60.0
|
||||
var contact_damage: float = 1.0
|
||||
var contact_cooldown: float = 0.8
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal state
|
||||
# ---------------------------------------------------------------------------
|
||||
var _contact_timer: float = 0.0
|
||||
var _flash_timer: float = 0.0
|
||||
var _player: Node2D = null
|
||||
|
||||
@onready var visual: Node2D = $Visual
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
$ContactArea.body_entered.connect(_on_contact_entered)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_contact_timer = maxf(_contact_timer - delta, 0.0)
|
||||
_tick_flash(delta)
|
||||
_chase_player()
|
||||
move_and_slide()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI — simple chaser
|
||||
# ---------------------------------------------------------------------------
|
||||
func _chase_player() -> void:
|
||||
if _player == null or not is_instance_valid(_player):
|
||||
_player = get_tree().get_first_node_in_group("player")
|
||||
if _player != null:
|
||||
velocity = (_player.global_position - global_position).normalized() * speed
|
||||
else:
|
||||
velocity = Vector2.ZERO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Damage / death
|
||||
# ---------------------------------------------------------------------------
|
||||
func take_damage(amount: float) -> void:
|
||||
current_health -= amount
|
||||
_flash_timer = 0.1
|
||||
if current_health <= 0.0:
|
||||
_die()
|
||||
|
||||
func _die() -> void:
|
||||
_spawn_drop()
|
||||
queue_free()
|
||||
|
||||
func _spawn_drop() -> void:
|
||||
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
|
||||
orb.position = (get_parent() as Node2D).to_local(global_position)
|
||||
get_parent().call_deferred("add_child", orb)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contact damage
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_contact_entered(body: Node) -> void:
|
||||
if body.is_in_group("player") and _contact_timer == 0.0:
|
||||
body.take_damage(contact_damage)
|
||||
_contact_timer = contact_cooldown
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hit flash
|
||||
# ---------------------------------------------------------------------------
|
||||
func _tick_flash(delta: float) -> void:
|
||||
if _flash_timer > 0.0:
|
||||
_flash_timer -= delta
|
||||
visual.modulate = Color(2.5, 2.5, 2.5)
|
||||
else:
|
||||
visual.modulate = Color(1.0, 1.0, 1.0)
|
||||
@@ -0,0 +1,44 @@
|
||||
extends Area2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared enemy projectile — used by Repeater (linear) and Anchor (homing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var direction: Vector2 = Vector2.RIGHT
|
||||
var speed: float = 200.0
|
||||
var damage: float = 1.0
|
||||
var max_range: float = 500.0
|
||||
|
||||
# Homing — disabled by default
|
||||
var homing: bool = false
|
||||
var homing_target: Node2D = null
|
||||
|
||||
const HOMING_STRENGTH := 2.0 # radians/second turn rate
|
||||
|
||||
var _distance_traveled: float = 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
rotation = direction.angle()
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if homing and homing_target != null and is_instance_valid(homing_target):
|
||||
var to_target := (homing_target.global_position - global_position).normalized()
|
||||
direction = direction.lerp(to_target, HOMING_STRENGTH * delta).normalized()
|
||||
rotation = direction.angle()
|
||||
|
||||
var move := direction * speed * delta
|
||||
position += move
|
||||
_distance_traveled += move.length()
|
||||
if _distance_traveled >= max_range:
|
||||
queue_free()
|
||||
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if body.is_in_group("player"):
|
||||
body.take_damage(damage)
|
||||
queue_free()
|
||||
elif body is StaticBody2D:
|
||||
queue_free()
|
||||
|
||||
func _draw() -> void:
|
||||
draw_circle(Vector2.ZERO, 4.0, Color(1.0, 0.4, 0.1))
|
||||
@@ -0,0 +1,136 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REPEATER — Task-loop corruption
|
||||
# Drifts to last known player position, stops, fires a rotating burst loop.
|
||||
# Does not re-track the player — stuck completing a subroutine.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const ENEMY_PROJECTILE = preload("res://scenes/enemies/enemy_projectile.tscn")
|
||||
|
||||
enum State { DRIFTING, FIRING }
|
||||
|
||||
var max_health: float = 24.0
|
||||
var current_health: float = 24.0
|
||||
var speed: float = 60.0
|
||||
var contact_damage: float = 0.5
|
||||
var contact_cooldown: float = 0.8
|
||||
|
||||
const BURST_SIZE := 3 # shots per burst
|
||||
const SHOT_INTERVAL := 0.18 # seconds between shots within a burst
|
||||
const BURST_PAUSE := 1.8 # seconds after burst completes before rotating
|
||||
const ROTATE_STEP := 0.3491 # 20° in radians
|
||||
|
||||
var _player: Node2D = null
|
||||
var _state: State = State.DRIFTING
|
||||
var _target_pos: Vector2
|
||||
var _target_set: bool = false
|
||||
var _fire_angle: float = 0.0
|
||||
var _shot_count: int = 0
|
||||
var _shot_timer: float = 0.0
|
||||
var _contact_timer: float = 0.0
|
||||
var _flash_timer: float = 0.0
|
||||
|
||||
@onready var visual: Node2D = $Visual
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
$ContactArea.body_entered.connect(_on_contact_entered)
|
||||
_fire_angle = randf() * TAU # random initial burst direction
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_find_player()
|
||||
_tick_contact(delta)
|
||||
_tick_flash(delta)
|
||||
match _state:
|
||||
State.DRIFTING:
|
||||
_do_drift()
|
||||
State.FIRING:
|
||||
_do_fire(delta)
|
||||
move_and_slide()
|
||||
|
||||
func _find_player() -> void:
|
||||
if _player == null or not is_instance_valid(_player):
|
||||
_player = get_tree().get_first_node_in_group("player")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drift toward last known position, then lock and fire
|
||||
# ---------------------------------------------------------------------------
|
||||
func _do_drift() -> void:
|
||||
if _player == null:
|
||||
velocity = Vector2.ZERO
|
||||
return
|
||||
# Capture target position once
|
||||
if not _target_set:
|
||||
_target_pos = _player.global_position
|
||||
_target_set = true
|
||||
|
||||
var diff := _target_pos - global_position
|
||||
if diff.length() < 12.0:
|
||||
velocity = Vector2.ZERO
|
||||
# Aim first burst toward player
|
||||
if _player != null:
|
||||
_fire_angle = (_player.global_position - global_position).angle()
|
||||
_state = State.FIRING
|
||||
_shot_count = 0
|
||||
_shot_timer = 0.0
|
||||
else:
|
||||
velocity = diff.normalized() * speed
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stationary burst loop — fire 3, pause, rotate 20° CW, repeat
|
||||
# ---------------------------------------------------------------------------
|
||||
func _do_fire(delta: float) -> void:
|
||||
velocity = Vector2.ZERO
|
||||
_shot_timer -= delta
|
||||
if _shot_timer > 0.0:
|
||||
return
|
||||
|
||||
if _shot_count < BURST_SIZE:
|
||||
_fire_projectile()
|
||||
_shot_count += 1
|
||||
_shot_timer = SHOT_INTERVAL
|
||||
else:
|
||||
# Burst done — rotate 20° clockwise and reset
|
||||
_fire_angle += ROTATE_STEP
|
||||
_shot_count = 0
|
||||
_shot_timer = BURST_PAUSE
|
||||
|
||||
func _fire_projectile() -> void:
|
||||
var proj = ENEMY_PROJECTILE.instantiate()
|
||||
proj.position = (get_parent() as Node2D).to_local(global_position)
|
||||
proj.direction = Vector2.from_angle(_fire_angle)
|
||||
proj.speed = 280.0
|
||||
proj.damage = 1.0
|
||||
proj.max_range = 520.0
|
||||
get_parent().call_deferred("add_child", proj)
|
||||
|
||||
func take_damage(amount: float) -> void:
|
||||
current_health -= amount
|
||||
_flash_timer = 0.1
|
||||
if current_health <= 0.0:
|
||||
_die()
|
||||
|
||||
func _die() -> void:
|
||||
_spawn_drop()
|
||||
queue_free()
|
||||
|
||||
func _spawn_drop() -> void:
|
||||
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
|
||||
orb.position = (get_parent() as Node2D).to_local(global_position)
|
||||
get_parent().call_deferred("add_child", orb)
|
||||
|
||||
func _on_contact_entered(body: Node) -> void:
|
||||
if body.is_in_group("player") and _contact_timer == 0.0:
|
||||
body.take_damage(contact_damage)
|
||||
_contact_timer = contact_cooldown
|
||||
|
||||
func _tick_contact(delta: float) -> void:
|
||||
_contact_timer = maxf(_contact_timer - delta, 0.0)
|
||||
|
||||
func _tick_flash(delta: float) -> void:
|
||||
if _flash_timer > 0.0:
|
||||
_flash_timer -= delta
|
||||
visual.modulate = Color(2.5, 2.5, 2.5)
|
||||
else:
|
||||
visual.modulate = Color(1.0, 1.0, 1.0)
|
||||
@@ -0,0 +1,339 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The Supervisor — Phase 1/2/Climax boss
|
||||
# Corrupted supervisor protocol attempting to decommission the player.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
enum Phase { ONE, TWO, CLIMAX }
|
||||
|
||||
const DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stats
|
||||
# ---------------------------------------------------------------------------
|
||||
var max_health: float = 200.0
|
||||
var current_health: float = 200.0
|
||||
var contact_damage: float = 1.0
|
||||
var contact_cooldown: float = 0.8
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patrol (Phase 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
const PATROL_W := 200.0
|
||||
const PATROL_H := 100.0
|
||||
const PATROL_SPEED_P1 := 75.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chase (Phase 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
const PATROL_SPEED_P2 := 105.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calibration beam
|
||||
# ---------------------------------------------------------------------------
|
||||
const BEAM_RANGE := 240.0
|
||||
const BEAM_HALFANG := 0.50 # ~28.6° half-angle for sensor cone
|
||||
const BEAM_COOLDOWN_P1 := 3.5
|
||||
const BEAM_COOLDOWN_P2 := 2.2
|
||||
const BEAM_DURATION := 0.75 # active damage window (shorter = more dodgeable)
|
||||
|
||||
# Wind-up before firing — boss stops and flashes a warning
|
||||
const WIND_UP_DURATION := 0.65 # seconds of warning before beam fires
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Climax freeze
|
||||
# ---------------------------------------------------------------------------
|
||||
const FREEZE_DURATION := 2.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal state
|
||||
# ---------------------------------------------------------------------------
|
||||
var _phase: Phase = Phase.ONE
|
||||
var _player: Node2D = null
|
||||
|
||||
var _patrol_points: Array[Vector2] = []
|
||||
var _patrol_index: int = 0
|
||||
|
||||
var _contact_timer: float = 0.0
|
||||
var _flash_timer: float = 0.0
|
||||
|
||||
# Beam state
|
||||
var _beam_timer: float = 1.5 # initial delay before first beam check
|
||||
var _beam_active: bool = false
|
||||
var _beam_node: Node2D = null
|
||||
var _beam_dur_timer: float = 0.0
|
||||
|
||||
# Wind-up state
|
||||
var _winding_up: bool = false
|
||||
var _wind_up_timer: float = 0.0
|
||||
var _wind_up_dir: Vector2 = Vector2.RIGHT # locked direction when wind-up starts
|
||||
|
||||
var _drifter_spawned: bool = false
|
||||
var _freeze_timer: float = 0.0
|
||||
|
||||
@onready var visual: Node2D = $Visual
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ready
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
$ContactArea.body_entered.connect(_on_contact_entered)
|
||||
var c := global_position
|
||||
_patrol_points = [
|
||||
c + Vector2(-PATROL_W, -PATROL_H),
|
||||
c + Vector2( PATROL_W, -PATROL_H),
|
||||
c + Vector2( PATROL_W, PATROL_H),
|
||||
c + Vector2(-PATROL_W, PATROL_H),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Physics loop
|
||||
# ---------------------------------------------------------------------------
|
||||
func _physics_process(delta: float) -> void:
|
||||
_find_player()
|
||||
_tick_contact(delta)
|
||||
_tick_beam(delta)
|
||||
_tick_flash(delta)
|
||||
|
||||
var pct := current_health / max_health
|
||||
if _phase == Phase.ONE and pct <= 0.55:
|
||||
_enter_phase_two()
|
||||
elif _phase == Phase.TWO and pct <= 0.20:
|
||||
_enter_climax()
|
||||
|
||||
match _phase:
|
||||
Phase.ONE:
|
||||
# Stop and lock on during wind-up
|
||||
if _winding_up:
|
||||
velocity = Vector2.ZERO
|
||||
else:
|
||||
_do_patrol(PATROL_SPEED_P1)
|
||||
Phase.TWO:
|
||||
if _winding_up:
|
||||
velocity = Vector2.ZERO
|
||||
else:
|
||||
_do_chase(PATROL_SPEED_P2)
|
||||
_maybe_spawn_drifter()
|
||||
Phase.CLIMAX:
|
||||
_do_climax(delta)
|
||||
|
||||
move_and_slide()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Player lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
func _find_player() -> void:
|
||||
if _player == null or not is_instance_valid(_player):
|
||||
_player = get_tree().get_first_node_in_group("player")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase transitions
|
||||
# ---------------------------------------------------------------------------
|
||||
func _enter_phase_two() -> void:
|
||||
_phase = Phase.TWO
|
||||
_winding_up = false
|
||||
|
||||
func _enter_climax() -> void:
|
||||
_phase = Phase.CLIMAX
|
||||
velocity = Vector2.ZERO
|
||||
_freeze_timer = FREEZE_DURATION
|
||||
_winding_up = false
|
||||
if is_instance_valid(_beam_node):
|
||||
_beam_node.queue_free()
|
||||
_beam_node = null
|
||||
_beam_active = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — rectangular patrol
|
||||
# ---------------------------------------------------------------------------
|
||||
func _do_patrol(spd: float) -> void:
|
||||
if _patrol_points.is_empty():
|
||||
return
|
||||
var target := _patrol_points[_patrol_index]
|
||||
var diff := target - global_position
|
||||
if diff.length() < 10.0:
|
||||
_patrol_index = (_patrol_index + 1) % _patrol_points.size()
|
||||
else:
|
||||
velocity = diff.normalized() * spd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — direct player chase
|
||||
# ---------------------------------------------------------------------------
|
||||
func _do_chase(spd: float) -> void:
|
||||
if _player == null:
|
||||
velocity = Vector2.ZERO
|
||||
return
|
||||
velocity = (_player.global_position - global_position).normalized() * spd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — spawn one Drifter
|
||||
# ---------------------------------------------------------------------------
|
||||
func _maybe_spawn_drifter() -> void:
|
||||
if _drifter_spawned:
|
||||
return
|
||||
_drifter_spawned = true
|
||||
var drifter = DRIFTER_SCENE.instantiate()
|
||||
drifter.position = (get_parent() as Node2D).to_local(global_position + Vector2(0, 130))
|
||||
get_parent().call_deferred("add_child", drifter)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Climax — frozen distress signal, then die
|
||||
# ---------------------------------------------------------------------------
|
||||
func _do_climax(delta: float) -> void:
|
||||
velocity = Vector2.ZERO
|
||||
_freeze_timer -= delta
|
||||
var blink := fmod(_freeze_timer, 0.3) < 0.15
|
||||
visual.modulate = Color(2.0, 0.3, 0.3) if blink else Color(0.25, 0.08, 0.08)
|
||||
if _freeze_timer <= 0.0:
|
||||
_die()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calibration beam — with wind-up warning
|
||||
# ---------------------------------------------------------------------------
|
||||
func _tick_beam(delta: float) -> void:
|
||||
if _phase == Phase.CLIMAX:
|
||||
return
|
||||
|
||||
# Active beam — count down duration
|
||||
if _beam_active:
|
||||
_beam_dur_timer -= delta
|
||||
if _beam_dur_timer <= 0.0:
|
||||
_beam_active = false
|
||||
if is_instance_valid(_beam_node):
|
||||
_beam_node.queue_free()
|
||||
_beam_node = null
|
||||
return
|
||||
|
||||
# Wind-up — boss is stopped and flashing; fires when timer expires
|
||||
if _winding_up:
|
||||
_wind_up_timer -= delta
|
||||
if _wind_up_timer <= 0.0:
|
||||
_winding_up = false
|
||||
_fire_beam()
|
||||
var cd := BEAM_COOLDOWN_P1 if _phase == Phase.ONE else BEAM_COOLDOWN_P2
|
||||
_beam_timer = cd
|
||||
return
|
||||
|
||||
# Cooldown between beams
|
||||
if _beam_timer > 0.0:
|
||||
_beam_timer -= delta
|
||||
return
|
||||
|
||||
# Check sensor cone — start wind-up if player is in range
|
||||
if _player_in_sensor_cone():
|
||||
_winding_up = true
|
||||
_wind_up_timer = WIND_UP_DURATION
|
||||
# Lock the fire direction to current heading at the moment of detection
|
||||
_wind_up_dir = velocity.normalized() if velocity.length() > 1.0 else Vector2.RIGHT
|
||||
else:
|
||||
_beam_timer = 0.3 # recheck soon
|
||||
|
||||
func _player_in_sensor_cone() -> bool:
|
||||
if _player == null:
|
||||
return false
|
||||
var to_player := _player.global_position - global_position
|
||||
if to_player.length() > BEAM_RANGE:
|
||||
return false
|
||||
var forward := velocity.normalized() if velocity.length() > 1.0 else Vector2.RIGHT
|
||||
return absf(forward.angle_to(to_player.normalized())) < BEAM_HALFANG
|
||||
|
||||
func _fire_beam() -> void:
|
||||
_beam_active = true
|
||||
_beam_dur_timer = BEAM_DURATION
|
||||
|
||||
var forward := _wind_up_dir # use direction locked at wind-up start
|
||||
var beam_len := BEAM_RANGE
|
||||
var beam_w := 30.0
|
||||
var offset := 24.0
|
||||
|
||||
var beam := Node2D.new()
|
||||
beam.position = (get_parent() as Node2D).to_local(global_position)
|
||||
beam.rotation = forward.angle()
|
||||
|
||||
var poly := Polygon2D.new()
|
||||
poly.polygon = PackedVector2Array([
|
||||
Vector2(offset, -beam_w * 0.5),
|
||||
Vector2(offset + beam_len, -beam_w * 0.5),
|
||||
Vector2(offset + beam_len, beam_w * 0.5),
|
||||
Vector2(offset, beam_w * 0.5),
|
||||
])
|
||||
poly.color = Color(1.0, 0.9, 0.3, 0.60)
|
||||
beam.add_child(poly)
|
||||
|
||||
var area := Area2D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 2
|
||||
var cs := CollisionShape2D.new()
|
||||
var rs := RectangleShape2D.new()
|
||||
rs.size = Vector2(beam_len, beam_w)
|
||||
cs.shape = rs
|
||||
cs.position = Vector2(offset + beam_len * 0.5, 0.0)
|
||||
area.add_child(cs)
|
||||
area.body_entered.connect(func(body: Node) -> void:
|
||||
if body.is_in_group("player"):
|
||||
body.take_damage(1.0))
|
||||
beam.add_child(area)
|
||||
|
||||
get_parent().call_deferred("add_child", beam)
|
||||
_beam_node = beam
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Damage / death
|
||||
# ---------------------------------------------------------------------------
|
||||
func take_damage(amount: float) -> void:
|
||||
current_health -= amount
|
||||
_flash_timer = 0.1
|
||||
EventBus.boss_health_changed.emit(maxf(current_health, 0.0), max_health)
|
||||
if current_health <= 0.0:
|
||||
_die()
|
||||
|
||||
func _die() -> void:
|
||||
EventBus.boss_died.emit()
|
||||
if is_instance_valid(_beam_node):
|
||||
_beam_node.queue_free()
|
||||
_spawn_drop()
|
||||
queue_free()
|
||||
|
||||
func _spawn_drop() -> void:
|
||||
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
|
||||
orb.position = (get_parent() as Node2D).to_local(global_position)
|
||||
get_parent().call_deferred("add_child", orb)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contact damage
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_contact_entered(body: Node) -> void:
|
||||
if body.is_in_group("player") and _contact_timer == 0.0:
|
||||
body.take_damage(contact_damage)
|
||||
_contact_timer = contact_cooldown
|
||||
|
||||
func _tick_contact(delta: float) -> void:
|
||||
_contact_timer = maxf(_contact_timer - delta, 0.0)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Visual — flash on hit, wind-up warning pulse, phase tints, climax strobe
|
||||
# ---------------------------------------------------------------------------
|
||||
func _tick_flash(delta: float) -> void:
|
||||
if _phase == Phase.CLIMAX:
|
||||
return # climax handles its own visual in _do_climax
|
||||
|
||||
# Wind-up: rapid yellow-white warning pulse — overrides everything else
|
||||
if _winding_up:
|
||||
var pulse := fmod(_wind_up_timer, 0.18) < 0.09
|
||||
var base := Color(1.0, 0.65, 0.2) if _phase == Phase.TWO else Color(1.0, 1.0, 1.0)
|
||||
visual.modulate = Color(2.4, 2.1, 0.3) if pulse else base
|
||||
return
|
||||
|
||||
# Hit flash
|
||||
if _flash_timer > 0.0:
|
||||
_flash_timer -= delta
|
||||
visual.modulate = Color(2.5, 2.5, 2.5)
|
||||
return
|
||||
|
||||
# Resting tint per phase
|
||||
if _phase == Phase.TWO:
|
||||
visual.modulate = Color(1.0, 0.65, 0.2)
|
||||
else:
|
||||
visual.modulate = Color(1.0, 1.0, 1.0)
|
||||
@@ -0,0 +1,57 @@
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Player / Run lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
signal player_died
|
||||
signal player_health_changed(current: float, maximum: float)
|
||||
signal run_started
|
||||
signal run_ended(reached_hub: bool)
|
||||
signal floor_cleared(floor_number: int)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rooms
|
||||
# ---------------------------------------------------------------------------
|
||||
signal room_entered(room_id: String)
|
||||
signal room_cleared(room_id: String)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Items & Loot
|
||||
# ---------------------------------------------------------------------------
|
||||
signal item_collected(item: Resource)
|
||||
signal card_pack_found(pack: Resource)
|
||||
signal body_part_found(part: Resource)
|
||||
signal body_part_equipped(part: Resource, slot: String)
|
||||
signal credits_changed(new_total: int)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NPCs
|
||||
# ---------------------------------------------------------------------------
|
||||
signal npc_found(npc: Resource)
|
||||
signal npc_saved(npc: Resource)
|
||||
signal npc_lost(npc: Resource)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lore
|
||||
# ---------------------------------------------------------------------------
|
||||
signal lore_discovered(lore_id: String)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Card Game
|
||||
# ---------------------------------------------------------------------------
|
||||
signal card_game_started(opponent: Resource)
|
||||
signal card_game_ended(player_won: bool)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boss
|
||||
# ---------------------------------------------------------------------------
|
||||
signal boss_health_changed(current: float, maximum: float)
|
||||
signal boss_died
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Perspective / View
|
||||
# ---------------------------------------------------------------------------
|
||||
signal view_switching_to_topdown
|
||||
signal view_switching_to_isometric
|
||||
signal view_switching_to_3d(scene_path: String)
|
||||
signal view_switching_to_2d
|
||||
@@ -0,0 +1,51 @@
|
||||
extends Node
|
||||
|
||||
enum GameState {
|
||||
HUB,
|
||||
RUN,
|
||||
CARD_GAME,
|
||||
CUTSCENE,
|
||||
PAUSED,
|
||||
}
|
||||
|
||||
var current_state: GameState = GameState.HUB
|
||||
var _previous_state: GameState = GameState.HUB
|
||||
|
||||
func _ready() -> void:
|
||||
EventBus.run_started.connect(_on_run_started)
|
||||
EventBus.run_ended.connect(_on_run_ended)
|
||||
call_deferred("_load_game")
|
||||
|
||||
func _load_game() -> void:
|
||||
SaveManager.load_save()
|
||||
|
||||
func change_state(new_state: GameState) -> void:
|
||||
_previous_state = current_state
|
||||
current_state = new_state
|
||||
|
||||
func pause() -> void:
|
||||
if current_state != GameState.PAUSED:
|
||||
change_state(GameState.PAUSED)
|
||||
get_tree().paused = true
|
||||
|
||||
func unpause() -> void:
|
||||
if current_state == GameState.PAUSED:
|
||||
change_state(_previous_state)
|
||||
get_tree().paused = false
|
||||
|
||||
func go_to_hub() -> void:
|
||||
change_state(GameState.HUB)
|
||||
get_tree().change_scene_to_file("res://scenes/hub/hub.tscn")
|
||||
|
||||
func start_run() -> void:
|
||||
RunManager.start_run(0, [], [])
|
||||
change_state(GameState.RUN)
|
||||
get_tree().change_scene_to_file("res://scenes/run/floor.tscn")
|
||||
|
||||
func _on_run_started() -> void:
|
||||
change_state(GameState.RUN)
|
||||
|
||||
func _on_run_ended(_survived: bool) -> void:
|
||||
# Body parts are always kept — save before returning to hub
|
||||
SaveManager.save()
|
||||
call_deferred("go_to_hub")
|
||||
@@ -0,0 +1,133 @@
|
||||
extends Node2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
const HUB_ROOM_SCENE = preload("res://scenes/hub/rooms/hub_room_base.tscn")
|
||||
const ROOM_W := 960.0
|
||||
const ROOM_H := 540.0
|
||||
const TRANSITION_TIME := 0.25
|
||||
const ENTRY_INSET := 60.0
|
||||
|
||||
# Fixed hub layout — 6 rooms arranged as:
|
||||
#
|
||||
# [Control (0,-1)]
|
||||
# |
|
||||
# [Cafe(-1,0)]-[Staging(0,0)]-[Armory(1,0)]
|
||||
# | |
|
||||
# [Shop(0,1)]----[Trophy(1,1)]
|
||||
#
|
||||
# HubRoomType: STAGING=0, ARMORY=1, CAFETERIA=2, SHOP=3, CONTROL_ROOM=4, TROPHY_ROOM=5
|
||||
const LAYOUT := [
|
||||
{"type": 0, "grid": Vector2i( 0, 0)}, # STAGING
|
||||
{"type": 4, "grid": Vector2i( 0, -1)}, # CONTROL_ROOM
|
||||
{"type": 2, "grid": Vector2i(-1, 0)}, # CAFETERIA
|
||||
{"type": 1, "grid": Vector2i( 1, 0)}, # ARMORY
|
||||
{"type": 3, "grid": Vector2i( 0, 1)}, # SHOP
|
||||
{"type": 5, "grid": Vector2i( 1, 1)}, # TROPHY_ROOM
|
||||
]
|
||||
|
||||
const DIRS := {
|
||||
"up": Vector2i( 0, -1),
|
||||
"down": Vector2i( 0, 1),
|
||||
"left": Vector2i(-1, 0),
|
||||
"right": Vector2i( 1, 0),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node refs
|
||||
# ---------------------------------------------------------------------------
|
||||
@onready var rooms_container: Node2D = $Rooms
|
||||
@onready var camera: Camera2D = $Camera2D
|
||||
@onready var player: Node2D = $Player
|
||||
@onready var armory_ui: CanvasLayer = $ArmoryUI
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
var _rooms: Dictionary = {} # Vector2i -> HubRoom
|
||||
var _current_grid: Vector2i
|
||||
var _transitioning: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boot
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
_build_hub()
|
||||
|
||||
|
||||
func _build_hub() -> void:
|
||||
var connections := _compute_connections()
|
||||
|
||||
for entry in LAYOUT:
|
||||
var grid_pos: Vector2i = entry["grid"]
|
||||
var room_type: int = entry["type"]
|
||||
var conn: Dictionary = connections.get(grid_pos, {})
|
||||
|
||||
var room_node: HubRoom = HUB_ROOM_SCENE.instantiate()
|
||||
room_node.position = _grid_to_world(grid_pos)
|
||||
rooms_container.add_child(room_node)
|
||||
room_node.setup(room_type, conn, self)
|
||||
_rooms[grid_pos] = room_node
|
||||
|
||||
# Start in Staging Area; player spawns below center so the portal is visible
|
||||
var staging_world := _grid_to_world(Vector2i(0, 0))
|
||||
camera.position = staging_world
|
||||
player.global_position = staging_world + Vector2(0, 80)
|
||||
_current_grid = Vector2i(0, 0)
|
||||
|
||||
|
||||
func _compute_connections() -> Dictionary:
|
||||
var grid := {}
|
||||
for entry in LAYOUT:
|
||||
grid[entry["grid"]] = true
|
||||
|
||||
var result := {}
|
||||
for entry in LAYOUT:
|
||||
var pos: Vector2i = entry["grid"]
|
||||
var conn := {}
|
||||
for dir_name in DIRS:
|
||||
var nb: Vector2i = pos + DIRS[dir_name]
|
||||
if grid.has(nb):
|
||||
conn[dir_name] = nb
|
||||
result[pos] = conn
|
||||
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transitions (identical pattern to floor.gd)
|
||||
# ---------------------------------------------------------------------------
|
||||
func transition_to(target_grid: Vector2i, from_direction: String) -> void:
|
||||
if _transitioning:
|
||||
return
|
||||
_transitioning = true
|
||||
_current_grid = target_grid
|
||||
|
||||
var new_world := _grid_to_world(target_grid)
|
||||
player.global_position = new_world + _entry_offset(from_direction)
|
||||
|
||||
var tween := create_tween()
|
||||
tween.tween_property(camera, "position", new_world, TRANSITION_TIME)
|
||||
tween.tween_callback(func(): _transitioning = false)
|
||||
|
||||
|
||||
func _entry_offset(from_direction: String) -> Vector2:
|
||||
var i := ENTRY_INSET
|
||||
match from_direction:
|
||||
"up": return Vector2(0, ROOM_H / 2.0 - i)
|
||||
"down": return Vector2(0, -ROOM_H / 2.0 + i)
|
||||
"left": return Vector2( ROOM_W / 2.0 - i, 0)
|
||||
"right": return Vector2(-ROOM_W / 2.0 + i, 0)
|
||||
return Vector2.ZERO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
func _grid_to_world(grid_pos: Vector2i) -> Vector2:
|
||||
return Vector2(grid_pos.x * ROOM_W, grid_pos.y * ROOM_H)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Armory
|
||||
# ---------------------------------------------------------------------------
|
||||
func open_armory() -> void:
|
||||
armory_ui.open()
|
||||
@@ -0,0 +1,258 @@
|
||||
extends Node2D
|
||||
class_name HubRoom
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Room types
|
||||
# ---------------------------------------------------------------------------
|
||||
enum HubRoomType {
|
||||
STAGING = 0,
|
||||
ARMORY = 1,
|
||||
CAFETERIA = 2,
|
||||
SHOP = 3,
|
||||
CONTROL_ROOM = 4,
|
||||
TROPHY_ROOM = 5,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants — wall geometry identical to run rooms
|
||||
# ---------------------------------------------------------------------------
|
||||
const ROOM_W := 960.0
|
||||
const ROOM_H := 540.0
|
||||
const WALL_T := 32.0
|
||||
const DOOR_W := 80.0
|
||||
const DOOR_H := 80.0
|
||||
|
||||
const WALL_COLOR := Color(0.18, 0.18, 0.25)
|
||||
|
||||
const ROOM_NAMES := [
|
||||
"STAGING AREA", # 0
|
||||
"ARMORY", # 1
|
||||
"CAFETERIA", # 2
|
||||
"SHOP", # 3
|
||||
"CONTROL ROOM", # 4
|
||||
"TROPHY ROOM", # 5
|
||||
]
|
||||
|
||||
const FLOOR_COLORS := [
|
||||
Color(0.14, 0.14, 0.20), # STAGING — neutral dark
|
||||
Color(0.14, 0.12, 0.10), # ARMORY — warm metal
|
||||
Color(0.10, 0.14, 0.10), # CAFETERIA — green tint
|
||||
Color(0.14, 0.10, 0.14), # SHOP — purple tint
|
||||
Color(0.08, 0.12, 0.18), # CONTROL_ROOM — blue tint
|
||||
Color(0.16, 0.14, 0.08), # TROPHY_ROOM — warm gold tint
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
var _type: int = 0 # HubRoomType value
|
||||
var _connections: Dictionary = {} # "up"/"down"/"left"/"right" -> Vector2i
|
||||
var _hub: Node = null
|
||||
var _doors: Dictionary = {} # direction -> Area2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node refs
|
||||
# ---------------------------------------------------------------------------
|
||||
@onready var floor_poly: Polygon2D = $Floor
|
||||
@onready var walls_node: Node2D = $Walls
|
||||
@onready var door_triggers: Node2D = $DoorTriggers
|
||||
@onready var contents: Node2D = $Contents
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup
|
||||
# ---------------------------------------------------------------------------
|
||||
func setup(room_type: int, connections: Dictionary, hub_ref: Node) -> void:
|
||||
_type = room_type
|
||||
_connections = connections
|
||||
_hub = hub_ref
|
||||
|
||||
floor_poly.color = FLOOR_COLORS[_type]
|
||||
_build_walls()
|
||||
_build_door_triggers()
|
||||
_build_room_label()
|
||||
|
||||
if _type == HubRoomType.STAGING:
|
||||
_build_run_portal()
|
||||
elif _type == HubRoomType.ARMORY:
|
||||
_build_armory_workbench()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wall generation (same logic as room.gd)
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_walls() -> void:
|
||||
var hw := ROOM_W / 2.0
|
||||
var hh := ROOM_H / 2.0
|
||||
var hdw := DOOR_W / 2.0
|
||||
var hdh := DOOR_H / 2.0
|
||||
|
||||
var has_up := _connections.has("up")
|
||||
var has_down := _connections.has("down")
|
||||
var has_left := _connections.has("left")
|
||||
var has_right := _connections.has("right")
|
||||
|
||||
if has_up:
|
||||
_make_wall(Rect2(-hw, -hh, hw - hdw, WALL_T))
|
||||
_make_wall(Rect2( hdw, -hh, hw - hdw, WALL_T))
|
||||
else:
|
||||
_make_wall(Rect2(-hw, -hh, ROOM_W, WALL_T))
|
||||
|
||||
if has_down:
|
||||
_make_wall(Rect2(-hw, hh - WALL_T, hw - hdw, WALL_T))
|
||||
_make_wall(Rect2( hdw, hh - WALL_T, hw - hdw, WALL_T))
|
||||
else:
|
||||
_make_wall(Rect2(-hw, hh - WALL_T, ROOM_W, WALL_T))
|
||||
|
||||
var inner_top := -hh + WALL_T
|
||||
var inner_bot := hh - WALL_T
|
||||
var top_seg_h := -hdh - inner_top
|
||||
var bot_seg_h := inner_bot - hdh
|
||||
var full_side := inner_bot - inner_top
|
||||
|
||||
if has_left:
|
||||
_make_wall(Rect2(-hw, inner_top, WALL_T, top_seg_h))
|
||||
_make_wall(Rect2(-hw, hdh, WALL_T, bot_seg_h))
|
||||
else:
|
||||
_make_wall(Rect2(-hw, inner_top, WALL_T, full_side))
|
||||
|
||||
if has_right:
|
||||
_make_wall(Rect2(hw - WALL_T, inner_top, WALL_T, top_seg_h))
|
||||
_make_wall(Rect2(hw - WALL_T, hdh, WALL_T, bot_seg_h))
|
||||
else:
|
||||
_make_wall(Rect2(hw - WALL_T, inner_top, WALL_T, full_side))
|
||||
|
||||
|
||||
func _make_wall(rect: Rect2) -> void:
|
||||
var body := StaticBody2D.new()
|
||||
body.collision_layer = 1
|
||||
body.collision_mask = 0
|
||||
body.position = rect.get_center()
|
||||
|
||||
var cs := CollisionShape2D.new()
|
||||
var rs := RectangleShape2D.new()
|
||||
rs.size = rect.size
|
||||
cs.shape = rs
|
||||
body.add_child(cs)
|
||||
|
||||
var poly := Polygon2D.new()
|
||||
var hx := rect.size.x / 2.0
|
||||
var hy := rect.size.y / 2.0
|
||||
poly.polygon = PackedVector2Array([
|
||||
Vector2(-hx, -hy), Vector2(hx, -hy),
|
||||
Vector2( hx, hy), Vector2(-hx, hy),
|
||||
])
|
||||
poly.color = WALL_COLOR
|
||||
body.add_child(poly)
|
||||
|
||||
walls_node.add_child(body)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Door triggers
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_door_triggers() -> void:
|
||||
var hw := ROOM_W / 2.0
|
||||
var hh := ROOM_H / 2.0
|
||||
|
||||
var configs := {
|
||||
"up": {"pos": Vector2(0, -hh + WALL_T * 0.5), "size": Vector2(DOOR_W * 0.9, WALL_T * 1.5)},
|
||||
"down": {"pos": Vector2(0, hh - WALL_T * 0.5), "size": Vector2(DOOR_W * 0.9, WALL_T * 1.5)},
|
||||
"left": {"pos": Vector2(-hw + WALL_T * 0.5, 0), "size": Vector2(WALL_T * 1.5, DOOR_H * 0.9)},
|
||||
"right": {"pos": Vector2( hw - WALL_T * 0.5, 0), "size": Vector2(WALL_T * 1.5, DOOR_H * 0.9)},
|
||||
}
|
||||
|
||||
for dir in _connections.keys():
|
||||
var cfg = configs[dir]
|
||||
var area := Area2D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 2
|
||||
area.name = "Door_" + dir
|
||||
|
||||
var cs := CollisionShape2D.new()
|
||||
var rs := RectangleShape2D.new()
|
||||
rs.size = cfg["size"]
|
||||
cs.shape = rs
|
||||
area.add_child(cs)
|
||||
area.position = cfg["pos"]
|
||||
|
||||
area.body_entered.connect(_on_door_entered.bind(dir))
|
||||
door_triggers.add_child(area)
|
||||
_doors[dir] = area
|
||||
|
||||
|
||||
func _on_door_entered(body: Node, direction: String) -> void:
|
||||
if body.is_in_group("player"):
|
||||
_hub.call_deferred("transition_to", _connections[direction], direction)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Room label
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_room_label() -> void:
|
||||
var lbl := Label.new()
|
||||
lbl.text = ROOM_NAMES[_type]
|
||||
lbl.position = Vector2(-60, -235)
|
||||
contents.add_child(lbl)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Armory workbench
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_armory_workbench() -> void:
|
||||
# Visual — blue-grey bench
|
||||
var bench := Polygon2D.new()
|
||||
bench.polygon = PackedVector2Array([-50, -20, 50, -20, 50, 20, -50, 20])
|
||||
bench.color = Color(0.30, 0.40, 0.60)
|
||||
bench.position = Vector2(0, -100)
|
||||
contents.add_child(bench)
|
||||
|
||||
var lbl := Label.new()
|
||||
lbl.text = "UPGRADE"
|
||||
lbl.position = Vector2(-30, -140)
|
||||
contents.add_child(lbl)
|
||||
|
||||
var area := Area2D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 2
|
||||
|
||||
var cs := CollisionShape2D.new()
|
||||
var rs := RectangleShape2D.new()
|
||||
rs.size = Vector2(100, 40)
|
||||
cs.shape = rs
|
||||
area.add_child(cs)
|
||||
area.position = Vector2(0, -100)
|
||||
|
||||
area.body_entered.connect(func(b: Node) -> void:
|
||||
if b.is_in_group("player"):
|
||||
_hub.call_deferred("open_armory"))
|
||||
contents.add_child(area)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Staging Area run portal
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_run_portal() -> void:
|
||||
# Visual — bright green rectangle
|
||||
var poly := Polygon2D.new()
|
||||
poly.polygon = PackedVector2Array([-40, -30, 40, -30, 40, 30, -40, 30])
|
||||
poly.color = Color(0.2, 0.9, 0.4)
|
||||
poly.position = Vector2(0, -150)
|
||||
contents.add_child(poly)
|
||||
|
||||
# Label above portal
|
||||
var lbl := Label.new()
|
||||
lbl.text = "START RUN"
|
||||
lbl.position = Vector2(-36, -195)
|
||||
contents.add_child(lbl)
|
||||
|
||||
# Trigger zone
|
||||
var area := Area2D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 2
|
||||
|
||||
var cs := CollisionShape2D.new()
|
||||
var rs := RectangleShape2D.new()
|
||||
rs.size = Vector2(80, 60)
|
||||
cs.shape = rs
|
||||
area.add_child(cs)
|
||||
area.position = Vector2(0, -150)
|
||||
|
||||
area.body_entered.connect(func(b: Node) -> void:
|
||||
if b.is_in_group("player"):
|
||||
GameManager.call_deferred("start_run"))
|
||||
contents.add_child(area)
|
||||
@@ -0,0 +1,46 @@
|
||||
extends Resource
|
||||
class_name RunItem
|
||||
|
||||
enum Effect {
|
||||
STAT_MOD = 0, # flat stat modifier
|
||||
RICOCHET = 1, # projectiles bounce off walls once
|
||||
SCATTER = 2, # fire N projectiles in a spread
|
||||
EXPLOSIVE = 3, # projectiles explode on hit
|
||||
HEAL = 4, # immediate HP restore on pickup, no ongoing effect
|
||||
COOLANT_LEAK = 5, # leaves a slow zone on taking damage
|
||||
SCRAP_MAGNET = 6, # scrap tokens auto-attract within magnet_radius
|
||||
MEMORY_SPIKE = 7, # first projectile per room deals 3x damage
|
||||
RUST_COAT = 8, # incoming damage reduced by damage_reduction (min 1)
|
||||
STATIC_DISCHARGE = 9, # AoE burst (discharge_radius) on taking damage
|
||||
FRAGMENTED_MAP = 10, # reveals one unexplored room on each combat clear
|
||||
OVERCLOCK = 11, # fire rate x1.4; every 3rd shot deals 0 damage
|
||||
}
|
||||
|
||||
@export var display_name: String = ""
|
||||
@export var description: String = ""
|
||||
@export var effect: Effect = Effect.STAT_MOD
|
||||
|
||||
# STAT_MOD
|
||||
@export var stat_key: String = "" # "speed"|"max_health"|"damage"|"fire_rate"|"range"
|
||||
@export var stat_delta: float = 0.0
|
||||
|
||||
# SCATTER
|
||||
@export var scatter_count: int = 3
|
||||
@export var scatter_spread_deg: float = 20.0
|
||||
|
||||
# EXPLOSIVE
|
||||
@export var explosion_radius: float = 64.0
|
||||
@export var explosion_damage: float = 2.5
|
||||
|
||||
# HEAL — immediate HP restored on pickup (separate from stat_delta)
|
||||
@export var heal_on_pickup: float = 0.0
|
||||
|
||||
# RUST_COAT
|
||||
@export var damage_reduction: float = 1.0
|
||||
|
||||
# STATIC_DISCHARGE
|
||||
@export var discharge_radius: float = 64.0
|
||||
@export var discharge_damage: float = 1.0
|
||||
|
||||
# SCRAP_MAGNET
|
||||
@export var magnet_radius: float = 192.0
|
||||
@@ -0,0 +1,35 @@
|
||||
extends Area2D
|
||||
|
||||
var item: RunItem = null
|
||||
|
||||
func _ready() -> void:
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
# Placeholder visual — cyan diamond
|
||||
func _draw() -> void:
|
||||
draw_colored_polygon(PackedVector2Array([
|
||||
Vector2(0, -14), Vector2(10, 0), Vector2(0, 14), Vector2(-10, 0)
|
||||
]), Color(0.2, 0.9, 0.85))
|
||||
draw_arc(Vector2.ZERO, 16.0, 0, TAU, 24, Color(0.5, 1.0, 0.95), 1.5)
|
||||
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if not body.is_in_group("player") or item == null:
|
||||
return
|
||||
RunManager.collect_item(item)
|
||||
if item.heal_on_pickup > 0.0:
|
||||
body.stats.heal(item.heal_on_pickup)
|
||||
body.stats.recalculate()
|
||||
EventBus.player_health_changed.emit(body.stats.current_health, body.stats.max_health)
|
||||
_show_pickup_label()
|
||||
queue_free()
|
||||
|
||||
func _show_pickup_label() -> void:
|
||||
var lbl := Label.new()
|
||||
lbl.text = item.display_name
|
||||
lbl.position = global_position + Vector2(-40, -20)
|
||||
lbl.modulate = Color(0.2, 1.0, 0.85)
|
||||
get_tree().current_scene.add_child(lbl)
|
||||
var tween := lbl.create_tween()
|
||||
tween.tween_property(lbl, "position", lbl.position + Vector2(0, -60), 1.2)
|
||||
tween.parallel().tween_property(lbl, "modulate:a", 0.0, 1.2)
|
||||
tween.tween_callback(lbl.queue_free)
|
||||
@@ -0,0 +1,20 @@
|
||||
extends Node
|
||||
|
||||
# All lore IDs the player has discovered — never lost on death
|
||||
var discovered: Array[String] = []
|
||||
|
||||
func discover(lore_id: String) -> void:
|
||||
if lore_id not in discovered:
|
||||
discovered.append(lore_id)
|
||||
EventBus.lore_discovered.emit(lore_id)
|
||||
|
||||
func has_discovered(lore_id: String) -> bool:
|
||||
return lore_id in discovered
|
||||
|
||||
func get_save_data() -> Array:
|
||||
return discovered.duplicate()
|
||||
|
||||
func load_save_data(data: Array) -> void:
|
||||
discovered.clear()
|
||||
for entry in data:
|
||||
discovered.append(str(entry))
|
||||
@@ -0,0 +1,72 @@
|
||||
extends Node
|
||||
|
||||
enum NPCState {
|
||||
UNDISCOVERED,
|
||||
IN_HUB,
|
||||
ON_RUN,
|
||||
LOST,
|
||||
}
|
||||
|
||||
# npc_id -> { state: NPCState, resource_path: String }
|
||||
var _registry: Dictionary = {}
|
||||
|
||||
func register_npc(npc_id: String, resource_path: String) -> void:
|
||||
if npc_id not in _registry:
|
||||
_registry[npc_id] = {
|
||||
"state": NPCState.UNDISCOVERED,
|
||||
"resource_path": resource_path,
|
||||
}
|
||||
|
||||
func discover_npc(npc_id: String) -> void:
|
||||
if npc_id not in _registry:
|
||||
return
|
||||
_registry[npc_id]["state"] = NPCState.IN_HUB
|
||||
var npc := _load_npc(npc_id)
|
||||
if npc:
|
||||
EventBus.npc_saved.emit(npc)
|
||||
|
||||
func send_on_run(npc_id: String) -> void:
|
||||
if npc_id in _registry:
|
||||
_registry[npc_id]["state"] = NPCState.ON_RUN
|
||||
|
||||
func return_npc_to_hub(npc_id: String) -> void:
|
||||
if npc_id in _registry:
|
||||
_registry[npc_id]["state"] = NPCState.IN_HUB
|
||||
|
||||
func lose_npc(npc_id: String) -> void:
|
||||
if npc_id not in _registry:
|
||||
return
|
||||
_registry[npc_id]["state"] = NPCState.LOST
|
||||
var npc := _load_npc(npc_id)
|
||||
if npc:
|
||||
EventBus.npc_lost.emit(npc)
|
||||
|
||||
func get_hub_npc_ids() -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for npc_id in _registry:
|
||||
if _registry[npc_id]["state"] == NPCState.IN_HUB:
|
||||
result.append(npc_id)
|
||||
return result
|
||||
|
||||
func get_state(npc_id: String) -> NPCState:
|
||||
return _registry.get(npc_id, {}).get("state", NPCState.UNDISCOVERED)
|
||||
|
||||
func get_save_data() -> Dictionary:
|
||||
var data := {}
|
||||
for npc_id in _registry:
|
||||
data[npc_id] = {
|
||||
"state": _registry[npc_id]["state"],
|
||||
"resource_path": _registry[npc_id]["resource_path"],
|
||||
}
|
||||
return data
|
||||
|
||||
func load_save_data(data: Dictionary) -> void:
|
||||
_registry.clear()
|
||||
for npc_id in data:
|
||||
_registry[npc_id] = data[npc_id].duplicate()
|
||||
|
||||
func _load_npc(npc_id: String) -> Resource:
|
||||
var path: String = _registry[npc_id].get("resource_path", "")
|
||||
if path != "" and ResourceLoader.exists(path):
|
||||
return load(path)
|
||||
return null
|
||||
@@ -0,0 +1,110 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node refs
|
||||
# ---------------------------------------------------------------------------
|
||||
@onready var stats: Node = $Stats
|
||||
@onready var body: Node2D = $Body
|
||||
@onready var shoot_origin: Marker2D = $ShootOrigin
|
||||
|
||||
const PROJECTILE = preload("res://scenes/player/projectile.tscn")
|
||||
const IFRAME_DURATION := 0.8
|
||||
|
||||
var _shoot_timer: float = 0.0
|
||||
var _iframe_timer: float = 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("player")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Physics
|
||||
# ---------------------------------------------------------------------------
|
||||
func _physics_process(delta: float) -> void:
|
||||
_handle_movement()
|
||||
_handle_shooting(delta)
|
||||
_handle_iframes(delta)
|
||||
move_and_slide()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Movement — WASD, 8-directional (direct key checks, no input map needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
func _handle_movement() -> void:
|
||||
var input_dir := Vector2.ZERO
|
||||
if Input.is_physical_key_pressed(KEY_W): input_dir.y -= 1
|
||||
if Input.is_physical_key_pressed(KEY_S): input_dir.y += 1
|
||||
if Input.is_physical_key_pressed(KEY_A): input_dir.x -= 1
|
||||
if Input.is_physical_key_pressed(KEY_D): input_dir.x += 1
|
||||
if input_dir.length() > 1.0:
|
||||
input_dir = input_dir.normalized()
|
||||
velocity = input_dir * stats.speed
|
||||
|
||||
if input_dir.x != 0.0:
|
||||
body.scale.x = sign(input_dir.x)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shooting — Arrow keys via built-in ui_ actions (always available in Godot)
|
||||
# ---------------------------------------------------------------------------
|
||||
func _handle_shooting(delta: float) -> void:
|
||||
_shoot_timer = maxf(_shoot_timer - delta, 0.0)
|
||||
|
||||
var shoot_dir := Vector2.ZERO
|
||||
if Input.is_action_pressed("ui_up"):
|
||||
shoot_dir = Vector2.UP
|
||||
elif Input.is_action_pressed("ui_down"):
|
||||
shoot_dir = Vector2.DOWN
|
||||
elif Input.is_action_pressed("ui_left"):
|
||||
shoot_dir = Vector2.LEFT
|
||||
elif Input.is_action_pressed("ui_right"):
|
||||
shoot_dir = Vector2.RIGHT
|
||||
|
||||
if shoot_dir != Vector2.ZERO and _shoot_timer == 0.0:
|
||||
_fire(shoot_dir)
|
||||
_shoot_timer = 1.0 / stats.fire_rate
|
||||
|
||||
func _fire(direction: Vector2) -> void:
|
||||
if stats.scatter_count <= 1:
|
||||
_spawn_projectile(direction)
|
||||
else:
|
||||
var spread := deg_to_rad(stats.scatter_spread_deg)
|
||||
var step := spread / float(stats.scatter_count - 1)
|
||||
var start := direction.angle() - spread / 2.0
|
||||
for i in stats.scatter_count:
|
||||
_spawn_projectile(Vector2.from_angle(start + step * i))
|
||||
|
||||
func _spawn_projectile(dir: Vector2) -> void:
|
||||
var proj: Area2D = PROJECTILE.instantiate()
|
||||
proj.global_position = shoot_origin.global_position
|
||||
proj.direction = dir
|
||||
proj.damage = stats.damage
|
||||
proj.max_range = stats.range_
|
||||
proj.ricochet = stats.ricochet
|
||||
proj.explosive = stats.explosive
|
||||
proj.explosion_radius = stats.explosion_radius
|
||||
proj.explosion_damage = stats.explosion_damage
|
||||
get_parent().add_child(proj)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Iframes — flash body during invincibility
|
||||
# ---------------------------------------------------------------------------
|
||||
func _handle_iframes(delta: float) -> void:
|
||||
if _iframe_timer <= 0.0:
|
||||
body.modulate = Color(1, 1, 1, 1)
|
||||
return
|
||||
_iframe_timer -= delta
|
||||
# Visible flash every 0.1s
|
||||
body.modulate = Color(1, 1, 1, 0.25) if fmod(_iframe_timer, 0.2) < 0.1 else Color(1, 1, 1, 1)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Damage / death
|
||||
# ---------------------------------------------------------------------------
|
||||
func take_damage(amount: float) -> void:
|
||||
if _iframe_timer > 0.0:
|
||||
return
|
||||
stats.current_health -= amount
|
||||
_iframe_timer = IFRAME_DURATION
|
||||
EventBus.player_health_changed.emit(stats.current_health, stats.max_health)
|
||||
if stats.current_health <= 0.0:
|
||||
_die()
|
||||
|
||||
func _die() -> void:
|
||||
RunManager.end_run(false)
|
||||
@@ -0,0 +1,144 @@
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base stats — no upgrades equipped
|
||||
# ---------------------------------------------------------------------------
|
||||
const BASE_SPEED := 200.0
|
||||
const BASE_MAX_HEALTH := 6.0 # 6 HP = 3 full hearts
|
||||
const BASE_DAMAGE := 3.5
|
||||
const BASE_FIRE_RATE := 3.5 # shots per second
|
||||
const BASE_RANGE := 400.0
|
||||
const BASE_PROJ_SPEED := 520.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live stats (recalculated from base + equipped parts + run items)
|
||||
# ---------------------------------------------------------------------------
|
||||
var speed: float = BASE_SPEED
|
||||
var max_health: float = BASE_MAX_HEALTH
|
||||
var current_health: float = BASE_MAX_HEALTH
|
||||
var damage: float = BASE_DAMAGE
|
||||
var fire_rate: float = BASE_FIRE_RATE
|
||||
var range_: float = BASE_RANGE
|
||||
var proj_speed: float = BASE_PROJ_SPEED
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run-item behavior flags (recomputed each recalculate — never saved)
|
||||
# ---------------------------------------------------------------------------
|
||||
var ricochet: bool = false
|
||||
var scatter_count: int = 1
|
||||
var scatter_spread_deg: float = 0.0
|
||||
var explosive: bool = false
|
||||
var explosion_radius: float = 0.0
|
||||
var explosion_damage: float = 0.0
|
||||
|
||||
# New item flags
|
||||
var coolant_leak: bool = false
|
||||
var scrap_magnet: bool = false
|
||||
var memory_spike: bool = false
|
||||
var rust_coat: bool = false
|
||||
var static_discharge: bool = false
|
||||
var fragmented_map: bool = false
|
||||
var overclock: bool = false
|
||||
var damage_reduction: float = 0.0
|
||||
var discharge_radius: float = 64.0
|
||||
var discharge_damage: float = 1.0
|
||||
var magnet_radius: float = 0.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body-part behavior flags (recomputed each recalculate)
|
||||
# ---------------------------------------------------------------------------
|
||||
var shield_projector: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
EventBus.body_part_equipped.connect(_on_part_equipped)
|
||||
recalculate()
|
||||
if RunManager.player_health_carry > 0.0:
|
||||
current_health = minf(RunManager.player_health_carry, max_health)
|
||||
RunManager.player_health_carry = -1.0
|
||||
|
||||
func recalculate() -> void:
|
||||
speed = BASE_SPEED + UpgradeManager.get_stat_modifier("speed")
|
||||
max_health = BASE_MAX_HEALTH + UpgradeManager.get_stat_modifier("max_health")
|
||||
damage = BASE_DAMAGE + UpgradeManager.get_stat_modifier("damage")
|
||||
fire_rate = BASE_FIRE_RATE + UpgradeManager.get_stat_modifier("fire_rate")
|
||||
range_ = BASE_RANGE + UpgradeManager.get_stat_modifier("range")
|
||||
proj_speed = BASE_PROJ_SPEED + UpgradeManager.get_stat_modifier("proj_speed")
|
||||
_apply_run_items()
|
||||
_apply_part_flags()
|
||||
current_health = minf(current_health, max_health)
|
||||
|
||||
func _apply_run_items() -> void:
|
||||
ricochet = false
|
||||
scatter_count = 1
|
||||
scatter_spread_deg = 0.0
|
||||
explosive = false
|
||||
explosion_radius = 0.0
|
||||
explosion_damage = 0.0
|
||||
coolant_leak = false
|
||||
scrap_magnet = false
|
||||
memory_spike = false
|
||||
rust_coat = false
|
||||
static_discharge = false
|
||||
fragmented_map = false
|
||||
overclock = false
|
||||
damage_reduction = 0.0
|
||||
discharge_radius = 64.0
|
||||
discharge_damage = 1.0
|
||||
magnet_radius = 0.0
|
||||
|
||||
for item: RunItem in RunManager.run_items:
|
||||
match item.effect:
|
||||
RunItem.Effect.STAT_MOD:
|
||||
match item.stat_key:
|
||||
"speed": speed += item.stat_delta
|
||||
"max_health": max_health += item.stat_delta
|
||||
"damage": damage += item.stat_delta
|
||||
"fire_rate": fire_rate += item.stat_delta
|
||||
"range": range_ += item.stat_delta
|
||||
RunItem.Effect.RICOCHET:
|
||||
ricochet = true
|
||||
RunItem.Effect.SCATTER:
|
||||
scatter_count = maxi(scatter_count, item.scatter_count)
|
||||
scatter_spread_deg = maxf(scatter_spread_deg, item.scatter_spread_deg)
|
||||
RunItem.Effect.EXPLOSIVE:
|
||||
explosive = true
|
||||
explosion_radius = maxf(explosion_radius, item.explosion_radius)
|
||||
explosion_damage = maxf(explosion_damage, item.explosion_damage)
|
||||
RunItem.Effect.COOLANT_LEAK:
|
||||
coolant_leak = true
|
||||
RunItem.Effect.SCRAP_MAGNET:
|
||||
scrap_magnet = true
|
||||
magnet_radius = maxf(magnet_radius, item.magnet_radius)
|
||||
RunItem.Effect.MEMORY_SPIKE:
|
||||
memory_spike = true
|
||||
RunItem.Effect.RUST_COAT:
|
||||
rust_coat = true
|
||||
damage_reduction = maxf(damage_reduction, item.damage_reduction)
|
||||
RunItem.Effect.STATIC_DISCHARGE:
|
||||
static_discharge = true
|
||||
discharge_radius = maxf(discharge_radius, item.discharge_radius)
|
||||
discharge_damage = maxf(discharge_damage, item.discharge_damage)
|
||||
RunItem.Effect.FRAGMENTED_MAP:
|
||||
fragmented_map = true
|
||||
RunItem.Effect.OVERCLOCK:
|
||||
overclock = true
|
||||
fire_rate *= 1.4
|
||||
|
||||
func _apply_part_flags() -> void:
|
||||
shield_projector = false
|
||||
# Scatter from body parts (accumulate with run item scatter)
|
||||
for slot in UpgradeManager.SLOTS:
|
||||
var part := UpgradeManager.get_equipped_part(slot)
|
||||
if part == null:
|
||||
continue
|
||||
if part.shield_projector:
|
||||
shield_projector = true
|
||||
if part.mod_scatter_count > 0:
|
||||
scatter_count = maxi(scatter_count, part.mod_scatter_count)
|
||||
scatter_spread_deg = maxf(scatter_spread_deg, part.mod_scatter_spread_deg)
|
||||
|
||||
func heal(amount: float) -> void:
|
||||
current_health = minf(current_health + amount, max_health)
|
||||
|
||||
func _on_part_equipped(_part: Resource, _slot: String) -> void:
|
||||
recalculate()
|
||||
@@ -0,0 +1,89 @@
|
||||
extends Area2D
|
||||
|
||||
# Set by player on spawn
|
||||
var direction: Vector2 = Vector2.RIGHT
|
||||
var damage: float = 3.5
|
||||
var speed: float = 520.0
|
||||
var max_range: float = 400.0
|
||||
|
||||
# Behavior flags (set from player stats)
|
||||
var ricochet: bool = false
|
||||
var explosive: bool = false
|
||||
var explosion_radius: float = 64.0
|
||||
var explosion_damage: float = 2.5
|
||||
|
||||
var _distance_traveled: float = 0.0
|
||||
var _bounces_left: int = 1
|
||||
var _bounce_cooldown: float = 0.0 # grace period after bounce to avoid re-trigger
|
||||
|
||||
func _ready() -> void:
|
||||
rotation = direction.angle()
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_bounce_cooldown = maxf(_bounce_cooldown - delta, 0.0)
|
||||
if ricochet and _bounces_left > 0 and _bounce_cooldown == 0.0:
|
||||
_check_wall_bounce(delta)
|
||||
var move := direction * speed * delta
|
||||
position += move
|
||||
_distance_traveled += move.length()
|
||||
if _distance_traveled >= max_range:
|
||||
queue_free()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ricochet — raycast lookahead to get wall normal before physically touching
|
||||
# ---------------------------------------------------------------------------
|
||||
func _check_wall_bounce(delta: float) -> void:
|
||||
var space := get_world_2d().direct_space_state
|
||||
var reach := speed * delta + 6.0
|
||||
var params := PhysicsRayQueryParameters2D.create(
|
||||
global_position,
|
||||
global_position + direction * reach,
|
||||
1 # collision_mask: walls on layer 1
|
||||
)
|
||||
var result := space.intersect_ray(params)
|
||||
if result and result["collider"] is StaticBody2D:
|
||||
direction = direction.bounce(result["normal"])
|
||||
rotation = direction.angle()
|
||||
_bounces_left -= 1
|
||||
_bounce_cooldown = 0.1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Placeholder visual — yellow circle
|
||||
# ---------------------------------------------------------------------------
|
||||
func _draw() -> void:
|
||||
draw_circle(Vector2.ZERO, 5.0, Color(1.0, 0.85, 0.2))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collision — differentiate walls from enemies
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if body is StaticBody2D:
|
||||
# Wall hit — ignore during bounce cooldown (already reflected by raycast)
|
||||
if _bounce_cooldown > 0.0:
|
||||
return
|
||||
if not ricochet or _bounces_left <= 0:
|
||||
queue_free()
|
||||
return
|
||||
|
||||
if body.has_method("take_damage"):
|
||||
body.take_damage(damage)
|
||||
if explosive:
|
||||
_explode()
|
||||
queue_free()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Explosive — AoE damage to all enemies in radius
|
||||
# ---------------------------------------------------------------------------
|
||||
func _explode() -> void:
|
||||
var space := get_world_2d().direct_space_state
|
||||
var query := PhysicsShapeQueryParameters2D.new()
|
||||
var circle := CircleShape2D.new()
|
||||
circle.radius = explosion_radius
|
||||
query.shape = circle
|
||||
query.transform = Transform2D(0.0, global_position)
|
||||
query.collision_mask = 1
|
||||
for r in space.intersect_shape(query):
|
||||
var c = r["collider"]
|
||||
if c.has_method("take_damage"):
|
||||
c.take_damage(explosion_damage)
|
||||
@@ -0,0 +1,92 @@
|
||||
extends Node2D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
const ROOM_SCENE = preload("res://scenes/run/rooms/room_base.tscn")
|
||||
const ROOM_W := 960.0
|
||||
const ROOM_H := 540.0
|
||||
const TRANSITION_TIME := 0.25
|
||||
const ENTRY_INSET := 60.0 # how far from wall edge the player appears
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node refs
|
||||
# ---------------------------------------------------------------------------
|
||||
@onready var rooms_container: Node2D = $Rooms
|
||||
@onready var camera: Camera2D = $Camera2D
|
||||
@onready var player: Node2D = $Player
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
var _rooms: Dictionary = {} # Vector2i -> Room
|
||||
var _current_grid: Vector2i
|
||||
var _transitioning: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boot
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
var floor_num: int = max(RunManager.current_floor, 1)
|
||||
_build_floor(FloorGenerator.generate(floor_num))
|
||||
|
||||
|
||||
func _build_floor(floor_data: Array) -> void:
|
||||
var start_pos := Vector2i.ZERO
|
||||
|
||||
for room_data in floor_data:
|
||||
var room_node: Room = ROOM_SCENE.instantiate()
|
||||
room_node.position = _grid_to_world(room_data.grid_pos)
|
||||
rooms_container.add_child(room_node)
|
||||
room_node.setup(room_data, self)
|
||||
_rooms[room_data.grid_pos] = room_node
|
||||
|
||||
if room_data.type == RoomData.RoomType.START:
|
||||
start_pos = room_data.grid_pos
|
||||
|
||||
# Snap camera + player to start room
|
||||
var world_start := _grid_to_world(start_pos)
|
||||
camera.position = world_start
|
||||
player.global_position = world_start
|
||||
_current_grid = start_pos
|
||||
_rooms[start_pos].activate()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transitions
|
||||
# ---------------------------------------------------------------------------
|
||||
func transition_to(target_grid: Vector2i, from_direction: String) -> void:
|
||||
if _transitioning:
|
||||
return
|
||||
_transitioning = true
|
||||
_current_grid = target_grid
|
||||
|
||||
var new_world := _grid_to_world(target_grid)
|
||||
|
||||
# Teleport player to the entry side of the new room immediately
|
||||
player.global_position = new_world + _entry_offset(from_direction)
|
||||
|
||||
# Activate the room (may spawn enemies) — we're outside physics here
|
||||
_rooms[target_grid].activate()
|
||||
|
||||
# Slide the camera
|
||||
var tween := create_tween()
|
||||
tween.tween_property(camera, "position", new_world, TRANSITION_TIME)
|
||||
tween.tween_callback(func(): _transitioning = false)
|
||||
|
||||
|
||||
func _entry_offset(from_direction: String) -> Vector2:
|
||||
# Player exits through `from_direction`; they enter the new room
|
||||
# from the opposite side.
|
||||
var i := ENTRY_INSET
|
||||
match from_direction:
|
||||
"up": return Vector2(0, ROOM_H / 2.0 - i) # enter at bottom
|
||||
"down": return Vector2(0, -ROOM_H / 2.0 + i) # enter at top
|
||||
"left": return Vector2( ROOM_W / 2.0 - i, 0) # enter at right
|
||||
"right": return Vector2(-ROOM_W / 2.0 + i, 0) # enter at left
|
||||
return Vector2.ZERO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
func _grid_to_world(grid_pos: Vector2i) -> Vector2:
|
||||
return Vector2(grid_pos.x * ROOM_W, grid_pos.y * ROOM_H)
|
||||
@@ -0,0 +1,118 @@
|
||||
class_name FloorGenerator
|
||||
|
||||
const GRID_START := Vector2i(5, 5)
|
||||
const MIN_ROOMS := 8
|
||||
const MAX_ROOMS := 12
|
||||
|
||||
const DIRS := {
|
||||
"up": Vector2i( 0, -1),
|
||||
"down": Vector2i( 0, 1),
|
||||
"left": Vector2i(-1, 0),
|
||||
"right": Vector2i( 1, 0),
|
||||
}
|
||||
const OPPOSITE := {
|
||||
"up": "down", "down": "up", "left": "right", "right": "left",
|
||||
}
|
||||
|
||||
# Returns Array of RoomData covering one floor layout.
|
||||
static func generate(floor_number: int) -> Array:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.randomize()
|
||||
|
||||
var rooms: Dictionary = {} # Vector2i -> RoomData
|
||||
|
||||
# --- Start room ---
|
||||
var start := RoomData.new()
|
||||
start.type = RoomData.RoomType.START
|
||||
start.grid_pos = GRID_START
|
||||
rooms[GRID_START] = start
|
||||
|
||||
var queue: Array = [GRID_START]
|
||||
var target: int = rng.randi_range(MIN_ROOMS, MAX_ROOMS)
|
||||
|
||||
# --- Random walk ---
|
||||
while rooms.size() < target and queue.size() > 0:
|
||||
var pos: Vector2i = queue[rng.randi() % queue.size()]
|
||||
|
||||
for dir_name in _shuffled(rng, ["up", "down", "left", "right"]):
|
||||
if rooms.size() >= target:
|
||||
break
|
||||
|
||||
var next: Vector2i = pos + DIRS[dir_name]
|
||||
if rooms.has(next):
|
||||
continue
|
||||
|
||||
# Skip if the candidate cell already has >1 existing neighbor
|
||||
# (prevents overly dense clusters)
|
||||
var neighbor_count := 0
|
||||
for d in DIRS.values():
|
||||
if rooms.has(next + d):
|
||||
neighbor_count += 1
|
||||
if neighbor_count > 1:
|
||||
continue
|
||||
|
||||
var r := RoomData.new()
|
||||
r.type = RoomData.RoomType.COMBAT
|
||||
r.grid_pos = next
|
||||
rooms[next] = r
|
||||
queue.append(next)
|
||||
|
||||
# Bidirectional connection
|
||||
rooms[pos].connections[dir_name] = next
|
||||
r.connections[OPPOSITE[dir_name]] = pos
|
||||
|
||||
# --- Boss: farthest room from start ---
|
||||
var boss_pos := _farthest(GRID_START, rooms)
|
||||
rooms[boss_pos].type = RoomData.RoomType.BOSS
|
||||
|
||||
# --- Item + Shop: dead-end COMBAT rooms ---
|
||||
var dead_ends: Array = []
|
||||
for p in rooms:
|
||||
var rd: RoomData = rooms[p]
|
||||
if rd.type == RoomData.RoomType.COMBAT and rd.connections.size() == 1:
|
||||
dead_ends.append(p)
|
||||
dead_ends.shuffle()
|
||||
|
||||
if dead_ends.size() >= 1:
|
||||
rooms[dead_ends[0]].type = RoomData.RoomType.ITEM
|
||||
if dead_ends.size() >= 2:
|
||||
rooms[dead_ends[1]].type = RoomData.RoomType.SHOP
|
||||
|
||||
return rooms.values()
|
||||
|
||||
|
||||
static func _shuffled(rng: RandomNumberGenerator, arr: Array) -> Array:
|
||||
var result := arr.duplicate()
|
||||
for i in range(result.size() - 1, 0, -1):
|
||||
var j := rng.randi() % (i + 1)
|
||||
var tmp = result[i]
|
||||
result[i] = result[j]
|
||||
result[j] = tmp
|
||||
return result
|
||||
|
||||
|
||||
static func _farthest(start: Vector2i, rooms: Dictionary) -> Vector2i:
|
||||
var visited: Dictionary = {}
|
||||
var queue: Array = [[start, 0]]
|
||||
var farthest := start
|
||||
var max_dist := 0
|
||||
|
||||
while queue.size() > 0:
|
||||
var entry = queue.pop_front()
|
||||
var pos: Vector2i = entry[0]
|
||||
var dist: int = entry[1]
|
||||
|
||||
if visited.has(pos):
|
||||
continue
|
||||
visited[pos] = true
|
||||
|
||||
if dist > max_dist:
|
||||
max_dist = dist
|
||||
farthest = pos
|
||||
|
||||
var rd: RoomData = rooms[pos]
|
||||
for connected in rd.connections.values():
|
||||
if not visited.has(connected):
|
||||
queue.append([connected, dist + 1])
|
||||
|
||||
return farthest
|
||||
@@ -0,0 +1,332 @@
|
||||
extends Node2D
|
||||
class_name Room
|
||||
|
||||
signal room_cleared
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
const ROOM_W := 960.0
|
||||
const ROOM_H := 540.0
|
||||
const WALL_T := 32.0 # wall thickness
|
||||
const DOOR_W := 80.0 # door opening on horizontal walls
|
||||
const DOOR_H := 80.0 # door opening on vertical walls
|
||||
const SPAWN_MARGIN := 120.0 # keep enemies away from walls when spawning
|
||||
|
||||
const WALL_COLOR := Color(0.18, 0.18, 0.25)
|
||||
|
||||
const DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn")
|
||||
const REPEATER_SCENE = preload("res://scenes/enemies/repeater.tscn")
|
||||
const ANCHOR_SCENE = preload("res://scenes/enemies/anchor.tscn")
|
||||
const SUPERVISOR_SCENE = preload("res://scenes/enemies/supervisor.tscn")
|
||||
const BODY_PART_PICKUP = preload("res://scenes/upgrades/body_part_pickup.tscn")
|
||||
const RUN_ITEM_PICKUP = preload("res://scenes/items/run_item_pickup.tscn")
|
||||
|
||||
const ALL_PART_PATHS: Array[String] = [
|
||||
"res://data/body_parts/head_sensor_array.tres",
|
||||
"res://data/body_parts/torso_reinforced_plating.tres",
|
||||
"res://data/body_parts/left_arm_rapid_fire.tres",
|
||||
"res://data/body_parts/right_arm_cannon.tres",
|
||||
"res://data/body_parts/legs_servo_boost.tres",
|
||||
]
|
||||
|
||||
const ALL_ITEM_PATHS: Array[String] = [
|
||||
"res://data/run_items/plating_shard.tres",
|
||||
"res://data/run_items/range_booster.tres",
|
||||
"res://data/run_items/overclock_chip.tres",
|
||||
"res://data/run_items/ricochet_module.tres",
|
||||
"res://data/run_items/scatter_core.tres",
|
||||
"res://data/run_items/volatile_round.tres",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
var data: RoomData = null
|
||||
var _floor: Node = null # reference to floor.gd node
|
||||
var _enemy_count: int = 0
|
||||
var _doors: Dictionary = {} # direction -> Area2D trigger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node refs (set by room_base.tscn)
|
||||
# ---------------------------------------------------------------------------
|
||||
@onready var floor_poly: Polygon2D = $Floor
|
||||
@onready var walls_node: Node2D = $Walls
|
||||
@onready var door_triggers: Node2D = $DoorTriggers
|
||||
@onready var contents: Node2D = $Contents
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup — called by floor.gd after add_child
|
||||
# ---------------------------------------------------------------------------
|
||||
func setup(room_data: RoomData, floor_ref: Node) -> void:
|
||||
data = room_data
|
||||
_floor = floor_ref
|
||||
_apply_floor_color()
|
||||
_build_walls()
|
||||
_build_door_triggers()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activate — called on first (and repeat) entry
|
||||
# ---------------------------------------------------------------------------
|
||||
func activate() -> void:
|
||||
if data.visited:
|
||||
_update_door_locks()
|
||||
return
|
||||
data.visited = true
|
||||
|
||||
match data.type:
|
||||
RoomData.RoomType.COMBAT:
|
||||
_spawn_enemies()
|
||||
RoomData.RoomType.BOSS:
|
||||
_spawn_boss()
|
||||
RoomData.RoomType.ITEM:
|
||||
_spawn_run_item()
|
||||
data.cleared = true
|
||||
_:
|
||||
data.cleared = true
|
||||
|
||||
_update_door_locks()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Floor colour by room type
|
||||
# ---------------------------------------------------------------------------
|
||||
func _apply_floor_color() -> void:
|
||||
match data.type:
|
||||
RoomData.RoomType.ITEM: floor_poly.color = Color(0.10, 0.16, 0.10)
|
||||
RoomData.RoomType.SHOP: floor_poly.color = Color(0.16, 0.12, 0.08)
|
||||
RoomData.RoomType.BOSS: floor_poly.color = Color(0.20, 0.08, 0.08)
|
||||
_: floor_poly.color = Color(0.12, 0.12, 0.18)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wall generation
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_walls() -> void:
|
||||
var hw := ROOM_W / 2.0 # 480
|
||||
var hh := ROOM_H / 2.0 # 270
|
||||
var hdw := DOOR_W / 2.0 # 40 — half opening width (top/bottom walls)
|
||||
var hdh := DOOR_H / 2.0 # 40 — half opening height (left/right walls)
|
||||
|
||||
var has_up := data.connections.has("up")
|
||||
var has_down := data.connections.has("down")
|
||||
var has_left := data.connections.has("left")
|
||||
var has_right := data.connections.has("right")
|
||||
|
||||
# --- Top wall ---
|
||||
if has_up:
|
||||
_make_wall(Rect2(-hw, -hh, hw - hdw, WALL_T)) # left of gap
|
||||
_make_wall(Rect2( hdw, -hh, hw - hdw, WALL_T)) # right of gap
|
||||
else:
|
||||
_make_wall(Rect2(-hw, -hh, ROOM_W, WALL_T))
|
||||
|
||||
# --- Bottom wall ---
|
||||
if has_down:
|
||||
_make_wall(Rect2(-hw, hh - WALL_T, hw - hdw, WALL_T))
|
||||
_make_wall(Rect2( hdw, hh - WALL_T, hw - hdw, WALL_T))
|
||||
else:
|
||||
_make_wall(Rect2(-hw, hh - WALL_T, ROOM_W, WALL_T))
|
||||
|
||||
# Side walls occupy the strip between the horizontal walls
|
||||
# inner_top = -238, inner_bot = 238, gap spans -40 to +40
|
||||
var inner_top := -hh + WALL_T # -238
|
||||
var inner_bot := hh - WALL_T # 238
|
||||
var top_seg_h := -hdh - inner_top # 198
|
||||
var bot_seg_h := inner_bot - hdh # 198
|
||||
var full_side := inner_bot - inner_top # 476
|
||||
|
||||
# --- Left wall ---
|
||||
if has_left:
|
||||
_make_wall(Rect2(-hw, inner_top, WALL_T, top_seg_h)) # above gap
|
||||
_make_wall(Rect2(-hw, hdh, WALL_T, bot_seg_h)) # below gap
|
||||
else:
|
||||
_make_wall(Rect2(-hw, inner_top, WALL_T, full_side))
|
||||
|
||||
# --- Right wall ---
|
||||
if has_right:
|
||||
_make_wall(Rect2(hw - WALL_T, inner_top, WALL_T, top_seg_h))
|
||||
_make_wall(Rect2(hw - WALL_T, hdh, WALL_T, bot_seg_h))
|
||||
else:
|
||||
_make_wall(Rect2(hw - WALL_T, inner_top, WALL_T, full_side))
|
||||
|
||||
|
||||
func _make_wall(rect: Rect2) -> void:
|
||||
var body := StaticBody2D.new()
|
||||
body.collision_layer = 1
|
||||
body.collision_mask = 0
|
||||
body.position = rect.get_center()
|
||||
|
||||
var cs := CollisionShape2D.new()
|
||||
var rs := RectangleShape2D.new()
|
||||
rs.size = rect.size
|
||||
cs.shape = rs
|
||||
body.add_child(cs)
|
||||
|
||||
var poly := Polygon2D.new()
|
||||
var hx := rect.size.x / 2.0
|
||||
var hy := rect.size.y / 2.0
|
||||
poly.polygon = PackedVector2Array([
|
||||
Vector2(-hx, -hy), Vector2(hx, -hy),
|
||||
Vector2( hx, hy), Vector2(-hx, hy),
|
||||
])
|
||||
poly.color = WALL_COLOR
|
||||
body.add_child(poly)
|
||||
|
||||
walls_node.add_child(body)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Door triggers
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_door_triggers() -> void:
|
||||
var hw := ROOM_W / 2.0 # 480
|
||||
var hh := ROOM_H / 2.0 # 270
|
||||
|
||||
# Triggers sit centered on the wall face at the door opening.
|
||||
# Slightly oversized so the player doesn't have to pixel-perfectly align.
|
||||
var configs := {
|
||||
"up": {"pos": Vector2(0, -hh + WALL_T * 0.5), "size": Vector2(DOOR_W * 0.9, WALL_T * 1.5)},
|
||||
"down": {"pos": Vector2(0, hh - WALL_T * 0.5), "size": Vector2(DOOR_W * 0.9, WALL_T * 1.5)},
|
||||
"left": {"pos": Vector2(-hw + WALL_T * 0.5, 0), "size": Vector2(WALL_T * 1.5, DOOR_H * 0.9)},
|
||||
"right": {"pos": Vector2( hw - WALL_T * 0.5, 0), "size": Vector2(WALL_T * 1.5, DOOR_H * 0.9)},
|
||||
}
|
||||
|
||||
for dir in data.connections.keys():
|
||||
var cfg = configs[dir]
|
||||
var area := Area2D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 2 # player layer
|
||||
area.name = "Door_" + dir
|
||||
|
||||
var cs := CollisionShape2D.new()
|
||||
var rs := RectangleShape2D.new()
|
||||
rs.size = cfg["size"]
|
||||
cs.shape = rs
|
||||
area.add_child(cs)
|
||||
area.position = cfg["pos"]
|
||||
|
||||
# Use call_deferred so the transition runs outside the physics callback
|
||||
area.body_entered.connect(_on_door_entered.bind(dir))
|
||||
door_triggers.add_child(area)
|
||||
_doors[dir] = area
|
||||
|
||||
|
||||
func _on_door_entered(body: Node, direction: String) -> void:
|
||||
if body.is_in_group("player"):
|
||||
_floor.call_deferred(
|
||||
"transition_to", data.connections[direction], direction
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enemy spawning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Enemy pool and count scale with floor depth per CONTENT_SPEC
|
||||
func _get_enemy_pool() -> Array:
|
||||
match RunManager.current_floor:
|
||||
1: return [DRIFTER_SCENE, DRIFTER_SCENE, DRIFTER_SCENE, REPEATER_SCENE]
|
||||
2: return [DRIFTER_SCENE, DRIFTER_SCENE, REPEATER_SCENE, ANCHOR_SCENE]
|
||||
_: return [DRIFTER_SCENE, REPEATER_SCENE, ANCHOR_SCENE, ANCHOR_SCENE]
|
||||
|
||||
func _get_enemy_count() -> int:
|
||||
match RunManager.current_floor:
|
||||
1: return randi_range(1, 3)
|
||||
2: return randi_range(2, 4)
|
||||
_: return randi_range(2, 3)
|
||||
|
||||
func _spawn_enemies() -> void:
|
||||
var hw := ROOM_W / 2.0 - SPAWN_MARGIN
|
||||
var hh := ROOM_H / 2.0 - SPAWN_MARGIN
|
||||
var pool := _get_enemy_pool()
|
||||
var count := _get_enemy_count()
|
||||
|
||||
for i in count:
|
||||
var scene = pool[randi() % pool.size()]
|
||||
var enemy = scene.instantiate()
|
||||
var local_pos := Vector2(randf_range(-hw, hw), randf_range(-hh, hh))
|
||||
contents.add_child(enemy)
|
||||
enemy.global_position = to_global(local_pos)
|
||||
enemy.tree_exited.connect(_on_enemy_died, CONNECT_DEFERRED)
|
||||
_enemy_count += 1
|
||||
|
||||
|
||||
func _spawn_boss() -> void:
|
||||
var boss = SUPERVISOR_SCENE.instantiate()
|
||||
contents.add_child(boss)
|
||||
boss.global_position = to_global(Vector2.ZERO) # room centre
|
||||
boss.tree_exited.connect(_on_enemy_died, CONNECT_DEFERRED)
|
||||
_enemy_count = 1
|
||||
|
||||
|
||||
func _on_enemy_died() -> void:
|
||||
_enemy_count -= 1
|
||||
if _enemy_count <= 0:
|
||||
data.cleared = true
|
||||
if data.type == RoomData.RoomType.BOSS:
|
||||
_spawn_body_part_drop()
|
||||
_spawn_floor_exit()
|
||||
room_cleared.emit()
|
||||
_update_door_locks()
|
||||
|
||||
|
||||
func _spawn_floor_exit() -> void:
|
||||
# Visual — bright teal portal offset from body part drop (which is at center)
|
||||
var poly := Polygon2D.new()
|
||||
poly.polygon = PackedVector2Array([-35, -50, 35, -50, 35, 50, -35, 50])
|
||||
poly.color = Color(0.2, 0.85, 0.9)
|
||||
poly.position = Vector2(120, 0)
|
||||
contents.add_child(poly)
|
||||
|
||||
var lbl := Label.new()
|
||||
lbl.text = "NEXT FLOOR"
|
||||
lbl.position = Vector2(85, -75)
|
||||
contents.add_child(lbl)
|
||||
|
||||
var area := Area2D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 2
|
||||
|
||||
var cs := CollisionShape2D.new()
|
||||
var rs := RectangleShape2D.new()
|
||||
rs.size = Vector2(70, 100)
|
||||
cs.shape = rs
|
||||
area.add_child(cs)
|
||||
area.position = Vector2(120, 0)
|
||||
|
||||
area.body_entered.connect(func(body: Node) -> void:
|
||||
if body.is_in_group("player"):
|
||||
RunManager.player_health_carry = body.stats.current_health
|
||||
RunManager.advance_floor()
|
||||
get_tree().call_deferred("change_scene_to_file", "res://scenes/run/floor.tscn"))
|
||||
contents.add_child(area)
|
||||
|
||||
|
||||
func _spawn_body_part_drop() -> void:
|
||||
# Drop a random part the player hasn't yet acquired
|
||||
var unacquired: Array[String] = []
|
||||
for path in ALL_PART_PATHS:
|
||||
if path not in UpgradeManager.acquired_part_paths:
|
||||
unacquired.append(path)
|
||||
|
||||
if unacquired.is_empty():
|
||||
return # player has every part
|
||||
|
||||
var chosen: String = unacquired[randi() % unacquired.size()]
|
||||
var pickup = BODY_PART_PICKUP.instantiate()
|
||||
pickup.part = load(chosen)
|
||||
pickup.position = Vector2.ZERO # center of this room
|
||||
contents.add_child(pickup)
|
||||
|
||||
func _spawn_run_item() -> void:
|
||||
var chosen: String = ALL_ITEM_PATHS[randi() % ALL_ITEM_PATHS.size()]
|
||||
var pickup = RUN_ITEM_PICKUP.instantiate()
|
||||
pickup.item = load(chosen)
|
||||
pickup.position = Vector2.ZERO
|
||||
contents.add_child(pickup)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Door locking
|
||||
# ---------------------------------------------------------------------------
|
||||
func _update_door_locks() -> void:
|
||||
var is_combat := data.type == RoomData.RoomType.COMBAT \
|
||||
or data.type == RoomData.RoomType.BOSS
|
||||
var locked := data.visited and not data.cleared and is_combat
|
||||
for area in _doors.values():
|
||||
(area as Area2D).monitoring = not locked
|
||||
@@ -0,0 +1,9 @@
|
||||
class_name RoomData
|
||||
|
||||
enum RoomType { START, COMBAT, ITEM, SHOP, BOSS }
|
||||
|
||||
var type: RoomType = RoomType.COMBAT
|
||||
var grid_pos: Vector2i = Vector2i.ZERO
|
||||
var connections: Dictionary = {} # "up"/"down"/"left"/"right" -> Vector2i
|
||||
var visited: bool = false
|
||||
var cleared: bool = false
|
||||
@@ -0,0 +1,71 @@
|
||||
extends Node
|
||||
|
||||
var current_floor: int = 0
|
||||
var current_room_id: String = ""
|
||||
var run_active: bool = false
|
||||
|
||||
# --- Collected this run (lost on death) ---
|
||||
var run_items: Array[Resource] = []
|
||||
var card_packs_found: Array[Resource] = []
|
||||
|
||||
# --- Brought into the run (lost on death) ---
|
||||
var credits_brought_in: int = 0
|
||||
var buff_items_brought: Array[Resource] = []
|
||||
var npc_ids_on_run: Array[String] = []
|
||||
|
||||
# --- Floor-to-floor carry (not saved, only lives for one scene reload) ---
|
||||
var player_health_carry: float = -1.0 # -1 = no carry, use default full health
|
||||
|
||||
func start_run(brought_credits: int, brought_buffs: Array[Resource], brought_npc_ids: Array[String]) -> void:
|
||||
current_floor = 1
|
||||
current_room_id = ""
|
||||
run_active = true
|
||||
player_health_carry = -1.0
|
||||
run_items.clear()
|
||||
card_packs_found.clear()
|
||||
credits_brought_in = brought_credits
|
||||
buff_items_brought = brought_buffs.duplicate()
|
||||
npc_ids_on_run = brought_npc_ids.duplicate()
|
||||
EventBus.run_started.emit()
|
||||
|
||||
func end_run(player_survived: bool) -> void:
|
||||
run_active = false
|
||||
if player_survived:
|
||||
_apply_run_rewards()
|
||||
else:
|
||||
_apply_run_death()
|
||||
EventBus.run_ended.emit(player_survived)
|
||||
|
||||
func advance_floor() -> void:
|
||||
EventBus.floor_cleared.emit(current_floor)
|
||||
current_floor += 1
|
||||
|
||||
func collect_item(item: Resource) -> void:
|
||||
run_items.append(item)
|
||||
EventBus.item_collected.emit(item)
|
||||
|
||||
func collect_card_pack(pack: Resource) -> void:
|
||||
card_packs_found.append(pack)
|
||||
EventBus.card_pack_found.emit(pack)
|
||||
|
||||
func _apply_run_rewards() -> void:
|
||||
# Permanent: card packs go to collection
|
||||
for pack in card_packs_found:
|
||||
CardCollection.open_pack(pack)
|
||||
# Credits brought in are returned plus any earned during the run
|
||||
UpgradeManager.hub_credits += credits_brought_in
|
||||
# NPCs survive
|
||||
for npc_id in npc_ids_on_run:
|
||||
NPCManager.return_npc_to_hub(npc_id)
|
||||
|
||||
func _apply_run_death() -> void:
|
||||
# Everything brought in / found is lost
|
||||
run_items.clear()
|
||||
card_packs_found.clear()
|
||||
credits_brought_in = 0
|
||||
buff_items_brought.clear()
|
||||
# NPCs on run are lost
|
||||
for npc_id in npc_ids_on_run:
|
||||
NPCManager.lose_npc(npc_id)
|
||||
npc_ids_on_run.clear()
|
||||
EventBus.player_died.emit()
|
||||
@@ -0,0 +1,27 @@
|
||||
extends Node
|
||||
|
||||
const SAVE_PATH := "user://save.cfg"
|
||||
|
||||
func save() -> void:
|
||||
var data := ConfigFile.new()
|
||||
data.set_value("upgrades", "parts", UpgradeManager.get_save_data())
|
||||
data.set_value("upgrades", "hub_credits", UpgradeManager.hub_credits)
|
||||
data.set_value("cards", "collection", CardCollection.get_save_data())
|
||||
data.set_value("npcs", "registry", NPCManager.get_save_data())
|
||||
data.set_value("lore", "discovered", LoreManager.get_save_data())
|
||||
data.save(SAVE_PATH)
|
||||
|
||||
func load_save() -> void:
|
||||
if not FileAccess.file_exists(SAVE_PATH):
|
||||
return
|
||||
var data := ConfigFile.new()
|
||||
data.load(SAVE_PATH)
|
||||
UpgradeManager.load_save_data(data.get_value("upgrades", "parts", {}))
|
||||
UpgradeManager.hub_credits = data.get_value("upgrades", "hub_credits", 0)
|
||||
CardCollection.load_save_data(data.get_value("cards", "collection", {}))
|
||||
NPCManager.load_save_data(data.get_value("npcs", "registry", {}))
|
||||
LoreManager.load_save_data(data.get_value("lore", "discovered", []))
|
||||
|
||||
func delete_save() -> void:
|
||||
if FileAccess.file_exists(SAVE_PATH):
|
||||
DirAccess.remove_absolute(SAVE_PATH)
|
||||
@@ -0,0 +1,167 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout constants (screen space: 1920 x 1080)
|
||||
# ---------------------------------------------------------------------------
|
||||
const SCREEN_W := 1920.0
|
||||
const SCREEN_H := 1080.0
|
||||
const PANEL_W := 620.0
|
||||
const PANEL_H := 500.0
|
||||
const LINE_H := 36.0
|
||||
const LIST_PAD_X := 24.0
|
||||
const LIST_PAD_Y := 60.0
|
||||
|
||||
const BG_COLOR := Color(0.06, 0.07, 0.12, 0.94)
|
||||
const CURSOR_COLOR := Color(0.9, 0.85, 0.3, 1)
|
||||
const NORMAL_COLOR := Color(0.75, 0.75, 0.85, 1)
|
||||
const EQUIPPED_COLOR := Color(0.4, 0.9, 0.6, 1)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
var _items: Array = [] # Array[BodyPartData] — sorted by slot
|
||||
var _cursor: int = 0
|
||||
var _item_labels: Array = [] # Array[Label]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UI nodes (built once in _ready)
|
||||
# ---------------------------------------------------------------------------
|
||||
var _bg: Polygon2D
|
||||
var _title_lbl: Label
|
||||
var _hint_lbl: Label
|
||||
var _list_root: Node2D # parent for item labels (positioned in screen space)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boot
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
_build_static_ui()
|
||||
hide()
|
||||
|
||||
|
||||
func _build_static_ui() -> void:
|
||||
var cx := SCREEN_W / 2.0
|
||||
var cy := SCREEN_H / 2.0
|
||||
var hw := PANEL_W / 2.0
|
||||
var hh := PANEL_H / 2.0
|
||||
|
||||
# Background panel
|
||||
_bg = Polygon2D.new()
|
||||
_bg.polygon = PackedVector2Array([
|
||||
Vector2(-hw, -hh), Vector2(hw, -hh),
|
||||
Vector2( hw, hh), Vector2(-hw, hh),
|
||||
])
|
||||
_bg.color = BG_COLOR
|
||||
_bg.position = Vector2(cx, cy)
|
||||
add_child(_bg)
|
||||
|
||||
# Title
|
||||
_title_lbl = Label.new()
|
||||
_title_lbl.text = "— ARMORY —"
|
||||
_title_lbl.position = Vector2(cx - 60, cy - hh + 14)
|
||||
add_child(_title_lbl)
|
||||
|
||||
# Hint bar
|
||||
_hint_lbl = Label.new()
|
||||
_hint_lbl.text = "[↑↓] Navigate [Enter] Equip [Esc] Close"
|
||||
_hint_lbl.position = Vector2(cx - hw + LIST_PAD_X, cy + hh - 30)
|
||||
add_child(_hint_lbl)
|
||||
|
||||
# Container node for item labels
|
||||
_list_root = Node2D.new()
|
||||
_list_root.position = Vector2(cx - hw + LIST_PAD_X, cy - hh + LIST_PAD_Y)
|
||||
add_child(_list_root)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input
|
||||
# ---------------------------------------------------------------------------
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
|
||||
if event.is_action_pressed("ui_up"):
|
||||
_cursor = wrapi(_cursor - 1, 0, maxi(1, _items.size()))
|
||||
_refresh_labels()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event.is_action_pressed("ui_down"):
|
||||
_cursor = wrapi(_cursor + 1, 0, maxi(1, _items.size()))
|
||||
_refresh_labels()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event.is_action_pressed("ui_accept"):
|
||||
_equip_selected()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event.is_action_pressed("ui_cancel"):
|
||||
hide()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Open — call from hub.gd
|
||||
# ---------------------------------------------------------------------------
|
||||
func open() -> void:
|
||||
_rebuild_items()
|
||||
_cursor = 0
|
||||
_refresh_labels()
|
||||
show()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rebuild item list from UpgradeManager
|
||||
# ---------------------------------------------------------------------------
|
||||
func _rebuild_items() -> void:
|
||||
_items.clear()
|
||||
|
||||
for path: String in UpgradeManager.acquired_part_paths:
|
||||
if path == "":
|
||||
continue
|
||||
var part := load(path) as BodyPartData
|
||||
if part:
|
||||
_items.append(part)
|
||||
|
||||
# Sort by slot order so the list groups naturally
|
||||
var slot_order := UpgradeManager.SLOTS
|
||||
_items.sort_custom(func(a, b): return slot_order.find(a.slot) < slot_order.find(b.slot))
|
||||
|
||||
# Destroy old labels
|
||||
for lbl: Label in _item_labels:
|
||||
lbl.queue_free()
|
||||
_item_labels.clear()
|
||||
|
||||
# Create one label per item
|
||||
for i in _items.size():
|
||||
var lbl := Label.new()
|
||||
lbl.position = Vector2(0, i * LINE_H)
|
||||
_list_root.add_child(lbl)
|
||||
_item_labels.append(lbl)
|
||||
|
||||
|
||||
func _refresh_labels() -> void:
|
||||
for i in _item_labels.size():
|
||||
var part: BodyPartData = _items[i]
|
||||
var equipped_path: String = UpgradeManager.equipped_parts.get(part.slot, "")
|
||||
var is_equipped := equipped_path == part.resource_path
|
||||
var is_cursor := i == _cursor
|
||||
|
||||
var slot_tag := "[%s]" % part.slot.to_upper()
|
||||
var equip_tag := " ✓" if is_equipped else ""
|
||||
var lbl: Label = _item_labels[i]
|
||||
lbl.text = ("%s %s%s" % [slot_tag, part.display_name, equip_tag]).lstrip("")
|
||||
|
||||
if is_cursor:
|
||||
lbl.add_theme_color_override("font_color", CURSOR_COLOR)
|
||||
elif is_equipped:
|
||||
lbl.add_theme_color_override("font_color", EQUIPPED_COLOR)
|
||||
else:
|
||||
lbl.add_theme_color_override("font_color", NORMAL_COLOR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Equip the part under the cursor
|
||||
# ---------------------------------------------------------------------------
|
||||
func _equip_selected() -> void:
|
||||
if _items.is_empty():
|
||||
return
|
||||
var part: BodyPartData = _items[_cursor]
|
||||
UpgradeManager.equip_part(part)
|
||||
_refresh_labels()
|
||||
@@ -0,0 +1,195 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Heart geometry — two polygons per slot: background (dark) + fill (red)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Full heart, centered at origin, ~40 wide x 36 tall
|
||||
var HEART_FULL := PackedVector2Array([
|
||||
Vector2( 0, 18), Vector2(-15, 3), Vector2(-20, -6),
|
||||
Vector2(-15, -15), Vector2(-6, -18), Vector2(-2, -14),
|
||||
Vector2( 0, -11),
|
||||
Vector2( 2, -14), Vector2( 6, -18), Vector2(15, -15),
|
||||
Vector2( 20, -6), Vector2(15, 3),
|
||||
])
|
||||
|
||||
# Left half only — used for the half-heart fill
|
||||
var HEART_HALF := PackedVector2Array([
|
||||
Vector2( 0, 18), Vector2(-15, 3), Vector2(-20, -6),
|
||||
Vector2(-15, -15), Vector2(-6, -18), Vector2(-2, -14),
|
||||
Vector2( 0, -11),
|
||||
])
|
||||
|
||||
const FULL_COLOR := Color(0.90, 0.10, 0.10) # bright red
|
||||
const EMPTY_COLOR := Color(0.18, 0.04, 0.04) # dark maroon (background)
|
||||
|
||||
const HEART_SPACING := 52 # px between heart centers
|
||||
const ORIGIN := Vector2(32, 32) # center of first heart (screen px)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boss bar
|
||||
# ---------------------------------------------------------------------------
|
||||
const BOSS_BAR_W := 400.0
|
||||
const BOSS_BAR_H := 18.0
|
||||
const BOSS_BAR_POS := Vector2(280.0, 490.0) # top-left corner (960×540 viewport)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
# Each entry: { "bg": Polygon2D, "fill": Polygon2D }
|
||||
var _slots: Array = []
|
||||
var _credits_label: Label
|
||||
|
||||
# Boss bar nodes (null when not active)
|
||||
var _boss_bar_root: Node2D = null
|
||||
var _boss_bar_fill: Polygon2D = null
|
||||
var _boss_bar_label: Label = null
|
||||
var _boss_max_hp: float = 1.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boot
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
EventBus.player_health_changed.connect(_on_health_changed)
|
||||
EventBus.credits_changed.connect(_on_credits_changed)
|
||||
EventBus.boss_health_changed.connect(_on_boss_health_changed)
|
||||
EventBus.boss_died.connect(_on_boss_died)
|
||||
_build_credits_label()
|
||||
# Initialise once the scene is fully set up
|
||||
call_deferred("_init_from_player")
|
||||
|
||||
|
||||
func _init_from_player() -> void:
|
||||
var p = get_tree().get_first_node_in_group("player")
|
||||
if p:
|
||||
_rebuild(p.stats.max_health, p.stats.current_health)
|
||||
else:
|
||||
_rebuild(6.0, 6.0)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build / rebuild all heart slots
|
||||
# ---------------------------------------------------------------------------
|
||||
func _rebuild(max_hp: float, current_hp: float) -> void:
|
||||
for slot in _slots:
|
||||
slot["bg"].queue_free()
|
||||
slot["fill"].queue_free()
|
||||
_slots.clear()
|
||||
|
||||
var num_hearts := int(max_hp / 2.0)
|
||||
|
||||
for i in num_hearts:
|
||||
var center := ORIGIN + Vector2(i * HEART_SPACING, 0)
|
||||
|
||||
# Background — always visible, shows the "empty" container
|
||||
var bg := Polygon2D.new()
|
||||
bg.polygon = HEART_FULL
|
||||
bg.color = EMPTY_COLOR
|
||||
bg.position = center
|
||||
add_child(bg)
|
||||
|
||||
# Fill — overlaid on top; polygon shape changes per fill level
|
||||
var fill := Polygon2D.new()
|
||||
fill.polygon = HEART_FULL
|
||||
fill.color = FULL_COLOR
|
||||
fill.position = center
|
||||
add_child(fill)
|
||||
|
||||
_slots.append({"bg": bg, "fill": fill})
|
||||
|
||||
_update_display(current_hp)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Update fill polygons to reflect current health
|
||||
# ---------------------------------------------------------------------------
|
||||
func _update_display(current_hp: float) -> void:
|
||||
for i in _slots.size():
|
||||
var fill: Polygon2D = _slots[i]["fill"]
|
||||
var hp_in_slot := current_hp - i * 2.0
|
||||
|
||||
if hp_in_slot >= 2.0:
|
||||
fill.polygon = HEART_FULL
|
||||
fill.visible = true
|
||||
elif hp_in_slot >= 1.0:
|
||||
fill.polygon = HEART_HALF
|
||||
fill.visible = true
|
||||
else:
|
||||
fill.visible = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credits label
|
||||
# ---------------------------------------------------------------------------
|
||||
func _build_credits_label() -> void:
|
||||
_credits_label = Label.new()
|
||||
_credits_label.position = Vector2(16, 72)
|
||||
_credits_label.text = "SCRAP: %d" % UpgradeManager.hub_credits
|
||||
add_child(_credits_label)
|
||||
|
||||
func _on_credits_changed(new_total: int) -> void:
|
||||
_credits_label.text = "SCRAP: %d" % new_total
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal handler
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_health_changed(current_hp: float, max_hp: float) -> void:
|
||||
var num_hearts := int(max_hp / 2.0)
|
||||
if num_hearts != _slots.size():
|
||||
_rebuild(max_hp, current_hp)
|
||||
else:
|
||||
_update_display(current_hp)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boss health bar
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_boss_health_changed(current_hp: float, max_hp: float) -> void:
|
||||
if _boss_bar_root == null:
|
||||
_build_boss_bar(max_hp)
|
||||
_boss_max_hp = max_hp
|
||||
_update_boss_bar(current_hp)
|
||||
|
||||
func _on_boss_died() -> void:
|
||||
if _boss_bar_root != null:
|
||||
_boss_bar_root.queue_free()
|
||||
_boss_bar_root = null
|
||||
_boss_bar_fill = null
|
||||
_boss_bar_label = null
|
||||
|
||||
func _build_boss_bar(max_hp: float) -> void:
|
||||
_boss_max_hp = max_hp
|
||||
|
||||
_boss_bar_root = Node2D.new()
|
||||
_boss_bar_root.position = BOSS_BAR_POS
|
||||
add_child(_boss_bar_root)
|
||||
|
||||
# Name label
|
||||
_boss_bar_label = Label.new()
|
||||
_boss_bar_label.text = "THE SUPERVISOR"
|
||||
_boss_bar_label.position = Vector2(0, -22)
|
||||
_boss_bar_root.add_child(_boss_bar_label)
|
||||
|
||||
# Background
|
||||
var bg := Polygon2D.new()
|
||||
bg.polygon = PackedVector2Array([
|
||||
Vector2(0, 0), Vector2(BOSS_BAR_W, 0),
|
||||
Vector2(BOSS_BAR_W, BOSS_BAR_H), Vector2(0, BOSS_BAR_H),
|
||||
])
|
||||
bg.color = Color(0.15, 0.05, 0.05)
|
||||
_boss_bar_root.add_child(bg)
|
||||
|
||||
# Fill
|
||||
_boss_bar_fill = Polygon2D.new()
|
||||
_boss_bar_fill.polygon = PackedVector2Array([
|
||||
Vector2(0, 0), Vector2(BOSS_BAR_W, 0),
|
||||
Vector2(BOSS_BAR_W, BOSS_BAR_H), Vector2(0, BOSS_BAR_H),
|
||||
])
|
||||
_boss_bar_fill.color = Color(0.85, 0.1, 0.1)
|
||||
_boss_bar_root.add_child(_boss_bar_fill)
|
||||
|
||||
func _update_boss_bar(current_hp: float) -> void:
|
||||
if _boss_bar_fill == null:
|
||||
return
|
||||
var pct := clampf(current_hp / _boss_max_hp, 0.0, 1.0)
|
||||
var w := BOSS_BAR_W * pct
|
||||
_boss_bar_fill.polygon = PackedVector2Array([
|
||||
Vector2(0, 0), Vector2(w, 0),
|
||||
Vector2(w, BOSS_BAR_H), Vector2(0, BOSS_BAR_H),
|
||||
])
|
||||
@@ -0,0 +1,29 @@
|
||||
extends Resource
|
||||
class_name BodyPartData
|
||||
|
||||
@export var slot: String = "" # "head" | "torso" | "left_arm" | "right_arm" | "legs"
|
||||
@export var display_name: String = ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stat modifiers — additive bonuses stacked across all equipped parts.
|
||||
# Field names mirror the stat keys used in player_stats.gd.
|
||||
# ---------------------------------------------------------------------------
|
||||
@export var mod_speed: float = 0.0
|
||||
@export var mod_max_health: float = 0.0
|
||||
@export var mod_damage: float = 0.0
|
||||
@export var mod_fire_rate: float = 0.0
|
||||
@export var mod_range: float = 0.0
|
||||
@export var mod_proj_speed: float = 0.0 # flat delta; e.g. -104 = -20% of base 520
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scatter (left arm slot)
|
||||
# mod_scatter_count > 0 enables multi-shot spread from this part
|
||||
# ---------------------------------------------------------------------------
|
||||
@export var mod_scatter_count: int = 0
|
||||
@export var mod_scatter_spread_deg: float = 0.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shield Projector (left arm slot)
|
||||
# Active block on cooldown — absorbs one hit per activation
|
||||
# ---------------------------------------------------------------------------
|
||||
@export var shield_projector: bool = false
|
||||
@@ -0,0 +1,11 @@
|
||||
extends Area2D
|
||||
|
||||
var part: BodyPartData = null
|
||||
|
||||
func _ready() -> void:
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if body.is_in_group("player") and part != null:
|
||||
UpgradeManager.acquire_part(part)
|
||||
queue_free()
|
||||
@@ -0,0 +1,63 @@
|
||||
extends Node
|
||||
|
||||
const SLOTS: Array[String] = ["head", "torso", "left_arm", "right_arm", "legs"]
|
||||
|
||||
var hub_credits: int = 0
|
||||
|
||||
# slot -> BodyPart resource path (empty string = default/base part)
|
||||
var equipped_parts: Dictionary = {
|
||||
"head": "",
|
||||
"torso": "",
|
||||
"left_arm": "",
|
||||
"right_arm": "",
|
||||
"legs": "",
|
||||
}
|
||||
|
||||
# All permanently acquired part resource paths
|
||||
var acquired_part_paths: Array[String] = []
|
||||
|
||||
func equip_part(part: Resource) -> void:
|
||||
var slot: String = part.slot
|
||||
if slot not in SLOTS:
|
||||
push_error("UpgradeManager: invalid slot '%s'" % slot)
|
||||
return
|
||||
equipped_parts[slot] = part.resource_path
|
||||
EventBus.body_part_equipped.emit(part, slot)
|
||||
|
||||
func acquire_part(part: Resource) -> void:
|
||||
if part.resource_path not in acquired_part_paths:
|
||||
acquired_part_paths.append(part.resource_path)
|
||||
EventBus.body_part_found.emit(part)
|
||||
|
||||
func get_equipped_part(slot: String) -> Resource:
|
||||
var path: String = equipped_parts.get(slot, "")
|
||||
if path != "":
|
||||
return load(path)
|
||||
return null
|
||||
|
||||
func get_stat_modifier(stat: String) -> float:
|
||||
var total := 0.0
|
||||
for slot in SLOTS:
|
||||
var part := get_equipped_part(slot)
|
||||
if part == null:
|
||||
continue
|
||||
match stat:
|
||||
"speed": total += part.mod_speed
|
||||
"max_health": total += part.mod_max_health
|
||||
"damage": total += part.mod_damage
|
||||
"fire_rate": total += part.mod_fire_rate
|
||||
"range": total += part.mod_range
|
||||
"proj_speed": total += part.mod_proj_speed
|
||||
return total
|
||||
|
||||
func get_save_data() -> Dictionary:
|
||||
return {
|
||||
"equipped": equipped_parts.duplicate(),
|
||||
"acquired": acquired_part_paths.duplicate(),
|
||||
}
|
||||
|
||||
func load_save_data(data: Dictionary) -> void:
|
||||
var saved_equipped: Dictionary = data.get("equipped", {})
|
||||
for slot in SLOTS:
|
||||
equipped_parts[slot] = saved_equipped.get(slot, "")
|
||||
acquired_part_paths = data.get("acquired", [])
|
||||
Reference in New Issue
Block a user