spaceship/PlanetSetupEditor.gd
Loïc Wisniewski df2f252970 Add the planet setup map editor (#48)
PlanetSetupEditor authors PlanetSetup resources: pick a surface
profile (drives the real lunar_surface background, not a blank
sheet), place/move/delete capture points and enemies from a palette
generated straight off EnemyDefinitions.all() (a new enemy definition
shows up with no editor change), pan/pinch-zoom the canvas with the
same touch idiom as GameController, then save to
res://PlanetSetups/<id>.tres via ResourceSaver - reachable now from
the main menu's Map Editor button. The mutation methods
(new_setup/load_setup/add_*/move_*/remove_*/save_setup) are the whole
model and work standalone, which is what lets
tests/test_setup_editor.gd drive placement/move/delete/save/reload
directly without simulating touch input.

Every cross-file reference is a preloaded script const rather than a
bare global class name, matching #42/#44's load-order notes - this
file is instantiated from other scripts' function bodies, never as a
script-mode test entry point itself, so it wasn't at risk the same way
the test files were, but the pattern is kept consistent throughout.

Part of the #40 conquest-mechanic breakdown. This closes out the
breakdown: #41-#48 are now all implemented, tested, and merged.
2026-08-15 21:24:28 +02:00

460 lines
16 KiB
GDScript

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/<id>.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)