Playtest-Physik = Quest 3 identisch (t_1da96275): CharacterBody3D + echte Kollider statt AABB-Blocking — KolliderBau (1:1-Spiegel svmap_world), Testmap-Player-Parameter (Kapsel 0.35x1.8, 6/14 m/s, Jump 4.5, Step-Up 0.45), Bauteil-Trimeshe (Treppe/Tür), Identitäts-Vertrag Suite (Editor vs. Client, 8 Entscheidungen identisch), FPS-Probe (Physik-Overhead 7%)
This commit is contained in:
parent
fabbbcce20
commit
0cdf68622c
24 changed files with 1591 additions and 535 deletions
|
|
@ -1,59 +1,80 @@
|
||||||
extends Node3D
|
extends CharacterBody3D
|
||||||
class_name PlaytestBody
|
class_name PlaytestBody
|
||||||
## Lauf-Modus Spieler (Karte t_1cdc1a72): erste Person, WASD + Maus,
|
## Lauf-Modus Spieler (Karte t_1cdc1a72, Physik-Neubau t_1da96275):
|
||||||
## Shift = rennen, Space = springen, Gravitation.
|
## erste Person, WASD + Maus, Shift = rennen, Space = springen, Gravitation.
|
||||||
##
|
##
|
||||||
## Kollision OHNE Physik-Welt (SubViewport ohne Physik-Tick):
|
## ECHTE PHYSIK — identisch zum Quest-3-Client (Sly-Forderung 20.10.:
|
||||||
## - Boden: Terrain-Raster des DOKUMENTS abtasten (SvmapDocument.
|
## „Die Möglichkeiten des Map Editors sollen ABSOLUT identisch mit der
|
||||||
## terrain_height_at) — nichts neu berechnen.
|
## Quest-3-Version sein“). Dieser Body ist ein CharacterBody3D mit EXAKT
|
||||||
## - Objekte/Bau-Teile: vorgecachte Welt-AABBs (playtest_mode.gd sammelt
|
## denselben Parametern wie client/scripts/world/testmap_player.gd
|
||||||
## sie beim Start aus den Marker-Metas + BuildOps.piece_aabb) als
|
## (TestmapPlayer): Kapsel 0.35 × 1.8, WALK/SPRINT/JUMP/GRAVITY,
|
||||||
## einfache Blöcke. Blöcke mit Oberkante ≤ Schritt-Höhe sind begehbar
|
## Step-Up 0.45 m nach move_and_slide (RayCast-Kanten-Abtastung, 1:1
|
||||||
## (Dach wird Boden), alles was darüber ragt blockt horizontal.
|
## gespiegelt inkl. Decken-Guard). Die Kollider kommen von KolliderBau
|
||||||
|
## (Spiegel von svmap_world.gd) — dieselben Trimeshe wie im Spiel.
|
||||||
##
|
##
|
||||||
## Steuerung bewusst als reine step()-Methode: die echte _physics_process
|
## Der alte AABB-Blocking-Code (vorgecachte Kisten, eigener step()-Solver
|
||||||
## pollt nur die Tasten und ruft step() mit einem Input-Dictionary —
|
## ohne Physik-Server) ist ENTFERNT — es gibt genau EINEN Bewegungscode,
|
||||||
## die Suite (tests/test_playtest.gd) ruft step() mit simulierten Inputs
|
## denselben wie auf der Quest.
|
||||||
## und testet Mathe/Kollision OHNE echten Input.
|
|
||||||
##
|
##
|
||||||
## ESC wird NICHT hier gefangen: main.gd behandelt Esc VOR der normalen
|
## Steuerung: _physics_process pollt die Tasten (oder die Autopilot-Haken
|
||||||
## Esc-Kette, solange der Lauf-Modus aktiv ist („Esc = zurück zum Editor“).
|
## auto_move_dir/auto_jump/auto_run für Suiten und spätere Automatisierung)
|
||||||
|
## und fährt dieselbe Logik wie der Client. ESC wird NICHT hier gefangen:
|
||||||
|
## main.gd behandelt Esc VOR der normalen Esc-Kette, solange der Lauf-Modus
|
||||||
|
## aktiv ist („Esc = zurück zum Editor“).
|
||||||
|
##
|
||||||
|
## Koordinaten-Konvention (wie Client): Body-Ursprung = KAPSEL-MITTE,
|
||||||
|
## Kamera sitzt EYE_HEIGHT über der Mitte (TestmapPlayer.EYE_HEIGHT).
|
||||||
|
|
||||||
signal exit_requested
|
signal exit_requested
|
||||||
|
|
||||||
const DEFAULT_MOVE_SPEED := 4.0 # m/s Gehen
|
# --- Parameter 1:1 aus client/scripts/world/testmap_player.gd ---------------
|
||||||
const DEFAULT_RUN_MULT := 2.0 # Shift = rennen
|
const WALK_SPEED := 6.0
|
||||||
const DEFAULT_JUMP_SPEED := 4.5 # m/s Sprungimpuls
|
const SPRINT_SPEED := 14.0
|
||||||
const DEFAULT_GRAVITY := 12.0 # m/s² — Spiele-Wert, kein Physik-Server
|
const JUMP_SPEED := 4.5
|
||||||
const DEFAULT_EYE_HEIGHT := 1.7 # Kamera-Augenhöhe über dem Fußpunkt
|
const GRAVITY := 9.8
|
||||||
const DEFAULT_STEP_HEIGHT := 0.45 # Stufen/Trittkanten, die begehbar sind
|
const EYE_HEIGHT := 0.65 # Kamera über der Kapsel-Mitte (Kapsel 1.8 m)
|
||||||
const DEFAULT_RADIUS := 0.35 # Spieler-Kolisionsradius (AABB-Aufblas)
|
const KILL_Y_DROP_M := -200.0 # Notfall: unter die Welt gefallen → Respawn
|
||||||
const DEFAULT_BODY_HEIGHT := 1.85 # Kopf-Raum (Block-ab-Prüfung)
|
const KAPSEL_RADIUS := 0.35
|
||||||
|
const KAPSEL_HEIGHT := 1.8
|
||||||
|
const HALF_H := 0.9 # Kapsel-Halbhöhe (Kapsel 1.8 m) — Füße = y − 0.9
|
||||||
|
const STEP_HEIGHT := 0.45 # = TestmapPlayer.STEP_HEIGHT (Karte t_f9adf97c)
|
||||||
|
const STEP_PROBE_DIST := 0.7
|
||||||
|
const STEP_LIFT_EPS := 0.02
|
||||||
|
|
||||||
|
# --- Editor-seitige Konventionen (unverändert gegenüber dem alten Body) -----
|
||||||
const DEFAULT_MOUSE_SENS := 0.25 # Grad pro Pixel Mausbewegung
|
const DEFAULT_MOUSE_SENS := 0.25 # Grad pro Pixel Mausbewegung
|
||||||
|
|
||||||
const PITCH_LIMIT := 89.0 # Kopfüber/Nackenschmerz-Grenze
|
const PITCH_LIMIT := 89.0 # Kopfüber/Nackenschmerz-Grenze
|
||||||
|
const TELEPORT_REICHWEITE := 4000.0
|
||||||
|
|
||||||
## Spielbare Parameter (PlaytestMode setzt die Defaults; Suite kann nachsetzen)
|
## Spielbare Parameter
|
||||||
var move_speed: float = DEFAULT_MOVE_SPEED
|
|
||||||
var run_mult: float = DEFAULT_RUN_MULT
|
|
||||||
var jump_speed: float = DEFAULT_JUMP_SPEED
|
|
||||||
var gravity: float = DEFAULT_GRAVITY
|
|
||||||
var eye_height: float = DEFAULT_EYE_HEIGHT
|
|
||||||
var step_height: float = DEFAULT_STEP_HEIGHT
|
|
||||||
var radius: float = DEFAULT_RADIUS
|
|
||||||
var body_height: float = DEFAULT_BODY_HEIGHT
|
|
||||||
var mouse_sens: float = DEFAULT_MOUSE_SENS
|
var mouse_sens: float = DEFAULT_MOUSE_SENS
|
||||||
|
|
||||||
## Map-Dokument (nur LESEND — Terrain-Höhenabfrage)
|
## Map-Dokument (nur LESEND — Terrain-Raster für Spawn/HUD/Notfall)
|
||||||
var doc: Dictionary = {}
|
var doc: Dictionary = {}
|
||||||
## Fliegen / God-Mode (Karte t_aaff5dcc, Taste G): KEINE Gravitation, Bewegung
|
## Fliegen / God-Mode (Karte t_aaff5dcc, Taste G): KEINE Gravitation, Bewegung
|
||||||
## in Blickrichtung (inkl. Nickwinkel), Space = steigen, Kollision aus
|
## in Blickrichtung (inkl. Nickwinkel), Space = steigen, Noclip — der Tester
|
||||||
## (Noclip — der Tester kommt auch über Wände/Hügel schnell ans Ziel).
|
## kommt auch über Wände/Hügel schnell ans Ziel.
|
||||||
var fly_mode := false
|
var fly_mode := false
|
||||||
## Vorgecachte Hindernis-AABBs (Weltkoordinaten) — von PlaytestMode gefüllt
|
## Autopilot-Haken (wie TestmapPlayer.auto_move_dir, Karte t_f9adf97c):
|
||||||
var obstacles: Array = [] # Array[AABB]
|
## nicht-leer = DIESER Welt-Vektor ist der Bewegungswunsch (Tastatur aus).
|
||||||
|
var auto_move_dir := Vector3.ZERO
|
||||||
|
## Autopilot-Ergänzung für Suiten: Sprung/Run erzwingen ohne Tastatur.
|
||||||
|
var auto_jump := false
|
||||||
|
var auto_run := false
|
||||||
|
## Suite-Hook: nicht-leer = komplettes Input-Dictionary überschreibt ALLES
|
||||||
|
## (gleiche Form wie der alte step()-Input: fwd/back/left/right/run/jump).
|
||||||
|
var auto_input: Dictionary = {}
|
||||||
|
|
||||||
|
## Bewegungszustand (Kompatibilität zur alten Oberfläche)
|
||||||
|
var yaw_deg: float = 0.0 # Gierwinkel Blickrichtung (Grad)
|
||||||
|
var pitch_deg: float = 0.0 # Nickwinkel Blickrichtung (Grad)
|
||||||
|
var on_floor: bool = false
|
||||||
|
|
||||||
|
## Kinder: die Spiel-Kamera (Augen) — PlaytestMode hängt sie an und macht current
|
||||||
|
var look_cam: Camera3D
|
||||||
|
|
||||||
## Höhenfeld-Cache (Zeile: res*res Meter) — von PlaytestMode via set_terrain()
|
## Höhenfeld-Cache (Zeile: res*res Meter) — von PlaytestMode via set_terrain()
|
||||||
## gefüllt, damit die Boden-Abfrage NICHT jeden Schritt das Base64 dekodiert.
|
## gefüllt (Spawn/HUD/jump_to_spawn; die PHYSIK läuft über den Trimesh-Kollider).
|
||||||
var heights: PackedFloat32Array = PackedFloat32Array()
|
var heights: PackedFloat32Array = PackedFloat32Array()
|
||||||
var res: int = 0
|
var res: int = 0
|
||||||
var size_x: float = 0.0
|
var size_x: float = 0.0
|
||||||
|
|
@ -61,22 +82,23 @@ var size_z: float = 0.0
|
||||||
var hmin: float = 0.0
|
var hmin: float = 0.0
|
||||||
var hmax: float = 0.0
|
var hmax: float = 0.0
|
||||||
|
|
||||||
## Bewegungszustand
|
var _spawn_pos := Vector3.ZERO
|
||||||
var yaw_deg: float = 0.0 # Gierwinkel Blickrichtung (Grad)
|
|
||||||
var pitch_deg: float = 0.0 # Nickwinkel Blickrichtung (Grad)
|
|
||||||
var velocity: Vector3 = Vector3.ZERO # m/s
|
|
||||||
var on_floor: bool = false
|
|
||||||
|
|
||||||
## Kinder: die Spiel-Kamera (Augen) — PlaytestMode hängt sie an und macht current
|
|
||||||
var look_cam: Camera3D
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
_spawn_pos = global_position
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var capsule := CapsuleShape3D.new()
|
||||||
|
capsule.radius = KAPSEL_RADIUS
|
||||||
|
capsule.height = KAPSEL_HEIGHT
|
||||||
|
cs.shape = capsule
|
||||||
|
add_child(cs)
|
||||||
look_cam = Camera3D.new()
|
look_cam = Camera3D.new()
|
||||||
look_cam.near = 0.1
|
look_cam.name = "Camera"
|
||||||
look_cam.far = 6000.0
|
look_cam.position.y = EYE_HEIGHT
|
||||||
look_cam.current = true
|
look_cam.far = 4000.0
|
||||||
add_child(look_cam)
|
add_child(look_cam)
|
||||||
|
look_cam.current = true
|
||||||
_apply_look()
|
_apply_look()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -85,7 +107,12 @@ func _apply_look() -> void:
|
||||||
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), deg_to_rad(yaw_deg), 0.0)
|
||||||
look_cam.position = Vector3(0.0, eye_height, 0.0)
|
look_cam.position = Vector3(0.0, EYE_HEIGHT, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
## Füß-Höhe über der Welt (Kapsel-Mitte minus Halbhöhe) — HUD/Tests.
|
||||||
|
func fuss_hoehe() -> float:
|
||||||
|
return global_position.y - HALF_H
|
||||||
|
|
||||||
|
|
||||||
## Volle 3D-Blickrichtung (Karte t_aaff5dcc): -Z um Pitch (X) und Yaw (Y)
|
## Volle 3D-Blickrichtung (Karte t_aaff5dcc): -Z um Pitch (X) und Yaw (Y)
|
||||||
|
|
@ -98,110 +125,192 @@ func view_direction() -> Vector3:
|
||||||
-cos(yaw_rad) * cos(pitch_rad))
|
-cos(yaw_rad) * cos(pitch_rad))
|
||||||
|
|
||||||
|
|
||||||
## Eine Bewegungs-Schritt (physics frame) mit EXPLIZITEM Input ausführen.
|
## Echter Physics-Step: Input zusammenbauen (Tasten ODER Autopilot) → fahren.
|
||||||
## input = {fwd: bool, back: bool, left: bool, right: bool, run: bool,
|
func _physics_process(delta: float) -> void:
|
||||||
## jump: bool} — die echte _physics_process baut das aus den
|
var input := {}
|
||||||
## Tasten, die Suite aus simulierten Flags. delta in Sekunden.
|
if not auto_input.is_empty():
|
||||||
## Rückgabe: {pos, vel, on_floor} nach dem Schritt.
|
input = auto_input
|
||||||
func step(delta: float, input: Dictionary) -> Dictionary:
|
else:
|
||||||
var fwd: bool = bool(input.get("fwd", false))
|
input = {
|
||||||
var back: bool = bool(input.get("back", false))
|
"fwd": Input.is_key_pressed(KEY_W),
|
||||||
var left: bool = bool(input.get("left", false))
|
"back": Input.is_key_pressed(KEY_S),
|
||||||
var right: bool = bool(input.get("right", false))
|
"left": Input.is_key_pressed(KEY_A),
|
||||||
var run: bool = bool(input.get("run", false))
|
"right": Input.is_key_pressed(KEY_D),
|
||||||
var jump: bool = bool(input.get("jump", false))
|
"run": Input.is_key_pressed(KEY_SHIFT) or auto_run,
|
||||||
|
"jump": Input.is_key_pressed(KEY_SPACE) or auto_jump,
|
||||||
|
}
|
||||||
|
if auto_move_dir.length_squared() > 0.0:
|
||||||
|
input["auto_dir"] = auto_move_dir
|
||||||
|
_fahren(delta, input)
|
||||||
|
|
||||||
var speed := move_speed * (run_mult if run else 1.0)
|
|
||||||
var move := Vector2.ZERO
|
|
||||||
if fwd:
|
|
||||||
move.y += 1.0
|
|
||||||
if back:
|
|
||||||
move.y -= 1.0
|
|
||||||
if right:
|
|
||||||
move.x += 1.0
|
|
||||||
if left:
|
|
||||||
move.x -= 1.0
|
|
||||||
if move.length_squared() > 0.0:
|
|
||||||
move = move.normalized()
|
|
||||||
|
|
||||||
# --- Fliegen / God-Mode (Karte t_aaff5dcc, Taste G) -----------------------
|
## Ein Bewegungs-Schritt (physics frame) mit EXPLIZITEM Input ausführen.
|
||||||
# Keine Gravitation, Bewegung in der VOLLEN Blickrichtung (Blick nach
|
## input = {fwd, back, left, right, run, jump: bool, auto_dir: Vector3}.
|
||||||
# unten = abwärts fliegen), Space = zusätzlich steigen, Kollision AUS
|
## DIESE Methode ist 1:1 die _physics_process des TestmapPlayers —
|
||||||
# (Noclip — schnell über die Map, auch über Wände/Hügel hinweg).
|
## identische Bewegungsmathe, identische Step-Up-Aufrufreihenfolge.
|
||||||
|
func _fahren(delta: float, input: Dictionary) -> void:
|
||||||
|
# --- Fliegen / God-Mode (Karte t_aaff5dcc, Taste G) ----------------------
|
||||||
|
# Keine Gravitation, Bewegung in der VOLLEN Blickrichtung, Space = steigen,
|
||||||
|
# Noclip (direkt positionieren — kein move_and_slide, kein Kollider).
|
||||||
if fly_mode:
|
if fly_mode:
|
||||||
var fly_speed := move_speed * (run_mult if run else 1.0)
|
var fly_speed := WALK_SPEED if not bool(input.get("run", false)) else SPRINT_SPEED
|
||||||
var fly_yaw_rad := deg_to_rad(yaw_deg)
|
var fly_yaw_rad := deg_to_rad(yaw_deg)
|
||||||
var fly_right := Vector3(cos(fly_yaw_rad), 0.0, -sin(fly_yaw_rad))
|
var fly_right := Vector3(cos(fly_yaw_rad), 0.0, -sin(fly_yaw_rad))
|
||||||
var wish3 := view_direction() * move.y + fly_right * move.x
|
var wish3: Vector3
|
||||||
if jump:
|
var ad: Variant = input.get("auto_dir", null)
|
||||||
|
if ad is Vector3 and (ad as Vector3).length_squared() > 0.0:
|
||||||
|
wish3 = (ad as Vector3).normalized() * fly_speed
|
||||||
|
else:
|
||||||
|
wish3 = view_direction() * (1.0 if bool(input.get("fwd", false)) else 0.0) \
|
||||||
|
- view_direction() * (1.0 if bool(input.get("back", false)) else 0.0) \
|
||||||
|
+ fly_right * (1.0 if bool(input.get("right", false)) else 0.0) \
|
||||||
|
- fly_right * (1.0 if bool(input.get("left", false)) else 0.0)
|
||||||
|
if bool(input.get("jump", false)):
|
||||||
wish3 += Vector3.UP
|
wish3 += Vector3.UP
|
||||||
if wish3.length_squared() > 0.0:
|
if wish3.length_squared() > 0.0:
|
||||||
wish3 = wish3.normalized()
|
wish3 = wish3.normalized() * fly_speed
|
||||||
velocity = wish3 * fly_speed
|
velocity = wish3
|
||||||
position += velocity * delta
|
position += velocity * delta
|
||||||
on_floor = false
|
on_floor = false
|
||||||
_apply_look()
|
_apply_look()
|
||||||
return {"pos": position, "vel": velocity, "on_floor": on_floor}
|
return
|
||||||
|
|
||||||
# Welt-Richtung aus dem Blickwinkel (y-only, keine Sprung-Schräglage)
|
# Welt-Richtung aus dem Blickwinkel (y-only, wie Client).
|
||||||
var yaw_rad := deg_to_rad(yaw_deg)
|
var forward := -transform.basis.z
|
||||||
var forward := Vector3(-sin(yaw_rad), 0.0, -cos(yaw_rad))
|
var right := transform.basis.x
|
||||||
var right_v := Vector3(cos(yaw_rad), 0.0, -sin(yaw_rad))
|
var dir := Vector3.ZERO
|
||||||
var wish := (forward * move.y + right_v * move.x) * speed
|
var ad2: Variant = input.get("auto_dir", null)
|
||||||
|
if ad2 is Vector3 and (ad2 as Vector3).length_squared() > 0.0:
|
||||||
# Horizontal ist snappy (kein Anlauf-Trägheit-Momentum): direkt setzen.
|
dir = ad2 as Vector3
|
||||||
velocity.x = wish.x
|
|
||||||
velocity.z = wish.z
|
|
||||||
|
|
||||||
# Vertikal: Gravitation + Sprung (nur vom Boden).
|
|
||||||
if jump and on_floor:
|
|
||||||
velocity.y = jump_speed
|
|
||||||
on_floor = false
|
|
||||||
elif on_floor:
|
|
||||||
velocity.y = 0.0
|
|
||||||
else:
|
else:
|
||||||
velocity.y = velocity.y - gravity * delta
|
if bool(input.get("fwd", false)):
|
||||||
|
dir += forward
|
||||||
var was_floor := on_floor
|
if bool(input.get("back", false)):
|
||||||
|
dir -= forward
|
||||||
# --- Horizontal X/Z SEPARAT auflösen (Wand-Sliding) ---
|
if bool(input.get("right", false)):
|
||||||
var next := position
|
dir += right
|
||||||
var nx := next.x + velocity.x * delta
|
if bool(input.get("left", false)):
|
||||||
if not _blocked_at(nx, next.z, next.y):
|
dir -= right
|
||||||
next.x = nx
|
var speed := SPRINT_SPEED if bool(input.get("run", false)) else WALK_SPEED
|
||||||
|
if dir.length_squared() > 0.0:
|
||||||
|
dir = dir.normalized() * speed
|
||||||
|
velocity.x = dir.x
|
||||||
|
velocity.z = dir.z
|
||||||
|
if is_on_floor() and bool(input.get("jump", false)):
|
||||||
|
velocity.y = JUMP_SPEED
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity.y -= GRAVITY * delta
|
||||||
|
var pre_pos := global_position
|
||||||
|
move_and_slide()
|
||||||
|
# Stufen-Hilfe (Karte t_f9adf97c, 1:1 aus TestmapPlayer): nach
|
||||||
|
# move_and_slide() prüfen, ob die horizontale Bewegung von einer Wand
|
||||||
|
# gestoppt wurde, während der Wunsch weiter in die Wand zeigt.
|
||||||
|
var wish_xz := Vector3(dir.x, 0.0, dir.z)
|
||||||
|
var blocked := false
|
||||||
|
if wish_xz.length_squared() > 0.0001:
|
||||||
|
if is_on_wall() and get_wall_normal().dot(wish_xz.normalized()) < -0.1:
|
||||||
|
blocked = true
|
||||||
else:
|
else:
|
||||||
velocity.x = 0.0
|
var got := global_position - pre_pos
|
||||||
var nz := next.z + velocity.z * delta
|
got.y = 0.0
|
||||||
if not _blocked_at(next.x, nz, next.y):
|
var want := wish_xz * delta
|
||||||
next.z = nz
|
if got.length() < want.length() * 0.4:
|
||||||
else:
|
blocked = true
|
||||||
velocity.z = 0.0
|
if blocked:
|
||||||
|
try_step_up(wish_xz)
|
||||||
# --- Vertikal: Boden finden (Terrain + begehbare Block-Decken) ---
|
if global_position.y < KILL_Y_DROP_M:
|
||||||
var ground := _ground_height_at(next.x, next.z, next.y)
|
# Sicherheitsnetz (sollte mit Trimesh-Terrain nicht passieren).
|
||||||
var ny := next.y + velocity.y * delta
|
global_position = _spawn_pos
|
||||||
if velocity.y <= 0.0:
|
velocity = Vector3.ZERO
|
||||||
# Fallen oder am Boden: Bodenhöhe greifen (auch beim Bergab-Gehen,
|
on_floor = is_on_floor()
|
||||||
# wenn der Boden unter der Füßen „wegrutscht“ — snap-down nur bis
|
|
||||||
# step_height pro Schritt, sonst fällt man Abhänge frei).
|
|
||||||
if ny <= ground:
|
|
||||||
ny = ground
|
|
||||||
velocity.y = 0.0
|
|
||||||
on_floor = true
|
|
||||||
elif was_floor and ny - ground <= step_height:
|
|
||||||
ny = ground # Bergab-Übergang (kleine Kante mitnehmen)
|
|
||||||
velocity.y = 0.0
|
|
||||||
on_floor = true
|
|
||||||
else:
|
|
||||||
on_floor = false
|
|
||||||
else:
|
|
||||||
# Sprung-Phase: kein Boden-Greifen; auf_floaten lassen
|
|
||||||
on_floor = false
|
|
||||||
next.y = ny
|
|
||||||
|
|
||||||
position = next
|
|
||||||
_apply_look()
|
_apply_look()
|
||||||
return {"pos": position, "vel": velocity, "on_floor": on_floor}
|
|
||||||
|
|
||||||
|
## ------------------------------------------------------------------
|
||||||
|
## Stufen-Hilfe — 1:1 aus client/scripts/world/testmap_player.gd
|
||||||
|
## (Karte t_f9adf97c): Stufen bis STEP_HEIGHT erklimmen. Nach
|
||||||
|
## move_and_slide(), wenn die horizontale Bewegung von einer Wand gestoppt
|
||||||
|
## wurde UND der Bewegungswunsch in die Wand zeigt:
|
||||||
|
## Ray 1 (Fußhöhe vorwärts) → Wand-/Kanten-Fläche finden,
|
||||||
|
## Ray 2 (Kopfhöhe vorwärts) → Wand überragt die Stufenhöhe? → blockt,
|
||||||
|
## Ray 3 (über der Kante abwärts) → neue Standfläche messen,
|
||||||
|
## Decken-Strahl → Platz für die Kapsel über der Stufe?
|
||||||
|
## ------------------------------------------------------------------
|
||||||
|
func _capsule_half_height() -> float:
|
||||||
|
for child in get_children():
|
||||||
|
var cs := child as CollisionShape3D
|
||||||
|
if cs != null and cs.shape is CapsuleShape3D:
|
||||||
|
return (cs.shape as CapsuleShape3D).height * 0.5
|
||||||
|
return 0.9
|
||||||
|
|
||||||
|
|
||||||
|
func try_step_up(wish: Vector3) -> bool:
|
||||||
|
var dir := Vector3(wish.x, 0.0, wish.z)
|
||||||
|
if not is_on_floor() or dir.length_squared() < 0.0001:
|
||||||
|
return false
|
||||||
|
dir = dir.normalized()
|
||||||
|
var space := get_world_3d().direct_space_state
|
||||||
|
if space == null:
|
||||||
|
return false
|
||||||
|
var half_h := _capsule_half_height()
|
||||||
|
var feet := global_position.y - half_h
|
||||||
|
var mask := collision_mask # nur die Welt tasten, nicht den eigenen Körper
|
||||||
|
var exclude: Array[RID] = [get_rid()]
|
||||||
|
|
||||||
|
# Ray 1: vorwärts in Knöchelhöhe — gibt es überhaupt eine Stufe/Wand?
|
||||||
|
var ankle := global_position + Vector3(0.0, -half_h + 0.05, 0.0)
|
||||||
|
var q1 := PhysicsRayQueryParameters3D.create(
|
||||||
|
ankle + dir * 0.1, ankle + dir * STEP_PROBE_DIST, mask, exclude)
|
||||||
|
var hit1 := space.intersect_ray(q1)
|
||||||
|
if hit1.is_empty():
|
||||||
|
return false
|
||||||
|
if (hit1["normal"] as Vector3).dot(dir) > -0.3:
|
||||||
|
return false # Fläche zeigt nicht gegen die Laufrichtung (Rampe/Kante)
|
||||||
|
|
||||||
|
# Ray 2: vorwärts knapp ÜBER der maximalen Stufenhöhe — trifft er die
|
||||||
|
# Wand noch, ist sie höher als STEP_HEIGHT → kein Anheben.
|
||||||
|
var top_probe := global_position + Vector3(0.0, -half_h + STEP_HEIGHT + 0.01, 0.0)
|
||||||
|
var q2 := PhysicsRayQueryParameters3D.create(
|
||||||
|
top_probe + dir * 0.1, top_probe + dir * STEP_PROBE_DIST, mask, exclude)
|
||||||
|
if not space.intersect_ray(q2).is_empty():
|
||||||
|
return false # Wand überragt die Stufenhöhe
|
||||||
|
|
||||||
|
# Ray 3: über der Kante abwärts — neue Standfläche messen (Startpunkt
|
||||||
|
# liegt über Ray 2 frei, also außerhalb der Wand).
|
||||||
|
var edge := (hit1["position"] as Vector3) + dir * 0.1
|
||||||
|
var q3 := PhysicsRayQueryParameters3D.create(
|
||||||
|
Vector3(edge.x, feet + STEP_HEIGHT + 0.05, edge.z),
|
||||||
|
Vector3(edge.x, feet - 0.05, edge.z), mask, exclude)
|
||||||
|
var hit3 := space.intersect_ray(q3)
|
||||||
|
if hit3.is_empty():
|
||||||
|
return false
|
||||||
|
var new_feet := (hit3["position"] as Vector3).y
|
||||||
|
if new_feet < feet - 0.01: # Abgrund statt Fläche? Nichts anheben.
|
||||||
|
return false
|
||||||
|
var diff := new_feet - feet
|
||||||
|
if diff > STEP_HEIGHT + 0.005 or diff <= 0.0:
|
||||||
|
return false
|
||||||
|
if (hit3["normal"] as Vector3).y < 0.5:
|
||||||
|
return false # keine begehbare Fläche (Decke/Schräge von unten)
|
||||||
|
|
||||||
|
# Decken-Check: Platz für die volle Kapselhöhe über der neuen Standfläche?
|
||||||
|
# An der Ziel-XZ prüfen (etwas vorwärts — dort landt die Kapsel nach dem
|
||||||
|
# Schritt), sonst entgeht die Terrassen-Decke über dem Stufenkopf.
|
||||||
|
var check := global_position + dir * 0.45
|
||||||
|
var q4 := PhysicsRayQueryParameters3D.create(
|
||||||
|
Vector3(check.x, new_feet + 0.05, check.z),
|
||||||
|
Vector3(check.x, new_feet + half_h * 2.0 + 0.05, check.z),
|
||||||
|
mask, exclude)
|
||||||
|
if not space.intersect_ray(q4).is_empty():
|
||||||
|
return false # Decke zu tief (z. B. Terrassen-Untersicht): unten bleiben
|
||||||
|
|
||||||
|
# Sanftes Anheben: Positions-Snap auf die neue Standfläche …
|
||||||
|
global_position.y = new_feet + half_h + STEP_LIFT_EPS
|
||||||
|
# … und Bewegung fortführen: der zweite move_and_slide()-Schritt trägt
|
||||||
|
# die Kapsel VOR die Kante auf die neue Stufe (sonst steht sie nur
|
||||||
|
# tangential auf der Kante und fällt zurück).
|
||||||
|
move_and_slide()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
## Maus-Bewegung (nur der Spieler selbst fängt sie — die Editor-Werkzeuge
|
## Maus-Bewegung (nur der Spieler selbst fängt sie — die Editor-Werkzeuge
|
||||||
|
|
@ -224,7 +333,9 @@ func request_exit() -> void:
|
||||||
|
|
||||||
## Terrain-Raster EINMALIG aus dem Dokument dekodieren (R16/LE/Base64 →
|
## Terrain-Raster EINMALIG aus dem Dokument dekodieren (R16/LE/Base64 →
|
||||||
## Meter-Array). Nutzt den Standard-Decoder SvmapIO.decode_heights (derselbe
|
## Meter-Array). Nutzt den Standard-Decoder SvmapIO.decode_heights (derselbe
|
||||||
## Code wie beim Editor-Rendering) — nichts neu rechnen.
|
## Code wie beim Editor-Rendering) — nichts neu rechnen. Die PHYSIK läuft
|
||||||
|
## über den Trimesh-Kollider (KolliderBau.terrain_form, identisch zum
|
||||||
|
## Client); dieses Raster dient Spawn/jump_to_spawn/HUD-Referenz.
|
||||||
func set_terrain(d: Dictionary) -> void:
|
func set_terrain(d: Dictionary) -> void:
|
||||||
doc = d
|
doc = d
|
||||||
heights = PackedFloat32Array()
|
heights = PackedFloat32Array()
|
||||||
|
|
@ -249,116 +360,55 @@ func set_terrain(d: Dictionary) -> void:
|
||||||
heights = decoded
|
heights = decoded
|
||||||
|
|
||||||
|
|
||||||
## Realer physics step: Tasten poll → step() aufrufen.
|
|
||||||
func _physics_process(_delta: float) -> void:
|
|
||||||
var input := {
|
|
||||||
"fwd": Input.is_key_pressed(KEY_W),
|
|
||||||
"back": Input.is_key_pressed(KEY_S),
|
|
||||||
"left": Input.is_key_pressed(KEY_A),
|
|
||||||
"right": Input.is_key_pressed(KEY_D),
|
|
||||||
"run": Input.is_key_pressed(KEY_SHIFT),
|
|
||||||
"jump": Input.is_key_pressed(KEY_SPACE),
|
|
||||||
}
|
|
||||||
step(_delta, input)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------ Kollision -------
|
|
||||||
|
|
||||||
## Ist die Position (x, z) mit Füßen bei feet_y von einem BLOCKIERNDEM
|
|
||||||
## Hindernis belegt? Blockierend = Oberkante über der Trittkante
|
|
||||||
## (feet_y + step_height) UND Unterkante unter der Kopf-Höhe.
|
|
||||||
## Aufblas um den Spieler-Radius: die AABB wächst um `radius` in x/z.
|
|
||||||
func _blocked_at(x: float, z: float, feet_y: float) -> bool:
|
|
||||||
var head := feet_y + body_height
|
|
||||||
for ab in obstacles:
|
|
||||||
var box: AABB = ab
|
|
||||||
if box.end.y <= feet_y + step_height:
|
|
||||||
continue # begehbar (Trittkante)
|
|
||||||
if box.position.y >= head:
|
|
||||||
continue # oberhalb des Kopfes — drunter durchlaufen
|
|
||||||
if x < box.position.x - radius or x > box.end.x + radius:
|
|
||||||
continue
|
|
||||||
if z < box.position.z - radius or z > box.end.z + radius:
|
|
||||||
continue
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
## Boden-Höhe an (x, z): Terrain-Raster + alle begehbaren Block-Decken,
|
|
||||||
## deren Oberkante zwischen Terrain und feet_y + step_height liegt.
|
|
||||||
func _ground_height_at(x: float, z: float, feet_y: float) -> float:
|
|
||||||
var g := terrain_height(x, z)
|
|
||||||
var limit := feet_y + step_height
|
|
||||||
for ab in obstacles:
|
|
||||||
var box: AABB = ab
|
|
||||||
if box.end.y > limit or box.end.y <= g:
|
|
||||||
continue
|
|
||||||
if x < box.position.x - radius or x > box.end.x + radius:
|
|
||||||
continue
|
|
||||||
if z < box.position.z - radius or z > box.end.z + radius:
|
|
||||||
continue
|
|
||||||
g = box.end.y
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
## Teleport zum Punkt unter dem Fadenkreuz (Karte t_96e3c4ee, Taste T im
|
## Teleport zum Punkt unter dem Fadenkreuz (Karte t_96e3c4ee, Taste T im
|
||||||
## Testlauf): Strahl aus dem Auge → erster Treffer auf Terrain ODER Hindernis-
|
## Testlauf): Strahl aus dem Auge gegen die ECHTE Physik-Welt (Trimesh-
|
||||||
## AABB; gelandet wird auf dem Boden DARUNTER (Terrain + begehbare Block-Decken
|
## Terrain + Objekt-/Bauteil-Kollider — dieselben Flächen wie im Spiel).
|
||||||
## bis zum Ziel-Höhenpunkt). Nie im Objekt landen: ist der Zielpunkt von einem
|
## Gelandet wird auf dem Boden DARUNTER (Strahl senkrecht abwärts). Nie im
|
||||||
## BLOCKIERENDEN Hindernis belegt, wird der Teleport abgelehnt (ok=false).
|
## Objekt landen: ist die Kapsel am Zielpunkt nicht frei, wird der Teleport
|
||||||
## Rückgabe: {ok, x, z, ground, msg} — msg im Klartext für HUD/Statuszeile.
|
## abgelehnt (ok=false). Rückgabe: {ok, x, z, ground, msg}.
|
||||||
func teleport_to_ground(origin: Vector3, dir: Vector3) -> Dictionary:
|
func teleport_to_ground(origin: Vector3, dir: Vector3) -> Dictionary:
|
||||||
# Terrain-Treffer (derselbe Decoder wie im Editor-Werkzeug).
|
var space := get_world_3d().direct_space_state
|
||||||
var t_hit: Variant = TerrainField.raycast(heights, res, size_x, size_z, origin, dir)
|
if space == null:
|
||||||
var t_dist := INF
|
return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0,
|
||||||
if t_hit != null:
|
"msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."}
|
||||||
t_dist = origin.distance_to(t_hit as Vector3)
|
|
||||||
# Hindernis-Treffer: nähester AABB-Einstiegspunkt gewinnt gegen Terrain.
|
|
||||||
var o_dist := INF
|
|
||||||
var o_point := Vector3.ZERO
|
|
||||||
var d := dir
|
var d := dir
|
||||||
if d.length_squared() > 0.000000001:
|
if d.length_squared() > 0.000000001:
|
||||||
d = d.normalized()
|
d = d.normalized()
|
||||||
for ab in obstacles:
|
var q := PhysicsRayQueryParameters3D.create(origin, origin + d * TELEPORT_REICHWEITE)
|
||||||
var box: AABB = ab
|
q.exclude = [get_rid()]
|
||||||
var t := TestStart.ray_aabb(origin, d, box)
|
var hit := space.intersect_ray(q)
|
||||||
if t >= 0.0 and t < o_dist:
|
if hit.is_empty():
|
||||||
o_dist = t
|
|
||||||
o_point = origin + d * t
|
|
||||||
# Ziel = der nähere der beiden Treffer.
|
|
||||||
var tx := 0.0
|
|
||||||
var tz := 0.0
|
|
||||||
var hit_y := 0.0
|
|
||||||
var found := false
|
|
||||||
if o_dist < t_dist:
|
|
||||||
tx = o_point.x
|
|
||||||
tz = o_point.z
|
|
||||||
hit_y = o_point.y
|
|
||||||
found = true
|
|
||||||
elif t_hit != null:
|
|
||||||
var th := t_hit as Vector3
|
|
||||||
tx = th.x
|
|
||||||
tz = th.z
|
|
||||||
hit_y = th.y
|
|
||||||
found = true
|
|
||||||
if not found:
|
|
||||||
return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0,
|
return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0,
|
||||||
"msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."}
|
"msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."}
|
||||||
# Boden unter dem Zielpunkt: Terrain + begehbare Block-Decken bis zum
|
var ziel := hit["position"] as Vector3
|
||||||
# Ziel-Höhenpunkt (Ziel auf einem Kisten-Dach = dort oben landen).
|
# Boden DARUNTER: senkrechter Strahl ab etwas über dem Treffer — trifft
|
||||||
var ground := terrain_height(tx, tz)
|
# er nichts (Treffer war eine Wandfläche ohne Boden drunter), Terrain-
|
||||||
var ceiling := maxf(hit_y, ground) + step_height
|
# Raster als Rückfallebene.
|
||||||
for ab in obstacles:
|
var ground := ziel.y
|
||||||
var box: AABB = ab
|
var q2 := PhysicsRayQueryParameters3D.create(
|
||||||
if box.end.y > ceiling or box.end.y <= ground:
|
Vector3(ziel.x, ziel.y + 0.5, ziel.z),
|
||||||
continue
|
Vector3(ziel.x, ziel.y - 50.0, ziel.z))
|
||||||
if tx < box.position.x or tx > box.end.x or tz < box.position.z or tz > box.end.z:
|
q2.exclude = [get_rid()]
|
||||||
continue
|
var hit2 := space.intersect_ray(q2)
|
||||||
ground = box.end.y
|
if hit2.is_empty():
|
||||||
if _blocked_at(tx, tz, ground):
|
ground = terrain_height(ziel.x, ziel.z)
|
||||||
|
else:
|
||||||
|
ground = (hit2["position"] as Vector3).y
|
||||||
|
var tx := ziel.x
|
||||||
|
var tz := ziel.z
|
||||||
|
# Landeplatz prüfen: passt die Kapsel dort frei? (Nie im Objekt landen.)
|
||||||
|
var probe := CapsuleShape3D.new()
|
||||||
|
probe.radius = KAPSEL_RADIUS
|
||||||
|
probe.height = KAPSEL_HEIGHT
|
||||||
|
var qp := PhysicsShapeQueryParameters3D.new()
|
||||||
|
qp.shape = probe
|
||||||
|
qp.transform = Transform3D(Basis.IDENTITY,
|
||||||
|
Vector3(tx, ground + HALF_H + 0.02, tz))
|
||||||
|
qp.exclude = [get_rid()]
|
||||||
|
if not space.intersect_shape(qp, 1).is_empty():
|
||||||
return {"ok": false, "x": tx, "z": tz, "ground": ground,
|
return {"ok": false, "x": tx, "z": tz, "ground": ground,
|
||||||
"msg": "Da steht etwas im Weg (%.1f, %.1f) — dort kannst du nicht landen." % [tx, tz]}
|
"msg": "Da steht etwas im Weg (%.1f, %.1f) — dort kannst du nicht landen." % [tx, tz]}
|
||||||
position = Vector3(tx, ground, tz)
|
position = Vector3(tx, ground + HALF_H + 0.02, tz)
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
on_floor = true
|
on_floor = true
|
||||||
_apply_look()
|
_apply_look()
|
||||||
|
|
@ -366,6 +416,7 @@ func teleport_to_ground(origin: Vector3, dir: Vector3) -> Dictionary:
|
||||||
|
|
||||||
|
|
||||||
## Terrain-Höhe an Welt-X/Z — aus dem DOCUMENT-Raster (nichts neu rechnen).
|
## Terrain-Höhe an Welt-X/Z — aus dem DOCUMENT-Raster (nichts neu rechnen).
|
||||||
|
## Referenz für Spawn/jump_to_spawn/HUD; die Physik nutzt den Trimesh.
|
||||||
func terrain_height(x: float, z: float) -> float:
|
func terrain_height(x: float, z: float) -> float:
|
||||||
if res < 2 or size_x <= 0.0 or size_z <= 0.0:
|
if res < 2 or size_x <= 0.0 or size_z <= 0.0:
|
||||||
return hmin
|
return hmin
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,14 @@ extends Node
|
||||||
## - Beim Start: die map_view_builder-Ausgabe (Modelle + Pack-Texturen)
|
## - Beim Start: die map_view_builder-Ausgabe (Modelle + Pack-Texturen)
|
||||||
## 1:1 weiterverwenden (der Node3D-Baum bleibt stehen, nur Editor-UI/Input
|
## 1:1 weiterverwenden (der Node3D-Baum bleibt stehen, nur Editor-UI/Input
|
||||||
## geht weg), Maus-Capture-Wunsch setzen, Panels ausblenden.
|
## geht weg), Maus-Capture-Wunsch setzen, Panels ausblenden.
|
||||||
## - PlaytestBody (src/editor/playtest/playtest_body.gd) anlegen, Terrain-
|
## - Kollider-Bau via KolliderBau (src/svmap/kollider_bau.gd — 1:1-Spiegel
|
||||||
## Raster EINMAL dekodieren, Start = erster Spawnpunkt (Team Blau),
|
## des Quest-Clients, Karte t_1da96275): Terrain-Trimesh + Objekt-Kollider
|
||||||
## sonst Kartenmitte.
|
## (box/kugel/zylinder/verbund/trimesh inkl. library/kollisions-Quellen)
|
||||||
|
## + Bauteil-Trimeshe (BuildOps-Pieces: Treppen/Türen/Wände begehbar
|
||||||
|
## wie echte Geometrie).
|
||||||
|
## - PlaytestBody (CharacterBody3D, IDENTISCHE Parameter wie der
|
||||||
|
## Testmap-Spieler inkl. Step-Up 0.45 m) anlegen, Terrain-Raster EINMAL
|
||||||
|
## dekodieren, Start = erster Spawnpunkt (Team Blau), sonst Kartenmitte.
|
||||||
## - ESC → exit_requested → main.gd stellt den Editor-Zustand 1:1 wieder her
|
## - ESC → exit_requested → main.gd stellt den Editor-Zustand 1:1 wieder her
|
||||||
## (Panels, Maus, Kamera, Auswahl, Dokument, Undo-Stapel unverändert).
|
## (Panels, Maus, Kamera, Auswahl, Dokument, Undo-Stapel unverändert).
|
||||||
## - HUD (Position, FPS, „ESC = zurück zum Editor") als CanvasLayer-Overlay.
|
## - HUD (Position, FPS, „ESC = zurück zum Editor") als CanvasLayer-Overlay.
|
||||||
|
|
@ -18,9 +23,13 @@ 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"
|
||||||
|
|
||||||
## Der laufende Spieler (PlaytestBody, step-basiert)
|
## Der laufende Spieler (PlaytestBody, CharacterBody3D mit echter Physik)
|
||||||
var body: Node3D = null
|
var body: Node3D = null
|
||||||
|
## Welt-Kollider (Terrain + Objekte + Bauteile) — Kind dieses Modus,
|
||||||
|
## wird mit exit() abgeräumt.
|
||||||
|
var _kollider_root: Node3D = null
|
||||||
|
|
||||||
## Editor-UI-Verweise (vom main.gd-Handler gesetzt): die Panels, die
|
## Editor-UI-Verweise (vom main.gd-Handler gesetzt): die Panels, die
|
||||||
## AUSGEBLENDET werden sollen (Variant statt Control-Typ — keine UI-Kopie).
|
## AUSGEBLENDET werden sollen (Variant statt Control-Typ — keine UI-Kopie).
|
||||||
|
|
@ -66,12 +75,19 @@ func enter(doc: Dictionary, view_builder: Node3D, start_override: Variant = null
|
||||||
|
|
||||||
body = load(BODY_SCRIPT).new()
|
body = load(BODY_SCRIPT).new()
|
||||||
body.call("set_terrain", doc)
|
body.call("set_terrain", doc)
|
||||||
body.obstacles = _collect_obstacles(doc, view_builder)
|
|
||||||
var start := _spawn_start(doc)
|
var start := _spawn_start(doc)
|
||||||
if start_override != null:
|
if start_override != null:
|
||||||
var sp := start_override as Vector3
|
var sp := start_override as Vector3
|
||||||
start = Vector3(sp.x, 0.0, sp.z)
|
start = Vector3(sp.x, 0.0, sp.z)
|
||||||
body.position = Vector3(start.x, body.terrain_height(start.x, start.z), start.z)
|
# Spawn AUF der Terrainhöhe: Kapsel-Mitte = Füße + Halbhöhe (+ Toleranz,
|
||||||
|
# die Physik lässt den Body sanft auf den Trimesh fallen — wie im Spiel).
|
||||||
|
body.position = Vector3(start.x,
|
||||||
|
body.terrain_height(start.x, start.z) + PlaytestBody.HALF_H + 0.05,
|
||||||
|
start.z)
|
||||||
|
# Kollider 1:1 vom Quest-Client (Karte t_1da96275): Terrain-Trimesh +
|
||||||
|
# Objekt-Kollider aus der Registry + Bauteil-Trimeshe. Alles als Kind
|
||||||
|
# dieses Modus — exit() räumt es mit ab.
|
||||||
|
_baue_kollider(doc)
|
||||||
add_child(body)
|
add_child(body)
|
||||||
# Maus-Empfindlichkeit aus den Einstellungen (Karte t_aaff5dcc): der
|
# Maus-Empfindlichkeit aus den Einstellungen (Karte t_aaff5dcc): der
|
||||||
# gespeicherte Multiplikator (0,1–5,0) skaliert die Body-Basis.
|
# gespeicherte Multiplikator (0,1–5,0) skaliert die Body-Basis.
|
||||||
|
|
@ -93,6 +109,9 @@ func exit() -> void:
|
||||||
if body != null:
|
if body != null:
|
||||||
body.queue_free()
|
body.queue_free()
|
||||||
body = null
|
body = null
|
||||||
|
if _kollider_root != null:
|
||||||
|
_kollider_root.queue_free()
|
||||||
|
_kollider_root = null
|
||||||
if hud != null:
|
if hud != null:
|
||||||
hud.queue_free()
|
hud.queue_free()
|
||||||
hud = null
|
hud = null
|
||||||
|
|
@ -144,83 +163,36 @@ func _spawn_start(doc: Dictionary) -> Vector3:
|
||||||
(float(mn[2]) + float(mx[2])) * 0.5)
|
(float(mn[2]) + float(mx[2])) * 0.5)
|
||||||
|
|
||||||
|
|
||||||
## Hindernis-AABBs sammeln (nur LESEND, keine neue Physik-Welt):
|
## Echter Kollider-Bau (Karte t_1da96275 — „Editor-Playtest-Physik = Quest 3
|
||||||
## - Objekt-Marker: der ECHTE Fußabdruck aus der Registry (collision.size ×
|
## identisch"): der alte AABB-Blocking-Code ist ENTFERNT. Es gibt genau EINEN
|
||||||
## scale, um rot_y_deg konservativ gedreht — Sly-Befund 20.10.: die alte
|
## Kollider-Pfad, denselben wie der Quest-Client:
|
||||||
## pick_radius-Kugel-Näherung blockte ~40×40 m um eine 17×35 m-Kaserne =
|
## - Terrain + Registry-Objekte: KolliderBau.baue_welt_kollider (1:1-Spiegel
|
||||||
## „unsichtbare Wand“ weit vor dem Modell).
|
## von svmap_world.gd — box/kugel/zylinder/verbund/trimesh inkl.
|
||||||
## - Bau-Teile: exakt über BuildOps.piece_aabb (Dokument-Teile → AABB).
|
## library/kollisions-Quelldateien).
|
||||||
## - Fallback: Objekte ohne Registry-Eintrag behalten die alte Kugel-Näherung.
|
## - Bauteile (gebaeude): Trimesh aus dem gebackten Piece-Mesh — Türen
|
||||||
func _collect_obstacles(doc: Dictionary, view_builder: Node3D) -> Array:
|
## bleiben Durchgänge, Treppen sind begehbar (wie die sichtbare Geometrie).
|
||||||
var out: Array = []
|
func _baue_kollider(doc: Dictionary) -> void:
|
||||||
var lib := _lade_objekt_registry()
|
var kb := load(KOLLIDER_SCRIPT)
|
||||||
if view_builder != null:
|
_kollider_root = kb.call("baue_welt_kollider", doc)
|
||||||
for child in view_builder.get_children():
|
add_child(_kollider_root)
|
||||||
if child is Node3D and (child as Node3D).has_meta("obj_index"):
|
|
||||||
var m := child as Node3D
|
|
||||||
var idx := int(m.get_meta("obj_index", -1))
|
|
||||||
var aabb_v: Variant = _objekt_aabb_aus_registry(doc, idx, lib, m)
|
|
||||||
if aabb_v != null:
|
|
||||||
out.append(aabb_v)
|
|
||||||
continue
|
|
||||||
# Fallback: alte Kugel-Näherung (Objekt fehlt in der Registry).
|
|
||||||
var r := float(m.get_meta("pick_radius", 1.0))
|
|
||||||
var ch := float(m.get_meta("half_height", 0.5))
|
|
||||||
var p := m.position
|
|
||||||
out.append(AABB(Vector3(p.x - r, p.y, p.z - r),
|
|
||||||
Vector3(r * 2.0, ch * 2.0, r * 2.0)))
|
|
||||||
var build_ops := load(BUILD_OPS_SCRIPT)
|
var build_ops := load(BUILD_OPS_SCRIPT)
|
||||||
if build_ops != null:
|
if build_ops == null:
|
||||||
|
return
|
||||||
for teil in doc.get("gebaeude", []):
|
for teil in doc.get("gebaeude", []):
|
||||||
if teil is Dictionary:
|
if not (teil is Dictionary):
|
||||||
var ab: AABB = build_ops.call("piece_aabb", teil)
|
continue
|
||||||
if ab.size.x > 0.0 or ab.size.y > 0.0 or ab.size.z > 0.0:
|
var mesh: Mesh = build_ops.call("piece_mesh", teil)
|
||||||
out.append(ab)
|
if mesh == null:
|
||||||
return out
|
continue
|
||||||
|
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
|
||||||
|
var pb := StaticBody3D.new()
|
||||||
## objects.json einmal je Sammel-Lauf laden (klein genug, Playtest startet
|
pb.name = "Bauteil_Kollider_%d" % _kollider_root.get_child_count()
|
||||||
## selten) — Key „objects“ → Dictionary oid → Entry.
|
var cs := CollisionShape3D.new()
|
||||||
func _lade_objekt_registry() -> Dictionary:
|
cs.shape = mesh.create_trimesh_shape()
|
||||||
var parsed: Variant = JSON.parse_string(
|
pb.add_child(cs)
|
||||||
FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH))
|
pb.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
|
||||||
if parsed is Dictionary and (parsed as Dictionary).has("objects"):
|
pb.rotation.y = deg_to_rad(float(teil.get("rot", 0.0)))
|
||||||
return (parsed as Dictionary)["objects"]
|
_kollider_root.add_child(pb)
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
## Rotierter Fußabdruck-AABB eines Objekts aus der Registry — null wenn keine
|
|
||||||
## belastbaren Daten da sind (Caller fällt auf die Kugel-Näherung zurück).
|
|
||||||
## Y läuft vom Fußpunkt (Marker-Position) bis Fußpunkt + size.y × scale —
|
|
||||||
## gleiche Konvention wie der Client-Kollider.
|
|
||||||
func _objekt_aabb_aus_registry(doc: Dictionary, idx: int, lib: Dictionary,
|
|
||||||
marker: Node3D) -> Variant:
|
|
||||||
var objects: Array = doc.get("objects", [])
|
|
||||||
if idx < 0 or idx >= objects.size() or not (objects[idx] is Dictionary):
|
|
||||||
return null
|
|
||||||
var obj: Dictionary = objects[idx]
|
|
||||||
var entry: Dictionary = lib.get(str(obj.get("id", "")), {})
|
|
||||||
if entry.is_empty():
|
|
||||||
return null
|
|
||||||
var col: Dictionary = entry.get("collision", {})
|
|
||||||
var size_v: Variant = col.get("size", null)
|
|
||||||
if not (size_v is Array) or (size_v as Array).size() != 3:
|
|
||||||
return null
|
|
||||||
var skala := float(obj.get("scale", 1.0))
|
|
||||||
var sy := float(size_v[1]) * skala
|
|
||||||
var hx := float(size_v[0]) * skala * 0.5
|
|
||||||
var hz := float(size_v[2]) * skala * 0.5
|
|
||||||
if hx <= 0.0 or hz <= 0.0:
|
|
||||||
return null
|
|
||||||
var p := marker.position
|
|
||||||
var rot := deg_to_rad(float(obj.get("rot_y_deg", 0.0)))
|
|
||||||
var c := cos(rot)
|
|
||||||
var s := sin(rot)
|
|
||||||
# 4 Grundflächen-Ecken rotieren → konservative xz-Ausdehnung (symmetrisch
|
|
||||||
# um die Position, da der Fußabdruck zentriert ist).
|
|
||||||
var ex := maxf(absf(hx * c + hz * s), absf(hx * c - hz * s))
|
|
||||||
var ez := maxf(absf(hx * s + hz * c), absf(hx * s - hz * c))
|
|
||||||
return AABB(Vector3(p.x - ex, p.y, p.z - ez), Vector3(ex * 2.0, sy, ez * 2.0))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- HUD ----
|
# ------------------------------------------------------------------- HUD ----
|
||||||
|
|
@ -288,8 +260,11 @@ func _process(delta: float) -> void:
|
||||||
if hud_pos != null:
|
if hud_pos != null:
|
||||||
hud_pos.text = "Pos: %.1f, %.1f, %.1f" % [body.position.x, body.position.y, body.position.z]
|
hud_pos.text = "Pos: %.1f, %.1f, %.1f" % [body.position.x, body.position.y, body.position.z]
|
||||||
if hud_dir != null:
|
if hud_dir != null:
|
||||||
|
# Höhe = FUSSPUNKT (Kapsel-Mitte minus Halbhöhe) — direkt vergleichbar
|
||||||
|
# mit der Terrainhöhe unter den Füßen (Karte t_1da96275).
|
||||||
hud_dir.text = "Blick: %.0f° / %.0f° · Höhe: %.1f m" % [
|
hud_dir.text = "Blick: %.0f° / %.0f° · Höhe: %.1f m" % [
|
||||||
float(body.get("yaw_deg")), float(body.get("pitch_deg")), body.position.y]
|
float(body.get("yaw_deg")), float(body.get("pitch_deg")),
|
||||||
|
body.position.y - PlaytestBody.HALF_H]
|
||||||
if hud_fps != null:
|
if hud_fps != null:
|
||||||
hud_fps.text = "FPS: %d" % int(round(_fps_smooth))
|
hud_fps.text = "FPS: %d" % int(round(_fps_smooth))
|
||||||
|
|
||||||
|
|
@ -395,7 +370,9 @@ func jump_to_spawn(index: int) -> bool:
|
||||||
return false
|
return false
|
||||||
var target: Vector3 = sp["pos"]
|
var target: Vector3 = sp["pos"]
|
||||||
var y: float = body.terrain_height(target.x, target.z)
|
var y: float = body.terrain_height(target.x, target.z)
|
||||||
body.set("position", Vector3(target.x, y, target.z))
|
# Kapsel-Mitte = Füße + Halbhöhe (+ Toleranz) — die Physik lässt den Body
|
||||||
|
# sanft auf den Trimesh fallen (wie beim Start, Karte t_1da96275).
|
||||||
|
body.set("position", Vector3(target.x, y + PlaytestBody.HALF_H + 0.02, target.z))
|
||||||
body.set("velocity", Vector3.ZERO)
|
body.set("velocity", Vector3.ZERO)
|
||||||
body.set("on_floor", true)
|
body.set("on_floor", true)
|
||||||
show_message("Spawnpunkt %d = %s: x=%.1f z=%.1f (Höhe %.1f) — Blickrichtung steht im HUD." % [
|
show_message("Spawnpunkt %d = %s: x=%.1f z=%.1f (Höhe %.1f) — Blickrichtung steht im HUD." % [
|
||||||
|
|
|
||||||
236
src/svmap/kollider_bau.gd
Normal file
236
src/svmap/kollider_bau.gd
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
extends RefCounted
|
||||||
|
class_name KolliderBau
|
||||||
|
## Kollider-Bau 1:1 aus dem Quest-3-Client gespiegelt (Karte t_1da96275 —
|
||||||
|
## „Editor-Playtest-Physik = Quest 3 identisch").
|
||||||
|
##
|
||||||
|
## Dieser Modul ist der SPIEGEL von client/scripts/world/svmap_world.gd
|
||||||
|
## (_collider/_primitiv_form/_trimesh_aus_datei/_half_height + Terrain-
|
||||||
|
## Trimesh). Die Playtest-Physik im Editor benutzt EXAKT dieselben Formen
|
||||||
|
## wie der Quest-Client: box/kugel/zylinder/verbund, Trimesh (inline oder
|
||||||
|
## aus library/kollisions/<oid>.json), Terrain als Trimesh des Höhenfelds.
|
||||||
|
##
|
||||||
|
## Regel: Ändert der Client den Kollider-Bau, wird dieser Spiegel MIT
|
||||||
|
## geändert (Karte verweist auf svmap_world.gd als Quelle der Wahrheit).
|
||||||
|
## Bewusst KEINE Eigenlogik hier — jede Abweichung wäre eine Abweichung
|
||||||
|
## vom Quest-3-Verhalten, die Sly ausdrücklich abgelehnt hat.
|
||||||
|
|
||||||
|
const DEFAULT_MANIFEST_PATH := "res://library/objects.json"
|
||||||
|
|
||||||
|
## Trimesh-Quelldateien (begehbare Gebäude): Cache über alle Exemplare —
|
||||||
|
## mehrere Platzierungen derselben OID teilen sich die Vertices (wie im Client).
|
||||||
|
static var _trimesh_datei_cache := {}
|
||||||
|
|
||||||
|
|
||||||
|
## Bequemer Einstieg: Manifest selbst laden (leer → Objekte ohne Eintrag
|
||||||
|
## werden übersprungen — die Editor-Validierung hätte sie abgelehnt).
|
||||||
|
## Spiegel von SvmapWorld.load_manifest().
|
||||||
|
static func lade_manifest() -> Dictionary:
|
||||||
|
if not FileAccess.file_exists(DEFAULT_MANIFEST_PATH):
|
||||||
|
return {}
|
||||||
|
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(DEFAULT_MANIFEST_PATH))
|
||||||
|
return parsed if parsed is Dictionary else {}
|
||||||
|
|
||||||
|
|
||||||
|
## Baut ALLE Welt-Kollider aus einem svmap-Dokument: Terrain-Trimesh +
|
||||||
|
## je Objekt ein StaticBody3D (Form aus der Registry, Quelle-Dateien aus
|
||||||
|
## library/kollisions). Gibt die Wurzel zurück (Caller hängt sie ein und
|
||||||
|
## gibt sie mit frei). Spiegel von SvmapWorld._build (nur Kollider, ohne
|
||||||
|
## Visuals — die sieht der Editor bereits durch den view_builder).
|
||||||
|
static func baue_welt_kollider(data: Dictionary, manifest: Dictionary = {}) -> Node3D:
|
||||||
|
var root := Node3D.new()
|
||||||
|
root.name = "PlaytestKollider"
|
||||||
|
var lib: Dictionary = {}
|
||||||
|
if manifest.is_empty():
|
||||||
|
manifest = lade_manifest()
|
||||||
|
lib = manifest.get("objects", {})
|
||||||
|
var terrain: Dictionary = data.get("terrain", {})
|
||||||
|
if not terrain.is_empty():
|
||||||
|
var tform := terrain_form(data)
|
||||||
|
if tform != null:
|
||||||
|
var tbody := StaticBody3D.new()
|
||||||
|
tbody.name = "TerrainKollider"
|
||||||
|
var tcs := CollisionShape3D.new()
|
||||||
|
tcs.shape = tform
|
||||||
|
tbody.add_child(tcs)
|
||||||
|
root.add_child(tbody)
|
||||||
|
var objects: Array = data.get("objects", [])
|
||||||
|
var i := 0
|
||||||
|
for obj in objects:
|
||||||
|
i += 1
|
||||||
|
if not (obj is Dictionary):
|
||||||
|
continue
|
||||||
|
var oid := str(obj.get("id", ""))
|
||||||
|
var entry: Dictionary = lib.get(oid, {})
|
||||||
|
if entry.is_empty():
|
||||||
|
continue # Editor-Validierung hätte das abgelehnt — defensiv.
|
||||||
|
var col_spec: Dictionary = entry.get("collision", {})
|
||||||
|
var scale := float(obj.get("scale", 1.0))
|
||||||
|
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
|
||||||
|
var marker := Node3D.new()
|
||||||
|
marker.name = "Kollider_%d_%s" % [i, oid]
|
||||||
|
marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
|
||||||
|
marker.rotation.y = deg_to_rad(float(obj.get("rot_y_deg", 0.0)))
|
||||||
|
marker.add_child(kollider(col_spec, scale))
|
||||||
|
root.add_child(marker)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
## --------------------------------------------------------------------- --
|
||||||
|
## Ab hier 1:1-SPIEGEL von svmap_world.gd (Client) — Funktionsweise, Reihen-
|
||||||
|
## folge und Toleranzen identisch, nur statisch gebunden.
|
||||||
|
## --------------------------------------------------------------------- --
|
||||||
|
|
||||||
|
## Spiegel von SvmapWorld._collider(): compound = mehrere Teile mit eigenem
|
||||||
|
## Versatz/Drehung, "none" = kein Kollider, sonst eine Form am Fußpunkt.
|
||||||
|
static func kollider(col_spec: Dictionary, scale: float) -> StaticBody3D:
|
||||||
|
var body := StaticBody3D.new()
|
||||||
|
# Rein dekorativ ("none"): kein Kollider.
|
||||||
|
if str(col_spec.get("shape", "box")) == "none":
|
||||||
|
return body
|
||||||
|
if str(col_spec.get("shape", "box")) == "compound":
|
||||||
|
for teil_v in col_spec.get("parts", []):
|
||||||
|
var teil: Dictionary = teil_v if teil_v is Dictionary else {}
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
cs.shape = primitiv_form(teil, scale)
|
||||||
|
var off: Array = teil.get("pos", [0.0, 0.0, 0.0])
|
||||||
|
cs.position = Vector3(float(off[0]), float(off[1]), float(off[2])) * scale
|
||||||
|
var rot: Array = teil.get("rot_deg", [0.0, 0.0, 0.0])
|
||||||
|
cs.rotation_degrees = Vector3(float(rot[0]), float(rot[1]), float(rot[2]))
|
||||||
|
body.add_child(cs)
|
||||||
|
return body
|
||||||
|
var cs2 := CollisionShape3D.new()
|
||||||
|
var form := primitiv_form(col_spec, scale)
|
||||||
|
if form == null:
|
||||||
|
return body # Ungültige Kollisions-Spec (z. B. defektes Trimesh): kein Kollider.
|
||||||
|
cs2.shape = form
|
||||||
|
cs2.position.y = halbe_hoehe(cs2.shape) # pos = Objekt-Fußpunkt (Server-Konvention)
|
||||||
|
body.add_child(cs2)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
## Spiegel von SvmapWorld._primitiv_form(): box/sphere/cylinder/trimesh.
|
||||||
|
## Trimesh: inline-vertices oder collision.quelle aus library/kollisions/,
|
||||||
|
## × scale, backface AN (begehbar + beschußbar von beiden Seiten).
|
||||||
|
## Defekte Specs → null (kein Kollider).
|
||||||
|
static func primitiv_form(col_spec: Dictionary, scale: float) -> Shape3D:
|
||||||
|
match str(col_spec.get("shape", "box")):
|
||||||
|
"sphere":
|
||||||
|
var s := SphereShape3D.new()
|
||||||
|
s.radius = float(col_spec.get("radius", 1.0)) * scale
|
||||||
|
return s
|
||||||
|
"cylinder":
|
||||||
|
var s := CylinderShape3D.new()
|
||||||
|
s.radius = float(col_spec.get("radius", 1.0)) * scale
|
||||||
|
s.height = float(col_spec.get("height", 1.0)) * scale
|
||||||
|
return s
|
||||||
|
"trimesh":
|
||||||
|
var verts_v: Variant = col_spec.get("vertices", null)
|
||||||
|
if verts_v == null and col_spec.has("quelle"):
|
||||||
|
verts_v = trimesh_aus_datei(str(col_spec["quelle"]))
|
||||||
|
if not (verts_v is Array):
|
||||||
|
return null
|
||||||
|
var verts: Array = verts_v
|
||||||
|
if verts.is_empty() or verts.size() % 3 != 0:
|
||||||
|
return null
|
||||||
|
var faces := PackedVector3Array()
|
||||||
|
faces.resize(verts.size())
|
||||||
|
var fi := 0
|
||||||
|
for v in verts:
|
||||||
|
if not (v is Array) or (v as Array).size() != 3 \
|
||||||
|
or not (v[0] is float or v[0] is int) \
|
||||||
|
or not (v[1] is float or v[1] is int) \
|
||||||
|
or not (v[2] is float or v[2] is int):
|
||||||
|
return null
|
||||||
|
faces[fi] = Vector3(float(v[0]), float(v[1]), float(v[2])) * scale
|
||||||
|
fi += 1
|
||||||
|
var s := ConcavePolygonShape3D.new()
|
||||||
|
s.set_faces(faces)
|
||||||
|
s.backface_collision = true
|
||||||
|
return s
|
||||||
|
_:
|
||||||
|
var s := BoxShape3D.new()
|
||||||
|
var size: Array = col_spec.get("size", [1.0, 1.0, 1.0])
|
||||||
|
s.size = Vector3(float(size[0]), float(size[1]), float(size[2])) * scale
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
## Spiegel von SvmapWorld._trimesh_aus_datei(): Quelle-Datei relativ zu
|
||||||
|
## res://library/, JSON {"vertices": [[x,y,z], ...]}.
|
||||||
|
static func trimesh_aus_datei(quelle: String) -> Variant:
|
||||||
|
if quelle.is_empty():
|
||||||
|
return null
|
||||||
|
if _trimesh_datei_cache.has(quelle):
|
||||||
|
return _trimesh_datei_cache[quelle]
|
||||||
|
var fa := FileAccess.open("res://library/" + quelle, FileAccess.READ)
|
||||||
|
if fa == null:
|
||||||
|
_trimesh_datei_cache[quelle] = null
|
||||||
|
return null
|
||||||
|
var parsed: Variant = JSON.parse_string(fa.get_as_text())
|
||||||
|
var verts: Variant = null
|
||||||
|
if parsed is Dictionary and (parsed as Dictionary).get("vertices", null) is Array:
|
||||||
|
verts = (parsed as Dictionary)["vertices"]
|
||||||
|
_trimesh_datei_cache[quelle] = verts
|
||||||
|
return verts
|
||||||
|
|
||||||
|
|
||||||
|
## Spiegel des Terrain-Kolliders aus SvmapWorld._build_terrain(): DASSELBE
|
||||||
|
## Mesh (gleiche Platzierungs-Konvention (0,0) = (−size/2, −size/2), gleiche
|
||||||
|
## Dreiecks-Wicklung, generate_normals) → create_trimesh_shape(). Nur der
|
||||||
|
## Kollider, kein MeshInstance (den rendered der Editor bereits).
|
||||||
|
static func terrain_form(data: Dictionary) -> ConcavePolygonShape3D:
|
||||||
|
var terrain: Dictionary = data.get("terrain", {})
|
||||||
|
var res := int(terrain.get("resolution", 0))
|
||||||
|
var size_x := float(terrain.get("size_x_m", 0.0))
|
||||||
|
var size_z := float(terrain.get("size_z_m", 0.0))
|
||||||
|
var heights := SvmapIO.decode_heights(terrain)
|
||||||
|
if res < 2 or heights.size() < res * res or size_x <= 0.0 or size_z <= 0.0:
|
||||||
|
return null
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
st.set_smooth_group(-1) # wie Client (einflussfrei auf die Geometrie)
|
||||||
|
var last := float(res - 1)
|
||||||
|
var zi := 0
|
||||||
|
while zi < res:
|
||||||
|
var xi := 0
|
||||||
|
while xi < res:
|
||||||
|
# Platzierungs-Konvention (TerrainField.gd, map_document,
|
||||||
|
# HeightMapShape3D): Index (0,0) liegt bei (−size/2, −size/2).
|
||||||
|
var idx := zi * res + xi
|
||||||
|
st.add_vertex(Vector3(
|
||||||
|
float(xi) / last * size_x - size_x * 0.5,
|
||||||
|
heights[idx],
|
||||||
|
float(zi) / last * size_z - size_z * 0.5))
|
||||||
|
xi += 1
|
||||||
|
zi += 1
|
||||||
|
# Dreiecke so gewickelt, dass die Normalen nach OBEN zeigen — exakt die
|
||||||
|
# Client-Wicklung ((a, d, c) + (a, b, d), Godot-Front-Faces = clockwise).
|
||||||
|
var zi2 := 0
|
||||||
|
while zi2 < res - 1:
|
||||||
|
var xi2 := 0
|
||||||
|
while xi2 < res - 1:
|
||||||
|
var a := zi2 * res + xi2
|
||||||
|
var b := zi2 * res + xi2 + 1
|
||||||
|
var c := (zi2 + 1) * res + xi2
|
||||||
|
var d := (zi2 + 1) * res + xi2 + 1
|
||||||
|
st.add_index(a)
|
||||||
|
st.add_index(d)
|
||||||
|
st.add_index(c)
|
||||||
|
st.add_index(a)
|
||||||
|
st.add_index(b)
|
||||||
|
st.add_index(d)
|
||||||
|
xi2 += 1
|
||||||
|
zi2 += 1
|
||||||
|
st.generate_normals()
|
||||||
|
var mesh := st.commit()
|
||||||
|
return mesh.create_trimesh_shape() as ConcavePolygonShape3D
|
||||||
|
|
||||||
|
|
||||||
|
## Spiegel von SvmapWorld._half_height().
|
||||||
|
static func halbe_hoehe(shape: Shape3D) -> float:
|
||||||
|
if shape is BoxShape3D:
|
||||||
|
return (shape as BoxShape3D).size.y * 0.5
|
||||||
|
if shape is CylinderShape3D:
|
||||||
|
return (shape as CylinderShape3D).height * 0.5
|
||||||
|
if shape is SphereShape3D:
|
||||||
|
return (shape as SphereShape3D).radius
|
||||||
|
return 0.0
|
||||||
1
src/svmap/kollider_bau.gd.uid
Normal file
1
src/svmap/kollider_bau.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cncg002d4e5x7
|
||||||
85
tests/gen_identitaet_map.gd
Normal file
85
tests/gen_identitaet_map.gd
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Identitäts-Probe-Map (Karte t_1da96275): deterministische Testkarte, die
|
||||||
|
## BOTH WORLDS — Editor-Playtest UND Quest-Client — mit EXAKT denselben
|
||||||
|
## Daten fährt (Identitäts-Vertrag: gleiche Map → gleiche Block/Walk-
|
||||||
|
## Entscheidungen).
|
||||||
|
##
|
||||||
|
## Layout (Höhenfeld res 33 = 2-m-Zellen, 64 × 64 m):
|
||||||
|
## x < 0 → Höhe 0.0 (flach)
|
||||||
|
## 0 ≤ x < 8 → Höhe 0.3 (Stufe 0.3 m — erklimmbar, Step-Up)
|
||||||
|
## x ≥ 8 → Höhe 1.1 (Stufe 0.8 m darüber — blockt)
|
||||||
|
## Objekte (alle auf FLACHEM Terrain, damit die Proben sauber sind):
|
||||||
|
## building_001 bei (−16, 0, −16) → Tür-Probe (Azimut 202.5°, Befund
|
||||||
|
## Karte t_f9adf97c: freie Breite 1.10 m)
|
||||||
|
## barracks_001 bei ( 20, 0, 12) → Wand-Probe (Trimesh blockt)
|
||||||
|
## box_006 bei (−16, 0, 8) → Kisten-Stufe 0.334 m (Step-Up)
|
||||||
|
## Run: godot4 --headless --path . -s res://tests/gen_identitaet_map.gd
|
||||||
|
## Schreibt: ${SVR_IDENTITAET_MAP:-/tmp/svr_identitaet_probe.svmap} (JSON).
|
||||||
|
|
||||||
|
const STEP_KLEIN := 0.3
|
||||||
|
const STUFE_GROSS := 1.1
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var res := 33
|
||||||
|
var size := 64.0
|
||||||
|
var heights := PackedFloat32Array()
|
||||||
|
heights.resize(res * res)
|
||||||
|
for zi in res:
|
||||||
|
for xi in res:
|
||||||
|
# Platzierungs-Konvention: (0,0) liegt bei (−size/2, −size/2);
|
||||||
|
# Höhe hängt nur von x ab (Spalten).
|
||||||
|
var x := float(xi) / float(res - 1) * size - size * 0.5
|
||||||
|
var h := 0.0
|
||||||
|
if x >= 0.0:
|
||||||
|
h = STEP_KLEIN
|
||||||
|
if x >= 8.0:
|
||||||
|
h = STUFE_GROSS
|
||||||
|
heights[zi * res + xi] = h
|
||||||
|
var raw_b64 := TerrainField.encode(heights, 0.0, STUFE_GROSS)
|
||||||
|
var doc: Dictionary = {
|
||||||
|
"format_version": 1,
|
||||||
|
"info": {"name": "Identitaets-Probe", "author": "Karte t_1da96275", "version": 1},
|
||||||
|
"terrain": {
|
||||||
|
"type": "heightmap",
|
||||||
|
"resolution": res,
|
||||||
|
"size_x_m": size,
|
||||||
|
"size_z_m": size,
|
||||||
|
"min_height_m": 0.0,
|
||||||
|
"max_height_m": STUFE_GROSS,
|
||||||
|
"heightmap_r16_b64": raw_b64,
|
||||||
|
},
|
||||||
|
"objects": [
|
||||||
|
{"id": "building_001", "pos": [-16.0, 0.0, -16.0], "rot_y_deg": 0.0, "scale": 1.0},
|
||||||
|
{"id": "barracks_001", "pos": [20.0, 0.0, 12.0], "rot_y_deg": 0.0, "scale": 1.0},
|
||||||
|
{"id": "box_006", "pos": [-16.0, 0.0, 8.0], "rot_y_deg": 0.0, "scale": 1.0},
|
||||||
|
# Wand-Ersatz für die „Stufe 0.8 m blockt"-Probe: 0.85 m hoch —
|
||||||
|
# über der Step-Up-Grenze (0.45 m) → muss blocken. Steht AUF der
|
||||||
|
# 0.3-Ebene (Terrain bei x=2 ist 0.3), Fußpunkt-Konvention.
|
||||||
|
{"id": "barrel_001", "pos": [2.0, 0.3, 20.0], "rot_y_deg": 0.0, "scale": 1.0},
|
||||||
|
],
|
||||||
|
"spawn_zones": {
|
||||||
|
"0": {"points": [
|
||||||
|
{"id": "A1", "pos": [-30.0, 0.0, -30.0]}, {"id": "A2", "pos": [-28.0, 0.0, -20.0]},
|
||||||
|
{"id": "A3", "pos": [-26.0, 0.0, 20.0]}, {"id": "A4", "pos": [-24.0, 0.0, 10.0]},
|
||||||
|
]},
|
||||||
|
"1": {"points": [
|
||||||
|
{"id": "B1", "pos": [30.0, 0.0, -30.0]}, {"id": "B2", "pos": [28.0, 0.0, -20.0]},
|
||||||
|
{"id": "B3", "pos": [26.0, 0.0, 20.0]}, {"id": "B4", "pos": [24.0, 0.0, 10.0]},
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
"bounds": {"min": [-32.0, -5.0, -32.0], "max": [32.0, 20.0, 32.0]},
|
||||||
|
"weather": "mittag",
|
||||||
|
}
|
||||||
|
var path := OS.get_environment("SVR_IDENTITAET_MAP")
|
||||||
|
if path.is_empty():
|
||||||
|
path = "/tmp/svr_identitaet_probe.svmap"
|
||||||
|
var fa := FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
if fa == null:
|
||||||
|
printerr("IDENT_GEN FAIL: kann nicht schreiben: " + path)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
fa.store_string(JSON.stringify(doc))
|
||||||
|
fa.close()
|
||||||
|
print("IDENT_MAP=%s" % path)
|
||||||
|
quit(0)
|
||||||
1
tests/gen_identitaet_map.gd.uid
Normal file
1
tests/gen_identitaet_map.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b83poyvxdh3cv
|
||||||
1
tests/probe_buttons_pos.gd.uid
Normal file
1
tests/probe_buttons_pos.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dgsj8nupg3hc4
|
||||||
1
tests/probe_dialog_min.gd.uid
Normal file
1
tests/probe_dialog_min.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://baoakwtc0wtvc
|
||||||
61
tests/probe_physik_subviewport.gd
Normal file
61
tests/probe_physik_subviewport.gd
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Wegwerf-Probe (Karte t_1da96275): tickt echte Physik (CharacterBody3D +
|
||||||
|
## StaticBody-Trimesh) in einem SubViewport mit own_world_3d — so lebt der
|
||||||
|
## Playtest im Editor. Exit 0 = ja.
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var vp := SubViewport.new()
|
||||||
|
vp.own_world_3d = true
|
||||||
|
root.add_child(vp)
|
||||||
|
var welt := Node3D.new()
|
||||||
|
vp.add_child(welt)
|
||||||
|
# Boden: Box als StaticBody (Oberkante y=0)
|
||||||
|
var boden := StaticBody3D.new()
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = Vector3(40, 0.2, 40)
|
||||||
|
cs.shape = box
|
||||||
|
boden.add_child(cs)
|
||||||
|
boden.position = Vector3(0, -0.1, 0)
|
||||||
|
welt.add_child(boden)
|
||||||
|
# Wand zum Blocken
|
||||||
|
var wand := StaticBody3D.new()
|
||||||
|
var cs2 := CollisionShape3D.new()
|
||||||
|
var box2 := BoxShape3D.new()
|
||||||
|
box2.size = Vector3(2, 3, 0.6)
|
||||||
|
cs2.shape = box2
|
||||||
|
wand.add_child(cs2)
|
||||||
|
wand.position = Vector3(0, 1.5, -5)
|
||||||
|
welt.add_child(wand)
|
||||||
|
# PlaytestBody via Autopilot
|
||||||
|
var body: CharacterBody3D = load("res://src/editor/playtest/playtest_body.gd").new()
|
||||||
|
welt.add_child(body)
|
||||||
|
body.auto_move_dir = Vector3(0, 0, -1)
|
||||||
|
body.global_position = Vector3(0, 1.2, 5)
|
||||||
|
for i in 40:
|
||||||
|
await physics_frame
|
||||||
|
print("PROBE nach Fall: feet=%.3f on_floor=%s" % [body.fuss_hoehe(), str(body.is_on_floor())])
|
||||||
|
var ok_fall: bool = body.is_on_floor() and absf(body.fuss_hoehe()) < 0.05
|
||||||
|
for i in 240:
|
||||||
|
await physics_frame
|
||||||
|
if body.global_position.z < -4.2:
|
||||||
|
break
|
||||||
|
body.auto_move_dir = Vector3.ZERO
|
||||||
|
var z: float = body.global_position.z
|
||||||
|
print("PROBE vor Wand gestoppt: z=%.3f (Wand-Fläche bei -4.7)" % z)
|
||||||
|
var ok_block: bool = z > -4.6
|
||||||
|
# RayQuery gegen den Space des SubViewports
|
||||||
|
var raum := body.get_world_3d().direct_space_state
|
||||||
|
var hit := raum.intersect_ray(PhysicsRayQueryParameters3D.create(
|
||||||
|
Vector3(0, 2, -10), Vector3(0, 0, -10)))
|
||||||
|
print("PROBE ray hit Wand: %s" % str(not hit.is_empty()))
|
||||||
|
var ok_ray: bool = not hit.is_empty()
|
||||||
|
if ok_fall and ok_block and ok_ray:
|
||||||
|
print("PROBE OK: Physik im SubViewport tickt (Fall+Block+Ray)")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("PROBE FAIL: fall=%s block=%s ray=%s" % [str(ok_fall), str(ok_block), str(ok_ray)])
|
||||||
|
quit(1)
|
||||||
1
tests/probe_physik_subviewport.gd.uid
Normal file
1
tests/probe_physik_subviewport.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://5fc1sxkkbxr8
|
||||||
105
tests/probe_playtest_fps.gd
Normal file
105
tests/probe_playtest_fps.gd
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
extends SceneTree
|
||||||
|
## FPS-Probe (Karte t_1da96275, Punkt 5 der Spec): Playtest mit ECHTER Physik
|
||||||
|
## (SubViewport + World3D + CharacterBody3D + Trimesh-Kollider: Terrain +
|
||||||
|
## several Gebäude/Bauteile) darf die Editor-Sicht NICHT unter ~60 fps drücken.
|
||||||
|
##
|
||||||
|
## Ausführen (braucht einen Bildschirm — virtueller Screen):
|
||||||
|
## xvfb-run -a godot4 --path . --rendering-driver opengl3 -s res://tests/probe_playtest_fps.gd
|
||||||
|
## Messung RELATIV (der Container rendert Software-OpenGL/llvmpipe — die
|
||||||
|
## absolute FPS-Zahl ist dort ein Render-Artefakt): Editor-Sicht OHNE Playtest
|
||||||
|
## vs. MIT Playtest-Physik (SubViewport + World3D + CharacterBody3D +
|
||||||
|
## Trimesh-Kollider). Exit 0, wenn der Physik-Zusatz ≤ 20 % Kosten bringt und
|
||||||
|
## die Editor-Basis unverändert flüssig bleibt (echte 60-fps-Abnahme: Sly-Test).
|
||||||
|
|
||||||
|
var main: Control
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var scene: PackedScene = load("res://scenes/main.tscn")
|
||||||
|
main = scene.instantiate()
|
||||||
|
root.add_child(main)
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
# Probe-Map: Terrain + mehrere Objekte (Trimeshe) + Bauteile — echte Last.
|
||||||
|
var doc: Dictionary = SvmapDocument.create_new("FPS-Probe", 200.0, "mittag")
|
||||||
|
doc["objects"] = [
|
||||||
|
{"id": "building_001", "pos": [0.0, 0.0, -30.0], "rot_y_deg": 0.0, "scale": 1.0},
|
||||||
|
{"id": "barracks_001", "pos": [20.0, 0.0, 30.0], "rot_y_deg": 90.0, "scale": 1.0},
|
||||||
|
{"id": "hangar_001", "pos": [-40.0, 0.0, 20.0], "rot_y_deg": 45.0, "scale": 1.0},
|
||||||
|
{"id": "container_001", "pos": [10.0, 0.0, 10.0], "rot_y_deg": 0.0, "scale": 1.0},
|
||||||
|
{"id": "tent_001", "pos": [-20.0, 0.0, -10.0], "rot_y_deg": 0.0, "scale": 1.0},
|
||||||
|
{"id": "box_006", "pos": [6.0, 0.0, 4.0], "rot_y_deg": 0.0, "scale": 1.0},
|
||||||
|
]
|
||||||
|
var build_ops := load("res://src/editor/build_ops.gd")
|
||||||
|
var teile: Array = []
|
||||||
|
for i in 24:
|
||||||
|
var kind: String = ["wand", "boden", "tuer", "treppe"][i % 4]
|
||||||
|
var px := -16.0 + float(i % 6) * 2.0
|
||||||
|
var pz := 40.0 + float(i / 6) * 2.0
|
||||||
|
teile.append(build_ops.call("make_piece", kind, Vector3(px, 0.0, pz), 0.0))
|
||||||
|
doc["gebaeude"] = teile
|
||||||
|
doc["spawn_zones"] = {"0": {"points": [{"id": "A1", "pos": [0.0, 0.0, 20.0]},
|
||||||
|
{"id": "A2", "pos": [5.0, 0.0, 20.0]}, {"id": "A3", "pos": [-5.0, 0.0, 20.0]},
|
||||||
|
{"id": "A4", "pos": [10.0, 0.0, 20.0]}]}}
|
||||||
|
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
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
# --- Basis: Editor-Sicht OHNE Playtest (240 Frames) ----------------------
|
||||||
|
await process_frame
|
||||||
|
var basis: Dictionary = await _messfenster(240)
|
||||||
|
print("FPS_PROBE ZAHLEN Basis (ohne Playtest): avg=%.1f min=%.0f max=%.0f" %
|
||||||
|
[basis.avg, basis.min, basis.max])
|
||||||
|
|
||||||
|
main.call("_on_test_run")
|
||||||
|
await process_frame
|
||||||
|
var pt: Node = main.get("playtest")
|
||||||
|
if pt == null:
|
||||||
|
print("FPS_PROBE FAIL: Playtest läuft nicht")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var body: Node = pt.get("body")
|
||||||
|
# Auto-Lauf durch die Szene (Bewegung + Physik + Render gleichzeitig).
|
||||||
|
body.set("auto_move_dir", Vector3(0, 0, -1))
|
||||||
|
var kollider: Node3D = pt.get("_kollider_root")
|
||||||
|
print("FPS_PROBE: Kollider-Kinder=%d (Terrain + Objekte + Bauteile)" % kollider.get_child_count())
|
||||||
|
|
||||||
|
# --- Playtest: Physik + Render (240 Frames) ------------------------------
|
||||||
|
await process_frame
|
||||||
|
var mit: Dictionary = await _messfenster(240)
|
||||||
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
|
print("FPS_PROBE ZAHLEN Playtest (mit Physik): avg=%.1f min=%.0f max=%.0f" %
|
||||||
|
[mit.avg, mit.min, mit.max])
|
||||||
|
var overhead := 0.0
|
||||||
|
if basis.avg > 0.0:
|
||||||
|
overhead = (basis.avg - mit.avg) / basis.avg * 100.0
|
||||||
|
print("FPS_PROBE ZAHLEN Overhead: %.1f %%" % overhead)
|
||||||
|
if overhead <= 20.0:
|
||||||
|
print("FPS_PROBE OK: Physik-Zusatz %.1f %% ≤ 20 %% — keine Editor-Regression" % overhead)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("FPS_PROBE FAIL: Physik-Zusatz %.1f %% > 20 %%" % overhead)
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _messfenster(frames: int) -> Dictionary:
|
||||||
|
var n_min := 9999.0
|
||||||
|
var n_max := 0.0
|
||||||
|
var sum := 0.0
|
||||||
|
for i in frames:
|
||||||
|
await process_frame
|
||||||
|
var fps := float(Engine.get_frames_per_second())
|
||||||
|
n_min = minf(n_min, fps)
|
||||||
|
n_max = maxf(n_max, fps)
|
||||||
|
sum += fps
|
||||||
|
return {"avg": sum / float(frames), "min": n_min, "max": n_max}
|
||||||
1
tests/probe_playtest_fps.gd.uid
Normal file
1
tests/probe_playtest_fps.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bnyeqe2tyxj58
|
||||||
|
|
@ -77,7 +77,7 @@ func _run() -> void:
|
||||||
print("ZAHLEN (a) Spawn: body.position=", body.position,
|
print("ZAHLEN (a) Spawn: body.position=", body.position,
|
||||||
" | Terrainhöhe dort=", body.terrain_height(0.0, 0.0))
|
" | Terrainhöhe dort=", body.terrain_height(0.0, 0.0))
|
||||||
print("ZAHLEN (a) yaw=", body.get("yaw_deg"), " pitch=", body.get("pitch_deg"),
|
print("ZAHLEN (a) yaw=", body.get("yaw_deg"), " pitch=", body.get("pitch_deg"),
|
||||||
" eye_height=", body.get("eye_height"), " radius=", body.get("radius"))
|
" Kapsel-Mitte y=", body.position.y, " feet=", body.fuss_hoehe())
|
||||||
var look_cam: Camera3D = body.get("look_cam")
|
var look_cam: Camera3D = body.get("look_cam")
|
||||||
var editor_cam: Camera3D = main.get("camera")
|
var editor_cam: Camera3D = main.get("camera")
|
||||||
print("ZAHLEN (a) Spiel-Kamera current=", look_cam.current,
|
print("ZAHLEN (a) Spiel-Kamera current=", look_cam.current,
|
||||||
|
|
@ -93,16 +93,17 @@ func _run() -> void:
|
||||||
" rechts=", main.get("ui_right_panel").visible)
|
" rechts=", main.get("ui_right_panel").visible)
|
||||||
await _shot("t1_a_spieler_startansicht.png")
|
await _shot("t1_a_spieler_startansicht.png")
|
||||||
|
|
||||||
# --- (b) 2 s Gehen (60 × step 1/30 s, fwd) — Bewegung + Terrainanpassung --
|
# --- (b) 1 s Gehen (60 Physik-Frames, Autopilot) — Bewegung + Terrain ----
|
||||||
var pos_vor: Vector3 = body.position
|
var pos_vor: Vector3 = body.position
|
||||||
|
body.set("auto_move_dir", Vector3(0, 0, -1))
|
||||||
for i in 60:
|
for i in 60:
|
||||||
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false,
|
await physics_frame
|
||||||
"right": false, "run": false, "jump": false})
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
var pos_nach: Vector3 = body.position
|
var pos_nach: Vector3 = body.position
|
||||||
print("ZAHLEN (b) Position vor/nach 60 steps: ", pos_vor, " → ", pos_nach,
|
print("ZAHLEN (b) Position vor/nach 60 Physik-Frames: ", pos_vor, " → ", pos_nach,
|
||||||
" (Δ=", pos_vor.distance_to(pos_nach), " m)")
|
" (Δ=", pos_vor.distance_to(pos_nach), " m)")
|
||||||
print("ZAHLEN (b) on_floor=", body.get("on_floor"),
|
print("ZAHLEN (b) on_floor=", body.is_on_floor(),
|
||||||
" y-nach=", pos_nach.y,
|
" feet=", body.fuss_hoehe(),
|
||||||
" Terrainhöhe dort=", body.terrain_height(pos_nach.x, pos_nach.z))
|
" Terrainhöhe dort=", body.terrain_height(pos_nach.x, pos_nach.z))
|
||||||
await _shot("t1_b_spieler_gelaufen.png")
|
await _shot("t1_b_spieler_gelaufen.png")
|
||||||
|
|
||||||
|
|
|
||||||
1
tests/probe_resize_shot.gd.uid
Normal file
1
tests/probe_resize_shot.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://j6ly4d03x0n2
|
||||||
1
tests/probe_resize_size.gd.uid
Normal file
1
tests/probe_resize_size.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://f0nwxnrohk4a
|
||||||
1
tests/probe_update_check.gd.uid
Normal file
1
tests/probe_update_check.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cntoenc8jaapy
|
||||||
94
tests/run_identitaet.sh
Executable file
94
tests/run_identitaet.sh
Executable file
|
|
@ -0,0 +1,94 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Identitäts-Vertrag (Karte t_1da96275 — „Editor-Playtest-Physik = Quest 3
|
||||||
|
# identisch"): dieselbe Probe-Map läuft durch
|
||||||
|
# A) Editor-Playtest (KolliderBau + PlaytestBody im SubViewport)
|
||||||
|
# B) Quest-Client (SvmapWorld + TestmapPlayer, im CLIENT-Projekt)
|
||||||
|
# Beide drucken `IDENT <name>=<wert>`-Zeilen; der Runner verlangt, dass die
|
||||||
|
# Zeilen IDENTISCH sind (gleiche Block/Walk-Entscheidungen in beiden Welten)
|
||||||
|
# und dass beide Suiten ihre Soll-Erwartungen erfüllen (Exit 0).
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# GODOT Godot-Binary (Default: $HOME/bin/godot4, dann $PATH)
|
||||||
|
# SVR_CLIENT_DIR Client-Projektordner (Default: ../scopedvr-client neben
|
||||||
|
# diesem Repo — erkannt an project.godot)
|
||||||
|
# Exit 0 = Identität bestätigt.
|
||||||
|
set -u
|
||||||
|
if [ -n "${GODOT:-}" ]; then :
|
||||||
|
elif [ -x "$HOME/bin/godot4" ]; then
|
||||||
|
GODOT="$HOME/bin/godot4"
|
||||||
|
elif command -v godot4 >/dev/null 2>&1; then
|
||||||
|
GODOT=$(command -v godot4)
|
||||||
|
else
|
||||||
|
echo "FAIL: Kein Godot gefunden — GODOT=<pfad> setzen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
EDITOR_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
# Client-Projekt finden: Env gewinnt, sonst Nachbar-Klon des Editors.
|
||||||
|
CLIENT_DIR="${SVR_CLIENT_DIR:-}"
|
||||||
|
if [ -z "$CLIENT_DIR" ]; then
|
||||||
|
for cand in "$EDITOR_DIR/../scopedvr-client" "$EDITOR_DIR/client"; do
|
||||||
|
if [ -f "$cand/project.godot" ]; then CLIENT_DIR="$cand"; break; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -z "$CLIENT_DIR" ] || [ ! -f "$CLIENT_DIR/project.godot" ]; then
|
||||||
|
echo "FAIL: Client-Projekt nicht gefunden (SVR_CLIENT_DIR setzen)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export GODOT_SILENCE_ROOT_WARNING=1
|
||||||
|
LOGDIR="${SVR_IDENTITAET_LOGDIR:-/tmp/svr_identitaet_logs_$$}"
|
||||||
|
mkdir -p "$LOGDIR"
|
||||||
|
MAP="${SVR_IDENTITAET_MAP:-/tmp/svr_identitaet_probe.svmap}"
|
||||||
|
export SVR_IDENTITAET_MAP="$MAP"
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
echo "== 0) Probe-Map erzeugen =="
|
||||||
|
"$GODOT" --headless --path "$EDITOR_DIR" -s res://tests/gen_identitaet_map.gd \
|
||||||
|
> "$LOGDIR/gen.log" 2>&1
|
||||||
|
if ! grep -q "^IDENT_MAP=" "$LOGDIR/gen.log"; then
|
||||||
|
echo "FAIL: Probe-Map nicht erzeugt (siehe $LOGDIR/gen.log)" >&2
|
||||||
|
cat "$LOGDIR/gen.log" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== 1) Import (beide Projekte) =="
|
||||||
|
"$GODOT" --headless --path "$EDITOR_DIR" --import > "$LOGDIR/import_editor.log" 2>&1
|
||||||
|
grep -E "Parse Error|SCRIPT ERROR" "$LOGDIR/import_editor.log" | head -5 >&2
|
||||||
|
"$GODOT" --headless --path "$CLIENT_DIR" --import > "$LOGDIR/import_client.log" 2>&1
|
||||||
|
grep -E "Parse Error|SCRIPT ERROR" "$LOGDIR/import_client.log" | head -5 >&2
|
||||||
|
|
||||||
|
echo "== 2) Editor-Playtest-Probe =="
|
||||||
|
"$GODOT" --headless --path "$EDITOR_DIR" -s res://tests/test_playtest_identitaet.gd \
|
||||||
|
> "$LOGDIR/editor.log" 2>&1
|
||||||
|
code=$?
|
||||||
|
grep -E "^IDENT [a-z_0-9]+=" "$LOGDIR/editor.log" > "$LOGDIR/editor.ident"
|
||||||
|
grep -E "ALL OK|FAILED" "$LOGDIR/editor.log" | tail -1
|
||||||
|
[ $code -ne 0 ] && fail=1 && echo "Editor-Suite rot (exit=$code):" >&2 && cat "$LOGDIR/editor.log" >&2
|
||||||
|
|
||||||
|
echo "== 3) Quest-Client-Probe =="
|
||||||
|
"$GODOT" --headless --path "$CLIENT_DIR" -s "$EDITOR_DIR/tests/test_identitaet_client.gd" \
|
||||||
|
> "$LOGDIR/client.log" 2>&1
|
||||||
|
code=$?
|
||||||
|
grep -E "^IDENT [a-z_0-9]+=" "$LOGDIR/client.log" > "$LOGDIR/client.ident"
|
||||||
|
grep -E "ALL OK|FAILED" "$LOGDIR/client.log" | tail -1
|
||||||
|
[ $code -ne 0 ] && fail=1 && echo "Client-Suite rot (exit=$code):" >&2 && cat "$LOGDIR/client.log" >&2
|
||||||
|
|
||||||
|
echo "== 4) Identitäts-Vergleich =="
|
||||||
|
n_editor=$(wc -l < "$LOGDIR/editor.ident")
|
||||||
|
n_client=$(wc -l < "$LOGDIR/client.ident")
|
||||||
|
if [ "$n_editor" -lt 6 ] || [ "$n_client" -lt 6 ]; then
|
||||||
|
echo "FAIL: Zu wenige IDENT-Zeilen (editor=$n_editor, client=$n_client) — Suiten sind gecrasht." >&2
|
||||||
|
tail -20 "$LOGDIR/editor.log" >&2
|
||||||
|
tail -20 "$LOGDIR/client.log" >&2
|
||||||
|
fail=1
|
||||||
|
elif diff -u "$LOGDIR/editor.ident" "$LOGDIR/client.ident" > "$LOGDIR/diff.txt"; then
|
||||||
|
echo "IDENTITAET OK: $n_editor Entscheidungen identisch."
|
||||||
|
else
|
||||||
|
echo "FAIL: Entscheidungen weichen ab:" >&2
|
||||||
|
cat "$LOGDIR/diff.txt" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ $fail -eq 0 ] && echo "Logs: $LOGDIR" && exit 0
|
||||||
|
echo "Logs: $LOGDIR"
|
||||||
|
exit 1
|
||||||
154
tests/test_identitaet_client.gd
Normal file
154
tests/test_identitaet_client.gd
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Identitäts-Vertrag, QUEST-CLIENT-SEITE (Karte t_1da96275): dieselbe
|
||||||
|
## Probe-Map wie tests/test_playtest_identitaet.gd, aber im echten
|
||||||
|
## Client-Kontext (SvmapWorld + TestmapPlayer — der Quest-3-Weg).
|
||||||
|
## Wird ÜBER das Editor-Repo mit dem Client-Projekt gefahren:
|
||||||
|
## SVR_IDENTITAET_MAP=/tmp/svr_identitaet_probe.svmap \
|
||||||
|
## godot4 --headless --path <scopedvr-client> -s <abs>/tests/test_identitaet_client.gd
|
||||||
|
## Druckt dieselben `IDENT <name>=<wert>`-Zeilen; der Runner diffed beide.
|
||||||
|
##
|
||||||
|
## BEWUSST 1:1 gespiegelt zu test_playtest_identitaet.gd — gleiche Start-
|
||||||
|
## Punkte, gleiche Frame-Zahlen, gleiche Entscheidungsregeln. Nur die
|
||||||
|
## Welt-/Spieler-Klassen sind die des Clients.
|
||||||
|
|
||||||
|
const HALF_H := 0.9
|
||||||
|
const TUER_AZIMUT_DEG := 202.5
|
||||||
|
const GEB_TUER := Vector2(-16.0, -16.0)
|
||||||
|
const GEB_HALF := Vector2(8.94, 3.54)
|
||||||
|
|
||||||
|
var fails: Array[String] = []
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _check(cond: bool, what: String) -> void:
|
||||||
|
if cond:
|
||||||
|
print("IDENT ok: ", what)
|
||||||
|
else:
|
||||||
|
fails.append(what)
|
||||||
|
print("IDENT FAIL: ", what)
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var map_path := OS.get_environment("SVR_IDENTITAET_MAP")
|
||||||
|
if map_path.is_empty():
|
||||||
|
map_path = "/tmp/svr_identitaet_probe.svmap"
|
||||||
|
if not FileAccess.file_exists(map_path):
|
||||||
|
printerr("IDENT FAIL: Probe-Map fehlt (%s)." % map_path)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var doc_v: Variant = JSON.parse_string(FileAccess.get_file_as_string(map_path))
|
||||||
|
if not (doc_v is Dictionary):
|
||||||
|
printerr("IDENT FAIL: Probe-Map nicht lesbar.")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var doc: Dictionary = doc_v
|
||||||
|
|
||||||
|
# --- Client-Aufbau: SvmapWorld (Kollider + Visuals) + TestmapPlayer ------
|
||||||
|
var welt: Node3D = SvmapWorld.build_from_data(doc, SvmapWorld.load_manifest())
|
||||||
|
root.add_child(welt)
|
||||||
|
var player: CharacterBody3D = TestmapPlayer.new()
|
||||||
|
welt.add_child(player)
|
||||||
|
await physics_frame
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
|
# 1) Stufe 0.3 m (Terrain) — erklommen.
|
||||||
|
var r := await _lauf(player, Vector3(-8.0, 0.0, 20.0), Vector3(1, 0, 0), 300)
|
||||||
|
var st30 := "BLOCK"
|
||||||
|
if r.max_feet >= 0.25 and r.end.x > 1.0:
|
||||||
|
st30 = "KLIMM"
|
||||||
|
_ident("stufe_terrain_030", "%s %.2f" % [st30, r.max_feet])
|
||||||
|
_check(st30 == "KLIMM", "Stufe 0.3 m erklommen (max feet %.2f, x %.2f)" % [r.max_feet, r.end.x])
|
||||||
|
|
||||||
|
# 2) Hohes Hindernis 0.85 m (barrel_001, Box-Kollider) — blockt: über der
|
||||||
|
# Step-Up-Grenze 0.45 m gibt es KEIN Klettern (Karte t_f9adf97c).
|
||||||
|
r = await _lauf(player, Vector3(0.5, 0.0, 20.0), Vector3(1, 0, 0), 240)
|
||||||
|
var st80 := "KLIMM"
|
||||||
|
if r.end.x < 1.55 and r.max_feet < 0.55:
|
||||||
|
st80 = "BLOCK"
|
||||||
|
_ident("wand_barrel", "%s %.2f x=%.2f" % [st80, r.max_feet, r.end.x])
|
||||||
|
_check(st80 == "BLOCK", "Hindernis 0.85 m blockt (max feet %.2f, x %.2f)" % [r.max_feet, r.end.x])
|
||||||
|
|
||||||
|
# 3) Kisten-Stufe 0.334 m (box_006) — erklommen (Step-Up über Objekt).
|
||||||
|
r = await _lauf(player, Vector3(-24.0, 0.0, 8.0), Vector3(1, 0, 0), 260)
|
||||||
|
var box := "BLOCK"
|
||||||
|
if r.max_feet >= 0.28:
|
||||||
|
box = "KLIMM"
|
||||||
|
_ident("kisten_stufe", "%s %.2f" % [box, r.max_feet])
|
||||||
|
_check(box == "KLIMM", "Kiste 0.334 m erklommen (max feet %.2f)" % r.max_feet)
|
||||||
|
|
||||||
|
# 4) Wand (barracks_001-Trimesh) — blockt.
|
||||||
|
r = await _lauf(player, Vector3(20.0, 0.0, 31.5), Vector3(0, 0, -1), 300)
|
||||||
|
var wand := "DURCH"
|
||||||
|
if r.end.z > 29.3:
|
||||||
|
wand = "BLOCK"
|
||||||
|
_ident("wand_barracks", "%s z=%.2f" % [wand, r.end.z])
|
||||||
|
_check(wand == "BLOCK", "Kaserne blockt (Ende z=%.2f)" % r.end.z)
|
||||||
|
|
||||||
|
# 5) Tür (building_001) — durchlässig, Spieler landet IM Gebäude.
|
||||||
|
var outward := Vector3(cos(deg_to_rad(TUER_AZIMUT_DEG)), 0.0,
|
||||||
|
sin(deg_to_rad(TUER_AZIMUT_DEG)))
|
||||||
|
var start := Vector3(GEB_TUER.x + outward.x * 12.0, 0.0,
|
||||||
|
GEB_TUER.y + outward.z * 12.0)
|
||||||
|
r = await _lauf(player, start, -outward, 600)
|
||||||
|
var drin := absf(r.end.x - GEB_TUER.x) < GEB_HALF.x - 0.3 \
|
||||||
|
and absf(r.end.z - GEB_TUER.y) < GEB_HALF.y - 0.3
|
||||||
|
_ident("tuer_building", "%s %.2f %.2f" % ["DRIN" if drin else "DRAUSSEN", r.end.x, r.end.z])
|
||||||
|
_check(drin, "Tür building_001 durchlässig (Ende %.2f, %.2f)" % [r.end.x, r.end.z])
|
||||||
|
|
||||||
|
# 6) Terrain-Höhe unter den Füßen (Strahl abwärts, exakt).
|
||||||
|
_hoehe(welt, Vector3(-10.0, 10.0, 0.0), "terrain_hoehe_a", 0.0)
|
||||||
|
_hoehe(welt, Vector3(4.0, 10.0, 20.0), "terrain_hoehe_b", 0.3)
|
||||||
|
_hoehe(welt, Vector3(10.0, 12.0, -28.0), "terrain_hoehe_c", 1.1)
|
||||||
|
|
||||||
|
if fails.is_empty():
|
||||||
|
print("IDENT CLIENT: ALLE OK")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("IDENT CLIENT: FAILED %d" % fails.size())
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _ident(name: String, wert: String) -> void:
|
||||||
|
print("IDENT %s=%s" % [name, wert])
|
||||||
|
|
||||||
|
|
||||||
|
## Autopilot-Lauf — 1:1 zur Editor-Seite: Start y=3.0 (Fall-Settle),
|
||||||
|
## dann Richtung fahren, max Füß-Höhe + Endposition messen.
|
||||||
|
func _lauf(player: CharacterBody3D, start_xz: Vector3, dir: Vector3,
|
||||||
|
frames: int) -> Dictionary:
|
||||||
|
player.auto_move_dir = Vector3.ZERO
|
||||||
|
player.velocity = Vector3.ZERO
|
||||||
|
player.global_position = Vector3(start_xz.x, 3.0, start_xz.z)
|
||||||
|
for i in 45:
|
||||||
|
await physics_frame
|
||||||
|
player.auto_move_dir = dir
|
||||||
|
var max_feet := -INF
|
||||||
|
for i in frames:
|
||||||
|
await physics_frame
|
||||||
|
var feet := player.global_position.y - HALF_H
|
||||||
|
max_feet = maxf(max_feet, feet)
|
||||||
|
if feet < -5.0:
|
||||||
|
break
|
||||||
|
player.auto_move_dir = Vector3.ZERO
|
||||||
|
for i in 10:
|
||||||
|
await physics_frame
|
||||||
|
return {"end": Vector3(player.global_position), "max_feet": max_feet}
|
||||||
|
|
||||||
|
|
||||||
|
## Boden-Strahl an einem Punkt → IDENT-Zeile + Soll-Prüfung (±1 cm).
|
||||||
|
func _hoehe(welt: Node3D, p: Vector3, name: String, soll: float) -> void:
|
||||||
|
var raum := welt.get_world_3d().direct_space_state
|
||||||
|
var hit := raum.intersect_ray(PhysicsRayQueryParameters3D.create(
|
||||||
|
p, p + Vector3(0, -30, 0)))
|
||||||
|
var y := INF
|
||||||
|
if not hit.is_empty():
|
||||||
|
y = (hit["position"] as Vector3).y
|
||||||
|
if y == INF:
|
||||||
|
_ident(name, "KEIN_TREFFER")
|
||||||
|
_check(false, name + ": kein Boden-Treffer")
|
||||||
|
return
|
||||||
|
_ident(name, "%.2f" % y)
|
||||||
|
_check(absf(y - soll) <= 0.01, "%s: %.3f ≈ Soll %.2f" % [name, y, soll])
|
||||||
1
tests/test_identitaet_client.gd.uid
Normal file
1
tests/test_identitaet_client.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://ddt6sblpqgh4m
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
extends SceneTree
|
extends SceneTree
|
||||||
## Editor-Test (headless): Karte t_1cdc1a72 — Lauf-Modus IM Map-Builder selbst
|
## Editor-Test (headless): Karte t_1cdc1a72 + Physik-Neubau t_1da96275 —
|
||||||
## (erste Person, WASD + Maus, OHNE Client-Ordner/VR/Server).
|
## Lauf-Modus IM Map-Builder selbst mit ECHTER Physik (CharacterBody3D +
|
||||||
## Ausführen: godot4 --headless --path . --script res://tests/test_playtest.gd
|
## Trimesh-Kollider via KolliderBau, identisch zum Quest-Client).
|
||||||
|
## Ausführen: godot4 --headless --path . --import && godot4 --headless --path . --script res://tests/test_playtest.gd
|
||||||
## Exit 0 = alles grün.
|
## Exit 0 = alles grün.
|
||||||
##
|
##
|
||||||
## Instanziert die ECHTE Main-UI (wie test_gui_smoke) und deckt ab:
|
## Instanziert die ECHTE Main-UI (wie test_gui_smoke) und deckt ab:
|
||||||
## 1. Start/Zurück-Zustand: Modus-Flags, Panels aus/an, Maus-Capture-Wunsch,
|
## 1. Start/Zurück-Zustand: Modus-Flags, Panels aus/an, Maus-Capture-Wunsch,
|
||||||
## Spieler lebt im world3d, Kamera übernimmt.
|
## Spieler = CharacterBody3D mit Kapsel 0.35 × 1.8 (TestmapPlayer-Params),
|
||||||
## 2. Bodenhöhe == Terrainhöhe an der Spielerposition (Body-Decoder ==
|
## Kollider-Baum existiert (Terrain + Objekte), Kamera übernimmt.
|
||||||
## SvmapDocument-Decoder am selben Dokument).
|
## 2. Bodenhöhe == Terrainhöhe an der Spielerposition (Physik-Landung auf
|
||||||
## 3. Spieler bleibt vor einem Objekt-AABB stehen (Block-Kollision).
|
## dem Trimesh == SvmapDocument-Decoder am selben Dokument).
|
||||||
## 4. Rennen/Springen wirkt korrekt (step-Input-Dictionary).
|
## 3. Echte Wand blockt den Spieler (StaticBody in der Physik-Welt).
|
||||||
## 5. Rückkehr stellt den Editor-Zustand VOLLSTÄNDIG wieder her
|
## 4. Step-Up: Kiste 0.3 m wird erklommen, Hindernis 0.85 m blockt
|
||||||
## (Dokument byte-identisch, Undo-Stapel unverändert, Panels sichtbar).
|
## (Stufen-Semantik 1:1 wie TestmapPlayer, Karte t_f9adf97c).
|
||||||
|
## 5. Gehen 6 m/s / Rennen 14 m/s / Springen (Client-Geschwindigkeiten).
|
||||||
|
## 6. Rückkehr stellt den Editor-Zustand VOLLSTÄNDIG wieder her.
|
||||||
|
|
||||||
var failures := 0
|
var failures := 0
|
||||||
var checks := 0
|
var checks := 0
|
||||||
|
|
@ -20,6 +23,8 @@ var main: Control
|
||||||
var camera: Camera3D
|
var camera: Camera3D
|
||||||
var subvp: SubViewport
|
var subvp: SubViewport
|
||||||
|
|
||||||
|
const HALF_H := 0.9
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
_run_all.call_deferred()
|
_run_all.call_deferred()
|
||||||
|
|
@ -54,10 +59,11 @@ func _run_all() -> void:
|
||||||
await process_frame
|
await process_frame
|
||||||
|
|
||||||
_test_start_state()
|
_test_start_state()
|
||||||
_test_terrain_height_matches_document()
|
await _test_terrain_height_matches_document()
|
||||||
_test_obstacle_blocks_player()
|
await _test_wall_blocks_player()
|
||||||
_test_run_and_jump()
|
await _test_step_up_semantics()
|
||||||
_test_obstacles_use_registry_footprint()
|
await _test_run_and_jump()
|
||||||
|
await _test_bauteile_treppe_tuer()
|
||||||
_test_exit_restores_editor()
|
_test_exit_restores_editor()
|
||||||
|
|
||||||
_finish()
|
_finish()
|
||||||
|
|
@ -103,9 +109,22 @@ func _body() -> Node:
|
||||||
return pt.get("body")
|
return pt.get("body")
|
||||||
|
|
||||||
|
|
||||||
|
## Statische Box in die Playtest-Physik-Welt hängen (wie im Spiel).
|
||||||
|
func _box(welt: Node3D, center: Vector3, size: Vector3) -> StaticBody3D:
|
||||||
|
var body := StaticBody3D.new()
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var shape := BoxShape3D.new()
|
||||||
|
shape.size = size
|
||||||
|
cs.shape = shape
|
||||||
|
body.add_child(cs)
|
||||||
|
body.position = center
|
||||||
|
welt.add_child(body)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
## --- Test 1: Start/Zurück-Zustand -------------------------------------------
|
## --- Test 1: Start/Zurück-Zustand -------------------------------------------
|
||||||
func _test_start_state() -> void:
|
func _test_start_state() -> void:
|
||||||
print("Testgruppe: Start-Zustand (Modus-Flags, Panels, Maus, Kamera)")
|
print("Testgruppe: Start-Zustand (Modus-Flags, Panels, Maus, Kamera, Kollider)")
|
||||||
main.call("_on_test_run")
|
main.call("_on_test_run")
|
||||||
await process_frame
|
await process_frame
|
||||||
|
|
||||||
|
|
@ -124,10 +143,28 @@ func _test_start_state() -> void:
|
||||||
_check(panel != null and not panel.visible, "%s ausgeblendet" % pair[1])
|
_check(panel != null and not panel.visible, "%s ausgeblendet" % pair[1])
|
||||||
|
|
||||||
# Spieler lebt in der 3D-Welt (world3d) und ist an den PlaytestMode gehängt.
|
# Spieler lebt in der 3D-Welt (world3d) und ist an den PlaytestMode gehängt.
|
||||||
var body: Node3D = pt.get("body")
|
var body: Node = pt.get("body")
|
||||||
_check(body != null and body.get_parent() == pt, "Body ist Kind des PlaytestMode")
|
_check(body is CharacterBody3D, "Body ist ein CharacterBody3D (echte Physik)")
|
||||||
|
_check(body.get_parent() == pt, "Body ist Kind des PlaytestMode")
|
||||||
_check(pt.get_parent() == main.get("world3d"), "PlaytestMode lebt im world3d")
|
_check(pt.get_parent() == main.get("world3d"), "PlaytestMode lebt im world3d")
|
||||||
|
|
||||||
|
# Kollider-Baum: Terrain + (leere) Objektliste — mindestens Terrain drin.
|
||||||
|
var kb: Node3D = pt.get("_kollider_root")
|
||||||
|
_check(kb != null and kb.get_child_count() >= 1,
|
||||||
|
"Kollider-Baum existiert (Terrain-Kollider drin, %d Kinder)" %
|
||||||
|
(kb.get_child_count() if kb != null else 0))
|
||||||
|
var tbody := kb.get_node_or_null("TerrainKollider") if kb != null else null
|
||||||
|
_check(tbody != null, "Terrain-Kollider (Trimesh wie im Client) vorhanden")
|
||||||
|
|
||||||
|
# Kapsel-Parameter 1:1 wie TestmapPlayer.
|
||||||
|
var capsule_ok := false
|
||||||
|
for child in body.get_children():
|
||||||
|
var cs := child as CollisionShape3D
|
||||||
|
if cs != null and cs.shape is CapsuleShape3D:
|
||||||
|
var cap := cs.shape as CapsuleShape3D
|
||||||
|
capsule_ok = is_equal_approx(cap.radius, 0.35) and is_equal_approx(cap.height, 1.8)
|
||||||
|
_check(capsule_ok, "Kapsel 0.35 × 1.8 m (identisch zum Testmap-Spieler)")
|
||||||
|
|
||||||
# Spiel-Kamera ist current, Editor-Kamera abgegeben.
|
# Spiel-Kamera ist current, Editor-Kamera abgegeben.
|
||||||
var body_cam: Camera3D = body.get("look_cam")
|
var body_cam: Camera3D = body.get("look_cam")
|
||||||
_check(body_cam != null and body_cam.current, "Spiel-Kamera ist current")
|
_check(body_cam != null and body_cam.current, "Spiel-Kamera ist current")
|
||||||
|
|
@ -149,188 +186,153 @@ func _test_start_state() -> void:
|
||||||
"Start == erster Spawnpunkt (%.1f, %.1f)" % [expected.x, expected.z])
|
"Start == erster Spawnpunkt (%.1f, %.1f)" % [expected.x, expected.z])
|
||||||
|
|
||||||
# Kein History-Eintrag durch den Start (Dokument/Undo-Stapel unangetastet).
|
# Kein History-Eintrag durch den Start (Dokument/Undo-Stapel unangetastet).
|
||||||
var hist: Node = main.get("history")
|
var hist: Variant = main.get("history")
|
||||||
_check(not hist.can_undo(), "Kein Undo-Eintrag durch den Testlauf-Start")
|
_check(not hist.can_undo(), "Kein Undo-Eintrag durch den Testlauf-Start")
|
||||||
|
|
||||||
|
|
||||||
## --- Test 2: Bodenhöhe == Terrainhöhe ---------------------------------------
|
## --- Test 2: Bodenhöhe == Terrainhöhe ---------------------------------------
|
||||||
func _test_terrain_height_matches_document() -> void:
|
func _test_terrain_height_matches_document() -> void:
|
||||||
print("Testgruppe: Bodenhöhe == Terrainhöhe an der Spielerposition")
|
print("Testgruppe: Bodenhöhe == Terrainhöhe (Physik-Landung auf dem Trimesh)")
|
||||||
var pt := _pt()
|
var pt := _pt()
|
||||||
if pt == null:
|
if pt == null:
|
||||||
_check(false, "PlaytestMode noch aktiv (Vorbedingung Test 1)")
|
_check(false, "PlaytestMode noch aktiv (Vorbedingung Test 1)")
|
||||||
return
|
return
|
||||||
var body: Node3D = pt.get("body")
|
var body: Node = pt.get("body")
|
||||||
var doc: Dictionary = main.get("doc")
|
var doc: Dictionary = main.get("doc")
|
||||||
# Schritt ausführen (Gravitation lässt den Spieler auf den Boden fallen).
|
# Gravitation lässt den Spieler auf den Terrain-Trimesh fallen.
|
||||||
body.call("step", 1.0 / 30.0, {"fwd": false, "back": false, "left": false,
|
for i in 45:
|
||||||
"right": false, "run": false, "jump": false})
|
await physics_frame
|
||||||
var feet_y: float = body.position.y
|
var feet_y: float = body.fuss_hoehe()
|
||||||
var terrain_y: float = SvmapDocument.terrain_height_at(doc, body.position.x, body.position.z)
|
var terrain_y: float = SvmapDocument.terrain_height_at(doc, body.position.x, body.position.z)
|
||||||
var body_terrain: float = body.call("terrain_height", body.position.x, body.position.z)
|
var body_terrain: float = body.call("terrain_height", body.position.x, body.position.z)
|
||||||
_check(is_equal_approx(feet_y, terrain_y),
|
_check(absf(feet_y - terrain_y) <= 0.05,
|
||||||
"Fusspunkt == Terrainhöhe am Spielerort (%.3f == %.3f)" % [feet_y, terrain_y])
|
"Fusspunkt == Terrainhöhe am Spielerort (%.3f ≈ %.3f)" % [feet_y, terrain_y])
|
||||||
_check(is_equal_approx(body_terrain, terrain_y),
|
_check(is_equal_approx(body_terrain, terrain_y),
|
||||||
"Body-Decoder == SvmapDocument-Decoder (%.3f == %.3f)" % [body_terrain, terrain_y])
|
"Body-Decoder == SvmapDocument-Decoder (%.3f == %.3f)" % [body_terrain, terrain_y])
|
||||||
|
|
||||||
|
|
||||||
## --- Test 3: Objekt-AABB blockiert den Spieler ------------------------------
|
## --- Test 3: Echte Wand blockiert den Spieler -------------------------------
|
||||||
func _test_obstacle_blocks_player() -> void:
|
func _test_wall_blocks_player() -> void:
|
||||||
print("Testgruppe: Spieler bleibt vor einem Objekt-AABB stehen")
|
print("Testgruppe: Echte Wand blockt den Spieler (StaticBody in der Physik-Welt)")
|
||||||
var pt := _pt()
|
var pt := _pt()
|
||||||
if pt == null:
|
if pt == null:
|
||||||
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
|
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
|
||||||
return
|
return
|
||||||
var body: Node3D = pt.get("body")
|
var body: Node = pt.get("body")
|
||||||
# Hindernis VOR dem Spieler (Blick -Z bei yaw 0): Wand spannt
|
var welt: Node3D = main.get("world3d")
|
||||||
# z ∈ [start_z - 3.0, start_z - 2.4] — der Spieler kommt aus +Z und
|
|
||||||
# trifft zuerst die Face bei end.z (start_z - 2.4).
|
|
||||||
var start_x: float = body.position.x
|
var start_x: float = body.position.x
|
||||||
var start_z: float = body.position.z
|
var start_z: float = body.position.z
|
||||||
var wall := AABB(Vector3(body.position.x - 2.0, -5.0, start_z - 3.0),
|
# Wand VOR dem Spieler: Fläche bei start_x + 2.4 (Lauf +x).
|
||||||
Vector3(4.0, 10.0, 0.6))
|
var wall := _box(welt, Vector3(start_x + 2.7, 0.0, start_z), Vector3(0.6, 10.0, 4.0))
|
||||||
body.get("obstacles").append(wall)
|
body.set("yaw_deg", -90.0) # Blick nach +x (Konsistenz mit dem Lauf)
|
||||||
body.set("yaw_deg", 0.0) # Blick nach -Z
|
body.set("auto_move_dir", Vector3(1, 0, 0))
|
||||||
|
for i in 90:
|
||||||
var ok := true
|
await physics_frame
|
||||||
for i in 60: # 2 Sekunden laufen
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false,
|
for i in 10:
|
||||||
"right": false, "run": false, "jump": false})
|
await physics_frame
|
||||||
# Abstand: führende Kante des Spielers (z - radius) zur dem Spieler
|
# Abstand: führende Kante des Spielers (x + radius) zur Wand-Fläche.
|
||||||
# zugewandten Wand-Face (end.z — er kommt aus +Z). >= 0 = davor geblieben.
|
var dist_to_wall: float = (start_x + 2.4) - (float(body.position.x) + 0.35)
|
||||||
var dist_to_wall: float = (
|
|
||||||
float(body.position.z) - float(body.get("radius"))
|
|
||||||
- float(wall.end.z))
|
|
||||||
_check(dist_to_wall >= -0.05,
|
_check(dist_to_wall >= -0.05,
|
||||||
"Spieler hält vor der Wand (Abstand %.2f m >= 0)" % dist_to_wall)
|
"Spieler hält vor der Wand (Abstand %.2f m >= 0)" % dist_to_wall)
|
||||||
if dist_to_wall < -0.05:
|
_check(absf(float(body.position.z) - start_z) < 0.01, "Kein Drift in Z")
|
||||||
ok = false
|
_check(absf(float(body.position.x) - start_x) > 0.5, "Gegangen, nicht sofort blockiert")
|
||||||
# Kein Drift in X (kein Links/Rechts-Input → Position unverändert).
|
wall.queue_free()
|
||||||
_check(absf(float(body.position.x) - start_x) < 0.01, "Kein Drift in X")
|
for i in 5:
|
||||||
# Hindernis wieder entfernen (sauberer Zustand für die Folgetests).
|
await physics_frame
|
||||||
body.get("obstacles").erase(wall)
|
|
||||||
|
|
||||||
|
|
||||||
## --- Test 3b: Hindernisse = echter Registry-Fußabdruck (Sly 20.10.) ---------
|
## --- Test 4: Step-Up-Semantik (1:1 TestmapPlayer) ---------------------------
|
||||||
## Die alte pick_radius-Kugel-Näherung blockte ~40×40 m um eine 17×35 m-Kaserne
|
func _test_step_up_semantics() -> void:
|
||||||
## (= „unsichtbare Wand“ weit vor dem Modell). Jetzt: collision.size × scale,
|
print("Testgruppe: Step-Up — Kiste 0.3 m erklommen, Hindernis 0.85 m blockt")
|
||||||
## um rot_y_deg gedreht; Fallback Kugel-Näherung nur ohne Registry-Eintrag.
|
|
||||||
func _test_obstacles_use_registry_footprint() -> void:
|
|
||||||
print("Testgruppe: Hindernis-AABBs aus Registry-Fußabdruck")
|
|
||||||
var pt := _pt()
|
var pt := _pt()
|
||||||
if pt == null:
|
if pt == null:
|
||||||
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
|
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
|
||||||
return
|
return
|
||||||
|
var body: Node = pt.get("body")
|
||||||
|
var welt: Node3D = main.get("world3d")
|
||||||
|
var sx: float = body.position.x
|
||||||
|
var sz: float = body.position.z
|
||||||
|
|
||||||
# Faken Viewport-Baukasten: 1 Marker mit obj_index + absurder Pick-Kugel.
|
# a) Kiste 0.3 m (unter Step-Up-Grenze 0.45) → raufsteigen (Lauf +x).
|
||||||
var builder := Node3D.new()
|
var kiste := _box(welt, Vector3(sx + 3.0, 0.15, sz), Vector3(1.0, 0.3, 2.0))
|
||||||
var marker := Node3D.new()
|
body.set("yaw_deg", -90.0)
|
||||||
marker.set_meta("obj_index", 0)
|
body.set("auto_move_dir", Vector3(1, 0, 0))
|
||||||
marker.set_meta("pick_radius", 20.3)
|
var feet_max := -INF
|
||||||
marker.set_meta("half_height", 3.1)
|
for i in 180:
|
||||||
marker.position = Vector3(0.0, 0.0, -5.0)
|
await physics_frame
|
||||||
builder.add_child(marker)
|
feet_max = maxf(feet_max, body.fuss_hoehe())
|
||||||
root.add_child(builder)
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
|
for i in 10:
|
||||||
|
await physics_frame
|
||||||
|
_check(feet_max >= 0.25, "Kiste 0.3 m erklommen (max feet %.2f)" % feet_max)
|
||||||
|
kiste.queue_free()
|
||||||
|
for i in 5:
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
# Kaserne um 90° gedreht: lange Achse (35.5 m) liegt dann auf X.
|
# b) Fass 0.85 m (über der Grenze) → blockt, kein Cheat-Klettern.
|
||||||
var doc: Dictionary = {"objects": [{
|
var fass := _box(welt, Vector3(sx + 3.0, 0.425, sz), Vector3(0.617, 0.85, 0.617))
|
||||||
"id": "barracks_001", "scale": 1.0, "rot_y_deg": 90.0, "pos": [0.0, 0.0, -5.0],
|
body.set("auto_move_dir", Vector3(1, 0, 0))
|
||||||
}]}
|
var feet_max2 := -INF
|
||||||
var obs: Array = pt.call("_collect_obstacles", doc, builder)
|
for i in 180:
|
||||||
_check(obs.size() == 1, "Genau 1 Hindernis gesammelt (ist %d)" % obs.size())
|
await physics_frame
|
||||||
if obs.size() == 1:
|
feet_max2 = maxf(feet_max2, body.fuss_hoehe())
|
||||||
var ab: AABB = obs[0]
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
_check(ab.size.x < 38.0,
|
for i in 10:
|
||||||
"X-Ausdehnung unter Pick-Kugel-Blob (ist %.1f < 38)" % ab.size.x)
|
await physics_frame
|
||||||
_check(absf(ab.size.x - 35.456) < 1.2,
|
_check(feet_max2 < 0.55, "Hindernis 0.85 m blockt (max feet %.2f < 0.55)" % feet_max2)
|
||||||
"Gedrehte Länge auf X ≈ 35.5 m (ist %.2f)" % ab.size.x)
|
fass.queue_free()
|
||||||
_check(absf(ab.size.z - 16.99) < 1.2,
|
for i in 5:
|
||||||
"Gedrehte Breite auf Z ≈ 17.0 m (ist %.2f)" % ab.size.z)
|
await physics_frame
|
||||||
_check(absf(ab.size.y - 6.247) < 0.5,
|
|
||||||
"Höhe ≈ 6.2 m (ist %.2f)" % ab.size.y)
|
|
||||||
_check(absf(ab.position.y - 0.0) < 0.01, "Fußpunkt an Marker-Y")
|
|
||||||
|
|
||||||
# Rotation 0: Länge wieder auf Z.
|
|
||||||
var doc0: Dictionary = {"objects": [{
|
|
||||||
"id": "barracks_001", "scale": 1.0, "rot_y_deg": 0.0, "pos": [0.0, 0.0, -5.0],
|
|
||||||
}]}
|
|
||||||
var obs0: Array = pt.call("_collect_obstacles", doc0, builder)
|
|
||||||
if obs0.size() == 1:
|
|
||||||
var ab0: AABB = obs0[0]
|
|
||||||
_check(absf(ab0.size.z - 35.456) < 1.2,
|
|
||||||
"Rotation 0: Länge auf Z ≈ 35.5 m (ist %.2f)" % ab0.size.z)
|
|
||||||
else:
|
|
||||||
_check(false, "Rotation-0-Fall liefert 1 Hindernis")
|
|
||||||
|
|
||||||
# Scale wird mitgenommen (2× → doppelte Grundfläche).
|
|
||||||
var doc2: Dictionary = {"objects": [{
|
|
||||||
"id": "barracks_001", "scale": 2.0, "rot_y_deg": 0.0, "pos": [0.0, 0.0, -5.0],
|
|
||||||
}]}
|
|
||||||
var obs2: Array = pt.call("_collect_obstacles", doc2, builder)
|
|
||||||
if obs2.size() == 1:
|
|
||||||
_check(absf((obs2[0] as AABB).size.z - 70.9) < 2.4,
|
|
||||||
"Scale 2× verdoppelt die Länge (ist %.1f)" % (obs2[0] as AABB).size.z)
|
|
||||||
else:
|
|
||||||
_check(false, "Scale-Fall liefert 1 Hindernis")
|
|
||||||
|
|
||||||
# Fallback: unbekannte OID → alte Kugel-Näherung bleibt.
|
|
||||||
var docx: Dictionary = {"objects": [{
|
|
||||||
"id": "gibts_nicht_999", "scale": 1.0, "rot_y_deg": 0.0, "pos": [0.0, 0.0, -5.0],
|
|
||||||
}]}
|
|
||||||
var obsx: Array = pt.call("_collect_obstacles", docx, builder)
|
|
||||||
if obsx.size() == 1:
|
|
||||||
var abx: AABB = obsx[0]
|
|
||||||
_check(absf(abx.size.x - 40.6) < 0.2,
|
|
||||||
"Fallback: Kugel-Näherung 2×pick_radius (ist %.1f)" % abx.size.x)
|
|
||||||
else:
|
|
||||||
_check(false, "Fallback-Fall liefert 1 Hindernis")
|
|
||||||
|
|
||||||
builder.queue_free()
|
|
||||||
|
|
||||||
|
|
||||||
## --- Test 4: Rennen/Springen ------------------------------------------------
|
## --- Test 5: Gehen/Rennen/Springen (Client-Geschwindigkeiten) ---------------
|
||||||
func _test_run_and_jump() -> void:
|
func _test_run_and_jump() -> void:
|
||||||
print("Testgruppe: Rennen/Springen wirkt korrekt")
|
print("Testgruppe: Gehen 6 m/s, Rennen 14 m/s, Springen (Client-Parameter)")
|
||||||
var pt := _pt()
|
var pt := _pt()
|
||||||
if pt == null:
|
if pt == null:
|
||||||
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
|
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
|
||||||
return
|
return
|
||||||
var body: Node3D = pt.get("body")
|
var body: Node = pt.get("body")
|
||||||
|
|
||||||
# Gehen: 1 s bei 4 m/s → ~4 m Wegstrecke.
|
# Gehen: 1 s bei 6 m/s → ~6 m Wegstrecke.
|
||||||
var start := Vector3(body.position)
|
var start := Vector3(body.position)
|
||||||
for i in 30:
|
body.set("auto_move_dir", Vector3(1, 0, 0))
|
||||||
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false,
|
for i in 60:
|
||||||
"right": false, "run": false, "jump": false})
|
await physics_frame
|
||||||
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
var walk_dist: float = (Vector3(body.position) - start).length()
|
var walk_dist: float = (Vector3(body.position) - start).length()
|
||||||
_check(walk_dist > 3.0 and walk_dist < 5.0,
|
_check(walk_dist > 5.0 and walk_dist < 7.0,
|
||||||
"Gehen ≈ 4 m/s (gemessen %.2f m in 1 s)" % walk_dist)
|
"Gehen ≈ 6 m/s (gemessen %.2f m in 1 s)" % walk_dist)
|
||||||
|
|
||||||
# Rennen: gleiche Zeit, Verdopplung → deutlich mehr Strecke.
|
# Rennen: gleiche Zeit, deutlich mehr Strecke (14 m/s).
|
||||||
var start2 := Vector3(body.position)
|
var start2 := Vector3(body.position)
|
||||||
for i in 30:
|
body.set("auto_run", true)
|
||||||
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false,
|
body.set("auto_move_dir", Vector3(1, 0, 0))
|
||||||
"right": false, "run": true, "jump": false})
|
for i in 60:
|
||||||
|
await physics_frame
|
||||||
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
|
body.set("auto_run", false)
|
||||||
var run_dist: float = (Vector3(body.position) - start2).length()
|
var run_dist: float = (Vector3(body.position) - start2).length()
|
||||||
_check(run_dist > walk_dist * 1.5,
|
_check(run_dist > 12.0 and run_dist < 16.0,
|
||||||
"Rennen deutlich schneller (%.2f m > %.2f m)" % [run_dist, walk_dist])
|
"Rennen ≈ 14 m/s (gemessen %.2f m in 1 s)" % run_dist)
|
||||||
|
|
||||||
# Springen: y-Impuls, Spieler verlässt den Boden und landet wieder.
|
# Springen: y-Impuls, Spieler verlässt den Boden und landet wieder.
|
||||||
var y0: float = body.position.y
|
var feet0: float = body.fuss_hoehe()
|
||||||
var airborne := false
|
var airborne := false
|
||||||
for i in 60:
|
body.set("auto_jump", true)
|
||||||
var state: Dictionary = body.call("step", 1.0 / 30.0,
|
for i in 90:
|
||||||
{"fwd": false, "back": false, "left": false, "right": false,
|
await physics_frame
|
||||||
"run": false, "jump": i < 2})
|
if body.fuss_hoehe() > feet0 + 0.2:
|
||||||
if not bool(state["on_floor"]):
|
|
||||||
airborne = true
|
airborne = true
|
||||||
if i > 5 and bool(state["on_floor"]):
|
if i > 5 and airborne and body.is_on_floor():
|
||||||
break
|
break
|
||||||
|
body.set("auto_jump", false)
|
||||||
_check(airborne, "Sprung verlässt den Boden")
|
_check(airborne, "Sprung verlässt den Boden")
|
||||||
_check(is_equal_approx(float(body.position.y), y0), "Landung wieder auf Terrainhöhe")
|
_check(absf(body.fuss_hoehe() - feet0) < 0.05, "Landung wieder auf Terrainhöhe")
|
||||||
|
|
||||||
|
|
||||||
## --- Test 5: Rückkehr stellt den Editor-Zustand wieder her ------------------
|
## --- Test 6: Rückkehr stellt den Editor-Zustand wieder her ------------------
|
||||||
func _test_exit_restores_editor() -> void:
|
func _test_exit_restores_editor() -> void:
|
||||||
print("Testgruppe: Rückkehr in den Editor (Dokument + Undo unverändert)")
|
print("Testgruppe: Rückkehr in den Editor (Dokument + Undo unverändert)")
|
||||||
var pt := _pt()
|
var pt := _pt()
|
||||||
|
|
@ -361,6 +363,97 @@ func _test_exit_restores_editor() -> void:
|
||||||
_check(hist.can_undo() == undo_before and hist.can_redo() == redo_before,
|
_check(hist.can_undo() == undo_before and hist.can_redo() == redo_before,
|
||||||
"Undo/Redo-Stapel unverändert")
|
"Undo/Redo-Stapel unverändert")
|
||||||
_check(str(main.get("tool_mode")) == tool_before, "Werkzeug-Modus unverändert")
|
_check(str(main.get("tool_mode")) == tool_before, "Werkzeug-Modus unverändert")
|
||||||
_check(not pt.wants_capture() if pt != null else true,
|
|
||||||
"Maus-Capture-Wunsch zurückgenommen")
|
|
||||||
_check(Input.mouse_mode == Input.MOUSE_MODE_VISIBLE, "Maus wieder frei")
|
_check(Input.mouse_mode == Input.MOUSE_MODE_VISIBLE, "Maus wieder frei")
|
||||||
|
|
||||||
|
|
||||||
|
## --- Test 7: Bauteile (Bauen-Modus) — Treppe begehbar, Tür durchlässig ------
|
||||||
|
## Karte t_1da96275: auch die Modular-Builder-Pieces bekommen echte Trimesh-
|
||||||
|
## Kollider (statt AABB-Kisten) — Treppen hochlaufen, durch Türen gehen.
|
||||||
|
func _test_bauteile_treppe_tuer() -> void:
|
||||||
|
print("Testgruppe: Bauteile — Treppe hoch, durch die Tür (Trimesh-Kollider)")
|
||||||
|
var pt := _pt()
|
||||||
|
if pt == null:
|
||||||
|
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
|
||||||
|
return
|
||||||
|
# Erst raus aus dem laufenden Testlauf, dann Map mit Bauteilen neu starten.
|
||||||
|
pt.call("handle_escape")
|
||||||
|
await process_frame
|
||||||
|
var build_ops := load("res://src/editor/build_ops.gd")
|
||||||
|
var doc: Dictionary = SvmapDocument.create_new("Bauteil-Karte", 100.0, "klar")
|
||||||
|
var t0: float = SvmapDocument.terrain_height_at(doc, 0.0, 0.0)
|
||||||
|
var treppe: Dictionary = build_ops.call("make_piece", "treppe", Vector3(4.0, t0, 0.0), 0.0)
|
||||||
|
var tuer: Dictionary = build_ops.call("make_piece", "tuer", Vector3(0.0, t0, 14.0), 0.0)
|
||||||
|
doc["gebaeude"] = [treppe, tuer]
|
||||||
|
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
|
||||||
|
var pt2 := _pt()
|
||||||
|
_check(pt2 != null and bool(pt2.get("active")), "Testlauf mit Bauteil-Map läuft")
|
||||||
|
if pt2 == null:
|
||||||
|
return
|
||||||
|
var body: Node = pt2.get("body")
|
||||||
|
var kb: Node3D = pt2.get("_kollider_root")
|
||||||
|
var bauteile := 0
|
||||||
|
for child in kb.get_children():
|
||||||
|
if str(child.name).begins_with("Bauteil_Kollider"):
|
||||||
|
bauteile += 1
|
||||||
|
_check(bauteile == 2, "2 Bauteil-Kollider gebaut (Treppe + Tür, ist %d)" % bauteile)
|
||||||
|
|
||||||
|
# a) Treppe (2 m lang, 1 m hoch, 10 Stufen à 0.1 m): hochlaufen.
|
||||||
|
# Erst VOR die Treppe stellen (Spawn liegt weit weg — Karte Mitte).
|
||||||
|
body.set("position", Vector3(0.0, t0 + PlaytestBody.HALF_H + 0.05, 0.0))
|
||||||
|
body.set("velocity", Vector3.ZERO)
|
||||||
|
for i in 20:
|
||||||
|
await physics_frame
|
||||||
|
var feet0: float = body.fuss_hoehe()
|
||||||
|
_check(absf(feet0 - t0) <= 0.05, "Start auf Terrainhöhe (%.3f ≈ %.3f)" % [feet0, t0])
|
||||||
|
body.set("auto_move_dir", Vector3(1, 0, 0))
|
||||||
|
var feet_max := -INF
|
||||||
|
for i in 240:
|
||||||
|
await physics_frame
|
||||||
|
feet_max = maxf(feet_max, body.fuss_hoehe())
|
||||||
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
|
for i in 10:
|
||||||
|
await physics_frame
|
||||||
|
_check(feet_max >= 0.9,
|
||||||
|
"Treppe (1.0 m, Stufen à 0.1) erklommen (max feet %.2f)" % feet_max)
|
||||||
|
|
||||||
|
# b) Tür (Wand 2 m entlang x, Öffnung 1 × 2 m): durchgehen (Lauf +z).
|
||||||
|
var t0_2: float = body.call("terrain_height", 1.0, 16.0)
|
||||||
|
body.set("position", Vector3(1.0, t0_2 + PlaytestBody.HALF_H + 0.05, 16.0))
|
||||||
|
body.set("velocity", Vector3.ZERO)
|
||||||
|
body.set("auto_move_dir", Vector3(0, 0, -1))
|
||||||
|
for i in 150:
|
||||||
|
await physics_frame
|
||||||
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
|
for i in 10:
|
||||||
|
await physics_frame
|
||||||
|
var durch: bool = body.position.z < 13.4
|
||||||
|
_check(durch, "Tür-Öffnung durchlässig (Ende z=%.2f < 13.4; Wandfläche bei 14)" %
|
||||||
|
body.position.z)
|
||||||
|
if not durch:
|
||||||
|
_check(false, "Tür-Block-Debug: pos=%s feet=%.2f" % [str(body.position), body.fuss_hoehe()])
|
||||||
|
|
||||||
|
# c) Aufräumen: zurück, dann ALTE Map für den Exit-Test wiederherstellen.
|
||||||
|
pt2.call("handle_escape")
|
||||||
|
await process_frame
|
||||||
|
var doc0: Dictionary = SvmapDocument.create_new("Testlauf-Karte", 100.0, "klar")
|
||||||
|
main.set("doc", doc0)
|
||||||
|
main.set("current_path", "")
|
||||||
|
main.set("dirty", false)
|
||||||
|
main.get("history").begin(doc0)
|
||||||
|
main.call("_clear_edit_state")
|
||||||
|
main.call("_refresh_all")
|
||||||
|
await process_frame
|
||||||
|
# Playtest auf der wiederhergestellten Karte neu starten — der Exit-Test
|
||||||
|
# braucht einen aktiven Lauf-Modus.
|
||||||
|
main.call("_on_test_run")
|
||||||
|
await process_frame
|
||||||
|
# Nachtrag (Bugfix): das obige Dateiende endete ohne Neustart — der Exit-Test
|
||||||
|
# braucht einen aktiven Lauf-Modus auf der wiederhergestellten Karte.
|
||||||
|
|
|
||||||
|
|
@ -120,9 +120,6 @@ func _key_event(code: Key) -> InputEventKey:
|
||||||
return evt
|
return evt
|
||||||
|
|
||||||
|
|
||||||
const NO_INPUT := {"fwd": false, "back": false, "left": false, "right": false,
|
|
||||||
"run": false, "jump": false}
|
|
||||||
|
|
||||||
|
|
||||||
## --- Test 1: Fliegen / God-Mode ---------------------------------------------
|
## --- Test 1: Fliegen / God-Mode ---------------------------------------------
|
||||||
func _test_fly_godmode() -> void:
|
func _test_fly_godmode() -> void:
|
||||||
|
|
@ -138,61 +135,68 @@ func _test_fly_godmode() -> void:
|
||||||
pt.call("_unhandled_input", _key_event(KEY_G))
|
pt.call("_unhandled_input", _key_event(KEY_G))
|
||||||
_check(bool(body.get("fly_mode")), "Taste G schaltet Fliegen AN (Dispatch)")
|
_check(bool(body.get("fly_mode")), "Taste G schaltet Fliegen AN (Dispatch)")
|
||||||
|
|
||||||
# Gravitation AUS: in der Luft schwebt der Spieler ohne Input.
|
# Gravitation AUS: in der Luft schwebt der Spieler ohne Input (Physik-
|
||||||
|
# Frames, wie im echten Testlauf).
|
||||||
body.set("position", Vector3(body.position.x, 10.0, body.position.z))
|
body.set("position", Vector3(body.position.x, 10.0, body.position.z))
|
||||||
var y0: float = body.position.y
|
var y0: float = body.position.y
|
||||||
for i in 30:
|
for i in 30:
|
||||||
body.call("step", 1.0 / 30.0, NO_INPUT)
|
await physics_frame
|
||||||
_check(absf(float(body.position.y) - y0) < 0.001,
|
_check(absf(float(body.position.y) - y0) < 0.001,
|
||||||
"Keine Gravitation: Höhe hält (10 m, %.3f nach 1 s)" % float(body.position.y))
|
"Keine Gravitation: Höhe hält (10 m, %.3f nach 0,5 s)" % float(body.position.y))
|
||||||
|
|
||||||
# Noclip: Wand quer vor dem Spieler, Fliegen geht DURCH (kein Block).
|
# Noclip: Fliegen geht durch alles (direkte Position, kein Kollider-Block).
|
||||||
var start := Vector3(body.position)
|
var start := Vector3(body.position)
|
||||||
body.set("yaw_deg", 0.0) # Blick nach -Z
|
body.set("yaw_deg", 0.0) # Blick nach -Z
|
||||||
body.get("obstacles").append(AABB(Vector3(start.x - 2.0, -5.0, start.z - 3.0),
|
body.set("auto_move_dir", Vector3(0, 0, -1))
|
||||||
Vector3(4.0, 10.0, 0.6)))
|
|
||||||
for i in 60:
|
for i in 60:
|
||||||
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false,
|
await physics_frame
|
||||||
"right": false, "run": false, "jump": false})
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
var dist: float = start.distance_to(body.position)
|
var dist: float = start.distance_to(body.position)
|
||||||
_check(dist > 3.5, "Fliegen geht durch die Wand (%.2f m Weg, Wand bei 2.4–3.0)" % dist)
|
_check(dist > 3.5, "Fliegen noclip: 6 m Weg ohne Block (%.2f m)" % dist)
|
||||||
body.get("obstacles").clear()
|
|
||||||
|
|
||||||
# Bewegung in Blickrichtung: Blick 30° nach unten → Wegstrecke teilt sich
|
# Bewegung in Blickrichtung: Blick 30° nach unten → Wegstrecke teilt sich
|
||||||
# in -z und -y (sin30 = 0.5, cos30 ≈ 0.866; 4 m in 1 s).
|
# in -z und -y (sin30 = 0.5, cos30 ≈ 0.866; 6 m/s → 6 m in 1 s).
|
||||||
var start2 := Vector3(body.position)
|
var blick: Vector3 = body.call("view_direction") if body.has_method("view_direction") \
|
||||||
|
else Vector3.ZERO
|
||||||
body.set("yaw_deg", 0.0)
|
body.set("yaw_deg", 0.0)
|
||||||
body.set("pitch_deg", -30.0)
|
body.set("pitch_deg", -30.0)
|
||||||
for i in 30:
|
blick = body.call("view_direction")
|
||||||
body.call("step", 1.0 / 30.0, {"fwd": true, "back": false, "left": false,
|
_check(absf(blick.z + 0.866) < 0.01 and absf(blick.y + 0.5) < 0.01,
|
||||||
"right": false, "run": false, "jump": false})
|
"Blickrichtung-Vektor 30° unter Horizont (%.3f, %.3f)" % [blick.z, blick.y])
|
||||||
|
var start2 := Vector3(body.position)
|
||||||
|
body.set("auto_move_dir", blick)
|
||||||
|
for i in 60:
|
||||||
|
await physics_frame
|
||||||
|
body.set("auto_move_dir", Vector3.ZERO)
|
||||||
var moved: Vector3 = body.position - start2
|
var moved: Vector3 = body.position - start2
|
||||||
_check(absf(moved.z + 3.46) < 0.05 and absf(moved.y + 2.0) < 0.05,
|
_check(absf(moved.z + 5.19) < 0.1 and absf(moved.y + 3.0) < 0.1,
|
||||||
"Blickrichtung inkl. Nickwinkel (Δz=%.2f, Δy=%.2f, Soll -3.46/-2.0)" % [moved.z, moved.y])
|
"Flug entlang der Blickrichtung (Δz=%.2f, Δy=%.2f, Soll -5.19/-3.0)" % [moved.z, moved.y])
|
||||||
|
|
||||||
# Space = steigen.
|
# Space = steigen.
|
||||||
var y_up: float = body.position.y
|
var y_up: float = body.position.y
|
||||||
for i in 15:
|
body.set("auto_input", {"fwd": false, "back": false, "left": false,
|
||||||
body.call("step", 1.0 / 30.0, {"fwd": false, "back": false, "left": false,
|
|
||||||
"right": false, "run": false, "jump": true})
|
"right": false, "run": false, "jump": true})
|
||||||
|
for i in 15:
|
||||||
|
await physics_frame
|
||||||
|
body.set("auto_input", {})
|
||||||
_check(float(body.position.y) > y_up + 1.0,
|
_check(float(body.position.y) > y_up + 1.0,
|
||||||
"Space steigt im Flug (+%.1f m)" % float(body.position.y - y_up))
|
"Space steigt im Flug (+%.1f m)" % float(body.position.y - y_up))
|
||||||
|
|
||||||
# Ausschalten: Gravitation wirkt wieder (Fällen zum Terrain).
|
# Ausschalten: Gravitation wirkt wieder (Fällen zum Terrain-Trimesh).
|
||||||
pt.call("_unhandled_input", _key_event(KEY_G))
|
pt.call("_unhandled_input", _key_event(KEY_G))
|
||||||
_check(not bool(body.get("fly_mode")), "Zweites G schaltet Fliegen AUS")
|
_check(not bool(body.get("fly_mode")), "Zweites G schaltet Fliegen AUS")
|
||||||
var y_fall: float = body.position.y
|
var y_fall: float = body.position.y
|
||||||
var landed := false
|
var landed := false
|
||||||
for i in 120:
|
for i in 240:
|
||||||
var state: Dictionary = body.call("step", 1.0 / 30.0, NO_INPUT)
|
await physics_frame
|
||||||
if bool(state["on_floor"]):
|
if body.is_on_floor():
|
||||||
landed = true
|
landed = true
|
||||||
break
|
break
|
||||||
_check(landed and float(body.position.y) < y_fall,
|
_check(landed and float(body.position.y) < y_fall,
|
||||||
"Fliegen aus: Spieler fällt und landet (y=%.2f)" % float(body.position.y))
|
"Fliegen aus: Spieler fällt und landet (y=%.2f)" % float(body.position.y))
|
||||||
var terrain_y: float = body.call("terrain_height", body.position.x, body.position.z)
|
var terrain_y: float = body.call("terrain_height", body.position.x, body.position.z)
|
||||||
_check(is_equal_approx(float(body.position.y), terrain_y),
|
_check(absf(float(body.fuss_hoehe()) - terrain_y) <= 0.05,
|
||||||
"Landung auf Terrainhöhe (%.3f == %.3f)" % [float(body.position.y), terrain_y])
|
"Landung auf Terrainhöhe (%.3f ≈ %.3f)" % [float(body.fuss_hoehe()), terrain_y])
|
||||||
|
|
||||||
|
|
||||||
## --- Test 2: Sprung zu den Spawnpunkten --------------------------------------
|
## --- Test 2: Sprung zu den Spawnpunkten --------------------------------------
|
||||||
|
|
@ -216,7 +220,8 @@ func _test_spawn_jumps() -> void:
|
||||||
continue
|
continue
|
||||||
var pos: Array = (pts[0] as Dictionary).get("pos", [0.0, 0.0, 0.0])
|
var pos: Array = (pts[0] as Dictionary).get("pos", [0.0, 0.0, 0.0])
|
||||||
var expected := Vector3(float(pos[0]),
|
var expected := Vector3(float(pos[0]),
|
||||||
body.call("terrain_height", float(pos[0]), float(pos[2])), float(pos[2]))
|
float(body.call("terrain_height", float(pos[0]), float(pos[2])))
|
||||||
|
+ PlaytestBody.HALF_H + 0.02, float(pos[2]))
|
||||||
# Vorher woanders hin (sichert, dass wirklich gesprungen wird).
|
# Vorher woanders hin (sichert, dass wirklich gesprungen wird).
|
||||||
body.set("position", Vector3(99.0, 5.0, 99.0))
|
body.set("position", Vector3(99.0, 5.0, 99.0))
|
||||||
var ok := bool(pt.call("jump_to_spawn", idx))
|
var ok := bool(pt.call("jump_to_spawn", idx))
|
||||||
|
|
|
||||||
164
tests/test_playtest_identitaet.gd
Normal file
164
tests/test_playtest_identitaet.gd
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Identitäts-Vertrag (Karte t_1da96275 — „Editor-Playtest-Physik = Quest 3
|
||||||
|
## identisch“): dieselbe Probe-Map läuft hier im EDITOR-Kontext (KolliderBau
|
||||||
|
## + PlaytestBody im SubViewport — exakt der Playtest-Aufbau des Editors)
|
||||||
|
## und in tests/test_identitaet_client.gd im QUEST-CLIENT-Kontext
|
||||||
|
## (SvmapWorld + TestmapPlayer). Der Runner (tests/run_identitaet.sh)
|
||||||
|
## verlangt IDENTISCHE Entscheidungen.
|
||||||
|
##
|
||||||
|
## Jede Probe druckt eine Zeile `IDENT <name>=<wert>` — Werte sind
|
||||||
|
## grobe Entscheidungen (KLIMM/BLOCK/DRIN/DRAUSSEN) plus auf cm gerundete
|
||||||
|
## Höhen, damit beide Welten vergleichbar sind (gleiche Engine, gleiche
|
||||||
|
## Shapes → deterministisch gleich).
|
||||||
|
##
|
||||||
|
## Run: godot4 --headless --path . -s res://tests/test_playtest_identitaet.gd
|
||||||
|
## Map: $SVR_IDENTITAET_MAP (Default /tmp/svr_identitaet_probe.svmap,
|
||||||
|
## erzeugt von tests/gen_identitaet_map.gd).
|
||||||
|
|
||||||
|
const HALF_H := 0.9
|
||||||
|
const TUER_AZIMUT_DEG := 202.5 # Befund Karte t_f9adf97c (building_001, rot 0)
|
||||||
|
const GEB_TUER := Vector2(-16.0, -16.0) # building_001-Fußpunkt (x, z)
|
||||||
|
const GEB_HALF := Vector2(8.94, 3.54) # AABB-Halbmaße (x, z) rot 0
|
||||||
|
|
||||||
|
var fails: Array[String] = []
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _check(cond: bool, what: String) -> void:
|
||||||
|
if cond:
|
||||||
|
print("IDENT ok: ", what)
|
||||||
|
else:
|
||||||
|
fails.append(what)
|
||||||
|
print("IDENT FAIL: ", what)
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var map_path := OS.get_environment("SVR_IDENTITAET_MAP")
|
||||||
|
if map_path.is_empty():
|
||||||
|
map_path = "/tmp/svr_identitaet_probe.svmap"
|
||||||
|
if not FileAccess.file_exists(map_path):
|
||||||
|
printerr("IDENT FAIL: Probe-Map fehlt (%s) — erst gen_identitaet_map.gd laufen lassen." % map_path)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var doc_v: Variant = JSON.parse_string(FileAccess.get_file_as_string(map_path))
|
||||||
|
if not (doc_v is Dictionary):
|
||||||
|
printerr("IDENT FAIL: Probe-Map nicht lesbar.")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var doc: Dictionary = doc_v
|
||||||
|
|
||||||
|
# --- Editor-Playtest-Aufbau: SubViewport (own world) + KolliderBau + Body
|
||||||
|
var vp := SubViewport.new()
|
||||||
|
vp.own_world_3d = true
|
||||||
|
root.add_child(vp)
|
||||||
|
var welt := Node3D.new()
|
||||||
|
vp.add_child(welt)
|
||||||
|
welt.add_child(KolliderBau.baue_welt_kollider(doc))
|
||||||
|
var body: CharacterBody3D = load("res://src/editor/playtest/playtest_body.gd").new()
|
||||||
|
welt.add_child(body)
|
||||||
|
body.call("set_terrain", doc)
|
||||||
|
await physics_frame
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
|
# 1) Stufe 0.3 m (Terrain) — erklommen.
|
||||||
|
var r := await _lauf(body, Vector3(-8.0, 0.0, 20.0), Vector3(1, 0, 0), 300)
|
||||||
|
var st30 := "BLOCK"
|
||||||
|
if r.max_feet >= 0.25 and r.end.x > 1.0:
|
||||||
|
st30 = "KLIMM"
|
||||||
|
_ident("stufe_terrain_030", "%s %.2f" % [st30, r.max_feet])
|
||||||
|
_check(st30 == "KLIMM", "Stufe 0.3 m erklommen (max feet %.2f, x %.2f)" % [r.max_feet, r.end.x])
|
||||||
|
|
||||||
|
# 2) Hohes Hindernis 0.85 m (barrel_001, Box-Kollider) — blockt: über der
|
||||||
|
# Step-Up-Grenze 0.45 m gibt es KEIN Klettern (Karte t_f9adf97c).
|
||||||
|
r = await _lauf(body, Vector3(0.5, 0.0, 20.0), Vector3(1, 0, 0), 240)
|
||||||
|
var st80 := "KLIMM"
|
||||||
|
if r.end.x < 1.55 and r.max_feet < 0.55:
|
||||||
|
st80 = "BLOCK"
|
||||||
|
_ident("wand_barrel", "%s %.2f x=%.2f" % [st80, r.max_feet, r.end.x])
|
||||||
|
_check(st80 == "BLOCK", "Hindernis 0.85 m blockt (max feet %.2f, x %.2f)" % [r.max_feet, r.end.x])
|
||||||
|
|
||||||
|
# 3) Kisten-Stufe 0.334 m (box_006) — erklommen (Step-Up über Objekt).
|
||||||
|
r = await _lauf(body, Vector3(-24.0, 0.0, 8.0), Vector3(1, 0, 0), 260)
|
||||||
|
var box := "BLOCK"
|
||||||
|
if r.max_feet >= 0.28:
|
||||||
|
box = "KLIMM"
|
||||||
|
_ident("kisten_stufe", "%s %.2f" % [box, r.max_feet])
|
||||||
|
_check(box == "KLIMM", "Kiste 0.334 m erklommen (max feet %.2f)" % r.max_feet)
|
||||||
|
|
||||||
|
# 4) Wand (barracks_001-Trimesh) — blockt.
|
||||||
|
r = await _lauf(body, Vector3(20.0, 0.0, 31.5), Vector3(0, 0, -1), 300)
|
||||||
|
var wand := "DURCH"
|
||||||
|
if r.end.z > 29.3:
|
||||||
|
wand = "BLOCK"
|
||||||
|
_ident("wand_barracks", "%s z=%.2f" % [wand, r.end.z])
|
||||||
|
_check(wand == "BLOCK", "Kaserne blockt (Ende z=%.2f, Fläche bei 29.73)" % r.end.z)
|
||||||
|
|
||||||
|
# 5) Tür (building_001) — durchlässig, Spieler landet IM Gebäude.
|
||||||
|
var outward := Vector3(cos(deg_to_rad(TUER_AZIMUT_DEG)), 0.0,
|
||||||
|
sin(deg_to_rad(TUER_AZIMUT_DEG)))
|
||||||
|
var start := Vector3(GEB_TUER.x + outward.x * 12.0, 0.0,
|
||||||
|
GEB_TUER.y + outward.z * 12.0)
|
||||||
|
r = await _lauf(body, start, -outward, 600)
|
||||||
|
var drin := absf(r.end.x - GEB_TUER.x) < GEB_HALF.x - 0.3 \
|
||||||
|
and absf(r.end.z - GEB_TUER.y) < GEB_HALF.y - 0.3
|
||||||
|
_ident("tuer_building", "%s %.2f %.2f" % ["DRIN" if drin else "DRAUSSEN", r.end.x, r.end.z])
|
||||||
|
_check(drin, "Tür building_001 durchlässig (Ende %.2f, %.2f — Gebäude x∈[%.1f,%.1f] z∈[%.1f,%.1f])"
|
||||||
|
% [r.end.x, r.end.z, GEB_TUER.x - GEB_HALF.x, GEB_TUER.x + GEB_HALF.x,
|
||||||
|
GEB_TUER.y - GEB_HALF.y, GEB_TUER.y + GEB_HALF.y])
|
||||||
|
|
||||||
|
# 6) Terrain-Höhe unter den Füßen (Strahl abwärts, exakt).
|
||||||
|
_hoehe(body, Vector3(-10.0, 10.0, 0.0), "terrain_hoehe_a", 0.0)
|
||||||
|
_hoehe(body, Vector3(4.0, 10.0, 20.0), "terrain_hoehe_b", 0.3)
|
||||||
|
_hoehe(body, Vector3(10.0, 12.0, -28.0), "terrain_hoehe_c", 1.1)
|
||||||
|
|
||||||
|
if fails.is_empty():
|
||||||
|
print("IDENT EDITOR: ALLE OK")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("IDENT EDITOR: FAILED %d" % fails.size())
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _ident(name: String, wert: String) -> void:
|
||||||
|
print("IDENT %s=%s" % [name, wert])
|
||||||
|
|
||||||
|
|
||||||
|
## Autopilot-Lauf — 1:1 zur Client-Seite: Start y=3.0 (Fall-Settle auf den
|
||||||
|
## Trimesh), dann Richtung fahren, max Füß-Höhe + Endposition messen.
|
||||||
|
func _lauf(body: CharacterBody3D, start_xz: Vector3, dir: Vector3,
|
||||||
|
frames: int) -> Dictionary:
|
||||||
|
body.auto_move_dir = Vector3.ZERO
|
||||||
|
body.velocity = Vector3.ZERO
|
||||||
|
body.global_position = Vector3(start_xz.x, 3.0, start_xz.z)
|
||||||
|
for i in 45:
|
||||||
|
await physics_frame
|
||||||
|
body.auto_move_dir = dir
|
||||||
|
var max_feet := -INF
|
||||||
|
for i in frames:
|
||||||
|
await physics_frame
|
||||||
|
max_feet = maxf(max_feet, body.fuss_hoehe())
|
||||||
|
if body.fuss_hoehe() < -5.0:
|
||||||
|
break # unter die Welt — nichts mehr zu holen
|
||||||
|
body.auto_move_dir = Vector3.ZERO
|
||||||
|
for i in 10:
|
||||||
|
await physics_frame
|
||||||
|
return {"end": Vector3(body.global_position), "max_feet": max_feet}
|
||||||
|
|
||||||
|
|
||||||
|
## Boden-Strahl an einem Punkt → IDENT-Zeile + Soll-Prüfung (±1 cm).
|
||||||
|
func _hoehe(body: CharacterBody3D, p: Vector3, name: String, soll: float) -> void:
|
||||||
|
var raum := body.get_world_3d().direct_space_state
|
||||||
|
var hit := raum.intersect_ray(PhysicsRayQueryParameters3D.create(
|
||||||
|
p, p + Vector3(0, -30, 0)))
|
||||||
|
var y := INF
|
||||||
|
if not hit.is_empty():
|
||||||
|
y = (hit["position"] as Vector3).y
|
||||||
|
if y == INF:
|
||||||
|
_ident(name, "KEIN_TREFFER")
|
||||||
|
_check(false, name + ": kein Boden-Treffer")
|
||||||
|
return
|
||||||
|
_ident(name, "%.2f" % y)
|
||||||
|
_check(absf(y - soll) <= 0.01, "%s: %.3f ≈ Soll %.2f" % [name, y, soll])
|
||||||
1
tests/test_playtest_identitaet.gd.uid
Normal file
1
tests/test_playtest_identitaet.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bay4ioxemv0r2
|
||||||
|
|
@ -9,7 +9,7 @@ extends SceneTree
|
||||||
## 2. Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm).
|
## 2. Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm).
|
||||||
## 3. Fallback-Reihenfolge je einmal belegt (Test-Start → Team-Spawn → Mitte).
|
## 3. Fallback-Reihenfolge je einmal belegt (Test-Start → Team-Spawn → Mitte).
|
||||||
## 4. Nach „Neustart" wiederhergestellt (Sidecar laden/leeren).
|
## 4. Nach „Neustart" wiederhergestellt (Sidecar laden/leeren).
|
||||||
## 5. T im Testlauf versetzt genau dorthin, Augenhöhe 1,70 m, Kollision
|
## 5. T im Testlauf versetzt genau dorthin, Kapsel-Physik (0.65 über Mitte), Kollision
|
||||||
## beachtet (blocked = ok=false, Position unverändert; Kisten-Dach = oben).
|
## beachtet (blocked = ok=false, Position unverändert; Kisten-Dach = oben).
|
||||||
## 6. Marker existiert nur bei gesetztem Punkt.
|
## 6. Marker existiert nur bei gesetztem Punkt.
|
||||||
## 7. Test-Start taucht in KEINER Spawn-Zone/Analyse auf.
|
## 7. Test-Start taucht in KEINER Spawn-Zone/Analyse auf.
|
||||||
|
|
@ -313,8 +313,8 @@ func _test_playtest_teleport() -> void:
|
||||||
body.set("yaw_deg", 0.0) # Blick nach -Z
|
body.set("yaw_deg", 0.0) # Blick nach -Z
|
||||||
body.set("pitch_deg", -45.0)
|
body.set("pitch_deg", -45.0)
|
||||||
body.call("_apply_look") # yaw/pitch erst durch _apply_look auf die Kamera
|
body.call("_apply_look") # yaw/pitch erst durch _apply_look auf die Kamera
|
||||||
var eye: Vector3 = body.position + Vector3(0.0, float(body.get("eye_height")), 0.0)
|
|
||||||
var cam: Camera3D = body.get("look_cam")
|
var cam: Camera3D = body.get("look_cam")
|
||||||
|
var eye: Vector3 = cam.global_position # Kapsel-Mitte + EYE_HEIGHT (0.65)
|
||||||
var dir: Vector3 = cam.global_transform.basis * Vector3(0.0, 0.0, -1.0)
|
var dir: Vector3 = cam.global_transform.basis * Vector3(0.0, 0.0, -1.0)
|
||||||
_check(absf(dir.y + 0.7071) < 0.01, "Kamera-Blick zeigt 45° nach unten (dir.y ≈ -0.707)")
|
_check(absf(dir.y + 0.7071) < 0.01, "Kamera-Blick zeigt 45° nach unten (dir.y ≈ -0.707)")
|
||||||
var h0: float = body.call("terrain_height", eye.x, eye.z)
|
var h0: float = body.call("terrain_height", eye.x, eye.z)
|
||||||
|
|
@ -322,14 +322,16 @@ func _test_playtest_teleport() -> void:
|
||||||
var expected := eye + dir * th
|
var expected := eye + dir * th
|
||||||
main._unhandled_key_input(_press_key(KEY_T)) # ECHTER T-Druck (main-Verdrahtung)
|
main._unhandled_key_input(_press_key(KEY_T)) # ECHTER T-Druck (main-Verdrahtung)
|
||||||
await process_frame
|
await process_frame
|
||||||
|
await physics_frame
|
||||||
var np := body.position
|
var np := body.position
|
||||||
_check(absf(np.x - expected.x) < 0.15 and absf(np.z - expected.z) < 0.15,
|
_check(absf(np.x - expected.x) < 0.15 and absf(np.z - expected.z) < 0.15,
|
||||||
"Teleport-Ziel == Fadenkreuz-Punkt (%.2f,%.2f ≈ %.2f,%.2f)" % [
|
"Teleport-Ziel == Fadenkreuz-Punkt (%.2f,%.2f ≈ %.2f,%.2f)" % [
|
||||||
np.x, np.z, expected.x, expected.z])
|
np.x, np.z, expected.x, expected.z])
|
||||||
var ground_here: float = body.call("terrain_height", np.x, np.z)
|
var ground_here: float = body.call("terrain_height", np.x, np.z)
|
||||||
_check(absf(np.y - ground_here) <= 0.01,
|
_check(absf((np.y - PlaytestBody.HALF_H) - ground_here) <= 0.05,
|
||||||
"Fusspunkt auf Terrainhöhe (Δ %.4f m)" % absf(np.y - ground_here))
|
"Fusspunkt auf Terrainhöhe (Δ %.4f m)" % absf((np.y - PlaytestBody.HALF_H) - ground_here))
|
||||||
_check(absf(float(cam.position.y) - 1.7) < 0.001, "Augenhöhe 1,70 m (look_cam-Offset)")
|
_check(absf(float(cam.position.y) - 0.65) < 0.001,
|
||||||
|
"Kamera-Offset 0.65 m über Kapsel-Mitte (identisch TestmapPlayer)")
|
||||||
var stored: Variant = main.get("test_start_point")
|
var stored: Variant = main.get("test_start_point")
|
||||||
_check(stored != null and absf((stored as Vector3).x - np.x) < 0.01
|
_check(stored != null and absf((stored as Vector3).x - np.x) < 0.01
|
||||||
and absf((stored as Vector3).z - np.z) < 0.01,
|
and absf((stored as Vector3).z - np.z) < 0.01,
|
||||||
|
|
@ -337,33 +339,50 @@ func _test_playtest_teleport() -> void:
|
||||||
var msg: String = (pt.get("hud_msg") as Label).text
|
var msg: String = (pt.get("hud_msg") as Label).text
|
||||||
_check(msg.contains("Test-Start"), "HUD meldet Klartext: '%s'" % msg)
|
_check(msg.contains("Test-Start"), "HUD meldet Klartext: '%s'" % msg)
|
||||||
|
|
||||||
# b) Kollision: hohe Wand vor dem Spieler, flacher Blick (−10°) zielt HINTER
|
# b) Kollision: hohe Wand vor dem Spieler (echter StaticBody in der
|
||||||
# die Wand ins Terrain — der Wand-AABB-Treffer kommt zuerst → ABGELEHNT.
|
# Physik-Welt), flacher Blick (−10°) trifft die Wand → ABGELEHNT
|
||||||
# (Wand ragt weit über die Augenhöhe am Ramp-Terrain: Augen ≈ 7,5 m dort.)
|
# (Kapsel am Zielpunkt nicht frei — wie im Spiel).
|
||||||
|
var welt: Node3D = main.get("world3d")
|
||||||
var vor := Vector3(body.position)
|
var vor := Vector3(body.position)
|
||||||
body.obstacles.append(AABB(Vector3(vor.x - 2.0, -5.0, vor.z - 4.0), Vector3(4.0, 20.0, 0.6)))
|
var wand := StaticBody3D.new()
|
||||||
|
var wcs := CollisionShape3D.new()
|
||||||
|
var wshape := BoxShape3D.new()
|
||||||
|
wshape.size = Vector3(4.0, 20.0, 0.6)
|
||||||
|
wcs.shape = wshape
|
||||||
|
wand.add_child(wcs)
|
||||||
|
wand.position = Vector3(vor.x, 5.0, vor.z - 2.7)
|
||||||
|
welt.add_child(wand)
|
||||||
body.set("yaw_deg", 0.0)
|
body.set("yaw_deg", 0.0)
|
||||||
body.set("pitch_deg", -10.0)
|
body.set("pitch_deg", -10.0)
|
||||||
body.call("_apply_look")
|
body.call("_apply_look")
|
||||||
main._unhandled_key_input(_press_key(KEY_T))
|
main._unhandled_key_input(_press_key(KEY_T))
|
||||||
await process_frame
|
await process_frame
|
||||||
|
await physics_frame
|
||||||
_check(body.position.distance_to(vor) < 0.001, "Im Objekt: Teleport abgelehnt, Position unverändert")
|
_check(body.position.distance_to(vor) < 0.001, "Im Objekt: Teleport abgelehnt, Position unverändert")
|
||||||
var blocked_msg: String = (pt.get("hud_msg") as Label).text
|
var blocked_msg: String = (pt.get("hud_msg") as Label).text
|
||||||
_check(blocked_msg.contains("nicht landen") or blocked_msg.contains("Weg"),
|
_check(blocked_msg.contains("nicht landen") or blocked_msg.contains("Weg"),
|
||||||
"Abgelehnt: Klartext im HUD: '%s'" % blocked_msg)
|
"Abgelehnt: Klartext im HUD: '%s'" % blocked_msg)
|
||||||
body.obstacles.pop_back()
|
wand.queue_free()
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
# c) Kisten-Dach: begehbare Block-Decke → oben drauf landen (Boden DARONTER,
|
# c) Kisten-Dach: echter Kisten-StaticBody → oben drauf landen (Boden
|
||||||
# nie im Objekt). Strahl von oben senkrecht auf die Kiste.
|
# DARAUFD, nie im Objekt). Strahl von oben senkrecht auf die Kiste.
|
||||||
var crate := AABB(Vector3(np.x + 5.0, np.y, np.z), Vector3(1.0, 1.0, 1.0))
|
var cground: float = body.call("terrain_height", np.x + 5.0, np.z)
|
||||||
body.obstacles.append(crate)
|
var kiste := StaticBody3D.new()
|
||||||
var ccenter := crate.get_center()
|
var ccs := CollisionShape3D.new()
|
||||||
|
var cshape := BoxShape3D.new()
|
||||||
|
cshape.size = Vector3(1.0, 1.0, 1.0)
|
||||||
|
ccs.shape = cshape
|
||||||
|
kiste.add_child(ccs)
|
||||||
|
kiste.position = Vector3(np.x + 5.0, cground + 0.5, np.z)
|
||||||
|
welt.add_child(kiste)
|
||||||
var roof: Dictionary = body.call("teleport_to_ground",
|
var roof: Dictionary = body.call("teleport_to_ground",
|
||||||
Vector3(ccenter.x, crate.end.y + 2.0, ccenter.z), Vector3(0.0, -1.0, 0.0))
|
Vector3(np.x + 5.0, cground + 3.0, np.z), Vector3(0.0, -1.0, 0.0))
|
||||||
_check(bool(roof.get("ok")) and absf(float(roof.get("ground")) - crate.end.y) < 0.01,
|
_check(bool(roof.get("ok")) and absf(float(roof.get("ground")) - (cground + 1.0)) < 0.01,
|
||||||
"Kisten-Dach: auf der Decke gelandet (ground %.2f == %.2f)" % [
|
"Kisten-Dach: auf der Decke gelandet (ground %.2f == %.2f)" % [
|
||||||
float(roof.get("ground")), crate.end.y])
|
float(roof.get("ground")), cground + 1.0])
|
||||||
body.obstacles.pop_back()
|
kiste.queue_free()
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
# d) ESC: zurück, Editor-Zustand intakt (Punkt bleibt gesetzt).
|
# d) ESC: zurück, Editor-Zustand intakt (Punkt bleibt gesetzt).
|
||||||
var doc_before := str(doc)
|
var doc_before := str(doc)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue