Refactor recent gameplay systems after code review

This commit is contained in:
Loïc Wisniewski 2026-08-16 21:38:07 +02:00
commit d68f4d4f88
57 changed files with 530 additions and 572 deletions

View file

@ -0,0 +1,191 @@
class_name AimedFleetAbilityController
extends Node2D
# 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.
const AIM_DEADZONE: float = 22.0
@export var minimum_zoom: float = 0.4
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
var button: IconButton
var status: Label
var status_message_until: int = 0
var aiming: bool = false
var aim_touch: int = -1
var aim_start_screen: Vector2 = Vector2.ZERO
var aim_screen: Vector2 = Vector2.ZERO
var aim_target: Vector2 = Vector2.ZERO
var saved_zoom: float = 1.0
var restoring_zoom: bool = false
func _ready() -> void:
button = get_node(_button_path())
status = get_node(_status_path())
button.visible = false
status.text = ""
queue_redraw()
func _process(delta: float) -> void:
var had_visuals: bool = aiming or _has_effect_visuals()
button.visible = fleet.has_capability(_component_id())
button.enabled = _next_ready_ship() != null and _can_afford_shot()
if not button.visible and aiming:
_cancel_aim()
if status.text != "" and Time.get_ticks_msec() >= status_message_until:
status.text = ""
if restoring_zoom and not aiming:
var zoom: float = move_toward(camera.zoom.x, saved_zoom,
maxf(saved_zoom, 0.1) * delta * 5.0)
camera.zoom = Vector2(zoom, zoom)
if is_equal_approx(zoom, saved_zoom):
restoring_zoom = false
_advance_effects(delta)
if aiming or _has_effect_visuals() or had_visuals:
queue_redraw()
func can_begin_aim() -> bool:
# An unaffordable shot still claims the press so the status label can
# explain what is missing instead of silently passing the touch through.
return button.visible and not button._blocked_by_modal() \
and not aiming and _next_ready_ship() != null
func handle_touch(event: InputEventScreenTouch) -> bool:
if event.pressed:
if not can_begin_aim() \
or not Rect2(button.global_position, button.size).has_point(event.position):
return false
if not _can_afford_shot():
_say(_unaffordable_message())
return true
aiming = true
aim_touch = event.index
aim_start_screen = event.position
aim_screen = event.position
saved_zoom = camera.zoom.x
restoring_zoom = false
button.pressed_down = true
button.queue_redraw()
_update_aim()
return true
if event.index != aim_touch:
return false
if aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE:
_fire()
else:
_cancel_aim()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if not aiming or event.index != aim_touch:
return false
aim_screen = event.position
_update_aim()
return true
func _update_aim() -> void:
var origin: Vector2 = _aim_origin()
aim_target = _calculate_aim_target(aim_screen - aim_start_screen, origin)
_update_aim_zoom(origin, aim_target)
queue_redraw()
func _update_aim_zoom(origin: Vector2, target: Vector2) -> void:
var displacement: Vector2 = target - origin
var half_screen: Vector2 = get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0)
var padding: float = _aim_padding()
var fit_x: float = saved_zoom if absf(displacement.x) < 1.0 \
else half_screen.x / (absf(displacement.x) + padding)
var fit_y: float = saved_zoom if absf(displacement.y) < 1.0 \
else half_screen.y / (absf(displacement.y) + padding)
var desired: float = clampf(minf(saved_zoom, minf(fit_x, fit_y)),
minimum_zoom, saved_zoom)
camera.zoom = Vector2(desired, desired)
func _finish_aim() -> void:
aiming = false
aim_touch = -1
button.pressed_down = false
button.queue_redraw()
restoring_zoom = true
func _cancel_aim() -> void:
_finish_aim()
queue_redraw()
func _fire() -> void:
var ship: Node2D = _next_ready_ship()
if ship == null:
_cancel_aim()
return
if not _can_afford_shot():
_say(_unaffordable_message())
_cancel_aim()
return
var component: Node = ship.get_component(_component_id())
if component == null or not _consume_shot(component):
_cancel_aim()
return
GameState.withdraw_fleet_resource(_ammo_resource(), _ammo_cost())
_perform_shot(ship, component)
_finish_aim()
button.enabled = _next_ready_ship() != null and _can_afford_shot()
func _next_ready_ship() -> Node2D:
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
var component: Node = ship.get_component(_component_id())
if component != null and component.is_ready():
return ship
return null
func _can_afford_shot() -> bool:
return GameState.fleet_resource(_ammo_resource()) >= _ammo_cost()
func _unaffordable_message() -> String:
var resource_type: String = _ammo_resource()
return "Need %d %s in the fleet hold to fire (have %d)" % [
_ammo_cost(), GameState.resource_name(resource_type).to_lower(),
GameState.fleet_resource(resource_type),
]
func _say(message: String) -> void:
status.text = message
status_message_until = Time.get_ticks_msec() + 3000
func _aim_origin() -> Vector2:
return fleet.get_centroid()
# Subclass hooks.
func _button_path() -> NodePath: return NodePath()
func _status_path() -> NodePath: return NodePath()
func _component_id() -> String: return ""
func _ammo_resource() -> String: return ""
func _ammo_cost() -> int: return 0
func _aim_padding() -> float: return 0.0
func _has_effect_visuals() -> bool: return false
func _advance_effects(_delta: float) -> void: pass
func _consume_shot(_component: Node) -> bool: return false
func _perform_shot(_ship: Node2D, _component: Node) -> void: pass
func _calculate_aim_target(_drag: Vector2, origin: Vector2) -> Vector2: return origin

View file

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

View file

@ -738,7 +738,7 @@ func _refresh_skill_menu(id: String = "") -> void:
var node: Dictionary = GameState.skill_node(id)
if node.is_empty():
return
var points_needed: int = int(node.get("research_points", 0))
var points_needed: int = GameState.skill_research_point_cost(id)
skill_detail.text = "%s\n%s" % [node.name, node.desc]
if points_needed > 0:
skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed]
@ -1043,7 +1043,7 @@ func _transfer_place_name() -> String:
var b: Dictionary = GameState.find_building(transfer_building_id)
if b.is_empty():
return "this building"
if b.type == GameState.BUILDING_MOTHERSHIP:
if GameState.building_has_capability(b, "bank"):
# Worth naming as the bank rather than as a structure: this is the
# one building where unloading makes the ore spendable everywhere.
return "the bank"
@ -1056,7 +1056,7 @@ func _refresh_transfer() -> void:
return
var building: Dictionary = GameState.find_building(transfer_building_id)
var heading: String = "This building"
if not building.is_empty() and building.type == GameState.BUILDING_MOTHERSHIP:
if GameState.building_has_capability(building, "bank"):
heading = "Mothership bank"
transfer_list.set_destination(transfer_building_id, heading)

View file

@ -9,6 +9,7 @@ extends Resource
@export var menu_icon: int
@export var visual_scene: PackedScene
@export var capabilities: PackedStringArray = []
@export var logistics: Resource
@export var map_color: Color = Color.WHITE
@export var map_sides: int = 4
@export var map_radius: float = 7.0

View file

@ -0,0 +1,13 @@
class_name BuildingLogisticsProfile
extends Resource
# Optional logistics and conversion behavior composed into a building
# definition. GameState consumes this data generically, so adding another
# converter does not require another resource-type branch in transport code.
@export var input_buffers: Dictionary = {}
@export var collected_outputs: PackedStringArray = []
@export var conversion_input: String = ""
@export var conversion_output: String = ""
@export var conversion_rate: float = 0.0
@export var conversion_yield: int = 1

View file

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

1
EnemyBigLaserBeam.gd.uid Normal file
View file

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

View file

@ -0,0 +1 @@
uid://6oj7ia3em4x0

View file

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

View file

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

View file

@ -80,7 +80,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO,
ship.destroyed.connect(_on_ship_destroyed.bind(ship))
fleet_target = center
GameState.sync_fleet_cargo_capacity(vessel_types())
_sync_composition_state()
func vessel_types() -> PackedStringArray:
@ -91,6 +91,15 @@ func vessel_types() -> PackedStringArray:
return result
func _sync_composition_state() -> void:
GameState.sync_fleet_cargo_capacity(vessel_types())
# Composition changes are authoritative; scene teardown is not. Keeping
# this out of SpeedFader prevents an outgoing scene's empty fleet from
# resetting a boost while the same vessels are being restored on a surface.
if not has_capability("distorder"):
GameState.set_fleet_speed_multiplier(1.0)
func has_vessel_type(vessel_type: String) -> bool:
for ship in ships:
if is_instance_valid(ship) and String(ship.vessel_type) == vessel_type:
@ -139,7 +148,7 @@ func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool:
if ship.has_signal("destroyed"):
ship.destroyed.connect(_on_ship_destroyed.bind(ship))
ship.set_move_target(fleet_target + offset)
GameState.sync_fleet_cargo_capacity(vessel_types())
_sync_composition_state()
return true
@ -157,7 +166,7 @@ func store_ship(index: int) -> String:
formation_offsets.remove_at(index)
ship.queue_free()
apply_formation()
GameState.sync_fleet_cargo_capacity(vessel_types())
_sync_composition_state()
return vessel_type
@ -283,7 +292,7 @@ func _on_ship_destroyed(ship: Node) -> void:
if idx != -1:
ships.remove_at(idx)
formation_offsets.remove_at(idx)
GameState.sync_fleet_cargo_capacity(vessel_types())
_sync_composition_state()
func get_centroid() -> Vector2:

View file

@ -387,7 +387,11 @@ func prerequisite_met(id: String) -> bool:
func can_unlock(id: String) -> bool:
var node: Dictionary = skill_node(id)
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \
and research_points >= int(node.get("research_points", 0))
and research_points >= skill_research_point_cost(id)
func skill_research_point_cost(id: String) -> int:
return int(skill_node(id).get("research_points", 0))
func unlock(id: String) -> bool:
@ -396,7 +400,7 @@ func unlock(id: String) -> bool:
var node: Dictionary = skill_node(id)
if not spend(int(node.cost)):
return false
var points_needed: int = int(node.get("research_points", 0))
var points_needed: int = skill_research_point_cost(id)
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
@ -446,6 +450,8 @@ func reset_new_game() -> void:
planet_id = ""
planet_definition_id = "rocky"
fleet_speed_multiplier = 1.0
_distorder_drain_progress = 0.0
boost_depleted_notice = false
research_points = 0
explored_chunks.clear()
bank_inventory = INVENTORY.new({RESOURCE_ORE: 500})
@ -520,6 +526,13 @@ func building_has_capability(building: Dictionary, capability: String) -> bool:
return definition != null and capability in definition.capabilities
func building_logistics_profile(building: Dictionary) -> Resource:
if building.is_empty():
return null
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type))
return null if definition == null else definition.logistics
# --- vessel types and their upgrades (#5) ---------------------------------
# Combat vessels can join the player fleet. Transporter metadata lives in the
@ -1044,19 +1057,20 @@ func has_building_of_type(type: String) -> bool:
func remove_buildings_of_type(type: String) -> int:
var kept: Array = []
var removed_ids: Array = []
var removed_hub_ids: Array = []
var removed: int = 0
for b in buildings:
if b.type == type:
removed += 1
removed_ids.append(int(b.id))
if building_has_capability(b, "transport_hub"):
removed_hub_ids.append(int(b.id))
else:
kept.append(b)
buildings = kept
if type == BUILDING_TRANSPORT_HUB and not removed_ids.is_empty():
if not removed_hub_ids.is_empty():
var kept_transporters: Array = []
for transporter in transporters:
if int(transporter.get("hub_id", 0)) not in removed_ids:
if int(transporter.get("hub_id", 0)) not in removed_hub_ids:
kept_transporters.append(transporter)
transporters = kept_transporters
return removed
@ -1186,44 +1200,50 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted:
return inventory
# --- gas burners (#54) -----------------------------------------------------
#
# Converts gas into energy at the building's own site, simulated here
# alongside extractor mining and production so it keeps running whether or
# not the player is nearby. Deliberately withdraws only from the building's
# OWN inventory, never the bank - a burner with no local gas simply stalls,
# exactly like a lab or crafter waiting on materials, rather than conjuring
# energy for free.
# --- building logistics and resource conversion ---------------------------
const GAS_BURN_RATE: float = 1.0 # gas/second
const ENERGY_PER_GAS: int = 2
# Local gas reserve a burner tries to keep topped up. Transporters treat
# falling short of this the same way they treat a queued production/research
# order (see _dispatch_transporters()'s gas_burner_gas_deficit() use).
const GAS_BURNER_BUFFER: int = 40
func gas_burner_gas_deficit(id: int) -> int:
var b: Dictionary = find_building(id)
if not building_has_capability(b, "gas_burner"):
func building_logistics_deficit(id: int, resource_type: String) -> int:
var building: Dictionary = find_building(id)
var profile: Resource = building_logistics_profile(building)
if profile == null:
return 0
return maxi(0, GAS_BURNER_BUFFER - building_resource(id, RESOURCE_GAS))
var target: int = int(profile.input_buffers.get(resource_type, 0))
return maxi(0, target - building_resource(id, resource_type))
func _advance_gas_burners(delta: float) -> void:
for b in buildings:
if not building_has_capability(b, "gas_burner"):
func building_requested_resource(id: int, resource_type: String) -> int:
return maxi(outstanding_resource_cost(id, resource_type),
building_logistics_deficit(id, resource_type))
func _collectable_building_resource(building: Dictionary, minimum_amount: int) -> String:
var profile: Resource = building_logistics_profile(building)
if profile == null:
return ""
for resource_type in profile.collected_outputs:
if building_resource(int(building.id), resource_type) >= minimum_amount:
return resource_type
return ""
func _advance_resource_converters(delta: float) -> void:
for building in buildings:
var profile: Resource = building_logistics_profile(building)
if profile == null or profile.conversion_input == "" \
or profile.conversion_output == "" or profile.conversion_rate <= 0.0 \
or profile.conversion_yield <= 0:
continue
var inventory: RefCounted = _inventory_for_record(b)
if inventory.amount(RESOURCE_GAS) <= 0:
continue # stalled - no local gas to burn
var progress: float = float(b.get("gas_burn_progress", 0.0)) + delta * GAS_BURN_RATE
var whole: int = mini(int(floor(progress)), inventory.amount(RESOURCE_GAS))
var inventory: RefCounted = _inventory_for_record(building)
if inventory.amount(profile.conversion_input) <= 0:
continue
var progress: float = float(building.get("conversion_progress", 0.0)) \
+ delta * profile.conversion_rate
var whole: int = mini(int(floor(progress)), inventory.amount(profile.conversion_input))
if whole > 0:
inventory.withdraw(RESOURCE_GAS, whole)
inventory.deposit(RESOURCE_ENERGY, whole * ENERGY_PER_GAS)
inventory.withdraw(profile.conversion_input, whole)
inventory.deposit(profile.conversion_output, whole * profile.conversion_yield)
progress -= whole
b.gas_burn_progress = progress
building.conversion_progress = progress
func _advance_distorder_drain(delta: float) -> void:
@ -1341,7 +1361,7 @@ func _process(delta: float) -> void:
_advance_production(delta)
_advance_lab_research(delta)
_advance_vessel_deliveries(delta)
_advance_gas_burners(delta)
_advance_resource_converters(delta)
_advance_distorder_drain(delta)
for e in extractors:
e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE
@ -1500,13 +1520,7 @@ func _dispatch_transporters() -> void:
for b in buildings:
var needed_resource := ""
for resource_type in resource_types():
# A gas burner's ongoing gas deficit is folded in alongside
# queued production/research costs (#54) - both are just "this
# building wants more of resource_type", so the one dispatch
# path already below serves both without any new transporter
# state.
var need: int = maxi(outstanding_resource_cost(int(b.id), resource_type),
gas_burner_gas_deficit(int(b.id)) if resource_type == RESOURCE_GAS else 0)
var need: int = building_requested_resource(int(b.id), resource_type)
if need > 0 and bank_inventory.amount(resource_type) > 0:
needed_resource = resource_type
break
@ -1559,14 +1573,11 @@ func _dispatch_transporters() -> void:
_begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position,
TRANSPORTER_TO_EXTRACTOR)
# Collecting a gas burner's energy output, last: queued deliveries and
# extractor collection both outrank it, same "supply what's needed
# before what's merely ready" ordering _dispatch_transporters() already
# holds for extractors over queued orders (f5c60f8).
# Collect building outputs last: queued deliveries and extractor collection
# both outrank resources that are merely ready for pickup.
for b in buildings:
if not building_has_capability(b, "gas_burner"):
continue
if building_resource(int(b.id), RESOURCE_ENERGY) < load or _transporter_inbound_for_pickup(int(b.id)):
var pickup_resource: String = _collectable_building_resource(b, load)
if pickup_resource == "" or _transporter_inbound_for_pickup(int(b.id)):
continue
var chosen_pickup: Dictionary = {}
var best_pickup_distance: float = INF
@ -1580,7 +1591,7 @@ func _dispatch_transporters() -> void:
if chosen_pickup.is_empty():
return
chosen_pickup.target_id = int(b.id)
chosen_pickup.resource_type = RESOURCE_ENERGY
chosen_pickup.resource_type = pickup_resource
chosen_pickup.extractor_index = -1
_begin_leg(chosen_pickup, transporter_position(chosen_pickup), b.position,
TRANSPORTER_TO_BUILDING_PICKUP)
@ -1610,8 +1621,7 @@ func _arrive(t: Dictionary) -> void:
TRANSPORTER_TO_MOTHERSHIP:
var target: Dictionary = find_building(int(t.target_id))
var resource_type: String = String(t.get("resource_type", RESOURCE_ORE))
var want: int = maxi(outstanding_resource_cost(int(t.target_id), resource_type),
gas_burner_gas_deficit(int(t.target_id)) if resource_type == RESOURCE_GAS else 0)
var want: int = building_requested_resource(int(t.target_id), resource_type)
var take: int = mini(mini(want, bank_inventory.amount(resource_type)), transporter_load_limit())
if take <= 0 or target.is_empty():
# Nothing to carry, or the building is gone. Go home empty.

View file

@ -1,79 +1,64 @@
class_name GasBombController
extends Node2D
extends "res://AimedFleetAbilityController.gd"
const BOMB_COLOR := Color(0.96, 0.22, 0.72)
const TARGET_COLOR := Color(0.78, 0.32, 1.0)
const MAX_RANGE := 950.0
const AOE_RADIUS := 180.0
const DRAG_RADIUS := 260.0
const AIM_DEADZONE := 22.0
const PROJECTILE_SPEED := 720.0
const DAMAGE := 3
const COOLDOWN := 5.0
# #60: ammunition from the fleet hold, not the bank - the fleet has to be
# carrying its own gas to fire.
const GAS_PER_SHOT: int = 5
@export var minimum_zoom: float = 0.4
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var button: IconButton = get_node("../UI/GasBombButton")
@onready var status: Label = get_node("../UI/GasBombStatus")
var status_message_until: int = 0
var aiming := false
var aim_touch := -1
var aim_start_screen := Vector2.ZERO
var aim_screen := Vector2.ZERO
var aim_target := Vector2.ZERO
var saved_zoom := 1.0
var restoring_zoom := false
var projectiles: Array = []
var explosions: Array = []
var last_hit_count := 0
var last_hit_count: int = 0
var projectile_active: bool:
get: return not projectiles.is_empty()
var explosion_time: float:
get:
var longest := 0.0
var longest: float = 0.0
for explosion in explosions:
longest = maxf(longest, float(explosion.time))
return longest
func _ready() -> void:
button.visible = false
status.text = ""
queue_redraw()
func _button_path() -> NodePath: return "../UI/GasBombButton"
func _status_path() -> NodePath: return "../UI/GasBombStatus"
func _component_id() -> String: return "gas_bomber"
func _ammo_resource() -> String: return GameState.RESOURCE_GAS
func _ammo_cost() -> int: return GAS_PER_SHOT
func _aim_padding() -> float: return AOE_RADIUS
func _has_effect_visuals() -> bool: return not projectiles.is_empty() or not explosions.is_empty()
func _can_afford_shot() -> bool:
return GameState.fleet_resource(GameState.RESOURCE_GAS) >= GAS_PER_SHOT
func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2:
if drag.length() < 0.001:
return origin
var distance: float = minf(drag.length() / DRAG_RADIUS, 1.0) * MAX_RANGE
return origin + drag.normalized() * distance
func _say(message: String) -> void:
status.text = message
status_message_until = Time.get_ticks_msec() + 3000
func _consume_shot(component: Node) -> bool:
return component.consume_shot(COOLDOWN)
func _process(delta: float) -> void:
var had_visuals := aiming or not projectiles.is_empty() or not explosions.is_empty()
button.visible = fleet.has_capability("gas_bomber")
button.enabled = _next_ready_bomber() != null and _can_afford_shot()
if not button.visible and aiming:
_cancel_aim()
if status.text != "" and Time.get_ticks_msec() >= status_message_until:
status.text = ""
func _perform_shot(ship: Node2D, _component: Node) -> void:
projectiles.append({
"position": ship.global_position,
"target": aim_target,
"trail": PackedVector2Array([ship.global_position]),
})
if restoring_zoom and not aiming:
var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0)
camera.zoom = Vector2(z, z)
if is_equal_approx(z, saved_zoom):
restoring_zoom = false
func _next_ready_bomber() -> Node2D:
return _next_ready_ship()
func _advance_effects(delta: float) -> void:
for i in range(projectiles.size() - 1, -1, -1):
var projectile: Dictionary = projectiles[i]
var distance: float = (projectile.position as Vector2).distance_to(projectile.target)
@ -82,8 +67,8 @@ func _process(delta: float) -> void:
_explode(projectile.target)
projectiles.remove_at(i)
else:
projectile.position = (projectile.position as Vector2).move_toward(projectile.target,
PROJECTILE_SPEED * delta)
projectile.position = (projectile.position as Vector2).move_toward(
projectile.target, PROJECTILE_SPEED * delta)
var trail: PackedVector2Array = projectile.trail
trail.append(projectile.position)
while trail.size() > 12:
@ -94,122 +79,6 @@ func _process(delta: float) -> void:
explosion.time = maxf(float(explosion.time) - delta, 0.0)
if float(explosion.time) <= 0.0:
explosions.remove_at(i)
if aiming or not projectiles.is_empty() or not explosions.is_empty() or had_visuals:
queue_redraw()
func can_begin_aim() -> bool:
# Deliberately NOT button.enabled: an unaffordable shot still needs the
# press to reach here so it can explain itself (IconButton's own "greyed
# but still pressable" convention) rather than the touch silently
# falling through to whatever's behind it. A wholly unready fleet (no
# bomber at all) has nothing more useful to say than "greyed out".
return button.visible and not button._blocked_by_modal() \
and not aiming and _next_ready_bomber() != null
func handle_touch(event: InputEventScreenTouch) -> bool:
if event.pressed:
if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position):
return false
if not _can_afford_shot():
_say("Need %d gas in the fleet hold to fire (have %d)" \
% [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)])
return true
aiming = true
aim_touch = event.index
aim_start_screen = event.position
aim_screen = event.position
saved_zoom = camera.zoom.x
restoring_zoom = false
button.pressed_down = true
button.queue_redraw()
_update_aim()
return true
if event.index != aim_touch:
return false
var should_fire := aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE
if should_fire:
_fire()
else:
_cancel_aim()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if not aiming or event.index != aim_touch:
return false
aim_screen = event.position
_update_aim()
return true
func _update_aim() -> void:
var drag := aim_screen - aim_start_screen
var origin: Vector2 = fleet.get_centroid()
if drag.length() < 0.001:
aim_target = origin
else:
var distance := minf(drag.length() / DRAG_RADIUS, 1.0) * MAX_RANGE
aim_target = origin + drag.normalized() * distance
_update_aim_zoom(origin, aim_target)
queue_redraw()
func _update_aim_zoom(origin: Vector2, target: Vector2) -> void:
var displacement := target - origin
var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0)
var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + AOE_RADIUS)
var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + AOE_RADIUS)
var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom)
camera.zoom = Vector2(desired, desired)
func _finish_aim() -> void:
aiming = false
aim_touch = -1
button.pressed_down = false
button.queue_redraw()
restoring_zoom = true
func _cancel_aim() -> void:
_finish_aim()
queue_redraw()
func _fire() -> void:
var bomber: Node2D = _next_ready_bomber()
if bomber == null:
_cancel_aim()
return
if not _can_afford_shot():
_say("Need %d gas in the fleet hold to fire (have %d)" \
% [GAS_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_GAS)])
_cancel_aim()
return
var component: Node = bomber.get_component("gas_bomber")
if not component.consume_shot(COOLDOWN):
_cancel_aim()
return
GameState.withdraw_fleet_resource(GameState.RESOURCE_GAS, GAS_PER_SHOT)
projectiles.append({
"position": bomber.global_position,
"target": aim_target,
"trail": PackedVector2Array([bomber.global_position]),
})
_finish_aim()
button.enabled = _next_ready_bomber() != null and _can_afford_shot()
func _next_ready_bomber() -> Node2D:
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
var component: Node = ship.get_component("gas_bomber")
if component != null and component.is_ready():
return ship
return null
func _explode(at: Vector2) -> void:
@ -224,28 +93,29 @@ func _explode(at: Vector2) -> void:
func _draw() -> void:
if aiming:
var origin: Vector2 = fleet.get_centroid()
NeonDraw.draw_glowing_line(self, origin, aim_target, TARGET_COLOR, 0.7, 0.8)
NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.8)
_draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8)
_draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0)
for projectile in projectiles:
var trail: PackedVector2Array = projectile.trail
if trail.size() >= 2:
for i in range(1, trail.size()):
var alpha := float(i) / trail.size()
draw_line(trail[i - 1], trail[i],
Color(BOMB_COLOR, alpha * 0.65), 4.0 + alpha * 4.0, true)
var alpha: float = float(i) / trail.size()
draw_line(trail[i - 1], trail[i], Color(BOMB_COLOR, alpha * 0.65),
4.0 + alpha * 4.0, true)
draw_circle(projectile.position, 12.0, Color(BOMB_COLOR, 0.35))
NeonDraw.draw_glowing_circle(self, projectile.position, 7.0, BOMB_COLOR, 1.0)
for explosion in explosions:
var progress := 1.0 - float(explosion.time) / 0.8
var radius := lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0))
var progress: float = 1.0 - float(explosion.time) / 0.8
var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0))
draw_circle(explosion.position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16))
_draw_ring(explosion.position, radius, BOMB_COLOR, 1.0 - progress)
for i in range(7):
var angle := TAU * float(i) / 7.0 + progress
var cloud_at: Vector2 = explosion.position + Vector2(cos(angle), sin(angle)) * radius * 0.55
draw_circle(cloud_at, 18.0 + 14.0 * progress, Color(BOMB_COLOR, (1.0 - progress) * 0.22))
var angle: float = TAU * float(i) / 7.0 + progress
var cloud_at: Vector2 = explosion.position \
+ Vector2(cos(angle), sin(angle)) * radius * 0.55
draw_circle(cloud_at, 18.0 + 14.0 * progress,
Color(BOMB_COLOR, (1.0 - progress) * 0.22))
func _draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void:

View file

@ -1,6 +1,19 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=4 format=3]
[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://GasBurnerBuildingVisual.tscn" id="2"]
[ext_resource type="Script" path="res://BuildingLogisticsProfile.gd" id="3"]
[sub_resource type="Resource" id="Resource_logistics"]
script = ExtResource("3")
input_buffers = {
"gas": 40
}
collected_outputs = PackedStringArray("energy")
conversion_input = "gas"
conversion_output = "energy"
conversion_rate = 1.0
conversion_yield = 2
[resource]
script = ExtResource("1")
definition_id = "gas_burner"
@ -11,6 +24,7 @@ action_icon = 33
menu_icon = 33
visual_scene = ExtResource("2")
capabilities = PackedStringArray("gas_burner")
logistics = SubResource("Resource_logistics")
map_color = Color(0.95, 0.35, 0.15, 1)
map_sides = 5
map_radius = 7.0

View file

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

View file

@ -1,195 +1,60 @@
class_name LaserController
extends Node2D
# Same press-drag-release aiming plumbing as GasBombController, adapted for
# an instant beam rather than a lobbed, timed projectile: there is no travel
# time or explosion delay - the beam strikes the instant the shot fires, and
# only a brief flash lingers for feedback. Damage/range/cooldown are read
# straight off the firing ship's own VesselLaserComponent (already resolved
# from vessel_stat/upgrades) rather than hardcoded here, unlike the bomber's
# AOE_RADIUS/DAMAGE/COOLDOWN.
#
# Reused as-is by both Main.tscn and PlanetSurface.tscn (same relative
# ../Fleet, ../Camera2D, ../UI/LaserButton paths as GasBombController), so
# it works identically in space and on a landed surface with no branching.
extends "res://AimedFleetAbilityController.gd"
const BEAM_COLOR := Color(0.3, 0.9, 1.0)
const TARGET_COLOR := Color(0.6, 0.95, 1.0)
const BEAM_HALF_WIDTH := 22.0
const DRAG_DEADZONE := 22.0
const FLASH_DURATION := 0.22
# #61: ammunition from the fleet hold, mirroring #60's gas bomber exactly.
const ENERGY_PER_SHOT: int = 8
@export var minimum_zoom: float = 0.4
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var button: IconButton = get_node("../UI/LaserButton")
@onready var status: Label = get_node("../UI/LaserStatus")
var status_message_until: int = 0
var aiming := false
var aim_touch := -1
var aim_start_screen := Vector2.ZERO
var aim_screen := Vector2.ZERO
var aim_target := Vector2.ZERO
var saved_zoom := 1.0
var restoring_zoom := false
var beams: Array = []
var last_hit_count := 0
var last_hit_count: int = 0
func _ready() -> void:
button.visible = false
status.text = ""
queue_redraw()
func _button_path() -> NodePath: return "../UI/LaserButton"
func _status_path() -> NodePath: return "../UI/LaserStatus"
func _component_id() -> String: return "laser"
func _ammo_resource() -> String: return GameState.RESOURCE_ENERGY
func _ammo_cost() -> int: return ENERGY_PER_SHOT
func _aim_padding() -> float: return BEAM_HALF_WIDTH
func _has_effect_visuals() -> bool: return not beams.is_empty()
func _can_afford_shot() -> bool:
return GameState.fleet_resource(GameState.RESOURCE_ENERGY) >= ENERGY_PER_SHOT
func _aim_max_range() -> float:
var ship: Node2D = _next_ready_ship()
if ship == null:
return 950.0
var component: Node = ship.get_component(_component_id())
return component.beam_range if component != null else 950.0
func _say(message: String) -> void:
status.text = message
status_message_until = Time.get_ticks_msec() + 3000
func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2:
# A beam always reaches full range; dragging chooses its direction only.
var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP
return origin + direction * _aim_max_range()
func _process(delta: float) -> void:
var had_visuals := aiming or not beams.is_empty()
button.visible = fleet.has_capability("laser")
button.enabled = _next_ready_laser_ship() != null and _can_afford_shot()
if not button.visible and aiming:
_cancel_aim()
if status.text != "" and Time.get_ticks_msec() >= status_message_until:
status.text = ""
func _consume_shot(component: Node) -> bool:
return component.consume_shot()
if restoring_zoom and not aiming:
var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0)
camera.zoom = Vector2(z, z)
if is_equal_approx(z, saved_zoom):
restoring_zoom = false
func _perform_shot(ship: Node2D, component: Node) -> void:
var direction: Vector2 = aim_target - _aim_origin()
direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP
var beam_to: Vector2 = ship.global_position + direction * component.beam_range
_strike(ship.global_position, beam_to, direction, int(component.damage))
func _next_ready_laser_ship() -> Node2D:
return _next_ready_ship()
func _advance_effects(delta: float) -> void:
for i in range(beams.size() - 1, -1, -1):
var beam: Dictionary = beams[i]
beam.time = maxf(float(beam.time) - delta, 0.0)
if float(beam.time) <= 0.0:
beams.remove_at(i)
if aiming or not beams.is_empty() or had_visuals:
queue_redraw()
func can_begin_aim() -> bool:
# Deliberately NOT button.enabled: an unaffordable shot still needs the
# press to reach here so it can explain itself (#60's identical
# reasoning on the gas bomber). A wholly unready fleet (no laser ship at
# all) has nothing more useful to say than "greyed out".
return button.visible and not button._blocked_by_modal() \
and not aiming and _next_ready_laser_ship() != null
func handle_touch(event: InputEventScreenTouch) -> bool:
if event.pressed:
if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position):
return false
if not _can_afford_shot():
_say("Need %d energy in the fleet hold to fire (have %d)" \
% [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)])
return true
aiming = true
aim_touch = event.index
aim_start_screen = event.position
aim_screen = event.position
saved_zoom = camera.zoom.x
restoring_zoom = false
button.pressed_down = true
button.queue_redraw()
_update_aim()
return true
if event.index != aim_touch:
return false
var should_fire := aim_start_screen.distance_to(aim_screen) >= DRAG_DEADZONE
if should_fire:
_fire()
else:
_cancel_aim()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if not aiming or event.index != aim_touch:
return false
aim_screen = event.position
_update_aim()
return true
func _aim_origin() -> Vector2:
return fleet.get_centroid()
func _aim_max_range() -> float:
var ship: Node2D = _next_ready_laser_ship()
if ship == null:
return 950.0
var component: Node = ship.get_component("laser")
return component.beam_range if component != null else 950.0
func _update_aim() -> void:
# A beam always reaches its full range - only the DIRECTION is aimed,
# unlike the bomb's drag-controlled distance.
var drag := aim_screen - aim_start_screen
var origin: Vector2 = _aim_origin()
var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP
aim_target = origin + direction * _aim_max_range()
_update_aim_zoom(origin, aim_target)
queue_redraw()
func _update_aim_zoom(origin: Vector2, target: Vector2) -> void:
var displacement := target - origin
var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0)
var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + BEAM_HALF_WIDTH)
var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + BEAM_HALF_WIDTH)
var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom)
camera.zoom = Vector2(desired, desired)
func _finish_aim() -> void:
aiming = false
aim_touch = -1
button.pressed_down = false
button.queue_redraw()
restoring_zoom = true
func _cancel_aim() -> void:
_finish_aim()
queue_redraw()
func _fire() -> void:
var shooter: Node2D = _next_ready_laser_ship()
if shooter == null:
_cancel_aim()
return
if not _can_afford_shot():
_say("Need %d energy in the fleet hold to fire (have %d)" \
% [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)])
_cancel_aim()
return
var component: Node = shooter.get_component("laser")
if not component.consume_shot():
_cancel_aim()
return
GameState.withdraw_fleet_resource(GameState.RESOURCE_ENERGY, ENERGY_PER_SHOT)
var direction: Vector2 = (aim_target - _aim_origin())
direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP
var beam_to: Vector2 = shooter.global_position + direction * component.beam_range
_strike(shooter.global_position, beam_to, direction, int(component.damage))
_finish_aim()
button.enabled = _next_ready_laser_ship() != null and _can_afford_shot()
func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void:
@ -209,20 +74,12 @@ func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> voi
last_hit_count += 1
func _next_ready_laser_ship() -> Node2D:
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
var component: Node = ship.get_component("laser")
if component != null and component.is_ready():
return ship
return null
func _draw() -> void:
if aiming:
NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.6)
NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target,
TARGET_COLOR, 0.7, 0.6)
for beam in beams:
var progress: float = 1.0 - float(beam.time) / FLASH_DURATION
var alpha: float = 1.0 - progress
NeonDraw.draw_glowing_line(self, beam.from, beam.to, BEAM_COLOR, alpha, 1.2 - progress * 0.6)
NeonDraw.draw_glowing_line(self, beam.from, beam.to,
BEAM_COLOR, alpha, 1.2 - progress * 0.6)

1
LaserController.gd.uid Normal file
View file

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

View file

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

View file

@ -35,12 +35,10 @@ func _process(_delta: float) -> void:
if has_distorder != visible:
visible = has_distorder
if not has_distorder:
# A fleet must never be left permanently boosted by a vessel it no
# longer has - reset the instant the capability disappears, not just
# hide the control on top of a stale multiplier.
# Fleet owns persistent composition state. This outgoing UI node can
# briefly observe an empty fleet during a scene swap, so it must only
# clear its own interaction state here.
active_touch_index = -1
if GameState.fleet_speed_multiplier != 1.0:
GameState.fleet_speed_multiplier = 1.0
knob_t = 0.0
return
knob_t = (GameState.fleet_speed_multiplier - 1.0) \

1
SpeedFader.gd.uid Normal file
View file

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

View file

@ -0,0 +1,84 @@
class_name VesselCooldownFillComponent
extends Node2D
# Shared per-vessel cooldown state and hull-fill feedback for abilities that
# grant one independently recharging shot per ship.
var cooldown_duration: float = 1.0
var cooldown_left: float = 0.0
func setup() -> void:
show_behind_parent = true
queue_redraw()
func is_ready() -> bool:
return cooldown_left <= 0.0
func _begin_cooldown(duration: float) -> bool:
if not is_ready():
return false
cooldown_duration = maxf(duration, 0.001)
cooldown_left = cooldown_duration
queue_redraw()
return true
func tick(delta: float) -> void:
if cooldown_left <= 0.0:
return
cooldown_left = maxf(cooldown_left - delta, 0.0)
queue_redraw()
func cooldown_progress() -> float:
return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001)
func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array:
if points.size() < 3 or progress <= 0.001:
return PackedVector2Array()
if progress >= 0.999:
return points.duplicate()
var top: float = INF
var bottom: float = -INF
for point in points:
top = minf(top, point.y)
bottom = maxf(bottom, point.y)
var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0))
var clipped := PackedVector2Array()
var previous: Vector2 = points[-1]
var previous_inside: bool = previous.y >= fill_y
for current in points:
var current_inside: bool = current.y >= fill_y
if current_inside != previous_inside:
var amount: float = (fill_y - previous.y) / (current.y - previous.y)
clipped.append(previous.lerp(current, amount))
if current_inside:
clipped.append(current)
previous = current
previous_inside = current_inside
return clipped
func _draw() -> void:
var ship: Node = get_parent()
if ship == null or ship.definition == null:
return
var hull: PackedVector2Array = ship.definition.hull_points
var ready_color: Color = _ready_color()
if is_ready():
draw_colored_polygon(hull, Color(ready_color, 0.42))
NeonDraw.draw_glowing_polygon(self, hull, ready_color, 0.75,
ship.core_color, 0.75)
return
draw_colored_polygon(hull, Color(_cooling_color(), 0.58))
var filled: PackedVector2Array = _filled_hull(hull, cooldown_progress())
if filled.size() >= 3:
draw_colored_polygon(filled, Color(ready_color, 0.68))
func _ready_color() -> Color: return Color.WHITE
func _cooling_color() -> Color: return Color(0.2, 0.2, 0.2)

View file

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

View file

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

View file

@ -1,84 +1,19 @@
class_name VesselGasBomberComponent
extends Node2D
extends "res://VesselCooldownFillComponent.gd"
var capability_id := "gas_bomber"
var cooldown_duration := 5.0
var cooldown_left := 0.0
const READY_COLOR := Color(1.0, 0.25, 0.78)
const COOLING_COLOR := Color(0.38, 0.16, 0.32)
func setup() -> void:
# Keep the ship's normal neon outline above the charge fill.
show_behind_parent = true
queue_redraw()
func is_ready() -> bool:
return cooldown_left <= 0.0
func _init() -> void:
cooldown_duration = 5.0
func consume_shot(duration: float) -> bool:
if not is_ready():
return false
cooldown_duration = maxf(duration, 0.001)
cooldown_left = cooldown_duration
queue_redraw()
return true
return _begin_cooldown(duration)
func tick(delta: float) -> void:
if cooldown_left <= 0.0:
return
cooldown_left = maxf(cooldown_left - delta, 0.0)
queue_redraw()
func cooldown_progress() -> float:
return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001)
func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array:
if points.size() < 3 or progress <= 0.001:
return PackedVector2Array()
if progress >= 0.999:
return points.duplicate()
var top := INF
var bottom := -INF
for point in points:
top = minf(top, point.y)
bottom = maxf(bottom, point.y)
var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0))
var clipped := PackedVector2Array()
var previous: Vector2 = points[-1]
var previous_inside := previous.y >= fill_y
for current in points:
var current_inside := current.y >= fill_y
if current_inside != previous_inside:
var amount: float = (fill_y - previous.y) / (current.y - previous.y)
clipped.append(previous.lerp(current, amount))
if current_inside:
clipped.append(current)
previous = current
previous_inside = current_inside
return clipped
func _draw() -> void:
var parent_ship: Node = get_parent()
if parent_ship == null or parent_ship.definition == null:
return
var core: Color = parent_ship.core_color if parent_ship != null else Color.WHITE
var hull: PackedVector2Array = parent_ship.definition.hull_points
if is_ready():
# A fully lit hull means this individual bomber owns an available shot.
draw_colored_polygon(hull, Color(READY_COLOR, 0.42))
NeonDraw.draw_glowing_polygon(self, hull, READY_COLOR, 0.75, core, 0.75)
return
# Rearming is shown inside the bomber instead of as a shield-like ring:
# the muted silhouette fills from its engines toward its nose.
draw_colored_polygon(hull, Color(COOLING_COLOR, 0.58))
var filled := _filled_hull(hull, cooldown_progress())
if filled.size() >= 3:
draw_colored_polygon(filled, Color(READY_COLOR, 0.68))
func _ready_color() -> Color: return READY_COLOR
func _cooling_color() -> Color: return COOLING_COLOR

View file

@ -1,29 +1,16 @@
class_name VesselLaserComponent
extends Node2D
# Same shape as VesselGasBomberComponent (per-ship cooldown, ready/cooling
# hull fill) - #58's own note: two laser vessels must give two shots, so
# this lives on the SHIP, not the fleet-wide LaserController.
#
# Unlike the gas bomber, damage/beam_range/cooldown are resolved from the
# vessel's own base_stats/upgrades (apply_stats(), the same pattern
# VesselWeaponComponent uses) rather than hardcoded in the controller, so
# ship-lab upgrades actually reach them.
extends "res://VesselCooldownFillComponent.gd"
var capability_id := "laser"
var damage: int = 2
var beam_range: float = 950.0
var cooldown_duration: float = 4.0
var cooldown_left: float = 0.0
const READY_COLOR := Color(0.3, 0.9, 1.0)
const COOLING_COLOR := Color(0.08, 0.28, 0.34)
func setup() -> void:
# Keep the ship's normal neon outline above the charge fill.
show_behind_parent = true
queue_redraw()
func _init() -> void:
cooldown_duration = 4.0
func apply_stats() -> void:
@ -35,66 +22,9 @@ func apply_stats() -> void:
queue_redraw()
func is_ready() -> bool:
return cooldown_left <= 0.0
func consume_shot() -> bool:
if not is_ready():
return false
cooldown_left = maxf(cooldown_duration, 0.001)
queue_redraw()
return true
return _begin_cooldown(cooldown_duration)
func tick(delta: float) -> void:
if cooldown_left <= 0.0:
return
cooldown_left = maxf(cooldown_left - delta, 0.0)
queue_redraw()
func cooldown_progress() -> float:
return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001)
func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array:
if points.size() < 3 or progress <= 0.001:
return PackedVector2Array()
if progress >= 0.999:
return points.duplicate()
var top := INF
var bottom := -INF
for point in points:
top = minf(top, point.y)
bottom = maxf(bottom, point.y)
var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0))
var clipped := PackedVector2Array()
var previous: Vector2 = points[-1]
var previous_inside := previous.y >= fill_y
for current in points:
var current_inside := current.y >= fill_y
if current_inside != previous_inside:
var amount: float = (fill_y - previous.y) / (current.y - previous.y)
clipped.append(previous.lerp(current, amount))
if current_inside:
clipped.append(current)
previous = current
previous_inside = current_inside
return clipped
func _draw() -> void:
var parent_ship: Node = get_parent()
if parent_ship == null or parent_ship.definition == null:
return
var core: Color = parent_ship.core_color if parent_ship != null else Color.WHITE
var hull: PackedVector2Array = parent_ship.definition.hull_points
if is_ready():
draw_colored_polygon(hull, Color(READY_COLOR, 0.42))
NeonDraw.draw_glowing_polygon(self, hull, READY_COLOR, 0.75, core, 0.75)
return
draw_colored_polygon(hull, Color(COOLING_COLOR, 0.58))
var filled := _filled_hull(hull, cooldown_progress())
if filled.size() >= 3:
draw_colored_polygon(filled, Color(READY_COLOR, 0.68))
func _ready_color() -> Color: return READY_COLOR
func _cooling_color() -> Color: return COOLING_COLOR

View file

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

1
gas_surface.gdshader.uid Normal file
View file

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

View file

@ -0,0 +1 @@
uid://25xjfqi2qk7a

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,8 @@ var hub_id: int = 0
var burner_id: int = 0
var load_limit: int = 0
var energy_before_stall: int = 0
var conversion_rate: float = 0.0
var conversion_yield: int = 0
const MOTHERSHIP_AT := Vector2.ZERO
const HUB_AT := Vector2(150.0, 0.0)
@ -48,6 +50,9 @@ func _initialize() -> void:
mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
burner_id = game_state.add_building(GameState.BUILDING_GAS_BURNER, BURNER_AT)
game_state.deposit_resource_to_building(burner_id, GameState.RESOURCE_GAS, 20)
var logistics: Resource = game_state.building_logistics_profile(game_state.find_building(burner_id))
conversion_rate = logistics.conversion_rate
conversion_yield = logistics.conversion_yield
func _process(delta: float) -> bool:
@ -73,10 +78,10 @@ func _process(delta: float) -> bool:
var energy_made: int = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY)
var gas_burned: int = 20 - gas_left
if gas_burned < 2:
return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, GameState.GAS_BURN_RATE])
if energy_made != gas_burned * GameState.ENERGY_PER_GAS:
return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, conversion_rate])
if energy_made != gas_burned * conversion_yield:
return _fail("burner made %d energy for %d gas burned, expected %dx" % [
energy_made, gas_burned, GameState.ENERGY_PER_GAS
energy_made, gas_burned, conversion_yield
])
if game_state.rocks != 0 or game_state.gas != 0:
return _fail("conversion touched the bank (rocks=%d gas=%d) - it must only use local stock" % [

View file

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

View file

@ -12,6 +12,7 @@ func _initialize() -> void:
state.transporters.clear()
state.reset_progression()
state.rocks = 900
state.research_points = state.skill_research_point_cost("freight_yards")
state.unlocked = ["shipyards", "logistics", "freight"]
var hub_a: int = state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(-500, 0))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,8 +23,7 @@ func _process(delta: float) -> bool:
return false
var definitions: Array = VESSEL_DEFINITIONS.all()
var fleet_definitions: Array = VESSEL_DEFINITIONS.fleet()
if fleet_definitions.size() != 5 \
or fleet_definitions.any(func(definition): return definition.has_capability("transport")):
if fleet_definitions.any(func(definition): return definition.has_capability("transport")):
return _fail("logistics transporters leaked into the combat-fleet catalog")
if ships.size() != definitions.size():
return _fail("catalog and composed fleet differ in size")