1442 lines
52 KiB
GDScript
1442 lines
52 KiB
GDScript
class_name NpcAvatar
|
|
extends CharacterBody3D
|
|
## Visual + navigation wrapper around a logic NPCController (ADR-0001).
|
|
## Gives the controller a body, color-coded by archetype, and simple movement.
|
|
|
|
const NPCController := preload("res://src/core/npc_controller.gd")
|
|
const Rules := preload("res://src/core/rules.gd")
|
|
const Materials := preload("res://src/game/materials.gd")
|
|
|
|
var npc: NPCController = null
|
|
var spawn_position: Vector3 = Vector3.ZERO
|
|
var target_position: Vector3 = Vector3.ZERO
|
|
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
|
|
var awaiting_interaction := false
|
|
var arrived := false
|
|
## Seconds idle at an arrived waypoint. Ordinary traffic lives for a while, then
|
|
## despawns so the entrance does not fill with standers (TODO: NPC crowd cap).
|
|
var idle_s := 0.0
|
|
var lifetime_s := 90.0
|
|
var ephemeral := true
|
|
## Crowd presentation (PLAN: Public Crowd Reaction). "watch" rubbernecks at the
|
|
## incident, "flee" clears away from it, "" is normal navigation.
|
|
var reaction_mode := ""
|
|
var reaction_point := Vector3.ZERO
|
|
var _reaction_timer := 0.0
|
|
const RUBBERNECK_S := 12.0
|
|
const FLEE_S := 20.0
|
|
const FLEE_SPEED := 2.4
|
|
## LOD distances in metres. Hysteresis keeps the tier from flickering at a
|
|
## boundary; the impostor is used far enough away that the silhouette is all
|
|
## that reads.
|
|
const LOD_TIERS := [14.0, 26.0]
|
|
const LOD_HYSTERESIS := 1.5
|
|
var agent: NavigationAgent3D = null
|
|
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
|
var _nav_usable := false
|
|
var _nav_try_frames := 0
|
|
|
|
const TYPE_COLORS := {
|
|
Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55),
|
|
Rules.NpcType.PERFORMER: Color(0.80, 0.55, 0.90),
|
|
Rules.NpcType.INFLUENCER: Color(0.95, 0.55, 0.55),
|
|
Rules.NpcType.CONFUSED_VISITOR: Color(0.95, 0.85, 0.50),
|
|
Rules.NpcType.INTOXICATED: Color(0.60, 0.75, 0.50),
|
|
Rules.NpcType.TRESPASSER: Color(0.55, 0.55, 0.75),
|
|
Rules.NpcType.VIOLENT_THREAT: Color(0.85, 0.25, 0.25),
|
|
Rules.NpcType.MENTAL_CRISIS: Color(0.85, 0.65, 0.45),
|
|
Rules.NpcType.AUTHORIZED_STAFF: Color(0.45, 0.70, 0.90),
|
|
Rules.NpcType.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65),
|
|
Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40),
|
|
Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85),
|
|
Rules.NpcType.PROTESTER: Color(0.90, 0.40, 0.20),
|
|
}
|
|
|
|
func _init() -> void:
|
|
npc = NPCController.new()
|
|
|
|
func get_npc_controller() -> NPCController:
|
|
return npc
|
|
|
|
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2), p_npc_id: String = "", p_override: Dictionary = {}) -> void:
|
|
var id := p_npc_id if p_npc_id != "" else "npc_%d" % seed_value
|
|
npc.setup(id, profile, intent, seed_value)
|
|
npc.access_override = p_override
|
|
_visual_seed = seed_value
|
|
_npc_type = profile.npc_type
|
|
spawn_position = p_spawn
|
|
target_position = p_target
|
|
door_position = p_door
|
|
position = p_spawn
|
|
npc.fsm.state_changed.connect(_on_state_changed)
|
|
add_to_group("npc")
|
|
_idle_style = idle_style_for(profile.npc_type, seed_value)
|
|
if not use_skinned_model or not _build_skinned_body(profile.npc_type):
|
|
_build_body(profile.npc_type)
|
|
fallback_avatar = true
|
|
_build_impostor()
|
|
_nav_usable = false
|
|
_nav_try_frames = 0
|
|
agent = NavigationAgent3D.new()
|
|
agent.name = "NavAgent"
|
|
agent.path_desired_distance = 0.4
|
|
agent.target_desired_distance = 4.0
|
|
agent.radius = 0.35
|
|
agent.height = 1.2
|
|
# Steer around street furniture instead of walking into it (props publish a
|
|
# NavigationObstacle3D; the navmesh itself stays a flat walkable plane).
|
|
agent.avoidance_enabled = true
|
|
agent.neighbor_distance = 4.0
|
|
agent.max_neighbors = 8
|
|
agent.time_horizon_agents = 0.6
|
|
agent.time_horizon_obstacles = 1.2
|
|
agent.max_speed = FLEE_SPEED
|
|
agent.velocity_computed.connect(_on_safe_velocity)
|
|
add_child(agent)
|
|
|
|
# Normalize intent targets: approach the entrance, stop near the door.
|
|
var collider := CollisionShape3D.new()
|
|
var shape := CapsuleShape3D.new()
|
|
shape.radius = 0.35
|
|
var height_scale: float = _rig.scale.y if _rig else 1.0
|
|
shape.height = 1.6 * height_scale
|
|
collider.shape = shape
|
|
collider.position = Vector3(0, 0.8 * height_scale, 0)
|
|
add_child(collider)
|
|
|
|
var _visual_seed := 0
|
|
var _rig: Node3D = null
|
|
var _npc_type := 0
|
|
var _head: Node3D = null
|
|
var _bob_phase := 0.0
|
|
var _guard_ref: Node3D = null
|
|
var _focus_circle: MeshInstance3D = null
|
|
var _name_tag: Label3D = null
|
|
## Theater props and limbs for the idle poses (ADR-0010 rig).
|
|
var _photo_prop: MeshInstance3D = null
|
|
var _photo_flash: MeshInstance3D = null
|
|
var _sign_prop: MeshInstance3D = null
|
|
var _legs: Array[Node3D] = []
|
|
var _arms: Array[Node3D] = []
|
|
var _knees: Array[Node3D] = []
|
|
var _elbows: Array[Node3D] = []
|
|
var _hands: Array[Node3D] = []
|
|
var _theater_t := 0.0
|
|
var _idle_t := 0.0
|
|
var _idle_style := "breathe"
|
|
var _reaction_tag: Label3D = null
|
|
var _impostor: Node3D = null
|
|
var _detail_nodes: Array[Node3D] = []
|
|
var _lod_tier := 0
|
|
var _lod_check_s := 0.0
|
|
var _queue_offset := Vector3.ZERO
|
|
var _height_scale := 1.0
|
|
var loaded_model := false
|
|
var fallback_avatar := true
|
|
var character_model := ""
|
|
## QA/review override for the visual model. Empty keeps role/seed selection;
|
|
## `capture_npc_review.gd` uses it to stage the one library model (guard) that
|
|
## has no spawned NPC role.
|
|
var character_override := ""
|
|
## Tests can force the offline rig; the game always tries the committed model.
|
|
var use_skinned_model := true
|
|
var _skeleton: Skeleton3D = null
|
|
var _bone_indices := {}
|
|
var _skinned_mesh: MeshInstance3D = null
|
|
var _shirt_color := Color(0.7, 0.7, 0.7)
|
|
var _skin_color := Color(0.88, 0.72, 0.60)
|
|
var _avoidance_active := false
|
|
var _photo_prop_base_y := 0.0
|
|
var _sign_prop_base_y := 0.0
|
|
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
|
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
|
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
|
|
|
## Which small accessory a role wears. This is look-only: the player must still
|
|
## read intent from behaviour, never from the archetype name (ADR-0010).
|
|
const ACCESSORIES := {
|
|
Rules.NpcType.AUTHORIZED_STAFF: "cap",
|
|
Rules.NpcType.RESPONDER: "cap",
|
|
Rules.NpcType.DELIVERY: "cap",
|
|
Rules.NpcType.TOURIST: "backpack",
|
|
Rules.NpcType.INFLUENCER: "sunglasses",
|
|
Rules.NpcType.TRESPASSER: "hood",
|
|
Rules.NpcType.VIOLENT_THREAT: "hood",
|
|
Rules.NpcType.MENTAL_CRISIS: "hood",
|
|
Rules.NpcType.AUTHORIZED_GUEST: "lanyard",
|
|
Rules.NpcType.PERFORMER: "none",
|
|
Rules.NpcType.PROTESTER: "none",
|
|
Rules.NpcType.INTOXICATED: "none",
|
|
Rules.NpcType.CONFUSED_VISITOR: "none",
|
|
}
|
|
|
|
const BLENDER_NPC_DIR := "res://assets/models/blender/npc/"
|
|
const BLENDER_GEAR_DIR := "res://assets/models/blender/npc_gear/"
|
|
## Final-context adjustments applied inside the attach node. The lanyard was
|
|
## authored under the jacket's front surface; a small forward offset keeps the
|
|
## ribbon and badge readable without changing the attach-point contract.
|
|
const GEAR_OFFSETS := {
|
|
"lanyard": Vector3(0, 0, 0.04),
|
|
"firefighter_helmet": Vector3(0, 0.03, 0),
|
|
}
|
|
## Hand gear is authored around the wrist proxy. The palm sits ~4 cm in front
|
|
## of that origin, so without this offset the first phone/flashlight versions
|
|
## were partly buried inside the hand. Keep the grip axis and move the whole
|
|
## item forward into the open palm.
|
|
const HAND_ITEM_FORWARD := Vector3(0, 0, 0.04)
|
|
const GEAR_ATTACH := {
|
|
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
|
|
"firefighter_helmet": "head",
|
|
"backpack": "back", "shoulder_bag": "back", "scba_tank": "back",
|
|
"lanyard": "chest",
|
|
"phone": "hand", "cup": "hand", "clipboard": "hand", "umbrella": "hand",
|
|
"camera": "hand", "protest_sign": "hand", "flashlight": "hand",
|
|
"wand": "hand", "radio": "hand",
|
|
}
|
|
## Character variants from tools/blender_assets.py. The manifest is the source
|
|
## of truth; this mapping keeps selection deterministic and testable offline.
|
|
const CHARACTER_MODELS := {
|
|
Rules.NpcType.TOURIST: "npc_casual",
|
|
Rules.NpcType.CONFUSED_VISITOR: "npc_casual",
|
|
Rules.NpcType.INTOXICATED: "npc_casual",
|
|
Rules.NpcType.AUTHORIZED_GUEST: "npc_jacket",
|
|
Rules.NpcType.AUTHORIZED_STAFF: "npc_jacket",
|
|
Rules.NpcType.PROTESTER: "npc_jacket",
|
|
Rules.NpcType.INFLUENCER: "npc_coat",
|
|
Rules.NpcType.TRESPASSER: "npc_coat",
|
|
Rules.NpcType.VIOLENT_THREAT: "npc_hoodie",
|
|
Rules.NpcType.MENTAL_CRISIS: "npc_hoodie",
|
|
Rules.NpcType.PERFORMER: "npc_dress",
|
|
Rules.NpcType.DELIVERY: "npc_vest",
|
|
Rules.NpcType.RESPONDER: "npc_firefighter",
|
|
}
|
|
|
|
## Which library character represents this profile. Pure and seed-stable; some
|
|
## roles alternate between two silhouettes so crowds do not clone.
|
|
static func character_model_for(npc_type: int, seed_value: int) -> String:
|
|
match npc_type:
|
|
Rules.NpcType.AUTHORIZED_GUEST:
|
|
return "npc_jacket" if seed_value % 3 == 0 else "npc_casual"
|
|
Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT:
|
|
return "npc_hoodie" if seed_value % 2 == 0 else "npc_coat"
|
|
_:
|
|
return str(CHARACTER_MODELS.get(npc_type, "npc_casual"))
|
|
|
|
const HAIR_STYLES := ["short", "long", "bun", "beard", "bald", "short"]
|
|
const HAND_ITEMS := ["phone", "cup", "clipboard", "umbrella", "camera", "none"]
|
|
|
|
## Outer layer per role/seed. Pure so the same person always dresses the same.
|
|
static func layer_for(npc_type: int, seed_value: int) -> String:
|
|
match npc_type:
|
|
Rules.NpcType.RESPONDER:
|
|
return "vest"
|
|
Rules.NpcType.DELIVERY:
|
|
return "vest"
|
|
Rules.NpcType.AUTHORIZED_STAFF:
|
|
return "jacket"
|
|
Rules.NpcType.INFLUENCER:
|
|
return "coat"
|
|
Rules.NpcType.PERFORMER:
|
|
return "dress" if seed_value % 2 == 0 else "coat"
|
|
Rules.NpcType.PROTESTER:
|
|
return "jacket"
|
|
Rules.NpcType.AUTHORIZED_GUEST:
|
|
return "jacket" if seed_value % 3 == 0 else "none"
|
|
Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT:
|
|
return "coat"
|
|
_:
|
|
return "none"
|
|
|
|
## Deterministic hand item so silhouettes differ without changing the role read.
|
|
static func hand_item_for(npc_type: int, seed_value: int) -> String:
|
|
match npc_type:
|
|
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
|
|
return "clipboard"
|
|
Rules.NpcType.TOURIST:
|
|
return "camera"
|
|
Rules.NpcType.INFLUENCER:
|
|
return "phone"
|
|
Rules.NpcType.AUTHORIZED_GUEST:
|
|
return "phone" if seed_value % 4 == 0 else "none"
|
|
Rules.NpcType.CONFUSED_VISITOR:
|
|
return "cup"
|
|
Rules.NpcType.PERFORMER, Rules.NpcType.PROTESTER:
|
|
return "none"
|
|
_:
|
|
return HAND_ITEMS[(seed_value * 5 + 1) % 3 + 3] \
|
|
if seed_value % 5 == 0 else "none"
|
|
|
|
## Clothing palette for one avatar. Pure and seed-driven so crowds read as
|
|
## individuals and the result is deterministic (tests rely on that).
|
|
static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
|
|
var base: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
|
var frac := float((seed_value * 1103515245 + 12345) % 1000) / 1000.0
|
|
var shirt := base.lerp(Color(0.92, 0.92, 0.94), frac * 0.22)
|
|
shirt = shirt.darkened(frac * 0.12)
|
|
# Jeans through khaki/chino, so not every avatar wears the same trousers.
|
|
var pants := Color(0.16, 0.18, 0.23).lerp(Color(0.46, 0.38, 0.28), frac)
|
|
var hair_index := (seed_value * 7) % HAIR_COLORS.size()
|
|
var style_index := (seed_value * 13 + npc_type * 3) % HAIR_STYLES.size()
|
|
return {
|
|
"shirt": shirt,
|
|
"sleeves": shirt.darkened(0.16),
|
|
"pants": pants,
|
|
"hair": HAIR_COLORS[hair_index],
|
|
"hair_style": HAIR_STYLES[style_index],
|
|
"skin": SKIN_TONE.darkened(frac * 0.18),
|
|
"accessory": ACCESSORIES.get(npc_type, "none"),
|
|
"layer": layer_for(npc_type, seed_value),
|
|
"hand_item": hand_item_for(npc_type, seed_value),
|
|
"height_scale": 0.92 + 0.16 * frac,
|
|
"blend": frac,
|
|
}
|
|
|
|
## Idle behaviour style: someone chatting on a phone, arms crossed, looking
|
|
## around, or just breathing. Pure so it is deterministic and testable.
|
|
static func idle_style_for(npc_type: int, seed_value: int) -> String:
|
|
match npc_type:
|
|
Rules.NpcType.INFLUENCER, Rules.NpcType.TOURIST:
|
|
return "phone"
|
|
Rules.NpcType.PROTESTER, Rules.NpcType.MENTAL_CRISIS:
|
|
return "look"
|
|
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
|
|
return "clipboard"
|
|
Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT:
|
|
return "look"
|
|
_:
|
|
if seed_value % 3 == 0:
|
|
return "crossed"
|
|
return "breathe"
|
|
|
|
## Queue slot behind the door for the nth person waiting, so arrivals stack
|
|
## instead of sharing one point.
|
|
static func queue_slot_for(index: int) -> Vector3:
|
|
var slots := [
|
|
Vector3(0.0, 0.0, 0.0),
|
|
Vector3(0.85, 0.0, 0.45),
|
|
Vector3(-0.85, 0.0, 0.45),
|
|
Vector3(0.85, 0.0, 1.15),
|
|
Vector3(-0.85, 0.0, 1.15),
|
|
Vector3(0.0, 0.0, 1.85),
|
|
]
|
|
return slots[clampi(index, 0, slots.size() - 1)]
|
|
|
|
## Yaw that makes the avatar's face (+Z, the procedural and Blender-front axis)
|
|
## point along `direction`. Godot's `look_at` aims -Z, which used to make every
|
|
## NPC walk backwards; keep the +Z convention in one tested helper.
|
|
static func yaw_towards(direction: Vector3) -> float:
|
|
var flat := Vector2(direction.x, direction.z)
|
|
if flat.length() < 0.001:
|
|
return 0.0
|
|
return atan2(flat.x, flat.y)
|
|
|
|
static func lod_tier_for_distance(distance: float) -> int:
|
|
if distance < LOD_TIERS[0]:
|
|
return 0
|
|
if distance < LOD_TIERS[1]:
|
|
return 1
|
|
return 2
|
|
|
|
## Short reaction symbol shown above the head: readable crowd state at a glance.
|
|
static func reaction_label(mode: String, escalation: int) -> String:
|
|
if mode == "flee":
|
|
return "!!"
|
|
if mode == "watch":
|
|
return "?!"
|
|
if escalation >= 3:
|
|
return "!"
|
|
if escalation >= 2:
|
|
return "?"
|
|
return ""
|
|
|
|
## Instantiate the Blender-authored skinned base mesh. Returns false when the
|
|
## GLB is missing/not imported, so setup falls back to the procedural rig.
|
|
func _build_skinned_body(npc_type: int) -> bool:
|
|
character_model = character_override if character_override != "" \
|
|
else character_model_for(npc_type, _visual_seed)
|
|
var path := BLENDER_NPC_DIR + character_model + ".glb"
|
|
if not ResourceLoader.exists(path):
|
|
character_model = "npc_base"
|
|
path = "res://assets/models/generated/npc_base.glb"
|
|
if not ResourceLoader.exists(path):
|
|
return false
|
|
var packed := ResourceLoader.load(path) as PackedScene
|
|
if packed == null:
|
|
return false
|
|
var model := packed.instantiate() as Node3D
|
|
if model == null:
|
|
return false
|
|
var skeleton := model.find_child("Skeleton3D", true, false) as Skeleton3D
|
|
var mesh_instance := model.find_child("npc_base", true, false) as MeshInstance3D
|
|
if skeleton == null or mesh_instance == null:
|
|
model.queue_free()
|
|
return false
|
|
|
|
_rig = Node3D.new()
|
|
_rig.name = "Rig"
|
|
add_child(_rig)
|
|
_rig.add_child(model)
|
|
_skeleton = skeleton
|
|
_skinned_mesh = mesh_instance
|
|
|
|
var outfit := outfit_for(npc_type, _visual_seed)
|
|
var cloth: Color = outfit["shirt"]
|
|
var pants: Color = outfit["pants"]
|
|
if character_model == "npc_base":
|
|
match str(outfit["layer"]):
|
|
"vest":
|
|
cloth = Color(0.92, 0.72, 0.12)
|
|
"coat":
|
|
cloth = cloth.darkened(0.25)
|
|
"jacket":
|
|
cloth = cloth.darkened(0.12)
|
|
else:
|
|
match character_model:
|
|
"npc_vest":
|
|
# The hi-vis vest is a fixed material; cloth is the undershirt.
|
|
pass
|
|
"npc_firefighter":
|
|
cloth = Color(0.16, 0.17, 0.20)
|
|
pants = Color(0.13, 0.14, 0.17)
|
|
"npc_guard":
|
|
cloth = Color(0.10, 0.13, 0.20)
|
|
pants = Color(0.09, 0.10, 0.14)
|
|
"npc_dress":
|
|
pants = outfit["pants"].darkened(0.25)
|
|
_:
|
|
match str(outfit["layer"]):
|
|
"coat":
|
|
cloth = cloth.darkened(0.25)
|
|
"jacket":
|
|
cloth = cloth.darkened(0.12)
|
|
_height_scale = float(outfit["height_scale"])
|
|
_rig.scale = Vector3.ONE * _height_scale
|
|
_shirt_color = cloth
|
|
_skin_color = outfit["skin"]
|
|
_apply_outfit_materials(npc_type, outfit, cloth, pants)
|
|
|
|
# Animation proxies: the existing _animate()/idle code drives these Node3D
|
|
# pivots; _sync_skeleton() copies their rotations onto the armature bones.
|
|
_head = Node3D.new()
|
|
_head.name = "Head"
|
|
_head.position = Vector3(0, 1.62, 0)
|
|
_rig.add_child(_head)
|
|
for side in [-1.0, 1.0]:
|
|
var suffix := "L" if side < 0.0 else "R"
|
|
var hip := Node3D.new()
|
|
hip.name = "Hip" + suffix
|
|
hip.position = Vector3(0.13 * side, 0.64, 0)
|
|
_rig.add_child(hip)
|
|
var knee := Node3D.new()
|
|
knee.name = "Knee"
|
|
knee.position = Vector3(0, -0.34, 0)
|
|
hip.add_child(knee)
|
|
_legs.append(hip)
|
|
_knees.append(knee)
|
|
var shoulder := Node3D.new()
|
|
shoulder.name = "Shoulder" + suffix
|
|
shoulder.position = Vector3(0.27 * side, 1.38, 0)
|
|
_rig.add_child(shoulder)
|
|
var elbow := Node3D.new()
|
|
elbow.name = "Elbow"
|
|
elbow.position = Vector3(0, -0.30, 0)
|
|
shoulder.add_child(elbow)
|
|
var hand := Node3D.new()
|
|
hand.name = "Hand"
|
|
hand.position = Vector3(0, -0.30, 0.02)
|
|
elbow.add_child(hand)
|
|
_arms.append(shoulder)
|
|
_elbows.append(elbow)
|
|
_hands.append(hand)
|
|
_bind_bones()
|
|
|
|
_add_accessory(str(outfit["accessory"]), cloth)
|
|
_add_character_gear()
|
|
_add_hand_item(str(outfit["hand_item"]), outfit["skin"])
|
|
_build_focus_circle()
|
|
_add_props(npc_type)
|
|
loaded_model = true
|
|
fallback_avatar = false
|
|
return true
|
|
|
|
## Tint the imported material slots per avatar. Each avatar gets its own copied
|
|
## materials so the shared imported scene is never mutated.
|
|
func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color,
|
|
pants: Color) -> void:
|
|
var mesh: Mesh = _skinned_mesh.mesh
|
|
var tints := {
|
|
"skin": outfit["skin"],
|
|
"cloth": cloth,
|
|
"pants": pants,
|
|
"hair": outfit["hair"],
|
|
"accessory": Color(0.10, 0.11, 0.14),
|
|
}
|
|
var ao := Materials.load_ao(
|
|
BLENDER_NPC_DIR + character_model + "_ao.png")
|
|
for index in mesh.get_surface_count():
|
|
var source := mesh.surface_get_material(index)
|
|
if source == null:
|
|
continue
|
|
var slot := str(source.resource_name)
|
|
# Only the five contract slots are tinted. Fixed-colour detail materials
|
|
# (hivis, reflective, leather, steel, ...) keep their authored colour.
|
|
if not tints.has(slot):
|
|
continue
|
|
var material := Materials.npc_slot(source, tints[slot])
|
|
if ao != null:
|
|
material.ao_enabled = true
|
|
material.ao_texture = ao
|
|
material.ao_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_RED
|
|
_skinned_mesh.set_surface_override_material(index, material)
|
|
|
|
func _bind_bones() -> void:
|
|
_bone_indices.clear()
|
|
if _skeleton == null:
|
|
return
|
|
for index in _skeleton.get_bone_count():
|
|
_bone_indices[_skeleton.get_bone_name(index)] = index
|
|
|
|
## Copy the proxy pivot rotations onto armature bones. Limb bones point down, so
|
|
## an X rotation swings them forward/back; the Head bone points up, so a Y
|
|
## rotation is the look yaw.
|
|
func _sync_skeleton() -> void:
|
|
if _skeleton == null or _bone_indices.is_empty():
|
|
return
|
|
var names := ["HipL", "HipR"]
|
|
for i in 2:
|
|
_pose_bone(names[i], _legs[i].rotation.x, Vector3.RIGHT)
|
|
names = ["KneeL", "KneeR"]
|
|
for i in 2:
|
|
_pose_bone(names[i], _knees[i].rotation.x, Vector3.RIGHT)
|
|
names = ["ShoulderL", "ShoulderR"]
|
|
for i in 2:
|
|
_pose_bone(names[i], _arms[i].rotation.x, Vector3.RIGHT)
|
|
names = ["ElbowL", "ElbowR"]
|
|
for i in 2:
|
|
_pose_bone(names[i], _elbows[i].rotation.x, Vector3.RIGHT)
|
|
if _head != null:
|
|
_pose_bone("Head", _head.rotation.y, Vector3.UP)
|
|
|
|
func _pose_bone(bone_name: String, angle: float, axis: Vector3) -> void:
|
|
if not _bone_indices.has(bone_name):
|
|
return
|
|
var index: int = _bone_indices[bone_name]
|
|
# Bone pose rotation is the absolute local orientation, not an offset from
|
|
# rest; compose with the rest rotation so identity preserves the bind pose.
|
|
var rest := _skeleton.get_bone_rest(index).basis.get_rotation_quaternion()
|
|
_skeleton.set_bone_pose_rotation(index, rest * Quaternion(axis, angle))
|
|
|
|
func _build_body(npc_type: int) -> void:
|
|
_rig = Node3D.new()
|
|
_rig.name = "Rig"
|
|
add_child(_rig)
|
|
var outfit := outfit_for(npc_type, _visual_seed)
|
|
var shirt: Color = outfit["shirt"]
|
|
var sleeves: Color = outfit["sleeves"]
|
|
var pants: Color = outfit["pants"]
|
|
var skin: Color = outfit["skin"]
|
|
var hair: Color = outfit["hair"]
|
|
_height_scale = float(outfit["height_scale"])
|
|
_rig.scale.y = _height_scale
|
|
_shirt_color = shirt
|
|
_skin_color = skin
|
|
var limbs_color := sleeves
|
|
# Hips and torso, with a shoulder block so the silhouette is not a tube.
|
|
_add_box_mesh(_rig, Vector3(0.42, 0.64, 0.27), Vector3(0, 1.05, 0), shirt, "cloth")
|
|
_add_box_mesh(_rig, Vector3(0.50, 0.13, 0.26), Vector3(0, 1.38, 0), shirt, "cloth")
|
|
_add_box_mesh(_rig, Vector3(0.38, 0.20, 0.25), Vector3(0, 0.70, 0), pants, "cloth")
|
|
_add_layer(str(outfit["layer"]), shirt)
|
|
# Articulated legs: hip pivot -> thigh -> knee -> shin -> shoe.
|
|
for side in [-1.0, 1.0]:
|
|
var hip := Node3D.new()
|
|
hip.name = "Hip%s" % ("L" if side < 0.0 else "R")
|
|
hip.position = Vector3(0.13 * side, 0.64, 0)
|
|
_rig.add_child(hip)
|
|
_add_capsule(hip, 0.105, 0.36, Vector3(0, -0.17, 0), pants, 0.0, "cloth")
|
|
var knee := Node3D.new()
|
|
knee.name = "Knee"
|
|
knee.position = Vector3(0, -0.34, 0)
|
|
hip.add_child(knee)
|
|
_add_capsule(knee, 0.09, 0.32, Vector3(0, -0.15, 0), pants, 0.0, "cloth")
|
|
_add_box_mesh(knee, Vector3(0.16, 0.10, 0.30),
|
|
Vector3(0, -0.33, 0.05), Color(0.12, 0.12, 0.14), "rubber")
|
|
_legs.append(hip)
|
|
_knees.append(knee)
|
|
# Articulated arms: shoulder pivot -> upper arm -> elbow -> forearm -> hand.
|
|
for side in [-1.0, 1.0]:
|
|
var shoulder := Node3D.new()
|
|
shoulder.name = "Shoulder%s" % ("L" if side < 0.0 else "R")
|
|
shoulder.position = Vector3(0.33 * side, 1.38, 0)
|
|
_rig.add_child(shoulder)
|
|
_add_capsule(shoulder, 0.075, 0.32, Vector3(0, -0.15, 0), limbs_color, 0.0, "cloth")
|
|
var elbow := Node3D.new()
|
|
elbow.name = "Elbow"
|
|
elbow.position = Vector3(0, -0.30, 0)
|
|
shoulder.add_child(elbow)
|
|
_add_capsule(elbow, 0.065, 0.28, Vector3(0, -0.14, 0), limbs_color, 0.0, "cloth")
|
|
var hand := Node3D.new()
|
|
hand.name = "Hand"
|
|
hand.position = Vector3(0, -0.30, 0.02)
|
|
elbow.add_child(hand)
|
|
_add_sphere(hand, 0.065, Vector3.ZERO, skin, "skin")
|
|
_arms.append(shoulder)
|
|
_elbows.append(elbow)
|
|
_hands.append(hand)
|
|
# Neck, head, face, hair, so the body reads as a person (ADR-0010).
|
|
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), skin, 0.0, "skin")
|
|
_head = Node3D.new()
|
|
_head.name = "Head"
|
|
_head.position = Vector3(0, 1.62, 0)
|
|
_add_sphere(_head, 0.17, Vector3.ZERO, skin, "skin")
|
|
_add_ears(_head, skin)
|
|
_add_face(_head)
|
|
_add_hair(_head, str(outfit["hair_style"]), hair)
|
|
_rig.add_child(_head)
|
|
_add_accessory(str(outfit["accessory"]), shirt)
|
|
_add_hand_item(str(outfit["hand_item"]), skin)
|
|
_build_focus_circle()
|
|
_add_props(npc_type)
|
|
|
|
## Outer layer: jacket, hi-vis vest, coat, or dress over the base shirt.
|
|
func _add_layer(kind: String, shirt: Color) -> void:
|
|
match kind:
|
|
"jacket":
|
|
_add_box_mesh(_rig, Vector3(0.46, 0.54, 0.31), Vector3(0, 1.08, 0),
|
|
shirt.darkened(0.28), "cloth")
|
|
_add_box_mesh(_rig, Vector3(0.05, 0.48, 0.02), Vector3(0, 1.12, 0.165),
|
|
shirt.darkened(0.42), "cloth")
|
|
"vest":
|
|
_add_box_mesh(_rig, Vector3(0.45, 0.44, 0.30), Vector3(0, 1.06, 0),
|
|
Color(0.92, 0.72, 0.12), "cloth")
|
|
_add_box_mesh(_rig, Vector3(0.48, 0.06, 0.31), Vector3(0, 0.84, 0),
|
|
Color(0.82, 0.82, 0.84), "cloth")
|
|
"coat":
|
|
_add_box_mesh(_rig, Vector3(0.47, 0.80, 0.33), Vector3(0, 1.01, 0),
|
|
shirt.darkened(0.35), "cloth")
|
|
_add_box_mesh(_rig, Vector3(0.54, 0.08, 0.30), Vector3(0, 1.41, 0),
|
|
shirt.darkened(0.42), "cloth")
|
|
_add_box_mesh(_rig, Vector3(0.04, 0.60, 0.02), Vector3(0, 1.02, 0.17),
|
|
shirt.darkened(0.5), "cloth")
|
|
"dress":
|
|
var skirt := CylinderMesh.new()
|
|
skirt.top_radius = 0.24
|
|
skirt.bottom_radius = 0.38
|
|
skirt.height = 0.52
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = skirt
|
|
mi.position = Vector3(0, 0.82, 0)
|
|
mi.material_override = _material(shirt.darkened(0.1), "cloth")
|
|
_rig.add_child(mi)
|
|
_:
|
|
pass
|
|
|
|
func _add_ears(head: Node3D, skin: Color) -> void:
|
|
for side in [-1.0, 1.0]:
|
|
_add_sphere(head, 0.045, Vector3(0.165 * side, 0.0, 0.0), skin, "skin")
|
|
|
|
func _add_face(head: Node3D) -> void:
|
|
var eye := Color(0.08, 0.08, 0.1)
|
|
for side in [-1.0, 1.0]:
|
|
_add_sphere(head, 0.032, Vector3(0.06 * side, 0.02, 0.145), eye, "glass")
|
|
_add_box_mesh(head, Vector3(0.07, 0.015, 0.02),
|
|
Vector3(0.06 * side, 0.075, 0.152), Color(0.16, 0.12, 0.10), "cloth")
|
|
_add_box_mesh(head, Vector3(0.09, 0.015, 0.02), Vector3(0, -0.075, 0.15),
|
|
Color(0.42, 0.22, 0.20), "cloth")
|
|
|
|
func _add_hair(head: Node3D, style: String, color: Color) -> void:
|
|
match style:
|
|
"long":
|
|
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
|
|
_add_box_mesh(head, Vector3(0.30, 0.36, 0.12), Vector3(0, -0.14, -0.11),
|
|
color, "cloth")
|
|
"bun":
|
|
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
|
|
_add_sphere(head, 0.09, Vector3(0, 0.20, -0.10), color, "cloth")
|
|
"beard":
|
|
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
|
|
_add_box_mesh(head, Vector3(0.22, 0.14, 0.10), Vector3(0, -0.11, 0.09),
|
|
color.darkened(0.1), "cloth")
|
|
"bald":
|
|
pass
|
|
_:
|
|
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
|
|
|
|
## Hand-held item attached to the right hand socket: phone, cup, clipboard,
|
|
## umbrella, camera, or nothing.
|
|
func _add_hand_item(kind: String, skin: Color) -> void:
|
|
if _hands.size() < 2:
|
|
return
|
|
var item := kind
|
|
match character_model:
|
|
"npc_firefighter":
|
|
item = "flashlight"
|
|
"npc_guard":
|
|
item = "radio"
|
|
"npc_hoodie":
|
|
if item == "none":
|
|
item = "cup"
|
|
if item != "none" and _attach_gear(item):
|
|
return
|
|
var hand: Node3D = _hands[1]
|
|
match kind:
|
|
"phone":
|
|
_detail(_add_box_mesh(hand, Vector3(0.055, 0.11, 0.012), Vector3(0, -0.03, 0.02),
|
|
Color(0.10, 0.11, 0.14), "glass"))
|
|
"cup":
|
|
_detail(_add_capsule(hand, 0.035, 0.10, Vector3(0, -0.05, 0.02),
|
|
Color(0.90, 0.88, 0.82), 0.0, "cloth"))
|
|
"clipboard":
|
|
_detail(_add_box_mesh(hand, Vector3(0.22, 0.28, 0.02), Vector3(0, -0.08, 0.05),
|
|
Color(0.55, 0.40, 0.22), "wood"))
|
|
_detail(_add_box_mesh(hand, Vector3(0.16, 0.02, 0.01), Vector3(0, 0.05, 0.07),
|
|
Color(0.85, 0.85, 0.88), "metal"))
|
|
"umbrella":
|
|
_detail(_add_capsule(hand, 0.014, 0.85, Vector3(0, -0.30, 0.02),
|
|
Color(0.15, 0.16, 0.20), 0.0, "metal"))
|
|
var canopy := CylinderMesh.new()
|
|
canopy.top_radius = 0.02
|
|
canopy.bottom_radius = 0.34
|
|
canopy.height = 0.22
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = canopy
|
|
mi.position = Vector3(0, 0.12, 0.02)
|
|
mi.material_override = _material(skin.lerp(Color(0.2, 0.25, 0.4), 0.7), "cloth")
|
|
hand.add_child(mi)
|
|
_detail(mi)
|
|
"camera":
|
|
_detail(_add_box_mesh(hand, Vector3(0.16, 0.10, 0.09), Vector3(0, -0.04, 0.02),
|
|
Color(0.10, 0.10, 0.12), "metal"))
|
|
_detail(_add_sphere(hand, 0.045, Vector3(0, -0.02, 0.08), Color(0.2, 0.35, 0.5), "glass"))
|
|
|
|
## Load and attach a Blender gear GLB at its documented attach point. Returns
|
|
## false when the asset is missing so the primitive fallback still works.
|
|
func _attach_gear(gear_name: String) -> bool:
|
|
if not GEAR_ATTACH.has(gear_name):
|
|
return false
|
|
var path := BLENDER_GEAR_DIR + gear_name + ".glb"
|
|
if not ResourceLoader.exists(path):
|
|
return false
|
|
var packed := ResourceLoader.load(path) as PackedScene
|
|
if packed == null:
|
|
return false
|
|
var node := packed.instantiate() as Node3D
|
|
if node == null:
|
|
return false
|
|
node.name = "Gear_" + gear_name
|
|
match str(GEAR_ATTACH[gear_name]):
|
|
"head":
|
|
if _head == null:
|
|
node.free()
|
|
return false
|
|
_head.add_child(node)
|
|
"hand":
|
|
if _hands.size() < 2:
|
|
node.free()
|
|
return false
|
|
_hands[1].add_child(node)
|
|
"back":
|
|
if _rig == null:
|
|
node.free()
|
|
return false
|
|
node.position = Vector3(0, 1.10, 0)
|
|
_rig.add_child(node)
|
|
"chest":
|
|
if _rig == null:
|
|
node.free()
|
|
return false
|
|
node.position = Vector3(0, 1.30, 0)
|
|
_rig.add_child(node)
|
|
_:
|
|
node.free()
|
|
return false
|
|
if GEAR_OFFSETS.has(gear_name):
|
|
node.position += GEAR_OFFSETS[gear_name]
|
|
if str(GEAR_ATTACH[gear_name]) == "hand":
|
|
node.position += HAND_ITEM_FORWARD
|
|
_detail_nodes.append(node)
|
|
var ao := Materials.load_ao(BLENDER_GEAR_DIR + gear_name + "_ao.png")
|
|
for child in node.find_children("*", "MeshInstance3D", true, false):
|
|
Materials.apply_ao(child as MeshInstance3D, ao)
|
|
return true
|
|
|
|
## Character-specific library gear (firefighter helmet/SCBA, dress bag, vest
|
|
## cap). Accessory/hand items already cover the shared roles.
|
|
func _add_character_gear() -> void:
|
|
match character_model:
|
|
"npc_firefighter":
|
|
_attach_gear("firefighter_helmet")
|
|
_attach_gear("scba_tank")
|
|
"npc_guard":
|
|
_attach_gear("cap")
|
|
"npc_vest":
|
|
_attach_gear("cap")
|
|
"npc_dress":
|
|
_attach_gear("shoulder_bag")
|
|
|
|
## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
|
|
func _add_accessory(kind: String, shirt: Color) -> void:
|
|
if character_model == "npc_firefighter":
|
|
return
|
|
if character_model == "npc_hoodie" and kind == "hood":
|
|
# The hoodie variant already models the hood up.
|
|
return
|
|
if _attach_gear(kind):
|
|
return
|
|
match kind:
|
|
"cap":
|
|
_detail(_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth"))
|
|
_detail(_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth"))
|
|
"hood":
|
|
_detail(_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth"))
|
|
"backpack":
|
|
_detail(_add_box_mesh(_rig, Vector3(0.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth"))
|
|
_detail(_add_box_mesh(_rig, Vector3(0.30, 0.06, 0.02), Vector3(0, 1.24, -0.33), Color(0.42, 0.44, 0.5), "cloth"))
|
|
"sunglasses":
|
|
_detail(_add_box_mesh(_head, Vector3(0.24, 0.05, 0.03), Vector3(0, 0.04, 0.15), Color(0.06, 0.06, 0.08), "glass"))
|
|
"lanyard":
|
|
_detail(_add_box_mesh(_rig, Vector3(0.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal"))
|
|
_detail(_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(-0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth"))
|
|
_detail(_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth"))
|
|
_:
|
|
pass
|
|
|
|
func _build_focus_circle() -> void:
|
|
_focus_circle = MeshInstance3D.new()
|
|
_focus_circle.name = "FocusCircle"
|
|
# Blue, semi-transparent disc floating in front of the face (ADR-0008).
|
|
# Billboarded so it always reads as a circle on screen.
|
|
var disc := CylinderMesh.new()
|
|
disc.top_radius = 0.15
|
|
disc.bottom_radius = 0.15
|
|
disc.height = 0.001
|
|
disc.radial_segments = 24
|
|
_focus_circle.mesh = disc
|
|
var mat := StandardMaterial3D.new()
|
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
mat.albedo_color = Color(0.25, 0.55, 1.0, 0.45)
|
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
|
_focus_circle.material_override = mat
|
|
_focus_circle.position = Vector3(0, 0, 0.30)
|
|
_focus_circle.visible = false
|
|
_head.add_child(_focus_circle)
|
|
_name_tag = Label3D.new()
|
|
_name_tag.name = "NameTag"
|
|
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
|
|
_name_tag.font_size = 48
|
|
_name_tag.pixel_size = 0.004
|
|
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
|
_name_tag.position = Vector3(0, 0.58, 0)
|
|
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
|
|
_name_tag.visible = false
|
|
_head.add_child(_name_tag)
|
|
_reaction_tag = Label3D.new()
|
|
_reaction_tag.name = "ReactionTag"
|
|
_reaction_tag.font_size = 72
|
|
_reaction_tag.pixel_size = 0.005
|
|
_reaction_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
|
_reaction_tag.position = Vector3(0, 2.05, 0)
|
|
_reaction_tag.visible = false
|
|
_rig.add_child(_reaction_tag)
|
|
|
|
func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
|
if _focus_circle:
|
|
_focus_circle.visible = focused
|
|
var mat := _focus_circle.material_override as StandardMaterial3D
|
|
if mat:
|
|
mat.albedo_color = Color(0.35, 0.65, 1.0, 0.7) if menu_open else Color(0.25, 0.55, 1.0, 0.45)
|
|
_focus_circle.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
|
|
if _name_tag:
|
|
_name_tag.visible = focused
|
|
|
|
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3,
|
|
color: Color, z_rot: float = 0.0, role: String = "cloth") -> MeshInstance3D:
|
|
var mi := MeshInstance3D.new()
|
|
var mesh := CapsuleMesh.new()
|
|
mesh.radius = radius
|
|
mesh.height = height
|
|
mi.mesh = mesh
|
|
mi.position = pos
|
|
mi.rotation_degrees.z = z_rot
|
|
mi.material_override = _material(color, role)
|
|
parent.add_child(mi)
|
|
return mi
|
|
|
|
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
|
|
role: String = "skin") -> MeshInstance3D:
|
|
var mi := MeshInstance3D.new()
|
|
var mesh := SphereMesh.new()
|
|
mesh.radius = radius
|
|
mesh.height = radius * 2.0
|
|
mi.mesh = mesh
|
|
mi.position = pos
|
|
mi.material_override = _material(color, role)
|
|
parent.add_child(mi)
|
|
return mi
|
|
|
|
## Register a small dress detail so the mid-distance LOD can hide it.
|
|
func _detail(node: Node3D) -> Node3D:
|
|
_detail_nodes.append(node)
|
|
return node
|
|
|
|
func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color,
|
|
role: String = "cloth") -> MeshInstance3D:
|
|
var mi := MeshInstance3D.new()
|
|
var mesh := BoxMesh.new()
|
|
mesh.size = size
|
|
mi.mesh = mesh
|
|
mi.position = pos
|
|
mi.material_override = _material(color, role)
|
|
parent.add_child(mi)
|
|
return mi
|
|
|
|
func _material(color: Color, role: String = "cloth") -> StandardMaterial3D:
|
|
return Materials.flat(color, role)
|
|
|
|
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
|
func _add_props(npc_type: int) -> void:
|
|
var chest_y := 1.25
|
|
match npc_type:
|
|
Rules.NpcType.INFLUENCER:
|
|
_photo_prop = _add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
|
_photo_prop.name = "PhotoProp"
|
|
_photo_prop.visible = false
|
|
_photo_prop_base_y = _photo_prop.position.y
|
|
_photo_flash = _add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
|
|
_photo_flash.visible = false
|
|
Rules.NpcType.DELIVERY:
|
|
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
|
|
Rules.NpcType.PERFORMER:
|
|
var hat := _material(Color(0.85, 0.2, 0.25))
|
|
var cone := MeshInstance3D.new()
|
|
var cm := CylinderMesh.new()
|
|
cm.top_radius = 0.02
|
|
cm.bottom_radius = 0.22
|
|
cm.height = 0.4
|
|
cone.mesh = cm
|
|
cone.material_override = hat
|
|
cone.position = Vector3(0, 0.26, 0)
|
|
_head.add_child(cone)
|
|
# Hat in one hand and a small board in the other: the performer plays
|
|
# to the sidewalk once at the waypoint.
|
|
_add_box_prop(_rig, Vector3(0.06, 0.5, 0.06), Vector3(-0.42, 1.3, 0.14), Color(0.45, 0.35, 0.25))
|
|
_sign_prop = _add_box_prop(_rig, Vector3(0.42, 0.32, 0.03), Vector3(-0.46, 1.6, 0.14), Color(0.95, 0.9, 0.75))
|
|
_sign_prop_base_y = _sign_prop.position.y
|
|
Rules.NpcType.PROTESTER:
|
|
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
|
|
_sign_prop = _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
|
_sign_prop_base_y = _sign_prop.position.y
|
|
Rules.NpcType.TOURIST:
|
|
_photo_prop = _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
|
_photo_prop.name = "PhotoProp"
|
|
_photo_prop.visible = false
|
|
_photo_prop_base_y = _photo_prop.position.y
|
|
Rules.NpcType.CONFUSED_VISITOR:
|
|
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
|
|
|
|
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
|
|
var mi := MeshInstance3D.new()
|
|
var mesh := BoxMesh.new()
|
|
mesh.size = size
|
|
mi.mesh = mesh
|
|
mi.position = pos
|
|
mi.material_override = _material(color)
|
|
parent.add_child(mi)
|
|
return mi
|
|
|
|
## Rubberneck: idle people stop and turn toward the incident. Returns false when
|
|
## this NPC is too engaged (walking, being talked to, already leaving) to watch.
|
|
func start_rubberneck(at: Vector3) -> bool:
|
|
if not arrived or npc.leave_requested or leave_requested_by_lifetime:
|
|
return false
|
|
# Someone actually waiting at the entrance is being dealt with, not a bystander.
|
|
# Avatars live under the identity SceneRoot, so `position` matches world space
|
|
# and also works before the node enters the tree (tests).
|
|
if awaiting_interaction and (position - door_position).length() < 3.0:
|
|
return false
|
|
if reaction_mode == "flee":
|
|
return false
|
|
reaction_mode = "watch"
|
|
reaction_point = at
|
|
_reaction_timer = RUBBERNECK_S
|
|
velocity = Vector3.ZERO
|
|
return true
|
|
|
|
## Panic: clear away from the incident (usually the door) in a straight line.
|
|
func start_flee(from: Vector3) -> void:
|
|
if npc.leave_requested or leave_requested_by_lifetime:
|
|
return
|
|
reaction_mode = "flee"
|
|
reaction_point = from
|
|
_reaction_timer = FLEE_S
|
|
var away := global_position - from
|
|
away.y = 0.0
|
|
if away.length() < 0.5:
|
|
away = Vector3(0.0, 0.0, 1.0)
|
|
target_position = global_position + away.normalized() * 25.0
|
|
target_position.y = 0.5
|
|
arrived = false
|
|
awaiting_interaction = false
|
|
|
|
func reaction_is_active() -> bool:
|
|
return reaction_mode != ""
|
|
|
|
func _tick_reaction(delta: float) -> void:
|
|
if reaction_mode == "":
|
|
return
|
|
_reaction_timer -= delta
|
|
if reaction_mode == "flee":
|
|
if _reaction_timer <= 0.0 or (global_position - target_position).length() < 1.5:
|
|
reaction_mode = ""
|
|
queue_free()
|
|
return
|
|
if _reaction_timer <= 0.0:
|
|
reaction_mode = ""
|
|
|
|
## Turn the body toward the reaction point without moving.
|
|
func _face_reaction_point(delta: float) -> void:
|
|
var to_point := reaction_point - global_position
|
|
to_point.y = 0.0
|
|
if to_point.length() < 0.1:
|
|
return
|
|
rotation.y = lerp_angle(rotation.y, yaw_towards(to_point),
|
|
clampf(delta * 4.0, 0.0, 1.0))
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
if npc == null or npc.fsm == null:
|
|
return
|
|
_tick_lod(delta)
|
|
if awaiting_interaction and _queue_offset != Vector3.ZERO:
|
|
var queued := door_position + _queue_offset
|
|
if target_position.distance_to(queued) > 0.05:
|
|
target_position = queued
|
|
arrived = false
|
|
_advance_fsm(delta)
|
|
_tick_reaction(delta)
|
|
if reaction_mode == "watch":
|
|
velocity = Vector3.ZERO
|
|
_face_reaction_point(delta)
|
|
_animate(delta)
|
|
return
|
|
if reaction_mode == "flee":
|
|
_move_toward_target(delta)
|
|
_animate(delta)
|
|
return
|
|
_move_toward_target(delta)
|
|
_animate(delta)
|
|
|
|
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
|
|
## Theater pose for the current intent once the NPC has stopped walking.
|
|
## "sit" at the steps, "photo" for camera props, "perform" for the hat/sign,
|
|
## "film" for a slow pan, otherwise "idle".
|
|
func theater_pose() -> String:
|
|
if not arrived:
|
|
return "idle"
|
|
match npc.intent:
|
|
"sit":
|
|
return "sit"
|
|
"photo":
|
|
return "photo"
|
|
"film":
|
|
return "film"
|
|
"perform":
|
|
return "perform"
|
|
_:
|
|
return "idle"
|
|
|
|
## How far the rig drops for a seated pose. Pure so it is headless-testable.
|
|
static func crouch_for_pose(pose: String) -> float:
|
|
return -0.34 if pose == "sit" else 0.0
|
|
|
|
## Vertical lift of a held camera prop. `phase` is seconds; the raise is brief so
|
|
## it reads as taking a photo rather than a permanently raised arm.
|
|
static func prop_lift_for_pose(pose: String, phase: float) -> float:
|
|
if pose != "photo" and pose != "film":
|
|
return 0.0
|
|
return maxf(0.0, sin(phase * 1.7)) * 0.16
|
|
|
|
## The tourist's camera bag and the influencer's chest camera are only part of
|
|
## the silhouette while the character is actually taking photos. Keeping them
|
|
## hidden at other times prevents the tourist from reading as two backpacks.
|
|
static func photo_prop_visible_for_pose(pose: String) -> bool:
|
|
return pose == "photo" or pose == "film"
|
|
|
|
## Opposite arm/leg swing for the walk cycle. `side` is 0 or 1; pure so the gait
|
|
## can be checked without a scene tree.
|
|
static func limb_swing(phase: float, side: int, amplitude: float) -> float:
|
|
return sin(phase + float(side) * PI) * amplitude
|
|
|
|
## Sway amount for a performer playing to the sidewalk.
|
|
static func sway_for_pose(pose: String, phase: float) -> float:
|
|
if pose != "perform":
|
|
return 0.0
|
|
return sin(phase * 2.2) * 0.09
|
|
|
|
func _animate(delta: float) -> void:
|
|
if _rig == null:
|
|
return
|
|
var speed := Vector2(velocity.x, velocity.z).length()
|
|
var pose := theater_pose()
|
|
var escalation: int = npc.highest_escalation_received if npc != null else 0
|
|
var crouch := crouch_for_pose(pose)
|
|
if _photo_prop:
|
|
_photo_prop.visible = photo_prop_visible_for_pose(pose)
|
|
if _photo_flash:
|
|
_photo_flash.visible = photo_prop_visible_for_pose(pose)
|
|
if speed > 0.3:
|
|
_idle_t = 0.0
|
|
_bob_phase += delta * (6.0 + speed * 1.7)
|
|
var urgency := 1.35 if reaction_mode == "flee" else 1.0
|
|
# Coats and skirts follow the hip/knee bones; a shorter stride keeps the
|
|
# hem from clipping through the legs during normal walking.
|
|
var hem_scale := 0.76 if character_model in ["npc_dress", "npc_coat"] else 1.0
|
|
var stride := clampf(0.36 + speed * 0.10, 0.42, 0.7) * urgency * hem_scale
|
|
_rig.position.y = sin(_bob_phase * 2.0) * 0.03
|
|
_rig.position.x = 0.0
|
|
_rig.rotation.x = sin(_bob_phase) * 0.03
|
|
_rig.rotation.z = sin(_bob_phase) * 0.02
|
|
for i in _legs.size():
|
|
var swing := limb_swing(_bob_phase, i, stride)
|
|
_legs[i].rotation.x = swing
|
|
# Bend the knee as the leg travels behind the body.
|
|
var bend := maxf(0.0, -swing) * (1.7 if reaction_mode == "flee" else 1.15)
|
|
_knees[i].rotation.x = lerpf(_knees[i].rotation.x, bend, 0.35)
|
|
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.34 * urgency)
|
|
_elbows[i].rotation.x = -0.18 - absf(swing) * 0.55
|
|
elif pose != "idle" or not is_zero_approx(_rig.position.y):
|
|
# Idle theater: seated, photographing, filming, or performing.
|
|
_theater_t += delta
|
|
_idle_t = 0.0
|
|
_bob_phase = 0.0
|
|
_rig.position.y = lerpf(_rig.position.y, crouch, 0.12)
|
|
_rig.position.x = lerpf(_rig.position.x, 0.0, 0.2)
|
|
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
|
var lift := prop_lift_for_pose(pose, _theater_t)
|
|
if _photo_prop:
|
|
_photo_prop.position.y = _photo_prop_base_y + lift
|
|
_photo_prop.rotation.x = -lift * 1.4
|
|
var sway := sway_for_pose(pose, _theater_t)
|
|
if _sign_prop:
|
|
_sign_prop.rotation.z = sway
|
|
_sign_prop.position.y = _sign_prop_base_y + absf(sway) * 0.25
|
|
if _legs.size() == 2:
|
|
# Negative pitch swings the legs forward (toward the way the body faces).
|
|
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
|
|
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
|
|
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
|
|
var knee_angle := deg_to_rad(80.0) if pose == "sit" else 0.0
|
|
_knees[0].rotation.x = lerpf(_knees[0].rotation.x, knee_angle, 0.12)
|
|
_knees[1].rotation.x = lerpf(_knees[1].rotation.x, knee_angle, 0.12)
|
|
for arm in _arms:
|
|
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14)
|
|
for elbow in _elbows:
|
|
elbow.rotation.x = lerpf(elbow.rotation.x, -0.1, 0.14)
|
|
else:
|
|
_bob_phase = 0.0
|
|
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
|
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
|
for i in _legs.size():
|
|
_legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2)
|
|
_knees[i].rotation.x = lerpf(_knees[i].rotation.x, 0.0, 0.2)
|
|
for i in _arms.size():
|
|
_arms[i].rotation.x = lerpf(_arms[i].rotation.x, 0.0, 0.18)
|
|
_elbows[i].rotation.x = lerpf(_elbows[i].rotation.x, -0.08, 0.18)
|
|
_idle_t += delta
|
|
_idle_layer(pose, escalation)
|
|
# The focus circle billboards via its material; no per-frame look_at needed.
|
|
# Attention cue: head turns toward the guard when awareness was raised.
|
|
if _head != null:
|
|
if npc != null and npc.guard_noticed:
|
|
if _guard_ref == null or not is_instance_valid(_guard_ref):
|
|
_guard_ref = get_tree().get_first_node_in_group("guard") as Node3D
|
|
if _guard_ref:
|
|
var to_guard := (_guard_ref.global_position - global_position)
|
|
to_guard.y = 0.0
|
|
var world_yaw := atan2(to_guard.x, to_guard.z)
|
|
var fwd := -_rig.global_transform.basis.z
|
|
var parent_yaw := atan2(fwd.x, fwd.z)
|
|
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
|
|
elif speed <= 0.3 and pose == "idle":
|
|
# Idle look-around, unless the person is already engaged.
|
|
_head.rotation.y = lerpf(_head.rotation.y, sin(_idle_t * 0.45) * 0.3, 0.05)
|
|
_update_reaction_label(escalation)
|
|
_sync_skeleton()
|
|
|
|
## Idle behaviour layered on top of the breathing pose: phone, clipboard,
|
|
## crossed arms, or looking around, plus escalation body language.
|
|
func _idle_layer(pose: String, escalation: int) -> void:
|
|
if _rig == null:
|
|
return
|
|
# Breathing and a slow weight shift keep idle people from looking frozen.
|
|
var breath := 1.0 + sin(_idle_t * 1.8) * 0.006
|
|
if _skeleton == null:
|
|
_rig.scale.y = lerpf(_rig.scale.y, _height_scale * breath, 0.15)
|
|
else:
|
|
# Skinned meshes must scale uniformly; non-uniform parent scale shears
|
|
# the skeleton's skin matrices.
|
|
_rig.scale = Vector3.ONE * lerpf(_rig.scale.x, _height_scale * breath, 0.15)
|
|
_rig.position.x = lerpf(_rig.position.x, sin(_idle_t * 0.7) * 0.015, 0.1)
|
|
_rig.rotation.z = lerpf(_rig.rotation.z, sin(_idle_t * 0.7) * 0.015, 0.1)
|
|
if pose != "idle" or _arms.size() < 2:
|
|
return
|
|
var right := _arms[1]
|
|
var right_elbow := _elbows[1]
|
|
if escalation >= 3:
|
|
# Both hands up: the person is being blocked or warned.
|
|
_arms[0].rotation.x = lerpf(_arms[0].rotation.x, -1.75, 0.14)
|
|
right.rotation.x = lerpf(right.rotation.x, -1.75, 0.14)
|
|
_elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -0.5, 0.14)
|
|
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.5, 0.14)
|
|
_rig.rotation.x = lerpf(_rig.rotation.x, -0.06, 0.1)
|
|
return
|
|
if escalation >= 2:
|
|
# One arm out, pointing forward while arguing.
|
|
right.rotation.x = lerpf(right.rotation.x, -1.15, 0.12)
|
|
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.15, 0.12)
|
|
return
|
|
match _idle_style:
|
|
"phone":
|
|
# Phone held up near the head in bursts.
|
|
var raise := 0.55 + maxf(0.0, sin(_idle_t * 0.6)) * 0.45
|
|
right.rotation.x = lerpf(right.rotation.x, -1.35 * raise, 0.08)
|
|
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -1.75 * raise, 0.1)
|
|
"clipboard":
|
|
_arms[0].rotation.x = lerpf(_arms[0].rotation.x, -0.55, 0.1)
|
|
_elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -0.9, 0.1)
|
|
right.rotation.x = lerpf(right.rotation.x, -0.45, 0.1)
|
|
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.8, 0.1)
|
|
"crossed":
|
|
_arms[0].rotation.x = lerpf(_arms[0].rotation.x, -0.55, 0.1)
|
|
right.rotation.x = lerpf(right.rotation.x, -0.55, 0.1)
|
|
_elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -1.25, 0.1)
|
|
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -1.25, 0.1)
|
|
"look":
|
|
pass
|
|
_:
|
|
pass
|
|
|
|
func _update_reaction_label(escalation: int) -> void:
|
|
if _reaction_tag == null:
|
|
return
|
|
var text := reaction_label(reaction_mode, escalation)
|
|
_reaction_tag.visible = text != ""
|
|
if text == "":
|
|
return
|
|
_reaction_tag.text = text
|
|
if reaction_mode == "flee":
|
|
_reaction_tag.modulate = Color(1.0, 0.26, 0.2)
|
|
elif reaction_mode == "watch":
|
|
_reaction_tag.modulate = Color(1.0, 0.72, 0.2)
|
|
elif escalation >= 3:
|
|
_reaction_tag.modulate = Color(1.0, 0.35, 0.3)
|
|
else:
|
|
_reaction_tag.modulate = Color(1.0, 0.85, 0.4)
|
|
|
|
## Build the far-distance impostor: two billboard quads (body + head) in the
|
|
## avatar's clothing colours.
|
|
func _build_impostor() -> void:
|
|
_impostor = Node3D.new()
|
|
_impostor.name = "Impostor"
|
|
_impostor.visible = false
|
|
add_child(_impostor)
|
|
var body := MeshInstance3D.new()
|
|
var quad := QuadMesh.new()
|
|
quad.size = Vector2(0.56, 1.15)
|
|
body.mesh = quad
|
|
body.position = Vector3(0, 0.95, 0)
|
|
body.material_override = _billboard_material(_shirt_color)
|
|
_impostor.add_child(body)
|
|
var head := MeshInstance3D.new()
|
|
var head_quad := QuadMesh.new()
|
|
head_quad.size = Vector2(0.34, 0.34)
|
|
head.mesh = head_quad
|
|
head.position = Vector3(0, 1.63, 0)
|
|
head.material_override = _billboard_material(_skin_color)
|
|
_impostor.add_child(head)
|
|
|
|
func _billboard_material(color: Color) -> StandardMaterial3D:
|
|
var material := StandardMaterial3D.new()
|
|
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
material.albedo_color = color
|
|
material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
|
return material
|
|
|
|
## Apply a LOD tier: 0 full detail, 1 no small props/shadows, 2 impostor.
|
|
func apply_lod(tier: int) -> void:
|
|
_lod_tier = clampi(tier, 0, 2)
|
|
if _rig != null:
|
|
_rig.visible = _lod_tier < 2
|
|
if _impostor != null:
|
|
_impostor.visible = _lod_tier == 2
|
|
for node in _detail_nodes:
|
|
if is_instance_valid(node):
|
|
node.visible = _lod_tier == 0
|
|
var shadows := _lod_tier == 0
|
|
for node in find_children("*", "MeshInstance3D", true, false):
|
|
(node as MeshInstance3D).cast_shadow = \
|
|
GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows \
|
|
else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
|
|
func _lod_tier_for(distance: float) -> int:
|
|
# Hysteresis: stay in the current tier until the distance clears the edge.
|
|
if _lod_tier == 0:
|
|
return 1 if distance > LOD_TIERS[0] + LOD_HYSTERESIS else 0
|
|
if _lod_tier == 1:
|
|
if distance < LOD_TIERS[0] - LOD_HYSTERESIS:
|
|
return 0
|
|
return 2 if distance > LOD_TIERS[1] + LOD_HYSTERESIS else 1
|
|
if distance < LOD_TIERS[1] - LOD_HYSTERESIS:
|
|
return 1 if distance >= LOD_TIERS[0] else 0
|
|
return 2
|
|
|
|
func set_queue_offset(offset: Vector3) -> void:
|
|
_queue_offset = offset
|
|
|
|
func _tick_lod(delta: float) -> void:
|
|
_lod_check_s -= delta
|
|
if _lod_check_s > 0.0:
|
|
return
|
|
_lod_check_s = 0.5
|
|
if not is_inside_tree():
|
|
return
|
|
var camera := get_viewport().get_camera_3d()
|
|
if camera == null:
|
|
return
|
|
var distance := global_position.distance_to(camera.global_position)
|
|
var desired := _lod_tier_for(distance)
|
|
if desired != _lod_tier:
|
|
apply_lod(desired)
|
|
|
|
func _advance_fsm(delta: float) -> void:
|
|
# Routine traffic despawns after a while once it has finished its walk, so
|
|
# the crowd cap is not permanently occupied by idle standers.
|
|
if ephemeral and arrived and npc.fsm.current_state != "LEAVING":
|
|
idle_s += delta
|
|
if idle_s >= lifetime_s:
|
|
leave_requested_by_lifetime = true
|
|
return
|
|
# Returning-trespasser: an NPC directed away may circle back to the door.
|
|
if npc.tick_return(delta):
|
|
arrived = false
|
|
awaiting_interaction = false
|
|
target_position = door_position
|
|
return
|
|
if npc.leave_requested or leave_requested_by_lifetime:
|
|
var away := global_position + (global_position - spawn_position).normalized() * 40.0
|
|
away.y = 0.5
|
|
target_position = away
|
|
if (global_position - away).length() < 2.0:
|
|
queue_free()
|
|
return
|
|
if arrived and not awaiting_interaction:
|
|
awaiting_interaction = true
|
|
|
|
var leave_requested_by_lifetime := false
|
|
|
|
func _move_toward_target(delta: float) -> void:
|
|
_avoidance_active = false
|
|
if agent == null:
|
|
_move_direct(delta)
|
|
return
|
|
# Re-assigning the target every physics frame invalidates the path, so
|
|
# get_next_path_position() keeps returning the current position and the
|
|
# avatar never moves. Only update the agent when the target changes.
|
|
if agent.target_position != target_position:
|
|
agent.target_position = target_position
|
|
if agent.is_navigation_finished():
|
|
if (global_position - target_position).length() < 3.0:
|
|
_arrive()
|
|
else:
|
|
# Path finished but we are far away: navigation is unusable, fall back.
|
|
_move_direct(delta)
|
|
return
|
|
# Learn whether the navmesh actually serves this spot.
|
|
if not _nav_usable:
|
|
_nav_try_frames += 1
|
|
if agent.is_target_reachable():
|
|
_nav_usable = true
|
|
elif _nav_try_frames > 10:
|
|
_move_direct(delta)
|
|
return
|
|
var next := agent.get_next_path_position()
|
|
# Godot reports the current position as the first path point (on the navmesh
|
|
# plane, so below the body) and only advances past it once the agent is within
|
|
# path_desired_distance. Steering at the point we are standing on gives a
|
|
# zero-length direction, and steering at any point merely "far enough" away can
|
|
# pick the old start behind us. Follow the first path point that is genuinely
|
|
# closer to the destination than we are.
|
|
var path := agent.get_current_navigation_path()
|
|
if path.size() >= 2:
|
|
var goal: Vector3 = path[path.size() - 1]
|
|
var here_to_goal := Vector2(goal.x - global_position.x, goal.z - global_position.z).length()
|
|
for i in range(1, path.size()):
|
|
var candidate: Vector3 = path[i]
|
|
candidate.y = global_position.y
|
|
var candidate_to_goal := Vector2(goal.x - candidate.x, goal.z - candidate.z).length()
|
|
if candidate_to_goal < here_to_goal - 0.05:
|
|
next = candidate
|
|
break
|
|
var dir := (next - global_position)
|
|
dir.y = 0.0
|
|
if dir.length() < 0.2:
|
|
velocity = Vector3.ZERO
|
|
return
|
|
dir = dir.normalized()
|
|
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
|
rotation.y = yaw_towards(dir)
|
|
if agent.avoidance_enabled:
|
|
# The navigation server returns the obstacle-aware velocity through
|
|
# velocity_computed; never dash straight at the point in the meantime.
|
|
_avoidance_active = true
|
|
agent.velocity = Vector3(dir.x * speed, 0.0, dir.z * speed)
|
|
else:
|
|
velocity.x = dir.x * speed
|
|
velocity.z = dir.z * speed
|
|
velocity.y -= 9.8 * delta
|
|
move_and_slide()
|
|
|
|
## Safe velocity from NavigationServer avoidance: move with the steered vector.
|
|
func _on_safe_velocity(safe: Vector3) -> void:
|
|
if not _avoidance_active:
|
|
return
|
|
velocity.x = safe.x
|
|
velocity.z = safe.z
|
|
if is_on_floor():
|
|
velocity.y = 0.0
|
|
else:
|
|
velocity.y -= 9.8 * get_physics_process_delta_time()
|
|
move_and_slide()
|
|
|
|
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
|
|
func _move_direct(delta: float) -> void:
|
|
_avoidance_active = false
|
|
var to_target := target_position - global_position
|
|
to_target.y = 0.0
|
|
var dist := to_target.length()
|
|
if dist < 0.7:
|
|
_arrive()
|
|
return
|
|
var dir := to_target.normalized()
|
|
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
|
velocity.x = dir.x * speed
|
|
velocity.z = dir.z * speed
|
|
velocity.y -= 9.8 * delta
|
|
rotation.y = yaw_towards(dir)
|
|
move_and_slide()
|
|
|
|
func _arrive() -> void:
|
|
arrived = true
|
|
velocity = Vector3.ZERO
|
|
if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive":
|
|
target_position = door_position
|
|
if (global_position - door_position).length() < 0.8:
|
|
awaiting_interaction = true
|
|
|
|
func _on_state_changed(_node: Object, _from: String, to_state: String) -> void:
|
|
match to_state:
|
|
"LEAVING":
|
|
npc.leave_requested = true
|
|
"THREATENING", "FORCING_ENTRY":
|
|
# Advance toward the door posture; keep it simple for the prototype.
|
|
pass
|