gmp(M02): add player input intent seam

Work-Package: M02-GMP-INPUT-001
Agent: sindo-main-codex
Tests: verify_player_input; renderer baseline dry-run; coordinate, streaming, documentation and coordination gates
Fidelity: preserves current sandbox bindings and movement/camera behavior; exact build-12340 semantics remain unverified
This commit is contained in:
2026-07-14 22:44:44 +04:00
parent 62eece991c
commit 6bd2e84048
13 changed files with 687 additions and 40 deletions
+47
View File
@@ -0,0 +1,47 @@
class_name MoveIntent
extends RefCounted
## Immutable player locomotion request expressed in character-local axes.
## Axis values are normalized to `[-1, 1]`: forward/right/up are positive.
## The intent contains no world coordinates, engine input events or presentation state.
var forward_axis: float:
get:
return _forward_axis
var strafe_axis: float:
get:
return _strafe_axis
var vertical_axis: float:
get:
return _vertical_axis
var is_sprint_requested: bool:
get:
return _is_sprint_requested
var _forward_axis: float
var _strafe_axis: float
var _vertical_axis: float
var _is_sprint_requested: bool
## Creates one frame's movement request without retaining mutable input state.
func _init(
forward_axis_value: float = 0.0,
strafe_axis_value: float = 0.0,
vertical_axis_value: float = 0.0,
sprint_requested: bool = false
) -> void:
_forward_axis = clampf(forward_axis_value, -1.0, 1.0)
_strafe_axis = clampf(strafe_axis_value, -1.0, 1.0)
_vertical_axis = clampf(vertical_axis_value, -1.0, 1.0)
_is_sprint_requested = sprint_requested
## Returns true when the intent requests planar or vertical translation.
func has_translation() -> bool:
return not is_zero_approx(_forward_axis) \
or not is_zero_approx(_strafe_axis) \
or not is_zero_approx(_vertical_axis)
+1
View File
@@ -0,0 +1 @@
uid://btk7brcavbeec
@@ -0,0 +1,34 @@
class_name PlayerInputActions
extends RefCounted
## Stable Input Map action names consumed by the runtime player input adapter.
## Defaults preserve the current render-sandbox controls; users may remap every
## action through Godot's Input Map without changing gameplay code.
const MOVE_FORWARD := &"openwc_player_move_forward"
const MOVE_BACKWARD := &"openwc_player_move_backward"
const STRAFE_LEFT := &"openwc_player_strafe_left"
const STRAFE_RIGHT := &"openwc_player_strafe_right"
const DEBUG_FLY_UP := &"openwc_player_debug_fly_up"
const DEBUG_FLY_DOWN := &"openwc_player_debug_fly_down"
const DEBUG_SPRINT := &"openwc_player_debug_sprint"
const DEBUG_TOGGLE_FLIGHT := &"openwc_player_debug_toggle_flight"
const CAMERA_ROTATE := &"openwc_player_camera_rotate"
const CAMERA_ZOOM_IN := &"openwc_player_camera_zoom_in"
const CAMERA_ZOOM_OUT := &"openwc_player_camera_zoom_out"
const RELEASE_CURSOR := &"openwc_player_release_cursor"
const REQUIRED_ACTIONS: Array[StringName] = [
MOVE_FORWARD,
MOVE_BACKWARD,
STRAFE_LEFT,
STRAFE_RIGHT,
DEBUG_FLY_UP,
DEBUG_FLY_DOWN,
DEBUG_SPRINT,
DEBUG_TOGGLE_FLIGHT,
CAMERA_ROTATE,
CAMERA_ZOOM_IN,
CAMERA_ZOOM_OUT,
RELEASE_CURSOR,
]
@@ -0,0 +1 @@
uid://bbhtyxm428bkm
+45
View File
@@ -0,0 +1,45 @@
class_name PlayerInputSource
extends RefCounted
## Converts remappable Godot Input Map actions into immutable [MoveIntent] values.
## This adapter owns no movement state and may be replaced independently of the
## local movement controller.
## Samples the current Input singleton state for one physics tick.
func sample_move_intent() -> MoveIntent:
return compose_move_intent(
Input.get_action_strength(PlayerInputActions.MOVE_FORWARD),
Input.get_action_strength(PlayerInputActions.MOVE_BACKWARD),
Input.get_action_strength(PlayerInputActions.STRAFE_LEFT),
Input.get_action_strength(PlayerInputActions.STRAFE_RIGHT),
Input.get_action_strength(PlayerInputActions.DEBUG_FLY_UP),
Input.get_action_strength(PlayerInputActions.DEBUG_FLY_DOWN),
Input.is_action_pressed(PlayerInputActions.DEBUG_SPRINT)
)
## Builds a normalized intent from action strengths for deterministic tests and
## alternative input adapters. Strengths outside `[0, 1]` are clamped.
static func compose_move_intent(
forward_strength: float,
backward_strength: float,
strafe_left_strength: float,
strafe_right_strength: float,
fly_up_strength: float,
fly_down_strength: float,
sprint_requested: bool
) -> MoveIntent:
var planar_axis := Vector2(
clampf(strafe_right_strength, 0.0, 1.0) - clampf(strafe_left_strength, 0.0, 1.0),
clampf(forward_strength, 0.0, 1.0) - clampf(backward_strength, 0.0, 1.0)
)
if planar_axis.length_squared() > 1.0:
planar_axis = planar_axis.normalized()
var vertical_axis := clampf(fly_up_strength, 0.0, 1.0) - clampf(fly_down_strength, 0.0, 1.0)
return MoveIntent.new(
planar_axis.y,
planar_axis.x,
vertical_axis,
sprint_requested
)
@@ -0,0 +1 @@
uid://b050i2lnei8qq
@@ -7,6 +7,8 @@ const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordina
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
const PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const UNIT_SIZE := CHUNK_SIZE / 8.0
@@ -55,9 +57,11 @@ var _pitch := deg_to_rad(-18.0)
var _horizontal_velocity := Vector3.ZERO
var _flight_enabled := false
var _adt_cache: Dictionary = {}
var _player_input_source: PlayerInputSource
func _ready() -> void:
_player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new()
_camera_pivot = get_node_or_null(camera_pivot_path) as Node3D
_camera = get_node_or_null(camera_path) as Camera3D
_visual = get_node_or_null(visual_path) as Node3D
@@ -79,19 +83,22 @@ func _ready() -> void:
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
_captured = event.pressed
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance)
_apply_camera_transform()
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance)
_apply_camera_transform()
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE):
_captured = true
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
elif event.is_action_released(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE):
_captured = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_SPACE:
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_IN):
camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance)
_apply_camera_transform()
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_OUT):
camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance)
_apply_camera_transform()
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.RELEASE_CURSOR):
_captured = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
_flight_enabled = not _flight_enabled
_horizontal_velocity = Vector3.ZERO
@@ -103,12 +110,12 @@ func _unhandled_input(event: InputEvent) -> void:
func _physics_process(delta: float) -> void:
var input_dir := _get_input_dir()
var move_intent := _player_input_source.sample_move_intent()
var target_velocity := Vector3.ZERO
if input_dir.length_squared() > 0.0:
target_velocity = _movement_vector(input_dir)
if move_intent.has_translation():
target_velocity = _movement_vector(move_intent)
if _flight_enabled:
target_velocity.y += _flight_vertical_velocity()
target_velocity.y += move_intent.vertical_axis * flight_vertical_speed * _debug_speed_multiplier(move_intent)
_horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta)
global_position += _horizontal_velocity * delta
@@ -128,20 +135,7 @@ func _physics_process(delta: float) -> void:
_apply_camera_transform()
func _get_input_dir() -> Vector2:
var dir := Vector2.ZERO
if Input.is_key_pressed(KEY_W):
dir.y -= 1.0
if Input.is_key_pressed(KEY_S):
dir.y += 1.0
if Input.is_key_pressed(KEY_A):
dir.x -= 1.0
if Input.is_key_pressed(KEY_D):
dir.x += 1.0
return dir.normalized()
func _movement_vector(input_dir: Vector2) -> Vector3:
func _movement_vector(move_intent: MoveIntent) -> Vector3:
var forward := -global_basis.z
var right := global_basis.x
if _flight_enabled and _camera_pivot:
@@ -153,20 +147,16 @@ func _movement_vector(input_dir: Vector2) -> Vector3:
forward = forward.normalized()
right = right.normalized()
var speed_z := run_speed if input_dir.y < 0.0 else backward_speed
var speed_z := run_speed if move_intent.forward_axis > 0.0 else backward_speed
var speed_x := strafe_speed
var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0
return (forward * -input_dir.y * speed_z + right * input_dir.x * speed_x) * sprint
return (
forward * move_intent.forward_axis * speed_z
+ right * move_intent.strafe_axis * speed_x
) * _debug_speed_multiplier(move_intent)
func _flight_vertical_velocity() -> float:
var dir := 0.0
if Input.is_key_pressed(KEY_E):
dir += 1.0
if Input.is_key_pressed(KEY_Q):
dir -= 1.0
var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0
return dir * flight_vertical_speed * sprint
func _debug_speed_multiplier(move_intent: MoveIntent) -> float:
return sprint_multiplier if move_intent.is_sprint_requested else 1.0
func _load_character_visual() -> void:
@@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://src/scenes/player/third_person_wow_controller.gd" id="1_player"]
[node name="PlayerInputRegression" type="CharacterBody3D"]
script = ExtResource("1_player")
spawn_at_tile_center = false
character_model_path = ""
[node name="Visual" type="Node3D" parent="."]
[node name="CameraPivot" type="Node3D" parent="."]
[node name="Camera3D" type="Camera3D" parent="CameraPivot"]
+161
View File
@@ -0,0 +1,161 @@
extends SceneTree
## Headless M02 contract regression for remappable actions and MoveIntent composition.
const MOVE_INTENT_PATH := "res://src/domain/input/move_intent.gd"
const PLAYER_INPUT_SOURCE_PATH := "res://src/gameplay/input/player_input_source.gd"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
_verify_default_input_actions(failures)
_verify_move_intent_composition(failures)
_verify_controller_boundary(failures)
_verify_regression_scene(failures)
if not failures.is_empty():
for failure in failures:
push_error("PLAYER_INPUT: %s" % failure)
quit(1)
return
print("PLAYER_INPUT PASS actions=%d intent_cases=6 controller=1 regression_scene=1" % PlayerInputActions.REQUIRED_ACTIONS.size())
quit(0)
func _verify_default_input_actions(failures: Array[String]) -> void:
for action_name in PlayerInputActions.REQUIRED_ACTIONS:
_expect_true(InputMap.has_action(action_name), "Input Map contains %s" % action_name, failures)
_expect_key_binding(PlayerInputActions.MOVE_FORWARD, KEY_W, true, failures)
_expect_key_binding(PlayerInputActions.MOVE_BACKWARD, KEY_S, true, failures)
_expect_key_binding(PlayerInputActions.STRAFE_LEFT, KEY_A, true, failures)
_expect_key_binding(PlayerInputActions.STRAFE_RIGHT, KEY_D, true, failures)
_expect_key_binding(PlayerInputActions.DEBUG_FLY_UP, KEY_E, true, failures)
_expect_key_binding(PlayerInputActions.DEBUG_FLY_DOWN, KEY_Q, true, failures)
_expect_key_binding(PlayerInputActions.DEBUG_SPRINT, KEY_SHIFT, false, failures)
_expect_key_binding(PlayerInputActions.DEBUG_TOGGLE_FLIGHT, KEY_SPACE, true, failures)
_expect_key_binding(PlayerInputActions.RELEASE_CURSOR, KEY_ESCAPE, false, failures)
_expect_mouse_binding(PlayerInputActions.CAMERA_ROTATE, MOUSE_BUTTON_RIGHT, failures)
_expect_mouse_binding(PlayerInputActions.CAMERA_ZOOM_IN, MOUSE_BUTTON_WHEEL_UP, failures)
_expect_mouse_binding(PlayerInputActions.CAMERA_ZOOM_OUT, MOUSE_BUTTON_WHEEL_DOWN, failures)
func _verify_move_intent_composition(failures: Array[String]) -> void:
var neutral := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false)
_expect_near(neutral.forward_axis, 0.0, "neutral forward", failures)
_expect_true(not neutral.has_translation(), "neutral has no translation", failures)
var forward := PlayerInputSource.compose_move_intent(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, false)
_expect_near(forward.forward_axis, 1.0, "forward axis", failures)
var canceled := PlayerInputSource.compose_move_intent(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, false)
_expect_true(not canceled.has_translation(), "opposite actions cancel", failures)
var diagonal := PlayerInputSource.compose_move_intent(1.0, 0.0, 0.0, 1.0, 0.0, 0.0, false)
_expect_near(Vector2(diagonal.strafe_axis, diagonal.forward_axis).length(), 1.0, "diagonal normalized", failures)
var vertical := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 1.5, -0.5, false)
_expect_near(vertical.vertical_axis, 1.0, "vertical strengths clamped", failures)
var debug_requests := PlayerInputSource.compose_move_intent(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, true)
_expect_true(debug_requests.is_sprint_requested, "sprint request retained", failures)
func _verify_controller_boundary(failures: Array[String]) -> void:
var controller_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
_expect_true(controller_source.contains("sample_move_intent()"), "controller samples PlayerInputSource", failures)
_expect_true(not controller_source.contains("Input.is_key_pressed"), "controller omits direct keyboard polling", failures)
_expect_true(not controller_source.contains("KEY_"), "controller omits hardcoded key constants", failures)
var move_intent_source := _read_text(MOVE_INTENT_PATH, failures)
_expect_true(not move_intent_source.contains("extends Node"), "MoveIntent does not inherit Node", failures)
_expect_true(not move_intent_source.contains("extends Resource"), "MoveIntent does not inherit Resource", failures)
_expect_true(not move_intent_source.contains("Input."), "MoveIntent does not read engine input", failures)
var input_source := _read_text(PLAYER_INPUT_SOURCE_PATH, failures)
_expect_true(input_source.contains("Input.get_action_strength"), "input source reads action strengths", failures)
_expect_true(not input_source.contains("is_key_pressed"), "input source omits physical-key polling", failures)
func _verify_regression_scene(failures: Array[String]) -> void:
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
_expect_true(packed_scene != null, "regression scene loads", failures)
if packed_scene == null:
return
var player := packed_scene.instantiate() as CharacterBody3D
_expect_true(player != null, "regression player instantiates", failures)
if player == null:
return
root.add_child(player)
player.set_physics_process(false)
Input.action_press(PlayerInputActions.MOVE_FORWARD)
player.call("_physics_process", 1.0)
Input.action_release(PlayerInputActions.MOVE_FORWARD)
_expect_near(player.position.z, -7.0, "forward action moves current sandbox controller", failures)
var camera_distance_before: float = player.get("camera_distance")
var zoom_event := InputEventMouseButton.new()
zoom_event.button_index = MOUSE_BUTTON_WHEEL_UP
zoom_event.pressed = true
player.call("_unhandled_input", zoom_event)
_expect_near(player.get("camera_distance"), camera_distance_before - 1.0, "zoom action updates camera distance", failures)
var flight_toggle_event := InputEventAction.new()
flight_toggle_event.action = PlayerInputActions.DEBUG_TOGGLE_FLIGHT
flight_toggle_event.pressed = true
player.call("_unhandled_input", flight_toggle_event)
_expect_true(player.get("_flight_enabled"), "flight action preserves immediate sandbox toggle", failures)
player.free()
func _expect_key_binding(
action_name: StringName,
expected_keycode: Key,
uses_physical_keycode: bool,
failures: Array[String]
) -> void:
for input_event in InputMap.action_get_events(action_name):
if input_event is InputEventKey:
var key_event := input_event as InputEventKey
var configured_keycode := key_event.physical_keycode if uses_physical_keycode else key_event.keycode
if configured_keycode == expected_keycode:
return
failures.append("%s missing expected key binding %s" % [action_name, expected_keycode])
func _expect_mouse_binding(
action_name: StringName,
expected_button: MouseButton,
failures: Array[String]
) -> void:
for input_event in InputMap.action_get_events(action_name):
if input_event is InputEventMouseButton and input_event.button_index == expected_button:
return
failures.append("%s missing expected mouse binding %s" % [action_name, expected_button])
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
if absf(actual_value - expected_value) > 0.000001:
failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)