"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>
340 lines
12 KiB
GDScript
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
|