destroyulator/game/scripts/Office.gd
Monster Robot Party d17e2314aa LANE6: LEVEL 01 is an office — Dunder Mifflin floor plan, and a spawn guard
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>
2026-07-31 17:13:23 +10:00

455 lines
17 KiB
GDScript

extends Node3D
class_name Office
## LEVEL 01 — the open-plan office.
##
## The prototype ran on a grey disc in a black void, which quietly broke the premise: a
## game about wrecking your workplace needs a workplace. This is that workplace, laid out
## after the Dunder Mifflin floor plan — the shape everyone already knows.
##
## z- (back)
## +--------------------------------------------------+
## | MANAGER | CONFERENCE | | WAREHOUSE |
## | (glass) | | | door |
## |----------+---------------+ +-----------|
## | | |
## | B U L L P E N | BREAK | x+
## | (4 facing desk pairs) | ROOM |
## | [copier] | |
## |----------+ +-----------|
## | RECEPTION| entrance |
## +--------------------------------------------------+
## z+ (front / street)
##
## Walls, desks, partitions and counters are boxes built in code — they ARE boxes, so
## modelling them would be wasted effort. Anything with a silhouette a box can't fake
## (chairs, vending machines, the plant) comes from tools/gen_office_props.py.
##
## This script owns the SHELL and the FITTINGS: everything static and non-smashable, plus
## the lighting. Smashable furniture stays Main's business, so the two can be tuned
## independently — Main asks this class where things go via the `spot_*` helpers.
##
## Godot 4.7 GDScript 2.0.
# ---------------------------------------------------------------- floor plan
const X_MIN := -11.0
const X_MAX := 11.0
const Z_BACK := -8.0
const Z_FRONT := 8.0
const H := 2.75 # drop ceiling
const WALL_T := 0.16
# interior room boundaries
const MGR_X := -6.6 # manager's office: X_MIN..MGR_X, Z_BACK..MGR_Z
const MGR_Z := -4.4
const CONF_X0 := -6.6 # conference room
const CONF_X1 := -0.4
const CONF_Z := -4.4
const BREAK_X := 5.4 # break room: BREAK_X..X_MAX, BREAK_Z..Z_FRONT
const BREAK_Z := -1.2
const RECEP_Z := 4.6 # reception partition
const DOOR_W := 1.1
const P := "res://assets/store/"
var _spots_desk: Array[Transform3D] = [] # where Main should put smashable desks
var _spots_chair: Array[Transform3D] = []
var _spot_copier := Transform3D.IDENTITY
# ---------------------------------------------------------------- materials
var _m_carpet: StandardMaterial3D
var _m_wall: StandardMaterial3D
var _m_ceil: StandardMaterial3D
var _m_trim: StandardMaterial3D
var _m_glass: StandardMaterial3D
var _m_part: StandardMaterial3D
var _m_wood: StandardMaterial3D
func build() -> void:
_materials()
_shell()
_interior_walls()
_ceiling_grid()
_lights_rig()
_bullpen()
_reception()
_conference()
_manager()
_break_room()
_dressing()
func _materials() -> void:
_m_carpet = StandardMaterial3D.new()
_m_carpet.albedo_color = Color(0.30, 0.30, 0.32) # the grey office carpet
_m_carpet.roughness = 1.0
_m_wall = StandardMaterial3D.new()
_m_wall.albedo_color = Color(0.80, 0.78, 0.72) # magnolia, of course
_m_wall.roughness = 0.96
_m_ceil = StandardMaterial3D.new()
_m_ceil.albedo_color = Color(0.88, 0.88, 0.86)
_m_ceil.roughness = 0.98
_m_trim = StandardMaterial3D.new()
_m_trim.albedo_color = Color(0.13, 0.12, 0.13)
_m_trim.roughness = 0.7
_m_part = StandardMaterial3D.new()
_m_part.albedo_color = Color(0.44, 0.44, 0.40) # cubicle partition fabric
_m_part.roughness = 1.0
_m_wood = StandardMaterial3D.new()
_m_wood.albedo_color = Color(0.42, 0.29, 0.17)
_m_wood.roughness = 0.55
_m_glass = StandardMaterial3D.new()
_m_glass.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_m_glass.albedo_color = Color(0.66, 0.74, 0.78, 0.15)
_m_glass.roughness = 0.05
_m_glass.cull_mode = BaseMaterial3D.CULL_DISABLED
# ---------------------------------------------------------------- primitives
func _slab(size: Vector3, centre: Vector3, mat: Material, collide := true,
shadow := true) -> StaticBody3D:
var body := StaticBody3D.new()
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = size
mi.mesh = bm
mi.material_override = mat
if not shadow:
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
body.add_child(mi)
if collide:
var col := CollisionShape3D.new()
var sh := BoxShape3D.new()
sh.size = size
col.shape = sh
body.add_child(col)
add_child(body)
body.position = centre
return body
## A wall running along X (or Z) with a doorway punched in it.
func _wall_x(z: float, x0: float, x1: float, mat: Material, door_at := INF) -> void:
if door_at == INF:
_slab(Vector3(x1 - x0, H, WALL_T), Vector3((x0 + x1) * 0.5, H * 0.5, z), mat)
return
var d0 := door_at - DOOR_W * 0.5
var d1 := door_at + DOOR_W * 0.5
if d0 > x0:
_slab(Vector3(d0 - x0, H, WALL_T), Vector3((x0 + d0) * 0.5, H * 0.5, z), mat)
if x1 > d1:
_slab(Vector3(x1 - d1, H, WALL_T), Vector3((d1 + x1) * 0.5, H * 0.5, z), mat)
_slab(Vector3(DOOR_W, H - 2.05, WALL_T), Vector3(door_at, (H + 2.05) * 0.5, z), mat)
func _wall_z(x: float, z0: float, z1: float, mat: Material, door_at := INF) -> void:
if door_at == INF:
_slab(Vector3(WALL_T, H, z1 - z0), Vector3(x, H * 0.5, (z0 + z1) * 0.5), mat)
return
var d0 := door_at - DOOR_W * 0.5
var d1 := door_at + DOOR_W * 0.5
if d0 > z0:
_slab(Vector3(WALL_T, H, d0 - z0), Vector3(x, H * 0.5, (z0 + d0) * 0.5), mat)
if z1 > d1:
_slab(Vector3(WALL_T, H, z1 - d1), Vector3(x, H * 0.5, (d1 + z1) * 0.5), mat)
_slab(Vector3(WALL_T, H - 2.05, DOOR_W), Vector3(x, (H + 2.05) * 0.5, door_at), mat)
## Static set dressing from a GLB, with a box collider sized to its own mesh so you can't
## walk through the vending machine.
func _glb(path: String, pos: Vector3, yaw := 0.0) -> Node3D:
if not ResourceLoader.exists(path):
return null
var packed := load(path) as PackedScene
if packed == null:
return null
var body := StaticBody3D.new()
add_child(body)
body.position = pos
body.rotation.y = yaw
var n: Node3D = packed.instantiate()
body.add_child(n)
var ab := _mesh_aabb(n)
if ab.size != Vector3.ZERO:
var col := CollisionShape3D.new()
var sh := BoxShape3D.new()
sh.size = ab.size
col.shape = sh
col.position = ab.get_center()
body.add_child(col)
return body
func _mesh_aabb(root: Node3D) -> AABB:
var acc := AABB()
var started := false
for m in _meshes(root):
var mi := m as MeshInstance3D
var a := mi.get_aabb()
a = mi.transform * a
if not started:
acc = a
started = true
else:
acc = acc.merge(a)
return acc
func _meshes(n: Node, acc: Array = []) -> Array:
if n is MeshInstance3D:
acc.append(n)
for c in n.get_children():
_meshes(c, acc)
return acc
## Chairs are NOT placed here. They're the most satisfying thing in an office to send
## across the room, so they're spawned by Main as smashable rigid bodies; Office only
## says where they start.
func _chair(pos: Vector3, yaw: float) -> void:
_spots_chair.append(Transform3D(Basis(Vector3.UP, yaw), pos))
# ---------------------------------------------------------------- shell
func _shell() -> void:
var w := X_MAX - X_MIN
var d := Z_FRONT - Z_BACK
var cx := (X_MIN + X_MAX) * 0.5
var cz := (Z_BACK + Z_FRONT) * 0.5
_slab(Vector3(w + 1.0, 0.30, d + 1.0), Vector3(cx, -0.15, cz), _m_carpet)
# outer walls; the +X wall is the window wall, so it's built in _windows()
_wall_x(Z_BACK - WALL_T * 0.5, X_MIN, X_MAX, _m_wall)
_wall_x(Z_FRONT + WALL_T * 0.5, X_MIN, X_MAX, _m_wall, -8.6) # entrance
_wall_z(X_MIN - WALL_T * 0.5, Z_BACK, Z_FRONT, _m_wall)
_windows()
## The window wall. Dunder Mifflin's bullpen has that whole run of blinded windows down
## one side, and it's what stops an interior reading as a sealed box.
func _windows() -> void:
var x := X_MAX + WALL_T * 0.5
var y0 := 0.85
var y1 := 2.35
_slab(Vector3(WALL_T, y0, Z_FRONT - Z_BACK), Vector3(x, y0 * 0.5,
(Z_BACK + Z_FRONT) * 0.5), _m_wall)
_slab(Vector3(WALL_T, H - y1, Z_FRONT - Z_BACK), Vector3(x, (H + y1) * 0.5,
(Z_BACK + Z_FRONT) * 0.5), _m_wall)
# mullions every 2.4 m, glazing between
var z := Z_BACK
while z < Z_FRONT - 0.1:
var z1: float = minf(z + 2.4, Z_FRONT)
_slab(Vector3(0.05, y1 - y0, z1 - z - 0.12), Vector3(X_MAX - 0.02,
(y0 + y1) * 0.5, (z + z1) * 0.5), _m_glass, false, false)
_slab(Vector3(WALL_T, y1 - y0, 0.12), Vector3(x, (y0 + y1) * 0.5, z1), _m_trim)
# venetian blind, half-raised
_slab(Vector3(0.06, 0.55, z1 - z - 0.14), Vector3(X_MAX - 0.10, y1 - 0.28,
(z + z1) * 0.5), _m_ceil, false, false)
z = z1
func _ceiling_grid() -> void:
var w := X_MAX - X_MIN
var d := Z_FRONT - Z_BACK
var cx := (X_MIN + X_MAX) * 0.5
var cz := (Z_BACK + Z_FRONT) * 0.5
_slab(Vector3(w, 0.16, d), Vector3(cx, H + 0.08, cz), _m_ceil, true, false)
# suspended-tile T-bar, purely visual — it's the texture of an office ceiling
var gx := X_MIN
while gx <= X_MAX + 0.01:
_slab(Vector3(0.05, 0.03, d), Vector3(gx, H - 0.02, cz), _m_trim, false, false)
gx += 1.22
var gz := Z_BACK
while gz <= Z_FRONT + 0.01:
_slab(Vector3(w, 0.03, 0.05), Vector3(cx, H - 0.02, gz), _m_trim, false, false)
gz += 1.22
# ---------------------------------------------------------------- interior walls
func _interior_walls() -> void:
# manager's office — half solid, half glass so you can be watched through it
_wall_x(MGR_Z, X_MIN, MGR_X, _m_wall, -9.2)
_slab(Vector3(WALL_T, 1.0, MGR_Z - Z_BACK), Vector3(MGR_X, 0.5,
(Z_BACK + MGR_Z) * 0.5), _m_wall)
_slab(Vector3(0.06, H - 1.05, MGR_Z - Z_BACK), Vector3(MGR_X, (H + 1.0) * 0.5,
(Z_BACK + MGR_Z) * 0.5), _m_glass, true, false)
# conference room
_wall_x(CONF_Z, CONF_X0, CONF_X1, _m_wall, -3.4)
_wall_z(CONF_X1, Z_BACK, CONF_Z, _m_wall)
# break room
_wall_z(BREAK_X, BREAK_Z, Z_FRONT, _m_wall, 2.2)
_wall_x(BREAK_Z, BREAK_X, X_MAX, _m_wall)
# warehouse roller door on the back wall
_slab(Vector3(2.6, 2.4, 0.12), Vector3(7.8, 1.2, Z_BACK + 0.06), _m_trim)
for i in range(9):
_slab(Vector3(2.5, 0.06, 0.05), Vector3(7.8, 0.18 + i * 0.26,
Z_BACK + 0.14), _m_ceil, false, false)
# ---------------------------------------------------------------- lighting
## Recessed fluorescent troffers in the ceiling grid. These ARE the light: the old scene
## lit everything with one outdoor directional lamp, which is exactly why it read as
## "props on a plane" rather than "inside somewhere".
func _lights_rig() -> void:
var panel := StandardMaterial3D.new()
panel.albedo_color = Color(0.97, 0.97, 0.93)
panel.emission_enabled = true
panel.emission = Color(1.0, 0.98, 0.92)
panel.emission_energy_multiplier = 1.5
var xs := [-8.5, -4.6, -0.7, 3.2, 7.1]
var zs := [-6.2, -2.6, 1.0, 4.6]
for xc in xs:
for zc in zs:
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(1.14, 0.05, 0.58)
mi.mesh = bm
mi.material_override = panel
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(mi)
mi.position = Vector3(xc, H - 0.06, zc)
var l := OmniLight3D.new()
l.light_color = Color(1.0, 0.98, 0.93)
l.light_energy = 1.9
l.omni_range = 6.5
l.omni_attenuation = 1.2
l.shadow_enabled = false # 20 shadow-casting omnis is not worth the cost
add_child(l)
l.position = Vector3(xc, H - 0.20, zc)
# two shadow-casters so props still sit in contact shadows
for zc in [-2.0, 3.0]:
var key := OmniLight3D.new()
key.light_color = Color(1.0, 0.97, 0.92)
key.light_energy = 1.5
key.omni_range = 13.0
key.shadow_enabled = true
add_child(key)
key.position = Vector3(-1.0, H - 0.30, zc)
# daylight through the window wall
var sun := DirectionalLight3D.new()
sun.light_color = Color(0.82, 0.88, 1.0)
sun.light_energy = 0.7
sun.rotation_degrees = Vector3(-30, -104, 0)
sun.shadow_enabled = true
add_child(sun)
func configure_environment(env: Environment) -> void:
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.55, 0.62, 0.72) # daylight past the glass
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.50, 0.50, 0.50)
env.ambient_light_energy = 0.5
env.ssao_enabled = true
env.ssao_radius = 0.8
env.ssao_intensity = 1.5
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
env.tonemap_exposure = 1.0
# ---------------------------------------------------------------- bullpen
## Four pairs of desks facing each other across a low partition — the Jim-and-Dwight
## arrangement, which is the single most recognisable thing about the floor plan.
## Rows are 4.8 m apart, not 3.6. At 3.6 the two rows' chairs met back-to-back in the
## middle of the aisle with 3 cm to spare, which Jolt resolved by firing them out of the
## building — and it left no room to walk between the rows either.
const CLUSTERS := [
Vector2(-4.6, -2.0), Vector2(-4.6, 2.8),
Vector2(-0.4, -2.0), Vector2(-0.4, 2.8),
]
func _bullpen() -> void:
for c in CLUSTERS:
_partition(Vector3(c.x, 0.0, c.y), 1.9)
for s in [-1.0, 1.0]:
var t := Transform3D(Basis(Vector3.UP, 0.0 if s > 0.0 else PI),
Vector3(c.x, 0.0, c.y + s * 0.78))
_spots_desk.append(t)
# NOTE: the bullpen chairs are NOT listed here. Their offset depends on how
# deep office-desk.glb actually is, which only Main knows once it has measured
# the mesh — guessing a constant here put chairs inside desks.
# the copier alcove, between the bullpen and the break room
_slab(Vector3(WALL_T, H, 2.6), Vector3(3.6, H * 0.5, -0.3), _m_wall)
_spot_copier = Transform3D(Basis(Vector3.UP, deg_to_rad(-90.0)),
Vector3(3.0, 0.0, -0.3))
## A cubicle divider: fabric panel on a dark base rail.
func _partition(at: Vector3, length: float) -> void:
_slab(Vector3(length, 1.18, 0.07), at + Vector3(0, 0.62, 0), _m_part)
_slab(Vector3(length + 0.06, 0.09, 0.11), at + Vector3(0, 1.24, 0), _m_trim)
for s in [-1.0, 1.0]:
_slab(Vector3(0.09, 1.24, 0.30), at + Vector3(s * length * 0.5, 0.62, 0), _m_trim)
func desk_spots() -> Array[Transform3D]:
return _spots_desk
func chair_spots() -> Array[Transform3D]:
return _spots_chair
func copier_spot() -> Transform3D:
return _spot_copier
# ---------------------------------------------------------------- rooms
func _reception() -> void:
# the counter you meet on the way in, with the waiting-area chairs
_slab(Vector3(0.70, 1.10, 3.0), Vector3(-9.9, 0.55, RECEP_Z + 1.0), _m_wood)
_slab(Vector3(0.92, 0.08, 3.2), Vector3(-9.9, 1.14, RECEP_Z + 1.0), _m_trim)
_slab(Vector3(1.8, 1.10, 0.70), Vector3(-9.6, 0.55, RECEP_Z - 0.15), _m_wood)
_wall_z(-6.9, RECEP_Z, Z_FRONT, _m_wall, 6.8)
for i in range(3):
_chair(Vector3(-7.6, 0.0, 5.5 + i * 0.85), deg_to_rad(-90))
_glb(P + "office-plant.glb", Vector3(-6.4, 0.0, 6.6))
func _conference() -> void:
var cx := (CONF_X0 + CONF_X1) * 0.5
var cz := (Z_BACK + CONF_Z) * 0.5
# the long table everyone dreads
_slab(Vector3(3.9, 0.09, 1.35), Vector3(cx, 0.74, cz), _m_wood)
for s in [-1.0, 1.0]:
_slab(Vector3(0.16, 0.72, 0.90), Vector3(cx + s * 1.6, 0.36, cz), _m_trim)
for i in range(4):
var x := cx - 1.35 + i * 0.9
_chair(Vector3(x, 0.0, cz - 1.15), 0.0)
_chair(Vector3(x, 0.0, cz + 1.15), PI)
# whiteboard
_slab(Vector3(2.4, 1.15, 0.05), Vector3(cx, 1.6, Z_BACK + 0.10), _m_ceil)
_slab(Vector3(2.5, 1.25, 0.03), Vector3(cx, 1.6, Z_BACK + 0.07), _m_trim)
func _manager() -> void:
var cx := (X_MIN + MGR_X) * 0.5
var cz := (Z_BACK + MGR_Z) * 0.5
_chair(Vector3(cx, 0.0, cz - 0.9), 0.0)
_chair(Vector3(cx - 0.8, 0.0, cz + 1.1), PI)
_glb(P + "office-plant.glb", Vector3(X_MIN + 0.7, 0.0, MGR_Z - 0.8))
func _break_room() -> void:
var cz := (BREAK_Z + Z_FRONT) * 0.5
# kitchen counter along the window wall
_slab(Vector3(0.62, 0.88, 3.4), Vector3(X_MAX - 0.42, 0.44, cz + 0.6), _m_wood)
_slab(Vector3(0.70, 0.06, 3.5), Vector3(X_MAX - 0.42, 0.90, cz + 0.6), _m_trim)
_glb(P + "microwave.glb", Vector3(X_MAX - 0.45, 0.93, cz + 1.7), deg_to_rad(-90))
_glb(P + "vending-machine.glb", Vector3(BREAK_X + 0.9, 0.0, Z_FRONT - 0.6), PI)
_glb(P + "vending-machine.glb", Vector3(BREAK_X + 1.9, 0.0, Z_FRONT - 0.6), PI)
# two round tables with chairs
for i in range(2):
var tx := 7.4
var tz := BREAK_Z + 1.6 + i * 2.6
_slab(Vector3(1.05, 0.07, 1.05), Vector3(tx, 0.73, tz), _m_ceil)
_slab(Vector3(0.12, 0.70, 0.12), Vector3(tx, 0.36, tz), _m_trim)
_slab(Vector3(0.62, 0.05, 0.62), Vector3(tx, 0.03, tz), _m_trim)
for a in range(3):
var ang := deg_to_rad(a * 120.0 + 40.0)
_chair(Vector3(tx + sin(ang) * 0.95, 0.0, tz + cos(ang) * 0.95), ang + PI)
func _dressing() -> void:
_glb(P + "office-plant.glb", Vector3(2.9, 0.0, 6.4))
_glb(P + "office-plant.glb", Vector3(-0.9, 0.0, -3.6))
# wall clock, because every office has one you stare at
_slab(Vector3(0.42, 0.42, 0.05), Vector3(-3.4, 2.15, Z_FRONT - 0.02), _m_ceil)
_slab(Vector3(0.48, 0.48, 0.03), Vector3(-3.4, 2.15, Z_FRONT - 0.01), _m_trim)
## Where the player should start: just inside the entrance, looking into the bullpen.
func spawn_point() -> Vector3:
return Vector3(-8.6, 0.0, 7.2)
func spawn_yaw() -> float:
return deg_to_rad(196.0)