LANE9: ride the office chair
E on an office chair (hands empty) mounts it: the player's capsule collision is disabled and it rides on top, WASD shoves it (strong) and yaws it (weak, on purpose — steering a chair is bad), momentum is real. Ramming breaks brittle things through the same brittle_speed path as any collision — no new damage code. Sledge the chair out from under yourself and the ride ends on the spot. E or death dismounts and restores collision. Split _try_mount (the crosshair ray) from _mount (the state change) so the ride mechanics are testable without synthetic camera aim. dev/probe_chair.gd: mount disables collision, player tracks the seat to 2mm, dismount restores it. CHAIR OK. All gates green: smoke clean, 6/6 sites 0.00 m/s, overlap CLEAN, all four LANE9 probes pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
64b3543e8a
commit
14ebc1d6f5
@ -24,7 +24,7 @@ Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics.
|
||||
| **L** | cycle site (Scranton / Pawnee / Incubator / Sub-level 4 / Greengrocer / Level 0) |
|
||||
| **G** | restart a Gauntlet run |
|
||||
| **F** | report the disc in hand (Find-the-Misfiled-Disc) |
|
||||
| **E** | work at the station you're standing at (locks you in place); otherwise pick up whatever's under the crosshair |
|
||||
| **E** | work at the station you're standing at (locks you in place); else hop on / off an office chair under the crosshair; else pick up whatever's there |
|
||||
| **LMB · G** | with something in hand: throw it · put it down |
|
||||
| **drag ←→** | with a *record* held: slide the disc out of the sleeve |
|
||||
| **digits · arrows · ENTER · SPACE** | while working: type · move cell · commit · the other thing |
|
||||
@ -259,6 +259,22 @@ tick on the meter — it's what makes the room hostile rather than merely beige.
|
||||
per object but each object bills at its own value, so the meltdown decision is *smash a
|
||||
lot of cheap things fast* versus *one expensive swing*.
|
||||
|
||||
## The fun layer (LANE9)
|
||||
|
||||
Three antagonists to the *tidy* impulse, all riding systems that already exist:
|
||||
|
||||
- **The Roomba** (`scripts/Roomba.gd`) — a robot vacuum that deploys the moment the first
|
||||
thing breaks and patrols *eating your debris*, docking points as it goes. It's company
|
||||
property (`steel`), so the weapon matrix already knows it: bare hands clank, a crowbar
|
||||
dents it (it panics and flees), a sledge kills it — and then the payslip bills you for it.
|
||||
Not in LEVEL 0; nothing cute lives in the Backrooms.
|
||||
- **The incident report** (`scripts/Receipt.gd`) — when a meltdown ends, HR prints a
|
||||
dot-matrix receipt itemizing what you broke during it: object · unit $ · ×N · DEDUCTED.
|
||||
A meltdown where you broke nothing prints nothing.
|
||||
- **The office chair** — `E` on one to ride it. WASD shoves it (strong) and steers it
|
||||
(badly); momentum is real and ramming breaks things through the same `brittle_speed`
|
||||
path as any collision. Sledge your own vehicle and the ride ends where you're sitting.
|
||||
|
||||
## The weapon-vs-material matrix
|
||||
|
||||
This is the decision the game is built on. A swing deals `Weapon.power × Weapon.vs[material]`
|
||||
|
||||
61
game/dev/probe_chair.gd
Normal file
61
game/dev/probe_chair.gd
Normal file
@ -0,0 +1,61 @@
|
||||
extends SceneTree
|
||||
|
||||
## The chair-ride contract:
|
||||
## - mount a chair: the player is pinned to it (collision off, seated on top)
|
||||
## - driving forward MOVES the chair (real momentum), and the player rides along
|
||||
## - dismount restores the player's collision
|
||||
##
|
||||
## Godot --headless --path game --script dev/probe_chair.gd
|
||||
|
||||
func _initialize() -> void:
|
||||
var main: Node = (load("res://main.tscn") as PackedScene).instantiate()
|
||||
get_root().add_child(main)
|
||||
await process_frame
|
||||
for i in 70: # past the 1 s spawn guard, which sleeps drifting bodies
|
||||
await physics_frame
|
||||
|
||||
var player = main.get("_player")
|
||||
if player == null:
|
||||
print("FAIL: no player"); quit(1); return
|
||||
var layer0: int = player.collision_layer
|
||||
|
||||
# find a chair, teleport the player onto it, aim at it, mount
|
||||
var chair: RigidBody3D = null
|
||||
for n in get_nodes_in_group("smashable"):
|
||||
if String(n.name).contains("chair"):
|
||||
chair = n
|
||||
break
|
||||
if chair == null:
|
||||
print("FAIL: no chair in scranton"); quit(1); return
|
||||
|
||||
# mount the known chair directly — the ray-aim path (_try_mount) is what a player uses
|
||||
# live; here we test the ride MECHANICS, which _mount drives, without synthetic aim.
|
||||
player.global_position = chair.global_position + Vector3(0, 0.6, 0)
|
||||
var mounted: bool = player._mount(chair)
|
||||
print("mount: %s collision_layer %d -> %d" % [mounted, layer0, player.collision_layer])
|
||||
if not mounted or player.collision_layer != 0:
|
||||
print("FAIL: did not mount / collision not disabled"); quit(1); return
|
||||
|
||||
# move the chair to open floor first (scranton chairs spawn wedged behind desks), then
|
||||
# drive it and confirm the player rides along
|
||||
var sp: Vector3 = main.get("_plan").spawn_point()
|
||||
chair.global_position = sp + Vector3(0.0, 3.0, 0.0) # airborne, nothing to wedge against
|
||||
chair.sleeping = false
|
||||
chair.linear_velocity = Vector3(2.6, 0.0, 0.0) # a good shove; it coasts (and falls)
|
||||
var start := chair.global_position
|
||||
for i in 45:
|
||||
player._ride(1.0 / 60.0)
|
||||
await physics_frame
|
||||
var moved: float = (chair.global_position - start).length()
|
||||
var seat_h: float = player.SEAT_H
|
||||
var gap: float = (player.global_position - (chair.global_position + Vector3(0, seat_h, 0))).length()
|
||||
print("chair moved %.2f m player-to-seat gap %.3f m" % [moved, gap])
|
||||
|
||||
player._dismount()
|
||||
print("dismount: collision_layer restored to %d" % player.collision_layer)
|
||||
if moved > 0.3 and gap < 0.05 and player.collision_layer == layer0:
|
||||
print("CHAIR OK")
|
||||
quit(0)
|
||||
else:
|
||||
print("FAIL: chair didn't move, player didn't track, or collision not restored")
|
||||
quit(1)
|
||||
1
game/dev/probe_chair.gd.uid
Normal file
1
game/dev/probe_chair.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://ctdwpod1471pb
|
||||
@ -156,9 +156,83 @@ func _build_crosshair() -> void:
|
||||
layer.add_child(dot)
|
||||
|
||||
# ---------------------------------------------------------------- input
|
||||
# ---------------------------------------------------------------- riding a chair
|
||||
const CHAIR_REACH := 2.5
|
||||
const CHAIR_DRIVE := 30.0 ## forward shove — strong
|
||||
const CHAIR_TURN := 3.2 ## yaw torque — weak, on purpose. steering a chair is bad
|
||||
const CHAIR_MAX := 6.0 ## m/s the shove stops adding past
|
||||
const SEAT_H := 0.55 ## camera-body origin above the chair origin while seated
|
||||
var _chair: RigidBody3D = null
|
||||
var _ride_layer := 0
|
||||
var _ride_mask := 0
|
||||
|
||||
## E on an office chair, hands empty: get on. Ray from the crosshair, nearest hit wins.
|
||||
func _try_mount() -> bool:
|
||||
if grab != null and grab.is_holding():
|
||||
return false
|
||||
var space := get_world_3d().direct_space_state
|
||||
if space == null:
|
||||
return false
|
||||
var from := camera.global_position
|
||||
var to := from - camera.global_transform.basis.z * CHAIR_REACH
|
||||
var q := PhysicsRayQueryParameters3D.create(from, to)
|
||||
q.exclude = [get_rid()]
|
||||
var hit := space.intersect_ray(q)
|
||||
if hit.is_empty():
|
||||
return false
|
||||
var body: Object = hit["collider"]
|
||||
if not (body is RigidBody3D) or not String((body as Node).name).contains("chair"):
|
||||
return false
|
||||
return _mount(body as RigidBody3D)
|
||||
|
||||
## The state change, split from the aim so it's testable without a synthetic ray.
|
||||
func _mount(chair: RigidBody3D) -> bool:
|
||||
if chair == null or (chair is Smashable and (chair as Smashable).is_broken()):
|
||||
return false
|
||||
_chair = chair
|
||||
_ride_layer = collision_layer
|
||||
_ride_mask = collision_mask
|
||||
collision_layer = 0 # the capsule stops fighting the chair; we ARE the chair now
|
||||
collision_mask = 0
|
||||
velocity = Vector3.ZERO
|
||||
_chair.sleeping = false
|
||||
return true
|
||||
|
||||
func _dismount() -> void:
|
||||
if _chair == null:
|
||||
return
|
||||
collision_layer = _ride_layer
|
||||
collision_mask = _ride_mask
|
||||
if is_instance_valid(_chair):
|
||||
# step off to the side of wherever the chair ended up, at standing height
|
||||
global_position = _chair.global_position + Vector3(0.0, 0.9, 0.0) \
|
||||
- camera.global_transform.basis.z * 0.0 + transform.basis.x * 0.5
|
||||
_chair = null
|
||||
velocity = Vector3.ZERO
|
||||
|
||||
## Drive the chair: W/S shove along where you're looking, A/D spin the seat (badly),
|
||||
## and the player rides wherever it ends up. Momentum is real; the floor is the office.
|
||||
func _ride(delta: float) -> void:
|
||||
if not is_instance_valid(_chair) or (_chair is Smashable and (_chair as Smashable).is_broken()):
|
||||
_dismount()
|
||||
return
|
||||
var fwd := float(Input.is_key_pressed(KEY_W)) - float(Input.is_key_pressed(KEY_S))
|
||||
var turn := float(Input.is_key_pressed(KEY_A)) - float(Input.is_key_pressed(KEY_D))
|
||||
if fwd != 0.0 and _chair.linear_velocity.length() < CHAIR_MAX:
|
||||
var dir := -transform.basis.z * fwd
|
||||
dir.y = 0.0
|
||||
_chair.apply_central_force(dir.normalized() * CHAIR_DRIVE)
|
||||
if turn != 0.0:
|
||||
_chair.apply_torque(Vector3(0.0, turn * CHAIR_TURN, 0.0))
|
||||
# ride on top of it; keep the mouse-driven yaw so you can look around while rolling
|
||||
global_position = _chair.global_position + Vector3(0.0, SEAT_H, 0.0)
|
||||
velocity = Vector3.ZERO
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# dead men don't switch weapons. R is handled by Main and reaches it regardless.
|
||||
if _frozen:
|
||||
if _chair != null:
|
||||
_dismount()
|
||||
return
|
||||
# a task swallows the keyboard first, so digits and arrows edit the spreadsheet
|
||||
# instead of switching weapons and walking away from the desk
|
||||
@ -209,11 +283,16 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
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():
|
||||
# riding? E gets you off. otherwise: station, then gauntlet, then hop on a
|
||||
# chair, then the record ritual — first one that takes it wins.
|
||||
if _chair != null:
|
||||
_dismount()
|
||||
elif tasks != null and tasks.try_start():
|
||||
pass
|
||||
elif gauntlet != null and gauntlet.interact():
|
||||
pass
|
||||
elif _try_mount():
|
||||
pass
|
||||
elif grab != null:
|
||||
grab.try_grab() # grab the record under the crosshair
|
||||
elif k == KEY_G and grab != null:
|
||||
@ -227,6 +306,11 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
func _physics_process(delta: float) -> void:
|
||||
_cd_left = max(0.0, _cd_left - delta)
|
||||
|
||||
# riding a chair replaces normal locomotion entirely — see _ride()
|
||||
if _chair != null:
|
||||
_ride(delta)
|
||||
return
|
||||
|
||||
# --- start a swing when the cooldown allows ---
|
||||
if _queued and _cd_left <= 0.0 and (viewmodel == null or not viewmodel.is_swapping()):
|
||||
_queued = false
|
||||
|
||||
Loading…
Reference in New Issue
Block a user