destroyulator/game/scripts/Player.gd
Monster Robot Party bcd67906a4 LANE7: you can die in LEVEL 0, and how you died decides which death you get
NO HEALTH BAR. Nothing else in this game does damage, so hit points would be a
survival-horror UI bolted onto a demolition game. Instead: one meter, DREAD, fed by
every way this place gets at you — being stared at, something close behind you, each
loop, and your own coughing. It decays when nothing is happening to you, and every loop
makes it grip harder (DREAD_LOOP_GRIP), so the place gets better at this the longer you
stay.

At 100% you die, and WHICH source pushed it over decides the death, which is what keeps
the three memes legible at the one moment it matters:

  STARE  "YOU LOOKED TOO LONG / it was still. it was always still. that was never the
         safe part."                                              (Slenderman)
  REACH  "IT WAS ALREADY IN THE ROOM / you were making so much noise."
         Sprays. This is the gory one.                            (the xenomorph)
  LOOP   "YOU ARE STILL IN THE ROOM / you walked it eleven times. you will walk it
         again." Eleven loops and the room simply keeps you.       (Blair Witch)

Gore.gd is procedural — seeded blobs that swell on the lens and then bleed downward in
streaks, a wet burst at the camera (physical deaths only; being taken by the static
shouldn't spray), the camera going down to land on its side on the carpet, then the
card. The sequence is deliberately SLOW: a jumpscare is a spike, but a death should take
its time, because the seconds spent watching the lens settle are where it lands. R wakes
you up.

Player.set_frozen() so you can't stroll around during your own death.

Renamed Backrooms' VHS layer to `tape` — `dread` is the meter now, and it was clearer
anyway.

dev/probe_death.gd drives all three deaths and the revive.

Note: the "Parameter material is null" warning only appears under the headless dummy
renderer; windowed runs are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:44:26 +10:00

350 lines
12 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 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)
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
func _unhandled_input(event: InputEvent) -> void:
# dead men don't switch weapons. R is handled by Main and reaches it regardless.
if _frozen:
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:
# a station in reach wins over the record ritual
if tasks != null and tasks.try_start():
pass
elif gauntlet != null and gauntlet.interact():
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)
# --- 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
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
var best: Node = null
var best_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 d < best_d:
best_d = d
best = c
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