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

366 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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