diff --git a/boss/BossEncounter.gd b/boss/BossEncounter.gd new file mode 100644 index 0000000..6f63aa0 --- /dev/null +++ b/boss/BossEncounter.gd @@ -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 = "" diff --git a/boss/BossEncounter.gd.uid b/boss/BossEncounter.gd.uid new file mode 100644 index 0000000..3b5c8ae --- /dev/null +++ b/boss/BossEncounter.gd.uid @@ -0,0 +1 @@ +uid://bk2e4e3256ifo diff --git a/boss/BossEncounter.tscn b/boss/BossEncounter.tscn new file mode 100644 index 0000000..f724766 --- /dev/null +++ b/boss/BossEncounter.tscn @@ -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 diff --git a/boss/BossEye.gd b/boss/BossEye.gd new file mode 100644 index 0000000..9ae5e83 --- /dev/null +++ b/boss/BossEye.gd @@ -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) diff --git a/boss/BossEye.gd.uid b/boss/BossEye.gd.uid new file mode 100644 index 0000000..93ae37c --- /dev/null +++ b/boss/BossEye.gd.uid @@ -0,0 +1 @@ +uid://b7lgtmpdft5dm diff --git a/boss/BossEye.tscn b/boss/BossEye.tscn new file mode 100644 index 0000000..a648d41 --- /dev/null +++ b/boss/BossEye.tscn @@ -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") diff --git a/boss/BossMinion.gd b/boss/BossMinion.gd new file mode 100644 index 0000000..b580785 --- /dev/null +++ b/boss/BossMinion.gd @@ -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) diff --git a/boss/BossMinion.gd.uid b/boss/BossMinion.gd.uid new file mode 100644 index 0000000..f0a63df --- /dev/null +++ b/boss/BossMinion.gd.uid @@ -0,0 +1 @@ +uid://cq85rc1dgdg82 diff --git a/boss/BossMinion.tscn b/boss/BossMinion.tscn new file mode 100644 index 0000000..6b2f339 --- /dev/null +++ b/boss/BossMinion.tscn @@ -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") diff --git a/boss/BossReturnSignal.gd b/boss/BossReturnSignal.gd new file mode 100644 index 0000000..dda89ce --- /dev/null +++ b/boss/BossReturnSignal.gd @@ -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)) diff --git a/boss/BossReturnSignal.gd.uid b/boss/BossReturnSignal.gd.uid new file mode 100644 index 0000000..249c35f --- /dev/null +++ b/boss/BossReturnSignal.gd.uid @@ -0,0 +1 @@ +uid://bq87q6csxgvu0 diff --git a/boss/BossReturnSignal.tscn b/boss/BossReturnSignal.tscn new file mode 100644 index 0000000..8bc1660 --- /dev/null +++ b/boss/BossReturnSignal.tscn @@ -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") diff --git a/boss/OctopusBoss.gd b/boss/OctopusBoss.gd new file mode 100644 index 0000000..61341af --- /dev/null +++ b/boss/OctopusBoss.gd @@ -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) diff --git a/boss/OctopusBoss.gd.uid b/boss/OctopusBoss.gd.uid new file mode 100644 index 0000000..9ad1d91 --- /dev/null +++ b/boss/OctopusBoss.gd.uid @@ -0,0 +1 @@ +uid://cnn2bwdod6sod diff --git a/boss/OctopusBoss.tscn b/boss/OctopusBoss.tscn new file mode 100644 index 0000000..9279ac1 --- /dev/null +++ b/boss/OctopusBoss.tscn @@ -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") diff --git a/controllers/AimedFleetAbilityController.gd b/controllers/AimedFleetAbilityController.gd index 46af21d..b9600df 100644 --- a/controllers/AimedFleetAbilityController.gd +++ b/controllers/AimedFleetAbilityController.gd @@ -1,6 +1,8 @@ class_name AimedFleetAbilityController extends Node2D +const COMBAT_TARGET = preload("res://core/CombatTarget.gd") + # Shared press-drag-release input and camera behavior for manually aimed fleet # abilities. Subclasses provide the component, ammunition, target calculation, # shot effect, and effect visuals. @@ -125,7 +127,7 @@ func _aim_at_nearest_enemy() -> bool: var reach: float = _aim_max_range() var nearest: Node2D = null 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): continue var distance: float = origin.distance_to((enemy as Node2D).global_position) diff --git a/planets/BossPlanetDefinition.tres b/planets/BossPlanetDefinition.tres new file mode 100644 index 0000000..aa550d2 --- /dev/null +++ b/planets/BossPlanetDefinition.tres @@ -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) diff --git a/planets/BossPlanetVisual.gd b/planets/BossPlanetVisual.gd new file mode 100644 index 0000000..bc331c9 --- /dev/null +++ b/planets/BossPlanetVisual.gd @@ -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) diff --git a/planets/BossPlanetVisual.gd.uid b/planets/BossPlanetVisual.gd.uid new file mode 100644 index 0000000..0adb09f --- /dev/null +++ b/planets/BossPlanetVisual.gd.uid @@ -0,0 +1 @@ +uid://dabh0dbwxu3i3 diff --git a/planets/BossPlanetVisual.tscn b/planets/BossPlanetVisual.tscn new file mode 100644 index 0000000..c16e62e --- /dev/null +++ b/planets/BossPlanetVisual.tscn @@ -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") diff --git a/planets/PlanetDefinitions.gd b/planets/PlanetDefinitions.gd index 644f49a..42b0b29 100644 --- a/planets/PlanetDefinitions.gd +++ b/planets/PlanetDefinitions.gd @@ -5,12 +5,14 @@ const ROCKY_ID: String = "rocky" const GAS_GIANT_ID: String = "gas_giant" const BLACK_HOLE_ID: String = "black_hole" const RESEARCH_LAB_ID: String = "research_lab" +const BOSS_ID: String = "boss" const DEFAULT_DEFINITION_ID: String = ROCKY_ID const DEFINITIONS: Dictionary = { ROCKY_ID: preload("res://planets/RockyPlanetDefinition.tres"), GAS_GIANT_ID: preload("res://planets/GasGiantDefinition.tres"), BLACK_HOLE_ID: preload("res://planets/BlackHolePlanetDefinition.tres"), RESEARCH_LAB_ID: preload("res://planets/ResearchLabPlanetDefinition.tres"), + BOSS_ID: preload("res://planets/BossPlanetDefinition.tres"), } diff --git a/shaders/boss_surface.gdshader b/shaders/boss_surface.gdshader new file mode 100644 index 0000000..a9f368e --- /dev/null +++ b/shaders/boss_surface.gdshader @@ -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); +} diff --git a/shaders/boss_surface.gdshader.uid b/shaders/boss_surface.gdshader.uid new file mode 100644 index 0000000..3a75748 --- /dev/null +++ b/shaders/boss_surface.gdshader.uid @@ -0,0 +1 @@ +uid://csf5umhh3dqft diff --git a/surface/BossSurfaceProfile.tres b/surface/BossSurfaceProfile.tres new file mode 100644 index 0000000..6d59bdb --- /dev/null +++ b/surface/BossSurfaceProfile.tres @@ -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) diff --git a/surface/CaptureLabel.gd b/surface/CaptureLabel.gd index 09d6ccc..0b84210 100644 --- a/surface/CaptureLabel.gd +++ b/surface/CaptureLabel.gd @@ -12,9 +12,13 @@ extends Label const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd") var _was_conquered: bool = false +var display_enabled: bool = true func _process(_delta: float) -> void: + if not display_enabled: + visible = false + return var planet_id: String = GameState.planet_id var total: int = GameState.total_capture_points(planet_id) if total <= 0: diff --git a/surface/PlanetSetupEditor.gd b/surface/PlanetSetupEditor.gd index 3d8cfd3..41b2501 100644 --- a/surface/PlanetSetupEditor.gd +++ b/surface/PlanetSetupEditor.gd @@ -97,7 +97,9 @@ var pan_start_camera: Vector2 = Vector2.ZERO 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")) profile_button.pressed.connect(_on_profile_pressed) reward_button.pressed.connect(_on_reward_pressed) diff --git a/surface/PlanetSurfaceController.gd b/surface/PlanetSurfaceController.gd index 2347325..6e11818 100644 --- a/surface/PlanetSurfaceController.gd +++ b/surface/PlanetSurfaceController.gd @@ -49,6 +49,12 @@ func _compose_surface(profile: Resource) -> void: var caption: Label = get_node(caption_path) caption.add_theme_color_override("font_color", profile.caption_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: var features: Node = profile.features_scene.instantiate() feature_host.add_child(features) @@ -64,7 +70,7 @@ func _compose_surface(profile: Resource) -> void: var setup_host: Node = SETUP_HOST_SCENE.instantiate() feature_host.add_child(setup_host) setup_host.apply_setup(setup) - else: + elif profile.encounter_scene == null: # No PlanetSetup exists for this profile at all (#55: "void" has # none authored) - register zero required points directly, rather # 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. 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: if transitioning: diff --git a/surface/PlanetSurfaceProfile.gd b/surface/PlanetSurfaceProfile.gd index d13a6f8..cf550dc 100644 --- a/surface/PlanetSurfaceProfile.gd +++ b/surface/PlanetSurfaceProfile.gd @@ -6,6 +6,12 @@ extends Resource @export var profile_id: String = "rocky" @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 # 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 diff --git a/surface/PlanetSurfaceProfiles.gd b/surface/PlanetSurfaceProfiles.gd index 8beeaac..80693f2 100644 --- a/surface/PlanetSurfaceProfiles.gd +++ b/surface/PlanetSurfaceProfiles.gd @@ -7,8 +7,17 @@ const PROFILES: Dictionary = { "gas": preload("res://surface/GasSurfaceProfile.tres"), "void": preload("res://surface/VoidSurfaceProfile.tres"), "research_lab": preload("res://surface/ResearchLabSurfaceProfile.tres"), + "boss": preload("res://surface/BossSurfaceProfile.tres"), } static func get_profile(profile_id: String) -> Resource: 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 diff --git a/tests/_boss_planet_preview.gd b/tests/_boss_planet_preview.gd new file mode 100644 index 0000000..6547f29 --- /dev/null +++ b/tests/_boss_planet_preview.gd @@ -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 diff --git a/tests/_boss_planet_preview.gd.uid b/tests/_boss_planet_preview.gd.uid new file mode 100644 index 0000000..6db0ee5 --- /dev/null +++ b/tests/_boss_planet_preview.gd.uid @@ -0,0 +1 @@ +uid://7n3t677nrm0b diff --git a/tests/_boss_planet_preview.tscn b/tests/_boss_planet_preview.tscn new file mode 100644 index 0000000..235c483 --- /dev/null +++ b/tests/_boss_planet_preview.tscn @@ -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") diff --git a/tests/test_boss_planet.gd b/tests/test_boss_planet.gd new file mode 100644 index 0000000..79a772c --- /dev/null +++ b/tests/test_boss_planet.gd @@ -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 diff --git a/tests/test_boss_planet.gd.uid b/tests/test_boss_planet.gd.uid new file mode 100644 index 0000000..bb4ba5f --- /dev/null +++ b/tests/test_boss_planet.gd.uid @@ -0,0 +1 @@ +uid://dgdo2pk2ra7ii diff --git a/tests/test_dev_mode.gd b/tests/test_dev_mode.gd index 57e027d..da7b4fa 100644 --- a/tests/test_dev_mode.gd +++ b/tests/test_dev_mode.gd @@ -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'" \ % [definition_id, world_pos.length()]) 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): 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" \ diff --git a/vfx/GasBombEffect.gd b/vfx/GasBombEffect.gd index bdd4775..a33ff7e 100644 --- a/vfx/GasBombEffect.gd +++ b/vfx/GasBombEffect.gd @@ -1,6 +1,8 @@ class_name GasBombEffect extends Node2D +const COMBAT_TARGET = preload("res://core/CombatTarget.gd") + # The gas bomb itself: the flight, the blast and the picture of both, with # nothing in it about who threw it. # @@ -67,13 +69,11 @@ func _explode() -> void: exploded = true explosion_time = EXPLOSION_DURATION hit_count = 0 - # Asteroids too - a blast that clears everything around it but leaves the - # rocks untouched would need explaining, and they take damage the same way. - # + # Every weapon target opts into the same damage contract: ordinary enemies, + # asteroids, boss hatchlings and opened boss eyes all belong here. # #176: player weapons never damage player buildings. A bomb may be thrown # into a siege without punishing the structure it is meant to defend. - for target_node in get_tree().get_nodes_in_group("enemies") \ - + get_tree().get_nodes_in_group("asteroids"): + for target_node in get_tree().get_nodes_in_group(COMBAT_TARGET.GROUP): if is_instance_valid(target_node) and target_node is Node2D \ and target_node.global_position.distance_to(target) <= AOE_RADIUS: target_node.take_damage(damage) diff --git a/vfx/LaserBeamEffect.gd b/vfx/LaserBeamEffect.gd index d530230..f6716ba 100644 --- a/vfx/LaserBeamEffect.gd +++ b/vfx/LaserBeamEffect.gd @@ -1,6 +1,8 @@ class_name LaserBeamEffect 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 # 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: hit_count = 0 var range_along: float = from.distance_to(to) - # Asteroids share the enemies' take_damage() contract, so the beam sweeps - # both groups in one pass. Shooting a path clear through a field is a - # perfectly good use of a laser, and the ore it leaves behind is the point. - for enemy in get_tree().get_nodes_in_group("enemies") \ - + get_tree().get_nodes_in_group("asteroids"): + # Every attackable composition shares this contract, including opened boss + # eyes. Shooting a path through an asteroid field still works unchanged. + for enemy in get_tree().get_nodes_in_group(COMBAT_TARGET.GROUP): if not is_instance_valid(enemy) or not (enemy is Node2D): continue var to_enemy: Vector2 = enemy.global_position - from diff --git a/world/WorldGen.gd b/world/WorldGen.gd index 1feb86f..5825d71 100644 --- a/world/WorldGen.gd +++ b/world/WorldGen.gd @@ -37,9 +37,13 @@ const BLACK_HOLE_CHANCE: float = 0.05 # from 0.08 after playtesting found that rate too scarce for how central # research points had become to progression. 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 -# 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 # 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(0, 1): PLANET_DEFINITIONS.RESEARCH_LAB_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) @@ -143,20 +148,26 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: var black_hole_roll: float = rng.randf() # Same principle, its own independent roll (#63). 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 # than a different resource whenever the scene reloads. 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 - # A rare third outcome, seeded the same deterministic way as everything - # else here. - if black_hole_roll < BLACK_HOLE_CHANCE: + # Rare authored outcomes, seeded deterministically like everything else. + if boss_roll < BOSS_PLANET_CHANCE: + definition_id = PLANET_DEFINITIONS.BOSS_ID + elif black_hole_roll < BLACK_HOLE_CHANCE: definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID elif research_lab_roll < RESEARCH_LAB_CHANCE: definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID # 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 # 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(): definition_id = forced_definition_id var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)