Add tentacled boss planet encounter

This commit is contained in:
Loïc Wisniewski 2026-09-03 10:33:05 +02:00
commit 40f284dcdd
38 changed files with 1051 additions and 19 deletions

37
boss/BossEncounter.gd Normal file
View 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 = ""

View file

@ -0,0 +1 @@
uid://bk2e4e3256ifo

38
boss/BossEncounter.tscn Normal file
View 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
View 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
View file

@ -0,0 +1 @@
uid://b7lgtmpdft5dm

12
boss/BossEye.tscn Normal file
View 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
View 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
View file

@ -0,0 +1 @@
uid://cq85rc1dgdg82

12
boss/BossMinion.tscn Normal file
View 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
View 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))

View file

@ -0,0 +1 @@
uid://bq87q6csxgvu0

View 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
View 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
View file

@ -0,0 +1 @@
uid://cnn2bwdod6sod

9
boss/OctopusBoss.tscn Normal file
View 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")

View file

@ -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)

View 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)

View 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)

View file

@ -0,0 +1 @@
uid://dabh0dbwxu3i3

View 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")

View file

@ -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"),
} }

View 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);
}

View file

@ -0,0 +1 @@
uid://csf5umhh3dqft

View 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)

View file

@ -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:

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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

View 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

View file

@ -0,0 +1 @@
uid://7n3t677nrm0b

View 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
View 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

View file

@ -0,0 +1 @@
uid://dgdo2pk2ra7ii

View file

@ -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" \

View file

@ -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)

View file

@ -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

View file

@ -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)