extends Control class_name EmoteWheel ## The radial emote dial: hold the emote button, point, release. ## ## A radial menu is the right shape for this and a list is not, for one reason: ## every option is the SAME DISTANCE from where the pointer starts. There is no ## scanning and no travel budget — the choice is a direction, and a direction can ## be learned as muscle memory in a way that "the fourth row down" cannot. After ## a few uses the player stops reading the wheel and just flicks. ## ## Which is why the selection is by ANGLE ALONE and not by distance. Pointing ## anywhere in a wedge selects it, however far out the cursor is, so a fast flick ## and a careful nudge do the same thing. A dead zone in the middle is the only ## exception, and it exists so releasing without moving cancels rather than ## picking whatever happened to be under the cursor at rest. ## ## Opened by HOLDING the button rather than toggled by tapping it, because an ## emote is a thing you do in a lull and a menu you have to close again is a ## thing that gets you killed. Release commits. A tap too short to have aimed ## replays the last emote instead, which is what the button did before the wheel ## existed. signal picked(index: int) signal cancelled ## The wheel is drawn at this radius, and a wedge is selected by pointing at it ## from further out than the dead zone. const RADIUS := 190.0 const INNER := 76.0 const DEAD_ZONE := 46.0 const LABEL_R := 250.0 ## How long the open/close ease takes. Short — this is a fast interaction and a ## slow bloom would defeat the point of it. const OPEN_TIME := 0.11 var _routines: Array = [] var _hover: int = -1 var _open: float = 0.0 var _target_open: float = 0.0 ## The direction the pointer has travelled from the wheel's centre since it ## opened. Accumulated from relative mouse motion rather than read from the ## cursor position, because the game captures the mouse and the OS cursor does ## not move. var _aim: Vector2 = Vector2.ZERO func _ready() -> void: _routines = DanceRoutines.ROUTINES set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_IGNORE visible = false set_process(false) set_process_input(false) ## Show the wheel and start tracking the pointer. func open() -> void: _aim = Vector2.ZERO _hover = -1 _target_open = 1.0 visible = true set_process(true) set_process_input(true) queue_redraw() ## Hide it, and report what was pointed at. Returns the index, or -1 for a ## cancel (nothing aimed at, or the pointer never left the dead zone). func close() -> int: _target_open = 0.0 set_process_input(false) var chosen := _hover if chosen >= 0: picked.emit(chosen) else: cancelled.emit() _hover = -1 queue_redraw() return chosen func _input(event: InputEvent) -> void: if event is InputEventMouseMotion: # Relative motion, because the game holds the mouse captured — the # cursor's absolute position never changes and reading it would leave # the wheel permanently pointing at nothing. _aim += (event as InputEventMouseMotion).relative _update_hover() ## Also drivable from a stick or the keyboard, for a controller or for a player ## who would rather not move the mouse. Same wedge maths, different source. func aim_by_vector(v: Vector2) -> void: _aim = v * (DEAD_ZONE + 1.0) if v.length() > 0.01 else Vector2.ZERO _update_hover() func _update_hover() -> void: var was := _hover if _aim.length() < DEAD_ZONE or _routines.is_empty(): _hover = -1 else: # Angle from straight UP, clockwise, so the first emote is at twelve # o'clock — the position a player will reach for without thinking. var a := fposmod(atan2(_aim.x, -_aim.y), TAU) var step := TAU / float(_routines.size()) _hover = int(floor((a + step * 0.5) / step)) % _routines.size() if _hover != was: queue_redraw() var am := get_tree().root.get_node_or_null("AudioManager") if am and _hover >= 0: am.play_ui("ui_hover") func _process(delta: float) -> void: var t := 1.0 - exp(-delta / maxf(OPEN_TIME, 0.001)) _open = lerpf(_open, _target_open, t) if _target_open <= 0.0 and _open < 0.01: _open = 0.0 visible = false set_process(false) queue_redraw() func _draw() -> void: if _open < 0.01 or _routines.is_empty(): return var mid := size * 0.5 # Scale up from 88% as it opens, and fade in. Small, because the wheel has to # be usable the instant it appears — an animation the player has to wait out # is an animation that makes the feature feel slower than the old toggle. var k: float = lerpf(0.88, 1.0, _open) var a: float = _open var count := _routines.size() var step := TAU / float(count) # A scrim, so the wheel reads over a bright skybox without needing a heavier # outline than everything else in the UI uses. draw_circle(mid, RADIUS * k * 1.06, Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.55 * a)) for i in count: var mid_angle := step * float(i) var from := mid_angle - step * 0.5 var hovered := i == _hover # Fill follows the state, and the label follows the fill — the theme's # one rule for staying readable. A hovered wedge is papaya and takes ink # glyphs; a resting one is near-black and takes paper. var fill: Color = UITheme.PAPAYA if hovered else UITheme.INK_SOFT _wedge(mid, from, step, INNER * k, RADIUS * k, Color(fill.r, fill.g, fill.b, (0.95 if hovered else 0.82) * a)) var dir := Vector2(sin(mid_angle), -cos(mid_angle)) var r := (INNER + RADIUS) * 0.5 * k _glyph(mid + dir * r, String(_routines[i].get("icon", "*")), 40, UITheme.ink_for(fill) if hovered else UITheme.PAPER, a) # The name sits OUTSIDE the ring rather than inside the wedge, so a long # one is never clipped by its own slice and the type size does not have # to shrink as emotes are added. _glyph(mid + dir * (LABEL_R * k), String(_routines[i].get("name", "")), 22, UITheme.VOLT if hovered else UITheme.PAPER, a) # The hub. Volt while a wedge is aimed at, so committing is confirmed before # the button is released rather than after. var hub: Color = UITheme.VOLT if _hover >= 0 else UITheme.INK draw_circle(mid, INNER * k * 0.42, Color(hub.r, hub.g, hub.b, (0.9 if _hover >= 0 else 0.75) * a)) draw_arc(mid, INNER * k * 0.42, 0, TAU, 40, Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, a), 3.0, true) if _hover < 0: _glyph(mid, "RELEASE TO CANCEL", 16, UITheme.PAPER_DIM, a * 0.9) ## One slice of the ring, as a triangle strip between the inner and outer radii. func _wedge(mid: Vector2, from: float, span: float, r0: float, r1: float, col: Color) -> void: var segs := 14 var pts := PackedVector2Array() for i in segs + 1: var ang := from + span * (float(i) / float(segs)) # A one-degree gap either side, so adjacent wedges read as separate # choices rather than as a solid ring with colour changes in it. ang = from + deg_to_rad(1.2) + (span - deg_to_rad(2.4)) * (float(i) / float(segs)) var d := Vector2(sin(ang), -cos(ang)) pts.append(mid + d * r0) pts.append(mid + d * r1) # Build an outline path around the strip so the ink edge can be stroked. var outer := PackedVector2Array() var inner := PackedVector2Array() for i in range(0, pts.size(), 2): inner.append(pts[i]) outer.append(pts[i + 1]) var poly := PackedVector2Array() poly.append_array(outer) inner.reverse() poly.append_array(inner) draw_colored_polygon(poly, col) poly.append(poly[0]) draw_polyline(poly, Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, col.a), 3.0) ## Centred text with the theme's ink outline under it. func _glyph(at: Vector2, text: String, fs: int, col: Color, alpha: float) -> void: var font := get_theme_default_font() if font == null or text == "": return var w := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs) var pos := at - Vector2(w.x * 0.5, -w.y * 0.32) draw_string_outline(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, 6, Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, alpha)) draw_string(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, Color(col.r, col.g, col.b, alpha))