Add Split Screen Demo showing input handling (#1023)

* Add Split Screen Demo showing input handling

* Style fixes, layout improvements, update to Godot 4.5

---------

Co-authored-by: Aaron Franke <arnfranke@yahoo.com>
This commit is contained in:
Markus Sauermann
2025-10-02 07:56:48 +02:00
committed by GitHub
parent 0cff177d59
commit 10eb2807b8
16 changed files with 409 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# Split Screen Input
A demo showing a Split Screen GUI and input handling for local multiplayer using viewports.
It demonstrates:
- Single World2D, that is shared among many Viewports
- Simplified Input Map, that uses the same Actions for all Split Screens
- Input event routing to different viewports based on joypad device id and dedicated keyboard keys
- Dynamic keybinding adjustment for each Split Screen
Language: GDScript
Renderer: Compatibility
## Screenshots
![Screenshot](screenshots/split_screen_input.webp)

View File

@@ -0,0 +1 @@
<svg height="128" width="128" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="2" width="124" height="124" rx="14" fill="#363d52" stroke="#212532" stroke-width="4"/><g transform="scale(.101) translate(122 122)"><g fill="#fff"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 813 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H447l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c3 34 55 34 58 0v-86c-3-34-55-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></g></svg>

After

Width:  |  Height:  |  Size: 950 B

View File

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ci5b7o7h2bmj0"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View File

@@ -0,0 +1,28 @@
class_name Player
extends CharacterBody2D
## Player implementation.
const factor: float = 200.0 # Factor to multiply the movement.
var _movement: Vector2 = Vector2(0, 0) # Current movement rate of node.
# Update movement variable based on input that reaches this SubViewport.
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ux_up") or event.is_action_released("ux_down"):
_movement.y -= 1
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ux_down") or event.is_action_released("ux_up"):
_movement.y += 1
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ux_left") or event.is_action_released("ux_right"):
_movement.x -= 1
get_viewport().set_input_as_handled()
elif event.is_action_pressed("ux_right") or event.is_action_released("ux_left"):
_movement.x += 1
get_viewport().set_input_as_handled()
# Move the node based on the content of the movement variable.
func _physics_process(delta: float) -> void:
move_and_collide(_movement * factor * delta)

View File

@@ -0,0 +1 @@
uid://b7p2tpqjka6jq

View File

@@ -0,0 +1,88 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="Split Screen Input"
config/tags=PackedStringArray("demo", "input", "official", "rendering")
run/main_scene="res://split_screen_demo.tscn"
config/features=PackedStringArray("4.5")
config/icon="res://icon.svg"
[display]
window/size/viewport_width=800
window/size/viewport_height=800
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
[input]
ui_left={
"deadzone": 0.2,
"events": []
}
ui_right={
"deadzone": 0.2,
"events": []
}
ui_up={
"deadzone": 0.2,
"events": []
}
ui_down={
"deadzone": 0.2,
"events": []
}
ux_left={
"deadzone": 0.2,
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194442,"key_label":0,"unicode":52,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":13,"pressure":0.0,"pressed":false,"script":null)
]
}
ux_right={
"deadzone": 0.2,
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":76,"key_label":0,"unicode":108,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194444,"key_label":0,"unicode":54,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":14,"pressure":0.0,"pressed":false,"script":null)
]
}
ux_up={
"deadzone": 0.2,
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194446,"key_label":0,"unicode":56,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
]
}
ux_down={
"deadzone": 0.2,
"events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194443,"key_label":0,"unicode":53,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":12,"pressure":0.0,"pressed":false,"script":null)
]
}
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"

View File

@@ -0,0 +1,46 @@
## Set up different Split Screens
## Provide Input configuration
## Connect Split Screens to Play Area
extends Node
const KEYBOARD_OPTIONS: Dictionary[String, Dictionary] = {
"wasd": {"keys": [KEY_W, KEY_A, KEY_S, KEY_D]},
"ijkl": {"keys": [KEY_I, KEY_J, KEY_K, KEY_L]},
"arrows": {"keys": [KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN]},
"numpad": {"keys": [KEY_KP_4, KEY_KP_5, KEY_KP_6, KEY_KP_8]},
} # 4 keyboard sets for moving players around.
const PLAYER_COLORS: Array[Color] = [
Color.WHITE,
Color("ff8f02"),
Color("05ff5a"),
Color("ff05a0")
] # Modulate Colors of each Player.
var config: Dictionary = {
"keyboard": KEYBOARD_OPTIONS,
"joypads": 4,
"world": null,
"position": Vector2(),
"index": -1,
"color": Color(),
} # Split Screen configuration Dictionary.
@onready var play_area: SubViewport = $PlayArea # The central Viewport, all Split Screens are sharing.
# Initialize each Split Screen and each player node.
func _ready() -> void:
config["world"] = play_area.world_2d
var children: Array[Node] = get_children()
var index: int = 0
for child: Node in children:
if child is SplitScreen:
config["position"] = Vector2(index % 2, floor(index / 2.0)) * 132 + Vector2(132, 0)
config["index"] = index
config["color"] = PLAYER_COLORS[index]
var split_child: SplitScreen = child as SplitScreen
split_child.set_config(config)
index += 1

View File

@@ -0,0 +1 @@
uid://bdikev0kwlu1g

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,40 @@
## Interface for a SplitScreen
class_name SplitScreen
extends Node
const JOYPAD_PREFIX: String = "Joypad"
@export var init_position := Vector2.ZERO
var _keyboard_options: Dictionary # Copy of all keyboard options.
@onready var opt: OptionButton = $OptionButton
@onready var viewport: SubViewport = $InputRoutingViewportContainer/SubViewport
@onready var input_router: InputRoutingViewportContainer = $InputRoutingViewportContainer
@onready var play: Player = $InputRoutingViewportContainer/SubViewport/Player
# Set the configuration of this split screen and perform OptionButton initialization.
func set_config(config_dict: Dictionary):
_keyboard_options = config_dict["keyboard"]
play.position = config_dict["position"]
var local_index: int = config_dict["index"]
play.modulate = config_dict["color"]
opt.clear()
for keyboard_opt in _keyboard_options:
opt.add_item(keyboard_opt)
for index in config_dict["joypads"]:
opt.add_item("%s %s" % [JOYPAD_PREFIX, index + 1])
opt.select(local_index)
_on_option_button_item_selected(local_index)
viewport.world_2d = config_dict["world"] # Connect all Split Screens to the same World2D.
# Update Keyboard Settings after selecting them in the OptionButton.
func _on_option_button_item_selected(index: int) -> void:
var text: String = opt.get_item_text(index)
if text.begins_with(JOYPAD_PREFIX):
input_router.set_input_config({"joypad": text.substr(text.length() - 1, -1).to_int(), "keyboard": []})
else:
input_router.set_input_config({"keyboard": _keyboard_options[text]["keys"], "joypad": -1})

View File

@@ -0,0 +1 @@
uid://crrvxnm6s4ssm

View File

@@ -0,0 +1,42 @@
[gd_scene load_steps=6 format=3 uid="uid://dqailbm8vcpf5"]
[ext_resource type="Script" uid="uid://crrvxnm6s4ssm" path="res://split_screen.gd" id="1_4fp0b"]
[ext_resource type="Script" uid="uid://mplfqw0th285" path="res://sub_viewport_container.gd" id="2_v8t84"]
[ext_resource type="Texture2D" uid="uid://ci5b7o7h2bmj0" path="res://icon.svg" id="4_787wn"]
[ext_resource type="Script" uid="uid://b7p2tpqjka6jq" path="res://player.gd" id="5_1qhfw"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_m48mh"]
size = Vector2(128, 128)
[node name="Split" type="VBoxContainer"]
auto_translate_mode = 1
offset_right = 350.0
offset_bottom = 374.0
script = ExtResource("1_4fp0b")
[node name="OptionButton" type="OptionButton" parent="."]
auto_translate_mode = 1
layout_mode = 2
[node name="InputRoutingViewportContainer" type="SubViewportContainer" parent="."]
auto_translate_mode = 1
layout_mode = 2
script = ExtResource("2_v8t84")
[node name="SubViewport" type="SubViewport" parent="InputRoutingViewportContainer"]
handle_input_locally = false
size = Vector2i(350, 350)
render_target_update_mode = 4
[node name="Player" type="CharacterBody2D" parent="InputRoutingViewportContainer/SubViewport"]
script = ExtResource("5_1qhfw")
[node name="CollisionShape2D" type="CollisionShape2D" parent="InputRoutingViewportContainer/SubViewport/Player"]
shape = SubResource("RectangleShape2D_m48mh")
[node name="Sprite2D" type="Sprite2D" parent="InputRoutingViewportContainer/SubViewport/Player"]
texture = ExtResource("4_787wn")
[node name="Camera2D" type="Camera2D" parent="InputRoutingViewportContainer/SubViewport/Player"]
[connection signal="item_selected" from="OptionButton" to="." method="_on_option_button_item_selected"]

View File

@@ -0,0 +1,73 @@
[gd_scene load_steps=3 format=3 uid="uid://ccutmhshaoqih"]
[ext_resource type="Script" uid="uid://bdikev0kwlu1g" path="res://root.gd" id="1_2itit"]
[ext_resource type="PackedScene" uid="uid://dqailbm8vcpf5" path="res://split_screen.tscn" id="1_mcbdt"]
[node name="Node" type="Node"]
script = ExtResource("1_2itit")
[node name="Panel" type="Panel" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="SplitScreen1" parent="." instance=ExtResource("1_mcbdt")]
anchors_preset = -1
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -375.0
offset_top = -387.0
offset_right = -25.0
offset_bottom = -13.0
grow_horizontal = 2
grow_vertical = 2
[node name="SplitScreen2" parent="." instance=ExtResource("1_mcbdt")]
anchors_preset = -1
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = 25.0
offset_top = -387.0
offset_right = 375.0
offset_bottom = -13.0
grow_horizontal = 2
grow_vertical = 2
init_position = Vector2(132, 0)
[node name="SplitScreen3" parent="." instance=ExtResource("1_mcbdt")]
anchors_preset = -1
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -375.0
offset_top = 13.0
offset_right = -25.0
offset_bottom = 387.0
grow_horizontal = 2
grow_vertical = 2
init_position = Vector2(0, 132)
[node name="SplitScreen4" parent="." instance=ExtResource("1_mcbdt")]
anchors_preset = -1
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = 25.0
offset_top = 13.0
offset_right = 375.0
offset_bottom = 387.0
grow_horizontal = 2
grow_vertical = 2
init_position = Vector2(132, 132)
[node name="PlayArea" type="SubViewport" parent="."]
render_target_update_mode = 4

View File

@@ -0,0 +1,27 @@
## Input Routing for different SubViewports.
## Based on the provided input configuration, ensures only the correct
## events reaching the SubViewport.
class_name InputRoutingViewportContainer
extends SubViewportContainer
var _current_keyboard_set: Array = [] # Currently used keyboard set.
var _current_joypad_device: int = -1 # Currently used joypad device id.
# Make sure, that only the events are sent to the SubViewport,
# that are allowed via the OptionButton selection.
func _propagate_input_event(input_event: InputEvent) -> bool:
if input_event is InputEventKey:
if _current_keyboard_set.has(input_event.keycode):
return true
elif input_event is InputEventJoypadButton:
if _current_joypad_device > -1 and input_event.device == _current_joypad_device:
return true
return false
# Set new config for input handling.
func set_input_config(config_dict: Dictionary):
_current_keyboard_set = config_dict["keyboard"]
_current_joypad_device = config_dict["joypad"]

View File

@@ -0,0 +1 @@
uid://mplfqw0th285