OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / game_mode.gd

311 lines · gd ref:
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