diff --git a/BuildController.gd b/BuildController.gd index e0169ad..fe8281d 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -432,7 +432,7 @@ func _process(delta: float) -> void: var actions: Array = _context_action_buttons() action_button.visible = not actions.is_empty() if current_building != null: - building_action_button.icon = int(current_building.definition.action_icon) + building_action_button.icon = _building_action_icon(current_building) building_action_button.queue_redraw() if not actions.is_empty(): action_button.icon = int(actions[0].icon) if actions.size() == 1 else IconButton.Icon.ACTION_MENU @@ -489,6 +489,16 @@ func _has_transfer_context() -> bool: return current_building != null and current_building.available_actions().has("transfer") +# A pause action's icon shows what the button will DO, not what the building +# currently is: two bars while it runs, a play triangle while it is stopped. +# Everything else uses the icon its definition names. +func _building_action_icon(building: Node) -> int: + if building.primary_action() == "pause": + return IconButton.Icon.RESUME if GameState.is_building_paused(building.building_id) \ + else IconButton.Icon.PAUSE + return int(building.definition.action_icon) + + func _button_for_building_action(action: String) -> IconButton: if action == "transfer": return null @@ -540,6 +550,15 @@ func _on_action_pressed() -> void: bar.building_id = current_building.building_id var action: String = current_building.primary_action() + if action == "pause": + # No panel: the button IS the control. Toggling here rather than + # opening something keeps a one-bit setting to a single tap. + var paused: bool = GameState.toggle_building_paused(current_building.building_id) + build_status.text = "%s %s" % [current_building.definition.display_name, + "paused" if paused else "running"] + building_action_button.icon = _building_action_icon(current_building) + building_action_button.queue_redraw() + return if action == "research": skill_menu.visible = true skill_tree.reset_scroll() diff --git a/BuildingVisualBase.gd b/BuildingVisualBase.gd index f1bdfe3..1c0949e 100644 --- a/BuildingVisualBase.gd +++ b/BuildingVisualBase.gd @@ -294,14 +294,20 @@ const BURNER_VENT_LENGTH: float = 26.0 const BURNER_PORT_RADIUS: float = 6.0 -func _draw_gas_burner_static(target: CanvasItem) -> void: - var c: Color = GAS_BURNER_COLOR +# `cold` draws the burner switched off (#78): the structure is still there, +# but the fire in the middle is out and the output ports are dark. A paused +# building that looked identical to a running one would be a setting the +# player has to remember rather than see. +func _draw_gas_burner_static(target: CanvasItem, cold: bool = false) -> void: + var c: Color = GAS_BURNER_COLOR.darkened(0.55) if cold else GAS_BURNER_COLOR - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CHAMBER_RADIUS, 6, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CHAMBER_RADIUS, 6, PI / 6.0), c, + 0.55 if cold else 1.0) NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CORE_RADIUS, 6, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7) - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CORE_RADIUS * 0.5, 6, PI / 6.0), - Color(1.0, 0.75, 0.35), 0.85) + if not cold: + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CORE_RADIUS * 0.5, 6, PI / 6.0), + Color(1.0, 0.75, 0.35), 0.85) for i in range(BURNER_VENT_COUNT): var a: float = (float(i) / BURNER_VENT_COUNT) * TAU + PI / 4.0 @@ -310,10 +316,12 @@ func _draw_gas_burner_static(target: CanvasItem) -> void: # Intake vent in gas's own colour, so it is unmistakably the feed # rather than another structural brace. NeonDraw.draw_glowing_line(target, dir * BURNER_CHAMBER_RADIUS, tip, - Color(0.85, 0.52, 1.0), 0.7, 0.34) + Color(0.85, 0.52, 1.0), 0.25 if cold else 0.7, 0.34) # Output port glowing in energy's colour, at the same vent tip - # the building's own silhouette shows both ends of the conversion. - NeonDraw.draw_glowing_polygon(target, _ring(tip, BURNER_PORT_RADIUS, 5), GAS_BURNER_ENERGY_COLOR, 0.9) + NeonDraw.draw_glowing_polygon(target, _ring(tip, BURNER_PORT_RADIUS, 5), + GAS_BURNER_ENERGY_COLOR.darkened(0.6) if cold else GAS_BURNER_ENERGY_COLOR, + 0.3 if cold else 0.9) func _diamond(centre: Vector2, r: float) -> PackedVector2Array: diff --git a/DistorderVesselDefinition.tres b/DistorderVesselDefinition.tres index 9d8777f..0282f25 100644 --- a/DistorderVesselDefinition.tres +++ b/DistorderVesselDefinition.tres @@ -4,21 +4,21 @@ [resource] script = ExtResource("1") definition_id = "distorder" -display_name = "Distorder" -description = "Unarmed space-time distortion hull. While one flies with the fleet, a fader lets you push cruise speed beyond its default." +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." build_cost = 14 build_resource_costs = {"ore": 14, "energy": 8} build_time = 16.0 icon = 37 -glow_color = Color(0.7, 0.3, 1, 1) -hull_points = PackedVector2Array(0, -24, -14, -2, -6, 18, 10, 10, 12, -10) +glow_color = Color(0.85, 0.95, 0.15, 1) +hull_points = PackedVector2Array(0, -26, 9, -8, 13, 6, 18, 20, 0, 12, -18, 20, -13, 6, -9, -8) fleet_eligible = true capabilities = PackedStringArray("distorder") component_scenes = Array[PackedScene]([ExtResource("2")]) base_stats = {"max_speed": 430.0, "acceleration": 1230.0, "delivery_speed": 180.0} unlock_id = "unlock_distorder" -unlock_name = "Unlock Distorder" -unlock_description = "Research a space-time distortion hull - while one flies with the fleet, a fader lets you push cruise speed beyond its default." +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_cost = 0 unlock_resource_costs = {"energy": 50} research_point_cost = 1 diff --git a/GameState.gd b/GameState.gd index d546073..a5320cd 100644 --- a/GameState.gd +++ b/GameState.gd @@ -31,8 +31,8 @@ var planet_definition_id: String = "rocky" # anchor, Ship's own max_speed/acceleration) rather than baked into stored # stats, so a speed upgrade researched while boosted can't corrupt the base # value - see Fleet.gd/Ship.gd for the multiply sites. -# The ceiling ONE distorder vessel reaches. Each ADDITIONAL one raises it by -# another 1.0 - see set_distorder_vessel_count(). Kept as the one-vessel +# The ceiling ONE Energy Propulser reaches. Every DOUBLING of them raises it +# by another 1.0 - see set_distorder_vessel_count(). Kept as the one-vessel # figure rather than an absolute cap so the fader's meaning at a single # vessel is unchanged. const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0 @@ -67,8 +67,23 @@ func set_fleet_speed_multiplier(value: float) -> void: # Re-clamps the CURRENT multiplier, so losing a distorder vessel mid-boost # pulls the fleet down to the new ceiling instead of leaving it travelling # faster than its remaining crew can sustain. +# #77: the ceiling grows with the LOGARITHM of how many propulsers fly, not +# linearly. Each one used to add a flat 1.0, so a fleet that could afford six +# of them simply drove at six times cruise and the vessel stopped being a +# choice. Base-2, which makes the rule one sentence: every DOUBLING of +# propulsers buys another 1.0. +# +# 1 -> 3.0 2 -> 4.0 4 -> 5.0 8 -> 6.0 +# +# Two propulsers are worth exactly what they were worth before, so nothing a +# player has already built is devalued; it is the fifth and sixth that stop +# paying for themselves. func set_distorder_vessel_count(count: int) -> void: - max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER + maxf(float(count) - 1.0, 0.0) + if count <= 1: + max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER + return + max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER \ + + log(float(count)) / log(2.0) set_fleet_speed_multiplier(fleet_speed_multiplier) @@ -1337,6 +1352,31 @@ func _collectable_building_resource(building: Dictionary, minimum_amount: int) - return "" +# #78: a converter can be switched off. A gas burner turns gas the player may +# want for bombs into energy they may not need yet, and until now it did that +# relentlessly - the only way to stop it was to demolish it. Paused is stored +# on the building record like every other piece of its state. +func is_building_paused(id: int) -> bool: + return bool(find_building(id).get("paused", false)) + + +func set_building_paused(id: int, paused: bool) -> void: + var building: Dictionary = find_building(id) + if building.is_empty(): + return + building.paused = paused + building_paused_changed.emit(id, paused) + + +func toggle_building_paused(id: int) -> bool: + var paused: bool = not is_building_paused(id) + set_building_paused(id, paused) + return paused + + +signal building_paused_changed(id: int, paused: bool) + + func _advance_resource_converters(delta: float) -> void: for building in buildings: var profile: Resource = building_logistics_profile(building) @@ -1344,6 +1384,11 @@ func _advance_resource_converters(delta: float) -> void: or profile.conversion_output == "" or profile.conversion_rate <= 0.0 \ or profile.conversion_yield <= 0: continue + if bool(building.get("paused", false)): + # Progress is left exactly where it was rather than reset, so + # switching a burner off and on again does not throw away the + # fraction of a unit it had already worked through. + continue var inventory: RefCounted = _inventory_for_record(building) if inventory.amount(profile.conversion_input) <= 0: continue diff --git a/GasBurnerBuildingVisual.gd b/GasBurnerBuildingVisual.gd index 472fadd..c7a8236 100644 --- a/GasBurnerBuildingVisual.gd +++ b/GasBurnerBuildingVisual.gd @@ -1,9 +1,45 @@ extends "res://BuildingVisualBase.gd" +# Drawn once, not per frame - the burner has no animation. It does have two +# LOOKS though (#78): lit while it is converting, cold while the player has +# paused it. A one-shot draw plus a redraw on the state signal keeps that free. +# +# GameState is reached through the tree rather than by its global autoload +# name. This script is compiled as part of a preload chain from +# BuildingDefinitions, which can happen before autoloads are registered - a +# bare `GameState` reference here failed to compile outright, taking the whole +# visual with it. Same hazard PlanetSetups and PlanetSurfaceController already +# work around, and the same fix. + + +func _state() -> Node: + return get_tree().root.get_node_or_null("GameState") + + func _ready() -> void: set_process(false) + var state: Node = _state() + if state != null: + state.building_paused_changed.connect(_on_building_paused_changed) queue_redraw() + +func _on_building_paused_changed(id: int, _paused: bool) -> void: + if id == _own_building_id(): + queue_redraw() + + +# The visual sits under Building/VisualHost, so its owner is two levels up. +# Asked for rather than stored, because the visual is instantiated before the +# Building node has finished setting its own id. +func _own_building_id() -> int: + var host: Node = get_parent() + var building: Node = host.get_parent() if host != null else null + return int(building.building_id) if building != null and "building_id" in building else 0 + + func _draw() -> void: register_draw() - _draw_gas_burner_static(self) + var state: Node = _state() + var paused: bool = state != null and state.is_building_paused(_own_building_id()) + _draw_gas_burner_static(self, paused) diff --git a/GasBurnerBuildingVisual.tscn b/GasBurnerBuildingVisual.tscn index adffe2d..dd2c56a 100644 --- a/GasBurnerBuildingVisual.tscn +++ b/GasBurnerBuildingVisual.tscn @@ -8,4 +8,4 @@ script = ExtResource("1") [node name="ActionProvider" type="Node" parent="."] script = ExtResource("2") -actions = PackedStringArray("transfer") +actions = PackedStringArray("pause", "transfer") diff --git a/GasVentField.gd b/GasVentField.gd index 8669495..6b17eb6 100644 --- a/GasVentField.gd +++ b/GasVentField.gd @@ -1,4 +1,12 @@ extends Node2D + +# By path, not by its `ResourcePickupVFX` global name: a bare class-name +# reference needs the global class cache already warm, which it is not when +# this script is reached indirectly - loading Main.tscn from inside a preview +# scene made it a hard parse error. Same workaround PlanetSetups and +# PlanetSetupEditor already use. +const RESOURCE_PICKUP_VFX = preload("res://ResourcePickupVFX.gd") + @export var fleet_path: NodePath = NodePath("../Fleet") @onready var fleet: Node2D = get_node(fleet_path) var vents: Array[Vector2] = [] @@ -20,7 +28,10 @@ func _process(delta: float) -> void: # undelivered whole units in progress rather than discarding # them, so gas is only ever lost by not gathering it. var whole: int = int(progress) - progress -= float(GameState.collect_gas_cargo(whole)) + var taken: int = GameState.collect_gas_cargo(whole) + progress -= float(taken) + RESOURCE_PICKUP_VFX.spawn(get_tree().root, + to_global(vent), GameState.RESOURCE_GAS, taken) return progress = 0.0 func _draw() -> void: diff --git a/IconButton.gd b/IconButton.gd index 81183be..836dd35 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -41,8 +41,15 @@ enum Icon { # #58: LASER_VESSEL is the ship glyph, LASER_SHOT the action button - # same two-icon split as GAS_BOMBER/GAS_BOMB above. LASER_VESSEL, LASER_SHOT, - # #59: a warped ring reads as a space-time distortion rather than a hull. + # #59 drew this as a warped ring, for a vessel then called a space-time + # distorder. #77 renamed it the Energy Propulser and the glyph is now an + # engine bell; the enum name stays put because renumbering it would + # silently renumber every icon int stored in every scene. DISTORDER_VESSEL, + # #78: switching a converter off and on. Appended, like everything above - + # inserting anywhere earlier renumbers every icon int already stored in a + # scene file. + PAUSE, RESUME, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -272,6 +279,10 @@ func _draw() -> void: _draw_gas_bomb(center, pulse, s) Icon.GAS_BURNER: _draw_gas_burner_icon(center, pulse, s) + Icon.PAUSE: + _draw_pause(center, pulse, s) + Icon.RESUME: + _draw_resume(center, pulse, s) Icon.CARRIER_VESSEL: _draw_carrier(center, pulse, s) Icon.LASER_VESSEL: @@ -319,15 +330,27 @@ func _draw_carrier(center: Vector2, pulse: float, s: float) -> void: func _draw_distorder_vessel(center: Vector2, pulse: float, s: float) -> void: - # A ring whose radius wobbles with angle instead of a plain circle - - # reads as warped space rather than a hull silhouette. - var points: PackedVector2Array = PackedVector2Array() - for i in range(17): - var a: float = TAU * float(i) / 16.0 - var r: float = (10.0 + 3.0 * sin(a * 3.0)) * s - points.append(center + Vector2(cos(a), sin(a)) * r) - NeonDraw.draw_glowing_path(self, points, _active_color(), pulse, 0.8, core_color) - NeonDraw.draw_glowing_circle(self, center, 2.5 * s, _active_color(), pulse, core_color) + # #77: an engine bell with a thrust plume under it. The glyph used to be a + # ring with a wobbling radius, which read as warped space - correct for a + # "space-time distorder" and wrong for the Energy Propulser it now is. + var hull: PackedVector2Array = PackedVector2Array([ + center + Vector2(0.0, -13.0) * s, + center + Vector2(5.0, -4.0) * s, + center + Vector2(7.0, 3.0) * s, + center + Vector2(10.0, 10.0) * s, + center + Vector2(-10.0, 10.0) * s, + center + Vector2(-7.0, 3.0) * s, + center + Vector2(-5.0, -4.0) * s, + ]) + NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color) + # Three exhaust strokes, the middle one longest, so the thrust reads as + # coming OUT of the bell rather than as decoration beside it. + for offset in [-5.0, 0.0, 5.0]: + var length: float = 16.0 if is_zero_approx(offset) else 12.0 + NeonDraw.draw_glowing_line(self, + center + Vector2(offset, 11.0) * s, + center + Vector2(offset, length) * s, + _active_color(), pulse * 0.8, 0.5, core_color) func _draw_laser_vessel(center: Vector2, pulse: float, s: float) -> void: @@ -409,6 +432,25 @@ func _draw_horizontal_arrow(center: Vector2, pulse: float, direction: float, s: NeonDraw.draw_glowing_line(self, tip, tip + Vector2(-r * 0.8 * direction, r * 0.7), _active_color(), pulse, 0.5 * s, core_color) +func _draw_pause(center: Vector2, pulse: float, s: float) -> void: + # Two upright bars. Shown while something is RUNNING, because that is the + # action the button performs, not the state it is in. + for side in [-1.0, 1.0]: + var x: float = 5.0 * side * s + NeonDraw.draw_glowing_line(self, center + Vector2(x, -11.0 * s), + center + Vector2(x, 11.0 * s), _active_color(), pulse, 0.9, core_color) + + +func _draw_resume(center: Vector2, pulse: float, s: float) -> void: + # A play triangle, shown while something is stopped. + var play: PackedVector2Array = PackedVector2Array([ + center + Vector2(-7.0, -11.0) * s, + center + Vector2(11.0, 0.0) * s, + center + Vector2(-7.0, 11.0) * s, + ]) + NeonDraw.draw_glowing_polygon(self, play, _active_color(), pulse, core_color) + + func _draw_minus(center: Vector2, pulse: float, s: float) -> void: var r: float = 9.0 * s NeonDraw.draw_glowing_line(self, center + Vector2(-r, 0.0), center + Vector2(r, 0.0), diff --git a/ResourcePickupVFX.gd b/ResourcePickupVFX.gd new file mode 100644 index 0000000..d84cb0c --- /dev/null +++ b/ResourcePickupVFX.gd @@ -0,0 +1,101 @@ +extends Node2D + +# Forgejo #79: a small "+N" with the resource's own mark, rising and fading +# where the fleet picked something up. +# +# Collecting used to be entirely silent - a rock vanished and a number in the +# corner of the screen changed. Which number, and by how much, was left for +# the player to notice on their own. This says it at the point it happened, +# in the resource's own colour, and gets out of the way. +# +# Built from the SAME glyph scene the resource tables and transfer rows use +# (ResourceDefinition.glyph_scene), so a new material added to the catalog +# gets a correct pickup mark with no work here - and can never end up with +# one mark in the HUD and a different one in the world. + +const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd") +# Deliberately NO class_name, and the constructor below loads this file by +# path rather than naming the class. A global class-name identifier - even a +# script's own, inside its own static function - needs the class cache already +# warm, and it is not when this is reached indirectly through a preview scene +# loading Main.tscn. That failed to compile outright, taking Rock and the gas +# vents down with it. Callers preload the path too; see Rock.gd. +const SELF_PATH: String = "res://ResourcePickupVFX.gd" + +const RISE_DISTANCE: float = 46.0 +const DURATION: float = 0.85 +const GLYPH_SIZE: float = 22.0 +const DIGIT_HEIGHT: float = 20.0 +# Held fully opaque for the first part of its life, so the eye has time to +# catch it before it starts going. +const FADE_START: float = 0.45 + +var age: float = 0.0 +var colour: Color = Color.WHITE +var start_y: float = 0.0 + + +static func spawn(parent: Node, world_position: Vector2, resource_type: String, + amount: int) -> void: + if amount <= 0: + return + var vfx: Node2D = (load(SELF_PATH) as GDScript).new() + vfx.position = world_position + vfx.build(resource_type, amount) + parent.add_child(vfx) + + +func build(resource_type: String, amount: int) -> void: + var definition: Resource = RESOURCE_DEFINITIONS.get_definition(resource_type) + colour = definition.color if definition != null else Color.WHITE + + if definition != null and definition.glyph_scene != null: + var glyph: Control = definition.glyph_scene.instantiate() + glyph.colour = definition.color + # Anchors first. The glyph scenes are authored as full-rect Controls + # for the panels that host them, and Godot recomputes the size of any + # Control with non-equal opposite anchors right after _ready() - so + # assigning size without this is overwritten, with a warning, the + # moment it enters the tree. + glyph.set_anchors_preset(Control.PRESET_TOP_LEFT) + glyph.size = Vector2(GLYPH_SIZE, GLYPH_SIZE) + glyph.position = Vector2(-2.0, -GLYPH_SIZE * 0.5) + add_child(glyph) + + # show_icon off: the resource's own glyph is already beside it, and + # NeonNumber's built-in mark is the ore hexagon whatever the amount is. + var number := NeonNumber.new() + number.show_icon = false + number.amount = amount + number.digit_height = DIGIT_HEIGHT + number.glow_color = colour + number.core_color = colour.darkened(0.8) + number.size = Vector2(60.0, DIGIT_HEIGHT) + number.position = Vector2(GLYPH_SIZE + 4.0, -DIGIT_HEIGHT * 0.5) + add_child(number) + + start_y = position.y + z_index = 40 + + +func _process(delta: float) -> void: + age += delta + var t: float = age / DURATION + if t >= 1.0: + queue_free() + return + # Eases out: quick off the mark, drifting by the end. + position.y = start_y - RISE_DISTANCE * (1.0 - pow(1.0 - t, 2.0)) + modulate.a = 1.0 if t < FADE_START else 1.0 - (t - FADE_START) / (1.0 - FADE_START) + queue_redraw() + + +func _draw() -> void: + # The plus sign, drawn here rather than as a third child - it is two + # strokes and giving it its own node would cost more than it saves. + var centre := Vector2(-12.0, 0.0) + var arm: float = 5.0 + NeonDraw.draw_glowing_line(self, centre - Vector2(arm, 0.0), centre + Vector2(arm, 0.0), + colour, 1.0, 0.5, colour.darkened(0.8)) + NeonDraw.draw_glowing_line(self, centre - Vector2(0.0, arm), centre + Vector2(0.0, arm), + colour, 1.0, 0.5, colour.darkened(0.8)) diff --git a/Rock.gd b/Rock.gd index 690fa84..f48b23d 100644 --- a/Rock.gd +++ b/Rock.gd @@ -16,6 +16,13 @@ extends Area2D signal collected +# By path, not by its `ResourcePickupVFX` global name: a bare class-name +# reference needs the global class cache already warm, which it is not when +# this script is reached indirectly - loading Main.tscn from inside a preview +# scene made it a hard parse error. Same workaround PlanetSetups and +# PlanetSetupEditor already use. +const RESOURCE_PICKUP_VFX = preload("res://ResourcePickupVFX.gd") + # Seconds before an uncollected rock disappears. 0 means never, which is what # hand-placed surface rocks use - a planet surface is bounded and authored, so # nothing accumulates. Ore dropped by a shot asteroid sets a real lifetime, @@ -98,8 +105,13 @@ func _on_area_entered(area: Area2D) -> void: # spendable once the fleet has flown it back to the mothership. # #56: the hold has a finite total_capacity now - a full fleet must # leave the rock behind rather than despawning it for nothing. - if GameState.collect_cargo(1) <= 0: + var taken: int = GameState.collect_cargo(1) + if taken <= 0: return + # #79: say what was picked up, where it was picked up. Parented to the + # root rather than to this node, which is about to be freed. + RESOURCE_PICKUP_VFX.spawn(get_tree().root, global_position, + GameState.RESOURCE_ORE, taken) collected.emit() queue_free() diff --git a/VesselDefinition.gd b/VesselDefinition.gd index f7af0a0..3abb992 100644 --- a/VesselDefinition.gd +++ b/VesselDefinition.gd @@ -1,6 +1,17 @@ class_name VesselDefinition extends Resource +# The stable internal key: capability strings, unlock ids in vessel_levels, +# component scene names and every test refer to a vessel by this, NOT by +# display_name. The two are deliberately allowed to diverge - #77 renamed the +# "distorder" to the Energy Propulser purely in what the player sees, and +# nothing underneath had to be migrated for it. +# +# Note for editors of the .tres files themselves: Godot's resource format does +# NOT accept `#` comments inside a [resource] block. One added there parses as +# part of the preceding value and silently blanks the property after it, which +# presents as " did not resolve its definition" a long way from the +# edit. Comments about the data go here, in the script. @export var definition_id: String @export var display_name: String @export_multiline var description: String diff --git a/tests/test_distorder_stacking.gd b/tests/test_distorder_stacking.gd index 7d13a09..babe31d 100644 --- a/tests/test_distorder_stacking.gd +++ b/tests/test_distorder_stacking.gd @@ -2,13 +2,17 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_distorder_stacking.gd -# Each space-time distorder vessel PAST THE FIRST raises the fleet's speed -# ceiling by 1.0, so a second one is worth crewing rather than dead weight. +# Energy Propulsers raise the fleet's speed ceiling together, so a second one +# is worth crewing rather than dead weight - but with DIMINISHING returns +# (#77). Each doubling of them adds 1.0, not each vessel: a flat 1.0 apiece +# meant a fleet that could afford six simply drove at six times cruise and the +# vessel stopped being a choice. # # Covers: # A. one vessel gives the original ceiling, so nothing changed for a fleet -# that carries a single distorder -# B. each additional vessel adds exactly 1.0 +# that carries a single propulser +# B. the ceiling follows log2 of the count - two vessels are worth exactly +# what they were worth before the change, and the growth tapers after # C. the fader's full travel reaches the WIDENED ceiling, not the old one # D. losing a vessel mid-boost pulls the fleet back down to the new # ceiling rather than leaving it faster than its crew can sustain @@ -53,14 +57,33 @@ func _process(delta: float) -> bool: % [game_state.max_fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER]) print("1 distorder -> ceiling %.1fx" % game_state.max_fleet_speed_multiplier) - # --- B. each additional vessel adds 1.0 ---------------------- + # --- B. the ceiling grows with log2 of the count --------------- + var previous_gain: float = 1.0 for extra in range(1, 4): fleet.add_ship(GameState.VESSEL_DISTORDER) - var expected: float = GameState.MAX_FLEET_SPEED_MULTIPLIER + float(extra) + var count: int = extra + 1 + var expected: float = GameState.MAX_FLEET_SPEED_MULTIPLIER \ + + log(float(count)) / log(2.0) if not is_equal_approx(game_state.max_fleet_speed_multiplier, expected): - return _fail("%d distorders give a ceiling of %.1f, expected %.1f" \ - % [extra + 1, game_state.max_fleet_speed_multiplier, expected]) - print("%d distorders -> ceiling %.1fx" % [extra + 1, game_state.max_fleet_speed_multiplier]) + return _fail("%d propulsers give a ceiling of %.2f, expected %.2f" \ + % [count, game_state.max_fleet_speed_multiplier, expected]) + # Each vessel must be worth strictly less than the one before + # it. That is the whole point of the change, and asserting the + # formula alone would pass just as happily on a linear one. + var gain: float = game_state.max_fleet_speed_multiplier \ + - (GameState.MAX_FLEET_SPEED_MULTIPLIER + log(float(count - 1)) / log(2.0)) + if count > 2 and gain >= previous_gain: + return _fail("propulser %d added %.2f, no less than the %.2f the one before it added - the returns are not diminishing" \ + % [count, gain, previous_gain]) + previous_gain = gain + print("%d propulsers -> ceiling %.2fx (this one added %.2f)" \ + % [count, game_state.max_fleet_speed_multiplier, gain]) + + # Two propulsers must still be worth exactly what they were before + # the change, or an existing fleet was quietly devalued. + if not is_equal_approx(GameState.MAX_FLEET_SPEED_MULTIPLIER + log(2.0) / log(2.0), + GameState.MAX_FLEET_SPEED_MULTIPLIER + 1.0): + return _fail("two propulsers no longer reach the ceiling they used to") # --- C. the fader reaches the widened ceiling ---------------- # Dragging the track to its top must mean the CURRENT ceiling, or diff --git a/tests/test_gas_burner_pause.gd b/tests/test_gas_burner_pause.gd new file mode 100644 index 0000000..8ffdc4d --- /dev/null +++ b/tests/test_gas_burner_pause.gd @@ -0,0 +1,159 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_gas_burner_pause.gd +# Forgejo #78: a gas burner converts gas the player may want for bombs into +# energy they may not need yet, and until now it did that relentlessly - the +# only way to stop it was to demolish it. +# +# Covers: +# A. the burner offers a "pause" action, and it is its PRIMARY one, so a +# single tap on the context button toggles it rather than opening a menu +# B. a running burner converts gas into energy +# C. a paused one converts nothing, and keeps its gas +# D. resuming converts again, and the fraction of a unit already worked +# through is not thrown away +# E. the button shows what it will DO - pause bars while running, a play +# triangle while stopped + +var game_state: Node +var main: Node +var controller: Node +var burner_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var gas_at_pause: int = 0 +var energy_at_pause: int = 0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + main = load("res://Main.tscn").instantiate() + root.add_child(main) + current_scene = main + controller = main.get_node("BuildController") + + +func _burner() -> Dictionary: + return game_state.find_building(burner_id) + + +func _stock(resource_type: String) -> int: + return game_state.building_resource(burner_id, resource_type) + + +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) + + # Through the FIELD, not GameState directly: place() both records + # the building and spawns its node, and part E needs the node. + main.get_node("BuildingField").place(GameState.BUILDING_GAS_BURNER, + Vector2(600.0, 0.0)) + burner_id = int(game_state.buildings[0].id) + game_state.building_inventory(burner_id).deposit(GameState.RESOURCE_GAS, 30) + if _stock(GameState.RESOURCE_GAS) <= 0: + return _fail("could not stock the burner with gas") + + # --- A. it offers pause, as its primary action ---------------- + var definition: Resource = load("res://BuildingDefinitions.gd") \ + .get_definition(GameState.BUILDING_GAS_BURNER) + var visual: Node = definition.visual_scene.instantiate() + var provider: Node = visual.get_node_or_null("ActionProvider") + if provider == null: + visual.free() + return _fail("the gas burner visual has no ActionProvider") + var actions: PackedStringArray = provider.actions + visual.free() + if not Array(actions).has("pause"): + return _fail("the gas burner does not offer a pause action: %s" % str(actions)) + if String(actions[0]) != "pause": + return _fail("pause is not the burner's primary action (%s comes first), so it would need a menu rather than one tap" % actions[0]) + print("gas burner offers pause as its primary action") + stage = 1 + elapsed = 0.0 + return false + 1: + # --- B. running, it converts ---------------------------------- + if elapsed < 1.5: + return false + if _stock(GameState.RESOURCE_ENERGY) <= 0: + return _fail("a running burner made no energy in %.1fs" % elapsed) + print("running: %d gas left, %d energy made" \ + % [_stock(GameState.RESOURCE_GAS), _stock(GameState.RESOURCE_ENERGY)]) + + if game_state.is_building_paused(burner_id): + return _fail("the burner started out paused") + if not game_state.toggle_building_paused(burner_id): + return _fail("toggling did not pause the burner") + gas_at_pause = _stock(GameState.RESOURCE_GAS) + energy_at_pause = _stock(GameState.RESOURCE_ENERGY) + stage = 2 + elapsed = 0.0 + return false + 2: + # --- C. paused, it does nothing ------------------------------- + if elapsed < 1.5: + return false + if _stock(GameState.RESOURCE_ENERGY) != energy_at_pause: + return _fail("a paused burner made %d more energy" \ + % (_stock(GameState.RESOURCE_ENERGY) - energy_at_pause)) + if _stock(GameState.RESOURCE_GAS) != gas_at_pause: + return _fail("a paused burner consumed %d gas" \ + % (gas_at_pause - _stock(GameState.RESOURCE_GAS))) + print("paused for %.1fs: no gas burned, no energy made" % elapsed) + + # --- D. resuming works again ---------------------------------- + if game_state.toggle_building_paused(burner_id): + return _fail("toggling a paused burner did not resume it") + stage = 3 + elapsed = 0.0 + return false + 3: + if elapsed < 1.5: + return false + if _stock(GameState.RESOURCE_ENERGY) <= energy_at_pause: + return _fail("a resumed burner made no further energy") + print("resumed: energy %d -> %d" \ + % [energy_at_pause, _stock(GameState.RESOURCE_ENERGY)]) + + # --- E. the icon reports the action, not the state ------------ + var building: Node = _find_building_node() + if building == null: + return _fail("no Building node was spawned for the burner") + var running_icon: int = controller._building_action_icon(building) + game_state.set_building_paused(burner_id, true) + var paused_icon: int = controller._building_action_icon(building) + game_state.set_building_paused(burner_id, false) + if running_icon != IconButton.Icon.PAUSE: + return _fail("a running burner's button does not show the pause glyph") + if paused_icon != IconButton.Icon.RESUME: + return _fail("a paused burner's button does not show the resume glyph") + print("button shows pause while running and resume while stopped") + + print("TEST_GAS_BURNER_PAUSE: PASS") + quit(0) + return true + return false + + +func _find_building_node() -> Node: + for node in get_nodes_in_group("buildings"): + if is_instance_valid(node) and int(node.building_id) == burner_id: + return node + return null + + +func _fail(message: String) -> bool: + print("TEST_GAS_BURNER_PAUSE: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_pickup_feedback.gd b/tests/test_pickup_feedback.gd new file mode 100644 index 0000000..053acb8 --- /dev/null +++ b/tests/test_pickup_feedback.gd @@ -0,0 +1,140 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_pickup_feedback.gd +# Forgejo #79: collecting used to be silent - a rock vanished and a number in +# the corner of the screen changed. Which number, and by how much, was left +# for the player to notice. A "+N" in the resource's own colour now rises +# where the pickup happened. +# +# Covers: +# A. collecting ore raises a marker at the pickup point, carrying the right +# resource and amount +# B. it is built from the resource catalog's own glyph, so a material added +# later cannot end up with one mark in the HUD and another in the world +# C. it clears itself up rather than accumulating one node per pickup +# D. a pickup that is REFUSED - a full hold - raises nothing, so the marker +# never claims something the player did not get + +var game_state: Node +var main: Node +var fleet: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + main = load("res://Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + + +const PICKUP_VFX = preload("res://ResourcePickupVFX.gd") + + +func _markers() -> Array: + var found: Array = [] + for node in root.get_children(): + if node.get_script() == PICKUP_VFX: + found.append(node) + return found + + +func _drop_rock_on_fleet() -> Node2D: + var rock: Node2D = load("res://Rock.tscn").instantiate() + rock.build_shape(7) + rock.position = fleet.ships[0].global_position + main.add_child(rock) + return rock + + +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) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("rocks"): + if is_instance_valid(hazard): + hazard.free() + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0) + if not _markers().is_empty(): + return _fail("a pickup marker existed before anything was collected") + _drop_rock_on_fleet() + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.4: + return false + # --- A. one marker, right resource, right amount -------------- + var markers: Array = _markers() + if markers.size() != 1: + return _fail("collecting one rock raised %d markers" % markers.size()) + var marker: Node2D = markers[0] + if game_state.fleet_resource(GameState.RESOURCE_ORE) <= 0: + return _fail("the rock was not actually collected, so the marker is reporting a pickup that did not happen") + if marker.global_position.distance_to(fleet.ships[0].global_position) > 120.0: + return _fail("the marker appeared %.0f away from where the pickup happened" \ + % marker.global_position.distance_to(fleet.ships[0].global_position)) + + # --- B. built from the catalog's own glyph -------------------- + var definitions: Script = load("res://ResourceDefinitions.gd") + var ore: Resource = definitions.get_definition(GameState.RESOURCE_ORE) + if not marker.colour.is_equal_approx(ore.color): + return _fail("the marker is %s, but ore is %s" % [marker.colour, ore.color]) + var glyph_scripts: Array = [] + for child in marker.get_children(): + glyph_scripts.append(child.get_script()) + var probe: Node = ore.glyph_scene.instantiate() + var expected: Script = probe.get_script() + probe.free() + if expected not in glyph_scripts: + return _fail("the marker does not use the ore glyph the resource catalog defines") + print("collecting ore raised one marker at the pickup point, in ore's own colour and glyph") + stage = 2 + elapsed = 0.0 + return false + 2: + # --- C. it cleans itself up ----------------------------------- + if elapsed < 1.5: + return false + if not _markers().is_empty(): + return _fail("%d marker(s) still on screen 1.5s later - they accumulate" % _markers().size()) + print("marker cleared itself up") + + # --- D. a refused pickup says nothing ------------------------- + # Hold filled to capacity, so collect_cargo takes nothing. + var capacity: int = game_state.fleet_inventory.total_capacity + if capacity < 0: + return _fail("the fleet hold has no capacity limit, so a refused pickup cannot be staged") + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, capacity) + _drop_rock_on_fleet() + stage = 3 + elapsed = 0.0 + return false + 3: + if elapsed < 0.4: + return false + if not _markers().is_empty(): + return _fail("a full hold refused the rock but a marker claimed it was collected") + print("a pickup refused by a full hold raises no marker") + + print("TEST_PICKUP_FEEDBACK: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_PICKUP_FEEDBACK: FAIL (%s)" % message) + quit(1) + return true