destroyulator/scripts/Player.gd
Monster Robot Party a8b735f79e Destroyulator prototype: first-person cascade smasher (Godot 4.7)
Mac-native (Apple Silicon / Metal), Jolt physics.
- First-person controller + semi-opaque viewmodel hands
- Punch (L-click) / kick (R-click) via a swappable MeleeAttack resource
- Structural support-graph cascade: knock the legs -> the rack pancakes
- Brittle vinyl records shatter on hard impact
- Day-0 stress gate (B: rain 500) — holds 60fps on the M3 Ultra

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:25:35 +10:00

247 lines
8.5 KiB
GDScript

extends CharacterBody3D
class_name Player
## First-person player for Destroyulator. Built entirely in code so Main.gd can
## spawn it where the old fixed Camera3D was and reuse `player.camera` for the
## crosshair raycast. Mouse-captured look, WASD move, Space jump; L-click PUNCH,
## R-click KICK; Esc releases the mouse (click recaptures).
##
## Melee is a swappable MeleeAttack resource (reach/radius/impulse/knockback/cooldown)
## so real weapons slot in by swapping the resource + viewmodel — movement never
## changes. Hits are fired from screen-center (the mouse is captured, so its position
## is meaningless) and resolved in _physics_process, where direct_space_state is valid.
##
## Godot 4.7 GDScript 2.0. No 3.x APIs: move_and_slide() takes no args and reads/writes
## `velocity`; get_gravity() supplies the project default; mouse_mode is a property.
@export var move_speed: float = 5.0
@export var accel: float = 12.0
@export var air_accel: float = 3.0
@export var jump_velocity: float = 4.5
@export var mouse_sensitivity: float = 0.0025 # radians per pixel of mouse motion
@export var pitch_limit_deg: float = 89.0
@export var eye_height: float = 1.6
var camera: Camera3D # Main reads this for the crosshair raycast
var _head: Node3D
var _hands_rig: Node3D # cosmetic viewmodel (hands + leg), swung on attack
var _r_hand: MeshInstance3D
var _l_hand: MeshInstance3D
var _leg: MeshInstance3D
var _swing_tween: Tween
var _pitch := 0.0
var _cd_left := 0.0
var _queued: MeleeAttack = null # buffered click, consumed next physics step
var _punch: MeleeAttack
var _kick: MeleeAttack
# resting local transforms of the viewmodel parts (relative to the camera)
const R_HAND_REST := Vector3(0.30, -0.32, -0.55)
const L_HAND_REST := Vector3(-0.30, -0.34, -0.58)
const LEG_REST := Vector3(0.06, -1.05, -0.42)
func _ready() -> void:
# --- collision capsule (feet at the body origin) ---
var col := CollisionShape3D.new()
var cap := CapsuleShape3D.new()
cap.radius = 0.35
cap.height = 1.8
col.shape = cap
col.position = Vector3(0, 0.9, 0) # capsule center at height/2 so feet sit at origin
add_child(col)
# --- head (pitch) + camera ---
_head = Node3D.new()
_head.name = "Head"
_head.position = Vector3(0, eye_height, 0)
add_child(_head)
camera = Camera3D.new()
camera.name = "Camera3D"
_head.add_child(camera)
camera.current = true
_build_viewmodel()
_build_crosshair()
_make_attacks()
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
# ---------------------------------------------------------------- attacks
func _make_attacks() -> void:
_punch = MeleeAttack.new()
_punch.reach = 1.7
_punch.radius = 0.34
_punch.impulse = 6.0
_punch.knockback = 2.5
_punch.cooldown = 0.26
_kick = MeleeAttack.new()
_kick.reach = 2.2
_kick.radius = 0.42
_kick.impulse = 10.0
_kick.knockback = 5.0
_kick.cooldown = 0.48
# ---------------------------------------------------------------- viewmodel
func _build_viewmodel() -> void:
_hands_rig = Node3D.new()
_hands_rig.name = "ViewModel"
camera.add_child(_hands_rig)
var mat := StandardMaterial3D.new()
# semi-opaque: BOTH the alpha mode AND alpha<1 are required (alpha alone renders opaque)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(0.86, 0.62, 0.5, 0.55)
mat.no_depth_test = true # hands draw over world geo, never clip
mat.render_priority = 1
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.roughness = 0.85
_r_hand = _make_capsule(mat)
_l_hand = _make_capsule(mat)
_leg = _make_leg(mat)
_hands_rig.add_child(_r_hand)
_hands_rig.add_child(_l_hand)
_hands_rig.add_child(_leg)
_r_hand.position = R_HAND_REST
_l_hand.position = L_HAND_REST
_leg.position = LEG_REST
_r_hand.rotation = Vector3(deg_to_rad(-70), 0.0, deg_to_rad(-12))
_l_hand.rotation = Vector3(deg_to_rad(-70), 0.0, deg_to_rad(12))
_leg.rotation = Vector3(deg_to_rad(-90), 0.0, 0.0)
func _make_capsule(mat: StandardMaterial3D) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var cap := CapsuleMesh.new()
cap.radius = 0.055
cap.height = 0.20 # full height incl. the hemispherical caps
mi.mesh = cap
mi.material_override = mat
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
return mi
func _make_leg(mat: StandardMaterial3D) -> MeshInstance3D:
var shin := MeshInstance3D.new()
var cap := CapsuleMesh.new()
cap.radius = 0.075
cap.height = 0.34
shin.mesh = cap
shin.material_override = mat
shin.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var foot := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(0.11, 0.07, 0.22)
foot.mesh = box
foot.material_override = mat
foot.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
foot.position = Vector3(0.0, -0.20, 0.09)
shin.add_child(foot)
return shin
func _build_crosshair() -> void:
var layer := CanvasLayer.new()
layer.name = "CrosshairLayer"
add_child(layer)
var dot := ColorRect.new()
dot.color = Color(1, 1, 1, 0.85)
dot.anchor_left = 0.5
dot.anchor_top = 0.5
dot.anchor_right = 0.5
dot.anchor_bottom = 0.5
dot.offset_left = -2
dot.offset_top = -2
dot.offset_right = 2
dot.offset_bottom = 2
layer.add_child(dot)
# ---------------------------------------------------------------- input
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * mouse_sensitivity) # yaw on the body only
_pitch = clamp(
_pitch - event.relative.y * mouse_sensitivity,
deg_to_rad(-pitch_limit_deg), deg_to_rad(pitch_limit_deg))
_head.rotation.x = _pitch # pitch on the head only
return
if event is InputEventMouseButton and event.pressed:
if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # click recaptures; this one isn't an attack
return
if event.button_index == MOUSE_BUTTON_LEFT:
_queue_attack(_punch)
elif event.button_index == MOUSE_BUTTON_RIGHT:
_queue_attack(_kick)
return
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_ESCAPE:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func _queue_attack(atk: MeleeAttack) -> void:
# buffer the click; the hit resolves in _physics_process where the space state is valid.
if _cd_left <= 0.0:
_queued = atk
# ---------------------------------------------------------------- physics / movement
func _physics_process(delta: float) -> void:
_cd_left = max(0.0, _cd_left - delta)
# resolve a buffered melee swing
if _queued != null and _cd_left <= 0.0:
var atk := _queued
_queued = null
_cd_left = atk.cooldown
var origin := camera.global_position
var dir := -camera.global_transform.basis.z # camera forward = crosshair
atk.strike(self, origin, dir, [get_rid()])
_swing_viewmodel(atk == _kick)
# gravity (only off the floor, or it accumulates and you rocket downward)
if not is_on_floor():
velocity += get_gravity() * delta
if Input.is_key_pressed(KEY_SPACE) and is_on_floor():
velocity.y = jump_velocity
var input_dir := Vector2(
float(Input.is_key_pressed(KEY_D)) - float(Input.is_key_pressed(KEY_A)),
float(Input.is_key_pressed(KEY_S)) - float(Input.is_key_pressed(KEY_W)))
var wish := transform.basis * Vector3(input_dir.x, 0.0, input_dir.y)
wish.y = 0.0
if wish.length() > 1.0:
wish = wish.normalized()
var target := wish * move_speed
var a := accel if is_on_floor() else air_accel
velocity.x = move_toward(velocity.x, target.x, a * move_speed * delta)
velocity.z = move_toward(velocity.z, target.z, a * move_speed * delta)
move_and_slide()
# ---------------------------------------------------------------- cosmetic swing
func _swing_viewmodel(is_kick: bool) -> void:
if _swing_tween and _swing_tween.is_valid():
_swing_tween.kill() # interrupt any lingering swing so rapid clicks don't fight
if is_kick:
_leg.position = LEG_REST
var up := LEG_REST + Vector3(0.0, 0.66, -0.12)
_swing_tween = create_tween()
_swing_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
_swing_tween.tween_property(_leg, "position", up, 0.10)
_swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
_swing_tween.tween_property(_leg, "position", LEG_REST, 0.20)
else:
_r_hand.position = R_HAND_REST
var fwd := R_HAND_REST + Vector3(-0.06, 0.10, -0.34)
_swing_tween = create_tween()
_swing_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
_swing_tween.tween_property(_r_hand, "position", fwd, 0.08)
_swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
_swing_tween.tween_property(_r_hand, "position", R_HAND_REST, 0.16)