diff --git a/MainMenu.gd b/MainMenu.gd index e5afbcb..7ea9255 100644 --- a/MainMenu.gd +++ b/MainMenu.gd @@ -9,10 +9,7 @@ extends Node2D func _ready() -> void: play_button.pressed.connect(_on_play_pressed) editor_button.pressed.connect(_on_editor_pressed) - # #48 wires the real map editor scene up to this button; until then it - # stays visible but explains itself instead of silently doing nothing - # or pointing at a scene that doesn't exist yet. - editor_button.enabled = false + editor_button.enabled = true quit_button.pressed.connect(_on_quit_pressed) quit_button.visible = not OS.has_feature("web") diff --git a/PaletteEntryButton.gd b/PaletteEntryButton.gd new file mode 100644 index 0000000..ea479a2 --- /dev/null +++ b/PaletteEntryButton.gd @@ -0,0 +1,56 @@ +class_name PaletteEntryButton +extends Control + +# One selectable tile in the setup editor's palette: either the generic +# capture-point marker, or one enemy definition's own hull/colour. Drawn +# directly from the definition rather than a fixed icon set, so a new +# EnemyDefinitions entry shows up automatically with no editor code change. + +signal pressed + +@export var is_capture_point: bool = false +@export var enemy_definition: Resource +@export var selected: bool = false: set = set_selected + +const RING_COLOR: Color = Color(0.55, 0.85, 1.0) + + +func set_selected(value: bool) -> void: + selected = value + queue_redraw() + + +func _ready() -> void: + queue_redraw() + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventScreenTouch and not event.pressed \ + and Rect2(Vector2.ZERO, size).has_point(event.position): + pressed.emit() + elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed: + pressed.emit() + + +func _draw() -> void: + var center: Vector2 = size / 2.0 + var margin: float = 4.0 + var frame: PackedVector2Array = PackedVector2Array([ + Vector2(margin, margin), Vector2(size.x - margin, margin), + Vector2(size.x - margin, size.y - margin), Vector2(margin, size.y - margin), + ]) + var frame_color: Color = Color(1.0, 0.95, 0.9) if selected else Color(0.4, 0.45, 0.5) + NeonDraw.draw_glowing_polygon(self, frame, frame_color, 0.85 if selected else 0.5) + + if is_capture_point: + var points: PackedVector2Array = PackedVector2Array() + for i in range(16): + var angle: float = float(i) / 16.0 * TAU + 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) diff --git a/PaletteEntryButton.gd.uid b/PaletteEntryButton.gd.uid new file mode 100644 index 0000000..8dda8dc --- /dev/null +++ b/PaletteEntryButton.gd.uid @@ -0,0 +1 @@ +uid://ge3e0dc3cekb diff --git a/PlanetSetupEditor.gd b/PlanetSetupEditor.gd new file mode 100644 index 0000000..0f34b17 --- /dev/null +++ b/PlanetSetupEditor.gd @@ -0,0 +1,460 @@ +extends Node2D + +# Authoring tool for PlanetSetup resources (#40/#48): place/move/delete +# capture points and enemy spawns for a chosen surface profile, then save +# to res://PlanetSetups/.tres so the game picks it up the same way it +# picks up every other authored setup. Deliberately does not touch +# GameState - this tool only edits committed resources, never gameplay +# state. +# +# The mutation methods below (new_setup/load_setup/add_*/move_*/remove_*/ +# save_setup) are the editor's whole model and are safe to call directly +# without any touch input, which is how tests/test_setup_editor.gd drives +# them; the touch handlers at the bottom are a thin input layer on top. + +# Preloaded by path rather than referenced by global class name, same as +# every other cross-file lookup added this session (see #42/#44's +# 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") +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") + +const MIN_ZOOM: float = 0.3 +const MAX_ZOOM: float = 4.0 +const WHEEL_ZOOM_STEP: float = 1.1 +const HIT_RADIUS: float = 50.0 +const ORIGIN_COLOR: Color = Color(1.0, 0.9, 0.4) +# Duplicated from PaletteEntryButton.RING_COLOR rather than referenced +# cross-class - a global class_name lookup at parse time needs the class +# cache already warm (see #42/#44's load-order notes), which a plain +# local const sidesteps entirely. +const CAPTURE_MARKER_COLOR: Color = Color(0.55, 0.85, 1.0) +const PALETTE_STEP: float = 92.0 + +@onready var camera: Camera2D = $Camera2D +@onready var background: ColorRect = $BackgroundLayer/Background +@onready var item_host: Node2D = $ItemHost +@onready var back_button: IconButton = $UI/BackButton +@onready var profile_button: IconButton = $UI/ProfileButton +@onready var profile_value_label: Label = $UI/ProfileValueLabel +@onready var name_input: LineEdit = $UI/NameInput +@onready var new_button: IconButton = $UI/NewButton +@onready var save_button: IconButton = $UI/SaveButton +@onready var load_button: IconButton = $UI/LoadButton +@onready var delete_button: IconButton = $UI/DeleteButton +@onready var status_label: Label = $UI/StatusLabel +@onready var palette_row: Control = $UI/PaletteRow + +var current_setup: Resource +var selected_palette: String = "capture_point" +var selected_item: Dictionary = {} +var _profile_ids: Array = [] +var _profile_cursor: int = 0 +var _load_cursor: int = -1 +var _palette_buttons: Array = [] +var _origin_marker: NeonPiece + +var touches: Dictionary = {} +var pinch_active: bool = false +var pinch_start_distance: float = 0.0 +var pinch_start_zoom: float = 1.0 +var drag_touch_index: int = -1 +var drag_mode: String = "" +var dragging_item: Dictionary = {} +var pan_start_screen: Vector2 = Vector2.ZERO +var pan_start_camera: Vector2 = Vector2.ZERO + + +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) + new_button.pressed.connect(_on_new_pressed) + save_button.pressed.connect(_on_save_pressed) + load_button.pressed.connect(_on_load_pressed) + delete_button.pressed.connect(_on_delete_pressed) + name_input.text_changed.connect(_on_name_changed) + + _origin_marker = NeonPiece.create(item_host, Vector2.ZERO, ORIGIN_COLOR) + var cross_size: float = 26.0 + _origin_marker.add_line(Vector2(-cross_size, 0), Vector2(cross_size, 0), 0.6) + _origin_marker.add_line(Vector2(0, -cross_size), Vector2(0, cross_size), 0.6) + _origin_marker.queue_redraw() + + _build_palette() + new_setup("untitled", "Untitled", _profile_ids[0]) + + +# --- model: setup lifecycle ------------------------------------------------- + +func new_setup(setup_id: String, display_name: String, profile_id: String) -> void: + current_setup = PLANET_SETUP_SCRIPT.new() + current_setup.setup_id = setup_id + current_setup.display_name = display_name + current_setup.surface_profile_id = profile_id + # capture_points/enemy_spawns are left at PlanetSetup's own declared + # defaults (already empty) rather than reassigned here - assigning a + # plain `[]` to enemy_spawns from outside PlanetSetup.gd's own file + # fails at runtime, since the compiler here only sees current_setup as + # a plain Resource and can't confirm the target is Array[Resource]. + selected_item = {} + _profile_cursor = maxi(_profile_ids.find(profile_id), 0) + if name_input != null: + name_input.text = display_name + _refresh_background() + _rebuild_markers() + _set_status("") + + +func load_setup(setup_id: String) -> bool: + for setup in PlanetSetups.all(): + if setup.setup_id == setup_id: + # Edit a duplicate so in-progress edits never mutate the live + # registry entry (which other code may be reading) until an + # explicit save. + current_setup = setup.duplicate(true) + selected_item = {} + _profile_cursor = maxi(_profile_ids.find(current_setup.surface_profile_id), 0) + if name_input != null: + name_input.text = current_setup.display_name + _refresh_background() + _rebuild_markers() + _set_status("Loaded '%s'." % setup_id) + return true + return false + + +func save_setup() -> Dictionary: + if current_setup == null: + return {"ok": false, "message": "No setup to save."} + if current_setup.setup_id.strip_edges() == "": + return {"ok": false, "message": "Name the setup before saving."} + if not OS.has_feature("editor"): + return {"ok": false, "message": "Setups can only be saved when running from the project."} + var path: String = "res://PlanetSetups/%s.tres" % current_setup.setup_id + var err: int = ResourceSaver.save(current_setup, path) + if err != OK: + return {"ok": false, "message": "Save failed (error %d)." % err} + PlanetSetups.rescan() + return {"ok": true, "message": "Saved '%s'." % current_setup.setup_id} + + +# --- model: capture points --------------------------------------------- + +func add_capture_point(at: Vector2) -> int: + if current_setup == null: + return -1 + var points: PackedVector2Array = current_setup.capture_points + points.append(at) + current_setup.capture_points = points + _rebuild_markers() + return points.size() - 1 + + +func move_capture_point(index: int, at: Vector2) -> void: + if current_setup == null or index < 0 or index >= current_setup.capture_points.size(): + return + current_setup.capture_points[index] = at + _rebuild_markers() + + +func remove_capture_point(index: int) -> void: + if current_setup == null or index < 0 or index >= current_setup.capture_points.size(): + return + var points: PackedVector2Array = current_setup.capture_points + points.remove_at(index) + current_setup.capture_points = points + _rebuild_markers() + + +# --- model: enemy spawns ------------------------------------------------ + +func add_enemy(enemy_id: String, at: Vector2) -> int: + if current_setup == null: + return -1 + var spawn: Resource = PLANET_SETUP_SPAWN_SCRIPT.new() + spawn.enemy_id = enemy_id + spawn.position = at + current_setup.enemy_spawns.append(spawn) + _rebuild_markers() + return current_setup.enemy_spawns.size() - 1 + + +func move_enemy(index: int, at: Vector2) -> void: + if current_setup == null or index < 0 or index >= current_setup.enemy_spawns.size(): + return + current_setup.enemy_spawns[index].position = at + _rebuild_markers() + + +func remove_enemy(index: int) -> void: + if current_setup == null or index < 0 or index >= current_setup.enemy_spawns.size(): + return + current_setup.enemy_spawns.remove_at(index) + _rebuild_markers() + + +# --- background/markers -------------------------------------------------- + +func _refresh_background() -> void: + if current_setup == null or background == null: + return + var profile: Resource = PlanetSurfaceProfiles.get_profile(current_setup.surface_profile_id) + var material: ShaderMaterial = background.material + material.set_shader_parameter("base_color", profile.base_color) + material.set_shader_parameter("crater_color", profile.crater_color) + 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() + + +func _rebuild_markers() -> void: + if item_host == null: + return + for child in item_host.get_children(): + if child != _origin_marker: + child.queue_free() + if current_setup == null: + return + for i in range(current_setup.capture_points.size()): + _add_capture_marker(i, current_setup.capture_points[i]) + for i in range(current_setup.enemy_spawns.size()): + _add_enemy_marker(i, current_setup.enemy_spawns[i]) + + +func _add_capture_marker(index: int, at: Vector2) -> void: + var is_selected: bool = selected_item.get("kind", "") == "capture" and selected_item.get("index", -1) == index + var color: Color = Color(1.0, 0.95, 0.9) if is_selected else CAPTURE_MARKER_COLOR + var marker: NeonPiece = NeonPiece.create(item_host, at, color) + var points: PackedVector2Array = PackedVector2Array() + for i in range(16): + var angle: float = float(i) / 16.0 * TAU + points.append(Vector2(cos(angle), sin(angle)) * 60.0) + marker.add_polygon(points, 0.6) + marker.queue_redraw() + + +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() + + +# --- palette --------------------------------------------------------------- + +func _build_palette() -> void: + var entries: Array = [{"id": "capture_point", "is_point": true, "definition": null}] + for definition in ENEMY_DEFINITIONS.all(): + entries.append({"id": definition.enemy_id, "is_point": false, "definition": definition}) + + var x: float = 0.0 + for entry in entries: + var button: Control = PALETTE_ENTRY_BUTTON_SCRIPT.new() + button.position = Vector2(x, 0) + button.size = Vector2(84, 84) + button.is_capture_point = entry.is_point + button.enemy_definition = entry.definition + button.selected = entry.id == selected_palette + button.pressed.connect(_on_palette_pressed.bind(entry.id)) + palette_row.add_child(button) + _palette_buttons.append(button) + x += PALETTE_STEP + + +func _on_palette_pressed(id: String) -> void: + selected_palette = id + _refresh_palette_selection() + + +func _refresh_palette_selection() -> void: + for button in _palette_buttons: + var entry_id: String = "capture_point" if button.is_capture_point else button.enemy_definition.enemy_id + button.selected = entry_id == selected_palette + + +# --- UI handlers ------------------------------------------------------- + +func _on_profile_pressed() -> void: + if _profile_ids.is_empty(): + return + _profile_cursor = (_profile_cursor + 1) % _profile_ids.size() + current_setup.surface_profile_id = _profile_ids[_profile_cursor] + _refresh_background() + + +func _on_new_pressed() -> void: + new_setup("untitled", "Untitled", _profile_ids[_profile_cursor]) + + +func _on_save_pressed() -> void: + var result: Dictionary = save_setup() + _set_status(result.message) + + +func _on_load_pressed() -> void: + var setups: Array = PlanetSetups.all() + if setups.is_empty(): + _set_status("No saved setups yet.") + return + _load_cursor = (_load_cursor + 1) % setups.size() + load_setup(setups[_load_cursor].setup_id) + + +func _on_delete_pressed() -> void: + if selected_item.is_empty(): + _set_status("Nothing selected to delete.") + return + if selected_item.kind == "capture": + remove_capture_point(selected_item.index) + else: + remove_enemy(selected_item.index) + selected_item = {} + + +func _on_name_changed(new_text: String) -> void: + if current_setup == null: + return + current_setup.display_name = new_text + current_setup.setup_id = _sanitize_id(new_text) + + +func _sanitize_id(text: String) -> String: + var lowered: String = text.strip_edges().to_lower() + var result: String = "" + for c in lowered: + result += c if (c.to_lower() != c.to_upper() or c == "_" or (c >= "0" and c <= "9")) else "_" + return result if result != "" else "untitled" + + +func _set_status(message: String) -> void: + if status_label != null: + status_label.text = message + + +# --- placement/selection helpers ---------------------------------------- + +func _place_selected(world_pos: Vector2) -> void: + if selected_palette == "capture_point": + add_capture_point(world_pos) + else: + add_enemy(selected_palette, world_pos) + + +func _find_marker_near(world_pos: Vector2) -> Dictionary: + if current_setup == null: + return {} + var best: Dictionary = {} + var best_dist: float = HIT_RADIUS + for i in range(current_setup.capture_points.size()): + var d: float = world_pos.distance_to(current_setup.capture_points[i]) + if d <= best_dist: + best_dist = d + best = {"kind": "capture", "index": i} + for i in range(current_setup.enemy_spawns.size()): + var d: float = world_pos.distance_to(current_setup.enemy_spawns[i].position) + if d <= best_dist: + best_dist = d + best = {"kind": "enemy", "index": i} + return best + + +func _move_selected(world_pos: Vector2) -> void: + if dragging_item.is_empty(): + return + if dragging_item.kind == "capture": + move_capture_point(dragging_item.index, world_pos) + else: + move_enemy(dragging_item.index, world_pos) + + +func _screen_to_world(screen_pos: Vector2) -> Vector2: + var viewport_size: Vector2 = get_viewport_rect().size + return camera.position + (screen_pos - viewport_size / 2.0) / camera.zoom.x + + +# --- touch/pan/zoom (mirrors GameController's pinch idiom) --------------- + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventScreenTouch: + _handle_touch(event) + elif event is InputEventScreenDrag: + _handle_drag(event) + elif event is InputEventMagnifyGesture: + _set_zoom(camera.zoom.x * event.factor) + elif event is InputEventMouseButton and event.pressed: + if event.button_index == MOUSE_BUTTON_WHEEL_UP: + _set_zoom(camera.zoom.x * WHEEL_ZOOM_STEP) + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _set_zoom(camera.zoom.x / WHEEL_ZOOM_STEP) + + +func _handle_touch(event: InputEventScreenTouch) -> void: + if event.pressed: + touches[event.index] = event.position + if touches.size() == 2: + _start_pinch() + drag_touch_index = -1 + drag_mode = "" + return + if touches.size() == 1: + var world_pos: Vector2 = _screen_to_world(event.position) + var hit: Dictionary = _find_marker_near(world_pos) + drag_touch_index = event.index + if not hit.is_empty(): + selected_item = hit + dragging_item = hit + drag_mode = "item" + _rebuild_markers() + elif selected_palette != "": + _place_selected(world_pos) + drag_mode = "" + else: + drag_mode = "pan" + pan_start_screen = event.position + pan_start_camera = camera.position + else: + touches.erase(event.index) + if touches.size() < 2: + pinch_active = false + if event.index == drag_touch_index: + drag_touch_index = -1 + drag_mode = "" + dragging_item = {} + + +func _handle_drag(event: InputEventScreenDrag) -> void: + if not touches.has(event.index): + return + touches[event.index] = event.position + + if pinch_active and touches.size() == 2 and pinch_start_distance > 0.0: + var positions: Array = touches.values() + var current_distance: float = positions[0].distance_to(positions[1]) + _set_zoom(pinch_start_zoom * (current_distance / maxf(pinch_start_distance, 1.0))) + return + + if event.index != drag_touch_index: + return + match drag_mode: + "item": + _move_selected(_screen_to_world(event.position)) + "pan": + var delta_screen: Vector2 = event.position - pan_start_screen + camera.position = pan_start_camera - delta_screen / camera.zoom.x + + +func _start_pinch() -> void: + pinch_active = true + var positions: Array = touches.values() + pinch_start_distance = positions[0].distance_to(positions[1]) + pinch_start_zoom = camera.zoom.x + + +func _set_zoom(raw_zoom: float) -> void: + var z: float = clampf(raw_zoom, MIN_ZOOM, MAX_ZOOM) + camera.zoom = Vector2(z, z) diff --git a/PlanetSetupEditor.gd.uid b/PlanetSetupEditor.gd.uid new file mode 100644 index 0000000..ce90c4d --- /dev/null +++ b/PlanetSetupEditor.gd.uid @@ -0,0 +1 @@ +uid://bprbw56kcmdsk diff --git a/PlanetSetupEditor.tscn b/PlanetSetupEditor.tscn new file mode 100644 index 0000000..99e033d --- /dev/null +++ b/PlanetSetupEditor.tscn @@ -0,0 +1,179 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://PlanetSetupEditor.gd" id="1"] +[ext_resource type="Script" path="res://LunarBackground.gd" id="2"] +[ext_resource type="Shader" path="res://lunar_surface.gdshader" id="3"] +[ext_resource type="Script" path="res://IconButton.gd" id="4"] + +[sub_resource type="ShaderMaterial" id="1"] +shader = ExtResource("3") +shader_parameter/camera_zoom = 1.0 +shader_parameter/viewport_size = Vector2(720, 1280) +shader_parameter/cell_size = 220.0 +shader_parameter/base_color = Color(0.75, 0.75, 0.76, 1) +shader_parameter/crater_color = Color(0.44, 0.44, 0.45, 1) +shader_parameter/highlight_color = Color(0.89, 0.89, 0.9, 1) + +[node name="PlanetSetupEditor" type="Node2D"] +script = ExtResource("1") + +[node name="BackgroundLayer" type="CanvasLayer" parent="."] +layer = -10 + +[node name="Background" type="ColorRect" parent="BackgroundLayer"] +material = SubResource("1") +mouse_filter = 2 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("2") + +[node name="Camera2D" type="Camera2D" parent="."] +position = Vector2(0, 0) +zoom = Vector2(1, 1) + +[node name="ItemHost" type="Node2D" parent="."] + +[node name="UI" type="CanvasLayer" parent="."] +layer = 1 + +[node name="BackButton" type="Control" parent="UI"] +offset_left = 20 +offset_top = 20 +offset_right = 92 +offset_bottom = 92 +script = ExtResource("4") +icon = 1 +glow_color = Color(0.9, 0.35, 0.35, 1) + +[node name="BackCaption" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 20 +offset_top = 96 +offset_right = 92 +offset_bottom = 122 +theme_override_font_sizes/font_size = 18 +horizontal_alignment = 1 +text = "Back" + +[node name="ProfileButton" type="Control" parent="UI"] +offset_left = 110 +offset_top = 20 +offset_right = 182 +offset_bottom = 92 +script = ExtResource("4") +icon = 0 +glow_color = Color(0.5, 0.75, 1, 1) + +[node name="ProfileValueLabel" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 190 +offset_top = 36 +offset_right = 340 +offset_bottom = 76 +theme_override_font_sizes/font_size = 26 +text = "ROCKY" + +[node name="NameInput" type="LineEdit" parent="UI"] +offset_left = 350 +offset_top = 36 +offset_right = 696 +offset_bottom = 76 +placeholder_text = "Setup name" +text = "Untitled" + +[node name="NewButton" type="Control" parent="UI"] +offset_left = 20 +offset_top = 140 +offset_right = 92 +offset_bottom = 212 +script = ExtResource("4") +icon = 5 +glow_color = Color(0.45, 0.9, 0.55, 1) + +[node name="NewCaption" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 20 +offset_top = 216 +offset_right = 92 +offset_bottom = 242 +theme_override_font_sizes/font_size = 18 +horizontal_alignment = 1 +text = "New" + +[node name="SaveButton" type="Control" parent="UI"] +offset_left = 110 +offset_top = 140 +offset_right = 182 +offset_bottom = 212 +script = ExtResource("4") +icon = 4 +glow_color = Color(0.45, 0.9, 0.55, 1) + +[node name="SaveCaption" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 110 +offset_top = 216 +offset_right = 182 +offset_bottom = 242 +theme_override_font_sizes/font_size = 18 +horizontal_alignment = 1 +text = "Save" + +[node name="LoadButton" type="Control" parent="UI"] +offset_left = 200 +offset_top = 140 +offset_right = 272 +offset_bottom = 212 +script = ExtResource("4") +icon = 3 +glow_color = Color(0.5, 0.75, 1, 1) + +[node name="LoadCaption" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 200 +offset_top = 216 +offset_right = 272 +offset_bottom = 242 +theme_override_font_sizes/font_size = 18 +horizontal_alignment = 1 +text = "Load" + +[node name="DeleteButton" type="Control" parent="UI"] +offset_left = 290 +offset_top = 140 +offset_right = 362 +offset_bottom = 212 +script = ExtResource("4") +icon = 28 +glow_color = Color(0.9, 0.35, 0.35, 1) + +[node name="DeleteCaption" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 290 +offset_top = 216 +offset_right = 362 +offset_bottom = 242 +theme_override_font_sizes/font_size = 18 +horizontal_alignment = 1 +text = "Delete" + +[node name="StatusLabel" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 380 +offset_top = 140 +offset_right = 696 +offset_bottom = 242 +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 +text = "" + +[node name="PaletteRow" type="Control" parent="UI"] +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 20 +offset_top = -110 +offset_right = 696 +offset_bottom = -20 diff --git a/tests/_setup_editor_preview.gd b/tests/_setup_editor_preview.gd new file mode 100644 index 0000000..2cc48dd --- /dev/null +++ b/tests/_setup_editor_preview.gd @@ -0,0 +1,14 @@ +extends Node2D + + +func _ready() -> void: + var editor: Node = load("res://PlanetSetupEditor.tscn").instantiate() + add_child(editor) + editor.new_setup("preview_setup", "Preview Setup", "rocky") + editor.add_capture_point(Vector2(300, 150)) + editor.add_capture_point(Vector2(-250, 300)) + editor.add_enemy("chaser", Vector2(200, -300)) + editor.add_enemy("sniper", Vector2(-300, -150)) + editor.add_enemy("artillery", Vector2(0, 350)) + editor.selected_item = {"kind": "capture", "index": 0} + editor._rebuild_markers() diff --git a/tests/_setup_editor_preview.gd.uid b/tests/_setup_editor_preview.gd.uid new file mode 100644 index 0000000..5a63532 --- /dev/null +++ b/tests/_setup_editor_preview.gd.uid @@ -0,0 +1 @@ +uid://dltm5ro7xcjii diff --git a/tests/_setup_editor_preview.tscn b/tests/_setup_editor_preview.tscn new file mode 100644 index 0000000..bb4e3e8 --- /dev/null +++ b/tests/_setup_editor_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_setup_editor_preview.gd" id="1"] + +[node name="SetupEditorPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_main_menu.gd b/tests/test_main_menu.gd index 5193f33..8d2982f 100644 --- a/tests/test_main_menu.gd +++ b/tests/test_main_menu.gd @@ -3,8 +3,8 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_main_menu.gd # Verifies: -# 1. the menu loads with Play/Map Editor/Quit, the editor button starts -# disabled and explains itself instead of silently doing nothing +# 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) @@ -33,8 +33,8 @@ func _process(delta: float) -> bool: # frame has actually processed. if elapsed < 0.1: return false - if menu.editor_button.enabled: - return _fail("map editor button is not disabled ahead of #48") + if not menu.editor_button.enabled: + return _fail("map editor button is not enabled (#48 shipped)") if menu.quit_button.visible != (not OS.has_feature("web")): return _fail("quit button visibility did not follow OS.has_feature('web')") @@ -43,13 +43,22 @@ func _process(delta: float) -> bool: stage = 1 return false 1: + if elapsed < 0.2: + return false + var on_editor: bool = current_scene != null and current_scene.name == "PlanetSetupEditor" + if not on_editor: + return _fail("pressing Map Editor did not reach PlanetSetupEditor.tscn") + + # Return to the menu the same way the player would, then test Play. + menu = load("res://MainMenu.tscn").instantiate() + root.add_child(menu) + current_scene = menu + elapsed = 0.0 + stage = 2 + return false + 2: if elapsed < 0.1: return false - if "coming soon" not in menu.status_label.text.to_lower(): - return _fail("disabled editor button press did not explain itself") - if current_scene != menu: - return _fail("pressing the disabled editor button changed scenes") - # Dirty the game state exactly like an in-progress run would, # so a genuine reset is distinguishable from a coincidental match. game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 999) @@ -59,9 +68,9 @@ func _process(delta: float) -> bool: _press(menu.play_button) elapsed = 0.0 - stage = 2 + stage = 3 return false - 2: + 3: if elapsed < 0.3: return false var on_main: bool = current_scene != null and current_scene.name == "Main" diff --git a/tests/test_setup_editor.gd b/tests/test_setup_editor.gd new file mode 100644 index 0000000..fe8c572 --- /dev/null +++ b/tests/test_setup_editor.gd @@ -0,0 +1,136 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_setup_editor.gd +# Drives PlanetSetupEditor's model directly (place/move/delete/save/reload) +# rather than through touch input - see the ticket's own note that the +# touch layer can stay out of the headless test. Verifies: +# 1. placing capture points and enemies updates current_setup +# 2. moving and deleting update it correctly, including index shifting +# 3. saving writes a real res://PlanetSetups/.tres and the registry +# picks it up +# 4. reopening that file in a fresh editor instance round-trips the +# exact points/enemies, and they can be moved/deleted again +# 5. the palette has one entry per EnemyDefinitions entry plus the +# capture point, generated from the registry rather than hardcoded + +const TEST_SETUP_ID: String = "test_editor_setup" +const TEST_SETUP_PATH: String = "res://PlanetSetups/test_editor_setup.tres" + +var editor: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + _cleanup_scratch_file() + editor = load("res://PlanetSetupEditor.tscn").instantiate() + root.add_child(editor) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # _ready() (palette build, initial new_setup) needs a frame to + # flush - same hazard as test_planet_setups.gd. + if elapsed < 0.1: + return false + + var expected_palette_size: int = 1 + EnemyDefinitions.all().size() + if editor._palette_buttons.size() != expected_palette_size: + return _fail("palette has %d entries, expected 1 + %d enemy definitions" % [ + editor._palette_buttons.size(), EnemyDefinitions.all().size() + ]) + + editor.new_setup(TEST_SETUP_ID, "Test Editor Setup", "rocky") + editor.add_capture_point(Vector2(100, 200)) + editor.add_capture_point(Vector2(-300, 50)) + editor.add_capture_point(Vector2(0, -400)) + editor.add_enemy("chaser", Vector2(500, 500)) + editor.add_enemy("sniper", Vector2(-500, -500)) + + if editor.current_setup.capture_points.size() != 3: + return _fail("placing 3 capture points did not land 3") + if editor.current_setup.enemy_spawns.size() != 2: + return _fail("placing 2 enemies did not land 2") + + editor.move_capture_point(0, Vector2(150, 250)) + if editor.current_setup.capture_points[0] != Vector2(150, 250): + return _fail("move_capture_point did not update the position") + + editor.remove_enemy(0) # removes the chaser spawn + if editor.current_setup.enemy_spawns.size() != 1: + return _fail("remove_enemy did not shrink enemy_spawns") + if editor.current_setup.enemy_spawns[0].enemy_id != "sniper": + return _fail("remove_enemy removed the wrong entry (index did not shift)") + + var result: Dictionary = editor.save_setup() + if not result.ok: + return _fail("save_setup reported failure: %s" % result.message) + if not FileAccess.file_exists(TEST_SETUP_PATH): + return _fail("save_setup reported success but wrote no file at %s" % TEST_SETUP_PATH) + + var found_in_registry: bool = false + for setup in PlanetSetups.all(): + if setup.setup_id == TEST_SETUP_ID: + found_in_registry = true + if not found_in_registry: + return _fail("saved setup was not picked up by PlanetSetups after save") + + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.1: + return false + # Reopen in a FRESH editor instance, as a re-landing/re-editing + # session would. + var reopened: Node = load("res://PlanetSetupEditor.tscn").instantiate() + root.add_child(reopened) + editor = reopened + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.1: + return false + if not editor.load_setup(TEST_SETUP_ID): + return _fail("reopening the saved setup failed") + var points: PackedVector2Array = editor.current_setup.capture_points + var spawns: Array = editor.current_setup.enemy_spawns + if points.size() != 3 or points[0] != Vector2(150, 250) \ + or points[1] != Vector2(-300, 50) or points[2] != Vector2(0, -400): + return _fail("reopened capture points did not match what was saved: %s" % [points]) + if spawns.size() != 1 or spawns[0].enemy_id != "sniper" or spawns[0].position != Vector2(-500, -500): + return _fail("reopened enemy spawns did not match what was saved") + + # The reopened items can still be moved and deleted. + editor.move_enemy(0, Vector2(-600, -600)) + if editor.current_setup.enemy_spawns[0].position != Vector2(-600, -600): + return _fail("could not move an item after reopening") + editor.remove_capture_point(1) + if editor.current_setup.capture_points.size() != 2: + return _fail("could not delete an item after reopening") + + print("TEST_SETUP_EDITOR: PASS") + _cleanup_scratch_file() + quit(0) + return true + return false + + +func _cleanup_scratch_file() -> void: + if FileAccess.file_exists(TEST_SETUP_PATH): + DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_SETUP_PATH)) + var import_file: String = TEST_SETUP_PATH + ".import" + if FileAccess.file_exists(import_file): + DirAccess.remove_absolute(ProjectSettings.globalize_path(import_file)) + PlanetSetups.rescan() + + +func _fail(message: String) -> bool: + print("TEST_SETUP_EDITOR: FAIL (%s)" % message) + _cleanup_scratch_file() + quit(1) + return true diff --git a/tests/test_setup_editor.gd.uid b/tests/test_setup_editor.gd.uid new file mode 100644 index 0000000..513f38c --- /dev/null +++ b/tests/test_setup_editor.gd.uid @@ -0,0 +1 @@ +uid://cegx70j0hkj4m