UI improvements

This commit is contained in:
2026-06-14 10:19:31 -05:00
parent 8ffde98fdd
commit 0cf1f92eaa
16 changed files with 263 additions and 114 deletions
+128
View File
@@ -0,0 +1,128 @@
# UI Navigation Design
---
## The four scenarios
1. **Two-panel navigation** — left sidebar selects a tab, right panel shows content; left/right crosses panels
2. **Modal focus** — when a modal opens the parent stops receiving input; closing restores it
3. **Nested modals** — multiple stacked modals; each blocks the one below; back unwinds the stack
4. **Mouse handling** — only the topmost active layer accepts clicks; background layers are blocked
---
## Core concept: Focus Stack
`UIFocusStack` (`ui/UIFocusStack.gd`) is a `RefCounted` that tracks an ordered stack of open `ClosableMenu` layers. Only the topmost layer processes input. When a layer is pushed, the one below is paused via `set_process_unhandled_input(false)`; when popped, it resumes. The topmost layer always renders on top — `z_index` is set automatically on push/pop so visual order always matches input priority.
Lives at `UI.FOCUS_STACK`. Key methods: `push(layer)`, `pop()`, `top() -> ClosableMenu`, `isTop(layer) -> bool`. Emits `activeLayerChanged(layer)` whenever the top changes; `null` means the stack is empty (world has focus).
```
Stack (bottom → top):
[GameMenu] z_index 10 ← paused while QuitDialog is open
[QuitConfirmDialog] z_index 20 ← top, owns input, renders on top
[ModalBackdrop] z_index 15 ← sits between them visually
```
---
## ClosableMenu
`ClosableMenu` (`ui/component/ClosableMenu.gd`) is the base for all interactive menus. Key additions:
- **`canClose:bool = true`** — when true, `open()`/`close()` push/pop the FocusStack. When false, the menu is purely passive (shown/hidden by code, no input ownership). Set in the Inspector or overridden in `_ready()`.
- **`focusGained` / `focusLost` signals** — emitted when the stack pushes/pops this layer.
- **`_savedFocusNode`** — the focused Control captured in `_onFocusLost()`. Restored in `_onFocusGained()` so that pressing back from a dialog returns focus to the button that triggered it.
- **`_grabInitialFocus()`** — override in subclasses to place focus on the right element when first opened (when no saved node exists).
- `open()` sets `visible = true`, pushes to stack (if `canClose`), then emits `opened`.
- `close()` pops from stack (if `canClose`), hides, then emits `closed`.
- `_ready()` calls `set_process_unhandled_input(false)` for `canClose` menus — they start silenced and only enable input when on top of the stack.
| Menu | canClose | Reason |
|---|---|---|
| `GameMenu` | `true` | Player opens and closes it |
| `ConfirmDialog` | `true` | Player dismisses it |
| `PauseMenu` | `true` | Player opens/closes via pause bind |
| `DialogueTextbox` | `false` | Dialogue system controls its lifetime |
| `PauseSettings` | n/a | Does not extend ClosableMenu — internal sub-panel |
---
## Z-indexing
`UIFocusStack` is the sole owner of `z_index` for all `ClosableMenu` layers. On push, `z_index = stack_depth * 10`. On pop, `z_index` resets to 0. `ModalBackdrop` always sits at `(top z_index) - 5`.
Never set `z_index` manually on a ClosableMenu — the stack manages it.
---
## ModalBackdrop
`ModalBackdrop` (`ui/component/ModalBackdrop.gd`) connects to `UI.FOCUS_STACK.activeLayerChanged` in `_ready()`. When a layer is active it becomes visible, sets `mouse_filter = MOUSE_FILTER_STOP` (blocking all clicks on anything behind it), and sets its own `z_index` to `(top layer z_index) - 5`. When the stack empties it hides and resets to `MOUSE_FILTER_IGNORE`.
The `register()` method and `_openOverlays` tracking are removed — the FocusStack signal replaces that entirely. `RootUI._ready()` no longer calls `register()`.
---
## Scenario 1: Two-Panel Navigation (SidebarMenu)
`SidebarMenu` (to be created at `ui/component/SidebarMenu.gd`) extends `ClosableMenu` and manages two internal panels: a left sidebar (tab selector) and a right content panel. An `_activePanel` enum (`SIDEBAR` / `CONTENT`) tracks which panel currently owns controller/keyboard navigation. Both panels live in the same focus layer — no stack push/pop when crossing between them.
**Controller / keyboard** — routed through `_unhandled_input`, which checks `_activePanel`:
- While `SIDEBAR`: UP/DOWN navigate sidebar items and update the content preview. RIGHT or ACCEPT calls `_enterContent()`. BACK closes the menu.
- While `CONTENT`: UP/DOWN navigate content items (wraps). LEFT or BACK calls `_exitContent()`. ACCEPT activates the item.
**Mouse** — ignores `_activePanel` entirely. `pressed` signals on items fire regardless of which panel the controller is in. Each item's press handler calls `_enterContent()` or `_exitContent()` as appropriate before processing the selection — these are no-ops if the panel is already active.
**ContentPanel protocol** — each right-panel tab must implement `grabFirstFocus()`, `releaseFocus()`, and `getSelectedIndex() -> int`. No enforced base class; convention only.
`GameMenu` will extend `SidebarMenu` once SidebarMenu is built.
---
## Scenario 2 & 3: Modal Focus and Nested Modals
Both are the same mechanism — one push vs. multiple. Opening a sub-layer calls `layer.open()` which pushes it; the layer below automatically loses input. Closing calls `close()` which pops; the layer below automatically resumes and `_savedFocusNode` is restored to wherever focus was when the sub-layer opened.
For 3-deep nesting (`MainMenu → LoadGameModal → ConfirmDialog`), each BACK unwinds one level. No special logic — the stack handles it.
---
## Scenario 4: Mouse Handling
`ModalBackdrop` with `MOUSE_FILTER_STOP` eats all mouse events aimed at anything behind it. The topmost layer (higher `z_index`) renders above the backdrop and receives clicks normally. Works at any nesting depth.
Within a `SidebarMenu`, both panels are in the same layer so no backdrop is between them — mouse clicks always work on either panel.
---
## World input and movement blocking
`EntityMovement._canMove()` and `OverworldCamera._canOrbit()` now check `UI.FOCUS_STACK.top() != null` — if any layer is active, movement and camera orbit are blocked. This replaces the previous ad-hoc `UI.GAME_MENU.isOpen()` check.
`UI.activeConversation` is kept separately for dialogue-mode blocking (dialogue does not use the FocusStack).
---
## What was changed
| File | Change |
|---|---|
| `ui/UIFocusStack.gd` | New — FocusStack manager |
| `ui/component/ClosableMenu.gd` | Added `canClose`, focus signals, `_onFocusGained/Lost`, `_savedFocusNode`, `_grabInitialFocus` |
| `ui/UISingleton.gd` | Added `FOCUS_STACK` (initialized via preload in `_ready`) |
| `ui/component/ModalBackdrop.gd` | Rewritten — connects to FocusStack signal, sets `MOUSE_FILTER_STOP` and `z_index` |
| `ui/RootUI.gd` | Removed `modalBackdrop.register()` calls |
| `ui/component/ConfirmDialog.gd` | Extends ClosableMenu; `_grabInitialFocus` focuses No button; removed `!isOpen` guard |
| `ui/gamemenu/GameMenu.gd` | Extends ClosableMenu; uses `_grabInitialFocus`; "menu" toggle via `_input` |
| `ui/pause/PauseMenu.gd` | Extends ClosableMenu; removed visibility/dialog guards from `_unhandled_input` |
| `ui/pause/PauseSettings.gd` | Unchanged — stays as Control (internal sub-panel, not in stack) |
| `ui/mainmenu/MainMenu.gd` | `settingsMenu.open()` replaces direct `isOpen` set; removed `_onSettingsOpened` stub |
| `scene/Pause.gd` | `menu.isOpen()``menu.isOpen` (property) |
| `overworld/entity/EntityMovement.gd` | `_canMove` checks `FOCUS_STACK.top() != null` |
| `overworld/camera/OverworldCamera.gd` | `_canOrbit` checks `FOCUS_STACK.top() != null` |
## Still to implement
- `SidebarMenu` base class
- `GameMenu` refactored to extend `SidebarMenu`
+2
View File
@@ -13,6 +13,7 @@ Detailed reference lives in [.claude/docs/](.claude/docs/):
- [Stubs](.claude/docs/stubs.md) — incomplete / placeholder systems to avoid relying on - [Stubs](.claude/docs/stubs.md) — incomplete / placeholder systems to avoid relying on
- [Overworld](.claude/docs/overworld.md) — map transitions, Entity exports, interaction types, camera, movement - [Overworld](.claude/docs/overworld.md) — map transitions, Entity exports, interaction types, camera, movement
- [UI](.claude/docs/ui.md) — UI singleton, VNTextbox, ClosableMenu, pause/debug/settings menus, AdvancedRichText - [UI](.claude/docs/ui.md) — UI singleton, VNTextbox, ClosableMenu, pause/debug/settings menus, AdvancedRichText
- [UI Navigation](.claude/docs/ui-navigation.md) — FocusStack, FocusLayer, SidebarMenu, modal layering, mouse blocking (design doc — not yet implemented)
@.claude/docs/code-style.md @.claude/docs/code-style.md
@.claude/docs/architecture.md @.claude/docs/architecture.md
@@ -21,3 +22,4 @@ Detailed reference lives in [.claude/docs/](.claude/docs/):
@.claude/docs/stubs.md @.claude/docs/stubs.md
@.claude/docs/overworld.md @.claude/docs/overworld.md
@.claude/docs/ui.md @.claude/docs/ui.md
@.claude/docs/ui-navigation.md
+5 -1
View File
@@ -43,7 +43,11 @@ var _mouseDelta:Vector2 = Vector2.ZERO
var _rightMouseHeld:bool = false var _rightMouseHeld:bool = false
func _canOrbit() -> bool: func _canOrbit() -> bool:
return not UI.activeConversation if UI.activeConversation:
return false
if UI.FOCUS_STACK.top() != null:
return false
return true
func _input(event:InputEvent) -> void: func _input(event:InputEvent) -> void:
if not _canOrbit(): if not _canOrbit():
+1 -1
View File
@@ -65,7 +65,7 @@ func _applyFriction(delta:float) -> void:
func _canMove() -> bool: func _canMove() -> bool:
if UI.activeConversation: if UI.activeConversation:
return false return false
if UI.GAME_MENU && UI.GAME_MENU.isOpen(): if UI.FOCUS_STACK.top() != null:
return false return false
return true return true
+2 -2
View File
@@ -9,9 +9,9 @@ func _unhandled_input(event:InputEvent) -> void:
var menu:PauseMenu = UI.PAUSE_MENU var menu:PauseMenu = UI.PAUSE_MENU
if menu == null: if menu == null:
return return
if SCENE.currentScene == SceneSingleton.SceneType.INITIAL and !menu.isOpen(): if SCENE.currentScene == SceneSingleton.SceneType.INITIAL and !menu.isOpen:
return return
if menu.isOpen(): if menu.isOpen:
menu.close() menu.close()
else: else:
menu.open() menu.open()
-5
View File
@@ -14,8 +14,3 @@ func _enter_tree() -> void:
func _exit_tree() -> void: func _exit_tree() -> void:
if UI.rootUi == self: if UI.rootUi == self:
UI.rootUi = null UI.rootUi = null
func _ready() -> void:
modalBackdrop.register(pauseMenu)
modalBackdrop.register(quitConfirmDialog)
modalBackdrop.register(mainMenuConfirmDialog)
+31
View File
@@ -0,0 +1,31 @@
class_name UIFocusStack extends RefCounted
signal activeLayerChanged(layer:ClosableMenu)
const Z_STEP:int = 10
var _stack:Array[ClosableMenu] = []
func push(layer:ClosableMenu) -> void:
if not _stack.is_empty():
_stack.back()._onFocusLost()
_stack.push_back(layer)
layer.z_index = _stack.size() * Z_STEP
layer._onFocusGained()
activeLayerChanged.emit(layer)
func pop() -> void:
if _stack.is_empty(): return
var removed:ClosableMenu = _stack.pop_back()
removed.z_index = 0
removed._onFocusLost()
var next:ClosableMenu = top()
if next != null:
next._onFocusGained()
activeLayerChanged.emit(next)
func top() -> ClosableMenu:
return _stack.back() if not _stack.is_empty() else null
func isTop(layer:ClosableMenu) -> bool:
return top() == layer
+1
View File
@@ -0,0 +1 @@
uid://bnttsy278nvaw
+4
View File
@@ -1,7 +1,10 @@
extends Node extends Node
const _FocusStackScript = preload("res://ui/UIFocusStack.gd")
var rootUi:RootUI = null var rootUi:RootUI = null
var interactIndicator:InteractIndicator = null var interactIndicator:InteractIndicator = null
var FOCUS_STACK:RefCounted = null
# True whenever any dialogue resource is being processed by DialogueManager. # True whenever any dialogue resource is being processed by DialogueManager.
# Driven by DialogueManager.dialogue_started / dialogue_ended signals. # Driven by DialogueManager.dialogue_started / dialogue_ended signals.
@@ -11,6 +14,7 @@ var dialogueActive:bool = false
var activeConversation:bool = false var activeConversation:bool = false
func _ready() -> void: func _ready() -> void:
FOCUS_STACK = _FocusStackScript.new()
DialogueManager.dialogue_started.connect(_onDialogueStarted) DialogueManager.dialogue_started.connect(_onDialogueStarted)
DialogueManager.dialogue_ended.connect(_onDialogueEnded) DialogueManager.dialogue_ended.connect(_onDialogueEnded)
SCENE.sceneChanged.connect(_onSceneChanged) SCENE.sceneChanged.connect(_onSceneChanged)
+57 -15
View File
@@ -1,18 +1,19 @@
class_name ClosableMenu extends Control class_name ClosableMenu extends Control
@export var isOpen: bool: signal opened
set(newValue): signal closed
isOpen = newValue signal focusGained
visible = newValue signal focusLost
if newValue:
opened.emit() @export var canClose:bool = true
else: @export var isOpen:bool = false:
closed.emit() set(v):
isOpen = v
visible = v
get(): get():
return isOpen return isOpen
signal closed var _savedFocusNode:Control = null
signal opened
func _enter_tree() -> void: func _enter_tree() -> void:
visible = isOpen visible = isOpen
@@ -22,13 +23,54 @@ func _exit_tree() -> void:
func _ready() -> void: func _ready() -> void:
visible = isOpen visible = isOpen
print("ClosableMenu is ready, isOpen: ", isOpen) if canClose:
set_process_unhandled_input(false)
func close() -> void:
isOpen = false
func open() -> void: func open() -> void:
visible = true
if canClose:
UI.FOCUS_STACK.push(self)
isOpen = true isOpen = true
opened.emit()
func close() -> void:
if canClose:
UI.FOCUS_STACK.pop()
isOpen = false
closed.emit()
func toggle() -> void: func toggle() -> void:
isOpen = !isOpen if isOpen:
close()
else:
open()
func _onFocusGained() -> void:
set_process_unhandled_input(true)
get_viewport().gui_focus_changed.connect(_onViewportFocusChanged)
if _savedFocusNode != null and is_instance_valid(_savedFocusNode):
_savedFocusNode.grab_focus()
else:
_grabInitialFocus()
var currentFocus:Control = get_viewport().gui_get_focus_owner()
if currentFocus != null and is_ancestor_of(currentFocus):
_savedFocusNode = currentFocus
focusGained.emit()
func _onFocusLost() -> void:
_savedFocusNode = get_viewport().gui_get_focus_owner()
if get_viewport().gui_focus_changed.is_connected(_onViewportFocusChanged):
get_viewport().gui_focus_changed.disconnect(_onViewportFocusChanged)
set_process_unhandled_input(false)
focusLost.emit()
func _onViewportFocusChanged(control:Control) -> void:
if control == null or is_ancestor_of(control):
return
if _savedFocusNode != null and is_instance_valid(_savedFocusNode):
_savedFocusNode.grab_focus()
else:
_grabInitialFocus()
func _grabInitialFocus() -> void:
pass
+2 -4
View File
@@ -6,6 +6,7 @@ signal confirmed
@export var btnNo:Button @export var btnNo:Button
func _ready() -> void: func _ready() -> void:
super._ready()
close() close()
btnYes.pressed.connect(_onYes) btnYes.pressed.connect(_onYes)
btnNo.pressed.connect(close) btnNo.pressed.connect(close)
@@ -18,13 +19,10 @@ func _onYes() -> void:
close() close()
confirmed.emit() confirmed.emit()
func open() -> void: func _grabInitialFocus() -> void:
super.open()
btnNo.grab_focus() btnNo.grab_focus()
func _unhandled_input(event:InputEvent) -> void: func _unhandled_input(event:InputEvent) -> void:
if !isOpen:
return
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
close() close()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
+9 -41
View File
@@ -1,48 +1,16 @@
class_name ModalBackdrop extends ColorRect class_name ModalBackdrop extends ColorRect
# Tracks which overlays are currently open. Each entry must be a direct sibling
# (child of the same parent). The backdrop repositions itself in the scene tree
# to sit immediately below whichever open overlay has the highest tree index,
# so only one backdrop is ever visible regardless of how many overlays are open.
var _openOverlays:Array[Control] = []
func _ready() -> void: func _ready() -> void:
visible = false visible = false
mouse_filter = MOUSE_FILTER_IGNORE
UI.FOCUS_STACK.activeLayerChanged.connect(_onActiveLayerChanged)
func register(overlay:Control) -> void: func _onActiveLayerChanged(layer:ClosableMenu) -> void:
assert(overlay.get_parent() == get_parent(), "ModalBackdrop: overlay must be a sibling") if layer == null or layer.get_parent() != get_parent():
assert(overlay.has_signal("opened") and overlay.has_signal("closed"),
"ModalBackdrop: overlay must have opened/closed signals")
overlay.connect("opened", func(): _onOpened(overlay))
overlay.connect("closed", func(): _onClosed(overlay))
func _onOpened(overlay:Control) -> void:
if overlay not in _openOverlays:
_openOverlays.append(overlay)
_reposition()
func _onClosed(overlay:Control) -> void:
_openOverlays.erase(overlay)
_reposition()
func _reposition() -> void:
if _openOverlays.is_empty():
visible = false visible = false
return mouse_filter = MOUSE_FILTER_IGNORE
visible = true z_index = 0
var top := _topOverlay()
var topIdx := top.get_index()
var myIdx := get_index()
if myIdx == topIdx - 1:
return
if myIdx < topIdx:
get_parent().move_child(self, topIdx - 1)
else: else:
get_parent().move_child(self, topIdx) visible = true
mouse_filter = MOUSE_FILTER_STOP
func _topOverlay() -> Control: z_index = layer.z_index - 5
var top:Control = _openOverlays[0]
for overlay in _openOverlays:
if overlay.get_index() > top.get_index():
top = overlay
return top
+12 -19
View File
@@ -1,4 +1,4 @@
class_name GameMenu extends Control class_name GameMenu extends ClosableMenu
enum Tab { PARTY, ITEMS } enum Tab { PARTY, ITEMS }
@@ -9,21 +9,14 @@ enum Tab { PARTY, ITEMS }
var _currentTab:Tab = Tab.PARTY var _currentTab:Tab = Tab.PARTY
func _ready() -> void: func _ready() -> void:
visible = false super._ready()
SIDEBAR.item_selected.connect(_onTabSelected) SIDEBAR.item_selected.connect(_onTabSelected)
func open() -> void: func _grabInitialFocus() -> void:
visible = true
_selectTab(_currentTab) _selectTab(_currentTab)
SIDEBAR.select(_currentTab) SIDEBAR.select(_currentTab)
SIDEBAR.grab_focus() SIDEBAR.grab_focus()
func close() -> void:
visible = false
func isOpen() -> bool:
return visible
func _onTabSelected(index:int) -> void: func _onTabSelected(index:int) -> void:
_selectTab(index as Tab) _selectTab(index as Tab)
@@ -37,16 +30,16 @@ func _selectTab(tab:Tab) -> void:
Tab.ITEMS: Tab.ITEMS:
ITEMS_TAB.refresh() ITEMS_TAB.refresh()
func _input(event:InputEvent) -> void:
if not event.is_action_pressed("menu"):
return
if isOpen:
close()
elif UI.FOCUS_STACK.top() == null and not UI.dialogueActive and SCENE.currentScene == SceneSingleton.SceneType.OVERWORLD:
open()
get_viewport().set_input_as_handled()
func _unhandled_input(event:InputEvent) -> void: func _unhandled_input(event:InputEvent) -> void:
if event.is_action_pressed("menu"):
if visible:
close()
elif !UI.dialogueActive and SCENE.currentScene == SceneSingleton.SceneType.OVERWORLD:
open()
get_viewport().set_input_as_handled()
return
if !visible:
return
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
close() close()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
+1 -7
View File
@@ -10,18 +10,12 @@ func _ready() -> void:
btnNewGame.pressed.connect(onNewGamePressed) btnNewGame.pressed.connect(onNewGamePressed)
btnSettings.pressed.connect(onSettingsPressed) btnSettings.pressed.connect(onSettingsPressed)
btnQuit.pressed.connect(_onQuitPressed) btnQuit.pressed.connect(_onQuitPressed)
settingsMenu.opened.connect(_onSettingsOpened)
settingsMenu.closed.connect(_onSettingsClosed) settingsMenu.closed.connect(_onSettingsClosed)
func _notification(what:int) -> void: func _notification(what:int) -> void:
if what == NOTIFICATION_ENTER_TREE: if what == NOTIFICATION_ENTER_TREE:
btnNewGame.call_deferred("grab_focus") btnNewGame.call_deferred("grab_focus")
func _onSettingsOpened() -> void:
# Move focus into the settings panel so the controller can navigate it.
# The SettingsMenu grabs its own internal focus via _notification.
pass
func _onSettingsClosed() -> void: func _onSettingsClosed() -> void:
btnSettings.grab_focus() btnSettings.grab_focus()
@@ -43,4 +37,4 @@ func onNewGamePressed() -> void:
OVERWORLD.mapChange(newGameScene, "PlayerSpawnPoint") OVERWORLD.mapChange(newGameScene, "PlayerSpawnPoint")
func onSettingsPressed() -> void: func onSettingsPressed() -> void:
settingsMenu.isOpen = true settingsMenu.open()
+7 -18
View File
@@ -1,32 +1,25 @@
class_name PauseMenu extends Control class_name PauseMenu extends ClosableMenu
signal opened
signal closed
@export var MAIN:PauseMain @export var MAIN:PauseMain
@export var settingsPanel:PauseSettings @export var settingsPanel:PauseSettings
func _ready() -> void: func _ready() -> void:
close() super._ready()
MAIN.resumeRequested.connect(close) MAIN.resumeRequested.connect(close)
MAIN.settingsRequested.connect(_openSettings) MAIN.settingsRequested.connect(_openSettings)
UI.MAIN_MENU_DIALOG.confirmed.connect(_goToMainMenu) UI.MAIN_MENU_DIALOG.confirmed.connect(_goToMainMenu)
func isOpen() -> bool:
return visible
func open() -> void: func open() -> void:
visible = true super.open()
get_tree().paused = true get_tree().paused = true
settingsPanel.close()
MAIN.open() MAIN.open()
opened.emit()
func close() -> void: func close() -> void:
get_tree().paused = false get_tree().paused = false
visible = false
MAIN.close()
settingsPanel.close() settingsPanel.close()
closed.emit() MAIN.close()
super.close()
func _openSettings() -> void: func _openSettings() -> void:
MAIN.close() MAIN.close()
@@ -37,11 +30,7 @@ func _goToMainMenu() -> void:
SCENE.setScene(SceneSingleton.SceneType.INITIAL) SCENE.setScene(SceneSingleton.SceneType.INITIAL)
func _unhandled_input(event:InputEvent) -> void: func _unhandled_input(event:InputEvent) -> void:
if !visible: if not event.is_action_pressed("ui_cancel"):
return
if !event.is_action_pressed("ui_cancel"):
return
if (UI.QUIT_DIALOG != null and UI.QUIT_DIALOG.isOpen) or (UI.MAIN_MENU_DIALOG != null and UI.MAIN_MENU_DIALOG.isOpen):
return return
if settingsPanel.isOpen(): if settingsPanel.isOpen():
settingsPanel.close() settingsPanel.close()
+1 -1
View File
@@ -2,7 +2,7 @@ class_name PauseSettings extends Control
func open() -> void: func open() -> void:
visible = true visible = true
func close() -> void: func close() -> void:
visible = false visible = false