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>
459 lines
16 KiB
GDScript
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", ""))
|