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:
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
|
||||
Reference in New Issue
Block a user