OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / build_hud.gd

459 lines · gd ref:
extends CanvasLayer
## THE BUILD-MODE UI — visible while building, generated from the keymap.
##
## Build mode had no interface at all: it announced itself with print statements to a
## terminal the player cannot see, and the only way to know what a key did was to read
## the source. So this is the mode's actual surface — what mode you are in, and what
## you can do right now.
##
## IT IS GENERATED, exactly like the F1 help screen, and for the same reason: a
## hand-written panel is a second claim about what the keys are, and it is wrong the
## first time one moves. Both read Keymap.bindings(), so the UI and the hotkeys cannot
## disagree — which is the property asked for ("it should line up with the hotkey
## options we have"), and it is a property of where the data comes from rather than of
## anyone remembering to update two files.
##
## It shows the ACTIVE contexts only. In build mode that is global + builder +
## build_mode, and the player row is gone — which is honest, because the exclusive
## context really has taken your legs away.


var _operator: String = ""
## What `B` will pick and therefore what `G` will move. A mode you were not told you
## were in is the failure mode modes have — the same reason the operator line exists.
var _select_mode: String = ""
var _title: Label = null
var _text: RichTextLabel = null


func _ready() -> void:
	process_mode = Node.PROCESS_MODE_ALWAYS
	_build()
	GameMode.mode_changed.connect(func(_m: String) -> void: _render())
	GameMode.mode_changed.connect(_on_mode_for_first_run)
	# CURSOR and FLY are exactly the sort of mode you must be told you are in —
	# "my clicks do nothing" is indistinguishable from a broken panel otherwise —
	# so the status line re-renders on the pointer sub-mode too.
	GameMode.build_submode_changed.connect(func(_s: String) -> void: _render())
	_render()


## TWO PANELS, NOT ONE, and the split is the point.
##
## This used to be a single top-right overlay with its own chrome, its own anchor
## and no height bound, so it grew off the bottom of the screen as the keymap grew.
## It is now content in BuildDock, which owns all of that — but it cannot simply
## become one collapsible section, because half of it must never be collapsed:
##
##   STATUS   which mode, which select mode, which operator is live. A modal tool
##            that does not announce itself is the failure mode modes have, and
##            hiding it behind a disclosure triangle recreates that exactly.
##   HOTKEYS  the generated key reference. Long, read occasionally, and the thing
##            that was making the panel outgrow the window. Collapsed by default.
##
## Both are still GENERATED from Keymap.bindings(), which is the property that
## matters: a hand-written list is a second claim about what the keys are and it
## is wrong the first time one moves.
func _build() -> void:
	# STATUS MOVED TO THE STATUS BAR (editor redesign E1): a fixed row under the bottom
	# region that never collapses — the property the paragraph above asked of it — and
	# that now also carries the generated key hints. _title stays as the model of the
	# mode line; it is no longer a dock panel.
	_title = Label.new()

	_text = RichTextLabel.new()
	_text.bbcode_enabled = true
	_text.fit_content = true
	_text.custom_minimum_size = Vector2(BuildDock.DOCK_WIDTH - 40, 0)
	BuildDock.register("hotkeys", "Hotkeys", _text, false)


## The active operator and its current setting. A modal tool must announce itself —
## being in a mode you were not told about is the failure mode modes have.
func set_operator(line: String) -> void:
	if line == _operator:
		return
	_operator = line
	_render()


func set_select_mode(mode: String) -> void:
	if mode == _select_mode:
		return
	_select_mode = mode
	_render()


## Rebuilt on show rather than cached: a rebind between openings must be reflected,
## and the cost is a few dozen string joins.
func _render() -> void:
	# THE MODE IS IN THE TITLE, not only in the key list. It is sticky and it changes
	# what every click and every G does — selecting "brush" and forgetting reads
	# exactly like "vertex selection is broken, it grabs the whole brush".
	_title.text = _operator
	if _operator == "":
		_title.text = "BUILD — SELECT %s" % _select_mode.to_upper() if _select_mode != "" else "BUILD MODE"
	# The pointer sub-mode belongs in the title for the same reason the select mode
	# does: it silently changes what the mouse does, and "my clicks do nothing" is
	# indistinguishable from a broken panel if nothing says you are in FLY.
	if GameMode.is_building():
		_title.text += "  [%s]" % ("CURSOR" if GameMode.is_cursor() else "FLY")
	# An active operator turns the header red, so "a tool is live" is legible without
	# reading the words.
	_title.add_theme_color_override("font_color",
		Color(1.0, 0.4, 0.35) if _operator != "" else Color(1.0, 0.7, 0.2))
	_render_status()

	var head := ""
	if _select_mode != "":
		head = "[color=#7fe3b0]select: %s[/color]\n" % _select_mode.to_upper()

	var by_context: Dictionary = {}
	for b: Dictionary in Keymap.bindings():
		var c: String = String(b["context"])
		# ONLY WHAT IS REACHABLE. Listing suppressed player actions would be a lie:
		# build_mode is exclusive, so `jump` genuinely does nothing here.
		if not Keymap.is_context_active(c):
			continue
		if String(b["keys"]) == "":
			continue
		if not by_context.has(c):
			by_context[c] = []
		(by_context[c] as Array).append(b)

	var out := ""
	for c: String in ["builder", "build_mode", "global"]:
		if not by_context.has(c):
			continue
		var rows: Array = by_context[c]
		rows.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
			return String(a["label"]) < String(b["label"]))
		out += "\n[color=#c9a227]%s[/color]\n" % c.to_upper()
		for b: Dictionary in rows:
			out += "  [color=#ffb84d]%-12s[/color] [color=#ddd]%s[/color]\n" % [
				String(b["keys"]), String(b["label"])]
	_text.text = head + out.strip_edges()


# ---- the status bar (editor redesign E1) ---------------------------------------

## The editing map supplies what only it knows — grid, selection — as a Callable
## returning {grid: String, sel: int}. Null on maps without the builder.
var info_provider: Callable = Callable()

## Idle hints: the verbs used most, each shown with its LIVE key. Generated, never
## typed: a rebind changes the bar the next frame.
## ORDER = PRIORITY: the bar is clipped to the bottom band's width, so the ways to find
## everything else (F1 keys, F3 search, Shift+Space tools) come first — measured in a
## 1600 px window, only the first few hints are visible.
const IDLE_HINTS: Array = [
	["global.help", "all keys"], ["builder.palette", "search"], ["builder.tool_popup", "tools"],
	# Keymap step 3: shapes come from the add menu (Shift+A); LMB selects.
	["builder.add_menu", "add"], ["builder.select_box", "box select"], ["builder.knife", "knife"],
	["builder.move", "move"], ["builder.extrude", "extrude"], ["builder.texture_apply", "texture"],
	["builder.undo", "undo"],
]

## MODIFIER HINT (editor redesign E3e, which-key style — the gap TrenchBroom leaves to its
## manual): hold Ctrl / Shift / Alt this long and the bar lists what it means RIGHT NOW
## and every chord with it in the active contexts. Not while looking (RMB held): Shift
## is fly-fast then, and you are not reading the bar.
const MOD_HINT_MS := 300
var _mod := ""
var _mod_since := 0

var _status_left_text := ""
var _status_right_text := ""


func status_text() -> String:
	return _status_left_text


func status_right() -> String:
	return _status_right_text


func _process(_d: float) -> void:
	# Grid and selection change without telling the HUD; re-reading them is a dict and
	# two string compares per frame, and only while building.
	if GameMode.is_building():
		_render_status()
	_render_readout()
	_fade_summary()


# ---- the live readout (editor redesign E4) ------------------------------------

## The editing map supplies the active tool's readout() ("+64u X", "+90° Y"); drawn just
## under the crosshair, where you are looking while you drag — Godot's set_message,
## Blender's header text, moved to the eye.
var readout_provider: Callable = Callable()
var _readout: Label = null


func readout_text() -> String:
	return _readout.text if _readout != null and _readout.visible else ""


func _render_readout() -> void:
	var t := ""
	if GameMode.is_building() and readout_provider.is_valid():
		t = String(readout_provider.call())
	if t == "" and _readout == null:
		return
	if _readout == null:
		_readout = Label.new()
		_readout.set_anchors_preset(Control.PRESET_CENTER)
		_readout.grow_horizontal = Control.GROW_DIRECTION_BOTH
		_readout.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		_readout.position = Vector2(-160, 28)          # just below the crosshair
		_readout.custom_minimum_size = Vector2(320, 0)
		_readout.mouse_filter = Control.MOUSE_FILTER_IGNORE
		_readout.add_theme_color_override("font_color", Color(1.0, 0.85, 0.4))
		_readout.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
		_readout.add_theme_constant_override("outline_size", 4)
		var holder := Control.new()
		holder.set_anchors_preset(Control.PRESET_FULL_RECT)
		holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
		add_child(holder)
		holder.add_child(_readout)
	_readout.text = t
	_readout.visible = t != ""


func _key(action: String) -> String:
	return "[color=#ffb84d]%s[/color]" % Keymap.describe(action)


## ---- THE MODAL CONVENTION's surfaces (#120 F3, ModalControls) ----------------
## The active tool's HELP (H): its key table with the state inline, top-left over the
## view while open. And the SUMMARY card after an instant op, fading out.
var _help: RichTextLabel = null
var _summary: Label = null
var _summary_until := 0


func set_tool_help(text: String) -> void:
	if text == "" and _help == null:
		return
	if _help == null:
		_help = RichTextLabel.new()
		_help.bbcode_enabled = true
		_help.fit_content = true
		_help.custom_minimum_size = Vector2(380, 0)
		_help.position = Vector2(24, 96)
		_help.mouse_filter = Control.MOUSE_FILTER_IGNORE
		_help.add_theme_color_override("default_color", Color(0.92, 0.92, 0.92))
		_help.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
		_help.add_theme_constant_override("outline_size", 4)
		add_child(_help)
	_help.text = text
	_help.visible = text != ""


func tool_help_text() -> String:
	return _help.text if _help != null and _help.visible else ""


func show_summary(text: String, ms: int = 2500) -> void:
	if _summary == null:
		_summary = Label.new()
		_summary.set_anchors_preset(Control.PRESET_CENTER)
		_summary.grow_horizontal = Control.GROW_DIRECTION_BOTH
		_summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		_summary.position = Vector2(-200, 64)
		_summary.custom_minimum_size = Vector2(400, 0)
		_summary.mouse_filter = Control.MOUSE_FILTER_IGNORE
		_summary.add_theme_color_override("font_color", Color(0.6, 1.0, 0.75))
		_summary.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
		_summary.add_theme_constant_override("outline_size", 4)
		var holder := Control.new()
		holder.set_anchors_preset(Control.PRESET_FULL_RECT)
		holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
		add_child(holder)
		holder.add_child(_summary)
	_summary.text = text
	_summary.modulate.a = 1.0
	_summary.visible = true
	_summary_until = Time.get_ticks_msec() + ms


func summary_text() -> String:
	return _summary.text if _summary != null and _summary.visible else ""


func _fade_summary() -> void:
	if _summary == null or not _summary.visible:
		return
	var left := _summary_until - Time.get_ticks_msec()
	if left <= 0:
		_summary.visible = false
	elif left < 600:
		_summary.modulate.a = float(left) / 600.0


## FIRST-RUN CARD (editor redesign E3f, §5.7): the first time a player enters build mode,
## name the four ways in — F1 keys, RMB hold/tap, F3 search, Shift+Space tools — with
## their LIVE keys. Dismissed once, remembered per player (BuildDock pref
## first_run_seen); leaving build mode hides it without dismissing.
var _card: PanelContainer = null
var _card_text: RichTextLabel = null


func _on_mode_for_first_run(mode: String) -> void:
	if mode != GameMode.BUILD:
		if _card != null:
			_card.visible = false
		return
	if bool(BuildDock.pref("first_run_seen", false)):
		return
	if _card == null:
		_build_card()
	_card_text.text = first_run_text()
	_card.visible = true


func first_run_visible() -> bool:
	return _card != null and _card.visible


func first_run_text() -> String:
	return ("[b]Build mode[/b]\n"
		+ "%s  every key, searchable\n" % _key("global.help")
		+ "[color=#ffb84d]RMB[/color]  hold to look and fly (WASD) · tap for a menu\n"
		+ "%s  fly mode (Enter keeps the view, Esc goes back)\n" % _key("builder.fly")
		+ "%s  search every command\n" % _key("builder.palette")
		+ "%s  tools at the cursor\n" % _key("builder.tool_popup")
		+ "hold [color=#ffb84d]Ctrl[/color] / [color=#ffb84d]Shift[/color]  the status bar says what they do")


func dismiss_first_run() -> void:
	if _card != null:
		_card.visible = false
	BuildDock.set_pref("first_run_seen", true)


func _build_card() -> void:
	_card = PanelContainer.new()
	_card.set_anchors_preset(Control.PRESET_CENTER)
	_card.custom_minimum_size = Vector2(420, 0)
	_card.position = Vector2(-210, -120)
	var box := VBoxContainer.new()
	_card.add_child(box)
	_card_text = RichTextLabel.new()
	_card_text.bbcode_enabled = true
	_card_text.fit_content = true
	_card_text.custom_minimum_size = Vector2(400, 0)
	box.add_child(_card_text)
	var ok := Button.new()
	ok.text = "Got it"
	ok.focus_mode = Control.FOCUS_NONE
	ok.pressed.connect(dismiss_first_run)
	box.add_child(ok)
	var holder := Control.new()
	holder.set_anchors_preset(Control.PRESET_FULL_RECT)
	holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(holder)
	holder.add_child(_card)


## What a held modifier does now: its own meaning (the fly camera's, or the tool's while
## one is up — keymap.md rule 1c) and every chord with it whose context is active.
func modifier_hint(mod: String) -> String:
	var tool_up := Keymap.is_context_active("tool")
	# Keymap step 1: Ctrl / Shift fly the camera ONLY while flying (RMB held / Shift+F),
	# and this hint is not shown then — so alone, with no tool up, they mean nothing.
	var own: Dictionary = {
		"ctrl": "invert snapping" if tool_up else "",
		"shift": "1/%d steps" % 8 if tool_up else "",
		"alt": "",
	}
	var name: String = {"ctrl": "Ctrl", "shift": "Shift", "alt": "Alt"}[mod]
	var parts: Array[String] = []
	if String(own[mod]) != "":
		parts.append("alone: %s" % own[mod])
	for b: Dictionary in Keymap.bindings():
		var keys := String(b["keys"])
		var action := String(b["action"])
		if not keys.begins_with(name + "+") or action.begins_with("debug.") or action == "toggle_metrics":
			continue
		if not Keymap.is_context_active(String(b["context"])):
			continue
		var tail := keys.substr(name.length() + 1)
		if tail.begins_with("F") and tail.length() <= 3 and tail.substr(1).is_valid_int():
			continue                     # Ctrl+F-key diagnostics: not editing chords
		parts.append("[color=#ffb84d]%s[/color] %s" % [keys, String(b["label"])])
	return "[b]%s[/b]   %s" % [name, " · ".join(parts)]


## Which single modifier is held long enough to explain itself; "" otherwise.
func _held_modifier() -> String:
	var held: Array[String] = []
	if Input.is_key_pressed(KEY_CTRL):
		held.append("ctrl")
	if Input.is_key_pressed(KEY_SHIFT):
		held.append("shift")
	if Input.is_key_pressed(KEY_ALT):
		held.append("alt")
	var m := held[0] if held.size() == 1 else ""
	if m != _mod:
		_mod = m
		_mod_since = Time.get_ticks_msec()
	if m == "" or not GameMode.wants_pointer() or Time.get_ticks_msec() - _mod_since < MOD_HINT_MS:
		return ""
	return m


## The active keymap contexts in words, innermost last: "BUILD · faces · tool: Move",
## "BUILD · FLYING", "BUILD · MENU". (Always and the editor-tools layer are implied.)
const STRIP_NAMES := {"player": "PLAY", "build_mode": "BUILD", "face_sel": "faces", "tool": "tool",
	"fly": "FLYING", "menu": "MENU", "text_input": "TYPING"}


func context_strip() -> String:
	var parts: Array[String] = []
	for c: String in Keymap.active_contexts():
		if not STRIP_NAMES.has(c):
			continue
		var w := String(STRIP_NAMES[c])
		if c == "tool" and _operator != "":
			w = "tool: %s" % _operator.get_slice(" ", 0)
		parts.append(w)
	return " · ".join(parts)


func _render_status() -> void:
	var left := ""
	var mod := _held_modifier()
	if mod != "":
		left = modifier_hint(mod)
	elif _operator != "":
		# A TOOL IS UP: what it is, and the keys that end it. Ctrl/Shift/Space are the
		# tool's now (keymap.md rule 1c) — say how to fly anyway.
		left = "[color=#ff6659][b]%s[/b][/color]   %s confirm · %s cancel · hold RMB fly" % [
			_operator, _key("builder.confirm"), "[color=#ffb84d]Esc[/color]"]
	else:
		# The first three IDLE_HINTS (how to find everything) lead, then the mouse, then
		# the verbs — the bar clips at the band's width, so the order is the priority.
		var parts: Array[String] = ["[b]%s[/b]" % _title.text]
		var mouse: Array[String] = ["[color=#ffb84d]LMB[/color] pick",
			"[color=#ffb84d]RMB[/color] look / tap menu", "[color=#ffb84d]Shift+LMB[/color] add"]
		for i in IDLE_HINTS.size():
			if i == 3:
				parts.append_array(mouse)
			var h: Array = IDLE_HINTS[i]
			if Keymap.describe(String(h[0])) != "":
				parts.append("%s %s" % [_key(String(h[0])), String(h[1])])
		left = " · ".join(parts)
	# THE CONTEXT STRIP (keymap step 4, docs/keymap-blender-279.md §4a) leads the right
	# side: the contexts a key is about to be read in, in words.
	var right := context_strip()
	if info_provider.is_valid():
		var info: Dictionary = info_provider.call()
		right += " · grid %s · %d selected" % [String(info.get("grid", "?")), int(info.get("sel", 0))]
	if left != _status_left_text or right != _status_right_text:
		_status_left_text = left
		_status_right_text = right
		BuildDock.set_status(left, right)