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", ""))