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.
This commit is contained in:
parent
def224667d
commit
df2f252970
12 changed files with 876 additions and 15 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
56
PaletteEntryButton.gd
Normal file
56
PaletteEntryButton.gd
Normal file
|
|
@ -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)
|
||||
1
PaletteEntryButton.gd.uid
Normal file
1
PaletteEntryButton.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ge3e0dc3cekb
|
||||
460
PlanetSetupEditor.gd
Normal file
460
PlanetSetupEditor.gd
Normal file
|
|
@ -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/<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)
|
||||
1
PlanetSetupEditor.gd.uid
Normal file
1
PlanetSetupEditor.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bprbw56kcmdsk
|
||||
179
PlanetSetupEditor.tscn
Normal file
179
PlanetSetupEditor.tscn
Normal file
|
|
@ -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
|
||||
14
tests/_setup_editor_preview.gd
Normal file
14
tests/_setup_editor_preview.gd
Normal file
|
|
@ -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()
|
||||
1
tests/_setup_editor_preview.gd.uid
Normal file
1
tests/_setup_editor_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dltm5ro7xcjii
|
||||
6
tests/_setup_editor_preview.tscn
Normal file
6
tests/_setup_editor_preview.tscn
Normal file
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
136
tests/test_setup_editor.gd
Normal file
136
tests/test_setup_editor.gd
Normal file
|
|
@ -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/<id>.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
|
||||
1
tests/test_setup_editor.gd.uid
Normal file
1
tests/test_setup_editor.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cegx70j0hkj4m
|
||||
Loading…
Reference in a new issue