ajhahn.de
← Theria
GDScript 686 lines
extends SceneTree
## Live top-down map editor: place, drag, and delete the map geometry by clicking, build it
## symmetric across the TL-BR diagonal with one click per side, then write the result straight
## back into src/sim/map_data.gd. Built so the map can be designed by eye instead of by typing
## coordinates — the companion to tools/map_shot.gd (which is a still snapshot; this is live).
##
## Run it (window stays open, it is interactive):
##   godot --path . -s tools/map_editor.gd
##
## Controls
##   1..6         pick the layer: 1 River  2 Camps  3 Nexus  4 Towers  5 LaneT  6 LaneB
##   left-click   empty ground      -> add a point to the active layer (Nexus: drag only)
##                on a point         -> grab it; hold and move to drag
##                on a river segment -> insert a vertex there, splitting it
##   right-click  on a point         -> delete it (Nexus: ignored — always two bases)
##   S            symmetry-lock: edits mirror across the y=x axis automatically (default ON)
##   A            snap the hovered point exactly onto the y=x axis
##   Z            undo the last edit
##   G            grid snap (50)        H  mirror ghost (shown when symmetry-lock is off)
##   wheel        zoom    middle-drag  pan    R  reset the view
##   W            WRITE the layers back into map_data.gd (a .bak is saved first)
##   ESC          quit
##
## The map mirrors AXIALLY across the TL-BR diagonal (the bright line, sim y = x): a point
## (x, y) mirrors to (y, x), which swaps the two bases, so the map stays team-fair. With
## symmetry-lock on, every add/drag/delete keeps a point and its mirror partner in step (river
## vertices pair by order, end-to-end; camps/towers pair by position; an on-axis point is
## its own mirror). The two lanes (LaneT, LaneB) are polylines like the river; each is its own
## mirror across the axis. Lane endpoints are not pinned to the nexus — line them up by eye.

const VIEW := 10560.0  # default world units the orthographic camera frames (past the 9600 bounds)
const SNAP := 50.0  # grid step a placed/dragged point snaps to when snap is on
const GRAB := 312.0  # world-unit radius within which a click grabs an existing point
const INSERT_DIST := 288.0  # how close to a river segment a click must be to insert a vertex
const PAIR_EPS := 192.0  # how close a point must sit to another's mirror to count as its partner
const AXIS_EPS := 2.0  # |x - y| under which a point counts as sitting on the y=x axis
const ZOOM_MIN := 2160.0
const ZOOM_MAX := 17280.0
const ZOOM_STEP := 1.12
const UNDO_MAX := 120
const MAP_PATH := "res://src/sim/map_data.gd"

# The editable layers, in number-key order. `add` is false for layers with a fixed membership
# (the two bases) — those points can be dragged but not created or deleted. `const_name` is the
# MapData const the layer writes back to.
# `poly` layers are ordered polylines (river, lanes): drawn as a connected line, numbered, with
# click-on-segment insert, and — under symmetry-lock — self-mirrored end-to-end. The rest are
# point sets that pair by position. `add` is false for fixed-membership layers (the two bases).
const LAYERS := [
	{"key": "river", "label": "River", "const_name": "RIVER", "add": true, "poly": true,
		"color": Color(0.30, 0.55, 0.85)},
	{"key": "camps", "label": "Camps", "const_name": "JUNGLE_CAMPS", "add": true,
		"color": Color(0.40, 0.70, 0.45)},
	{"key": "nexus", "label": "Nexus", "const_name": "NEXUS_POSITIONS", "add": false,
		"color": Color(0.85, 0.50, 0.85)},
	{"key": "towers", "label": "Towers", "const_name": "TOWER_SLOTS", "add": true,
		"color": Color(0.80, 0.40, 0.40)},
	{"key": "lane_t", "label": "LaneT", "const_name": "LANE_TOP", "add": true, "poly": true,
		"color": Color(0.72, 0.62, 0.40)},
	{"key": "lane_b", "label": "LaneB", "const_name": "LANE_BOTTOM", "add": true, "poly": true,
		"color": Color(0.55, 0.62, 0.42)},
]

var _cam: Camera3D
var _world: Node3D
var _decor: Node3D  # everything redrawn each frame lives under here
var _hud: RichTextLabel

var _model := {}  # layer key -> Array[Vector2], the live geometry being edited
var _layer := 0  # index into LAYERS of the active layer
var _drag := -1  # index of the point being dragged in the active layer, or -1
var _snap_on := true
var _ghost_on := true
var _sym_on := true  # symmetry-lock: keep each point and its axial mirror in step
var _undo: Array = []  # stack of whole-model snapshots for Z

var _prev_keys := {}
var _was_left := false
var _was_right := false
var _was_mid := false
var _last_px := Vector2.ZERO


func _initialize() -> void:
	DisplayServer.window_set_size(Vector2i(1000, 1000))
	DisplayServer.window_set_title("Theria — map editor")
	_world = Node3D.new()
	get_root().add_child(_world)

	# Overhead orthographic camera: sim x reads as screen-x, sim y as screen-down, matching a
	# top-down map on paper and tools/map_shot.gd.
	_cam = Camera3D.new()
	_cam.projection = Camera3D.PROJECTION_ORTHOGONAL
	_cam.size = VIEW
	_cam.position = Vector3(0.0, 6000.0, 0.0)
	_cam.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
	_cam.far = 20000.0
	_world.add_child(_cam)
	_cam.make_current()

	var light := DirectionalLight3D.new()
	light.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
	_world.add_child(light)

	var ground := MeshInstance3D.new()
	var plane := PlaneMesh.new()
	plane.size = MapData.BOUNDS.size
	ground.mesh = plane
	ground.material_override = _mat(Color(0.12, 0.13, 0.15))
	_world.add_child(ground)

	_decor = Node3D.new()
	_world.add_child(_decor)

	var layer2d := CanvasLayer.new()
	get_root().add_child(layer2d)
	_hud = RichTextLabel.new()
	_hud.bbcode_enabled = true
	_hud.scroll_active = false
	_hud.autowrap_mode = TextServer.AUTOWRAP_OFF  # keep the layer tabs on one line
	_hud.mouse_filter = Control.MOUSE_FILTER_IGNORE  # never eat clicks/wheel under the HUD
	_hud.position = Vector2(12.0, 8.0)
	_hud.size = Vector2(980.0, 140.0)
	_hud.add_theme_font_size_override("normal_font_size", 16)
	_hud.add_theme_font_size_override("bold_font_size", 16)
	layer2d.add_child(_hud)

	# A tiny node that catches discrete mouse-wheel events for zoom — the SceneTree script
	# itself does not receive input events, so this forwards them back to us.
	var wheel := WheelCatcher.new()
	wheel.editor = self
	get_root().add_child(wheel)

	_load_model()
	print("map editor — 1..5 layer | click add/drag/insert | right delete | S sym | Z undo | W write")


## Copies the MapData consts into the mutable in-memory model so editing never touches the
## stored geometry until W writes it back.
func _load_model() -> void:
	_model["river"] = _copy(MapData.RIVER)
	_model["camps"] = _copy(MapData.JUNGLE_CAMPS)
	_model["nexus"] = _copy(MapData.NEXUS_POSITIONS)
	_model["towers"] = _copy(MapData.TOWER_SLOTS)
	_model["lane_t"] = _copy(MapData.LANE_TOP)
	_model["lane_b"] = _copy(MapData.LANE_BOTTOM)


## True if `key` is an ordered polyline (river or a lane): drawn connected, numbered, with
## click-on-segment insert and end-to-end self-mirroring.
func _poly(key: String) -> bool:
	for layer in LAYERS:
		if layer["key"] == key:
			return layer.get("poly", false)
	return false


## True if `key`'s points pair by order rather than by position — the polylines (self-mirror,
## end to end) and the nexus pair (the two bases).
func _ordered(key: String) -> bool:
	return key == "nexus" or _poly(key)


func _copy(points: Array) -> Array:
	var out := []
	for p in points:
		out.append(p)
	return out


func _process(_delta: float) -> bool:
	_handle_keys()
	_handle_mouse()
	_handle_camera()
	_redraw()
	_update_hud()
	return false  # keep the editor running; the window close / ESC quits it


# --- input -------------------------------------------------------------------------------------

func _handle_keys() -> void:
	for i in LAYERS.size():
		if _tap(KEY_1 + i):
			_layer = i
			_drag = -1
	if _tap(KEY_G):
		_snap_on = not _snap_on
	if _tap(KEY_H):
		_ghost_on = not _ghost_on
	if _tap(KEY_S):
		_sym_on = not _sym_on
	if _tap(KEY_A):
		_axis_snap()
	if _tap(KEY_Z):
		_undo_last()
	if _tap(KEY_R):
		_cam.size = VIEW
		_cam.position = Vector3(0.0, 6000.0, 0.0)
	if _tap(KEY_W):
		_write_back()
	if _tap(KEY_ESCAPE):
		quit()


func _handle_mouse() -> void:
	var world := _cursor_world()
	var points: Array = _active_points()
	var key: String = _active_layer()["key"]

	var left := Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT)
	if left and not _was_left:
		var hit := _nearest(points, world)
		if hit >= 0:
			_push_undo()
			_drag = hit
		elif _poly(key):
			_push_undo()
			var seg := _nearest_segment(points, world)
			if seg >= 0:
				_drag = _river_insert(points, seg, _place(world))
			else:
				_drag = _add(points, key, _place(world))
		elif _active_layer()["add"]:
			_push_undo()
			_drag = _add(points, key, _place(world))
	elif left and _drag >= 0:
		var newpos := _place(world)
		var partner := _partner(points, _drag, key) if _sym_on else -1
		points[_drag] = newpos
		if _sym_on and partner >= 0 and partner != _drag and not _on_axis(newpos):
			points[partner] = _mirror(newpos)
	elif not left:
		_drag = -1
	_was_left = left

	var right := Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
	if right and not _was_right and _active_layer()["add"]:
		var hit := _nearest(points, world)
		if hit >= 0:
			_push_undo()
			_delete(points, key, hit)
			_drag = -1
	_was_right = right


## Mouse-wheel zoom and middle-button drag-to-pan. Both leave the camera looking straight down,
## so the cursor-to-world mapping stays correct.
func _handle_camera() -> void:
	var mid := Input.is_mouse_button_pressed(MOUSE_BUTTON_MIDDLE)
	var px := get_root().get_mouse_position()
	if mid and _was_mid:
		var per_px := _cam.size / float(get_root().size.y)
		var d := px - _last_px
		_cam.position.x -= d.x * per_px
		_cam.position.z -= d.y * per_px
	_last_px = px
	_was_mid = mid


func _zoom(dir: int) -> void:
	var f := ZOOM_STEP if dir > 0 else 1.0 / ZOOM_STEP
	_cam.size = clampf(_cam.size * f, ZOOM_MIN, ZOOM_MAX)


# --- editing model -----------------------------------------------------------------------------

## Adds a point to a layer. With symmetry-lock on, an off-axis point gets its mirror partner too:
## a river point is mirrored at the far end of the polyline (keeping the end-to-end pairing), a
## point-set point gets a mirrored twin. Returns the index of the user's own point.
func _add(points: Array, key: String, p: Vector2) -> int:
	if _poly(key):
		points.append(p)
		if _sym_on and not _on_axis(p):
			points.insert(0, _mirror(p))  # mirror at the opposite end keeps i <-> last-i
		return points.size() - 1
	points.append(p)
	var idx := points.size() - 1
	if _sym_on and not _on_axis(p):
		points.append(_mirror(p))
	return idx


## Inserts a vertex into the river polyline on segment `seg` (between seg and seg+1). With
## symmetry-lock on, the mirror vertex is inserted on the mirrored segment so the end-to-end
## pairing survives. Returns the index of the user's own new vertex.
func _river_insert(points: Array, seg: int, p: Vector2) -> int:
	if not _sym_on or _on_axis(p):
		points.insert(seg + 1, p)
		return seg + 1
	var mseg := points.size() - 2 - seg  # lower index of the mirrored segment
	var mp := _mirror(p)
	if seg <= mseg:
		points.insert(mseg + 1, mp)  # insert the higher index first so the lower stays valid
		points.insert(seg + 1, p)
		return seg + 1
	points.insert(seg + 1, p)
	points.insert(mseg + 1, mp)
	return seg + 2  # the mirror insert below seg+1 shifted our vertex up by one


## Deletes a point from a layer, taking its mirror partner with it when symmetry-lock is on.
func _delete(points: Array, key: String, i: int) -> void:
	if _ordered(key):
		var j := points.size() - 1 - i
		points.remove_at(i)
		if _sym_on and j != i:
			points.remove_at(j - 1 if j > i else j)
		return
	var m := _mirror(points[i])
	points.remove_at(i)
	if _sym_on:
		var p := _index_near(points, m, PAIR_EPS)
		if p >= 0:
			points.remove_at(p)


## Snaps the point under the cursor onto the y=x axis (its closest point on the line). Leaves any
## mirror partner alone — on-axis points are their own mirror, so a stray twin can be deleted by
## hand if symmetry-lock had made one.
func _axis_snap() -> void:
	var points := _active_points()
	var i := _nearest(points, _cursor_world())
	if i < 0:
		return
	_push_undo()
	var p: Vector2 = points[i]
	var c := (p.x + p.y) * 0.5
	points[i] = _place(Vector2(c, c))


## The index of `i`'s mirror partner in `points`, or -1. River and nexus pair by order (the
## point and its end-to-end opposite); the others pair by position (the point nearest `i`'s
## mirror). A point that is its own partner returns -1.
func _partner(points: Array, i: int, key: String) -> int:
	if _ordered(key):
		var j := points.size() - 1 - i
		return j if j != i else -1
	return _index_near_except(points, _mirror(points[i]), PAIR_EPS, i)


func _mirror(p: Vector2) -> Vector2:
	return Vector2(p.y, p.x)  # reflection across the line y = x


func _on_axis(p: Vector2) -> bool:
	return absf(p.x - p.y) < AXIS_EPS


func _push_undo() -> void:
	var snap := {}
	for k in _model:
		snap[k] = _model[k].duplicate()
	_undo.append(snap)
	if _undo.size() > UNDO_MAX:
		_undo.pop_front()


func _undo_last() -> void:
	if _undo.is_empty():
		return
	_model = _undo.pop_back()
	_drag = -1


# --- geometry queries --------------------------------------------------------------------------

## The cursor's position on the ground plane (y = 0), from the camera ray under the mouse.
func _cursor_world() -> Vector2:
	var m := get_root().get_mouse_position()
	var from := _cam.project_ray_origin(m)
	var dir := _cam.project_ray_normal(m)
	if absf(dir.y) < 0.00001:
		return Vector2.ZERO
	var t := -from.y / dir.y
	var p := from + dir * t
	return Vector2(p.x, p.z)


## A cursor position resolved to where a point should sit: snapped to the grid (if on) and
## clamped inside the playable bounds.
func _place(w: Vector2) -> Vector2:
	var p := w
	if _snap_on:
		p = Vector2(roundf(p.x / SNAP) * SNAP, roundf(p.y / SNAP) * SNAP)
	return MapData.clamp_to_bounds(p)


## Index of the point within `GRAB` reach of the cursor, nearest first, or -1.
func _nearest(points: Array, cursor: Vector2) -> int:
	return _index_near(points, cursor, GRAB)


func _index_near(points: Array, target: Vector2, radius: float) -> int:
	return _index_near_except(points, target, radius, -1)


func _index_near_except(points: Array, target: Vector2, radius: float, skip: int) -> int:
	var best := -1
	var best_d := radius
	for i in points.size():
		if i == skip:
			continue
		var d: float = points[i].distance_to(target)
		if d <= best_d:
			best_d = d
			best = i
	return best


## Index of the river segment (seg..seg+1) within `INSERT_DIST` of the cursor, nearest first, or
## -1 — where a click splits the polyline rather than extending it.
func _nearest_segment(points: Array, cursor: Vector2) -> int:
	var best := -1
	var best_d := INSERT_DIST
	for k in points.size() - 1:
		var d := _segment_distance(points[k], points[k + 1], cursor)
		if d <= best_d:
			best_d = d
			best = k
	return best


func _segment_distance(a: Vector2, b: Vector2, p: Vector2) -> float:
	var ab := b - a
	var len2 := ab.length_squared()
	var t := 0.0 if len2 <= 0.0 else clampf((p - a).dot(ab) / len2, 0.0, 1.0)
	return p.distance_to(a + ab * t)


func _active_layer() -> Dictionary:
	return LAYERS[_layer]


func _active_points() -> Array:
	return _model[_active_layer()["key"]]


# --- drawing -----------------------------------------------------------------------------------

## Clears and rebuilds every visual each frame: grid, axis, bounds, reference lanes, all layers'
## points, the active layer's draggable handles + indices, and the mirror ghosts. Cheap enough
## for a few dozen nodes; redrawing live keeps hover and drag responsive.
func _redraw() -> void:
	for c in _decor.get_children():
		c.free()

	_grid()
	_axis()
	_bounds()

	var cursor := _cursor_world()
	for i in LAYERS.size():
		var layer: Dictionary = LAYERS[i]
		var points: Array = _model[layer["key"]]
		var active: bool = i == _layer
		var poly: bool = layer.get("poly", false)
		if poly:
			_polyline(points, 72.0, layer["color"].darkened(0.2))
		var hover := _nearest(points, cursor) if active else -1
		for j in points.size():
			var radius := 180.0 if active else 108.0
			var color: Color = Color(1.0, 0.95, 0.4) if active and j == hover else layer["color"]
			_disc(points[j], radius, color, 6.0 if active else 3.0)
			if active and poly:
				_index(points[j], j)
		# The ghost previews the predicted mirror only when symmetry-lock is off — with it on the
		# mirror points are real, so a ghost would just double them.
		if active and _ghost_on and not _sym_on:
			for p in points:
				if not _on_axis(p):
					_disc(_mirror(p), 144.0, layer["color"], 4.0, 0.30)


## The coordinate grid: a faint line every 500 units, brighter through the origin — so a spot
## on screen maps back to MapData numbers.
func _grid() -> void:
	for k in range(-4800, 4801, 1200):
		var major := k == 0
		var color := Color(0.45, 0.45, 0.52) if major else Color(0.20, 0.21, 0.25)
		var w := 24.0 if major else 7.0
		_strip(Vector3(float(k), 1.0, 0.0), Vector3(w, 1.0, 9600.0), color)
		_strip(Vector3(0.0, 1.0, float(k)), Vector3(9600.0, 1.0, w), color)


## The mirror axis: the TL-BR diagonal (sim y = x), drawn bright so the symmetry reads.
func _axis() -> void:
	var diag := sqrt(2.0) * 9600.0
	var strip := MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = Vector3(24.0, 1.0, diag)
	strip.mesh = box
	strip.material_override = _mat(Color(0.85, 0.75, 0.35))
	strip.position = Vector3(0.0, 1.5, 0.0)
	strip.rotation.y = deg_to_rad(45.0)  # along (−2000,−2000)→(2000,2000)
	_decor.add_child(strip)


## The playable-bounds outline, so the map edges read.
func _bounds() -> void:
	var b := MapData.BOUNDS
	var color := Color(0.35, 0.35, 0.40)
	_strip(Vector3(b.position.x, 1.0, b.get_center().y), Vector3(20.0, 1.0, b.size.y), color)
	_strip(Vector3(b.end.x, 1.0, b.get_center().y), Vector3(20.0, 1.0, b.size.y), color)
	_strip(Vector3(b.get_center().x, 1.0, b.position.y), Vector3(b.size.x, 1.0, 20.0), color)
	_strip(Vector3(b.get_center().x, 1.0, b.end.y), Vector3(b.size.x, 1.0, 20.0), color)


## Draws a polyline as a ribbon of connected segments — the river course or a reference lane.
func _polyline(points: Array, width: float, color: Color) -> void:
	for i in points.size() - 1:
		var a: Vector2 = points[i]
		var b: Vector2 = points[i + 1]
		var delta := b - a
		var length := delta.length()
		if length <= 0.0:
			continue
		var strip := MeshInstance3D.new()
		var box := BoxMesh.new()
		box.size = Vector3(width, 1.0, length)
		strip.mesh = box
		strip.material_override = _mat(color)
		var mid := (a + b) * 0.5
		strip.position = Vector3(mid.x, 2.0, mid.y)
		strip.rotation.y = atan2(delta.x, delta.y)
		_decor.add_child(strip)


func _strip(pos: Vector3, size: Vector3, color: Color) -> void:
	var strip := MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = size
	strip.mesh = box
	strip.material_override = _mat(color)
	strip.position = pos
	_decor.add_child(strip)


func _disc(pos: Vector2, radius: float, color: Color, lift := 3.0, alpha := 1.0) -> void:
	var disc := MeshInstance3D.new()
	var cyl := CylinderMesh.new()
	cyl.top_radius = radius
	cyl.bottom_radius = radius
	cyl.height = 1.0
	disc.mesh = cyl
	disc.material_override = _mat(Color(color.r, color.g, color.b, alpha))
	disc.position = Vector3(pos.x, lift, pos.y)
	_decor.add_child(disc)


func _index(pos: Vector2, n: int) -> void:
	var label := Label3D.new()
	label.text = str(n)
	label.font_size = 216
	label.modulate = Color.WHITE
	label.position = Vector3(pos.x, 10.0, pos.y)
	label.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
	_decor.add_child(label)


func _mat(color: Color) -> StandardMaterial3D:
	var mat := StandardMaterial3D.new()
	mat.albedo_color = color
	if color.a < 1.0:
		mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	return mat


## The on-screen overlay: the layer tabs (each in its own colour, the active one bold), a live
## coordinate readout of the cursor and the hovered point, the toggle states, and the controls.
func _update_hud() -> void:
	var tabs := PackedStringArray()
	for i in LAYERS.size():
		var layer: Dictionary = LAYERS[i]
		var hexc: String = layer["color"].to_html(false)
		var name := "%d %s(%d)" % [i + 1, layer["label"], _model[layer["key"]].size()]
		if i == _layer:
			tabs.append("[b][color=#%s]▸%s[/color][/b]" % [hexc, name])
		else:
			tabs.append("[color=#%s]%s[/color]" % [hexc, name])

	var points := _active_points()
	var cur := _place(_cursor_world())
	var read := "cursor (%d, %d)" % [int(cur.x), int(cur.y)]
	var hi := _nearest(points, _cursor_world())
	if hi >= 0:
		read += "    point[%d] (%d, %d)" % [hi, int(points[hi].x), int(points[hi].y)]

	var flags := "sym %s(S)  snap %s(G)  ghost %s(H)" % [
		_onoff(_sym_on), _onoff(_snap_on), _onoff(_ghost_on)
	]
	var ctl := "click add/drag | click line=insert | right-del | A axis | Z undo"
	var ctl2 := "wheel/middrag/R camera | W write | ESC quit"
	_hud.text = "  ".join(tabs) + "\n" + read + "\n" + flags + "\n" + ctl + "  |  " + ctl2


func _onoff(b: bool) -> String:
	return "ON" if b else "off"


# --- write-back --------------------------------------------------------------------------------

## Writes every layer back into the matching const in map_data.gd, after saving a .bak. Each
## const is rewritten body-only: the `## doc` block and the `const … = [` / `]` lines are kept,
## and the element lines between them are replaced with the live points. (Per-element inline
## comments inside an array are not preserved — re-add any once the shape is final.)
func _write_back() -> void:
	var f := FileAccess.open(MAP_PATH, FileAccess.READ)
	if f == null:
		push_error("map editor: cannot read %s" % MAP_PATH)
		return
	var src := f.get_as_text()
	f.close()

	var bak := FileAccess.open(MAP_PATH + ".bak", FileAccess.WRITE)
	bak.store_string(src)
	bak.close()

	for layer in LAYERS:
		src = _replace_const(src, layer["const_name"], _model[layer["key"]])

	var w := FileAccess.open(MAP_PATH, FileAccess.WRITE)
	w.store_string(src)
	w.close()
	print("map editor: wrote %s (backup: map_data.gd.bak)" % MAP_PATH)


## Replaces the element lines of `const <name>: Array[Vector2] = [ … ]` with `points`, keeping
## the declaration line, the closing `]`, and everything outside the array untouched.
func _replace_const(src: String, name: String, points: Array) -> String:
	var lines := src.split("\n")
	var start := -1
	for i in lines.size():
		if lines[i].strip_edges().begins_with("const " + name):
			start = i
			break
	if start == -1:
		push_warning("map editor: const %s not found — skipped" % name)
		return src
	var endi := -1
	for i in range(start + 1, lines.size()):
		if lines[i].strip_edges() == "]":
			endi = i
			break
	if endi == -1:
		push_warning("map editor: closing ] for %s not found — skipped" % name)
		return src

	var out := PackedStringArray()
	for i in start + 1:  # keep lines 0..start (the doc block + the `= [` line)
		out.append(lines[i])
	for p in points:
		out.append("\tVector2(%s, %s)," % [_num(p.x), _num(p.y)])
	for i in range(endi, lines.size()):  # keep the `]` and everything after
		out.append(lines[i])
	return "\n".join(out)


func _num(v: float) -> String:
	return "%.1f" % v


# --- input edge detection ----------------------------------------------------------------------

## True only on the frame `code` transitions from up to down — so a held key fires once.
func _tap(code: int) -> bool:
	var down := Input.is_physical_key_pressed(code)
	var was: bool = _prev_keys.get(code, false)
	_prev_keys[code] = down
	return down and not was


## Catches discrete mouse-wheel events (which have no held state to poll) and forwards them to
## the editor as zoom. The SceneTree script gets no input events itself; a node in the tree does.
class WheelCatcher:
	extends Node

	var editor

	func _unhandled_input(event: InputEvent) -> void:
		if event is InputEventMouseButton and event.pressed:
			if event.button_index == MOUSE_BUTTON_WHEEL_UP:
				editor._zoom(-1)
			elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
				editor._zoom(1)