extends SceneTree
## GATE — keymap step 1: NO PERMANENT WASD, Shift+F fly modal, suppress while flying
## (docs/keymap-blender-279.md §2a; user 2026-10-12 "suppress while flying like blender").
##
## 1. build mode, not flying: W does NOT move the camera; the `fly` context is off
## 2. RMB held: `fly` is on (exclusive — builder suppressed), W moves; released: builder back
## 3. Shift+F: the fly modal — mouse captured, W moves, E rises; Esc CANCELS back to the
## start (position and view); Shift+F again + Enter CONFIRMS (keeps the new view)
## 4. the freed keys: Ctrl+Numpad − / * / ÷ = subtract / intersect / slice; hotspot fit
## is Shift+U; Shift+F is fly
##
## Run: HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_fly_modal.gd
var ok := true
var checks := 0
var _frame := 0
var _km: Node
var _gm: Node
var _ply: Node3D
var _p0 := Vector3.ZERO
var _yaw0 := 0.0
func _p(cond: bool, what: String, note: Variant = "") -> void:
checks += 1
if not cond:
ok = false
print(" %-62s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _key(code: Key, shift := false) -> InputEventKey:
var e := InputEventKey.new()
e.keycode = code
e.physical_keycode = code
e.pressed = true
e.shift_pressed = shift
return e
func _process(_d: float) -> bool:
_frame += 1
match _frame:
1:
_km = root.get_node("/root/Keymap")
_gm = root.get_node("/root/GameMode")
# GameMode finds the local player under Game/Players (as in a real session).
var game := Node.new()
game.name = "Game"
root.add_child(game)
var players := Node.new()
players.name = "Players"
game.add_child(players)
_ply = (load("res://scenes/player/player_procedural.tscn") as PackedScene).instantiate()
players.add_child(_ply)
_ply.global_position = Vector3(0, 50, 0)
6:
_gm.set_mode("build")
_km.push_context("builder") # the level map pushes it; there is no map here
print("-- 4. keys --")
var sub: Dictionary = _km.binding_of("build_mode.csg_subtract")
_p(int(sub.get("key", 0)) == KEY_KP_SUBTRACT and bool(sub.get("ctrl", false)), "Ctrl+Numpad − subtracts", sub)
var isx: Dictionary = _km.binding_of("build_mode.csg_intersect")
_p(int(isx.get("key", 0)) == KEY_KP_MULTIPLY and bool(isx.get("ctrl", false)), "Ctrl+Numpad * intersects", isx)
var sl: Dictionary = _km.binding_of("build_mode.csg_slice")
_p(int(sl.get("key", 0)) == KEY_KP_DIVIDE and bool(sl.get("ctrl", false)), "Ctrl+Numpad / slices", sl)
var hs: Dictionary = _km.binding_of("builder.uv_hotspot")
_p(int(hs.get("key", 0)) == KEY_U and bool(hs.get("shift", false)), "hotspot fit is Shift+U", hs)
var fl: Dictionary = _km.binding_of("builder.fly")
_p(int(fl.get("key", 0)) == KEY_F and bool(fl.get("shift", false)), "Shift+F is fly", fl)
_p(_km.CONTEXTS.has("fly") and bool(_km.CONTEXTS["fly"].get("exclusive", false)), "the fly context is exclusive")
print("\n-- 1. not flying: W does not move the camera --")
_p(not _km.is_context_active("fly"), "fly context off in build mode")
_p(_km.is_context_active("builder"), "builder on")
_p0 = _ply.global_position
Input.action_press("move_forward")
26:
_p(_ply.global_position.distance_to(_p0) < 0.01, "W held 20 frames: the camera did not move",
"%.3f m" % _ply.global_position.distance_to(_p0))
print("\n-- 2. RMB held: fly --")
_gm.set("_temp_look", true)
_p(_km.is_context_active("fly"), "fly context ON while RMB is held")
_p(not _km.is_context_active("builder"), "builder SUPPRESSED while flying (Blender's modal rule)")
_p0 = _ply.global_position
46:
_p(_ply.global_position.distance_to(_p0) > 0.05, "RMB + W flies", "%.3f m" % _ply.global_position.distance_to(_p0))
Input.action_release("move_forward")
_gm.set("_temp_look", false)
_p(not _km.is_context_active("fly") and _km.is_context_active("builder"), "RMB released: fly off, builder back")
print("\n-- 3. Shift+F fly modal, cancel --")
_p0 = _ply.global_position
_yaw0 = float(_ply.get("camera_head").rotation.y)
_km.run("builder.fly")
50:
_p(bool(_gm.call("is_fly_modal")), "Shift+F entered the fly modal")
_p(_km.is_context_active("fly") and not bool(_gm.wants_pointer()), "fly context on, mouse captured")
_ply.get("camera_head").rotation.y = _yaw0 + 0.7 # look somewhere else
Input.action_press("move_forward")
Input.action_press("fly.up")
70:
var moved := _ply.global_position - _p0
_p(moved.length() > 0.05 and moved.y > 0.05, "W moves and E rises inside the modal", str(moved))
Input.action_release("move_forward")
Input.action_release("fly.up")
_gm._unhandled_key_input(_key(KEY_ESCAPE))
74:
_p(not bool(_gm.call("is_fly_modal")) and not _km.is_context_active("fly"), "Esc left the modal")
_p(_ply.global_position.distance_to(_p0) < 0.01, "Esc CANCELS: back to the start position",
"%.3f m" % _ply.global_position.distance_to(_p0))
_p(absf(float(_ply.get("camera_head").rotation.y) - _yaw0) < 0.001, "... and the start view",
[_yaw0, _ply.get("camera_head").rotation.y])
_p(_km.is_context_active("builder") and bool(_gm.wants_pointer()), "builder back, cursor back")
print("\n-- 3. Shift+F, confirm --")
_km.run("builder.fly")
78:
Input.action_press("move_forward")
98:
Input.action_release("move_forward")
var p1 := _ply.global_position
_gm._unhandled_key_input(_key(KEY_ENTER))
_p(not bool(_gm.call("is_fly_modal")), "Enter left the modal")
_p(_ply.global_position.distance_to(p1) < 0.01 and p1.distance_to(_p0) > 0.05,
"Enter CONFIRMS: the new position is kept", "%.3f m from start" % p1.distance_to(_p0))
_gm.set_mode("play")
if checks < 20:
_p(false, "only %d checks ran" % checks)
print("\nGATE fly_modal: %s (%d checks)" % ["PASS" if ok else "FAIL", checks])
quit(0 if ok else 1)
return true
return false