diff --git a/buildings/BuildingHitbox.gd b/buildings/BuildingHitbox.gd index c9cc451..0ae5617 100644 --- a/buildings/BuildingHitbox.gd +++ b/buildings/BuildingHitbox.gd @@ -15,54 +15,11 @@ extends Area2D # for. const STRIKE_INTERVAL: float = 0.7 -# #165: how fast a friendly vessel has to be going before touching a structure -# counts as hitting it. Comfortably above the speed a fleet drifts onto its own -# mothership to unload at, and below cruise - so ordinary docking is free and -# flying INTO a building is not. -const RAM_SPEED: float = 420.0 -const RAM_DAMAGE: int = 4 -const RAM_DAMAGE_MAX: int = 12 - var _strike_timer: float = 0.0 func _ready() -> void: add_to_group("player_buildings") - area_entered.connect(_on_area_entered) - - -# #165: a vessel of the player's own, arriving fast enough, damages what it -# hits. -# -# On ENTRY, not on the strike tick above. The tick is right for a siege - an -# enemy parked on a structure is besieging it - but wrong for a collision: a -# vessel is only moving fast for the instant it arrives, and by the time a -# 0.7s tick came round it had already braked to nothing. Measured: a ship sent -# in at 900 did no damage at all until this moved to entry. -# -# Below the threshold it is docking, not a collision. The fleet sits on its own -# mothership constantly to bank ore, and if resting there hurt, the economy -# loop would grind the player's own buildings down. -func _on_area_entered(area: Area2D) -> void: - if not is_instance_valid(area) or not area.is_in_group("player_ships"): - return - # A vessel stationed at an outpost is not ramming it. It launches from - # there, patrols, and comes back - repeatedly, and at speed. Only the - # fleet can collide with a structure. - if area.is_in_group("garrison_ships"): - return - var speed: float = float(area.velocity.length()) if "velocity" in area else 0.0 - if speed < RAM_SPEED: - return - var id: int = building_id() - if id == 0: - return - # Scaled by how much faster than the threshold it was going, so a glancing - # touch above the line is not a full-speed charge, and capped so no engine - # upgrade turns a vessel into a wrecking ball. - var impact: int = mini(int(round(RAM_DAMAGE * speed / RAM_SPEED)), RAM_DAMAGE_MAX) - GameState.damage_building(id, impact) - area.take_damage(1) func building_id() -> int: diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index f226f8d..c9589d8 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -383,7 +383,7 @@ func _show_building_info(type: String) -> void: GameState.resource_name(planet.resource_type), planet.planet_name]) elif can_build and definition.unique and GameState.has_building_of_type(type): lines.append("WARNING: only one may exist - this destroys your current one.") - if can_build and not GameState.can_afford_resources(costs): + if can_build and not GameState.can_afford_resources(costs, fleet.get_centroid()): can_build = false pending_build_type = type if can_build else "" build_menu.show_info(definition.display_name, "\n\n".join(lines), costs, can_build) @@ -416,7 +416,7 @@ func _confirm_build() -> void: if String(planet.resource_type) == "": build_menu.set_status("%s has nothing to extract" % planet.planet_name) return - if not GameState.spend_resources(GameState.building_costs(type)): + if not GameState.spend_resources(GameState.building_costs(type), fleet.get_centroid()): build_menu.set_status(_cannot_afford(type)) return GameState.add_extractor(planet.planet_name, planet.position, @@ -424,7 +424,7 @@ func _confirm_build() -> void: build_menu.close_menu() return if definition.unique and GameState.has_building_of_type(type): - if not GameState.can_afford_resources(GameState.building_costs(type)): + if not GameState.can_afford_resources(GameState.building_costs(type), fleet.get_centroid()): build_menu.set_status(_cannot_afford(type)) return GameState.remove_buildings_of_type(type) @@ -437,14 +437,14 @@ func _cannot_afford(type: String) -> String: for resource_type in GameState.building_costs(type): var id: String = String(resource_type) var want: int = int(GameState.building_costs(type)[resource_type]) - var have: int = GameState.available_resource(id) + var have: int = GameState.available_resource_at(id, fleet.get_centroid()) if have < want: missing.append("%s (need %d, have %d)" % [GameState.resource_name(id), want, have]) return "" if missing.is_empty() else "Not enough %s" % " and ".join(missing) func _try_place(type: String) -> void: - if not GameState.spend_resources(GameState.building_costs(type)): + if not GameState.spend_resources(GameState.building_costs(type), fleet.get_centroid()): build_menu.set_status(_cannot_afford(type)) return building_field.place(type, fleet.get_centroid()) @@ -452,7 +452,12 @@ func _try_place(type: String) -> void: func _on_unlock_requested(id: String) -> void: - if not GameState.unlock(id): + var research_building: Dictionary = GameState.find_building(skill_menu.building_id) + if research_building.is_empty(): + skill_menu.set_status("The research building is no longer available") + return + var payment_position: Variant = research_building.get("position", null) + if not GameState.unlock(id, payment_position): skill_menu.refresh(id) return if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0: diff --git a/controllers/DashController.gd b/controllers/DashController.gd new file mode 100644 index 0000000..9951423 --- /dev/null +++ b/controllers/DashController.gd @@ -0,0 +1,66 @@ +class_name DashController +extends "res://controllers/AimedFleetAbilityController.gd" + +# Energy Propulser active ability (#179): hold, aim a direction, release, and +# the complete fleet surges along that heading. Cooldown belongs to the +# particular Propulser selected by the shared ability controller, so adding a +# second hull adds a second independently recharging dash. + +const DASH_COLOR := Color(0.85, 0.95, 0.15) +const DASH_DISTANCE: float = 520.0 +const ENERGY_PER_DASH: int = 15 +const TRAIL_SECONDS: float = 0.45 + +var trail_from: Vector2 = Vector2.ZERO +var trail_time: float = 0.0 + + +func _button_path() -> NodePath: return "../UI/DashButton" +func _status_path() -> NodePath: return "../UI/DashStatus" +func _component_id() -> String: return "distorder" +func _ammo_resource() -> String: return GameState.RESOURCE_ENERGY +func _ammo_cost() -> int: return ENERGY_PER_DASH +func _aim_padding() -> float: return 36.0 +func _aim_max_range() -> float: return DASH_DISTANCE +func _has_effect_visuals() -> bool: return trail_time > 0.0 or fleet.is_dashing() + + +func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2: + var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP + return origin + direction * DASH_DISTANCE + + +func _consume_shot(component: Node) -> bool: + return component.consume_dash() + + +func _perform_shot(_ship: Node2D, _component: Node) -> void: + var direction: Vector2 = aim_target - _aim_origin() + if direction.length() < 0.001: + return + trail_from = fleet.get_centroid() + trail_time = TRAIL_SECONDS + fleet.dash(direction, DASH_DISTANCE) + + +func _advance_effects(delta: float) -> void: + trail_time = maxf(trail_time - delta, 0.0) + + +func _draw() -> void: + if aiming: + var origin: Vector2 = _aim_origin() + NeonDraw.draw_glowing_line(self, origin, aim_target, DASH_COLOR, 0.65, 0.65) + var direction: Vector2 = (aim_target - origin).normalized() + var side: Vector2 = direction.orthogonal() * 18.0 + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + aim_target - direction * 38.0 + side, aim_target, + aim_target - direction * 38.0 - side, + ]), DASH_COLOR, 0.9, 0.55) + if trail_time > 0.0: + var alpha: float = trail_time / TRAIL_SECONDS + var now: Vector2 = fleet.get_centroid() + for offset in [-20.0, 0.0, 20.0]: + var across: Vector2 = (now - trail_from).normalized().orthogonal() * offset + NeonDraw.draw_glowing_line(self, trail_from + across, now + across, + DASH_COLOR, alpha * 0.6, 0.35) diff --git a/controllers/DashController.gd.uid b/controllers/DashController.gd.uid new file mode 100644 index 0000000..41b3117 --- /dev/null +++ b/controllers/DashController.gd.uid @@ -0,0 +1 @@ +uid://bk38ws0glrkyk diff --git a/controllers/GameController.gd b/controllers/GameController.gd index 75fa7c9..e018b60 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -31,6 +31,7 @@ extends Node2D # indirectly - e.g. Main.tscn loaded from inside a _screenshot_tool.gd # preview - for a class_name this new. Node + duck-typed calls sidesteps it. @onready var laser_controller: Node = get_node_or_null("LaserController") +@onready var dash_controller: Node = get_node_or_null("DashController") @onready var speed_fader: Node = get_node_or_null("UI/SpeedFader") var touches: Dictionary = {} @@ -367,6 +368,8 @@ func _handle_touch(event: InputEventScreenTouch) -> void: return if laser_controller != null and laser_controller.handle_touch(event): return + if dash_controller != null and dash_controller.handle_touch(event): + return if speed_fader != null and speed_fader.handle_touch(event): return if _handle_pivot_touch(event): @@ -399,6 +402,8 @@ func _handle_drag(event: InputEventScreenDrag) -> void: return if laser_controller != null and laser_controller.handle_drag(event): return + if dash_controller != null and dash_controller.handle_drag(event): + return if speed_fader != null and speed_fader.handle_drag(event): return if joystick != null and joystick.visible and joystick.handle_drag(event): diff --git a/controllers/MouseCombatController.gd b/controllers/MouseCombatController.gd index 5ea06a2..9777eb3 100644 --- a/controllers/MouseCombatController.gd +++ b/controllers/MouseCombatController.gd @@ -23,7 +23,7 @@ extends Node2D # A literal, not PackedStringArray(...): a constructor call is not a constant # expression, and the script fails to PARSE rather than to run - which shows up # as "nonexistent function" on a node that looks fine in the scene. -const ABILITY_ORDER: PackedStringArray = ["GasBombController", "LaserController"] +const ABILITY_ORDER: PackedStringArray = ["GasBombController", "LaserController", "DashController"] @onready var fleet: Node2D = get_node("../Fleet") @onready var camera: Camera2D = get_node("../Camera2D") diff --git a/core/GameState.gd b/core/GameState.gd index 808f402..57949b1 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -657,10 +657,10 @@ func skill_costs(id: String) -> Dictionary: return {RESOURCE_ORE: int(node.cost)} if int(node.cost) > 0 else {} -func can_unlock(id: String) -> bool: +func can_unlock(id: String, payment_position: Variant = null) -> bool: var node: Dictionary = skill_node(id) return not is_unlocked(id) and prerequisite_met(id) \ - and can_afford_resources(skill_costs(id)) \ + and can_afford_resources(skill_costs(id), payment_position) \ and research_points >= skill_research_point_cost(id) @@ -668,11 +668,11 @@ func skill_research_point_cost(id: String) -> int: return int(skill_node(id).get("research_points", 0)) -func unlock(id: String) -> bool: - if not can_unlock(id): +func unlock(id: String, payment_position: Variant = null) -> bool: + if not can_unlock(id, payment_position): return false var node: Dictionary = skill_node(id) - if not spend_resources(skill_costs(id)): + if not spend_resources(skill_costs(id), payment_position): return false var points_needed: int = skill_research_point_cost(id) if points_needed > 0: @@ -1314,13 +1314,13 @@ func vessel_for_unlock(unlock_id: String) -> Resource: return null -func unlock_vessel(vessel: String) -> bool: +func unlock_vessel(vessel: String, payment_position: Variant = null) -> bool: var definition: Resource = vessel_definition(vessel) if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab(): return false var costs: Dictionary = research_costs(definition.unlock_id) var points_needed: int = int(definition.research_point_cost) - if research_points < points_needed or not spend_resources(costs): + if research_points < points_needed or not spend_resources(costs, payment_position): return false if points_needed > 0: research_points -= points_needed @@ -1370,15 +1370,16 @@ func upgrade_cost(id: String) -> int: return int(d.cost) * (upgrade_level(id) + 1) -func can_buy_upgrade(id: String) -> bool: - return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id)) \ +func can_buy_upgrade(id: String, payment_position: Variant = null) -> bool: + return has_ship_lab() and not upgrade_maxed(id) \ + and can_afford(upgrade_cost(id), payment_position) \ and research_points >= research_point_cost(id) -func buy_upgrade(id: String) -> bool: - if not can_buy_upgrade(id): +func buy_upgrade(id: String, payment_position: Variant = null) -> bool: + if not can_buy_upgrade(id, payment_position): return false - if not spend(upgrade_cost(id)): + if not spend(upgrade_cost(id), payment_position): return false var points_needed: int = research_point_cost(id) if points_needed > 0: @@ -1601,7 +1602,12 @@ const LOCAL_PAY_RANGE: float = 150.0 func fleet_is_at(building: Dictionary) -> bool: if building.is_empty(): return false - return fleet_position.distance_to(building.position) <= LOCAL_PAY_RANGE + return fleet_can_pay_at(building.position) + + +func fleet_can_pay_at(payment_position: Variant) -> bool: + return payment_position is Vector2 \ + and fleet_position.distance_to(payment_position as Vector2) <= LOCAL_PAY_RANGE func local_payment_available(building: Dictionary, inventory: RefCounted, @@ -3332,10 +3338,10 @@ func _advance_quests() -> void: # --- paying for things ------------------------------------------------------ # -# EVERY payment draws on the bank AND on what the ships are carrying. The -# player can already move resources between the two by hand at any building -# that takes them, so refusing a purchase that a couple of taps would have -# made affordable was pure ceremony. +# Payments draw on the bank and may also draw on what the ships are carrying +# when the caller supplies the concrete place being paid. The player can move +# resources between the two by hand, so using nearby cargo avoids ceremony; +# #180 makes "nearby" part of the API instead of an assumption in the caller. # # In one case it was worse than ceremony. A fresh game starts with an empty # bank, ore is mined into the fleet hold, and the only way to bank it is to @@ -3343,9 +3349,8 @@ func _advance_quests() -> void: # made the first mothership cost ore that could not yet exist. Mine forever, # build nothing. # -# Bank first, hold second. The hold is also the fleet's mining capacity, so -# leaving it as full as possible means the next trip out can still carry -# something back. +# Bank first, nearby hold second. A call with no position is deliberately +# bank-only: missing context must fail safe instead of teleporting cargo. # # The one deliberate exception is AMMO - see AimedFleetAbilityController, # which checks fleet_resource() directly. A gas bomb is fired from what the @@ -3357,36 +3362,50 @@ func available_resource(resource_type: String) -> int: return bank_inventory.amount(resource_type) + fleet_inventory.amount(resource_type) +# What can pay at one concrete place. Banked resources remain globally +# available; cargo in the fleet hold joins them only when the fleet is close +# enough to physically hand it to the building or construction site (#180). +# A null position deliberately means "no local payer", so a forgotten context +# can never reach into the hold from across the map. +func available_resource_at(resource_type: String, payment_position: Variant = null) -> int: + var amount: int = bank_inventory.amount(resource_type) + if fleet_can_pay_at(payment_position): + amount += fleet_inventory.amount(resource_type) + return amount + + func available_ore() -> int: return available_resource(RESOURCE_ORE) -func can_afford(cost: int) -> bool: - return available_ore() >= cost +func can_afford(cost: int, payment_position: Variant = null) -> bool: + return available_resource_at(RESOURCE_ORE, payment_position) >= cost -func can_afford_resources(costs: Dictionary) -> bool: +func can_afford_resources(costs: Dictionary, payment_position: Variant = null) -> bool: for resource_type in costs: - if available_resource(String(resource_type)) < int(costs[resource_type]): + if available_resource_at(String(resource_type), payment_position) \ + < int(costs[resource_type]): return false return true -func spend(cost: int) -> bool: - return spend_resources({RESOURCE_ORE: cost}) +func spend(cost: int, payment_position: Variant = null) -> bool: + return spend_resources({RESOURCE_ORE: cost}, payment_position) -func spend_resources(costs: Dictionary) -> bool: +func spend_resources(costs: Dictionary, payment_position: Variant = null) -> bool: # Affordability is settled in full BEFORE anything is taken. Charging for # part of a purchase that then fails is worse than refusing it, and with # several resource types in one price that is a real possibility rather # than a theoretical one. - if not can_afford_resources(costs): + if not can_afford_resources(costs, payment_position): return false + var fleet_can_pay: bool = fleet_can_pay_at(payment_position) for resource_type in costs: var id: String = String(resource_type) var owed: int = int(costs[resource_type]) owed -= bank_inventory.withdraw(id, owed) - if owed > 0: + if owed > 0 and fleet_can_pay: fleet_inventory.withdraw(id, owed) return true diff --git a/surface/CapturePoint.gd b/surface/CapturePoint.gd index 6b138cd..4767aa4 100644 --- a/surface/CapturePoint.gd +++ b/surface/CapturePoint.gd @@ -13,7 +13,9 @@ enum State { NEUTRAL, BLOCKED, CAPTURING, CAPTURED } @export var point_index: int = 0 @export var capture_radius: float = 220.0 -@export var capture_time: float = 8.0 +# #177: holding an already-cleared point should resolve briskly. Eight seconds +# made every point feel like waiting after the fight was over. +@export var capture_time: float = 4.0 @export var enemy_clear_radius: float = 700.0 @export var drain_rate: float = 0.5 diff --git a/surface/PlanetSurface.tscn b/surface/PlanetSurface.tscn index 678d079..304ae58 100644 --- a/surface/PlanetSurface.tscn +++ b/surface/PlanetSurface.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=19 format=3] +[gd_scene load_steps=20 format=3] [ext_resource type="Script" path="res://controllers/GameController.gd" id="1"] [ext_resource type="PackedScene" path="res://surface/SurfaceBackground.tscn" id="3"] @@ -11,6 +11,7 @@ [ext_resource type="Script" path="res://controllers/FireStickController.gd" id="fire_ctrl"] [ext_resource type="Script" path="res://controllers/GasBombController.gd" id="14"] [ext_resource type="Script" path="res://controllers/LaserController.gd" id="16"] +[ext_resource type="Script" path="res://controllers/DashController.gd" id="dash_ctrl"] [ext_resource type="Script" path="res://ui/SpeedFader.gd" id="17"] [ext_resource type="Script" path="res://surface/CaptureLabel.gd" id="15"] [ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="banner"] @@ -39,6 +40,9 @@ script = ExtResource("mouse_ctrl") [node name="LaserController" type="Node2D" parent="."] script = ExtResource("16") +[node name="DashController" type="Node2D" parent="."] +script = ExtResource("dash_ctrl") + [node name="Camera2D" type="Camera2D" parent="."] position = Vector2(0, 0) zoom = Vector2(1, 1) @@ -205,8 +209,25 @@ anchor_top = 1.0 anchor_bottom = 1.0 offset_left = -196 offset_right = -100 -offset_top = -492 -offset_bottom = -396 +offset_top = -548 +offset_bottom = -452 + +[node name="DashButton" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +script = ExtResource("6") +round_frame = true +icon = 45 +glow_color = Color(0.54, 0.62, 0.05, 1) +core_color = Color(0.02, 0.09, 0.15, 1) +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -452 +offset_right = -356 +offset_top = -548 +offset_bottom = -452 [node name="PivotCCWButton" type="Control" parent="UI"] mouse_filter = 2 @@ -219,8 +240,8 @@ anchor_left = 1.0 anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = -468 -offset_right = -364 +offset_left = -620 +offset_right = -516 offset_top = -130 offset_bottom = -26 @@ -238,8 +259,8 @@ anchor_top = 1.0 anchor_bottom = 1.0 offset_left = -324 offset_right = -228 -offset_top = -424 -offset_bottom = -328 +offset_top = -548 +offset_bottom = -452 [node name="GasBombStatus" type="Label" parent="UI"] mouse_filter = 2 @@ -273,6 +294,24 @@ offset_right = -24 offset_top = -616 offset_bottom = -568 +[node name="DashStatus" type="Label" parent="UI"] +mouse_filter = 2 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.54, 0.62, 0.05, 1) +theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85) +theme_override_constants/outline_size = 4 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -500 +offset_right = -104 +offset_top = -672 +offset_bottom = -624 + [node name="PivotCWButton" type="Control" parent="UI"] mouse_filter = 2 script = ExtResource("6") @@ -284,8 +323,8 @@ anchor_left = 1.0 anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 -offset_left = -356 -offset_right = -252 +offset_left = -508 +offset_right = -404 offset_top = -130 offset_bottom = -26 diff --git a/tests/_hud_preview.gd b/tests/_hud_preview.gd index 1e508d8..fbdd642 100644 --- a/tests/_hud_preview.gd +++ b/tests/_hud_preview.gd @@ -11,6 +11,8 @@ func _ready() -> void: GameState.extractors.clear() GameState.vessel_levels["unlock_laser"] = 1 GameState.vessel_levels["unlock_gas_bomber"] = 1 + GameState.vessel_levels["unlock_distorder"] = 1 + GameState.unlocked.append("wing") GameState.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 200) GameState.deposit_fleet_resource(GameState.RESOURCE_GAS, 200) var main: Node = load("res://world/Main.tscn").instantiate() @@ -18,10 +20,12 @@ func _ready() -> void: main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) var fleet: Node = main.get_node("Fleet") - fleet.spawn_ships(3, Vector2.ZERO, PackedStringArray([ - GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER, GameState.VESSEL_GAS_BOMBER])) + fleet.spawn_ships(4, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER, + GameState.VESSEL_GAS_BOMBER, GameState.VESSEL_DISTORDER])) main.get_node("LaserController")._process(0.0) main.get_node("GasBombController")._process(0.0) + main.get_node("DashController")._process(0.0) # Push the fire stick, so its knob shows deflected the way it looks in use. var stick: Control = main.get_node("UI/FireStick") var press := InputEventScreenTouch.new() diff --git a/tests/test_building_collateral.gd b/tests/test_building_collateral.gd index 2818594..61028c6 100644 --- a/tests/test_building_collateral.gd +++ b/tests/test_building_collateral.gd @@ -2,19 +2,18 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_building_collateral.gd -# Forgejo #165: a gas bomb and a vessel flying fast enough both damage a -# structure. The blast used to stop politely at anything the player owned, -# which made a bomb dropped on a besieged mothership a free way to sweep the -# attackers off it. +# Forgejo #176: the player's own ships and their weapons do not damage the +# player's buildings. Friendly fire on structures made defending them with a +# gas bomb or crossing them at speed punish ordinary play. # # Covers: -# A. a bomb landing on a structure damages it +# A. a bomb landing on a structure leaves it unharmed # B. a bomb landing clear of it does not - the radius is the radius, and # this is a weapon that has to be aimed # C. a vessel resting against a structure does NOT damage it. The fleet # sits on its own mothership constantly to bank ore; if that hurt, the # game's own economy loop would grind its buildings down -# D. a vessel arriving fast does +# D. a vessel arriving fast still does not damage it or itself var game_state: Node var main: Node @@ -23,6 +22,7 @@ var building_id: int = 0 var stage: int = 0 var elapsed: float = 0.0 var hp_before: int = 0 +var ships_before: int = 0 const BUILDING_AT := Vector2(1200.0, 0.0) @@ -78,11 +78,11 @@ func _process(delta: float) -> bool: return _fail("a bomb 900 away still damaged the structure") print("a bomb clear of the structure leaves it alone") - # --- A. a bomb on top of it does -------------------------------- + # --- A. a friendly bomb on top of it is safe ---------------------- _detonate_at(BUILDING_AT) - if _hp() >= hp_before: - return _fail("a bomb landing on the structure did not damage it (hp %d)" % _hp()) - print("a bomb on the structure took it from %d to %d" % [hp_before, _hp()]) + if _hp() != hp_before: + return _fail("a friendly bomb damaged the structure (%d -> %d)" % [hp_before, _hp()]) + print("a friendly bomb on the structure leaves it at %d hp" % _hp()) # --- C. resting against it is free ------------------------------ hp_before = _hp() @@ -104,8 +104,9 @@ func _process(delta: float) -> bool: % [hp_before, _hp()]) print("a vessel parked on the structure for %.1fs did nothing to it" % elapsed) - # --- D. arriving fast does -------------------------------------- + # --- D. arriving fast is still friendly ------------------------- hp_before = _hp() + ships_before = fleet.ships.size() for ship in fleet.ships: # Placed clear and sent IN, because the impact is detected on # entry - a ship spawned already overlapping never enters. @@ -118,9 +119,11 @@ func _process(delta: float) -> bool: 3: if elapsed < 1.2: return false - if _hp() >= hp_before: - return _fail("a vessel arriving at 900 did not damage the structure") - print("a vessel arriving fast took it from %d to %d" % [hp_before, _hp()]) + if _hp() != hp_before: + return _fail("a fast friendly vessel damaged the structure (%d -> %d)" % [hp_before, _hp()]) + if fleet.ships.size() != ships_before: + return _fail("a friendly building destroyed a vessel crossing it") + print("a vessel crossing the structure at speed leaves both unharmed") print("TEST_BUILDING_COLLATERAL: PASS") quit(0) diff --git a/tests/test_capture_points.gd b/tests/test_capture_points.gd index 444e8de..17d68c0 100644 --- a/tests/test_capture_points.gd +++ b/tests/test_capture_points.gd @@ -95,6 +95,8 @@ func _process(delta: float) -> bool: point = load("res://surface/CapturePoint.tscn").instantiate() point.point_index = 0 + if point.capture_time > 4.0: + return _fail("default capture still takes %.1fs" % point.capture_time) point.capture_time = 1.0 point.capture_radius = 200.0 point.enemy_clear_radius = 300.0 diff --git a/tests/test_dialog_typing.gd b/tests/test_dialog_typing.gd index 3e242ed..5f89ad7 100644 --- a/tests/test_dialog_typing.gd +++ b/tests/test_dialog_typing.gd @@ -21,10 +21,10 @@ extends SceneTree # E. it types while the game is stopped, being the one node still running # F. the sci-fi part: the panel is drawn differently while decoding, and # settles when the line is complete -# G. #168 - a KEY does exactly what a click does, both halves of it, since -# the game is played on a desktop with WASD and a mouse and pressing -# space at a dialog reached nothing at all. Modifiers alone and held-key -# echoes are not presses, or a line would be run out by a held key. +# G. #175 - keyboard presses do not finish or dismiss a dialog; only a +# deliberate tap/click advances it +# H. #178 - revealed text is seated at the left edge rather than growing +# outward from the middle var game_state: Node var scene: Node @@ -90,6 +90,8 @@ func _process(delta: float) -> bool: return _fail("the label holds a partial string - the line will re-wrap as it types") if narration.label.visible_characters < 0: return _fail("the whole line is visible already") + if narration.label.horizontal_alignment != HORIZONTAL_ALIGNMENT_LEFT: + return _fail("dialog text is not left-aligned - partial text will grow from the middle") seen.append(narration.label.visible_characters) # Hand-ticked, so the reveal is measured rather than raced. The @@ -151,37 +153,34 @@ func _process(delta: float) -> bool: return _fail("dismissing landed on \"%s\"" % narration.current) print("the press after that moves on to \"%s\"" % narration.current) - # --- G. #168: a key is a press too ----------------------- + # --- G. #175: keys never advance a dialog ---------------- narration.clear() narration.say(first_line, "TEST") narration.say("Second.", "TEST") if not narration.is_typing(): return _fail("the fresh line was not arriving") - # Not everything that reaches the banner is a press. _key(KEY_SHIFT) _key(KEY_SPACE, true) - if not narration.is_typing(): - return _fail("a modifier or a held-key echo finished the line") - # Still arriving: the key shows the whole of it, and no more. _key(KEY_SPACE) - if narration.is_typing(): - return _fail("a key did not finish a line that was still arriving") - if narration.current != first_line: - return _fail("a key on an arriving line dismissed it - the next line would be skipped unread") - # Whole: the key moves on, the same as a click. - _key(KEY_SPACE) - if narration.current != "Second.": - return _fail("a key on a finished line landed on \"%s\"" % narration.current) - # Any key, not one chosen key - the player has no reason to guess. _key(KEY_W) - if narration.is_typing(): - return _fail("only some keys finish a line") _key(KEY_ENTER) + if not narration.is_typing() or narration.current != first_line: + return _fail("a keyboard press changed a dialog that was still arriving") + narration.finish_typing() + _key(KEY_W) + _key(KEY_ENTER) + if narration.current != first_line or narration.is_typing(): + return _fail("a keyboard press dismissed a complete dialog") + _click() + if narration.current != "Second.": + return _fail("clicking after ignored keys did not reach the second line") + narration.finish_typing() + _click() if narration.is_speaking(): - return _fail("a key on the last line did not close the dialog") + return _fail("clicking the last complete line did not close the dialog") if paused: - return _fail("keying through every line left the game stopped") - print("space, W and enter all finish then advance a line; shift and echoes do not") + return _fail("clicking through every line left the game stopped") + print("space, W and enter leave dialogs untouched; clicks still finish and advance") print("TEST_DIALOG_TYPING: PASS") quit(0) diff --git a/tests/test_energy_propulser_dash.gd b/tests/test_energy_propulser_dash.gd new file mode 100644 index 0000000..24c30ac --- /dev/null +++ b/tests/test_energy_propulser_dash.gd @@ -0,0 +1,137 @@ +extends SceneTree + +# Forgejo #179: an Energy Propulser in the fleet grants a hold-aim-release +# dash. Covers visibility, aimed movement, energy cost, formation integrity, +# and one cooldown per Propulser. + +const DASH_COST: int = 15 +const DASH_DISTANCE: float = 520.0 + +var state: Node +var main: Node +var fleet: Node +var dash: Node +var stage: int = 0 +var elapsed: float = 0.0 +var first_origin: Vector2 +var second_origin: Vector2 +var offsets_before: Array = [] +var first_component: Node + + +func _initialize() -> void: + state = root.get_node("GameState") + state.reset_new_game() + state.intro_active = false + state.reset_quests() + state.vessel_levels["unlock_distorder"] = 1 + state.fleet_inventory.total_capacity = -1 + state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, DASH_COST * 3) + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + dash = main.get_node("DashController") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + if main.get_node("UI/DashButton").visible: + return _fail("dash button appeared without an Energy Propulser") + # The carrier keeps enough hold capacity for both dashes. Two + # Propulsers prove that cooldown belongs to a vessel, not the fleet. + fleet.spawn_ships(3, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_CARRIER, GameState.VESSEL_DISTORDER, + GameState.VESSEL_DISTORDER, + ])) + dash._process(0.0) + if not main.get_node("UI/DashButton").visible: + return _fail("an Energy Propulser did not reveal the dash button") + first_component = dash._next_ready_ship().get_component("distorder") + first_origin = fleet.get_centroid() + for ship in fleet.ships: + offsets_before.append(ship.position - first_origin) + _aim_and_release(Vector2.RIGHT, 11) + if not fleet.is_dashing(): + return _fail("releasing a rightward aim did not begin a dash") + if state.fleet_resource(GameState.RESOURCE_ENERGY) != DASH_COST * 2: + return _fail("first dash did not consume %d energy" % DASH_COST) + if first_component.is_ready(): + return _fail("the Propulser that dashed did not enter cooldown") + dash._process(0.0) + if not main.get_node("UI/DashButton").enabled: + return _fail("one cooling Propulser disabled a second ready one") + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.7: + return false + var moved: Vector2 = fleet.get_centroid() - first_origin + if absf(moved.x - DASH_DISTANCE) > 8.0 or absf(moved.y) > 8.0: + return _fail("rightward aim moved the fleet by %s" % moved) + for i in range(fleet.ships.size()): + var offset: Vector2 = fleet.ships[i].position - fleet.get_centroid() + if offset.distance_to(offsets_before[i]) > 1.0: + return _fail("dash distorted formation slot %d by %.2f" \ + % [i, offset.distance_to(offsets_before[i])]) + second_origin = fleet.get_centroid() + _aim_and_release(Vector2.UP, 12) + if state.fleet_resource(GameState.RESOURCE_ENERGY) != DASH_COST: + return _fail("second Propulser did not pay for its own dash") + dash._process(0.0) + if main.get_node("UI/DashButton").enabled: + return _fail("dash remained enabled after both Propulsers fired") + stage = 2 + elapsed = 0.0 + return false + 2: + if elapsed < 0.7: + return false + var moved: Vector2 = fleet.get_centroid() - second_origin + if absf(moved.y + DASH_DISTANCE) > 8.0 or absf(moved.x) > 8.0: + return _fail("upward aim moved the fleet by %s" % moved) + first_component.tick(first_component.DASH_COOLDOWN) + dash._process(0.0) + if not main.get_node("UI/DashButton").enabled: + return _fail("button did not re-enable when one Propulser recharged") + var surface: Node = load("res://surface/PlanetSurface.tscn").instantiate() + if not surface.has_node("DashController") or not surface.has_node("UI/DashButton"): + return _fail("planet surface does not carry the dash ability") + surface.free() + print("two aimed dashes moved %.0f units, preserved formation, and cooled down per Propulser" \ + % DASH_DISTANCE) + print("TEST_ENERGY_PROPULSER_DASH: PASS") + quit(0) + return true + return false + + +func _aim_and_release(direction: Vector2, touch_index: int) -> void: + var button: Control = main.get_node("UI/DashButton") + var press := InputEventScreenTouch.new() + press.index = touch_index + press.pressed = true + press.position = button.global_position + button.size * 0.5 + main._handle_touch(press) + var drag := InputEventScreenDrag.new() + drag.index = touch_index + drag.position = press.position + direction * 240.0 + main._handle_drag(drag) + var release := InputEventScreenTouch.new() + release.index = touch_index + release.pressed = false + release.position = drag.position + main._handle_touch(release) + + +func _fail(message: String) -> bool: + print("TEST_ENERGY_PROPULSER_DASH: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_main_menu.gd b/tests/test_main_menu.gd index d12b011..f7f2aea 100644 --- a/tests/test_main_menu.gd +++ b/tests/test_main_menu.gd @@ -15,6 +15,8 @@ extends SceneTree # 5. #169 - a second press while a load is running is ignored, or an # impatient double tap builds the world twice (on the web a button fires # on PRESS, so that tap costs nothing to make) +# 6. #174 - the opaque loading cover fills the viewport and puts its +# activity bar at the middle of the screen var menu: Node var game_state: Node @@ -61,7 +63,19 @@ func _process(delta: float) -> bool: return _fail("the press started a load with no loading screen up") if current_scene != menu: return _fail("the scene swapped inside the press, so nothing was ever shown") + var viewport_size: Vector2 = menu.get_viewport().get_visible_rect().size + if menu.loading_screen.size.distance_to(viewport_size) > 1.0: + return _fail("loading cover is %s, viewport is %s" \ + % [menu.loading_screen.size, viewport_size]) + if menu.loading_screen.BACKDROP.a < 0.999: + return _fail("loading cover is translucent (alpha %.2f)" \ + % menu.loading_screen.BACKDROP.a) + var bar: Rect2 = menu.loading_screen._bar_rect() + if absf(bar.get_center().y - viewport_size.y * 0.5) > 1.0: + return _fail("loading bar centre %.1f is not screen centre %.1f" \ + % [bar.get_center().y, viewport_size.y * 0.5]) print("pressing an entry raises the loading screen and leaves the menu up") + print("loading cover fills %s and centres its bar" % viewport_size) # --- 5. and a second press changes nothing -------------------- var screen: Node = menu.loading_screen diff --git a/tests/test_payments_use_fleet_hold.gd b/tests/test_payments_use_fleet_hold.gd index 9b5f539..690c9ad 100644 --- a/tests/test_payments_use_fleet_hold.gd +++ b/tests/test_payments_use_fleet_hold.gd @@ -2,9 +2,9 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_payments_use_fleet_hold.gd -# Every payment draws on the bank AND on what the ships are carrying. The -# player can already shuttle resources between the two by hand, so refusing a -# purchase that a couple of taps would have made affordable was ceremony. +# A payment draws on the bank and, only at the place being paid, what the +# ships are carrying. Fleet cargo is physical: a building across the map may +# not reach into it (#180). # # It was also a deadlock. A new game starts with an empty bank, ore is mined # into the fleet hold, and the only way to bank it is to deliver it to a @@ -22,8 +22,10 @@ extends SceneTree # are physically carrying # F. end to end: an empty bank and ore in the hold actually gets a # mothership built through BuildController +# G. no payment position, or a distant one, cannot drain the fleet hold const HOLD_ORE: int = 40 +const PAYMENT_AT := Vector2(120.0, 60.0) var game_state: Node var scene: Node @@ -37,6 +39,7 @@ func _initialize() -> void: game_state.reset_progression() game_state.buildings.clear() game_state.extractors.clear() + game_state.fleet_position = PAYMENT_AT var cost: int = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) @@ -50,13 +53,16 @@ func _initialize() -> void: if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) >= cost: _fail("the bank was not actually empty, so nothing below tests the deadlock") return - if not game_state.can_afford(cost): + if game_state.can_afford(cost): + _fail("a location-free affordability check reached into the fleet hold") + return + if not game_state.can_afford(cost, PAYMENT_AT): _fail("a fleet holding %d ore could not afford a %d purchase" % [HOLD_ORE, cost]) return print("bank 0 + hold %d can afford a %d purchase" % [HOLD_ORE, cost]) # --- B. bank first, hold for the shortfall ------------------------------- - if not game_state.spend(cost): + if not game_state.spend(cost, PAYMENT_AT): _fail("spend refused a purchase it had just called affordable") return if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \ @@ -68,7 +74,7 @@ func _initialize() -> void: game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 8) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 30) - if not game_state.spend(20): + if not game_state.spend(20, PAYMENT_AT): _fail("a 20 purchase was refused with 8 banked and 30 held") return if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \ @@ -82,7 +88,7 @@ func _initialize() -> void: # --- C. no partial charge ----------------------------------------------- game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 5) - if game_state.spend(50): + if game_state.spend(50, PAYMENT_AT): _fail("a purchase costing more than everything owned was allowed") return if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 5 \ @@ -93,6 +99,22 @@ func _initialize() -> void: return print("a purchase nobody can afford takes nothing") + # --- G. cargo does not teleport to a remote purchase ------------------ + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost) + game_state.fleet_position = PAYMENT_AT + Vector2(9000.0, 0.0) + if game_state.spend(cost, PAYMENT_AT): + _fail("a remote purchase took resources from the fleet hold") + return + if game_state.fleet_resource(GameState.RESOURCE_ORE) != cost: + _fail("a refused remote payment still drained the hold") + return + if game_state.spend(cost): + _fail("a purchase with no location reached into the fleet hold") + return + game_state.fleet_position = PAYMENT_AT + print("distant and location-free payments leave fleet cargo untouched") + # The hold has a real capacity (one starting interceptor carries 20), and # set_amount() is clamped by it - which silently gave the checks below # less than they asked for and made an affordable price look unaffordable. @@ -125,12 +147,12 @@ func _check_multi_resource_atomicity() -> bool: game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 10) game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 1) game_state.fleet_inventory.set_amount(GameState.RESOURCE_GAS, 1) - if game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 40}): + if game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 40}, PAYMENT_AT): return _fail("a two-resource price was charged with only one of them affordable") if game_state.available_ore() != 20 or game_state.available_resource(GameState.RESOURCE_GAS) != 2: return _fail("a refused two-resource purchase still took some ore or gas (ore %d, gas %d)" \ % [game_state.available_ore(), game_state.available_resource(GameState.RESOURCE_GAS)]) - if not game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 2}): + if not game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 2}, PAYMENT_AT): return _fail("an affordable two-resource price was refused") if game_state.available_ore() != 5 or game_state.available_resource(GameState.RESOURCE_GAS) != 0: return _fail("a two-resource purchase charged wrongly (ore %d left, gas %d left)" \ @@ -147,9 +169,9 @@ func _check_skill_tree() -> bool: game_state.research_points = 999 game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost) - if not game_state.can_unlock(id): + if not game_state.can_unlock(id, PAYMENT_AT): return _fail("the skill tree refused '%s' (cost %d) with the whole price in the fleet hold" % [id, cost]) - if not game_state.unlock(id): + if not game_state.unlock(id, PAYMENT_AT): return _fail("unlocking '%s' failed despite can_unlock saying yes" % id) if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 0: return _fail("the skill unlock did not charge the hold: %d left of %d" \ @@ -177,9 +199,9 @@ func _check_ship_lab() -> bool: game_state.research_points = 999 game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost) - if not game_state.can_buy_upgrade(id): + if not game_state.can_buy_upgrade(id, PAYMENT_AT): return _fail("the ship lab refused upgrade '%s' (cost %d) with the whole price in the hold" % [id, cost]) - if not game_state.buy_upgrade(id): + if not game_state.buy_upgrade(id, PAYMENT_AT): return _fail("buying '%s' failed despite can_buy_upgrade saying yes" % id) if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 0: return _fail("the upgrade did not charge the hold: %d left of %d" \ @@ -206,7 +228,7 @@ func _check_vessel_research() -> bool: for resource_type in costs: game_state.bank_inventory.set_amount(String(resource_type), 0) game_state.fleet_inventory.set_amount(String(resource_type), int(costs[resource_type])) - if not game_state.unlock_vessel(vessel): + if not game_state.unlock_vessel(vessel, PAYMENT_AT): return _fail("researching '%s' was refused with its whole price (%s) in the fleet hold" % [vessel, str(costs)]) for resource_type in costs: if game_state.available_resource(String(resource_type)) != 0: diff --git a/tests/test_research_double_tap.gd b/tests/test_research_double_tap.gd index fa33d5e..4b78366 100644 --- a/tests/test_research_double_tap.gd +++ b/tests/test_research_double_tap.gd @@ -33,6 +33,7 @@ func _initialize() -> void: game_state.buildings.clear() game_state.extractors.clear() game_state.reset_progression() + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) main = load("res://world/Main.tscn").instantiate() root.add_child(main) current_scene = main @@ -92,9 +93,17 @@ func _process(delta: float) -> bool: return _fail("the research panel still has an UnlockButton node") print("the separate buy button is gone from the panel") - main.get_node("UI/SkillMenu").visible = true - tree_view.visible = true - tree_view.reset_scroll() + # Open through the menu's public API so research has the mothership as its + # payment site. Since fleet-held resources became location-aware, merely + # forcing the Control visible no longer represents a reachable game state. + var mothership_id: int = 0 + for building in game_state.buildings: + if String(building.type) == GameState.BUILDING_MOTHERSHIP: + mothership_id = int(building.id) + break + if mothership_id == 0: + return _fail("the test has no mothership research site") + main.get_node("UI/SkillMenu").open_for(mothership_id) game_state.unlocked.clear() game_state.research_points = 99 game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 500) diff --git a/ui/IconButton.gd b/ui/IconButton.gd index aa59414..d73a022 100644 --- a/ui/IconButton.gd +++ b/ui/IconButton.gd @@ -61,6 +61,8 @@ enum Icon { # inserting anywhere earlier renumbers every icon int already stored in # every scene. QUEST, + # #179: a fleet-wide thrust burst, distinct from the Propulser vessel icon. + DASH, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -319,6 +321,24 @@ func _draw() -> void: _draw_repair_drone_icon(center, pulse, s) Icon.QUEST: _draw_quest_icon(center, pulse, s) + Icon.DASH: + _draw_dash_icon(center, pulse, s) + + +func _draw_dash_icon(center: Vector2, pulse: float, s: float) -> void: + var tip: Vector2 = center + Vector2(12.0, 0.0) * s + var tail: Vector2 = center + Vector2(-5.0, 0.0) * s + NeonDraw.draw_glowing_line(self, tail, tip, _active_color(), pulse, + 0.7 * s, core_color) + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + tip + Vector2(-7.0, -7.0) * s, tip, + tip + Vector2(-7.0, 7.0) * s, + ]), _active_color(), pulse, 0.7 * s, core_color) + for y in [-7.0, 0.0, 7.0]: + NeonDraw.draw_glowing_line(self, + center + Vector2(-13.0, y) * s, + center + Vector2(-7.0, y) * s, + _active_color(), pulse * 0.7, 0.45 * s, core_color) # #140: a checklist - a page with a tick against the top line. Reads as diff --git a/ui/LoadingScreen.gd b/ui/LoadingScreen.gd index 81890ec..7961f75 100644 --- a/ui/LoadingScreen.gd +++ b/ui/LoadingScreen.gd @@ -25,7 +25,7 @@ extends Control # with nothing to author, and building it here keeps MainMenu.tscn - which the # player's own editor touches - out of the change. -const BACKDROP: Color = Color(0.01, 0.02, 0.06, 0.94) +const BACKDROP: Color = Color(0.01, 0.02, 0.06, 1.0) const GLOW: Color = Color(0.42, 0.86, 0.95) const TEXT: Color = Color(0.82, 0.94, 1.0) const MUTED: Color = Color(0.52, 0.66, 0.76) @@ -33,7 +33,6 @@ const TITLE_SIZE: int = 44 const SUBTITLE_SIZE: int = 24 const BAR_WIDTH: float = 520.0 const BAR_HEIGHT: float = 16.0 -const BAR_GAP: float = 46.0 # The travelling band inside the bar, and how much of the bar it covers. const SWEEP_SECONDS: float = 1.2 const SWEEP_WIDTH: float = 0.22 @@ -55,12 +54,22 @@ func _ready() -> void: # MainMenu refuses a second press while a load is running anyway. mouse_filter = Control.MOUSE_FILTER_IGNORE set_anchors_preset(Control.PRESET_FULL_RECT) + _fit_viewport() + get_viewport().size_changed.connect(_fit_viewport) title_label = _make_label("LOADING", TITLE_SIZE, TEXT) subtitle_label = _make_label("", SUBTITLE_SIZE, MUTED) resized.connect(_layout) _layout() +# A Control parent would resolve full-rect anchors for us. This node sits +# directly under a CanvasLayer, so its first layout pass can otherwise be +# 0x0 - exactly the frame that is supposed to cover the menu before loading. +func _fit_viewport() -> void: + position = Vector2.ZERO + size = get_viewport_rect().size + + func _make_label(text_value: String, font_size: int, colour: Color) -> Label: var label := Label.new() label.text = text_value @@ -83,16 +92,17 @@ func begin(what: String) -> void: func _bar_rect() -> Rect2: var width: float = minf(BAR_WIDTH, maxf(size.x - 120.0, 40.0)) - return Rect2(Vector2((size.x - width) * 0.5, size.y * 0.5 + BAR_GAP), + return Rect2(Vector2((size.x - width) * 0.5, (size.y - BAR_HEIGHT) * 0.5), Vector2(width, BAR_HEIGHT)) func _layout() -> void: if title_label == null: return - title_label.position = Vector2(0.0, size.y * 0.5 - float(TITLE_SIZE) * 1.4) + var bar: Rect2 = _bar_rect() + title_label.position = Vector2(0.0, bar.position.y - float(TITLE_SIZE) * 1.8) title_label.size = Vector2(size.x, float(TITLE_SIZE) * 1.4) - subtitle_label.position = Vector2(0.0, size.y * 0.5) + subtitle_label.position = Vector2(0.0, bar.end.y + 18.0) subtitle_label.size = Vector2(size.x, float(SUBTITLE_SIZE) * 1.6) queue_redraw() diff --git a/ui/NarrationBanner.gd b/ui/NarrationBanner.gd index 6ee6354..f3e65f3 100644 --- a/ui/NarrationBanner.gd +++ b/ui/NarrationBanner.gd @@ -36,9 +36,9 @@ extends Control # lines never sees the second one at all, and the whole point of #127 was # that lines wait for the reader. # -# #168 says the same rule out loud - still arriving, show it all; already -# whole, move on - and extends it to the KEYBOARD, which had no way in at -# all. See _is_advance_press. +# A dialog is advanced deliberately with a tap or click. Keyboard presses are +# ignored while it is open: movement keys are too easy to hold when a line +# arrives, and must never turn into an accidental skip (#175). signal dismissed @@ -139,7 +139,9 @@ func _ensure_label() -> void: label.add_theme_color_override("font_color", TEXT_COLOR) label.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.06, 0.95)) label.add_theme_constant_override("outline_size", 4) - label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + # #178: decoded text grows from a stable left edge. Centring a partially + # revealed line made it appear in the middle and expand in both directions. + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART add_child(label) @@ -268,28 +270,18 @@ func _process(delta: float) -> void: queue_redraw() -# #168: what counts as "go on". A tap, a left click, or a key. +# What counts as "go on": a deliberate tap or left click. # -# The rule the ticket asks for was already the rule for a click (#149) - a -# press finishes a line still arriving, a press on a finished line moves to -# the next - but the game is played on a desktop with WASD and a mouse, and -# a keyboard press reached nothing at all. Sitting behind a dialog pressing -# space is the most obvious thing a player can do, so the keyboard now goes -# through the same one door every other press does (dismiss()). -# -# Modifiers alone are not a press: holding shift before typing must not eat -# a line. Neither is an auto-repeat echo - a held key would otherwise run -# the whole queue out in a second. +# A press finishes a line still arriving; a press on a finished line moves to +# the next (#149). #175 deliberately limits that gesture to pointer input: the +# player may still be holding a +# movement key when a transmission arrives, and that must leave the line +# untouched rather than finishing or dismissing it. func _is_advance_press(event: InputEvent) -> bool: if event is InputEventScreenTouch: return event.pressed if event is InputEventMouseButton: return event.pressed and event.button_index == MOUSE_BUTTON_LEFT - if event is InputEventKey: - if not event.pressed or event.echo: - return false - return not (event.keycode in [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META, - KEY_CAPSLOCK, KEY_NUMLOCK, KEY_SCROLLLOCK]) return false diff --git a/ui/PriceTag.gd b/ui/PriceTag.gd index ce14a1d..375cc13 100644 --- a/ui/PriceTag.gd +++ b/ui/PriceTag.gd @@ -67,6 +67,11 @@ var _entries: Array = [] var _shown_costs: Dictionary = {} var _shown_points: int = 0 var _built: bool = false +# Optional site whose purchase this tag describes. With one, the displayed +# "have" includes fleet cargo only while the fleet is close enough to hand it +# over. Most tags describe construction at the fleet itself and leave this +# null, retaining the ordinary combined inventory readout. +var payment_position: Variant = null func _ready() -> void: @@ -89,7 +94,7 @@ func show_price(costs: Dictionary, points: int = 0) -> void: if amount <= 0: continue _add_entry(String(resource_type), "", amount, - GameState.available_resource(String(resource_type)), + _available(String(resource_type)), GameState.resource_color(resource_type)) if points > 0: _add_entry("", POINT_MARK, points, GameState.research_points, POINT_COLOR) @@ -102,12 +107,22 @@ func set_costs(costs: Dictionary) -> void: show_price(costs) +func set_payment_position(at: Variant) -> void: + payment_position = at + _refresh_held() + + +func _available(resource_type: String) -> int: + return GameState.available_resource(resource_type) if payment_position == null \ + else GameState.available_resource_at(resource_type, payment_position) + + # What the player holds, re-read. The price does not move; the wallet does. func _refresh_held() -> void: var relayout: bool = false for entry in _entries: var have: int = GameState.research_points if String(entry.resource) == "" \ - else GameState.available_resource(String(entry.resource)) + else _available(String(entry.resource)) if have == int(entry.have): continue entry.have = have diff --git a/ui/menus/SkillMenu.gd b/ui/menus/SkillMenu.gd index bbe8166..34c0497 100644 --- a/ui/menus/SkillMenu.gd +++ b/ui/menus/SkillMenu.gd @@ -8,6 +8,8 @@ signal research_requested(id: String) @onready var status: Label = $StatusLabel @onready var resource_bar: Control = $Bar +var building_id: int = 0 + func _ready() -> void: super._ready() @@ -15,13 +17,22 @@ func _ready() -> void: tree.node_confirmed.connect(func(id: String): research_requested.emit(id)) -func open_for(building_id: int) -> void: +func open_for(next_building_id: int) -> void: + building_id = next_building_id resource_bar.building_id = building_id + var building: Dictionary = GameState.find_building(building_id) + cost.set_payment_position(building.get("position", null)) open_menu() tree.reset_scroll() refresh() +func close_menu() -> void: + super.close_menu() + building_id = 0 + cost.set_payment_position(null) + + func selected_id() -> String: return String(tree.selected_id) @@ -31,6 +42,10 @@ func research_completed(id: String) -> void: refresh(id) +func set_status(message: String) -> void: + status.text = message + + func refresh(id: String = "") -> void: if id == "": id = String(tree.selected_id) diff --git a/vessels/DistorderVesselDefinition.tres b/vessels/DistorderVesselDefinition.tres index 4242027..fc9f890 100644 --- a/vessels/DistorderVesselDefinition.tres +++ b/vessels/DistorderVesselDefinition.tres @@ -5,7 +5,7 @@ script = ExtResource("1") definition_id = "distorder" display_name = "Energy Propulser" -description = "Unarmed propulsion hull that burns energy to drive the whole fleet faster. While one flies with you, a fader lets you push cruise speed past its default." +description = "Unarmed propulsion hull that burns energy to drive the whole fleet faster. It also grants an aimed fleet dash; each Propulser carries its own recharge." build_cost = 14 build_resource_costs = {"ore": 14, "energy": 8} build_time = 16.0 @@ -18,7 +18,7 @@ component_scenes = Array[PackedScene]([ExtResource("2")]) base_stats = {"max_speed": 645.0, "acceleration": 1845.0, "delivery_speed": 180.0} unlock_id = "unlock_distorder" unlock_name = "Unlock Energy Propulser" -unlock_description = "Research a propulsion hull that burns energy to drive the whole fleet faster - while one flies with you, a fader lets you push cruise speed past its default." +unlock_description = "Research a propulsion hull that burns energy to drive the whole fleet faster and grants an aimed fleet dash." unlock_cost = 0 unlock_resource_costs = {"energy": 50} research_point_cost = 1 diff --git a/vessels/Fleet.gd b/vessels/Fleet.gd index b96d157..982a142 100644 --- a/vessels/Fleet.gd +++ b/vessels/Fleet.gd @@ -33,6 +33,11 @@ var fleet_target: Vector2 = Vector2.ZERO # travelling along. Zero heading means the fleet is not being steered. var anchor: Vector2 = Vector2.ZERO var steer_direction: Vector2 = Vector2.ZERO +# An aimed Propulser burst translates the complete formation as one body. +var dash_direction: Vector2 = Vector2.ZERO +var dash_remaining: float = 0.0 + +const DASH_SPEED: float = 1400.0 func _ready() -> void: @@ -227,12 +232,29 @@ func move_in_direction(direction: Vector2) -> void: steer_direction = direction.normalized() +func dash(direction: Vector2, distance: float) -> bool: + if direction.length() < 0.001 or distance <= 0.0 or ships.is_empty(): + return false + if dash_remaining <= 0.0: + anchor = get_formation_origin() + dash_direction = direction.normalized() + dash_remaining = distance + return true + + +func is_dashing() -> bool: + return dash_remaining > 0.0 + + func _process(delta: float) -> void: # #158: a building may pay a local cost out of the hold when the fleet is # actually standing at it, and the autoload has no other way to know # where that is. Written before the early return below - a parked fleet # is exactly the case that matters. GameState.fleet_position = get_centroid() + if is_dashing(): + _advance_dash(delta) + return if steer_direction == Vector2.ZERO: return # The anchor - the fleet's formation centre - travels at cruise_speed. @@ -244,6 +266,29 @@ func _process(delta: float) -> void: _assign_slot_targets() +func _advance_dash(delta: float) -> void: + var step: float = minf(DASH_SPEED * delta, dash_remaining) + var displacement: Vector2 = dash_direction * step + dash_remaining -= step + anchor += displacement + fleet_target += displacement + for ship in ships: + if not is_instance_valid(ship): + continue + # Move the hull and its destination together. That preserves every + # formation offset and prevents normal steering from trying to "repair" + # the burst by pulling ships back toward their pre-dash targets. + ship.position += displacement + ship.move_target += displacement + ship.velocity = Vector2.ZERO + ship.fleet_velocity = Vector2.ZERO + ship.rotation = dash_direction.angle() + PI / 2.0 + if dash_remaining <= 0.001: + dash_remaining = 0.0 + if steer_direction != Vector2.ZERO: + _assign_slot_targets() + + func _effective_cruise_speed() -> float: return cruise_speed * GameState.fleet_speed_multiplier diff --git a/vessels/VesselDistorderComponent.gd b/vessels/VesselDistorderComponent.gd index 25a31d7..5fe5acd 100644 --- a/vessels/VesselDistorderComponent.gd +++ b/vessels/VesselDistorderComponent.gd @@ -1,9 +1,25 @@ class_name VesselDistorderComponent -extends Node +extends "res://vessels/VesselCooldownFillComponent.gd" -# Pure presence marker (#59) - no per-shot behaviour, no tick(). Its only -# job is to make Ship.has_capability("distorder") true, which is what -# SpeedFader.gd/Fleet.gd key off to reveal and apply the speed boost. -# Energy drain while boosted is a later ticket (#62). +# The presence marker used by the speed fader, plus the Energy Propulser's +# independently recharging dash (#179). Keeping both on one component means +# a vessel is the source of both propulsion capabilities and carries its own +# cooldown just like gas bombers and laser vessels do. var capability_id := "distorder" + +const DASH_COOLDOWN: float = 5.0 +const READY_COLOR := Color(0.85, 0.95, 0.15) +const COOLING_COLOR := Color(0.28, 0.31, 0.08) + + +func _init() -> void: + cooldown_duration = DASH_COOLDOWN + + +func consume_dash() -> bool: + return _begin_cooldown(DASH_COOLDOWN) + + +func _ready_color() -> Color: return READY_COLOR +func _cooling_color() -> Color: return COOLING_COLOR diff --git a/vessels/VesselDistorderComponent.tscn b/vessels/VesselDistorderComponent.tscn index 70586f5..a84161e 100644 --- a/vessels/VesselDistorderComponent.tscn +++ b/vessels/VesselDistorderComponent.tscn @@ -2,5 +2,5 @@ [ext_resource type="Script" path="res://vessels/VesselDistorderComponent.gd" id="1"] -[node name="DistorderComponent" type="Node"] +[node name="DistorderComponent" type="Node2D"] script = ExtResource("1") diff --git a/vfx/GasBombEffect.gd b/vfx/GasBombEffect.gd index e2e6361..bdd4775 100644 --- a/vfx/GasBombEffect.gd +++ b/vfx/GasBombEffect.gd @@ -70,13 +70,10 @@ func _explode() -> void: # Asteroids too - a blast that clears everything around it but leaves the # rocks untouched would need explaining, and they take damage the same way. # - # #165: and STRUCTURES. The blast used to stop politely at anything the - # player owned, which made a bomb dropped on a besieged mothership a free - # way to clear the attackers off it. Everything inside the radius is - # inside the radius; where it lands is the decision the weapon asks for. + # #176: player weapons never damage player buildings. A bomb may be thrown + # into a siege without punishing the structure it is meant to defend. for target_node in get_tree().get_nodes_in_group("enemies") \ - + get_tree().get_nodes_in_group("asteroids") \ - + get_tree().get_nodes_in_group("player_buildings"): + + get_tree().get_nodes_in_group("asteroids"): if is_instance_valid(target_node) and target_node is Node2D \ and target_node.global_position.distance_to(target) <= AOE_RADIUS: target_node.take_damage(damage) diff --git a/world/Main.tscn b/world/Main.tscn index 8a1a2ef..849148d 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=43 format=3] +[gd_scene load_steps=44 format=3] [ext_resource type="PackedScene" path="res://ui/menus/BuildMenu.tscn" id="menu_build"] [ext_resource type="PackedScene" path="res://ui/menus/HangarMenu.tscn" id="menu_hangar"] [ext_resource type="PackedScene" path="res://ui/menus/LabMenu.tscn" id="menu_lab"] @@ -39,6 +39,7 @@ [ext_resource type="Script" path="res://controllers/FireStickController.gd" id="fire_ctrl"] [ext_resource type="Script" path="res://controllers/GasBombController.gd" id="29"] [ext_resource type="Script" path="res://controllers/LaserController.gd" id="30"] +[ext_resource type="Script" path="res://controllers/DashController.gd" id="dash_ctrl"] [ext_resource type="Script" path="res://ui/SpeedFader.gd" id="31"] [ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"] [ext_resource type="Script" path="res://ui/QuestPanel.gd" id="quest_panel"] @@ -82,6 +83,9 @@ script = ExtResource("mouse_ctrl") [node name="LaserController" type="Node2D" parent="."] script = ExtResource("30") +[node name="DashController" type="Node2D" parent="."] +script = ExtResource("dash_ctrl") + [node name="Camera2D" type="Camera2D" parent="."] position = Vector2(0, 0) zoom = Vector2(1, 1) @@ -343,6 +347,22 @@ offset_right = -100 offset_top = -548 offset_bottom = -452 +[node name="DashButton" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +script = ExtResource("10") +round_frame = true +icon = 45 +glow_color = Color(0.85, 0.95, 0.15, 1) +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -452 +offset_right = -356 +offset_top = -548 +offset_bottom = -452 + [node name="PivotCCWButton" type="Control" parent="UI"] mouse_filter = 2 script = ExtResource("10") @@ -405,6 +425,22 @@ offset_right = -24 offset_top = -616 offset_bottom = -568 +[node name="DashStatus" type="Label" parent="UI"] +mouse_filter = 2 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.85, 0.95, 0.15, 1) +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -500 +offset_right = -104 +offset_top = -672 +offset_bottom = -624 + [node name="PivotCWButton" type="Control" parent="UI"] mouse_filter = 2 script = ExtResource("10")