E on an office chair (hands empty) mounts it: the player's capsule collision is disabled and it rides on top, WASD shoves it (strong) and yaws it (weak, on purpose — steering a chair is bad), momentum is real. Ramming breaks brittle things through the same brittle_speed path as any collision — no new damage code. Sledge the chair out from under yourself and the ride ends on the spot. E or death dismounts and restores collision. Split _try_mount (the crosshair ray) from _mount (the state change) so the ride mechanics are testable without synthetic camera aim. dev/probe_chair.gd: mount disables collision, player tracks the seat to 2mm, dismount restores it. CHAIR OK. All gates green: smoke clean, 6/6 sites 0.00 m/s, overlap CLEAN, all four LANE9 probes pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
456 lines
16 KiB
GDScript
456 lines
16 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; Esc releases the mouse (click recaptures).
|
|
##
|
|
## Melee is a `Weapon` resource (stats + the weapon-vs-material matrix + a swing
|
|
## archetype) and the arms are a rigged `ViewModel`. Swapping weapon swaps both; movement
|
|
## never changes.
|
|
##
|
|
## The hit does NOT land on the click — it lands at `weapon.contact` through the swing
|
|
## animation. That single delay is most of why a sledgehammer feels different from a box
|
|
## cutter: you commit, then it connects.
|
|
##
|
|
## 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
|
|
## How far below the eye a swing actually starts (see _strike).
|
|
const SWING_DROP := 0.32
|
|
|
|
var camera: Camera3D # Main reads this for the crosshair raycast
|
|
var grab: GrabController # the record grab/extract/throw hands
|
|
var viewmodel: ViewModel # the arms + weapon rig
|
|
## Set by Main. While a task is open the player is LOCKED at the station: no walking, no
|
|
## looking, and keys go to the task instead. You're at your desk — that's the joke.
|
|
var tasks: Tasks = null
|
|
## Set by Main. E opens cabinet drawers / feeds the shredder when a run is live.
|
|
var gauntlet: Gauntlet = null
|
|
## Set by Main. RMB discharges powder on sites where the smashables are invisible.
|
|
var dust: Dust = null
|
|
## Set by Main. Juice on the floor costs you grip — the more mess you make, the less
|
|
## the floor cooperates.
|
|
var splat: Splat = null
|
|
## Set while you're dying, so you can't stroll around during it.
|
|
var _frozen := false
|
|
|
|
func set_frozen(v: bool) -> void:
|
|
_frozen = v
|
|
if v:
|
|
velocity = Vector3.ZERO
|
|
|
|
var _head: Node3D
|
|
var _pitch := 0.0
|
|
var _cd_left := 0.0
|
|
|
|
# --- loadout -------------------------------------------------------------
|
|
var _loadout: Array[Weapon] = []
|
|
var _slot := 0
|
|
var _prev_slot := 0
|
|
|
|
# --- swing scheduling ----------------------------------------------------
|
|
var _queued := false # a click waiting for the cooldown
|
|
var _swing_left := -1.0 # seconds until this swing's contact frame
|
|
var _swinging: Weapon = null
|
|
|
|
# --- feel bookkeeping ----------------------------------------------------
|
|
var _look_delta := Vector2.ZERO # mouse motion this frame, fed to viewmodel sway
|
|
var _was_on_floor := true
|
|
var _fall_speed := 0.0
|
|
|
|
signal weapon_changed(w: Weapon)
|
|
signal hit_landed(node: Node, w: Weapon)
|
|
signal swung(w: Weapon)
|
|
|
|
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
|
|
camera.near = 0.03 # the viewmodel sits ~40 cm out; don't clip it
|
|
|
|
_loadout = Weapon.loadout()
|
|
|
|
viewmodel = ViewModel.new()
|
|
viewmodel.setup(camera) # parents itself to the camera
|
|
viewmodel.equip(_loadout[_slot])
|
|
|
|
_build_crosshair()
|
|
|
|
# the record ritual hands: a child node that owns a hold point under the camera
|
|
grab = GrabController.new()
|
|
add_child(grab)
|
|
grab.setup(camera, self)
|
|
|
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
|
weapon_changed.emit(_loadout[_slot])
|
|
|
|
# ---------------------------------------------------------------- loadout
|
|
func weapon() -> Weapon:
|
|
return _loadout[_slot]
|
|
|
|
func loadout() -> Array[Weapon]:
|
|
return _loadout
|
|
|
|
func slot() -> int:
|
|
return _slot
|
|
|
|
func select(i: int) -> void:
|
|
if i < 0 or i >= _loadout.size() or i == _slot:
|
|
return
|
|
if viewmodel != null and viewmodel.is_swapping():
|
|
return
|
|
_prev_slot = _slot
|
|
_slot = i
|
|
_queued = false
|
|
_swing_left = -1.0
|
|
_swinging = null
|
|
if viewmodel != null:
|
|
viewmodel.request_swap(_loadout[_slot])
|
|
weapon_changed.emit(_loadout[_slot])
|
|
|
|
func cycle(step: int) -> void:
|
|
select(posmod(_slot + step, _loadout.size()))
|
|
|
|
func quick_swap() -> void:
|
|
select(_prev_slot)
|
|
|
|
# ---------------------------------------------------------------- crosshair
|
|
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
|
|
# ---------------------------------------------------------------- riding a chair
|
|
const CHAIR_REACH := 2.5
|
|
const CHAIR_DRIVE := 30.0 ## forward shove — strong
|
|
const CHAIR_TURN := 3.2 ## yaw torque — weak, on purpose. steering a chair is bad
|
|
const CHAIR_MAX := 6.0 ## m/s the shove stops adding past
|
|
const SEAT_H := 0.55 ## camera-body origin above the chair origin while seated
|
|
var _chair: RigidBody3D = null
|
|
var _ride_layer := 0
|
|
var _ride_mask := 0
|
|
|
|
## E on an office chair, hands empty: get on. Ray from the crosshair, nearest hit wins.
|
|
func _try_mount() -> bool:
|
|
if grab != null and grab.is_holding():
|
|
return false
|
|
var space := get_world_3d().direct_space_state
|
|
if space == null:
|
|
return false
|
|
var from := camera.global_position
|
|
var to := from - camera.global_transform.basis.z * CHAIR_REACH
|
|
var q := PhysicsRayQueryParameters3D.create(from, to)
|
|
q.exclude = [get_rid()]
|
|
var hit := space.intersect_ray(q)
|
|
if hit.is_empty():
|
|
return false
|
|
var body: Object = hit["collider"]
|
|
if not (body is RigidBody3D) or not String((body as Node).name).contains("chair"):
|
|
return false
|
|
return _mount(body as RigidBody3D)
|
|
|
|
## The state change, split from the aim so it's testable without a synthetic ray.
|
|
func _mount(chair: RigidBody3D) -> bool:
|
|
if chair == null or (chair is Smashable and (chair as Smashable).is_broken()):
|
|
return false
|
|
_chair = chair
|
|
_ride_layer = collision_layer
|
|
_ride_mask = collision_mask
|
|
collision_layer = 0 # the capsule stops fighting the chair; we ARE the chair now
|
|
collision_mask = 0
|
|
velocity = Vector3.ZERO
|
|
_chair.sleeping = false
|
|
return true
|
|
|
|
func _dismount() -> void:
|
|
if _chair == null:
|
|
return
|
|
collision_layer = _ride_layer
|
|
collision_mask = _ride_mask
|
|
if is_instance_valid(_chair):
|
|
# step off to the side of wherever the chair ended up, at standing height
|
|
global_position = _chair.global_position + Vector3(0.0, 0.9, 0.0) \
|
|
- camera.global_transform.basis.z * 0.0 + transform.basis.x * 0.5
|
|
_chair = null
|
|
velocity = Vector3.ZERO
|
|
|
|
## Drive the chair: W/S shove along where you're looking, A/D spin the seat (badly),
|
|
## and the player rides wherever it ends up. Momentum is real; the floor is the office.
|
|
func _ride(delta: float) -> void:
|
|
if not is_instance_valid(_chair) or (_chair is Smashable and (_chair as Smashable).is_broken()):
|
|
_dismount()
|
|
return
|
|
var fwd := float(Input.is_key_pressed(KEY_W)) - float(Input.is_key_pressed(KEY_S))
|
|
var turn := float(Input.is_key_pressed(KEY_A)) - float(Input.is_key_pressed(KEY_D))
|
|
if fwd != 0.0 and _chair.linear_velocity.length() < CHAIR_MAX:
|
|
var dir := -transform.basis.z * fwd
|
|
dir.y = 0.0
|
|
_chair.apply_central_force(dir.normalized() * CHAIR_DRIVE)
|
|
if turn != 0.0:
|
|
_chair.apply_torque(Vector3(0.0, turn * CHAIR_TURN, 0.0))
|
|
# ride on top of it; keep the mouse-driven yaw so you can look around while rolling
|
|
global_position = _chair.global_position + Vector3(0.0, SEAT_H, 0.0)
|
|
velocity = Vector3.ZERO
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
# dead men don't switch weapons. R is handled by Main and reaches it regardless.
|
|
if _frozen:
|
|
if _chair != null:
|
|
_dismount()
|
|
return
|
|
# a task swallows the keyboard first, so digits and arrows edit the spreadsheet
|
|
# instead of switching weapons and walking away from the desk
|
|
if tasks != null and tasks.is_working():
|
|
if event is InputEventKey and event.pressed and not event.echo:
|
|
tasks.handle_key((event as InputEventKey).keycode)
|
|
return
|
|
|
|
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
|
var mm := event as InputEventMouseMotion
|
|
_look_delta += mm.relative * 0.01
|
|
# 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(mm.relative.x)
|
|
else:
|
|
rotate_y(-mm.relative.x * mouse_sensitivity) # yaw on the body only
|
|
_pitch = clamp(
|
|
_pitch - mm.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:
|
|
var mb := event as InputEventMouseButton
|
|
if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
|
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # click recaptures; not an attack
|
|
return
|
|
match mb.button_index:
|
|
MOUSE_BUTTON_LEFT:
|
|
# holding a record? LMB throws the disc / trashes the sleeve, not a swing.
|
|
if grab != null and grab.is_holding():
|
|
grab.primary()
|
|
else:
|
|
_queued = true
|
|
MOUSE_BUTTON_RIGHT:
|
|
# the extinguisher is the only thing you can shake powder out of
|
|
if dust != null and dust.active and weapon().id == "extinguisher":
|
|
dust.discharge()
|
|
MOUSE_BUTTON_WHEEL_UP:
|
|
cycle(-1)
|
|
MOUSE_BUTTON_WHEEL_DOWN:
|
|
cycle(1)
|
|
return
|
|
|
|
if event is InputEventKey and event.pressed and not event.echo:
|
|
var k := (event as InputEventKey).keycode
|
|
if k == KEY_ESCAPE:
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
elif k == KEY_E:
|
|
# riding? E gets you off. otherwise: station, then gauntlet, then hop on a
|
|
# chair, then the record ritual — first one that takes it wins.
|
|
if _chair != null:
|
|
_dismount()
|
|
elif tasks != null and tasks.try_start():
|
|
pass
|
|
elif gauntlet != null and gauntlet.interact():
|
|
pass
|
|
elif _try_mount():
|
|
pass
|
|
elif grab != null:
|
|
grab.try_grab() # grab the record under the crosshair
|
|
elif k == KEY_G and grab != null:
|
|
grab.drop() # drop / trash whatever's in hand
|
|
elif k == KEY_Q:
|
|
quick_swap()
|
|
elif k >= KEY_1 and k <= KEY_6:
|
|
select(k - KEY_1)
|
|
|
|
# ---------------------------------------------------------------- physics / movement
|
|
func _physics_process(delta: float) -> void:
|
|
_cd_left = max(0.0, _cd_left - delta)
|
|
|
|
# riding a chair replaces normal locomotion entirely — see _ride()
|
|
if _chair != null:
|
|
_ride(delta)
|
|
return
|
|
|
|
# --- start a swing when the cooldown allows ---
|
|
if _queued and _cd_left <= 0.0 and (viewmodel == null or not viewmodel.is_swapping()):
|
|
_queued = false
|
|
var w := weapon()
|
|
_cd_left = w.cooldown
|
|
_swinging = w
|
|
_swing_left = w.swing_time * w.contact
|
|
if viewmodel != null:
|
|
viewmodel.start_swing()
|
|
swung.emit(w)
|
|
|
|
# --- land the hit at the contact frame, not on the click ---
|
|
if _swing_left >= 0.0:
|
|
_swing_left -= delta
|
|
if _swing_left <= 0.0:
|
|
var w := _swinging
|
|
_swing_left = -1.0
|
|
_swinging = null
|
|
if w != null:
|
|
var hit := _strike(w)
|
|
if hit != null:
|
|
hit_landed.emit(hit, w)
|
|
|
|
# gravity (only off the floor, or it accumulates and you rocket downward)
|
|
if not is_on_floor():
|
|
velocity += get_gravity() * delta
|
|
_fall_speed = maxf(_fall_speed, -velocity.y)
|
|
elif not _was_on_floor:
|
|
if viewmodel != null:
|
|
viewmodel.land(_fall_speed)
|
|
_fall_speed = 0.0
|
|
_was_on_floor = is_on_floor()
|
|
|
|
if Input.is_key_pressed(KEY_SPACE) and is_on_floor():
|
|
velocity.y = jump_velocity
|
|
|
|
var seated := (tasks != null and tasks.is_working()) or _frozen
|
|
var input_dir := Vector2.ZERO
|
|
if not seated:
|
|
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
|
|
if splat != null and is_on_floor():
|
|
# standing in juice: you keep your top speed but lose the ability to change it,
|
|
# which reads as sliding rather than as being slowed down
|
|
a *= 1.0 - 0.82 * splat.slip_under_player()
|
|
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()
|
|
|
|
func _process(dt: float) -> void:
|
|
if viewmodel != null:
|
|
var planar := Vector2(velocity.x, velocity.z).length()
|
|
viewmodel.drive(_look_delta, planar, is_on_floor(), dt)
|
|
_look_delta = _look_delta.lerp(Vector2.ZERO, clampf(dt * 14.0, 0.0, 1.0))
|
|
|
|
# ---------------------------------------------------------------- the hit
|
|
## Forgiving CAPSULE sweep from the camera along the crosshair, so melee doesn't need
|
|
## pixel aim. intersect_shape is NOT distance-sorted, so pick the nearest collider.
|
|
##
|
|
## This used to be a sphere parked AT `reach`, which meant anything CLOSER than the
|
|
## weapon's reach fell in front of the test and was missed completely — you could stand
|
|
## against the printer with a sledgehammer and swing through it. A capsule spanning
|
|
## camera -> camera + dir*reach covers the whole swing instead of just its far end.
|
|
##
|
|
## Runs from _physics_process, where direct_space_state is valid.
|
|
func _strike(w: Weapon) -> Node:
|
|
var space := get_world_3d().direct_space_state
|
|
if space == null:
|
|
return null
|
|
# Swing from roughly where the hands are, not from the eyeballs. With eyes at 1.6 m,
|
|
# a carton sitting on the floor is ~1.5 m away even when you're standing right over
|
|
# it, so a short-reach weapon could never touch anything on the ground. Dropping the
|
|
# origin to hand height fixes that without inflating every weapon's reach.
|
|
var origin := camera.global_position - Vector3(0.0, SWING_DROP, 0.0)
|
|
var dir := -camera.global_transform.basis.z
|
|
|
|
var cap := CapsuleShape3D.new()
|
|
cap.radius = w.radius
|
|
cap.height = maxf(w.reach, w.radius * 2.0 + 0.01) # total height, caps included
|
|
# CapsuleShape3D runs along its local Y, so build a basis whose Y is the view dir
|
|
var ref := Vector3.UP if absf(dir.dot(Vector3.UP)) < 0.95 else Vector3.RIGHT
|
|
var bx := ref.cross(dir).normalized()
|
|
var bz := bx.cross(dir).normalized()
|
|
var params := PhysicsShapeQueryParameters3D.new()
|
|
params.shape = cap
|
|
params.transform = Transform3D(Basis(bx, dir, bz), origin + dir * (w.reach * 0.5))
|
|
params.collide_with_bodies = true
|
|
params.collide_with_areas = false
|
|
params.exclude = [get_rid()]
|
|
|
|
var hits := space.intersect_shape(params, 8)
|
|
if hits.is_empty():
|
|
return null
|
|
|
|
# Prefer a SMASHABLE over anything else in the sweep, and only fall back to loose
|
|
# bodies if there isn't one.
|
|
#
|
|
# This used to just take the nearest collider of any kind, which meant a static
|
|
# surface could eat the swing: leaning over a greengrocer's display table, the table
|
|
# edge is a few centimetres nearer than the fruit piled on it, so every swing hit
|
|
# the table and nothing broke. Same bug was quietly costing hits on desks and shelves
|
|
# in the offices too.
|
|
var best: Node = null
|
|
var best_d := INF
|
|
var fallback: Node = null
|
|
var fallback_d := INF
|
|
for h in hits:
|
|
var c = h.get("collider")
|
|
if c == null or not (c is Node3D):
|
|
continue
|
|
var d: float = (c.global_position - origin).length_squared()
|
|
if c is Smashable:
|
|
if d < best_d:
|
|
best_d = d
|
|
best = c
|
|
elif c is RigidBody3D and d < fallback_d:
|
|
fallback_d = d
|
|
fallback = c
|
|
if best == null:
|
|
best = fallback
|
|
if best == null:
|
|
return null
|
|
|
|
# Smashable extends RigidBody3D, so this branch MUST come first or we'd only shove
|
|
# props around and never break them.
|
|
if best is Smashable:
|
|
var s := best as Smashable
|
|
s.smash(dir * w.impulse, w.damage_against(s.kind))
|
|
elif best is RigidBody3D:
|
|
var hit_pos: Vector3 = origin + dir * w.reach
|
|
(best as RigidBody3D).apply_impulse(dir * w.knockback,
|
|
hit_pos - (best as RigidBody3D).global_position)
|
|
return best
|