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
This commit is contained in:
2026-09-08 21:37:43 +08:00
parent 175621f66c
commit fc6c3c1bd3
87 changed files with 4546 additions and 506 deletions

View File

@@ -8,27 +8,27 @@ class_name NPCController
# 主要功能:
# - 播放 NPC 待机动画
# - 响应玩家射线交互
# - 触发聊天气泡与 NPC 对话事件
# - 打开 NPC 对话框并广播 NPC 对话事件
#
# 依赖: EventSystem, EventNames, ChatBubble
# 依赖: EventSystem, EventNames, ChatUI
# 作者: Codex
# 创建时间: 2026-03-10
# ============================================================================
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_COLLISION_LAYER: int = 2
const NPC_COLLISION_MASK: int = 1
const WORLD_SORT_Z_OFFSET: int = 2048
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const NAMEPLATE_RENDER_SCALE: float = 0.5
const NAMEPLATE_FONT_SIZE: int = 24
const NAMEPLATE_VISUAL_HEIGHT: int = 22
const NAMEPLATE_VISUAL_MIN_WIDTH: int = 76
const NAMEPLATE_VISUAL_MAX_WIDTH: int = 118
const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
const NAMEPLATE_FONT_SIZE: int = 16
const NAMEPLATE_VISUAL_HEIGHT: int = 15
const NAMEPLATE_VISUAL_MIN_WIDTH: int = 64
const NAMEPLATE_VISUAL_MAX_WIDTH: int = 100
const NAMEPLATE_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
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_multiline var dialogue: String = "欢迎来到WhaleTown我是镇长范鲸晶"
@@ -38,6 +38,7 @@ const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
@onready var animation_player: AnimationPlayer = $AnimationPlayer
var _nameplate: Label
var _nicknameFont: FontFile
func _ready() -> void:
# 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
@@ -46,16 +47,18 @@ func _ready() -> void:
_update_nameplate()
_update_world_sort_z()
# 保持 NPC 可被玩家射线与角色碰撞识别
collision_layer = 3
collision_mask = 3
# NPC 单独占用交互层;玩家的物理掩码同时包含地图层和 NPC 层
collision_layer = NPC_COLLISION_LAYER
collision_mask = NPC_COLLISION_MASK
func _physics_process(_delta: float) -> void:
_update_world_sort_z()
# 处理玩家交互,展示气泡并向全局事件系统广播。
# 处理玩家交互,打开统一对话框并向全局事件系统广播。
func interact() -> void:
show_bubble(dialogue)
var chatUi := get_tree().root.find_child("ChatUI", true, false)
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")
if eventSystem != null:
eventSystem.call("emit_event", NPC_TALKED_EVENT, {
@@ -65,31 +68,6 @@ func interact() -> void:
})
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:
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
@@ -123,34 +101,20 @@ func _update_nameplate() -> void:
_nameplate.clip_text = true
_nameplate.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameplate.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameplate.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
_nameplate.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
_nameplate.add_theme_constant_override("shadow_offset_x", 0)
_nameplate.add_theme_constant_override("shadow_offset_y", 1)
_nameplate.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
_nameplate.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
_nameplate.add_theme_constant_override("outline_size", 3)
_nameplate.add_theme_font_override("font", _get_nickname_font())
_nameplate.add_theme_font_size_override("font_size", NAMEPLATE_FONT_SIZE)
_nameplate.add_theme_stylebox_override("normal", _create_nameplate_style())
_nameplate.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
func _nameplate_visual_width(displayName: String) -> int:
var estimatedWidth := displayName.length() * NAMEPLATE_VISUAL_CHAR_WIDTH + 28
return clampi(estimatedWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH)
var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAMEPLATE_FONT_SIZE).x
var visualWidth := ceili(measuredWidth * NAMEPLATE_RENDER_SCALE + 20.0)
return clampi(visualWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH)
func _create_nameplate_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
style.border_color = Color(0.18, 0.34, 0.38, 0.92)
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
func _get_nickname_font() -> FontFile:
if _nicknameFont == null:
_nicknameFont = NAMEPLATE_FONT_ZH.duplicate() as FontFile
_nicknameFont.fallbacks = [NAMEPLATE_FONT_LATIN]
return _nicknameFont

View File

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

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

View File

@@ -6,10 +6,23 @@ 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_OFFSET: Vector2 = Vector2(-70, -112)
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,
@@ -24,9 +37,32 @@ const DIRECTION_ROWS: Dictionary = {
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()
@@ -81,6 +117,8 @@ func _physics_process(delta: float) -> void:
_handle_interaction()
func _handle_interaction() -> void:
if _spectator_mode:
return
if _is_text_input_focused():
return
if Input.is_action_just_pressed("interact"):
@@ -100,6 +138,7 @@ func _handle_movement(_delta: float) -> void:
velocity = Vector2.ZERO
_play_idle_animation()
move_and_slide()
_emit_movement_sync("idle")
return
# 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色
@@ -108,6 +147,7 @@ func _handle_movement(_delta: float) -> void:
velocity = Vector2.ZERO
_play_idle_animation()
move_and_slide()
_emit_movement_sync("idle")
return
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
@@ -126,17 +166,28 @@ func _handle_movement(_delta: float) -> void:
move_and_slide()
# 发送移动事件 (如果位置发生明显变化)
if velocity.length() > 0:
# 移动中持续发送位置;停止时额外发送一次 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
"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:
@@ -153,6 +204,8 @@ func _update_animation_state(direction: Vector2) -> void:
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:
@@ -241,19 +294,61 @@ func _create_name_label() -> void:
return
_nameLabel = Label.new()
_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)
func _update_name_label() -> void:
if not is_instance_valid(_nameLabel):
return
_nameLabel.text = _current_username()
_nameLabel.visible = _settings_bool("show_name_always", false)
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")
@@ -268,16 +363,3 @@ func _settings_bool(key: String, defaultValue: bool) -> bool:
if settingsManager != null and settingsManager.has_method("get_bool"):
return bool(settingsManager.call("get_bool", key))
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,17 +7,35 @@ class_name RemotePlayer
var userId: String = ""
var username: String = ""
var skinId: String = ""
const DEFAULT_REMOTE_SKIN_ID: String = "classic_whale"
var skinId: String = DEFAULT_REMOTE_SKIN_ID
var skinAsset: Dictionary = {}
var avatarId: String = ""
var targetPosition: Vector2 = Vector2.ZERO
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"
const WORLD_SORT_Z_OFFSET = 2048
const WALK_ANIMATION_LENGTH = 0.8
const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112)
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 CAFE_NAME_LABEL_RENDER_SCALE: float = 0.5
const CAFE_NAME_LABEL_FONT_SIZE: int = 24
const CAFE_NAME_LABEL_VISUAL_HEIGHT: int = 22
@@ -26,6 +44,8 @@ const CAFE_NAME_LABEL_VISUAL_MAX_WIDTH: int = 118
const CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH: int = 12
const CAFE_NAME_LABEL_OFFSET_Y: float = -96.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,
@@ -73,37 +93,15 @@ func _process(delta: float) -> void:
global_position = newPos
_update_world_sort_z()
else:
# 距离很近时直接吸附并播放待机动画
# 距离很近时吸附;动画状态由发送端的 idle/walk 决定。
global_position = targetPosition
_update_world_sort_z()
_play_idle_animation()
_play_current_animation()
# 统一初始化方法
# data: 包含 camelCase 字段的字典 (userId, username, position 等)
func setup(data: Dictionary) -> void:
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()
update_metadata(data)
if data.has("position"):
var positionData: Variant = data.position
@@ -111,15 +109,73 @@ func setup(data: Dictionary) -> void:
global_position = positionData
targetPosition = positionData
_update_world_sort_z()
elif positionData.has("x") and positionData.has("y"):
elif positionData is Dictionary and positionData.has("x") and positionData.has("y"):
var newPos := Vector2(positionData.x, positionData.y)
global_position = newPos
targetPosition = newPos
_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) -> void:
func update_position(newPos: Vector2, direction: String = "", nextMovementState: String = "walk", sequence: int = -1) -> 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
if global_position.distance_to(targetPosition) <= 1.0:
_play_current_animation()
func _update_world_sort_z() -> void:
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
@@ -128,20 +184,43 @@ func _update_animation(moveVec: Vector2) -> void:
if not animation_player:
return
# 确定主方向
if abs(moveVec.x) > abs(moveVec.y):
if moveVec.x > 0:
lastDirection = "right"
# 新协议使用发送端方向;兼容旧位置包时再从位移向量推断。
if not _hasSyncedDirection:
if abs(moveVec.x) > abs(moveVec.y):
if moveVec.x > 0:
lastDirection = "right"
else:
lastDirection = "left"
else:
lastDirection = "left"
else:
if moveVec.y > 0:
lastDirection = "down"
else:
lastDirection = "up"
if moveVec.y > 0:
lastDirection = "down"
else:
lastDirection = "up"
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:
if animation_player:
animation_player.play("idle_" + lastDirection)
@@ -206,25 +285,26 @@ func _create_name_label() -> void:
return
_nameLabel = Label.new()
_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)
func _update_name_label() -> void:
if not is_instance_valid(_nameLabel):
return
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():
_configure_cafe_name_label(personaName)
_nameLabel.visible = true
return
_configure_default_name_label()
_nameLabel.text = username if not username.strip_edges().is_empty() else "玩家"
_nameLabel.visible = _settings_bool("show_name_always", false)
else:
_configure_default_name_label()
_nameLabel.text = displayName
_nameLabel.visible = showName
_cachedNameLabelText = displayName
_cachedNameLabelPersona = personaName
_cachedNameLabelDirection = lastDirection
_cachedNameLabelVisible = showName
func _configure_cafe_name_label(personaName: String) -> void:
var displayName := personaName.strip_edges()
@@ -246,30 +326,61 @@ func _configure_cafe_name_label(personaName: String) -> void:
_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.12, 0.20, 0.24, 1.0))
_nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
_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_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_stylebox_override("normal", _create_cafe_name_label_style())
func _configure_default_name_label() -> void:
_nameLabel.theme = null
_nameLabel.position = NAME_LABEL_OFFSET
_nameLabel.scale = Vector2.ONE
_nameLabel.custom_minimum_size = Vector2(140, 28)
var displayName := username if not username.strip_edges().is_empty() else "玩家"
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.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_TOP
_nameLabel.clip_text = false
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
_nameLabel.mouse_filter = Control.MOUSE_FILTER_STOP
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
_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.remove_theme_color_override("font_shadow_color")
_nameLabel.remove_theme_constant_override("shadow_offset_x")
_nameLabel.remove_theme_constant_override("shadow_offset_y")
_nameLabel.add_theme_font_size_override("font_size", 14)
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
_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())
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:
var estimatedWidth := displayName.length() * CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH + 28
@@ -340,18 +451,9 @@ func _settings_bool(key: String, defaultValue: bool) -> bool:
return bool(settingsManager.call("get_bool", key))
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
func _is_guest_mode() -> bool:
var chatManager := get_node_or_null("/root/ChatManager")
return chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode"))
func _create_cafe_name_label_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()

View File

@@ -0,0 +1,18 @@
[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,6 +157,8 @@ _data = {
[node name="Player" type="CharacterBody2D"]
script = ExtResource("1_script")
collision_layer = 1
collision_mask = 3
[node name="Sprite2D" type="Sprite2D" parent="."]
texture_filter = 2