Sync working project files - hub layout, boss roster, doc updates
This commit is contained in:
+47
-10
@@ -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
@@ -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 8–10 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 2–3 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 | 4–5 rooms | Learn the rhythm. One item. Maybe find the shop. First Repeater feels like a puzzle. |
|
| Floor 1 | 4–5 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 | 5–6 rooms | Mixed threats. Find a body part. Build takes shape. One tense near-death moment. |
|
| Floor 2 | 5–6 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 | 2–3 rooms + boss | Room composition tightens. Anchors in tighter spaces. Charging station. Hivemind — three phases, everything converges. Victory. |
|
||||||
|
|
||||||
**Target run time**: 20–30 minutes.
|
**Target run time**: 20–30 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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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")]
|
||||||
@@ -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")]
|
||||||
@@ -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")
|
||||||
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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", []))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user