destroyulator/game/scripts/Player.gd
Monster Robot Party 7c282c298e LANE7: LEVEL 0 — the Backrooms, generated walls, and the dust mechanic
"stuff is invisible have to release dust or gas or something to coat items to see them
to destroy them? and you inhale some of it too"

THE SITE (Levels.backrooms)
Mono-yellow, 2.55 m ceiling, damp carpet, a dense grid of buzzing troffers, no windows
and no exit. THE WALLS ARE GENERATED — this is the site that proves the Floorplan spec
can be emitted rather than authored: a seeded RNG lays partial wall runs on a 4.6 m
lattice with random gaps, plus 22 freestanding pillars. It follows the reference photos
rather than a maze algorithm on purpose — the Backrooms aren't corridors, they're big
offset slabs in an open floor, and a proper maze would be legible. Legible is wrong.

THE MECHANIC (Dust.gd) — it argues with itself, which is the whole point.
Everything smashable here is invisible but SOLID; you find things by walking into them.
RMB with the extinguisher throws a cloud of powder. Anything it touches is coated,
glows faintly out of all that yellow for 7 seconds, then settles back into nothing.

And you breathe it. Every cloud puts a little in your lungs, and the haze that builds is
on YOUR side of the glass. So: spray more to see individual objects, and see the room
less. Past 72% you cough — camera jolt, screen bloom, and a rage tick, because of course
it does. Intended rhythm is spray -> switch to something heavy -> break it before the
coating settles -> spray again, coughing. Pairs with TOTAL DESTRUCTION, since the level
IS a search.

Tuning that mattered: the first cloud was a wall of big blocky quads that hid the very
thing it was revealing (150 finer, shorter-lived particles now), and a coated object has
to POP out of a room that is entirely one colour, so the coat is near-white and faintly
self-lit rather than a subtle tint. 78 objects, not 34 — a search with nothing in it is
just a walk.

Bug found while testing: `x as Smashable` on a freed object is itself an error in Godot
4, so the validity check has to come BEFORE the cast — destroying anything mid-level was
spewing "trying to cast a freed object" every frame.

dev/probe_backrooms.gd verifies the whole loop headlessly, including that the mechanic
does NOT leak into the other four sites when you leave.

All five sites still settle to 0.00 m/s.

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

340 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
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:
# 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()
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