Sync working project files - hub layout, boss roster, doc updates

This commit is contained in:
2026-03-05 15:50:37 -05:00
parent 6ce4531b76
commit f1b4820307
47 changed files with 1476 additions and 402 deletions
+47 -10
View File
@@ -18,6 +18,15 @@ You play as a robot aboard the last surviving human monitoring station, orbiting
**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 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 Secret Creature — Late-Game Narrative Arc**
Deep in the station, in an area the player eventually discovers, is a living creature from the old world. Humanity was conducting classified research on it — the data was never released, never archived, never shared with the monitoring robots. No NPC has ever seen it. No card exists for it. The Rogue Robot Compendium has no entry. It is completely unknown — to everyone except the shopkeeper, who heard things through the counter over thousands of years and may hint at it in their lore drops before the player ever finds it.
The interaction is non-verbal. The player cannot fight it, cannot speak to it. The goal is to understand what it needs and help it reach a state of happiness — through observation, environment, and action. The specific mechanic is TBD (the creature design is undecided). The emotional logic: the player has spent the entire game learning about extinct life through cards and data. This is the first time they encounter something still alive.
On completion of the arc, the creature comes to live in the hub. It joins the community of survivors — robots who remember, a shopkeeper who observed, and now one living thing from the world they've all been mourning.
The player receives a **1/1 card** of the creature — the only one in existence, made by the player after the interaction. Whether to use it in a deck is entirely the player's choice. It is mechanically valid but emotionally weighted. Its flavor text is written by the player's robot, not from archival data — the first card ever created from a living encounter rather than a memory.
**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. **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.
--- ---
@@ -207,21 +216,49 @@ A rare run item. When used, it activates a homing signal that allows a lost NPC
A multi-room physical space. Feels like an abandoned station — dark, dusty, only partially functional. The player navigates between rooms on foot. A multi-room physical space. Feels like an abandoned station — dark, dusty, only partially functional. The player navigates between rooms on foot.
**Hub layout — 7 rooms**
```
[ Control ][ (empty) ][ (empty) ]
[ Armory ][ Staging ][ Shop ]
[ Practice ][ Cafeteria ][ Trophy Room ]
```
Grid coordinates (col, row): Control=(-1,-1), Armory=(-1,0), Staging=(0,0), Shop=(1,0), Practice=(-1,1), Cafeteria=(0,1), Trophy=(1,1).
Run entrance is on the **top wall** of the Staging Area (no room above it — door leads directly to run).
| Room | Purpose | | Room | Purpose |
|------|---------| |------|---------|
| **Control Room** | Central hub area, lore terminals with tapes, overview of station status | | **Staging Area** | Run entrance (top wall). ATM deposit machine. Talk to NPCs before a run. |
| **Armory** | Browse and equip body parts, view part stats/trade-offs | | **Cafeteria** | Card game tables. Rescued NPC robots sit here and can be challenged. |
| **Cafeteria** | Card game tables, NPC opponents sit here when saved | | **Armory / Storage** | Equip body parts, browse acquired parts. Card storage and (later) consumable storage. Combined inventory hub. |
| **Shop** | Buy cosmetics, card packs with accumulated currency | | **Shop** | Buy cosmetics, card packs, curated items. **Locked until shopkeeper NPC is rescued.** Before rescue: doors open, lights off, "NO KEEPER" sign, nothing to buy. |
| **Trophy Room** | Collectibles and cosmetics unlocked during runs displayed here | | **Control Room** | Lore terminals, station status overview, Rogue Robot Compendium terminal. |
| **Staging Area** | Final room before run entrance — equip/adjust loadout, talk to NPCs going with you | | **Trophy Room** | Collectibles and cosmetics unlocked during runs. Card Compendium terminal. |
| **Rogue Robot Compendium** | Terminal in hub (Control Room or Trophy Room) displaying all logged corrupted robots encountered | | **Practice Room** | Spawn enemies from your Enemy Compendium to practice their attack patterns. Empty until at least one enemy type has been killed. |
| **Card Compendium** | Hub terminal displaying all cards ever seen or collected, organized by zone series |
**Shopkeeper NPC**
The shopkeeper is a found NPC — discovered in a run and rescued like any other robot. They are not present at game start.
- Before rescue: shop room is physically accessible but non-functional. Rundown aesthetic, "NO KEEPER" sign visible.
- After rescue: shopkeeper moves in, shop becomes fully functional.
- Shop upgrades by bringing the shopkeeper on escort runs — standard escort risk applies (shopkeeper can be lost if the run goes badly).
- The shopkeeper plays the card game. Their deck is commerce/trade-themed — quirky, mechanically clever, reflects that they've spent thousands of years studying the cards as items to sell rather than as creatures to appreciate. They exploit game mechanics in ways other NPCs don't. High skill ceiling opponent.
- **Lore profile: wide but shallow.** The shopkeeper has been stationary for thousands of years running the shop — every robot in the station passed through at some point. They've overheard things, noticed what people were buying before they disappeared, watched the station decline from behind a counter. Their lore drops are ambient and observational: fragments, rumors, second-hand accounts. Interesting and often surprising, but never deep — they were never out there. They know a lot about what happened to everyone else without fully understanding what any of it meant.
**Practice Room — Rogue Robot Compendium integration + Repair**
The practice room pulls directly from the Rogue Robot Compendium. When the player kills an enemy, that type's entry populates in the Compendium (lore + original designation). That same entry makes the enemy type available to spawn in the practice room. One system, two outputs: narrative log and freeform training. No door locking, no stakes — player can exit any time.
The practice room requires repair to reach full functionality:
- **Base repair item** — found in the early game world (not a drop, a placed item). Repairing with it unlocks standard enemy simulation (Drifters, Repeaters, Anchors, and any non-boss enemies unlocked in the Compendium).
- **Boss simulation components** — each boss has its own simulation component. Chance drop after the *first* time the player defeats that boss. Finding and installing it unlocks simulation for that specific boss. New players face bosses without practice first — the mechanic rewards experience, not preparation.
**| Rogue Robot Compendium** | Terminal (Control Room). Narrative log of every corrupted robot encountered — original designation, zone assignment, what happened to them. |
**| Card Compendium** | Terminal (Trophy Room). Every card ever seen or acquired, organized by zone series. Includes flavor text and artwork. |
The hub evolves as the player progresses: The hub evolves as the player progresses:
- More lore terminals light up - More lore terminals light up
- NPCs populate the cafeteria - NPCs populate the cafeteria
- Trophy room fills in - Trophy room fills in
- Shop unlocks when shopkeeper is rescued
- Practice room populates as enemy types are encountered
- Certain rooms may unlock new areas as story progresses - Certain rooms may unlock new areas as story progresses
--- ---
@@ -547,9 +584,9 @@ signal view_switching_to_2d
| 1 | Player: movement, shooting placeholder, modular body (stat system) | | 1 | Player: movement, shooting placeholder, modular body (stat system) |
| 2 | Single test room: enemies, basic combat, drops | | 2 | Single test room: enemies, basic combat, drops |
| 3 | Room generation: multi-room floors, door system, room types | | 3 | Room generation: multi-room floors, door system, room types |
| 4 | Hub world: all 6 rooms, physical navigation | | 4 | Hub world: all 7 rooms, physical navigation |
| 5 | Permanent upgrade system: body parts found in runs, survive to keep | | 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 | | 6 | Full run loop: 3 floors, 3 bosses (Warden → Relay → Hivemind), death screen, victory screen, return to hub with rewards |
| 7 | Combat depth: BOI-style item variety, status effects, projectile behaviors | | 7 | Combat depth: BOI-style item variety, status effects, projectile behaviors |
| 8 | NPC system: find in runs, bring to hub, card opponents, lore dialogue | | 8 | NPC system: find in runs, bring to hub, card opponents, lore dialogue |
| 9 | Card game: rules engine, deck builder, AI opponents | | 9 | Card game: rules engine, deck builder, AI opponents |
+132 -27
View File
@@ -1,6 +1,6 @@
# Station 0 — Content Spec (Vertical Slice) # Station 0 — Content Spec (Vertical Slice)
Last updated: 2026-03-03 Last updated: 2026-03-05
Scope: Vertical slice only. All content here is confirmed for VS unless marked **[OPTIONAL]**. Scope: Vertical slice only. All content here is confirmed for VS unless marked **[OPTIONAL]**.
--- ---
@@ -39,36 +39,87 @@ All enemies are corrupted Adaptive Maintenance Units — same chassis class as t
--- ---
## Boss: THE SUPERVISOR ## Boss: THE WARDEN *(Floor 1)*
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. A containment unit whose crowd-control protocol has turned inward — it is now attempting to contain the entire room, including the player. Slow and methodical. The room itself becomes the threat.
**Room**: Large. No environmental hazards. Preceded by a charging station in the hallway (full HP restore before entry). **Room**: Large. No environmental hazards on entry. Preceded by a charging station (full HP restore before entry).
### Phase 1 (100%55% HP) ### Movement
- Patrols a fixed rectangular path around the room. Patrols the room perimeter in a slow, continuous circuit. Never crosses the center. Fully predictable path — the danger comes from what it leaves behind, not from the boss itself.
- 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) ### Attack 1 — Barrier Sweep
- Patrol pattern collapses. Supervisor begins tracking the player directly. Fires a wall of slow projectiles horizontally across the room. One or two gaps the player must find and move through. Telegraphed by a brief charge-up flash before firing.
- 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) ### Attack 2 — Mine Drop
- Supervisor freezes mid-room. Broadcasts a distress signal (visual + audio cue). Drops a stationary proximity mine at its current patrol position each cycle. Mines persist until cleared or end of fight. Over time the room fills with no-go zones — the safe path shrinks. Player must manage the accumulating hazard while dealing damage.
- 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 ### Post-boss
- Body part drop (guaranteed). - Body part drop (guaranteed).
- Explicit early exit prompt offered. - Floor 2 portal opens.
- Hub door opens.
---
## Boss: THE RELAY *(Floor 2)*
A communications hub unit whose broadcast function corrupted into something aggressive. Erratic, unpredictable positioning. Forces the player to stay mobile and read the room constantly.
**Room**: Large. No environmental hazards on entry. Preceded by a charging station (full HP restore before entry).
### Movement
Anchors near room center but teleports to a random position every 810 seconds. No warning before teleport — position resets without notice. Punishes clustering near the boss.
### Attack 1 — Burst Transmission
On each teleport arrival, fires an 8-directional projectile burst. Player caught nearby takes damage before they can react. Creates a strong incentive to stay at mid-range and keep moving.
### Attack 2 — Signal Drifters
Periodically broadcasts a signal that summons 23 Drifters. Not full waves — just enough to split attention. Relay continues teleporting and bursting while adds are alive.
### Post-boss
- Body part drop (guaranteed).
- Floor 3 portal opens.
---
## Boss: THE HIVEMIND *(Floor 3 — Final Boss)*
What the corruption becomes when it fully realizes itself. Individual units lose their original purpose and merge into a single machine that was never meant to exist.
**Room**: Large. Largest boss room in the run. Preceded by a charging station (full HP restore before entry).
### Phase 1 — Assembly
No central target yet. Corrupted units flood the room in waves — Drifters, Repeaters, Anchors. As each one dies, it is pulled toward the center and physically joins the assembling structure (visual indicator builds up). Player must clear everything they've learned across floors 1 and 2 simultaneously, under pressure. The Hivemind core cannot be damaged during this phase.
### Phase 2 — Active
The assembled machine comes online. Attacks are recognizable — a spread shot pattern like the Repeater, a slow sweeping area attack like the Warden, drifting projectiles like the Drifter. Familiar in shape, dangerous in scale. Player knows what each attack is but they are now coming from a single powerful source at elevated intensity.
### Phase 3 — Unique
The Hivemind stops mimicking its components and expresses something none of them could do individually. Attacks to design:
- Rotating beam that forces constant movement around the room
- Room-wide pulse that pushes the player toward walls
- Tracking projectiles that home slowly but persistently
Phase 3 begins below 30% HP. Visual state change — structure begins to crack and glow.
### Post-boss
- Body part drop (guaranteed).
- Victory screen triggers. Run complete.
- All remaining run credits returned to wallet on hub return.
**Emotional intent**: The Hivemind is not a villain. It's what happens when nothing is left to maintain and the only function remaining is persistence. Killing it ends the run — but the station is still empty.
---
## Shelved: THE SUPERVISOR
Built and functional. Shelved from VS because its movement and attack profile (patrol + beam + adds) overlaps too closely with the Warden at the same tier.
Candidate uses post-VS:
- Named mini-boss variant in a sealed room
- Reintroduced as a late-floor elite enemy on higher difficulty
- Escort mission antagonist
The Supervisor's WALL-E freeze/distress moment should be preserved whenever it returns — that beat is worth keeping.
--- ---
@@ -154,7 +205,13 @@ One item on a pedestal. No enemies. Safe. 20% chance to also contain a lore term
Unmanned. No NPC keeper. Sells 2 items + 1 consumable at fixed scrap prices. No haggling. Purely transactional — all NPC personality lives in the hub. 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** **Boss room**
See Boss section above. One boss per floor. Each preceded by a charging station room (no enemies, full HP restore). Boss rooms are the largest rooms in the run.
| Floor | Boss |
|---|---|
| Floor 1 | The Warden |
| Floor 2 | The Relay |
| Floor 3 | The Hivemind (final) |
--- ---
@@ -178,14 +235,62 @@ Both hazard types included in VS.
| Floor | Room count | Beat | | Floor | Room count | Beat |
|---|---|---| |---|---|---|
| Floor 1 | 45 rooms | Learn the rhythm. One item. Maybe find the shop. First Repeater feels like a puzzle. | | Floor 1 | 45 rooms + boss | Learn the rhythm. One item. Maybe find the shop. First Repeater feels like a puzzle. Warden fight — room fills with mines, learn to manage space. |
| Floor 2 | 56 rooms | Mixed threats. Find a body part. Build takes shape. One tense near-death moment. | | Floor 2 | 56 rooms + boss | Mixed threats. Find a body part. Build takes shape. One tense near-death moment. Relay fight — erratic, keeps moving, adds split focus. |
| Floor 3 | 2 rooms + boss | Charging station. Supervisor fight. The freeze. Kill it. Hub door opens. | | Floor 3 | 23 rooms + boss | Room composition tightens. Anchors in tighter spaces. Charging station. Hivemind — three phases, everything converges. Victory. |
**Target run time**: 2030 minutes. **Target run time**: 2030 minutes.
--- ---
---
## Post-VS Enemies
---
### SUICIDE BOMBER
**Corruption type**: Self-destruct loop
**Movement**: Faster than all standard enemies. Pathfinds directly to the player.
**Attack**: On close approach, stops and begins blinking — blink frequency ramps up. Explodes on trigger. AoE damages both the player AND other enemies in range.
**Threat level**: High
**Design role**: Forces constant movement. Punishes clustering with other enemies. Rewards luring — a bomber detonating in a group of Anchors or Repeaters creates player-controlled room clearing. The ramping blink gives a readable window to escape or detonate intentionally.
---
### CHOMPER
**Corruption type**: Aggression loop
**Movement**: Frog-like dash cadence — dash toward player, brief recovery pause, dash again. Chains dashes together rather than returning to idle between them.
**Attack**: Melee bite on contact during the dash. No projectiles.
**Recovery window**: Brief stall after each dash, especially on a miss. Readable and punishable — the skill floor is dodging the dash; the skill ceiling is baiting a miss and punishing the recovery.
**Threat level**: Medium-High
**Design role**: Teaches dash-reading and dodge timing. Relentless once it starts chaining. High pressure in small rooms. Pairs well with Anchor — the Anchor pins a corner while the Chomper forces the player into it.
---
## Post-VS Body Parts
---
### HAMMER ARM *(Right Arm — damage slot)*
**Effect**: Replaces the ranged attack entirely with a charged melee swing. Hold the shoot key to charge — arm winds up while held. Release to swing in the direction of the last movement input. High damage, small AoE on impact.
**Charge behavior**: Movement speed is reduced while charging — not stopped, but noticeably slower. The player can still reposition but not freely. This makes the commit real: you close distance, start charging, and become a slower target while you do it.
**Trade-off**: High damage output and AoE but requires closing distance, committing to a charge through reduced movement, and reading your position before releasing. Full playstyle conversion — this is a melee build piece, not a modifier.
**Synergies**:
- **Arc Pulse (left arm)**: Gives the melee build a ranged utility option on cooldown — the two together define a coherent melee archetype.
- **Static Discharge**: Taking damage in melee range triggers the AoE burst — high risk, high chaos.
- **Rust Coat**: The damage floor makes surviving close-range hits more viable.
- **Coolant Leak**: Getting hit while swinging lays a slick — passive room control from a melee build.
---
### ARC PULSE *(Left Arm — utility slot)*
**Effect**: Separate active ability (not tied to primary fire input). Fires a curved, boomerang-trajectory arc shot — slow-moving, pierces through multiple enemies, covers a wide area. Long cooldown. Does not replace or interact with the primary attack unless Hammer Arm is also equipped.
**Trade-off**: No offensive change to primary fire. The arc shot is powerful and area-covering but available infrequently.
**Design role**: Standalone utility — adds ranged pressure on cooldown. Becomes the primary ranged option when paired with Hammer Arm, defining the melee archetype's attack kit.
---
## Open questions (not resolved, revisit post-VS) ## Open questions (not resolved, revisit post-VS)
- Zone-specific enemy variants — do hazard types change per zone, or stay consistent? - Zone-specific enemy variants — do hazard types change per zone, or stay consistent?
+1 -1
View File
@@ -107,7 +107,7 @@ 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. 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. Every card in the game is an act of preservation. The robots who built these series had spent thousands of years alongside the archived data — these creatures became real to them through that proximity, and the card game came from that genuine affinity. The player inherits that connection 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. **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.
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" fill="#1a1a2e"/>
<rect x="44" y="20" width="40" height="30" rx="4" fill="#4fc3f7"/>
<rect x="30" y="54" width="68" height="44" rx="4" fill="#4fc3f7"/>
<rect x="20" y="60" width="10" height="20" rx="2" fill="#81d4fa"/>
<rect x="98" y="60" width="10" height="20" rx="2" fill="#81d4fa"/>
<rect x="40" y="98" width="16" height="20" rx="2" fill="#81d4fa"/>
<rect x="72" y="98" width="16" height="20" rx="2" fill="#81d4fa"/>
<circle cx="54" cy="36" r="5" fill="#1a1a2e"/>
<circle cx="74" cy="36" r="5" fill="#1a1a2e"/>
</svg>

After

Width:  |  Height:  |  Size: 657 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dliwbojwv4mad"
path="res://.godot/imported/icon.svg-56083ea2a1f1a4f1e49773bdc6d7826c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icon.svg"
dest_files=["res://.godot/imported/icon.svg-56083ea2a1f1a4f1e49773bdc6d7826c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+10
View File
@@ -0,0 +1,10 @@
[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 = "Targeting Spike"
mod_proj_speed = 208.0
mod_range = 100.0
+10
View File
@@ -0,0 +1,10 @@
[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 = "Wide-Angle Lens"
mod_range = 120.0
mod_proj_speed = -104.0
@@ -0,0 +1,11 @@
[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 = "Scatter Emitter"
mod_scatter_count = 3
mod_scatter_spread_deg = 30.0
mod_damage = -1.4
@@ -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 = "Shield Projector"
shield_projector = true
@@ -0,0 +1,10 @@
[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 = "Heavy Emitter"
mod_damage = 2.1
mod_fire_rate = -1.4
@@ -0,0 +1,10 @@
[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 = "Rapid Emitter"
mod_fire_rate = 1.75
mod_damage = -1.05
@@ -0,0 +1,10 @@
[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 = "Lightweight Frame"
mod_speed = 40.0
mod_max_health = -1.0
@@ -0,0 +1,10 @@
[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 Chassis"
mod_max_health = 2.0
mod_speed = -20.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 = "Coolant Leak"
description = "Taking damage leaves a slick trail. Enemies that touch it are slowed for 2s."
effect = 5
+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 = "Fragmented Map"
description = "Each time you clear a combat room, one unexplored room on the floor map is revealed."
effect = 10
+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 = "Memory Spike"
description = "First projectile fired in each room deals 3x damage."
effect = 7
+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 = "Overclock Module"
description = "Fire rate +40%. Every 3rd shot deals 0 damage."
effect = 11
+10
View File
@@ -0,0 +1,10 @@
[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 = "Patch Kit"
description = "Consumed on pickup: restore 2 HP immediately."
effect = 4
heal_on_pickup = 2.0
+10
View File
@@ -0,0 +1,10 @@
[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 = "Rust Coat"
description = "Reduce all incoming damage by 1 (minimum 1)."
effect = 8
damage_reduction = 1.0
+10
View File
@@ -0,0 +1,10 @@
[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 = "Scrap Magnet"
description = "Scrap tokens auto-collect within 3 tiles."
effect = 6
magnet_radius = 192.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 = "Static Discharge"
description = "On taking damage, emit a short-range AoE burst (1 tile radius)."
effect = 9
discharge_radius = 64.0
discharge_damage = 1.0
+4
View File
@@ -36,6 +36,10 @@ window/size/viewport_width=1920
window/size/viewport_height=1080 window/size/viewport_height=1080
window/stretch/mode="canvas_items" window/stretch/mode="canvas_items"
[dotnet]
project/assembly_name="Station"
[rendering] [rendering]
textures/canvas_textures/default_texture_filter=0 textures/canvas_textures/default_texture_filter=0
+4 -1
View File
@@ -1,9 +1,10 @@
[gd_scene load_steps=5 format=3] [gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://scripts/hub/hub.gd" id="1"] [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/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/hud.tscn" id="3"]
[ext_resource type="PackedScene" path="res://scenes/ui/armory_ui.tscn" id="4"] [ext_resource type="PackedScene" path="res://scenes/ui/armory_ui.tscn" id="4"]
[ext_resource type="PackedScene" path="res://scenes/ui/atm_ui.tscn" id="5"]
[node name="Hub" type="Node2D"] [node name="Hub" type="Node2D"]
script = ExtResource("1") script = ExtResource("1")
@@ -18,3 +19,5 @@ zoom = Vector2(2, 2)
[node name="HUD" parent="." instance=ExtResource("3")] [node name="HUD" parent="." instance=ExtResource("3")]
[node name="ArmoryUI" parent="." instance=ExtResource("4")] [node name="ArmoryUI" parent="." instance=ExtResource("4")]
[node name="AtmUI" parent="." instance=ExtResource("5")]
+4 -1
View File
@@ -1,8 +1,9 @@
[gd_scene load_steps=4 format=3] [gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://scripts/rooms/floor.gd" id="1"] [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/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/hud.tscn" id="3"]
[ext_resource type="PackedScene" path="res://scenes/ui/pause_menu.tscn" id="4"]
[node name="Floor" type="Node2D"] [node name="Floor" type="Node2D"]
script = ExtResource("1") script = ExtResource("1")
@@ -15,3 +16,5 @@ zoom = Vector2(2, 2)
[node name="Player" parent="." instance=ExtResource("2")] [node name="Player" parent="." instance=ExtResource("2")]
[node name="HUD" parent="." instance=ExtResource("3")] [node name="HUD" parent="." instance=ExtResource("3")]
[node name="PauseMenu" parent="." instance=ExtResource("4")]
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://atm_ui"]
[ext_resource type="Script" path="res://scripts/ui/atm_ui.gd" id="1"]
[node name="AtmUI" type="CanvasLayer"]
script = ExtResource("1")
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://pause_menu"]
[ext_resource type="Script" path="res://scripts/ui/pause_menu.gd" id="1"]
[node name="PauseMenu" type="CanvasLayer"]
script = ExtResource("1")
+50 -78
View File
@@ -1,93 +1,95 @@
extends CharacterBody2D extends EnemyBase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ANCHOR — Structural integrity corruption # ANCHOR — Structural integrity corruption
# Pathfinds to nearest wall, locks in. Fires slow homing projectiles. # Drifts to a random floor point, anchors for a random duration while firing
# Does not move once anchored. # homing projectiles, then picks a new point. Invincible while drifting.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
const ENEMY_PROJECTILE = preload("res://scenes/enemies/enemy_projectile.tscn") const ENEMY_PROJECTILE = preload("res://scenes/enemies/enemy_projectile.tscn")
enum State { SEEKING, ANCHORED } enum State { DRIFTING, 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) # Room interior half-extents (960×540 room, 32px walls, 16px gap buffer)
const INNER_HALF_X := 432.0 const INNER_HALF_X := 432.0
const INNER_HALF_Y := 222.0 const INNER_HALF_Y := 222.0
# Margin from walls so it stays on the floor interior
const FLOOR_MARGIN := 80.0
var _player: Node2D = null const SHOT_COOLDOWN := 2.2
var _state: State = State.SEEKING const ANCHOR_TIME_MIN := 3.0
var _target_wall: Vector2 const ANCHOR_TIME_MAX := 6.0
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 var _state: State = State.DRIFTING
var _target_pos: Vector2
var _shot_timer: float = 1.0
var _anchor_timer: float = 0.0
func _ready() -> void: func _ready() -> void:
add_to_group("enemies") max_health = 40.0
$ContactArea.body_entered.connect(_on_contact_entered) current_health = 40.0
_compute_wall_target() speed = 95.0
contact_damage = 0.5
super._ready()
_pick_floor_point()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Find the nearest wall face and set it as the target # Pick a random point within the room interior
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _compute_wall_target() -> void: func _pick_floor_point() -> void:
var room_center: Vector2 = (get_parent() as Node2D).global_position var room_center: Vector2 = (get_parent() as Node2D).global_position
var lp: Vector2 = global_position - room_center var rx: float = randf_range(-INNER_HALF_X + FLOOR_MARGIN, INNER_HALF_X - FLOOR_MARGIN)
var ry: float = randf_range(-INNER_HALF_Y + FLOOR_MARGIN, INNER_HALF_Y - FLOOR_MARGIN)
var d_left: float = lp.x - (-INNER_HALF_X) _target_pos = room_center + Vector2(rx, ry)
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: func _physics_process(delta: float) -> void:
_find_player() _find_player()
_tick_contact(delta) _tick_contact(delta)
_tick_flash(delta) _tick_flash(delta)
_tick_slow(delta)
match _state: match _state:
State.SEEKING: State.DRIFTING:
_do_seek() _do_seek()
State.ANCHORED: State.ANCHORED:
_do_anchored(delta) _do_anchored(delta)
velocity *= _slow_factor
move_and_slide() move_and_slide()
func _find_player() -> void: # ---------------------------------------------------------------------------
if _player == null or not is_instance_valid(_player): # Invincible while drifting
_player = get_tree().get_first_node_in_group("player") # ---------------------------------------------------------------------------
func take_damage(amount: float) -> void:
if _state == State.DRIFTING:
return
super.take_damage(amount)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Move toward the nearest wall, then lock in # Drift toward target floor point, then anchor
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _do_seek() -> void: func _do_seek() -> void:
var diff := _target_wall - global_position var diff := _target_pos - global_position
if diff.length() < 12.0: if diff.length() < 12.0:
velocity = Vector2.ZERO velocity = Vector2.ZERO
_state = State.ANCHORED _state = State.ANCHORED
_shot_timer = SHOT_COOLDOWN * 0.5
_anchor_timer = randf_range(ANCHOR_TIME_MIN, ANCHOR_TIME_MAX)
else: else:
velocity = diff.normalized() * speed velocity = diff.normalized() * speed
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stationary — fire slow homing projectiles at the player # Anchored — fire homing projectiles; leave when anchor_timer expires
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _do_anchored(delta: float) -> void: func _do_anchored(delta: float) -> void:
velocity = Vector2.ZERO velocity = Vector2.ZERO
_anchor_timer -= delta
if _anchor_timer <= 0.0:
_state = State.DRIFTING
_pick_floor_point()
return
_shot_timer -= delta _shot_timer -= delta
if _shot_timer <= 0.0 and _player != null: if _shot_timer <= 0.0 and _player != null:
_fire_at_player() _fire_at_player()
@@ -104,33 +106,3 @@ func _fire_at_player() -> void:
proj.homing = true proj.homing = true
proj.homing_target = _player proj.homing_target = _player
get_parent().call_deferred("add_child", proj) 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)
+13 -2
View File
@@ -10,8 +10,19 @@ func _draw() -> void:
draw_circle(Vector2.ZERO, 5.0, Color(1.0, 0.8, 0.1)) 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) draw_arc(Vector2.ZERO, 5.0, 0, TAU, 16, Color(1.0, 1.0, 0.4), 1.0)
func _physics_process(delta: float) -> void:
var player: Node2D = get_tree().get_first_node_in_group("player") as Node2D
if player == null:
return
var pstats: Node = player.get_node_or_null("Stats")
if pstats == null or not pstats.get("scrap_magnet"):
return
var dist: float = global_position.distance_to(player.global_position)
if dist <= float(pstats.get("magnet_radius")) and dist > 1.0:
var dir: Vector2 = (player.global_position - global_position).normalized()
global_position += dir * 240.0 * delta
func _on_body_entered(body: Node) -> void: func _on_body_entered(body: Node) -> void:
if body.is_in_group("player"): if body.is_in_group("player"):
UpgradeManager.hub_credits += value RunManager.earn_credits(value)
EventBus.credits_changed.emit(UpgradeManager.hub_credits)
queue_free() queue_free()
+10 -50
View File
@@ -1,40 +1,30 @@
extends CharacterBody2D extends EnemyBase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DRIFTER — Locomotion corruption # DRIFTER — Locomotion corruption
# Moves in slow arcs and wide curves. Never charges straight. Contact only. # Moves in slow arcs and wide curves. Never charges straight. Contact only.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
var max_health: float = 16.0 var _wobble_time: float = 0.0
var current_health: float = 16.0 var _wobble_phase: float = 0.0 # random start so groups don't sync
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: func _ready() -> void:
add_to_group("enemies") max_health = 24.0
$ContactArea.body_entered.connect(_on_contact_entered) current_health = 24.0
speed = 100.0
contact_damage = 1.0
super._ready()
_wobble_phase = randf() * TAU _wobble_phase = randf() * TAU
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_find_player() _find_player()
_tick_contact(delta) _tick_contact(delta)
_tick_flash(delta) _tick_flash(delta)
_tick_slow(delta)
_do_arc_chase(delta) _do_arc_chase(delta)
velocity *= _slow_factor
move_and_slide() 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 # Arcing movement — sinusoidal perpendicular drift applied to toward-player dir
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -47,33 +37,3 @@ func _do_arc_chase(delta: float) -> void:
_wobble_time += delta _wobble_time += delta
var wobble := sin(_wobble_time * 1.8 + _wobble_phase) * 0.75 var wobble := sin(_wobble_time * 1.8 + _wobble_phase) * 0.75
velocity = (toward + perp * wobble).normalized() * speed 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)
+37 -25
View File
@@ -1,4 +1,5 @@
extends CharacterBody2D extends CharacterBody2D
class_name EnemyBase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stats — override in derived enemy types # Stats — override in derived enemy types
@@ -10,11 +11,13 @@ var contact_damage: float = 1.0
var contact_cooldown: float = 0.8 var contact_cooldown: float = 0.8
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Internal state # Internal state — shared across all enemies
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
var _contact_timer: float = 0.0 var _contact_timer: float = 0.0
var _flash_timer: float = 0.0 var _flash_timer: float = 0.0
var _player: Node2D = null var _slow_factor: float = 1.0
var _slow_timer: float = 0.0
var _player: Node2D = null
@onready var visual: Node2D = $Visual @onready var visual: Node2D = $Visual
@@ -22,22 +25,41 @@ func _ready() -> void:
add_to_group("enemies") add_to_group("enemies")
$ContactArea.body_entered.connect(_on_contact_entered) $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 # Shared helpers — call from derived _physics_process as needed
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _chase_player() -> void: func _find_player() -> void:
if _player == null or not is_instance_valid(_player): if _player == null or not is_instance_valid(_player):
_player = get_tree().get_first_node_in_group("player") _player = get_tree().get_first_node_in_group("player")
if _player != null:
velocity = (_player.global_position - global_position).normalized() * speed func _tick_contact(delta: float) -> void:
if _contact_timer > 0.0:
_contact_timer -= delta
if _contact_timer <= 0.0:
_contact_timer = 0.0
# Re-apply damage if player is still overlapping after cooldown
for body in $ContactArea.get_overlapping_bodies():
if body.is_in_group("player"):
body.take_damage(contact_damage)
_contact_timer = contact_cooldown
break
func _tick_flash(delta: float) -> void:
if _flash_timer > 0.0:
_flash_timer -= delta
visual.modulate = Color(2.5, 2.5, 2.5)
else: else:
velocity = Vector2.ZERO visual.modulate = Color(1.0, 1.0, 1.0)
func apply_slow(duration: float, factor: float) -> void:
_slow_factor = factor
_slow_timer = duration
func _tick_slow(delta: float) -> void:
if _slow_timer > 0.0:
_slow_timer -= delta
if _slow_timer <= 0.0:
_slow_factor = 1.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Damage / death # Damage / death
@@ -64,13 +86,3 @@ func _on_contact_entered(body: Node) -> void:
if body.is_in_group("player") and _contact_timer == 0.0: if body.is_in_group("player") and _contact_timer == 0.0:
body.take_damage(contact_damage) body.take_damage(contact_damage)
_contact_timer = contact_cooldown _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)
+46 -90
View File
@@ -1,87 +1,74 @@
extends CharacterBody2D extends EnemyBase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# REPEATER — Task-loop corruption # REPEATER — Task-loop corruption
# Drifts to last known player position, stops, fires a rotating burst loop. # Snaps to the nearest wall on spawn and stays there forever.
# Does not re-track the player — stuck completing a subroutine. # Fires a rotating burst loop indefinitely — a stationary turret.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
const ENEMY_PROJECTILE = preload("res://scenes/enemies/enemy_projectile.tscn") const ENEMY_PROJECTILE = preload("res://scenes/enemies/enemy_projectile.tscn")
enum State { DRIFTING, FIRING } # Room interior half-extents (960×540 room, 32px walls, 16px gap buffer)
const INNER_HALF_X := 432.0
var max_health: float = 24.0 const INNER_HALF_Y := 222.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 BURST_SIZE := 3 # shots per burst
const SHOT_INTERVAL := 0.18 # seconds between shots within a burst const SHOT_INTERVAL := 0.18 # seconds between shots within a burst
const BURST_PAUSE := 1.8 # seconds after burst completes before rotating const BURST_PAUSE := 1.8 # seconds after burst before rotating
const ROTATE_STEP := 0.3491 # 20° in radians const ROTATE_STEP := 0.3491 # 20° in radians
var _player: Node2D = null var _fire_angle: float = 0.0
var _state: State = State.DRIFTING var _shot_count: int = 0
var _target_pos: Vector2 var _shot_timer: float = 1.0 # initial delay before first burst
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: func _ready() -> void:
add_to_group("enemies") max_health = 30.0
$ContactArea.body_entered.connect(_on_contact_entered) current_health = 30.0
_fire_angle = randf() * TAU # random initial burst direction speed = 0.0
contact_damage = 0.5
super._ready()
_fire_angle = randf() * TAU
_snap_to_wall()
# ---------------------------------------------------------------------------
# Snap to the nearest wall face at spawn
# ---------------------------------------------------------------------------
func _snap_to_wall() -> 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))
var wall_pos: Vector2
if md == d_left:
wall_pos = room_center + Vector2(-INNER_HALF_X, lp.y)
elif md == d_right:
wall_pos = room_center + Vector2( INNER_HALF_X, lp.y)
elif md == d_up:
wall_pos = room_center + Vector2(lp.x, -INNER_HALF_Y)
else:
wall_pos = room_center + Vector2(lp.x, INNER_HALF_Y)
global_position = wall_pos
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_find_player() _find_player()
_tick_contact(delta) _tick_contact(delta)
_tick_flash(delta) _tick_flash(delta)
match _state: _tick_slow(delta)
State.DRIFTING: velocity = Vector2.ZERO
_do_drift() _do_fire(delta)
State.FIRING: velocity *= _slow_factor
_do_fire(delta)
move_and_slide() 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 # Stationary burst loop — fire 3, pause, rotate 20° CW, repeat
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _do_fire(delta: float) -> void: func _do_fire(delta: float) -> void:
velocity = Vector2.ZERO
_shot_timer -= delta _shot_timer -= delta
if _shot_timer > 0.0: if _shot_timer > 0.0:
return return
@@ -91,7 +78,6 @@ func _do_fire(delta: float) -> void:
_shot_count += 1 _shot_count += 1
_shot_timer = SHOT_INTERVAL _shot_timer = SHOT_INTERVAL
else: else:
# Burst done — rotate 20° clockwise and reset
_fire_angle += ROTATE_STEP _fire_angle += ROTATE_STEP
_shot_count = 0 _shot_count = 0
_shot_timer = BURST_PAUSE _shot_timer = BURST_PAUSE
@@ -104,33 +90,3 @@ func _fire_projectile() -> void:
proj.damage = 1.0 proj.damage = 1.0
proj.max_range = 520.0 proj.max_range = 520.0
get_parent().call_deferred("add_child", proj) 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)
+77 -69
View File
@@ -1,4 +1,4 @@
extends CharacterBody2D extends EnemyBase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The Supervisor — Phase 1/2/Climax boss # The Supervisor — Phase 1/2/Climax boss
@@ -9,14 +9,6 @@ enum Phase { ONE, TWO, CLIMAX }
const DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn") 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) # Patrol (Phase 1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -46,18 +38,20 @@ const WIND_UP_DURATION := 0.65 # seconds of warning before beam fires
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
const FREEZE_DURATION := 2.0 const FREEZE_DURATION := 2.0
# ---------------------------------------------------------------------------
# Phase 2 Drifter spawning — one at a time, over a fixed window
# ---------------------------------------------------------------------------
const DRIFTER_SPAWN_COUNT := 4 # total drifters spawned in phase 2
const DRIFTER_SPAWN_INTERVAL := 8.0 # seconds between each spawn
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Internal state # Internal state
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
var _phase: Phase = Phase.ONE var _phase: Phase = Phase.ONE
var _player: Node2D = null
var _patrol_points: Array[Vector2] = [] var _patrol_points: Array[Vector2] = []
var _patrol_index: int = 0 var _patrol_index: int = 0
var _contact_timer: float = 0.0
var _flash_timer: float = 0.0
# Beam state # Beam state
var _beam_timer: float = 1.5 # initial delay before first beam check var _beam_timer: float = 1.5 # initial delay before first beam check
var _beam_active: bool = false var _beam_active: bool = false
@@ -65,21 +59,28 @@ var _beam_node: Node2D = null
var _beam_dur_timer: float = 0.0 var _beam_dur_timer: float = 0.0
# Wind-up state # Wind-up state
var _winding_up: bool = false var _winding_up: bool = false
var _wind_up_timer: float = 0.0 var _wind_up_timer: float = 0.0
var _wind_up_dir: Vector2 = Vector2.RIGHT # locked direction when wind-up starts var _wind_up_dir: Vector2 = Vector2.RIGHT # locked direction when wind-up starts
var _drifter_spawned: bool = false # Phase 2 Drifter spawning
var _freeze_timer: float = 0.0 var _drifters_spawned: int = 0
var _drifter_timer: float = DRIFTER_SPAWN_INTERVAL
@onready var visual: Node2D = $Visual var _freeze_timer: float = 0.0
# Facing indicator — Line2D child added to visual at runtime
var _facing_indicator: Line2D = null
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Ready # Ready
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _ready() -> void: func _ready() -> void:
add_to_group("enemies") max_health = 200.0
$ContactArea.body_entered.connect(_on_contact_entered) current_health = 200.0
contact_damage = 1.0
contact_cooldown = 0.8
super._ready()
var c := global_position var c := global_position
_patrol_points = [ _patrol_points = [
c + Vector2(-PATROL_W, -PATROL_H), c + Vector2(-PATROL_W, -PATROL_H),
@@ -87,6 +88,23 @@ func _ready() -> void:
c + Vector2( PATROL_W, PATROL_H), c + Vector2( PATROL_W, PATROL_H),
c + Vector2(-PATROL_W, PATROL_H), c + Vector2(-PATROL_W, PATROL_H),
] ]
_build_facing_indicator()
# ---------------------------------------------------------------------------
# Facing indicator — arrow line attached to the visual node
# ---------------------------------------------------------------------------
func _build_facing_indicator() -> void:
var arrow := Line2D.new()
arrow.width = 3.0
arrow.default_color = Color(1.0, 0.9, 0.3, 0.85)
arrow.add_point(Vector2(0.0, 0.0))
arrow.add_point(Vector2(22.0, 0.0))
visual.add_child(arrow)
_facing_indicator = arrow
func _update_facing(dir: Vector2) -> void:
if _facing_indicator != null:
_facing_indicator.rotation = dir.angle()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Physics loop # Physics loop
@@ -100,12 +118,11 @@ func _physics_process(delta: float) -> void:
var pct := current_health / max_health var pct := current_health / max_health
if _phase == Phase.ONE and pct <= 0.55: if _phase == Phase.ONE and pct <= 0.55:
_enter_phase_two() _enter_phase_two()
elif _phase == Phase.TWO and pct <= 0.20: elif _phase == Phase.TWO and current_health <= 5.0:
_enter_climax() _enter_climax()
match _phase: match _phase:
Phase.ONE: Phase.ONE:
# Stop and lock on during wind-up
if _winding_up: if _winding_up:
velocity = Vector2.ZERO velocity = Vector2.ZERO
else: else:
@@ -115,25 +132,38 @@ func _physics_process(delta: float) -> void:
velocity = Vector2.ZERO velocity = Vector2.ZERO
else: else:
_do_chase(PATROL_SPEED_P2) _do_chase(PATROL_SPEED_P2)
_maybe_spawn_drifter() _tick_drifter_spawns(delta)
Phase.CLIMAX: Phase.CLIMAX:
_do_climax(delta) _do_climax(delta)
move_and_slide() # Update facing indicator (hidden during climax)
if _phase != Phase.CLIMAX:
if _facing_indicator != null:
_facing_indicator.visible = true
var facing: Vector2
if _winding_up:
facing = _wind_up_dir
elif velocity.length() > 1.0:
facing = velocity.normalized()
elif _player != null:
facing = (_player.global_position - global_position).normalized()
else:
facing = Vector2.RIGHT
_update_facing(facing)
else:
if _facing_indicator != null:
_facing_indicator.visible = false
# --------------------------------------------------------------------------- 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 # Phase transitions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _enter_phase_two() -> void: func _enter_phase_two() -> void:
_phase = Phase.TWO _phase = Phase.TWO
_winding_up = false _winding_up = false
_drifters_spawned = 0
_drifter_timer = DRIFTER_SPAWN_INTERVAL * 0.5 # first spawn sooner
func _enter_climax() -> void: func _enter_climax() -> void:
_phase = Phase.CLIMAX _phase = Phase.CLIMAX
@@ -168,15 +198,18 @@ func _do_chase(spd: float) -> void:
velocity = (_player.global_position - global_position).normalized() * spd velocity = (_player.global_position - global_position).normalized() * spd
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Phase 2 — spawn one Drifter # Phase 2 — spawn Drifters one at a time on a fixed interval
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _maybe_spawn_drifter() -> void: func _tick_drifter_spawns(delta: float) -> void:
if _drifter_spawned: if _drifters_spawned >= DRIFTER_SPAWN_COUNT:
return return
_drifter_spawned = true _drifter_timer -= delta
var drifter = DRIFTER_SCENE.instantiate() if _drifter_timer <= 0.0:
drifter.position = (get_parent() as Node2D).to_local(global_position + Vector2(0, 130)) var drifter = DRIFTER_SCENE.instantiate()
get_parent().call_deferred("add_child", drifter) drifter.position = (get_parent() as Node2D).to_local(global_position + Vector2(0, 130))
get_parent().call_deferred("add_child", drifter)
_drifters_spawned += 1
_drifter_timer = DRIFTER_SPAWN_INTERVAL
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Climax — frozen distress signal, then die # Climax — frozen distress signal, then die
@@ -196,7 +229,6 @@ func _tick_beam(delta: float) -> void:
if _phase == Phase.CLIMAX: if _phase == Phase.CLIMAX:
return return
# Active beam — count down duration
if _beam_active: if _beam_active:
_beam_dur_timer -= delta _beam_dur_timer -= delta
if _beam_dur_timer <= 0.0: if _beam_dur_timer <= 0.0:
@@ -206,7 +238,6 @@ func _tick_beam(delta: float) -> void:
_beam_node = null _beam_node = null
return return
# Wind-up — boss is stopped and flashing; fires when timer expires
if _winding_up: if _winding_up:
_wind_up_timer -= delta _wind_up_timer -= delta
if _wind_up_timer <= 0.0: if _wind_up_timer <= 0.0:
@@ -216,19 +247,16 @@ func _tick_beam(delta: float) -> void:
_beam_timer = cd _beam_timer = cd
return return
# Cooldown between beams
if _beam_timer > 0.0: if _beam_timer > 0.0:
_beam_timer -= delta _beam_timer -= delta
return return
# Check sensor cone — start wind-up if player is in range
if _player_in_sensor_cone(): if _player_in_sensor_cone():
_winding_up = true _winding_up = true
_wind_up_timer = WIND_UP_DURATION _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 _wind_up_dir = velocity.normalized() if velocity.length() > 1.0 else Vector2.RIGHT
else: else:
_beam_timer = 0.3 # recheck soon _beam_timer = 0.3
func _player_in_sensor_cone() -> bool: func _player_in_sensor_cone() -> bool:
if _player == null: if _player == null:
@@ -243,7 +271,7 @@ func _fire_beam() -> void:
_beam_active = true _beam_active = true
_beam_dur_timer = BEAM_DURATION _beam_dur_timer = BEAM_DURATION
var forward := _wind_up_dir # use direction locked at wind-up start var forward := _wind_up_dir
var beam_len := BEAM_RANGE var beam_len := BEAM_RANGE
var beam_w := 30.0 var beam_w := 30.0
var offset := 24.0 var offset := 24.0
@@ -280,7 +308,7 @@ func _fire_beam() -> void:
_beam_node = beam _beam_node = beam
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Damage / death # Damage / death — override base to emit boss signals; no drop (room handles it)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func take_damage(amount: float) -> void: func take_damage(amount: float) -> void:
current_health -= amount current_health -= amount
@@ -293,46 +321,26 @@ func _die() -> void:
EventBus.boss_died.emit() EventBus.boss_died.emit()
if is_instance_valid(_beam_node): if is_instance_valid(_beam_node):
_beam_node.queue_free() _beam_node.queue_free()
_spawn_drop() queue_free() # no drop — room.gd spawns the guaranteed body part
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 # Visual — overrides base _tick_flash with phase-aware tints
# ---------------------------------------------------------------------------
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: func _tick_flash(delta: float) -> void:
if _phase == Phase.CLIMAX: if _phase == Phase.CLIMAX:
return # climax handles its own visual in _do_climax return # climax handles its own visual in _do_climax
# Wind-up: rapid yellow-white warning pulse — overrides everything else
if _winding_up: if _winding_up:
var pulse := fmod(_wind_up_timer, 0.18) < 0.09 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) 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 visual.modulate = Color(2.4, 2.1, 0.3) if pulse else base
return return
# Hit flash
if _flash_timer > 0.0: if _flash_timer > 0.0:
_flash_timer -= delta _flash_timer -= delta
visual.modulate = Color(2.5, 2.5, 2.5) visual.modulate = Color(2.5, 2.5, 2.5)
return return
# Resting tint per phase
if _phase == Phase.TWO: if _phase == Phase.TWO:
visual.modulate = Color(1.0, 0.65, 0.2) visual.modulate = Color(1.0, 0.65, 0.2)
else: else:
+1
View File
@@ -14,6 +14,7 @@ signal floor_cleared(floor_number: int)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
signal room_entered(room_id: String) signal room_entered(room_id: String)
signal room_cleared(room_id: String) signal room_cleared(room_id: String)
signal room_revealed
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Items & Loot # Items & Loot
+4 -1
View File
@@ -38,7 +38,10 @@ func go_to_hub() -> void:
get_tree().change_scene_to_file("res://scenes/hub/hub.tscn") get_tree().change_scene_to_file("res://scenes/hub/hub.tscn")
func start_run() -> void: func start_run() -> void:
RunManager.start_run(0, [], []) # Bring everything in the wallet into the run — it's on the player, so it's at risk
var brought := UpgradeManager.wallet_credits
UpgradeManager.wallet_credits = 0
RunManager.start_run(brought, [], [])
change_state(GameState.RUN) change_state(GameState.RUN)
get_tree().change_scene_to_file("res://scenes/run/floor.tscn") get_tree().change_scene_to_file("res://scenes/run/floor.tscn")
+16 -11
View File
@@ -9,21 +9,22 @@ const ROOM_H := 540.0
const TRANSITION_TIME := 0.25 const TRANSITION_TIME := 0.25
const ENTRY_INSET := 60.0 const ENTRY_INSET := 60.0
# Fixed hub layout — 6 rooms arranged as: # Fixed hub layout — 7 rooms arranged as:
# #
# [Control (0,-1)] # [Control(-1,-1)]
# | # |
# [Cafe(-1,0)]-[Staging(0,0)]-[Armory(1,0)] # [Armory(-1,0)]-[Staging(0,0)]-[Shop(1,0)]
# | | # | | |
# [Shop(0,1)]----[Trophy(1,1)] # [Practice(-1,1)]-[Cafeteria(0,1)]-[Trophy(1,1)]
# #
# HubRoomType: STAGING=0, ARMORY=1, CAFETERIA=2, SHOP=3, CONTROL_ROOM=4, TROPHY_ROOM=5 # HubRoomType: STAGING=0, ARMORY=1, CAFETERIA=2, SHOP=3, CONTROL_ROOM=4, TROPHY_ROOM=5, PRACTICE=6
const LAYOUT := [ const LAYOUT := [
{"type": 0, "grid": Vector2i( 0, 0)}, # STAGING {"type": 0, "grid": Vector2i( 0, 0)}, # STAGING
{"type": 4, "grid": Vector2i( 0, -1)}, # CONTROL_ROOM {"type": 4, "grid": Vector2i(-1, -1)}, # CONTROL_ROOM
{"type": 2, "grid": Vector2i(-1, 0)}, # CAFETERIA {"type": 1, "grid": Vector2i(-1, 0)}, # ARMORY
{"type": 1, "grid": Vector2i( 1, 0)}, # ARMORY {"type": 3, "grid": Vector2i( 1, 0)}, # SHOP
{"type": 3, "grid": Vector2i( 0, 1)}, # SHOP {"type": 6, "grid": Vector2i(-1, 1)}, # PRACTICE
{"type": 2, "grid": Vector2i( 0, 1)}, # CAFETERIA
{"type": 5, "grid": Vector2i( 1, 1)}, # TROPHY_ROOM {"type": 5, "grid": Vector2i( 1, 1)}, # TROPHY_ROOM
] ]
@@ -41,6 +42,7 @@ const DIRS := {
@onready var camera: Camera2D = $Camera2D @onready var camera: Camera2D = $Camera2D
@onready var player: Node2D = $Player @onready var player: Node2D = $Player
@onready var armory_ui: CanvasLayer = $ArmoryUI @onready var armory_ui: CanvasLayer = $ArmoryUI
@onready var atm_ui: CanvasLayer = $AtmUI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# State # State
@@ -131,3 +133,6 @@ func _grid_to_world(grid_pos: Vector2i) -> Vector2:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func open_armory() -> void: func open_armory() -> void:
armory_ui.open() armory_ui.open()
func open_atm() -> void:
atm_ui.open()
+53
View File
@@ -11,6 +11,7 @@ enum HubRoomType {
SHOP = 3, SHOP = 3,
CONTROL_ROOM = 4, CONTROL_ROOM = 4,
TROPHY_ROOM = 5, TROPHY_ROOM = 5,
PRACTICE = 6,
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -31,6 +32,7 @@ const ROOM_NAMES := [
"SHOP", # 3 "SHOP", # 3
"CONTROL ROOM", # 4 "CONTROL ROOM", # 4
"TROPHY ROOM", # 5 "TROPHY ROOM", # 5
"PRACTICE ROOM", # 6
] ]
const FLOOR_COLORS := [ const FLOOR_COLORS := [
@@ -40,6 +42,7 @@ const FLOOR_COLORS := [
Color(0.14, 0.10, 0.14), # SHOP — purple tint Color(0.14, 0.10, 0.14), # SHOP — purple tint
Color(0.08, 0.12, 0.18), # CONTROL_ROOM — blue tint Color(0.08, 0.12, 0.18), # CONTROL_ROOM — blue tint
Color(0.16, 0.14, 0.08), # TROPHY_ROOM — warm gold tint Color(0.16, 0.14, 0.08), # TROPHY_ROOM — warm gold tint
Color(0.12, 0.16, 0.13), # PRACTICE — muted green
] ]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -73,8 +76,11 @@ func setup(room_type: int, connections: Dictionary, hub_ref: Node) -> void:
if _type == HubRoomType.STAGING: if _type == HubRoomType.STAGING:
_build_run_portal() _build_run_portal()
_build_atm()
elif _type == HubRoomType.ARMORY: elif _type == HubRoomType.ARMORY:
_build_armory_workbench() _build_armory_workbench()
elif _type == HubRoomType.PRACTICE:
_build_practice_room()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Wall generation (same logic as room.gd) # Wall generation (same logic as room.gd)
@@ -256,3 +262,50 @@ func _build_run_portal() -> void:
if b.is_in_group("player"): if b.is_in_group("player"):
GameManager.call_deferred("start_run")) GameManager.call_deferred("start_run"))
contents.add_child(area) contents.add_child(area)
# ---------------------------------------------------------------------------
# Deposit machine — shows bank balance, lets player set withdrawal for next run
# ---------------------------------------------------------------------------
func _build_atm() -> void:
# Visual — grey terminal with cyan screen
var body := Polygon2D.new()
body.polygon = PackedVector2Array([-28, -40, 28, -40, 28, 40, -28, 40])
body.color = Color(0.25, 0.28, 0.32)
body.position = Vector2(200, -150)
contents.add_child(body)
var screen := Polygon2D.new()
screen.polygon = PackedVector2Array([-18, -28, 18, -28, 18, 0, -18, 0])
screen.color = Color(0.1, 0.8, 0.9, 0.85)
screen.position = Vector2(200, -150)
contents.add_child(screen)
var lbl := Label.new()
lbl.text = "DEPOSIT"
lbl.position = Vector2(172, -202)
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(56, 80)
cs.shape = rs
area.add_child(cs)
area.position = Vector2(200, -150)
area.body_entered.connect(func(b: Node) -> void:
if b.is_in_group("player"):
_hub.call_deferred("open_atm"))
contents.add_child(area)
# ---------------------------------------------------------------------------
# Practice room — placeholder until VS combat is implemented
# ---------------------------------------------------------------------------
func _build_practice_room() -> void:
var lbl := Label.new()
lbl.text = "PRACTICE ROOM — OFFLINE"
lbl.position = Vector2(-90, -20)
contents.add_child(lbl)
+110
View File
@@ -0,0 +1,110 @@
extends Area2D
# ---------------------------------------------------------------------------
# Shop Item Pedestal
# Player walks up → prompt appears → press E to buy
# ---------------------------------------------------------------------------
var item: RunItem = null
var price: int = 20
var _sold: bool = false
var _player_in_range: bool = false
var _prompt_label: Label = null
var _info_label: Label = null
func _ready() -> void:
collision_layer = 0
collision_mask = 2 # player layer
var cs := CollisionShape2D.new()
var rs := CircleShape2D.new()
rs.radius = 36.0
cs.shape = rs
add_child(cs)
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
# Item name + price above pedestal
_info_label = Label.new()
_info_label.text = "%s\n%d SCRAP" % [item.display_name if item else "???", price]
_info_label.position = Vector2(-48, -56)
add_child(_info_label)
# Buy prompt — hidden until player is in range
_prompt_label = Label.new()
_prompt_label.text = "[E] Buy"
_prompt_label.position = Vector2(-20, -76)
_prompt_label.modulate = Color(1.0, 0.9, 0.3)
_prompt_label.visible = false
add_child(_prompt_label)
queue_redraw()
func _draw() -> void:
# Pedestal base
var col := Color(0.25, 0.25, 0.25) if _sold else Color(0.85, 0.70, 0.10)
draw_rect(Rect2(-14, -14, 28, 28), col)
# Sold X
if _sold:
draw_line(Vector2(-10, -10), Vector2(10, 10), Color(0.5, 0.5, 0.5), 2.0)
draw_line(Vector2(10, -10), Vector2(-10, 10), Color(0.5, 0.5, 0.5), 2.0)
func _unhandled_input(event: InputEvent) -> void:
if _sold or not _player_in_range:
return
if event.is_action_pressed("ui_accept"):
_attempt_purchase()
return
if event is InputEventKey:
var key := event as InputEventKey
if key.pressed and key.physical_keycode == KEY_E:
_attempt_purchase()
func _attempt_purchase() -> void:
if RunManager.run_credits < price:
_flash_no_funds()
return
RunManager.spend_credits(price)
var player := get_tree().get_first_node_in_group("player") as Node2D
if player == null:
return
RunManager.collect_item(item)
if item.heal_on_pickup > 0.0:
player.stats.heal(item.heal_on_pickup)
player.stats.recalculate()
EventBus.player_health_changed.emit(player.stats.current_health, player.stats.max_health)
_sold = true
_player_in_range = false
if _prompt_label:
_prompt_label.visible = false
if _info_label:
_info_label.text = "%s\n[SOLD]" % (item.display_name if item else "???")
queue_redraw()
func _on_body_entered(body: Node) -> void:
if body.is_in_group("player") and not _sold:
_player_in_range = true
if _prompt_label:
_prompt_label.visible = true
func _on_body_exited(body: Node) -> void:
if body.is_in_group("player"):
_player_in_range = false
if _prompt_label:
_prompt_label.visible = false
func _flash_no_funds() -> void:
if _prompt_label == null:
return
_prompt_label.text = "Need %d SCRAP" % price
_prompt_label.modulate = Color(1.0, 0.2, 0.2)
await get_tree().create_timer(1.2).timeout
if is_instance_valid(self) and not _sold:
_prompt_label.text = "[E] Buy"
_prompt_label.modulate = Color(1.0, 0.9, 0.3)
+141 -8
View File
@@ -9,12 +9,29 @@ extends CharacterBody2D
const PROJECTILE = preload("res://scenes/player/projectile.tscn") const PROJECTILE = preload("res://scenes/player/projectile.tscn")
const IFRAME_DURATION := 0.8 const IFRAME_DURATION := 0.8
const SHIELD_COOLDOWN := 3.0
var _shoot_timer: float = 0.0 var _shoot_timer: float = 0.0
var _iframe_timer: float = 0.0 var _iframe_timer: float = 0.0
# Overclock — counts shots fired; every 3rd deals 0 damage
var _overclock_counter: int = 0
# Memory Spike — first projectile per room deals 3x damage
var _memory_spike_available: bool = true
# Shield Projector — active block, absorbs one hit
var _shield_ready: bool = true
var _shield_active: bool = false
var _shield_cd_timer: float = 0.0
# Oil slick — count of slick zones currently overlapping player
var _slick_count: int = 0
func _ready() -> void: func _ready() -> void:
add_to_group("player") add_to_group("player")
EventBus.room_entered.connect(_on_room_entered)
EventBus.room_cleared.connect(_on_room_cleared)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Physics # Physics
@@ -23,6 +40,7 @@ func _physics_process(delta: float) -> void:
_handle_movement() _handle_movement()
_handle_shooting(delta) _handle_shooting(delta)
_handle_iframes(delta) _handle_iframes(delta)
_handle_shield(delta)
move_and_slide() move_and_slide()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -36,7 +54,12 @@ func _handle_movement() -> void:
if Input.is_physical_key_pressed(KEY_D): input_dir.x += 1 if Input.is_physical_key_pressed(KEY_D): input_dir.x += 1
if input_dir.length() > 1.0: if input_dir.length() > 1.0:
input_dir = input_dir.normalized() input_dir = input_dir.normalized()
velocity = input_dir * stats.speed
if _slick_count > 0:
# Oil slick — momentum-based: velocity slowly drifts toward desired direction
velocity = velocity.lerp(input_dir * stats.speed, 0.08)
else:
velocity = input_dir * stats.speed
if input_dir.x != 0.0: if input_dir.x != 0.0:
body.scale.x = sign(input_dir.x) body.scale.x = sign(input_dir.x)
@@ -62,27 +85,62 @@ func _handle_shooting(delta: float) -> void:
_shoot_timer = 1.0 / stats.fire_rate _shoot_timer = 1.0 / stats.fire_rate
func _fire(direction: Vector2) -> void: func _fire(direction: Vector2) -> void:
# Overclock: every 3rd shot deals 0 damage
var is_zeroed_shot := false
if stats.overclock:
_overclock_counter += 1
if _overclock_counter >= 3:
_overclock_counter = 0
is_zeroed_shot = true
if stats.scatter_count <= 1: if stats.scatter_count <= 1:
_spawn_projectile(direction) _spawn_projectile(direction, is_zeroed_shot)
else: else:
var spread := deg_to_rad(stats.scatter_spread_deg) var spread := deg_to_rad(stats.scatter_spread_deg)
var step := spread / float(stats.scatter_count - 1) var step := spread / float(stats.scatter_count - 1) if stats.scatter_count > 1 else 0.0
var start := direction.angle() - spread / 2.0 var start := direction.angle() - spread / 2.0
for i in stats.scatter_count: for i in stats.scatter_count:
_spawn_projectile(Vector2.from_angle(start + step * i)) _spawn_projectile(Vector2.from_angle(start + step * i), is_zeroed_shot)
func _spawn_projectile(dir: Vector2) -> void: func _spawn_projectile(dir: Vector2, zero_damage: bool = false) -> void:
var proj: Area2D = PROJECTILE.instantiate() var proj: Area2D = PROJECTILE.instantiate()
proj.global_position = shoot_origin.global_position proj.global_position = shoot_origin.global_position
proj.direction = dir proj.direction = dir
proj.damage = stats.damage proj.speed = stats.proj_speed
proj.max_range = stats.range_ proj.max_range = stats.range_
proj.ricochet = stats.ricochet proj.ricochet = stats.ricochet
proj.explosive = stats.explosive proj.explosive = stats.explosive
proj.explosion_radius = stats.explosion_radius proj.explosion_radius = stats.explosion_radius
proj.explosion_damage = stats.explosion_damage proj.explosion_damage = stats.explosion_damage
if zero_damage:
proj.damage = 0.0
elif stats.memory_spike and _memory_spike_available:
proj.damage = stats.damage * 3.0
_memory_spike_available = false
else:
proj.damage = stats.damage
get_parent().add_child(proj) get_parent().add_child(proj)
# ---------------------------------------------------------------------------
# Shield Projector — Space to activate; absorbs one hit, SHIELD_COOLDOWN
# ---------------------------------------------------------------------------
func _handle_shield(delta: float) -> void:
if not stats.shield_projector:
_shield_active = false
_shield_ready = true
_shield_cd_timer = 0.0
return
if _shield_cd_timer > 0.0:
_shield_cd_timer -= delta
if _shield_cd_timer <= 0.0:
_shield_ready = true
if _shield_ready and Input.is_physical_key_pressed(KEY_SPACE):
_shield_active = true
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Iframes — flash body during invincibility # Iframes — flash body during invincibility
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -100,11 +158,86 @@ func _handle_iframes(delta: float) -> void:
func take_damage(amount: float) -> void: func take_damage(amount: float) -> void:
if _iframe_timer > 0.0: if _iframe_timer > 0.0:
return return
stats.current_health -= amount
# Shield Projector — absorb one hit
if _shield_active:
_shield_active = false
_shield_ready = false
_shield_cd_timer = SHIELD_COOLDOWN
return
# Rust Coat — reduce damage (min 1)
var actual := amount
if stats.rust_coat:
actual = maxf(actual - stats.damage_reduction, 1.0)
stats.current_health -= actual
_iframe_timer = IFRAME_DURATION _iframe_timer = IFRAME_DURATION
EventBus.player_health_changed.emit(stats.current_health, stats.max_health) EventBus.player_health_changed.emit(stats.current_health, stats.max_health)
# Reactive effects on taking damage
if stats.static_discharge:
_trigger_static_discharge()
if stats.coolant_leak:
_spawn_coolant_zone()
if stats.current_health <= 0.0: if stats.current_health <= 0.0:
_die() _die()
func _die() -> void: func _die() -> void:
RunManager.end_run(false) RunManager.end_run(false)
# ---------------------------------------------------------------------------
# Static Discharge — instant AoE damage to enemies in radius
# ---------------------------------------------------------------------------
func _trigger_static_discharge() -> void:
for enemy in get_tree().get_nodes_in_group("enemies"):
if not is_instance_valid(enemy):
continue
if global_position.distance_to(enemy.global_position) <= stats.discharge_radius:
if enemy.has_method("take_damage"):
enemy.take_damage(stats.discharge_damage)
# ---------------------------------------------------------------------------
# Coolant Leak — spawn a temporary slow zone at player position (1s)
# ---------------------------------------------------------------------------
func _spawn_coolant_zone() -> void:
var zone := Area2D.new()
zone.collision_layer = 0
zone.collision_mask = 8 # enemy CharacterBody2D layer
var cs := CollisionShape2D.new()
var sh := CircleShape2D.new()
sh.radius = 24.0
cs.shape = sh
zone.add_child(cs)
zone.position = (get_parent() as Node2D).to_local(global_position)
zone.body_entered.connect(func(b: Node) -> void:
if b.has_method("apply_slow"):
b.apply_slow(2.0, 0.4))
get_parent().call_deferred("add_child", zone)
get_tree().create_timer(1.0).timeout.connect(func() -> void:
if is_instance_valid(zone):
zone.queue_free())
# ---------------------------------------------------------------------------
# Oil slick — called by hazard Area2D nodes
# ---------------------------------------------------------------------------
func enter_slick() -> void:
_slick_count += 1
func exit_slick() -> void:
_slick_count = maxi(_slick_count - 1, 0)
# ---------------------------------------------------------------------------
# Room event handlers
# ---------------------------------------------------------------------------
func _on_room_entered(_room_id: String) -> void:
_memory_spike_available = true
_overclock_counter = 0
func _on_room_cleared(_room_id: String) -> void:
if stats.fragmented_map:
EventBus.room_revealed.emit()
+4 -2
View File
@@ -55,6 +55,8 @@ func _ready() -> void:
if RunManager.player_health_carry > 0.0: if RunManager.player_health_carry > 0.0:
current_health = minf(RunManager.player_health_carry, max_health) current_health = minf(RunManager.player_health_carry, max_health)
RunManager.player_health_carry = -1.0 RunManager.player_health_carry = -1.0
else:
current_health = max_health # fresh run — start at full HP
func recalculate() -> void: func recalculate() -> void:
speed = BASE_SPEED + UpgradeManager.get_stat_modifier("speed") speed = BASE_SPEED + UpgradeManager.get_stat_modifier("speed")
@@ -122,13 +124,13 @@ func _apply_run_items() -> void:
fragmented_map = true fragmented_map = true
RunItem.Effect.OVERCLOCK: RunItem.Effect.OVERCLOCK:
overclock = true overclock = true
fire_rate *= 1.4 fire_rate *= 1.6
func _apply_part_flags() -> void: func _apply_part_flags() -> void:
shield_projector = false shield_projector = false
# Scatter from body parts (accumulate with run item scatter) # Scatter from body parts (accumulate with run item scatter)
for slot in UpgradeManager.SLOTS: for slot in UpgradeManager.SLOTS:
var part := UpgradeManager.get_equipped_part(slot) var part: BodyPartData = UpgradeManager.get_equipped_part(slot) as BodyPartData
if part == null: if part == null:
continue continue
if part.shield_projector: if part.shield_projector:
+182 -11
View File
@@ -21,22 +21,28 @@ const ANCHOR_SCENE = preload("res://scenes/enemies/anchor.tscn")
const SUPERVISOR_SCENE = preload("res://scenes/enemies/supervisor.tscn") const SUPERVISOR_SCENE = preload("res://scenes/enemies/supervisor.tscn")
const BODY_PART_PICKUP = preload("res://scenes/upgrades/body_part_pickup.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 RUN_ITEM_PICKUP = preload("res://scenes/items/run_item_pickup.tscn")
const SHOP_PEDESTAL_SCR = preload("res://scripts/items/shop_item_pedestal.gd")
const ALL_PART_PATHS: Array[String] = [ const ALL_PART_PATHS: Array[String] = [
"res://data/body_parts/head_sensor_array.tres", "res://data/body_parts/head_wide_angle_lens.tres",
"res://data/body_parts/torso_reinforced_plating.tres", "res://data/body_parts/head_targeting_spike.tres",
"res://data/body_parts/left_arm_rapid_fire.tres", "res://data/body_parts/torso_reinforced_chassis.tres",
"res://data/body_parts/right_arm_cannon.tres", "res://data/body_parts/torso_lightweight_frame.tres",
"res://data/body_parts/legs_servo_boost.tres", "res://data/body_parts/left_arm_scatter_emitter.tres",
"res://data/body_parts/left_arm_shield_projector.tres",
"res://data/body_parts/right_arm_heavy_emitter.tres",
"res://data/body_parts/right_arm_rapid_emitter.tres",
] ]
const ALL_ITEM_PATHS: Array[String] = [ const ALL_ITEM_PATHS: Array[String] = [
"res://data/run_items/plating_shard.tres", "res://data/run_items/coolant_leak.tres",
"res://data/run_items/range_booster.tres", "res://data/run_items/overclock_module.tres",
"res://data/run_items/overclock_chip.tres", "res://data/run_items/scrap_magnet.tres",
"res://data/run_items/ricochet_module.tres", "res://data/run_items/memory_spike.tres",
"res://data/run_items/scatter_core.tres", "res://data/run_items/rust_coat.tres",
"res://data/run_items/volatile_round.tres", "res://data/run_items/static_discharge.tres",
"res://data/run_items/fragmented_map.tres",
"res://data/run_items/patch_kit.tres",
] ]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -69,6 +75,7 @@ func setup(room_data: RoomData, floor_ref: Node) -> void:
# Activate — called on first (and repeat) entry # Activate — called on first (and repeat) entry
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func activate() -> void: func activate() -> void:
EventBus.room_entered.emit(data.id if "id" in data else "")
if data.visited: if data.visited:
_update_door_locks() _update_door_locks()
return return
@@ -82,6 +89,9 @@ func activate() -> void:
RoomData.RoomType.ITEM: RoomData.RoomType.ITEM:
_spawn_run_item() _spawn_run_item()
data.cleared = true data.cleared = true
RoomData.RoomType.SHOP:
_spawn_shop()
data.cleared = true
_: _:
data.cleared = true data.cleared = true
@@ -232,6 +242,9 @@ func _get_enemy_count() -> int:
_: return randi_range(2, 3) _: return randi_range(2, 3)
func _spawn_enemies() -> void: func _spawn_enemies() -> void:
# Hazards first — added to contents before enemies so they draw underneath
_spawn_hazards()
var hw := ROOM_W / 2.0 - SPAWN_MARGIN var hw := ROOM_W / 2.0 - SPAWN_MARGIN
var hh := ROOM_H / 2.0 - SPAWN_MARGIN var hh := ROOM_H / 2.0 - SPAWN_MARGIN
var pool := _get_enemy_pool() var pool := _get_enemy_pool()
@@ -263,6 +276,7 @@ func _on_enemy_died() -> void:
_spawn_body_part_drop() _spawn_body_part_drop()
_spawn_floor_exit() _spawn_floor_exit()
room_cleared.emit() room_cleared.emit()
EventBus.room_cleared.emit(data.id if "id" in data else "")
_update_door_locks() _update_door_locks()
@@ -321,6 +335,163 @@ func _spawn_run_item() -> void:
pickup.position = Vector2.ZERO pickup.position = Vector2.ZERO
contents.add_child(pickup) contents.add_child(pickup)
# ---------------------------------------------------------------------------
# Environmental hazards — spawned in combat rooms
# ---------------------------------------------------------------------------
func _spawn_hazards() -> void:
# Oil slicks — 0-2 random puddles in the room interior
var hw := ROOM_W / 2.0 - SPAWN_MARGIN
var hh := ROOM_H / 2.0 - SPAWN_MARGIN
for i in randi_range(0, 2):
_make_oil_slick(Vector2(randf_range(-hw, hw), randf_range(-hh, hh)))
# Exposed wiring — 0-2 sections along walls
for i in randi_range(0, 2):
_make_exposed_wiring()
func _make_oil_slick(pos: Vector2) -> void:
var slick := Area2D.new()
slick.collision_layer = 0
slick.collision_mask = 2 | 8 # player (2) + enemies (8)
slick.position = pos
var radius: float = randf_range(44.0, 68.0)
var cs := CollisionShape2D.new()
var sh := CircleShape2D.new()
sh.radius = radius
cs.shape = sh
slick.add_child(cs)
# Visual — flattened dark ellipse (puddle)
var poly := Polygon2D.new()
var pts := PackedVector2Array()
var segs := 14
for i in segs:
var a := i * TAU / segs
pts.append(Vector2(cos(a) * radius, sin(a) * radius * 0.55))
poly.polygon = pts
poly.color = Color(0.04, 0.07, 0.14, 0.78)
slick.add_child(poly)
slick.body_entered.connect(func(b: Node) -> void:
if b.is_in_group("player") and b.has_method("enter_slick"):
b.enter_slick()
elif b.is_in_group("enemies") and b.has_method("apply_slow"):
b.apply_slow(999.0, 0.35))
slick.body_exited.connect(func(b: Node) -> void:
if b.is_in_group("player") and b.has_method("exit_slick"):
b.exit_slick()
elif b.is_in_group("enemies") and b.has_method("apply_slow"):
b.apply_slow(0.0, 1.0)) # clear slow immediately
contents.add_child(slick)
func _make_exposed_wiring() -> void:
var hw := ROOM_W / 2.0 - WALL_T
var hh := ROOM_H / 2.0 - WALL_T
var wire_len := randf_range(80.0, 150.0)
var wire_thick := 14.0
var inset := wire_thick / 2.0 + 2.0
var half := wire_len / 2.0
var hdw := DOOR_W / 2.0 # 40 — half door gap on horiz walls
var hdh := DOOR_H / 2.0 # 40 — half door gap on vert walls
# Build candidates: {wall, min, max} for center position along the wall.
# Door walls get two segments (one each side of gap); non-door walls get one full segment.
var wall_dirs := ["up", "down", "left", "right"]
var candidates : Array = []
for wall_id in 4:
var has_door := data.connections.has(wall_dirs[wall_id])
var is_horiz := wall_id <= 1
var extent := hw if is_horiz else hh
var door_half := hdw if is_horiz else hdh
if has_door:
var a_min := -extent + half; var a_max := -door_half - half
var b_min := door_half + half; var b_max := extent - half
if a_min <= a_max: candidates.append({"wall": wall_id, "min": a_min, "max": a_max})
if b_min <= b_max: candidates.append({"wall": wall_id, "min": b_min, "max": b_max})
else:
var s_min := -extent + half; var s_max := extent - half
if s_min <= s_max: candidates.append({"wall": wall_id, "min": s_min, "max": s_max})
if candidates.is_empty():
return
var chosen: Dictionary = candidates[randi() % candidates.size()]
var wall := chosen["wall"] as int
var along := randf_range(chosen["min"], chosen["max"])
var pos: Vector2
var size: Vector2
match wall:
0: pos = Vector2(along, -hh + inset); size = Vector2(wire_len, wire_thick)
1: pos = Vector2(along, hh - inset); size = Vector2(wire_len, wire_thick)
2: pos = Vector2(-hw + inset, along); size = Vector2(wire_thick, wire_len)
_: pos = Vector2( hw - inset, along); size = Vector2(wire_thick, wire_len)
var area := Area2D.new()
area.collision_layer = 0
area.collision_mask = 2 # player layer
area.position = pos
var cs := CollisionShape2D.new()
var rs := RectangleShape2D.new()
rs.size = size
cs.shape = rs
area.add_child(cs)
# Visual — bright yellow-orange strip
var poly := Polygon2D.new()
var hx := size.x / 2.0
var hy := size.y / 2.0
poly.polygon = PackedVector2Array([
Vector2(-hx, -hy), Vector2(hx, -hy),
Vector2( hx, hy), Vector2(-hx, hy),
])
poly.color = Color(1.0, 0.85, 0.05, 0.90)
area.add_child(poly)
# Damage on contact — player iframes act as natural cooldown
area.body_entered.connect(func(b: Node) -> void:
if b.is_in_group("player") and b.has_method("take_damage"):
b.take_damage(1.0))
contents.add_child(area)
# ---------------------------------------------------------------------------
# Shop spawning — 2 random items + 1 Patch Kit, spaced horizontally
# ---------------------------------------------------------------------------
func _spawn_shop() -> void:
# Item slot paths (exclude Patch Kit from the random pool)
const PATCH_KIT_PATH := "res://data/run_items/patch_kit.tres"
var item_pool: Array[String] = []
for p in ALL_ITEM_PATHS:
if p != PATCH_KIT_PATH:
item_pool.append(p)
item_pool.shuffle()
# Positions: left item, center item, right = Patch Kit
var positions: Array[Vector2] = [
Vector2(-200, 0),
Vector2( 0, 0),
Vector2( 200, 0),
]
var chosen: Array[String] = [
item_pool[0],
item_pool[1],
PATCH_KIT_PATH,
]
var prices: Array[int] = [20, 20, 10]
for i in 3:
var pedestal := Area2D.new()
pedestal.set_script(SHOP_PEDESTAL_SCR)
pedestal.position = positions[i]
pedestal.set("item", load(chosen[i]))
pedestal.set("price", prices[i])
contents.add_child(pedestal)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Door locking # Door locking
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+26 -3
View File
@@ -8,8 +8,11 @@ var run_active: bool = false
var run_items: Array[Resource] = [] var run_items: Array[Resource] = []
var card_packs_found: Array[Resource] = [] var card_packs_found: Array[Resource] = []
# --- Brought into the run (lost on death) --- # --- Credits (brought in + earned in run lost on death, returned on success) ---
var run_credits: int = 0 # total spendable during this run
var credits_brought_in: int = 0 var credits_brought_in: int = 0
# --- Brought into the run (lost on death) ---
var buff_items_brought: Array[Resource] = [] var buff_items_brought: Array[Resource] = []
var npc_ids_on_run: Array[String] = [] var npc_ids_on_run: Array[String] = []
@@ -24,8 +27,10 @@ func start_run(brought_credits: int, brought_buffs: Array[Resource], brought_npc
run_items.clear() run_items.clear()
card_packs_found.clear() card_packs_found.clear()
credits_brought_in = brought_credits credits_brought_in = brought_credits
run_credits = brought_credits
buff_items_brought = brought_buffs.duplicate() buff_items_brought = brought_buffs.duplicate()
npc_ids_on_run = brought_npc_ids.duplicate() npc_ids_on_run = brought_npc_ids.duplicate()
EventBus.credits_changed.emit(run_credits)
EventBus.run_started.emit() EventBus.run_started.emit()
func end_run(player_survived: bool) -> void: func end_run(player_survived: bool) -> void:
@@ -48,12 +53,29 @@ func collect_card_pack(pack: Resource) -> void:
card_packs_found.append(pack) card_packs_found.append(pack)
EventBus.card_pack_found.emit(pack) EventBus.card_pack_found.emit(pack)
# ---------------------------------------------------------------------------
# Credit management — called by currency_orb and shop_item_pedestal
# ---------------------------------------------------------------------------
func earn_credits(amount: int) -> void:
run_credits += amount
EventBus.credits_changed.emit(run_credits)
func spend_credits(amount: int) -> void:
run_credits = maxi(run_credits - amount, 0)
EventBus.credits_changed.emit(run_credits)
func _apply_run_rewards() -> void: func _apply_run_rewards() -> void:
# Permanent: card packs go to collection # Permanent: card packs go to collection
for pack in card_packs_found: for pack in card_packs_found:
CardCollection.open_pack(pack) CardCollection.open_pack(pack)
# Credits brought in are returned plus any earned during the run # Return all remaining run credits to the player's wallet (on hand)
UpgradeManager.hub_credits += credits_brought_in UpgradeManager.wallet_credits += run_credits
run_credits = 0
# Run items are non-permanent — clear them (body parts stay via UpgradeManager)
run_items.clear()
card_packs_found.clear()
# Health resets on hub return — do not carry it over
player_health_carry = -1.0
# NPCs survive # NPCs survive
for npc_id in npc_ids_on_run: for npc_id in npc_ids_on_run:
NPCManager.return_npc_to_hub(npc_id) NPCManager.return_npc_to_hub(npc_id)
@@ -62,6 +84,7 @@ func _apply_run_death() -> void:
# Everything brought in / found is lost # Everything brought in / found is lost
run_items.clear() run_items.clear()
card_packs_found.clear() card_packs_found.clear()
run_credits = 0
credits_brought_in = 0 credits_brought_in = 0
buff_items_brought.clear() buff_items_brought.clear()
# NPCs on run are lost # NPCs on run are lost
+4 -2
View File
@@ -5,7 +5,8 @@ const SAVE_PATH := "user://save.cfg"
func save() -> void: func save() -> void:
var data := ConfigFile.new() var data := ConfigFile.new()
data.set_value("upgrades", "parts", UpgradeManager.get_save_data()) data.set_value("upgrades", "parts", UpgradeManager.get_save_data())
data.set_value("upgrades", "hub_credits", UpgradeManager.hub_credits) data.set_value("upgrades", "hub_credits", UpgradeManager.hub_credits)
data.set_value("upgrades", "wallet_credits", UpgradeManager.wallet_credits)
data.set_value("cards", "collection", CardCollection.get_save_data()) data.set_value("cards", "collection", CardCollection.get_save_data())
data.set_value("npcs", "registry", NPCManager.get_save_data()) data.set_value("npcs", "registry", NPCManager.get_save_data())
data.set_value("lore", "discovered", LoreManager.get_save_data()) data.set_value("lore", "discovered", LoreManager.get_save_data())
@@ -17,7 +18,8 @@ func load_save() -> void:
var data := ConfigFile.new() var data := ConfigFile.new()
data.load(SAVE_PATH) data.load(SAVE_PATH)
UpgradeManager.load_save_data(data.get_value("upgrades", "parts", {})) UpgradeManager.load_save_data(data.get_value("upgrades", "parts", {}))
UpgradeManager.hub_credits = data.get_value("upgrades", "hub_credits", 0) UpgradeManager.hub_credits = data.get_value("upgrades", "hub_credits", 0)
UpgradeManager.wallet_credits = data.get_value("upgrades", "wallet_credits", 0)
CardCollection.load_save_data(data.get_value("cards", "collection", {})) CardCollection.load_save_data(data.get_value("cards", "collection", {}))
NPCManager.load_save_data(data.get_value("npcs", "registry", {})) NPCManager.load_save_data(data.get_value("npcs", "registry", {}))
LoreManager.load_save_data(data.get_value("lore", "discovered", [])) LoreManager.load_save_data(data.get_value("lore", "discovered", []))
+1
View File
@@ -164,4 +164,5 @@ func _equip_selected() -> void:
return return
var part: BodyPartData = _items[_cursor] var part: BodyPartData = _items[_cursor]
UpgradeManager.equip_part(part) UpgradeManager.equip_part(part)
SaveManager.save()
_refresh_labels() _refresh_labels()
+195
View File
@@ -0,0 +1,195 @@
extends CanvasLayer
# ---------------------------------------------------------------------------
# ATM / Deposit Machine UI
#
# BANK — safe storage, never lost on death (hub_credits)
# ON HAND — scrap the player is currently carrying (wallet_credits)
#
# DEPOSIT moves ON HAND → BANK
# WITHDRAW moves BANK → ON HAND
# ---------------------------------------------------------------------------
var _bank_label: Label = null
var _hand_label: Label = null
var _amount_label: Label = null
var _custom_input: LineEdit = null
var _transfer_amount: int = 0
const STEP_SMALL := 10
const STEP_LARGE := 100
func _ready() -> void:
layer = 9
process_mode = Node.PROCESS_MODE_ALWAYS
_build_ui()
visible = false
func _build_ui() -> void:
var bg := ColorRect.new()
bg.color = Color(0, 0, 0, 0.65)
bg.anchor_right = 1.0
bg.anchor_bottom = 1.0
bg.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(bg)
var panel := PanelContainer.new()
panel.anchor_left = 0.5
panel.anchor_top = 0.5
panel.anchor_right = 0.5
panel.anchor_bottom = 0.5
panel.offset_left = -200.0
panel.offset_top = -160.0
panel.offset_right = 200.0
panel.offset_bottom = 160.0
bg.add_child(panel)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 10)
panel.add_child(vbox)
# Title
var title := Label.new()
title.text = "SCRAP DEPOSIT MACHINE"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(title)
vbox.add_child(HSeparator.new())
# Balance display — two rows
_bank_label = Label.new()
_bank_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(_bank_label)
_hand_label = Label.new()
_hand_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(_hand_label)
vbox.add_child(HSeparator.new())
# Transfer amount row
var amt_row := HBoxContainer.new()
amt_row.alignment = BoxContainer.ALIGNMENT_CENTER
amt_row.add_theme_constant_override("separation", 6)
vbox.add_child(amt_row)
var amt_lbl := Label.new()
amt_lbl.text = "AMOUNT:"
amt_row.add_child(amt_lbl)
_add_btn(amt_row, "-100", func(): _adjust_amount(-STEP_LARGE))
_add_btn(amt_row, "-10", func(): _adjust_amount(-STEP_SMALL))
_amount_label = Label.new()
_amount_label.custom_minimum_size = Vector2(80, 0)
_amount_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
amt_row.add_child(_amount_label)
_add_btn(amt_row, "+10", func(): _adjust_amount(STEP_SMALL))
_add_btn(amt_row, "+100", func(): _adjust_amount(STEP_LARGE))
# Custom input row
var custom_row := HBoxContainer.new()
custom_row.alignment = BoxContainer.ALIGNMENT_CENTER
custom_row.add_theme_constant_override("separation", 6)
vbox.add_child(custom_row)
_custom_input = LineEdit.new()
_custom_input.placeholder_text = "Custom amount..."
_custom_input.custom_minimum_size = Vector2(140, 0)
_custom_input.text_submitted.connect(_on_custom_submitted)
custom_row.add_child(_custom_input)
_add_btn(custom_row, "SET", func(): _on_custom_submitted(_custom_input.text))
vbox.add_child(HSeparator.new())
# Deposit / Withdraw row
var action_row := HBoxContainer.new()
action_row.alignment = BoxContainer.ALIGNMENT_CENTER
action_row.add_theme_constant_override("separation", 16)
vbox.add_child(action_row)
var dep_btn := Button.new()
dep_btn.text = "DEPOSIT\n(ON HAND → BANK)"
dep_btn.pressed.connect(_on_deposit)
action_row.add_child(dep_btn)
var wth_btn := Button.new()
wth_btn.text = "WITHDRAW\n(BANK → ON HAND)"
wth_btn.pressed.connect(_on_withdraw)
action_row.add_child(wth_btn)
# Close
var close_btn := Button.new()
close_btn.text = "CLOSE"
close_btn.pressed.connect(close)
vbox.add_child(close_btn)
func _add_btn(parent: Node, text: String, cb: Callable) -> void:
var b := Button.new()
b.text = text
b.pressed.connect(cb)
parent.add_child(b)
# ---------------------------------------------------------------------------
# Open / close
# ---------------------------------------------------------------------------
func open() -> void:
_transfer_amount = 0
_custom_input.text = ""
_refresh()
visible = true
func close() -> void:
visible = false
func _unhandled_input(event: InputEvent) -> void:
if visible and event.is_action_pressed("ui_cancel"):
close()
# ---------------------------------------------------------------------------
# Transfer amount controls
# ---------------------------------------------------------------------------
func _adjust_amount(delta: int) -> void:
_transfer_amount = maxi(_transfer_amount + delta, 0)
_refresh()
func _on_custom_submitted(text: String) -> void:
_transfer_amount = maxi(text.to_int(), 0)
_custom_input.text = ""
_refresh()
# ---------------------------------------------------------------------------
# Deposit / Withdraw
# ---------------------------------------------------------------------------
func _on_deposit() -> void:
# Move from wallet to bank — capped by what the player actually has on hand
var amount := mini(_transfer_amount, UpgradeManager.wallet_credits)
if amount <= 0:
return
UpgradeManager.wallet_credits -= amount
UpgradeManager.hub_credits += amount
EventBus.credits_changed.emit(UpgradeManager.wallet_credits)
SaveManager.save()
_refresh()
func _on_withdraw() -> void:
# Move from bank to wallet — capped by bank balance
var amount := mini(_transfer_amount, UpgradeManager.hub_credits)
if amount <= 0:
return
UpgradeManager.hub_credits -= amount
UpgradeManager.wallet_credits += amount
EventBus.credits_changed.emit(UpgradeManager.wallet_credits)
SaveManager.save()
_refresh()
# ---------------------------------------------------------------------------
# Refresh display
# ---------------------------------------------------------------------------
func _refresh() -> void:
_bank_label.text = "BANK (SAFE): %d SCRAP" % UpgradeManager.hub_credits
_hand_label.text = "ON HAND: %d SCRAP" % UpgradeManager.wallet_credits
_amount_label.text = "%d" % _transfer_amount
+2 -1
View File
@@ -121,7 +121,8 @@ func _update_display(current_hp: float) -> void:
func _build_credits_label() -> void: func _build_credits_label() -> void:
_credits_label = Label.new() _credits_label = Label.new()
_credits_label.position = Vector2(16, 72) _credits_label.position = Vector2(16, 72)
_credits_label.text = "SCRAP: %d" % UpgradeManager.hub_credits var amount := RunManager.run_credits if RunManager.run_active else UpgradeManager.wallet_credits
_credits_label.text = "SCRAP: %d" % amount
add_child(_credits_label) add_child(_credits_label)
func _on_credits_changed(new_total: int) -> void: func _on_credits_changed(new_total: int) -> void:
+75
View File
@@ -0,0 +1,75 @@
extends CanvasLayer
# ---------------------------------------------------------------------------
# Pause menu — opened with Escape during a run
# Buttons: Resume | Return to Hub | Quit to Desktop
# ---------------------------------------------------------------------------
var _root: Control = null
func _ready() -> void:
layer = 10
process_mode = Node.PROCESS_MODE_ALWAYS
_build_ui()
visible = false
func _build_ui() -> void:
_root = ColorRect.new()
_root.color = Color(0, 0, 0, 0.65)
_root.anchor_right = 1.0
_root.anchor_bottom = 1.0
_root.offset_right = 0.0
_root.offset_bottom = 0.0
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var vbox := VBoxContainer.new()
vbox.anchor_left = 0.5
vbox.anchor_top = 0.5
vbox.anchor_right = 0.5
vbox.anchor_bottom = 0.5
vbox.offset_left = -120.0
vbox.offset_top = -80.0
vbox.offset_right = 120.0
vbox.offset_bottom = 80.0
vbox.add_theme_constant_override("separation", 16)
_root.add_child(vbox)
_add_button(vbox, "RESUME", _on_resume)
_add_button(vbox, "RETURN TO HUB", _on_return_to_hub)
_add_button(vbox, "QUIT TO DESKTOP", _on_quit)
func _add_button(parent: Node, text: String, callback: Callable) -> void:
var btn := Button.new()
btn.text = text
btn.custom_minimum_size = Vector2(240, 48)
btn.pressed.connect(callback)
parent.add_child(btn)
# ---------------------------------------------------------------------------
# Input — Escape toggles open/close
# ---------------------------------------------------------------------------
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
if visible:
_on_resume()
else:
_open()
func _open() -> void:
visible = true
get_tree().paused = true
# ---------------------------------------------------------------------------
# Button callbacks
# ---------------------------------------------------------------------------
func _on_resume() -> void:
visible = false
get_tree().paused = false
func _on_return_to_hub() -> void:
get_tree().paused = false
RunManager.end_run(true)
func _on_quit() -> void:
get_tree().quit()
+6 -1
View File
@@ -2,7 +2,12 @@ extends Node
const SLOTS: Array[String] = ["head", "torso", "left_arm", "right_arm", "legs"] const SLOTS: Array[String] = ["head", "torso", "left_arm", "right_arm", "legs"]
var hub_credits: int = 0 var hub_credits: int = 0 # safe bank — survives death, stored in deposit machine
var wallet_credits: int = 0 # on-hand scrap — carried by player, shown on HUD
func change_wallet(delta: int) -> void:
wallet_credits = maxi(wallet_credits + delta, 0)
EventBus.credits_changed.emit(wallet_credits)
# slot -> BodyPart resource path (empty string = default/base part) # slot -> BodyPart resource path (empty string = default/base part)
var equipped_parts: Dictionary = { var equipped_parts: Dictionary = {