- add network NPC synchronization and dialogue interactions - add world bulletin publishing and display - improve authentication, session refresh, and appearance sync - synchronize player direction and movement animations - improve input focus and progressive Web loading - add macOS/Windows builds and deployment configuration - include required fonts, shaders, and runtime assets
366 lines
12 KiB
GDScript
366 lines
12 KiB
GDScript
extends CharacterBody2D
|
||
class_name PlayerController
|
||
|
||
# 信号定义
|
||
signal player_moved(position: Vector2)
|
||
|
||
# 常量定义
|
||
const MOVE_SPEED = 200.0
|
||
const PLAYER_COLLISION_LAYER = 1
|
||
const PLAYER_COLLISION_MASK = 3
|
||
const WORLD_SORT_Z_OFFSET = 2048
|
||
const INTERACTION_COLLISION_MASK = 2
|
||
const WALK_ANIMATION_LENGTH = 0.8
|
||
const NAME_LABEL_RENDER_SCALE: float = 0.5
|
||
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 = {
|
||
"down": 0,
|
||
"up": 1,
|
||
"right": 2,
|
||
"left": 3,
|
||
}
|
||
|
||
# 节点引用
|
||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||
@onready var sprite: Sprite2D = $Sprite2D
|
||
@onready var ray_cast: RayCast2D = $RayCast2D
|
||
|
||
var lastDirection: String = "down"
|
||
var _nameLabel: Label
|
||
var _nicknameFont: FontFile
|
||
var _cachedNameLabelText: String = ""
|
||
var _cachedNameLabelDirection: String = ""
|
||
var _cachedNameLabelVisible: bool = false
|
||
var _movementLocked: bool = false
|
||
var _lastEmittedMovementState: String = "idle"
|
||
var _spectator_mode: bool = false
|
||
|
||
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:
|
||
collision_layer = PLAYER_COLLISION_LAYER
|
||
collision_mask = PLAYER_COLLISION_MASK
|
||
_reset_movement_input_state()
|
||
_apply_current_appearance()
|
||
_subscribe_to_appearance_events()
|
||
_create_name_label()
|
||
_update_name_label()
|
||
|
||
# 检查是否有初始位置设置
|
||
call_deferred("_check_spawn_position")
|
||
|
||
# 播放初始动画
|
||
if animation_player.has_animation("idle_down"):
|
||
animation_player.play("idle_down")
|
||
|
||
# Initialize RayCast
|
||
ray_cast.add_exception(self) # Ignore local player
|
||
ray_cast.enabled = true
|
||
ray_cast.collide_with_areas = true
|
||
ray_cast.collide_with_bodies = true
|
||
ray_cast.collision_mask = INTERACTION_COLLISION_MASK
|
||
ray_cast.target_position = Vector2(0, 60)
|
||
_update_world_sort_z()
|
||
|
||
func _exit_tree() -> void:
|
||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||
if eventSystem != null:
|
||
eventSystem.call("disconnect_event", EventNames.APPEARANCE_SKIN_CHANGED, _on_appearance_skin_changed, self)
|
||
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
|
||
|
||
func _notification(what: int) -> void:
|
||
if what == NOTIFICATION_APPLICATION_FOCUS_IN:
|
||
_reset_movement_input_state()
|
||
|
||
func _reset_movement_input_state() -> void:
|
||
Input.flush_buffered_events()
|
||
_release_movement_actions()
|
||
|
||
func _release_movement_actions() -> void:
|
||
Input.action_release("move_left")
|
||
Input.action_release("move_right")
|
||
Input.action_release("move_up")
|
||
Input.action_release("move_down")
|
||
|
||
func _check_spawn_position() -> void:
|
||
var spawnPos: Variant = SceneManager.get_next_scene_position()
|
||
if spawnPos != null:
|
||
global_position = spawnPos
|
||
_update_world_sort_z()
|
||
|
||
func _physics_process(delta: float) -> void:
|
||
_handle_movement(delta)
|
||
_update_world_sort_z()
|
||
_handle_interaction()
|
||
|
||
func _handle_interaction() -> void:
|
||
if _spectator_mode:
|
||
return
|
||
if _is_text_input_focused():
|
||
return
|
||
if Input.is_action_just_pressed("interact"):
|
||
EventSystem.emit_event(EventNames.INTERACT_PRESSED, {
|
||
"player": self,
|
||
"position": global_position,
|
||
"direction": lastDirection
|
||
})
|
||
if ray_cast.is_colliding():
|
||
var collider := ray_cast.get_collider()
|
||
if collider and collider.has_method("interact"):
|
||
collider.interact()
|
||
|
||
func _handle_movement(_delta: float) -> void:
|
||
if _movementLocked:
|
||
_release_movement_actions()
|
||
velocity = Vector2.ZERO
|
||
_play_idle_animation()
|
||
move_and_slide()
|
||
_emit_movement_sync("idle")
|
||
return
|
||
|
||
# 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色
|
||
if _is_text_input_focused():
|
||
_release_movement_actions()
|
||
velocity = Vector2.ZERO
|
||
_play_idle_animation()
|
||
move_and_slide()
|
||
_emit_movement_sync("idle")
|
||
return
|
||
|
||
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
|
||
var direction := Input.get_vector(
|
||
"move_left", "move_right",
|
||
"move_up", "move_down"
|
||
)
|
||
|
||
# 应用移动
|
||
if direction != Vector2.ZERO:
|
||
velocity = direction * MOVE_SPEED
|
||
_update_animation_state(direction)
|
||
else:
|
||
velocity = Vector2.ZERO
|
||
_play_idle_animation()
|
||
|
||
move_and_slide()
|
||
|
||
# 移动中持续发送位置;停止时额外发送一次 idle,供远端切回待机动画。
|
||
var movementState := "walk" if velocity.length() > 0 else "idle"
|
||
_emit_movement_sync(movementState)
|
||
|
||
func _emit_movement_sync(movementState: String) -> void:
|
||
if _spectator_mode:
|
||
return
|
||
if movementState == "walk":
|
||
player_moved.emit(global_position)
|
||
if movementState == "walk" or movementState != _lastEmittedMovementState:
|
||
EventSystem.emit_event(EventNames.PLAYER_MOVED, {
|
||
"position": global_position,
|
||
"direction": lastDirection,
|
||
"movement_state": movementState
|
||
})
|
||
_lastEmittedMovementState = movementState
|
||
|
||
func _update_animation_state(direction: Vector2) -> void:
|
||
if not animation_player:
|
||
return
|
||
|
||
var previousDirection := lastDirection
|
||
# Determine primary direction
|
||
if abs(direction.x) > abs(direction.y):
|
||
if direction.x > 0:
|
||
lastDirection = "right"
|
||
ray_cast.target_position = Vector2(60, 0)
|
||
else:
|
||
lastDirection = "left"
|
||
ray_cast.target_position = Vector2(-60, 0)
|
||
else:
|
||
if direction.y > 0:
|
||
lastDirection = "down"
|
||
ray_cast.target_position = Vector2(0, 60)
|
||
else:
|
||
lastDirection = "up"
|
||
ray_cast.target_position = Vector2(0, -60)
|
||
|
||
if lastDirection != previousDirection:
|
||
_update_name_label()
|
||
animation_player.play("walk_" + lastDirection)
|
||
|
||
func _play_idle_animation() -> void:
|
||
if animation_player:
|
||
animation_player.play("idle_" + lastDirection)
|
||
|
||
func set_movement_locked(locked: bool) -> void:
|
||
_movementLocked = locked
|
||
if locked:
|
||
_release_movement_actions()
|
||
velocity = Vector2.ZERO
|
||
_play_idle_animation()
|
||
|
||
func _update_world_sort_z() -> void:
|
||
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
|
||
|
||
func _subscribe_to_appearance_events() -> void:
|
||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||
if eventSystem != null:
|
||
eventSystem.call("connect_event", EventNames.APPEARANCE_SKIN_CHANGED, _on_appearance_skin_changed, self)
|
||
eventSystem.call("connect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
|
||
|
||
func _on_appearance_skin_changed(_data: Dictionary) -> void:
|
||
_apply_current_appearance()
|
||
|
||
func _on_settings_changed(_data: Dictionary) -> void:
|
||
_update_name_label()
|
||
|
||
func _apply_current_appearance() -> void:
|
||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||
if appearanceManager != null and appearanceManager.has_method("apply_skin_to_sprite"):
|
||
appearanceManager.call("apply_skin_to_sprite", sprite)
|
||
_configure_directional_animations()
|
||
|
||
func _configure_directional_animations() -> void:
|
||
if animation_player == null or sprite == null:
|
||
return
|
||
|
||
var library := animation_player.get_animation_library("")
|
||
if library == null:
|
||
library = AnimationLibrary.new()
|
||
animation_player.add_animation_library("", library)
|
||
|
||
var frameCount: int = max(1, sprite.hframes)
|
||
for direction in DIRECTION_ROWS.keys():
|
||
var row := int(DIRECTION_ROWS[direction])
|
||
_set_frame_animation(library, "idle_%s" % direction, [row * frameCount], 0.1)
|
||
var walkFrames: Array[int] = []
|
||
for column in range(frameCount):
|
||
walkFrames.append(row * frameCount + column)
|
||
_set_frame_animation(library, "walk_%s" % direction, walkFrames, WALK_ANIMATION_LENGTH)
|
||
|
||
func _set_frame_animation(library: AnimationLibrary, animationName: String, frameValues: Array[int], length: float) -> void:
|
||
if library.has_animation(animationName):
|
||
library.remove_animation(animationName)
|
||
|
||
var animation := Animation.new()
|
||
animation.resource_name = animationName
|
||
animation.length = length
|
||
animation.loop_mode = Animation.LOOP_LINEAR
|
||
|
||
var trackIndex := animation.add_track(Animation.TYPE_VALUE)
|
||
animation.track_set_path(trackIndex, NodePath("Sprite2D:frame"))
|
||
animation.value_track_set_update_mode(trackIndex, Animation.UPDATE_DISCRETE)
|
||
|
||
var frameStep: float = length / float(max(1, frameValues.size()))
|
||
for index in range(frameValues.size()):
|
||
animation.track_insert_key(trackIndex, index * frameStep, frameValues[index])
|
||
|
||
library.add_animation(animationName, animation)
|
||
|
||
func _is_text_input_focused() -> bool:
|
||
var focusOwner: Control = get_viewport().gui_get_focus_owner()
|
||
if focusOwner == null or not focusOwner.is_inside_tree() or not focusOwner.is_visible_in_tree():
|
||
return false
|
||
if focusOwner is LineEdit:
|
||
var lineEdit := focusOwner as LineEdit
|
||
return lineEdit.editable
|
||
if focusOwner is TextEdit:
|
||
var textEdit := focusOwner as TextEdit
|
||
return textEdit.editable
|
||
return false
|
||
|
||
func _create_name_label() -> void:
|
||
if is_instance_valid(_nameLabel):
|
||
return
|
||
_nameLabel = Label.new()
|
||
_nameLabel.name = "NameLabel"
|
||
add_child(_nameLabel)
|
||
|
||
func _update_name_label() -> void:
|
||
if not is_instance_valid(_nameLabel):
|
||
return
|
||
var displayName := _current_username()
|
||
var showName := _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:
|
||
var authManager := get_node_or_null("/root/AuthManager")
|
||
if authManager != null and authManager.has_method("get_current_username"):
|
||
var username := str(authManager.call("get_current_username")).strip_edges()
|
||
if not username.is_empty():
|
||
return username
|
||
return "玩家"
|
||
|
||
func _settings_bool(key: String, defaultValue: bool) -> bool:
|
||
var settingsManager := get_node_or_null("/root/SettingsManager")
|
||
if settingsManager != null and settingsManager.has_method("get_bool"):
|
||
return bool(settingsManager.call("get_bool", key))
|
||
return defaultValue
|