From 14ebc1d6f54ab510f2c66be37052aa72da6bde63 Mon Sep 17 00:00:00 2001 From: Monster Robot Party Date: Tue, 4 Aug 2026 00:25:32 +1000 Subject: [PATCH] LANE9: ride the office chair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- game/README.md | 18 +++++++- game/dev/probe_chair.gd | 61 +++++++++++++++++++++++++ game/dev/probe_chair.gd.uid | 1 + game/scripts/Player.gd | 88 ++++++++++++++++++++++++++++++++++++- 4 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 game/dev/probe_chair.gd create mode 100644 game/dev/probe_chair.gd.uid diff --git a/game/README.md b/game/README.md index 25da577..7eba639 100644 --- a/game/README.md +++ b/game/README.md @@ -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]` diff --git a/game/dev/probe_chair.gd b/game/dev/probe_chair.gd new file mode 100644 index 0000000..5169583 --- /dev/null +++ b/game/dev/probe_chair.gd @@ -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) diff --git a/game/dev/probe_chair.gd.uid b/game/dev/probe_chair.gd.uid new file mode 100644 index 0000000..3078fe4 --- /dev/null +++ b/game/dev/probe_chair.gd.uid @@ -0,0 +1 @@ +uid://ctdwpod1471pb diff --git a/game/scripts/Player.gd b/game/scripts/Player.gd index 13a89ad..43b2707 100644 --- a/game/scripts/Player.gd +++ b/game/scripts/Player.gd @@ -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