Files
whale-town-front-v2/_Core/managers/InputFocusManager.gd
xiangwang fc6c3c1bd3 feat: expand multiplayer, chat, and release support
- add network NPC synchronization and dialogue interactions
- add world bulletin publishing and display
- improve authentication, session refresh, and appearance sync
- synchronize player direction and movement animations
- improve input focus and progressive Web loading
- add macOS/Windows builds and deployment configuration
- include required fonts, shaders, and runtime assets
2026-09-08 21:37:43 +08:00

202 lines
8.0 KiB
GDScript

extends Node
const FOCUS_BORDER_COLOR := Color(0.12, 0.49, 0.82, 1.0)
const CARET_COLOR := Color(0.04, 0.31, 0.60, 1.0)
const SELECTION_COLOR := Color(0.25, 0.61, 0.91, 0.30)
const STYLED_META := &"whaletown_input_focus_styled"
const INITIAL_PLACEHOLDER_META := &"whaletown_initial_placeholder"
const FOCUS_PLACEHOLDER_META := &"whaletown_focus_placeholder"
const NATIVE_INPUT_ID := "whaletown-native-input"
var _javascript_callback: Variant
var _native_inputs: Dictionary = {}
var _active_native_input: Control
var _bridge_initialized := false
func _ready() -> void:
get_tree().node_added.connect(_on_node_added)
call_deferred("_initialize_javascript_bridge")
call_deferred("_decorate_existing_inputs")
func _process(_delta: float) -> void:
if not _bridge_initialized:
_initialize_javascript_bridge()
func _initialize_javascript_bridge() -> void:
if _bridge_initialized or OS.get_name() != "Web":
return
var window: Variant = JavaScriptBridge.get_interface("window")
if window == null:
return
_javascript_callback = JavaScriptBridge.create_callback(_on_native_input_event)
window["__whaletownNativeInputCallback"] = _javascript_callback
_bridge_initialized = true
func _exit_tree() -> void:
_close_native_input()
if _bridge_initialized:
var window: Variant = JavaScriptBridge.get_interface("window")
if window != null:
window["__whaletownNativeInputCallback"] = null
func _on_node_added(node: Node) -> void:
if node is LineEdit or node is TextEdit:
_decorate_input.call_deferred(node)
func _decorate_existing_inputs() -> void:
_decorate_branch(get_tree().root)
func _decorate_branch(node: Node) -> void:
if node is LineEdit or node is TextEdit:
_decorate_input(node)
for child in node.get_children():
_decorate_branch(child)
func _decorate_input(input: Control) -> void:
if not is_instance_valid(input) or input.has_meta(STYLED_META):
return
input.set_meta(STYLED_META, true)
input.add_theme_stylebox_override("focus", _create_focus_style(input))
input.add_theme_color_override("caret_color", CARET_COLOR)
input.add_theme_color_override("selection_color", SELECTION_COLOR)
input.add_theme_constant_override("caret_width", 2)
input.set_meta(INITIAL_PLACEHOLDER_META, input.placeholder_text)
input.focus_entered.connect(_on_input_focus_entered.bind(input))
input.focus_exited.connect(_on_input_focus_exited.bind(input))
if input is LineEdit:
(input as LineEdit).text_changed.connect(func(_value: String) -> void: _sync_native_input(input))
else:
(input as TextEdit).text_changed.connect(func() -> void: _sync_native_input(input))
func _on_input_focus_entered(input: Control) -> void:
if not is_instance_valid(input):
return
if input.text.strip_edges().is_empty():
input.set_meta(FOCUS_PLACEHOLDER_META, input.placeholder_text)
input.placeholder_text = ""
_open_native_input(input)
func _on_input_focus_exited(input: Control) -> void:
if not is_instance_valid(input) or not input.text.strip_edges().is_empty():
return
var placeholder := str(input.get_meta(FOCUS_PLACEHOLDER_META, input.get_meta(INITIAL_PLACEHOLDER_META, "")))
if not placeholder.is_empty():
input.placeholder_text = placeholder
func _open_native_input(input: Control) -> void:
if not _bridge_initialized or not is_instance_valid(input):
return
_close_native_input()
_active_native_input = input
_native_inputs[input.get_instance_id()] = input
var callback_name := "window.__whaletownNativeInputCallback"
var input_id := str(input.get_instance_id())
var initial_text: String = input.text
var multiline := input is TextEdit
var secret := input is LineEdit and (input as LineEdit).secret
var rect := input.get_global_rect()
var viewport_size := get_viewport().get_visible_rect().size
var script := """
(() => {
const callback = %s;
if (typeof callback !== 'function') return false;
const previous = document.getElementById('%s');
if (previous) previous.remove();
const target = document.createElement(%s ? 'textarea' : 'input');
target.id = '%s';
target.value = %s;
target.spellcheck = false;
target.autocomplete = 'off';
if (%s) target.type = 'password';
const rect = {x: %f, y: %f, width: %f, height: %f};
const viewport = {width: %f, height: %f};
const canvas = document.getElementById('canvas');
const position = () => {
const bounds = canvas.getBoundingClientRect();
const sx = bounds.width / viewport.width;
const sy = bounds.height / viewport.height;
target.style.left = (bounds.left + rect.x * sx) + 'px';
target.style.top = (bounds.top + rect.y * sy) + 'px';
target.style.width = (rect.width * sx) + 'px';
target.style.height = (rect.height * sy) + 'px';
};
Object.assign(target.style, {
position: 'fixed', zIndex: '10000',
opacity: '0.01', color: 'transparent', caretColor: 'transparent',
background: 'transparent', border: '0', outline: '0', padding: '0',
margin: '0', resize: 'none', WebkitTextFillColor: 'transparent'
});
const send = (kind) => callback(kind, %s, target.value,
target.selectionStart || 0, target.selectionEnd || 0);
target.addEventListener('input', () => send('input'));
target.addEventListener('blur', () => send('blur'));
target.addEventListener('keydown', (event) => {
if (!%s && event.key === 'Enter') { event.preventDefault(); send('submit'); }
if (event.key === 'Escape') { event.preventDefault(); send('cancel'); }
});
document.querySelectorAll('.ime').forEach((element) => element.remove());
document.body.appendChild(target);
position();
window.addEventListener('resize', position, {passive: true});
target.__whaletownPosition = position;
target.focus({preventScroll: true});
target.setSelectionRange(target.value.length, target.value.length);
return true;
})()
""" % [callback_name, NATIVE_INPUT_ID, str(multiline).to_lower(), NATIVE_INPUT_ID,
JSON.stringify(initial_text), str(secret).to_lower(), rect.position.x, rect.position.y,
rect.size.x, rect.size.y, viewport_size.x, viewport_size.y, JSON.stringify(input_id), str(multiline).to_lower()]
var opened: Variant = JavaScriptBridge.eval(script, true)
if opened != true:
_native_inputs.erase(input.get_instance_id())
_active_native_input = null
func _close_native_input() -> void:
if _bridge_initialized:
JavaScriptBridge.eval("(() => { const el = document.getElementById('%s'); if (el) { if (el.__whaletownPosition) window.removeEventListener('resize', el.__whaletownPosition); el.remove(); } })()" % NATIVE_INPUT_ID, true)
_native_inputs.clear()
_active_native_input = null
func _sync_native_input(input: Control) -> void:
if not _bridge_initialized or input != _active_native_input or not is_instance_valid(input):
return
var value: String = input.text
var script := "(() => { const el = document.getElementById('%s'); if (el && el.value !== %s) el.value = %s; })()" % [NATIVE_INPUT_ID, JSON.stringify(value), JSON.stringify(value)]
JavaScriptBridge.eval(script, true)
func _on_native_input_event(arguments: Array) -> void:
if arguments.size() < 3:
return
var event_kind := str(arguments[0])
var input_id := int(arguments[1])
var input := _native_inputs.get(input_id) as Control
if not is_instance_valid(input):
return
var value := str(arguments[2])
if input.text != value:
input.text = value
if input is LineEdit:
(input as LineEdit).set_caret_column(value.length())
match event_kind:
"submit":
if input is LineEdit:
(input as LineEdit).text_submitted.emit(input.text)
"cancel":
_close_native_input()
func _create_focus_style(input: Control) -> StyleBoxFlat:
var focusStyle := StyleBoxFlat.new()
focusStyle.bg_color = Color.TRANSPARENT
focusStyle.border_color = FOCUS_BORDER_COLOR
focusStyle.set_border_width_all(2)
var normalStyle := input.get_theme_stylebox("normal") as StyleBoxFlat
if normalStyle != null:
focusStyle.corner_radius_top_left = normalStyle.corner_radius_top_left
focusStyle.corner_radius_top_right = normalStyle.corner_radius_top_right
focusStyle.corner_radius_bottom_right = normalStyle.corner_radius_bottom_right
focusStyle.corner_radius_bottom_left = normalStyle.corner_radius_bottom_left
else:
focusStyle.set_corner_radius_all(6)
return focusStyle