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:
Station 0 Dev
2026-03-04 12:33:05 -05:00
commit 342175f4c3
66 changed files with 4683 additions and 0 deletions
+23
View File
@@ -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
View File
@@ -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 (35 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 | **LowMedium** | 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
View File
@@ -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 13 | 12 | Drifters only |
| Floor 1, rooms 4+ | 23 | Drifters + 1 Repeater |
| Floor 2 | 24 | Mix of all three types |
| Floor 3 (pre-boss) | 23 | 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 | 45 rooms | Learn the rhythm. One item. Maybe find the shop. First Repeater feels like a puzzle. |
| Floor 2 | 56 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**: 2030 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
View File
@@ -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 17)
- 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.
+82
View File
@@ -0,0 +1,82 @@
# Production Notes — Backing, Team & Funding
---
## Solo Dev Reality
Possible, but 58 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
**34 people | 23 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 110 (full roguelike + card game with AI + lore + NPCs) without online PvP.
---
## Comfortable Team
**56 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.
+91
View File
@@ -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]
+9
View File
@@ -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
+9
View File
@@ -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
+9
View File
@@ -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
+9
View File
@@ -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
+11
View File
@@ -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
+12
View File
@@ -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
+11
View File
@@ -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
+9
View File
@@ -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
+11
View File
@@ -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
+11
View File
@@ -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
+43
View File
@@ -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
+34
View File
@@ -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")
+14
View File
@@ -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")
+34
View File
@@ -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")
+34
View File
@@ -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")
+14
View File
@@ -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")
+34
View File
@@ -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")
+34
View File
@@ -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")
+20
View File
@@ -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")]
+16
View File
@@ -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="."]
+14
View File
@@ -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")
+61
View File
@@ -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")
+14
View File
@@ -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")
+17
View File
@@ -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")]
+16
View File
@@ -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="."]
+30
View File
@@ -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)
+6
View File
@@ -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")
+6
View File
@@ -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")
+18
View File
@@ -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)
+33
View File
@@ -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", [])
+136
View File
@@ -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)
+17
View File
@@ -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()
+79
View File
@@ -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)
+76
View File
@@ -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)
+44
View File
@@ -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))
+136
View File
@@ -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)
+339
View File
@@ -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)
+57
View File
@@ -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
+51
View File
@@ -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")
+133
View File
@@ -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()
+258
View File
@@ -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)
+46
View File
@@ -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
+35
View File
@@ -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)
+20
View File
@@ -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))
+72
View File
@@ -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
+110
View File
@@ -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)
+144
View File
@@ -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()
+89
View File
@@ -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)
+92
View File
@@ -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)
+118
View File
@@ -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
+332
View File
@@ -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
+9
View File
@@ -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
+71
View File
@@ -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()
+27
View File
@@ -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)
+167
View File
@@ -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()
+195
View File
@@ -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),
])
+29
View File
@@ -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
+11
View File
@@ -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()
+63
View File
@@ -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", [])