The only site whose contents are mostly SOFT, and that one material change rewrites the
level. `produce` is a new Smashable material that throws no shards at all — Splat.gd
bursts it instead: coloured pulp, a puddle that STAYS, a squelch, and litres counted,
because "how much juice did you make" is a far better score for a greengrocer than "how
many objects did you destroy".
Two knock-ons make the level play itself, both falling out of existing systems rather
than needing new ones:
* produce has a low brittle_speed, so anything heavy landing on it squashes it — which
means a pyramid crushes its OWN bottom layer as it collapses, with no special code.
Knock one off the top and the pile does the rest. Shove test: 257 items -> 45, and
42 litres of juice out of the collapse.
* puddles are slippery, cutting ACCELERATION rather than top speed so it reads as
sliding rather than as being slowed down. The more mess you make, the less the floor
cooperates.
Stacks are built bottom-up by Main._build_stack and nothing is glued or frozen — they
stand because they are stacked, so pulling one out of the bottom row does what you'd
hope. 258 produce items spawn and settle to 0.00 m/s with no spawn crush. The glass
bottles behind the juice bar are the deliberate exception: one shelf in the room that
still rewards a proper swing, and the contrast between litres and shards is the joke.
Round produce is authored CENTRED, not floor-centred like every other prop in this repo,
because a rolling body whose origin sits on the floor plane wobbles like a loaded die.
_glb_piece grew a `centred` flag for it.
REAL BUG FOUND, and it was costing hits in every level: _strike took the NEAREST collider
of any kind, so a static surface could eat a swing. Leaning over a display table, the
table edge is a few centimetres nearer than the fruit piled on it, so every swing hit the
table and nothing broke — 0/306 for the whole first take. It now prefers a Smashable and
only falls back to loose bodies if there isn't one. The offices were quietly losing hits
on desks and shelves the same way.
tools/gen_produce.py: apple, orange, tomato, watermelon, cabbage, banana hand, glass
juice bottle, display crate. The melon's stripes were radial boxes first time and it came
out looking like a sea mine; they're thin lenses through the middle now.
dev/probe_grocer.gd verifies stacks settle, that produce yields litres and NO debris,
that a collapse crushes, and that none of it leaks to the next site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
372 lines
13 KiB
GDScript
372 lines
13 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 by Main. Juice on the floor costs you grip — the more mess you make, the less
|
|
## the floor cooperates.
|
|
var splat: Splat = 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
|
|
if splat != null and is_on_floor():
|
|
# standing in juice: you keep your top speed but lose the ability to change it,
|
|
# which reads as sliding rather than as being slowed down
|
|
a *= 1.0 - 0.82 * splat.slip_under_player()
|
|
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
|
|
|
|
# Prefer a SMASHABLE over anything else in the sweep, and only fall back to loose
|
|
# bodies if there isn't one.
|
|
#
|
|
# This used to just take the nearest collider of any kind, which meant a static
|
|
# surface could eat the swing: leaning over a greengrocer's display table, the table
|
|
# edge is a few centimetres nearer than the fruit piled on it, so every swing hit
|
|
# the table and nothing broke. Same bug was quietly costing hits on desks and shelves
|
|
# in the offices too.
|
|
var best: Node = null
|
|
var best_d := INF
|
|
var fallback: Node = null
|
|
var fallback_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 c is Smashable:
|
|
if d < best_d:
|
|
best_d = d
|
|
best = c
|
|
elif c is RigidBody3D and d < fallback_d:
|
|
fallback_d = d
|
|
fallback = c
|
|
if best == null:
|
|
best = fallback
|
|
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
|