Add tentacled boss planet encounter
This commit is contained in:
parent
0f7edff15a
commit
40f284dcdd
38 changed files with 1051 additions and 19 deletions
37
boss/BossEncounter.gd
Normal file
37
boss/BossEncounter.gd
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
class_name BossEncounter
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
@onready var boss: Node2D = $Boss
|
||||||
|
@onready var status_label: Label = $EncounterUI/StatusLabel
|
||||||
|
@onready var hint_label: Label = $EncounterUI/HintLabel
|
||||||
|
@onready var game_state: Node = get_node("/root/GameState")
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# One persistent objective lets boss victory flow through the same conquest
|
||||||
|
# state as authored capture-point maps without pretending the boss is a
|
||||||
|
# configurable capture point.
|
||||||
|
game_state.register_capture_points(game_state.planet_id, 1,
|
||||||
|
game_state.planet_definition_id)
|
||||||
|
if game_state.is_planet_conquered(game_state.planet_id):
|
||||||
|
boss.suppress_as_defeated()
|
||||||
|
status_label.text = "THE WARDEN IS DEAD"
|
||||||
|
hint_label.text = ""
|
||||||
|
return
|
||||||
|
boss.defeated.connect(_on_boss_defeated)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if not is_instance_valid(boss) or boss.phase == boss.Phase.DEFEATED:
|
||||||
|
return
|
||||||
|
var phase_name: String = "PHASE II · LASER STORM" \
|
||||||
|
if boss.phase == boss.Phase.LASER_STORM else "PHASE I · HATCHLINGS"
|
||||||
|
status_label.text = "THE WARDEN · %s · EYES %d/%d" % [
|
||||||
|
phase_name, boss.eyes_destroyed, boss.EYE_COUNT]
|
||||||
|
hint_label.text = "DESTROY GOLDEN HATCHLINGS · FOLLOW THEIR LIGHT · BREAK THE OPEN EYES"
|
||||||
|
|
||||||
|
|
||||||
|
func _on_boss_defeated() -> void:
|
||||||
|
game_state.mark_point_captured(game_state.planet_id, 0)
|
||||||
|
status_label.text = "THE WARDEN IS DEAD · PLANET CONQUERED"
|
||||||
|
hint_label.text = ""
|
||||||
1
boss/BossEncounter.gd.uid
Normal file
1
boss/BossEncounter.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bk2e4e3256ifo
|
||||||
38
boss/BossEncounter.tscn
Normal file
38
boss/BossEncounter.tscn
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://boss/BossEncounter.gd" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://boss/OctopusBoss.tscn" id="2"]
|
||||||
|
|
||||||
|
[node name="BossEncounter" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="Boss" parent="." instance=ExtResource("2")]
|
||||||
|
|
||||||
|
[node name="EncounterUI" type="CanvasLayer" parent="."]
|
||||||
|
layer = 2
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="EncounterUI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
offset_left = 20.0
|
||||||
|
offset_top = 268.0
|
||||||
|
offset_right = 700.0
|
||||||
|
offset_bottom = 310.0
|
||||||
|
theme_override_colors/font_color = Color(1, 0.45, 0.72, 1)
|
||||||
|
theme_override_colors/font_outline_color = Color(0.04, 0.005, 0.035, 0.95)
|
||||||
|
theme_override_constants/outline_size = 4
|
||||||
|
theme_override_font_sizes/font_size = 24
|
||||||
|
text = "THE WARDEN"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="HintLabel" type="Label" parent="EncounterUI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
offset_left = 20.0
|
||||||
|
offset_top = 310.0
|
||||||
|
offset_right = 700.0
|
||||||
|
offset_bottom = 350.0
|
||||||
|
theme_override_colors/font_color = Color(0.45, 0.9, 1, 1)
|
||||||
|
theme_override_colors/font_outline_color = Color(0.04, 0.005, 0.035, 0.95)
|
||||||
|
theme_override_constants/outline_size = 3
|
||||||
|
theme_override_font_sizes/font_size = 17
|
||||||
|
text = "DESTROY GOLDEN HATCHLINGS · FOLLOW THEIR LIGHT · BREAK THE OPEN EYES"
|
||||||
|
horizontal_alignment = 1
|
||||||
68
boss/BossEye.gd
Normal file
68
boss/BossEye.gd
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
class_name BossEye
|
||||||
|
extends Area2D
|
||||||
|
|
||||||
|
signal destroyed
|
||||||
|
|
||||||
|
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
|
||||||
|
|
||||||
|
@export var max_hp: int = 3
|
||||||
|
|
||||||
|
var hp: int = 3
|
||||||
|
var opened: bool = false
|
||||||
|
var phase: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
hp = max_hp
|
||||||
|
$CollisionShape2D.disabled = true
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func open_eye() -> void:
|
||||||
|
if opened:
|
||||||
|
return
|
||||||
|
opened = true
|
||||||
|
COMBAT_TARGET.register(self, COMBAT_TARGET.DANGER_PRIORITY)
|
||||||
|
add_to_group("boss_eyes")
|
||||||
|
$CollisionShape2D.set_deferred("disabled", false)
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func take_damage(amount: int) -> bool:
|
||||||
|
if not opened or hp <= 0:
|
||||||
|
return false
|
||||||
|
hp -= maxi(amount, 1)
|
||||||
|
if hp > 0:
|
||||||
|
queue_redraw()
|
||||||
|
return false
|
||||||
|
hp = 0
|
||||||
|
EnemyExplosionVFX.spawn(get_tree().root, global_position)
|
||||||
|
Sfx.play(get_tree(), Sfx.ENEMY_DEATH)
|
||||||
|
destroyed.emit()
|
||||||
|
queue_free()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
phase += delta
|
||||||
|
if opened:
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
var flesh := Color(0.95, 0.18, 0.58)
|
||||||
|
var dark := Color(0.08, 0.005, 0.055)
|
||||||
|
if not opened:
|
||||||
|
# A sealed lid at the end of the tentacle.
|
||||||
|
NeonDraw.draw_glowing_line(self, Vector2(-20, -5), Vector2(20, 5),
|
||||||
|
flesh, 0.5, 0.75, dark)
|
||||||
|
NeonDraw.draw_glowing_line(self, Vector2(-20, 5), Vector2(20, -5),
|
||||||
|
flesh, 0.5, 0.75, dark)
|
||||||
|
return
|
||||||
|
var pulse: float = 0.78 + 0.22 * sin(phase * 5.0)
|
||||||
|
draw_set_transform(Vector2.ZERO, 0.0, Vector2(1.0, 0.66))
|
||||||
|
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 25.0,
|
||||||
|
Color(0.25, 0.92, 1.0), pulse, dark)
|
||||||
|
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 10.0,
|
||||||
|
Color(1.0, 0.9, 0.3), 1.0, dark)
|
||||||
|
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||||
1
boss/BossEye.gd.uid
Normal file
1
boss/BossEye.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b7lgtmpdft5dm
|
||||||
12
boss/BossEye.tscn
Normal file
12
boss/BossEye.tscn
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://boss/BossEye.gd" id="1"]
|
||||||
|
|
||||||
|
[sub_resource type="CircleShape2D" id="CircleShape_eye"]
|
||||||
|
radius = 25.0
|
||||||
|
|
||||||
|
[node name="BossEye" type="Area2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||||
|
shape = SubResource("CircleShape_eye")
|
||||||
96
boss/BossMinion.gd
Normal file
96
boss/BossMinion.gd
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
class_name BossMinion
|
||||||
|
extends Area2D
|
||||||
|
|
||||||
|
signal destroyed
|
||||||
|
|
||||||
|
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
|
||||||
|
|
||||||
|
@export var marked: bool = false
|
||||||
|
@export var max_hp: int = 2
|
||||||
|
@export var speed: float = 245.0
|
||||||
|
@export var acceleration: float = 520.0
|
||||||
|
|
||||||
|
var boss: Node2D
|
||||||
|
var hp: int = 2
|
||||||
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
var phase: float = 0.0
|
||||||
|
var dead: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
hp = max_hp
|
||||||
|
add_to_group("enemies")
|
||||||
|
add_to_group("boss_minions")
|
||||||
|
COMBAT_TARGET.register(self, COMBAT_TARGET.DANGER_PRIORITY)
|
||||||
|
phase = randf() * TAU
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
phase += delta
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet != null and not fleet.ships.is_empty():
|
||||||
|
var to_fleet: Vector2 = fleet.get_centroid() - global_position
|
||||||
|
if to_fleet.length() > 1.0:
|
||||||
|
velocity = velocity.move_toward(to_fleet.normalized() * speed,
|
||||||
|
acceleration * delta)
|
||||||
|
global_position += velocity * delta
|
||||||
|
if velocity.length() > 4.0:
|
||||||
|
rotation = lerp_angle(rotation, velocity.angle() + PI / 2.0,
|
||||||
|
7.0 * delta)
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func take_damage(amount: int) -> bool:
|
||||||
|
if dead:
|
||||||
|
return false
|
||||||
|
hp -= maxi(amount, 1)
|
||||||
|
if hp > 0:
|
||||||
|
return false
|
||||||
|
_die(true)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func collide_with_player_vessel() -> void:
|
||||||
|
_die(false)
|
||||||
|
|
||||||
|
|
||||||
|
func retire() -> void:
|
||||||
|
if dead:
|
||||||
|
return
|
||||||
|
dead = true
|
||||||
|
hp = 0
|
||||||
|
destroyed.emit()
|
||||||
|
queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _die(killed_by_player: bool) -> void:
|
||||||
|
if dead:
|
||||||
|
return
|
||||||
|
dead = true
|
||||||
|
hp = 0
|
||||||
|
if killed_by_player and marked and is_instance_valid(boss):
|
||||||
|
boss.accept_marked_kill(global_position)
|
||||||
|
EnemyExplosionVFX.spawn(get_tree().root, global_position)
|
||||||
|
Sfx.play(get_tree(), Sfx.ENEMY_DEATH)
|
||||||
|
destroyed.emit()
|
||||||
|
queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
var glow := Color(1.0, 0.78, 0.18) if marked else Color(0.9, 0.16, 0.58)
|
||||||
|
var core := Color(0.13, 0.01, 0.09)
|
||||||
|
# A small bulb with four streaming arms: recognisably related to the boss,
|
||||||
|
# but compact enough to read among an entire wave.
|
||||||
|
draw_set_transform(Vector2.ZERO, 0.0, Vector2(1.0, 0.76))
|
||||||
|
NeonDraw.draw_glowing_circle(self, Vector2(0, -7), 13.0, glow, 1.0, core)
|
||||||
|
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||||
|
for i in range(4):
|
||||||
|
var x: float = -12.0 + float(i) * 8.0
|
||||||
|
var curl: float = sin(phase * 5.0 + float(i)) * 5.0
|
||||||
|
NeonDraw.draw_glowing_path(self, PackedVector2Array([
|
||||||
|
Vector2(x, 1), Vector2(x + curl * 0.35, 10), Vector2(x + curl, 21),
|
||||||
|
]), glow, 0.85, 0.35, core)
|
||||||
|
if marked:
|
||||||
|
NeonDraw.draw_glowing_circle(self, Vector2(0, -8), 4.0,
|
||||||
|
Color.WHITE, 1.0, core)
|
||||||
1
boss/BossMinion.gd.uid
Normal file
1
boss/BossMinion.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cq85rc1dgdg82
|
||||||
12
boss/BossMinion.tscn
Normal file
12
boss/BossMinion.tscn
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://boss/BossMinion.gd" id="1"]
|
||||||
|
|
||||||
|
[sub_resource type="CircleShape2D" id="CircleShape_minion"]
|
||||||
|
radius = 16.0
|
||||||
|
|
||||||
|
[node name="BossMinion" type="Area2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||||
|
shape = SubResource("CircleShape_minion")
|
||||||
39
boss/BossReturnSignal.gd
Normal file
39
boss/BossReturnSignal.gd
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
class_name BossReturnSignal
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
@export var speed: float = 520.0
|
||||||
|
|
||||||
|
var target: Node2D
|
||||||
|
var trail: PackedVector2Array = PackedVector2Array()
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
add_to_group("boss_returns")
|
||||||
|
trail.append(global_position)
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not is_instance_valid(target):
|
||||||
|
queue_free()
|
||||||
|
return
|
||||||
|
var destination: Vector2 = target.global_position
|
||||||
|
if global_position.distance_to(destination) <= speed * delta:
|
||||||
|
global_position = destination
|
||||||
|
target.open_next_eye()
|
||||||
|
queue_free()
|
||||||
|
return
|
||||||
|
global_position = global_position.move_toward(destination, speed * delta)
|
||||||
|
trail.append(global_position)
|
||||||
|
while trail.size() > 14:
|
||||||
|
trail.remove_at(0)
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
for i in range(1, trail.size()):
|
||||||
|
var alpha: float = float(i) / float(trail.size())
|
||||||
|
draw_line(trail[i - 1] - global_position, trail[i] - global_position,
|
||||||
|
Color(1.0, 0.82, 0.22, alpha * 0.7), 3.0 + alpha * 5.0, true)
|
||||||
|
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 9.0,
|
||||||
|
Color(1.0, 0.82, 0.22), 1.0, Color(0.16, 0.04, 0.01))
|
||||||
1
boss/BossReturnSignal.gd.uid
Normal file
1
boss/BossReturnSignal.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bq87q6csxgvu0
|
||||||
6
boss/BossReturnSignal.tscn
Normal file
6
boss/BossReturnSignal.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://boss/BossReturnSignal.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="BossReturnSignal" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
213
boss/OctopusBoss.gd
Normal file
213
boss/OctopusBoss.gd
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
class_name OctopusBoss
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
signal phase_changed(phase: int)
|
||||||
|
signal defeated
|
||||||
|
|
||||||
|
const EYE_SCENE: PackedScene = preload("res://boss/BossEye.tscn")
|
||||||
|
const MINION_SCENE: PackedScene = preload("res://boss/BossMinion.tscn")
|
||||||
|
const RETURN_SCENE: PackedScene = preload("res://boss/BossReturnSignal.tscn")
|
||||||
|
const LASER_SCENE: PackedScene = preload("res://enemies/EnemyBigLaserBeam.tscn")
|
||||||
|
|
||||||
|
enum Phase { MINION_WAVES, LASER_STORM, DEFEATED }
|
||||||
|
|
||||||
|
const EYE_COUNT: int = 6
|
||||||
|
const PHASE_TWO_EYES: int = 3
|
||||||
|
const BODY_RADIUS: float = 86.0
|
||||||
|
const TENTACLE_LENGTH: float = 235.0
|
||||||
|
|
||||||
|
@export var first_wave_delay: float = 1.5
|
||||||
|
@export var wave_interval: float = 6.0
|
||||||
|
@export var minions_per_wave: int = 5
|
||||||
|
@export var laser_interval: float = 5.5
|
||||||
|
@export var laser_charge_time: float = 1.6
|
||||||
|
@export var laser_range: float = 1500.0
|
||||||
|
|
||||||
|
var phase: int = Phase.MINION_WAVES
|
||||||
|
var wave_timer: float = 0.0
|
||||||
|
var laser_timer: float = 0.0
|
||||||
|
var wave_index: int = 0
|
||||||
|
var eyes: Array = []
|
||||||
|
var eyes_opened: int = 0
|
||||||
|
var eyes_destroyed: int = 0
|
||||||
|
var spawned_minions: Array = []
|
||||||
|
var return_signals: Array = []
|
||||||
|
var animation_phase: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
add_to_group("bosses")
|
||||||
|
wave_timer = first_wave_delay
|
||||||
|
laser_timer = laser_interval
|
||||||
|
_build_eyes()
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_eyes() -> void:
|
||||||
|
for i in range(EYE_COUNT):
|
||||||
|
var eye: Area2D = EYE_SCENE.instantiate()
|
||||||
|
eye.position = eye_position(i)
|
||||||
|
eye.rotation = eye.position.angle()
|
||||||
|
add_child(eye)
|
||||||
|
eye.destroyed.connect(_on_eye_destroyed.bind(i))
|
||||||
|
eyes.append(eye)
|
||||||
|
|
||||||
|
|
||||||
|
func eye_position(index: int) -> Vector2:
|
||||||
|
var angle: float = -PI / 2.0 + TAU * float(index) / float(EYE_COUNT)
|
||||||
|
# Alternating reach keeps the silhouette organic without randomising the
|
||||||
|
# actual weak-point layout between attempts.
|
||||||
|
var reach: float = TENTACLE_LENGTH * (0.88 if index % 2 == 0 else 1.0)
|
||||||
|
return Vector2.from_angle(angle) * reach
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if phase == Phase.DEFEATED:
|
||||||
|
return
|
||||||
|
animation_phase += delta
|
||||||
|
spawned_minions = spawned_minions.filter(func(node): return is_instance_valid(node))
|
||||||
|
return_signals = return_signals.filter(func(node): return is_instance_valid(node))
|
||||||
|
|
||||||
|
wave_timer -= delta
|
||||||
|
if wave_timer <= 0.0:
|
||||||
|
spawn_wave()
|
||||||
|
wave_timer = wave_interval * (1.25 if phase == Phase.LASER_STORM else 1.0)
|
||||||
|
|
||||||
|
if phase == Phase.LASER_STORM:
|
||||||
|
laser_timer -= delta
|
||||||
|
if laser_timer <= 0.0:
|
||||||
|
fire_laser_volley()
|
||||||
|
laser_timer = laser_interval
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func spawn_wave() -> void:
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet == null or fleet.ships.is_empty() or get_parent() == null:
|
||||||
|
return
|
||||||
|
var count: int = maxi(3, minions_per_wave - (2 if phase == Phase.LASER_STORM else 0))
|
||||||
|
var marked_slot: int = wave_index % count
|
||||||
|
for i in range(count):
|
||||||
|
var minion: Area2D = MINION_SCENE.instantiate()
|
||||||
|
minion.marked = i == marked_slot
|
||||||
|
minion.boss = self
|
||||||
|
get_parent().add_child(minion)
|
||||||
|
var angle: float = TAU * float(i) / float(count) + float(wave_index) * 0.47
|
||||||
|
# Minions are siblings rather than boss children, so account for the
|
||||||
|
# encounter scene's imposing boss scale when placing them off its body.
|
||||||
|
var launch_radius: float = BODY_RADIUS * scale.x + 34.0
|
||||||
|
minion.global_position = global_position + Vector2.from_angle(angle) * launch_radius
|
||||||
|
spawned_minions.append(minion)
|
||||||
|
wave_index += 1
|
||||||
|
|
||||||
|
|
||||||
|
func accept_marked_kill(from: Vector2) -> void:
|
||||||
|
if phase == Phase.DEFEATED or get_parent() == null:
|
||||||
|
return
|
||||||
|
var returning: Node2D = RETURN_SCENE.instantiate()
|
||||||
|
returning.target = self
|
||||||
|
get_parent().add_child(returning)
|
||||||
|
returning.global_position = from
|
||||||
|
return_signals.append(returning)
|
||||||
|
|
||||||
|
|
||||||
|
func open_next_eye() -> bool:
|
||||||
|
if phase == Phase.DEFEATED:
|
||||||
|
return false
|
||||||
|
for eye in eyes:
|
||||||
|
if is_instance_valid(eye) and not bool(eye.opened):
|
||||||
|
eye.open_eye()
|
||||||
|
eyes_opened += 1
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _on_eye_destroyed(_index: int) -> void:
|
||||||
|
eyes_destroyed += 1
|
||||||
|
if eyes_destroyed >= EYE_COUNT:
|
||||||
|
_defeat()
|
||||||
|
return
|
||||||
|
if phase == Phase.MINION_WAVES and eyes_destroyed >= PHASE_TWO_EYES:
|
||||||
|
phase = Phase.LASER_STORM
|
||||||
|
laser_timer = 0.35
|
||||||
|
phase_changed.emit(phase)
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func fire_laser_volley() -> void:
|
||||||
|
if phase != Phase.LASER_STORM:
|
||||||
|
return
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet == null or fleet.ships.is_empty():
|
||||||
|
return
|
||||||
|
var toward: Vector2 = fleet.get_centroid() - global_position
|
||||||
|
if toward.length() < 1.0:
|
||||||
|
return
|
||||||
|
# Three locked, dodgeable lanes form the second phase's distinct pattern.
|
||||||
|
for spread in [-0.17, 0.0, 0.17]:
|
||||||
|
var beam: Node2D = LASER_SCENE.instantiate()
|
||||||
|
beam.charge_time = laser_charge_time
|
||||||
|
beam.beam_range = laser_range
|
||||||
|
beam.beam_half_width = 24.0
|
||||||
|
beam.damage = 1
|
||||||
|
beam.color = Color(1.0, 0.12, 0.58)
|
||||||
|
get_tree().root.add_child(beam)
|
||||||
|
beam.add_to_group("boss_lasers")
|
||||||
|
beam.launch(global_position, toward.rotated(float(spread)))
|
||||||
|
|
||||||
|
|
||||||
|
func _defeat() -> void:
|
||||||
|
if phase == Phase.DEFEATED:
|
||||||
|
return
|
||||||
|
phase = Phase.DEFEATED
|
||||||
|
for minion in spawned_minions:
|
||||||
|
if is_instance_valid(minion):
|
||||||
|
minion.retire()
|
||||||
|
for returning in return_signals:
|
||||||
|
if is_instance_valid(returning):
|
||||||
|
returning.queue_free()
|
||||||
|
EnemyExplosionVFX.spawn(get_tree().root, global_position)
|
||||||
|
Sfx.play(get_tree(), Sfx.ENEMY_DEATH)
|
||||||
|
visible = false
|
||||||
|
phase_changed.emit(phase)
|
||||||
|
defeated.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func suppress_as_defeated() -> void:
|
||||||
|
phase = Phase.DEFEATED
|
||||||
|
visible = false
|
||||||
|
set_process(false)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
if phase == Phase.DEFEATED:
|
||||||
|
return
|
||||||
|
var flesh := Color(0.95, 0.18, 0.58)
|
||||||
|
var phase_color := Color(1.0, 0.1, 0.32) if phase == Phase.LASER_STORM \
|
||||||
|
else flesh
|
||||||
|
var dark := Color(0.075, 0.004, 0.05)
|
||||||
|
for i in range(EYE_COUNT):
|
||||||
|
var end: Vector2 = eye_position(i)
|
||||||
|
var angle: float = end.angle()
|
||||||
|
var side := Vector2.from_angle(angle + PI / 2.0)
|
||||||
|
var points := PackedVector2Array()
|
||||||
|
for step in range(9):
|
||||||
|
var t: float = float(step) / 8.0
|
||||||
|
var sway: float = sin(animation_phase * 1.7 + float(i) * 1.4 + t * PI * 2.0)
|
||||||
|
points.append(end * t + side * sway * 18.0 * sin(t * PI))
|
||||||
|
NeonDraw.draw_glowing_path(self, points, phase_color, 0.8, 0.8, dark)
|
||||||
|
|
||||||
|
var body := PackedVector2Array()
|
||||||
|
for i in range(28):
|
||||||
|
var angle: float = TAU * float(i) / 28.0
|
||||||
|
var pulse: float = 1.0 + 0.045 * sin(animation_phase * 2.4 + float(i) * 1.9)
|
||||||
|
body.append(Vector2.from_angle(angle) * BODY_RADIUS * pulse)
|
||||||
|
NeonDraw.draw_glowing_polygon(self, body, phase_color, 0.92, dark)
|
||||||
|
# The central eye watches but is armoured; only tentacle eyes opened by a
|
||||||
|
# returning marked minion join the combat-target group.
|
||||||
|
draw_set_transform(Vector2.ZERO, 0.0, Vector2(0.72, 1.0))
|
||||||
|
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 31.0,
|
||||||
|
Color(0.28, 0.92, 1.0), 0.8 + 0.2 * sin(animation_phase * 3.0), dark)
|
||||||
|
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 12.0,
|
||||||
|
Color(1.0, 0.82, 0.2), 1.0, dark)
|
||||||
|
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||||
1
boss/OctopusBoss.gd.uid
Normal file
1
boss/OctopusBoss.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cnn2bwdod6sod
|
||||||
9
boss/OctopusBoss.tscn
Normal file
9
boss/OctopusBoss.tscn
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://boss/OctopusBoss.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="Boss" type="Node2D"]
|
||||||
|
position = Vector2(0, -560)
|
||||||
|
scale = Vector2(1.35, 1.35)
|
||||||
|
z_index = 2
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
class_name AimedFleetAbilityController
|
class_name AimedFleetAbilityController
|
||||||
extends Node2D
|
extends Node2D
|
||||||
|
|
||||||
|
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
|
||||||
|
|
||||||
# Shared press-drag-release input and camera behavior for manually aimed fleet
|
# Shared press-drag-release input and camera behavior for manually aimed fleet
|
||||||
# abilities. Subclasses provide the component, ammunition, target calculation,
|
# abilities. Subclasses provide the component, ammunition, target calculation,
|
||||||
# shot effect, and effect visuals.
|
# shot effect, and effect visuals.
|
||||||
|
|
@ -125,7 +127,7 @@ func _aim_at_nearest_enemy() -> bool:
|
||||||
var reach: float = _aim_max_range()
|
var reach: float = _aim_max_range()
|
||||||
var nearest: Node2D = null
|
var nearest: Node2D = null
|
||||||
var nearest_distance: float = INF
|
var nearest_distance: float = INF
|
||||||
for enemy in get_tree().get_nodes_in_group("enemies"):
|
for enemy in get_tree().get_nodes_in_group(COMBAT_TARGET.GROUP):
|
||||||
if not is_instance_valid(enemy) or not (enemy is Node2D):
|
if not is_instance_valid(enemy) or not (enemy is Node2D):
|
||||||
continue
|
continue
|
||||||
var distance: float = origin.distance_to((enemy as Node2D).global_position)
|
var distance: float = origin.distance_to((enemy as Node2D).global_position)
|
||||||
|
|
|
||||||
14
planets/BossPlanetDefinition.tres
Normal file
14
planets/BossPlanetDefinition.tres
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[gd_resource type="Resource" script_class="PlanetDefinition" load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://planets/PlanetDefinition.gd" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://planets/BossPlanetVisual.tscn" id="2"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
definition_id = "boss"
|
||||||
|
display_name = "Tentacled world"
|
||||||
|
names = PackedStringArray("The Sleeper", "Many-Eyed Deep", "Nacre Wound", "The Coiled Moon", "Abyssal Crown")
|
||||||
|
visual_scene = ExtResource("2")
|
||||||
|
surface_profile_id = "boss"
|
||||||
|
resource_id = ""
|
||||||
|
map_color = Color(0.95, 0.18, 0.58, 1)
|
||||||
51
planets/BossPlanetVisual.gd
Normal file
51
planets/BossPlanetVisual.gd
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
# The space-side landmark: organic, asymmetric and visibly awake. The landed
|
||||||
|
# encounter uses the same pink/cyan palette at a much larger scale.
|
||||||
|
const RADIUS: float = 104.0
|
||||||
|
const FLESH_COLOR := Color(0.95, 0.18, 0.58)
|
||||||
|
const VEIN_COLOR := Color(0.25, 0.9, 1.0)
|
||||||
|
const CORE_COLOR := Color(0.11, 0.015, 0.08)
|
||||||
|
|
||||||
|
var phase: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func configure(shape_seed: int) -> void:
|
||||||
|
phase = float(posmod(shape_seed, 628)) / 100.0
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func extractor_anchor() -> Vector2:
|
||||||
|
return Vector2(0.0, -(RADIUS + 36.0))
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
phase += delta
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
# Eight short feelers make this read as a creature before the central eye
|
||||||
|
# is even noticed.
|
||||||
|
for i in range(8):
|
||||||
|
var angle: float = TAU * float(i) / 8.0 + phase * 0.08
|
||||||
|
var points := PackedVector2Array()
|
||||||
|
for step in range(7):
|
||||||
|
var t: float = float(step) / 6.0
|
||||||
|
var radial: float = lerpf(RADIUS * 0.72, RADIUS * 1.35, t)
|
||||||
|
var curl: float = sin(t * PI * 1.7 + phase * 1.5 + float(i)) * 0.16 * t
|
||||||
|
points.append(Vector2.from_angle(angle + curl) * radial)
|
||||||
|
NeonDraw.draw_glowing_path(self, points, FLESH_COLOR, 0.75, 0.55, CORE_COLOR)
|
||||||
|
|
||||||
|
var body := PackedVector2Array()
|
||||||
|
for i in range(24):
|
||||||
|
var angle: float = TAU * float(i) / 24.0
|
||||||
|
var pulse: float = 1.0 + 0.035 * sin(phase * 2.2 + float(i) * 1.7)
|
||||||
|
body.append(Vector2.from_angle(angle) * RADIUS * pulse)
|
||||||
|
NeonDraw.draw_glowing_polygon(self, body, FLESH_COLOR, 0.9, CORE_COLOR)
|
||||||
|
|
||||||
|
var eye_scale: float = 1.0 + 0.08 * sin(phase * 3.0)
|
||||||
|
draw_set_transform(Vector2.ZERO, 0.0, Vector2(0.62, 1.0) * eye_scale)
|
||||||
|
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 34.0, VEIN_COLOR, 1.0, CORE_COLOR)
|
||||||
|
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 13.0, Color.WHITE, 1.0, CORE_COLOR)
|
||||||
|
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||||
1
planets/BossPlanetVisual.gd.uid
Normal file
1
planets/BossPlanetVisual.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dabh0dbwxu3i3
|
||||||
6
planets/BossPlanetVisual.tscn
Normal file
6
planets/BossPlanetVisual.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://planets/BossPlanetVisual.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="Visual" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
@ -5,12 +5,14 @@ const ROCKY_ID: String = "rocky"
|
||||||
const GAS_GIANT_ID: String = "gas_giant"
|
const GAS_GIANT_ID: String = "gas_giant"
|
||||||
const BLACK_HOLE_ID: String = "black_hole"
|
const BLACK_HOLE_ID: String = "black_hole"
|
||||||
const RESEARCH_LAB_ID: String = "research_lab"
|
const RESEARCH_LAB_ID: String = "research_lab"
|
||||||
|
const BOSS_ID: String = "boss"
|
||||||
const DEFAULT_DEFINITION_ID: String = ROCKY_ID
|
const DEFAULT_DEFINITION_ID: String = ROCKY_ID
|
||||||
const DEFINITIONS: Dictionary = {
|
const DEFINITIONS: Dictionary = {
|
||||||
ROCKY_ID: preload("res://planets/RockyPlanetDefinition.tres"),
|
ROCKY_ID: preload("res://planets/RockyPlanetDefinition.tres"),
|
||||||
GAS_GIANT_ID: preload("res://planets/GasGiantDefinition.tres"),
|
GAS_GIANT_ID: preload("res://planets/GasGiantDefinition.tres"),
|
||||||
BLACK_HOLE_ID: preload("res://planets/BlackHolePlanetDefinition.tres"),
|
BLACK_HOLE_ID: preload("res://planets/BlackHolePlanetDefinition.tres"),
|
||||||
RESEARCH_LAB_ID: preload("res://planets/ResearchLabPlanetDefinition.tres"),
|
RESEARCH_LAB_ID: preload("res://planets/ResearchLabPlanetDefinition.tres"),
|
||||||
|
BOSS_ID: preload("res://planets/BossPlanetDefinition.tres"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
47
shaders/boss_surface.gdshader
Normal file
47
shaders/boss_surface.gdshader
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
shader_type canvas_item;
|
||||||
|
|
||||||
|
// An organic membrane rather than terrain: broad folds move below branching
|
||||||
|
// cyan veins. It follows SurfaceBackground's shared camera-uniform contract.
|
||||||
|
uniform vec2 camera_cell = vec2(0.0, 0.0);
|
||||||
|
uniform vec2 camera_frac = vec2(0.0, 0.0);
|
||||||
|
uniform float camera_zoom = 1.0;
|
||||||
|
uniform vec2 viewport_size = vec2(720.0, 1280.0);
|
||||||
|
uniform float cell_size = 220.0;
|
||||||
|
uniform float tile_cells = 32.0;
|
||||||
|
uniform vec4 base_color : source_color = vec4(0.055, 0.008, 0.05, 1.0);
|
||||||
|
uniform vec4 crater_color : source_color = vec4(0.16, 0.018, 0.10, 1.0);
|
||||||
|
uniform vec4 highlight_color : source_color = vec4(0.16, 0.66, 0.72, 1.0);
|
||||||
|
|
||||||
|
float hash21(vec2 p) {
|
||||||
|
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
||||||
|
p3 += dot(p3, p3.yzx + 33.33);
|
||||||
|
return fract((p3.x + p3.y) * p3.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec2 screen_px = UV * viewport_size;
|
||||||
|
vec2 centered = screen_px - viewport_size * 0.5;
|
||||||
|
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
|
||||||
|
vec2 cell_rel = floor(rel);
|
||||||
|
vec2 local = rel - cell_rel;
|
||||||
|
vec2 wrapped = mod(camera_cell + cell_rel, tile_cells);
|
||||||
|
vec2 p = (wrapped + local) / tile_cells * 6.28318530718;
|
||||||
|
|
||||||
|
float fold_a = sin(p.x * 3.0 + sin(p.y * 2.0 - TIME * 0.16) * 1.1);
|
||||||
|
// Integer frequencies on both axes keep the 32-cell wrapped field
|
||||||
|
// seamless where p crosses from 2*PI back to zero.
|
||||||
|
float fold_b = sin(p.y * 4.0 - p.x * 2.0 + TIME * 0.11);
|
||||||
|
float tissue = 0.5 + 0.25 * fold_a + 0.25 * fold_b;
|
||||||
|
vec3 color = mix(base_color.rgb, crater_color.rgb, smoothstep(0.18, 0.85, tissue));
|
||||||
|
|
||||||
|
float vein_a = abs(sin(p.x * 5.0 + sin(p.y * 3.0 + TIME * 0.22)));
|
||||||
|
float vein_b = abs(sin(p.y * 6.0 - p.x * 2.0 - TIME * 0.18));
|
||||||
|
// Veins are environmental texture, not attack telegraphs. Keep them thin
|
||||||
|
// and subdued so the boss's bright pink laser lanes remain unambiguous.
|
||||||
|
float vein = 1.0 - smoothstep(0.0, 0.028, min(vein_a, vein_b));
|
||||||
|
float pulse = 0.55 + 0.45 * sin(TIME * 1.8 + p.x + p.y);
|
||||||
|
color = mix(color, highlight_color.rgb, vein * (0.10 + pulse * 0.20));
|
||||||
|
|
||||||
|
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
|
||||||
|
COLOR = vec4(color + vec3(dither), 1.0);
|
||||||
|
}
|
||||||
1
shaders/boss_surface.gdshader.uid
Normal file
1
shaders/boss_surface.gdshader.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://csf5umhh3dqft
|
||||||
18
surface/BossSurfaceProfile.tres
Normal file
18
surface/BossSurfaceProfile.tres
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://surface/PlanetSurfaceProfile.gd" id="1"]
|
||||||
|
[ext_resource type="Shader" path="res://shaders/boss_surface.gdshader" id="2"]
|
||||||
|
[ext_resource type="PackedScene" path="res://boss/BossEncounter.tscn" id="3"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
profile_id = "boss"
|
||||||
|
encounter_scene = ExtResource("3")
|
||||||
|
editor_visible = false
|
||||||
|
show_capture_status = false
|
||||||
|
background_shader = ExtResource("2")
|
||||||
|
base_color = Color(0.055, 0.008, 0.05, 1)
|
||||||
|
crater_color = Color(0.16, 0.018, 0.10, 1)
|
||||||
|
highlight_color = Color(0.16, 0.66, 0.72, 1)
|
||||||
|
caption_color = Color(0.95, 0.62, 0.82, 1)
|
||||||
|
caption_outline_color = Color(0.025, 0.005, 0.025, 0.95)
|
||||||
|
|
@ -12,9 +12,13 @@ extends Label
|
||||||
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
|
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
|
||||||
|
|
||||||
var _was_conquered: bool = false
|
var _was_conquered: bool = false
|
||||||
|
var display_enabled: bool = true
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
|
if not display_enabled:
|
||||||
|
visible = false
|
||||||
|
return
|
||||||
var planet_id: String = GameState.planet_id
|
var planet_id: String = GameState.planet_id
|
||||||
var total: int = GameState.total_capture_points(planet_id)
|
var total: int = GameState.total_capture_points(planet_id)
|
||||||
if total <= 0:
|
if total <= 0:
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ var pan_start_camera: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_profile_ids = PlanetSurfaceProfiles.PROFILES.keys()
|
# Scripted encounters own their layout and rules, so their profiles opt
|
||||||
|
# out rather than being special-cased here.
|
||||||
|
_profile_ids = PlanetSurfaceProfiles.editor_profile_ids()
|
||||||
back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://ui/MainMenu.tscn"))
|
back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://ui/MainMenu.tscn"))
|
||||||
profile_button.pressed.connect(_on_profile_pressed)
|
profile_button.pressed.connect(_on_profile_pressed)
|
||||||
reward_button.pressed.connect(_on_reward_pressed)
|
reward_button.pressed.connect(_on_reward_pressed)
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,12 @@ func _compose_surface(profile: Resource) -> void:
|
||||||
var caption: Label = get_node(caption_path)
|
var caption: Label = get_node(caption_path)
|
||||||
caption.add_theme_color_override("font_color", profile.caption_color)
|
caption.add_theme_color_override("font_color", profile.caption_color)
|
||||||
caption.add_theme_color_override("font_outline_color", profile.caption_outline_color)
|
caption.add_theme_color_override("font_outline_color", profile.caption_outline_color)
|
||||||
|
var capture_label: Label = get_node("../UI/CaptureLabel")
|
||||||
|
# This controller readies before the UI sibling. Store the policy on the
|
||||||
|
# label itself so its first process tick cannot make a scripted encounter's
|
||||||
|
# deliberately hidden generic objective readout visible again.
|
||||||
|
capture_label.display_enabled = bool(profile.show_capture_status)
|
||||||
|
capture_label.visible = bool(profile.show_capture_status)
|
||||||
if profile.features_scene != null:
|
if profile.features_scene != null:
|
||||||
var features: Node = profile.features_scene.instantiate()
|
var features: Node = profile.features_scene.instantiate()
|
||||||
feature_host.add_child(features)
|
feature_host.add_child(features)
|
||||||
|
|
@ -64,7 +70,7 @@ func _compose_surface(profile: Resource) -> void:
|
||||||
var setup_host: Node = SETUP_HOST_SCENE.instantiate()
|
var setup_host: Node = SETUP_HOST_SCENE.instantiate()
|
||||||
feature_host.add_child(setup_host)
|
feature_host.add_child(setup_host)
|
||||||
setup_host.apply_setup(setup)
|
setup_host.apply_setup(setup)
|
||||||
else:
|
elif profile.encounter_scene == null:
|
||||||
# No PlanetSetup exists for this profile at all (#55: "void" has
|
# No PlanetSetup exists for this profile at all (#55: "void" has
|
||||||
# none authored) - register zero required points directly, rather
|
# none authored) - register zero required points directly, rather
|
||||||
# than leaving this planet's conquest state unregistered forever.
|
# than leaving this planet's conquest state unregistered forever.
|
||||||
|
|
@ -75,6 +81,10 @@ func _compose_surface(profile: Resource) -> void:
|
||||||
# points() treats a total of 0 as vacuously already conquered.
|
# points() treats a total of 0 as vacuously already conquered.
|
||||||
GameState.register_capture_points(GameState.planet_id, 0)
|
GameState.register_capture_points(GameState.planet_id, 0)
|
||||||
|
|
||||||
|
if profile.encounter_scene != null:
|
||||||
|
var encounter: Node = profile.encounter_scene.instantiate()
|
||||||
|
feature_host.add_child(encounter)
|
||||||
|
|
||||||
|
|
||||||
func _on_exit_pressed() -> void:
|
func _on_exit_pressed() -> void:
|
||||||
if transitioning:
|
if transitioning:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ extends Resource
|
||||||
|
|
||||||
@export var profile_id: String = "rocky"
|
@export var profile_id: String = "rocky"
|
||||||
@export var features_scene: PackedScene
|
@export var features_scene: PackedScene
|
||||||
|
# Optional self-contained gameplay encounter. Unlike an authored PlanetSetup,
|
||||||
|
# this owns its own objectives and layout and is therefore not configurable in
|
||||||
|
# the setup editor.
|
||||||
|
@export var encounter_scene: PackedScene
|
||||||
|
@export var editor_visible: bool = true
|
||||||
|
@export var show_capture_status: bool = true
|
||||||
# Which shader draws this surface's backdrop. Every profile names one - the
|
# Which shader draws this surface's backdrop. Every profile names one - the
|
||||||
# old "null means keep whatever the scene already had" convention is what let
|
# old "null means keep whatever the scene already had" convention is what let
|
||||||
# the game and the setup editor drift apart, since each scene "already had" a
|
# the game and the setup editor drift apart, since each scene "already had" a
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,17 @@ const PROFILES: Dictionary = {
|
||||||
"gas": preload("res://surface/GasSurfaceProfile.tres"),
|
"gas": preload("res://surface/GasSurfaceProfile.tres"),
|
||||||
"void": preload("res://surface/VoidSurfaceProfile.tres"),
|
"void": preload("res://surface/VoidSurfaceProfile.tres"),
|
||||||
"research_lab": preload("res://surface/ResearchLabSurfaceProfile.tres"),
|
"research_lab": preload("res://surface/ResearchLabSurfaceProfile.tres"),
|
||||||
|
"boss": preload("res://surface/BossSurfaceProfile.tres"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static func get_profile(profile_id: String) -> Resource:
|
static func get_profile(profile_id: String) -> Resource:
|
||||||
return PROFILES.get(profile_id, PROFILES[DEFAULT_PROFILE_ID])
|
return PROFILES.get(profile_id, PROFILES[DEFAULT_PROFILE_ID])
|
||||||
|
|
||||||
|
|
||||||
|
static func editor_profile_ids() -> Array:
|
||||||
|
var result: Array = []
|
||||||
|
for profile_id in PROFILES:
|
||||||
|
if bool(PROFILES[profile_id].editor_visible):
|
||||||
|
result.append(profile_id)
|
||||||
|
return result
|
||||||
|
|
|
||||||
49
tests/_boss_planet_preview.gd
Normal file
49
tests/_boss_planet_preview.gd
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
# Stable portrait composition for visually checking #188. The real surface,
|
||||||
|
# encounter, fleet, UI, minions, open eyes and laser telegraph are all used;
|
||||||
|
# only their timers are frozen once the representative second phase is laid
|
||||||
|
# out so a screenshot does not depend on capture timing.
|
||||||
|
|
||||||
|
var surface: Node
|
||||||
|
var laid_out: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
GameState.reset_new_game()
|
||||||
|
GameState.intro_active = false
|
||||||
|
GameState.reset_quests(false)
|
||||||
|
GameState.planet_name = "Many-Eyed Deep"
|
||||||
|
GameState.planet_id = "preview:boss"
|
||||||
|
GameState.planet_definition_id = "boss"
|
||||||
|
GameState.surviving_ship_count = 3
|
||||||
|
GameState.surviving_vessel_types = PackedStringArray([
|
||||||
|
GameState.VESSEL_INTERCEPTOR,
|
||||||
|
GameState.VESSEL_GAS_BOMBER,
|
||||||
|
GameState.VESSEL_LASER,
|
||||||
|
])
|
||||||
|
surface = load("res://surface/PlanetSurface.tscn").instantiate()
|
||||||
|
add_child(surface)
|
||||||
|
surface.get_node("FadeOverlay/FadeRect").visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if laid_out:
|
||||||
|
return
|
||||||
|
laid_out = true
|
||||||
|
var encounter: Node = surface.get_node("SurfaceFeatures/BossEncounter")
|
||||||
|
var boss: Node2D = encounter.get_node("Boss")
|
||||||
|
boss.wave_timer = 999.0
|
||||||
|
boss.laser_timer = 999.0
|
||||||
|
boss.phase = boss.Phase.LASER_STORM
|
||||||
|
for i in range(3):
|
||||||
|
boss.eyes[i].open_eye()
|
||||||
|
boss.eyes_opened += 1
|
||||||
|
boss.spawn_wave()
|
||||||
|
for minion in get_tree().get_nodes_in_group("boss_minions"):
|
||||||
|
minion.speed = 0.0
|
||||||
|
minion.acceleration = 0.0
|
||||||
|
boss.laser_charge_time = 999.0
|
||||||
|
boss.fire_laser_volley()
|
||||||
|
encounter._process(0.0)
|
||||||
|
get_tree().paused = true
|
||||||
1
tests/_boss_planet_preview.gd.uid
Normal file
1
tests/_boss_planet_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://7n3t677nrm0b
|
||||||
6
tests/_boss_planet_preview.tscn
Normal file
6
tests/_boss_planet_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tests/_boss_planet_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="BossPlanetPreview" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
255
tests/test_boss_planet.gd
Normal file
255
tests/test_boss_planet.gd
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Forgejo #188: the authored, non-editor boss planet and its complete weak-
|
||||||
|
# point loop. This test drives the real landed surface and encounter nodes.
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# A. the planet/profile are registered, dev mode plants it nearby, and its
|
||||||
|
# scripted profile is excluded from the map editor
|
||||||
|
# B. landing composes BossEncounter instead of a PlanetSetup and does not
|
||||||
|
# mark the world conquered for free
|
||||||
|
# C. phase one spawns an octopus wave with exactly one marked hatchling
|
||||||
|
# D. killing the marked hatchling sends a visible return to the boss and
|
||||||
|
# opens an attackable tentacle eye
|
||||||
|
# E. three destroyed eyes start the three-lane laser phase
|
||||||
|
# F. destroying every eye defeats the boss, persists conquest, and a second
|
||||||
|
# landing does not resurrect it
|
||||||
|
|
||||||
|
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
|
||||||
|
const BOSS_SCRIPT = preload("res://boss/OctopusBoss.gd")
|
||||||
|
|
||||||
|
var state: Node
|
||||||
|
var surface: Node
|
||||||
|
var encounter: Node
|
||||||
|
var boss: Node2D
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
state = root.get_node("GameState")
|
||||||
|
state.reset_new_game()
|
||||||
|
state.intro_active = false
|
||||||
|
state.reset_quests(false)
|
||||||
|
state.planet_name = "Many-Eyed Deep"
|
||||||
|
state.planet_id = "test:boss:planet"
|
||||||
|
state.planet_definition_id = PlanetDefinitions.BOSS_ID
|
||||||
|
state.surviving_ship_count = 1
|
||||||
|
state.surviving_vessel_types = PackedStringArray([GameState.VESSEL_INTERCEPTOR])
|
||||||
|
surface = load("res://surface/PlanetSurface.tscn").instantiate()
|
||||||
|
root.add_child(surface)
|
||||||
|
current_scene = surface
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
if elapsed < 0.2:
|
||||||
|
return false
|
||||||
|
# --- A. definition, dev placement, editor exclusion ----------
|
||||||
|
var definition: Resource = PlanetDefinitions.get_definition(
|
||||||
|
PlanetDefinitions.BOSS_ID)
|
||||||
|
if definition == null or definition.definition_id != PlanetDefinitions.BOSS_ID \
|
||||||
|
or definition.surface_profile_id != "boss" \
|
||||||
|
or definition.visual_scene == null:
|
||||||
|
return _fail("boss planet definition is not registered")
|
||||||
|
# Load at runtime, after the GameState autoload has compiled. Resolving
|
||||||
|
# the whole surface registry while that autoload is still compiling
|
||||||
|
# also pulls in pickup scripts which legitimately refer back to it.
|
||||||
|
var surface_profiles: Script = load("res://surface/PlanetSurfaceProfiles.gd")
|
||||||
|
var profile: Resource = surface_profiles.get_profile("boss")
|
||||||
|
if profile == null or profile.encounter_scene == null:
|
||||||
|
return _fail("boss surface has no scripted encounter")
|
||||||
|
if profile.editor_visible or "boss" in surface_profiles.editor_profile_ids():
|
||||||
|
return _fail("boss surface is configurable in the map editor")
|
||||||
|
state.dev_mode = true
|
||||||
|
var boss_chunk: Variant = null
|
||||||
|
for chunk in WorldGen.DEV_PLANET_CHUNKS:
|
||||||
|
if String(WorldGen.DEV_PLANET_CHUNKS[chunk]) == PlanetDefinitions.BOSS_ID:
|
||||||
|
boss_chunk = chunk
|
||||||
|
break
|
||||||
|
if boss_chunk == null or not WorldGen.has_planet_at_chunk(boss_chunk) \
|
||||||
|
or String(WorldGen.planet_data_at_chunk(boss_chunk).definition_id) \
|
||||||
|
!= PlanetDefinitions.BOSS_ID:
|
||||||
|
return _fail("dev mode does not plant the boss planet")
|
||||||
|
state.dev_mode = false
|
||||||
|
var normal_boss_found: bool = false
|
||||||
|
for x in range(-24, 25):
|
||||||
|
for y in range(-24, 25):
|
||||||
|
var chunk := Vector2i(x, y)
|
||||||
|
if WorldGen.has_planet_at_chunk(chunk) \
|
||||||
|
and String(WorldGen.planet_data_at_chunk(chunk).get(
|
||||||
|
"definition_id", "")) == PlanetDefinitions.BOSS_ID:
|
||||||
|
normal_boss_found = true
|
||||||
|
break
|
||||||
|
if normal_boss_found:
|
||||||
|
break
|
||||||
|
if not normal_boss_found:
|
||||||
|
return _fail("normal world generation never produces the boss planet")
|
||||||
|
print("boss planet registered, editor-hidden, and generated normally plus in dev mode")
|
||||||
|
|
||||||
|
# --- B. generic surface composition ---------------------------
|
||||||
|
encounter = surface.get_node_or_null("SurfaceFeatures/BossEncounter")
|
||||||
|
if encounter == null:
|
||||||
|
return _fail("landing did not compose BossEncounter")
|
||||||
|
if surface.get_node_or_null("SurfaceFeatures/PlanetSetupHost") != null:
|
||||||
|
return _fail("boss planet was routed through configurable PlanetSetup")
|
||||||
|
if state.total_capture_points(state.planet_id) != 1 \
|
||||||
|
or state.is_planet_conquered(state.planet_id):
|
||||||
|
return _fail("boss planet did not start as one unfinished persistent objective")
|
||||||
|
if surface.get_node("UI/CaptureLabel").visible:
|
||||||
|
return _fail("ordinary capture-point status is visible during the boss encounter")
|
||||||
|
boss = encounter.get_node("Boss")
|
||||||
|
boss.wave_timer = 999.0
|
||||||
|
boss.laser_timer = 999.0
|
||||||
|
if boss.eyes.size() != boss.EYE_COUNT:
|
||||||
|
return _fail("boss has %d tentacle eyes, expected %d" \
|
||||||
|
% [boss.eyes.size(), boss.EYE_COUNT])
|
||||||
|
for eye in boss.eyes:
|
||||||
|
if eye.opened or eye.is_in_group(COMBAT_TARGET.GROUP):
|
||||||
|
return _fail("a tentacle eye starts open and attackable")
|
||||||
|
|
||||||
|
# --- C. phase-one wave ----------------------------------------
|
||||||
|
boss.spawn_wave()
|
||||||
|
var minions: Array = get_nodes_in_group("boss_minions")
|
||||||
|
if minions.size() != boss.minions_per_wave:
|
||||||
|
return _fail("phase one spawned %d hatchlings, expected %d" \
|
||||||
|
% [minions.size(), boss.minions_per_wave])
|
||||||
|
var marked: Node = null
|
||||||
|
for minion in minions:
|
||||||
|
if minion.marked:
|
||||||
|
if marked != null:
|
||||||
|
return _fail("one wave contains more than one marked hatchling")
|
||||||
|
marked = minion
|
||||||
|
else:
|
||||||
|
minion.take_damage(999)
|
||||||
|
if marked == null:
|
||||||
|
return _fail("phase-one wave has no marked hatchling")
|
||||||
|
if not get_nodes_in_group("boss_returns").is_empty():
|
||||||
|
return _fail("an ordinary hatchling returned energy to the boss")
|
||||||
|
marked.take_damage(999)
|
||||||
|
var returns: Array = get_nodes_in_group("boss_returns")
|
||||||
|
if returns.size() != 1:
|
||||||
|
return _fail("marked death launched %d returns, expected one" % returns.size())
|
||||||
|
returns[0].speed = 4000.0
|
||||||
|
print("phase one spawned a wave; the marked kill returned toward the boss")
|
||||||
|
stage = 1
|
||||||
|
elapsed = 0.0
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
# --- D. return opens an attackable eye ------------------------
|
||||||
|
if boss.eyes_opened < 1:
|
||||||
|
if elapsed > 1.0:
|
||||||
|
var pending: Array = get_nodes_in_group("boss_returns")
|
||||||
|
var detail: String = "no return remains"
|
||||||
|
if not pending.is_empty():
|
||||||
|
detail = "return at %s, boss at %s, speed %.1f, processing %s" % [
|
||||||
|
pending[0].global_position, boss.global_position,
|
||||||
|
pending[0].speed, pending[0].is_processing()]
|
||||||
|
return _fail("marked energy reached no tentacle eye (%s)" % detail)
|
||||||
|
return false
|
||||||
|
var eye: Node = boss.eyes[0]
|
||||||
|
if not eye.opened or not eye.is_in_group(COMBAT_TARGET.GROUP):
|
||||||
|
return _fail("returned energy did not make the eye attackable")
|
||||||
|
var hp_before: int = eye.hp
|
||||||
|
eye.take_damage(1)
|
||||||
|
if eye.hp != hp_before - 1:
|
||||||
|
return _fail("an open eye ignored weapon damage")
|
||||||
|
eye.take_damage(999)
|
||||||
|
if boss.eyes_destroyed != 1:
|
||||||
|
return _fail("destroying the first eye did not damage the boss")
|
||||||
|
print("return opened a tentacle eye; the eye accepted damage and broke")
|
||||||
|
_open_return()
|
||||||
|
stage = 2
|
||||||
|
elapsed = 0.0
|
||||||
|
return false
|
||||||
|
2:
|
||||||
|
if boss.eyes_opened < 2:
|
||||||
|
if elapsed > 1.0:
|
||||||
|
return _fail("second marked return opened no eye")
|
||||||
|
return false
|
||||||
|
boss.eyes[1].take_damage(999)
|
||||||
|
_open_return()
|
||||||
|
stage = 3
|
||||||
|
elapsed = 0.0
|
||||||
|
return false
|
||||||
|
3:
|
||||||
|
# --- E. phase two and laser pattern ---------------------------
|
||||||
|
if boss.eyes_opened < 3:
|
||||||
|
if elapsed > 1.0:
|
||||||
|
return _fail("third marked return opened no eye")
|
||||||
|
return false
|
||||||
|
boss.eyes[2].take_damage(999)
|
||||||
|
if boss.phase != BOSS_SCRIPT.Phase.LASER_STORM:
|
||||||
|
return _fail("three broken eyes did not start the laser phase")
|
||||||
|
boss.laser_timer = 999.0
|
||||||
|
boss.fire_laser_volley()
|
||||||
|
var beams: Array = get_nodes_in_group("boss_lasers")
|
||||||
|
if beams.size() != 3:
|
||||||
|
return _fail("laser phase launched %d beams, expected three lanes" % beams.size())
|
||||||
|
if beams[0].aim_direction.is_equal_approx(beams[1].aim_direction) \
|
||||||
|
or beams[1].aim_direction.is_equal_approx(beams[2].aim_direction):
|
||||||
|
return _fail("laser volley lanes all aim in the same direction")
|
||||||
|
for beam in beams:
|
||||||
|
if beam.phase != 0 or beam.charge_left <= 0.0:
|
||||||
|
return _fail("boss laser was not telegraphed before firing")
|
||||||
|
beam.queue_free()
|
||||||
|
boss.spawn_wave()
|
||||||
|
var phase_two_minions: Array = get_nodes_in_group("boss_minions")
|
||||||
|
var phase_two_marked: int = 0
|
||||||
|
for minion in phase_two_minions:
|
||||||
|
if minion.marked:
|
||||||
|
phase_two_marked += 1
|
||||||
|
minion.retire()
|
||||||
|
if phase_two_minions.size() != 3 or phase_two_marked != 1:
|
||||||
|
return _fail("laser phase no longer supplies one marked target to open remaining eyes")
|
||||||
|
print("three broken eyes started a telegraphed three-lane laser phase")
|
||||||
|
|
||||||
|
# --- F. all eyes -> persistent victory ------------------------
|
||||||
|
for i in range(3, boss.EYE_COUNT):
|
||||||
|
boss.eyes[i].open_eye()
|
||||||
|
boss.eyes[i].take_damage(999)
|
||||||
|
if boss.phase != BOSS_SCRIPT.Phase.DEFEATED or boss.visible:
|
||||||
|
return _fail("breaking every eye did not defeat and hide the boss")
|
||||||
|
if not state.is_planet_conquered(state.planet_id):
|
||||||
|
return _fail("boss victory did not persist through planet conquest state")
|
||||||
|
print("all eyes broken: boss defeated and planet conquest persisted")
|
||||||
|
|
||||||
|
surface.free()
|
||||||
|
surface = load("res://surface/PlanetSurface.tscn").instantiate()
|
||||||
|
root.add_child(surface)
|
||||||
|
current_scene = surface
|
||||||
|
stage = 4
|
||||||
|
elapsed = 0.0
|
||||||
|
return false
|
||||||
|
4:
|
||||||
|
if elapsed < 0.2:
|
||||||
|
return false
|
||||||
|
var second_encounter: Node = surface.get_node_or_null(
|
||||||
|
"SurfaceFeatures/BossEncounter")
|
||||||
|
if second_encounter == null:
|
||||||
|
return _fail("second landing lost the boss encounter host")
|
||||||
|
var second_boss: Node = second_encounter.get_node("Boss")
|
||||||
|
if second_boss.phase != BOSS_SCRIPT.Phase.DEFEATED or second_boss.visible:
|
||||||
|
return _fail("defeated boss resurrected on the second landing")
|
||||||
|
if not "DEAD" in second_encounter.status_label.text:
|
||||||
|
return _fail("second landing does not report the defeated boss")
|
||||||
|
print("second landing keeps the boss dead")
|
||||||
|
print("TEST_BOSS_PLANET: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _open_return() -> void:
|
||||||
|
boss.accept_marked_kill(boss.global_position + Vector2(12, 0))
|
||||||
|
var returns: Array = get_nodes_in_group("boss_returns")
|
||||||
|
for returning in returns:
|
||||||
|
returning.speed = 4000.0
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_BOSS_PLANET: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_boss_planet.gd.uid
Normal file
1
tests/test_boss_planet.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dgdo2pk2ra7ii
|
||||||
|
|
@ -105,7 +105,8 @@ func _process(delta: float) -> bool:
|
||||||
return _fail("dev mode's %s spawned %.0f units away, too far to be 'near the start'" \
|
return _fail("dev mode's %s spawned %.0f units away, too far to be 'near the start'" \
|
||||||
% [definition_id, world_pos.length()])
|
% [definition_id, world_pos.length()])
|
||||||
for definition_id in [PlanetDefinitions.ROCKY_ID, PlanetDefinitions.GAS_GIANT_ID,
|
for definition_id in [PlanetDefinitions.ROCKY_ID, PlanetDefinitions.GAS_GIANT_ID,
|
||||||
PlanetDefinitions.BLACK_HOLE_ID, PlanetDefinitions.RESEARCH_LAB_ID]:
|
PlanetDefinitions.BLACK_HOLE_ID, PlanetDefinitions.RESEARCH_LAB_ID,
|
||||||
|
PlanetDefinitions.BOSS_ID]:
|
||||||
if not found.has(definition_id):
|
if not found.has(definition_id):
|
||||||
return _fail("dev mode is missing a %s - it must plant one of EVERY planet type" % definition_id)
|
return _fail("dev mode is missing a %s - it must plant one of EVERY planet type" % definition_id)
|
||||||
print("dev mode plants all %d planet types within %.0f units of the spawn" \
|
print("dev mode plants all %d planet types within %.0f units of the spawn" \
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
class_name GasBombEffect
|
class_name GasBombEffect
|
||||||
extends Node2D
|
extends Node2D
|
||||||
|
|
||||||
|
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
|
||||||
|
|
||||||
# The gas bomb itself: the flight, the blast and the picture of both, with
|
# The gas bomb itself: the flight, the blast and the picture of both, with
|
||||||
# nothing in it about who threw it.
|
# nothing in it about who threw it.
|
||||||
#
|
#
|
||||||
|
|
@ -67,13 +69,11 @@ func _explode() -> void:
|
||||||
exploded = true
|
exploded = true
|
||||||
explosion_time = EXPLOSION_DURATION
|
explosion_time = EXPLOSION_DURATION
|
||||||
hit_count = 0
|
hit_count = 0
|
||||||
# Asteroids too - a blast that clears everything around it but leaves the
|
# Every weapon target opts into the same damage contract: ordinary enemies,
|
||||||
# rocks untouched would need explaining, and they take damage the same way.
|
# asteroids, boss hatchlings and opened boss eyes all belong here.
|
||||||
#
|
|
||||||
# #176: player weapons never damage player buildings. A bomb may be thrown
|
# #176: player weapons never damage player buildings. A bomb may be thrown
|
||||||
# into a siege without punishing the structure it is meant to defend.
|
# into a siege without punishing the structure it is meant to defend.
|
||||||
for target_node in get_tree().get_nodes_in_group("enemies") \
|
for target_node in get_tree().get_nodes_in_group(COMBAT_TARGET.GROUP):
|
||||||
+ get_tree().get_nodes_in_group("asteroids"):
|
|
||||||
if is_instance_valid(target_node) and target_node is Node2D \
|
if is_instance_valid(target_node) and target_node is Node2D \
|
||||||
and target_node.global_position.distance_to(target) <= AOE_RADIUS:
|
and target_node.global_position.distance_to(target) <= AOE_RADIUS:
|
||||||
target_node.take_damage(damage)
|
target_node.take_damage(damage)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
class_name LaserBeamEffect
|
class_name LaserBeamEffect
|
||||||
extends Node2D
|
extends Node2D
|
||||||
|
|
||||||
|
const COMBAT_TARGET = preload("res://core/CombatTarget.gd")
|
||||||
|
|
||||||
# The laser shot itself: the strike and the picture of it, with nothing in it
|
# The laser shot itself: the strike and the picture of it, with nothing in it
|
||||||
# about who fired or why.
|
# about who fired or why.
|
||||||
#
|
#
|
||||||
|
|
@ -71,11 +73,9 @@ static func fire(world: Node, origin: Vector2, direction: Vector2,
|
||||||
func _strike(direction: Vector2, damage: int) -> void:
|
func _strike(direction: Vector2, damage: int) -> void:
|
||||||
hit_count = 0
|
hit_count = 0
|
||||||
var range_along: float = from.distance_to(to)
|
var range_along: float = from.distance_to(to)
|
||||||
# Asteroids share the enemies' take_damage() contract, so the beam sweeps
|
# Every attackable composition shares this contract, including opened boss
|
||||||
# both groups in one pass. Shooting a path clear through a field is a
|
# eyes. Shooting a path through an asteroid field still works unchanged.
|
||||||
# perfectly good use of a laser, and the ore it leaves behind is the point.
|
for enemy in get_tree().get_nodes_in_group(COMBAT_TARGET.GROUP):
|
||||||
for enemy in get_tree().get_nodes_in_group("enemies") \
|
|
||||||
+ get_tree().get_nodes_in_group("asteroids"):
|
|
||||||
if not is_instance_valid(enemy) or not (enemy is Node2D):
|
if not is_instance_valid(enemy) or not (enemy is Node2D):
|
||||||
continue
|
continue
|
||||||
var to_enemy: Vector2 = enemy.global_position - from
|
var to_enemy: Vector2 = enemy.global_position - from
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,13 @@ const BLACK_HOLE_CHANCE: float = 0.05
|
||||||
# from 0.08 after playtesting found that rate too scarce for how central
|
# from 0.08 after playtesting found that rate too scarce for how central
|
||||||
# research points had become to progression.
|
# research points had become to progression.
|
||||||
const RESEARCH_LAB_CHANCE: float = 0.16
|
const RESEARCH_LAB_CHANCE: float = 0.16
|
||||||
|
# A boss world is rarer than every ordinary discovery. Its roll uses a
|
||||||
|
# separately seeded generator below, so adding it cannot rename or re-roll
|
||||||
|
# any planet in an existing world.
|
||||||
|
const BOSS_PLANET_CHANCE: float = 0.025
|
||||||
|
|
||||||
# DEV MODE ONLY: one of every planet type, parked in the chunks immediately
|
# DEV MODE ONLY: one of every planet type, parked in the chunks immediately
|
||||||
# around the spawn so all four can be reached in seconds.
|
# around the spawn so all five can be reached in seconds.
|
||||||
#
|
#
|
||||||
# This replaces the guaranteed gas giant that used to sit in the origin
|
# This replaces the guaranteed gas giant that used to sit in the origin
|
||||||
# chunk for every game. That was a play-testing convenience which had become
|
# chunk for every game. That was a play-testing convenience which had become
|
||||||
|
|
@ -56,6 +60,7 @@ const DEV_PLANET_CHUNKS: Dictionary = {
|
||||||
Vector2i(1, 0): PLANET_DEFINITIONS.ROCKY_ID,
|
Vector2i(1, 0): PLANET_DEFINITIONS.ROCKY_ID,
|
||||||
Vector2i(0, 1): PLANET_DEFINITIONS.RESEARCH_LAB_ID,
|
Vector2i(0, 1): PLANET_DEFINITIONS.RESEARCH_LAB_ID,
|
||||||
Vector2i(1, 1): PLANET_DEFINITIONS.BLACK_HOLE_ID,
|
Vector2i(1, 1): PLANET_DEFINITIONS.BLACK_HOLE_ID,
|
||||||
|
Vector2i(-1, 0): PLANET_DEFINITIONS.BOSS_ID,
|
||||||
}
|
}
|
||||||
const DEV_PLANET_LOCAL_POS: Vector2 = Vector2(600.0, 600.0)
|
const DEV_PLANET_LOCAL_POS: Vector2 = Vector2(600.0, 600.0)
|
||||||
|
|
||||||
|
|
@ -143,20 +148,26 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
|
||||||
var black_hole_roll: float = rng.randf()
|
var black_hole_roll: float = rng.randf()
|
||||||
# Same principle, its own independent roll (#63).
|
# Same principle, its own independent roll (#63).
|
||||||
var research_lab_roll: float = rng.randf()
|
var research_lab_roll: float = rng.randf()
|
||||||
|
# Independent from the established generation stream: existing chunks keep
|
||||||
|
# their old names and shapes when this new rare outcome is introduced.
|
||||||
|
var boss_rng := RandomNumberGenerator.new()
|
||||||
|
boss_rng.seed = hash("boss:%d:%d" % [chunk.x, chunk.y])
|
||||||
|
var boss_roll: float = boss_rng.randf()
|
||||||
# A stable per-chunk roll makes gas giants persistent landmarks rather
|
# A stable per-chunk roll makes gas giants persistent landmarks rather
|
||||||
# than a different resource whenever the scene reloads.
|
# than a different resource whenever the scene reloads.
|
||||||
var gas_giant: bool = biome_at_chunk(chunk) == Biome.SPACE
|
var gas_giant: bool = biome_at_chunk(chunk) == Biome.SPACE
|
||||||
var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID
|
var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID
|
||||||
# A rare third outcome, seeded the same deterministic way as everything
|
# Rare authored outcomes, seeded deterministically like everything else.
|
||||||
# else here.
|
if boss_roll < BOSS_PLANET_CHANCE:
|
||||||
if black_hole_roll < BLACK_HOLE_CHANCE:
|
definition_id = PLANET_DEFINITIONS.BOSS_ID
|
||||||
|
elif black_hole_roll < BLACK_HOLE_CHANCE:
|
||||||
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
|
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
|
||||||
elif research_lab_roll < RESEARCH_LAB_CHANCE:
|
elif research_lab_roll < RESEARCH_LAB_CHANCE:
|
||||||
definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID
|
definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID
|
||||||
# Dev mode's fixed planting wins over every roll above. Applied last, and
|
# Dev mode's fixed planting wins over every roll above. Applied last, and
|
||||||
# AFTER all the rng draws, so switching it on cannot shift which random
|
# AFTER all the rng draws, so switching it on cannot shift which random
|
||||||
# values any other chunk consumes - a dev world and a normal one differ
|
# values any other chunk consumes - a dev world and a normal one differ
|
||||||
# only in these four chunks.
|
# only in these five chunks.
|
||||||
if not forced_definition_id.is_empty():
|
if not forced_definition_id.is_empty():
|
||||||
definition_id = forced_definition_id
|
definition_id = forced_definition_id
|
||||||
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
|
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue