destroyulator/game/scripts/Player.gd
Monster Robot Party dd905d908d LANE7: CUBICLE HELL — the tilt loop, the job, and the payslip
The game stopped being a smash sandbox. You work a shift, the work provokes you, a
meter fills, you snap, and you cannot go back to your desk until you have broken
enough things. Then you sit back down and try to earn the rest of your day.

The score is your payslip: WAGES (tasks completed) - DAMAGES (everything you broke).
The load-bearing detail is that rage drains a FLAT amount per object destroyed while
each object bills at its OWN value — so during a meltdown the play is to wreck a lot
of cheap rubbish fast rather than put the sledgehammer through a filing cabinet. You
don't choose whether you lose your temper, you choose how much it costs. FURY (objects
broken while melting down) is tracked separately and deliberately isn't money, so
"I ended the shift owing them $2,400" stays a brag.

Rage.gd — WORKING -> MELTDOWN -> COOLDOWN. During a meltdown the clock STALLS if you
stop destroying; you must actually be breaking things.

RageOverlay.gd — the blood vessel. A vignette that closes in with rage and jumps
inward on every heartbeat, procedural veins that creep further into frame the angrier
you get, and a synthesized lub-dub going 62 -> 172 BPM. Zero assets: the shader is
built in code, the veins are draw_polyline from a seeded RNG, the heartbeat is a
generated WAV. Tops out as a heavy frame rather than a red screen — you have to be
able to see what you're about to destroy.

Tasks.gd — stations you walk to and are LOCKED IN PLACE at, because you're at your
desk and that's the joke. Two so far:
  SPREADSHEET  the selected cell silently drifts one cell partway through entry, with
               no sound and no animation. Commit without noticing and the figure lands
               in the wrong cell and you start again. This is the whole thesis of the
               game in about forty lines.
  PHOTOCOPIER  jams, and wants the right tray out of 1, 2, 2A, 3, 3B.
Snapping ejects you from the station at no penalty — you didn't choose to walk away.

CubicleHell.gd — 5-minute shift, wages vs damages, meltdown count, end-of-shift
payslip with a verdict line.

Also: HUD gets a monospace face (Godot's fallback is proportional, so the spreadsheet
grid and the work-order docket never lined up), and the arcade HUD's headline becomes
net pay, red when negative.

LANES/LANE7-cubicle-hell.md captures the rest of the design: more provocations, the
GAUNTLET mode (find which of many cabinets, pull drawers, destroy files individually,
and work out which tool the level wants without being told), the other three
workplaces, and why doratracyer/floor_plan can't help — it's OpenSCAD SVG->STL for
3D printing, no generation, no room polygons, GPL-3.0. Office.gd is already most of
a parametric floor-plan builder; lifting its numbers into a data spec is the real path.

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

330 lines
11 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
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_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 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