Character and movement pass
NPC characters - Articulated rig (hips/knees, shoulders/elbows/hands) replaces rigid capsules; the walk cycle bends knees and elbows and flees with a longer stride. - Layered outfits (jacket/vest/coat/dress), extended accessories and hand items (bag, umbrella, phone, camera, clipboard, cup), face details and five hair silhouettes, all deterministic per profile seed. - Idle behaviour layers (phone, clipboard, crossed arms, look-around), breathing and weight shift; escalation raises hands or points, and noticed NPCs turn toward the guard. - Reaction symbols above the head (!!, ?!, !, ?), door-queue offsets assigned by GameManager, three LOD tiers with hysteresis and a billboard impostor. - smoke_npc_budget.gd: 16 avatars = 534 meshes, ~6.9 ms average (50 ms budget). Player movement - Separate accel/decel, step-up assist for kerbs and the entrance steps, and a forward-axis fix: W was inverted (get_axis(forward, back) is negative). - Head bob, landing dip and precision-walk FOV, gated by persisted head-bob and invert-Y settings; precision chip and tick; surface-aware footsteps and a landing cue forwarded to AudioFeedback. - Gamepad support (sticks, deadzone, face buttons, bumpers), a control legend that follows the active device, and named actions for the remaining keys. - Key/button remapping in Settings, persisted as input_bindings and applied by InputBindings (mouse and gamepad axes are never removed). - smoke_movement.gd (windowed): climbs the steps, checks precision speed, chip, footsteps and landing; new 11-precision-walk capture and catalogue entry. Verification: headless 208/208, Python tooling 56/56, all 11 smokes pass, compare 11 good / 0 bad, movement judge match. Version 0.5.0. Only optional Future ideas remain in TODO.
This commit is contained in:
1 parent
2a4e0d1998
commit
a27e8cb74c
35 files changed
+1608
-185
No files matched your search
+484
-49
@@ -26,6 +26,11 @@ 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
|
||||
@@ -64,7 +69,9 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
||||
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)
|
||||
_build_body(profile.npc_type)
|
||||
_build_impostor()
|
||||
_nav_usable = false
|
||||
_nav_try_frames = 0
|
||||
agent = NavigationAgent3D.new()
|
||||
@@ -104,9 +111,23 @@ var _name_tag: Label3D = null
|
||||
## Theater props and limbs for the idle poses (ADR-0010 rig).
|
||||
var _photo_prop: MeshInstance3D = null
|
||||
var _sign_prop: MeshInstance3D = null
|
||||
var _legs: Array[MeshInstance3D] = []
|
||||
var _arms: Array[MeshInstance3D] = []
|
||||
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 _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
|
||||
@@ -132,6 +153,50 @@ const ACCESSORIES := {
|
||||
Rules.NpcType.CONFUSED_VISITOR: "none",
|
||||
}
|
||||
|
||||
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:
|
||||
@@ -142,17 +207,70 @@ static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
|
||||
# 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)]
|
||||
|
||||
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 ""
|
||||
|
||||
func _build_body(npc_type: int) -> void:
|
||||
_rig = Node3D.new()
|
||||
_rig.name = "Rig"
|
||||
@@ -163,53 +281,184 @@ func _build_body(npc_type: int) -> void:
|
||||
var pants: Color = outfit["pants"]
|
||||
var skin: Color = outfit["skin"]
|
||||
var hair: Color = outfit["hair"]
|
||||
_rig.scale.y = float(outfit["height_scale"])
|
||||
_height_scale = float(outfit["height_scale"])
|
||||
_rig.scale.y = _height_scale
|
||||
_shirt_color = shirt
|
||||
_skin_color = skin
|
||||
var limbs_color := sleeves
|
||||
# Torso, with a shoulder block and hips so the silhouette is not a tube.
|
||||
_add_capsule(_rig, 0.30, 0.90, Vector3(0, 1.02, 0), shirt, 0.0, "cloth")
|
||||
_add_box_mesh(_rig, Vector3(0.58, 0.14, 0.28), Vector3(0, 1.36, 0), shirt, "cloth")
|
||||
_add_box_mesh(_rig, Vector3(0.44, 0.18, 0.26), Vector3(0, 0.62, 0), pants, "cloth")
|
||||
# Legs and arms are tracked so the walk cycle can swing them.
|
||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), pants, 0.0, "cloth"))
|
||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), pants, 0.0, "cloth"))
|
||||
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0, "cloth"))
|
||||
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0, "cloth"))
|
||||
# Hands and shoes give the limbs an end point at conversation distance.
|
||||
_add_sphere(_rig, 0.065, Vector3(-0.47, 0.94, 0.03), skin, "skin")
|
||||
_add_sphere(_rig, 0.065, Vector3(0.47, 0.94, 0.03), skin, "skin")
|
||||
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
|
||||
# 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_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), hair, "cloth")
|
||||
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
|
||||
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
|
||||
_add_ears(_head, skin)
|
||||
_add_face(_head)
|
||||
_add_hair(_head, str(outfit["hair_style"]), hair)
|
||||
_rig.add_child(_head)
|
||||
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
|
||||
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
|
||||
_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 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"))
|
||||
|
||||
## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
|
||||
func _add_accessory(kind: String, shirt: Color) -> void:
|
||||
match kind:
|
||||
"cap":
|
||||
_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth")
|
||||
_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth")
|
||||
_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":
|
||||
_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth")
|
||||
_detail(_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth"))
|
||||
"backpack":
|
||||
_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")
|
||||
_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")
|
||||
_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":
|
||||
_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")
|
||||
_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":
|
||||
_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")
|
||||
_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")
|
||||
_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.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
|
||||
|
||||
@@ -243,6 +492,14 @@ func _build_focus_circle() -> void:
|
||||
_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:
|
||||
@@ -268,7 +525,7 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3,
|
||||
return mi
|
||||
|
||||
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
|
||||
role: String = "skin") -> void:
|
||||
role: String = "skin") -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := SphereMesh.new()
|
||||
mesh.radius = radius
|
||||
@@ -277,6 +534,12 @@ func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
|
||||
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:
|
||||
@@ -399,6 +662,12 @@ func _face_reaction_point(delta: float) -> void:
|
||||
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":
|
||||
@@ -459,20 +728,32 @@ func _animate(delta: float) -> void:
|
||||
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 speed > 0.3:
|
||||
_bob_phase += delta * (6.0 + speed)
|
||||
_rig.position.y = sin(_bob_phase) * 0.035
|
||||
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
||||
_idle_t = 0.0
|
||||
_bob_phase += delta * (6.0 + speed * 1.7)
|
||||
var urgency := 1.35 if reaction_mode == "flee" else 1.0
|
||||
var stride := clampf(0.36 + speed * 0.10, 0.42, 0.7) * urgency
|
||||
_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():
|
||||
_legs[i].rotation.x = limb_swing(_bob_phase, i, 0.5)
|
||||
for i in _arms.size():
|
||||
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.38)
|
||||
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:
|
||||
@@ -487,28 +768,182 @@ func _animate(delta: float) -> void:
|
||||
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)
|
||||
for arm in _arms:
|
||||
arm.rotation.x = lerpf(arm.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 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)
|
||||
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)
|
||||
|
||||
## 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
|
||||
_rig.scale.y = lerpf(_rig.scale.y, _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
|
||||
|
||||
Reference in new issue
Block a user