destroyulator/game/scripts/Player.gd
Monster Robot Party ad0ca62f3e LANE1: record-destruction ritual + Find-the-Misfiled-Disc mode
Turn records from one-hit props into the store's signature dexterity ritual,
and add the first game mode on top of it.

Record.gd (class_name Record extends Smashable): a cardboard SLEEVE that hides a
vinyl DISC. Splits record.glb's Sleeve_Mesh / Vinyl_Disc nodes at runtime — the
sleeve stays on the body, the disc becomes its own frozen-kinematic vinyl Smashable
that follows the sleeve until freed. State machine IN_CRATE -> HELD -> EXTRACTING
-> DISC_FREE. set_extract() slides the disc up and out of the mouth; release_disc()
arms it as a live brittle throwable; shatter() spills the disc first so brute-force
smashing still spills it. Genre tints on disc label / sleeve spine make a misfiled
disc read at a glance.

GrabController.gd (under Player): hold point on the camera, E grabs the Record under
the crosshair (fat-sphere query, Record-filtered), horizontal mouse drag pulls the
disc (~600px = full), LMB throws the freed disc then trashes the empty sleeve, G
drops. On-screen pull meter.

GameMode.gd (class_name GameMode, owned by Main): FreePlay (default) + FindMisfiled —
plants exactly one disc_genre != sleeve_genre, 60s timer, win by extracting it and
pressing F while holding it, scores the mess (bodies smashed) + records disturbed.
Prints the plant on start.

Wiring: Player creates the GrabController and routes E/G/LMB/drag (yaw suspended
mid-pull, Esc untouched); Main._build_rack spawns Record instances on the crates
(keeps supports cascade + sleeve-art skinning + per-record genres), forwards smashes
to the mode, and adds keys 1/2 (modes) and F (report).

Headless smoke test clean (0 script errors / 0 warnings, 15 records); a runtime
harness exercised grab/extract/throw/drop/shatter-spill/mode-switch/reset — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:40:52 +10:00

266 lines
9.4 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 grab: GrabController # the record grab/extract/throw hands (Main reads this)
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()
# the record ritual hands: a child node that owns a hold point under the camera
grab = GrabController.new()
add_child(grab)
grab.setup(camera)
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:
# while pulling a disc out, horizontal drag slides the disc (yaw is suspended);
# vertical still looks up/down so you're never fully locked.
if grab != null and grab.is_extracting():
grab.feed_drag(event.relative.x)
else:
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:
# holding a record? LMB throws the disc / trashes the sleeve, not a punch.
if grab != null and grab.is_holding():
grab.primary()
else:
_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
elif event.keycode == KEY_E and grab != null:
grab.try_grab() # grab the record under the crosshair
elif event.keycode == KEY_G and grab != null:
grab.drop() # drop / trash whatever's in hand
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)