diff --git a/Planet.gd b/Planet.gd index 4882311..b682030 100644 --- a/Planet.gd +++ b/Planet.gd @@ -20,6 +20,20 @@ extends Node2D var shape_points: PackedVector2Array = PackedVector2Array() +# An extractor is built down on this planet's SURFACE, in a different scene +# the player is no longer looking at, so from space nothing distinguished a +# planet already being mined from an untouched one - you had to land to find +# out. This is a derrick planted on the rim: the same amber the world map +# uses for its extractor badge, so the two read as the same fact. +const EXTRACTOR_MARKER_COLOR: Color = Color(1.0, 0.85, 0.3) +const DERRICK_HEIGHT: float = 44.0 +const DERRICK_HALF_WIDTH: float = 16.0 + +# Resolved once in _ready() rather than polled: building an extractor happens +# on PlanetSurface.tscn, and coming back to space rebuilds Main.tscn from +# scratch, so a Planet node never outlives a change to its own answer. +var shows_extractor: bool = false + func _ready() -> void: add_to_group("planets") @@ -32,8 +46,50 @@ func _ready() -> void: var angle: float = (float(i) / point_count) * TAU var radius: float = rng.randf_range(70.0, 100.0) shape_points.append(Vector2(cos(angle), sin(angle)) * radius) + shows_extractor = GameState.has_extractor_at(planet_name) queue_redraw() +func extractor_derrick_anchor() -> Vector2: + # The rim vertex the derrick stands on: the shape's own topmost point, + # rather than a fixed radius, so it sits ON the outline whatever lumpy + # shape the seed drew instead of floating off it or sinking inside it. + var base: Vector2 = Vector2(0.0, -100.0) + for p in shape_points: + if p.y < base.y: + base = p + return base + + +func extractor_derrick_tip() -> Vector2: + # Exposed separately from the polygon because "does the derrick clear the + # planet's outline" is a question about the TIP alone. The base corners + # sit a half-width off to either side, so they are sqrt(|base|^2 + w^2) + # from the centre and can clear the rim on their own even when the tower + # points straight into the ground. + var base: Vector2 = extractor_derrick_anchor() + var out: Vector2 = base.normalized() + if out == Vector2.ZERO: + out = Vector2.UP + return base + out * DERRICK_HEIGHT + + +func extractor_derrick_points() -> PackedVector2Array: + var base: Vector2 = extractor_derrick_anchor() + var out: Vector2 = base.normalized() + if out == Vector2.ZERO: + out = Vector2.UP + var side: Vector2 = Vector2(-out.y, out.x) + var tip: Vector2 = extractor_derrick_tip() + return PackedVector2Array([ + base - side * DERRICK_HALF_WIDTH, + tip - side * (DERRICK_HALF_WIDTH * 0.35), + tip + side * (DERRICK_HALF_WIDTH * 0.35), + base + side * DERRICK_HALF_WIDTH, + ]) + + func _draw() -> void: NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0) + if shows_extractor: + NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0) diff --git a/WorldMapContent.gd b/WorldMapContent.gd index b0fe79d..1398021 100644 --- a/WorldMapContent.gd +++ b/WorldMapContent.gd @@ -2,6 +2,23 @@ extends Node2D const WORLD_TO_LOCAL: float = 0.04 +# Mirrors Building.gd's own MOTHERSHIP_COLOR/CRAFTER_COLOR/COMMAND_COLOR so a +# marker on the map reads as "the same building" at a glance. Duplicated +# rather than cross-referenced - the same colour is already independently +# duplicated between Ship.gd, Fleet.gd and Building.gd's HULL_COLOR, so this +# follows the codebase's existing convention rather than inventing a new one. +const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55) +const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25) +const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0) + +# A structure built on a planet's surface (see GameState.extractors) is not +# itself a map marker - it is a small badge on the existing planet marker, +# in a colour distinct from both the planets and the space buildings so it +# reads as "something is mined here" rather than another building type. +const EXTRACTOR_BADGE_COLOR: Color = Color(1.0, 0.85, 0.3) +const EXTRACTOR_BADGE_OFFSET: Vector2 = Vector2(8.0, -8.0) +const EXTRACTOR_BADGE_RADIUS: float = 5.0 + @onready var tracker: Node2D = get_node("../../../ExplorationTracker") @onready var fleet: Node2D = get_node("../../../Fleet") @@ -11,6 +28,59 @@ func _process(_delta: float) -> void: queue_redraw() +# Local-space marker data for every player-placed building (GameState. +# buildings), one entry per building with its colour already resolved. Kept +# as a separate, callable step (rather than inlined in _draw()) so a +# headless test - which has no GPU and cannot check drawn pixels - can +# verify a built building actually produces a marker. +func building_markers() -> Array: + var markers: Array = [] + for b in GameState.buildings: + markers.append({ + "position": b.position * WORLD_TO_LOCAL, + "type": b.type, + "color": _building_marker_color(b.type), + }) + return markers + + +func _building_marker_color(type: String) -> Color: + match type: + GameState.BUILDING_MOTHERSHIP: + return MOTHERSHIP_MARKER_COLOR + GameState.BUILDING_SHIP_CRAFTER: + return CRAFTER_MARKER_COLOR + _: + return COMMAND_MARKER_COLOR + + +# Whether the planet in this (explored) chunk has an extractor on it, per +# GameState.has_extractor_at(). A separate predicate for the same +# GPU-less-testability reason as building_markers(). +func chunk_has_extractor_badge(chunk: Vector2i) -> bool: + if not WorldGen.has_planet_at_chunk(chunk): + return false + return GameState.has_extractor_at(WorldGen.planet_data_at_chunk(chunk).name) + + +func _regular_polygon(center: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array: + var pts: PackedVector2Array = PackedVector2Array() + for i in range(sides): + var a: float = (float(i) / float(sides)) * TAU + phase + pts.append(center + Vector2(cos(a), sin(a)) * radius) + return pts + + +# Filled shape plus a thin white outline - the same construction the fleet +# marker below already uses, just factored out since buildings now need it +# three times over (once per type) and the extractor badge needs it too. +func _draw_marker_polygon(pts: PackedVector2Array, color: Color) -> void: + draw_colored_polygon(pts, color) + var closed: PackedVector2Array = pts.duplicate() + closed.append(pts[0]) + draw_polyline(closed, Color(1, 1, 1), 1.5, true) + + func _draw() -> void: var chunk_size_local: float = WorldGen.CHUNK_SIZE * WORLD_TO_LOCAL @@ -32,6 +102,22 @@ func _draw() -> void: draw_circle(planet_local, 5.0, Color(0.7, 0.65, 0.55)) draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true) + if chunk_has_extractor_badge(chunk): + var badge_pts: PackedVector2Array = _regular_polygon( + planet_local + EXTRACTOR_BADGE_OFFSET, EXTRACTOR_BADGE_RADIUS, 4, PI / 4.0) + _draw_marker_polygon(badge_pts, EXTRACTOR_BADGE_COLOR) + + # Buildings are placed freely in space rather than tied to a chunk, so + # they get their own pass rather than living inside the chunk loop above. + for marker in building_markers(): + match marker.type: + GameState.BUILDING_MOTHERSHIP: + _draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color) + GameState.BUILDING_SHIP_CRAFTER: + _draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color) + _: + _draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color) + var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL var marker_points: PackedVector2Array = PackedVector2Array([ fleet_local + Vector2(0, -10), diff --git a/tests/_marker_preview.gd b/tests/_marker_preview.gd new file mode 100644 index 0000000..465cb20 --- /dev/null +++ b/tests/_marker_preview.gd @@ -0,0 +1,45 @@ +extends Node2D + +# Preview, not a test (underscore prefix keeps it out of run_tests.sh). +# Renders the world map with buildings placed and an extractor built, so the +# issue #11 building markers and the issue #9 extractor badge can actually be +# LOOKED at. --headless draws no pixels, so no headless test can tell whether +# a marker is visible - see tests/screenshot.sh. +# +# bash tests/screenshot.sh res://tests/_marker_preview.tscn /tmp/map.png 20 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.explored_chunks.clear() + + # Near the origin, where the fleet starts, so everything lands on screen. + GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(900.0, -700.0)) + GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(-1100.0, 300.0)) + GameState.add_building(GameState.BUILDING_COMMAND_CENTER, Vector2(400.0, 1200.0)) + + # Reveal a patch of map, and put an extractor on whichever planet is + # nearest the origin so its badge is in frame too. + var best: Vector2i = Vector2i.ZERO + var best_distance: int = 1 << 30 + for x in range(-6, 7): + for y in range(-6, 7): + var chunk: Vector2i = Vector2i(x, y) + GameState.explored_chunks[chunk] = true + if WorldGen.has_planet_at_chunk(chunk): + var d: int = absi(x) + absi(y) + if d < best_distance: + best_distance = d + best = chunk + if best_distance < (1 << 30): + var data: Dictionary = WorldGen.planet_data_at_chunk(best) + GameState.add_extractor(data.name, WorldGen.chunk_origin(best) + data.local_pos) + print("extractor on '%s' at chunk %s" % [data.name, best]) + + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + # open_map(), not `visible = true` - opening also recentres the view on + # the fleet, and without that the map is drawn from its own origin in the + # corner of the screen with everything near the fleet cropped off. + main.get_node("UI/WorldMap").open_map() diff --git a/tests/_marker_preview.gd.uid b/tests/_marker_preview.gd.uid new file mode 100644 index 0000000..f49b06c --- /dev/null +++ b/tests/_marker_preview.gd.uid @@ -0,0 +1 @@ +uid://blbow3jq36fdw diff --git a/tests/_marker_preview.tscn b/tests/_marker_preview.tscn new file mode 100644 index 0000000..85c61cb --- /dev/null +++ b/tests/_marker_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_marker_preview.gd" id="1"] + +[node name="MarkerPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_planet_derrick_preview.gd b/tests/_planet_derrick_preview.gd new file mode 100644 index 0000000..bfcbc31 --- /dev/null +++ b/tests/_planet_derrick_preview.gd @@ -0,0 +1,25 @@ +extends Node2D + +# Preview, not a test. Two planets side by side in SPACE - the left one has +# an extractor built on it, the right one does not - so the issue #9 derrick +# can be compared against a plain planet and judged as actually visible. +# +# bash tests/screenshot.sh res://tests/_planet_derrick_preview.tscn /tmp/derrick.png 15 1280x720 + + +func _ready() -> void: + GameState.extractors.clear() + GameState.buildings.clear() + GameState.add_extractor("Mined World", Vector2.ZERO) + + _spawn("Mined World", Vector2(380.0, 380.0), 12345) + _spawn("Untouched World", Vector2(900.0, 380.0), 4242) + + +func _spawn(planet_name: String, at: Vector2, shape_seed: int) -> void: + var planet: Node2D = load("res://Planet.tscn").instantiate() + planet.planet_name = planet_name + planet.shape_seed = shape_seed + planet.position = at + add_child(planet) + print("%s: derrick=%s" % [planet_name, planet.shows_extractor]) diff --git a/tests/_planet_derrick_preview.gd.uid b/tests/_planet_derrick_preview.gd.uid new file mode 100644 index 0000000..053a54b --- /dev/null +++ b/tests/_planet_derrick_preview.gd.uid @@ -0,0 +1 @@ +uid://dl4abkye58bd6 diff --git a/tests/_planet_derrick_preview.tscn b/tests/_planet_derrick_preview.tscn new file mode 100644 index 0000000..cbbfb77 --- /dev/null +++ b/tests/_planet_derrick_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_planet_derrick_preview.gd" id="1"] + +[node name="PlanetDerrickPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/screenshot.sh b/tests/screenshot.sh index 739b163..8fa7168 100644 --- a/tests/screenshot.sh +++ b/tests/screenshot.sh @@ -28,6 +28,13 @@ WAIT_FRAMES="${3:-60}" RESOLUTION="${4:-1280x720}" proot-distro login ubuntu -- bash -c " +# A killed run (or a session that ended mid-screenshot) leaves /tmp/.X99-lock +# behind, and Xvfb then refuses to claim :99 - which surfaces much later and +# far less obviously as Godot's 'X11 Display is not available'. Nothing else +# uses :99 here, so a lock with no live Xvfb behind it is always stale. +if ! pgrep -x Xvfb >/dev/null; then + rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 +fi Xvfb :99 -screen 0 ${RESOLUTION}x24 & XVFB_PID=\$! sleep 2 diff --git a/tests/test_map_building_markers.gd b/tests/test_map_building_markers.gd new file mode 100644 index 0000000..e92320a --- /dev/null +++ b/tests/test_map_building_markers.gd @@ -0,0 +1,99 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_map_building_markers.gd +# Forgejo issue #11: a building the player has placed must appear on the +# world map. Without it the map showed explored chunks, planets and the +# fleet, but nothing the player had actually built - so there was no way to +# find your own base again once you had flown away from it. +# +# Buildings are not tied to a chunk (they are placed freely in space), so +# they cannot ride along with the chunk markers and need their own pass. +# +# --headless has no GPU and draws no pixels, so this cannot assert on what +# was rendered. It asserts on building_markers(), the step that turns +# GameState.buildings into map-local marker data, which is the part that +# _draw() then simply stamps out. +# +# Verifies: +# 1. no buildings -> no markers (so a passing count is not just "always 3") +# 2. every placed building produces exactly one marker +# 3. markers land at the building's world position scaled into map space +# 4. the three building types are told apart by colour, rather than all +# being drawn as the same anonymous dot + +var scene: Node +var game_state: Node +var map_content: Node2D +var stage: int = 0 + +const MOTHERSHIP_AT: Vector2 = Vector2(2000.0, -1200.0) +const CRAFTER_AT: Vector2 = Vector2(-800.0, 400.0) +const COMMAND_AT: Vector2 = Vector2(150.0, 3000.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + map_content = scene.get_node("UI/WorldMap/MapContent") + + +func _process(_delta: float) -> bool: + match stage: + 0: + # Nothing built yet: the map must show no building markers at all. + var empty: Array = map_content.building_markers() + if not empty.is_empty(): + print("TEST_MAP_BUILDING_MARKERS: FAIL (%d markers with nothing built)" % empty.size()) + quit(1) + return true + + game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + game_state.add_building(GameState.BUILDING_COMMAND_CENTER, COMMAND_AT) + stage = 1 + return false + 1: + var markers: Array = map_content.building_markers() + print("markers=%d for %d buildings" % [markers.size(), game_state.buildings.size()]) + if markers.size() != 3: + print("TEST_MAP_BUILDING_MARKERS: FAIL (expected one marker per building)") + quit(1) + return true + + var scale: float = map_content.WORLD_TO_LOCAL + var expected: Dictionary = { + GameState.BUILDING_MOTHERSHIP: MOTHERSHIP_AT * scale, + GameState.BUILDING_SHIP_CRAFTER: CRAFTER_AT * scale, + GameState.BUILDING_COMMAND_CENTER: COMMAND_AT * scale, + } + var colours: Array = [] + for m in markers: + if not expected.has(m.type): + print("TEST_MAP_BUILDING_MARKERS: FAIL (unexpected marker type %s)" % m.type) + quit(1) + return true + var want: Vector2 = expected[m.type] + if m.position.distance_to(want) > 0.001: + print("%s marker at %s, expected %s" % [m.type, m.position, want]) + print("TEST_MAP_BUILDING_MARKERS: FAIL (marker is not where the building is)") + quit(1) + return true + colours.append(m.color) + + # Three buildings drawn in one indistinguishable colour would + # satisfy every check above and still be useless to read. + if colours[0] == colours[1] or colours[1] == colours[2] or colours[0] == colours[2]: + print("colours=%s" % [colours]) + print("TEST_MAP_BUILDING_MARKERS: FAIL (building types are not visually distinct)") + quit(1) + return true + + print("all 3 markers positioned correctly, colours distinct: %s" % [colours]) + print("TEST_MAP_BUILDING_MARKERS: PASS") + quit(0) + return true + return false diff --git a/tests/test_map_building_markers.gd.uid b/tests/test_map_building_markers.gd.uid new file mode 100644 index 0000000..ecae550 --- /dev/null +++ b/tests/test_map_building_markers.gd.uid @@ -0,0 +1 @@ +uid://bx3bce8ix4rbg diff --git a/tests/test_planet_extractor_marker.gd b/tests/test_planet_extractor_marker.gd new file mode 100644 index 0000000..2104da5 --- /dev/null +++ b/tests/test_planet_extractor_marker.gd @@ -0,0 +1,138 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_extractor_marker.gd +# Forgejo issue #9: a planet with an extractor built on it must show that +# something is built there. The extractor lives on PlanetSurface.tscn, a +# scene the player is not looking at from space, so an already-mined planet +# was indistinguishable from an untouched one - you had to land to find out. +# +# Covered in both places the player looks at a planet: +# - in space, as a derrick on the planet's own outline (Planet.gd) +# - on the world map, as a badge on the planet marker (WorldMapContent.gd) +# +# --headless has no GPU, so this asserts on the state and geometry that +# _draw() consumes, not on pixels. tests/screenshot.sh covers whether it is +# actually visible. +# +# Verifies: +# 1. a planet with no extractor shows neither derrick nor badge (so a pass +# cannot just mean "always on") +# 2. building one turns both on, keyed by planet NAME - the same key +# GameState.has_extractor_at uses +# 3. the derrick sits outside the planet's outline rather than buried in +# it, which is the difference between visible and not + +var game_state: Node +var scene: Node +var map_content: Node2D +var planet_chunk: Vector2i +var planet_name: String = "" +var stage: int = 0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + planet_chunk = chunk + planet_name = WorldGen.planet_data_at_chunk(chunk).name + found = true + break + if found: + break + if not found: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no planet chunk to test with)") + quit(1) + return + + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + map_content = scene.get_node("UI/WorldMap/MapContent") + + +func _spawn_planet() -> Node2D: + var planet: Node2D = load("res://Planet.tscn").instantiate() + planet.planet_name = planet_name + planet.shape_seed = 12345 + scene.add_child(planet) + return planet + + +func _process(_delta: float) -> bool: + match stage: + 0: + # --- nothing built there yet -------------------------------- + var clean: Node2D = _spawn_planet() + if clean.shows_extractor: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick on a planet with no extractor)") + quit(1) + return true + if map_content.chunk_has_extractor_badge(planet_chunk): + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (map badge with no extractor)") + quit(1) + return true + clean.queue_free() + print("no extractor -> no derrick, no badge (planet '%s')" % planet_name) + + game_state.add_extractor(planet_name, WorldGen.chunk_origin(planet_chunk)) + stage = 1 + return false + 1: + # --- extractor built ---------------------------------------- + var mined: Node2D = _spawn_planet() + if not mined.shows_extractor: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no derrick after building an extractor)") + quit(1) + return true + if not map_content.chunk_has_extractor_badge(planet_chunk): + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no map badge after building an extractor)") + quit(1) + return true + + # The derrick has to clear the outline to be seen at all. Compare + # its tip against the planet's own radius in that direction, not + # against a constant - the outline is a lumpy seeded shape. + var derrick: PackedVector2Array = mined.extractor_derrick_points() + if derrick.size() < 3: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick has no shape)") + quit(1) + return true + # Measure the TIP, not the whole polygon. The base corners sit a + # half-width to either side of the rim vertex, so they are + # sqrt(|base|^2 + w^2) from the centre and clear the rim on their + # own - a max over every point called a derrick pointing straight + # into the ground "visible", which is exactly the bug this check + # exists to catch. + var furthest: float = mined.extractor_derrick_tip().length() + var rim: float = 0.0 + for p in mined.shape_points: + rim = maxf(rim, p.length()) + print("derrick tip reaches %.1f, planet outline reaches %.1f" % [furthest, rim]) + if furthest <= rim: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick buried inside the planet)") + quit(1) + return true + + # A different planet must NOT inherit the marker: the flag is keyed + # by name, and getting that wrong would light up every planet. + var other: Node2D = load("res://Planet.tscn").instantiate() + other.planet_name = planet_name + " II" + other.shape_seed = 999 + scene.add_child(other) + if other.shows_extractor: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (marker leaked onto a different planet)") + quit(1) + return true + + print("extractor -> derrick + badge, and only on the right planet") + print("TEST_PLANET_EXTRACTOR_MARKER: PASS") + quit(0) + return true + return false diff --git a/tests/test_planet_extractor_marker.gd.uid b/tests/test_planet_extractor_marker.gd.uid new file mode 100644 index 0000000..f1bd124 --- /dev/null +++ b/tests/test_planet_extractor_marker.gd.uid @@ -0,0 +1 @@ +uid://dcr7qb1yoewcy