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

@@ -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