OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / operators / level_operator.gd

488 lines · gd ref:
extends Node
class_name LevelOperator

## The keyboard-layout rule for the keys a tool reads itself (keymap step 5): letters by
## character, [ ] and the rest by position. Preloaded, not the Keymap autoload, because
## operators compile before autoloads exist in --script gates.
const KeyRules := preload("res://autoloads/key_rules.gd")
## THE OPERATOR CONTRACT — activate, adjust, confirm or cancel.
##
## What we had was not a tool model: every binding mutated immediately. `C` cut on the
## spot using a plane inferred from wherever you happened to be standing, because
## there was no moment in which to AIM it. That is also why a mis-aimed cut wrote a
## record that replicated to every peer and had to be undone.
##
## TrenchBroom's shape, from the source (lib/TbAppLib/include/ui/Tool.h,
## ToolController.h, ToolChain.h): a Tool has activate()/deactivate() and an active()
## state, a ToolController translates input while it is active, and a chain passes
## input down until something handles it. Escape is first-wins down that chain.
##
## Two properties matter more here than they do there:
##
##   NOTHING IS WRITTEN UNTIL CONFIRM. A cancelled operator leaves the journal
##   untouched, so no peer ever sees it and there is nothing to undo. In a
##   single-user desktop editor a stray undo is an annoyance; in a shared room it is
##   an edit someone else watched happen.
##
##   MOVEMENT KEEPS WORKING. You are flying while you edit. An operator that
##   swallowed WASD would strand you, so handle_key returns whether it consumed the
##   event and unclaimed keys fall through untouched.

## The DCLevel node, and the map that owns the aim, grid and journal.
var level: Node = null
var map: Node = null


## Display name, for the HUD row.
func op_name() -> String:
	return "operator"


# ---- THE MODAL CONVENTION (#120 F3, ModalControls) ----------------------------------
# A migrated operator builds its key table + values once (modal()), asks
# modal().handle_key() first in handle_key, and returns modal().status_text() from
# status(). The help overlay (H) and the status line are then the same table.

var _modal: ModalControls = null


func modal() -> ModalControls:
	if _modal == null:
		_modal = ModalControls.new()
		setup_modal(_modal)
	return _modal


## Fill the table and bind values (override). Called once, on first use.
func setup_modal(_m: ModalControls) -> void:
	pass


func has_modal() -> bool:
	return _modal != null


## The current setting, shown beside the name — "axis X · grid 8u · Enter to cut".
## This is where a modal operator earns its keep: the state is visible instead of
## being inferred from what happened last time.
func status() -> String:
	return ""


## False REFUSES activation — nothing to act on, so the operator never starts and the
## key press reads as "not here" rather than as a mode you have to escape out of.
func on_activate() -> bool:
	return true


func on_deactivate() -> void:
	pass


## True if this operator consumed the event. Anything it does not claim falls through
## to the map, and from there to movement.
func handle_key(_event: InputEvent) -> bool:
	return false


## Per-frame, for previews that follow the aim.
func update_preview() -> void:
	pass


## True if this tool's value follows the POINTER (aim_steps) — the map then wraps the
## mouse at the viewport edge while it is up, so a long drag never runs out of window
## (#109, Blender's continuous grab; map_dc_level._update_pointer_wrap_at).
func follows_aim() -> bool:
	return false


## True if moving the mouse drags this tool's FOCUSED value (#120 §17.7 Q2, Blender's):
## only a tool that does not use the pointer to AIM while it is up (rotate, scale, the
## cable tools that work on a selection). The map then wraps the pointer as for follows_aim.
func drags_value() -> bool:
	return false


## Commit exactly one journal record. True if something was written.
func confirm() -> bool:
	return false


# ---- tool settings (editor redesign E4, docs/editor-redesign.md §8b) ---------
#
# SETTINGS ARE DATA. One generator (ToolSettingsBar) renders them, so the row, the ⋯
# popover and the gates read the same list — the action-registry idea applied to a
# tool's own parameters. A descriptor:
#   {id, label, type: "enum"|"int"|"float"|"bool"|"action", value, choices: [String],
#    min, max, step, unit: "grid"|"deg"|"", more: bool, tip}
# `value` for an enum is the choice INDEX. An "action" has no value; setting it runs it.

## The active tool's settings, current values filled in.
func tool_settings() -> Array:
	return []


## Apply one setting (from the bar, a typed value, or a gate). True if it was known.
## Implementations refresh their preview; the bar re-reads tool_settings() next frame.
func set_tool_setting(_sid: String, _value: Variant) -> bool:
	return false


## The settings row's commit button: through the stack, so a click commits exactly like
## Enter (one confirm, then the tool is left). A stub map with no stack confirms directly.
func confirm_via_stack() -> bool:
	if map != null and map.has_method("_ensure_ops"):
		return bool(map.call("_ensure_ops").confirm())
	return confirm()


# ---- history + adjust last (editor redesign E5) -------------------------------

## What the History panel calls this tool's gesture ("Rotate", "Stairs").
func gesture_label() -> String:
	return op_name().capitalize()


## ADJUST LAST (Blender F9): an operator that can be RE-RUN from a stored placement with
## different settings opts in by returning the setting ids that may change afterwards.
## OperatorStack takes adjust_snapshot() just before a confirm; an adjustment is: undo that
## gesture, adjust_restore(snapshot) (the operator now acts on the stored placement, not
## the crosshair), set_tool_setting(id, v), confirm() as a new gesture, adjust_end().
func adjust_ids() -> Array:
	return []


func adjust_snapshot() -> Dictionary:
	return {}


func adjust_restore(_snap: Dictionary) -> void:
	pass


func adjust_end() -> void:
	clear_preview()


## The three axis names as an enum's choices, and back — shared by every axis tool.
const AXIS_CHOICES := ["X", "Y", "Z"]


static func axis_index(a: String) -> int:
	return maxi(0, ["x", "y", "z"].find(a))


static func axis_name(i: int) -> String:
	return ["x", "y", "z"][clampi(i, 0, 2)]


## Short text drawn under the crosshair while the tool is up ("+64u X", "+90° Y"). "" = none.
func readout() -> String:
	return ""


## STICKY SETTINGS: what a player set last time is what the tool starts with next time
## (Blender keeps last-used tool settings). Per player, local (§4a): BuildDock prefs
## `tool.<op_name>.<id>`. Only the MAP calls load_prefs(), so the stub-map operator gates
## start from the defaults they assert and never from whatever this machine last used.
var prefs_enabled := false


func load_prefs() -> void:
	prefs_enabled = true


func pref(sid: String, default: Variant) -> Variant:
	var dock := _dock()
	if not prefs_enabled or dock == null:
		return default
	return dock.pref("tool.%s.%s" % [op_name(), sid], default)


func save_pref(sid: String, value: Variant) -> void:
	var dock := _dock()
	if prefs_enabled and dock != null:
		dock.set_pref("tool.%s.%s" % [op_name(), sid], value)


func _dock() -> Node:
	var tree := Engine.get_main_loop() as SceneTree
	return tree.root.get_node_or_null("BuildDock") if tree != null else null


# ---- objects in SCENE space (reparenting, demo sprint §3) ---------------------
#
# Move / Rotate / Scale work on objects in scene space and let the map write each one in
# its parent's space. A stub map (operator gates) has no hierarchy: the node's own
# transform is its scene transform there.

func obj_get(dot: String) -> Transform3D:
	if map.has_method("object_xform"):
		return map.object_xform(dot)
	var n := map.object_node(dot) as Node3D
	return n.transform if n != null else Transform3D.IDENTITY


func obj_set(dot: String, t: Transform3D) -> void:
	if map.has_method("set_object_xform_live"):
		map.set_object_xform_live(dot, t)
		return
	var n := map.object_node(dot) as Node3D
	if n != null:
		n.transform = t


## Drop selected objects whose ancestor is selected too (they move with it).
func obj_roots(dots: Array) -> Array:
	return map.transform_roots(dots) if map.has_method("transform_roots") else dots.duplicate()


# ---- preview drawing --------------------------------------------------------
#
# Shared because there are four operators now and each one wants the same thing: a
# throwaway mesh in the level's space, drawn on top, cleared when the tool goes away.
# Copied four times it would drift four ways — and the colour convention IS the
# language of the tool (red = about to cut, amber = about to move), so it belongs
# somewhere one person can change it.

var _preview_mi: MeshInstance3D = null


## The colour convention IS the language of the tool: red is about to cut, amber is
## about to move, green is about to create. Overridden per operator.
func preview_color() -> Color:
	return Color(1.0, 0.6, 0.2, 0.45)


func preview() -> MeshInstance3D:
	if _preview_mi != null and is_instance_valid(_preview_mi):
		return _preview_mi
	_preview_mi = MeshInstance3D.new()
	_preview_mi.name = "%sPreview" % op_name().capitalize()
	_preview_mi.mesh = ImmediateMesh.new()
	var m := StandardMaterial3D.new()
	m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	m.albedo_color = preview_color()
	m.cull_mode = BaseMaterial3D.CULL_DISABLED   # you may be on either side of it
	m.no_depth_test = true                       # a preview you cannot see is not one
	m.use_point_size = true
	m.point_size = 9.0
	_preview_mi.material_override = m
	_preview_mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	level.add_child(_preview_mi)
	return _preview_mi


func clear_preview() -> void:
	if _preview_mi != null and is_instance_valid(_preview_mi):
		(_preview_mi.mesh as ImmediateMesh).clear_surfaces()


## Line pairs, as the store hands them back.
func draw_lines(pts: PackedVector3Array, offset := Vector3.ZERO) -> void:
	if pts.size() < 2:
		return
	var im: ImmediateMesh = preview().mesh
	im.surface_begin(Mesh.PRIMITIVE_LINES)
	for p: Vector3 in pts:
		im.surface_add_vertex(p + offset)
	im.surface_end()


## Loose points — placed handles, for an operator you build up a click at a time.
## The material already carries point_size, for the same reason brush_view does: a
## point without one renders as a single pixel and is invisible at any distance.
func draw_points(pts: PackedVector3Array) -> void:
	if pts.is_empty():
		return
	var im: ImmediateMesh = preview().mesh
	im.surface_begin(Mesh.PRIMITIVE_POINTS)
	for p: Vector3 in pts:
		im.surface_add_vertex(p)
	im.surface_end()


## A convex polygon, filled and outlined. Filled alone reads as a ghost wall; outline
## alone disappears edge-on. A brush face is convex by construction, so the fan is
## exact rather than an approximation.
func draw_polygon(pts: PackedVector3Array, offset := Vector3.ZERO) -> void:
	if pts.size() < 3:
		return
	var im: ImmediateMesh = preview().mesh
	im.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
	for i in range(1, pts.size() - 1):
		im.surface_add_vertex(pts[0] + offset)
		im.surface_add_vertex(pts[i] + offset)
		im.surface_add_vertex(pts[i + 1] + offset)
	im.surface_end()
	im.surface_begin(Mesh.PRIMITIVE_LINES)
	for i in range(pts.size()):
		im.surface_add_vertex(pts[i] + offset)
		im.surface_add_vertex(pts[(i + 1) % pts.size()] + offset)
	im.surface_end()


# ---- the drag ---------------------------------------------------------------
#
# EVERY OPERATOR ADJUSTS ONE NUMBER ALONG ONE DIRECTION, and until now the only way
# to set that number was to count keypresses on `[` and `]`. That is not a drag, it
# is a stepper — you cannot see where a face is going while you tap it there, which
# is the whole reason Blender's G follows the pointer.
#
# Here the mouse IS the camera, so the pointer is the crosshair ray. The value is the
# point on the line `grab + t * axis` closest to that ray, which is the exact form of
# what Blender computes in screen space: look along a wall and the face slides along
# it. It is also ZERO at activation with no jump, because the ray passes through the
# grab point, so the grab point is its own closest point.
#
# `[` and `]` remain, and are no longer decoration: they are the fallback for the
# degenerate case below, and the way to place something exactly when you are not in a
# position to look at it. (They also only started reaching operators at all in the
# same change as this — see map_dc_level's dispatch comment.)

## How parallel the axis and the view may be before the drag is meaningless. At 0.98
## the two lines are within ~11 degrees, where a small turn of the head swings the
## closest point enormously.
const AIM_PARALLEL := 0.98


## Where the crosshair ray meets the constraint axis, as a distance along it from
## `grab`. NAN when you are looking too nearly ALONG the axis — a real degeneracy,
## not a numerical one: aiming carries no information about depth when the axis
## points at your eye, so there is nothing for any amount of maths to recover.
## Callers hold their last value on NAN rather than following it, because a brush
## that leaps 400 m because you glanced down its own axis is an edit you then have to
## hunt for and undo.
func aim_along(grab: Vector3, axis: Vector3) -> float:
	var ray: Dictionary = map._crosshair_ray()
	if ray.is_empty():
		return NAN
	var origin: Vector3 = ray["from"]
	var dir: Vector3 = ray["dir"]
	var b: float = axis.dot(dir)
	if absf(b) > AIM_PARALLEL:
		return NAN
	# Closest approach of two lines: t = (b*e - d) / (1 - b^2), with both directions
	# unit length, w0 = grab - origin, d = axis.w0, e = dir.w0.
	var w0: Vector3 = grab - origin
	return (b * dir.dot(w0) - axis.dot(w0)) / (1.0 - b * b)


## Whether the last aim_steps() had a usable ray. Lives here so status() can SAY the
## drag is unavailable — a tool that silently stops responding reads as broken, which
## is the failure this whole line of work started from.
var aim_ok: bool = true


## The same distance as a whole number of grid steps, which is the form every
## operator actually wants: the geometry was authored on the lattice and has to stay
## on it, so a drag is a step count you can see rather than a float you cannot.
func aim_steps(grab: Vector3, axis: Vector3, fallback: int) -> int:
	var t: float = aim_along(grab, axis)
	aim_ok = not is_nan(t)
	if not aim_ok:
		return fallback
	return roundi(t / grid_step())


## One grid step in metres, and the map's grid in Quake units — every operator needs
## both, and reaching into the map for them four times over is how they drift.
##
## The EFFECTIVE step (editor redesign E2): the map applies the snap toggle, Ctrl-invert
## and Shift-precision, so every operator and every placement snap follows them through
## these two functions. Float: the grid reaches 0.25 u and precision divides by 8.
func grid_units() -> float:
	# A map without the E2 snap model (the operator gates' stub maps) still has its
	# grid table — read that rather than requiring every map to grow the method.
	if map.has_method("grid_units"):
		return float(map.grid_units())
	return float(map.GRID_UNITS[map._grid_idx])


func grid_step() -> float:
	return grid_units() * map.WORLD_SCALE


## "snap off: 1u steps" while snapping is off (toggle, or Ctrl held in a tool), else "".
## Snap off ALSO shrinks every size step to 1u, so a box's [ ] / wheel moves in hair-
## widths and reads as "the keys do nothing" — the line has to say why.
func snap_note() -> String:
	if map == null or not map.has_method("snap_active") or map.snap_active():
		return ""
	return "snap off: %s steps" % fmt_units(grid_units())


## A length as the player reads it — Quake units or metres (Settings "editor_units").
func fmt_units(u: float) -> String:
	return String(map.fmt_units(u)) if map != null and map.has_method("fmt_units") else "%su" % String.num(u, 3)


# ---- PROPORTIONAL EDITING, shared by Rotate and Scale (#109; Move has its own) ----
#
# map.proportional_neighbours names the units near the selection with their weights.
# The operator previews their brushes as weighted proxies and their objects live, and on
# confirm hands them to map.transform_units / move_objects AFTER its own edit.

var _prop_units: Array = []
var _prop_ids: Dictionary = {}        # neighbour brush id -> w (the ones lifted)
var _prop_obj: Dictionary = {}        # neighbour object dot -> [start Transform3D, w]


func prop_on() -> bool:
	return map != null and map.has_method("proportional_neighbours") and map.get("proportional") == true


## Who is near `sel_ids` / `sel_dots`, and with what weight. Puts any live-moved
## neighbour objects back first (the radius can change while the tool is up).
func prop_collect(sel_ids: Array, sel_dots: Array) -> void:
	prop_restore_objects()
	_prop_units = []
	_prop_ids.clear()
	_prop_obj.clear()
	if not prop_on():
		return
	_prop_units = map.proportional_neighbours(sel_ids, sel_dots)
	for u: Dictionary in _prop_units:
		var w := float(u["w"])
		match String(u["kind"]):
			"shape":
				for b: int in level.get_group_brush_ids(String(u["shape"])):
					_prop_ids[b] = w
			"brush":
				var b2: int = level.brush_by_key(String(u["key"]))
				if b2 >= 0:
					_prop_ids[b2] = w
			"object":
				_prop_obj[String(u["dot"])] = [obj_get(String(u["dot"])), w]


func prop_restore_objects() -> void:
	for dot: String in _prop_obj:
		obj_set(dot, (_prop_obj[dot] as Array)[0])


## The radius as a ring round `c` (Blender draws the same circle).
func prop_ring(c: Vector3) -> PackedVector3Array:
	var out := PackedVector3Array()
	var r := float(map.get("prop_radius"))
	for i in 48:
		var a0 := TAU * float(i) / 48.0
		var a1 := TAU * float(i + 1) / 48.0
		out.append(c + Vector3(cos(a0), 0.0, sin(a0)) * r)
		out.append(c + Vector3(cos(a1), 0.0, sin(a1)) * r)
	return out


## PgUp / PgDn while the tool is up: the radius ×1.25 / ÷1.25. True when it was ours.
func prop_radius_key(event: InputEvent) -> bool:
	if not prop_on() or not (event is InputEventKey) or not (event as InputEventKey).pressed:
		return false
	var k := KeyRules.key_of(event as InputEventKey)
	if k != KEY_PAGEUP and k != KEY_PAGEDOWN:
		return false
	var r := float(map.get("prop_radius"))
	map.set("prop_radius", r * 1.25 if k == KEY_PAGEUP else r / 1.25)
	return true