647 lines
24 KiB
GDScript
647 lines
24 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://enemies/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://enemies/EnemyMarker.gd")
|
|
const PLANET_SETUP_SCRIPT = preload("res://surface/PlanetSetup.gd")
|
|
const PLANET_SETUP_SPAWN_SCRIPT = preload("res://surface/PlanetSetupSpawn.gd")
|
|
const PALETTE_ENTRY_BUTTON_SCRIPT = preload("res://ui/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
|
|
@onready var load_title_label: Label = $UI/LoadPanel/TitleLabel
|
|
@onready var delete_map_button: IconButton = $UI/LoadPanel/DeleteMapButton
|
|
|
|
var current_setup: Resource
|
|
# The file the open setup was loaded from, or "" for one that has never been
|
|
# saved. Saving writes HERE rather than to a path derived from setup_id - see
|
|
# PlanetSetups.path_for_id for why deriving it forks a duplicate.
|
|
var current_setup_path: String = ""
|
|
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()
|
|
# While armed, tapping a row in the load panel DELETES that setup instead of
|
|
# loading it. One button and one meaning per tap, rather than a second list
|
|
# or a per-row delete affordance that would be easy to hit by accident on a
|
|
# phone - and the panel says plainly which mode it is in.
|
|
var _delete_map_armed: bool = false
|
|
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:
|
|
# Scripted encounters own their layout and rules, so their profiles opt
|
|
# out rather than being special-cased here.
|
|
_profile_ids = PlanetSurfaceProfiles.editor_profile_ids()
|
|
back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://ui/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(_on_load_close_pressed)
|
|
delete_map_button.pressed.connect(_on_delete_map_pressed)
|
|
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_path = ""
|
|
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)
|
|
# duplicate() deliberately has no resource_path of its own, so the
|
|
# source file is remembered separately - saving must go back to
|
|
# the file this was opened from.
|
|
current_setup_path = String(setup.resource_path)
|
|
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 = current_setup_path if current_setup_path != "" \
|
|
else "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}
|
|
current_setup_path = path
|
|
PlanetSetups.rescan()
|
|
return {"ok": true, "message": "Saved '%s'." % current_setup.setup_id}
|
|
|
|
|
|
func delete_setup(setup_id: String) -> Dictionary:
|
|
# Deliberately mirrors save_setup(): same editor-only guard, same
|
|
# {ok, message} shape, and the same rescan afterwards so the in-memory
|
|
# registry stops offering a file that is no longer there.
|
|
if setup_id.strip_edges() == "":
|
|
return {"ok": false, "message": "No setup to delete."}
|
|
if not OS.has_feature("editor"):
|
|
return {"ok": false, "message": "Setups can only be deleted when running from the project."}
|
|
# Asked of the registry, not assembled from the id - see
|
|
# PlanetSetups.path_for_id. A setup whose filename differs from its id
|
|
# could not be deleted at all before this, which is why the first entry in
|
|
# the list appeared to be undeletable: it is the one with the mismatch.
|
|
var path: String = PlanetSetups.path_for_id(setup_id)
|
|
if not ResourceLoader.exists(path):
|
|
return {"ok": false, "message": "No setup file for '%s'." % setup_id}
|
|
var err: int = DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
|
if err != OK:
|
|
return {"ok": false, "message": "Delete failed (error %d)." % err}
|
|
# Godot writes a .uid sidecar next to a saved resource; leaving it behind
|
|
# makes the next import complain about a uid with no file.
|
|
var uid_path: String = ProjectSettings.globalize_path(path + ".uid")
|
|
if FileAccess.file_exists(uid_path):
|
|
DirAccess.remove_absolute(uid_path)
|
|
PlanetSetups.rescan()
|
|
# The deleted setup may be the one on screen. Leaving it loaded would let
|
|
# the next save silently recreate the file the player just removed.
|
|
if current_setup != null and String(current_setup.setup_id) == setup_id:
|
|
new_setup("untitled", "Untitled", _profile_ids[_profile_cursor])
|
|
elif current_setup_path == path:
|
|
# Same file under a different id - the open copy has nowhere to go
|
|
# back to either.
|
|
current_setup_path = ""
|
|
return {"ok": true, "message": "Deleted '%s'." % 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
|
|
# The exact call PlanetSurfaceController makes when landing, on the exact
|
|
# same instanced scene. Setting the colours here by hand is what let the
|
|
# editor and the game drift: this side never swapped in the profile's own
|
|
# SHADER, so a gas giant authored here was drawn as recoloured craters
|
|
# while the game drew cloud bands. See the header of SurfaceBackground.gd.
|
|
var profile: Resource = PlanetSurfaceProfiles.get_profile(current_setup.surface_profile_id)
|
|
background.apply_profile(profile)
|
|
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))
|
|
if REWARD_OPTIONS.is_empty():
|
|
return
|
|
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
|
|
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:
|
|
if not _refresh_load_list():
|
|
return
|
|
_delete_map_armed = false
|
|
_refresh_load_panel_mode()
|
|
load_panel.visible = true
|
|
|
|
|
|
func _refresh_load_list() -> bool:
|
|
var setups: Array = PlanetSetups.all()
|
|
if setups.is_empty():
|
|
_set_status("No saved setups yet.")
|
|
load_panel.visible = false
|
|
return false
|
|
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)
|
|
return true
|
|
|
|
|
|
func _refresh_load_panel_mode() -> void:
|
|
if load_title_label != null:
|
|
load_title_label.text = "Tap a setup to DELETE" if _delete_map_armed else "Load a setup"
|
|
|
|
|
|
func _on_delete_map_pressed() -> void:
|
|
_delete_map_armed = not _delete_map_armed
|
|
_refresh_load_panel_mode()
|
|
|
|
|
|
func _on_load_close_pressed() -> void:
|
|
load_panel.visible = false
|
|
_delete_map_armed = false
|
|
_refresh_load_panel_mode()
|
|
|
|
|
|
func _on_load_list_item_selected(index: int) -> void:
|
|
if index < 0 or index >= _load_list_setup_ids.size():
|
|
return
|
|
var setup_id: String = _load_list_setup_ids[index]
|
|
if _delete_map_armed:
|
|
var outcome: Dictionary = delete_setup(setup_id)
|
|
_set_status(outcome.message)
|
|
# Disarmed either way: a delete that failed should not leave the next
|
|
# tap primed to try again on a different setup.
|
|
_delete_map_armed = false
|
|
if not _refresh_load_list():
|
|
return
|
|
_refresh_load_panel_mode()
|
|
return
|
|
load_setup(setup_id)
|
|
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)
|