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:
@@ -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
|
||||
|
||||
510
scenes/characters/NetworkNpc.gd
Normal file
510
scenes/characters/NetworkNpc.gd
Normal 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
|
||||
1
scenes/characters/NetworkNpc.gd.uid
Normal file
1
scenes/characters/NetworkNpc.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bfkuofgw3ftnp
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
18
scenes/characters/network_npc.tscn
Normal file
18
scenes/characters/network_npc.tscn
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user