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)