Playtest-Avatar (t_17a35c59): Spieler-Modell = Bot-Rig + First/Third-Person (Taste V)
- playtest_avatar.gd (NEU): Spiegel-Modul des Bot-Avatars (Quelle bot_avatar.gd/MatchRemoteAvatar) — UAL-GLB + m40-Rifle aus dem Nachbar-Klon ../scopedvr-client (GLTFDocument, kein Duplikat), AnimationTree-Pipeline 1:1 (BlendSpace1D Idle/Walk/Jog auf den Clip-Naturgeschwindigkeiten 0/0,979/2,620 m/s, sync, TimeScale-Kadenz nach BOT2: Schrittlängen 0,95/0,85 m, Klammern 1,0–4,5 / 0,6–2,0, Blend-Glättung 0,25 s), Gait-Schwellen 0,3/2,6 m/s aus der ECHTEN PlaytestBody-Geschwindigkeit, Frontachse +Z → MODEL_YAW_OFFSET = PI. Importer-Semantik nachgebildet: "_loop"-Endungen gestrichen + LOOP_LINEAR (GLTFDocument liefert rohe GLTF-Namen). - playtest_body.gd: Körper trägt den Blick-Yaw (1:1 TestmapPlayer rotate_y — vorher weltachsen-fixierte WASD-Bewegung, Beweis-Probe), Avatar-Halterung (Kopf-Anker 1,65 m, GLB-Füße am Boden), Third-Person- SpringArm3D (3,5 m + 0,3 m Margin, eigener Body ausgeschlossen), toggle_perspektive/set_perspektive, horizontale_geschwindigkeit(). - playtest_settings.gd: persistenter Eintrag dritte_person (Default First). - playtest_mode.gd: Avatar-Bau in enter() (graceful ohne Client-Klon: First Person + Klartext-HUD-Hinweis), Taste V, Gait-Speisung pro Frame, HUD-Hinweis + F1-Hilfe erweitert. - settings_panel.gd: Perspektive-Auswahl (Erste/Dritte) im Einstellungen- Panel, live wirksam + persistiert. - tests/test_playtest_avatar.gd (NEU, 84 checks): Identitäts-Vertrag gegen die Client-Quelle (Konstanten aus bot_avatar/match_remote_avatar/ testmap_player gelesen), GLB-Ladung, Anim-Pipeline, Kadenz-Mathe, Modellhöhe == Kapsel == Client (1,83 m), Rifle-Mount, V-Umschalter + Persistenz, SpringArm (Blick zum Körper, kein Clipping durch Wand), Blickrichtung == Bewegungsrichtung, Playtest-Integration (enter + Gait).
This commit is contained in:
parent
52919b36b4
commit
6f00737dd3
8 changed files with 1128 additions and 3 deletions
461
src/editor/playtest/playtest_avatar.gd
Normal file
461
src/editor/playtest/playtest_avatar.gd
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
class_name PlaytestAvatar
|
||||||
|
extends Node3D
|
||||||
|
## Playtest-Avatar (Karte t_17a35c59) — SPIEGEL des Bot-Avatars aus dem
|
||||||
|
## Quest-Client (scripts/match/bot_avatar.gd + MatchRemoteAvatar), Sly-Wunsch
|
||||||
|
## 20.10.: „das Player Model in den Map Editor … genau so wie im Spiel bei
|
||||||
|
## den Bots" + „direkt natürlich auch mit den Animationen bitte!".
|
||||||
|
##
|
||||||
|
## Quelle der Wahrheit: bot_avatar.gd. Dieses Modul übernimmt 1:1:
|
||||||
|
## - das GLB assets/avatars/quaternius_ual/ual1_standard.glb (Quaternius
|
||||||
|
## UAL, CC0) + das Rifle assets/weapons/m40_rifle.glb an hand_r —
|
||||||
|
## aufgelöst über den NACHBAR-KLON ../scopedvr-client (gleiche Auflösung
|
||||||
|
## wie GlbMeshStore/TestRun), NICHT dupliziert. Lädt mit GLTFDocument
|
||||||
|
## (die GLB liegt außerhalb res:// — kein Import-System).
|
||||||
|
## - die AnimationTree-Pipeline (A4/W14): BlendSpace1D Idle/Walk/Jog mit
|
||||||
|
## Blend-Punkten auf den Clip-Naturgeschwindigkeiten (0,0 / 0,979 /
|
||||||
|
## 2,620 m/s), sync = true, TimeScale-Subtrees für die Kadenz, darüber
|
||||||
|
## die StateMachine mit dem Motion-State. Crossfade ~0,2 s.
|
||||||
|
## - die Kadenz-Mathe (BOT2/vc23): Kadenz = m/s / Ziel-Schrittlänge
|
||||||
|
## (Walk 0,95 m / Jog 0,85 m), TimeScale = Kadenz × Zykluslänge / 2
|
||||||
|
## Schritte pro Zyklus, Klammern CADENCE 1,0–4,5 und FOOT_SYNC 0,6–2,0.
|
||||||
|
## - die Gait-Schwellen (A2 §6.2): ≤ 0,3 m/s Idle, ≤ 2,6 m/s Walk, sonst
|
||||||
|
## Jog — im Editor aus der ECHTEN PlaytestBody-Geschwindigkeit gespeist.
|
||||||
|
## - die Frontachse (vc21-Fix): das UAL-GLB blickt nach +Z, der Body-Yaw
|
||||||
|
## richtet −Z aus → MODEL_YAW_OFFSET = π auf dem Modell-Kind. Blick-
|
||||||
|
## richtung == Bewegungsrichtung.
|
||||||
|
## - den Blend-Ruckel-Fix: Sollwerte werden in _process weich nachgezogen
|
||||||
|
## (BLEND_SMOOTH_S = 0,25 s), Parameter-Writes nur bei Änderung.
|
||||||
|
##
|
||||||
|
## NICHT gespiegelt (Match-only, kein Trigger im Editor-Testlauf): Engage/
|
||||||
|
## Shoot/Hit/Death-One-Shots, Team-Tönung, Namensschild, Netzwerk-Interpo-
|
||||||
|
## lation, Ragdoll. Die StateMachine trägt deshalb nur den Motion-State —
|
||||||
|
## die Loop-Pipeline (die Sly sieht) ist identisch zum Spiel.
|
||||||
|
##
|
||||||
|
## Regel (wie KolliderBau/svmap_tint): Ändert der Client die Pipeline, wird
|
||||||
|
## dieser Spiegel MIT geändert. Bewusst KEINE Editor-eigenen Maße oder
|
||||||
|
## Anim-Parameter — Proportionen/Konstanten strikt aus der Client-Quelle.
|
||||||
|
##
|
||||||
|
## Fehlt der Client-Klon (oder die GLB): lade_avatar() gibt null zurück —
|
||||||
|
## PlaytestMode bleibt dann komplett in First Person (graceful, kein Crash).
|
||||||
|
|
||||||
|
## ------------------------------------------------ Quelle (Client-Klon) ----
|
||||||
|
## Relative Pfade IM Client-Repo (Nachbar-Klon, nicht dupliziert).
|
||||||
|
const MODEL_REL := "assets/avatars/quaternius_ual/ual1_standard.glb"
|
||||||
|
const RIFLE_REL := "assets/weapons/m40_rifle.glb"
|
||||||
|
|
||||||
|
## ------------------------------------- Clips (MatchRemoteAvatar-Erbe) -----
|
||||||
|
const CLIP_IDLE := "Idle"
|
||||||
|
const CLIP_WALK := "Walk"
|
||||||
|
const CLIP_JOG := "Jog_Fwd"
|
||||||
|
|
||||||
|
## Server-/Inferenz-States (A1-Vertrag, bot_avatar.gd): 0=IDLE 1=WALK 2=JOG.
|
||||||
|
const STATE_IDLE := 0
|
||||||
|
const STATE_WALK := 1
|
||||||
|
const STATE_JOG := 2
|
||||||
|
|
||||||
|
## Gait-Schwellen (A2 §6.2, bot_avatar.gd): Velocity → State.
|
||||||
|
const IDLE_SPEED_MAX_MPS := 0.3
|
||||||
|
const WALK_SPEED_MAX_MPS := 2.6
|
||||||
|
|
||||||
|
## Blend-Punkte = Clip-Naturgeschwindigkeiten (H1-Messung, bot_avatar.gd).
|
||||||
|
const WALK_NATURAL_MPS := 0.979
|
||||||
|
const JOG_NATURAL_MPS := 2.620
|
||||||
|
const BLEND_IDLE_MPS := 0.0
|
||||||
|
const BLEND_MAX_MPS := 4.0
|
||||||
|
|
||||||
|
## Kadenz-Kalibrierung (BOT2/vc23, bot_avatar.gd): Ziel-Schrittlängen + Klammern.
|
||||||
|
const WALK_STEP_M := 0.95
|
||||||
|
const JOG_STEP_M := 0.85
|
||||||
|
const CADENCE_MIN := 1.0
|
||||||
|
const CADENCE_MAX := 4.5
|
||||||
|
const FOOT_SYNC_MIN := 0.6
|
||||||
|
const FOOT_SYNC_MAX := 2.0
|
||||||
|
|
||||||
|
## Crossfade (A4) + Blend-Glättung (vc21-Ruckel-Fix, bot_avatar.gd).
|
||||||
|
const CROSSFADE_S := 0.2
|
||||||
|
const BLEND_SMOOTH_S := 0.25
|
||||||
|
|
||||||
|
## Modell-Anker (bot_avatar.gd): Knoten = Kopf-Anker in Augenhöhe (1,65 m),
|
||||||
|
## GLB-Kind hängt mit negativem Y-Versatz (Füße am Boden, Modell 1,83 m).
|
||||||
|
const MODEL_EYE_HEIGHT := 1.65
|
||||||
|
const MODEL_Y_OFFSET := -1.65
|
||||||
|
## Frontachse (vc21-Messung): GLB blickt nach +Z → 180°-Korrektur auf dem
|
||||||
|
## Modell-Kind, damit die Front auf das lokale −Z (Body-Vorwärts) zeigt.
|
||||||
|
const MODEL_FORWARD_LOCAL := Vector3(0.0, 0.0, 1.0)
|
||||||
|
const MODEL_YAW_OFFSET := PI
|
||||||
|
|
||||||
|
## Rifle-Mount (vc21-Messung, bot_avatar.gd): hand_r, Lauf → Hand-+Y,
|
||||||
|
## Rifle-oben → Hand-+Z = reine +90°-X-Drehung; Griff 0,067 m auf Hand-+Y.
|
||||||
|
const RIFLE_HAND_BONE := "hand_r"
|
||||||
|
const RIFLE_ROT_BASIS := Basis(Vector3(1.0, 0.0, 0.0), deg_to_rad(90.0))
|
||||||
|
const RIFLE_POS := Vector3(0.0, 0.067, 0.0)
|
||||||
|
|
||||||
|
## AnimationTree-Vertrag (bot_avatar.gd — Namen sind Teil des Vertrags).
|
||||||
|
const SM_MOTION := "Motion"
|
||||||
|
const P_BLEND := "parameters/Motion/Speed/blend_position"
|
||||||
|
const P_WALK_SCALE := "parameters/Motion/Speed/Walk/WalkTs/scale"
|
||||||
|
const P_JOG_SCALE := "parameters/Motion/Speed/Jog/JogTs/scale"
|
||||||
|
const P_PLAYBACK := "parameters/playback"
|
||||||
|
|
||||||
|
## GLB-Instanz (Modell-Wurzel), AnimationPlayer (Clip-Lieferant), Tree.
|
||||||
|
var _model: Node3D = null
|
||||||
|
var _anim: AnimationPlayer = null
|
||||||
|
var _tree: AnimationTree = null
|
||||||
|
var _playback: AnimationNodeStateMachinePlayback = null
|
||||||
|
## Sichtbarer Zustand (Tests/Debug): Soll-Clip + Soll-Blend.
|
||||||
|
var _cur_clip := ""
|
||||||
|
var state := STATE_IDLE
|
||||||
|
var speed_mps := 0.0
|
||||||
|
var _blend_cur := 0.0
|
||||||
|
var _blend_target := 0.0
|
||||||
|
var _walk_scale_cur := 1.0
|
||||||
|
var _walk_scale_target := 1.0
|
||||||
|
var _jog_scale_cur := 1.0
|
||||||
|
var _jog_scale_target := 1.0
|
||||||
|
|
||||||
|
|
||||||
|
## Client-Projektordner (Nachbar-Klon oder konfigurierter Pfad) — dieselbe
|
||||||
|
## Auflösung wie der alte Client-Start (test_run.gd). "" = keiner gefunden.
|
||||||
|
static func client_wurzel() -> String:
|
||||||
|
return TestRun.find_client(ProjectSettings.globalize_path("res://"),
|
||||||
|
TestRun.load_cfg())
|
||||||
|
|
||||||
|
|
||||||
|
## Absoluter Modell-/Rifle-Pfad im Client-Klon ("" = kein Klon gefunden).
|
||||||
|
static func model_pfad() -> String:
|
||||||
|
var root := client_wurzel()
|
||||||
|
if root.is_empty():
|
||||||
|
return ""
|
||||||
|
return root.path_join(MODEL_REL)
|
||||||
|
|
||||||
|
|
||||||
|
static func rifle_pfad() -> String:
|
||||||
|
var root := client_wurzel()
|
||||||
|
if root.is_empty():
|
||||||
|
return ""
|
||||||
|
return root.path_join(RIFLE_REL)
|
||||||
|
|
||||||
|
|
||||||
|
## State aus der echten Geschwindigkeit (Spiegel von _track_velocity,
|
||||||
|
## bot_avatar.gd — dieselben Schwellen, ohne Netzwerk-Glättung).
|
||||||
|
static func zustand_fuer(speed: float) -> int:
|
||||||
|
if speed <= IDLE_SPEED_MAX_MPS:
|
||||||
|
return STATE_IDLE
|
||||||
|
if speed <= WALK_SPEED_MAX_MPS:
|
||||||
|
return STATE_WALK
|
||||||
|
return STATE_JOG
|
||||||
|
|
||||||
|
|
||||||
|
## Avatar aus dem Client-Klon laden (null = fehlt/nicht ladbar → graceful
|
||||||
|
## Fallback auf First Person beim Aufrufer). Baut Modell + AnimationTree
|
||||||
|
## + Rifle-Mount exakt wie bot_avatar._ready/_build_model/_build_anim_tree.
|
||||||
|
static func lade_avatar() -> PlaytestAvatar:
|
||||||
|
var glb_pfad := model_pfad()
|
||||||
|
if glb_pfad.is_empty() or not FileAccess.file_exists(glb_pfad):
|
||||||
|
push_warning("PLAYTEST_AVATAR: Modell fehlt (%s) — Third Person nicht verfügbar" % glb_pfad)
|
||||||
|
return null
|
||||||
|
var gltf := GLTFDocument.new()
|
||||||
|
var state := GLTFState.new()
|
||||||
|
if gltf.append_from_file(glb_pfad, state) != OK:
|
||||||
|
push_warning("PLAYTEST_AVATAR: GLB nicht ladbar: %s" % glb_pfad)
|
||||||
|
return null
|
||||||
|
var szene: Node = gltf.generate_scene(state)
|
||||||
|
if szene == null:
|
||||||
|
push_warning("PLAYTEST_AVATAR: GLB-Szene leer: %s" % glb_pfad)
|
||||||
|
return null
|
||||||
|
var avatar := PlaytestAvatar.new()
|
||||||
|
avatar.name = "Avatar"
|
||||||
|
avatar._model = szene as Node3D
|
||||||
|
if avatar._model == null:
|
||||||
|
push_warning("PLAYTEST_AVATAR: GLB-Wurzel kein Node3D: %s" % glb_pfad)
|
||||||
|
szene.free()
|
||||||
|
return null
|
||||||
|
avatar._model.name = "Body"
|
||||||
|
# Kopf-Anker-Konvention (bot_avatar._build_model): das GLB-Kind hängt mit
|
||||||
|
# MODEL_Y_OFFSET nach unten (GLB-Ursprung = Füße) — der Avatar-Node selbst
|
||||||
|
# sitzt bei PlaytestBody in Augenhöhe über dem Füßpunkt (set_avatar).
|
||||||
|
# vc21-Frontachse: GLB blickt nach +Z → 180° auf das Modell-Kind, damit
|
||||||
|
# die Modell-Front auf das Avatar-lokale −Z zeigt (= Body-Vorwärts).
|
||||||
|
avatar._model.position = Vector3(0.0, MODEL_Y_OFFSET, 0.0)
|
||||||
|
avatar._model.rotation.y = MODEL_YAW_OFFSET
|
||||||
|
avatar.add_child(avatar._model)
|
||||||
|
for child in avatar._model.find_children("*", "AnimationPlayer", true, false):
|
||||||
|
avatar._anim = child
|
||||||
|
if avatar._anim == null:
|
||||||
|
push_warning("PLAYTEST_AVATAR: kein AnimationPlayer im GLB — Avatar unbrauchbar")
|
||||||
|
avatar.free()
|
||||||
|
return null
|
||||||
|
# Importer-Semantik nachbilden: der Godot-Editor-Import (Client .import)
|
||||||
|
# streicht Animations-Namens-ENDUNGEN "-loop"/"_loop" und setzt dann
|
||||||
|
# Loop-Mode LOOP_LINEAR. GLTFDocument (Runtime-Ladung) liefert die rohen
|
||||||
|
# Namen ("Idle_Loop", "Walk_Loop", …) OHNE Loop-Mode — ohne diese
|
||||||
|
# Normalisierung hätten die Loops den falschen Namen (Bot-Vertrag: Idle/
|
||||||
|
# Walk/Jog_Fwd) und würden nach einem Zyklus stehen bleiben.
|
||||||
|
avatar._normalisiere_clips()
|
||||||
|
if avatar._anim.get_animation_list().is_empty():
|
||||||
|
push_warning("PLAYTEST_AVATAR: kein AnimationPlayer/Clips im GLB — Avatar unbrauchbar")
|
||||||
|
avatar.free()
|
||||||
|
return null
|
||||||
|
avatar._baue_anim_tree()
|
||||||
|
avatar._baue_rifle()
|
||||||
|
# Start-Anim: Idle (wie bot_avatar._ready — kein T-Pose-Blitz).
|
||||||
|
avatar.apply_state(STATE_IDLE, 0.0)
|
||||||
|
return avatar
|
||||||
|
|
||||||
|
|
||||||
|
## Importer-Semantik (siehe Aufrufkommentar): "_loop"/"-loop"-Endungen
|
||||||
|
## streichen, LOOP_LINEAR setzen — genau das, was der Client-.import mit
|
||||||
|
## derselben GLB macht (dort heißen die Clips Idle/Walk/Jog_Fwd, Loop-Mode
|
||||||
|
## im Asset korrekt, siehe bot_avatar.gd-Clip-Doku).
|
||||||
|
func _normalisiere_clips() -> void:
|
||||||
|
for lib_name in _anim.get_animation_library_list():
|
||||||
|
var lib := _anim.get_animation_library(lib_name)
|
||||||
|
for clip_name in lib.get_animation_list():
|
||||||
|
var animation: Animation = lib.get_animation(clip_name)
|
||||||
|
var lower := String(clip_name).to_lower()
|
||||||
|
if lower.ends_with("_loop") or lower.ends_with("-loop"):
|
||||||
|
var clean := String(clip_name).substr(0, String(clip_name).length() - 5)
|
||||||
|
lib.rename_animation(clip_name, clean)
|
||||||
|
animation.loop_mode = Animation.LOOP_LINEAR
|
||||||
|
|
||||||
|
|
||||||
|
## AnimationTree (A4-Pipeline, Motion-Zweig): BlendSpace1D Idle/Walk/Jog +
|
||||||
|
## TimeScale-Subtrees, darüber die StateMachine mit dem Motion-State.
|
||||||
|
## Verdrahtungs-Fallen (bot_avatar.gd A4-Sektion, Probe 18.10.): Clips als
|
||||||
|
## EIGENE Library auf die Tree kopieren (anim_player-Compatibility-Pfad
|
||||||
|
## überspringt die GLB-Default-Library), root_node = Modell-Wurzel
|
||||||
|
## (Track-Pfade "Armature/Skeleton3D:*"), Player bleibt reiner Clip-Lieferant.
|
||||||
|
func _baue_anim_tree() -> void:
|
||||||
|
_anim.stop()
|
||||||
|
var tree := AnimationTree.new()
|
||||||
|
tree.name = "AnimTree"
|
||||||
|
_model.add_child(tree)
|
||||||
|
var lib := AnimationLibrary.new()
|
||||||
|
for clip in _anim.get_animation_list():
|
||||||
|
lib.add_animation(clip, _anim.get_animation(clip))
|
||||||
|
tree.add_animation_library("", lib)
|
||||||
|
tree.root_node = tree.get_path_to(_model)
|
||||||
|
var motion := AnimationNodeBlendTree.new()
|
||||||
|
var blend := AnimationNodeBlendSpace1D.new()
|
||||||
|
var idle := AnimationNodeAnimation.new()
|
||||||
|
idle.animation = CLIP_IDLE
|
||||||
|
blend.add_blend_point(idle, BLEND_IDLE_MPS, 0, "Idle")
|
||||||
|
blend.add_blend_point(_speed_scaled_clip(CLIP_WALK, "WalkAnim", "WalkTs"),
|
||||||
|
WALK_NATURAL_MPS, 1, "Walk")
|
||||||
|
blend.add_blend_point(_speed_scaled_clip(CLIP_JOG, "JogAnim", "JogTs"),
|
||||||
|
JOG_NATURAL_MPS, 2, "Jog")
|
||||||
|
blend.min_space = 0.0
|
||||||
|
blend.max_space = BLEND_MAX_MPS
|
||||||
|
blend.sync = true # vc21: gemeinsame Phase beim Blenden (kein Fuß-Pop)
|
||||||
|
motion.add_node("Speed", blend)
|
||||||
|
motion.connect_node("output", 0, "Speed")
|
||||||
|
var sm := AnimationNodeStateMachine.new()
|
||||||
|
sm.add_node(SM_MOTION, motion)
|
||||||
|
tree.tree_root = sm
|
||||||
|
tree.active = true
|
||||||
|
tree.process_callback = AnimationTree.ANIMATION_PROCESS_IDLE
|
||||||
|
_tree = tree
|
||||||
|
# _playback bewusst NICHT hier holen: der Baum hängt noch nicht im
|
||||||
|
# Szenenbaum — tree.get(P_PLAYBACK) liefert vorher null. Der Motion-
|
||||||
|
# Start passiert in apply_state() über _playback_holen() (nach dem
|
||||||
|
# Baum-Eintritt, wie _ensure_motion im Client).
|
||||||
|
|
||||||
|
|
||||||
|
## A4-Helfer (1:1 bot_avatar.gd): Clip-Subtree mit AnimationNodeTimeScale.
|
||||||
|
func _speed_scaled_clip(clip: String, anim_name: String, ts_name: String) -> AnimationNodeBlendTree:
|
||||||
|
var bt := AnimationNodeBlendTree.new()
|
||||||
|
var an := AnimationNodeAnimation.new()
|
||||||
|
an.animation = clip
|
||||||
|
var ts := AnimationNodeTimeScale.new()
|
||||||
|
bt.add_node(anim_name, an)
|
||||||
|
bt.add_node(ts_name, ts)
|
||||||
|
bt.connect_node(ts_name, 0, anim_name)
|
||||||
|
bt.connect_node("output", 0, ts_name)
|
||||||
|
return bt
|
||||||
|
|
||||||
|
|
||||||
|
## Rifle an hand_r (1:1 bot_avatar._build_rifle). Fehlt das Rifle-GLB oder
|
||||||
|
## der Knochen: warnen und ohne Waffe weiter (Avatar bleibt brauchbar).
|
||||||
|
func _baue_rifle() -> void:
|
||||||
|
var rifle_pfad_datei := rifle_pfad()
|
||||||
|
if rifle_pfad_datei.is_empty() or not FileAccess.file_exists(rifle_pfad_datei):
|
||||||
|
push_warning("PLAYTEST_AVATAR: Rifle fehlt (%s) — Avatar ohne Waffe" % rifle_pfad_datei)
|
||||||
|
return
|
||||||
|
var skeleton := _skeleton()
|
||||||
|
if skeleton == null:
|
||||||
|
push_warning("PLAYTEST_AVATAR: kein Skeleton3D — Rifle bleibt unattached")
|
||||||
|
return
|
||||||
|
if skeleton.find_bone(RIFLE_HAND_BONE) < 0:
|
||||||
|
push_warning("PLAYTEST_AVATAR: Hand-Knochen '%s' fehlt — Rifle bleibt unattached" % RIFLE_HAND_BONE)
|
||||||
|
return
|
||||||
|
var gltf := GLTFDocument.new()
|
||||||
|
var state := GLTFState.new()
|
||||||
|
if gltf.append_from_file(rifle_pfad_datei, state) != OK:
|
||||||
|
push_warning("PLAYTEST_AVATAR: Rifle-GLB nicht ladbar: %s" % rifle_pfad_datei)
|
||||||
|
return
|
||||||
|
var szene: Node = gltf.generate_scene(state)
|
||||||
|
if szene == null:
|
||||||
|
return
|
||||||
|
var attach := BoneAttachment3D.new()
|
||||||
|
attach.name = "RifleMount"
|
||||||
|
attach.bone_name = RIFLE_HAND_BONE
|
||||||
|
skeleton.add_child(attach)
|
||||||
|
var rifle := szene as Node3D
|
||||||
|
rifle.name = "Rifle"
|
||||||
|
rifle.basis = RIFLE_ROT_BASIS
|
||||||
|
rifle.position = RIFLE_POS
|
||||||
|
attach.add_child(rifle)
|
||||||
|
|
||||||
|
|
||||||
|
## Erstes Skeleton3D unter dem GLB (bot_avatar._build_model-Suche).
|
||||||
|
func _skeleton() -> Skeleton3D:
|
||||||
|
if _model == null:
|
||||||
|
return null
|
||||||
|
for child in _model.find_children("*", "Skeleton3D", true, false):
|
||||||
|
return child
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------- Anim ----
|
||||||
|
|
||||||
|
## Playtest-Movement-State anlegen (Kartenvorgabe: aus der ECHTEN Bewegung).
|
||||||
|
## mps < 0 = "nicht bekannt" → Kadenz unverändert lassen (wie bot_avatar).
|
||||||
|
func apply_state(new_state: int, new_speed_mps: float = -1.0) -> void:
|
||||||
|
if new_state != STATE_IDLE and new_state != STATE_WALK and new_state != STATE_JOG:
|
||||||
|
return # unbekannter State: nichts ändern (wie _clip_for_state "").
|
||||||
|
state = new_state
|
||||||
|
if new_speed_mps >= 0.0:
|
||||||
|
speed_mps = new_speed_mps
|
||||||
|
var clip := _clip_for_state(state)
|
||||||
|
if clip.is_empty():
|
||||||
|
return
|
||||||
|
_cur_clip = clip
|
||||||
|
if state == STATE_WALK:
|
||||||
|
_blend_target = WALK_NATURAL_MPS
|
||||||
|
_walk_scale_target = gait_scale_for(state, speed_mps)
|
||||||
|
_jog_scale_target = 1.0
|
||||||
|
elif state == STATE_JOG:
|
||||||
|
_blend_target = JOG_NATURAL_MPS
|
||||||
|
_jog_scale_target = gait_scale_for(state, speed_mps)
|
||||||
|
_walk_scale_target = 1.0
|
||||||
|
else:
|
||||||
|
_blend_target = BLEND_IDLE_MPS
|
||||||
|
_walk_scale_target = 1.0
|
||||||
|
_jog_scale_target = 1.0
|
||||||
|
if not is_processing():
|
||||||
|
# Ohne _process-Treiber (Tests vor dem ersten Frame) sofort schreiben.
|
||||||
|
_blend_cur = _blend_target
|
||||||
|
_walk_scale_cur = _walk_scale_target
|
||||||
|
_jog_scale_cur = _jog_scale_target
|
||||||
|
_write_blend_params()
|
||||||
|
var playback := _playback_holen()
|
||||||
|
if playback != null:
|
||||||
|
# Wie bot_avatar._ensure_motion: Loop-State halten bzw. zurück.
|
||||||
|
# Der pre-tree start() der StateMachine kann beim Baum-Eintritt
|
||||||
|
# verworfen werden (leerer Knoten) → hier sauber (neu) starten.
|
||||||
|
var node := String(playback.get_current_node())
|
||||||
|
if node.is_empty():
|
||||||
|
playback.start(SM_MOTION)
|
||||||
|
elif node != SM_MOTION:
|
||||||
|
playback.travel(SM_MOTION)
|
||||||
|
|
||||||
|
|
||||||
|
## Bot2-Kadenz (1:1 bot_avatar.gd): Kadenz = m/s / Ziel-Schrittlänge, geklemmt.
|
||||||
|
func gait_cadence_for(s: int, mps: float) -> float:
|
||||||
|
var step := WALK_STEP_M if s == STATE_WALK else JOG_STEP_M
|
||||||
|
if s != STATE_WALK and s != STATE_JOG:
|
||||||
|
return 0.0
|
||||||
|
return clampf(mps / maxf(step, 0.05), CADENCE_MIN, CADENCE_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
## Bot2-TimeScale (1:1 bot_avatar.gd): Kadenz × Zykluslänge / 2 Schritte.
|
||||||
|
func gait_scale_for(s: int, mps: float) -> float:
|
||||||
|
var cadence := gait_cadence_for(s, mps)
|
||||||
|
if cadence <= 0.0:
|
||||||
|
return 1.0
|
||||||
|
var cycle := _cycle_len_for(s)
|
||||||
|
if cycle <= 0.0:
|
||||||
|
return 1.0
|
||||||
|
return clampf(cadence * cycle / 2.0, FOOT_SYNC_MIN, FOOT_SYNC_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
func _cycle_len_for(s: int) -> float:
|
||||||
|
var clip := _clip_for_state(s)
|
||||||
|
if clip.is_empty() or _anim == null or not _anim.has_animation(clip):
|
||||||
|
return 0.0
|
||||||
|
return _anim.get_animation(clip).length
|
||||||
|
|
||||||
|
|
||||||
|
## StateMachine-Playback faul holen: erst NACH dem Baum-Eintritt kann die
|
||||||
|
## Tree den "parameters/playback"-Parameter auflösen (vorher null).
|
||||||
|
func _playback_holen() -> AnimationNodeStateMachinePlayback:
|
||||||
|
if _playback == null and _tree != null:
|
||||||
|
_playback = _tree.get(P_PLAYBACK) as AnimationNodeStateMachinePlayback
|
||||||
|
return _playback
|
||||||
|
|
||||||
|
|
||||||
|
## Clip-Auflösung (1:1 bot_avatar._clip_for_state, ohne ENGAGE — Match-only).
|
||||||
|
func _clip_for_state(s: int) -> String:
|
||||||
|
match s:
|
||||||
|
STATE_IDLE:
|
||||||
|
return CLIP_IDLE
|
||||||
|
STATE_WALK:
|
||||||
|
return CLIP_WALK
|
||||||
|
STATE_JOG:
|
||||||
|
return CLIP_JOG
|
||||||
|
_:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
## vc21: Welt-Richtung, in die das MESH blickt (Vertrag wie
|
||||||
|
## bot_avatar.model_forward: Front == Bewegungsrichtung). Hier wird die
|
||||||
|
## ECHTE Modell-Transform gemessen — der MODEL_YAW_OFFSET sitzt in diesem
|
||||||
|
## Spiegel auf dem Modell-Kind (nicht auf dem Node wie im Client), die
|
||||||
|
## Messung über _model.global_transform ist davon unabhängig korrekt.
|
||||||
|
func model_forward() -> Vector3:
|
||||||
|
if _model == null:
|
||||||
|
return Vector3(0.0, 0.0, -1.0)
|
||||||
|
var f: Vector3 = _model.global_transform.basis * MODEL_FORWARD_LOCAL
|
||||||
|
f.y = 0.0
|
||||||
|
return f.normalized()
|
||||||
|
|
||||||
|
|
||||||
|
## Aktueller StateMachine-State ("" ohne Baum) — Tests/Debug.
|
||||||
|
func tree_state() -> String:
|
||||||
|
var playback := _playback_holen()
|
||||||
|
if playback == null:
|
||||||
|
return ""
|
||||||
|
return String(playback.get_current_node())
|
||||||
|
|
||||||
|
|
||||||
|
## Soll-Clip des aktuellen States (wie bot_avatar.current_clip, ohne One-Shots).
|
||||||
|
func current_clip() -> String:
|
||||||
|
return _cur_clip
|
||||||
|
|
||||||
|
|
||||||
|
## AnimationTree aktiv? (Tests: "animiert überhaupt".)
|
||||||
|
func anim_running() -> bool:
|
||||||
|
return _tree != null and _tree.active
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
# vc21: Blend/Kadenz weich nachziehen (kein sichtbares Springen).
|
||||||
|
if _tree == null:
|
||||||
|
return
|
||||||
|
var k := clampf(delta / BLEND_SMOOTH_S, 0.0, 1.0)
|
||||||
|
_blend_cur = lerpf(_blend_cur, _blend_target, k)
|
||||||
|
_walk_scale_cur = lerpf(_walk_scale_cur, _walk_scale_target, k)
|
||||||
|
_jog_scale_cur = lerpf(_jog_scale_cur, _jog_scale_target, k)
|
||||||
|
_write_blend_params()
|
||||||
|
|
||||||
|
|
||||||
|
## vc21: geglätteten Stand in die Tree schreiben (nur bei Änderung).
|
||||||
|
func _write_blend_params() -> void:
|
||||||
|
if _tree == null:
|
||||||
|
return
|
||||||
|
if absf(float(_tree.get(P_BLEND)) - _blend_cur) > 0.0005:
|
||||||
|
_tree.set(P_BLEND, _blend_cur)
|
||||||
|
if absf(float(_tree.get(P_WALK_SCALE)) - _walk_scale_cur) > 0.0005:
|
||||||
|
_tree.set(P_WALK_SCALE, _walk_scale_cur)
|
||||||
|
if absf(float(_tree.get(P_JOG_SCALE)) - _jog_scale_cur) > 0.0005:
|
||||||
|
_tree.set(P_JOG_SCALE, _jog_scale_cur)
|
||||||
1
src/editor/playtest/playtest_avatar.gd.uid
Normal file
1
src/editor/playtest/playtest_avatar.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cvj6x35cenpbm
|
||||||
|
|
@ -99,15 +99,61 @@ func _ready() -> void:
|
||||||
look_cam.far = 4000.0
|
look_cam.far = 4000.0
|
||||||
add_child(look_cam)
|
add_child(look_cam)
|
||||||
look_cam.current = true
|
look_cam.current = true
|
||||||
|
_baue_dritte_kamera()
|
||||||
_apply_look()
|
_apply_look()
|
||||||
|
|
||||||
|
|
||||||
## Blickrichtung auf die Kamera übertragen (first person: Kamera schaut -Z).
|
## Third-Person-Kamera (Karte t_17a35c59): SpringArm3D = RayCast hinter dem
|
||||||
|
## Spieler — die Kamera wird an die Kollision zurückgezogen (kein Clipping
|
||||||
|
## durch Wände). Pivot auf Augenhöhe, Arm-Länge 3,5 m + 0,3 m Margin, der
|
||||||
|
## eigene Body ist ausgeschlossen (sonst trifft der Strahl die eigene Kapsel).
|
||||||
|
func _baue_dritte_kamera() -> void:
|
||||||
|
spring = SpringArm3D.new()
|
||||||
|
spring.name = "ThirdPersonArm"
|
||||||
|
spring.position = Vector3(0.0, EYE_HEIGHT, 0.0)
|
||||||
|
spring.spring_length = 3.5
|
||||||
|
spring.margin = 0.3
|
||||||
|
spring.collision_mask = 1
|
||||||
|
tpp_cam = Camera3D.new()
|
||||||
|
tpp_cam.name = "ThirdPersonCamera"
|
||||||
|
tpp_cam.far = 4000.0
|
||||||
|
spring.add_child(tpp_cam)
|
||||||
|
add_child(spring)
|
||||||
|
spring.add_excluded_object(get_rid())
|
||||||
|
tpp_cam.current = false
|
||||||
|
|
||||||
|
|
||||||
|
## Kinder: die Third-Person-Kamera (SpringArm + Kamera) — Karte t_17a35c59.
|
||||||
|
var spring: SpringArm3D = null
|
||||||
|
var tpp_cam: Camera3D = null
|
||||||
|
|
||||||
|
## Playtest-Avatar (Spiegel des Bot-Rigs, playtest_avatar.gd). null = kein
|
||||||
|
## Client-Klon/Modell → Third Person bleibt gesperrt (graceful Fallback).
|
||||||
|
var avatar: Node3D = null
|
||||||
|
|
||||||
|
## Perspektive (Taste V, persistiert in PlaytestSettings): false = First
|
||||||
|
## Person (Standard), true = Third Person (Avatar sichtbar, Kamera hinten).
|
||||||
|
var perspektive_dritte := false
|
||||||
|
|
||||||
|
|
||||||
|
## Blickrichtung auf Körper + Kameras übertragen.
|
||||||
|
## Der BODY rotiert mit dem Blick-Yaw (1:1 TestmapPlayer._unhandled_input
|
||||||
|
## rotate_y) — damit folgt die WASD-Bewegung der Blickrichtung wie im
|
||||||
|
## Client. Vor t_17a35c59 blieb der Body ungedreht (Beweis-Probe
|
||||||
|
## tests/probe_move_dir.gd: yaw −90° + W → −Z statt +X = weltachsen-fixiert,
|
||||||
|
## Abweichung vom Client-Mirror).
|
||||||
func _apply_look() -> void:
|
func _apply_look() -> void:
|
||||||
|
# 1:1 TestmapPlayer: der BODY trägt den Yaw (rotate_y), die Kamera nur
|
||||||
|
# den Pitch — sonst doppelt sich die Drehung (Kamera hängt am Körper).
|
||||||
|
rotation.y = deg_to_rad(yaw_deg)
|
||||||
if look_cam == null:
|
if look_cam == null:
|
||||||
return
|
return
|
||||||
look_cam.rotation = Vector3(deg_to_rad(pitch_deg), deg_to_rad(yaw_deg), 0.0)
|
look_cam.rotation = Vector3(deg_to_rad(pitch_deg), 0.0, 0.0)
|
||||||
look_cam.position = Vector3(0.0, EYE_HEIGHT, 0.0)
|
look_cam.position = Vector3(0.0, EYE_HEIGHT, 0.0)
|
||||||
|
# Third Person: die SpringArm-Kamera teilt dieselbe Blickachse (Yaw über
|
||||||
|
# den Körper, Pitch über den Arm) — Blickrichtung == Kamerarichtung.
|
||||||
|
if spring != null:
|
||||||
|
spring.rotation = Vector3(deg_to_rad(pitch_deg), 0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
## Füß-Höhe über der Welt (Kapsel-Mitte minus Halbhöhe) — HUD/Tests.
|
## Füß-Höhe über der Welt (Kapsel-Mitte minus Halbhöhe) — HUD/Tests.
|
||||||
|
|
@ -457,3 +503,76 @@ func _sample(xi: int, zi: int) -> float:
|
||||||
func set_sensitivity(mult: float) -> void:
|
func set_sensitivity(mult: float) -> void:
|
||||||
var m := clampf(mult, PlaytestSettings.MIN_SENS, PlaytestSettings.MAX_SENS)
|
var m := clampf(mult, PlaytestSettings.MIN_SENS, PlaytestSettings.MAX_SENS)
|
||||||
mouse_sens = DEFAULT_MOUSE_SENS * m
|
mouse_sens = DEFAULT_MOUSE_SENS * m
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------ Avatar + Perspektive (V) -------
|
||||||
|
# Karte t_17a35c59: derselbe Avatar wie die Bots im Spiel (Spiegel-Modul
|
||||||
|
# playtest_avatar.gd, GLB aus dem Nachbar-Klon), umschaltbar First/Third.
|
||||||
|
|
||||||
|
## Avatar (aus PlaytestAvatar.lade_avatar) anhängen. Anker: Kopf-Anker-
|
||||||
|
## Konvention des Clients (bot_avatar.gd) — Avatar-Node in Augenhöhe
|
||||||
|
## (MODEL_EYE_HEIGHT 1,65 m) über dem Füßpunkt, GLB-Kind hängt mit
|
||||||
|
## MODEL_Y_OFFSET nach unten. null = kein Modell (Third bleibt gesperrt).
|
||||||
|
func set_avatar(node: Node3D) -> void:
|
||||||
|
if avatar != null and is_instance_valid(avatar):
|
||||||
|
avatar.queue_free()
|
||||||
|
avatar = node
|
||||||
|
if avatar == null:
|
||||||
|
perspektive_dritte = false
|
||||||
|
_apply_perspektive()
|
||||||
|
return
|
||||||
|
# add_child reparentet NICHT — ein Node mit altem Parent würde still
|
||||||
|
# abgelehnt (Probe 20.10.: Avatar blieb unter root). Erst lösen, dann
|
||||||
|
# als Kind anhängen.
|
||||||
|
if avatar.get_parent() != null:
|
||||||
|
avatar.get_parent().remove_child(avatar)
|
||||||
|
add_child(avatar)
|
||||||
|
avatar.position = Vector3(0.0,
|
||||||
|
PlaytestAvatar.MODEL_EYE_HEIGHT - HALF_H, 0.0)
|
||||||
|
# Start-Zustand sauber in den Baum schreiben (apply_state vor add_child
|
||||||
|
# könnte Tree-Parameter noch nicht flushen).
|
||||||
|
avatar.call("apply_state", PlaytestAvatar.STATE_IDLE, 0.0)
|
||||||
|
_apply_perspektive()
|
||||||
|
|
||||||
|
|
||||||
|
## Taste V: Perspektive umschalten. Rückgabe: neuer Zustand (true = Third).
|
||||||
|
## Ohne Avatar bleibt First Person aktiv (graceful Fallback — Meldung macht
|
||||||
|
## der Aufrufer/PlaytestMode).
|
||||||
|
func toggle_perspektive() -> bool:
|
||||||
|
var neu := not perspektive_dritte
|
||||||
|
if avatar == null:
|
||||||
|
neu = false
|
||||||
|
return set_perspektive(neu)
|
||||||
|
|
||||||
|
|
||||||
|
## Perspektive direkt setzen (Enter-Pfad liest PlaytestSettings). Rückgabe:
|
||||||
|
## der tatsächlich aktive Zustand (ohne Avatar nie Third).
|
||||||
|
func set_perspektive(dritte: bool) -> bool:
|
||||||
|
if avatar == null:
|
||||||
|
dritte = false
|
||||||
|
perspektive_dritte = dritte
|
||||||
|
_apply_perspektive()
|
||||||
|
return perspektive_dritte
|
||||||
|
|
||||||
|
|
||||||
|
## Sichtbarkeit + aktive Kamera je Perspektive:
|
||||||
|
## First Person — Avatar unsichtbar (Kapsel bleibt physisch aktiv,
|
||||||
|
## Kamera auf Augenhöhe wie Client-TestmapPlayer),
|
||||||
|
## Third Person — Avatar sichtbar, SpringArm-Kamera hinter/über dem
|
||||||
|
## Spieler mit Wand-Kollision (kein Clipping).
|
||||||
|
func _apply_perspektive() -> void:
|
||||||
|
if avatar != null and is_instance_valid(avatar):
|
||||||
|
avatar.visible = perspektive_dritte
|
||||||
|
if look_cam != null:
|
||||||
|
look_cam.current = not perspektive_dritte
|
||||||
|
if tpp_cam != null:
|
||||||
|
tpp_cam.current = perspektive_dritte
|
||||||
|
|
||||||
|
|
||||||
|
## Echte horizontale Geschwindigkeit (m/s) aus dem letzten Physik-Schritt —
|
||||||
|
## Quelle für die Avatar-Gait-Speisung (Stillstand/Walk/Jog aus der realen
|
||||||
|
## Bewegung, nicht aus dem Input-Wunsch).
|
||||||
|
func horizontale_geschwindigkeit() -> float:
|
||||||
|
var v := velocity
|
||||||
|
v.y = 0.0
|
||||||
|
return v.length()
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ extends Node
|
||||||
const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd"
|
const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd"
|
||||||
const BUILD_OPS_SCRIPT := "res://src/editor/build_ops.gd"
|
const BUILD_OPS_SCRIPT := "res://src/editor/build_ops.gd"
|
||||||
const KOLLIDER_SCRIPT := "res://src/svmap/kollider_bau.gd"
|
const KOLLIDER_SCRIPT := "res://src/svmap/kollider_bau.gd"
|
||||||
|
const AVATAR_SCRIPT := "res://src/editor/playtest/playtest_avatar.gd"
|
||||||
|
|
||||||
## Der laufende Spieler (PlaytestBody, CharacterBody3D mit echter Physik)
|
## Der laufende Spieler (PlaytestBody, CharacterBody3D mit echter Physik)
|
||||||
var body: Node3D = null
|
var body: Node3D = null
|
||||||
|
|
@ -55,6 +56,9 @@ var _axis_node: Node3D = null
|
||||||
var _fps_smooth := 0.0
|
var _fps_smooth := 0.0
|
||||||
## Ist der Lauf-Modus gerade aktiv? (Reentrancy-Schutz für enter/exit)
|
## Ist der Lauf-Modus gerade aktiv? (Reentrancy-Schutz für enter/exit)
|
||||||
var active := false
|
var active := false
|
||||||
|
## Avatar-Modelle waren beim Start verfügbar? (Karte t_17a35c59 — ohne
|
||||||
|
## Client-Klon bleibt Third Person gesperrt, graceful Fallback.)
|
||||||
|
var _avatar_da := false
|
||||||
|
|
||||||
signal exit_requested
|
signal exit_requested
|
||||||
|
|
||||||
|
|
@ -93,6 +97,21 @@ func enter(doc: Dictionary, view_builder: Node3D, start_override: Variant = null
|
||||||
# gespeicherte Multiplikator (0,1–5,0) skaliert die Body-Basis.
|
# gespeicherte Multiplikator (0,1–5,0) skaliert die Body-Basis.
|
||||||
body.call("set_sensitivity", PlaytestSettings.load_sens())
|
body.call("set_sensitivity", PlaytestSettings.load_sens())
|
||||||
|
|
||||||
|
# Playtest-Avatar (Karte t_17a35c59): derselbe Rig wie die Bots im Spiel
|
||||||
|
# (GLB aus dem Nachbar-Klon ../scopedvr-client). Fehlt der Klon/Modell:
|
||||||
|
# graceful — First Person bleibt, Third Person wird per Meldung gesperrt.
|
||||||
|
var avatar: Node3D = load(AVATAR_SCRIPT).call("lade_avatar") as Node3D
|
||||||
|
_avatar_da = avatar != null
|
||||||
|
if _avatar_da:
|
||||||
|
body.call("set_avatar", avatar)
|
||||||
|
# Persistierte Perspektive (Taste V): Third nur mit Avatar — sonst
|
||||||
|
# First Person wie bisher + Klartext-Hinweis.
|
||||||
|
if PlaytestSettings.load_third():
|
||||||
|
if _avatar_da:
|
||||||
|
body.call("set_perspektive", true)
|
||||||
|
else:
|
||||||
|
show_message("Gespeicherte Third-Person-Einstellung ist ohne Avatar-Modelle nicht verfügbar (Client-Klon ../scopedvr-client fehlt) — First Person aktiv.")
|
||||||
|
|
||||||
_build_hud()
|
_build_hud()
|
||||||
exit_requested.connect(_on_exit_requested, CONNECT_ONE_SHOT)
|
exit_requested.connect(_on_exit_requested, CONNECT_ONE_SHOT)
|
||||||
|
|
||||||
|
|
@ -212,7 +231,7 @@ func _build_hud() -> void:
|
||||||
hud_hint = Label.new()
|
hud_hint = Label.new()
|
||||||
hud_msg = Label.new()
|
hud_msg = Label.new()
|
||||||
hud_dir = Label.new()
|
hud_dir = Label.new()
|
||||||
hud_hint.text = "WASD + Maus · Shift = rennen · Space = springen/steigen · G = Fliegen · 1-4 = Spawnpunkt · T = Test-Start · H = HUD · X = Achsen · F1 = Hilfe · ESC = zurück zum Editor"
|
hud_hint.text = "WASD + Maus · Shift = rennen · Space = springen/steigen · G = Fliegen · V = 1./3.-Person · 1-4 = Spawnpunkt · T = Test-Start · H = HUD · X = Achsen · F1 = Hilfe · ESC = zurück zum Editor"
|
||||||
hud_msg.text = ""
|
hud_msg.text = ""
|
||||||
for l: Label in [hud_pos, hud_dir, hud_fps, hud_hint, hud_msg]:
|
for l: Label in [hud_pos, hud_dir, hud_fps, hud_hint, hud_msg]:
|
||||||
l.add_theme_font_size_override("font_size", 16)
|
l.add_theme_font_size_override("font_size", 16)
|
||||||
|
|
@ -252,6 +271,7 @@ func teleport_crosshair() -> Dictionary:
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if not active or body == null:
|
if not active or body == null:
|
||||||
return
|
return
|
||||||
|
_speise_avatar()
|
||||||
# FPS glätten (Anzeige-Genauigkeit, keine Physik-Betroffenheit)
|
# FPS glätten (Anzeige-Genauigkeit, keine Physik-Betroffenheit)
|
||||||
if _fps_smooth <= 0.0:
|
if _fps_smooth <= 0.0:
|
||||||
_fps_smooth = 1.0 / maxf(delta, 0.0001)
|
_fps_smooth = 1.0 / maxf(delta, 0.0001)
|
||||||
|
|
@ -298,6 +318,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||||
match key:
|
match key:
|
||||||
KEY_G:
|
KEY_G:
|
||||||
toggle_fly()
|
toggle_fly()
|
||||||
|
KEY_V:
|
||||||
|
toggle_perspektive()
|
||||||
KEY_1:
|
KEY_1:
|
||||||
jump_to_spawn(1)
|
jump_to_spawn(1)
|
||||||
KEY_2:
|
KEY_2:
|
||||||
|
|
@ -325,6 +347,39 @@ func apply_sensitivity(mult: float) -> void:
|
||||||
body.call("set_sensitivity", mult)
|
body.call("set_sensitivity", mult)
|
||||||
|
|
||||||
|
|
||||||
|
## Taste V (Karte t_17a35c59): First/Third-Person umschalten. Der neue
|
||||||
|
## Zustand wird persistiert (PlaytestSettings, wirksam über Neustarts).
|
||||||
|
## Ohne Avatar-Modelle (kein Client-Klon ../scopedvr-client) bleibt First
|
||||||
|
## Person aktiv und der Grund steht im HUD — kein Crash (graceful Fallback).
|
||||||
|
func toggle_perspektive() -> bool:
|
||||||
|
if body == null:
|
||||||
|
return false
|
||||||
|
var neu: bool = body.call("toggle_perspektive")
|
||||||
|
if not _avatar_da:
|
||||||
|
show_message("Kein Avatar-Modell gefunden (Client-Klon ../scopedvr-client fehlt) — Third Person nicht verfügbar.")
|
||||||
|
return false
|
||||||
|
PlaytestSettings.save_third(neu)
|
||||||
|
if neu:
|
||||||
|
show_message("Dritte Person AN — derselbe Avatar wie die Bots im Spiel. V = zurück zur ersten Person.")
|
||||||
|
else:
|
||||||
|
show_message("Erste Person AN (Standard). V = dritte Person.")
|
||||||
|
return neu
|
||||||
|
|
||||||
|
|
||||||
|
## Avatar-Gait aus der ECHTEN Bewegung speisen (Kartenvorgabe: Stillstand/
|
||||||
|
## Walk/Jog aus der tatsächlichen Geschwindigkeit, Blickrichtung ==
|
||||||
|
## Bewegungsrichtung über die Body-Rotation). Jeden Frame — die Kadenz-
|
||||||
|
## Mathe des Avatars glättet selbst (BLEND_SMOOTH_S, wie die Bots).
|
||||||
|
func _speise_avatar() -> void:
|
||||||
|
if body == null:
|
||||||
|
return
|
||||||
|
var av: Node3D = body.get("avatar") as Node3D
|
||||||
|
if av == null or not is_instance_valid(av):
|
||||||
|
return
|
||||||
|
var speed: float = body.call("horizontale_geschwindigkeit")
|
||||||
|
av.call("apply_state", PlaytestAvatar.zustand_fuer(speed), speed)
|
||||||
|
|
||||||
|
|
||||||
## Fliegen / God-Mode umschalten (Taste G). Rückgabe: neuer Zustand.
|
## Fliegen / God-Mode umschalten (Taste G). Rückgabe: neuer Zustand.
|
||||||
func toggle_fly() -> bool:
|
func toggle_fly() -> bool:
|
||||||
if body == null:
|
if body == null:
|
||||||
|
|
@ -431,6 +486,7 @@ func _build_help() -> void:
|
||||||
WASD — bewegen · Maus — umsehen
|
WASD — bewegen · Maus — umsehen
|
||||||
Shift — rennen · Space — springen / im Flug steigen
|
Shift — rennen · Space — springen / im Flug steigen
|
||||||
G — Fliegen (God-Mode) an/aus: keine Gravitation, Blickrichtung fliegen
|
G — Fliegen (God-Mode) an/aus: keine Gravitation, Blickrichtung fliegen
|
||||||
|
V — Perspektive umschalten (erste / dritte Person)
|
||||||
1 — Sprung zu Team A (Spawnpunkt) · 2 — Team B
|
1 — Sprung zu Team A (Spawnpunkt) · 2 — Team B
|
||||||
3 / 4 — weitere Spawnpunkte (Zonen 2/3)
|
3 / 4 — weitere Spawnpunkte (Zonen 2/3)
|
||||||
T — Test-Start auf den Blickpunkt setzen (sofort dort)
|
T — Test-Start auf den Blickpunkt setzen (sofort dort)
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,14 @@ const KEY_SENS := "maus_empfindlichkeit"
|
||||||
const DEFAULT_SENS := 1.0
|
const DEFAULT_SENS := 1.0
|
||||||
const MIN_SENS := 0.1
|
const MIN_SENS := 0.1
|
||||||
const MAX_SENS := 5.0
|
const MAX_SENS := 5.0
|
||||||
|
## Perspektive im Testlauf (Karte t_17a35c59, Taste V): false = First Person
|
||||||
|
## (Standard, wie bisher), true = Third Person (Avatar sichtbar).
|
||||||
|
const KEY_THIRD := "dritte_person"
|
||||||
|
|
||||||
## Cache (null = noch nicht gelesen). static var ist Godot 4.1+.
|
## Cache (null = noch nicht gelesen). static var ist Godot 4.1+.
|
||||||
static var _cache: Variant = null
|
static var _cache: Variant = null
|
||||||
|
## Cache für den Perspektive-Eintrag (null = noch nicht gelesen).
|
||||||
|
static var _cache_third: Variant = null
|
||||||
|
|
||||||
|
|
||||||
## Gespeicherte Empfindlichkeit lesen (geclampt auf 0,1–5,0), Standard 1,0.
|
## Gespeicherte Empfindlichkeit lesen (geclampt auf 0,1–5,0), Standard 1,0.
|
||||||
|
|
@ -48,6 +53,32 @@ static func save_sens(value: float) -> float:
|
||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
## Gespeicherte Perspektive lesen (Karte t_17a35c59): true = Third Person.
|
||||||
|
## Default FALSE = First Person — der Testlauf startet so wie bisher.
|
||||||
|
static func load_third() -> bool:
|
||||||
|
if _cache_third != null:
|
||||||
|
return bool(_cache_third)
|
||||||
|
var cfg := ConfigFile.new()
|
||||||
|
var raw: Variant = false
|
||||||
|
if cfg.load(CFG_PATH) == OK:
|
||||||
|
raw = cfg.get_value(SECTION, KEY_THIRD, false)
|
||||||
|
var val := bool(raw)
|
||||||
|
_cache_third = val
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
## Perspektive speichern (persistiert über Editor-Neustarts) und Cache frisch
|
||||||
|
## halten. Rückgabe: der tatsächlich gespeicherte Wert.
|
||||||
|
static func save_third(value: bool) -> bool:
|
||||||
|
var cfg := ConfigFile.new()
|
||||||
|
cfg.load(CFG_PATH) # Fehler → leere Config, nur unser Key wird angelegt.
|
||||||
|
cfg.set_value(SECTION, KEY_THIRD, value)
|
||||||
|
cfg.save(CFG_PATH)
|
||||||
|
_cache_third = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
## Cache verwerfen (für Tests / frisches Neulesen von der Platte).
|
## Cache verwerfen (für Tests / frisches Neulesen von der Platte).
|
||||||
static func reset_cache() -> void:
|
static func reset_cache() -> void:
|
||||||
_cache = null
|
_cache = null
|
||||||
|
_cache_third = null
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ extends "res://src/ui/panels/editor_panel.gd"
|
||||||
## Maus-Empfindlichkeit SOFORT über main.playtest.apply_sensitivity wirksam.
|
## Maus-Empfindlichkeit SOFORT über main.playtest.apply_sensitivity wirksam.
|
||||||
|
|
||||||
var sens_box: SpinBox
|
var sens_box: SpinBox
|
||||||
|
## Perspektive im Testlauf (Karte t_17a35c59): Erste/Dritte Person, derselbe
|
||||||
|
## Eintrag wie Taste V (PlaytestSettings, persistiert).
|
||||||
|
var perspektive_box: OptionButton
|
||||||
|
|
||||||
|
|
||||||
static func spec() -> Dictionary:
|
static func spec() -> Dictionary:
|
||||||
|
|
@ -32,6 +35,16 @@ func build() -> void:
|
||||||
sens_box.value_changed.connect(_on_sens_changed)
|
sens_box.value_changed.connect(_on_sens_changed)
|
||||||
add_child(sens_box)
|
add_child(sens_box)
|
||||||
|
|
||||||
|
add_child(_field_label("Perspektive im Testlauf"))
|
||||||
|
perspektive_box = OptionButton.new()
|
||||||
|
perspektive_box.add_item("Erste Person (Standard)")
|
||||||
|
perspektive_box.add_item("Dritte Person (Bot-Avatar)")
|
||||||
|
perspektive_box.selected = 1 if PlaytestSettings.load_third() else 0
|
||||||
|
perspektive_box.custom_minimum_size = UIScale.v2(0, 36)
|
||||||
|
perspektive_box.tooltip_text = "Dritte Person zeigt denselben Avatar wie die Bots im Spiel (Taste V schaltet im Testlauf ebenfalls). Dritte Person braucht die Avatar-Modelle aus dem Client-Klon."
|
||||||
|
perspektive_box.item_selected.connect(_on_perspektive_changed)
|
||||||
|
add_child(perspektive_box)
|
||||||
|
|
||||||
add_child(_separator())
|
add_child(_separator())
|
||||||
add_child(_muted_label("Gilt im Lauf-Modus („▶ Karte testen“). Standard 1,0 — jede PC-/Maus-Kombination ist anders. Wird automatisch gespeichert."))
|
add_child(_muted_label("Gilt im Lauf-Modus („▶ Karte testen“). Standard 1,0 — jede PC-/Maus-Kombination ist anders. Wird automatisch gespeichert."))
|
||||||
|
|
||||||
|
|
@ -41,3 +54,17 @@ func _on_sens_changed(value: float) -> void:
|
||||||
var pt: Variant = main.get("playtest")
|
var pt: Variant = main.get("playtest")
|
||||||
if pt != null and pt.has_method("apply_sensitivity"):
|
if pt != null and pt.has_method("apply_sensitivity"):
|
||||||
pt.call("apply_sensitivity", value)
|
pt.call("apply_sensitivity", value)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_perspektive_changed(index: int) -> void:
|
||||||
|
var dritte := index == 1
|
||||||
|
PlaytestSettings.save_third(dritte)
|
||||||
|
# Live anwenden, falls der Testlauf gerade läuft. Ohne Avatar-Modelle
|
||||||
|
# bleibt First Person aktiv (set_perspektive lehnt Third ab) — der
|
||||||
|
# Klartext-Grund steht beim Start/V-Druck im HUD.
|
||||||
|
var pt: Variant = main.get("playtest")
|
||||||
|
if pt == null:
|
||||||
|
return
|
||||||
|
var body: Variant = pt.get("body")
|
||||||
|
if body != null:
|
||||||
|
body.call("set_perspektive", dritte)
|
||||||
|
|
|
||||||
429
tests/test_playtest_avatar.gd
Normal file
429
tests/test_playtest_avatar.gd
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Editor-Test (headless): Karte t_17a35c59 — Playtest-Avatar (Bot-Rig aus dem
|
||||||
|
## Client-Klon) + First/Third-Person-Umschalter (Taste V).
|
||||||
|
## Ausführen: godot4 --headless --path . --import && godot4 --headless --path . --script res://tests/test_playtest_avatar.gd
|
||||||
|
## Exit 0 = alles grün.
|
||||||
|
##
|
||||||
|
## Abgedeckt:
|
||||||
|
## 1. Identität zum Client: Spiegel-Konstanten == bot_avatar.gd /
|
||||||
|
## MatchRemoteAvatar / TestmapPlayer (aus dem NACHBAR-KLON gelesen —
|
||||||
|
## Quelle der Wahrheit, keine Editor-eigenen Maße).
|
||||||
|
## 2. Avatar lädt aus dem Nachbar-Klon (GLTFDocument, GLB außerhalb res://),
|
||||||
|
## AnimationTree-Pipeline (BlendSpace1D + Kadenz-Mathe wie BOT2),
|
||||||
|
## Modellhöhe == Kapsel-Konvention == Client-Avatar, Rifle an hand_r.
|
||||||
|
## 3. Perspektive: Default First Person, Taste V schaltet, persistiert,
|
||||||
|
## Third zeigt Avatar + SpringArm-Kamera (Blick zum Körper, kein
|
||||||
|
## Clipping durch eine Wand hinter dem Spieler), graceful ohne Avatar.
|
||||||
|
## 4. Blickrichtung == Bewegungsrichtung: Body-Yaw dreht die WASD-Bewegung
|
||||||
|
## (1:1 TestmapPlayer) und die Avatar-Front zeigt in Bewegungsrichtung.
|
||||||
|
## 5. Playtest-Integration: enter() baut den Avatar, die Gait-Speisung
|
||||||
|
## leitet Stillstand/Walk/Jog aus der ECHTEN Geschwindigkeit ab.
|
||||||
|
|
||||||
|
var failures := 0
|
||||||
|
var checks := 0
|
||||||
|
|
||||||
|
const HALF_H := 0.9
|
||||||
|
const BEWEIS_TOLERANZ_HOEHE := 0.1
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run_all.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run_all() -> void:
|
||||||
|
_test_identitaet_konstanten()
|
||||||
|
var avatar: Node3D = await _test_avatar_laden()
|
||||||
|
await _test_perspektive_und_kamera(avatar)
|
||||||
|
await _test_integration_playtest()
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures == 0:
|
||||||
|
print("ALL OK: %d checks" % checks)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("FAILED: %d/%d checks" % [failures, checks])
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(cond: bool, label: String) -> void:
|
||||||
|
checks += 1
|
||||||
|
if cond:
|
||||||
|
print(" ok: %s" % label)
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
print(" FAIL: %s" % label)
|
||||||
|
|
||||||
|
|
||||||
|
## ------------------------------------------------------------- Helfer ------
|
||||||
|
|
||||||
|
## Roher Konstanten-Wert aus einer Client-GDScript-Datei ("" = fehlt).
|
||||||
|
## Muster: `const NAME := WERT` — WERT als Text (Vergleich über str()).
|
||||||
|
func _const_wert(text: String, name: String) -> String:
|
||||||
|
var zeilen := text.split("\n")
|
||||||
|
for zeile in zeilen:
|
||||||
|
var t := zeile.strip_edges()
|
||||||
|
if t.begins_with("const " + name + " :=") or t.begins_with("const " + name + "="):
|
||||||
|
return t.substr(("const " + name + " :=").length()).strip_edges()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func _client_datei(rel: String) -> String:
|
||||||
|
var root: String = PlaytestAvatar.client_wurzel()
|
||||||
|
if root.is_empty():
|
||||||
|
return ""
|
||||||
|
return root.path_join(rel)
|
||||||
|
|
||||||
|
|
||||||
|
## --------------------------------------------------- 1. Identität ----------
|
||||||
|
|
||||||
|
func _test_identitaet_konstanten() -> void:
|
||||||
|
print("Testgruppe: Identität — Spiegel-Konstanten == Client-Quelle")
|
||||||
|
_check(not PlaytestAvatar.client_wurzel().is_empty(),
|
||||||
|
"Client-Klon auflösbar (../scopedvr-client)")
|
||||||
|
var bot_text := FileAccess.get_file_as_string(_client_datei("scripts/match/bot_avatar.gd"))
|
||||||
|
_check(not bot_text.is_empty(), "bot_avatar.gd lesbar (Quelle der Wahrheit)")
|
||||||
|
var parent_text := FileAccess.get_file_as_string(_client_datei("scripts/match/match_remote_avatar.gd"))
|
||||||
|
_check(not parent_text.is_empty(), "match_remote_avatar.gd lesbar")
|
||||||
|
var player_text := FileAccess.get_file_as_string(_client_datei("scripts/world/testmap_player.gd"))
|
||||||
|
_check(not player_text.is_empty(), "testmap_player.gd lesbar")
|
||||||
|
|
||||||
|
# Zahl-Konstanten: exakter Text-Vergleich Quelle ↔ Spiegel.
|
||||||
|
var zahl_paare := [
|
||||||
|
["bot_avatar.gd", bot_text, "IDLE_SPEED_MAX_MPS", str(PlaytestAvatar.IDLE_SPEED_MAX_MPS)],
|
||||||
|
["bot_avatar.gd", bot_text, "WALK_SPEED_MAX_MPS", str(PlaytestAvatar.WALK_SPEED_MAX_MPS)],
|
||||||
|
["bot_avatar.gd", bot_text, "WALK_STEP_M", str(PlaytestAvatar.WALK_STEP_M)],
|
||||||
|
["bot_avatar.gd", bot_text, "JOG_STEP_M", str(PlaytestAvatar.JOG_STEP_M)],
|
||||||
|
["bot_avatar.gd", bot_text, "CADENCE_MIN", str(PlaytestAvatar.CADENCE_MIN)],
|
||||||
|
["bot_avatar.gd", bot_text, "CADENCE_MAX", str(PlaytestAvatar.CADENCE_MAX)],
|
||||||
|
["bot_avatar.gd", bot_text, "FOOT_SYNC_MIN", str(PlaytestAvatar.FOOT_SYNC_MIN)],
|
||||||
|
["bot_avatar.gd", bot_text, "FOOT_SYNC_MAX", str(PlaytestAvatar.FOOT_SYNC_MAX)],
|
||||||
|
["bot_avatar.gd", bot_text, "CROSSFADE_S", str(PlaytestAvatar.CROSSFADE_S)],
|
||||||
|
["bot_avatar.gd", bot_text, "BLEND_SMOOTH_S", str(PlaytestAvatar.BLEND_SMOOTH_S)],
|
||||||
|
["bot_avatar.gd", bot_text, "MODEL_EYE_HEIGHT", str(PlaytestAvatar.MODEL_EYE_HEIGHT)],
|
||||||
|
["bot_avatar.gd", bot_text, "MODEL_Y_OFFSET", str(PlaytestAvatar.MODEL_Y_OFFSET)],
|
||||||
|
["bot_avatar.gd", bot_text, "RIFLE_POS", "Vector3(0.0, 0.067, 0.0)"],
|
||||||
|
["match_remote_avatar.gd", parent_text, "WALK_NATURAL_MPS", str(PlaytestAvatar.WALK_NATURAL_MPS)],
|
||||||
|
["match_remote_avatar.gd", parent_text, "JOG_NATURAL_MPS", str(PlaytestAvatar.JOG_NATURAL_MPS)],
|
||||||
|
["testmap_player.gd", player_text, "EYE_HEIGHT", str(PlaytestBody.EYE_HEIGHT)],
|
||||||
|
["testmap_player.gd", player_text, "WALK_SPEED", str(PlaytestBody.WALK_SPEED)],
|
||||||
|
["testmap_player.gd", player_text, "SPRINT_SPEED", str(PlaytestBody.SPRINT_SPEED)],
|
||||||
|
["testmap_player.gd", player_text, "JUMP_SPEED", str(PlaytestBody.JUMP_SPEED)],
|
||||||
|
["testmap_player.gd", player_text, "GRAVITY", str(PlaytestBody.GRAVITY)],
|
||||||
|
]
|
||||||
|
for paar in zahl_paare:
|
||||||
|
var quelle := _const_wert(paar[1], paar[2])
|
||||||
|
var quell_zahl := float(quelle) if not quelle.is_empty() else -999.0
|
||||||
|
_check(is_equal_approx(quell_zahl, float(paar[3])),
|
||||||
|
"%s.%s == %s (Quelle: %s)" % [paar[0], paar[2], paar[3], quelle])
|
||||||
|
|
||||||
|
# Text-Konstanten (Clips, Knochen).
|
||||||
|
_check(_const_wert(parent_text, "CLIP_IDLE") == "\"Idle\"" and PlaytestAvatar.CLIP_IDLE == "Idle",
|
||||||
|
"CLIP_IDLE == Client (Idle)")
|
||||||
|
_check(_const_wert(parent_text, "CLIP_WALK") == "\"Walk\"" and PlaytestAvatar.CLIP_WALK == "Walk",
|
||||||
|
"CLIP_WALK == Client (Walk)")
|
||||||
|
_check(_const_wert(parent_text, "CLIP_JOG") == "\"Jog_Fwd\"" and PlaytestAvatar.CLIP_JOG == "Jog_Fwd",
|
||||||
|
"CLIP_JOG == Client (Jog_Fwd)")
|
||||||
|
_check(_const_wert(bot_text, "RIFLE_HAND_BONE") == "\"hand_r\"",
|
||||||
|
"RIFLE_HAND_BONE == Client (hand_r)")
|
||||||
|
|
||||||
|
# Frontachse: Quelle deklariert MODEL_YAW_OFFSET = PI (vc21-Rückwärts-Fix).
|
||||||
|
_check(_const_wert(bot_text, "MODEL_YAW_OFFSET") == "PI"
|
||||||
|
and is_equal_approx(PlaytestAvatar.MODEL_YAW_OFFSET, PI),
|
||||||
|
"MODEL_YAW_OFFSET == PI (GLB-Frontachse +Z, vc21)")
|
||||||
|
_check(PlaytestAvatar.MODEL_FORWARD_LOCAL == Vector3(0.0, 0.0, 1.0),
|
||||||
|
"MODEL_FORWARD_LOCAL == (0, 0, 1) (vc21-Messung)")
|
||||||
|
|
||||||
|
# Gait-Schwellen spiegeln die Velocity-Inferenz des Clients.
|
||||||
|
_check(PlaytestAvatar.zustand_fuer(0.1) == PlaytestAvatar.STATE_IDLE
|
||||||
|
and PlaytestAvatar.zustand_fuer(1.0) == PlaytestAvatar.STATE_WALK
|
||||||
|
and PlaytestAvatar.zustand_fuer(6.0) == PlaytestAvatar.STATE_JOG,
|
||||||
|
"zustand_fuer spiegelt die Client-Schwellen (0,3/2,6 m/s)")
|
||||||
|
|
||||||
|
|
||||||
|
## --------------------------------------------------- 2. Avatar laden -------
|
||||||
|
|
||||||
|
func _test_avatar_laden() -> Node3D:
|
||||||
|
print("Testgruppe: Avatar aus dem Nachbar-Klon laden (GLTF + AnimationTree)")
|
||||||
|
var pfad := PlaytestAvatar.model_pfad()
|
||||||
|
_check(not pfad.is_empty() and FileAccess.file_exists(pfad),
|
||||||
|
"Avatar-GLB im Client-Klon gefunden (kein Duplikat im Editor)")
|
||||||
|
var avatar: Node3D = PlaytestAvatar.lade_avatar()
|
||||||
|
_check(avatar != null, "Avatar lädt (GLTFDocument, GLB außerhalb res://)")
|
||||||
|
if avatar == null:
|
||||||
|
return null
|
||||||
|
root.add_child(avatar)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
# AnimationTree-Pipeline: Motion-State aktiv, Idle als Start-Zustand.
|
||||||
|
_check(avatar.get("_tree") != null, "AnimationTree gebaut (A4-Pipeline)")
|
||||||
|
# apply_state (wie _speise_avatar im Betrieb) startet den Motion-State
|
||||||
|
# sauber nach dem Baum-Eintritt; die StateMachine schreibt den Knoten
|
||||||
|
# beim nächsten Prozess-Tick — daher ein Frame abwarten.
|
||||||
|
avatar.call("apply_state", PlaytestAvatar.STATE_IDLE, 0.0)
|
||||||
|
await process_frame
|
||||||
|
_check(avatar.call("tree_state") == "Motion", "StateMachine startet im Motion-State")
|
||||||
|
_check(avatar.call("current_clip") == "Idle", "Start-Anim = Idle (kein T-Pose-Blitz)")
|
||||||
|
_check(avatar.call("anim_running"), "AnimationTree aktiv")
|
||||||
|
# Loop-Mode: Idle/Walk/Jog laufen endlos (Importer-Semantik nachgebildet).
|
||||||
|
var anim: AnimationPlayer = avatar.get("_anim")
|
||||||
|
_check(anim.has_animation("Idle") and anim.has_animation("Walk")
|
||||||
|
and anim.has_animation("Jog_Fwd"),
|
||||||
|
"Clips unter den Bot-Namen (Idle/Walk/Jog_Fwd — _loop-Endung gestrichen)")
|
||||||
|
_check(anim.get_animation("Idle").loop_mode == Animation.LOOP_LINEAR,
|
||||||
|
"Idle-Clip loopt (GLTFDocument liefert sonst Einmal-Abspiel)")
|
||||||
|
|
||||||
|
# Gait-Speisung: Walk/Jog-Sollwerte wie bot_avatar._set_state_animation.
|
||||||
|
avatar.call("apply_state", PlaytestAvatar.STATE_WALK, 2.0)
|
||||||
|
_check(avatar.call("current_clip") == "Walk", "STATE_WALK → Walk-Clip")
|
||||||
|
_check(is_equal_approx(float(avatar.get("_blend_target")), PlaytestAvatar.WALK_NATURAL_MPS),
|
||||||
|
"Blend-Soll = Walk-Natur (0,979 m/s) — der State wählt den Gang")
|
||||||
|
avatar.call("apply_state", PlaytestAvatar.STATE_JOG, 6.0)
|
||||||
|
_check(avatar.call("current_clip") == "Jog_Fwd", "STATE_JOG → Jog-Clip")
|
||||||
|
_check(is_equal_approx(float(avatar.get("_blend_target")), PlaytestAvatar.JOG_NATURAL_MPS),
|
||||||
|
"Blend-Soll = Jog-Natur (2,620 m/s)")
|
||||||
|
|
||||||
|
# Kadenz-Mathe (BOT2): TimeScale = Kadenz × Zykluslänge / 2, geklemmt.
|
||||||
|
var walk_len: float = anim.get_animation("Walk").length
|
||||||
|
var jog_len: float = anim.get_animation("Jog_Fwd").length
|
||||||
|
var walk_soll: float = clampf(2.0 / PlaytestAvatar.WALK_STEP_M * walk_len / 2.0,
|
||||||
|
PlaytestAvatar.FOOT_SYNC_MIN, PlaytestAvatar.FOOT_SYNC_MAX)
|
||||||
|
var jog_soll: float = clampf(6.0 / PlaytestAvatar.JOG_STEP_M * jog_len / 2.0,
|
||||||
|
PlaytestAvatar.FOOT_SYNC_MIN, PlaytestAvatar.FOOT_SYNC_MAX)
|
||||||
|
_check(is_equal_approx(avatar.call("gait_scale_for", PlaytestAvatar.STATE_WALK, 2.0), walk_soll),
|
||||||
|
"Walk-Kadenz = BOT2-Formel (m/s / 0,95 m → TimeScale %.2f)" % walk_soll)
|
||||||
|
_check(is_equal_approx(avatar.call("gait_scale_for", PlaytestAvatar.STATE_JOG, 6.0), jog_soll),
|
||||||
|
"Jog-Kadenz = BOT2-Formel (m/s / 0,85 m → TimeScale %.2f, geklemmt)" % jog_soll)
|
||||||
|
# 6 m/s Gehen im Playtest ist CLIENT-seitig schneller als jeder Bot — die
|
||||||
|
# Kadenz-Klammer (FOOT_SYNC_MAX 2,0) muss greifen, kein Ausreißer.
|
||||||
|
_check(avatar.call("gait_scale_for", PlaytestAvatar.STATE_JOG, 6.0) <= PlaytestAvatar.FOOT_SYNC_MAX,
|
||||||
|
"Kadenz-Klammer greift bei Playtest-Tempi (6 m/s)")
|
||||||
|
avatar.call("apply_state", PlaytestAvatar.STATE_IDLE, 0.0)
|
||||||
|
|
||||||
|
# Identität der Proportionen: Modellhöhe ≈ Client (1,83 m), Kapsel 1,8 m.
|
||||||
|
var hoehe := _modell_hoehe(avatar)
|
||||||
|
_check(absf(hoehe - 1.83) < BEWEIS_TOLERANZ_HOEHE,
|
||||||
|
"Modellhöhe ≈ Client-Avatar (gemessen %.2f m ≈ 1,83 m)" % hoehe)
|
||||||
|
_check(absf(hoehe - PlaytestBody.KAPSEL_HEIGHT) < BEWEIS_TOLERANZ_HOEHE,
|
||||||
|
"Avatar-Höhe == Kapsel-Höhe-Konvention (%.2f ≈ %.2f m)" % [hoehe, PlaytestBody.KAPSEL_HEIGHT])
|
||||||
|
# Füße am Boden: GLB-Ursprung = Füße → Unterkante ≈ MODEL_Y_OFFSET unterm
|
||||||
|
# Kopf-Anker (= exakt Füßpunkt des Körpers bei Anker 1,65 − Halbhöhe 0,9).
|
||||||
|
var unten := _modell_unterkante(avatar)
|
||||||
|
_check(absf(unten - PlaytestAvatar.MODEL_Y_OFFSET) < 0.05,
|
||||||
|
"Modell-Füße am Anker minus 1,65 m (gemessen %.2f)" % unten)
|
||||||
|
|
||||||
|
# Rifle-Mount (wie die Bots): BoneAttachment3D + Rifle-GLB. Pfad über
|
||||||
|
# Suche (Skeleton hängt unter Armature — fester Node-Pfad ist spröde).
|
||||||
|
var mount: Node3D = null
|
||||||
|
for b in avatar.find_children("RifleMount", "BoneAttachment3D", true, false):
|
||||||
|
mount = b
|
||||||
|
_check(mount != null, "RifleMount (BoneAttachment3D hand_r) vorhanden")
|
||||||
|
_check(mount != null and mount.get_node_or_null("Rifle") != null,
|
||||||
|
"Rifle hängt am Mount (m40_rifle.glb aus dem Client-Klon)")
|
||||||
|
return avatar
|
||||||
|
|
||||||
|
|
||||||
|
## Vereinigte AABB-Höhe aller Meshes unter dem Avatar (Weltbezug des Knotens).
|
||||||
|
func _modell_hoehe(avatar: Node3D) -> float:
|
||||||
|
var box := _modell_box(avatar)
|
||||||
|
return box.size.y
|
||||||
|
|
||||||
|
|
||||||
|
## Unterkante der vereinigten AABB relativ zum Avatar-Node (Kopf-Anker).
|
||||||
|
func _modell_unterkante(avatar: Node3D) -> float:
|
||||||
|
return _modell_box(avatar).position.y
|
||||||
|
|
||||||
|
|
||||||
|
func _modell_box(avatar: Node3D) -> AABB:
|
||||||
|
var lokal := avatar.global_transform.affine_inverse()
|
||||||
|
var ergebnis := AABB(Vector3.ZERO, Vector3.ZERO)
|
||||||
|
var erste := true
|
||||||
|
for mi in avatar.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
var m := mi as MeshInstance3D
|
||||||
|
if m.mesh == null:
|
||||||
|
continue
|
||||||
|
var welt := m.global_transform * m.mesh.get_aabb()
|
||||||
|
var hier := lokal * welt
|
||||||
|
if erste:
|
||||||
|
ergebnis = hier
|
||||||
|
erste = false
|
||||||
|
else:
|
||||||
|
ergebnis = ergebnis.merge(hier)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
## ------------------------------------------- 3. Perspektive + Kamera -------
|
||||||
|
|
||||||
|
func _test_perspektive_und_kamera(avatar: Node3D) -> void:
|
||||||
|
print("Testgruppe: Perspektive (Taste V) — First/Third, SpringArm, Persistenz")
|
||||||
|
|
||||||
|
# Persistenz: Default First Person.
|
||||||
|
PlaytestSettings.reset_cache()
|
||||||
|
PlaytestSettings.save_third(false)
|
||||||
|
PlaytestSettings.reset_cache()
|
||||||
|
_check(not PlaytestSettings.load_third(), "Default = First Person (persistiert false)")
|
||||||
|
|
||||||
|
# Leichter Body ohne Avatar (graceful Fallback, kein Crash).
|
||||||
|
var body_leer: CharacterBody3D = load("res://src/editor/playtest/playtest_body.gd").new()
|
||||||
|
var boden := StaticBody3D.new()
|
||||||
|
var boden_form := CollisionShape3D.new()
|
||||||
|
var boden_box := BoxShape3D.new()
|
||||||
|
boden_box.size = Vector3(400.0, 0.2, 400.0)
|
||||||
|
boden_form.shape = boden_box
|
||||||
|
boden.add_child(boden_form)
|
||||||
|
boden.position = Vector3(500.0, -0.1, 500.0) # getrennt vom Haupt-Body
|
||||||
|
root.add_child(boden)
|
||||||
|
body_leer.position = Vector3(500.0, 1.0, 500.0)
|
||||||
|
root.add_child(body_leer)
|
||||||
|
await physics_frame
|
||||||
|
_check(not body_leer.call("toggle_perspektive"), "Ohne Avatar: V schaltet NICHT (First bleibt)")
|
||||||
|
_check(not bool(body_leer.get("perspektive_dritte")), "Ohne Avatar: Zustand bleibt First")
|
||||||
|
|
||||||
|
# Mit Avatar: V schaltet, Third zeigt Körper, SpringArm-Kamera übernimmt.
|
||||||
|
var body: CharacterBody3D = load("res://src/editor/playtest/playtest_body.gd").new()
|
||||||
|
root.add_child(body)
|
||||||
|
body.position = Vector3(0.0, 1.0, 0.0)
|
||||||
|
await physics_frame
|
||||||
|
body.call("set_avatar", avatar)
|
||||||
|
_check(body.get("avatar") == avatar, "Avatar am Body (Kopf-Anker-Konvention)")
|
||||||
|
_check(not bool(body.get("perspektive_dritte")), "Start = First Person")
|
||||||
|
_check(not avatar.visible, "First Person: Avatar-Körper unsichtbar (Kapsel bleibt)")
|
||||||
|
var look: Camera3D = body.get("look_cam")
|
||||||
|
_check(look.current, "First Person: Augen-Kamera aktiv")
|
||||||
|
|
||||||
|
_check(body.call("toggle_perspektive"), "V schaltet auf Third Person")
|
||||||
|
_check(avatar.visible, "Third Person: Bot-Körper sichtbar")
|
||||||
|
_check(not look.current, "First-Kamera abgegeben")
|
||||||
|
var tpp: Camera3D = body.get("tpp_cam")
|
||||||
|
_check(tpp != null and tpp.current, "Third-Kamera aktiv (SpringArm)")
|
||||||
|
var spring: SpringArm3D = body.get("spring")
|
||||||
|
_check(spring != null and is_equal_approx(spring.spring_length, 3.5),
|
||||||
|
"SpringArm-Länge 3,5 m")
|
||||||
|
|
||||||
|
# Kamera steht HINTER dem Spieler und schaut AUF IHN (kein 180°-Dreher).
|
||||||
|
await process_frame
|
||||||
|
await physics_frame
|
||||||
|
var kamera_pos: Vector3 = tpp.global_position
|
||||||
|
var pivot_pos: Vector3 = spring.global_position
|
||||||
|
var hinter: Vector3 = kamera_pos - body.global_position
|
||||||
|
_check(hinter.z > 1.0, "Kamera hinter dem Spieler (Z +%.2f m bei Blick −Z)" % hinter.z)
|
||||||
|
var blick: Vector3 = tpp.global_transform.basis * Vector3(0.0, 0.0, -1.0)
|
||||||
|
var zum_koerper := (pivot_pos - kamera_pos).normalized()
|
||||||
|
_check(blick.dot(zum_koerper) > 0.9, "Third-Kamera schaut auf den Spieler (dot %.2f)" % blick.dot(zum_koerper))
|
||||||
|
|
||||||
|
# Kein Clipping: Wand 1,0 m hinter dem Spieler → Kamera zieht vor die Wand.
|
||||||
|
var wand := StaticBody3D.new()
|
||||||
|
var wand_form := CollisionShape3D.new()
|
||||||
|
var wand_box := BoxShape3D.new()
|
||||||
|
wand_box.size = Vector3(8.0, 12.0, 0.4)
|
||||||
|
wand_form.shape = wand_box
|
||||||
|
wand.add_child(wand_form)
|
||||||
|
wand.position = Vector3(body.global_position.x, 1.0, body.global_position.z + 1.2)
|
||||||
|
root.add_child(wand)
|
||||||
|
await physics_frame
|
||||||
|
await physics_frame
|
||||||
|
var abstand: float = tpp.global_position.distance_to(spring.global_position)
|
||||||
|
_check(abstand < 1.0,
|
||||||
|
"Kamera zieht vor die Wand zurück (Arm %.2f m < 1,0 m — kein Clipping)" % abstand)
|
||||||
|
wand.queue_free()
|
||||||
|
|
||||||
|
# Zurück zu First: V again — Avatar unsichtbar, Augen-Kamera aktiv.
|
||||||
|
_check(not body.call("toggle_perspektive"), "V schaltet zurück auf First")
|
||||||
|
_check(not avatar.visible and look.current, "First wiederhergestellt (Körper weg, Augen-Kamera aktiv)")
|
||||||
|
|
||||||
|
# Persistenz: Third speichern + zurücklesen.
|
||||||
|
PlaytestSettings.save_third(true)
|
||||||
|
PlaytestSettings.reset_cache()
|
||||||
|
_check(PlaytestSettings.load_third(), "Third Person persistiert (Neustart-Simulation)")
|
||||||
|
PlaytestSettings.save_third(false)
|
||||||
|
|
||||||
|
# Blickrichtung == Bewegungsrichtung: yaw −90° (Blick +x) + W → +x.
|
||||||
|
body.set("yaw_deg", -90.0)
|
||||||
|
body.set("auto_input", {"fwd": true, "back": false, "left": false,
|
||||||
|
"right": false, "run": false, "jump": false})
|
||||||
|
var start := Vector3(body.position)
|
||||||
|
for i in 30:
|
||||||
|
await physics_frame
|
||||||
|
body.set("auto_input", {})
|
||||||
|
var delta := Vector3(body.position) - start
|
||||||
|
_check(delta.x > 1.0 and absf(delta.z) < 0.2,
|
||||||
|
"Bewegung folgt der Blickrichtung (W bei Blick +x → Δx %.2f, Δz %.2f)" % [delta.x, delta.z])
|
||||||
|
# Avatar-Front == Bewegungsrichtung (vc21-Vertrag, MODEL_YAW_OFFSET).
|
||||||
|
avatar.call("apply_state", PlaytestAvatar.STATE_WALK, 6.0)
|
||||||
|
_check(avatar.call("model_forward").dot(Vector3(1.0, 0.0, 0.0)) > 0.9,
|
||||||
|
"Avatar-Front == Bewegungsrichtung (+x, MODEL_YAW_OFFSET korrekt)")
|
||||||
|
|
||||||
|
body_leer.queue_free()
|
||||||
|
body.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
## ------------------------------------------------ 4. Playtest-Integration --
|
||||||
|
|
||||||
|
func _test_integration_playtest() -> void:
|
||||||
|
print("Testgruppe: Integration — enter() baut Avatar, Gait aus echter Bewegung")
|
||||||
|
var scene: PackedScene = load("res://scenes/main.tscn")
|
||||||
|
var main: Control = scene.instantiate()
|
||||||
|
root.add_child(main)
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var doc: Dictionary = SvmapDocument.create_new("Avatar-Test", 100.0, "klar")
|
||||||
|
main.set("doc", doc)
|
||||||
|
main.set("current_path", "")
|
||||||
|
main.set("dirty", false)
|
||||||
|
main.get("history").begin(doc)
|
||||||
|
main.call("_clear_edit_state")
|
||||||
|
main.call("_refresh_all")
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
main.call("_on_test_run")
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
var pt: Node = main.get("playtest")
|
||||||
|
_check(pt != null, "PlaytestMode angelegt")
|
||||||
|
if pt == null:
|
||||||
|
main.queue_free()
|
||||||
|
return
|
||||||
|
var body: Node = pt.get("body")
|
||||||
|
_check(bool(pt.get("_avatar_da")), "enter() fand die Avatar-Modelle (Client-Klon)")
|
||||||
|
var avatar: Node3D = body.get("avatar") as Node3D
|
||||||
|
_check(avatar != null, "Avatar am Playtest-Body")
|
||||||
|
|
||||||
|
# Gait-Speisung aus der ECHTEN Geschwindigkeit: loslaufen → JOG.
|
||||||
|
body.set("auto_move_dir", Vector3(1, 0, 0))
|
||||||
|
for i in 30:
|
||||||
|
await physics_frame
|
||||||
|
var speed: float = body.call("horizontale_geschwindigkeit")
|
||||||
|
_check(speed > 5.0, "Spieler wirklich in Bewegung (%.1f m/s)" % speed)
|
||||||
|
_check(int(avatar.get("state")) == PlaytestAvatar.STATE_JOG
|
||||||
|
and avatar.call("current_clip") == "Jog_Fwd",
|
||||||
|
"Laufen (6 m/s) → Jog-State wie die Bots im Spiel")
|
||||||
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
|
for i in 30:
|
||||||
|
await physics_frame
|
||||||
|
_check(int(avatar.get("state")) == PlaytestAvatar.STATE_IDLE
|
||||||
|
and avatar.call("current_clip") == "Idle",
|
||||||
|
"Stillstand → Idle-State")
|
||||||
|
|
||||||
|
# Taste V über den PlaytestMode (persistiert + Meldung).
|
||||||
|
_check(bool(pt.call("toggle_perspektive")), "PlaytestMode.toggle_perspektive → Third")
|
||||||
|
_check(bool(body.get("perspektive_dritte")) and avatar.visible,
|
||||||
|
"Third aktiv (Avatar sichtbar)")
|
||||||
|
_check(bool(PlaytestSettings.load_third()), "Umschalten persistiert")
|
||||||
|
_check(not bool(pt.call("toggle_perspektive")), "PlaytestMode.toggle_perspektive → First")
|
||||||
|
_check(not PlaytestSettings.load_third(), "Rückschalten persistiert (Default First)")
|
||||||
|
|
||||||
|
# HUD-Hinweis nennt die Taste V.
|
||||||
|
var hint_text := ""
|
||||||
|
for label in pt.get("hud").find_children("*", "Label", true, false):
|
||||||
|
var l := label as Label
|
||||||
|
if l.text.begins_with("WASD"):
|
||||||
|
hint_text = l.text
|
||||||
|
_check(hint_text.contains("V = 1./3.-Person"), "HUD-Hinweis nennt Taste V")
|
||||||
|
|
||||||
|
# ESC → Editor-Zustand wiederhergestellt (Avatar mit dem Body abgeräumt).
|
||||||
|
pt.call("handle_escape")
|
||||||
|
await process_frame
|
||||||
|
_check(main.get("playtest") == null, "PlaytestMode abgeräumt (Avatar mit weg)")
|
||||||
|
main.queue_free()
|
||||||
1
tests/test_playtest_avatar.gd.uid
Normal file
1
tests/test_playtest_avatar.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://de8r5nfunbbpb
|
||||||
Loading…
Reference in a new issue