4 Commits
main ... dev

Author SHA1 Message Date
ANG-Server
bb91ccea21 docs: document frontend exports 2026-07-22 00:45:16 +08:00
ANG-Server
aa19f157f2 fix: align interaction anchors with collision areas 2026-07-21 23:47:24 +08:00
ANG-Server
74a0fb8309 refactor: unify interactable components 2026-07-21 23:23:26 +08:00
ANG-Server
9dee47492c feat: add nearby social interactions 2026-07-21 23:07:31 +08:00
138 changed files with 2034 additions and 5682 deletions

3
.gitignore vendored
View File

@@ -36,8 +36,6 @@ Godot/
*.log *.log
*.tmp *.tmp
*.temp *.temp
/tmp/
/.playwright-cli/
# Build outputs # Build outputs
build/ build/
@@ -53,7 +51,6 @@ dist/
# Credentials and local secrets # Credentials and local secrets
secrets/ secrets/
/.secrets/
*.pem *.pem
*.key *.key
*.p12 *.p12

View File

@@ -8,16 +8,16 @@
"active_profile": "production", "active_profile": "production",
"profiles": { "profiles": {
"production": { "production": {
"api_base_url": "https://whaletown.novamailio.com/api", "api_base_url": "https://whaletownend.xinghangee.icu",
"chat_ws_url": "wss://whaletown.novamailio.com/game", "chat_ws_url": "wss://whaletownend.xinghangee.icu/game",
"location_ws_url": "wss://whaletown.novamailio.com/game", "location_ws_url": "wss://whaletownend.xinghangee.icu/game",
"game_ws_url": "wss://whaletown.novamailio.com/game" "game_ws_url": "wss://whaletownend.xinghangee.icu/game"
} }
}, },
"api_base_url": "https://whaletown.novamailio.com/api", "api_base_url": "https://whaletownend.xinghangee.icu",
"chat_ws_url": "wss://whaletown.novamailio.com/game", "chat_ws_url": "wss://whaletownend.xinghangee.icu/game",
"location_ws_url": "wss://whaletown.novamailio.com/game", "location_ws_url": "wss://whaletownend.xinghangee.icu/game",
"game_ws_url": "wss://whaletown.novamailio.com/game", "game_ws_url": "wss://whaletownend.xinghangee.icu/game",
"timeout": 30, "timeout": 30,
"retry_count": 3 "retry_count": 3
}, },

View File

@@ -1,37 +0,0 @@
# WhaleTown 桌面客户端构建
桌面客户端和 Web 客户端使用同一套 Godot 项目、正式登录接口和 WebSocket 服务。用户下载客户端后直接进入登录页,账号数据仍由线上服务保存,不写入项目目录。
## 构建目标
- macOS`build/desktop/macos/WhaleTown.app`,通用架构,支持 Apple Silicon 和 Intel Mac。
- Windows`build/desktop/windows/WhaleTown.exe`64 位桌面版本。
## 环境要求
1. 安装与项目一致的 Godot `4.6.2.stable`
2. 在 Godot 编辑器的导出模板管理器中安装 macOS 和 Windows Desktop 模板。
3. 默认网络配置位于 `Config/game_config.json`,发布前确认 `production` 地址指向正式服务。
## 构建命令
`whale-town-front-v2` 目录执行:
```bash
./scripts/build_desktop.sh macos
./scripts/build_desktop.sh windows
./scripts/build_desktop.sh all
./scripts/build_desktop.sh release
```
`release` 会同时构建两个平台,生成 ZIP并在 `build/desktop/release/SHA256SUMS.txt` 写入校验值。也可以通过 `GODOT_BIN=/path/to/Godot``WHALETOWN_VERSION=1.0.0` 指定 Godot 路径及发布版本。
Windows 构建可以从 macOS 交叉导出,但最终发布前仍应在真实 Windows 机器上启动一次,确认显卡、字体、文件选择器和网络连接正常。
## 面向用户发布
首版可以直接将 `build/desktop/release` 中的 ZIP 供内测下载。正式公开下载前还需要:
- macOS 使用 Apple Developer ID 签名和 notarization避免 Gatekeeper 拦截。
- Windows 使用代码签名证书,降低 SmartScreen 警告。
- 将客户端压缩包上传到下载站,并提供版本号、更新日期和 SHA-256 校验值。

View File

@@ -16,6 +16,17 @@ WhaleTown V2 前端是基于 Godot 4.6 的 2D 多人小镇客户端。
2. 用 Godot 打开 `project.godot` 2. 用 Godot 打开 `project.godot`
3. 运行默认主场景。 3. 运行默认主场景。
## 导出
仓库包含 Web 和 Linux 导出预设。安装 Godot 4.6 导出模板后可执行:
```bash
godot --headless --path . --export-release Web build/web/index.html
godot --headless --path . --export-release "Linux/X11" build/linux/WhaleTown-V2.x86_64
```
Web 产物是静态文件,必须通过 HTTP/HTTPS 服务器发布,不要直接用 `file://` 打开。
默认连接 WhaleTown 生产 API 和 WebSocket 服务。本地联调时可使用以下环境变量覆盖: 默认连接 WhaleTown 生产 API 和 WebSocket 服务。本地联调时可使用以下环境变量覆盖:
- `WHALETOWN_API_BASE_URL` - `WHALETOWN_API_BASE_URL`

View File

@@ -19,12 +19,6 @@ const REMOTE_PLAYER_JOINED = "remote_player_joined"
const REMOTE_PLAYER_LEFT = "remote_player_left" const REMOTE_PLAYER_LEFT = "remote_player_left"
const REMOTE_PLAYER_POSITION_UPDATED = "remote_player_position_updated" const REMOTE_PLAYER_POSITION_UPDATED = "remote_player_position_updated"
const REMOTE_PLAYERS_SNAPSHOT_READY = "remote_players_snapshot_ready" const REMOTE_PLAYERS_SNAPSHOT_READY = "remote_players_snapshot_ready"
const NPC_SNAPSHOT_READY = "npc_snapshot_ready"
const NPC_ACTION_STARTED = "npc_action_started"
const NPC_ACTION_COMPLETED = "npc_action_completed"
const NPC_SPOKE = "npc_spoke"
const NPC_INTERACTION_ERROR = "npc_interaction_error"
const NPC_CONVERSATION = "npc_conversation"
const REMOTE_SKIN_READY = "remote_skin_ready" const REMOTE_SKIN_READY = "remote_skin_ready"
const REMOTE_SKIN_FAILED = "remote_skin_failed" const REMOTE_SKIN_FAILED = "remote_skin_failed"
const PLAYER_HEALTH_CHANGED = "player_health_changed" const PLAYER_HEALTH_CHANGED = "player_health_changed"
@@ -88,6 +82,8 @@ const CHAT_LOGIN_FAILED = "chat_login_failed"
const CHAT_PRIVATE_TARGET_SELECTED = "chat_private_target_selected" const CHAT_PRIVATE_TARGET_SELECTED = "chat_private_target_selected"
const CHAT_FRIEND_SELECTED = "chat_friend_selected" const CHAT_FRIEND_SELECTED = "chat_friend_selected"
const CHAT_FRIENDS_UPDATED = "chat_friends_updated" const CHAT_FRIENDS_UPDATED = "chat_friends_updated"
const SOCIAL_NOTIFICATION_RECEIVED = "social_notification_received"
const SOCIAL_PROFILE_UPDATED = "social_profile_updated"
# ============================================================================ # ============================================================================
# 咖啡店陪伴机器人事件 # 咖啡店陪伴机器人事件

View File

@@ -0,0 +1,100 @@
class_name InteractableComponent
extends Node
# 可挂载于任意 Node2D 宿主的交互组件。
# 它统一负责交互锚点、距离过滤、白圈位置和静态动作;复杂宿主可通过
# build_interaction_actions(component, player) 返回多个 InteractionAction。
const GROUP: StringName = &"whaletown_interactable_component"
@export_category("Interaction")
@export var interaction_id: String = ""
@export var interaction_title: String = ""
@export var interaction_priority: int = 100
@export var interaction_distance: float = 150.0
@export var activation_method: StringName = &""
@export var show_marker: bool = true
@export_category("Anchor")
@export var anchor_path: NodePath = NodePath("")
var _host: Node2D
func _ready() -> void:
_host = get_parent() as Node2D
if _host == null:
push_error("InteractableComponent 必须挂在 Node2D 宿主下:%s" % get_path())
return
add_to_group(GROUP)
func get_actions(player: Node2D) -> Array[InteractionAction]:
var actions: Array[InteractionAction] = []
if player == null or not is_interaction_active():
return actions
var distance: float = get_anchor_position().distance_to(player.global_position)
if distance > interaction_distance:
return actions
if _host != null and _host.has_method("build_interaction_actions"):
var actions_variant: Variant = _host.call("build_interaction_actions", self, player)
if actions_variant is Array:
for action_variant: Variant in actions_variant as Array:
if action_variant is InteractionAction:
var action: InteractionAction = action_variant as InteractionAction
if action.is_valid():
action.distance = distance
actions.append(action)
return actions
var default_action: InteractionAction = _create_default_action()
if default_action != null:
default_action.distance = distance
actions.append(default_action)
return actions
func get_marker_positions() -> Array[Vector2]:
if not show_marker or not is_interaction_active():
return []
return [get_anchor_position()]
func get_anchor_position() -> Vector2:
var anchor: Node2D = _resolve_anchor()
if anchor == null:
return Vector2.ZERO
var collision_shape: CollisionShape2D = _first_enabled_collision_shape(anchor)
return collision_shape.global_position if collision_shape != null else anchor.global_position
func is_interaction_active() -> bool:
if _host == null:
return false
if _host.has_method("is_interaction_active"):
return bool(_host.call("is_interaction_active", self))
return true
func _create_default_action() -> InteractionAction:
if _host == null or activation_method.is_empty() or interaction_id.strip_edges().is_empty() or interaction_title.strip_edges().is_empty():
return null
if not _host.has_method(activation_method):
push_warning("InteractableComponent: %s 不存在方法 %s" % [_host.get_path(), activation_method])
return null
return InteractionAction.create(interaction_id, interaction_title, interaction_priority, Callable(_host, activation_method))
func _resolve_anchor() -> Node2D:
if _host == null:
return null
if not anchor_path.is_empty():
var explicit_anchor: Node2D = _host.get_node_or_null(anchor_path) as Node2D
if explicit_anchor != null:
return explicit_anchor
return _host
func _first_enabled_collision_shape(anchor: Node) -> CollisionShape2D:
var direct_shape: CollisionShape2D = anchor as CollisionShape2D
if direct_shape != null and not direct_shape.disabled and direct_shape.shape != null:
return direct_shape
for child: Node in anchor.get_children():
var collision_shape: CollisionShape2D = child as CollisionShape2D
if collision_shape != null and not collision_shape.disabled and collision_shape.shape != null:
return collision_shape
for child: Node in anchor.get_children():
var nested_shape: CollisionShape2D = _first_enabled_collision_shape(child)
if nested_shape != null:
return nested_shape
return null

View File

@@ -0,0 +1 @@
uid://cn3fccr0qc41h

View File

@@ -0,0 +1,20 @@
class_name InteractionAction
extends RefCounted
# 交互管理器使用的强类型动作数据,避免各交互物品以 Dictionary 约定字段。
var id: String = ""
var title: String = ""
var priority: int = 100
var distance: float = INF
var activate: Callable = Callable()
static func create(action_id: String, action_title: String, action_priority: int, callback: Callable) -> InteractionAction:
var action: InteractionAction = InteractionAction.new()
action.id = action_id
action.title = action_title
action.priority = action_priority
action.activate = callback
return action
func is_valid() -> bool:
return not id.strip_edges().is_empty() and not title.strip_edges().is_empty() and activate.is_valid()

View File

@@ -0,0 +1 @@
uid://dbrui8h3sbjrn

View File

@@ -13,16 +13,6 @@ const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0 const REQUEST_TIMEOUT: float = 12.0
var _activeRequests: Array[HTTPRequest] = [] var _activeRequests: Array[HTTPRequest] = []
var _pendingAuthRetries: Array[Dictionary] = []
var _awaitingSessionRefresh: bool = false
func _ready() -> void:
call_deferred("_connect_auth_manager")
func _connect_auth_manager() -> void:
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("session_refresh_completed") and not authManager.is_connected("session_refresh_completed", _on_session_refresh_completed):
authManager.connect("session_refresh_completed", _on_session_refresh_completed)
func _exit_tree() -> void: func _exit_tree() -> void:
for request in _activeRequests: for request in _activeRequests:
@@ -30,7 +20,6 @@ func _exit_tree() -> void:
request.cancel_request() request.cancel_request()
request.queue_free() request.queue_free()
_activeRequests.clear() _activeRequests.clear()
_pendingAuthRetries.clear()
func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void: func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated) request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated)
@@ -45,9 +34,6 @@ func put_json(endpoint: String, payload: Dictionary, callback: Callable, authent
request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated) request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated)
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void: func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void:
_request_json(endpoint, payload, callback, method, authenticated, 0)
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int, authenticated: bool, authRetryCount: int) -> void:
var request := HTTPRequest.new() var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT request.timeout = REQUEST_TIMEOUT
add_child(request) add_child(request)
@@ -56,7 +42,7 @@ func _request_json(endpoint: String, payload: Dictionary, callback: Callable, me
request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_activeRequests.erase(request) _activeRequests.erase(request)
request.queue_free() request.queue_free()
_handle_response(endpoint, result, responseCode, body, callback, payload, method, authenticated, authRetryCount) _handle_response(endpoint, result, responseCode, body, callback)
) )
var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint] var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint]
@@ -78,27 +64,13 @@ func _headers(authenticated: bool) -> PackedStringArray:
headers.append("Authorization: Bearer %s" % accessToken) headers.append("Authorization: Bearer %s" % accessToken)
return headers return headers
func _handle_response( func _handle_response(endpoint: String, result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void:
endpoint: String,
result: int,
responseCode: int,
body: PackedByteArray,
callback: Callable,
payload: Dictionary,
method: int,
authenticated: bool,
authRetryCount: int
) -> void:
if result != HTTPRequest.RESULT_SUCCESS: if result != HTTPRequest.RESULT_SUCCESS:
var message := "网络请求失败: %s" % _http_result_to_string(result) var message := "网络请求失败: %s" % _http_result_to_string(result)
request_failed.emit(endpoint, message) request_failed.emit(endpoint, message)
callback.call(false, {}, {"message": message}) callback.call(false, {}, {"message": message})
return return
if responseCode == 401 and authenticated and authRetryCount < 1:
_queue_authenticated_retry(endpoint, payload, callback, method, authRetryCount + 1)
return
var bodyText := body.get_string_from_utf8() var bodyText := body.get_string_from_utf8()
var json := JSON.new() var json := JSON.new()
if json.parse(bodyText) != OK: if json.parse(bodyText) != OK:
@@ -128,63 +100,6 @@ func _handle_response(
request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败"))) request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败")))
callback.call(false, response, errorInfo) callback.call(false, response, errorInfo)
func _queue_authenticated_retry(endpoint: String, payload: Dictionary, callback: Callable, method: int, authRetryCount: int) -> void:
_pendingAuthRetries.append({
"endpoint": endpoint,
"payload": payload.duplicate(true),
"callback": callback,
"method": method,
"auth_retry_count": authRetryCount,
"account_generation": _current_account_generation(),
})
if _awaitingSessionRefresh:
return
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("refresh_session"):
_finish_authenticated_retries(false)
return
_awaitingSessionRefresh = true
if not bool(authManager.call("refresh_session")):
_finish_authenticated_retries(false)
func _on_session_refresh_completed(success: bool) -> void:
_finish_authenticated_retries(success)
func _finish_authenticated_retries(success: bool) -> void:
var retries := _pendingAuthRetries.duplicate()
_pendingAuthRetries.clear()
_awaitingSessionRefresh = false
var currentGeneration := _current_account_generation()
for retryVariant in retries:
if not (retryVariant is Dictionary):
continue
var retry: Dictionary = retryVariant
var callback: Callable = retry.get("callback", Callable())
if not callback.is_valid():
continue
if not success or int(retry.get("account_generation", -1)) != currentGeneration:
callback.call(false, {}, {
"message": "登录状态已过期,请重新登录",
"response_code": 401,
"error_code": "SESSION_EXPIRED",
})
continue
_request_json(
str(retry.get("endpoint", "")),
retry.get("payload", {}) as Dictionary,
callback,
int(retry.get("method", HTTPClient.METHOD_GET)),
true,
int(retry.get("auth_retry_count", 1))
)
func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("get_account_generation"):
return -1
return int(authManager.call("get_account_generation"))
func _http_result_to_string(result: int) -> String: func _http_result_to_string(result: int) -> String:
match result: match result:
HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH: HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH:

View File

@@ -21,7 +21,6 @@ const REMOTE_SKIN_MAX_RETRIES: int = 3
const REMOTE_SKIN_CACHE_DIR: String = "user://skin_cache" const REMOTE_SKIN_CACHE_DIR: String = "user://skin_cache"
const AVATAR_CORNER_RADIUS_RATIO: float = 0.22 const AVATAR_CORNER_RADIUS_RATIO: float = 0.22
const DEFAULT_CHARACTER_TEXTURE_PATH: String = "res://assets/characters/player_pixel_spritesheet.png" const DEFAULT_CHARACTER_TEXTURE_PATH: String = "res://assets/characters/player_pixel_spritesheet.png"
const AVATAR_MASK_SHADER = preload("res://assets/shaders/avatar_round_mask.gdshader")
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const SKINS: Array[Dictionary] = [ const SKINS: Array[Dictionary] = [
@@ -100,12 +99,9 @@ var _remoteSkinRetryCounts: Dictionary = {}
var _accountAvatarUrl: String = "" var _accountAvatarUrl: String = ""
var _accountAvatarTexture: Texture2D var _accountAvatarTexture: Texture2D
var _profileSyncSuppressed: bool = false var _profileSyncSuppressed: bool = false
var _confirmedSkinId: String = FALLBACK_SKIN_ID
var _pendingSkinId: String = ""
func _ready() -> void: func _ready() -> void:
_load() _load()
_confirmedSkinId = selectedSkinId
_connect_auth_events() _connect_auth_events()
_emit_profile_changed() _emit_profile_changed()
@@ -230,17 +226,12 @@ func set_selected_skin(skinId: String, allowLocked: bool = false) -> bool:
if normalizedId == selectedSkinId: if normalizedId == selectedSkinId:
return true return true
var shouldSync := not _profileSyncSuppressed and _is_account_authenticated()
if shouldSync:
_pendingSkinId = normalizedId
selectedSkinId = normalizedId selectedSkinId = normalizedId
_save() _save()
_emit_skin_changed() _emit_skin_changed()
_emit_profile_changed() _emit_profile_changed()
if shouldSync: if not _profileSyncSuppressed:
_sync_account_skin_profile() _sync_account_skin_profile()
else:
_confirmedSkinId = normalizedId
return true return true
func apply_account_profile(profile: Dictionary) -> void: func apply_account_profile(profile: Dictionary) -> void:
@@ -249,28 +240,23 @@ func apply_account_profile(profile: Dictionary) -> void:
_clear_session_custom_skin() _clear_session_custom_skin()
var skinId := str(profile.get("skin_id", "")).strip_edges() var skinId := str(profile.get("skin_id", "")).strip_edges()
_accountAvatarUrl = _absolute_backend_url(str(profile.get("avatar_url", "")).strip_edges()) _accountAvatarUrl = _absolute_backend_url(str(profile.get("avatar_url", "")).strip_edges())
_accountAvatarTexture = _texture_from_base64(str(profile.get("avatar_base64", "")).strip_edges(), true) _accountAvatarTexture = _texture_from_base64(str(profile.get("avatar_base64", "")).strip_edges())
var ownedSkinsVariant: Variant = profile.get("owned_skins", []) var ownedSkinsVariant: Variant = profile.get("owned_skins", [])
if ownedSkinsVariant is Array: if ownedSkinsVariant is Array:
apply_account_skin_assets(ownedSkinsVariant as Array) apply_account_skin_assets(ownedSkinsVariant as Array)
var ownedVariant: Variant = profile.get("owned_skin_ids", []) var ownedVariant: Variant = profile.get("owned_skin_ids", [])
if ownedVariant is Array: if ownedVariant is Array:
set_owned_skin_ids(ownedVariant) set_owned_skin_ids(ownedVariant)
var shouldApplySkin := _pendingSkinId.is_empty() or skinId == _pendingSkinId if not skinId.is_empty():
if not skinId.is_empty() and shouldApplySkin:
grant_owned_skin(skinId) grant_owned_skin(skinId)
set_selected_skin(skinId, true) set_selected_skin(skinId, true)
_confirmedSkinId = skinId else:
if skinId == _pendingSkinId:
_pendingSkinId = ""
elif skinId.is_empty() and _pendingSkinId.is_empty():
var fallbackSkinId := _first_owned_skin_id() var fallbackSkinId := _first_owned_skin_id()
if selectedSkinId != fallbackSkinId: if selectedSkinId != fallbackSkinId:
selectedSkinId = fallbackSkinId selectedSkinId = fallbackSkinId
_save() _save()
_emit_skin_changed() _emit_skin_changed()
_emit_profile_changed() _emit_profile_changed()
_confirmedSkinId = fallbackSkinId
_profileSyncSuppressed = false _profileSyncSuppressed = false
if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty(): if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty():
selectedAvatarId = CUSTOM_AVATAR_ID selectedAvatarId = CUSTOM_AVATAR_ID
@@ -320,7 +306,7 @@ func apply_account_avatar_base64(avatarUrl: String, avatarBase64: String) -> voi
_accountAvatarUrl = _absolute_backend_url(avatarUrl.strip_edges()) _accountAvatarUrl = _absolute_backend_url(avatarUrl.strip_edges())
_customAvatarTexture = null _customAvatarTexture = null
_customAvatarActive = false _customAvatarActive = false
_accountAvatarTexture = _texture_from_base64(avatarBase64, true) _accountAvatarTexture = _texture_from_base64(avatarBase64)
if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty(): if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty():
selectedAvatarId = CUSTOM_AVATAR_ID selectedAvatarId = CUSTOM_AVATAR_ID
else: else:
@@ -390,7 +376,7 @@ func has_custom_skin() -> bool:
return _customSkinActive and _customSkinTexture != null return _customSkinActive and _customSkinTexture != null
func get_custom_avatar_texture() -> Texture2D: func get_custom_avatar_texture() -> Texture2D:
if _customAvatarActive and _customAvatarTexture != null: if has_custom_avatar():
return _customAvatarTexture return _customAvatarTexture
return _accountAvatarTexture return _accountAvatarTexture
@@ -729,45 +715,11 @@ func _sync_account_skin_profile() -> void:
func _connect_auth_events() -> void: func _connect_auth_events() -> void:
var eventSystem := get_node_or_null("/root/EventSystem") var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null: if eventSystem == null:
eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("appearance_update_succeeded"):
authManager.connect("appearance_update_succeeded", _on_appearance_update_succeeded)
if authManager != null and authManager.has_signal("appearance_update_failed"):
authManager.connect("appearance_update_failed", _on_appearance_update_failed)
func _on_appearance_update_succeeded(requestedSkinId: String, profile: Dictionary) -> void:
if requestedSkinId != _pendingSkinId:
return return
var confirmedSkinId := str(profile.get("skin_id", requestedSkinId)).strip_edges() eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
if confirmedSkinId.is_empty():
confirmedSkinId = requestedSkinId
_pendingSkinId = ""
_confirmedSkinId = confirmedSkinId
if selectedSkinId != confirmedSkinId:
_apply_selected_skin_locally(confirmedSkinId)
func _on_appearance_update_failed(requestedSkinId: String, _message: String) -> void:
if requestedSkinId != _pendingSkinId:
return
_pendingSkinId = ""
if not _confirmedSkinId.is_empty() and selectedSkinId == requestedSkinId:
_apply_selected_skin_locally(_confirmedSkinId)
func _apply_selected_skin_locally(skinId: String) -> void:
selectedSkinId = skinId
_save()
_emit_skin_changed()
_emit_profile_changed()
func _is_account_authenticated() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated"))
func _on_auth_logout(_data: Variant = null) -> void: func _on_auth_logout(_data: Variant = null) -> void:
_pendingSkinId = ""
_confirmedSkinId = FALLBACK_SKIN_ID
clear_account_profile() clear_account_profile()
func get_profile_payload() -> Dictionary: func get_profile_payload() -> Dictionary:
@@ -810,19 +762,11 @@ func _get_or_create_avatar_texture_rect(panel: PanelContainer) -> TextureRect:
textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
textureRect.stretch_mode = TextureRect.STRETCH_SCALE textureRect.stretch_mode = TextureRect.STRETCH_SCALE
textureRect.material = _create_avatar_mask_material()
textureRect.custom_minimum_size = panel.custom_minimum_size textureRect.custom_minimum_size = panel.custom_minimum_size
textureRect.set_anchors_preset(Control.PRESET_FULL_RECT) textureRect.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_child(textureRect) panel.add_child(textureRect)
return textureRect return textureRect
func _create_avatar_mask_material() -> ShaderMaterial:
var material := ShaderMaterial.new()
material.shader = AVATAR_MASK_SHADER
material.set_shader_parameter("corner_radius", 0.22)
material.set_shader_parameter("edge_feather", 0.008)
return material
func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary: func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary:
var normalized := skin.duplicate(true) var normalized := skin.duplicate(true)
var skinId := str(normalized.get("id", normalized.get("skin_id", ""))).strip_edges() var skinId := str(normalized.get("id", normalized.get("skin_id", ""))).strip_edges()
@@ -840,7 +784,7 @@ func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary:
normalized["account_asset"] = true normalized["account_asset"] = true
return normalized return normalized
func _texture_from_base64(base64Text: String, roundAvatar: bool = false) -> Texture2D: func _texture_from_base64(base64Text: String) -> Texture2D:
var bytes := Marshalls.base64_to_raw(base64Text.strip_edges()) var bytes := Marshalls.base64_to_raw(base64Text.strip_edges())
if bytes.is_empty(): if bytes.is_empty():
return null return null
@@ -848,9 +792,6 @@ func _texture_from_base64(base64Text: String, roundAvatar: bool = false) -> Text
var err := image.load_png_from_buffer(bytes) var err := image.load_png_from_buffer(bytes)
if err != OK: if err != OK:
return null return null
if roundAvatar:
image = _create_square_avatar_image(image)
_apply_rounded_rect_alpha(image)
return ImageTexture.create_from_image(image) return ImageTexture.create_from_image(image)
func _absolute_backend_url(path: String) -> String: func _absolute_backend_url(path: String) -> String:

View File

@@ -16,20 +16,12 @@ signal email_verification_sent(email: String)
signal email_verification_failed(message: String) signal email_verification_failed(message: String)
signal profile_update_succeeded(profile: Dictionary) signal profile_update_succeeded(profile: Dictionary)
signal profile_update_failed(message: String) signal profile_update_failed(message: String)
signal appearance_update_succeeded(skin_id: String, profile: Dictionary)
signal appearance_update_failed(skin_id: String, message: String)
signal session_refresh_completed(success: bool)
signal logout_completed() signal logout_completed()
signal browser_bootstrap_received(kind: String)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg" const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg"
const REQUEST_TIMEOUT: float = 12.0 const REQUEST_TIMEOUT: float = 12.0
const BROWSER_BOOTSTRAP_STORAGE_KEY: String = "whaletown.auth.bootstrap"
const BROWSER_BOOTSTRAP_POLL_INTERVAL: float = 0.25
const SESSION_REFRESH_CHECK_INTERVAL: float = 15.0
const SESSION_REFRESH_LEAD_TIME: float = 120.0
var _access_token: String = "" var _access_token: String = ""
var _refresh_token: String = "" var _refresh_token: String = ""
@@ -40,33 +32,10 @@ var _session_generation: int = 0
var _account_generation: int = 0 var _account_generation: int = 0
var _refresh_in_flight: bool = false var _refresh_in_flight: bool = false
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
var _pending_registration_username: String = "" var _show_welcome_after_registration: bool = false
var _pending_registration_password: String = ""
var _registration_recovery_in_flight: bool = false
var _registration_recovery_error: String = ""
var _browser_bootstrap_kind: String = ""
var _browser_bootstrap_poll_elapsed: float = 0.0
var _session_refresh_check_elapsed: float = 0.0
var _access_token_expires_at: float = 0.0
func _ready() -> void: func _ready() -> void:
_load_cached_session() _load_cached_session()
_try_import_browser_bootstrap(false)
if is_authenticated() and _token_needs_refresh():
call_deferred("refresh_session")
func _process(delta: float) -> void:
_session_refresh_check_elapsed += delta
if _session_refresh_check_elapsed >= SESSION_REFRESH_CHECK_INTERVAL:
_session_refresh_check_elapsed = 0.0
if is_authenticated() and _token_needs_refresh():
refresh_session()
if OS.get_name() == "Web" and _browser_bootstrap_kind.is_empty():
_browser_bootstrap_poll_elapsed += delta
if _browser_bootstrap_poll_elapsed >= BROWSER_BOOTSTRAP_POLL_INTERVAL:
_browser_bootstrap_poll_elapsed = 0.0
_try_import_browser_bootstrap(true)
func _exit_tree() -> void: func _exit_tree() -> void:
for request in _active_requests: for request in _active_requests:
@@ -102,13 +71,10 @@ func get_account_generation() -> int:
func get_auth_config_path() -> String: func get_auth_config_path() -> String:
return _auth_config_path return _auth_config_path
func consume_browser_bootstrap_kind() -> String: func consume_registration_welcome() -> bool:
var kind := _browser_bootstrap_kind var should_show: bool = _show_welcome_after_registration
_browser_bootstrap_kind = "" _show_welcome_after_registration = false
return kind return should_show
func get_browser_bootstrap_kind() -> String:
return _browser_bootstrap_kind
func login(identifier: String, password: String) -> void: func login(identifier: String, password: String) -> void:
var normalized_identifier := identifier.strip_edges() var normalized_identifier := identifier.strip_edges()
@@ -125,12 +91,8 @@ func login(identifier: String, password: String) -> void:
"password": password "password": password
}, _on_login_response, HTTPClient.METHOD_POST, false, true) }, _on_login_response, HTTPClient.METHOD_POST, false, true)
func send_email_verification(email: String, invitation_code: String = "") -> void: func send_email_verification(email: String) -> void:
var normalized_email := email.strip_edges() var normalized_email := email.strip_edges()
var normalized_invitation_code := invitation_code.strip_edges().to_upper()
if normalized_invitation_code.is_empty():
email_verification_failed.emit("请输入邀请码")
return
if normalized_email.is_empty(): if normalized_email.is_empty():
email_verification_failed.emit("请输入邮箱") email_verification_failed.emit("请输入邮箱")
return return
@@ -139,8 +101,7 @@ func send_email_verification(email: String, invitation_code: String = "") -> voi
return return
_request_json("/auth/send-email-verification", { _request_json("/auth/send-email-verification", {
"email": normalized_email, "email": normalized_email
"invitation_code": normalized_invitation_code
}, func(success: bool, _data: Dictionary, error_info: Dictionary) -> void: }, func(success: bool, _data: Dictionary, error_info: Dictionary) -> void:
if success: if success:
email_verification_sent.emit(normalized_email) email_verification_sent.emit(normalized_email)
@@ -148,12 +109,11 @@ func send_email_verification(email: String, invitation_code: String = "") -> voi
email_verification_failed.emit(str(error_info.get("message", "验证码发送失败"))) email_verification_failed.emit(str(error_info.get("message", "验证码发送失败")))
) )
func register(username: String, password: String, nickname: String = "", email: String = "", email_verification_code: String = "", skin_id: String = "", invitation_code: String = "") -> void: func register(username: String, password: String, nickname: String = "", email: String = "", email_verification_code: String = "", skin_id: String = "") -> void:
var normalized_username := username.strip_edges() var normalized_username := username.strip_edges()
var normalized_nickname := nickname.strip_edges() var normalized_nickname := nickname.strip_edges()
var normalized_email := email.strip_edges() var normalized_email := email.strip_edges()
var normalized_code := email_verification_code.strip_edges() var normalized_code := email_verification_code.strip_edges()
var normalized_invitation_code := invitation_code.strip_edges().to_upper()
if normalized_username.is_empty(): if normalized_username.is_empty():
register_failed.emit("请输入用户名") register_failed.emit("请输入用户名")
@@ -161,9 +121,6 @@ func register(username: String, password: String, nickname: String = "", email:
if not _is_valid_username(normalized_username): if not _is_valid_username(normalized_username):
register_failed.emit("用户名只能包含字母、数字和下划线,长度 1-50") register_failed.emit("用户名只能包含字母、数字和下划线,长度 1-50")
return return
if normalized_invitation_code.is_empty():
register_failed.emit("请输入邀请码")
return
if password.length() < 8: if password.length() < 8:
register_failed.emit("密码至少 8 位,并需要包含字母和数字") register_failed.emit("密码至少 8 位,并需要包含字母和数字")
return return
@@ -187,17 +144,12 @@ func register(username: String, password: String, nickname: String = "", email:
"password": password, "password": password,
"nickname": normalized_nickname, "nickname": normalized_nickname,
"email": normalized_email, "email": normalized_email,
"email_verification_code": normalized_code, "email_verification_code": normalized_code
"invitation_code": normalized_invitation_code
} }
var normalized_skin_id := skin_id.strip_edges() var normalized_skin_id := skin_id.strip_edges()
if not normalized_skin_id.is_empty(): if not normalized_skin_id.is_empty():
payload["skin_id"] = normalized_skin_id payload["skin_id"] = normalized_skin_id
_pending_registration_username = normalized_username
_pending_registration_password = password
_registration_recovery_in_flight = false
_registration_recovery_error = ""
_advance_account_generation() _advance_account_generation()
_request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true) _request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true)
@@ -220,11 +172,10 @@ func update_profile(profile_data: Dictionary) -> void:
assetPayload.erase("skin_id") assetPayload.erase("skin_id")
assetPayload.erase("settings") assetPayload.erase("settings")
if profile_data.has("skin_id") and playerStateManager.has_method("update_appearance"): if profile_data.has("skin_id") and playerStateManager.has_method("update_appearance"):
var requestedSkinId := str(profile_data.get("skin_id", "")).strip_edges()
playerStateManager.call( playerStateManager.call(
"update_appearance", "update_appearance",
requestedSkinId, str(profile_data.get("skin_id", "")),
Callable(self, "_on_appearance_profile_response").bind(requestedSkinId) Callable(self, "_on_profile_response")
) )
if profile_data.has("settings") and playerStateManager.has_method("update_settings"): if profile_data.has("settings") and playerStateManager.has_method("update_settings"):
var settingsVariant: Variant = profile_data.get("settings", {}) var settingsVariant: Variant = profile_data.get("settings", {})
@@ -251,17 +202,14 @@ func update_profile(profile_data: Dictionary) -> void:
if not profile_data.has("skin_id") and not profile_data.has("settings"): if not profile_data.has("skin_id") and not profile_data.has("settings"):
profile_update_failed.emit("没有可保存的玩家资料") profile_update_failed.emit("没有可保存的玩家资料")
func refresh_session() -> bool: func refresh_session() -> void:
if _refresh_token.strip_edges().is_empty(): if _refresh_token.strip_edges().is_empty() or _refresh_in_flight:
return false return
if _refresh_in_flight:
return true
_refresh_in_flight = true _refresh_in_flight = true
_request_json("/auth/refresh-token", { _request_json("/auth/refresh-token", {
"refresh_token": _refresh_token "refresh_token": _refresh_token
}, _on_refresh_response, HTTPClient.METHOD_POST, false, true) }, _on_refresh_response, HTTPClient.METHOD_POST, false, true)
return true
func logout() -> void: func logout() -> void:
_clear_session_memory() _clear_session_memory()
@@ -347,21 +295,15 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary)
func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void: func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
if not success: if not success:
var message := str(error_info.get("message", "注册失败")) register_failed.emit(str(error_info.get("message", "注册失败")))
if _should_recover_registration(message):
_recover_registration_session(message)
return
_clear_pending_registration()
register_failed.emit(message)
return return
_apply_auth_payload(data) _apply_auth_payload(data)
if not is_authenticated(): if not is_authenticated():
_clear_pending_registration()
register_failed.emit("注册响应缺少 access_token") register_failed.emit("注册响应缺少 access_token")
return return
_clear_pending_registration() _show_welcome_after_registration = true
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, { _emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
"user": get_current_user() "user": get_current_user()
}) })
@@ -369,58 +311,13 @@ func _on_register_response(success: bool, data: Dictionary, error_info: Dictiona
auth_state_changed.emit(true, get_current_user()) auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot() _refresh_player_snapshot()
func _should_recover_registration(message: String) -> bool:
if _registration_recovery_in_flight or _pending_registration_username.is_empty() or _pending_registration_password.is_empty():
return false
return message.begins_with("网络请求失败") \
or message.begins_with("网络请求发送失败") \
or message.contains("用户名已存在") \
or message.contains("用户名已被注册")
func _recover_registration_session(original_error: String) -> void:
_registration_recovery_in_flight = true
_registration_recovery_error = original_error
_request_json("/auth/login", {
"identifier": _pending_registration_username,
"password": _pending_registration_password,
}, _on_registration_recovery_response, HTTPClient.METHOD_POST, false, true)
func _on_registration_recovery_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
if not success:
var original_error := _registration_recovery_error
_clear_pending_registration()
register_failed.emit(str(error_info.get("message", original_error if not original_error.is_empty() else "注册状态确认失败")))
return
_apply_auth_payload(data)
if not is_authenticated():
_clear_pending_registration()
register_failed.emit("账号已创建,但登录确认失败")
return
_clear_pending_registration()
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
"user": get_current_user()
})
register_succeeded.emit(get_current_user())
auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot()
func _clear_pending_registration() -> void:
_pending_registration_username = ""
_pending_registration_password = ""
_registration_recovery_in_flight = false
_registration_recovery_error = ""
func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void: func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
_refresh_in_flight = false _refresh_in_flight = false
if not success: if not success:
session_refresh_completed.emit(false)
logout() logout()
return return
_apply_auth_payload(data) _apply_auth_payload(data)
session_refresh_completed.emit(true)
auth_state_changed.emit(true, get_current_user()) auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot() _refresh_player_snapshot()
@@ -432,11 +329,6 @@ func _apply_auth_payload(payload: Dictionary) -> void:
var auth_data: Dictionary = data_variant var auth_data: Dictionary = data_variant
_access_token = str(auth_data.get("access_token", "")).strip_edges() _access_token = str(auth_data.get("access_token", "")).strip_edges()
_refresh_token = str(auth_data.get("refresh_token", _refresh_token)).strip_edges() _refresh_token = str(auth_data.get("refresh_token", _refresh_token)).strip_edges()
_access_token_expires_at = _jwt_expiration_time(_access_token)
if _access_token_expires_at <= 0.0:
var expiresIn := float(auth_data.get("expires_in", 0.0))
if expiresIn > 0.0:
_access_token_expires_at = Time.get_unix_time_from_system() + expiresIn
var user_variant: Variant = auth_data.get("user", {}) var user_variant: Variant = auth_data.get("user", {})
if user_variant is Dictionary: if user_variant is Dictionary:
@@ -449,38 +341,6 @@ func _apply_auth_payload(payload: Dictionary) -> void:
_session_generation += 1 _session_generation += 1
_save_cached_session() _save_cached_session()
func _try_import_browser_bootstrap(emit_signal: bool) -> bool:
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
return false
var js_bridge: Object = Engine.get_singleton("JavaScriptBridge")
if js_bridge == null:
return false
var raw: String = str(js_bridge.eval(
"window.sessionStorage.getItem('%s') || ''" % BROWSER_BOOTSTRAP_STORAGE_KEY,
true
)).strip_edges()
if raw.is_empty():
return false
var json := JSON.new()
if json.parse(raw) != OK or not (json.data is Dictionary):
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
return false
var bootstrap := json.data as Dictionary
var kind := str(bootstrap.get("kind", "")).strip_edges()
var payload_variant: Variant = bootstrap.get("payload", {})
if not ["login", "register"].has(kind) or not (payload_variant is Dictionary):
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
return false
_apply_auth_payload(payload_variant as Dictionary)
if not is_authenticated():
return false
_browser_bootstrap_kind = kind
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
if emit_signal:
browser_bootstrap_received.emit(kind)
auth_state_changed.emit(true, get_current_user())
return true
func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void: func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
if not success: if not success:
profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败"))) profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败")))
@@ -500,36 +360,6 @@ func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictiona
profile_update_succeeded.emit(get_current_profile()) profile_update_succeeded.emit(get_current_profile())
auth_state_changed.emit(is_authenticated(), get_current_user()) auth_state_changed.emit(is_authenticated(), get_current_user())
func _on_appearance_profile_response(
success: bool,
data: Dictionary,
errorInfo: Dictionary,
requestedSkinId: String
) -> void:
if not success:
var message := str(errorInfo.get("message", "玩家资料保存失败"))
appearance_update_failed.emit(requestedSkinId, message)
profile_update_failed.emit(message)
auth_state_changed.emit(is_authenticated(), get_current_user())
return
var dataVariant: Variant = data.get("data", {})
if not (dataVariant is Dictionary):
var message := "玩家状态响应格式错误"
appearance_update_failed.emit(requestedSkinId, message)
profile_update_failed.emit(message)
auth_state_changed.emit(is_authenticated(), get_current_user())
return
var snapshot := dataVariant as Dictionary
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_method("apply_snapshot"):
playerStateManager.call("apply_snapshot", snapshot)
_apply_snapshot_profile_payload(snapshot)
var profile := get_current_profile()
appearance_update_succeeded.emit(requestedSkinId, profile)
profile_update_succeeded.emit(profile)
auth_state_changed.emit(is_authenticated(), get_current_user())
func _on_profile_assets_response(success: bool, data: Dictionary, _error_info: Dictionary, requestGeneration: int) -> void: func _on_profile_assets_response(success: bool, data: Dictionary, _error_info: Dictionary, requestGeneration: int) -> void:
if requestGeneration != _account_generation: if requestGeneration != _account_generation:
return return
@@ -603,7 +433,6 @@ func _load_cached_session(emit_cached_state: bool = true) -> void:
_refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges() _refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges()
_access_token = str(config.get_value("auth", "access_token", "")).strip_edges() _access_token = str(config.get_value("auth", "access_token", "")).strip_edges()
_access_token_expires_at = _jwt_expiration_time(_access_token)
_current_user.clear() _current_user.clear()
_current_profile.clear() _current_profile.clear()
@@ -619,7 +448,6 @@ func _emit_cached_auth_state() -> void:
func _clear_session_memory() -> void: func _clear_session_memory() -> void:
_access_token = "" _access_token = ""
_refresh_token = "" _refresh_token = ""
_access_token_expires_at = 0.0
_current_user.clear() _current_user.clear()
_current_profile.clear() _current_profile.clear()
_advance_account_generation() _advance_account_generation()
@@ -676,26 +504,6 @@ func _password_has_letter_and_number(password: String) -> bool:
has_number = true has_number = true
return has_letter and has_number return has_letter and has_number
func _token_needs_refresh() -> bool:
if _refresh_in_flight or _refresh_token.is_empty():
return false
if _access_token_expires_at <= 0.0:
return false
return Time.get_unix_time_from_system() + SESSION_REFRESH_LEAD_TIME >= _access_token_expires_at
func _jwt_expiration_time(token: String) -> float:
var parts := token.split(".")
if parts.size() != 3:
return 0.0
var payload := str(parts[1]).replace("-", "+").replace("_", "/")
while payload.length() % 4 != 0:
payload += "="
var jsonText := Marshalls.base64_to_raw(payload).get_string_from_utf8()
var json := JSON.new()
if json.parse(jsonText) != OK or not (json.data is Dictionary):
return 0.0
return float((json.data as Dictionary).get("exp", 0.0))
func _http_result_to_string(result: int) -> String: func _http_result_to_string(result: int) -> String:
match result: match result:
HTTPRequest.RESULT_SUCCESS: HTTPRequest.RESULT_SUCCESS:

View File

@@ -66,13 +66,11 @@ const CHAT_WEBSOCKET_MANAGER_SCRIPT: Script = preload("res://_Core/managers/WebS
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
# WebSocket 服务器 URL原生 WebSocket # WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletown.novamailio.com/game" const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
# 重连配置 # 重连配置
const RECONNECT_MAX_ATTEMPTS: int = 5 const RECONNECT_MAX_ATTEMPTS: int = 5
const RECONNECT_BASE_DELAY: float = 3.0 const RECONNECT_BASE_DELAY: float = 3.0
const APPEARANCE_BROADCAST_RETRY_DELAY: float = 1.5
const APPEARANCE_BROADCAST_MAX_RETRIES: int = 5
# 频率限制配置 # 频率限制配置
const RATE_LIMIT_MESSAGES: int = 10 const RATE_LIMIT_MESSAGES: int = 10
@@ -100,9 +98,7 @@ const CHAT_ERROR_MESSAGES: Dictionary = {
"PERMISSION_DENIED": "您没有权限发送消息", "PERMISSION_DENIED": "您没有权限发送消息",
"SESSION_EXPIRED": "会话已过期,请重新连接", "SESSION_EXPIRED": "会话已过期,请重新连接",
"ZULIP_ERROR": "消息服务暂时不可用", "ZULIP_ERROR": "消息服务暂时不可用",
"INTERNAL_ERROR": "服务器内部错误", "INTERNAL_ERROR": "服务器内部错误"
"INSUFFICIENT_BALANCE": "鲸币余额不足,发布世界公告需要 100 鲸币",
"WALLET_UNAVAILABLE": "鲸币服务暂不可用,请稍后再试"
} }
# ============================================================================ # ============================================================================
@@ -129,18 +125,11 @@ var _message_timestamps: Array[float] = []
# 当前用户信息 # 当前用户信息
var _current_username: String = "" var _current_username: String = ""
var _current_user_id: String = ""
var _current_map: String = "" var _current_map: String = ""
var _world_ready_map: String = "" var _world_ready_map: String = ""
var _world_ready_confirmed: bool = false
var _appearance_broadcast_pending: bool = false
var _appearance_broadcast_skin_id: String = ""
var _appearance_broadcast_retry_count: int = 0
var _appearance_broadcast_retry_timer: Timer
# 游戏 token # 游戏 token
var _game_token: String = "" var _game_token: String = ""
var _guest_mode: bool = false
# 连接/登录完成前暂存用户已经提交的消息,避免刚进游戏时第一条消息被丢掉。 # 连接/登录完成前暂存用户已经提交的消息,避免刚进游戏时第一条消息被丢掉。
var _queued_messages: Array[Dictionary] = [] var _queued_messages: Array[Dictionary] = []
@@ -175,10 +164,6 @@ func _ready() -> void:
_history_request.timeout = 12.0 _history_request.timeout = 12.0
_history_request.request_completed.connect(_on_history_request_completed) _history_request.request_completed.connect(_on_history_request_completed)
add_child(_history_request) add_child(_history_request)
_appearance_broadcast_retry_timer = Timer.new()
_appearance_broadcast_retry_timer.one_shot = true
_appearance_broadcast_retry_timer.timeout.connect(_on_appearance_broadcast_retry_timeout)
add_child(_appearance_broadcast_retry_timer)
# 连接信号 # 连接信号
_connect_signals() _connect_signals()
@@ -224,24 +209,13 @@ func set_game_token(token: String) -> void:
_queued_messages.clear() _queued_messages.clear()
_recoverable_messages.clear() _recoverable_messages.clear()
_world_ready_map = "" _world_ready_map = ""
_world_ready_confirmed = false
_clear_pending_appearance_broadcast()
func is_logged_in() -> bool: func is_logged_in() -> bool:
return _is_logged_in return _is_logged_in
func is_guest_mode() -> bool: func mark_world_ready(mapId: String, position: Vector2) -> void:
return _guest_mode
func start_guest_session() -> void:
_guest_mode = true
_game_token = ""
_is_logged_in = false
connect_to_chat_server()
func mark_world_ready(mapId: String, position: Vector2, direction: String = "down", movementState: String = "idle", sequence: int = 0) -> void:
var normalizedMapId := mapId.strip_edges() var normalizedMapId := mapId.strip_edges()
if not _is_logged_in or normalizedMapId.is_empty() or (_world_ready_map == normalizedMapId and _world_ready_confirmed): if not _is_logged_in or normalizedMapId.is_empty() or _world_ready_map == normalizedMapId:
return return
var payload := { var payload := {
@@ -249,13 +223,9 @@ func mark_world_ready(mapId: String, position: Vector2, direction: String = "dow
"mapId": normalizedMapId, "mapId": normalizedMapId,
"x": position.x, "x": position.x,
"y": position.y, "y": position.y,
"direction": direction,
"movementState": movementState,
"sequence": sequence,
} }
if _websocket_manager.send_message(JSON.stringify(payload)) == OK: if _websocket_manager.send_message(JSON.stringify(payload)) == OK:
_world_ready_map = normalizedMapId _world_ready_map = normalizedMapId
_world_ready_confirmed = false
func leave_world(sceneId: String = "private_space") -> void: func leave_world(sceneId: String = "private_space") -> void:
if not _is_logged_in or _world_ready_map.is_empty(): if not _is_logged_in or _world_ready_map.is_empty():
@@ -269,60 +239,11 @@ func leave_world(sceneId: String = "private_space") -> void:
} }
if _websocket_manager.send_message(JSON.stringify(payload)) == OK: if _websocket_manager.send_message(JSON.stringify(payload)) == OK:
_world_ready_map = "" _world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
func notify_appearance_changed(skinId: String = "") -> void: func notify_appearance_changed() -> void:
var normalizedSkinId := skinId.strip_edges() if not _is_logged_in or _world_ready_map.is_empty():
if not normalizedSkinId.is_empty():
_appearance_broadcast_skin_id = normalizedSkinId
_appearance_broadcast_pending = true
_appearance_broadcast_retry_count = 0
_try_send_pending_appearance_broadcast()
func _try_send_pending_appearance_broadcast() -> void:
if not _appearance_broadcast_pending or not _is_logged_in or not _world_ready_confirmed:
return return
if _world_ready_map.is_empty() or _appearance_broadcast_retry_count >= APPEARANCE_BROADCAST_MAX_RETRIES:
return
_appearance_broadcast_retry_count += 1
_websocket_manager.send_message(JSON.stringify({"type": "appearance_changed"})) _websocket_manager.send_message(JSON.stringify({"type": "appearance_changed"}))
_schedule_appearance_broadcast_retry()
func _schedule_appearance_broadcast_retry() -> void:
if _appearance_broadcast_retry_count >= APPEARANCE_BROADCAST_MAX_RETRIES:
return
if is_instance_valid(_appearance_broadcast_retry_timer):
_appearance_broadcast_retry_timer.start(APPEARANCE_BROADCAST_RETRY_DELAY)
func _stop_appearance_broadcast_retry() -> void:
if is_instance_valid(_appearance_broadcast_retry_timer):
_appearance_broadcast_retry_timer.stop()
func _clear_pending_appearance_broadcast() -> void:
_appearance_broadcast_pending = false
_appearance_broadcast_skin_id = ""
_appearance_broadcast_retry_count = 0
_stop_appearance_broadcast_retry()
func _on_appearance_broadcast_retry_timeout() -> void:
_try_send_pending_appearance_broadcast()
func _handle_world_ready_success(data: Dictionary) -> void:
var confirmedMapId := str(data.get("mapId", data.get("map_id", ""))).strip_edges()
if not confirmedMapId.is_empty():
_world_ready_map = confirmedMapId
_world_ready_confirmed = not _world_ready_map.is_empty()
if _appearance_broadcast_pending:
_appearance_broadcast_retry_count = 0
_try_send_pending_appearance_broadcast()
func _handle_appearance_changed_success(data: Dictionary) -> void:
var confirmedSkinId := str(data.get("skinId", data.get("skin_id", ""))).strip_edges()
if not _appearance_broadcast_skin_id.is_empty() and confirmedSkinId != _appearance_broadcast_skin_id:
_schedule_appearance_broadcast_retry()
return
_clear_pending_appearance_broadcast()
# 获取游戏 token # 获取游戏 token
# #
@@ -346,12 +267,12 @@ func connect_to_chat_server() -> void:
push_warning("聊天服务器正在连接") push_warning("聊天服务器正在连接")
return return
if not _guest_mode and _game_token.strip_edges().is_empty(): if _game_token.strip_edges().is_empty():
var authToken := _get_auth_manager_token() var authToken := _get_auth_manager_token()
if not authToken.is_empty(): if not authToken.is_empty():
_game_token = authToken _game_token = authToken
if not _guest_mode and _game_token.strip_edges().is_empty(): if _game_token.strip_edges().is_empty():
_handle_error("AUTH_FAILED", "请先登录后再使用聊天") _handle_error("AUTH_FAILED", "请先登录后再使用聊天")
return return
@@ -370,7 +291,6 @@ func disconnect_from_chat_server() -> void:
# 断开连接 # 断开连接
_websocket_manager.disconnect_websocket() _websocket_manager.disconnect_websocket()
_guest_mode = false
# 检查是否已连接 # 检查是否已连接
# #
@@ -405,49 +325,6 @@ func can_attempt_chat_connection() -> bool:
func send_chat_message(content: String, scope: String = "local", show_bubble: bool = false) -> bool: func send_chat_message(content: String, scope: String = "local", show_bubble: bool = false) -> bool:
return _send_chat_payload(content, scope, {"bubble": show_bubble and _settings_bool("show_chat_bubbles", true)}) return _send_chat_payload(content, scope, {"bubble": show_bubble and _settings_bool("show_chat_bubbles", true)})
## Publish a paid world announcement. The server owns validation and charging.
func send_world_bulletin(content: String) -> bool:
if not _is_logged_in:
_handle_error("AUTH_FAILED", "请先登录后再发布世界公告")
return false
# 客户端只表达发布意图;价格、实时余额校验和扣费全由服务端处理。
return _send_chat_payload(content, "global", {
"bubble": false,
"worldBulletin": true,
"_suppressLocalEcho": true,
"_nonRecoverable": true
})
func interact_with_world_npc(npc_id: String, message: String = "", session_id: String = "") -> bool:
var normalized_npc_id := npc_id.strip_edges()
if normalized_npc_id.is_empty() or not _is_logged_in:
return false
if not _websocket_manager.is_websocket_connected():
connect_to_chat_server()
return false
var payload := {
"type": "npc_interact",
"npcId": normalized_npc_id,
"message": message.strip_edges().left(300),
}
if not session_id.strip_edges().is_empty():
payload["sessionId"] = session_id.strip_edges()
var send_err: Error = _websocket_manager.send_message(JSON.stringify(payload))
if send_err != OK:
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
return false
return true
func end_world_npc_session(npc_id: String, session_id: String = "") -> bool:
if npc_id.strip_edges().is_empty() or not _is_logged_in or not _websocket_manager.is_websocket_connected():
return false
return _websocket_manager.send_message(JSON.stringify({
"type": "npc_session_end", "npcId": npc_id.strip_edges(), "sessionId": session_id.strip_edges(),
})) == OK
func get_current_user_id() -> String:
return _current_user_id
func send_private_message(content: String, target_user_id: String, target_username: String = "", private_context: String = "") -> bool: func send_private_message(content: String, target_user_id: String, target_username: String = "", private_context: String = "") -> bool:
if not _settings_bool("allow_nearby_private", true) and private_context.strip_edges() == "whisper": if not _settings_bool("allow_nearby_private", true) and private_context.strip_edges() == "whisper":
_handle_error("PRIVATE_DISABLED", "附近私聊已在设置中关闭") _handle_error("PRIVATE_DISABLED", "附近私聊已在设置中关闭")
@@ -572,7 +449,6 @@ func _send_chat_payload(content: String, scope: String = "local", extra_data: Di
var show_bubble: bool = bool(extra_data.get("bubble", extra_data.get("showBubble", extra_data.get("show_bubble", false)))) and _settings_bool("show_chat_bubbles", true) var show_bubble: bool = bool(extra_data.get("bubble", extra_data.get("showBubble", extra_data.get("show_bubble", false)))) and _settings_bool("show_chat_bubbles", true)
var suppress_local_echo := bool(extra_data.get("_suppressLocalEcho", false)) var suppress_local_echo := bool(extra_data.get("_suppressLocalEcho", false))
var is_recovery_retry := bool(extra_data.get("_isRecoveryRetry", false)) var is_recovery_retry := bool(extra_data.get("_isRecoveryRetry", false))
var non_recoverable := bool(extra_data.get("_nonRecoverable", false))
var recoverable_extra_data := extra_data.duplicate(true) var recoverable_extra_data := extra_data.duplicate(true)
recoverable_extra_data.erase("_suppressLocalEcho") recoverable_extra_data.erase("_suppressLocalEcho")
@@ -625,12 +501,11 @@ func _send_chat_payload(content: String, scope: String = "local", extra_data: Di
if send_err != OK: if send_err != OK:
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err)) _handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
return false return false
if not non_recoverable: _track_recoverable_message({
_track_recoverable_message({ "content": normalized_content,
"content": normalized_content, "scope": scope,
"scope": scope, "extra_data": recoverable_extra_data
"extra_data": recoverable_extra_data })
})
# 记录发送时间 # 记录发送时间
if not is_recovery_retry: if not is_recovery_retry:
@@ -700,12 +575,10 @@ func _on_chat_message_sent(_request_id: String, success: bool, data: Dictionary,
# #
# 使用示例: # 使用示例:
# ChatManager.update_player_position(150.0, 200.0, "novice_village") # ChatManager.update_player_position(150.0, 200.0, "novice_village")
func update_player_position(x: float, y: float, map_id: String, direction: String = "down", movementState: String = "walk", sequence: int = 0) -> void: func update_player_position(x: float, y: float, map_id: String) -> void:
update_player_position_with_appearance(x, y, map_id, {}, direction, movementState, sequence) update_player_position_with_appearance(x, y, map_id, {})
func update_player_position_with_appearance(x: float, y: float, map_id: String, _appearance: Dictionary = {}, direction: String = "down", movementState: String = "walk", sequence: int = 0) -> void: func update_player_position_with_appearance(x: float, y: float, map_id: String, _appearance: Dictionary = {}) -> void:
if _guest_mode:
return
if not _websocket_manager.is_websocket_connected(): if not _websocket_manager.is_websocket_connected():
connect_to_chat_server() connect_to_chat_server()
return return
@@ -716,10 +589,7 @@ func update_player_position_with_appearance(x: float, y: float, map_id: String,
"type": "position", "type": "position",
"x": x, "x": x,
"y": y, "y": y,
"mapId": map_id, "mapId": map_id
"direction": direction,
"movementState": movementState,
"sequence": sequence
} }
# 发送消息JSON 字符串) # 发送消息JSON 字符串)
var json_string := JSON.stringify(position_data) var json_string := JSON.stringify(position_data)
@@ -734,8 +604,6 @@ func update_player_position_with_appearance(x: float, y: float, map_id: String,
# 返回值: # 返回值:
# bool - 是否可以发送 # bool - 是否可以发送
func can_send_message() -> bool: func can_send_message() -> bool:
if _guest_mode:
return false
var current_time := Time.get_unix_time_from_system() var current_time := Time.get_unix_time_from_system()
# 清理过期的时间戳 # 清理过期的时间戳
@@ -926,8 +794,6 @@ func _connect_signals() -> void:
func _on_connection_lost() -> void: func _on_connection_lost() -> void:
_is_logged_in = false _is_logged_in = false
_world_ready_map = "" _world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
_restore_recoverable_messages_to_queue() _restore_recoverable_messages_to_queue()
func _load_token_from_environment() -> void: func _load_token_from_environment() -> void:
@@ -1045,9 +911,6 @@ func _preview_text(text: String) -> String:
# 发送登录消息 # 发送登录消息
func _send_login_message() -> void: func _send_login_message() -> void:
if _guest_mode:
_websocket_manager.send_message(JSON.stringify({"type": "guest_login"}))
return
if _game_token.strip_edges().is_empty(): if _game_token.strip_edges().is_empty():
_handle_error("AUTH_FAILED", "缺少聊天认证 token") _handle_error("AUTH_FAILED", "缺少聊天认证 token")
return return
@@ -1104,12 +967,8 @@ func _on_data_received(message: String) -> void:
match message_type: match message_type:
"connected": "connected":
pass pass
"pong":
pass
"login_success": "login_success":
_handle_login_success(data) _handle_login_success(data)
"guest_login_success":
_handle_guest_login_success(data)
"login_error": "login_error":
_handle_login_error(data) _handle_login_error(data)
"chat": "chat":
@@ -1120,6 +979,12 @@ func _on_data_received(message: String) -> void:
_handle_chat_error(data) _handle_chat_error(data)
"chat_render": "chat_render":
_handle_chat_render(data) _handle_chat_render(data)
"dm_message":
_handle_direct_message(data)
"dm_read":
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_read", "data": data})
"notification_created", "friendship_changed", "friend_presence_changed":
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": message_type, "data": data})
"friend_list": "friend_list":
_handle_friend_list(data) _handle_friend_list(data)
"friend_added": "friend_added":
@@ -1138,50 +1003,6 @@ func _on_data_received(message: String) -> void:
_handle_friend_error(data) _handle_friend_error(data)
"map_players_snapshot": "map_players_snapshot":
_handle_map_players_snapshot(data) _handle_map_players_snapshot(data)
"npc_snapshot":
_handle_npc_snapshot(data)
"npc_action_started":
_emit_event(EventNames.NPC_ACTION_STARTED, _normalize_npc_action_event(data))
"npc_action_completed":
_emit_event(EventNames.NPC_ACTION_COMPLETED, _normalize_npc_action_event(data))
"npc_spoke":
_emit_event(EventNames.NPC_SPOKE, {
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
"npc_name": str(data.get("npcName", data.get("npc_name", "NPC"))),
"response": str(data.get("response", "")),
"public_intention": str(data.get("publicIntention", data.get("public_intention", ""))),
"activity": data.get("activity", {}),
"memory_id": str(data.get("memoryId", data.get("memory_id", ""))),
"target_user_id": str(data.get("targetUserId", data.get("target_user_id", ""))),
"target_username": str(data.get("targetUsername", data.get("target_username", ""))),
"session_id": str(data.get("sessionId", data.get("session_id", ""))),
})
"npc_interaction_success":
pass
"npc_interaction_error":
_emit_event(EventNames.NPC_INTERACTION_ERROR, {
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
"code": str(data.get("code", "INTERACTION_REJECTED")),
"message": str(data.get("message", "NPC暂时无法回应")),
})
"npc_conversation":
var conversationLines: Array[Dictionary] = []
var rawLines: Variant = data.get("lines", [])
if rawLines is Array:
for rawLine in rawLines:
if rawLine is Dictionary:
conversationLines.append({
"speaker_npc_id": str(rawLine.get("speakerNpcId", rawLine.get("speaker_npc_id", ""))),
"speaker_name": str(rawLine.get("speakerName", rawLine.get("speaker_name", "NPC"))),
"text": str(rawLine.get("text", "")),
})
_emit_event(EventNames.NPC_CONVERSATION, {
"conversation_id": str(data.get("conversationId", data.get("conversation_id", ""))),
"encounter_id": str(data.get("encounterId", data.get("encounter_id", ""))),
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"location_id": str(data.get("locationId", data.get("location_id", ""))),
"lines": conversationLines,
})
"system_presence": "system_presence":
_handle_system_presence(data) _handle_system_presence(data)
"position_update": "position_update":
@@ -1191,13 +1012,9 @@ func _on_data_received(message: String) -> void:
"appearance_changed": "appearance_changed":
_handle_player_joined(data) _handle_player_joined(data)
"world_ready_success": "world_ready_success":
_handle_world_ready_success(data) pass
"appearance_changed_success":
_handle_appearance_changed_success(data)
"world_left": "world_left":
_world_ready_map = "" _world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
"player_left": "player_left":
_handle_player_left(data) _handle_player_left(data)
"position_updated": "position_updated":
@@ -1220,17 +1037,13 @@ func _warn_empty_message_type_limited(data: Dictionary) -> void:
# 处理登录成功 # 处理登录成功
func _handle_login_success(data: Dictionary) -> void: func _handle_login_success(data: Dictionary) -> void:
_guest_mode = false
_is_logged_in = true _is_logged_in = true
_refreshing_auth_session = false _refreshing_auth_session = false
_chat_login_refresh_attempted = false _chat_login_refresh_attempted = false
_friend_protocol_supported = true _friend_protocol_supported = true
_current_username = data.get("username", "") _current_username = data.get("username", "")
_current_user_id = str(data.get("userId", data.get("user_id", "")))
_current_map = data.get("currentMap", "") _current_map = data.get("currentMap", "")
_world_ready_map = "" _world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
# 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息) # 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息)
reset_session() reset_session()
@@ -1244,18 +1057,6 @@ func _handle_login_success(data: Dictionary) -> void:
_flush_queued_messages() _flush_queued_messages()
request_friend_list() request_friend_list()
func _handle_guest_login_success(data: Dictionary) -> void:
_guest_mode = true
_is_logged_in = true
_friend_protocol_supported = false
_current_username = "游客"
_current_user_id = ""
_current_map = str(data.get("currentMap", "whale_port"))
_world_ready_map = ""
_world_ready_confirmed = false
reset_session()
_emit_event(EventNames.CHAT_LOGIN_SUCCESS, {"username": "游客", "current_map": _current_map, "guest": true})
func _handle_system_presence(data: Dictionary) -> void: func _handle_system_presence(data: Dictionary) -> void:
var content := str(data.get("content", "")).strip_edges() var content := str(data.get("content", "")).strip_edges()
if content.is_empty(): if content.is_empty():
@@ -1275,8 +1076,8 @@ func _handle_system_presence(data: Dictionary) -> void:
chat_message_received.emit("系统", content, false, timestamp) chat_message_received.emit("系统", content, false, timestamp)
_emit_event(EventNames.CHAT_MESSAGE_RECEIVED, message) _emit_event(EventNames.CHAT_MESSAGE_RECEIVED, message)
func _on_account_profile_updated(profile: Dictionary) -> void: func _on_account_profile_updated(_profile: Dictionary) -> void:
notify_appearance_changed(str(profile.get("skin_id", ""))) notify_appearance_changed()
func _on_auth_state_changed(isAuthenticated: bool, _user: Dictionary) -> void: func _on_auth_state_changed(isAuthenticated: bool, _user: Dictionary) -> void:
if not isAuthenticated: if not isAuthenticated:
@@ -1316,19 +1117,6 @@ func _handle_login_error(data: Dictionary) -> void:
func _handle_chat_sent(data: Dictionary) -> void: func _handle_chat_sent(data: Dictionary) -> void:
var message_id: String = str(data.get("messageId", "")) var message_id: String = str(data.get("messageId", ""))
var timestamp: float = data.get("timestamp", 0.0) var timestamp: float = data.get("timestamp", 0.0)
var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false)))
if is_world_bulletin and data.has("balance"):
# 直接采用这次后端交易返回的余额,不在客户端自行计算扣费。
var player_state_manager := get_node_or_null("/root/PlayerStateManager")
if player_state_manager != null and player_state_manager.has_method("apply_wallet"):
var wallet: Dictionary = {}
if player_state_manager.has_method("get_wallet"):
var current_wallet: Variant = player_state_manager.call("get_wallet")
if current_wallet is Dictionary:
wallet = (current_wallet as Dictionary).duplicate(true)
wallet["balance"] = int(data.get("balance", 0))
wallet["currency"] = "whale_coin"
player_state_manager.call("apply_wallet", wallet)
# 发射信号 # 发射信号
chat_message_sent.emit(message_id, timestamp) chat_message_sent.emit(message_id, timestamp)
@@ -1336,28 +1124,24 @@ func _handle_chat_sent(data: Dictionary) -> void:
# 通过 EventSystem 广播Signal Up # 通过 EventSystem 广播Signal Up
_emit_event(EventNames.CHAT_MESSAGE_SENT, { _emit_event(EventNames.CHAT_MESSAGE_SENT, {
"message_id": message_id, "message_id": message_id,
"timestamp": timestamp, "timestamp": timestamp
"world_bulletin": is_world_bulletin,
"charged": int(data.get("charged", 0)),
"balance": int(data.get("balance", 0))
}) })
if not is_world_bulletin and not _recoverable_messages.is_empty(): if not _recoverable_messages.is_empty():
_recoverable_messages.pop_front() _recoverable_messages.pop_front()
# 处理聊天消息发送失败 # 处理聊天消息发送失败
func _handle_chat_error(data: Dictionary) -> void: func _handle_chat_error(data: Dictionary) -> void:
var error_message: String = data.get("message", "消息发送失败") var error_message: String = data.get("message", "消息发送失败")
var error_code: String = str(data.get("code", data.get("error_code", "CHAT_SEND_FAILED"))).strip_edges() var error_code: String = str(data.get("code", data.get("error_code", "CHAT_SEND_FAILED"))).strip_edges()
var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false))) if error_code == "SESSION_EXPIRED":
_handle_session_expired(error_message)
return
# 通过 EventSystem 广播错误Signal Up # 通过 EventSystem 广播错误Signal Up
_emit_event(EventNames.CHAT_ERROR_OCCURRED, { _emit_event(EventNames.CHAT_ERROR_OCCURRED, {
"error_code": error_code if not error_code.is_empty() else "CHAT_SEND_FAILED", "error_code": error_code if not error_code.is_empty() else "CHAT_SEND_FAILED",
"message": error_message, "message": error_message
"world_bulletin": is_world_bulletin
}) })
if error_code == "SESSION_EXPIRED":
_handle_session_expired(error_message)
func _handle_friend_list(data: Dictionary) -> void: func _handle_friend_list(data: Dictionary) -> void:
_friends.clear() _friends.clear()
@@ -1478,7 +1262,6 @@ func _handle_chat_render(data: Dictionary) -> void:
var to_username: String = str(data.get("toUsername", data.get("to_username", data.get("targetUsername", data.get("target_username", ""))))).strip_edges() var to_username: String = str(data.get("toUsername", data.get("to_username", data.get("targetUsername", data.get("target_username", ""))))).strip_edges()
var private_context: String = str(data.get("privateContext", data.get("private_context", ""))).strip_edges() var private_context: String = str(data.get("privateContext", data.get("private_context", ""))).strip_edges()
var is_private: bool = scope == "private" var is_private: bool = scope == "private"
var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false)))
var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0)) var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
@@ -1503,8 +1286,7 @@ func _handle_chat_render(data: Dictionary) -> void:
"to_user_id": to_user_id, "to_user_id": to_user_id,
"to_username": to_username, "to_username": to_username,
"private_context": private_context, "private_context": private_context,
"is_private": is_private, "is_private": is_private
"world_bulletin": is_world_bulletin
}) })
# 发射信号 # 发射信号
@@ -1522,10 +1304,44 @@ func _handle_chat_render(data: Dictionary) -> void:
"to_user_id": to_user_id, "to_user_id": to_user_id,
"to_username": to_username, "to_username": to_username,
"private_context": private_context, "private_context": private_context,
"is_private": is_private, "is_private": is_private
"world_bulletin": is_world_bulletin
}) })
func _handle_direct_message(data: Dictionary) -> void:
var message_variant: Variant = data.get("message", {})
if not (message_variant is Dictionary):
return
var message: Dictionary = message_variant
var sender_variant: Variant = message.get("sender", {})
var sender: Dictionary = sender_variant if sender_variant is Dictionary else {}
var sender_id := str(message.get("senderId", "")).strip_edges()
var recipient_id := str(message.get("recipientId", "")).strip_edges()
var from_user := str(sender.get("nickname", sender.get("username", "玩家"))).strip_edges()
var is_self := sender_id == _current_user_id()
var payload := {
"from": from_user,
"fromUserId": sender_id,
"txt": str(message.get("content", "")),
"timestamp": message.get("createdAt", Time.get_unix_time_from_system()),
"scope": "private",
"toUserId": recipient_id,
"toUsername": "",
"privateContext": "dm",
"bubble": false,
}
if is_self and _consume_pending_self_message(str(payload.get("txt", "")), "private", recipient_id):
return
_handle_chat_render(payload)
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_message", "data": data})
func _current_user_id() -> String:
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_method("get_current_user"):
var user_variant: Variant = authManager.call("get_current_user")
if user_variant is Dictionary:
return str((user_variant as Dictionary).get("id", ""))
return ""
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串) # 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float: func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT: if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
@@ -1584,16 +1400,13 @@ func _handle_position_update(data: Dictionary) -> void:
"userId": user_id, "userId": user_id,
"username": str(data.get("username", "")), "username": str(data.get("username", "")),
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))), "position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
"direction": str(data.get("direction", "down")), "mapId": str(data.get("mapId", data.get("map_id", ""))),
"movement_state": str(data.get("movementState", data.get("movement_state", "walk"))), "skin_id": str(appearance.get("skin_id", "")),
"sequence": int(data.get("sequence", -1)), "avatar_id": str(appearance.get("avatar_id", "")),
"mapId": str(data.get("mapId", data.get("map_id", ""))), "skin_asset": appearance.get("skin_asset", {}),
"skin_id": str(appearance.get("skin_id", "")), "cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
"avatar_id": str(appearance.get("avatar_id", "")), "movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
"skin_asset": appearance.get("skin_asset", {}), })
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
})
func _handle_map_players_snapshot(data: Dictionary) -> void: func _handle_map_players_snapshot(data: Dictionary) -> void:
var players: Array[Dictionary] = [] var players: Array[Dictionary] = []
@@ -1611,88 +1424,19 @@ func _handle_map_players_snapshot(data: Dictionary) -> void:
"userId": user_id, "userId": user_id,
"username": str(player.get("username", "")), "username": str(player.get("username", "")),
"position": Vector2(float(player.get("x", 0.0)), float(player.get("y", 0.0))), "position": Vector2(float(player.get("x", 0.0)), float(player.get("y", 0.0))),
"direction": str(player.get("direction", "down")), "mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))),
"movement_state": str(player.get("movementState", player.get("movement_state", "idle"))), "skin_id": str(appearance.get("skin_id", "")),
"sequence": int(player.get("sequence", -1)), "avatar_id": str(appearance.get("avatar_id", "")),
"mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))), "skin_asset": appearance.get("skin_asset", {}),
"skin_id": str(appearance.get("skin_id", "")), "cafe_companion": _normalize_cafe_companion(player.get("cafeCompanion", player.get("cafe_companion", null))),
"avatar_id": str(appearance.get("avatar_id", "")), "movement_locked": bool(player.get("movementLocked", player.get("movement_locked", false)))
"skin_asset": appearance.get("skin_asset", {}), })
"cafe_companion": _normalize_cafe_companion(player.get("cafeCompanion", player.get("cafe_companion", null))),
"movement_locked": bool(player.get("movementLocked", player.get("movement_locked", false)))
})
_emit_event(EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, { _emit_event(EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, {
"mapId": str(data.get("mapId", data.get("map_id", ""))), "mapId": str(data.get("mapId", data.get("map_id", ""))),
"players": players "players": players
}) })
func _handle_npc_snapshot(data: Dictionary) -> void:
var npcs: Array[Dictionary] = []
var npcsVariant: Variant = data.get("npcs", [])
if npcsVariant is Array:
for npcVariant in npcsVariant:
if not (npcVariant is Dictionary):
continue
var npc: Dictionary = npcVariant
var npcId := str(npc.get("npcId", npc.get("npc_id", ""))).strip_edges()
if npcId.is_empty():
continue
npcs.append({
"npc_id": npcId,
"name": str(npc.get("name", "NPC")),
"x": float(npc.get("x", 0.0)),
"y": float(npc.get("y", 0.0)),
"direction": str(npc.get("direction", "down")),
"movement_state": str(npc.get("movementState", npc.get("movement_state", "idle"))),
"state": str(npc.get("state", "idle")),
"version": int(npc.get("version", 0)),
"daily_goal": str(npc.get("dailyGoal", npc.get("daily_goal", ""))),
"plan_source": str(npc.get("planSource", npc.get("plan_source", "fallback"))),
"current_activity": npc.get("currentActivity", npc.get("current_activity", {})),
"public_intention": str(npc.get("publicIntention", npc.get("public_intention", ""))),
"dialogue": str(npc.get("dialogue", "")),
"scene": str(npc.get("scene", "classic_whale")),
"active_action": _normalize_npc_action(npc.get("activeAction", npc.get("active_action", null))),
})
_emit_event(EventNames.NPC_SNAPSHOT_READY, {
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"server_now": int(data.get("serverNow", data.get("server_now", 0))),
"version": int(data.get("version", 0)),
"npcs": npcs,
})
func _normalize_npc_action_event(data: Dictionary) -> Dictionary:
return {
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"server_now": int(data.get("serverNow", data.get("server_now", 0))),
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
"action": _normalize_npc_action(data.get("action", {})),
}
func _normalize_npc_action(value: Variant) -> Dictionary:
if not (value is Dictionary):
return {}
var action: Dictionary = value
return {
"action_id": str(action.get("actionId", action.get("action_id", ""))),
"kind": str(action.get("kind", "walk")),
"from_x": float(action.get("fromX", action.get("from_x", 0.0))),
"from_y": float(action.get("fromY", action.get("from_y", 0.0))),
"to_x": float(action.get("toX", action.get("to_x", 0.0))),
"to_y": float(action.get("toY", action.get("to_y", 0.0))),
"from_map_id": str(action.get("fromMapId", action.get("from_map_id", ""))),
"to_map_id": str(action.get("toMapId", action.get("to_map_id", ""))),
"from_location_id": str(action.get("fromLocationId", action.get("from_location_id", ""))),
"to_location_id": str(action.get("toLocationId", action.get("to_location_id", ""))),
"activity_id": str(action.get("activityId", action.get("activity_id", ""))),
"activity_kind": str(action.get("activityKind", action.get("activity_kind", ""))),
"started_at": int(action.get("startedAt", action.get("started_at", 0))),
"completes_at": int(action.get("completesAt", action.get("completes_at", 0))),
"version": int(action.get("version", 0)),
}
func _handle_player_joined(data: Dictionary) -> void: func _handle_player_joined(data: Dictionary) -> void:
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges() var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
if user_id.is_empty(): if user_id.is_empty():
@@ -1703,25 +1447,19 @@ func _handle_player_joined(data: Dictionary) -> void:
"userId": user_id, "userId": user_id,
"username": str(data.get("username", "")), "username": str(data.get("username", "")),
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))), "position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
"direction": str(data.get("direction", "down")), "mapId": str(data.get("mapId", data.get("map_id", ""))),
"movement_state": str(data.get("movementState", data.get("movement_state", "idle"))), "skin_id": str(appearance.get("skin_id", "")),
"sequence": int(data.get("sequence", -1)), "avatar_id": str(appearance.get("avatar_id", "")),
"mapId": str(data.get("mapId", data.get("map_id", ""))), "skin_asset": appearance.get("skin_asset", {}),
"skin_id": str(appearance.get("skin_id", "")), "cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
"avatar_id": str(appearance.get("avatar_id", "")), "movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
"skin_asset": appearance.get("skin_asset", {}), })
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
})
func _normalize_player_appearance(data: Dictionary) -> Dictionary: func _normalize_player_appearance(data: Dictionary) -> Dictionary:
var appearanceVariant: Variant = data.get("appearance", {}) var appearanceVariant: Variant = data.get("appearance", {})
var appearance: Dictionary = appearanceVariant if appearanceVariant is Dictionary else {} var appearance: Dictionary = appearanceVariant if appearanceVariant is Dictionary else {}
var skinId := str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))).strip_edges()
if skinId.is_empty() or skinId == "pending_initial_skin":
skinId = "classic_whale"
return { return {
"skin_id": skinId, "skin_id": str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))),
"avatar_id": str(data.get("avatarId", data.get("avatar_id", appearance.get("avatarId", appearance.get("avatar_id", ""))))), "avatar_id": str(data.get("avatarId", data.get("avatar_id", appearance.get("avatarId", appearance.get("avatar_id", ""))))),
"skin_asset": data.get("skinAsset", data.get("skin_asset", appearance.get("skinAsset", appearance.get("skin_asset", {})))), "skin_asset": data.get("skinAsset", data.get("skin_asset", appearance.get("skinAsset", appearance.get("skin_asset", {})))),
} }
@@ -1814,12 +1552,14 @@ func _normalize_friend_requests(requests_variant: Variant) -> Array[Dictionary]:
if not (request_variant is Dictionary): if not (request_variant is Dictionary):
continue continue
var request: Dictionary = request_variant var request: Dictionary = request_variant
var user_id := str(request.get("userId", request.get("user_id", ""))).strip_edges() var requester_variant: Variant = request.get("requester", {})
var requester: Dictionary = requester_variant if requester_variant is Dictionary else {}
var user_id := str(request.get("userId", request.get("user_id", requester.get("id", "")))).strip_edges()
if user_id.is_empty(): if user_id.is_empty():
continue continue
requests.append({ requests.append({
"user_id": user_id, "user_id": user_id,
"username": str(request.get("username", "玩家")).strip_edges(), "username": str(request.get("username", requester.get("nickname", requester.get("username", "玩家")))).strip_edges(),
"created_at": str(request.get("createdAt", request.get("created_at", ""))).strip_edges() "created_at": str(request.get("createdAt", request.get("created_at", ""))).strip_edges()
}) })
return requests return requests

View File

@@ -1,201 +0,0 @@
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

View File

@@ -1 +0,0 @@
uid://lg8ud3kgjliv

View File

@@ -0,0 +1,245 @@
extends Node
# 全地图统一交互收集角色附近的动作通过方向键选择、E 执行。
const SCAN_INTERVAL: float = 0.10
const MAX_ACTIONS_VISIBLE: int = 5
const ACCENT: Color = Color("58c7db")
const PANEL_COLOR: Color = Color(0.035, 0.071, 0.106, 0.94)
const INTERACTION_POINT_MARKERS_SCRIPT: Script = preload("res://_Core/ui/InteractionPointMarkers.gd")
var _actions: Array[InteractionAction] = []
var _selected_index: int = 0
var _last_selected_id: String = ""
var _scan_elapsed: float = SCAN_INTERVAL
var _canvas: CanvasLayer
var _panel: PanelContainer
var _list: VBoxContainer
var _hint: Label
var _executing: bool = false
var _pointMarkers: Node2D
func _ready() -> void:
_build_hud()
func _process(delta: float) -> void:
_scan_elapsed += delta
if _scan_elapsed < SCAN_INTERVAL:
return
_scan_elapsed = 0.0
_refresh_actions()
func _unhandled_input(event: InputEvent) -> void:
if _actions.is_empty() or _is_text_input_focused():
return
if not (event is InputEventKey):
return
var key_event := event as InputEventKey
if not key_event.pressed or key_event.echo:
return
if key_event.keycode == KEY_UP:
_select_offset(-1)
get_viewport().set_input_as_handled()
elif key_event.keycode == KEY_DOWN:
_select_offset(1)
get_viewport().set_input_as_handled()
elif key_event.keycode == KEY_E:
_execute_selected()
get_viewport().set_input_as_handled()
func is_selection_active() -> bool:
return not _actions.is_empty() and not _is_text_input_focused()
func _refresh_actions() -> void:
if _is_text_input_focused() or SceneManager.is_changing_scene:
_set_actions([])
return
var player := _local_player()
if player == null:
_set_actions([])
return
var collected: Array[InteractionAction] = []
for node: Node in get_tree().get_nodes_in_group(InteractableComponent.GROUP):
var interactable: InteractableComponent = node as InteractableComponent
if interactable == null:
continue
for action: InteractionAction in interactable.get_actions(player):
collected.append(action)
collected.sort_custom(func(a: InteractionAction, b: InteractionAction) -> bool:
if a.priority != b.priority:
return a.priority < b.priority
if not is_equal_approx(a.distance, b.distance):
return a.distance < b.distance
return a.title < b.title
)
_set_actions(collected)
func _set_actions(next_actions: Array[InteractionAction]) -> void:
_actions = next_actions
if _actions.is_empty():
_selected_index = 0
_last_selected_id = ""
_render()
return
var matched_index := -1
for index in _actions.size():
if _actions[index].id == _last_selected_id:
matched_index = index
break
_selected_index = matched_index if matched_index >= 0 else clampi(_selected_index, 0, _actions.size() - 1)
_last_selected_id = _actions[_selected_index].id
_render()
func _select_offset(offset: int) -> void:
if _actions.is_empty():
return
_selected_index = posmod(_selected_index + offset, _actions.size())
_last_selected_id = _actions[_selected_index].id
_render()
func _execute_selected() -> void:
if _executing or _actions.is_empty():
return
var action: InteractionAction = _actions[_selected_index]
if not action.activate.is_valid():
return
_executing = true
_render()
action.activate.call()
await get_tree().create_timer(0.18).timeout
_executing = false
_refresh_actions()
func _local_player() -> Node2D:
var players := get_tree().get_nodes_in_group("whaletown_local_player")
return players.front() as Node2D if not players.is_empty() else null
func _build_hud() -> void:
_canvas = CanvasLayer.new()
_canvas.layer = 90
add_child(_canvas)
_panel = PanelContainer.new()
_panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_panel.position = Vector2(-250, -282)
_panel.size = Vector2(500, 214)
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.add_theme_stylebox_override("panel", _panel_style())
_canvas.add_child(_panel)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 14)
margin.add_theme_constant_override("margin_top", 10)
margin.add_theme_constant_override("margin_right", 14)
margin.add_theme_constant_override("margin_bottom", 10)
_panel.add_child(margin)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 4)
margin.add_child(content)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 2)
content.add_child(_list)
_hint = Label.new()
_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_hint.add_theme_color_override("font_color", Color(0.67, 0.79, 0.85, 1.0))
_hint.add_theme_font_size_override("font_size", 14)
_hint.text = "[↑/↓] 选择 [E] 交互"
content.add_child(_hint)
_render()
func _render() -> void:
if not is_instance_valid(_panel):
return
var show_hints: bool = _should_show_interaction_hints()
_panel.visible = show_hints and not _actions.is_empty()
_render_interaction_points()
for child in _list.get_children():
child.queue_free()
if _actions.is_empty():
return
var start: int = clampi(_selected_index - 2, 0, maxi(0, _actions.size() - MAX_ACTIONS_VISIBLE))
var finish: int = mini(_actions.size(), start + MAX_ACTIONS_VISIBLE)
for index in range(start, finish):
var action: InteractionAction = _actions[index]
var row := Label.new()
row.custom_minimum_size = Vector2(0, 29)
row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
row.add_theme_font_size_override("font_size", 16)
var selected := index == _selected_index
row.text = (" " if selected else " ") + action.title + ("" if selected and _executing else "")
row.add_theme_color_override("font_color", ACCENT if selected else Color(0.92, 0.96, 0.98, 1.0))
if selected:
row.add_theme_stylebox_override("normal", _selected_style())
_list.add_child(row)
_hint.text = "[↑/↓] 选择 [E] 交互 [Esc] 关闭" + (" %d" % _actions.size() if _actions.size() > 1 else "")
func _render_interaction_points() -> void:
if not _should_show_interaction_points():
if is_instance_valid(_pointMarkers):
var empty_points: Array[Vector2] = []
_pointMarkers.call("set_points", empty_points)
return
var markers: Node2D = _ensure_point_markers()
if markers == null:
return
var positions: Array[Vector2] = []
for node: Node in get_tree().get_nodes_in_group(InteractableComponent.GROUP):
var interactable: InteractableComponent = node as InteractableComponent
if interactable == null:
continue
for position: Vector2 in interactable.get_marker_positions():
positions.append(position)
markers.call("set_points", positions)
func _ensure_point_markers() -> Node2D:
var current_scene: Node = get_tree().current_scene
var world_root: Node2D = current_scene as Node2D
if world_root == null:
return null
if is_instance_valid(_pointMarkers) and _pointMarkers.get_parent() == world_root:
return _pointMarkers
var markers: Node2D = INTERACTION_POINT_MARKERS_SCRIPT.new() as Node2D
if markers == null:
return null
markers.name = "InteractionPointMarkers"
markers.z_index = 4096
markers.z_as_relative = false
world_root.add_child(markers)
_pointMarkers = markers
return _pointMarkers
func _should_show_interaction_hints() -> bool:
return _get_setting_enabled("show_interaction_hints", true)
func _should_show_interaction_points() -> bool:
return _get_setting_enabled("show_interaction_points", false)
func _get_setting_enabled(setting_key: String, fallback: bool) -> bool:
var settings_manager: Node = get_node_or_null("/root/SettingsManager")
if settings_manager != null and settings_manager.has_method("get_bool"):
return bool(settings_manager.call("get_bool", setting_key))
return fallback
func _panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = PANEL_COLOR
style.border_color = Color(0.20, 0.45, 0.54, 0.86)
style.set_border_width_all(1)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 10
style.shadow_color = Color(0, 0, 0, 0.35)
style.shadow_size = 10
return style
func _selected_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.32, 0.38, 0.74)
style.corner_radius_top_left = 6
style.corner_radius_top_right = 6
style.corner_radius_bottom_left = 6
style.corner_radius_bottom_right = 6
style.content_margin_left = 6
return style
func _is_text_input_focused() -> bool:
var focus_owner := get_viewport().gui_get_focus_owner()
return focus_owner is LineEdit or focus_owner is TextEdit

View File

@@ -0,0 +1 @@
uid://cg46uuk3hxvqe

View File

@@ -30,9 +30,6 @@ signal scene_changed(scene_name: String)
# 场景切换开始信号 # 场景切换开始信号
# 参数: scene_name - 即将切换到的场景名称 # 参数: scene_name - 即将切换到的场景名称
signal scene_change_started(scene_name: String) signal scene_change_started(scene_name: String)
signal scene_pack_progress(scene_name: String, downloaded_bytes: int, total_bytes: int)
signal scene_pack_failed(scene_name: String, message: String)
signal scene_pack_load_finished(pack_key: String, success: bool)
# ============ 成员变量 ============ # ============ 成员变量 ============
@@ -41,38 +38,7 @@ var current_scene_name: String = "" # 当前场景名称
var is_changing_scene: bool = false # 是否正在切换场景 var is_changing_scene: bool = false # 是否正在切换场景
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null) var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String) var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
var _pack_manifest: Dictionary = {} var _next_destination_id: String = "" # 快速传送目标,用于发现登记
var _loaded_scene_packs: Dictionary = {}
var _loading_scene_packs: Dictionary = {}
var _scene_pack_prefetch_queue: Array[String] = []
var _scene_pack_prefetch_running: bool = false
var _active_pack_request: HTTPRequest
var _active_pack_scene_name: String = ""
var _active_pack_expected_size: int = 0
var _pack_overlay: CanvasLayer
var _pack_status_label: Label
var _pack_progress_bar: ProgressBar
var _web_pack_progress_last_emit_msec: int = 0
const PACK_MANIFEST_PATH: String = "packs/manifest.json"
const PACK_CACHE_DIR: String = "user://scene-packs"
const SCENE_PACK_KEYS: Dictionary = {
"main": "auth",
"auth": "auth",
"square": "square",
"work_zone": "work_zone",
"cafe_interior": "cafe_interior",
"room": "personal_space",
"personal_space": "personal_space",
}
# 只预取当前地图可以直接到达的区域。
# 顺序很重要:登录进入广场后先准备个人空间,再准备打工区;
# 咖啡馆只有在进入打工区后才加入队列。
const SCENE_PREFETCH_ROUTES: Dictionary = {
"square": ["personal_space", "work_zone"],
"work_zone": ["cafe_interior"],
}
# 场景路径映射表 # 场景路径映射表
# 将场景名称映射到实际的文件路径 # 将场景名称映射到实际的文件路径
@@ -136,14 +102,6 @@ func change_scene(scene_name: String, use_transition: bool = true):
if use_transition: if use_transition:
await show_transition() await show_transition()
if OS.get_name() == "Web" and SCENE_PACK_KEYS.has(scene_name):
var packLoaded := await _ensure_scene_pack(scene_name)
if not packLoaded:
is_changing_scene = false
if use_transition:
await hide_transition()
return false
# 执行场景切换 # 执行场景切换
var error = get_tree().change_scene_to_file(scene_path) var error = get_tree().change_scene_to_file(scene_path)
if error != OK: if error != OK:
@@ -155,7 +113,6 @@ func change_scene(scene_name: String, use_transition: bool = true):
current_scene_name = scene_name current_scene_name = scene_name
is_changing_scene = false is_changing_scene = false
scene_changed.emit(scene_name) scene_changed.emit(scene_name)
_schedule_scene_prefetch(scene_name)
# 隐藏过渡效果 # 隐藏过渡效果
if use_transition: if use_transition:
@@ -163,272 +120,6 @@ func change_scene(scene_name: String, use_transition: bool = true):
return true return true
func _process(_delta: float) -> void:
if not is_instance_valid(_active_pack_request):
return
var downloaded := _active_pack_request.get_downloaded_bytes()
var total := _active_pack_request.get_body_size()
if total <= 0:
total = _active_pack_expected_size
if is_instance_valid(_pack_progress_bar):
_pack_progress_bar.indeterminate = total <= 0
if total > 0:
_pack_progress_bar.max_value = total
_pack_progress_bar.value = downloaded
if is_instance_valid(_pack_status_label):
_pack_status_label.text = _format_scene_loading_status(downloaded, total)
_notify_web_auth_pack_progress(downloaded, total)
scene_pack_progress.emit(_active_pack_scene_name, downloaded, total)
func _notify_web_auth_pack_progress(downloaded: int, total: int) -> void:
if OS.get_name() != "Web" or _active_pack_scene_name != "auth":
return
var now := Time.get_ticks_msec()
if now - _web_pack_progress_last_emit_msec < 250:
return
_web_pack_progress_last_emit_msec = now
if not Engine.has_singleton("JavaScriptBridge"):
return
var bridge: Object = Engine.get_singleton("JavaScriptBridge")
bridge.eval("window.whaletownPackProgress && window.whaletownPackProgress(%d, %d)" % [downloaded, total], true)
func _ensure_scene_pack(scene_name: String) -> bool:
var packKey := str(SCENE_PACK_KEYS.get(scene_name, ""))
if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)):
return true
_show_pack_overlay("正在准备场景...")
# 场景切换也进入同一个预取队列,避免用户点击入口时与后台下载并发。
_queue_scene_pack_prefetch([scene_name])
while not bool(_loaded_scene_packs.get(packKey, false)):
if not bool(_loading_scene_packs.get(packKey, false)) and not _scene_pack_prefetch_queue.has(scene_name):
break
await scene_pack_load_finished
if bool(_loaded_scene_packs.get(packKey, false)):
_hide_pack_overlay()
return true
_show_pack_error(scene_name, "场景下载失败,请检查网络后重试")
return false
func preload_scene_pack(scene_name: String) -> void:
if OS.get_name() != "Web":
return
_queue_scene_pack_prefetch([scene_name])
func _schedule_scene_prefetch(scene_name: String) -> void:
if OS.get_name() != "Web" or not _should_prefetch_scene_packs():
return
var routeVariant: Variant = SCENE_PREFETCH_ROUTES.get(scene_name, [])
if routeVariant is Array and not (routeVariant as Array).is_empty():
_queue_scene_pack_prefetch(routeVariant as Array)
func _should_prefetch_scene_packs() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("is_authenticated"):
return false
if not bool(authManager.call("is_authenticated")):
return false
var chatManager := get_node_or_null("/root/ChatManager")
return chatManager == null or not chatManager.has_method("is_guest_mode") or not bool(chatManager.call("is_guest_mode"))
func _queue_scene_pack_prefetch(scene_names: Array) -> void:
for sceneNameVariant in scene_names:
var sceneName := str(sceneNameVariant).strip_edges()
var packKey := str(SCENE_PACK_KEYS.get(sceneName, ""))
if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)) or bool(_loading_scene_packs.get(packKey, false)):
continue
if _scene_pack_prefetch_queue.has(sceneName):
continue
_scene_pack_prefetch_queue.append(sceneName)
_run_scene_pack_prefetch_queue()
func _run_scene_pack_prefetch_queue() -> void:
if _scene_pack_prefetch_running:
return
_scene_pack_prefetch_running = true
while not _scene_pack_prefetch_queue.is_empty():
var sceneName: String = str(_scene_pack_prefetch_queue.pop_front())
var packKey := str(SCENE_PACK_KEYS.get(sceneName, ""))
if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)):
continue
if not bool(_loading_scene_packs.get(packKey, false)):
_start_scene_pack_load(sceneName, packKey)
while bool(_loading_scene_packs.get(packKey, false)):
await scene_pack_load_finished
_scene_pack_prefetch_running = false
func _start_scene_pack_load(scene_name: String, pack_key: String) -> void:
_loading_scene_packs[pack_key] = true
_load_scene_pack_task(scene_name, pack_key)
func _load_scene_pack_task(scene_name: String, pack_key: String) -> void:
var success := await _load_scene_pack_file(scene_name, pack_key)
if success:
_loaded_scene_packs[pack_key] = true
_loading_scene_packs.erase(pack_key)
scene_pack_load_finished.emit(pack_key, success)
func _load_scene_pack_file(scene_name: String, pack_key: String) -> bool:
if _pack_manifest.is_empty():
_pack_manifest = await _download_pack_manifest()
if _pack_manifest.is_empty():
return false
var entryVariant: Variant = _pack_manifest.get(pack_key, {})
if not (entryVariant is Dictionary):
return false
var entry := entryVariant as Dictionary
var fileName := str(entry.get("file", "")).get_file()
if fileName.is_empty() or not fileName.ends_with(".pck"):
return false
var expectedSize := int(entry.get("size", 0))
var cacheDirAbsolute := ProjectSettings.globalize_path(PACK_CACHE_DIR)
DirAccess.make_dir_recursive_absolute(cacheDirAbsolute)
var localPath := "%s/%s" % [PACK_CACHE_DIR, fileName]
if _is_scene_pack_file_valid(localPath, fileName, expectedSize) and ProjectSettings.load_resource_pack(localPath, true):
return true
if FileAccess.file_exists(localPath):
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
var packUrl := _resolve_web_url("packs/%s" % fileName)
if packUrl.is_empty() or not await _download_pack(scene_name, packUrl, localPath, expectedSize):
return false
if not _is_scene_pack_file_valid(localPath, fileName, expectedSize):
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
return false
if not ProjectSettings.load_resource_pack(localPath, true):
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
return false
return true
func _is_scene_pack_file_valid(local_path: String, file_name: String, expected_size: int) -> bool:
if not FileAccess.file_exists(local_path):
return false
var file := FileAccess.open(local_path, FileAccess.READ)
if file == null:
return false
var actualSize := file.get_length()
file.close()
if expected_size > 0 and actualSize != expected_size:
return false
var stem := file_name.trim_suffix(".pck")
var separator := stem.rfind("-")
if separator < 0:
return true
var expectedHashPrefix := stem.substr(separator + 1).to_lower()
if expectedHashPrefix.length() != 12:
return true
var actualHash := FileAccess.get_sha256(local_path).to_lower()
return not actualHash.is_empty() and actualHash.begins_with(expectedHashPrefix)
func _download_pack_manifest() -> Dictionary:
var request := HTTPRequest.new()
request.timeout = 12.0
add_child(request)
var url := _resolve_web_url(PACK_MANIFEST_PATH)
if url.is_empty() or request.request(url, ["Cache-Control: no-cache"]) != OK:
request.queue_free()
return {}
var response: Array = await request.request_completed
request.queue_free()
if response.size() < 4 or int(response[0]) != HTTPRequest.RESULT_SUCCESS:
return {}
var responseCode := int(response[1])
if responseCode < 200 or responseCode >= 300:
return {}
var json := JSON.new()
if json.parse((response[3] as PackedByteArray).get_string_from_utf8()) != OK:
return {}
if not (json.data is Dictionary):
return {}
var root := json.data as Dictionary
var packsVariant: Variant = root.get("packs", {})
return packsVariant as Dictionary if packsVariant is Dictionary else {}
func _download_pack(scene_name: String, url: String, local_path: String, expected_size: int) -> bool:
var request := HTTPRequest.new()
request.timeout = 900.0
request.download_file = local_path
add_child(request)
_active_pack_request = request
_active_pack_scene_name = scene_name
_active_pack_expected_size = expected_size
if request.request(url) != OK:
_active_pack_request = null
_active_pack_scene_name = ""
_active_pack_expected_size = 0
request.queue_free()
return false
var response: Array = await request.request_completed
_active_pack_request = null
_active_pack_scene_name = ""
_active_pack_expected_size = 0
request.queue_free()
if response.size() < 2 or int(response[0]) != HTTPRequest.RESULT_SUCCESS:
return false
var responseCode := int(response[1])
return responseCode >= 200 and responseCode < 300
func _resolve_web_url(path: String) -> String:
if not Engine.has_singleton("JavaScriptBridge"):
return ""
var jsBridge: Object = Engine.get_singleton("JavaScriptBridge")
if jsBridge == null:
return ""
return str(jsBridge.eval("new URL('%s', window.location.href).href" % path, true)).strip_edges()
func _show_pack_overlay(message: String) -> void:
if not is_instance_valid(_pack_overlay):
_pack_overlay = CanvasLayer.new()
_pack_overlay.layer = 1000
add_child(_pack_overlay)
var backdrop := ColorRect.new()
backdrop.color = Color(0.025, 0.09, 0.15, 0.92)
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
_pack_overlay.add_child(backdrop)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_pack_overlay.add_child(center)
var box := VBoxContainer.new()
box.custom_minimum_size = Vector2(420, 100)
box.add_theme_constant_override("separation", 18)
center.add_child(box)
_pack_status_label = Label.new()
_pack_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_pack_status_label.add_theme_font_size_override("font_size", 22)
box.add_child(_pack_status_label)
_pack_progress_bar = ProgressBar.new()
_pack_progress_bar.custom_minimum_size = Vector2(420, 14)
_pack_progress_bar.show_percentage = false
_pack_progress_bar.indeterminate = true
box.add_child(_pack_progress_bar)
_pack_overlay.show()
_pack_status_label.text = message
_pack_progress_bar.indeterminate = true
func _hide_pack_overlay() -> void:
if is_instance_valid(_pack_overlay):
_pack_overlay.hide()
func _show_pack_error(scene_name: String, message: String) -> void:
if not is_instance_valid(_pack_overlay):
_show_pack_overlay(message)
_pack_status_label.text = message
_pack_progress_bar.hide()
scene_pack_failed.emit(scene_name, message)
await get_tree().create_timer(3.0).timeout
_pack_progress_bar.show()
_hide_pack_overlay()
func _format_download_progress(downloaded: int, total: int) -> String:
var downloadedMb := float(downloaded) / 1048576.0
if total <= 0:
return "%.1f MB" % downloadedMb
return "%.1f / %.1f MB" % [downloadedMb, float(total) / 1048576.0]
func _format_scene_loading_status(downloaded: int, total: int) -> String:
if total <= 0:
return "正在加载场景..."
var percent: int = mini(100, int(round(float(downloaded) / float(total) * 100.0)))
return "正在加载场景 %d%%%s" % [percent, _format_download_progress(downloaded, total)]
# ============ 查询方法 ============ # ============ 查询方法 ============
# 获取当前场景名称 # 获取当前场景名称
@@ -504,6 +195,14 @@ func get_next_spawn_name() -> String:
_next_spawn_name = "" _next_spawn_name = ""
return spawn_name return spawn_name
func set_next_destination_id(destination_id: String) -> void:
_next_destination_id = destination_id.strip_edges()
func get_next_destination_id() -> String:
var destination_id := _next_destination_id
_next_destination_id = ""
return destination_id
# ============ 场景注册方法 ============ # ============ 场景注册方法 ============
# 注册新场景 # 注册新场景

View File

@@ -15,7 +15,8 @@ const DEFAULT_SETTINGS: Dictionary = {
"effects_volume": 0.90, "effects_volume": 0.90,
"ui_scale": 1.00, "ui_scale": 1.00,
"fullscreen": false, "fullscreen": false,
"show_interaction_hints": false, "show_interaction_hints": true,
"show_interaction_points": false,
"show_name_always": false, "show_name_always": false,
"show_chat_bubbles": true, "show_chat_bubbles": true,
"world_notifications": true, "world_notifications": true,
@@ -23,6 +24,7 @@ const DEFAULT_SETTINGS: Dictionary = {
"friend_request_notifications": true, "friend_request_notifications": true,
"allow_nearby_private": true, "allow_nearby_private": true,
"allow_nearby_friend_requests": true, "allow_nearby_friend_requests": true,
"allow_nearby_profile": true,
"mute_ui_sfx": false, "mute_ui_sfx": false,
} }

View File

@@ -0,0 +1,438 @@
extends Node
# 社区资料、通知与旅行 API 的客户端协调器。
const ACCENT := Color("58c7db")
const PANEL_COLOR := Color(0.035, 0.071, 0.106, 0.97)
const DISCOVERY_DISTANCE: float = 180.0
const DISCOVERY_POINTS: Array[Dictionary] = [
{"id": "square_center", "mapId": "whale_port", "position": Vector2(0, 30)},
{"id": "square_dock", "mapId": "whale_port", "position": Vector2(-870, -202)},
{"id": "square_headquarters", "mapId": "whale_port", "position": Vector2(13, -600)},
{"id": "square_cottage", "mapId": "whale_port", "position": Vector2(845, -192)},
{"id": "square_workshop", "mapId": "whale_port", "position": Vector2(645, 500)},
{"id": "square_notice", "mapId": "whale_port", "position": Vector2(-542, 648)},
{"id": "square_work_zone_gate", "mapId": "whale_port", "position": Vector2(0, 775)},
{"id": "work_entrance", "mapId": "work_zone", "position": Vector2(0, 755)},
{"id": "work_mall", "mapId": "work_zone", "position": Vector2(0, -614)},
{"id": "work_cafe_gate", "mapId": "work_zone", "position": Vector2(-1044, 222)},
{"id": "work_jobs", "mapId": "work_zone", "position": Vector2(-502, 192)},
{"id": "work_courses", "mapId": "work_zone", "position": Vector2(496, 0)},
{"id": "work_ai", "mapId": "work_zone", "position": Vector2(452, 548)},
{"id": "work_exchange", "mapId": "work_zone", "position": Vector2(1075, 548)},
{"id": "cafe_entrance", "mapId": "whale_cafe", "position": Vector2(0, 363)},
{"id": "cafe_counter", "mapId": "whale_cafe", "position": Vector2(0, -37)},
{"id": "cafe_companion", "mapId": "whale_cafe", "position": Vector2(-398, -226)},
{"id": "personal_room", "mapId": "personal_space", "position": Vector2.ZERO},
]
var _canvas: CanvasLayer
var _profile_panel: PanelContainer
var _profile_content: VBoxContainer
var _notification_panel: PanelContainer
var _notification_content: VBoxContainer
var _current_profile: Dictionary = {}
var _discoveryRequests: Dictionary = {}
var _discoveredDestinations: Dictionary = {}
func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
_build_ui()
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("connect_event", EventNames.SOCIAL_NOTIFICATION_RECEIVED, _on_social_realtime, self)
func _exit_tree() -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("disconnect_event", EventNames.SOCIAL_NOTIFICATION_RECEIVED, _on_social_realtime, self)
func show_profile(user_id: String) -> void:
var normalized := user_id.strip_edges()
if normalized.is_empty():
return
_request_get("/social/profiles/%s" % normalized, func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status("无法查看名片:%s" % str(error.get("message", "请求失败")))
return
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_current_profile = data_variant as Dictionary
_render_profile()
)
func show_own_profile() -> void:
_request_get("/social/profile", func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status("无法读取个人名片:%s" % str(error.get("message", "请求失败")))
return
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_current_profile = data_variant as Dictionary
_render_profile()
)
func request_friend(user_id: String, username: String = "") -> void:
var chat := get_node_or_null("/root/ChatManager")
if chat != null and chat.has_method("request_friend"):
chat.call("request_friend", user_id, username)
func open_private_chat(user_id: String, username: String) -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {"userId": user_id, "username": username})
_close_profile()
func request_travel(destination_id: String, completion: Callable = Callable()) -> void:
_request_post("/world/travel-destinations/%s/travel" % destination_id.uri_encode(), {}, func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status(str(error.get("message", "该地点尚未解锁")))
if completion.is_valid(): completion.call(false, {})
return
var data_variant: Variant = response.get("data", {})
var data: Dictionary = data_variant if data_variant is Dictionary else {}
if completion.is_valid(): completion.call(true, data)
)
func discover_destination(destination_id: String) -> void:
var normalized := destination_id.strip_edges()
if normalized.is_empty() or _discoveredDestinations.has(normalized) or _discoveryRequests.has(normalized):
return
_discoveryRequests[normalized] = true
_request_post("/world/travel-destinations/%s/discover" % normalized.uri_encode(), {}, func(success: bool, _response: Dictionary, _error: Dictionary) -> void:
_discoveryRequests.erase(normalized)
if success:
_discoveredDestinations[normalized] = true
)
func discover_nearby_destinations(map_id: String, position: Vector2) -> void:
for point in DISCOVERY_POINTS:
if str(point.get("mapId", "")) != map_id:
continue
var target := point.get("position", Vector2.ZERO) as Vector2
if position.distance_to(target) <= DISCOVERY_DISTANCE:
discover_destination(str(point.get("id", "")))
func toggle_notifications() -> void:
_notification_panel.visible = not _notification_panel.visible
if _notification_panel.visible:
_refresh_notifications()
func is_escape_dismissible() -> bool:
return (is_instance_valid(_profile_panel) and _profile_panel.visible) or (is_instance_valid(_notification_panel) and _notification_panel.visible)
func get_escape_priority() -> int:
return 800
func request_escape_close() -> void:
if is_instance_valid(_profile_panel) and _profile_panel.visible:
_close_profile()
return
if is_instance_valid(_notification_panel):
_notification_panel.visible = false
func _on_social_realtime(_payload: Dictionary) -> void:
if _notification_panel.visible:
_refresh_notifications()
func _refresh_notifications() -> void:
_request_get("/social/notifications?limit=30", func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_notification_rows(["通知暂时无法读取:%s" % str(error.get("message", "请求失败"))])
return
var data_variant: Variant = response.get("data", {})
var data: Dictionary = data_variant if data_variant is Dictionary else {}
var rows: Array[String] = []
var notifications_variant: Variant = data.get("notifications", [])
if notifications_variant is Array:
for item in notifications_variant as Array:
if item is Dictionary:
var notification: Dictionary = item
rows.append("%s\n%s" % [str(notification.get("title", "通知")), str(notification.get("content", ""))])
if rows.is_empty(): rows.append("暂无通知")
_show_notification_rows(rows)
)
func _render_profile() -> void:
_profile_panel.visible = true
for child in _profile_content.get_children():
child.queue_free()
var nickname := str(_current_profile.get("nickname", "玩家"))
var username := str(_current_profile.get("username", ""))
var online := bool(_current_profile.get("online", false))
var area := str(_current_profile.get("currentArea", ""))
var identity := HBoxContainer.new()
identity.add_theme_constant_override("separation", 12)
identity.add_child(_avatar_badge(nickname))
var identity_text := VBoxContainer.new()
identity_text.size_flags_horizontal = Control.SIZE_EXPAND_FILL
identity_text.add_child(_label(nickname, 26, ACCENT))
identity_text.add_child(_label("@%s · %s" % [username, "在线" if online else "离线"], 15, Color(0.72, 0.82, 0.87, 1)))
identity.add_child(identity_text)
_profile_content.add_child(identity)
_profile_content.add_child(_label("当前区域:%s" % _area_label(area), 16, Color(0.88, 0.94, 0.96, 1)))
var skin_id := str(_current_profile.get("skinId", "")).strip_edges()
_profile_content.add_child(_label("角色:%s" % (skin_id if not skin_id.is_empty() else "默认角色"), 14, Color(0.50, 0.76, 0.80, 1)))
var bio := str(_current_profile.get("bio", "")).strip_edges()
_profile_content.add_child(_label(bio if not bio.is_empty() else "这个玩家还没有留下简介。", 16, Color(0.84, 0.90, 0.92, 1)))
var interests_variant: Variant = _current_profile.get("interests", [])
var interests: Array[String] = []
if interests_variant is Array:
for interest in interests_variant as Array: interests.append(str(interest))
_profile_content.add_child(_label("兴趣:%s" % (" · ".join(interests) if not interests.is_empty() else "未设置"), 14, Color(0.50, 0.76, 0.80, 1)))
var user_id := str(_current_profile.get("id", ""))
var self_profile := user_id == _current_user_id()
if self_profile:
_profile_content.add_child(_label("编辑社区资料", 17, ACCENT))
var nickname_input := LineEdit.new()
nickname_input.placeholder_text = "昵称(首次可立即修改,此后每 7 天一次)"
nickname_input.text = nickname
_profile_content.add_child(nickname_input)
var bio_input := TextEdit.new()
bio_input.placeholder_text = "简介(最多 160 字)"
bio_input.text = bio
bio_input.custom_minimum_size = Vector2(0, 82)
_profile_content.add_child(bio_input)
var current_interests: Array[String] = []
for interest in interests: current_interests.append(interest)
var interest_row := HBoxContainer.new()
interest_row.add_theme_constant_override("separation", 6)
var interest_selects: Array[OptionButton] = []
for slot in 3:
var selector := OptionButton.new()
selector.custom_minimum_size = Vector2(145, 34)
selector.add_item("兴趣标签")
for tag in _interest_catalog():
selector.add_item(str(tag.get("label", "")))
selector.set_item_metadata(selector.item_count - 1, str(tag.get("id", "")))
if slot < current_interests.size():
for item_index in range(1, selector.item_count):
if str(selector.get_item_metadata(item_index)) == current_interests[slot]:
selector.select(item_index)
break
interest_selects.append(selector)
interest_row.add_child(selector)
_profile_content.add_child(interest_row)
_profile_content.add_child(_button("保存资料", func() -> void: _save_own_profile(nickname_input.text, bio_input.text, interest_selects)))
else:
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 8)
actions.add_child(_button("私聊", func() -> void: open_private_chat(user_id, nickname)))
actions.add_child(_button("加好友", func() -> void: request_friend(user_id, nickname)))
_profile_content.add_child(actions)
var safety := HBoxContainer.new()
safety.add_theme_constant_override("separation", 8)
safety.add_child(_button("拉黑", func() -> void: _block_user(user_id)))
var report_form := _report_form(user_id)
var report_button := _button("举报", func() -> void: report_form.visible = not report_form.visible)
safety.add_child(report_button)
_profile_content.add_child(safety)
_profile_content.add_child(report_form)
_profile_content.add_child(_button("关闭", _close_profile))
func _block_user(user_id: String) -> void:
_request_post("/social/blocks", {"userId": user_id}, func(success: bool, _response: Dictionary, error: Dictionary) -> void:
_show_status("已拉黑该玩家" if success else str(error.get("message", "拉黑失败")))
if success: _close_profile()
)
func _save_own_profile(nickname: String, bio: String, interest_selects: Array[OptionButton]) -> void:
var interests: Array[String] = []
for selector in interest_selects:
var index := selector.selected
if index > 0:
var value := str(selector.get_item_metadata(index)).strip_edges()
if not value.is_empty() and not interests.has(value): interests.append(value)
_request_patch("/social/profile", {"nickname": nickname.strip_edges(), "bio": bio.strip_edges(), "interests": interests}, func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status(str(error.get("message", "资料保存失败")))
return
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_current_profile = data_variant as Dictionary
_render_profile()
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null: event_system.call("emit_event", EventNames.SOCIAL_PROFILE_UPDATED, _current_profile)
)
func _report_form(user_id: String) -> VBoxContainer:
var form := VBoxContainer.new()
form.visible = false
form.add_theme_constant_override("separation", 6)
form.add_theme_stylebox_override("panel", _subpanel_style())
form.add_child(_label("举报原因", 15, ACCENT))
var reason := OptionButton.new()
for item in [
{"id": "harassment", "label": "骚扰或辱骂"},
{"id": "spam", "label": "垃圾信息"},
{"id": "inappropriate_content", "label": "不当内容"},
{"id": "impersonation", "label": "冒充他人"},
{"id": "other", "label": "其他"},
]:
reason.add_item(str(item.get("label", "其他")))
reason.set_item_metadata(reason.item_count - 1, str(item.get("id", "other")))
form.add_child(reason)
var note := TextEdit.new()
note.placeholder_text = "补充说明(可选,最多 500 字)"
note.custom_minimum_size = Vector2(0, 68)
form.add_child(note)
var block_also := CheckBox.new()
block_also.text = "同时拉黑此玩家"
block_also.button_pressed = false
form.add_child(block_also)
form.add_child(_button("提交举报", func() -> void:
_submit_report(user_id, str(reason.get_item_metadata(reason.selected)), note.text, block_also.button_pressed)
))
return form
func _submit_report(user_id: String, reason: String, note: String, block_also: bool) -> void:
_request_post("/social/reports", {
"userId": user_id,
"reason": reason,
"note": note.strip_edges(),
"blockAlso": block_also,
}, func(success: bool, _response: Dictionary, error: Dictionary) -> void:
_show_status("举报已提交" if success else str(error.get("message", "举报失败")))
if success and block_also:
_close_profile()
)
func _build_ui() -> void:
_canvas = CanvasLayer.new()
_canvas.layer = 88
add_child(_canvas)
_profile_panel = _panel(Vector2(540, 610))
_profile_panel.set_anchors_preset(Control.PRESET_CENTER)
_profile_panel.position = Vector2(-270, -305)
_canvas.add_child(_profile_panel)
var profile_margin := _margin(_profile_panel)
var profile_scroll := ScrollContainer.new()
profile_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
profile_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
profile_margin.add_child(profile_scroll)
_profile_content = VBoxContainer.new()
_profile_content.add_theme_constant_override("separation", 12)
_profile_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
profile_scroll.add_child(_profile_content)
_notification_panel = _panel(Vector2(390, 430))
_notification_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_notification_panel.position = Vector2(-414, 110)
_canvas.add_child(_notification_panel)
var notification_margin := _margin(_notification_panel)
_notification_content = VBoxContainer.new()
_notification_content.add_theme_constant_override("separation", 9)
notification_margin.add_child(_notification_content)
_notification_panel.visible = false
_profile_panel.visible = false
func _show_notification_rows(rows: Array[String]) -> void:
for child in _notification_content.get_children(): child.queue_free()
_notification_content.add_child(_label("通知中心", 22, ACCENT))
for row in rows:
_notification_content.add_child(_label(row, 15, Color(0.87, 0.93, 0.95, 1)))
_notification_content.add_child(_button("全部标为已读", func() -> void:
_request_patch("/social/notifications/read-all", {}, func(_success: bool, _response: Dictionary, _error: Dictionary) -> void: _refresh_notifications())
))
func _panel(panel_size: Vector2) -> PanelContainer:
var panel := PanelContainer.new()
panel.size = panel_size
panel.mouse_filter = Control.MOUSE_FILTER_STOP
var style := StyleBoxFlat.new()
style.bg_color = PANEL_COLOR
style.border_color = Color(0.20, 0.52, 0.61, 0.9)
style.set_border_width_all(1)
style.set_corner_radius_all(12)
style.shadow_color = Color(0, 0, 0, 0.45)
style.shadow_size = 16
panel.add_theme_stylebox_override("panel", style)
return panel
func _margin(parent: Control) -> MarginContainer:
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 20)
margin.add_theme_constant_override("margin_top", 18)
margin.add_theme_constant_override("margin_right", 20)
margin.add_theme_constant_override("margin_bottom", 18)
parent.add_child(margin)
return margin
func _label(text: String, font_size: int, color: Color) -> Label:
var label := Label.new()
label.text = text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", color)
return label
func _button(text: String, callback: Callable) -> Button:
var button := Button.new()
button.text = text
button.custom_minimum_size = Vector2(92, 34)
button.pressed.connect(callback)
return button
func _avatar_badge(nickname: String) -> Label:
var badge := Label.new()
badge.text = nickname.left(1).to_upper() if not nickname.is_empty() else ""
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
badge.custom_minimum_size = Vector2(52, 52)
badge.add_theme_font_size_override("font_size", 22)
badge.add_theme_color_override("font_color", Color(0.92, 0.99, 1.0, 1.0))
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.40, 0.48, 1.0)
style.set_corner_radius_all(26)
style.border_color = ACCENT
style.set_border_width_all(1)
badge.add_theme_stylebox_override("normal", style)
return badge
func _subpanel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.06, 0.13, 0.18, 0.88)
style.set_border_width_all(1)
style.border_color = Color(0.18, 0.45, 0.52, 0.75)
style.set_corner_radius_all(8)
style.content_margin_left = 10
style.content_margin_right = 10
style.content_margin_top = 8
style.content_margin_bottom = 8
return style
func _close_profile() -> void:
_profile_panel.visible = false
func _show_status(message: String) -> void:
push_warning("Social: %s" % message)
func _request_get(endpoint: String, callback: Callable) -> void:
var api := get_node_or_null("/root/ApiClient")
if api != null: api.call("get_json", endpoint, callback, true)
func _request_post(endpoint: String, payload: Dictionary, callback: Callable) -> void:
var api := get_node_or_null("/root/ApiClient")
if api != null: api.call("post_json", endpoint, payload, callback, true)
func _request_patch(endpoint: String, payload: Dictionary, callback: Callable) -> void:
var api := get_node_or_null("/root/ApiClient")
if api != null: api.call("patch_json", endpoint, payload, callback, true)
func _current_user_id() -> String:
var auth := get_node_or_null("/root/AuthManager")
if auth != null and auth.has_method("get_current_user"):
var user_variant: Variant = auth.call("get_current_user")
if user_variant is Dictionary: return str((user_variant as Dictionary).get("id", ""))
return ""
func _area_label(map_id: String) -> String:
return {"whale_port": "中心广场", "work_zone": "打工区", "whale_cafe": "鲸鱼咖啡馆", "personal_space": "个人空间"}.get(map_id, map_id)
func _interest_catalog() -> Array[Dictionary]:
return [
{"id": "ai", "label": "AI/大模型"}, {"id": "programming", "label": "编程开发"},
{"id": "data_science", "label": "数据科学"}, {"id": "open_source", "label": "开源协作"},
{"id": "product", "label": "产品"}, {"id": "design", "label": "设计"},
{"id": "game_dev", "label": "游戏开发"}, {"id": "content_creation", "label": "内容创作"},
{"id": "community", "label": "社区活动"}, {"id": "learning_partner", "label": "学习搭子"},
{"id": "career", "label": "职业成长"}, {"id": "casual_chat", "label": "轻松闲聊"},
]

View File

@@ -0,0 +1 @@
uid://cm5em8mgr5i24

View File

@@ -1,26 +0,0 @@
extends Node
# Enforce a Chinese-capable font for every UI control, including controls
# constructed dynamically after their scene has loaded.
const UI_FONT: FontFile = preload("res://assets/fonts/msyh-web.ttf")
func _enter_tree() -> void:
get_tree().node_added.connect(_on_node_added)
_apply_font_recursively(get_tree().root)
func _exit_tree() -> void:
var node_added_callback := Callable(self, "_on_node_added")
if get_tree().node_added.is_connected(node_added_callback):
get_tree().node_added.disconnect(node_added_callback)
func _on_node_added(node: Node) -> void:
_apply_font_recursively(node)
func _apply_font_recursively(node: Node) -> void:
if node is Control:
var control := node as Control
control.add_theme_font_override(&"font", UI_FONT)
if control is RichTextLabel:
control.add_theme_font_override(&"normal_font", UI_FONT)
for child in node.get_children():
_apply_font_recursively(child)

View File

@@ -1 +0,0 @@
uid://cvb5smbfhqnfc

View File

@@ -0,0 +1,58 @@
extends Node
# 统一处理 UI 的 Esc 退出。可关闭界面加入 whaletown_escape_dismissible 分组,
# 并实现 is_escape_dismissible / get_escape_priority / request_escape_close。
const ESCAPE_DISMISSIBLE_GROUP: StringName = &"whaletown_escape_dismissible"
func _ready() -> void:
# 公告、排行榜等界面会暂停场景树Esc 仍必须有效。
process_mode = Node.PROCESS_MODE_ALWAYS
func _input(event: InputEvent) -> void:
if not (event is InputEventKey):
return
var key_event: InputEventKey = event as InputEventKey
if not key_event.pressed or key_event.echo or key_event.keycode != KEY_ESCAPE:
return
if _hide_open_popup_menu() or _close_topmost_dismissible() or _release_control_focus():
get_viewport().set_input_as_handled()
func _hide_open_popup_menu() -> bool:
var popup_nodes: Array[Node] = get_tree().root.find_children("*", "PopupMenu", true, false)
for popup_node: Node in popup_nodes:
var popup: PopupMenu = popup_node as PopupMenu
if popup != null and popup.visible:
popup.hide()
return true
return false
func _close_topmost_dismissible() -> bool:
var target: Node = null
var target_priority: int = -2147483648
var dismissibles: Array[Node] = get_tree().get_nodes_in_group(ESCAPE_DISMISSIBLE_GROUP)
for dismissible: Node in dismissibles:
if not is_instance_valid(dismissible):
continue
if not dismissible.has_method("is_escape_dismissible") or not dismissible.has_method("request_escape_close"):
continue
var is_dismissible_variant: Variant = dismissible.call("is_escape_dismissible")
if not bool(is_dismissible_variant):
continue
var priority: int = 0
if dismissible.has_method("get_escape_priority"):
var priority_variant: Variant = dismissible.call("get_escape_priority")
priority = int(priority_variant)
if target == null or priority > target_priority:
target = dismissible
target_priority = priority
if target == null:
return false
target.call("request_escape_close")
return true
func _release_control_focus() -> bool:
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner == null:
return false
focus_owner.release_focus()
return true

View File

@@ -0,0 +1 @@
uid://otx02vxmfni3

View File

@@ -69,7 +69,7 @@ enum ConnectionState {
# ============================================================================ # ============================================================================
# WebSocket 服务器 URL原生 WebSocket # WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletown.novamailio.com/game" const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
# 默认最大重连次数 # 默认最大重连次数
const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5 const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5
@@ -119,9 +119,6 @@ var _heartbeat_timer: Timer
# 心跳间隔(秒) # 心跳间隔(秒)
const HEARTBEAT_INTERVAL: float = 30.0 const HEARTBEAT_INTERVAL: float = 30.0
const HEARTBEAT_TIMEOUT: float = 75.0
var _last_server_message_at_msec: int = 0
# ============================================================================ # ============================================================================
# 生命周期方法 # 生命周期方法
@@ -152,7 +149,6 @@ func _process(_delta: float) -> void:
while _websocket_peer.get_available_packet_count() > 0: while _websocket_peer.get_available_packet_count() > 0:
var packet: PackedByteArray = _websocket_peer.get_packet() var packet: PackedByteArray = _websocket_peer.get_packet()
var message: String = packet.get_string_from_utf8() var message: String = packet.get_string_from_utf8()
_last_server_message_at_msec = Time.get_ticks_msec()
# 发射消息接收信号 # 发射消息接收信号
data_received.emit(message) data_received.emit(message)
@@ -198,6 +194,9 @@ func connect_to_game_server(is_reconnect_attempt: bool = false) -> void:
_set_connection_state(ConnectionState.ERROR) _set_connection_state(ConnectionState.ERROR)
return return
# 启动心跳
_start_heartbeat()
# 断开 WebSocket 连接 # 断开 WebSocket 连接
func disconnect_websocket() -> void: func disconnect_websocket() -> void:
_disconnect() _disconnect()
@@ -359,12 +358,9 @@ func _on_websocket_connected() -> void:
reconnection_succeeded.emit() reconnection_succeeded.emit()
_set_connection_state(ConnectionState.CONNECTED) _set_connection_state(ConnectionState.CONNECTED)
_last_server_message_at_msec = Time.get_ticks_msec()
_start_heartbeat()
# WebSocket 连接关闭处理 # WebSocket 连接关闭处理
func _on_websocket_closed() -> void: func _on_websocket_closed() -> void:
_stop_heartbeat()
if not _manual_disconnect_requested and _auto_reconnect_enabled: if not _manual_disconnect_requested and _auto_reconnect_enabled:
connection_lost.emit() connection_lost.emit()
_attempt_reconnect() _attempt_reconnect()
@@ -386,6 +382,13 @@ func _setup_reconnect_timer() -> void:
# 尝试重连 # 尝试重连
func _attempt_reconnect() -> void: func _attempt_reconnect() -> void:
# 检查是否超过最大重连次数
if _reconnect_attempt >= _max_reconnect_attempts:
push_error("WebSocketManager: 达到最大重连次数 (%d),停止重连" % _max_reconnect_attempts)
reconnection_failed.emit(_reconnect_attempt, _max_reconnect_attempts)
_set_connection_state(ConnectionState.ERROR)
return
_reconnect_attempt += 1 _reconnect_attempt += 1
_set_connection_state(ConnectionState.RECONNECTING) _set_connection_state(ConnectionState.RECONNECTING)
@@ -398,8 +401,7 @@ func _attempt_reconnect() -> void:
# 计算重连延迟(指数退避) # 计算重连延迟(指数退避)
func _calculate_reconnect_delay() -> float: func _calculate_reconnect_delay() -> float:
# 指数退避: base_delay * 2^(attempt-1) # 指数退避: base_delay * 2^(attempt-1)
var cappedAttempt := mini(_reconnect_attempt, maxi(_max_reconnect_attempts, 1)) var delay: float = _reconnect_base_delay * pow(2.0, _reconnect_attempt - 1)
var delay: float = _reconnect_base_delay * pow(2.0, cappedAttempt - 1)
# 限制最大延迟 # 限制最大延迟
return min(delay, MAX_RECONNECT_DELAY) return min(delay, MAX_RECONNECT_DELAY)
@@ -432,16 +434,9 @@ func _stop_heartbeat() -> void:
# 心跳超时处理 # 心跳超时处理
func _on_heartbeat() -> void: func _on_heartbeat() -> void:
if _websocket_peer.get_ready_state() != WebSocketPeer.STATE_OPEN: # 不发送心跳,避免服务器返回 "消息格式错误"
return # 如果需要心跳,服务器应该支持特定格式
var now := Time.get_ticks_msec() pass
if _last_server_message_at_msec > 0 and now - _last_server_message_at_msec > int(HEARTBEAT_TIMEOUT * 1000.0):
push_warning("WebSocketManager: 心跳超时,正在重新连接")
_websocket_peer.close(4000, "Heartbeat timeout")
return
var err := _websocket_peer.send_text(JSON.stringify({"type": "ping"}))
if err != OK:
push_warning("WebSocketManager: 心跳发送失败 - %s" % error_string(err))
# ============================================================================ # ============================================================================
# 工具方法 # 工具方法
@@ -460,7 +455,7 @@ func get_state_description() -> String:
ConnectionState.CONNECTED: ConnectionState.CONNECTED:
return "已连接" return "已连接"
ConnectionState.RECONNECTING: ConnectionState.RECONNECTING:
return "重连中 (%d)" % _reconnect_attempt return "重连中 (%d/%d)" % [_reconnect_attempt, _max_reconnect_attempts]
ConnectionState.ERROR: ConnectionState.ERROR:
return "错误" return "错误"
_: _:

View File

@@ -0,0 +1,20 @@
extends Node2D
# 地图内可交互目标的轻量视觉标记,由 InteractionManager 提供世界坐标。
const RING_COLOR: Color = Color(1.0, 1.0, 1.0, 0.94)
const CORE_COLOR: Color = Color(1.0, 1.0, 1.0, 0.58)
const RING_RADIUS: float = 9.0
const RING_WIDTH: float = 2.0
var _points: Array[Vector2] = []
func set_points(points: Array[Vector2]) -> void:
if _points == points:
return
_points = points.duplicate()
queue_redraw()
func _draw() -> void:
for point: Vector2 in _points:
draw_arc(point, RING_RADIUS, 0.0, TAU, 24, RING_COLOR, RING_WIDTH, true)
draw_circle(point, 2.0, CORE_COLOR)

View File

@@ -0,0 +1 @@
uid://de27p7wr4gj2b

View File

@@ -17,8 +17,8 @@ const CONFIG_PATHS: Array[String] = [
] ]
const DEFAULT_PROFILE: String = "production" const DEFAULT_PROFILE: String = "production"
const DEFAULT_API_BASE_URL: String = "https://whaletown.novamailio.com/api" const DEFAULT_API_BASE_URL: String = "https://whaletownend.xinghangee.icu"
const DEFAULT_WS_URL: String = "wss://whaletown.novamailio.com/game" const DEFAULT_WS_URL: String = "wss://whaletownend.xinghangee.icu/game"
const API_BASE_URL_ENV_KEY: String = "WHALETOWN_API_BASE_URL" const API_BASE_URL_ENV_KEY: String = "WHALETOWN_API_BASE_URL"
const CHAT_WS_URL_ENV_KEY: String = "WHALETOWN_CHAT_WS_URL" const CHAT_WS_URL_ENV_KEY: String = "WHALETOWN_CHAT_WS_URL"
@@ -35,10 +35,6 @@ static func get_api_base_url() -> String:
if not env_url.is_empty(): if not env_url.is_empty():
return _trim_trailing_slash(env_url) return _trim_trailing_slash(env_url)
var browser_url := _get_browser_same_origin_url("/api")
if not browser_url.is_empty():
return browser_url
var network_config := _get_active_network_config() var network_config := _get_active_network_config()
var url: String = str(network_config.get("api_base_url", "")).strip_edges() var url: String = str(network_config.get("api_base_url", "")).strip_edges()
if not url.is_empty(): if not url.is_empty():
@@ -55,10 +51,6 @@ static func get_chat_ws_url() -> String:
if not env_url.is_empty(): if not env_url.is_empty():
return env_url return env_url
var browser_url := _get_browser_same_origin_websocket_url("/game")
if not browser_url.is_empty():
return browser_url
var network_config := _get_active_network_config() var network_config := _get_active_network_config()
var url: String = str(network_config.get("chat_ws_url", network_config.get("game_ws_url", ""))).strip_edges() var url: String = str(network_config.get("chat_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
if not url.is_empty(): if not url.is_empty():
@@ -83,10 +75,6 @@ static func get_location_ws_url() -> String:
if not game_env_url.is_empty(): if not game_env_url.is_empty():
return game_env_url return game_env_url
var browser_url := _get_browser_same_origin_websocket_url("/game")
if not browser_url.is_empty():
return browser_url
var network_config := _get_active_network_config() var network_config := _get_active_network_config()
var url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges() var url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
if not url.is_empty(): if not url.is_empty():
@@ -166,7 +154,7 @@ static func _load_config() -> Dictionary:
return {} return {}
static func _get_browser_query_value(key: String) -> String: static func _get_browser_query_value(key: String) -> String:
if OS.get_name() != "Web": if OS.get_name() != "Web" or not OS.is_debug_build():
return "" return ""
if not Engine.has_singleton("JavaScriptBridge"): if not Engine.has_singleton("JavaScriptBridge"):
@@ -180,25 +168,6 @@ static func _get_browser_query_value(key: String) -> String:
var value: Variant = js_bridge.eval(script, true) var value: Variant = js_bridge.eval(script, true)
return str(value).strip_edges() return str(value).strip_edges()
static func _get_browser_same_origin_url(path: String) -> String:
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
return ""
var js_bridge: Object = Engine.get_singleton("JavaScriptBridge")
if js_bridge == null:
return ""
var origin: String = str(js_bridge.eval("window.location.origin || ''", true)).strip_edges()
if not origin.begins_with("http://") and not origin.begins_with("https://"):
return ""
return _trim_trailing_slash(origin) + path
static func _get_browser_same_origin_websocket_url(path: String) -> String:
var http_url := _get_browser_same_origin_url("")
if http_url.begins_with("https://"):
return "wss://" + http_url.substr(8) + path
if http_url.begins_with("http://"):
return "ws://" + http_url.substr(7) + path
return ""
static func _get_browser_url_override(key: String, allowed_schemes: Array[String]) -> String: static func _get_browser_url_override(key: String, allowed_schemes: Array[String]) -> String:
var value := _get_browser_query_value(key) var value := _get_browser_query_value(key)
if value.is_empty(): if value.is_empty():

View File

@@ -1,123 +0,0 @@
extends RefCounted
var _javascript_callback: Variant
var _result_callback: Callable
var _callback_name: String = ""
func open(accept: String, max_bytes: int, result_callback: Callable) -> bool:
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
return false
if _result_callback.is_valid():
return false
var javascript_bridge: Object = Engine.get_singleton("JavaScriptBridge")
_callback_name = "__whaletownFilePicker%d" % get_instance_id()
_result_callback = result_callback
_javascript_callback = javascript_bridge.create_callback(_on_javascript_result)
var window: Variant = javascript_bridge.get_interface("window")
if window == null:
_reset()
return false
window[_callback_name] = _javascript_callback
var script := """
(() => {
const callbackName = %s;
const callback = window[callbackName];
if (typeof callback !== "function") return false;
const previous = document.getElementById("whaletown-web-file-picker");
if (previous) previous.remove();
const input = document.createElement("input");
input.id = "whaletown-web-file-picker";
input.type = "file";
input.accept = %s;
input.style.display = "none";
let finished = false;
const finish = (status, name, mime, payload) => {
if (finished) return;
finished = true;
callback(status, name || "", mime || "", payload || "");
input.remove();
delete window[callbackName];
};
input.addEventListener("cancel", () => finish("cancel", "", "", ""));
input.addEventListener("change", () => {
const file = input.files && input.files[0];
if (!file) {
finish("cancel", "", "", "");
return;
}
if (file.size > %d) {
finish("too_large", file.name, file.type, "");
return;
}
const reader = new FileReader();
reader.onerror = () => finish("read_error", file.name, file.type, "");
reader.onload = () => {
const bytes = new Uint8Array(reader.result);
const chunks = [];
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
}
finish("ok", file.name, file.type, btoa(chunks.join("")));
};
reader.readAsArrayBuffer(file);
});
document.body.appendChild(input);
input.click();
return true;
})()
""" % [JSON.stringify(_callback_name), JSON.stringify(accept), max_bytes]
var opened: Variant = javascript_bridge.eval(script, true)
if opened != true:
_reset()
return false
return true
func _on_javascript_result(arguments: Array) -> void:
var callback := _result_callback
_reset()
if not callback.is_valid():
return
var status := str(arguments[0]) if arguments.size() > 0 else "read_error"
var file_name := str(arguments[1]) if arguments.size() > 1 else ""
var mime_type := str(arguments[2]) if arguments.size() > 2 else ""
var base64_data := str(arguments[3]) if arguments.size() > 3 else ""
callback.call(status, file_name, mime_type, base64_data)
func _reset() -> void:
_javascript_callback = null
_result_callback = Callable()
_callback_name = ""
static func save_base64_file(base64_data: String, file_name: String, prefix: String) -> String:
if base64_data.is_empty():
return ""
var bytes := Marshalls.base64_to_raw(base64_data)
if bytes.is_empty():
return ""
var extension := file_name.get_extension().to_lower()
if not ["png", "jpg", "jpeg", "webp"].has(extension):
extension = "bin"
var root := DirAccess.open("user://")
if root == null:
return ""
if not root.dir_exists("web_uploads") and root.make_dir("web_uploads") != OK:
return ""
var safe_prefix := prefix.to_lower().replace(" ", "_")
var path := "user://web_uploads/%s_%d.%s" % [safe_prefix, Time.get_ticks_msec(), extension]
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return ""
file.store_buffer(bytes)
file.close()
return path

View File

@@ -1 +0,0 @@
uid://dqvrn7yv0ap4y

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dku0dqqhtnjbk"
path="res://.godot/imported/horned_creature_npc_idle_down.png-fae6e26bf555f707761f7ec796a16f56.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png"
dest_files=["res://.godot/imported/horned_creature_npc_idle_down.png-fae6e26bf555f707761f7ec796a16f56.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bgwcow6lkrb6c"
path="res://.godot/imported/horned_creature_npc_idle_left.png-80f388f72940bd5985d973d135215c97.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png"
dest_files=["res://.godot/imported/horned_creature_npc_idle_left.png-80f388f72940bd5985d973d135215c97.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cmf8t7qsogtkv"
path="res://.godot/imported/horned_creature_npc_idle_right.png-98efb444bb786263b2f3dd1d1241be34.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png"
dest_files=["res://.godot/imported/horned_creature_npc_idle_right.png-98efb444bb786263b2f3dd1d1241be34.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bv70hix5m85sf"
path="res://.godot/imported/horned_creature_npc_idle_up.png-aa780407ff47d0ba8647dab1272956e4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png"
dest_files=["res://.godot/imported/horned_creature_npc_idle_up.png-aa780407ff47d0ba8647dab1272956e4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b8ogjmo7jx5x5"
path="res://.godot/imported/horned_creature_npc_spritesheet.png-443e96d87eae79edde2818f42c4ec378.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png"
dest_files=["res://.godot/imported/horned_creature_npc_spritesheet.png-443e96d87eae79edde2818f42c4ec378.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://i2qjcms43oc0"
path="res://.godot/imported/whale_researcher_no_feet_spritesheet.png-828a36866400f9adbedeaaedec6e718f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png"
dest_files=["res://.godot/imported/whale_researcher_no_feet_spritesheet.png-828a36866400f9adbedeaaedec6e718f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 KiB

After

Width:  |  Height:  |  Size: 398 KiB

View File

@@ -1,96 +0,0 @@
Fusion Pixel Font
https://github.com/TakWolf/fusion-pixel-font
Copyright (c) 2022, TakWolf (https://takwolf.com).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,36 +0,0 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://dss1envknmwvy"
path="res://.godot/imported/fusion-pixel-12px-proportional-latin.ttf.woff2-d7baa0634e626d804d8eea7c4b5e5637.fontdata"
[deps]
source_file="res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2"
dest_files=["res://.godot/imported/fusion-pixel-12px-proportional-latin.ttf.woff2-d7baa0634e626d804d8eea7c4b5e5637.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

View File

@@ -1,36 +0,0 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://cea60eanpeue4"
path="res://.godot/imported/fusion-pixel-12px-proportional-zh_hans.ttf.woff2-3005ce2bd956afbf78a39ebd1102f9dc.fontdata"
[deps]
source_file="res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2"
dest_files=["res://.godot/imported/fusion-pixel-12px-proportional-zh_hans.ttf.woff2-3005ce2bd956afbf78a39ebd1102f9dc.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -1,36 +0,0 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://cy7uxq32lpgou"
path="res://.godot/imported/msyh-web.ttf-0b9bbf1e573e29d7e152628e09746417.fontdata"
[deps]
source_file="res://assets/fonts/msyh-web.ttf"
dest_files=["res://.godot/imported/msyh-web.ttf-0b9bbf1e573e29d7e152628e09746417.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cw2ywnvsjk4tw"
path="res://.godot/imported/whale_cafe_large_service_hall_base_v4.png-a01976399336db42367bf3da210ce89b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/cafe/whale_cafe_large_service_hall_base_v4.png"
dest_files=["res://.godot/imported/whale_cafe_large_service_hall_base_v4.png-a01976399336db42367bf3da210ce89b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -1,15 +0,0 @@
shader_type canvas_item;
uniform float corner_radius = 0.22;
uniform float edge_feather = 0.008;
void fragment() {
vec2 q = abs(UV - vec2(0.5)) - (vec2(0.5) - vec2(corner_radius));
float outside_distance = length(max(q, vec2(0.0)));
float inside_distance = min(max(q.x, q.y), 0.0);
float signed_distance = outside_distance + inside_distance - corner_radius;
float mask = 1.0 - smoothstep(0.0, edge_feather, signed_distance);
COLOR = texture(TEXTURE, UV);
COLOR.a *= mask;
}

View File

@@ -1 +0,0 @@
uid://bkudybm8sbvug

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://eq2fin4oby2k"
path="res://.godot/imported/empty_whale.png-ffea64aa04d74a02a6935a88182cee3c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/community/empty_whale.png"
dest_files=["res://.godot/imported/empty_whale.png-ffea64aa04d74a02a6935a88182cee3c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bx5uy1p64o4vo"
path="res://.godot/imported/pinned_note.png-034bf127b63da4f239b070c415812df8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/community/pinned_note.png"
dest_files=["res://.godot/imported/pinned_note.png-034bf127b63da4f239b070c415812df8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b0w5mutlqnlxw"
path="res://.godot/imported/recruit_whales.png-d87526a4f19c9d60855712b106907786.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/community/recruit_whales.png"
dest_files=["res://.godot/imported/recruit_whales.png-d87526a4f19c9d60855712b106907786.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bk6gkltoavsd"
path="res://.godot/imported/town_banner.png-ee7512df5a2c66f421bc751ceff6e080.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/community/town_banner.png"
dest_files=["res://.godot/imported/town_banner.png-ee7512df5a2c66f421bc751ceff6e080.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://xpkmqb81fjw6"
path="res://.godot/imported/world_bulletin_icon_hd_simple_tight.png-b97116e82ce39ccf8dbc8ba2be158537.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png"
dest_files=["res://.godot/imported/world_bulletin_icon_hd_simple_tight.png-b97116e82ce39ccf8dbc8ba2be158537.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://s42gvnli8h2e"
path="res://.godot/imported/world_bulletin_send_button_final_192.png-8db1899e79e2361bfc869a0da3dc8d51.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/world_bulletin_send_button_final_192.png"
dest_files=["res://.godot/imported/world_bulletin_send_button_final_192.png-8db1899e79e2361bfc869a0da3dc8d51.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://2ukykf5vywnj"
path="res://.godot/imported/world_bulletin_send_v2_128.png-e1f7806cd2d6195a5af01a8fcf36310f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/world_bulletin_send_v2_128.png"
dest_files=["res://.godot/imported/world_bulletin_send_v2_128.png-e1f7806cd2d6195a5af01a8fcf36310f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -1,6 +1,6 @@
[gd_resource type="Theme" format=3 uid="uid://brk6ca2npglqc"] [gd_resource type="Theme" format=3 uid="uid://brk6ca2npglqc"]
[ext_resource type="FontFile" uid="uid://cy7uxq32lpgou" path="res://assets/fonts/msyh-web.ttf" id="1_font"] [ext_resource type="FontFile" uid="uid://ce7ujbeobblyr" path="res://assets/fonts/msyh.ttc" id="1_font"]
[resource] [resource]
resource_local_to_scene = true resource_local_to_scene = true

View File

@@ -1,24 +0,0 @@
whaletown.novamailio.com {
root * /var/www/whale-town-front-v2/build/site
@playWithoutSlash path /play
@wasm path *.wasm
@pack path *.pck
redir @playWithoutSlash /play/ 308
handle_path /play/* {
root * /var/www/whale-town-front-v2/build/site/play
header @wasm Content-Type application/wasm
header @pack Content-Type application/octet-stream
file_server
}
handle /downloads/* {
header Content-Disposition "attachment"
file_server
}
handle {
file_server
}
}

View File

@@ -1,129 +0,0 @@
# Existing Sub2API and blog sites on the Singapore host.
api.novamailio.com {
@long_stream path /v1/responses /responses
handle @long_stream {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
stream_close_delay 5m
transport http {
keepalive 30s
keepalive_interval 15s
keepalive_idle_conns_per_host 128
}
}
}
handle {
reverse_proxy 127.0.0.1:8080
}
}
xiangking.novamailio.com {
root * /var/www/xiangking-blog
encode zstd gzip
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
file_server
}
whaletown.novamailio.com {
log {
output file /var/log/caddy/whaletown-access.json {
roll_size 100MiB
roll_keep 10
roll_keep_for 720h
}
format json
}
encode zstd gzip
root * /var/www/whaletown/site
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
@immutable_pack {
path /play/packs/*.pck
file {path}
}
header @immutable_pack {
Cache-Control "public, max-age=31536000, immutable"
Content-Type "application/octet-stream"
}
@immutable_admin path /admin/assets/*
header @immutable_admin Cache-Control "public, max-age=31536000, immutable"
@pack_manifest path /play/packs/manifest.json
header @pack_manifest Cache-Control "no-cache"
@runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js
header @runtime Cache-Control "public, max-age=14400"
@html path / /index.html /play /play/ /play/index.html /admin /admin/ /admin/index.html
header @html Cache-Control "no-cache"
@play_without_slash path /play
redir @play_without_slash /play/ 308
handle_path /play/* {
root * /var/www/whaletown/site/play
file_server
}
@game path /game*
handle @game {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@location path /location-broadcast
handle @location {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@notice path /ws/notice
handle @notice {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@account_assets path /assets/account/*
handle @account_assets {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
redir /api /api/
handle /api/* {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
redir /admin /admin/
handle_path /admin/* {
root * /var/www/whaletown/admin
try_files {path} /index.html
file_server
}
handle /downloads/* {
header Content-Disposition "attachment"
file_server
}
handle {
file_server
}
}

View File

@@ -1,89 +0,0 @@
whaletown.novamailio.com {
log {
output file /var/log/caddy/whaletown-access.json {
roll_size 20MiB
roll_keep 5
roll_keep_for 168h
}
format json
}
encode zstd gzip
root * /var/www/whaletown/site
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
@play path /play/*
header @play {
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Embedder-Policy require-corp
}
@immutable_pack {
path /play/packs/*.pck
file {path}
}
header @immutable_pack {
Cache-Control "public, max-age=31536000, immutable"
Content-Type "application/octet-stream"
}
@pack_manifest path /play/packs/manifest.json
header @pack_manifest Cache-Control "no-cache"
@runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js
header @runtime Cache-Control "public, max-age=14400"
@html path / /index.html /play /play/ /play/index.html
header @html Cache-Control "no-cache"
@play_without_slash path /play
redir @play_without_slash /play/ 308
handle_path /play/* {
root * /var/www/whaletown/site/play
file_server
}
@game path /game*
handle @game {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@location path /location-broadcast
handle @location {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@notice path /ws/notice
handle @notice {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@account_assets path /assets/account/*
handle @account_assets {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
redir /api /api/
handle /api/* {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
handle {
file_server
}
}

View File

@@ -1,89 +0,0 @@
162-251-94-42.sslip.io {
log {
output file /var/log/caddy/whaletown-access.json {
roll_size 20MiB
roll_keep 5
roll_keep_for 168h
}
format json
}
encode zstd gzip
root * /var/www/whaletown/site
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
@play path /play/*
header @play {
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Embedder-Policy require-corp
}
@immutable_pack {
path /play/packs/*.pck
file {path}
}
header @immutable_pack {
Cache-Control "public, max-age=31536000, immutable"
Content-Type "application/octet-stream"
}
@pack_manifest path /play/packs/manifest.json
header @pack_manifest Cache-Control "no-cache"
@runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js
header @runtime Cache-Control "public, max-age=14400"
@html path / /index.html /play /play/ /play/index.html
header @html Cache-Control "no-cache"
@play_without_slash path /play
redir @play_without_slash /play/ 308
handle_path /play/* {
root * /var/www/whaletown/site/play
file_server
}
@game path /game*
handle @game {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@location path /location-broadcast
handle @location {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@notice path /ws/notice
handle @notice {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@account_assets path /assets/account/*
handle @account_assets {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
redir /api /api/
handle /api/* {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
handle {
file_server
}
}

View File

@@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name whaletown.novamailio.com; server_name whaletown.xinghangee.icu;
root /var/www/whale-town-front-v2/build/web; root /var/www/whale-town-front-v2/build/web;
index index.html; index index.html;

View File

@@ -6,9 +6,8 @@ runnable=true
advanced_options=false advanced_options=false
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="scenes" export_filter="all_resources"
export_files=PackedStringArray("res://scenes/ui/WebBootstrap.tscn") include_filter=""
include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/managers/*.gd,_Core/systems/*.gd,_Core/utils/*.gd,assets/audio/ui/*.wav,assets/fonts/msyh-web.ttf,assets/shaders/*.gdshader"
exclude_filter="" exclude_filter=""
export_path="build/web/index.html" export_path="build/web/index.html"
patches=PackedStringArray() patches=PackedStringArray()
@@ -28,7 +27,7 @@ variant/thread_support=false
vram_texture_compression/for_desktop=true vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false vram_texture_compression/for_mobile=false
html/export_icon=true html/export_icon=true
html/custom_html_shell="res://web/progressive_shell.html" html/custom_html_shell=""
html/head_include="" html/head_include=""
html/canvas_resize_policy=2 html/canvas_resize_policy=2
html/focus_canvas_on_start=true html/focus_canvas_on_start=true
@@ -53,7 +52,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**" exclude_filter=""
export_path="build/linux/WhaleTown-V2.x86_64" export_path="build/linux/WhaleTown-V2.x86_64"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
@@ -73,218 +72,3 @@ texture_format/s3tc_bptc=true
texture_format/etc2_astc=false texture_format/etc2_astc=false
architecture/x86_64=true architecture/x86_64=true
ssh_remote_deploy/enabled=false ssh_remote_deploy/enabled=false
[preset.2]
name="Scene Square"
platform="Web"
runnable=false
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="scenes"
export_files=PackedStringArray("res://scenes/Maps/square.tscn")
include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/audio/ui/*.wav,assets/characters/*.png,assets/characters/skins/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/ui/world_bulletin/*.png,assets/ui/mall/branding/*.png,assets/ui/mall/icons/processed/*.png,assets/ui/mall/items/*.png,assets/ui/mall/skins/*_product.png,assets/ui/settings/*.png"
exclude_filter="assets/maps/work_zone/**/*.png,assets/maps/personal_space/**/*.png,assets/maps/cafe/**/*.png"
export_path="build/packs/square.pck"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.2.options]
variant/extensions_support=false
variant/thread_support=false
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false
[preset.3]
name="Scene Work Zone"
platform="Web"
runnable=false
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="scenes"
export_files=PackedStringArray("res://scenes/Maps/work_zone.tscn")
include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png"
exclude_filter="assets/maps/square/**/*.png,assets/maps/personal_space/**/*.png,assets/maps/cafe/**/*.png"
export_path="build/packs/work_zone.pck"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.3.options]
variant/extensions_support=false
variant/thread_support=false
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false
[preset.4]
name="Scene Cafe"
platform="Web"
runnable=false
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="scenes"
export_files=PackedStringArray("res://scenes/Maps/cafe_interior.tscn")
include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png"
exclude_filter="assets/maps/square/**/*.png,assets/maps/work_zone/**/*.png,assets/maps/personal_space/**/*.png"
export_path="build/packs/cafe_interior.pck"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.4.options]
variant/extensions_support=false
variant/thread_support=false
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false
[preset.5]
name="Scene Personal Space"
platform="Web"
runnable=false
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="scenes"
export_files=PackedStringArray("res://scenes/Maps/personal_space.tscn")
include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png,assets/maps/personal_space/v1/decor/*.png"
exclude_filter="assets/maps/square/**/*.png,assets/maps/work_zone/**/*.png,assets/maps/cafe/**/*.png"
export_path="build/packs/personal_space.pck"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.5.options]
variant/extensions_support=false
variant/thread_support=false
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false
[preset.6]
name="Scene Auth"
platform="Web"
runnable=false
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="scenes"
export_files=PackedStringArray("res://scenes/ui/AuthScene.tscn")
include_filter="scenes/ui/AuthScene.gd,assets/characters/player_pixel_spritesheet.png,assets/characters/skins/*.png,assets/ui/auth/v1/bg_auth_scene.png,assets/ui/auth/redesign/whaletown_login_panel_hd.png,assets/ui/auth/redesign/whaletown_register_panel_compact_hd.png,assets/ui/auth/registration_choice/redesign/main_panel.png,assets/ui/auth/registration_choice/redesign/top_whale.png,assets/ui/auth/registration_choice/redesign/card_base.png,assets/ui/auth/registration_choice/redesign/card_selected.png,assets/ui/auth/registration_choice/redesign/header_pill.png,assets/ui/auth/registration_choice/redesign/primary_button.png,assets/ui/auth/registration_choice/redesign/footer_bar.png,assets/ui/auth/registration_choice/redesign/skin_slot_base.png,assets/ui/auth/registration_choice/redesign/skin_slot_selected.png,assets/ui/auth/registration_choice/redesign/upload_file_box.png,assets/ui/auth/registration_choice/redesign/spritesheet_grid_box.png,assets/ui/auth/registration_choice/redesign/reference_upload_box.png,assets/ui/auth/registration_choice/redesign/image_placeholder_box.png"
exclude_filter="assets/maps/**/*.png,assets/ui/mall/**/*.png,assets/ui/settings/*.png"
export_path="build/packs/auth.pck"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.6.options]
variant/extensions_support=false
variant/thread_support=false
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false
[preset.7]
name="WhaleTown macOS"
platform="macOS"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**,assets/characters/reference/**,assets/characters/skin_generation_references/**,assets/ui/chat/**,assets/ui/auth/registration_choice/generated/**,assets/ui/auth/registration_choice/preview/**,assets/ui/auth/registration_choice/redesign_preview/**,assets/ui/auth/registration_choice/redesign/*_magenta.png*,assets/maps/square/v1/square_base_terrain_v*.png*,assets/maps/work_zone/v1/**,assets/maps/work_zone/v2/**,assets/maps/work_zone/v3/**,assets/maps/work_zone/v4/**,assets/maps/work_zone/v5/**,assets/maps/work_zone/v6/**,assets/maps/work_zone/v7/**,assets/maps/work_zone/v8/**,assets/maps/work_zone/v9/**,assets/maps/work_zone/v10/**,assets/maps/work_zone/v11/**,assets/maps/work_zone/v12/**,assets/maps/work_zone/v16/**,assets/maps/work_zone/v17/**,assets/maps/work_zone/v18/**,assets/maps/work_zone/v19/**,assets/maps/work_zone/v20/**,assets/maps/work_zone/v21/**,assets/maps/work_zone/v22/**,assets/maps/work_zone/v23/**,assets/maps/work_zone/v24/**,assets/maps/work_zone/v25/**"
export_path="build/desktop/macos/WhaleTown.app"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.7.options]
export/distribution_type=0
binary_format/architecture="universal"
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
application/short_version="1.0"
application/version="1.0.0"
application/icon="res://icon.svg"
application/bundle_identifier="com.whaletown.game"
application/signature=""
application/app_category="Games"
display/high_res=true
codesign/codesign=1
codesign/installer_identity=""
codesign/apple_team_id=""
notarization/notarization=0
[preset.8]
name="WhaleTown Windows"
platform="Windows Desktop"
runnable=false
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**,assets/characters/reference/**,assets/characters/skin_generation_references/**,assets/ui/chat/**,assets/ui/auth/registration_choice/generated/**,assets/ui/auth/registration_choice/preview/**,assets/ui/auth/registration_choice/redesign_preview/**,assets/ui/auth/registration_choice/redesign/*_magenta.png*,assets/maps/square/v1/square_base_terrain_v*.png*,assets/maps/work_zone/v1/**,assets/maps/work_zone/v2/**,assets/maps/work_zone/v3/**,assets/maps/work_zone/v4/**,assets/maps/work_zone/v5/**,assets/maps/work_zone/v6/**,assets/maps/work_zone/v7/**,assets/maps/work_zone/v8/**,assets/maps/work_zone/v9/**,assets/maps/work_zone/v10/**,assets/maps/work_zone/v11/**,assets/maps/work_zone/v12/**,assets/maps/work_zone/v16/**,assets/maps/work_zone/v17/**,assets/maps/work_zone/v18/**,assets/maps/work_zone/v19/**,assets/maps/work_zone/v20/**,assets/maps/work_zone/v21/**,assets/maps/work_zone/v22/**,assets/maps/work_zone/v23/**,assets/maps/work_zone/v24/**,assets/maps/work_zone/v25/**"
export_path="build/desktop/windows/WhaleTown.exe"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.8.options]
binary_format/architecture="x86_64"
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=true
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc2=false
application/modify_resources=true
application/icon="res://icon.svg"
application/file_version="1.0.0.0"
application/product_version="1.0.0"
application/product_name="WhaleTown"

View File

@@ -14,15 +14,14 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application] [application]
config/name="WhaleTown" config/name="WhaleTown V2"
config/version="1.0.0" run/main_scene="res://scenes/ui/AuthScene.tscn"
run/main_scene="res://scenes/ui/WebBootstrap.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility") config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg" config/icon="res://icon.svg"
[autoload] [autoload]
UIFontManager="*res://_Core/managers/UIFontManager.gd" UiEscapeManager="*res://_Core/managers/UiEscapeManager.gd"
SceneManager="*res://_Core/managers/SceneManager.gd" SceneManager="*res://_Core/managers/SceneManager.gd"
EventSystem="*res://_Core/systems/EventSystem.gd" EventSystem="*res://_Core/systems/EventSystem.gd"
ApiClient="*res://_Core/managers/ApiClient.gd" ApiClient="*res://_Core/managers/ApiClient.gd"
@@ -34,7 +33,8 @@ CafeCompanionManager="*res://_Core/managers/CafeCompanionManager.gd"
AppearanceManager="*res://_Core/managers/AppearanceManager.gd" AppearanceManager="*res://_Core/managers/AppearanceManager.gd"
SettingsManager="*res://_Core/managers/SettingsManager.gd" SettingsManager="*res://_Core/managers/SettingsManager.gd"
NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd" NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd"
InputFocusManager="*res://_Core/managers/InputFocusManager.gd" SocialManager="*res://_Core/managers/SocialManager.gd"
InteractionManager="*res://_Core/managers/InteractionManager.gd"
[display] [display]
@@ -43,10 +43,6 @@ window/size/viewport_height=1440
window/stretch/mode="canvas_items" window/stretch/mode="canvas_items"
window/stretch/aspect="expand" window/stretch/aspect="expand"
[gui]
theme/custom_font="res://assets/fonts/msyh-web.ttf"
[input] [input]
move_left={ move_left={
@@ -78,11 +74,6 @@ interact={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
] ]
} }
friend_request={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
]
}
[rendering] [rendering]

View File

@@ -16,12 +16,11 @@ const WORK_ZONE_CAFE_RETURN_POSITION: Vector2 = Vector2(-1093, 560)
const CAFE_DOOR_POSITION: Vector2 = Vector2(0, 392) const CAFE_DOOR_POSITION: Vector2 = Vector2(0, 392)
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const COMPANION_NAMEPLATE_RENDER_SCALE: float = 0.5 const COMPANION_NAMEPLATE_RENDER_SCALE: float = 0.5
const COMPANION_NAMEPLATE_FONT_SIZE: int = 16 const COMPANION_NAMEPLATE_FONT_SIZE: int = 24
const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 15 const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 22
const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 64 const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 76
const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 100 const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 118
const COMPANION_NAMEPLATE_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2") const COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
const COMPANION_NAMEPLATE_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
const NPC_NAMEPLATE_OFFSET_Y: float = -136.0 const NPC_NAMEPLATE_OFFSET_Y: float = -136.0
const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0 const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0
@@ -37,7 +36,6 @@ const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0
var _isChangingScene: bool = false var _isChangingScene: bool = false
var _lastRecruitmentClickMsec: int = 0 var _lastRecruitmentClickMsec: int = 0
var _companionNicknameFont: FontFile
func _ready() -> void: func _ready() -> void:
_align_service_occupants() _align_service_occupants()
@@ -47,6 +45,16 @@ func _ready() -> void:
_connect_exit_area() _connect_exit_area()
_connect_recruitment_area() _connect_recruitment_area()
_connect_cafe_companion_events() _connect_cafe_companion_events()
_register_interactables()
_discover_destination()
func _discover_destination() -> void:
var destination_id := SceneManager.get_next_destination_id()
if destination_id.is_empty():
destination_id = "cafe_entrance"
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("discover_destination"):
socialManager.call("discover_destination", destination_id)
func _exit_tree() -> void: func _exit_tree() -> void:
var eventSystem := get_node_or_null("/root/EventSystem") var eventSystem := get_node_or_null("/root/EventSystem")
@@ -58,6 +66,8 @@ func _align_service_occupants() -> void:
cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position
func _apply_spawn_point() -> void: func _apply_spawn_point() -> void:
if player.has_scene_position_override:
return
var spawnName: String = SceneManager.get_next_spawn_name() var spawnName: String = SceneManager.get_next_spawn_name()
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
var marker := $Markers.get_node_or_null(markerName) as Marker2D var marker := $Markers.get_node_or_null(markerName) as Marker2D
@@ -77,8 +87,7 @@ func _configure_camera() -> void:
playerCamera.limit_smoothed = true playerCamera.limit_smoothed = true
func _connect_exit_area() -> void: func _connect_exit_area() -> void:
if not exitToWorkZoneArea.body_entered.is_connected(_on_exit_area_body_entered): exitToWorkZoneArea.collision_mask = 0
exitToWorkZoneArea.body_entered.connect(_on_exit_area_body_entered)
func _connect_recruitment_area() -> void: func _connect_recruitment_area() -> void:
cafeRecruitmentLogoArea.input_pickable = true cafeRecruitmentLogoArea.input_pickable = true
@@ -93,8 +102,30 @@ func _connect_cafe_companion_events() -> void:
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_cafe_companion_agent_registered, self) eventSystem.call("connect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_cafe_companion_agent_registered, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, self) eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, self)
func _register_interactables() -> void:
var exit_interactable: InteractableComponent = InteractableComponent.new()
exit_interactable.interaction_id = "cafe_exit"
exit_interactable.interaction_title = "离开咖啡馆"
exit_interactable.interaction_priority = 20
exit_interactable.interaction_distance = 160.0
exit_interactable.activation_method = &"_leave_to_work_zone"
exit_interactable.anchor_path = NodePath("InteractionAreas/ExitToWorkZoneArea")
add_child(exit_interactable)
var recruitment_interactable: InteractableComponent = InteractableComponent.new()
recruitment_interactable.interaction_id = "cafe_recruitment"
recruitment_interactable.interaction_title = "登记咖啡店陪伴机器人"
recruitment_interactable.interaction_priority = 30
recruitment_interactable.interaction_distance = 150.0
recruitment_interactable.activation_method = &"_try_emit_recruitment_selected"
recruitment_interactable.anchor_path = NodePath("InteractionAreas/CafeRecruitmentLogoArea")
add_child(recruitment_interactable)
func _on_exit_area_body_entered(body: Node2D) -> void: func _on_exit_area_body_entered(body: Node2D) -> void:
if _isChangingScene or body != player: return
func _leave_to_work_zone() -> void:
if _isChangingScene:
return return
_isChangingScene = true _isChangingScene = true
SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION) SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION)
@@ -254,20 +285,34 @@ func _configure_companion_nameplate(label: Label, personaName: String, offsetY:
label.clip_text = true label.clip_text = true
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.mouse_filter = Control.MOUSE_FILTER_IGNORE label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0)) label.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
label.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98)) label.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
label.add_theme_constant_override("outline_size", 3) label.add_theme_constant_override("shadow_offset_x", 0)
label.add_theme_font_override("font", _get_companion_nickname_font()) label.add_theme_constant_override("shadow_offset_y", 1)
label.add_theme_font_size_override("font_size", COMPANION_NAMEPLATE_FONT_SIZE) label.add_theme_font_size_override("font_size", COMPANION_NAMEPLATE_FONT_SIZE)
label.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) label.add_theme_stylebox_override("normal", _create_companion_nameplate_style())
func _companion_nameplate_visual_width(displayName: String) -> int: func _companion_nameplate_visual_width(displayName: String) -> int:
var measuredWidth := _get_companion_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, COMPANION_NAMEPLATE_FONT_SIZE).x var estimatedWidth := displayName.length() * COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH + 28
var visualWidth := ceili(measuredWidth * COMPANION_NAMEPLATE_RENDER_SCALE + 20.0) return clampi(estimatedWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH)
return clampi(visualWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH)
func _get_companion_nickname_font() -> FontFile: func _create_companion_nameplate_style() -> StyleBoxFlat:
if _companionNicknameFont == null: var style := StyleBoxFlat.new()
_companionNicknameFont = COMPANION_NAMEPLATE_FONT_ZH.duplicate() as FontFile style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
_companionNicknameFont.fallbacks = [COMPANION_NAMEPLATE_FONT_LATIN] style.border_color = Color(0.18, 0.34, 0.38, 0.92)
return _companionNicknameFont style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 12
style.corner_radius_top_right = 12
style.corner_radius_bottom_left = 12
style.corner_radius_bottom_right = 12
style.content_margin_left = 12
style.content_margin_top = 4
style.content_margin_right = 12
style.content_margin_bottom = 4
style.shadow_color = Color(0.05, 0.08, 0.09, 0.18)
style.shadow_size = 4
style.shadow_offset = Vector2(0, 2)
return style

View File

@@ -7,7 +7,6 @@ extends Node
# ============================================================================ # ============================================================================
const REMOTE_PLAYER_SCENE: PackedScene = preload("res://scenes/characters/remote_player.tscn") const REMOTE_PLAYER_SCENE: PackedScene = preload("res://scenes/characters/remote_player.tscn")
const NETWORK_NPC_SCENE: PackedScene = preload("res://scenes/characters/network_npc.tscn")
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn") const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
const DEFAULT_MAP_ID: String = "whale_port" const DEFAULT_MAP_ID: String = "whale_port"
const POSITION_SEND_INTERVAL: float = 0.12 const POSITION_SEND_INTERVAL: float = 0.12
@@ -20,26 +19,19 @@ const PRIVATE_CHAT_FORWARD_DOT_THRESHOLD: float = 0.25
@export var map_id: String = DEFAULT_MAP_ID @export var map_id: String = DEFAULT_MAP_ID
@export var local_player_path: NodePath = NodePath("../Characters/Players/Player") @export var local_player_path: NodePath = NodePath("../Characters/Players/Player")
@export var remote_players_root_path: NodePath = NodePath("../Characters/Players/RemotePlayers") @export var remote_players_root_path: NodePath = NodePath("../Characters/Players/RemotePlayers")
@export var network_npcs_root_path: NodePath = NodePath("../Characters/Npcs")
var _local_player: Node2D var _local_player: Node2D
var _remote_players_root: Node2D var _remote_players_root: Node2D
var _network_npcs_root: Node2D
var _remote_players: Dictionary = {} var _remote_players: Dictionary = {}
var _network_npcs: Dictionary = {}
var _pending_remote_players: Dictionary = {} var _pending_remote_players: Dictionary = {}
var _last_sent_position: Vector2 = Vector2.INF var _last_sent_position: Vector2 = Vector2.INF
var _last_sent_at_msec: int = 0 var _last_sent_at_msec: int = 0
var _last_sent_direction: String = ""
var _last_sent_movement_state: String = ""
var _movement_sequence: int = 0
var _last_private_chat_interaction_msec: int = 0 var _last_private_chat_interaction_msec: int = 0
var _last_friend_request_interaction_msec: int = 0 var _last_friend_request_interaction_msec: int = 0
func _ready() -> void: func _ready() -> void:
_local_player = get_node_or_null(local_player_path) as Node2D _local_player = get_node_or_null(local_player_path) as Node2D
_remote_players_root = get_node_or_null(remote_players_root_path) as Node2D _remote_players_root = get_node_or_null(remote_players_root_path) as Node2D
_network_npcs_root = get_node_or_null(network_npcs_root_path) as Node2D
if _local_player == null: if _local_player == null:
push_warning("MapMultiplayerController: local player not found.") push_warning("MapMultiplayerController: local player not found.")
@@ -54,9 +46,6 @@ func _ready() -> void:
eventSystem.call("connect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self) eventSystem.call("connect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self) eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self) eventSystem.call("connect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
eventSystem.call("connect_event", EventNames.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self)
eventSystem.call("connect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self)
eventSystem.call("connect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self) eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self) eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self) eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
@@ -67,19 +56,10 @@ func _ready() -> void:
call_deferred("_send_world_ready") call_deferred("_send_world_ready")
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if Input.is_action_just_pressed("interact"): return
_try_start_private_chat()
if Input.is_action_just_pressed("friend_request"):
_try_request_friend()
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("interact"): return
if _try_start_private_chat():
get_viewport().set_input_as_handled()
return
if event.is_action_pressed("friend_request") and _try_request_friend():
get_viewport().set_input_as_handled()
func _exit_tree() -> void: func _exit_tree() -> void:
var eventSystem := _get_event_system() var eventSystem := _get_event_system()
@@ -89,9 +69,6 @@ func _exit_tree() -> void:
eventSystem.call("disconnect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self) eventSystem.call("disconnect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self) eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self) eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
eventSystem.call("disconnect_event", EventNames.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self)
eventSystem.call("disconnect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self)
eventSystem.call("disconnect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self) eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self) eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self) eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
@@ -131,7 +108,7 @@ func _is_text_input_focused() -> bool:
return false return false
func _on_interact_pressed(_data: Dictionary = {}) -> void: func _on_interact_pressed(_data: Dictionary = {}) -> void:
_try_start_private_chat() return
func _try_start_private_chat() -> bool: func _try_start_private_chat() -> bool:
if _is_text_input_focused(): if _is_text_input_focused():
@@ -189,9 +166,7 @@ func _on_chat_login_success(_data: Dictionary) -> void:
func _on_local_player_moved(data: Dictionary) -> void: func _on_local_player_moved(data: Dictionary) -> void:
var position_variant: Variant = data.get("position", null) var position_variant: Variant = data.get("position", null)
if position_variant is Vector2: if position_variant is Vector2:
var direction := str(data.get("direction", _get_local_direction())) _send_position(position_variant as Vector2)
var movementState := str(data.get("movement_state", "walk"))
_send_position(position_variant as Vector2, direction, movementState)
func _send_world_ready() -> void: func _send_world_ready() -> void:
if _local_player == null: if _local_player == null:
@@ -199,16 +174,13 @@ func _send_world_ready() -> void:
var chatManager := _get_chat_manager() var chatManager := _get_chat_manager()
if chatManager == null or not chatManager.has_method("mark_world_ready"): if chatManager == null or not chatManager.has_method("mark_world_ready"):
return return
chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position, _get_local_direction(), "idle", _movement_sequence) chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position)
func _send_position(position: Vector2, direction: String, movementState: String, force: bool = false) -> void: func _send_position(position: Vector2, force: bool = false) -> void:
var normalizedDirection := _normalize_direction(direction)
var normalizedMovementState := _normalize_movement_state(movementState)
var stateChanged := normalizedDirection != _last_sent_direction or normalizedMovementState != _last_sent_movement_state
var now := Time.get_ticks_msec() var now := Time.get_ticks_msec()
if not force and not stateChanged and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0): if not force and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0):
return return
if not force and not stateChanged and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA: if not force and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA:
return return
var chatManager := _get_chat_manager() var chatManager := _get_chat_manager()
@@ -219,10 +191,7 @@ func _send_position(position: Vector2, direction: String, movementState: String,
_last_sent_position = position _last_sent_position = position
_last_sent_at_msec = now _last_sent_at_msec = now
_last_sent_direction = normalizedDirection chatManager.call("update_player_position", position.x, position.y, _get_map_id())
_last_sent_movement_state = normalizedMovementState
_movement_sequence += 1
chatManager.call("update_player_position", position.x, position.y, _get_map_id(), normalizedDirection, normalizedMovementState, _movement_sequence)
func _on_remote_player_joined(data: Dictionary) -> void: func _on_remote_player_joined(data: Dictionary) -> void:
if not _is_event_for_current_map(data): if not _is_event_for_current_map(data):
@@ -232,8 +201,8 @@ func _on_remote_player_joined(data: Dictionary) -> void:
return return
var remote_player := _ensure_remote_player(user_id, data) var remote_player := _ensure_remote_player(user_id, data)
var position_variant: Variant = data.get("position", null) var position_variant: Variant = data.get("position", null)
if remote_player != null and position_variant is Vector2: if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
_update_remote_player_position(remote_player, position_variant as Vector2, data) remote_player.call("update_position", position_variant as Vector2)
func _on_remote_players_snapshot_ready(data: Dictionary) -> void: func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
if not _is_event_for_current_map(data): if not _is_event_for_current_map(data):
@@ -252,8 +221,8 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
seen_user_ids[user_id] = true seen_user_ids[user_id] = true
var remote_player := _ensure_remote_player(user_id, player_data) var remote_player := _ensure_remote_player(user_id, player_data)
var position_variant: Variant = player_data.get("position", null) var position_variant: Variant = player_data.get("position", null)
if remote_player != null and position_variant is Vector2: if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
_update_remote_player_position(remote_player, position_variant as Vector2, player_data) remote_player.call("update_position", position_variant as Vector2)
for user_id_variant in _remote_players.keys().duplicate(): for user_id_variant in _remote_players.keys().duplicate():
var user_id := str(user_id_variant) var user_id := str(user_id_variant)
@@ -268,70 +237,6 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
if not seen_user_ids.has(pendingUserId): if not seen_user_ids.has(pendingUserId):
_pending_remote_players.erase(pendingUserId) _pending_remote_players.erase(pendingUserId)
func _on_npc_snapshot_ready(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
return
if _network_npcs_root == null:
return
var seenNpcIds: Dictionary = {}
var npcsVariant: Variant = data.get("npcs", [])
if npcsVariant is Array:
for npcVariant in npcsVariant:
if not (npcVariant is Dictionary):
continue
var npcData: Dictionary = npcVariant
var npcId := str(npcData.get("npc_id", npcData.get("npcId", ""))).strip_edges()
if npcId.is_empty():
continue
seenNpcIds[npcId] = true
var networkNpc := _ensure_network_npc(npcId)
if networkNpc != null and networkNpc.has_method("apply_snapshot"):
networkNpc.call("apply_snapshot", npcData)
for npcIdVariant in _network_npcs.keys().duplicate():
var npcId := str(npcIdVariant)
if seenNpcIds.has(npcId):
continue
var networkNpc := _network_npcs.get(npcId) as Node
_network_npcs.erase(npcId)
if is_instance_valid(networkNpc):
networkNpc.queue_free()
func _ensure_network_npc(npcId: String) -> Node2D:
if _network_npcs.has(npcId):
var existingNpc := _network_npcs.get(npcId) as Node2D
if is_instance_valid(existingNpc):
return existingNpc
_network_npcs.erase(npcId)
var networkNpc := NETWORK_NPC_SCENE.instantiate() as Node2D
if networkNpc == null:
return null
networkNpc.name = "NetworkNpc_%s" % npcId
networkNpc.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_network_npcs_root.add_child(networkNpc)
_network_npcs[npcId] = networkNpc
return networkNpc
func _on_npc_action_started(data: Dictionary) -> void:
_apply_npc_action(data, "apply_action_started")
func _on_npc_action_completed(data: Dictionary) -> void:
_apply_npc_action(data, "apply_action_completed")
func _apply_npc_action(data: Dictionary, methodName: String) -> void:
if not _is_event_for_current_map(data):
return
var npcId := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
if npcId.is_empty():
return
var networkNpc := _network_npcs.get(npcId) as Node
if networkNpc == null:
networkNpc = _ensure_network_npc(npcId)
if networkNpc != null and networkNpc.has_method(methodName):
networkNpc.call(methodName, data)
func _on_remote_player_position_updated(data: Dictionary) -> void: func _on_remote_player_position_updated(data: Dictionary) -> void:
if not _is_event_for_current_map(data): if not _is_event_for_current_map(data):
return return
@@ -346,8 +251,8 @@ func _on_remote_player_position_updated(data: Dictionary) -> void:
return return
var remote_player := _ensure_remote_player(user_id, data) var remote_player := _ensure_remote_player(user_id, data)
if remote_player != null: if remote_player != null and remote_player.has_method("update_position"):
_update_remote_player_position(remote_player, position_variant as Vector2, data) remote_player.call("update_position", position_variant as Vector2)
func _on_remote_player_left(data: Dictionary) -> void: func _on_remote_player_left(data: Dictionary) -> void:
if not _is_event_for_current_map(data): if not _is_event_for_current_map(data):
@@ -443,8 +348,8 @@ func _on_remote_skin_ready(data: Dictionary) -> void:
_pending_remote_players.erase(userId) _pending_remote_players.erase(userId)
var remotePlayer := _ensure_remote_player(userId, pendingData) var remotePlayer := _ensure_remote_player(userId, pendingData)
var positionVariant: Variant = pendingData.get("position", null) var positionVariant: Variant = pendingData.get("position", null)
if remotePlayer != null and positionVariant is Vector2: if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
_update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData) remotePlayer.call("update_position", positionVariant as Vector2)
func _on_remote_skin_failed(data: Dictionary) -> void: func _on_remote_skin_failed(data: Dictionary) -> void:
var skinId := str(data.get("skin_id", "")).strip_edges() var skinId := str(data.get("skin_id", "")).strip_edges()
@@ -458,14 +363,14 @@ func _on_remote_skin_failed(data: Dictionary) -> void:
continue continue
_pending_remote_players.erase(userId) _pending_remote_players.erase(userId)
var fallbackData := pendingData.duplicate(true) var fallbackData := pendingData.duplicate(true)
fallbackData["skin_id"] = "classic_whale" fallbackData["skin_id"] = ""
fallbackData["skinId"] = "classic_whale" fallbackData["skinId"] = ""
fallbackData.erase("skin_asset") fallbackData.erase("skin_asset")
fallbackData.erase("skinAsset") fallbackData.erase("skinAsset")
var remotePlayer := _ensure_remote_player(userId, fallbackData) var remotePlayer := _ensure_remote_player(userId, fallbackData)
var positionVariant: Variant = pendingData.get("position", null) var positionVariant: Variant = pendingData.get("position", null)
if remotePlayer != null and positionVariant is Vector2: if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
_update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData) remotePlayer.call("update_position", positionVariant as Vector2)
func _find_private_chat_target() -> Node2D: func _find_private_chat_target() -> Node2D:
if _local_player == null or _remote_players.is_empty(): if _local_player == null or _remote_players.is_empty():
@@ -522,31 +427,9 @@ func _apply_remote_player_metadata(remote_player: Node2D, data: Dictionary) -> v
var username := str(data.get("username", "")).strip_edges() var username := str(data.get("username", "")).strip_edges()
if not username.is_empty(): if not username.is_empty():
remote_player.set("username", username) remote_player.set("username", username)
if remote_player.has_method("update_metadata"): if data.has("skin_id") or data.has("skinId") or data.has("skin_asset") or data.has("skinAsset") or data.has("avatar_id") or data.has("avatarId") or data.has("cafe_companion") or data.has("cafeCompanion"):
remote_player.call("update_metadata", data) if remote_player.has_method("setup"):
remote_player.call("setup", data)
func _update_remote_player_position(remotePlayer: Node2D, position: Vector2, data: Dictionary) -> void:
if not remotePlayer.has_method("update_position"):
return
remotePlayer.call(
"update_position",
position,
str(data.get("direction", "")),
str(data.get("movement_state", data.get("movementState", "walk"))),
int(data.get("sequence", -1))
)
func _get_local_direction() -> String:
if _local_player != null:
return _normalize_direction(str(_local_player.get("lastDirection")))
return "down"
func _normalize_direction(value: String) -> String:
var normalized := value.strip_edges().to_lower()
return normalized if normalized in ["down", "up", "right", "left"] else "down"
func _normalize_movement_state(value: String) -> String:
return "walk" if value.strip_edges().to_lower() == "walk" else "idle"
func _resolve_chat_bubble_target(data: Dictionary) -> Node2D: func _resolve_chat_bubble_target(data: Dictionary) -> Node2D:
var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges() var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges()

View File

@@ -46,6 +46,7 @@ var _inventoryRequestInFlight: bool = false
var _inventoryRequestReportsErrors: bool = false var _inventoryRequestReportsErrors: bool = false
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
_leave_multiplayer_world() _leave_multiplayer_world()
_apply_spawn_point() _apply_spawn_point()
_configure_camera() _configure_camera()
@@ -54,8 +55,17 @@ func _ready() -> void:
_build_room_decor_ui() _build_room_decor_ui()
_connect_room_decor_events() _connect_room_decor_events()
_fetch_room_decor_inventory() _fetch_room_decor_inventory()
_discover_destination()
set_process(false) set_process(false)
func _discover_destination() -> void:
var destination_id := SceneManager.get_next_destination_id()
if destination_id.is_empty():
destination_id = "personal_room"
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("discover_destination"):
socialManager.call("discover_destination", destination_id)
func _leave_multiplayer_world() -> void: func _leave_multiplayer_world() -> void:
var chatManager := get_node_or_null("/root/ChatManager") var chatManager := get_node_or_null("/root/ChatManager")
if chatManager != null and chatManager.has_method("leave_world"): if chatManager != null and chatManager.has_method("leave_world"):
@@ -73,6 +83,8 @@ func _exit_tree() -> void:
saveManager.decor_save_failed.disconnect(_on_decor_save_failed) saveManager.decor_save_failed.disconnect(_on_decor_save_failed)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if get_viewport().is_input_handled():
return
if event is InputEventMouseButton: if event is InputEventMouseButton:
var mouseEvent := event as InputEventMouseButton var mouseEvent := event as InputEventMouseButton
if mouseEvent.button_index != MOUSE_BUTTON_LEFT: if mouseEvent.button_index != MOUSE_BUTTON_LEFT:
@@ -91,7 +103,22 @@ func _process(_delta: float) -> void:
if _draggedDecor != null: if _draggedDecor != null:
_update_dragged_decor_position() _update_dragged_decor_position()
func is_escape_dismissible() -> bool:
return _draggedDecor != null or (is_instance_valid(_inventoryPanel) and _inventoryPanel.visible)
func get_escape_priority() -> int:
return 500
func request_escape_close() -> void:
if _draggedDecor != null:
_finish_decor_drag()
return
if is_instance_valid(_inventoryPanel):
_inventoryPanel.visible = false
func _apply_spawn_point() -> void: func _apply_spawn_point() -> void:
if player.has_scene_position_override:
return
var spawnName: String = SceneManager.get_next_spawn_name() var spawnName: String = SceneManager.get_next_spawn_name()
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
var marker := $Markers.get_node_or_null(markerName) as Marker2D var marker := $Markers.get_node_or_null(markerName) as Marker2D

View File

@@ -10,14 +10,23 @@ extends Area2D
@export var targetSceneName: String = "" @export var targetSceneName: String = ""
@export var targetSpawnName: String = "" @export var targetSpawnName: String = ""
@export var targetPosition: Vector2 = Vector2.ZERO @export var targetPosition: Vector2 = Vector2.ZERO
@export var interactionTitle: String = "进入"
@export var interactionDistance: float = 160.0
func _ready() -> void: func _ready() -> void:
body_entered.connect(_on_body_entered) var label: String = interactionTitle.strip_edges()
if label.is_empty():
label = "进入 %s" % targetSceneName
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_id = "portal:%s" % str(get_path())
interactable.interaction_title = label
interactable.interaction_priority = 20
interactable.interaction_distance = interactionDistance
interactable.activation_method = &"_change_scene"
add_child(interactable)
func _on_body_entered(body: Node2D) -> void: func _on_body_entered(body: Node2D) -> void:
if not (body is PlayerController): return
return
_change_scene()
func _change_scene() -> void: func _change_scene() -> void:
if targetSceneName.is_empty(): if targetSceneName.is_empty():

View File

@@ -12,6 +12,8 @@ const CAMERA_LIMIT_LEFT: int = -1280
const CAMERA_LIMIT_TOP: int = -960 const CAMERA_LIMIT_TOP: int = -960
const CAMERA_LIMIT_RIGHT: int = 1280 const CAMERA_LIMIT_RIGHT: int = 1280
const CAMERA_LIMIT_BOTTOM: int = 960 const CAMERA_LIMIT_BOTTOM: int = 960
const WELCOME_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/welcome_dialog.tscn")
const WELCOME_DIALOG_NAME: String = "WelcomeDialog"
@onready var player: PlayerController = $YSortWorld/Characters/Players/Player @onready var player: PlayerController = $YSortWorld/Characters/Players/Player
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D @onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
@@ -20,11 +22,35 @@ const CAMERA_LIMIT_BOTTOM: int = 960
func _ready() -> void: func _ready() -> void:
_apply_spawn_point() _apply_spawn_point()
_configure_camera() _configure_camera()
var chatManager := get_node_or_null("/root/ChatManager") _discover_destination()
if chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode")): call_deferred("_show_registration_welcome")
player.set_spectator_mode(true)
func _show_registration_welcome() -> void:
var auth_manager: Node = get_node_or_null("/root/AuthManager")
if auth_manager == null or not auth_manager.has_method("consume_registration_welcome"):
return
if not bool(auth_manager.call("consume_registration_welcome")):
return
var root: Window = get_tree().root
if root.has_node(WELCOME_DIALOG_NAME):
return
var dialog: CanvasLayer = WELCOME_DIALOG_SCENE.instantiate() as CanvasLayer
if dialog == null:
return
dialog.name = WELCOME_DIALOG_NAME
root.add_child(dialog)
func _discover_destination() -> void:
var destination_id := SceneManager.get_next_destination_id()
if destination_id.is_empty():
destination_id = "square_center"
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("discover_destination"):
socialManager.call("discover_destination", destination_id)
func _apply_spawn_point() -> void: func _apply_spawn_point() -> void:
if player.has_scene_position_override:
return
var spawnName: String = SceneManager.get_next_spawn_name() var spawnName: String = SceneManager.get_next_spawn_name()
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
var marker := $Markers.get_node_or_null(markerName) as Marker2D var marker := $Markers.get_node_or_null(markerName) as Marker2D

View File

@@ -38,12 +38,23 @@ func _ready() -> void:
_ensure_mall_entrance_area() _ensure_mall_entrance_area()
EventSystem.connect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self) EventSystem.connect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
EventSystem.connect_event(EventNames.MALL_CLOSED, _on_mall_closed, self) EventSystem.connect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
_discover_destination()
func _discover_destination() -> void:
var destination_id := SceneManager.get_next_destination_id()
if destination_id.is_empty():
destination_id = "work_entrance"
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("discover_destination"):
socialManager.call("discover_destination", destination_id)
func _exit_tree() -> void: func _exit_tree() -> void:
EventSystem.disconnect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self) EventSystem.disconnect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
EventSystem.disconnect_event(EventNames.MALL_CLOSED, _on_mall_closed, self) EventSystem.disconnect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
func _apply_spawn_point() -> void: func _apply_spawn_point() -> void:
if player.has_scene_position_override:
return
var spawnName: String = SceneManager.get_next_spawn_name() var spawnName: String = SceneManager.get_next_spawn_name()
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
var marker := $Markers.get_node_or_null(markerName) as Marker2D var marker := $Markers.get_node_or_null(markerName) as Marker2D
@@ -112,15 +123,14 @@ func _ensure_mall_entrance_area() -> void:
func _setup_mall_entrance_trigger(entrance: Area2D) -> void: func _setup_mall_entrance_trigger(entrance: Area2D) -> void:
_mallEntranceArea = entrance _mallEntranceArea = entrance
_mallEntranceArea.collision_mask = PLAYER_COLLISION_LAYER _mallEntranceArea.collision_mask = 0
if not _mallEntranceArea.body_entered.is_connected(_on_mall_entrance_body_entered):
_mallEntranceArea.body_entered.connect(_on_mall_entrance_body_entered)
func _on_object_interacted(data: Dictionary) -> void: func _on_object_interacted(data: Dictionary) -> void:
var buildingId := str(data.get("buildingId", "")) var buildingId := str(data.get("buildingId", ""))
if buildingId.is_empty(): if buildingId.is_empty():
return return
if buildingId == "whale_super_mall": if buildingId == "whale_super_mall":
_enter_mall()
return return
if buildingId == "virtual_whale_recruitment_board": if buildingId == "virtual_whale_recruitment_board":
_open_course_board() _open_course_board()
@@ -142,9 +152,7 @@ func _open_course_board() -> void:
_courseBoardPanel.call("show_panel") _courseBoardPanel.call("show_panel")
func _on_mall_entrance_body_entered(body: Node2D) -> void: func _on_mall_entrance_body_entered(body: Node2D) -> void:
if body != player or not _mallCanEnter: return
return
_enter_mall()
func _enter_mall() -> void: func _enter_mall() -> void:
if _isInsideMall: if _isInsideMall:

View File

@@ -12,10 +12,23 @@ const INTERACTION_COLLISION_LAYER: int = 2
@export var buildingId: String = "" @export var buildingId: String = ""
@export var buildingTitle: String = "" @export var buildingTitle: String = ""
@export var buildingRole: String = "" @export var buildingRole: String = ""
@export var interactionDistance: float = 150.0
func _ready() -> void: func _ready() -> void:
collision_layer = INTERACTION_COLLISION_LAYER collision_layer = INTERACTION_COLLISION_LAYER
collision_mask = 0 collision_mask = 0
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_distance = interactionDistance
add_child(interactable)
func is_interaction_active(_component: InteractableComponent) -> bool:
return not buildingId.strip_edges().is_empty()
func build_interaction_actions(_component: InteractableComponent, _player: Node2D) -> Array[InteractionAction]:
var actions: Array[InteractionAction] = []
var title: String = buildingTitle if not buildingTitle.is_empty() else "设施"
actions.append(InteractionAction.create("building:%s" % buildingId, "使用 %s" % title, 30, Callable(self, "interact")))
return actions
func interact() -> void: func interact() -> void:
var payload := { var payload := {

View File

@@ -1,7 +1,7 @@
[gd_scene format=3 uid="uid://c2flhkqk5icab"] [gd_scene format=3 uid="uid://c2flhkqk5icab"]
[ext_resource type="Script" uid="uid://ccesagjisqodl" path="res://scenes/Maps/CafeInterior.gd" id="1_script"] [ext_resource type="Script" uid="uid://ccesagjisqodl" path="res://scenes/Maps/CafeInterior.gd" id="1_script"]
[ext_resource type="Texture2D" path="res://assets/maps/cafe/whale_cafe_large_service_hall_base_v4.png" id="2_map"] [ext_resource type="Texture2D" path="res://assets/maps/cafe/cafe_service_hall_base_v1.png" id="2_map"]
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="3_player"] [ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="3_player"]
[ext_resource type="PackedScene" path="res://scenes/ui/ChatUI.tscn" id="4_chatui"] [ext_resource type="PackedScene" path="res://scenes/ui/ChatUI.tscn" id="4_chatui"]
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="5_playerhud"] [ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="5_playerhud"]
@@ -146,9 +146,6 @@ disabled = true
[node name="RemotePlayers" type="Node2D" parent="YSortWorld/Characters/Players" unique_id=2071839484] [node name="RemotePlayers" type="Node2D" parent="YSortWorld/Characters/Players" unique_id=2071839484]
y_sort_enabled = true y_sort_enabled = true
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters"]
y_sort_enabled = true
[node name="CafeWhaleBaristaNpc" parent="YSortWorld/Characters" unique_id=1188569491 instance=ExtResource("8_barista_npc")] [node name="CafeWhaleBaristaNpc" parent="YSortWorld/Characters" unique_id=1188569491 instance=ExtResource("8_barista_npc")]
position = Vector2(-472, -291) position = Vector2(-472, -291)

View File

@@ -1,4 +1,4 @@
[gd_scene load_steps=12 format=4] [gd_scene load_steps=13 format=4]
[ext_resource type="Script" path="res://scenes/Maps/PersonalSpace.gd" id="1_personal_space"] [ext_resource type="Script" path="res://scenes/Maps/PersonalSpace.gd" id="1_personal_space"]
[ext_resource type="Texture2D" path="res://assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png" id="2_room_base"] [ext_resource type="Texture2D" path="res://assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png" id="2_room_base"]
@@ -8,6 +8,7 @@
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="6_playerhud"] [ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="6_playerhud"]
[ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="7_friendpanel"] [ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="7_friendpanel"]
[ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="8_settingspanel"] [ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="8_settingspanel"]
[ext_resource type="PackedScene" path="res://scenes/ui/MapPanel.tscn" id="9_mappanel"]
[sub_resource type="RectangleShape2D" id="Shape_TopWall"] [sub_resource type="RectangleShape2D" id="Shape_TopWall"]
size = Vector2(882, 76) size = Vector2(882, 76)
@@ -35,6 +36,8 @@ layer = 10
[node name="SettingsPanel" parent="UILayer" instance=ExtResource("8_settingspanel")] [node name="SettingsPanel" parent="UILayer" instance=ExtResource("8_settingspanel")]
[node name="MapPanel" parent="UILayer" instance=ExtResource("9_mappanel")]
[node name="StageBackdrop" type="ColorRect" parent="."] [node name="StageBackdrop" type="ColorRect" parent="."]
z_index = -200 z_index = -200
offset_left = -2000.0 offset_left = -2000.0

View File

@@ -12,7 +12,9 @@
[ext_resource type="Texture2D" uid="uid://c8ubnxut51f0r" path="res://assets/maps/square/v1/props/bottom_entrance_left_task_props_v2_hd_clean.png" id="12_lefttask"] [ext_resource type="Texture2D" uid="uid://c8ubnxut51f0r" path="res://assets/maps/square/v1/props/bottom_entrance_left_task_props_v2_hd_clean.png" id="12_lefttask"]
[ext_resource type="Texture2D" uid="uid://b4bvp0r5hua1k" path="res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png" id="13_rightsvc"] [ext_resource type="Texture2D" uid="uid://b4bvp0r5hua1k" path="res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png" id="13_rightsvc"]
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="14_u1t8b"] [ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="14_u1t8b"]
[ext_resource type="PackedScene" path="res://scenes/characters/npc.tscn" id="17_qog0y"]
[ext_resource type="Texture2D" uid="uid://c0kgmsaach8wh" path="res://assets/maps/square/v1/props/foliage_v13_concept_broadleaf_single/broadleaf_tree_v13_clean.png" id="19_edt5w"] [ext_resource type="Texture2D" uid="uid://c0kgmsaach8wh" path="res://assets/maps/square/v1/props/foliage_v13_concept_broadleaf_single/broadleaf_tree_v13_clean.png" id="19_edt5w"]
[ext_resource type="PackedScene" path="res://scenes/characters/crayfish_npc.tscn" id="20_lemld"]
[ext_resource type="Texture2D" uid="uid://q3vldth6fyvw" path="res://assets/maps/square/v1/props/foliage_v14_concept_evergreen_single/evergreen_tree_v14_clean.png" id="20_rixdf"] [ext_resource type="Texture2D" uid="uid://q3vldth6fyvw" path="res://assets/maps/square/v1/props/foliage_v14_concept_evergreen_single/evergreen_tree_v14_clean.png" id="20_rixdf"]
[ext_resource type="Texture2D" uid="uid://cbqj51mpelsr4" path="res://assets/maps/square/v1/props/top_fence_v1/top_fence_v1_clean.png" id="22_jagxe"] [ext_resource type="Texture2D" uid="uid://cbqj51mpelsr4" path="res://assets/maps/square/v1/props/top_fence_v1/top_fence_v1_clean.png" id="22_jagxe"]
[ext_resource type="Texture2D" uid="uid://csxvgs1v5puvt" path="res://assets/maps/square/v1/props/guild_left_tree_flowers_v1/guild_left_tree_flowers_v1_clean.png" id="24_8hf0h"] [ext_resource type="Texture2D" uid="uid://csxvgs1v5puvt" path="res://assets/maps/square/v1/props/guild_left_tree_flowers_v1/guild_left_tree_flowers_v1_clean.png" id="24_8hf0h"]
@@ -33,7 +35,6 @@
[ext_resource type="Script" uid="uid://dxupavbgcw3tw" path="res://scenes/Maps/MapMultiplayerController.gd" id="38_multiplayer"] [ext_resource type="Script" uid="uid://dxupavbgcw3tw" path="res://scenes/Maps/MapMultiplayerController.gd" id="38_multiplayer"]
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="39_playerhud"] [ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="39_playerhud"]
[ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="40_friendpanel"] [ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="40_friendpanel"]
[ext_resource type="PackedScene" path="res://scenes/ui/WorldBulletinPanel.tscn" id="45_world_bulletin"]
[ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="41_settingspanel"] [ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="41_settingspanel"]
[ext_resource type="Script" uid="uid://dr6uk7m4fsr4l" path="res://scenes/Maps/ScenePortal.gd" id="42_sceneportal"] [ext_resource type="Script" uid="uid://dr6uk7m4fsr4l" path="res://scenes/Maps/ScenePortal.gd" id="42_sceneportal"]
[ext_resource type="Script" uid="uid://biu0igl6133q5" path="res://scenes/Maps/Square.gd" id="43_square"] [ext_resource type="Script" uid="uid://biu0igl6133q5" path="res://scenes/Maps/Square.gd" id="43_square"]
@@ -116,9 +117,6 @@ size = Vector2(180, 160)
[sub_resource type="RectangleShape2D" id="Shape_NoticeBoard"] [sub_resource type="RectangleShape2D" id="Shape_NoticeBoard"]
size = Vector2(123, 88) size = Vector2(123, 88)
[sub_resource type="RectangleShape2D" id="Shape_WelcomeBoard"]
size = Vector2(97.5, 95)
[node name="Square" type="Node2D" unique_id=1071968945] [node name="Square" type="Node2D" unique_id=1071968945]
script = ExtResource("43_square") script = ExtResource("43_square")
@@ -133,9 +131,6 @@ layer = 10
[node name="FriendListPanel" parent="UILayer" unique_id=914934559 instance=ExtResource("40_friendpanel")] [node name="FriendListPanel" parent="UILayer" unique_id=914934559 instance=ExtResource("40_friendpanel")]
[node name="WorldBulletinPanel" parent="UILayer" instance=ExtResource("45_world_bulletin")]
z_index = 1000
[node name="SettingsPanel" parent="UILayer" unique_id=588068795 instance=ExtResource("41_settingspanel")] [node name="SettingsPanel" parent="UILayer" unique_id=588068795 instance=ExtResource("41_settingspanel")]
[node name="HDGrassBase" type="Sprite2D" parent="." unique_id=1853750890] [node name="HDGrassBase" type="Sprite2D" parent="." unique_id=1853750890]
@@ -387,6 +382,20 @@ y_sort_enabled = true
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters" unique_id=1114010880] [node name="Npcs" type="Node2D" parent="YSortWorld/Characters" unique_id=1114010880]
y_sort_enabled = true y_sort_enabled = true
[node name="GuildReceptionNpc" parent="YSortWorld/Characters/Npcs" unique_id=442345899 instance=ExtResource("17_qog0y")]
texture_filter = 1
position = Vector2(-199, -515)
npcName = "范鲸晶"
dialogue = "欢迎来到 WhaleTown 广场。上方是公会大厅,南边是小镇入口,左边通往码头。"
showNameplate = true
nameplateOffsetY = -72.0
[node name="DockGuideNpc" parent="YSortWorld/Characters/Npcs" unique_id=626857126 instance=ExtResource("20_lemld")]
texture_filter = 1
position = Vector2(-825, 437)
showNameplate = true
nameplateOffsetY = -66.0
[node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1810372571] [node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1810372571]
script = ExtResource("38_multiplayer") script = ExtResource("38_multiplayer")
@@ -1225,15 +1234,6 @@ script = ExtResource("28_notice")
position = Vector2(-59.5, 19) position = Vector2(-59.5, 19)
shape = SubResource("Shape_NoticeBoard") shape = SubResource("Shape_NoticeBoard")
[node name="WelcomeBoardArea" type="Area2D" parent="InteractionAreas" unique_id=1460340742]
position = Vector2(707.5, 575)
collision_layer = 0
collision_mask = 0
[node name="CollisionShape2D" type="CollisionShape2D" parent="InteractionAreas/WelcomeBoardArea" unique_id=123982451]
position = Vector2(-159.25, -10.5)
shape = SubResource("Shape_WelcomeBoard")
[node name="BlockoutDebug" type="Node2D" parent="." unique_id=932099081] [node name="BlockoutDebug" type="Node2D" parent="." unique_id=932099081]
visible = false visible = false

View File

@@ -241,9 +241,6 @@ position = Vector2(0, 921)
y_sort_enabled = true y_sort_enabled = true
position = Vector2(0, -1) position = Vector2(0, -1)
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters"]
y_sort_enabled = true
[node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1024843959] [node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1024843959]
script = ExtResource("26_multiplayer") script = ExtResource("26_multiplayer")
map_id = "work_zone" map_id = "work_zone"

View File

@@ -19,9 +19,22 @@ class_name CafeCompanionTarget
var _lastClickMsec: int = 0 var _lastClickMsec: int = 0
func _ready() -> void: func _ready() -> void:
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_distance = 160.0
add_child(interactable)
input_pickable = true input_pickable = true
set_process_unhandled_input(true) set_process_unhandled_input(true)
func is_interaction_active(_component: InteractableComponent) -> bool:
return not servicePointId.strip_edges().is_empty() and not companionId.strip_edges().is_empty()
func build_interaction_actions(_component: InteractableComponent, _player: Node2D) -> Array[InteractionAction]:
var actions: Array[InteractionAction] = []
var display_name: String = _resolved_persona_name()
var action_id: String = "cafe_companion:%s" % servicePointId
actions.append(InteractionAction.create(action_id, "%s 交流" % display_name, 45, Callable(self, "_try_emit_target_selected")))
return actions
func _input_event(_viewport: Viewport, event: InputEvent, _shapeIdx: int) -> void: func _input_event(_viewport: Viewport, event: InputEvent, _shapeIdx: int) -> void:
if not (event is InputEventMouseButton): if not (event is InputEventMouseButton):
return return

View File

@@ -8,57 +8,63 @@ class_name NPCController
# 主要功能: # 主要功能:
# - 播放 NPC 待机动画 # - 播放 NPC 待机动画
# - 响应玩家射线交互 # - 响应玩家射线交互
# - 打开 NPC 对话框并广播 NPC 对话事件 # - 触发聊天气泡与 NPC 对话事件
# #
# 依赖: EventSystem, EventNames, ChatUI # 依赖: EventSystem, EventNames, ChatBubble
# 作者: Codex # 作者: Codex
# 创建时间: 2026-03-10 # 创建时间: 2026-03-10
# ============================================================================ # ============================================================================
signal interaction_happened(text: String) signal interaction_happened(text: String)
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
const CHAT_BUBBLE_LAYER_NAME: String = "WorldChatBubbleLayer"
const CHAT_BUBBLE_TARGET_OFFSET: Vector2 = Vector2(0, -84)
const NPC_TALKED_EVENT: String = "npc_talked" const NPC_TALKED_EVENT: String = "npc_talked"
const NPC_COLLISION_LAYER: int = 2
const NPC_COLLISION_MASK: int = 1
const WORLD_SORT_Z_OFFSET: int = 2048 const WORLD_SORT_Z_OFFSET: int = 2048
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const NAMEPLATE_RENDER_SCALE: float = 0.5 const NAMEPLATE_RENDER_SCALE: float = 0.5
const NAMEPLATE_FONT_SIZE: int = 16 const NAMEPLATE_FONT_SIZE: int = 24
const NAMEPLATE_VISUAL_HEIGHT: int = 15 const NAMEPLATE_VISUAL_HEIGHT: int = 22
const NAMEPLATE_VISUAL_MIN_WIDTH: int = 64 const NAMEPLATE_VISUAL_MIN_WIDTH: int = 76
const NAMEPLATE_VISUAL_MAX_WIDTH: int = 100 const NAMEPLATE_VISUAL_MAX_WIDTH: int = 118
const NAMEPLATE_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2") const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
const NAMEPLATE_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
@export var npcName: String = "NPC" @export var npcName: String = "NPC"
@export_multiline var dialogue: String = "欢迎来到WhaleTown我是镇长范鲸晶" @export_multiline var dialogue: String = "欢迎来到WhaleTown我是镇长范鲸晶"
@export var showNameplate: bool = false @export var showNameplate: bool = false
@export var nameplateOffsetY: float = -112.0 @export var nameplateOffsetY: float = -112.0
@export var interactionDistance: float = 150.0
@onready var animation_player: AnimationPlayer = $AnimationPlayer @onready var animation_player: AnimationPlayer = $AnimationPlayer
var _nameplate: Label var _nameplate: Label
var _nicknameFont: FontFile
func _ready() -> void: func _ready() -> void:
if get_node_or_null("CafeCompanionTarget") == null:
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_id = "npc:%s" % str(get_path())
interactable.interaction_title = "%s 交谈" % npcName
interactable.interaction_priority = 40
interactable.interaction_distance = interactionDistance
interactable.activation_method = &"interact"
add_child(interactable)
# 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。 # 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
if animation_player.has_animation("idle"): if animation_player.has_animation("idle"):
animation_player.play("idle") animation_player.play("idle")
_update_nameplate() _update_nameplate()
_update_world_sort_z() _update_world_sort_z()
# NPC 单独占用交互层;玩家的物理掩码同时包含地图层和 NPC 层 # 保持 NPC 可被玩家射线与角色碰撞识别
collision_layer = NPC_COLLISION_LAYER collision_layer = 3
collision_mask = NPC_COLLISION_MASK collision_mask = 3
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
_update_world_sort_z() _update_world_sort_z()
# 处理玩家交互,打开统一对话框并向全局事件系统广播。 # 处理玩家交互,展示气泡并向全局事件系统广播。
func interact() -> void: func interact() -> void:
var chatUi := get_tree().root.find_child("ChatUI", true, false) show_bubble(dialogue)
if chatUi != null and chatUi.has_method("show_npc_dialogue"):
chatUi.call("show_npc_dialogue", npcName, dialogue)
var eventSystem: Node = get_node_or_null("/root/EventSystem") var eventSystem: Node = get_node_or_null("/root/EventSystem")
if eventSystem != null: if eventSystem != null:
eventSystem.call("emit_event", NPC_TALKED_EVENT, { eventSystem.call("emit_event", NPC_TALKED_EVENT, {
@@ -66,7 +72,32 @@ func interact() -> void:
"npc_name": npcName, "npc_name": npcName,
"dialogue": dialogue "dialogue": dialogue
}) })
interaction_happened.emit(dialogue) interaction_happened.emit(dialogue)
# 在 NPC 头顶生成一次性聊天气泡。
#
# 参数:
# text: String - 要展示的对话内容
func show_bubble(text: String) -> void:
var bubble: Control = CHAT_BUBBLE_SCENE.instantiate() as Control
if bubble == null:
return
var bubbleLayer: CanvasLayer = _get_chat_bubble_layer()
bubbleLayer.add_child(bubble)
if bubble.has_method("set_text"):
bubble.call("set_text", text, self, CHAT_BUBBLE_TARGET_OFFSET)
func _get_chat_bubble_layer() -> CanvasLayer:
var root: Window = get_tree().root
var layer: CanvasLayer = root.get_node_or_null(CHAT_BUBBLE_LAYER_NAME) as CanvasLayer
if layer != null:
return layer
layer = CanvasLayer.new()
layer.name = CHAT_BUBBLE_LAYER_NAME
layer.layer = 20
root.add_child(layer)
return layer
func _update_world_sort_z() -> void: func _update_world_sort_z() -> void:
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y)) z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
@@ -101,20 +132,34 @@ func _update_nameplate() -> void:
_nameplate.clip_text = true _nameplate.clip_text = true
_nameplate.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _nameplate.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameplate.mouse_filter = Control.MOUSE_FILTER_IGNORE _nameplate.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameplate.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0)) _nameplate.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
_nameplate.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98)) _nameplate.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
_nameplate.add_theme_constant_override("outline_size", 3) _nameplate.add_theme_constant_override("shadow_offset_x", 0)
_nameplate.add_theme_font_override("font", _get_nickname_font()) _nameplate.add_theme_constant_override("shadow_offset_y", 1)
_nameplate.add_theme_font_size_override("font_size", NAMEPLATE_FONT_SIZE) _nameplate.add_theme_font_size_override("font_size", NAMEPLATE_FONT_SIZE)
_nameplate.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) _nameplate.add_theme_stylebox_override("normal", _create_nameplate_style())
func _nameplate_visual_width(displayName: String) -> int: func _nameplate_visual_width(displayName: String) -> int:
var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAMEPLATE_FONT_SIZE).x var estimatedWidth := displayName.length() * NAMEPLATE_VISUAL_CHAR_WIDTH + 28
var visualWidth := ceili(measuredWidth * NAMEPLATE_RENDER_SCALE + 20.0) return clampi(estimatedWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH)
return clampi(visualWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH)
func _get_nickname_font() -> FontFile: func _create_nameplate_style() -> StyleBoxFlat:
if _nicknameFont == null: var style := StyleBoxFlat.new()
_nicknameFont = NAMEPLATE_FONT_ZH.duplicate() as FontFile style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
_nicknameFont.fallbacks = [NAMEPLATE_FONT_LATIN] style.border_color = Color(0.18, 0.34, 0.38, 0.92)
return _nicknameFont style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 12
style.corner_radius_top_right = 12
style.corner_radius_bottom_left = 12
style.corner_radius_bottom_right = 12
style.content_margin_left = 12
style.content_margin_top = 4
style.content_margin_right = 12
style.content_margin_bottom = 4
style.shadow_color = Color(0.05, 0.08, 0.09, 0.18)
style.shadow_size = 4
style.shadow_offset = Vector2(0, 2)
return style

View File

@@ -1,510 +0,0 @@
extends NPCController
class_name NetworkNpc
var npcId: String = ""
var stateVersion: int = -1
var worldState: String = "idle"
var publicIntention: String = ""
var dailyGoal: String = ""
var planSource: String = "fallback"
var currentActivity: Dictionary = {}
var movementState: String = "idle"
var activeAction: Dictionary = {}
var _actionStartedLocalMsec: int = 0
var _actionCompletesLocalMsec: int = 0
const DIRECTION_ROWS: Dictionary = {"down": 0, "up": 1, "right": 2, "left": 3}
const WALK_ANIMATION_LENGTH: float = 0.8
const IDLE_ANIMATION_LENGTH: float = 1.2
const ACTIVITY_ANIMATION_LENGTH: float = 1.2
const COLLISION_MOTION_SAMPLE_DISTANCE: float = 8.0
const RESEARCHER_TEXTURE: Texture2D = preload("res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png")
const MAYOR_TEXTURE: Texture2D = preload("res://assets/characters/npc_286_241.png")
const CRAYFISH_TEXTURE: Texture2D = preload("res://assets/characters/crayfish_npc_256_256.png")
const NIULAI_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png")
const NIULAI_IDLE_DOWN_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png")
const NIULAI_IDLE_UP_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png")
const NIULAI_IDLE_RIGHT_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png")
const NIULAI_IDLE_LEFT_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png")
var lastDirection: String = "down"
var visualScene: String = ""
var spriteColumns: int = 8
var _lastBlockedActionId: String = ""
var _baseSpritePosition: Vector2 = Vector2.ZERO
var _niulaiIdleTexture: Texture2D
var _baseSpriteScale: Vector2 = Vector2.ONE
var _baseSpriteRotation: float = 0.0
func _ready() -> void:
super._ready()
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("connect_event", EventNames.NPC_CONVERSATION, _on_npc_conversation, self)
if animation_player != null:
animation_player.stop()
var sharedLibrary := animation_player.get_animation_library("")
if sharedLibrary != null:
animation_player.remove_animation_library("")
animation_player.add_animation_library("", sharedLibrary.duplicate(true))
_configure_visual("classic_whale")
func _exit_tree() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("disconnect_event", EventNames.NPC_CONVERSATION, _on_npc_conversation, self)
func interact() -> void:
# 第一次交互和后续交流都进入同一个居中对话框。
var chatUi := get_tree().root.find_child("ChatUI", true, false)
if chatUi != null and chatUi.has_method("start_npc_whisper"):
chatUi.call("start_npc_whisper", npcId, npcName, dialogue)
func _on_npc_conversation(data: Dictionary) -> void:
# 环境中的 NPC 对话只更新下次交互时的开场白,不再弹出世界气泡。
var linesValue: Variant = data.get("lines", [])
if not (linesValue is Array):
return
for lineValue: Variant in (linesValue as Array):
if not (lineValue is Dictionary):
continue
var line: Dictionary = lineValue
if str(line.get("speaker_npc_id", "")) != npcId:
continue
var text := str(line.get("text", "")).strip_edges()
if not text.is_empty():
dialogue = text
func _process(delta: float) -> void:
if activeAction.is_empty():
return
var actionKind := str(activeAction.get("kind", "walk"))
if actionKind == "transition":
return
var now := Time.get_ticks_msec()
var duration: int = maxi(1, _actionCompletesLocalMsec - _actionStartedLocalMsec)
var progress := clampf(float(now - _actionStartedLocalMsec) / float(duration), 0.0, 1.0)
var from := Vector2(float(activeAction.get("from_x", global_position.x)), float(activeAction.get("from_y", global_position.y)))
var to := Vector2(float(activeAction.get("to_x", global_position.x)), float(activeAction.get("to_y", global_position.y)))
var reachedAuthoritativePosition := _move_to_authoritative_position(from.lerp(to, progress))
_update_world_sort_z()
if progress >= 1.0:
if reachedAuthoritativePosition and global_position.distance_to(to) <= 0.5:
global_position = to
activeAction = {}
movementState = "idle"
_play_idle_animation()
func _play_idle_animation() -> void:
if animation_player != null:
if visualScene == "niulai_ambassador" and has_node("Sprite2D"):
_set_niulai_idle_texture("down")
animation_player.play("idle_down")
else:
animation_player.play("idle")
func _play_activity_animation(activityKind: String = "") -> void:
if animation_player == null:
return
if visualScene == "niulai_ambassador" and has_node("Sprite2D"):
_set_niulai_idle_texture("down")
animation_player.play("idle_down")
return
var normalized := activityKind.strip_edges().to_lower()
var animationPrefix := "activity_work"
if normalized == "socialize" or normalized == "share":
animationPrefix = "activity_talk"
var animationName := "%s_%s" % [animationPrefix, lastDirection]
if animation_player.has_animation(animationName):
animation_player.play(animationName)
else:
_play_idle_animation()
func _set_niulai_idle_texture(direction: String) -> void:
var idleTexture: Texture2D = NIULAI_IDLE_DOWN_TEXTURE
match direction:
"up": idleTexture = NIULAI_IDLE_UP_TEXTURE
"right": idleTexture = NIULAI_IDLE_RIGHT_TEXTURE
"left": idleTexture = NIULAI_IDLE_LEFT_TEXTURE
$Sprite2D.texture = idleTexture
$Sprite2D.hframes = 4
$Sprite2D.vframes = 1
func _configure_visual(sceneKey: String) -> void:
if not has_node("Sprite2D"):
return
var normalized := sceneKey.strip_edges()
if normalized.is_empty():
normalized = "classic_whale"
if visualScene == normalized:
return
visualScene = normalized
var sprite := $Sprite2D as Sprite2D
# Network NPC sheets are authored for crisp 2D rendering. Set the filter on
# the actual Sprite2D as well as the parent, since imported textures may
# otherwise fall back to the renderer's linear sampler on Web.
sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
sprite.texture_repeat = CanvasItem.TEXTURE_REPEAT_DISABLED
match visualScene:
"town_mayor":
sprite.texture = MAYOR_TEXTURE
spriteColumns = 4
sprite.position = Vector2.ZERO
sprite.scale = Vector2(0.72, 0.72)
# The mayor frames have transparent padding above the propeller, so the
# name must follow the visible silhouette instead of the frame boundary.
nameplateOffsetY = -52.0
_set_collision_size(Vector2(48, 24))
"dock_crayfish":
sprite.texture = CRAYFISH_TEXTURE
spriteColumns = 4
sprite.position = Vector2.ZERO
sprite.scale = Vector2(0.65, 0.65)
nameplateOffsetY = -66.0
_set_collision_size(Vector2(44, 22))
"niulai_ambassador":
sprite.texture = NIULAI_TEXTURE
spriteColumns = 4
# The generated sheet includes feet. Align the sprite's feet with the
# network node origin used for sorting and collision.
# Its source frames are 384x256 (larger than the 160x160 sheets used
# by the other NPCs), so use a smaller display scale while preserving
# the same ground anchor and a slightly broader physical footprint.
sprite.position = Vector2(0, -45)
sprite.scale = Vector2(0.4, 0.4)
nameplateOffsetY = -105.0
_set_collision_size(Vector2(52, 24))
_:
sprite.texture = RESEARCHER_TEXTURE
spriteColumns = 8
sprite.position = Vector2(0, -29)
sprite.scale = Vector2(0.5, 0.5)
nameplateOffsetY = -72.0
_set_collision_size(Vector2(48, 24))
_baseSpritePosition = sprite.position
_baseSpriteScale = sprite.scale
_baseSpriteRotation = sprite.rotation
sprite.hframes = spriteColumns
sprite.vframes = 4
_configure_directional_animations()
_play_idle_animation()
_update_nameplate()
func _set_collision_size(value: Vector2) -> void:
var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D
if collision != null and collision.shape is RectangleShape2D:
collision.shape = collision.shape.duplicate()
(collision.shape as RectangleShape2D).size = value
func _direction_row(direction: String) -> int:
if visualScene == "niulai_ambassador":
return int({"down": 0, "up": 1, "right": 2, "left": 3}.get(direction, 0))
if spriteColumns == 4:
return int({"down": 0, "right": 1, "up": 2, "left": 3}.get(direction, 0))
return int(DIRECTION_ROWS.get(direction, 0))
func _configure_directional_animations() -> void:
if animation_player == null or not has_node("Sprite2D"):
return
var sprite := $Sprite2D as Sprite2D
var library := animation_player.get_animation_library("")
if library == null:
library = AnimationLibrary.new()
animation_player.add_animation_library("", library)
if library.has_animation("idle"):
library.remove_animation("idle")
var idle := Animation.new()
idle.resource_name = "idle"
idle.length = IDLE_ANIMATION_LENGTH
idle.loop_mode = Animation.LOOP_LINEAR
var idleTrack := idle.add_track(Animation.TYPE_VALUE)
idle.track_set_path(idleTrack, NodePath("Sprite2D:frame"))
idle.value_track_set_update_mode(idleTrack, Animation.UPDATE_DISCRETE)
var idleFrames: Array[int] = [0, 1, 0, 2, 0]
if visualScene == "niulai_ambassador":
sprite.texture = NIULAI_IDLE_DOWN_TEXTURE
idleFrames = [0, 1, 2, 3, 2, 1, 0]
if spriteColumns >= 8:
idleFrames = [0, 2, 0, 4, 0]
for frameIndex in range(idleFrames.size()):
var frameTime := idle.length * float(frameIndex) / float(idleFrames.size() - 1)
idle.track_insert_key(idleTrack, frameTime, idleFrames[frameIndex])
var idlePositionTrack := idle.add_track(Animation.TYPE_VALUE)
idle.track_set_path(idlePositionTrack, NodePath("Sprite2D:position"))
idle.track_insert_key(idlePositionTrack, 0.0, _baseSpritePosition)
idle.track_insert_key(idlePositionTrack, idle.length * 0.25, _baseSpritePosition + Vector2(0.0, -0.75))
idle.track_insert_key(idlePositionTrack, idle.length * 0.5, _baseSpritePosition)
idle.track_insert_key(idlePositionTrack, idle.length * 0.75, _baseSpritePosition + Vector2(0.0, -0.35))
idle.track_insert_key(idlePositionTrack, idle.length, _baseSpritePosition)
library.add_animation("idle", idle)
if visualScene == "niulai_ambassador":
sprite.texture = NIULAI_TEXTURE
sprite.hframes = 4
sprite.vframes = 4
for direction in DIRECTION_ROWS.keys():
var row := _direction_row(str(direction))
if visualScene == "niulai_ambassador":
var idleTextureForDirection: Texture2D = NIULAI_IDLE_DOWN_TEXTURE
match str(direction):
"up": idleTextureForDirection = NIULAI_IDLE_UP_TEXTURE
"right": idleTextureForDirection = NIULAI_IDLE_RIGHT_TEXTURE
"left": idleTextureForDirection = NIULAI_IDLE_LEFT_TEXTURE
sprite.texture = idleTextureForDirection
sprite.hframes = 4
sprite.vframes = 1
var niulaiIdleName := "idle_%s" % direction
if library.has_animation(niulaiIdleName): library.remove_animation(niulaiIdleName)
var niulaiIdle := Animation.new()
niulaiIdle.resource_name = niulaiIdleName
niulaiIdle.length = IDLE_ANIMATION_LENGTH
niulaiIdle.loop_mode = Animation.LOOP_LINEAR
var niulaiIdleTrack := niulaiIdle.add_track(Animation.TYPE_VALUE)
niulaiIdle.track_set_path(niulaiIdleTrack, NodePath("Sprite2D:frame"))
niulaiIdle.value_track_set_update_mode(niulaiIdleTrack, Animation.UPDATE_DISCRETE)
for niulaiFrameIndex in range(idleFrames.size()):
var niulaiFrameTime := niulaiIdle.length * float(niulaiFrameIndex) / float(idleFrames.size() - 1)
niulaiIdle.track_insert_key(niulaiIdleTrack, niulaiFrameTime, idleFrames[niulaiFrameIndex])
library.add_animation(niulaiIdleName, niulaiIdle)
var niulaiWalkName := "walk_%s" % direction
if library.has_animation(niulaiWalkName): library.remove_animation(niulaiWalkName)
var niulaiWalk := Animation.new()
niulaiWalk.resource_name = niulaiWalkName
niulaiWalk.length = WALK_ANIMATION_LENGTH
niulaiWalk.loop_mode = Animation.LOOP_LINEAR
var niulaiWalkTrack := niulaiWalk.add_track(Animation.TYPE_VALUE)
niulaiWalk.track_set_path(niulaiWalkTrack, NodePath("Sprite2D:frame"))
niulaiWalk.value_track_set_update_mode(niulaiWalkTrack, Animation.UPDATE_DISCRETE)
for column in range(4):
niulaiWalk.track_insert_key(
niulaiWalkTrack,
float(column) * WALK_ANIMATION_LENGTH / 4.0,
row * 4 + column,
)
library.add_animation(niulaiWalkName, niulaiWalk)
for staticPrefix in ["activity_work", "activity_talk"]:
var staticName := "%s_%s" % [staticPrefix, direction]
if library.has_animation(staticName): library.remove_animation(staticName)
var staticAnimation := Animation.new()
staticAnimation.resource_name = staticName
staticAnimation.length = 1.0
staticAnimation.loop_mode = Animation.LOOP_LINEAR
var staticTrack := staticAnimation.add_track(Animation.TYPE_VALUE)
staticAnimation.track_set_path(staticTrack, NodePath("Sprite2D:frame"))
staticAnimation.value_track_set_update_mode(staticTrack, Animation.UPDATE_DISCRETE)
staticAnimation.track_insert_key(staticTrack, 0.0, row * spriteColumns)
library.add_animation(staticName, staticAnimation)
continue
var walkName := "walk_%s" % direction
if library.has_animation(walkName): library.remove_animation(walkName)
var walk := Animation.new()
walk.length = WALK_ANIMATION_LENGTH
walk.loop_mode = Animation.LOOP_LINEAR
var walkTrack := walk.add_track(Animation.TYPE_VALUE)
walk.track_set_path(walkTrack, NodePath("Sprite2D:frame"))
walk.value_track_set_update_mode(walkTrack, Animation.UPDATE_DISCRETE)
for column in range(spriteColumns):
walk.track_insert_key(walkTrack, float(column) * WALK_ANIMATION_LENGTH / float(spriteColumns), row * spriteColumns + column)
library.add_animation(walkName, walk)
# Activity animations deliberately reuse the canonical character frame.
# Only the timing and a tiny pixel-scale body motion change, so every NPC
# keeps the exact same face, outfit, proportions, and palette while working.
for activityPrefix in ["activity_work", "activity_talk"]:
var activityName := "%s_%s" % [activityPrefix, direction]
if library.has_animation(activityName): library.remove_animation(activityName)
var activity := Animation.new()
activity.length = ACTIVITY_ANIMATION_LENGTH
activity.loop_mode = Animation.LOOP_LINEAR
var frameTrack := activity.add_track(Animation.TYPE_VALUE)
activity.track_set_path(frameTrack, NodePath("Sprite2D:frame"))
activity.value_track_set_update_mode(frameTrack, Animation.UPDATE_DISCRETE)
var motionFrames: Array[int] = [0, 1, 0, 2, 0]
if activityPrefix == "activity_talk":
motionFrames = [0, 2, 1, 3, 0]
if spriteColumns >= 8:
motionFrames = [0, 2, 0, 4, 0]
if activityPrefix == "activity_talk":
motionFrames = [0, 4, 2, 6, 0]
for frameIndex in range(motionFrames.size()):
var frameTime := ACTIVITY_ANIMATION_LENGTH * float(frameIndex) / float(motionFrames.size() - 1)
activity.track_insert_key(frameTrack, frameTime, row * spriteColumns + motionFrames[frameIndex])
var positionTrack := activity.add_track(Animation.TYPE_VALUE)
activity.track_set_path(positionTrack, NodePath("Sprite2D:position"))
var bobAmount := 1.0 if activityPrefix == "activity_work" else 1.5
activity.track_insert_key(positionTrack, 0.0, _baseSpritePosition)
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpritePosition + Vector2(0.0, -bobAmount))
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpritePosition)
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpritePosition + Vector2(0.0, -bobAmount * 0.5))
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpritePosition)
var rotationTrack := activity.add_track(Animation.TYPE_VALUE)
activity.track_set_path(rotationTrack, NodePath("Sprite2D:rotation"))
var tiltAmount := 0.018 if activityPrefix == "activity_work" else 0.028
activity.track_insert_key(rotationTrack, 0.0, _baseSpriteRotation)
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpriteRotation - tiltAmount)
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpriteRotation)
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpriteRotation + tiltAmount * 0.6)
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpriteRotation)
var scaleTrack := activity.add_track(Animation.TYPE_VALUE)
activity.track_set_path(scaleTrack, NodePath("Sprite2D:scale"))
var scaleAmount := 0.012 if activityPrefix == "activity_work" else 0.018
activity.track_insert_key(scaleTrack, 0.0, _baseSpriteScale)
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpriteScale * Vector2(1.0, 1.0 + scaleAmount))
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpriteScale)
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpriteScale * Vector2(1.0, 1.0 + scaleAmount * 0.5))
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpriteScale)
library.add_animation(activityName, activity)
func apply_snapshot(data: Dictionary) -> void:
var incomingVersion := int(data.get("version", 0))
if stateVersion > incomingVersion:
return
var isInitialSnapshot := stateVersion < 0
npcId = str(data.get("npc_id", data.get("npcId", npcId))).strip_edges()
_configure_visual(str(data.get("scene", "classic_whale")))
stateVersion = incomingVersion
npcName = str(data.get("name", npcName)).strip_edges()
worldState = str(data.get("state", "idle")).strip_edges()
publicIntention = str(data.get("public_intention", data.get("publicIntention", ""))).strip_edges()
dailyGoal = str(data.get("daily_goal", data.get("dailyGoal", ""))).strip_edges()
planSource = str(data.get("plan_source", data.get("planSource", "fallback"))).strip_edges()
var activityValue: Variant = data.get("current_activity", data.get("currentActivity", {}))
currentActivity = activityValue if activityValue is Dictionary else {}
dialogue = str(data.get("dialogue", _fallback_dialogue())).strip_edges()
movementState = str(data.get("movement_state", data.get("movementState", "idle"))).strip_edges()
var snapshotDirection := str(data.get("direction", lastDirection)).strip_edges().to_lower()
if DIRECTION_ROWS.has(snapshotDirection):
lastDirection = snapshotDirection
showNameplate = true
var snapshotPosition := Vector2(float(data.get("x", global_position.x)), float(data.get("y", global_position.y)))
if isInitialSnapshot:
_place_at_clear_position(snapshotPosition)
else:
_move_to_authoritative_position(snapshotPosition)
_update_nameplate()
_update_world_sort_z()
var snapshotAction: Variant = data.get("active_action", data.get("activeAction", {}))
if snapshotAction is Dictionary and not (snapshotAction as Dictionary).is_empty():
_apply_action(snapshotAction, int(data.get("server_now", 0)))
else:
activeAction = {}
if _is_activity_state():
_play_activity_animation(str(currentActivity.get("activityKind", currentActivity.get("activity_kind", ""))))
else:
_play_idle_animation()
func apply_action_started(data: Dictionary) -> void:
_apply_action(data.get("action", {}), int(data.get("server_now", data.get("serverNow", 0))))
func _is_activity_state() -> bool:
var normalizedState := worldState.strip_edges().to_lower()
if normalizedState in ["working", "talking", "performing", "acting", "socializing", "sharing"]:
return not currentActivity.is_empty() or normalizedState != "working"
return false
func apply_action_completed(data: Dictionary) -> void:
var action: Variant = data.get("action", {})
if not (action is Dictionary):
return
var completed: Dictionary = action
var actionKind := str(completed.get("kind", "walk"))
var incomingVersion := int(completed.get("version", 0))
if incomingVersion < stateVersion:
return
stateVersion = incomingVersion
if actionKind == "transition":
visible = false
else:
var completedPosition := Vector2(float(completed.get("to_x", completed.get("toX", global_position.x))), float(completed.get("to_y", completed.get("toY", global_position.y))))
if not _move_to_authoritative_position(completedPosition):
movementState = "idle"
_play_idle_animation()
return
_update_world_sort_z()
activeAction = {}
movementState = "idle"
_play_idle_animation()
func _apply_action(value: Variant, serverNow: int) -> void:
if not (value is Dictionary):
return
var incoming: Dictionary = value
var incomingVersion := int(incoming.get("version", 0))
if incomingVersion < stateVersion:
return
stateVersion = incomingVersion
activeAction = incoming
visible = true
var actionKind := str(incoming.get("kind", "walk"))
movementState = "walk" if actionKind == "walk" else "idle"
worldState = "travelling" if actionKind == "transition" else ("talking" if str(incoming.get("activity_kind", "")) == "socialize" else ("working" if actionKind == "perform" else "walking"))
var dx := float(incoming.get("to_x", incoming.get("toX", 0.0))) - float(incoming.get("from_x", incoming.get("fromX", 0.0)))
var dy := float(incoming.get("to_y", incoming.get("toY", 0.0))) - float(incoming.get("from_y", incoming.get("fromY", 0.0)))
if actionKind == "walk":
if absf(dx) > absf(dy):
lastDirection = "right" if dx >= 0.0 else "left"
else:
lastDirection = "down" if dy >= 0.0 else "up"
if animation_player != null and actionKind == "walk":
if visualScene == "niulai_ambassador" and has_node("Sprite2D"):
$Sprite2D.texture = NIULAI_TEXTURE
$Sprite2D.hframes = 4
$Sprite2D.vframes = 4
animation_player.play("walk_%s" % lastDirection)
elif animation_player != null and actionKind == "perform":
_play_activity_animation(str(incoming.get("activity_kind", incoming.get("activityKind", ""))))
elif animation_player != null:
_play_idle_animation()
var startedAt := int(incoming.get("started_at", incoming.get("startedAt", 0)))
var completesAt := int(incoming.get("completes_at", incoming.get("completesAt", startedAt)))
var offset := Time.get_ticks_msec() - serverNow if serverNow > 0 else 0
_actionStartedLocalMsec = startedAt + offset
_actionCompletesLocalMsec = completesAt + offset
if actionKind != "transition":
var from := Vector2(float(incoming.get("from_x", incoming.get("fromX", global_position.x))), float(incoming.get("from_y", incoming.get("fromY", global_position.y))))
var to := Vector2(float(incoming.get("to_x", incoming.get("toX", from.x))), float(incoming.get("to_y", incoming.get("toY", from.y))))
var duration: int = maxi(1, _actionCompletesLocalMsec - _actionStartedLocalMsec)
var progress := clampf(float(Time.get_ticks_msec() - _actionStartedLocalMsec) / float(duration), 0.0, 1.0)
_move_to_authoritative_position(from.lerp(to, progress))
_update_world_sort_z()
func _move_to_authoritative_position(target: Vector2) -> bool:
# Network NPC positions are authoritative. Static map collisions are already
# resolved by the server's route graph; blocking the replicated position on
# the client makes every NPC freeze when a decorative collider is slightly
# offset from a route point. Keep the shape for interaction, but never reject
# an authoritative movement update locally.
global_position = target
return true
func _place_at_clear_position(target: Vector2) -> bool:
global_position = target
return true
func _has_static_collision_at(target: Vector2) -> bool:
var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D
if collision == null or collision.shape == null or not is_inside_tree():
return false
var query := PhysicsShapeQueryParameters2D.new()
query.shape = collision.shape
var candidateTransform := collision.global_transform
candidateTransform.origin += target - global_position
query.transform = candidateTransform
query.collision_mask = 1
query.collide_with_areas = false
query.collide_with_bodies = true
query.exclude = [get_rid()]
for hit in get_world_2d().direct_space_state.intersect_shape(query, 16):
if hit.get("collider") is StaticBody2D:
return true
return false
func _report_blocked_route(position: Vector2) -> void:
var actionId := str(activeAction.get("action_id", activeAction.get("actionId", "snapshot")))
if actionId == _lastBlockedActionId:
return
_lastBlockedActionId = actionId
push_warning("NetworkNpc route blocked by static collision: npc=%s action=%s position=%s" % [npcId, actionId, position])
func _fallback_dialogue() -> String:
if not publicIntention.is_empty():
return "你好,我是%s%s" % [npcName, publicIntention]
return "你好,我是%s" % npcName

View File

@@ -1 +0,0 @@
uid://bfkuofgw3ftnp

View File

@@ -6,23 +6,10 @@ signal player_moved(position: Vector2)
# 常量定义 # 常量定义
const MOVE_SPEED = 200.0 const MOVE_SPEED = 200.0
const PLAYER_COLLISION_LAYER = 1
const PLAYER_COLLISION_MASK = 3
const WORLD_SORT_Z_OFFSET = 2048 const WORLD_SORT_Z_OFFSET = 2048
const INTERACTION_COLLISION_MASK = 2 const INTERACTION_COLLISION_MASK = 2
const WALK_ANIMATION_LENGTH = 0.8 const WALK_ANIMATION_LENGTH = 0.8
const NAME_LABEL_RENDER_SCALE: float = 0.5 const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112)
const NAME_LABEL_FONT_SIZE: int = 16
const NAME_LABEL_VISUAL_HEIGHT: int = 15
const NAME_LABEL_VISUAL_MIN_WIDTH: int = 64
const NAME_LABEL_VISUAL_MAX_WIDTH: int = 100
const NAME_LABEL_VISUAL_CHAR_WIDTH: int = 10
const NAME_LABEL_OFFSET_Y: float = -72.0
const NAME_LABEL_SIDE_OFFSET_Y: float = -84.0
const NAME_LABEL_BACK_OFFSET_Y: float = -78.0
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const NAME_LABEL_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
const NAME_LABEL_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
const DIRECTION_ROWS: Dictionary = { const DIRECTION_ROWS: Dictionary = {
"down": 0, "down": 0,
"up": 1, "up": 1,
@@ -37,32 +24,12 @@ const DIRECTION_ROWS: Dictionary = {
var lastDirection: String = "down" var lastDirection: String = "down"
var _nameLabel: Label var _nameLabel: Label
var _nicknameFont: FontFile
var _cachedNameLabelText: String = ""
var _cachedNameLabelDirection: String = ""
var _cachedNameLabelVisible: bool = false
var _movementLocked: bool = false var _movementLocked: bool = false
var _lastEmittedMovementState: String = "idle" var has_scene_position_override: bool = false
var _spectator_mode: bool = false var _discoveryElapsed: float = 0.0
func set_spectator_mode(enabled: bool) -> void:
_spectator_mode = enabled
if is_instance_valid(sprite):
sprite.visible = not enabled
if is_instance_valid(_nameLabel):
_nameLabel.visible = not enabled
if is_instance_valid(ray_cast):
ray_cast.enabled = not enabled
if enabled:
collision_layer = 0
collision_mask = 0
else:
collision_layer = PLAYER_COLLISION_LAYER
collision_mask = PLAYER_COLLISION_MASK
func _ready() -> void: func _ready() -> void:
collision_layer = PLAYER_COLLISION_LAYER add_to_group("whaletown_local_player")
collision_mask = PLAYER_COLLISION_MASK
_reset_movement_input_state() _reset_movement_input_state()
_apply_current_appearance() _apply_current_appearance()
_subscribe_to_appearance_events() _subscribe_to_appearance_events()
@@ -107,30 +74,40 @@ func _release_movement_actions() -> void:
func _check_spawn_position() -> void: func _check_spawn_position() -> void:
var spawnPos: Variant = SceneManager.get_next_scene_position() var spawnPos: Variant = SceneManager.get_next_scene_position()
if spawnPos != null: if spawnPos is Vector2:
global_position = spawnPos global_position = spawnPos
_update_world_sort_z() has_scene_position_override = true
_update_world_sort_z()
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_handle_movement(delta) _handle_movement(delta)
_update_world_sort_z() _update_world_sort_z()
_handle_interaction() _handle_interaction()
_report_nearby_discoveries(delta)
func _handle_interaction() -> void: func _handle_interaction() -> void:
if _spectator_mode: # 统一交互由 /root/InteractionManager 收集附近候选并处理 E 键。
return
func _report_nearby_discoveries(delta: float) -> void:
_discoveryElapsed += delta
if _discoveryElapsed < 0.35:
return return
if _is_text_input_focused(): _discoveryElapsed = 0.0
var scene := get_tree().current_scene
if scene == null:
return return
if Input.is_action_just_pressed("interact"): var map_id: String = str({
EventSystem.emit_event(EventNames.INTERACT_PRESSED, { "Square": "whale_port",
"player": self, "WorkZone": "work_zone",
"position": global_position, "CafeInterior": "whale_cafe",
"direction": lastDirection "PersonalSpace": "personal_space",
}) }.get(str(scene.name), ""))
if ray_cast.is_colliding(): if map_id.is_empty():
var collider := ray_cast.get_collider() return
if collider and collider.has_method("interact"): var social_manager := get_node_or_null("/root/SocialManager")
collider.interact() if social_manager != null and social_manager.has_method("discover_nearby_destinations"):
social_manager.call("discover_nearby_destinations", map_id, global_position)
func _handle_movement(_delta: float) -> void: func _handle_movement(_delta: float) -> void:
if _movementLocked: if _movementLocked:
@@ -138,7 +115,6 @@ func _handle_movement(_delta: float) -> void:
velocity = Vector2.ZERO velocity = Vector2.ZERO
_play_idle_animation() _play_idle_animation()
move_and_slide() move_and_slide()
_emit_movement_sync("idle")
return return
# 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色 # 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色
@@ -147,14 +123,10 @@ func _handle_movement(_delta: float) -> void:
velocity = Vector2.ZERO velocity = Vector2.ZERO
_play_idle_animation() _play_idle_animation()
move_and_slide() move_and_slide()
_emit_movement_sync("idle")
return return
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md) # 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
var direction := Input.get_vector( var direction := _movement_direction()
"move_left", "move_right",
"move_up", "move_down"
)
# 应用移动 # 应用移动
if direction != Vector2.ZERO: if direction != Vector2.ZERO:
@@ -166,28 +138,27 @@ func _handle_movement(_delta: float) -> void:
move_and_slide() move_and_slide()
# 移动中持续发送位置;停止时额外发送一次 idle供远端切回待机动画。 # 发送移动事件 (如果位置发生明显变化)
var movementState := "walk" if velocity.length() > 0 else "idle" if velocity.length() > 0:
_emit_movement_sync(movementState)
func _emit_movement_sync(movementState: String) -> void:
if _spectator_mode:
return
if movementState == "walk":
player_moved.emit(global_position) player_moved.emit(global_position)
if movementState == "walk" or movementState != _lastEmittedMovementState:
EventSystem.emit_event(EventNames.PLAYER_MOVED, { EventSystem.emit_event(EventNames.PLAYER_MOVED, {
"position": global_position, "position": global_position
"direction": lastDirection,
"movement_state": movementState
}) })
_lastEmittedMovementState = movementState
func _movement_direction() -> Vector2:
var interactionManager := get_node_or_null("/root/InteractionManager")
if interactionManager != null and interactionManager.has_method("is_selection_active") and bool(interactionManager.call("is_selection_active")):
# 有交互候选时方向键用于选择,保留 WASD 移动。
return Vector2(
(-1.0 if Input.is_key_pressed(KEY_A) else 0.0) + (1.0 if Input.is_key_pressed(KEY_D) else 0.0),
(-1.0 if Input.is_key_pressed(KEY_W) else 0.0) + (1.0 if Input.is_key_pressed(KEY_S) else 0.0)
).normalized()
return Input.get_vector("move_left", "move_right", "move_up", "move_down")
func _update_animation_state(direction: Vector2) -> void: func _update_animation_state(direction: Vector2) -> void:
if not animation_player: if not animation_player:
return return
var previousDirection := lastDirection
# Determine primary direction # Determine primary direction
if abs(direction.x) > abs(direction.y): if abs(direction.x) > abs(direction.y):
if direction.x > 0: if direction.x > 0:
@@ -204,8 +175,6 @@ func _update_animation_state(direction: Vector2) -> void:
lastDirection = "up" lastDirection = "up"
ray_cast.target_position = Vector2(0, -60) ray_cast.target_position = Vector2(0, -60)
if lastDirection != previousDirection:
_update_name_label()
animation_player.play("walk_" + lastDirection) animation_player.play("walk_" + lastDirection)
func _play_idle_animation() -> void: func _play_idle_animation() -> void:
@@ -294,61 +263,19 @@ func _create_name_label() -> void:
return return
_nameLabel = Label.new() _nameLabel = Label.new()
_nameLabel.name = "NameLabel" _nameLabel.name = "NameLabel"
_nameLabel.position = NAME_LABEL_OFFSET
_nameLabel.custom_minimum_size = Vector2(140, 28)
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
_nameLabel.add_theme_font_size_override("font_size", 14)
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
add_child(_nameLabel) add_child(_nameLabel)
func _update_name_label() -> void: func _update_name_label() -> void:
if not is_instance_valid(_nameLabel): if not is_instance_valid(_nameLabel):
return return
var displayName := _current_username() _nameLabel.text = _current_username()
var showName := _settings_bool("show_name_always", false) _nameLabel.visible = _settings_bool("show_name_always", false)
if displayName == _cachedNameLabelText and lastDirection == _cachedNameLabelDirection and showName == _cachedNameLabelVisible:
return
var visualWidth := _name_label_visual_width(displayName)
var renderWidth := ceili(float(visualWidth) / NAME_LABEL_RENDER_SCALE)
var renderHeight := ceili(float(NAME_LABEL_VISUAL_HEIGHT) / NAME_LABEL_RENDER_SCALE)
_nameLabel.theme = WORLD_TEXT_THEME
_nameLabel.text = displayName
_nameLabel.visible = showName
_nameLabel.z_index = 30
_nameLabel.scale = Vector2.ONE * NAME_LABEL_RENDER_SCALE
_nameLabel.position = Vector2(float(visualWidth) * -0.5, _name_label_offset_y())
_nameLabel.custom_minimum_size = Vector2(renderWidth, renderHeight)
_nameLabel.size = _nameLabel.custom_minimum_size
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_nameLabel.clip_text = true
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
_nameLabel.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
_nameLabel.add_theme_constant_override("outline_size", 3)
_nameLabel.add_theme_font_override("font", _get_nickname_font())
_nameLabel.add_theme_font_size_override("font_size", NAME_LABEL_FONT_SIZE)
_nameLabel.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
_cachedNameLabelText = displayName
_cachedNameLabelDirection = lastDirection
_cachedNameLabelVisible = showName
func _name_label_visual_width(displayName: String) -> int:
var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAME_LABEL_FONT_SIZE).x
var visualWidth := ceili(measuredWidth * NAME_LABEL_RENDER_SCALE + 20.0)
return clampi(visualWidth, NAME_LABEL_VISUAL_MIN_WIDTH, NAME_LABEL_VISUAL_MAX_WIDTH)
func _get_nickname_font() -> FontFile:
if _nicknameFont == null:
_nicknameFont = NAME_LABEL_FONT_ZH.duplicate() as FontFile
_nicknameFont.fallbacks = [NAME_LABEL_FONT_LATIN]
return _nicknameFont
func _name_label_offset_y() -> float:
match lastDirection:
"left", "right":
return NAME_LABEL_SIDE_OFFSET_Y
"up":
return NAME_LABEL_BACK_OFFSET_Y
_:
return NAME_LABEL_OFFSET_Y
func _current_username() -> String: func _current_username() -> String:
var authManager := get_node_or_null("/root/AuthManager") var authManager := get_node_or_null("/root/AuthManager")
@@ -363,3 +290,16 @@ func _settings_bool(key: String, defaultValue: bool) -> bool:
if settingsManager != null and settingsManager.has_method("get_bool"): if settingsManager != null and settingsManager.has_method("get_bool"):
return bool(settingsManager.call("get_bool", key)) return bool(settingsManager.call("get_bool", key))
return defaultValue return defaultValue
func _create_name_label_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 1.0, 1.0, 0.74)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 10
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 4
style.content_margin_bottom = 4
return style

View File

@@ -7,35 +7,17 @@ class_name RemotePlayer
var userId: String = "" var userId: String = ""
var username: String = "" var username: String = ""
const DEFAULT_REMOTE_SKIN_ID: String = "classic_whale" var skinId: String = ""
var skinId: String = DEFAULT_REMOTE_SKIN_ID
var skinAsset: Dictionary = {} var skinAsset: Dictionary = {}
var avatarId: String = "" var avatarId: String = ""
var targetPosition: Vector2 = Vector2.ZERO var targetPosition: Vector2 = Vector2.ZERO
var cafeCompanionData: Dictionary = {} var cafeCompanionData: Dictionary = {}
var movementState: String = "idle"
var lastSequence: int = -1
var _hasSyncedDirection: bool = false
var _nicknameFont: FontFile
var _cachedNameLabelText: String = ""
var _cachedNameLabelPersona: String = ""
var _cachedNameLabelDirection: String = ""
var _cachedNameLabelVisible: bool = false
# 内部状态 # 内部状态
var lastDirection: String = "down" var lastDirection: String = "down"
const WORLD_SORT_Z_OFFSET = 2048 const WORLD_SORT_Z_OFFSET = 2048
const WALK_ANIMATION_LENGTH = 0.8 const WALK_ANIMATION_LENGTH = 0.8
const NAME_LABEL_RENDER_SCALE: float = 0.5 const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112)
const NAME_LABEL_FONT_SIZE: int = 16
const NAME_LABEL_VISUAL_HEIGHT: int = 15
const NAME_LABEL_VISUAL_MIN_WIDTH: int = 64
const NAME_LABEL_VISUAL_MAX_WIDTH: int = 100
const NAME_LABEL_VISUAL_CHAR_WIDTH: int = 10
const NAME_LABEL_OFFSET_Y: float = -72.0
const NAME_LABEL_SIDE_OFFSET_Y: float = -84.0
const NAME_LABEL_BACK_OFFSET_Y: float = -78.0
const CAFE_NAME_LABEL_RENDER_SCALE: float = 0.5 const CAFE_NAME_LABEL_RENDER_SCALE: float = 0.5
const CAFE_NAME_LABEL_FONT_SIZE: int = 24 const CAFE_NAME_LABEL_FONT_SIZE: int = 24
const CAFE_NAME_LABEL_VISUAL_HEIGHT: int = 22 const CAFE_NAME_LABEL_VISUAL_HEIGHT: int = 22
@@ -44,8 +26,6 @@ const CAFE_NAME_LABEL_VISUAL_MAX_WIDTH: int = 118
const CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH: int = 12 const CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH: int = 12
const CAFE_NAME_LABEL_OFFSET_Y: float = -96.0 const CAFE_NAME_LABEL_OFFSET_Y: float = -96.0
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const NAME_LABEL_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
const NAME_LABEL_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
const DIRECTION_ROWS: Dictionary = { const DIRECTION_ROWS: Dictionary = {
"down": 0, "down": 0,
"up": 1, "up": 1,
@@ -56,8 +36,12 @@ const DIRECTION_ROWS: Dictionary = {
@onready var sprite: Sprite2D = $Sprite2D @onready var sprite: Sprite2D = $Sprite2D
var _nameLabel: Label var _nameLabel: Label
var _cafeCompanionTarget: CafeCompanionTarget var _cafeCompanionTarget: CafeCompanionTarget
var _interactable: InteractableComponent
func _ready() -> void: func _ready() -> void:
_interactable = InteractableComponent.new()
_interactable.interaction_distance = 160.0
add_child(_interactable)
# 初始化时确保无物理处理 # 初始化时确保无物理处理
set_physics_process(false) set_physics_process(false)
# 初始位置设为当前位置 # 初始位置设为当前位置
@@ -72,6 +56,32 @@ func _ready() -> void:
if has_node("CollisionShape2D"): if has_node("CollisionShape2D"):
$CollisionShape2D.disabled = true $CollisionShape2D.disabled = true
func is_interaction_active(_component: InteractableComponent) -> bool:
return not userId.strip_edges().is_empty()
func build_interaction_actions(_component: InteractableComponent, _player: Node2D) -> Array[InteractionAction]:
var actions: Array[InteractionAction] = []
var display_name := username if not username.strip_edges().is_empty() else "玩家"
actions.append(InteractionAction.create("player_card:%s" % userId, "查看 %s 的社区名片" % display_name, 60, Callable(self, "_show_community_profile")))
actions.append(InteractionAction.create("player_dm:%s" % userId, "私聊 %s" % display_name, 61, Callable(self, "_open_private_chat")))
actions.append(InteractionAction.create("player_friend:%s" % userId, "申请添加 %s 为好友" % display_name, 62, Callable(self, "_request_friend")))
return actions
func _show_community_profile() -> void:
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("show_profile"):
socialManager.call("show_profile", userId)
func _open_private_chat() -> void:
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("open_private_chat"):
socialManager.call("open_private_chat", userId, username)
func _request_friend() -> void:
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("request_friend"):
socialManager.call("request_friend", userId, username)
func _exit_tree() -> void: func _exit_tree() -> void:
var eventSystem := get_node_or_null("/root/EventSystem") var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null: if eventSystem != null:
@@ -93,15 +103,37 @@ func _process(delta: float) -> void:
global_position = newPos global_position = newPos
_update_world_sort_z() _update_world_sort_z()
else: else:
# 距离很近时吸附;动画状态由发送端的 idle/walk 决定。 # 距离很近时直接吸附并播放待机动画
global_position = targetPosition global_position = targetPosition
_update_world_sort_z() _update_world_sort_z()
_play_current_animation() _play_idle_animation()
# 统一初始化方法 # 统一初始化方法
# data: 包含 camelCase 字段的字典 (userId, username, position 等) # data: 包含 camelCase 字段的字典 (userId, username, position 等)
func setup(data: Dictionary) -> void: func setup(data: Dictionary) -> void:
update_metadata(data) if data.has("userId"):
userId = data.userId
if data.has("username"):
username = str(data.username)
if data.has("skin_id"):
skinId = str(data.get("skin_id", ""))
elif data.has("skinId"):
skinId = str(data.get("skinId", ""))
if data.has("skin_asset"):
var skinAssetData: Variant = data.get("skin_asset", {})
skinAsset = skinAssetData if skinAssetData is Dictionary else {}
elif data.has("skinAsset"):
var skinAssetPayload: Variant = data.get("skinAsset", {})
skinAsset = skinAssetPayload if skinAssetPayload is Dictionary else {}
if data.has("avatar_id"):
avatarId = str(data.get("avatar_id", ""))
elif data.has("avatarId"):
avatarId = str(data.get("avatarId", ""))
if data.has("cafe_companion") or data.has("cafeCompanion"):
cafeCompanionData = _normalize_cafe_companion_data(data.get("cafe_companion", data.get("cafeCompanion", null)))
_apply_appearance()
_configure_cafe_companion_target()
_update_name_label()
if data.has("position"): if data.has("position"):
var positionData: Variant = data.position var positionData: Variant = data.position
@@ -109,73 +141,15 @@ func setup(data: Dictionary) -> void:
global_position = positionData global_position = positionData
targetPosition = positionData targetPosition = positionData
_update_world_sort_z() _update_world_sort_z()
elif positionData is Dictionary and positionData.has("x") and positionData.has("y"): elif positionData.has("x") and positionData.has("y"):
var newPos := Vector2(positionData.x, positionData.y) var newPos := Vector2(positionData.x, positionData.y)
global_position = newPos global_position = newPos
targetPosition = newPos targetPosition = newPos
_update_world_sort_z() _update_world_sort_z()
_apply_movement_state(data)
func update_metadata(data: Dictionary) -> void:
var appearanceChanged := false
var companionChanged := false
if data.has("userId"):
userId = data.userId
if data.has("username"):
username = str(data.username)
if data.has("skin_id"):
var nextSkinId := _normalize_remote_skin_id(str(data.get("skin_id", "")))
appearanceChanged = appearanceChanged or nextSkinId != skinId
skinId = nextSkinId
elif data.has("skinId"):
var nextSkinId := _normalize_remote_skin_id(str(data.get("skinId", "")))
appearanceChanged = appearanceChanged or nextSkinId != skinId
skinId = nextSkinId
if data.has("skin_asset"):
var skinAssetData: Variant = data.get("skin_asset", {})
var nextSkinAsset: Dictionary = skinAssetData if skinAssetData is Dictionary else {}
appearanceChanged = appearanceChanged or nextSkinAsset != skinAsset
skinAsset = nextSkinAsset
elif data.has("skinAsset"):
var skinAssetPayload: Variant = data.get("skinAsset", {})
var nextSkinAsset: Dictionary = skinAssetPayload if skinAssetPayload is Dictionary else {}
appearanceChanged = appearanceChanged or nextSkinAsset != skinAsset
skinAsset = nextSkinAsset
if data.has("avatar_id"):
avatarId = str(data.get("avatar_id", ""))
elif data.has("avatarId"):
avatarId = str(data.get("avatarId", ""))
if data.has("cafe_companion") or data.has("cafeCompanion"):
var nextCompanionData := _normalize_cafe_companion_data(data.get("cafe_companion", data.get("cafeCompanion", null)))
companionChanged = nextCompanionData != cafeCompanionData
cafeCompanionData = nextCompanionData
if appearanceChanged:
_apply_appearance()
if companionChanged:
_configure_cafe_companion_target()
_update_name_label()
func _normalize_remote_skin_id(value: String) -> String:
var normalized := value.strip_edges()
if normalized.is_empty() or normalized == "pending_initial_skin":
return DEFAULT_REMOTE_SKIN_ID
return normalized
# 更新目标位置 # 更新目标位置
func update_position(newPos: Vector2, direction: String = "", nextMovementState: String = "walk", sequence: int = -1) -> void: func update_position(newPos: Vector2) -> void:
if sequence >= 0 and lastSequence >= 0 and sequence <= lastSequence:
return
if sequence >= 0:
lastSequence = sequence
var normalizedDirection := _normalize_direction(direction)
if not normalizedDirection.is_empty():
lastDirection = normalizedDirection
_hasSyncedDirection = true
_update_name_label()
movementState = "walk" if nextMovementState.strip_edges().to_lower() == "walk" else "idle"
targetPosition = newPos targetPosition = newPos
if global_position.distance_to(targetPosition) <= 1.0:
_play_current_animation()
func _update_world_sort_z() -> void: func _update_world_sort_z() -> void:
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y)) z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
@@ -184,43 +158,20 @@ func _update_animation(moveVec: Vector2) -> void:
if not animation_player: if not animation_player:
return return
# 新协议使用发送端方向;兼容旧位置包时再从位移向量推断。 # 确定主方向
if not _hasSyncedDirection: if abs(moveVec.x) > abs(moveVec.y):
if abs(moveVec.x) > abs(moveVec.y): if moveVec.x > 0:
if moveVec.x > 0: lastDirection = "right"
lastDirection = "right"
else:
lastDirection = "left"
else: else:
if moveVec.y > 0: lastDirection = "left"
lastDirection = "down" else:
else: if moveVec.y > 0:
lastDirection = "up" lastDirection = "down"
else:
lastDirection = "up"
animation_player.play("walk_" + lastDirection) animation_player.play("walk_" + lastDirection)
func _apply_movement_state(data: Dictionary) -> void:
var normalizedDirection := _normalize_direction(str(data.get("direction", "")))
if not normalizedDirection.is_empty():
lastDirection = normalizedDirection
_hasSyncedDirection = true
_update_name_label()
movementState = "walk" if str(data.get("movement_state", data.get("movementState", "idle"))).strip_edges().to_lower() == "walk" else "idle"
lastSequence = int(data.get("sequence", lastSequence))
_play_current_animation()
func _normalize_direction(value: String) -> String:
var normalized := value.strip_edges().to_lower()
return normalized if normalized in ["down", "up", "right", "left"] else ""
func _play_current_animation() -> void:
if animation_player == null:
return
if movementState == "walk":
animation_player.play("walk_" + lastDirection)
else:
_play_idle_animation()
func _play_idle_animation() -> void: func _play_idle_animation() -> void:
if animation_player: if animation_player:
animation_player.play("idle_" + lastDirection) animation_player.play("idle_" + lastDirection)
@@ -285,26 +236,25 @@ func _create_name_label() -> void:
return return
_nameLabel = Label.new() _nameLabel = Label.new()
_nameLabel.name = "NameLabel" _nameLabel.name = "NameLabel"
_nameLabel.position = NAME_LABEL_OFFSET
_nameLabel.custom_minimum_size = Vector2(140, 28)
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
_nameLabel.add_theme_font_size_override("font_size", 14)
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
add_child(_nameLabel) add_child(_nameLabel)
func _update_name_label() -> void: func _update_name_label() -> void:
if not is_instance_valid(_nameLabel): if not is_instance_valid(_nameLabel):
return return
var personaName := str(cafeCompanionData.get("persona_name", "")).strip_edges() var personaName := str(cafeCompanionData.get("persona_name", "")).strip_edges()
var displayName := personaName if not personaName.is_empty() else (username if not username.strip_edges().is_empty() else "玩家")
var showName := true if not personaName.is_empty() else (_is_guest_mode() or _settings_bool("show_name_always", false))
if displayName == _cachedNameLabelText and personaName == _cachedNameLabelPersona and lastDirection == _cachedNameLabelDirection and showName == _cachedNameLabelVisible:
return
if not personaName.is_empty(): if not personaName.is_empty():
_configure_cafe_name_label(personaName) _configure_cafe_name_label(personaName)
else: _nameLabel.visible = true
_configure_default_name_label() return
_nameLabel.text = displayName _configure_default_name_label()
_nameLabel.visible = showName _nameLabel.text = username if not username.strip_edges().is_empty() else "玩家"
_cachedNameLabelText = displayName _nameLabel.visible = _settings_bool("show_name_always", false)
_cachedNameLabelPersona = personaName
_cachedNameLabelDirection = lastDirection
_cachedNameLabelVisible = showName
func _configure_cafe_name_label(personaName: String) -> void: func _configure_cafe_name_label(personaName: String) -> void:
var displayName := personaName.strip_edges() var displayName := personaName.strip_edges()
@@ -326,61 +276,30 @@ func _configure_cafe_name_label(personaName: String) -> void:
_nameLabel.clip_text = true _nameLabel.clip_text = true
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE _nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0)) _nameLabel.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
_nameLabel.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85)) _nameLabel.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
_nameLabel.add_theme_constant_override("shadow_offset_x", 0) _nameLabel.add_theme_constant_override("shadow_offset_x", 0)
_nameLabel.add_theme_constant_override("shadow_offset_y", 1) _nameLabel.add_theme_constant_override("shadow_offset_y", 1)
_nameLabel.remove_theme_color_override("font_outline_color")
_nameLabel.remove_theme_constant_override("outline_size")
_nameLabel.add_theme_font_size_override("font_size", CAFE_NAME_LABEL_FONT_SIZE) _nameLabel.add_theme_font_size_override("font_size", CAFE_NAME_LABEL_FONT_SIZE)
_nameLabel.add_theme_stylebox_override("normal", _create_cafe_name_label_style()) _nameLabel.add_theme_stylebox_override("normal", _create_cafe_name_label_style())
func _configure_default_name_label() -> void: func _configure_default_name_label() -> void:
var displayName := username if not username.strip_edges().is_empty() else "玩家" _nameLabel.theme = null
var visualWidth := _name_label_visual_width(displayName) _nameLabel.position = NAME_LABEL_OFFSET
var renderWidth := ceili(float(visualWidth) / NAME_LABEL_RENDER_SCALE) _nameLabel.scale = Vector2.ONE
var renderHeight := ceili(float(NAME_LABEL_VISUAL_HEIGHT) / NAME_LABEL_RENDER_SCALE) _nameLabel.custom_minimum_size = Vector2(140, 28)
_nameLabel.theme = WORLD_TEXT_THEME
_nameLabel.z_index = 30
_nameLabel.scale = Vector2.ONE * NAME_LABEL_RENDER_SCALE
_nameLabel.position = Vector2(float(visualWidth) * -0.5, _name_label_offset_y())
_nameLabel.custom_minimum_size = Vector2(renderWidth, renderHeight)
_nameLabel.size = _nameLabel.custom_minimum_size _nameLabel.size = _nameLabel.custom_minimum_size
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_TOP
_nameLabel.clip_text = true _nameLabel.clip_text = false
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _nameLabel.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE _nameLabel.mouse_filter = Control.MOUSE_FILTER_STOP
_nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0)) _nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
_nameLabel.remove_theme_color_override("font_shadow_color") _nameLabel.remove_theme_color_override("font_shadow_color")
_nameLabel.remove_theme_constant_override("shadow_offset_x") _nameLabel.remove_theme_constant_override("shadow_offset_x")
_nameLabel.remove_theme_constant_override("shadow_offset_y") _nameLabel.remove_theme_constant_override("shadow_offset_y")
_nameLabel.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98)) _nameLabel.add_theme_font_size_override("font_size", 14)
_nameLabel.add_theme_constant_override("outline_size", 3) _nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
_nameLabel.add_theme_font_override("font", _get_nickname_font())
_nameLabel.add_theme_font_size_override("font_size", NAME_LABEL_FONT_SIZE)
_nameLabel.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
func _name_label_visual_width(displayName: String) -> int:
var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAME_LABEL_FONT_SIZE).x
var visualWidth := ceili(measuredWidth * NAME_LABEL_RENDER_SCALE + 20.0)
return clampi(visualWidth, NAME_LABEL_VISUAL_MIN_WIDTH, NAME_LABEL_VISUAL_MAX_WIDTH)
func _get_nickname_font() -> FontFile:
if _nicknameFont == null:
_nicknameFont = NAME_LABEL_FONT_ZH.duplicate() as FontFile
_nicknameFont.fallbacks = [NAME_LABEL_FONT_LATIN]
return _nicknameFont
func _name_label_offset_y() -> float:
match lastDirection:
"left", "right":
return NAME_LABEL_SIDE_OFFSET_Y
"up":
return NAME_LABEL_BACK_OFFSET_Y
_:
return NAME_LABEL_OFFSET_Y
func _cafe_name_label_visual_width(displayName: String) -> int: func _cafe_name_label_visual_width(displayName: String) -> int:
var estimatedWidth := displayName.length() * CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH + 28 var estimatedWidth := displayName.length() * CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH + 28
@@ -451,9 +370,18 @@ func _settings_bool(key: String, defaultValue: bool) -> bool:
return bool(settingsManager.call("get_bool", key)) return bool(settingsManager.call("get_bool", key))
return defaultValue return defaultValue
func _is_guest_mode() -> bool: func _create_name_label_style() -> StyleBoxFlat:
var chatManager := get_node_or_null("/root/ChatManager") var style := StyleBoxFlat.new()
return chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode")) style.bg_color = Color(1.0, 1.0, 1.0, 0.74)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 10
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 4
style.content_margin_bottom = 4
return style
func _create_cafe_name_label_style() -> StyleBoxFlat: func _create_cafe_name_label_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new() var style := StyleBoxFlat.new()

View File

@@ -1,18 +0,0 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="PackedScene" path="res://scenes/characters/npc.tscn" id="1_base"]
[ext_resource type="Script" path="res://scenes/characters/NetworkNpc.gd" id="2_script"]
[ext_resource type="Texture2D" path="res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png" id="3_researcher"]
[node name="NetworkNpc" instance=ExtResource("1_base")]
script = ExtResource("2_script")
showNameplate = true
nameplateOffsetY = -72.0
[node name="Sprite2D" parent="." index="0"]
position = Vector2(0, -29)
scale = Vector2(0.5, 0.5)
texture_filter = 1
texture = ExtResource("3_researcher")
hframes = 8
vframes = 4

View File

@@ -157,8 +157,6 @@ _data = {
[node name="Player" type="CharacterBody2D"] [node name="Player" type="CharacterBody2D"]
script = ExtResource("1_script") script = ExtResource("1_script")
collision_layer = 1
collision_mask = 3
[node name="Sprite2D" type="Sprite2D" parent="."] [node name="Sprite2D" type="Sprite2D" parent="."]
texture_filter = 2 texture_filter = 2

View File

@@ -8,6 +8,13 @@ const INTERACTION_COLLISION_LAYER: int = 2
func _ready() -> void: func _ready() -> void:
collision_layer = INTERACTION_COLLISION_LAYER collision_layer = INTERACTION_COLLISION_LAYER
collision_mask = 0 collision_mask = 0
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_id = "honor_board:%s" % str(get_path())
interactable.interaction_title = "查看荣誉榜"
interactable.interaction_priority = 35
interactable.interaction_distance = 150.0
interactable.activation_method = &"interact"
add_child(interactable)
func interact() -> void: func interact() -> void:
var root: Window = get_tree().root var root: Window = get_tree().root

View File

@@ -8,6 +8,13 @@ const INTERACTION_COLLISION_LAYER: int = 2
func _ready() -> void: func _ready() -> void:
collision_layer = INTERACTION_COLLISION_LAYER collision_layer = INTERACTION_COLLISION_LAYER
collision_mask = 0 collision_mask = 0
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_id = "notice_board:%s" % str(get_path())
interactable.interaction_title = "查看公告栏"
interactable.interaction_priority = 35
interactable.interaction_distance = 150.0
interactable.activation_method = &"interact"
add_child(interactable)
func interact() -> void: func interact() -> void:
var root: Window = get_tree().root var root: Window = get_tree().root

Some files were not shown because too many files have changed in this diff Show More