Stops scrap from dropping in practice room and reworks hivemind, removes prod notes as well
This commit is contained in:
@@ -1,82 +0,0 @@
|
|||||||
# Production Notes — Backing, Team & Funding
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Solo Dev Reality
|
|
||||||
|
|
||||||
Possible, but 5–8 years to do it properly. The game has four disciplines that each need to be excellent:
|
|
||||||
|
|
||||||
- Systems programming (roguelike, card rules engine, networking)
|
|
||||||
- Art (2D sprites, isometric assets, 3D assets, UI, animations)
|
|
||||||
- Writing (NPC dialogue, lore tapes, card flavor text)
|
|
||||||
- Audio (music, SFX)
|
|
||||||
|
|
||||||
The NPC dialogue and lore delivery are the core differentiator — weak writing will kill the game regardless of how good the systems are.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Minimum Viable Team
|
|
||||||
|
|
||||||
**3–4 people | 2–3 years | ~$150k–$300k**
|
|
||||||
|
|
||||||
| Role | Priority | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| Artist | Critical | Visual identity makes or breaks the Steam page |
|
|
||||||
| Writer | Critical | NPC dialogue and lore are the soul of this game |
|
|
||||||
| Second programmer | High | Card AI, networking, and roguelike systems in parallel is a lot |
|
|
||||||
| Composer/audio | Medium | Can be contracted per-milestone rather than full-time |
|
|
||||||
|
|
||||||
This gets to Phases 1–10 (full roguelike + card game with AI + lore + NPCs) without online PvP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comfortable Team
|
|
||||||
|
|
||||||
**5–6 people | 2 years | ~$400k–$700k**
|
|
||||||
|
|
||||||
Adds dedicated QA and a proper audio lead. QA matters more than it sounds for a game this interconnected — a card game with AI opponents, online play, and a complex NPC state machine will generate serious edge-case bugs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Funding Paths
|
|
||||||
|
|
||||||
### Grants (No Equity — Best First Step)
|
|
||||||
- **Epic MegaGrants** — up to $500k, no strings attached
|
|
||||||
- **National/regional funds** — UK Games Fund, Canada Media Fund, Screen Australia, etc. (depending on location)
|
|
||||||
- Apply for these before approaching publishers. The architecture doc is unusually well-structured for a grant application.
|
|
||||||
|
|
||||||
### Publisher (Marketing + Porting Support)
|
|
||||||
Good fits for this game's tone and scope:
|
|
||||||
|
|
||||||
| Publisher | Why |
|
|
||||||
|---|---|
|
|
||||||
| Raw Fury | Narrative-heavy indie, strong aesthetic fit |
|
|
||||||
| Fellow Traveller | Story-driven games specifically |
|
|
||||||
| Devolver Digital | If the tone goes darker or weirder |
|
|
||||||
| Humble Games | Mid-tier indie, less creative interference |
|
|
||||||
|
|
||||||
Publishers typically fund $200k–$800k against a revenue share (usually 70/30 or 80/20 after recoup). You give up some control but gain QA, marketing, and often a console port — which can double lifetime revenue.
|
|
||||||
|
|
||||||
### Avoid
|
|
||||||
Equity/investment funding unless building toward a live service model. A premium indie doesn't need that pressure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Online PvP — Separate Budget Item
|
|
||||||
|
|
||||||
Online card PvP is **not a one-time development cost**. It requires:
|
|
||||||
- Server hosting (ongoing monthly)
|
|
||||||
- Anti-cheat
|
|
||||||
- Balance patches and moderation
|
|
||||||
- Customer support for trade disputes
|
|
||||||
|
|
||||||
This is a live service commitment. Treat it as a separate business decision, not just a phase on the build list.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority Order
|
|
||||||
|
|
||||||
1. **Find an artist.** Without a strong visual identity you can't pitch, can't get funding, can't build an audience.
|
|
||||||
2. **Apply for grants** — the concept and architecture are already in good shape for an application.
|
|
||||||
3. **Build to Phase 6** (full run loop) before approaching publishers. A playable vertical slice is worth 10x more than a document.
|
|
||||||
4. **Decide on online PvP scope** before any publisher conversation — it changes the funding ask significantly.
|
|
||||||
@@ -85,6 +85,8 @@ func _die() -> void:
|
|||||||
queue_free()
|
queue_free()
|
||||||
|
|
||||||
func _spawn_drop() -> void:
|
func _spawn_drop() -> void:
|
||||||
|
if not RunManager.run_active:
|
||||||
|
return # No drops in practice/hub
|
||||||
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
|
var orb = preload("res://scenes/enemies/currency_orb.tscn").instantiate()
|
||||||
orb.position = (get_parent() as Node2D).to_local(global_position)
|
orb.position = (get_parent() as Node2D).to_local(global_position)
|
||||||
get_parent().call_deferred("add_child", orb)
|
get_parent().call_deferred("add_child", orb)
|
||||||
|
|||||||
+450
-157
@@ -2,8 +2,8 @@ extends EnemyBase
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Hivemind — Floor 3 Final Boss
|
# Hivemind — Floor 3 Final Boss
|
||||||
# Phase 1: Assembly (spawn waves, enemies join core on death)
|
# Phase 1: Assembly (spawn waves, enemies join core on death, core grows)
|
||||||
# Phase 2: Active (mimics Repeater spread, Warden sweep, Drifter projectiles)
|
# Phase 2: Active (teleports like Relay, orbiting projectiles follow)
|
||||||
# Phase 3: Unique (rotating beam, room pulse, homing projectiles)
|
# Phase 3: Unique (rotating beam, room pulse, homing projectiles)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -15,37 +15,46 @@ enum Phase { ASSEMBLY, ACTIVE, UNIQUE, CLIMAX }
|
|||||||
const ROOM_HW := 480.0
|
const ROOM_HW := 480.0
|
||||||
const ROOM_HH := 270.0
|
const ROOM_HH := 270.0
|
||||||
const WALL_T := 32.0
|
const WALL_T := 32.0
|
||||||
|
const WALL_MARGIN := 80.0
|
||||||
|
|
||||||
# Assembly phase
|
# Assembly phase
|
||||||
const WAVE_COUNT := 3
|
const WAVE_COUNT := 3
|
||||||
const ENEMIES_PER_WAVE := 3
|
const ENEMIES_PER_WAVE := 3
|
||||||
const WAVE_DELAY := 3.0
|
const WAVE_DELAY := 3.5
|
||||||
const ASSEMBLY_COMPLETE := 8 # kills needed to complete assembly
|
|
||||||
|
|
||||||
# Phase 2 attacks
|
# Teleport (like Relay)
|
||||||
const ATTACK_CD_P2 := 3.5
|
const TELEPORT_CD_MIN := 5.0
|
||||||
const SPREAD_COUNT := 5
|
const TELEPORT_CD_MAX := 7.0
|
||||||
const SPREAD_ANGLE := 0.6 # radians total spread
|
const FADE_OUT_TIME := 0.15
|
||||||
const SPREAD_SPEED := 120.0
|
const TRANSIT_TIME := 0.5
|
||||||
const SWEEP_SPEED := 60.0
|
|
||||||
const SWEEP_PROJ_SPACE := 22.0
|
# Orbiting projectiles
|
||||||
const DRIFT_COUNT := 4
|
const ORBIT_COUNT := 6
|
||||||
const DRIFT_SPEED := 45.0
|
const ORBIT_RADIUS := 60.0
|
||||||
const DRIFT_CURVE := 0.8
|
const ORBIT_SPEED := 2.0 # rad/sec
|
||||||
|
const ORBIT_PROJ_SIZE := 10.0
|
||||||
|
const ORBIT_TRANSITION_SPEED := 200.0 # how fast orbiters follow after teleport
|
||||||
|
|
||||||
|
# Sweep attack (from Warden)
|
||||||
|
const SWEEP_CD := 4.5
|
||||||
|
const SWEEP_SPEED := 65.0
|
||||||
|
const SWEEP_PROJ_SPACE := 22.0
|
||||||
|
const SWEEP_GAP_W := 90.0
|
||||||
|
|
||||||
# Phase 3 attacks
|
# Phase 3 attacks
|
||||||
const ATTACK_CD_P3 := 2.5
|
const ATTACK_CD_P3 := 2.5
|
||||||
const BEAM_ROT_SPEED := 1.2 # rad/sec
|
const BEAM_TELEGRAPH := 0.8 # warning time before beam fires
|
||||||
const BEAM_DURATION := 3.0
|
const BEAM_ROT_SPEED := 1.2 # rad/sec
|
||||||
const BEAM_LENGTH := 500.0
|
const BEAM_DURATION := 3.0
|
||||||
const BEAM_WIDTH := 28.0
|
const BEAM_LENGTH := 500.0
|
||||||
const PULSE_FORCE := 350.0
|
const BEAM_WIDTH := 28.0
|
||||||
const PULSE_CD := 5.0
|
const PULSE_FORCE := 350.0
|
||||||
const HOMING_COUNT := 3
|
const PULSE_CD := 5.0
|
||||||
const HOMING_SPEED := 70.0
|
const HOMING_COUNT := 4
|
||||||
const HOMING_TURN := 1.5 # rad/sec
|
const HOMING_SPEED := 80.0
|
||||||
|
const HOMING_TURN := 1.8 # rad/sec
|
||||||
|
|
||||||
const FREEZE_DURATION := 2.0
|
const FREEZE_DURATION := 2.0
|
||||||
|
|
||||||
const DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn")
|
const DRIFTER_SCENE = preload("res://scenes/enemies/drifter.tscn")
|
||||||
const REPEATER_SCENE = preload("res://scenes/enemies/repeater.tscn")
|
const REPEATER_SCENE = preload("res://scenes/enemies/repeater.tscn")
|
||||||
@@ -59,31 +68,49 @@ var _room_center: Vector2
|
|||||||
|
|
||||||
# Assembly
|
# Assembly
|
||||||
var _wave_index: int = 0
|
var _wave_index: int = 0
|
||||||
var _wave_timer: float = 2.5 # initial delay before first wave
|
var _wave_timer: float = 2.0
|
||||||
var _kills: int = 0
|
|
||||||
var _assembly_parts: Array[Node2D] = []
|
|
||||||
var _spawned_enemies: Array[Node] = []
|
var _spawned_enemies: Array[Node] = []
|
||||||
|
var _core_scale: float = 0.3 # starts small, grows to 1.0
|
||||||
|
|
||||||
# Core visual
|
# Core visual
|
||||||
var _core_visual: Node2D = null
|
var _core_visual: Node2D = null
|
||||||
var _core_poly: Polygon2D = null
|
var _core_poly: Polygon2D = null
|
||||||
|
var _contact_area: Area2D = null
|
||||||
|
|
||||||
# Phase 2/3 attacks
|
# Teleport state
|
||||||
var _attack_timer: float = 2.0
|
enum TeleportState { IDLE, FADING_OUT, IN_TRANSIT }
|
||||||
var _attack_index: int = 0
|
var _tp_state: int = TeleportState.IDLE
|
||||||
var _projectiles: Array[Dictionary] = [] # {node, vel, type, ...}
|
var _teleport_timer: float = 3.0
|
||||||
|
var _fade_timer: float = 0.0
|
||||||
|
var _transit_timer: float = 0.0
|
||||||
|
|
||||||
|
# Orbiting projectiles
|
||||||
|
var _orbiters: Array[Dictionary] = [] # {node, angle, target_pos}
|
||||||
|
var _orbit_angle: float = 0.0
|
||||||
|
|
||||||
|
# Sweep attack
|
||||||
|
var _sweep_timer: float = 3.0
|
||||||
|
var _sweep_projs: Array[Dictionary] = []
|
||||||
|
|
||||||
|
# Phase 3 attacks
|
||||||
|
var _attack_timer: float = 2.0
|
||||||
|
var _attack_index: int = 0
|
||||||
|
var _homing_projs: Array[Dictionary] = []
|
||||||
|
|
||||||
# Beam state
|
# Beam state
|
||||||
var _beam_active: bool = false
|
var _beam_telegraphing: bool = false
|
||||||
var _beam_angle: float = 0.0
|
var _beam_active: bool = false
|
||||||
var _beam_timer: float = 0.0
|
var _beam_angle: float = 0.0
|
||||||
var _beam_node: Node2D = null
|
var _beam_timer: float = 0.0
|
||||||
|
var _beam_node: Node2D = null
|
||||||
|
var _telegraph_node: Node2D = null
|
||||||
|
var _telegraph_timer: float = 0.0
|
||||||
|
|
||||||
# Pulse state
|
# Pulse state
|
||||||
var _pulse_timer: float = 3.0
|
var _pulse_timer: float = 3.0
|
||||||
|
|
||||||
# Climax
|
# Climax
|
||||||
var _climax_timer: float = 0.0
|
var _climax_timer: float = 0.0
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
@@ -92,12 +119,18 @@ func _ready() -> void:
|
|||||||
super._ready()
|
super._ready()
|
||||||
max_health = 350.0
|
max_health = 350.0
|
||||||
current_health = max_health
|
current_health = max_health
|
||||||
contact_damage = 1.0
|
contact_damage = 1.5
|
||||||
contact_cooldown = 0.8
|
contact_cooldown = 0.8
|
||||||
|
|
||||||
_room_center = global_position
|
_room_center = global_position
|
||||||
_build_core_visual()
|
_build_core_visual()
|
||||||
_core_visual.visible = false # hidden during assembly
|
_core_visual.visible = false # hidden during assembly
|
||||||
|
_core_visual.scale = Vector2(_core_scale, _core_scale)
|
||||||
|
|
||||||
|
# Disable contact during assembly
|
||||||
|
if _contact_area:
|
||||||
|
_contact_area.monitoring = false
|
||||||
|
_contact_area.monitorable = false
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
@@ -115,7 +148,9 @@ func _physics_process(delta: float) -> void:
|
|||||||
Phase.CLIMAX:
|
Phase.CLIMAX:
|
||||||
_tick_climax(delta)
|
_tick_climax(delta)
|
||||||
|
|
||||||
_tick_projectiles(delta)
|
_tick_orbiters(delta)
|
||||||
|
_tick_sweep_projs(delta)
|
||||||
|
_tick_homing_projs(delta)
|
||||||
_tick_flash(delta)
|
_tick_flash(delta)
|
||||||
velocity = Vector2.ZERO
|
velocity = Vector2.ZERO
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
@@ -148,9 +183,28 @@ func _build_core_visual() -> void:
|
|||||||
eye.color = Color(0.8, 0.2, 0.5)
|
eye.color = Color(0.8, 0.2, 0.5)
|
||||||
_core_visual.add_child(eye)
|
_core_visual.add_child(eye)
|
||||||
|
|
||||||
|
# Contact area for damage
|
||||||
|
_contact_area = Area2D.new()
|
||||||
|
_contact_area.collision_layer = 0
|
||||||
|
_contact_area.collision_mask = 2
|
||||||
|
var cs := CollisionShape2D.new()
|
||||||
|
var circle := CircleShape2D.new()
|
||||||
|
circle.radius = 40.0
|
||||||
|
cs.shape = circle
|
||||||
|
_contact_area.add_child(cs)
|
||||||
|
_contact_area.body_entered.connect(_on_contact_hit)
|
||||||
|
_core_visual.add_child(_contact_area)
|
||||||
|
|
||||||
add_child(_core_visual)
|
add_child(_core_visual)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_contact_hit(body: Node) -> void:
|
||||||
|
if _phase == Phase.ASSEMBLY:
|
||||||
|
return
|
||||||
|
if body.is_in_group("player") and body.has_method("take_damage"):
|
||||||
|
body.take_damage(contact_damage)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Phase 1 — Assembly
|
# Phase 1 — Assembly
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -159,10 +213,10 @@ func _tick_assembly(delta: float) -> void:
|
|||||||
if _wave_timer <= 0.0 and _wave_index < WAVE_COUNT:
|
if _wave_timer <= 0.0 and _wave_index < WAVE_COUNT:
|
||||||
_spawn_wave()
|
_spawn_wave()
|
||||||
_wave_index += 1
|
_wave_index += 1
|
||||||
_wave_timer = WAVE_DELAY + randf_range(0.0, 1.0)
|
_wave_timer = WAVE_DELAY
|
||||||
|
|
||||||
# Check if assembly complete
|
# Check if assembly complete: all waves spawned AND all enemies dead
|
||||||
if _kills >= ASSEMBLY_COMPLETE:
|
if _wave_index >= WAVE_COUNT and _spawned_enemies.size() == 0:
|
||||||
_enter_active()
|
_enter_active()
|
||||||
|
|
||||||
|
|
||||||
@@ -179,8 +233,8 @@ func _spawn_wave() -> void:
|
|||||||
randf_range(-hh, hh)
|
randf_range(-hh, hh)
|
||||||
)
|
)
|
||||||
# Avoid spawning too close to center
|
# Avoid spawning too close to center
|
||||||
if pos.length() < 100.0:
|
if pos.length() < 120.0:
|
||||||
pos = pos.normalized() * 100.0
|
pos = pos.normalized() * 120.0
|
||||||
get_parent().add_child(enemy)
|
get_parent().add_child(enemy)
|
||||||
enemy.global_position = _room_center + pos
|
enemy.global_position = _room_center + pos
|
||||||
|
|
||||||
@@ -191,110 +245,304 @@ func _spawn_wave() -> void:
|
|||||||
|
|
||||||
func _on_enemy_died(enemy: Node) -> void:
|
func _on_enemy_died(enemy: Node) -> void:
|
||||||
if _phase != Phase.ASSEMBLY:
|
if _phase != Phase.ASSEMBLY:
|
||||||
|
_spawned_enemies.erase(enemy)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Remove from tracked list
|
# Remove from tracked list
|
||||||
_spawned_enemies.erase(enemy)
|
_spawned_enemies.erase(enemy)
|
||||||
_kills += 1
|
|
||||||
|
|
||||||
# Create visual part flying to center (use last known position offset)
|
# Create purple dot flying to center
|
||||||
var part := Polygon2D.new()
|
var part := Polygon2D.new()
|
||||||
var pts := PackedVector2Array()
|
var pts := PackedVector2Array()
|
||||||
for i in 6:
|
for i in 6:
|
||||||
var a := TAU * i / 6.0
|
var a := TAU * i / 6.0
|
||||||
pts.append(Vector2(cos(a), sin(a)) * randf_range(8.0, 14.0))
|
pts.append(Vector2(cos(a), sin(a)) * randf_range(8.0, 14.0))
|
||||||
part.polygon = pts
|
part.polygon = pts
|
||||||
part.color = Color(0.4, 0.2, 0.5, 0.8)
|
part.color = Color(0.5, 0.2, 0.6, 0.9)
|
||||||
# Position near center since enemy is already freed by now
|
# Position at edge since we don't know exact death position
|
||||||
part.global_position = _room_center + Vector2(randf_range(-100, 100), randf_range(-100, 100))
|
part.global_position = _room_center + Vector2(randf_range(-150, 150), randf_range(-150, 150))
|
||||||
get_parent().add_child(part)
|
get_parent().add_child(part)
|
||||||
_assembly_parts.append(part)
|
|
||||||
|
|
||||||
# Tween to center
|
# Tween to center and grow the core
|
||||||
var tw := get_tree().create_tween()
|
var tw := get_tree().create_tween()
|
||||||
tw.tween_property(part, "global_position", _room_center, 0.8).set_ease(Tween.EASE_IN)
|
tw.tween_property(part, "global_position", _room_center, 0.6).set_ease(Tween.EASE_IN)
|
||||||
tw.tween_property(part, "modulate:a", 0.0, 0.3)
|
tw.tween_callback(_grow_core)
|
||||||
|
tw.tween_property(part, "modulate:a", 0.0, 0.2)
|
||||||
tw.tween_callback(part.queue_free)
|
tw.tween_callback(part.queue_free)
|
||||||
|
|
||||||
|
|
||||||
|
func _grow_core() -> void:
|
||||||
|
# Each kill grows the core toward full size
|
||||||
|
var total_enemies := WAVE_COUNT * ENEMIES_PER_WAVE
|
||||||
|
var growth_per_kill := 0.7 / float(total_enemies) # grows from 0.3 to 1.0
|
||||||
|
_core_scale = minf(_core_scale + growth_per_kill, 1.0)
|
||||||
|
|
||||||
|
# Show core once it starts growing
|
||||||
|
if not _core_visual.visible:
|
||||||
|
_core_visual.visible = true
|
||||||
|
_core_visual.scale = Vector2(_core_scale, _core_scale)
|
||||||
|
|
||||||
|
|
||||||
func _enter_active() -> void:
|
func _enter_active() -> void:
|
||||||
_phase = Phase.ACTIVE
|
_phase = Phase.ACTIVE
|
||||||
_core_visual.visible = true
|
_core_visual.visible = true
|
||||||
_attack_timer = 1.5
|
_core_visual.scale = Vector2(1.0, 1.0)
|
||||||
_attack_index = 0
|
|
||||||
|
|
||||||
# Clean up any remaining assembly parts
|
# Enable contact
|
||||||
for part in _assembly_parts:
|
if _contact_area:
|
||||||
if is_instance_valid(part):
|
_contact_area.monitoring = true
|
||||||
part.queue_free()
|
_contact_area.monitorable = true
|
||||||
_assembly_parts.clear()
|
|
||||||
|
_teleport_timer = 2.0
|
||||||
|
_sweep_timer = 3.0
|
||||||
|
|
||||||
|
# Spawn orbiting projectiles
|
||||||
|
_spawn_orbiters()
|
||||||
|
|
||||||
# Emit boss health bar start
|
# Emit boss health bar start
|
||||||
EventBus.boss_health_changed.emit(current_health, max_health)
|
EventBus.boss_health_changed.emit(current_health, max_health)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Phase 2 — Active (mimic attacks)
|
# Phase 2 — Active (teleport + orbiters + sweep)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
func _tick_active(delta: float) -> void:
|
func _tick_active(delta: float) -> void:
|
||||||
_find_player()
|
_find_player()
|
||||||
_tick_contact(delta)
|
_tick_contact(delta)
|
||||||
|
_tick_teleport(delta)
|
||||||
|
|
||||||
_attack_timer -= delta
|
# Sweep attack on timer (only when idle)
|
||||||
if _attack_timer <= 0.0:
|
if _tp_state == TeleportState.IDLE:
|
||||||
_do_active_attack()
|
_sweep_timer -= delta
|
||||||
_attack_index = (_attack_index + 1) % 3
|
if _sweep_timer <= 0.0:
|
||||||
_attack_timer = ATTACK_CD_P2
|
_attack_sweep()
|
||||||
|
_sweep_timer = SWEEP_CD
|
||||||
|
|
||||||
|
|
||||||
func _do_active_attack() -> void:
|
func _tick_teleport(delta: float) -> void:
|
||||||
match _attack_index:
|
match _tp_state:
|
||||||
0: _attack_spread()
|
TeleportState.IDLE:
|
||||||
1: _attack_sweep()
|
_teleport_timer -= delta
|
||||||
2: _attack_drift()
|
if _teleport_timer <= 0.0:
|
||||||
|
_start_fade_out()
|
||||||
|
TeleportState.FADING_OUT:
|
||||||
|
_fade_timer -= delta
|
||||||
|
_core_visual.modulate.a = _fade_timer / FADE_OUT_TIME
|
||||||
|
if _fade_timer <= 0.0:
|
||||||
|
_enter_transit()
|
||||||
|
TeleportState.IN_TRANSIT:
|
||||||
|
_transit_timer -= delta
|
||||||
|
if _transit_timer <= 0.0:
|
||||||
|
_reappear()
|
||||||
|
|
||||||
|
|
||||||
func _attack_spread() -> void:
|
func _start_fade_out() -> void:
|
||||||
# Repeater-style spread shot toward player
|
_tp_state = TeleportState.FADING_OUT
|
||||||
if _player == null:
|
_fade_timer = FADE_OUT_TIME
|
||||||
|
|
||||||
|
|
||||||
|
func _enter_transit() -> void:
|
||||||
|
_tp_state = TeleportState.IN_TRANSIT
|
||||||
|
_core_visual.visible = false
|
||||||
|
_core_visual.modulate.a = 0.0
|
||||||
|
if _contact_area:
|
||||||
|
_contact_area.monitoring = false
|
||||||
|
_transit_timer = TRANSIT_TIME
|
||||||
|
|
||||||
|
|
||||||
|
func _reappear() -> void:
|
||||||
|
# Pick random position within room bounds
|
||||||
|
var min_x := _room_center.x - ROOM_HW + WALL_T + WALL_MARGIN
|
||||||
|
var max_x := _room_center.x + ROOM_HW - WALL_T - WALL_MARGIN
|
||||||
|
var min_y := _room_center.y - ROOM_HH + WALL_T + WALL_MARGIN
|
||||||
|
var max_y := _room_center.y + ROOM_HH - WALL_T - WALL_MARGIN
|
||||||
|
|
||||||
|
global_position = Vector2(
|
||||||
|
randf_range(min_x, max_x),
|
||||||
|
randf_range(min_y, max_y)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show visual with flash
|
||||||
|
_core_visual.visible = true
|
||||||
|
_core_visual.modulate = Color(2.0, 2.0, 2.0, 1.0)
|
||||||
|
if _contact_area:
|
||||||
|
_contact_area.monitoring = true
|
||||||
|
|
||||||
|
var tw := create_tween()
|
||||||
|
var base_color := Color(0.5, 0.15, 0.25) if _phase == Phase.UNIQUE else Color(1, 1, 1, 1)
|
||||||
|
tw.tween_property(_core_visual, "modulate", base_color, 0.15)
|
||||||
|
|
||||||
|
_tp_state = TeleportState.IDLE
|
||||||
|
|
||||||
|
# Set orbiters to transition toward new position (don't teleport with boss)
|
||||||
|
for orb in _orbiters:
|
||||||
|
orb["transitioning"] = true
|
||||||
|
|
||||||
|
_roll_teleport_cooldown()
|
||||||
|
|
||||||
|
|
||||||
|
func _roll_teleport_cooldown() -> void:
|
||||||
|
_teleport_timer = randf_range(TELEPORT_CD_MIN, TELEPORT_CD_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Orbiting Projectiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
func _spawn_orbiters() -> void:
|
||||||
|
for i in ORBIT_COUNT:
|
||||||
|
var angle := TAU * i / float(ORBIT_COUNT)
|
||||||
|
var orb := _create_orbiter(angle)
|
||||||
|
_orbiters.append(orb)
|
||||||
|
|
||||||
|
|
||||||
|
func _create_orbiter(angle: float) -> Dictionary:
|
||||||
|
var proj := Node2D.new()
|
||||||
|
|
||||||
|
var poly := Polygon2D.new()
|
||||||
|
var hs := ORBIT_PROJ_SIZE * 0.5
|
||||||
|
poly.polygon = PackedVector2Array([
|
||||||
|
Vector2(-hs, -hs), Vector2(hs, -hs),
|
||||||
|
Vector2(hs, hs), Vector2(-hs, hs)
|
||||||
|
])
|
||||||
|
poly.color = Color(0.6, 0.3, 0.7)
|
||||||
|
proj.add_child(poly)
|
||||||
|
|
||||||
|
var area := Area2D.new()
|
||||||
|
area.collision_layer = 0
|
||||||
|
area.collision_mask = 2
|
||||||
|
var cs := CollisionShape2D.new()
|
||||||
|
var circle := CircleShape2D.new()
|
||||||
|
circle.radius = hs
|
||||||
|
cs.shape = circle
|
||||||
|
area.add_child(cs)
|
||||||
|
area.body_entered.connect(_on_orbiter_hit.bind(proj))
|
||||||
|
proj.add_child(area)
|
||||||
|
|
||||||
|
get_parent().add_child(proj)
|
||||||
|
proj.global_position = global_position + Vector2.from_angle(angle) * ORBIT_RADIUS
|
||||||
|
|
||||||
|
return {"node": proj, "angle": angle, "transitioning": false}
|
||||||
|
|
||||||
|
|
||||||
|
func _on_orbiter_hit(body: Node, proj: Node2D) -> void:
|
||||||
|
if body.is_in_group("player") and body.has_method("take_damage"):
|
||||||
|
body.take_damage(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _tick_orbiters(delta: float) -> void:
|
||||||
|
if _phase == Phase.ASSEMBLY:
|
||||||
return
|
return
|
||||||
|
|
||||||
var base_dir := ((_player.global_position) - global_position).normalized()
|
_orbit_angle += ORBIT_SPEED * delta
|
||||||
var start_angle := base_dir.angle() - SPREAD_ANGLE * 0.5
|
|
||||||
var step := SPREAD_ANGLE / float(SPREAD_COUNT - 1) if SPREAD_COUNT > 1 else 0.0
|
|
||||||
|
|
||||||
for i in SPREAD_COUNT:
|
for orb in _orbiters:
|
||||||
var angle := start_angle + step * i
|
var node: Node2D = orb["node"]
|
||||||
var dir := Vector2.from_angle(angle)
|
if not is_instance_valid(node):
|
||||||
_spawn_projectile(global_position, dir * SPREAD_SPEED, "spread")
|
continue
|
||||||
|
|
||||||
|
var angle: float = orb["angle"] + _orbit_angle
|
||||||
|
var target_pos := global_position + Vector2.from_angle(angle) * ORBIT_RADIUS
|
||||||
|
|
||||||
|
if orb["transitioning"]:
|
||||||
|
# Move toward target position
|
||||||
|
var dir := (target_pos - node.global_position).normalized()
|
||||||
|
var dist := node.global_position.distance_to(target_pos)
|
||||||
|
if dist < ORBIT_TRANSITION_SPEED * delta:
|
||||||
|
node.global_position = target_pos
|
||||||
|
orb["transitioning"] = false
|
||||||
|
else:
|
||||||
|
node.global_position += dir * ORBIT_TRANSITION_SPEED * delta
|
||||||
|
else:
|
||||||
|
# Normal orbit
|
||||||
|
node.global_position = target_pos
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_orbiters() -> void:
|
||||||
|
for orb in _orbiters:
|
||||||
|
if is_instance_valid(orb["node"]):
|
||||||
|
orb["node"].queue_free()
|
||||||
|
_orbiters.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sweep Attack (from Warden)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
func _attack_sweep() -> void:
|
func _attack_sweep() -> void:
|
||||||
# Warden-style horizontal sweep with gaps
|
|
||||||
var inner_left := -ROOM_HW + WALL_T
|
var inner_left := -ROOM_HW + WALL_T
|
||||||
var inner_right := ROOM_HW - WALL_T
|
var inner_right := ROOM_HW - WALL_T
|
||||||
var sweep_dir := 1.0 if randf() < 0.5 else -1.0
|
var sweep_dir := 1.0 if randf() < 0.5 else -1.0
|
||||||
var start_y := _room_center.y + (-ROOM_HH + WALL_T) * -sweep_dir
|
|
||||||
var vel := Vector2(0.0, SWEEP_SPEED * sweep_dir)
|
# Fix: sweep_dir 1 = moving DOWN, so start at TOP
|
||||||
|
var start_y: float
|
||||||
|
if sweep_dir > 0.0:
|
||||||
|
start_y = _room_center.y - ROOM_HH + WALL_T - 10.0 # top edge
|
||||||
|
else:
|
||||||
|
start_y = _room_center.y + ROOM_HH - WALL_T + 10.0 # bottom edge
|
||||||
|
|
||||||
|
var vel := Vector2(0.0, SWEEP_SPEED * sweep_dir)
|
||||||
|
|
||||||
# One gap
|
# One gap
|
||||||
var gap_center := randf_range(inner_left + 80.0, inner_right - 80.0)
|
var gap_center := randf_range(inner_left + 100.0, inner_right - 100.0)
|
||||||
var gap_width := 90.0
|
|
||||||
|
|
||||||
var x := inner_left
|
var x := inner_left
|
||||||
while x <= inner_right:
|
while x <= inner_right:
|
||||||
if abs(x - gap_center) > gap_width * 0.5:
|
if abs(x - gap_center) > SWEEP_GAP_W * 0.5:
|
||||||
_spawn_projectile(Vector2(_room_center.x + x, start_y), vel, "sweep")
|
_spawn_sweep_proj(Vector2(_room_center.x + x, start_y), vel)
|
||||||
x += SWEEP_PROJ_SPACE
|
x += SWEEP_PROJ_SPACE
|
||||||
|
|
||||||
|
|
||||||
func _attack_drift() -> void:
|
func _spawn_sweep_proj(pos: Vector2, vel: Vector2) -> void:
|
||||||
# Drifter-style curving projectiles
|
var proj := Node2D.new()
|
||||||
for i in DRIFT_COUNT:
|
|
||||||
var angle := TAU * i / float(DRIFT_COUNT) + randf_range(-0.2, 0.2)
|
var poly := Polygon2D.new()
|
||||||
var dir := Vector2.from_angle(angle)
|
poly.polygon = PackedVector2Array([
|
||||||
var curve := DRIFT_CURVE if randf() < 0.5 else -DRIFT_CURVE
|
Vector2(-7, -7), Vector2(7, -7),
|
||||||
_spawn_projectile(global_position, dir * DRIFT_SPEED, "drift", {"curve": curve})
|
Vector2(7, 7), Vector2(-7, 7)
|
||||||
|
])
|
||||||
|
poly.color = Color(0.7, 0.3, 0.5)
|
||||||
|
proj.add_child(poly)
|
||||||
|
|
||||||
|
var area := Area2D.new()
|
||||||
|
area.collision_layer = 0
|
||||||
|
area.collision_mask = 2
|
||||||
|
var cs := CollisionShape2D.new()
|
||||||
|
var circle := CircleShape2D.new()
|
||||||
|
circle.radius = 6.0
|
||||||
|
cs.shape = circle
|
||||||
|
area.add_child(cs)
|
||||||
|
area.body_entered.connect(_on_sweep_hit.bind(proj))
|
||||||
|
proj.add_child(area)
|
||||||
|
|
||||||
|
get_parent().add_child(proj)
|
||||||
|
proj.global_position = pos
|
||||||
|
|
||||||
|
_sweep_projs.append({"node": proj, "vel": vel})
|
||||||
|
|
||||||
|
|
||||||
|
func _on_sweep_hit(body: Node, proj: Node2D) -> void:
|
||||||
|
if body.is_in_group("player") and body.has_method("take_damage"):
|
||||||
|
body.take_damage(1.0)
|
||||||
|
if is_instance_valid(proj):
|
||||||
|
proj.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _tick_sweep_projs(delta: float) -> void:
|
||||||
|
var still_valid: Array[Dictionary] = []
|
||||||
|
|
||||||
|
for p in _sweep_projs:
|
||||||
|
var node: Node2D = p["node"]
|
||||||
|
if not is_instance_valid(node):
|
||||||
|
continue
|
||||||
|
|
||||||
|
node.global_position += p["vel"] * delta
|
||||||
|
|
||||||
|
# Bounds check
|
||||||
|
if absf(node.global_position.y - _room_center.y) > ROOM_HH + 30.0:
|
||||||
|
node.queue_free()
|
||||||
|
else:
|
||||||
|
still_valid.append(p)
|
||||||
|
|
||||||
|
_sweep_projs = still_valid
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -302,7 +550,7 @@ func _attack_drift() -> void:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
func _enter_unique() -> void:
|
func _enter_unique() -> void:
|
||||||
_phase = Phase.UNIQUE
|
_phase = Phase.UNIQUE
|
||||||
_attack_timer = 1.0
|
_attack_timer = 1.5
|
||||||
_attack_index = 0
|
_attack_index = 0
|
||||||
_pulse_timer = PULSE_CD * 0.5
|
_pulse_timer = PULSE_CD * 0.5
|
||||||
|
|
||||||
@@ -313,9 +561,16 @@ func _enter_unique() -> void:
|
|||||||
func _tick_unique(delta: float) -> void:
|
func _tick_unique(delta: float) -> void:
|
||||||
_find_player()
|
_find_player()
|
||||||
_tick_contact(delta)
|
_tick_contact(delta)
|
||||||
|
_tick_teleport(delta)
|
||||||
|
|
||||||
# Rotating beam
|
# Only attack when not teleporting
|
||||||
if _beam_active:
|
if _tp_state != TeleportState.IDLE:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Beam telegraph phase
|
||||||
|
if _beam_telegraphing:
|
||||||
|
_tick_beam_telegraph(delta)
|
||||||
|
elif _beam_active:
|
||||||
_tick_beam(delta)
|
_tick_beam(delta)
|
||||||
else:
|
else:
|
||||||
_attack_timer -= delta
|
_attack_timer -= delta
|
||||||
@@ -335,13 +590,57 @@ func _do_unique_attack() -> void:
|
|||||||
match _attack_index:
|
match _attack_index:
|
||||||
0: _start_beam()
|
0: _start_beam()
|
||||||
1: _attack_homing()
|
1: _attack_homing()
|
||||||
2: _attack_homing() # double up on homing since beam takes time
|
2: _attack_sweep()
|
||||||
|
|
||||||
|
|
||||||
func _start_beam() -> void:
|
func _start_beam() -> void:
|
||||||
|
# Start with telegraph phase
|
||||||
|
_beam_telegraphing = true
|
||||||
|
_telegraph_timer = BEAM_TELEGRAPH
|
||||||
|
_beam_angle = randf() * TAU
|
||||||
|
|
||||||
|
# Create thin flashing telegraph line
|
||||||
|
_telegraph_node = Node2D.new()
|
||||||
|
_telegraph_node.position = Vector2.ZERO
|
||||||
|
|
||||||
|
var poly := Polygon2D.new()
|
||||||
|
poly.polygon = PackedVector2Array([
|
||||||
|
Vector2(0, -2), Vector2(BEAM_LENGTH, -2),
|
||||||
|
Vector2(BEAM_LENGTH, 2), Vector2(0, 2)
|
||||||
|
])
|
||||||
|
poly.color = Color(1.0, 0.5, 0.5, 0.8)
|
||||||
|
poly.name = "TelegraphLine"
|
||||||
|
_telegraph_node.add_child(poly)
|
||||||
|
|
||||||
|
_telegraph_node.rotation = _beam_angle
|
||||||
|
add_child(_telegraph_node)
|
||||||
|
|
||||||
|
|
||||||
|
func _tick_beam_telegraph(delta: float) -> void:
|
||||||
|
_telegraph_timer -= delta
|
||||||
|
|
||||||
|
# Flash the telegraph line
|
||||||
|
if _telegraph_node != null:
|
||||||
|
var line := _telegraph_node.get_node_or_null("TelegraphLine") as Polygon2D
|
||||||
|
if line:
|
||||||
|
var flash := fmod(_telegraph_timer, 0.15) < 0.075
|
||||||
|
line.color = Color(1.0, 0.3, 0.3, 0.9) if flash else Color(1.0, 0.7, 0.7, 0.5)
|
||||||
|
|
||||||
|
if _telegraph_timer <= 0.0:
|
||||||
|
_end_telegraph_start_beam()
|
||||||
|
|
||||||
|
|
||||||
|
func _end_telegraph_start_beam() -> void:
|
||||||
|
_beam_telegraphing = false
|
||||||
|
|
||||||
|
# Remove telegraph
|
||||||
|
if _telegraph_node != null:
|
||||||
|
_telegraph_node.queue_free()
|
||||||
|
_telegraph_node = null
|
||||||
|
|
||||||
|
# Now spawn the actual beam
|
||||||
_beam_active = true
|
_beam_active = true
|
||||||
_beam_timer = BEAM_DURATION
|
_beam_timer = BEAM_DURATION
|
||||||
_beam_angle = randf() * TAU
|
|
||||||
|
|
||||||
_beam_node = Node2D.new()
|
_beam_node = Node2D.new()
|
||||||
_beam_node.position = Vector2.ZERO
|
_beam_node.position = Vector2.ZERO
|
||||||
@@ -367,6 +666,7 @@ func _start_beam() -> void:
|
|||||||
area.body_entered.connect(_on_beam_hit)
|
area.body_entered.connect(_on_beam_hit)
|
||||||
_beam_node.add_child(area)
|
_beam_node.add_child(area)
|
||||||
|
|
||||||
|
_beam_node.rotation = _beam_angle
|
||||||
add_child(_beam_node)
|
add_child(_beam_node)
|
||||||
|
|
||||||
|
|
||||||
@@ -382,9 +682,13 @@ func _tick_beam(delta: float) -> void:
|
|||||||
|
|
||||||
func _end_beam() -> void:
|
func _end_beam() -> void:
|
||||||
_beam_active = false
|
_beam_active = false
|
||||||
|
_beam_telegraphing = false
|
||||||
if _beam_node != null:
|
if _beam_node != null:
|
||||||
_beam_node.queue_free()
|
_beam_node.queue_free()
|
||||||
_beam_node = null
|
_beam_node = null
|
||||||
|
if _telegraph_node != null:
|
||||||
|
_telegraph_node.queue_free()
|
||||||
|
_telegraph_node = null
|
||||||
|
|
||||||
|
|
||||||
func _on_beam_hit(body: Node) -> void:
|
func _on_beam_hit(body: Node) -> void:
|
||||||
@@ -393,7 +697,6 @@ func _on_beam_hit(body: Node) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _attack_pulse() -> void:
|
func _attack_pulse() -> void:
|
||||||
# Push player toward walls
|
|
||||||
if _player == null or not is_instance_valid(_player):
|
if _player == null or not is_instance_valid(_player):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -421,30 +724,19 @@ func _attack_pulse() -> void:
|
|||||||
func _attack_homing() -> void:
|
func _attack_homing() -> void:
|
||||||
for i in HOMING_COUNT:
|
for i in HOMING_COUNT:
|
||||||
var angle := TAU * i / float(HOMING_COUNT) + randf_range(-0.3, 0.3)
|
var angle := TAU * i / float(HOMING_COUNT) + randf_range(-0.3, 0.3)
|
||||||
var dir := Vector2.from_angle(angle)
|
_spawn_homing_proj(angle)
|
||||||
_spawn_projectile(global_position + dir * 30.0, dir * HOMING_SPEED, "homing")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
func _spawn_homing_proj(start_angle: float) -> void:
|
||||||
# Projectile system
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
func _spawn_projectile(pos: Vector2, vel: Vector2, type: String, extra: Dictionary = {}) -> void:
|
|
||||||
var proj := Node2D.new()
|
var proj := Node2D.new()
|
||||||
|
proj.set_meta("angle", start_angle)
|
||||||
|
|
||||||
var poly := Polygon2D.new()
|
var poly := Polygon2D.new()
|
||||||
var size := 10.0 if type != "sweep" else 14.0
|
|
||||||
var hs := size * 0.5
|
|
||||||
poly.polygon = PackedVector2Array([
|
poly.polygon = PackedVector2Array([
|
||||||
Vector2(-hs, -hs), Vector2(hs, -hs),
|
Vector2(0, -8), Vector2(5, 0),
|
||||||
Vector2(hs, hs), Vector2(-hs, hs)
|
Vector2(0, 8), Vector2(-5, 0)
|
||||||
])
|
])
|
||||||
|
poly.color = Color(1.0, 0.2, 0.4)
|
||||||
match type:
|
|
||||||
"spread": poly.color = Color(0.9, 0.5, 0.2)
|
|
||||||
"sweep": poly.color = Color(0.7, 0.3, 0.5)
|
|
||||||
"drift": poly.color = Color(0.5, 0.4, 0.8)
|
|
||||||
"homing": poly.color = Color(1.0, 0.2, 0.4)
|
|
||||||
|
|
||||||
proj.add_child(poly)
|
proj.add_child(poly)
|
||||||
|
|
||||||
var area := Area2D.new()
|
var area := Area2D.new()
|
||||||
@@ -452,70 +744,56 @@ func _spawn_projectile(pos: Vector2, vel: Vector2, type: String, extra: Dictiona
|
|||||||
area.collision_mask = 2
|
area.collision_mask = 2
|
||||||
var cs := CollisionShape2D.new()
|
var cs := CollisionShape2D.new()
|
||||||
var circle := CircleShape2D.new()
|
var circle := CircleShape2D.new()
|
||||||
circle.radius = hs
|
circle.radius = 6.0
|
||||||
cs.shape = circle
|
cs.shape = circle
|
||||||
area.add_child(cs)
|
area.add_child(cs)
|
||||||
area.body_entered.connect(_on_proj_hit.bind(proj))
|
area.body_entered.connect(_on_homing_hit.bind(proj))
|
||||||
proj.add_child(area)
|
proj.add_child(area)
|
||||||
|
|
||||||
get_parent().add_child(proj)
|
get_parent().add_child(proj)
|
||||||
proj.global_position = pos # Set AFTER add_child for correct transform
|
proj.global_position = global_position + Vector2.from_angle(start_angle) * 30.0
|
||||||
|
|
||||||
var data := {"node": proj, "vel": vel, "type": type}
|
_homing_projs.append({"node": proj, "angle": start_angle})
|
||||||
data.merge(extra)
|
|
||||||
_projectiles.append(data)
|
|
||||||
|
|
||||||
|
|
||||||
func _on_proj_hit(body: Node, proj: Node2D) -> void:
|
func _on_homing_hit(body: Node, proj: Node2D) -> void:
|
||||||
if body.is_in_group("player") and body.has_method("take_damage"):
|
if body.is_in_group("player") and body.has_method("take_damage"):
|
||||||
body.take_damage(1.0)
|
body.take_damage(1.0)
|
||||||
if is_instance_valid(proj):
|
if is_instance_valid(proj):
|
||||||
proj.queue_free()
|
proj.queue_free()
|
||||||
|
|
||||||
|
|
||||||
func _tick_projectiles(delta: float) -> void:
|
func _tick_homing_projs(delta: float) -> void:
|
||||||
|
if _player == null or not is_instance_valid(_player):
|
||||||
|
return
|
||||||
|
|
||||||
var still_valid: Array[Dictionary] = []
|
var still_valid: Array[Dictionary] = []
|
||||||
|
|
||||||
for p in _projectiles:
|
for p in _homing_projs:
|
||||||
var node: Node2D = p["node"]
|
var node: Node2D = p["node"]
|
||||||
if not is_instance_valid(node):
|
if not is_instance_valid(node):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
var vel: Vector2 = p["vel"]
|
var angle: float = p["angle"]
|
||||||
|
var to_player := (_player.global_position - node.global_position).normalized()
|
||||||
|
var desired_angle := to_player.angle()
|
||||||
|
|
||||||
# Apply curve for drift projectiles
|
var diff := angle_difference(angle, desired_angle)
|
||||||
if p["type"] == "drift" and p.has("curve"):
|
angle += clampf(diff, -HOMING_TURN * delta, HOMING_TURN * delta)
|
||||||
var curve_rate: float = p["curve"]
|
p["angle"] = angle
|
||||||
vel = vel.rotated(curve_rate * delta)
|
|
||||||
p["vel"] = vel
|
|
||||||
|
|
||||||
# Homing behavior
|
var dir := Vector2.from_angle(angle)
|
||||||
if p["type"] == "homing" and _player != null and is_instance_valid(_player):
|
node.global_position += dir * HOMING_SPEED * delta
|
||||||
var to_player := (_player.global_position - node.global_position).normalized()
|
node.rotation = angle + PI / 2
|
||||||
var current_dir := vel.normalized()
|
|
||||||
var new_dir := current_dir.rotated(
|
|
||||||
clampf(current_dir.angle_to(to_player), -HOMING_TURN * delta, HOMING_TURN * delta)
|
|
||||||
)
|
|
||||||
vel = new_dir * vel.length()
|
|
||||||
p["vel"] = vel
|
|
||||||
|
|
||||||
node.global_position += vel * delta
|
|
||||||
|
|
||||||
# Bounds check
|
# Bounds check
|
||||||
var rel := node.global_position - _room_center
|
var rel := node.global_position - _room_center
|
||||||
if absf(rel.x) > ROOM_HW + 20.0 or absf(rel.y) > ROOM_HH + 20.0:
|
if absf(rel.x) > ROOM_HW - WALL_T or absf(rel.y) > ROOM_HH - WALL_T:
|
||||||
node.queue_free()
|
node.queue_free()
|
||||||
else:
|
else:
|
||||||
still_valid.append(p)
|
still_valid.append(p)
|
||||||
|
|
||||||
_projectiles = still_valid
|
_homing_projs = still_valid
|
||||||
|
|
||||||
|
|
||||||
func _clear_projectiles() -> void:
|
|
||||||
for p in _projectiles:
|
|
||||||
if is_instance_valid(p["node"]):
|
|
||||||
p["node"].queue_free()
|
|
||||||
_projectiles.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -524,6 +802,8 @@ func _clear_projectiles() -> void:
|
|||||||
func take_damage(amount: float) -> void:
|
func take_damage(amount: float) -> void:
|
||||||
if _phase == Phase.ASSEMBLY or _phase == Phase.CLIMAX:
|
if _phase == Phase.ASSEMBLY or _phase == Phase.CLIMAX:
|
||||||
return # invulnerable during assembly and climax
|
return # invulnerable during assembly and climax
|
||||||
|
if _tp_state != TeleportState.IDLE:
|
||||||
|
return # invulnerable while teleporting
|
||||||
|
|
||||||
current_health -= amount
|
current_health -= amount
|
||||||
_flash_timer = 0.1
|
_flash_timer = 0.1
|
||||||
@@ -538,6 +818,8 @@ func take_damage(amount: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _tick_flash(delta: float) -> void:
|
func _tick_flash(delta: float) -> void:
|
||||||
|
if _phase == Phase.ASSEMBLY:
|
||||||
|
return
|
||||||
if _flash_timer > 0.0:
|
if _flash_timer > 0.0:
|
||||||
_flash_timer -= delta
|
_flash_timer -= delta
|
||||||
_core_poly.color = Color(2.5, 2.5, 2.5)
|
_core_poly.color = Color(2.5, 2.5, 2.5)
|
||||||
@@ -569,7 +851,18 @@ func _tick_climax(delta: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _die() -> void:
|
func _die() -> void:
|
||||||
_clear_projectiles()
|
_clear_orbiters()
|
||||||
_end_beam()
|
_end_beam()
|
||||||
|
|
||||||
|
for p in _sweep_projs:
|
||||||
|
if is_instance_valid(p["node"]):
|
||||||
|
p["node"].queue_free()
|
||||||
|
_sweep_projs.clear()
|
||||||
|
|
||||||
|
for p in _homing_projs:
|
||||||
|
if is_instance_valid(p["node"]):
|
||||||
|
p["node"].queue_free()
|
||||||
|
_homing_projs.clear()
|
||||||
|
|
||||||
EventBus.boss_died.emit()
|
EventBus.boss_died.emit()
|
||||||
queue_free()
|
queue_free()
|
||||||
|
|||||||
Reference in New Issue
Block a user