extends Node
## THE MODE — one owner for "am I playing or building", and everything that implies.
##
## Modes were implicit before: the level map pushed an additive keymap layer, fly was
## its own F5 toggle, and nothing tied the two together. So you could be flying with
## editor keys live and a body that still jumped, and there was no single thing to ask
## "what mode am I in" — which is exactly what a UI needs to be able to ask.
##
## SETTING, NOT TOGGLING (except for Tab, which is a deliberate two-mode shortcut).
## Pressing the current mode's key is a no-op, so the mode is a value rather than the
## parity of how many times you pressed something. That is what makes the keymap's
## context stack meaningful: one mode, one exclusive context, no accumulation.
##
## FLY IS NOT A MODE OF ITS OWN. Build mode IS the free-fly camera: a separate toggle
## would be a second way into the same state and the two would drift apart. Entering
## build asks the player to fly; leaving asks it to stop. The player owns HOW (it
## already had a FLY state); this owns WHEN.
signal mode_changed(mode: String)
## Build mode's pointer sub-mode. Separate signal because most listeners care about
## play-vs-build and would be woken pointlessly by every cursor flip.
signal build_submode_changed(sub: String)
const PLAY := "play"
const BUILD := "build"
## THE POINTER IS A SUB-MODE OF BUILD, and this owns it for the same reason this
## file owns the mode: it was implicit and scattered.
##
## Six files poked Input.mouse_mode directly, and BOTH player scripts carried a
## click-to-recapture branch with a hardcoded exception for one specific overlay:
##
## var panel_open := game and game.get("_sky_debug_panel") and game._sky_debug_panel.visible
## if not panel_open: Input.mouse_mode = MOUSE_MODE_CAPTURED
##
## That does not scale to a panel LAYER — every new panel would have to be added to
## a condition inside two player scripts, and the one that got missed would silently
## eat its own clicks. So the question moved here: ask wants_pointer(), never
## Input.mouse_mode.
##
## CURSOR IS THE DEFAULT ON ENTERING BUILD. An editor whose panels need a mode flip
## before they accept a click is an editor you have to be told how to use, and
## click-to-select in the viewport (hyper-games #28's outliner) fights the flip.
const CURSOR := "cursor"
const FLY := "fly"
## mode -> the keymap context it pushes. `play` pushes nothing: "player" is the
## resting state of the stack, so play is the absence of a mode rather than a layer.
const MODE_CONTEXT: Dictionary = {
BUILD: "build_mode",
}
var current: String = PLAY
## True while RMB is held in CURSOR sub-mode — a TEMPORARY look, not a sub-mode
## change. Kept separate so releasing RMB restores the cursor rather than deciding
## which sub-mode you meant to be in.
##
## A PROPERTY, because it decides the keymap's `fly` context (keymap step 1,
## docs/keymap-blender-279.md §2a): whoever sets it — this file, a gate — keeps the
## context in step, so "flying" can never disagree with what the keymap thinks.
var _temp_look: bool = false:
set(v):
_temp_look = v
_sync_fly()
## THE Shift+F FLY MODAL (Blender 2.79's): the camera flies until Enter / Space / LMB
## keeps the view or Esc / RMB returns to where it started (_fly_from).
var _fly_modal: bool = false:
set(v):
_fly_modal = v
_sync_fly()
var _fly_from: Dictionary = {}
## `fly` is on the keymap stack exactly while flying in build mode.
func _sync_fly() -> void:
var km := get_node_or_null("/root/Keymap")
if km == null:
return
var want: bool = current == BUILD and (_temp_look or _fly_modal)
if want and not km.is_context_active("fly"):
km.push_context("fly")
elif not want and km.is_context_active("fly"):
km.pop_context("fly")
func is_fly_modal() -> bool:
return _fly_modal
## Shift+F: fly from here. The view is saved so Esc can put you back.
func enter_fly_modal() -> void:
if current != BUILD or _fly_modal:
return
var p := _local_player()
_fly_from = {}
if p != null:
var head: Node3D = p.get("camera_head")
var pivot: Node3D = p.get("camera_pivot")
_fly_from = {"pos": (p as Node3D).global_position,
"yaw": head.rotation.y if head != null else 0.0,
"pitch": pivot.rotation.x if pivot != null else 0.0}
_temp_look = false
_fly_modal = true
_apply_pointer()
build_submode_changed.emit(FLY)
## Leave the fly modal: keep the new view, or go back to where it started.
func exit_fly_modal(keep: bool) -> void:
if not _fly_modal:
return
if not keep and not _fly_from.is_empty():
var p := _local_player()
if p != null:
(p as Node3D).global_position = _fly_from["pos"]
var head: Node3D = p.get("camera_head")
var pivot: Node3D = p.get("camera_pivot")
if head != null:
head.rotation.y = float(_fly_from["yaw"])
if pivot != null:
pivot.rotation.x = float(_fly_from["pitch"])
# The synced copies too, or the player lerps the head back to the flown view.
var sync: Object = p.get("input_sync")
if sync != null:
sync.set("camera_rotation_y", float(_fly_from["yaw"]))
sync.set("camera_pitch", float(_fly_from["pitch"]))
_fly_modal = false
_apply_pointer()
build_submode_changed.emit(CURSOR)
## Our own body (only one is ever local), or null.
func _local_player() -> Node:
var players := get_tree().root.get_node_or_null("Game/Players")
if players == null:
return null
for p: Node in players.get_children():
if p.is_multiplayer_authority():
return p
return null
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
func _unhandled_key_input(event: InputEvent) -> void:
# `global` is an `always` context, so the way out of a mode is always reachable
# from inside it — a mode must not be able to trap you.
if Keymap.tapped(event, "global.mode_toggle"):
toggle()
get_viewport().set_input_as_handled()
return
# Keymap step 1 (§2a): Shift+F flies; inside, Enter / Space keep, Esc goes back.
if Keymap.tapped(event, "builder.fly"):
enter_fly_modal()
get_viewport().set_input_as_handled()
return
if _fly_modal:
if Keymap.tapped(event, "fly.confirm") or Keymap.tapped(event, "fly.confirm_alt"):
exit_fly_modal(true)
get_viewport().set_input_as_handled()
elif Keymap.tapped(event, "fly.cancel"):
exit_fly_modal(false)
get_viewport().set_input_as_handled()
## RMB-HOLD IS THE LOOK, and it is the only one.
##
## There was a persistent FLY sub-mode on its own key as well, on the assumption
## that holding a button to turn would be tiring. It is not — and two ways to do
## one thing is how the mode/fly pair drifted apart in the first place, which is
## the whole reason this file exists. So: build mode always has a cursor, and you
## hold RMB when you want to look. One key for the mode, one button for the look.
##
## A TAP IS NOT A LOOK (editor redesign E3d, user decision 2026-10-02): a press and
## release within TAP_MS with under TAP_PX of mouse motion emits look_tapped(at) — the
## level map opens its context menu on it. The look still starts and ends exactly as
## before (a tap is a very short look), so holding / dragging is unchanged. Only seen
## here when no tool is up: the map takes RMB first while an operator runs (cancel).
signal look_tapped(at: Vector2)
const TAP_MS := 250
const TAP_PX := 6.0
var _look_t0 := 0
var _look_moved := 0.0
var _look_at := Vector2.ZERO
## Look MOTION is counted here, before the GUI, and only read (never consumed): in
## _unhandled_input a panel under the cursor (a dock, the first-run card) swallows the
## motion, a drag then looks motionless, and releasing it popped the context menu.
func _input(event: InputEvent) -> void:
# THE FLY MODAL'S MOUSE BUTTONS, before anything else sees them — Blender's modal owns
# every event: LMB keeps the view, RMB goes back. Taken here (not _unhandled_input) so
# the confirming click never also selects something under the crosshair.
if _fly_modal and event is InputEventMouseButton and event.pressed:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT or mb.button_index == MOUSE_BUTTON_RIGHT:
exit_fly_modal(mb.button_index == MOUSE_BUTTON_LEFT)
get_viewport().set_input_as_handled()
return
if current == BUILD and _temp_look and event is InputEventMouseMotion:
_look_moved += (event as InputEventMouseMotion).relative.length()
## THE 3D CURSOR'S BUTTON (#109; Blender's left-select Shift+RMB): Shift+RMB places the
## cursor and a drag slides it, INSTEAD of starting the look. Decided at the press, so
## Shift pressed after RMB (fast fly while looking) is unchanged.
signal cursor_press(at: Vector2)
signal cursor_drag(at: Vector2)
signal cursor_release(at: Vector2)
var _cursor_drag := false
func _unhandled_input(event: InputEvent) -> void:
if current != BUILD:
return
if _cursor_drag:
if event is InputEventMouseMotion:
cursor_drag.emit((event as InputEventMouseMotion).position)
get_viewport().set_input_as_handled()
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and not event.pressed:
_cursor_drag = false
cursor_release.emit((event as InputEventMouseButton).position)
get_viewport().set_input_as_handled()
return
if not _temp_look and Keymap.tapped(event, "builder.cursor_place"):
_cursor_drag = true
cursor_press.emit((event as InputEventMouseButton).position)
get_viewport().set_input_as_handled()
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
if _temp_look == event.pressed:
return
_temp_look = event.pressed
if _temp_look:
_look_t0 = Time.get_ticks_msec()
_look_moved = 0.0
_look_at = (event as InputEventMouseButton).position
_apply_pointer()
build_submode_changed.emit(FLY if _temp_look else CURSOR)
get_viewport().set_input_as_handled()
if not _temp_look and Time.get_ticks_msec() - _look_t0 <= TAP_MS and _look_moved < TAP_PX:
look_tapped.emit(_look_at)
func wants_pointer() -> bool:
return current == BUILD and not _temp_look and not _fly_modal
func is_cursor() -> bool:
return wants_pointer()
func _apply_pointer() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if wants_pointer() else Input.MOUSE_MODE_CAPTURED
func toggle() -> void:
set_mode(PLAY if current == BUILD else BUILD)
func set_mode(mode: String) -> void:
if mode == current:
return # idempotent: a mode is a value, not a parity
if mode != PLAY and not MODE_CONTEXT.has(mode):
push_warning("[GameMode] unknown mode %s" % mode)
return
# Leave the old context before entering the new one, so two exclusive contexts
# are never on the stack together.
if MODE_CONTEXT.has(current):
Keymap.pop_context(MODE_CONTEXT[current])
current = mode
if MODE_CONTEXT.has(mode):
Keymap.push_context(MODE_CONTEXT[mode])
# Entering build gives you the cursor; leaving hands the pointer back to the game.
# A stale _temp_look would otherwise survive a mode change as a captured mouse
# nobody asked for.
_temp_look = false
_fly_modal = false # leaving build ends a fly modal (and pops `fly`)
_apply_pointer()
_apply_to_player(mode == BUILD)
mode_changed.emit(mode)
if mode == BUILD:
build_submode_changed.emit(CURSOR)
print("[GameMode] mode -> %s (contexts %s)" % [mode, str(Keymap.active_contexts())])
func is_building() -> bool:
return current == BUILD
## Ask the local player to fly, or stop. Duck-typed on purpose: there are two player
## scripts and only the procedural one has a FLY state, so this must degrade to
## "nothing happens" rather than to an error — the mode itself is still valid, you
## simply keep your legs.
func _apply_to_player(fly: bool) -> void:
var players := get_tree().root.get_node_or_null("Game/Players")
if players == null:
return
for p: Node in players.get_children():
if not p.is_multiplayer_authority():
continue # only ever our own body
if p.has_method("set_fly_mode"):
p.set_fly_mode(fly)
return