diff --git a/ArtilleryEnemyDefinition.tres b/ArtilleryEnemyDefinition.tres index f47c597..938e573 100644 --- a/ArtilleryEnemyDefinition.tres +++ b/ArtilleryEnemyDefinition.tres @@ -11,6 +11,8 @@ max_hp = 8 speed = 60.0 acceleration = 120.0 rotation_speed = 3.0 -hull_points = PackedVector2Array(0, -26, 18.4, -18.4, 26, 0, 18.4, 18.4, 0, 26, -18.4, 18.4, -26, 0, -18.4, -18.4) +hull_points = PackedVector2Array(0, -30, 14, -22, 16, -6, 26, 2, 22, 18, 10, 26, -10, 26, -22, 18, -26, 2, -16, -6, -14, -22) glow_color = Color(1.0, 0.5, 0.2, 1) +detail_lines = PackedVector2Array(-14, -20, 14, -20, -12, 6, -20, 16, 12, 6, 20, 16, 0, -22, 0, 6) +detail_rings = PackedVector2Array(0, -20, 8, 0) component_scenes = Array[PackedScene]([ExtResource("2")]) diff --git a/ArtilleryShell.gd b/ArtilleryShell.gd index 2c8b1f9..ce7e46e 100644 --- a/ArtilleryShell.gd +++ b/ArtilleryShell.gd @@ -1,11 +1,11 @@ class_name ArtilleryShell extends Node2D -# Self-contained telegraph -> travel -> explode sequence for the artillery -# enemy's attack (see EnemyArtilleryComponent). Lives independently of the +# Self-contained travel -> explode sequence for the artillery enemy's attack +# (see EnemyArtilleryComponent). Lives independently of the # enemy that fired it once launched - added to the tree root, exactly like # EnemyProjectile/Projectile - so the shooter dying mid-flight doesn't -# cancel a shot already telegraphed to the player. +# cancel a shell already in the air. # # Shield interaction: a shield charge absorbs this hit entirely, same # all-or-nothing semantics as every other damage source in this game @@ -14,20 +14,26 @@ extends Node2D signal finished -@export var telegraph_time: float = 1.8 +# The tower fires as soon as it decides to; the warning is the shell you can +# SEE coming and the cross marking where it will land. There used to be a +# 1.8s charge-up before anything left the barrel, which arrived long before +# the threat was legible and mostly read as the tower being stuck. @export var shell_speed: float = 180.0 @export var aoe_radius: float = 220.0 @export var damage: int = 3 @export var color: Color = Color(1.0, 0.5, 0.2) -enum Phase { TELEGRAPHING, TRAVELING, EXPLODING } +enum Phase { TRAVELING, EXPLODING } const EXPLOSION_DURATION: float = 0.8 +# The impact cross. Small on purpose: it says WHERE, and the shell in flight +# already says that something is coming. A full blast-radius ring here read +# as an attack that had already happened. +const IMPACT_CROSS_RADIUS: float = 20.0 -var phase: int = Phase.TELEGRAPHING +var phase: int = Phase.TRAVELING var target: Vector2 = Vector2.ZERO var shell_pos: Vector2 = Vector2.ZERO -var telegraph_left: float = 0.0 var explosion_time: float = 0.0 var trail: PackedVector2Array = PackedVector2Array() @@ -39,20 +45,14 @@ func _ready() -> void: func launch(from: Vector2, at: Vector2) -> void: target = at shell_pos = from - telegraph_left = telegraph_time - phase = Phase.TELEGRAPHING - position = target + phase = Phase.TRAVELING + trail = PackedVector2Array([shell_pos]) + position = shell_pos queue_redraw() func _process(delta: float) -> void: match phase: - Phase.TELEGRAPHING: - telegraph_left -= delta - if telegraph_left <= 0.0: - phase = Phase.TRAVELING - trail = PackedVector2Array([shell_pos]) - queue_redraw() Phase.TRAVELING: var to_target: Vector2 = target - shell_pos var step: float = shell_speed * delta @@ -85,15 +85,10 @@ func _explode() -> void: func _draw() -> void: match phase: - Phase.TELEGRAPHING: - var progress: float = clampf(1.0 - telegraph_left / maxf(telegraph_time, 0.001), 0.0, 1.0) - # Growing ring plus a filling countdown arc: the warning reads - # clearly without needing a number on screen. - var radius: float = lerpf(24.0, aoe_radius, progress) - _draw_ring(radius, 0.7) - draw_arc(Vector2.ZERO, aoe_radius + 14.0, -PI / 2.0, -PI / 2.0 + TAU * progress, 48, - Color(color, 0.85), 8.0, true) Phase.TRAVELING: + # Where it will land, drawn on the ground. Local coordinates, + # because this node rides along with the shell while it flies. + _draw_impact_cross(target - shell_pos) for i in range(1, trail.size()): var alpha: float = float(i) / trail.size() draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true) @@ -106,6 +101,18 @@ func _draw() -> void: _draw_ring(eradius, 1.0 - eprogress) +# A small cross where the shell will land, plus a dot at the exact point. +# Deliberately not a filled circle the size of the blast: the player needs to +# read "move off this spot" at a glance, and a large translucent area was +# easy to mistake for the explosion itself. +func _draw_impact_cross(at: Vector2) -> void: + var r: float = IMPACT_CROSS_RADIUS + for offset in [Vector2(r, r), Vector2(r, -r)]: + draw_line(at - offset, at + offset, Color(color, 0.35), 7.0, true) + draw_line(at - offset, at + offset, Color(1.0, 0.92, 0.86, 0.85), 2.5, true) + draw_arc(at, r * 0.32, 0.0, TAU, 20, Color(color, 0.9), 2.5, true) + + func _draw_ring(radius: float, alpha: float) -> void: draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true) draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true) diff --git a/BuildController.gd b/BuildController.gd index 3fd7f04..f976de6 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -62,6 +62,8 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") @onready var hangar_button: IconButton = get_node("../UI/HangarButton") @onready var hangar_menu: Control = get_node("../UI/HangarMenu") @onready var hangar_status: Label = get_node("../UI/HangarMenu/StatusLabel") +@onready var hangar_available_title: Label = get_node("../UI/HangarMenu/AvailableTitle") +@onready var hangar_fleet_title: Label = get_node("../UI/HangarMenu/FleetTitle") @onready var hangar_close_button: IconButton = get_node("../UI/HangarMenu/CloseButton") @onready var hangar_available_slots: Array = [ get_node("../UI/HangarMenu/AvailableInterceptor"), @@ -247,6 +249,7 @@ func _seat_hud_columns() -> void: func _ready() -> void: _seat_hud_columns() + _layout_hangar() build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) _configure_build_menu_entries() @@ -1223,6 +1226,75 @@ func _hangar_types() -> Array: return types +# How many vessel types sit side by side in the hangar. Six in a single row +# gave each one about 100px while its two-line label needs 155, so the names +# ran into each other - "InterceptSheld VessGas Bomber". Three columns give +# each cell room, and the second row costs nothing: the lower half of the +# menu was empty. +const HANGAR_COLUMNS: int = 3 + + +# Arranges both hangar sections as grids sized from the space actually +# available, rather than from the single row the scene authored. A seventh +# vessel type now wraps instead of squeezing the other six. +func _layout_hangar() -> void: + var count: int = hangar_available_slots.size() + if count == 0: + return + var width: float = hangar_menu.size.x + var columns: int = mini(HANGAR_COLUMNS, count) + var rows: int = int(ceil(float(count) / float(columns))) + + # The band between the first section's heading and the status line under + # both of them. Derived, so moving either in the scene moves the grids. + var top: float = hangar_available_title.position.y + hangar_available_title.size.y + 8.0 + var bottom: float = hangar_status.position.y - 12.0 + var title_height: float = hangar_fleet_title.size.y + var section_height: float = (bottom - top - title_height - 16.0) * 0.5 + if section_height <= 0.0: + return + + var cell_width: float = width / float(columns) + var cell_height: float = section_height / float(rows) + # Squarer than the authored 75x120: a grid cell is wider than it is tall + # here, so height is what constrains the icon. + var slot_size: float = minf(cell_height * 0.58, cell_width * 0.42) + var label_height: float = maxf(cell_height - slot_size - 6.0, 1.0) + + _layout_hangar_section(hangar_available_slots, hangar_available_labels, + top, columns, cell_width, cell_height, slot_size, label_height, width) + # AFTER the first section's last row, not before it. Placing the heading + # by subtracting from the second section's top put it back inside the + # first one, straight over its bottom row. + var available_bottom: float = top + section_height + hangar_fleet_title.position.y = available_bottom + 4.0 + var fleet_top: float = available_bottom + title_height + 8.0 + _layout_hangar_section(hangar_fleet_slots, hangar_fleet_labels, + fleet_top, columns, cell_width, cell_height, slot_size, label_height, width) + + +func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: int, + cell_width: float, cell_height: float, slot_size: float, + label_height: float, width: float) -> void: + for i in range(slots.size()): + var column: int = i % columns + var row: int = i / columns + # Control.position is in PARENT space, whatever the node's anchors + # are - the authored offsets being negative (anchored to the menu's + # centre) does not make position centre-relative too. + var cell_x: float = float(column) * cell_width + var cell_y: float = top + float(row) * cell_height + var slot: Control = slots[i] + slot.position = Vector2(cell_x + (cell_width - slot_size) * 0.5, cell_y) + slot.size = Vector2(slot_size, slot_size) + var label: Label = labels[i] + label.position = Vector2(cell_x, cell_y + slot_size + 4.0) + label.size = Vector2(cell_width, label_height) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_TOP + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + + func _refresh_hangar() -> void: var types: Array = _hangar_types() for i in range(types.size()): diff --git a/Enemy.gd b/Enemy.gd index 5d5e796..16335b6 100644 --- a/Enemy.gd +++ b/Enemy.gd @@ -60,9 +60,13 @@ func take_damage(amount: int) -> void: func _draw() -> void: - var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([ - Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), - ]) - var glow: Color = definition.glow_color if definition != null else Color(1.0, 0.15, 0.2) + # Drawn through EnemyDefinitions.draw_definition, the same routine the + # map editor's markers and palette use, so a definition cannot end up + # looking one way where it is placed and another where it is fought. + if definition == null: + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), + ]), Color(1.0, 0.15, 0.2), 1.0) + return # Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw. - NeonDraw.draw_glowing_polygon(self, points, glow, 1.0) + ENEMY_DEFINITIONS.draw_definition(self, definition, definition.glow_color) diff --git a/EnemyArtilleryComponent.gd b/EnemyArtilleryComponent.gd index 9832905..cdf98ce 100644 --- a/EnemyArtilleryComponent.gd +++ b/EnemyArtilleryComponent.gd @@ -3,7 +3,7 @@ extends Node # Stationary siege piece: drifts slowly toward the fleet (deliberately no # pursuit component - closing distance is incidental, not its purpose) and -# fires a slow, heavily telegraphed AOE shell on a long cooldown via a +# fires a slow, clearly visible AOE shell on a long cooldown via a # standalone ArtilleryShell, so an already-launched shot survives this # enemy dying mid-flight, same as EnemyProjectile/Projectile already do. @@ -11,7 +11,6 @@ var capability_id: String = "artillery" @export var fire_cooldown: float = 6.0 @export var fire_range: float = 900.0 -@export var telegraph_time: float = 1.8 @export var shell_speed: float = 180.0 @export var aoe_radius: float = 220.0 @export var damage: int = 3 @@ -38,7 +37,6 @@ func tick(delta: float) -> void: return var shell: Node2D = shell_scene.instantiate() - shell.telegraph_time = telegraph_time shell.shell_speed = shell_speed shell.aoe_radius = aoe_radius shell.damage = damage diff --git a/EnemyDefinition.gd b/EnemyDefinition.gd index bb0927b..e214181 100644 --- a/EnemyDefinition.gd +++ b/EnemyDefinition.gd @@ -16,4 +16,15 @@ extends Resource Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), ]) @export var glow_color: Color = Color(1.0, 0.15, 0.2) +# Interior detail drawn on top of the hull, as consecutive PAIRS of points +# (a, b, c, d -> segments a-b and c-d). Every enemy used to be a single flat +# outline, which left the artillery and the turret spawner as the same +# octagon at two sizes - the silhouette carried no information about what a +# thing did. Kept as DATA rather than a per-type draw branch, for the same +# reason hull_points is: adding an enemy must not mean editing Enemy.gd. +@export var detail_lines: PackedVector2Array = PackedVector2Array() +# Rings, as triples of (centre x, centre y, radius). Sensor domes, muzzles +# and bay openings all read better as circles than as polygon chords. +@export var detail_rings: PackedVector2Array = PackedVector2Array() +@export var core_color: Color = Color(0.06, 0.02, 0.03) @export var component_scenes: Array[PackedScene] = [] diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd index fb5fd15..20cf8b0 100644 --- a/EnemyDefinitions.gd +++ b/EnemyDefinitions.gd @@ -12,6 +12,31 @@ const DEFINITIONS: Dictionary = { } +# The single place that knows what an enemy LOOKS like. Three things draw +# enemies - the enemy itself, the map editor's markers, and the editor's +# palette buttons - and each used to reimplement it from hull_points alone. +# Adding interior detail to the definitions therefore showed up in the game +# and nowhere else, so an editor marker no longer matched the ship it placed. +# +# `at` and `scale` exist because the palette draws into a small button +# centred on itself, while the other two draw around their own origin at +# full size. +static func draw_definition(item: CanvasItem, definition: Resource, glow: Color, + scale: float = 1.0, at: Vector2 = Vector2.ZERO) -> void: + if definition == null: + return + var core: Color = definition.core_color + item.draw_set_transform(at, 0.0, Vector2(scale, scale)) + NeonDraw.draw_glowing_polygon(item, definition.hull_points, glow, 1.0, core) + var lines: PackedVector2Array = definition.detail_lines + for i in range(0, lines.size() - 1, 2): + NeonDraw.draw_glowing_line(item, lines[i], lines[i + 1], glow, 0.85, 0.55, core) + var rings: PackedVector2Array = definition.detail_rings + for i in range(0, rings.size() - 1, 2): + NeonDraw.draw_glowing_circle(item, rings[i], rings[i + 1].x, glow, 0.85, core) + item.draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + + static func get_definition(enemy_id: String) -> Resource: return DEFINITIONS.get(enemy_id, DEFINITIONS[DEFAULT_ID]) diff --git a/EnemyMarker.gd b/EnemyMarker.gd new file mode 100644 index 0000000..de38c1f --- /dev/null +++ b/EnemyMarker.gd @@ -0,0 +1,19 @@ +class_name EnemyMarker +extends Node2D + +# An enemy as the map editor shows it. Exists so the editor can draw a +# spawn through EnemyDefinitions.draw_definition - the same routine the +# real enemy uses - instead of reimplementing it from hull_points, which is +# how the two drifted apart in the first place. +# +# glow_color is separate from the definition's own so the editor can tint a +# selected marker without touching the definition. + +const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") + +var definition: Resource +var glow_color: Color = Color(1.0, 0.15, 0.2) + + +func _draw() -> void: + ENEMY_DEFINITIONS.draw_definition(self, definition, glow_color) diff --git a/EnemyMarker.gd.uid b/EnemyMarker.gd.uid new file mode 100644 index 0000000..380eed1 --- /dev/null +++ b/EnemyMarker.gd.uid @@ -0,0 +1 @@ +uid://c6ldnity1nxq7 diff --git a/Fleet.gd b/Fleet.gd index dae7f48..fda2244 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -96,6 +96,9 @@ func _sync_composition_state() -> void: # Composition changes are authoritative; scene teardown is not. Keeping # this out of SpeedFader prevents an outgoing scene's empty fleet from # resetting a boost while the same vessels are being restored on a surface. + # Each distorder vessel past the first widens the speed ceiling, so the + # COUNT matters, not merely whether one is present. + GameState.set_distorder_vessel_count(count_ships_with_capability("distorder")) if not has_capability("distorder"): GameState.set_fleet_speed_multiplier(1.0) @@ -118,6 +121,14 @@ func has_capability(capability: String) -> bool: return first_ship_with_capability(capability) != null +func count_ships_with_capability(capability: String) -> int: + var total: int = 0 + for ship in ships: + if is_instance_valid(ship) and ship.has_capability(capability): + total += 1 + return total + + func first_ship_with_capability(capability: String) -> Node2D: for ship in ships: if is_instance_valid(ship) and ship.has_capability(capability): diff --git a/GameState.gd b/GameState.gd index 5d514bf..1e46592 100644 --- a/GameState.gd +++ b/GameState.gd @@ -31,7 +31,16 @@ 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 +# 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 +# How far the fader can actually go right now, given the fleet's current +# composition. Pushed by Fleet whenever that composition changes, the same +# direction as every other fleet-derived figure here - GameState does not +# reach into the scene tree to count ships. +var max_fleet_speed_multiplier: float = MAX_FLEET_SPEED_MULTIPLIER var fleet_speed_multiplier: float = 1.0 # #62: energy cost of sustaining a boost, scaled by how far above 1.0 the @@ -47,7 +56,20 @@ var boost_depleted_notice: bool = false func set_fleet_speed_multiplier(value: float) -> void: - fleet_speed_multiplier = clampf(value, 1.0, MAX_FLEET_SPEED_MULTIPLIER) + fleet_speed_multiplier = clampf(value, 1.0, max_fleet_speed_multiplier) + + +# Every distorder vessel past the first widens the fleet's speed ceiling by +# 1.0, so stacking them is worth doing rather than the second one being dead +# weight. Zero vessels returns the ceiling to its one-vessel value; the +# multiplier itself is reset separately by Fleet when the last one is lost. +# +# 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. +func set_distorder_vessel_count(count: int) -> void: + max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER + maxf(float(count) - 1.0, 0.0) + set_fleet_speed_multiplier(fleet_speed_multiplier) # #63: currency gated behind conquering a research_lab planet - required by @@ -1335,7 +1357,10 @@ func _advance_distorder_drain(delta: float) -> void: # carry over into a LATER, unrelated boost. _distorder_drain_progress = 0.0 return - var rate: float = (fleet_speed_multiplier - 1.0) / (MAX_FLEET_SPEED_MULTIPLIER - 1.0) \ + # Normalized against the CURRENT ceiling, so "full boost" always costs + # ENERGY_DRAIN_PER_SECOND however many distorder vessels widened it - + # a bigger fleet buys more speed, not cheaper speed. + var rate: float = (fleet_speed_multiplier - 1.0) / maxf(max_fleet_speed_multiplier - 1.0, 0.001) \ * ENERGY_DRAIN_PER_SECOND _distorder_drain_progress += rate * delta var whole: int = int(floor(_distorder_drain_progress)) @@ -1365,7 +1390,8 @@ var planet_capture: Dictionary = {} signal planet_conquered(planet_id: String) -func register_capture_points(planet_key: String, total: int, definition_id: String = "") -> void: +func register_capture_points(planet_key: String, total: int, definition_id: String = "", + reward_override: int = 0) -> void: # Idempotent: landing again on a planet must not wipe points already # captured on a previous visit. if planet_capture.has(planet_key): @@ -1381,6 +1407,11 @@ func register_capture_points(planet_key: String, total: int, definition_id: Stri # the capture system. var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id) var point_reward: int = 0 if definition == null else int(definition.conquest_research_points) + # A map may be worth more than its planet type's flat rate - a harder + # lab should pay better, and only the authored layout knows how hard it + # is. Zero means the setup did not express an opinion. + if reward_override > 0: + point_reward = reward_override planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0, "research_point_reward": point_reward, "research_awarded": false} diff --git a/Main.tscn b/Main.tscn index 536b8f5..9fa7529 100644 --- a/Main.tscn +++ b/Main.tscn @@ -1903,6 +1903,9 @@ offset_right = -200 offset_bottom = 166 text = "Transfer cargo" horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 +theme_override_font_sizes/font_size = 44 [node name="StatusLabel" type="Label" parent="UI/TransferMenu"] mouse_filter = 2 diff --git a/MainMenu.gd b/MainMenu.gd index bb44be4..fb07dce 100644 --- a/MainMenu.gd +++ b/MainMenu.gd @@ -3,8 +3,10 @@ extends Node2D # The menu stacks whatever entries are actually available, rather than # trusting the positions authored in the scene. # -# Two of the four entries only exist when the game is run from Godot, so a -# shipped build shows a two-entry menu and an editor run shows four. Fixed +# Two of the three entries only exist when the game is run from Godot, so a +# shipped build shows one entry and an editor run shows three. There is no +# Quit: a web build has nothing to quit TO, and on Android the system back +# gesture already does it. Fixed # offsets would leave holes where the hidden entries used to be - the same # failure the HUD's button columns had, where a literal y meant "below the # thing above me" and stopped being true when that thing moved. Here the @@ -28,8 +30,6 @@ const STATUS_HEIGHT: float = 70.0 @onready var editor_label: Label = $UI/EditorLabel @onready var dev_button: IconButton = $UI/DevModeButton @onready var dev_label: Label = $UI/DevModeLabel -@onready var quit_button: IconButton = $UI/QuitButton -@onready var quit_label: Label = $UI/QuitLabel @onready var status_label: Label = $UI/StatusLabel @@ -39,8 +39,6 @@ func _ready() -> void: editor_button.enabled = true dev_button.pressed.connect(_on_dev_mode_pressed) dev_button.enabled = true - quit_button.pressed.connect(_on_quit_pressed) - quit_button.visible = not OS.has_feature("web") # Dev mode and the map editor are development tools, not features of the # game: they are for building and play-testing it, and a player who @@ -65,7 +63,7 @@ func is_developer_build() -> bool: func _layout_stack() -> void: var rows: Array = [] for row in [[play_button, play_label], [editor_button, editor_label], - [dev_button, dev_label], [quit_button, quit_label]]: + [dev_button, dev_label]]: if row[0].visible: rows.append(row) if rows.is_empty(): @@ -93,7 +91,17 @@ func _layout_stack() -> void: func _on_play_pressed() -> void: GameState.reset_new_game() - get_tree().change_scene_to_file("res://Main.tscn") + _start("res://Main.tscn") + + +# change_scene_to_file() returns an error and otherwise fails SILENTLY - the +# menu simply stays up, which is indistinguishable from the button not having +# been pressed at all. Reporting it turns "nothing happens" into something a +# player can read back. +func _start(scene_path: String) -> void: + var result: int = get_tree().change_scene_to_file(scene_path) + if result != OK: + status_label.text = "Could not start (%s, error %d)" % [scene_path.get_file(), result] func _on_dev_mode_pressed() -> void: @@ -110,10 +118,8 @@ func _on_editor_pressed() -> void: status_label.text = "Map editor only runs from Godot" return if editor_button.enabled: - get_tree().change_scene_to_file("res://PlanetSetupEditor.tscn") + _start("res://PlanetSetupEditor.tscn") return status_label.text = "Map editor coming soon" -func _on_quit_pressed() -> void: - get_tree().quit() diff --git a/MainMenu.tscn b/MainMenu.tscn index 20899a8..14e9ce3 100644 --- a/MainMenu.tscn +++ b/MainMenu.tscn @@ -1,18 +1,7 @@ -[gd_scene load_steps=4 format=3] +[gd_scene load_steps=3 format=3] [ext_resource type="Script" path="res://MainMenu.gd" id="1"] [ext_resource type="Script" path="res://IconButton.gd" id="2"] -[ext_resource type="Shader" path="res://starfield.gdshader" id="3"] - -[sub_resource type="ShaderMaterial" id="1"] -shader = ExtResource("3") -shader_parameter/camera_cell = Vector2(0, 0) -shader_parameter/camera_frac = Vector2(0, 0) -shader_parameter/camera_zoom = 1.0 -shader_parameter/viewport_size = Vector2(720, 1280) -shader_parameter/cell_size = 60.0 -shader_parameter/bg_color = Color(0.02, 0.02, 0.08, 1) - [node name="MainMenu" type="Node2D"] script = ExtResource("1") @@ -20,7 +9,7 @@ script = ExtResource("1") layer = -10 [node name="Starfield" type="ColorRect" parent="BackgroundLayer"] -material = SubResource("1") +color = Color(0, 0, 0, 1) mouse_filter = 2 anchors_preset = 15 anchor_right = 1.0 @@ -41,7 +30,7 @@ offset_top = -560 offset_bottom = -460 theme_override_font_sizes/font_size = 60 horizontal_alignment = 1 -text = "SPACESHIP TEST" +text = "SPACESHIP" [node name="PlayButton" type="Control" parent="UI"] anchor_left = 0.5 @@ -137,30 +126,3 @@ theme_override_font_sizes/font_size = 26 horizontal_alignment = 1 autowrap_mode = 2 text = "" - -[node name="QuitButton" type="Control" parent="UI"] -anchor_left = 0.5 -anchor_right = 0.5 -anchor_top = 0.5 -anchor_bottom = 0.5 -offset_left = -64 -offset_right = 64 -offset_top = 320 -offset_bottom = 448 -script = ExtResource("2") -icon = 1 -glow_color = Color(0.9, 0.35, 0.35, 1) - -[node name="QuitLabel" type="Label" parent="UI"] -mouse_filter = 2 -anchor_left = 0.5 -anchor_right = 0.5 -anchor_top = 0.5 -anchor_bottom = 0.5 -offset_left = -160 -offset_right = 160 -offset_top = 460 -offset_bottom = 526 -theme_override_font_sizes/font_size = 32 -horizontal_alignment = 1 -text = "Quit" diff --git a/MothershipEnemyDefinition.tres b/MothershipEnemyDefinition.tres index f031799..d8e3603 100644 --- a/MothershipEnemyDefinition.tres +++ b/MothershipEnemyDefinition.tres @@ -16,4 +16,6 @@ acceleration = 45.0 rotation_speed = 1.5 hull_points = PackedVector2Array(0, -50, 35, -25, 48, 10, 20, 48, -20, 48, -48, 10, -35, -25) glow_color = Color(0.55, 0.05, 0.85, 1) +detail_lines = PackedVector2Array(0, -44, 0, 40, -28, -18, 28, -18, -34, 4, 34, 4, -20, 28, 20, 28, -12, -40, 12, -40) +detail_rings = PackedVector2Array(0, -28, 9, 0, 0, 18, 6, 0) component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3"), ExtResource("4"), ExtResource("5")]) diff --git a/PaletteEntryButton.gd b/PaletteEntryButton.gd index ea479a2..c841907 100644 --- a/PaletteEntryButton.gd +++ b/PaletteEntryButton.gd @@ -9,6 +9,8 @@ extends Control signal pressed @export var is_capture_point: bool = false +const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") + @export var enemy_definition: Resource @export var selected: bool = false: set = set_selected @@ -49,8 +51,8 @@ func _draw() -> void: points.append(center + Vector2(cos(angle), sin(angle)) * (size.x * 0.28)) NeonDraw.draw_glowing_polygon(self, points, RING_COLOR, 0.8) elif enemy_definition != null: - var scale_factor: float = (size.x * 0.5) / 28.0 - var points: PackedVector2Array = PackedVector2Array() - for p in enemy_definition.hull_points: - points.append(center + p * scale_factor * 0.6) - NeonDraw.draw_glowing_polygon(self, points, enemy_definition.glow_color, 0.9) + # Same routine as the marker and the enemy, so the button previews + # what will actually be placed. + var scale_factor: float = (size.x * 0.5) / 28.0 * 0.6 + ENEMY_DEFINITIONS.draw_definition(self, enemy_definition, + enemy_definition.glow_color, scale_factor, center) diff --git a/PlanetSetup.gd b/PlanetSetup.gd index fd81af1..ca5a6a6 100644 --- a/PlanetSetup.gd +++ b/PlanetSetup.gd @@ -14,3 +14,10 @@ extends Resource # Surface-local positions (the fleet lands at Vector2.ZERO). @export var capture_points: PackedVector2Array = PackedVector2Array() @export var enemy_spawns: Array[Resource] = [] +# Research points awarded for conquering this map, set in the map editor. +# A harder lab is worth more, and how hard a lab IS depends on the layout +# authored here - how many capture points, how many and what kind of +# defenders - which the planet type cannot know. 0 means "use whatever the +# planet type awards", so every setup written before this existed keeps its +# old value rather than silently dropping to nothing. +@export var research_point_reward: int = 0 diff --git a/PlanetSetupEditor.gd b/PlanetSetupEditor.gd index 7d0f9fd..0f31e15 100644 --- a/PlanetSetupEditor.gd +++ b/PlanetSetupEditor.gd @@ -17,6 +17,10 @@ extends Node2D # load-order notes) - a bare class-name reference needs the global # class_name cache already warm, which an explicit script path never does. const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") +# By path, not by its `EnemyMarker` global name: the class_name cache is not +# guaranteed warm before an import pass has run, the same hazard PlanetSetups +# and PlanetSurfaceController already work around. +const ENEMY_MARKER = preload("res://EnemyMarker.gd") const PLANET_SETUP_SCRIPT = preload("res://PlanetSetup.gd") const PLANET_SETUP_SPAWN_SCRIPT = preload("res://PlanetSetupSpawn.gd") const PALETTE_ENTRY_BUTTON_SCRIPT = preload("res://PaletteEntryButton.gd") @@ -43,6 +47,8 @@ const PALETTE_STEP: float = 92.0 @onready var back_button: IconButton = $UI/BackButton @onready var profile_button: IconButton = $UI/ProfileButton @onready var profile_value_label: Label = $UI/ProfileValueLabel +@onready var reward_button: IconButton = $UI/RewardButton +@onready var reward_value_label: Label = $UI/RewardValueLabel @onready var name_input: LineEdit = $UI/NameInput @onready var new_button: IconButton = $UI/NewButton @onready var save_button: IconButton = $UI/SaveButton @@ -83,6 +89,7 @@ func _ready() -> void: _profile_ids = PlanetSurfaceProfiles.PROFILES.keys() back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://MainMenu.tscn")) profile_button.pressed.connect(_on_profile_pressed) + reward_button.pressed.connect(_on_reward_pressed) new_button.pressed.connect(_on_new_pressed) save_button.pressed.connect(_on_save_pressed) load_button.pressed.connect(_on_load_pressed) @@ -224,6 +231,7 @@ func _refresh_background() -> void: material.set_shader_parameter("highlight_color", profile.highlight_color) if profile_value_label != null: profile_value_label.text = current_setup.surface_profile_id.to_upper() + _refresh_reward_label() func _rebuild_markers() -> void: @@ -256,9 +264,14 @@ func _add_enemy_marker(index: int, spawn: Resource) -> void: var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id) var is_selected: bool = selected_item.get("kind", "") == "enemy" and selected_item.get("index", -1) == index var color: Color = Color(1.0, 0.95, 0.9) if is_selected else definition.glow_color - var marker: NeonPiece = NeonPiece.create(item_host, spawn.position, color) - marker.add_polygon(definition.hull_points, 0.9) - marker.queue_redraw() + # Drawn through the same routine the enemy itself uses, rather than from + # hull_points alone - a marker that does not look like what it places is + # worse than no picture at all. + var marker: Node2D = ENEMY_MARKER.new() + marker.position = spawn.position + marker.definition = definition + marker.glow_color = color + item_host.add_child(marker) # --- palette --------------------------------------------------------------- @@ -295,6 +308,27 @@ func _refresh_palette_selection() -> void: # --- UI handlers ------------------------------------------------------- +# What conquering this map is worth in research points. 0 keeps the planet +# type's own flat rate, so a setup that says nothing behaves as it always +# did; the rest let an author price a hard lab above an easy one. +const REWARD_OPTIONS: Array[int] = [0, 1, 2, 3, 5, 8] + + +func _on_reward_pressed() -> void: + if current_setup == null: + return + var index: int = REWARD_OPTIONS.find(int(current_setup.research_point_reward)) + current_setup.research_point_reward = REWARD_OPTIONS[(index + 1) % REWARD_OPTIONS.size()] + _refresh_reward_label() + + +func _refresh_reward_label() -> void: + if reward_value_label == null or current_setup == null: + return + var reward: int = int(current_setup.research_point_reward) + reward_value_label.text = "DEFAULT RP" if reward <= 0 else "%d RESEARCH PT" % reward + + func _on_profile_pressed() -> void: if _profile_ids.is_empty(): return diff --git a/PlanetSetupEditor.tscn b/PlanetSetupEditor.tscn index 0d41a64..e726a03 100644 --- a/PlanetSetupEditor.tscn +++ b/PlanetSetupEditor.tscn @@ -170,6 +170,25 @@ theme_override_font_sizes/font_size = 20 autowrap_mode = 2 text = "" +[node name="RewardButton" type="Control" parent="UI"] +offset_left = 350 +offset_top = 20 +offset_right = 422 +offset_bottom = 92 +script = ExtResource("4") +icon = 29 +glow_color = Color(0.55, 0.9, 0.65, 1) + +[node name="RewardValueLabel" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 430 +offset_top = 36 +offset_right = 690 +offset_bottom = 76 +theme_override_font_sizes/font_size = 26 +theme_override_colors/font_color = Color(0.55, 0.9, 0.65, 1) +text = "DEFAULT RP" + [node name="SelectedLabel" type="Label" parent="UI"] mouse_filter = 2 offset_left = 20 diff --git a/PlanetSetupHost.gd b/PlanetSetupHost.gd index 99e145e..7624851 100644 --- a/PlanetSetupHost.gd +++ b/PlanetSetupHost.gd @@ -20,7 +20,7 @@ func apply_setup(new_setup: Resource) -> void: return GameState.register_capture_points(GameState.planet_id, setup.capture_points.size(), - GameState.planet_definition_id) + GameState.planet_definition_id, int(setup.research_point_reward)) for i in range(setup.capture_points.size()): _add_capture_point(i, setup.capture_points[i]) for spawn in setup.enemy_spawns: diff --git a/ResearchLabSurfaceProfile.tres b/ResearchLabSurfaceProfile.tres index 98e83c8..d779e0f 100644 --- a/ResearchLabSurfaceProfile.tres +++ b/ResearchLabSurfaceProfile.tres @@ -1,12 +1,14 @@ -[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=2 format=3] +[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3] [ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"] +[ext_resource type="Shader" path="res://research_lab_surface.gdshader" id="2"] [resource] script = ExtResource("1") profile_id = "research_lab" -base_color = Color(0.13, 0.1, 0.1, 1) -crater_color = Color(0.05, 0.03, 0.03, 1) -highlight_color = Color(0.55, 0.15, 0.1, 1) +background_shader = ExtResource("2") +base_color = Color(0.17, 0.13, 0.13, 1) +crater_color = Color(0.045, 0.028, 0.03, 1) +highlight_color = Color(0.85, 0.24, 0.16, 1) caption_color = Color(0.9, 0.6, 0.55, 1) caption_outline_color = Color(0.05, 0.02, 0.02, 0.9) diff --git a/ResourceTransferList.gd b/ResourceTransferList.gd index 3424191..de22bf2 100644 --- a/ResourceTransferList.gd +++ b/ResourceTransferList.gd @@ -78,17 +78,13 @@ func _ready() -> void: increase.glow_color = colour increase.pressed.connect(_change_amount.bind(resource_type, 1)) add_child(increase) - var amount_caption := _make_label("AMOUNT", 18, Color(0.62, 0.78, 0.86)) - amount_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - add_child(amount_caption) - _rows[resource_type] = { "glyph": glyph, "name": name_label, "fleet_amount": fleet_amount, "local_amount": local_amount, "unload": unload, "unload_label": unload_label, "load": load, "load_label": load_label, "decrease": decrease, "amount_label": amount_label, - "increase": increase, "amount_caption": amount_caption, + "increase": increase, "amount_index": AMOUNT_OPTIONS.size() - 1, } @@ -183,39 +179,61 @@ func _layout() -> void: for resource_type in GameState.resource_types(): var row: Dictionary = _rows[resource_type] var y: float = HEADER_HEIGHT + float(row_index) * row_height - var glyph_size: float = minf(66.0, row_height * 0.23) - row.glyph.position = Vector2(size.x * 0.5 - glyph_size * 0.5, y + 10.0) + + # EVERY offset below is a fraction of row_height. It used to be a + # fixed pixel ladder - glyph at +10, buttons at +88, selector at + # +142, captions under that - which needed about 244px inside a row + # whose height is divided from the available space. At three + # resources that fit; the fourth cut the row to 180 and every row + # spilled 64px into the one below it, and the last one into the hint + # text under the list. Proportions cannot overflow: the bands always + # sum to the row. + var pad: float = row_height * 0.04 + var content: float = row_height - pad * 2.0 + var top: float = y + pad + # amounts, with the resource's glyph and name between them + var band_totals: float = content * 0.32 + # the arrows, and the amount selector between them + var band_controls: float = content * 0.48 + # the arrow captions + var band_captions: float = content * 0.20 + + row.fleet_amount.position = Vector2(0.0, top) + row.fleet_amount.size = Vector2(side_width, band_totals) + row.local_amount.position = Vector2(right_x, top) + row.local_amount.size = Vector2(side_width, band_totals) + + # Glyph and name sit at the TOP of their row rather than under its + # controls, so a row reads as one thing. Previously the name hung + # low enough to look like a heading for the row beneath it. + var glyph_size: float = minf(band_totals * 0.52, 44.0) + row.glyph.position = Vector2(size.x * 0.5 - glyph_size * 0.5, top) row.glyph.size = Vector2(glyph_size, glyph_size) - row.name.position = Vector2(side_width, y + 10.0 + glyph_size) - row.name.size = Vector2(centre_width, 44.0) + row.name.position = Vector2(side_width, top + glyph_size) + row.name.size = Vector2(centre_width, maxf(band_totals - glyph_size, 1.0)) - row.fleet_amount.position = Vector2(0.0, y + 10.0) - row.fleet_amount.size = Vector2(side_width, 70.0) - row.local_amount.position = Vector2(right_x, y + 10.0) - row.local_amount.size = Vector2(side_width, 70.0) - - var button_size: float = minf(132.0, row_height * 0.44) - var button_y: float = y + 88.0 + var controls_y: float = top + band_totals + var button_size: float = minf(band_controls, side_width * 0.5) + var button_y: float = controls_y + (band_controls - button_size) * 0.5 row.unload.position = Vector2(side_width * 0.5 - button_size * 0.5, button_y) row.unload.size = Vector2(button_size, button_size) row.load.position = Vector2(right_x + side_width * 0.5 - button_size * 0.5, button_y) row.load.size = Vector2(button_size, button_size) - var caption_y: float = button_y + button_size + 2.0 - row.unload_label.position = Vector2(0.0, caption_y) - row.unload_label.size = Vector2(side_width, 42.0) - row.load_label.position = Vector2(right_x, caption_y) - row.load_label.size = Vector2(side_width, 42.0) - var selector_size: float = minf(66.0, centre_width * 0.33) - var selector_y: float = y + 142.0 + var selector_size: float = minf(band_controls * 0.62, centre_width * 0.30) + var selector_y: float = controls_y + (band_controls - selector_size) * 0.5 row.decrease.position = Vector2(side_width + 2.0, selector_y) row.decrease.size = Vector2(selector_size, selector_size) row.increase.position = Vector2(right_x - selector_size - 2.0, selector_y) row.increase.size = Vector2(selector_size, selector_size) row.amount_label.position = Vector2(side_width + selector_size + 2.0, selector_y) row.amount_label.size = Vector2(centre_width - selector_size * 2.0 - 4.0, selector_size) - row.amount_caption.position = Vector2(side_width, selector_y + selector_size + 2.0) - row.amount_caption.size = Vector2(centre_width, 34.0) + + var caption_y: float = controls_y + band_controls + row.unload_label.position = Vector2(0.0, caption_y) + row.unload_label.size = Vector2(side_width, band_captions) + row.load_label.position = Vector2(right_x, caption_y) + row.load_label.size = Vector2(side_width, band_captions) row_index += 1 queue_redraw() diff --git a/SniperEnemyDefinition.tres b/SniperEnemyDefinition.tres index 102455f..75654f8 100644 --- a/SniperEnemyDefinition.tres +++ b/SniperEnemyDefinition.tres @@ -12,6 +12,8 @@ max_hp = 4 speed = 90.0 acceleration = 180.0 rotation_speed = 5.0 -hull_points = PackedVector2Array(0, -28, 7, -6, 7, 22, -7, 22, -7, -6) +hull_points = PackedVector2Array(0, -34, 5, -20, 9, 4, 16, 20, 6, 18, 4, 26, -4, 26, -6, 18, -16, 20, -9, 4, -5, -20) glow_color = Color(0.42, 0.72, 1.0, 1) +detail_lines = PackedVector2Array(0, -30, 0, 10, -6, 12, 6, 12) +detail_rings = PackedVector2Array(0, -2, 5, 0) component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")]) diff --git a/SpeedFader.gd b/SpeedFader.gd index 46edf9e..69d9a0f 100644 --- a/SpeedFader.gd +++ b/SpeedFader.gd @@ -20,7 +20,9 @@ extends Control @onready var label: Label = get_node("Label") var active_touch_index: int = -1 -# 0 = bottom of the track (1.0x), 1 = top (GameState.MAX_FLEET_SPEED_MULTIPLIER). +# 0 = bottom of the track (1.0x), 1 = top. The top is +# GameState.max_fleet_speed_multiplier, which widens with each distorder +# vessel past the first - so the track always spans the whole usable range. var knob_t: float = 0.0 var status_message_until: int = 0 @@ -42,7 +44,7 @@ func _process(_delta: float) -> void: knob_t = 0.0 return knob_t = (GameState.fleet_speed_multiplier - 1.0) \ - / maxf(GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0, 0.001) + / maxf(GameState.max_fleet_speed_multiplier - 1.0, 0.001) # #62: GameState._process() (not this node's own _process()) is what # drains energy and force-resets the multiplier, since it has to keep # running across the space<->surface swap and while this control might @@ -104,7 +106,7 @@ func _update_from_local(local: Vector2) -> void: # Up is 1.0 (top of the track), matching how a real fader/throttle reads. var t: float = 1.0 - clampf((local.y - track_margin) / usable, 0.0, 1.0) knob_t = t - GameState.set_fleet_speed_multiplier(1.0 + t * (GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0)) + GameState.set_fleet_speed_multiplier(1.0 + t * (GameState.max_fleet_speed_multiplier - 1.0)) label.text = "x%.1f" % GameState.fleet_speed_multiplier queue_redraw() diff --git a/TurretSpawnerEnemyDefinition.tres b/TurretSpawnerEnemyDefinition.tres index 59e000f..3e33fb3 100644 --- a/TurretSpawnerEnemyDefinition.tres +++ b/TurretSpawnerEnemyDefinition.tres @@ -13,4 +13,6 @@ acceleration = 0.0 rotation_speed = 0.0 hull_points = PackedVector2Array(0, -32, 22.6, -22.6, 32, 0, 22.6, 22.6, 0, 32, -22.6, 22.6, -32, 0, -22.6, -22.6) glow_color = Color(0.68, 0.92, 0.18, 1) +detail_lines = PackedVector2Array(-16, -16, 16, 16, 16, -16, -16, 16) +detail_rings = PackedVector2Array(0, 0, 13, 0, 0, -22, 5, 0, 0, 22, 5, 0, -22, 0, 5, 0, 22, 0, 5, 0) component_scenes = Array[PackedScene]([ExtResource("2")]) diff --git a/project.godot b/project.godot index 0dcc344..5c355de 100644 --- a/project.godot +++ b/project.godot @@ -10,7 +10,7 @@ config_version=5 [application] -config/name="Spaceship Test" +config/name="Spaceship" run/main_scene="res://MainMenu.tscn" config/features=PackedStringArray("4.7") config/icon="res://icon.svg" diff --git a/research_lab_surface.gdshader b/research_lab_surface.gdshader new file mode 100644 index 0000000..51d7842 --- /dev/null +++ b/research_lab_surface.gdshader @@ -0,0 +1,117 @@ +shader_type canvas_item; + +// Backdrop for a landed enemy research lab. +// +// The other surfaces are NATURAL: lunar_surface.gdshader erodes craters, +// gas_surface.gdshader bands clouds. A research lab is the one place in the +// game that is BUILT, and it was previously borrowing the crater field with +// a dark red palette - which read as "rocky planet, tinted", not as walking +// onto someone's facility. So this draws a plated floor instead: panel +// seams, service conduits running through them, and lit junctions where two +// conduits cross. +// +// Deliberately loop-free. lunar_surface.gdshader needs a neighbour scan +// because a crater overlaps its cell's borders; nothing here does, so every +// feature is a function of this pixel's own cell and the shader stays a +// handful of hashes per pixel (#68's lesson, applied up front rather than +// after the fact). +// +// The conduits still have to run CONTINUOUSLY across cell borders, which is +// what a neighbour scan would normally buy. The trick is to make a +// horizontal run's existence and height depend only on cell.y, and a +// vertical run's on cell.x: every cell in a row then independently agrees on +// the same answer, so the line joins up without any cell ever looking at +// its neighbours. + +uniform vec2 camera_cell = vec2(0.0, 0.0); +uniform vec2 camera_frac = vec2(0.0, 0.0); +uniform float camera_zoom = 1.0; +uniform vec2 viewport_size = vec2(720.0, 1280.0); +uniform float cell_size = 220.0; +// Shared with the lunar background so the same camera feed drives both, and +// so the field repeats on the same period. See LunarBackground.gd. +uniform float tile_cells = 32.0; +uniform vec4 base_color : source_color = vec4(0.16, 0.12, 0.12, 1.0); +uniform vec4 crater_color : source_color = vec4(0.05, 0.03, 0.03, 1.0); +uniform vec4 highlight_color : source_color = vec4(0.75, 0.20, 0.15, 1.0); + +// Plates per cell, across and down. +const float SUBPANELS = 3.0; +const float SEAM_WIDTH = 0.05; +const float CONDUIT_WIDTH = 0.055; +// How many rows/columns carry a conduit. Low enough that the floor stays +// mostly plating, high enough that a screen usually shows a junction. +const float CONDUIT_CHANCE = 0.38; + +float hash11(float p) { + vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float hash21(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +void fragment() { + vec2 screen_px = UV * viewport_size; + vec2 centered = screen_px - viewport_size * 0.5; + + // Same camera-relative construction as every other background here: an + // absolute world position reaches the tens of thousands, where a mobile + // float has already lost the fractional bits this needs. camera_cell + // arrives pre-wrapped from GDScript's double precision. + vec2 rel = camera_frac + centered * (camera_zoom / cell_size); + vec2 cell_rel = floor(rel); + vec2 local = rel - cell_rel; + vec2 cell = mod(camera_cell + cell_rel, tile_cells); + + // --- plating --------------------------------------------------------- + vec2 sub = local * SUBPANELS; + vec2 sub_cell = floor(sub); + vec2 sub_local = sub - sub_cell; + vec2 edge = min(sub_local, 1.0 - sub_local); + float seam = 1.0 - smoothstep(0.0, SEAM_WIDTH, min(edge.x, edge.y)); + // A little per-plate variation, so a large floor does not read as one + // flat fill: plates are stamped, not poured. + float plate = hash21(cell * SUBPANELS + sub_cell); + + vec3 color = mix(base_color.rgb, crater_color.rgb, plate * 0.40); + color = mix(color, crater_color.rgb, seam * 0.85); + + // --- conduits -------------------------------------------------------- + // Existence and offset keyed to a SINGLE axis of the cell index, which + // is what makes a run continuous across every cell it passes through. + float has_h = step(1.0 - CONDUIT_CHANCE, hash11(cell.y + 0.5)); + float has_v = step(1.0 - CONDUIT_CHANCE, hash11(cell.x + 13.7)); + float run_y = 0.25 + 0.5 * hash11(cell.y + 7.3); + float run_x = 0.25 + 0.5 * hash11(cell.x + 3.1); + + float h_band = has_h * (1.0 - smoothstep(0.0, CONDUIT_WIDTH, abs(local.y - run_y))); + float v_band = has_v * (1.0 - smoothstep(0.0, CONDUIT_WIDTH, abs(local.x - run_x))); + + // Charge travelling along the run. Phase is built from cell + local, a + // continuous coordinate, so the pulse crosses cell borders unbroken + // rather than restarting inside every cell. + float pulse_h = 0.55 + 0.45 * sin((cell.x + local.x) * 2.4 - TIME * 1.6); + float pulse_v = 0.55 + 0.45 * sin((cell.y + local.y) * 2.4 - TIME * 1.6); + float conduit = max(h_band * pulse_h, v_band * pulse_v); + // A dark channel under the glow, so a conduit reads as recessed into the + // floor rather than painted on top of it. + color = mix(color, crater_color.rgb, max(h_band, v_band) * 0.55); + color = mix(color, highlight_color.rgb, conduit * 0.75); + + // Junction box wherever a horizontal and a vertical run cross. + float junction = has_h * has_v * (1.0 - smoothstep(0.0, CONDUIT_WIDTH * 2.4, + length(vec2(local.x - run_x, local.y - run_y)))); + color = mix(color, highlight_color.rgb, junction * 0.9); + + // Same dither as the other backgrounds: the gradients here are shallow + // and dark, which is exactly where 8-bit quantization bands visibly. + float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0; + color += vec3(dither); + + COLOR = vec4(color, 1.0); +} diff --git a/research_lab_surface.gdshader.uid b/research_lab_surface.gdshader.uid new file mode 100644 index 0000000..7690c08 --- /dev/null +++ b/research_lab_surface.gdshader.uid @@ -0,0 +1 @@ +uid://eigpctjmqkr diff --git a/tests/_artillery_telegraph_gas_preview.gd b/tests/_artillery_telegraph_gas_preview.gd index c255b9a..703aaf4 100644 --- a/tests/_artillery_telegraph_gas_preview.gd +++ b/tests/_artillery_telegraph_gas_preview.gd @@ -1,5 +1,9 @@ extends Node2D +# Preview, not a test - an artillery shell in flight with the cross marking +# where it will land. Named "telegraph" from when the tower charged up +# before firing; the warning is now the shell itself plus that cross. + func _ready() -> void: GameState.planet_name = "Violet Tempest" @@ -11,10 +15,15 @@ func _ready() -> void: add_child(surface) var shell: Node = load("res://ArtilleryShell.tscn").instantiate() - shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot shell.aoe_radius = 220.0 surface.add_child(shell) + # Frozen with the shell part-way to its target, which is the moment the + # warning has to work: the shell is visibly in the air and the cross + # marks where it will land. There is no charge-up phase to capture any + # more - the tower fires straight away. shell.launch(Vector2(-600, -600), Vector2.ZERO) - shell.telegraph_left = shell.telegraph_time * 0.45 + shell.shell_pos = Vector2(-260, -260) + shell.position = shell.shell_pos + shell.trail = PackedVector2Array([Vector2(-600, -600), Vector2(-430, -430), shell.shell_pos]) shell.set_process(false) shell.queue_redraw() diff --git a/tests/_artillery_telegraph_rocky_preview.gd b/tests/_artillery_telegraph_rocky_preview.gd index eaa8372..bff4cd0 100644 --- a/tests/_artillery_telegraph_rocky_preview.gd +++ b/tests/_artillery_telegraph_rocky_preview.gd @@ -1,5 +1,9 @@ extends Node2D +# Preview, not a test - an artillery shell in flight with the cross marking +# where it will land. Named "telegraph" from when the tower charged up +# before firing; the warning is now the shell itself plus that cross. + func _ready() -> void: GameState.planet_name = "Kepler's Rest" @@ -11,10 +15,15 @@ func _ready() -> void: add_child(surface) var shell: Node = load("res://ArtilleryShell.tscn").instantiate() - shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot shell.aoe_radius = 220.0 surface.add_child(shell) + # Frozen with the shell part-way to its target, which is the moment the + # warning has to work: the shell is visibly in the air and the cross + # marks where it will land. There is no charge-up phase to capture any + # more - the tower fires straight away. shell.launch(Vector2(-600, -600), Vector2.ZERO) - shell.telegraph_left = shell.telegraph_time * 0.45 + shell.shell_pos = Vector2(-260, -260) + shell.position = shell.shell_pos + shell.trail = PackedVector2Array([Vector2(-600, -600), Vector2(-430, -430), shell.shell_pos]) shell.set_process(false) shell.queue_redraw() diff --git a/tests/_enemy_gallery_preview.gd b/tests/_enemy_gallery_preview.gd new file mode 100644 index 0000000..c6e6a99 --- /dev/null +++ b/tests/_enemy_gallery_preview.gd @@ -0,0 +1,29 @@ +extends Node2D + +# Preview, not a test - every enemy hull side by side, so their silhouettes +# can be compared at a glance. The chaser is included as the reference the +# others are meant to be more distinctive than. +# bash tests/screenshot.sh res://tests/_enemy_gallery_preview.tscn /tmp/enemies.png 15 720x1280 + +const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") + + +func _ready() -> void: + var enemy_scene: PackedScene = load("res://Enemy.tscn") + var ids: Array = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"] + var y: float = 200.0 + for id in ids: + var definition: Resource = ENEMY_DEFINITIONS.get_definition(id) + if definition == null: + continue + var enemy: Node2D = enemy_scene.instantiate() + enemy.enemy_id = id + enemy.position = Vector2(230.0, y) + add_child(enemy) + var label := Label.new() + label.text = definition.display_name + label.position = Vector2(330.0, y - 20.0) + label.add_theme_font_size_override("font_size", 30) + label.add_theme_color_override("font_color", definition.glow_color) + add_child(label) + y += 200.0 diff --git a/tests/_enemy_gallery_preview.gd.uid b/tests/_enemy_gallery_preview.gd.uid new file mode 100644 index 0000000..3cd5b3a --- /dev/null +++ b/tests/_enemy_gallery_preview.gd.uid @@ -0,0 +1 @@ +uid://dn5mgaf76i0m3 diff --git a/tests/_enemy_gallery_preview.tscn b/tests/_enemy_gallery_preview.tscn new file mode 100644 index 0000000..362f581 --- /dev/null +++ b/tests/_enemy_gallery_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_enemy_gallery_preview.gd" id="1"] + +[node name="EnemyGalleryPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_research_lab_surface_preview.gd b/tests/_research_lab_surface_preview.gd new file mode 100644 index 0000000..f88d073 --- /dev/null +++ b/tests/_research_lab_surface_preview.gd @@ -0,0 +1,14 @@ +extends Node2D + +# Preview, not a test - the enemy research lab's own backdrop. +# bash tests/screenshot.sh res://tests/_research_lab_surface_preview.tscn /tmp/lab.png 20 720x1280 + + +func _ready() -> void: + GameState.planet_name = "Vault Nine" + GameState.planet_definition_id = "research_lab" + GameState.surviving_ship_count = 3 + GameState.rocks = 128 + var surface: Node = load("res://PlanetSurface.tscn").instantiate() + surface.get_node("SurfaceController").fade_duration = 0.0 + add_child(surface) diff --git a/tests/_research_lab_surface_preview.gd.uid b/tests/_research_lab_surface_preview.gd.uid new file mode 100644 index 0000000..a0303df --- /dev/null +++ b/tests/_research_lab_surface_preview.gd.uid @@ -0,0 +1 @@ +uid://b330nrwpm5003 diff --git a/tests/_research_lab_surface_preview.tscn b/tests/_research_lab_surface_preview.tscn new file mode 100644 index 0000000..1679f44 --- /dev/null +++ b/tests/_research_lab_surface_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_research_lab_surface_preview.gd" id="1"] + +[node name="ResearchLabSurfacePreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_web_play_probe.gd b/tests/_web_play_probe.gd new file mode 100644 index 0000000..e384286 --- /dev/null +++ b/tests/_web_play_probe.gd @@ -0,0 +1,29 @@ +extends Node2D + +# Probe, not a test - presses Play on the main menu under the REAL +# gl_compatibility renderer, which is the same path the web export takes +# (WebGL2 == GLES3 compatibility). --headless uses a dummy renderer and so +# cannot surface a failure that only happens once something actually has to +# be rendered or a shader has to compile. +# +# bash tests/screenshot.sh res://tests/_web_play_probe.tscn /tmp/play.png 45 720x1280 + +var menu: Node +var frames: int = 0 +var pressed: bool = false + + +func _ready() -> void: + menu = load("res://MainMenu.tscn").instantiate() + add_child(menu) + + +func _process(_delta: float) -> void: + frames += 1 + if frames == 8 and not pressed: + pressed = true + print("PLAY_PROBE: pressing Play") + menu._on_play_pressed() + if frames == 30: + var scene: Node = get_tree().current_scene + print("PLAY_PROBE: current_scene=%s" % [scene.name if scene != null else ""]) diff --git a/tests/_web_play_probe.gd.uid b/tests/_web_play_probe.gd.uid new file mode 100644 index 0000000..84ce0ce --- /dev/null +++ b/tests/_web_play_probe.gd.uid @@ -0,0 +1 @@ +uid://dtudtqerivj84 diff --git a/tests/_web_play_probe.tscn b/tests/_web_play_probe.tscn new file mode 100644 index 0000000..e63c2a9 --- /dev/null +++ b/tests/_web_play_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_web_play_probe.gd" id="1"] + +[node name="WebPlayProbe" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_artillery_enemy.gd b/tests/test_artillery_enemy.gd index 3fe97c0..8ca93e5 100644 --- a/tests/test_artillery_enemy.gd +++ b/tests/test_artillery_enemy.gd @@ -3,8 +3,9 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_artillery_enemy.gd # Verifies: -# 1. the telegraph is visible (phase == TELEGRAPHING) for the whole -# window before any damage lands +# 1. the tower fires IMMEDIATELY - the shell is in the air on the frame it +# is launched - and the warning is the shell's flight plus the impact +# cross drawn at its target, not a charge-up before anything happens # 2. a ship that leaves the blast radius before impact takes no damage; # one that stays takes the full hit # 3. a shielded ship inside the blast survives, losing exactly one charge @@ -34,6 +35,7 @@ var live_shell: Node var recorded_target: Vector2 = Vector2.ZERO var impact_point: Vector2 = Vector2.ZERO var travel_time: float = 0.0 +var origin: Vector2 = Vector2.ZERO var stage2_wait: float = 0.0 var stage5_wait: float = 0.0 var stage: int = 0 @@ -79,10 +81,9 @@ func _process(delta: float) -> bool: shield_ship = fleet.ships[fleet.ships.size() - 1] shield_ship.position = impact_point + Vector2(-100, 0) - var origin: Vector2 = impact_point + Vector2(400, 400) + origin = impact_point + Vector2(400, 400) travel_time = origin.distance_to(impact_point) / 300.0 shell = load("res://ArtilleryShell.tscn").instantiate() - shell.telegraph_time = 1.0 shell.shell_speed = 300.0 shell.aoe_radius = 220.0 shell.damage = 3 @@ -93,28 +94,40 @@ func _process(delta: float) -> bool: stage = 1 return false 1: - # Mid-telegraph: nobody hurt yet, still visibly telegraphing. - if elapsed < shell.telegraph_time * 0.8: + # The shell must already be travelling - there is no charge-up + # phase to sit in any more - and it must have moved off the + # tower under its own speed. + if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TRAVELING: + return _fail("the shell is not in the air on the frame after firing") + if elapsed < travel_time * 0.4: return false - if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TELEGRAPHING: - return _fail("shell left the telegraph phase too early") + if shell.shell_pos.distance_to(shell.target) >= origin.distance_to(impact_point): + return _fail("the shell has not closed any distance on its target") + # The warning the player actually gets: a cross on the ground at + # the point of impact. Drawing cannot be asserted headlessly, so + # this checks what it is drawn FROM - the aim point is fixed and + # the marker has a real size. + if not shell.target.is_equal_approx(impact_point): + return _fail("the impact marker would not be drawn at the point the shell was aimed at") + if ARTILLERY_SHELL_SCRIPT.IMPACT_CROSS_RADIUS <= 0.0: + return _fail("the impact cross has no size, so nothing marks the landing point") if not is_instance_valid(ship_inside) or not is_instance_valid(ship_leaves) \ or not is_instance_valid(shield_ship): - return _fail("damage landed before the telegraph window finished") + return _fail("damage landed before the shell arrived") # Ship B clears the blast radius now, well before impact. ship_leaves.position = impact_point + Vector2(2000, 0) # Captured once, as a plain float: the shell frees itself once - # it resolves, so re-reading shell.telegraph_time from inside - # the wait condition below (evaluated every frame) would touch - # a freed object on any frame after that. - stage2_wait = shell.telegraph_time * 0.2 + travel_time + 1.5 + # it resolves, so reading off it from inside the wait condition + # below (evaluated every frame) would touch a freed object on + # any frame after that. + stage2_wait = travel_time + 1.5 elapsed = 0.0 stage = 2 return false 2: - # Enough time for the remaining telegraph, travel, and the full - # explosion visual to resolve. + # Enough time for the remaining travel and the full explosion + # visual to resolve. if elapsed < stage2_wait: return false if is_instance_valid(ship_inside): @@ -138,7 +151,6 @@ func _process(delta: float) -> bool: main.add_child(artillery) art_component = artillery.get_component("artillery") art_component.cooldown_left = 0.0 - art_component.telegraph_time = 0.3 art_component.fire_cooldown = 999.0 # only the one shot for this check elapsed = 0.0 stage = 3 @@ -173,7 +185,9 @@ func _process(delta: float) -> bool: # destroy the artillery enemy (and with it art_component) well # before this wait ends, so the condition below must not keep # re-reading a property off a node that might already be freed. - stage5_wait = art_component.telegraph_time + (300.0 / 180.0) + 1.5 + # No charge-up to wait out any more - just flight time plus the + # explosion visual. + stage5_wait = (300.0 / 180.0) + 1.5 elapsed = 0.0 stage = 5 return false diff --git a/tests/test_dev_mode.gd b/tests/test_dev_mode.gd index 38b8a2f..63b73c0 100644 --- a/tests/test_dev_mode.gd +++ b/tests/test_dev_mode.gd @@ -131,8 +131,8 @@ func _process(delta: float) -> bool: return _fail("the dev mode button's visibility did not follow is_developer_build()") if menu.editor_button.visible != developing: return _fail("the map editor button's visibility did not follow is_developer_build()") - if menu.play_button.visible != true or menu.quit_button.visible != (not OS.has_feature("web")): - return _fail("gating the development entries also hid an entry a player needs") + if not menu.play_button.visible: + return _fail("gating the development entries also hid Play") # Hiding is presentation; pressing it anyway must still be refused. if not developing: menu._on_dev_mode_pressed() diff --git a/tests/test_dev_mode.gd.uid b/tests/test_dev_mode.gd.uid new file mode 100644 index 0000000..eea061e --- /dev/null +++ b/tests/test_dev_mode.gd.uid @@ -0,0 +1 @@ +uid://xmhkba108cv6 diff --git a/tests/test_distorder_stacking.gd b/tests/test_distorder_stacking.gd new file mode 100644 index 0000000..7d13a09 --- /dev/null +++ b/tests/test_distorder_stacking.gd @@ -0,0 +1,135 @@ +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. +# +# 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 +# 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 +# E. full boost still costs the same energy per second however wide the +# ceiling is - more vessels buy more speed, not cheaper speed + +var game_state: Node +var main: Node +var fleet: Node +var fader: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.reset_progression() + game_state.vessel_levels["unlock_distorder"] = 1 + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + fader = main.get_node("UI/SpeedFader") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + # Room for a fleet of distorders. + game_state.unlocked.append_array(["wing", "squadron", "armada"]) + + # --- A. one vessel is unchanged ------------------------------ + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER])) + if not is_equal_approx(game_state.max_fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER): + return _fail("one distorder gives a ceiling of %.1f, expected the unchanged %.1f" \ + % [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 ---------------------- + for extra in range(1, 4): + fleet.add_ship(GameState.VESSEL_DISTORDER) + var expected: float = GameState.MAX_FLEET_SPEED_MULTIPLIER + float(extra) + 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]) + + # --- C. the fader reaches the widened ceiling ---------------- + # Dragging the track to its top must mean the CURRENT ceiling, or + # the extra vessels would be unreachable through the only control + # that exists for them. + fader._process(0.0) + if not fader.visible: + return _fail("the fader is hidden with four distorder vessels crewed") + var press := InputEventScreenTouch.new() + press.index = 3 + press.position = fader.global_position + Vector2(fader.size.x * 0.5, fader.size.y - 4.0) + press.pressed = true + main._handle_touch(press) + var drag := InputEventScreenDrag.new() + drag.index = 3 + drag.position = fader.global_position + Vector2(fader.size.x * 0.5, 0.0) + main._handle_drag(drag) + if not is_equal_approx(game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier): + return _fail("dragging the fader to the top reached %.2fx, not the ceiling %.2fx" \ + % [game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier]) + print("fader top reaches %.1fx" % game_state.fleet_speed_multiplier) + + # --- E. full boost costs the same per second ----------------- + # The drain is normalized against the current ceiling, so sitting + # at full boost is the same price whatever the ceiling is. + var wide_energy: int = _energy_spent_over(1.0) + + # --- D. losing vessels pulls the boost back down ------------- + var boosted_before: float = game_state.fleet_speed_multiplier + for i in range(fleet.ships.size() - 1, -1, -1): + if is_instance_valid(fleet.ships[i]) and fleet.ships[i].vessel_type == GameState.VESSEL_DISTORDER: + fleet.store_ship(i) + break + if game_state.max_fleet_speed_multiplier >= boosted_before: + return _fail("losing a distorder left the ceiling at %.1f, no lower than the %.1f the fleet was already doing" \ + % [game_state.max_fleet_speed_multiplier, boosted_before]) + if game_state.fleet_speed_multiplier > game_state.max_fleet_speed_multiplier + 0.001: + return _fail("the fleet is still doing %.2fx after its ceiling dropped to %.2fx" \ + % [game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier]) + print("losing one pulled %.1fx down to the new %.1fx ceiling" \ + % [boosted_before, game_state.fleet_speed_multiplier]) + + # Same measurement at the narrower ceiling, also at full boost. + game_state.set_fleet_speed_multiplier(game_state.max_fleet_speed_multiplier) + var narrow_energy: int = _energy_spent_over(1.0) + if absi(wide_energy - narrow_energy) > 1: + return _fail("full boost cost %d energy with a wide ceiling and %d with a narrow one - extra vessels changed the PRICE, not just the speed" \ + % [wide_energy, narrow_energy]) + print("full boost costs %d/%d energy per second at either ceiling" % [wide_energy, narrow_energy]) + + print("TEST_DISTORDER_STACKING: PASS") + quit(0) + return true + return false + + +# Energy actually consumed by holding the current multiplier for `seconds`, +# driven directly so the measurement does not depend on frame pacing. +func _energy_spent_over(seconds: float) -> int: + var before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + var step: float = 0.05 + var t: float = 0.0 + while t < seconds: + game_state._advance_distorder_drain(step) + t += step + return before - game_state.fleet_resource(GameState.RESOURCE_ENERGY) + + +func _fail(message: String) -> bool: + print("TEST_DISTORDER_STACKING: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_distorder_stacking.gd.uid b/tests/test_distorder_stacking.gd.uid new file mode 100644 index 0000000..80916da --- /dev/null +++ b/tests/test_distorder_stacking.gd.uid @@ -0,0 +1 @@ +uid://cftfhg1hdb5c4 diff --git a/tests/test_fleet_growth.gd.uid b/tests/test_fleet_growth.gd.uid new file mode 100644 index 0000000..6ff75b3 --- /dev/null +++ b/tests/test_fleet_growth.gd.uid @@ -0,0 +1 @@ +uid://hbmlomqcx20q diff --git a/tests/test_lab_research_reward.gd b/tests/test_lab_research_reward.gd new file mode 100644 index 0000000..0d721d4 --- /dev/null +++ b/tests/test_lab_research_reward.gd @@ -0,0 +1,115 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_lab_research_reward.gd +# A harder enemy research lab should be worth more research points, and how +# hard a lab is depends on the layout authored for it - how many capture +# points, how many defenders - which the planet TYPE cannot know. So the +# reward is a per-map value set in the map editor, falling back to the +# planet type's flat rate when a setup does not express an opinion. +# +# Covers: +# A. a setup carries its own reward and it survives a save/load round trip +# B. a map's reward overrides the planet type's flat rate on conquest +# C. a setup that says nothing (0) still gets the type's rate, so every +# map authored before this existed keeps its old value +# D. the editor's control cycles the value and reports it + +var game_state: Node +var editor: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.reset_new_game() + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- A. the field exists and round-trips --------------------- + var setup: Resource = load("res://PlanetSetup.gd").new() + setup.setup_id = "reward_probe" + setup.display_name = "Reward Probe" + setup.surface_profile_id = "rocky" + setup.capture_points = PackedVector2Array([Vector2(10, 10), Vector2(-10, -10)]) + setup.research_point_reward = 5 + var path: String = "user://test_reward_probe.tres" + if ResourceSaver.save(setup, path) != OK: + return _fail("could not save a setup carrying a research reward") + var reloaded: Resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) + if reloaded == null or int(reloaded.research_point_reward) != 5: + return _fail("the reward did not survive a save/load round trip") + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + print("a setup carries its own reward (%d) across a save/load" % reloaded.research_point_reward) + + # --- B. the map's reward beats the planet type's rate -------- + # A research_lab planet's own definition pays a flat 1. + var flat_rate: int = int(load("res://ResearchLabPlanetDefinition.tres").conquest_research_points) + game_state.research_points = 0 + game_state.register_capture_points("hard_lab", 2, "research_lab", 6) + game_state.mark_point_captured("hard_lab", 0) + game_state.mark_point_captured("hard_lab", 1) + if game_state.research_points != 6: + return _fail("a hard lab authored at 6 points awarded %d" % game_state.research_points) + if flat_rate >= 6: + return _fail("the planet type's flat rate (%d) is not below the authored one - this test proves nothing" % flat_rate) + print("a hard lab authored at 6 pays 6, over the planet type's flat %d" % flat_rate) + + # --- C. silence still means the type's rate ------------------ + game_state.research_points = 0 + game_state.register_capture_points("plain_lab", 1, "research_lab", 0) + game_state.mark_point_captured("plain_lab", 0) + if game_state.research_points != flat_rate: + return _fail("a setup with no authored reward paid %d, expected the type's %d" \ + % [game_state.research_points, flat_rate]) + print("a setup with no opinion still pays the planet type's %d" % flat_rate) + + # A harder map must never be worth LESS than an easier one. + if 6 <= flat_rate: + return _fail("authoring a harder lab did not raise its worth") + + editor = load("res://PlanetSetupEditor.tscn").instantiate() + root.add_child(editor) + current_scene = editor + elapsed = 0.0 + stage = 1 + return false + 1: + # --- D. the editor can set it -------------------------------- + if elapsed < 0.2: + return false + editor.new_setup("reward_edit", "Reward Edit", "rocky") + var seen: Array = [] + for i in range(editor.REWARD_OPTIONS.size()): + seen.append(int(editor.current_setup.research_point_reward)) + editor._on_reward_pressed() + # Cycling the whole list must come back round to where it began. + if int(editor.current_setup.research_point_reward) != seen[0]: + return _fail("cycling every reward option did not return to the start") + if seen.size() != editor.REWARD_OPTIONS.size() or seen.max() <= 0: + return _fail("the reward control did not offer a range of values: %s" % [seen]) + editor.current_setup.research_point_reward = 3 + editor._refresh_reward_label() + if not "3" in editor.reward_value_label.text: + return _fail("the editor does not report the chosen reward: '%s'" % editor.reward_value_label.text) + editor.current_setup.research_point_reward = 0 + editor._refresh_reward_label() + if not "DEFAULT" in editor.reward_value_label.text: + return _fail("the editor does not distinguish 'use the planet type's rate': '%s'" \ + % editor.reward_value_label.text) + print("the map editor cycles %s and reports the choice" % [seen]) + + print("TEST_LAB_RESEARCH_REWARD: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_LAB_RESEARCH_REWARD: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_lab_research_reward.gd.uid b/tests/test_lab_research_reward.gd.uid new file mode 100644 index 0000000..9e1d2c9 --- /dev/null +++ b/tests/test_lab_research_reward.gd.uid @@ -0,0 +1 @@ +uid://cka4wugwoaway diff --git a/tests/test_main_menu.gd b/tests/test_main_menu.gd index 938ca89..5a86a7a 100644 --- a/tests/test_main_menu.gd +++ b/tests/test_main_menu.gd @@ -5,9 +5,8 @@ extends SceneTree # Verifies: # 1. the menu loads with Play/Map Editor/Quit; Map Editor is enabled and # reaches PlanetSetupEditor.tscn (#48) -# 2. Quit's visibility follows OS.has_feature("web") (this platform is -# not a web export, so this run exercises the "visible" branch; the -# condition itself is what's under test, not the concrete boolean) +# 2. there is no Quit entry in any build - a web build has nothing to quit +# to, and Android's back gesture already does it # 3. Play resets GameState to a fresh game AND reaches Main.tscn var menu: Node @@ -43,8 +42,8 @@ func _process(delta: float) -> bool: return _fail("map editor entry visibility did not follow is_developer_build()") if menu.dev_button.visible != developing or menu.dev_label.visible != developing: return _fail("dev mode entry visibility did not follow is_developer_build()") - if menu.quit_button.visible != (not OS.has_feature("web")): - return _fail("quit button visibility did not follow OS.has_feature('web')") + if menu.has_node("UI/QuitButton") or menu.has_node("UI/QuitLabel"): + return _fail("the menu still carries a Quit entry") _press(menu.editor_button) elapsed = 0.0