The game had no room. Props sat on a grey disc in a black void, which quietly broke the premise: a game about wrecking your workplace needs a workplace. Office.gd — LEVEL 01 - Open-plan office laid out after The Office (US) floor plan: bullpen of facing desk pairs behind cubicle partitions, reception, glass-walled manager's office, conference room, break room with vending machines, copier alcove, warehouse roller door. - Drop ceiling on a T-bar grid with fluorescent troffers that ARE the light sources (the old scene lit an interior with one outdoor directional lamp, which is exactly why it read as "props on a plane"), window wall with blinds, magnolia and carpet. - Office owns the shell + static fittings; Main._populate() owns everything smashable and asks Office where things go. Walls/desks/counters are boxes because they ARE boxes; chairs, vending machines, microwave and plant come from a new Blender generator (tools/gen_office_props.py) because a box reads as wrong for those. The level no longer falls over on its own - Records were stacked 3 cm apart vertically while being 30 cm TALL, so Jolt resolved 27 cm of interpenetration explosively on frame one and shoved the furniture over before the player touched anything. They now stand side by side. - Per-material mass (MASSES): everything was 1 kg, so a thrown record could tip a filing cabinet. - Spawn guard: placing ~40 props by formula guarantees an occasional overlap, and depenetration is violent (a chair left the building at 500 m/s). Dynamic bodies are speed-limited for 0.75 s, and each offender is named once with the position it was PLACED at, so the cause stays visible instead of being papered over. It then found the real bug: bullpen rows 3.6 m apart left the two rows' chairs meeting back-to-back with 3 cm to spare. Rows are now 4.8 m apart. - dev/DemoDriver.gd act 0 touches nothing for 5 s and prints total body speed. Now reads 0.00 m/s with zero spawn warnings. Two real melee bugs found while testing the level - The hit test was a SPHERE parked at `reach`, so anything CLOSER than the weapon's reach fell in front of it and was missed — you could stand against the printer with a sledgehammer and swing straight through it. Now a capsule swept from the camera. - Swings started at eye height (1.6 m), so a carton on the floor was ~1.5 m away even standing over it and short-reach weapons could never touch anything on the ground. Swings now originate at hand height. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
313 lines
11 KiB
GDScript
313 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
|
|
|
|
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:
|
|
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 and 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 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
|