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>
266 lines
9.4 KiB
GDScript
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)
|