destroyulator/game/scripts/Floorplan.gd
Monster Robot Party 4bcd7b49d9 LANE7: four workplaces, built from data
Office.gd is gone. Floorplan.gd consumes a spec Dictionary — bounds, palette, walls
with doors and glazing styles, window runs with mullions and blinds, ceiling style,
light grid and style, slabs, static props, desk clusters, chairs, level-specific
smashables and spawn — and Levels.gd holds four of them.

Making this data rather than four subclasses means a site is authorable in minutes,
levels can be diffed, and a real generator can later emit the same structure (a BSP
split of the footprint -> rooms -> doors on shared walls -> fittings by room type).
That was the actual answer to "can we use this git to generate plans": the repo John
found is OpenSCAD SVG->STL for 3D printing, with no generation, no plan parsing, no
room polygons and GPL-3.0, so it can't help — but Office.gd was already most of a
parametric plan builder, and lifting its numbers out is the real path.

The four sites are deliberately not reskins; they differ in the three things a player
actually reads — palette, light, and what the walls are made of:

  SCRANTON      magnolia, grey carpet, drop ceiling, fluorescent troffers, daylight
                down one glazed wall. The baseline.
  PAWNEE        civic beige and blue-grey, low partitions everywhere, a public
                counter, pinboards. Municipal and over-partitioned.
  THE INCUBATOR timber floor, white walls, 3 m ceiling, PENDANT lights, glass wall
                onto a pool. Nobody has an office; they work at a dining table.
  SUB-LEVEL 4   concrete, NO windows at all, exposed services, bare strip lights, a
                wall of server racks. The light is green and everything is junk.

L cycles sites in-game; _load_level tears down the shell, rebuilds, re-registers task
stations and re-arms the shift.

tools/gen_level_props.py adds ten more procedural props. The filing cabinet is the
important one: it exports as a CARCASS plus a separate DRAWER, each with its own
floor-centre origin, so the Gauntlet can pull a drawer out as its own rigid body and
spill the files. Also file folder, desk phone, guillotine, shredder, server rack,
sofa, wastebin, stapler.

dev/probe_levels.gd builds every level and reports residual motion after a full
second. All four read 0.00 m/s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:58:41 +10:00

459 lines
16 KiB
GDScript

extends Node3D
class_name Floorplan
## A workplace, built from a data spec.
##
## This started as Office.gd with the Dunder Mifflin numbers hard-coded. Lifting those
## numbers into a Dictionary buys three things: each new workplace is a data file rather
## than a new script, levels can be diffed and tuned without touching build code, and a
## real *generator* becomes possible later — a BSP split of the footprint emitting rooms,
## doors on shared walls and fittings by room type produces the same spec this already
## consumes.
##
## (For the record: `doratracyer/floor_plan` can't do that job. It's OpenSCAD turning
## hand-drawn SVG wall paths into 3D-printable STLs — no generation, no plan parsing, no
## room polygons, and GPL-3.0. Levels.gd is the better path.)
##
## SPEC — every key optional unless marked. See Levels.gd for worked examples.
## name, subtitle ...... strings for the HUD
## bounds * ............ {x0, x1, z0, z1, h}
## palette * ........... {floor, wall, ceiling, trim, partition, wood, accent} Colors
## floor_style ......... "carpet" | "lino" | "concrete" | "timber"
## ceiling ............. "tile" (T-bar grid) | "flat" | "open"
## walls ............... [{a:"x"|"z", at, from, to, door, style:"solid"|"glass_top"|"half"}]
## windows ............. [{a, at, from, to, y0, y1, blinds}]
## lights .............. {xs:[], zs:[], color, energy, style:"troffer"|"pendant"|"strip"}
## sun ................. {color, energy, rot:Vector3}
## env ................. {bg, ambient, ambient_energy}
## slabs ............... [{size:Vector3, at:Vector3, mat:String, collide, shadow}]
## props ............... [{glb, at:Vector3, yaw}] static, gets a box collider
## clusters ............ [Vector2] facing desk-pair centres
## cluster_gap ......... metres from a cluster centre to each of its two desks
## chairs .............. [{at:Vector3, yaw}] non-bullpen chairs
## smashables .......... [{glb, kind, at:Vector2, yaw, frozen}] level-specific breakables
## copier .............. {at:Vector3, yaw}
## spawn * ............. {at:Vector3, yaw}
##
## This class owns the SHELL, the FITTINGS and the LIGHTING — everything static. Main
## owns everything smashable and asks here where things go.
##
## Godot 4.7 GDScript 2.0.
const P := "res://assets/store/"
var spec: Dictionary = {}
var _mats: Dictionary = {}
var _spots_desk: Array[Transform3D] = []
var _spots_chair: Array[Transform3D] = []
var _clusters: Array = []
func build(s: Dictionary) -> void:
spec = s
_materials()
_shell()
_walls()
_windows()
_ceiling()
_lights()
_slabs()
_props()
_bullpen()
_chairs()
# ---------------------------------------------------------------- convenience
func _b(k: String) -> float:
return float((spec["bounds"] as Dictionary)[k])
func _pal(k: String, dflt := Color(0.7, 0.7, 0.7)) -> Color:
var p: Dictionary = spec.get("palette", {})
return p.get(k, dflt)
func _materials() -> void:
var rough := {"carpet": 1.0, "lino": 0.55, "concrete": 0.92, "timber": 0.5}
_mats["floor"] = _mk(_pal("floor", Color(0.30, 0.30, 0.32)),
float(rough.get(String(spec.get("floor_style", "carpet")), 1.0)))
_mats["wall"] = _mk(_pal("wall", Color(0.80, 0.78, 0.72)), 0.96)
_mats["ceiling"] = _mk(_pal("ceiling", Color(0.88, 0.88, 0.86)), 0.98)
_mats["trim"] = _mk(_pal("trim", Color(0.13, 0.12, 0.13)), 0.7)
_mats["partition"] = _mk(_pal("partition", Color(0.44, 0.44, 0.40)), 1.0)
_mats["wood"] = _mk(_pal("wood", Color(0.42, 0.29, 0.17)), 0.55)
_mats["accent"] = _mk(_pal("accent", Color(0.55, 0.20, 0.22)), 0.6)
var g := StandardMaterial3D.new()
g.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
g.albedo_color = Color(0.66, 0.74, 0.78, 0.15)
g.roughness = 0.05
g.cull_mode = BaseMaterial3D.CULL_DISABLED
_mats["glass"] = g
func _mk(c: Color, rough: float) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = c
m.roughness = rough
return m
func mat(name: String) -> Material:
return _mats.get(name, _mats["wall"])
# ---------------------------------------------------------------- primitives
func slab(size: Vector3, centre: Vector3, m: 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 = m
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
## Static dressing from a GLB, with a box collider sized to its own mesh.
func glb(path: String, pos: Vector3, yaw := 0.0) -> Node3D:
if not ResourceLoader.exists(path):
push_warning("[floorplan] missing prop %s" % 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
static 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.transform * mi.get_aabb()
if not started:
acc = a
started = true
else:
acc = acc.merge(a)
return acc
static 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
# ---------------------------------------------------------------- shell
func _shell() -> void:
var w := _b("x1") - _b("x0")
var d := _b("z1") - _b("z0")
var cx := (_b("x0") + _b("x1")) * 0.5
var cz := (_b("z0") + _b("z1")) * 0.5
slab(Vector3(w + 1.0, 0.30, d + 1.0), Vector3(cx, -0.15, cz), mat("floor"))
## One wall segment, optionally with a doorway punched in it. `style` picks how the
## upper half is treated: solid, glazed (the manager watching you), or absent (a
## counter-height divider you can see over but not walk through).
func _wall_seg(a: String, at: float, from: float, to: float, door: float,
style: String) -> void:
var h := _b("h")
var T := 0.16
var lo := 0.0
var hi := h
if style == "half":
hi = 1.15
var solid_h := hi - lo
var pieces: Array = []
if door == INF:
pieces.append([from, to, lo, solid_h])
else:
var d0 := door - 0.55
var d1 := door + 0.55
if d0 > from:
pieces.append([from, d0, lo, solid_h])
if to > d1:
pieces.append([d1, to, lo, solid_h])
if hi > 2.05:
pieces.append([d0, d1, 2.05, hi - 2.05])
for p in pieces:
var c := (float(p[0]) + float(p[1])) * 0.5
var l: float = float(p[1]) - float(p[0])
var y0: float = float(p[2])
var hh: float = float(p[3])
if hh <= 0.0 or l <= 0.0:
continue
if a == "x":
slab(Vector3(l, hh, T), Vector3(c, y0 + hh * 0.5, at), mat("wall"))
else:
slab(Vector3(T, hh, l), Vector3(at, y0 + hh * 0.5, c), mat("wall"))
# glazing above a half-height base
if style == "glass_top" and h > 1.05:
var gc := (from + to) * 0.5
var gl := to - from
if a == "x":
slab(Vector3(gl, h - 1.05, 0.06), Vector3(gc, (h + 1.0) * 0.5, at),
mat("glass"), true, false)
else:
slab(Vector3(0.06, h - 1.05, gl), Vector3(at, (h + 1.0) * 0.5, gc),
mat("glass"), true, false)
func _walls() -> void:
var h := _b("h")
var T := 0.16
var cx := (_b("x0") + _b("x1")) * 0.5
var cz := (_b("z0") + _b("z1")) * 0.5
# outer shell, minus any side declared as a window wall
var win_sides := {}
for wnd in spec.get("windows", []):
win_sides[_side_key(wnd)] = true
if not win_sides.has("z:" + str(_b("z0"))):
_wall_seg("x", _b("z0") - T * 0.5, _b("x0"), _b("x1"), INF, "solid")
if not win_sides.has("z:" + str(_b("z1"))):
_wall_seg("x", _b("z1") + T * 0.5, _b("x0"), _b("x1"), INF, "solid")
if not win_sides.has("x:" + str(_b("x0"))):
_wall_seg("z", _b("x0") - T * 0.5, _b("z0"), _b("z1"), INF, "solid")
if not win_sides.has("x:" + str(_b("x1"))):
_wall_seg("z", _b("x1") + T * 0.5, _b("z0"), _b("z1"), INF, "solid")
for wl in spec.get("walls", []):
var d: Dictionary = wl
_wall_seg(String(d.get("a", "x")), float(d["at"]), float(d["from"]),
float(d["to"]), float(d.get("door", INF)), String(d.get("style", "solid")))
func _side_key(w: Dictionary) -> String:
return "%s:%s" % [String(w.get("a", "x")), str(float(w["at"]))]
## A glazed run with mullions every 2.4 m and, optionally, half-raised blinds. It's what
## stops an interior reading as a sealed box.
func _windows() -> void:
var h := _b("h")
var T := 0.16
for wnd in spec.get("windows", []):
var d: Dictionary = wnd
var a := String(d.get("a", "x"))
var at := float(d["at"])
var f := float(d["from"])
var t := float(d["to"])
var y0 := float(d.get("y0", 0.85))
var y1 := float(d.get("y1", 2.35))
var blinds := bool(d.get("blinds", true))
var out := at + (T * 0.5 * signf(at))
var span := t - f
var c := (f + t) * 0.5
if a == "x":
slab(Vector3(span, y0, T), Vector3(c, y0 * 0.5, out), mat("wall"))
slab(Vector3(span, h - y1, T), Vector3(c, (h + y1) * 0.5, out), mat("wall"))
else:
slab(Vector3(T, y0, span), Vector3(out, y0 * 0.5, c), mat("wall"))
slab(Vector3(T, h - y1, span), Vector3(out, (h + y1) * 0.5, c), mat("wall"))
var p := f
while p < t - 0.1:
var p1: float = minf(p + 2.4, t)
var mid := (p + p1) * 0.5
var seg := p1 - p - 0.12
if a == "x":
slab(Vector3(seg, y1 - y0, 0.05), Vector3(mid, (y0 + y1) * 0.5, at),
mat("glass"), false, false)
slab(Vector3(0.12, y1 - y0, T), Vector3(p1, (y0 + y1) * 0.5, out), mat("trim"))
if blinds:
slab(Vector3(seg, 0.55, 0.06), Vector3(mid, y1 - 0.28,
at - 0.10 * signf(at)), mat("ceiling"), false, false)
else:
slab(Vector3(0.05, y1 - y0, seg), Vector3(at, (y0 + y1) * 0.5, mid),
mat("glass"), false, false)
slab(Vector3(T, y1 - y0, 0.12), Vector3(out, (y0 + y1) * 0.5, p1), mat("trim"))
if blinds:
slab(Vector3(0.06, 0.55, seg), Vector3(at - 0.10 * signf(at),
y1 - 0.28, mid), mat("ceiling"), false, false)
p = p1
func _ceiling() -> void:
var style := String(spec.get("ceiling", "tile"))
if style == "open":
return
var w := _b("x1") - _b("x0")
var d := _b("z1") - _b("z0")
var cx := (_b("x0") + _b("x1")) * 0.5
var cz := (_b("z0") + _b("z1")) * 0.5
var h := _b("h")
slab(Vector3(w, 0.16, d), Vector3(cx, h + 0.08, cz), mat("ceiling"), true, false)
if style != "tile":
return
# suspended-tile T-bar. Purely visual, and it IS the texture of an office ceiling.
var gx := _b("x0")
while gx <= _b("x1") + 0.01:
slab(Vector3(0.05, 0.03, d), Vector3(gx, h - 0.02, cz), mat("trim"), false, false)
gx += 1.22
var gz := _b("z0")
while gz <= _b("z1") + 0.01:
slab(Vector3(w, 0.03, 0.05), Vector3(cx, h - 0.02, gz), mat("trim"), false, false)
gz += 1.22
# ---------------------------------------------------------------- lighting
func _lights() -> void:
var L: Dictionary = spec.get("lights", {})
var xs: Array = L.get("xs", [])
var zs: Array = L.get("zs", [])
var col: Color = L.get("color", Color(1.0, 0.98, 0.93))
var energy := float(L.get("energy", 1.9))
var style := String(L.get("style", "troffer"))
var h := _b("h")
var glow := StandardMaterial3D.new()
glow.albedo_color = Color(0.97, 0.97, 0.93)
glow.emission_enabled = true
glow.emission = col
glow.emission_energy_multiplier = 1.5
for xc in xs:
for zc in zs:
var y := h - 0.06
var size := Vector3(1.14, 0.05, 0.58)
match style:
"pendant":
y = h - 0.55
size = Vector3(0.30, 0.16, 0.30)
slab(Vector3(0.02, 0.45, 0.02), Vector3(xc, h - 0.24, zc),
mat("trim"), false, false)
"strip":
size = Vector3(1.30, 0.10, 0.14)
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = size
mi.mesh = bm
mi.material_override = glow
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(mi)
mi.position = Vector3(xc, y, zc)
var l := OmniLight3D.new()
l.light_color = col
l.light_energy = energy
l.omni_range = 6.5
l.omni_attenuation = 1.2
l.shadow_enabled = false # a grid of shadow-casting omnis isn't worth it
add_child(l)
l.position = Vector3(xc, y - 0.14, zc)
# a couple of shadow-casters so props still sit in contact shadows
var cx := (_b("x0") + _b("x1")) * 0.5
for zc in [_b("z0") * 0.45, _b("z1") * 0.45]:
var key := OmniLight3D.new()
key.light_color = col
key.light_energy = float(L.get("key_energy", 1.5))
key.omni_range = 13.0
key.shadow_enabled = true
add_child(key)
key.position = Vector3(cx, h - 0.30, zc)
var S: Dictionary = spec.get("sun", {})
if not S.is_empty():
var sun := DirectionalLight3D.new()
sun.light_color = S.get("color", Color(0.82, 0.88, 1.0))
sun.light_energy = float(S.get("energy", 0.7))
sun.rotation_degrees = S.get("rot", Vector3(-30, -104, 0))
sun.shadow_enabled = bool(S.get("shadow", true))
add_child(sun)
func configure_environment(env: Environment) -> void:
var E: Dictionary = spec.get("env", {})
env.background_mode = Environment.BG_COLOR
env.background_color = E.get("bg", Color(0.55, 0.62, 0.72))
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = E.get("ambient", Color(0.50, 0.50, 0.50))
env.ambient_light_energy = float(E.get("ambient_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 = float(E.get("exposure", 1.0))
# ---------------------------------------------------------------- contents
func _slabs() -> void:
for s in spec.get("slabs", []):
var d: Dictionary = s
slab(d["size"], d["at"], mat(String(d.get("mat", "wall"))),
bool(d.get("collide", true)), bool(d.get("shadow", true)))
func _props() -> void:
for p in spec.get("props", []):
var d: Dictionary = p
glb(P + String(d["glb"]) + ".glb", d["at"], float(d.get("yaw", 0.0)))
## Facing desk pairs behind a cubicle divider — the Jim-and-Dwight arrangement.
func _bullpen() -> void:
_clusters = spec.get("clusters", [])
var gap := float(spec.get("cluster_gap", 0.78))
for c in _clusters:
var v: Vector2 = c
_partition(Vector3(v.x, 0.0, v.y), 1.9)
for s in [-1.0, 1.0]:
_spots_desk.append(Transform3D(Basis(Vector3.UP, 0.0 if s > 0.0 else PI),
Vector3(v.x, 0.0, v.y + s * gap)))
func _partition(at: Vector3, length: float) -> void:
slab(Vector3(length, 1.18, 0.07), at + Vector3(0, 0.62, 0), mat("partition"))
slab(Vector3(length + 0.06, 0.09, 0.11), at + Vector3(0, 1.24, 0), mat("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), mat("trim"))
func _chairs() -> void:
for c in spec.get("chairs", []):
var d: Dictionary = c
_spots_chair.append(Transform3D(Basis(Vector3.UP, float(d.get("yaw", 0.0))),
d["at"]))
# ---------------------------------------------------------------- reads
func desk_spots() -> Array[Transform3D]:
return _spots_desk
func chair_spots() -> Array[Transform3D]:
return _spots_chair
## Cluster centre for desk index `i`, so Main can work out which side the chair goes.
func desk_cluster(i: int) -> Vector2:
var c: int = i / 2
if c < 0 or c >= _clusters.size():
return Vector2.ZERO
return _clusters[c]
func copier_spot() -> Transform3D:
var c: Dictionary = spec.get("copier", {})
if c.is_empty():
return Transform3D.IDENTITY
return Transform3D(Basis(Vector3.UP, float(c.get("yaw", 0.0))), c["at"])
func has_copier() -> bool:
return spec.has("copier")
func smashables() -> Array:
return spec.get("smashables", [])
func spawn_point() -> Vector3:
return (spec["spawn"] as Dictionary)["at"]
func spawn_yaw() -> float:
return float((spec["spawn"] as Dictionary).get("yaw", 0.0))
func level_name() -> String:
return String(spec.get("name", "UNNAMED SITE"))
func subtitle() -> String:
return String(spec.get("subtitle", ""))