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") # 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") 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 # #65: how far a single-finger press has to move before it counts as a pan # rather than a tap-to-place, once it's confirmed not to be starting on an # existing marker. const PAN_DEADZONE: float = 24.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 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 @onready var load_button: IconButton = $UI/LoadButton @onready var delete_button: IconButton = $UI/DeleteButton @onready var status_label: Label = $UI/StatusLabel @onready var selected_label: Label = $UI/SelectedLabel @onready var palette_row: Control = $UI/PaletteRow @onready var load_panel: Control = $UI/LoadPanel @onready var load_list: ItemList = $UI/LoadPanel/List @onready var load_close_button: IconButton = $UI/LoadPanel/CloseButton var current_setup: Resource var selected_palette: String = "capture_point" var selected_item: Dictionary = {} var _profile_ids: Array = [] var _profile_cursor: int = 0 # #64: replaced the old "Load cycles to the next saved setup" behaviour, # which gave no way to see what existed or jump straight to one by name - # kept only as the id list _load_list_setup_ids indexes into when the # panel's own selection fires. var _load_list_setup_ids: PackedStringArray = PackedStringArray() 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) 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) delete_button.pressed.connect(_on_delete_pressed) name_input.text_changed.connect(_on_name_changed) load_close_button.pressed.connect(func(): load_panel.visible = false) load_list.item_selected.connect(_on_load_list_item_selected) _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 = {} _refresh_selected_label() _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 = {} _refresh_selected_label() _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() _refresh_reward_label() 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 # 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 --------------------------------------------------------------- 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 ------------------------------------------------------- # 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 _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_list.clear() _load_list_setup_ids = PackedStringArray() for setup in setups: load_list.add_item("%s (%s)" % [setup.display_name, setup.surface_profile_id]) _load_list_setup_ids.append(setup.setup_id) load_panel.visible = true func _on_load_list_item_selected(index: int) -> void: if index < 0 or index >= _load_list_setup_ids.size(): return load_setup(_load_list_setup_ids[index]) load_panel.visible = false 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 = {} _refresh_selected_label() 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 # #66: names the currently-selected marker so a tap actually confirms what # got picked, instead of the player having to infer it from which dot on # screen turned white. func _refresh_selected_label() -> void: if selected_label == null: return if selected_item.is_empty() or current_setup == null: selected_label.text = "" return var index: int = int(selected_item.index) if selected_item.kind == "capture": selected_label.text = "Selected: Capture Point #%d" % (index + 1) else: var spawn: Resource = current_setup.enemy_spawns[index] var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id) selected_label.text = "Selected: %s #%d" % [definition.display_name, index + 1] # --- 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 pan_start_screen = event.position pan_start_camera = camera.position if not hit.is_empty(): selected_item = hit dragging_item = hit drag_mode = "item" _rebuild_markers() _refresh_selected_label() else: # #65: a press over empty canvas no longer commits to placing # an item immediately - it might be the start of a pan. Which # one it turns out to be is only known on release (still # under PAN_DEADZONE = a tap = place) or once a drag actually # crosses that deadzone (= a pan), same tap-vs-drag idiom # GasBombController uses for its own AIM_DEADZONE. dragging_item = {} drag_mode = "pending" else: if event.index == drag_touch_index: if drag_mode == "pending": _place_selected(_screen_to_world(event.position)) drag_touch_index = -1 drag_mode = "" dragging_item = {} touches.erase(event.index) if touches.size() < 2: pinch_active = false 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 if drag_mode == "pending" and event.position.distance_to(pan_start_screen) >= PAN_DEADZONE: drag_mode = "pan" 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)