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>
This commit is contained in:
Monster Robot Party 2026-07-31 17:58:41 +10:00
parent dd905d908d
commit 4bcd7b49d9
31 changed files with 1919 additions and 581 deletions

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://si6xdny7yehl"
path="res://.godot/imported/cabinet-carcass.glb-238befe94afa5291839887691432da2b.scn"
[deps]
source_file="res://assets/store/cabinet-carcass.glb"
dest_files=["res://.godot/imported/cabinet-carcass.glb-238befe94afa5291839887691432da2b.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cey5a314gtnuy"
path="res://.godot/imported/cabinet-drawer.glb-b3f72509ee62d2d19c1a0038604be29c.scn"
[deps]
source_file="res://assets/store/cabinet-drawer.glb"
dest_files=["res://.godot/imported/cabinet-drawer.glb-b3f72509ee62d2d19c1a0038604be29c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b0ryrh1b87oad"
path="res://.godot/imported/desk-phone.glb-0b4e710e838e5ab39f12cd38794928db.scn"
[deps]
source_file="res://assets/store/desk-phone.glb"
dest_files=["res://.godot/imported/desk-phone.glb-0b4e710e838e5ab39f12cd38794928db.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b6wn6ivjipo38"
path="res://.godot/imported/file-folder.glb-996d4a433542c9c1abe7cf8adf443e60.scn"
[deps]
source_file="res://assets/store/file-folder.glb"
dest_files=["res://.godot/imported/file-folder.glb-996d4a433542c9c1abe7cf8adf443e60.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://me4h4r87qi8a"
path="res://.godot/imported/guillotine.glb-0850d7d61b8c7893d2ad4b765c14a621.scn"
[deps]
source_file="res://assets/store/guillotine.glb"
dest_files=["res://.godot/imported/guillotine.glb-0850d7d61b8c7893d2ad4b765c14a621.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dfj8ngisb8pji"
path="res://.godot/imported/server-rack.glb-e13aeaf7bf0cdf2cd49217c33d8e2448.scn"
[deps]
source_file="res://assets/store/server-rack.glb"
dest_files=["res://.godot/imported/server-rack.glb-e13aeaf7bf0cdf2cd49217c33d8e2448.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dtjftptqfqw2j"
path="res://.godot/imported/shredder.glb-e7be012212295ed44f5a7a52ad82e1b0.scn"
[deps]
source_file="res://assets/store/shredder.glb"
dest_files=["res://.godot/imported/shredder.glb-e7be012212295ed44f5a7a52ad82e1b0.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

BIN
game/assets/store/sofa.glb Normal file

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dinkdfb8jin4m"
path="res://.godot/imported/sofa.glb-c2ee6c7bd1e23613e6084f217cc88f31.scn"
[deps]
source_file="res://assets/store/sofa.glb"
dest_files=["res://.godot/imported/sofa.glb-c2ee6c7bd1e23613e6084f217cc88f31.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://02eubr818s2p"
path="res://.godot/imported/stapler.glb-9abfdda7570f61d9ca4808557b7ea9c6.scn"
[deps]
source_file="res://assets/store/stapler.glb"
dest_files=["res://.godot/imported/stapler.glb-9abfdda7570f61d9ca4808557b7ea9c6.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://baxe2k4e2nkxf"
path="res://.godot/imported/wastebin.glb-fbd43ede5b2ede38a8e605f1a922673f.scn"
[deps]
source_file="res://assets/store/wastebin.glb"
dest_files=["res://.godot/imported/wastebin.glb-fbd43ede5b2ede38a8e605f1a922673f.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

View File

@ -48,85 +48,33 @@ func _find_player(n: Node) -> Player:
return null
func _build_script() -> void:
# Office floor plan (see Office.gd): spawn at reception (-8.6, 6.6) looking into the
# bullpen; desk clusters at (-4.6,-1.4) (-4.6,2.2) (-0.4,-1.4) (-0.4,2.2); copier
# alcove at (3.0,-0.3); break room x>5.4 z>-1.2; conference and manager along z<-4.4;
# warehouse corner around (7,-6).
# A lap of all four sites. Each gets a settle beat (nothing must move on its own)
# and two vantage points, so the palette, lighting and layout can all be read.
_script = []
var tours := {
"scranton": [[Vector3(-8.6, 0, 6.4), Vector3(-4.0, 1.1, 0.0)],
[Vector3(-2.6, 0, 3.4), Vector3(-0.4, 1.0, -1.0)],
[Vector3(1.6, 0, -2.0), Vector3(8.0, 1.3, -6.5)]],
"pawnee": [[Vector3(-8.0, 0, 6.6), Vector3(-2.0, 1.1, 1.0)],
[Vector3(-1.0, 0, 2.6), Vector3(-2.6, 1.2, -5.2)],
[Vector3(4.0, 0, 0.6), Vector3(6.5, 1.2, -3.2)]],
"house": [[Vector3(-1.0, 0, 7.0), Vector3(-4.4, 1.2, 0.6)],
[Vector3(0.5, 0, 2.0), Vector3(6.0, 1.3, 6.0)],
[Vector3(-6.0, 0, -2.0), Vector3(4.0, 1.4, -6.0)]],
"basement": [[Vector3(1.0, 0, -5.2), Vector3(-8.0, 1.3, -1.0)],
[Vector3(-4.0, 0, -1.0), Vector3(-10.6, 1.4, -1.0)],
[Vector3(4.0, 0, 1.0), Vector3(9.0, 1.3, 5.4)]],
}
for id in ["scranton", "pawnee", "house", "basement"]:
var shots: Array = tours[id]
_script.append({"dur": 0.4, "move_to": shots[0][0], "look_at": shots[0][1],
"act": "level:" + id})
_script.append({"dur": 2.2, "move_to": null, "look_at": null, "act": ""})
for i in range(1, shots.size()):
_script.append({"dur": 2.4, "move_to": shots[i][0], "look_at": shots[i][1],
"act": ""})
_script.append({"dur": 1.2, "move_to": null, "look_at": null, "act": ""})
# ---- ACT 0: SETTLE CHECK. Stand at the entrance and touch nothing. If anything
# ---- moves in these five seconds, the level is falling over by itself again.
_script += [
{"dur": 2.5, "move_to": Vector3(-8.6, 0, 6.4), "look_at": Vector3(-4.0, 1.1, 0.0), "act": ""},
{"dur": 2.5, "move_to": null, "look_at": null, "act": ""},
]
# ---- ACT 0b: CUBICLE HELL. Sit at a desk, do the spreadsheet, get wound up until
# ---- the meter snaps, then wreck the place until it drains.
_script += [
{"dur": 2.4, "move_to": Vector3(-4.6, 0, -0.55), "look_at": Vector3(-4.6, 1.0, -1.4), "act": ""},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "task"},
{"dur": 1.6, "move_to": null, "look_at": null, "act": "type"},
{"dur": 1.6, "move_to": null, "look_at": null, "act": "commit"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "rage:0.30"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "rage:0.30"},
{"dur": 1.6, "move_to": null, "look_at": null, "act": "rage:0.35"},
# SNAP — the vignette closes in, veins crawl, heartbeat goes. Now wreck things.
{"dur": 1.0, "move_to": Vector3(-4.6, 0, 0.3), "look_at": Vector3(-4.6, 0.9, -0.7), "act": "weapon:2"},
{"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.6, "move_to": Vector3(-2.6, 0, 4.8), "look_at": Vector3(-2.3, 0.25, 5.2), "act": ""},
{"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "swing"},
]
# ---- ACT 1: a walk through the floor plan, so the rooms read.
_script += [
{"dur": 2.2, "move_to": Vector3(-8.4, 0, 2.0), "look_at": Vector3(-4.6, 1.1, 1.0), "act": ""},
{"dur": 2.2, "move_to": Vector3(-2.6, 0, 3.4), "look_at": Vector3(-0.4, 1.0, 0.5), "act": ""},
{"dur": 2.2, "move_to": Vector3(-3.0, 0, -3.0), "look_at": Vector3(-3.4, 1.4, -6.4), "act": ""},
{"dur": 2.2, "move_to": Vector3(1.6, 0, -2.0), "look_at": Vector3(8.0, 1.3, -6.5), "act": ""},
{"dur": 2.2, "move_to": Vector3(3.4, 0, 3.0), "look_at": Vector3(8.0, 1.2, 4.0), "act": ""},
]
# ---- ACT 2: the loadout, held still so each grip can be inspected.
for slot in range(6):
_script.append({"dur": 0.5, "move_to": Vector3(-2.2, 0, 4.6),
"look_at": Vector3(-2.0, 1.2, 0.0), "act": "weapon:%d" % slot})
_script.append({"dur": 0.8, "move_to": null, "look_at": null, "act": ""})
_script.append({"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"})
# ---- ACT 3: the matrix. Cutter shreds a carton, clanks off a filing cabinet,
# ---- then the sledge takes the cabinet out.
_script += [
{"dur": 0.5, "move_to": null, "look_at": null, "act": "weapon:1"},
{"dur": 2.0, "move_to": Vector3(-1.6, 0, 5.3), "look_at": Vector3(-2.3, 0.25, 5.2), "act": ""},
{"dur": 0.5, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 2.0, "move_to": Vector3(3.1, 0, -0.9), "look_at": Vector3(4.5, 0.6, -0.9), "act": ""},
{"dur": 0.4, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 0.5, "move_to": null, "look_at": null, "act": "weapon:4"},
{"dur": 1.1, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.3, "move_to": null, "look_at": null, "act": "swing"},
]
# ---- ACT 4: the printer boss, then a lap of the HUD styles.
_script += [
{"dur": 2.0, "move_to": Vector3(1.5, 0, -0.3), "look_at": Vector3(3.0, 0.95, -0.3), "act": ""},
{"dur": 1.1, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.1, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.1, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.5, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 2.0, "move_to": Vector3(-1.0, 0, 5.6), "look_at": Vector3(-1.0, 1.2, -1.0), "act": "hud"},
{"dur": 1.8, "move_to": null, "look_at": null, "act": "hud"},
{"dur": 1.8, "move_to": null, "look_at": null, "act": "hud"},
{"dur": 1.8, "move_to": null, "look_at": null, "act": "hud"},
]
## Total speed across every physics body in the level. During the settle act nobody has
## touched anything, so this MUST fall to ~0 — that is the objective version of "is the
## level quietly falling over on its own before the player does anything".
func _motion(verbose := false) -> float:
var total := 0.0
var seen := {}
@ -215,6 +163,10 @@ func _do(act: String) -> void:
if act.begins_with("weapon:"):
_player.select(int(act.substr(7)))
return
if act.begins_with("level:"):
if _main.has_method("_load_level"):
_main._load_level(act.substr(6))
return
if act.begins_with("rage:"):
var r = _main.get("_rage")
if r != null:

49
game/dev/probe_levels.gd Normal file
View File

@ -0,0 +1,49 @@
extends SceneTree
## Build every level in Levels.ORDER and report whether it's structurally sound:
## prop count, missing assets, and — the one that matters — whether anything is spawned
## inside anything else. A level that fires furniture across the room on frame one is
## broken whatever it looks like in a screenshot.
##
## Godot --headless --path game --script dev/probe_levels.gd
func _initialize() -> void:
var main: Node = (load("res://main.tscn") as PackedScene).instantiate()
get_root().add_child(main)
await process_frame
for id in Levels.ORDER:
main._load_level(id)
for i in 8:
await physics_frame
var plan = main.get("_plan")
var bodies := get_nodes_in_group("smashable").size()
var statics := _count_static(plan)
# let it run a full second and see if anything is still moving
for i in 60:
await physics_frame
var motion := 0.0
var worst := ""
var worst_v := 0.0
for n in get_nodes_in_group("smashable"):
var b := n as RigidBody3D
if b == null or b.freeze:
continue
var v := b.linear_velocity.length()
motion += v
if v > worst_v:
worst_v = v
worst = "%s at %s" % [b.name, b.global_position.snappedf(0.1)]
print("\n=== %-14s %s" % [id, plan.level_name()])
print(" smashables=%-4d static bodies=%-4d" % [bodies, statics])
print(" residual motion after 1s = %.2f m/s worst: %s" % [
motion, worst if worst != "" else "-"])
quit()
func _count_static(n: Node) -> int:
var c := 0
if n is StaticBody3D:
c += 1
for ch in n.get_children():
c += _count_static(ch)
return c

458
game/scripts/Floorplan.gd Normal file
View File

@ -0,0 +1,458 @@
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", ""))

View File

@ -0,0 +1 @@
uid://d0oexxkrw0hln

428
game/scripts/Levels.gd Normal file
View File

@ -0,0 +1,428 @@
extends RefCounted
class_name Levels
## The workplaces, as data. Each is a Floorplan spec (see Floorplan.gd for the schema).
##
## The point of these being data and not four subclasses: a new site is authorable in
## minutes, they can be diffed, and a generator can later emit the same structure.
##
## They are deliberately NOT reskins. Each differs in the three things a player actually
## reads — the palette, the light, and what the walls are made of:
##
## SCRANTON ..... magnolia, grey carpet, drop ceiling, fluorescent troffers, daylight
## down one glazed wall. The baseline office.
## PAWNEE ....... civic beige and blue-grey, low partitions everywhere, a public
## counter, pinboards. Municipal, over-partitioned, no privacy.
## THE HOUSE .... timber floor, white walls, 3 m ceiling, pendant lights, a glass wall
## onto a pool. Nobody here has an office; they work at a dining table.
## THE BASEMENT . concrete, no windows at all, exposed services, bare strip lights, a
## wall of server racks. The light is green and everything is junk.
##
## Godot 4.7 GDScript 2.0.
const ORDER := ["scranton", "pawnee", "house", "basement"]
static func get_level(id: String) -> Dictionary:
match id:
"pawnee": return pawnee()
"house": return house()
"basement": return basement()
return scranton()
static func next_id(id: String) -> String:
var i := ORDER.find(id)
return ORDER[(i + 1) % ORDER.size()] if i >= 0 else ORDER[0]
# ================================================================ LEVEL 01
static func scranton() -> Dictionary:
return {
"name": "SCRANTON BRANCH",
"subtitle": "paper company · 2nd floor",
"bounds": {"x0": -11.0, "x1": 11.0, "z0": -8.0, "z1": 8.0, "h": 2.75},
"palette": {
"floor": Color(0.30, 0.30, 0.32), "wall": Color(0.80, 0.78, 0.72),
"ceiling": Color(0.88, 0.88, 0.86), "trim": Color(0.13, 0.12, 0.13),
"partition": Color(0.44, 0.44, 0.40), "wood": Color(0.42, 0.29, 0.17),
"accent": Color(0.36, 0.42, 0.50),
},
"floor_style": "carpet",
"ceiling": "tile",
"walls": [
# manager's office — half solid, half glass, so you can be watched through it
{"a": "x", "at": -4.4, "from": -11.0, "to": -6.6, "door": -9.2},
{"a": "z", "at": -6.6, "from": -8.0, "to": -4.4, "style": "glass_top"},
# conference room
{"a": "x", "at": -4.4, "from": -6.6, "to": -0.4, "door": -3.4},
{"a": "z", "at": -0.4, "from": -8.0, "to": -4.4},
# break room
{"a": "z", "at": 5.4, "from": -1.2, "to": 8.0, "door": 2.2},
{"a": "x", "at": -1.2, "from": 5.4, "to": 11.0},
# copier alcove
{"a": "z", "at": 3.6, "from": -1.6, "to": 1.0},
# reception screen
{"a": "z", "at": -6.9, "from": 4.6, "to": 8.0, "door": 6.8},
],
"windows": [{"a": "z", "at": 11.0, "from": -8.0, "to": 8.0,
"y0": 0.85, "y1": 2.35, "blinds": true}],
"lights": {"xs": [-8.5, -4.6, -0.7, 3.2, 7.1], "zs": [-6.2, -2.6, 1.0, 4.6],
"color": Color(1.0, 0.98, 0.93), "energy": 1.9, "style": "troffer"},
"sun": {"color": Color(0.82, 0.88, 1.0), "energy": 0.7,
"rot": Vector3(-30, -104, 0)},
"env": {"bg": Color(0.55, 0.62, 0.72), "ambient": Color(0.50, 0.50, 0.50),
"ambient_energy": 0.5},
"clusters": [Vector2(-4.6, -2.0), Vector2(-4.6, 2.8),
Vector2(-0.4, -2.0), Vector2(-0.4, 2.8)],
"cluster_gap": 0.78,
"slabs": [
# reception counter
{"size": Vector3(0.70, 1.10, 3.0), "at": Vector3(-9.9, 0.55, 5.6), "mat": "wood"},
{"size": Vector3(0.92, 0.08, 3.2), "at": Vector3(-9.9, 1.14, 5.6), "mat": "trim"},
{"size": Vector3(1.8, 1.10, 0.70), "at": Vector3(-9.6, 0.55, 4.45), "mat": "wood"},
# conference table + whiteboard
{"size": Vector3(3.9, 0.09, 1.35), "at": Vector3(-3.5, 0.74, -6.2), "mat": "wood"},
{"size": Vector3(0.16, 0.72, 0.90), "at": Vector3(-5.1, 0.36, -6.2), "mat": "trim"},
{"size": Vector3(0.16, 0.72, 0.90), "at": Vector3(-1.9, 0.36, -6.2), "mat": "trim"},
{"size": Vector3(2.5, 1.25, 0.03), "at": Vector3(-3.5, 1.6, -7.93), "mat": "trim"},
{"size": Vector3(2.4, 1.15, 0.05), "at": Vector3(-3.5, 1.6, -7.90), "mat": "ceiling"},
# break room kitchen + tables
{"size": Vector3(0.62, 0.88, 3.4), "at": Vector3(10.58, 0.44, 4.0), "mat": "wood"},
{"size": Vector3(0.70, 0.06, 3.5), "at": Vector3(10.58, 0.90, 4.0), "mat": "trim"},
{"size": Vector3(1.05, 0.07, 1.05), "at": Vector3(7.4, 0.73, 0.4), "mat": "ceiling"},
{"size": Vector3(0.12, 0.70, 0.12), "at": Vector3(7.4, 0.36, 0.4), "mat": "trim"},
{"size": Vector3(1.05, 0.07, 1.05), "at": Vector3(7.4, 0.73, 3.0), "mat": "ceiling"},
{"size": Vector3(0.12, 0.70, 0.12), "at": Vector3(7.4, 0.36, 3.0), "mat": "trim"},
# warehouse roller door
{"size": Vector3(2.6, 2.4, 0.12), "at": Vector3(7.8, 1.2, -7.94), "mat": "trim"},
],
"props": [
{"glb": "store-column-white", "at": Vector3(-2.2, 0.0, -3.4)},
{"glb": "store-column-white", "at": Vector3(2.2, 0.0, -3.4)},
{"glb": "store-aircon", "at": Vector3(2.6, 2.35, -7.9)},
{"glb": "office-plant", "at": Vector3(-6.4, 0.0, 6.6)},
{"glb": "office-plant", "at": Vector3(2.9, 0.0, 6.4)},
{"glb": "vending-machine", "at": Vector3(6.3, 0.0, 7.4), "yaw": PI},
{"glb": "vending-machine", "at": Vector3(7.3, 0.0, 7.4), "yaw": PI},
{"glb": "microwave", "at": Vector3(10.55, 0.93, 5.1), "yaw": -PI / 2},
],
"chairs": [
{"at": Vector3(-7.6, 0.0, 5.5), "yaw": -PI / 2},
{"at": Vector3(-7.6, 0.0, 6.35), "yaw": -PI / 2},
{"at": Vector3(-7.6, 0.0, 7.2), "yaw": -PI / 2},
{"at": Vector3(-4.85, 0.0, -7.35), "yaw": 0.0},
{"at": Vector3(-3.95, 0.0, -7.35), "yaw": 0.0},
{"at": Vector3(-3.05, 0.0, -7.35), "yaw": 0.0},
{"at": Vector3(-2.15, 0.0, -7.35), "yaw": 0.0},
{"at": Vector3(-4.85, 0.0, -5.05), "yaw": PI},
{"at": Vector3(-3.95, 0.0, -5.05), "yaw": PI},
{"at": Vector3(-3.05, 0.0, -5.05), "yaw": PI},
{"at": Vector3(-2.15, 0.0, -5.05), "yaw": PI},
{"at": Vector3(-8.8, 0.0, -7.1), "yaw": 0.0},
{"at": Vector3(-9.6, 0.0, -5.1), "yaw": PI},
{"at": Vector3(8.0, 0.0, 1.13), "yaw": 2.0},
{"at": Vector3(7.7, 0.0, -0.49), "yaw": 4.1},
{"at": Vector3(6.5, 0.0, 0.56), "yaw": 0.2},
{"at": Vector3(8.0, 0.0, 3.73), "yaw": 2.0},
{"at": Vector3(7.7, 0.0, 2.11), "yaw": 4.1},
{"at": Vector3(6.5, 0.0, 3.16), "yaw": 0.2},
],
"copier": {"at": Vector3(3.0, 0.0, -0.3), "yaw": -PI / 2},
"smashables": [
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(4.5, -1.6)},
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(4.5, -0.9)},
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(4.5, -0.2)},
{"glb": "water-cooler", "kind": "steel", "at": Vector2(-6.2, 3.4)},
{"glb": "water-cooler", "kind": "steel", "at": Vector2(5.9, -0.6)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-2.3, 5.2)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(1.6, 5.4)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(2.1, 4.7)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(6.4, -3.3)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(7.3, -3.8)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-9.7, 1.2)},
{"glb": "wastebin", "kind": "plastic", "at": Vector2(-6.0, -1.0)},
{"glb": "wastebin", "kind": "plastic", "at": Vector2(1.0, 1.0)},
{"glb": "desk-phone", "kind": "plastic", "at": Vector2(-9.8, 5.0), "sit_on": 1.18},
{"glb": "rack", "kind": "wood", "at": Vector2(9.4, -6.6), "frozen": true},
{"glb": "rack", "kind": "wood", "at": Vector2(9.4, -4.3), "frozen": true},
{"glb": "turntable", "kind": "wood", "at": Vector2(6.9, -5.9)},
],
"records": [Vector2(6.2, -6.6), Vector2(7.0, -6.9), Vector2(7.8, -6.6)],
"spawn": {"at": Vector3(-8.6, 0.0, 7.2), "yaw": deg_to_rad(196.0)},
}
# ================================================================ LEVEL 02
static func pawnee() -> Dictionary:
return {
"name": "PAWNEE CITY HALL",
"subtitle": "parks & recreation · 4th floor",
"bounds": {"x0": -10.0, "x1": 10.0, "z0": -7.0, "z1": 7.5, "h": 2.70},
"palette": {
"floor": Color(0.34, 0.36, 0.40), "wall": Color(0.84, 0.80, 0.68),
"ceiling": Color(0.90, 0.89, 0.85), "trim": Color(0.30, 0.22, 0.14),
"partition": Color(0.52, 0.50, 0.42), "wood": Color(0.50, 0.34, 0.19),
"accent": Color(0.16, 0.36, 0.28),
},
"floor_style": "carpet",
"ceiling": "tile",
"walls": [
# the department head's office, and the meeting room beside it
{"a": "x", "at": -3.4, "from": -10.0, "to": -5.6, "door": -7.0},
{"a": "z", "at": -5.6, "from": -7.0, "to": -3.4, "style": "glass_top"},
{"a": "x", "at": -3.4, "from": -5.6, "to": 0.4, "door": -1.6},
{"a": "z", "at": 0.4, "from": -7.0, "to": -3.4},
# records room, and the corridor wall that makes this place a warren
{"a": "z", "at": 4.6, "from": -7.0, "to": -2.0, "door": -4.4},
{"a": "x", "at": -2.0, "from": 4.6, "to": 10.0},
# the public counter divides the front third from everything else
{"a": "x", "at": 3.6, "from": -10.0, "to": 3.0, "door": -8.0, "style": "half"},
],
"windows": [{"a": "x", "at": 7.5, "from": -10.0, "to": 10.0,
"y0": 0.95, "y1": 2.30, "blinds": true}],
"lights": {"xs": [-7.5, -3.4, 0.7, 4.8, 8.4], "zs": [-5.2, -1.6, 2.0, 5.6],
"color": Color(1.0, 0.96, 0.86), "energy": 1.8, "style": "troffer",
"key_energy": 1.4},
"sun": {"color": Color(0.90, 0.88, 0.78), "energy": 0.55,
"rot": Vector3(-34, 20, 0)},
"env": {"bg": Color(0.62, 0.66, 0.62), "ambient": Color(0.50, 0.48, 0.44),
"ambient_energy": 0.52, "exposure": 1.02},
"clusters": [Vector2(-6.0, 0.4), Vector2(-1.4, 0.4), Vector2(2.6, 0.4)],
"cluster_gap": 0.78,
"slabs": [
# the public counter itself — the thing between you and the citizens
{"size": Vector3(6.6, 1.06, 0.66), "at": Vector3(-1.2, 0.53, 5.4), "mat": "wood"},
{"size": Vector3(6.9, 0.07, 0.86), "at": Vector3(-1.2, 1.10, 5.4), "mat": "trim"},
{"size": Vector3(0.66, 1.06, 2.2), "at": Vector3(2.1, 0.53, 6.4), "mat": "wood"},
# meeting room table
{"size": Vector3(2.9, 0.09, 1.25), "at": Vector3(-2.6, 0.74, -5.2), "mat": "wood"},
{"size": Vector3(0.14, 0.72, 0.84), "at": Vector3(-3.7, 0.36, -5.2), "mat": "trim"},
{"size": Vector3(0.14, 0.72, 0.84), "at": Vector3(-1.5, 0.36, -5.2), "mat": "trim"},
# pinboards — municipal offices are 40% pinboard by surface area
{"size": Vector3(2.2, 1.20, 0.05), "at": Vector3(5.0, 1.65, -6.93), "mat": "accent"},
{"size": Vector3(2.3, 1.30, 0.03), "at": Vector3(5.0, 1.65, -6.90), "mat": "trim"},
{"size": Vector3(0.05, 1.20, 2.6), "at": Vector3(-9.93, 1.62, 2.0), "mat": "accent"},
{"size": Vector3(0.03, 1.30, 2.7), "at": Vector3(-9.90, 1.62, 2.0), "mat": "trim"},
# department head's desk
{"size": Vector3(1.7, 0.08, 0.85), "at": Vector3(-7.4, 0.74, -5.4), "mat": "wood"},
{"size": Vector3(1.5, 0.66, 0.72), "at": Vector3(-7.4, 0.35, -5.4), "mat": "wood"},
],
"props": [
{"glb": "office-plant", "at": Vector3(-9.3, 0.0, 6.6)},
{"glb": "office-plant", "at": Vector3(3.2, 0.0, -2.6)},
{"glb": "office-plant", "at": Vector3(-9.3, 0.0, -1.0)},
{"glb": "sofa", "at": Vector3(-6.6, 0.0, 6.5), "yaw": PI},
{"glb": "vending-machine", "at": Vector3(9.2, 0.0, 6.6), "yaw": -PI / 2},
{"glb": "microwave", "at": Vector3(9.3, 0.90, 1.2), "yaw": -PI / 2},
{"glb": "store-aircon", "at": Vector3(-6.0, 2.30, -6.9)},
],
"chairs": [
{"at": Vector3(-7.4, 0.0, -4.2), "yaw": PI},
{"at": Vector3(-8.6, 0.0, -6.3), "yaw": 0.0},
{"at": Vector3(-3.5, 0.0, -6.3), "yaw": 0.0},
{"at": Vector3(-2.6, 0.0, -6.3), "yaw": 0.0},
{"at": Vector3(-1.7, 0.0, -6.3), "yaw": 0.0},
{"at": Vector3(-3.5, 0.0, -4.1), "yaw": PI},
{"at": Vector3(-2.6, 0.0, -4.1), "yaw": PI},
{"at": Vector3(-1.7, 0.0, -4.1), "yaw": PI},
{"at": Vector3(-4.0, 0.0, 6.4), "yaw": PI},
{"at": Vector3(0.4, 0.0, 6.5), "yaw": PI},
{"at": Vector3(1.2, 0.0, 6.5), "yaw": PI},
],
"copier": {"at": Vector3(6.4, 0.0, 2.4), "yaw": PI},
"smashables": [
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(5.4, -3.2)},
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(6.1, -3.2)},
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(6.8, -3.2)},
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(7.5, -3.2)},
{"glb": "water-cooler", "kind": "steel", "at": Vector2(3.9, 4.2)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(8.6, -5.4)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(9.2, -4.8)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-4.8, 3.0)},
{"glb": "wastebin", "kind": "plastic", "at": Vector2(-8.8, -3.9)},
{"glb": "wastebin", "kind": "plastic", "at": Vector2(4.4, 0.9)},
{"glb": "guillotine", "kind": "steel", "at": Vector2(6.4, 3.6), "sit_on": 0.0},
{"glb": "desk-phone", "kind": "plastic", "at": Vector2(-7.4, -5.4), "sit_on": 0.78},
{"glb": "crt-monitor", "kind": "glass", "at": Vector2(-7.6, -5.2), "sit_on": 0.78},
],
"records": [],
"spawn": {"at": Vector3(-8.0, 0.0, 6.6), "yaw": deg_to_rad(178.0)},
}
# ================================================================ LEVEL 03
static func house() -> Dictionary:
return {
"name": "THE INCUBATOR",
"subtitle": "seed round · someone's house",
"bounds": {"x0": -10.0, "x1": 10.0, "z0": -8.0, "z1": 8.0, "h": 3.05},
"palette": {
"floor": Color(0.46, 0.33, 0.20), "wall": Color(0.92, 0.91, 0.88),
"ceiling": Color(0.95, 0.95, 0.94), "trim": Color(0.18, 0.17, 0.17),
"partition": Color(0.72, 0.72, 0.70), "wood": Color(0.40, 0.27, 0.16),
"accent": Color(0.22, 0.44, 0.62),
},
"floor_style": "timber",
"ceiling": "flat",
"walls": [
# only two real walls: a bedroom-turned-office and the bathroom nobody cleans
{"a": "z", "at": -4.6, "from": -8.0, "to": -2.4, "door": -4.0},
{"a": "x", "at": -2.4, "from": -10.0, "to": -4.6, "door": -7.4},
{"a": "z", "at": 4.0, "from": -8.0, "to": -5.2, "door": -6.6},
# the kitchen island's back wall
{"a": "x", "at": -1.0, "from": 4.0, "to": 10.0, "style": "half"},
],
"windows": [{"a": "x", "at": 8.0, "from": -10.0, "to": 10.0,
"y0": 0.35, "y1": 2.70, "blinds": false}],
"lights": {"xs": [-6.5, -1.5, 3.5, 7.5], "zs": [-5.0, 0.0, 5.0],
"color": Color(1.0, 0.90, 0.76), "energy": 2.4, "style": "pendant",
"key_energy": 1.7},
"sun": {"color": Color(1.0, 0.94, 0.80), "energy": 1.5,
"rot": Vector3(-46, -96, 0)},
"env": {"bg": Color(0.70, 0.80, 0.90), "ambient": Color(0.56, 0.55, 0.52),
"ambient_energy": 0.62, "exposure": 1.05},
# nobody has a desk; they work at the big table
"clusters": [Vector2(1.6, -1.4), Vector2(1.6, 2.6)],
"cluster_gap": 0.80,
"slabs": [
# kitchen island + counter
{"size": Vector3(2.6, 0.92, 1.05), "at": Vector3(4.2, 0.46, 5.6), "mat": "wood"},
{"size": Vector3(2.8, 0.08, 1.20), "at": Vector3(4.2, 0.95, 5.6), "mat": "ceiling"},
{"size": Vector3(6.0, 0.90, 0.65), "at": Vector3(5.0, 0.45, 7.5), "mat": "ceiling"},
{"size": Vector3(6.2, 0.06, 0.75), "at": Vector3(5.0, 0.92, 7.5), "mat": "trim"},
# the long table everyone actually works at
{"size": Vector3(1.5, 0.09, 4.6), "at": Vector3(-4.4, 0.75, 0.6), "mat": "wood"},
{"size": Vector3(0.12, 0.72, 0.12), "at": Vector3(-4.9, 0.36, -1.4), "mat": "trim"},
{"size": Vector3(0.12, 0.72, 0.12), "at": Vector3(-3.9, 0.36, 2.6), "mat": "trim"},
# whiteboard, obviously
{"size": Vector3(0.05, 1.40, 3.0), "at": Vector3(-9.93, 1.75, 1.0), "mat": "ceiling"},
{"size": Vector3(0.03, 1.50, 3.1), "at": Vector3(-9.90, 1.75, 1.0), "mat": "trim"},
# a low bookshelf run
{"size": Vector3(0.42, 0.90, 3.2), "at": Vector3(-9.7, 0.45, -5.0), "mat": "wood"},
# pool decking visible through the glass
{"size": Vector3(7.0, 0.10, 16.0), "at": Vector3(12.6, -0.05, 0.0),
"mat": "ceiling", "collide": false},
],
"props": [
{"glb": "sofa", "at": Vector3(-6.6, 0.0, 5.4), "yaw": -PI / 2},
{"glb": "sofa", "at": Vector3(-2.0, 0.0, 6.6), "yaw": PI},
{"glb": "office-plant", "at": Vector3(-0.6, 0.0, 3.2)},
{"glb": "office-plant", "at": Vector3(7.2, 0.0, -6.4)},
{"glb": "office-plant", "at": Vector3(-9.2, 0.0, 7.2)},
{"glb": "microwave", "at": Vector3(5.4, 0.95, 7.4), "yaw": PI},
{"glb": "server-rack", "at": Vector3(9.0, 0.0, -6.8), "yaw": -PI / 2},
],
"chairs": [
{"at": Vector3(-6.0, 0.0, -1.4), "yaw": -PI / 2},
{"at": Vector3(-6.0, 0.0, -0.2), "yaw": -PI / 2},
{"at": Vector3(-6.0, 0.0, 1.4), "yaw": -PI / 2},
{"at": Vector3(-6.0, 0.0, 2.6), "yaw": -PI / 2},
{"at": Vector3(-2.8, 0.0, -1.0), "yaw": PI / 2},
{"at": Vector3(-2.8, 0.0, 1.8), "yaw": PI / 2},
{"at": Vector3(4.2, 0.0, 4.2), "yaw": 0.0},
{"at": Vector3(5.2, 0.0, 4.2), "yaw": 0.0},
{"at": Vector3(-7.6, 0.0, -6.4), "yaw": 0.2},
],
"copier": {"at": Vector3(-8.6, 0.0, -3.4), "yaw": PI / 2},
"smashables": [
{"glb": "crt-monitor", "kind": "glass", "at": Vector2(-4.4, -2.2)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(8.4, 2.2)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(8.9, 1.5)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-8.2, 6.4)},
{"glb": "wastebin", "kind": "plastic", "at": Vector2(-2.6, -3.2)},
{"glb": "wastebin", "kind": "plastic", "at": Vector2(6.2, 3.0)},
{"glb": "water-cooler", "kind": "steel", "at": Vector2(1.0, 6.8)},
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(-9.3, -1.6)},
{"glb": "turntable", "kind": "wood", "at": Vector2(-9.4, -6.6)},
{"glb": "desk-phone", "kind": "plastic", "at": Vector2(4.0, 5.6), "sit_on": 0.99},
],
"records": [Vector2(-8.4, -7.0), Vector2(-7.6, -7.0)],
"spawn": {"at": Vector3(-1.0, 0.0, 7.0), "yaw": deg_to_rad(186.0)},
}
# ================================================================ LEVEL 04
static func basement() -> Dictionary:
return {
"name": "SUB-LEVEL 4",
"subtitle": "i.t. department · no windows",
"bounds": {"x0": -12.0, "x1": 12.0, "z0": -6.0, "z1": 6.0, "h": 2.90},
"palette": {
"floor": Color(0.22, 0.22, 0.23), "wall": Color(0.50, 0.53, 0.46),
"ceiling": Color(0.20, 0.20, 0.21), "trim": Color(0.10, 0.10, 0.10),
"partition": Color(0.34, 0.36, 0.32), "wood": Color(0.30, 0.20, 0.12),
"accent": Color(0.52, 0.10, 0.09),
},
"floor_style": "concrete",
"ceiling": "flat",
"walls": [
# the boss's glass box, bolted into a room that never wanted one
{"a": "z", "at": 4.6, "from": -6.0, "to": 1.2, "door": -1.0,
"style": "glass_top"},
{"a": "x", "at": 1.2, "from": 4.6, "to": 12.0},
# server room behind a security door
{"a": "z", "at": -6.4, "from": -6.0, "to": 6.0, "door": 1.0},
# a stub wall someone put up and nobody took down
{"a": "x", "at": -1.4, "from": -4.0, "to": -1.6},
],
"windows": [], # it is a basement
"lights": {"xs": [-9.0, -4.5, 0.0, 4.5, 9.0], "zs": [-3.6, 0.4, 4.0],
"color": Color(0.86, 0.96, 0.84), "energy": 2.0, "style": "strip",
"key_energy": 1.1},
"env": {"bg": Color(0.06, 0.07, 0.06), "ambient": Color(0.32, 0.36, 0.32),
"ambient_energy": 0.40, "exposure": 1.08},
"clusters": [Vector2(3.6, 0.0)],
"cluster_gap": 0.82,
"slabs": [
# exposed services, because there's no ceiling to hide them
{"size": Vector3(24.0, 0.22, 0.22), "at": Vector3(0.0, 2.70, -3.0),
"mat": "trim", "collide": false},
{"size": Vector3(24.0, 0.16, 0.16), "at": Vector3(0.0, 2.62, 1.4),
"mat": "trim", "collide": false},
{"size": Vector3(24.0, 0.30, 0.10), "at": Vector3(0.0, 2.78, 4.2),
"mat": "trim", "collide": false},
# boss's desk in the glass box
{"size": Vector3(1.8, 0.08, 0.86), "at": Vector3(8.0, 0.74, 5.4), "mat": "wood"},
{"size": Vector3(1.6, 0.66, 0.74), "at": Vector3(8.0, 0.35, 5.4), "mat": "wood"},
# workbench along the back
{"size": Vector3(5.0, 0.08, 0.80), "at": Vector3(-6.0, 0.90, -5.5), "mat": "trim"},
{"size": Vector3(0.10, 0.88, 0.70), "at": Vector3(-8.4, 0.44, -5.5), "mat": "trim"},
{"size": Vector3(0.10, 0.88, 0.70), "at": Vector3(-3.6, 0.44, -5.5), "mat": "trim"},
# the famous red door
{"size": Vector3(1.10, 2.10, 0.10), "at": Vector3(1.0, 1.05, -6.35),
"mat": "accent"},
],
"props": [
{"glb": "server-rack", "at": Vector3(-10.6, 0.0, -4.4), "yaw": PI / 2},
{"glb": "server-rack", "at": Vector3(-10.6, 0.0, -2.4), "yaw": PI / 2},
{"glb": "server-rack", "at": Vector3(-10.6, 0.0, -0.4), "yaw": PI / 2},
{"glb": "server-rack", "at": Vector3(-10.6, 0.0, 1.6), "yaw": PI / 2},
{"glb": "sofa", "at": Vector3(-4.0, 0.0, 3.4), "yaw": 0.15},
{"glb": "office-plant", "at": Vector3(11.2, 0.0, 5.4)},
{"glb": "vending-machine", "at": Vector3(-0.4, 0.0, 5.4), "yaw": PI},
{"glb": "microwave", "at": Vector3(-5.0, 0.95, -5.5), "yaw": PI},
],
"chairs": [
{"at": Vector3(8.0, 0.0, 4.1), "yaw": PI},
{"at": Vector3(6.6, 0.0, 5.9), "yaw": 0.3},
{"at": Vector3(-6.6, 0.0, -4.2), "yaw": PI},
{"at": Vector3(-1.0, 0.0, 2.2), "yaw": 1.2},
],
"copier": {"at": Vector3(0.4, 0.0, -4.4), "yaw": 0.0},
"smashables": [
{"glb": "crt-monitor", "kind": "glass", "at": Vector2(-7.0, -5.5), "sit_on": 0.94},
{"glb": "crt-monitor", "kind": "glass", "at": Vector2(-5.0, -4.6)},
{"glb": "crt-monitor", "kind": "glass", "at": Vector2(-8.4, -3.2)},
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(10.8, 3.0)},
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(10.8, 2.2)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-2.6, -4.8)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-2.0, -5.3)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-1.4, -4.6)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(5.6, -4.8)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(6.3, -5.2)},
{"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(7.0, -4.6)},
{"glb": "wastebin", "kind": "plastic", "at": Vector2(2.0, 2.6)},
{"glb": "wastebin", "kind": "plastic", "at": Vector2(9.0, 4.4)},
{"glb": "water-cooler", "kind": "steel", "at": Vector2(0.6, 2.2)},
{"glb": "shredder", "kind": "plastic", "at": Vector2(-2.2, -2.0)},
{"glb": "desk-phone", "kind": "plastic", "at": Vector2(8.3, 5.4), "sit_on": 0.78},
{"glb": "turntable", "kind": "wood", "at": Vector2(-9.0, 4.4)},
],
"records": [Vector2(-8.2, 5.0), Vector2(-7.5, 5.0)],
"spawn": {"at": Vector3(1.0, 0.0, -5.2), "yaw": deg_to_rad(0.0)},
}

View File

@ -0,0 +1 @@
uid://c0ic0hj3e0do0

View File

@ -30,11 +30,13 @@ var _juice: Juice
var _player: Player # kept so GameMode can reach player.grab
var _records: Array[Record] = [] # the live records, handed to GameMode each round
var _game_mode: GameMode
var _office: Office # the level shell: walls, fittings, lighting
var _plan: Floorplan # the level shell: walls, fittings, lighting — see Levels.gd
var _level_id := "scranton"
var _rage: Rage # the tilt meter — see Rage.gd
var _rage_hud: RageOverlay
var _tasks: Tasks
var _shift: CubicleHell
var _env_node: WorldEnvironment
func _ready() -> void:
randomize()
@ -74,34 +76,35 @@ func _ready() -> void:
## Every desk is a spreadsheet you have to fill in; the copier is the copier.
func _register_stations() -> void:
var i := 1
for t in _office.desk_spots():
for t in _plan.desk_spots():
_tasks.register(Tasks.Kind.SPREADSHEET, (t as Transform3D).origin,
"work at desk %d" % i)
i += 1
_tasks.register(Tasks.Kind.COPIER, _office.copier_spot().origin,
"use the photocopier")
if _plan.has_copier():
_tasks.register(Tasks.Kind.COPIER, _plan.copier_spot().origin,
"use the photocopier")
# ---------------------------------------------------------------- world / camera
func _setup_world() -> void:
# The level shell: floor, walls, ceiling, windows, fittings and ALL the lighting.
# It owns the environment too, because interior lighting and the ambient/tonemap
# settings are one decision, not two.
_office = Office.new()
add_child(_office)
_office.build()
_plan = Floorplan.new()
add_child(_plan)
_plan.build(Levels.get_level(_level_id))
var we := WorldEnvironment.new()
_env_node = WorldEnvironment.new()
var env := Environment.new()
_office.configure_environment(env)
we.environment = env
add_child(we)
_plan.configure_environment(env)
_env_node.environment = env
add_child(_env_node)
# First-person player. add_child() runs its _ready() synchronously, so player.camera
# and player.grab exist on the next line; we reuse them.
_player = Player.new()
add_child(_player)
_player.global_position = _office.spawn_point()
_player.rotation.y = _office.spawn_yaw()
_player.global_position = _plan.spawn_point()
_player.rotation.y = _plan.spawn_yaw()
_cam = _player.camera
_world = Node3D.new()
@ -125,6 +128,7 @@ const COOLER_GLB := "res://assets/store/water-cooler.glb"
const BOX_GLB := "res://assets/store/cardboard-box.glb"
const TURNTABLE_GLB := "res://assets/store/turntable.glb"
const CHAIR_GLB := "res://assets/store/office-chair.glb"
const P := "res://assets/store/"
## Mass by material, so a thrown record can't tip a filing cabinet but a sledgehammer
## still can. Everything defaulted to 1 kg before, which is why the level fell over.
@ -149,7 +153,7 @@ func _populate() -> void:
_records.clear() # stale refs from the freed world; repopulated by _place_record
# --- the bullpen: a desk at every cluster spot, each with a monitor on it ---
var spots := _office.desk_spots()
var spots := _plan.desk_spots()
for i in spots.size():
var t: Transform3D = spots[i]
var xz := Vector2(t.origin.x, t.origin.z)
@ -161,53 +165,45 @@ func _populate() -> void:
# used to guess a constant here and kept putting chairs inside desks (or, once
# pushed out far enough to clear them, inside each other across the aisle).
var depth: float = (desk["aabb"] as AABB).size.z
var back := signf(t.origin.z - _office.CLUSTERS[i / 2].y)
var back := signf(t.origin.z - _plan.desk_cluster(i).y)
if is_zero_approx(back):
back = 1.0
_glb_piece(CHAIR_GLB, "plastic", xz + Vector2(0.0, back * (depth * 0.5 + 0.42)),
false, 0.0, t.basis.get_euler().y)
# --- the copier alcove: the PRINTER is the level boss, on its own desk ---
var cs := _office.copier_spot()
var cxz := Vector2(cs.origin.x, cs.origin.z)
var cdesk := _glb_piece(DESK_GLB, "wood", cxz, true, 0.0, cs.basis.get_euler().y)
var printer := _glb_piece(PRINTER_GLB, "steel", cxz, false, cdesk["top"])
var boss := printer["piece"] as Smashable
boss.is_boss = true
boss.toughness_scale = 5.0 # steel x5 — a real fight for anything but the sledge
boss.mass = 8.0 # heavy, so hits rock it but don't shove it off the desk
(cdesk["piece"] as Smashable).supports.append(printer["piece"])
if _plan.has_copier():
var cs := _plan.copier_spot()
var cxz := Vector2(cs.origin.x, cs.origin.z)
var cdesk := _glb_piece(DESK_GLB, "wood", cxz, true, 0.0, cs.basis.get_euler().y)
var printer := _glb_piece(PRINTER_GLB, "steel", cxz, false, cdesk["top"])
var boss := printer["piece"] as Smashable
boss.is_boss = true
boss.toughness_scale = 5.0 # steel x5 — a real fight for anything but the sledge
boss.mass = 8.0 # heavy: hits rock it but don't shove it off the desk
(cdesk["piece"] as Smashable).supports.append(printer["piece"])
# --- filing cabinets down the wall by the copier, water coolers, cartons ---
for z in [-1.6, -0.9, -0.2]:
_glb_piece(CABINET_GLB, "steel", Vector2(4.5, z), false)
_glb_piece(COOLER_GLB, "steel", Vector2(-6.2, 3.4), false)
_glb_piece(COOLER_GLB, "steel", Vector2(5.9, -0.6), false)
# Cartons go on OPEN floor only. Tucking them under desks or against the conference
# table spawned them inside those colliders, and Jolt resolved the overlap by firing
# them across the room on frame one.
for c in [Vector2(-2.3, 5.2), Vector2(1.6, 5.4), Vector2(2.1, 4.7),
Vector2(6.4, -3.3), Vector2(7.3, -3.8), Vector2(-9.7, 1.2)]:
_glb_piece(BOX_GLB, "cardboard", c, false)
# --- everything else this site contains, straight from its spec ---
for e in _plan.smashables():
var d: Dictionary = e
_glb_piece(P + String(d["glb"]) + ".glb", String(d.get("kind", "wood")),
d["at"], bool(d.get("frozen", false)), float(d.get("sit_on", 0.0)),
float(d.get("yaw", 0.0)))
# --- the warehouse corner: shelving, crates, and somebody's record collection.
# --- This is where the grab/slide/throw ritual still lives.
_glb_piece(RACK_GLB, "wood", Vector2(9.4, -6.6), true)
_glb_piece(RACK_GLB, "wood", Vector2(9.4, -4.3), true)
_glb_piece(TURNTABLE_GLB, "wood", Vector2(6.9, -5.9), false)
for spot in [Vector2(6.2, -6.6), Vector2(7.0, -6.9), Vector2(7.8, -6.6)]:
# --- crates of records, where the site has any. Records stand side by side ON the
# --- crate lid, offset by more than a sleeve is thick: they used to be stacked 3 cm
# --- apart vertically while being 30 cm TALL, so Jolt resolved 27 cm of
# --- interpenetration explosively on frame one.
for spot in _plan.spec.get("records", []):
var crate := _glb_piece(CRATE_GLB, "wood", spot, false)
var crate_top: float = crate["top"]
# Records stand side by side ON the crate lid, offset along Z by more than a
# sleeve is thick. They used to be stacked 3 cm apart vertically while being
# 30 cm TALL, so Jolt resolved 27 cm of interpenetration explosively on frame
# one and shoved the surrounding furniture over before you touched anything.
for i in range(3):
var rec := _place_record(spot + Vector2(randf_range(-0.02, 0.02),
(float(i) - 1.0) * 0.045), crate_top)
(crate["piece"] as Smashable).supports.append(rec)
# --- chairs: the best thing in an office to send across the room ---
for t in _office.chair_spots():
for t in _plan.chair_spots():
_glb_piece(CHAIR_GLB, "plastic", Vector2(t.origin.x, t.origin.z), false, 0.0,
t.basis.get_euler().y)
@ -404,8 +400,9 @@ func _physics_process(dt: float) -> void:
# report where it was PLACED, not where it is now — at 500 m/s a body is
# already 8 m away by the time this runs, which points at nothing useful
var at = b.get_meta("spawn_pos", b.global_position)
push_warning("[spawn] %s placed overlapping something at %s (%.0f m/s) — clamped" % [
b.name, (at as Vector3).snappedf(0.01), v])
push_warning("[spawn] %s (%s) placed overlapping something at %s (%.0f m/s) — clamped" % [
b.name, (b as Smashable).kind if b is Smashable else "?",
(at as Vector3).snappedf(0.01), v])
b.linear_velocity = b.linear_velocity.normalized() * SPAWN_MAX_SPEED
if b.angular_velocity.length() > SPAWN_MAX_SPIN:
b.angular_velocity = b.angular_velocity.normalized() * SPAWN_MAX_SPIN
@ -464,10 +461,53 @@ func _unhandled_input(event: InputEvent) -> void:
KEY_H:
if _hud != null:
_hud.cycle()
KEY_L:
_load_level(Levels.next_id(_level_id))
KEY_F:
if _game_mode != null:
_game_mode.report() # report the disc you're holding (FindMisfiled)
## Swap sites without restarting: tear down the shell and everything in it, build the
## new spec, and re-arm the shift. The player is moved to the new spawn because the old
## one is very likely now inside a wall.
func _load_level(id: String) -> void:
_level_id = id
for c in _world.get_children():
c.queue_free()
_plan.queue_free()
if _env_node != null:
_env_node.queue_free()
await get_tree().process_frame
_plan = Floorplan.new()
add_child(_plan)
_plan.build(Levels.get_level(_level_id))
_env_node = WorldEnvironment.new()
var env := Environment.new()
_plan.configure_environment(env)
_env_node.environment = env
add_child(_env_node)
_player.global_position = _plan.spawn_point()
_player.rotation.y = _plan.spawn_yaw()
_player.velocity = Vector3.ZERO
_smash_count = 0
_score = 0
_tally.clear()
_populate()
_spawn_guard = SPAWN_GUARD_TIME
_guard_warned.clear()
_tasks.clear_stations()
_register_stations()
if _game_mode != null:
_game_mode.begin_free_play(_records)
if _shift != null:
_shift.begin()
if _hud != null:
_hud.toast("%s%s" % [_plan.level_name(), _plan.subtitle()])
# The Day-0 stress gate: rain N bodies, watch the FPS counter hold.
func _rain(n: int) -> void:
for i in n:
@ -494,6 +534,10 @@ func _reset() -> void:
_tally.clear()
await get_tree().process_frame
_populate()
_spawn_guard = SPAWN_GUARD_TIME
_guard_warned.clear()
if _shift != null:
_shift.begin()
# re-arm whatever mode we're in against the fresh set of records
if _game_mode != null:
if _game_mode.mode == GameMode.Mode.FIND_MISFILED:
@ -519,7 +563,7 @@ func _on_weapon_changed(w: Weapon) -> void:
if _hud != null and w != null:
_hud.toast(w.display_name)
const KEYS_LINE := "WASD move · 1-6 / wheel weapon · Q swap · LMB swing · E grab · drag ←→ pull · G drop · F report · M mode · H hud · B rain · R reset"
const KEYS_LINE := "WASD move · 1-6 / wheel weapon · Q swap · LMB swing · E grab · drag ←→ pull · G drop · F report · M mode · H hud · L site · B rain · R reset"
func _process(_dt: float) -> void:
if _hud == null:

View File

@ -1,454 +0,0 @@
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)

View File

@ -1 +0,0 @@
uid://btc0q0j2wj0bs

View File

@ -68,6 +68,12 @@ func setup(player: Node3D, r: Rage) -> void:
func register(kind: int, pos: Vector3, label: String) -> void:
_stations.append({"kind": kind, "pos": pos, "label": label})
## Swapping sites invalidates every station position.
func clear_stations() -> void:
_stations.clear()
_near = {}
phase = Phase.IDLE
func is_working() -> bool:
return phase != Phase.IDLE and phase != Phase.DONE

404
tools/gen_level_props.py Normal file
View File

@ -0,0 +1,404 @@
"""Second batch of procedural props: the ones the Gauntlet and the three new workplaces
need that a box can't fake.
Same rule as tools/gen_office_props.py if it IS a box (counters, partitions, tables,
whiteboards) it gets built in code by the level builder. This file is only for silhouettes.
/Applications/Blender.app/Contents/MacOS/Blender --background \
--python tools/gen_level_props.py -- [--render]
Convention: glTF Y-up, 1 unit = 1 m, ORIGIN AT FLOOR-CENTRE, so a level can drop a prop
at a floor position with no offset maths. In Blender that means authoring Z-up with the
base on z = 0.
The cabinet is the exception and the important one: it exports as a CARCASS plus four
separate DRAWER meshes, each with its own origin at its own floor-centre, so the Gauntlet
can pull a drawer out as its own rigid body and spill the files inside it.
"""
import bpy
import math
import os
import sys
from mathutils import Vector, Matrix
OUT_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"game", "assets", "store")
PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews")
RENDER = "--render" in sys.argv
def reset():
bpy.ops.wm.read_factory_settings(use_empty=True)
def mat(name, color, rough=0.6, metal=0.0, alpha=1.0):
m = bpy.data.materials.new(name)
m.use_nodes = True
b = m.node_tree.nodes["Principled BSDF"]
b.inputs["Base Color"].default_value = (color[0], color[1], color[2], alpha)
b.inputs["Roughness"].default_value = rough
b.inputs["Metallic"].default_value = metal
if alpha < 1.0:
b.inputs["Alpha"].default_value = alpha
m.blend_method = 'BLEND'
return m
def assign(o, m):
o.data.materials.clear()
o.data.materials.append(m)
return o
def box(size, loc=(0, 0, 0), rot=(0, 0, 0)):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot)
o = bpy.context.active_object
o.scale = (size[0], size[1], size[2])
bpy.ops.object.transform_apply(scale=True)
return o
def cyl(r, depth, loc=(0, 0, 0), rot=(0, 0, 0), verts=16, r2=None):
if r2 is None:
bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=depth, vertices=verts,
location=loc, rotation=rot)
else:
bpy.ops.mesh.primitive_cone_add(radius1=r, radius2=r2, depth=depth,
vertices=verts, location=loc, rotation=rot)
return bpy.context.active_object
def bevel(o, width=0.008, segments=2):
m = o.modifiers.new("bevel", "BEVEL")
m.width = width
m.segments = segments
m.limit_method = 'ANGLE'
m.angle_limit = math.radians(40)
bpy.context.view_layer.objects.active = o
try:
bpy.ops.object.modifier_apply(modifier=m.name)
except Exception:
o.modifiers.remove(m)
return o
def shade_smooth(o, angle=34.0):
bpy.context.view_layer.objects.active = o
o.select_set(True)
try:
bpy.ops.object.shade_auto_smooth(angle=math.radians(angle))
except Exception:
bpy.ops.object.shade_smooth()
o.select_set(False)
# ---------------------------------------------------------------- props
def p_cabinet_carcass(M):
"""Four-drawer cabinet with the drawer OPENINGS empty — drawers ship separately."""
parts = []
W, D, H = 0.46, 0.62, 1.32
T = 0.022
for s in (-1, 1): # sides
parts.append(assign(box((T, D, H), loc=(s * (W / 2 - T / 2), 0, H / 2)), M["cab"]))
parts.append(assign(box((W, T, H), loc=(0, D / 2 - T / 2, H / 2)), M["cab"])) # back
parts.append(assign(box((W, D, T), loc=(0, 0, H - T / 2)), M["cab_top"])) # top
parts.append(assign(box((W, D, T), loc=(0, 0, T / 2)), M["cab"])) # base
for i in range(5): # shelf rails between drawers
z = 0.045 + i * 0.312
parts.append(assign(box((W - 2 * T, D - T, 0.012), loc=(0, 0, z)), M["cab_dark"]))
plinth = box((W - 0.04, D - 0.04, 0.045), loc=(0, 0, 0.0225))
parts.append(assign(plinth, M["cab_dark"]))
return parts
def p_cabinet_drawer(M):
"""One drawer, origin at ITS OWN floor-centre so it can become its own rigid body."""
parts = []
W, D, H = 0.40, 0.58, 0.27
T = 0.014
parts.append(assign(box((W, T, H), loc=(0, -D / 2 + T / 2, H / 2)), M["cab"])) # front
parts.append(assign(box((W, T, H * 0.7), loc=(0, D / 2 - T / 2, H * 0.35)), M["cab"]))
for s in (-1, 1):
parts.append(assign(box((T, D, H * 0.8), loc=(s * (W / 2 - T / 2), 0, H * 0.4)),
M["cab"]))
parts.append(assign(box((W, D, T), loc=(0, 0, T / 2)), M["cab"]))
handle = box((0.17, 0.030, 0.032), loc=(0, -D / 2 - 0.012, H * 0.60))
parts.append(assign(bevel(handle, 0.008), M["cab_dark"]))
label = box((0.10, 0.006, 0.036), loc=(0, -D / 2 - 0.004, H * 0.26))
parts.append(assign(label, M["paper"]))
return parts
def p_file_folder(M):
"""A manila folder. Lies flat; the Gauntlet stands them up inside a drawer."""
parts = []
body = box((0.24, 0.32, 0.014), loc=(0, 0, 0.007))
parts.append(assign(bevel(body, 0.004), M["manila"]))
tab = box((0.075, 0.030, 0.012), loc=(-0.06, 0.172, 0.006))
parts.append(assign(tab, M["manila"]))
for i in range(3): # paper peeking out
sheet = box((0.225, 0.30, 0.003), loc=(0.002, -0.004 + i * 0.003, 0.012 + i * 0.003))
parts.append(assign(sheet, M["paper"]))
return parts
def p_desk_phone(M):
parts = []
base = box((0.21, 0.24, 0.055), loc=(0, 0, 0.028), rot=(math.radians(-7), 0, 0))
parts.append(assign(bevel(base, 0.010, 2), M["plastic_dark"]))
for r in range(4): # keypad
for c in range(3):
k = box((0.030, 0.024, 0.008),
loc=(-0.038 + c * 0.038, -0.052 + r * 0.032, 0.062 + r * 0.004))
parts.append(assign(k, M["plastic_pale"]))
cradle = box((0.20, 0.075, 0.045), loc=(0, 0.086, 0.085))
parts.append(assign(bevel(cradle, 0.012, 2), M["plastic_dark"]))
handset = box((0.055, 0.235, 0.048), loc=(0, 0.086, 0.128))
parts.append(assign(bevel(handset, 0.020, 3), M["plastic_dark"]))
for s in (-1, 1):
ear = box((0.070, 0.062, 0.036), loc=(0, s * 0.088 + 0.086, 0.142))
parts.append(assign(bevel(ear, 0.016, 3), M["plastic_dark"]))
# coiled cord
pos = Vector((-0.10, 0.02, 0.10))
for i in range(9):
a = i * 1.35
nxt = pos + Vector((math.cos(a) * 0.018 - 0.006, math.sin(a) * 0.018, -0.010))
s = _span(0.006, pos, nxt)
if s:
parts.append(assign(s, M["plastic_dark"]))
pos = nxt
return parts
def p_guillotine(M):
"""Paper trimmer. The alignment guide is 2 mm off and always has been."""
parts = []
base = box((0.42, 0.46, 0.030), loc=(0, 0, 0.015))
parts.append(assign(bevel(base, 0.006), M["board"]))
grid = box((0.34, 0.38, 0.002), loc=(-0.02, 0, 0.031))
parts.append(assign(grid, M["paper"]))
rail = box((0.030, 0.46, 0.028), loc=(0.196, 0, 0.044))
parts.append(assign(bevel(rail, 0.005), M["steel"]))
guide = box((0.30, 0.020, 0.026), loc=(-0.05, -0.16, 0.043))
parts.append(assign(guide, M["steel"]))
arm = box((0.022, 0.44, 0.055), loc=(0.185, 0.02, 0.10), rot=(math.radians(16), 0, 0))
parts.append(assign(bevel(arm, 0.005), M["steel"]))
blade = box((0.016, 0.40, 0.075), loc=(0.170, 0.02, 0.085), rot=(math.radians(16), 0, 0))
parts.append(assign(blade, M["blade"]))
grip = cyl(0.018, 0.10, loc=(0.185, 0.235, 0.175), rot=(math.radians(16), 0, 0), verts=12)
parts.append(assign(grip, M["plastic_dark"]))
return parts
def p_shredder(M):
"""The Gauntlet's perfect tool, if you work out that's what it is."""
parts = []
bin_ = box((0.36, 0.30, 0.44), loc=(0, 0, 0.22))
parts.append(assign(bevel(bin_, 0.012, 2), M["bin_grey"]))
win = box((0.22, 0.006, 0.26), loc=(0, -0.153, 0.24))
parts.append(assign(win, M["glass_dark"]))
head = box((0.40, 0.34, 0.13), loc=(0, 0, 0.505))
parts.append(assign(bevel(head, 0.014, 2), M["plastic_dark"]))
slot = box((0.30, 0.020, 0.012), loc=(0, 0, 0.572))
parts.append(assign(slot, M["black"]))
for i in range(3):
led = box((0.016, 0.014, 0.010), loc=(0.13, -0.15 + i * 0.02, 0.545))
parts.append(assign(led, M["led"] if i == 0 else M["black"]))
return parts
def p_server_rack(M):
parts = []
W, D, H = 0.62, 0.90, 1.90
for s in (-1, 1):
parts.append(assign(box((0.030, D, H), loc=(s * (W / 2), 0, H / 2)), M["black"]))
parts.append(assign(box((W, 0.030, H), loc=(0, D / 2, H / 2)), M["black"]))
parts.append(assign(box((W, D, 0.030), loc=(0, 0, H)), M["black"]))
parts.append(assign(box((W, D, 0.030), loc=(0, 0, 0.015)), M["black"]))
for i in range(9): # blade servers
z = 0.14 + i * 0.19
u = box((W - 0.07, D - 0.10, 0.155), loc=(0, 0.01, z))
parts.append(assign(bevel(u, 0.005), M["server"]))
for j in range(4):
led = box((0.014, 0.010, 0.012), loc=(-0.20 + j * 0.05, -D / 2 + 0.06, z + 0.05))
parts.append(assign(led, M["led"] if (i + j) % 3 else M["led_amber"]))
vent = box((0.22, 0.008, 0.10), loc=(0.09, -D / 2 + 0.055, z))
parts.append(assign(vent, M["black"]))
return parts
def p_sofa(M):
parts = []
seat = box((1.85, 0.86, 0.30), loc=(0, 0, 0.34))
parts.append(assign(bevel(seat, 0.045, 3), M["fabric"]))
back = box((1.85, 0.24, 0.52), loc=(0, 0.31, 0.62), rot=(math.radians(-8), 0, 0))
parts.append(assign(bevel(back, 0.050, 3), M["fabric"]))
for s in (-1, 1):
arm = box((0.20, 0.86, 0.28), loc=(s * 0.825, 0, 0.56))
parts.append(assign(bevel(arm, 0.055, 3), M["fabric"]))
for i in range(2):
cush = box((0.84, 0.76, 0.14), loc=(-0.44 + i * 0.88, -0.02, 0.545))
parts.append(assign(bevel(cush, 0.050, 3), M["fabric_light"]))
for sx in (-1, 1):
for sy in (-1, 1):
parts.append(assign(cyl(0.030, 0.19, loc=(sx * 0.78, sy * 0.34, 0.095), verts=10),
M["wood"]))
return parts
def p_bin(M):
parts = []
body = cyl(0.15, 0.36, loc=(0, 0, 0.18), r2=0.175, verts=18)
parts.append(assign(body, M["bin_grey"]))
rim = cyl(0.182, 0.028, loc=(0, 0, 0.352), verts=18)
parts.append(assign(rim, M["plastic_dark"]))
for i in range(5): # crumpled paper spilling out
a = i * 1.3
ball = box((0.075, 0.075, 0.065),
loc=(math.cos(a) * 0.06, math.sin(a) * 0.06, 0.36 + (i % 2) * 0.05),
rot=(a, a * 0.7, a * 0.3))
parts.append(assign(bevel(ball, 0.022, 2), M["paper"]))
return parts
def p_stapler(M):
parts = []
base = box((0.052, 0.175, 0.022), loc=(0, 0, 0.011))
parts.append(assign(bevel(base, 0.008, 2), M["stapler_red"]))
anvil = box((0.040, 0.040, 0.006), loc=(0, -0.058, 0.024))
parts.append(assign(anvil, M["steel"]))
top = box((0.048, 0.168, 0.030), loc=(0, 0.006, 0.041), rot=(math.radians(4), 0, 0))
parts.append(assign(bevel(top, 0.010, 2), M["stapler_red"]))
hinge = cyl(0.012, 0.052, loc=(0, 0.082, 0.030), rot=(0, math.radians(90), 0), verts=10)
parts.append(assign(hinge, M["steel"]))
return parts
def _span(r, p0, p1, verts=8):
p0, p1 = Vector(p0), Vector(p1)
d = p1 - p0
n = d.length
if n < 1e-6:
return None
o = cyl(r, n, verts=verts)
quat = Vector((0, 0, 1)).rotation_difference(d.normalized())
o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ quat.to_matrix().to_4x4()
bpy.ops.object.transform_apply(location=True, rotation=True)
return o
PROPS = {
"cabinet-carcass": p_cabinet_carcass,
"cabinet-drawer": p_cabinet_drawer,
"file-folder": p_file_folder,
"desk-phone": p_desk_phone,
"guillotine": p_guillotine,
"shredder": p_shredder,
"server-rack": p_server_rack,
"sofa": p_sofa,
"wastebin": p_bin,
"stapler": p_stapler,
}
def materials():
return {
"cab": mat("cab", (0.62, 0.63, 0.64), rough=0.45, metal=0.6),
"cab_top": mat("cab_top", (0.58, 0.59, 0.60), rough=0.42, metal=0.6),
"cab_dark": mat("cab_dark", (0.22, 0.23, 0.25), rough=0.5),
"manila": mat("manila", (0.78, 0.66, 0.40), rough=0.95),
"paper": mat("paper", (0.93, 0.92, 0.88), rough=1.0),
"plastic_dark": mat("plastic_dark", (0.10, 0.10, 0.11), rough=0.55),
"plastic_pale": mat("plastic_pale", (0.80, 0.79, 0.74), rough=0.6),
"black": mat("black", (0.05, 0.05, 0.06), rough=0.6),
"steel": mat("steel", (0.66, 0.67, 0.70), rough=0.32, metal=1.0),
"blade": mat("blade", (0.86, 0.87, 0.90), rough=0.14, metal=1.0),
"board": mat("board", (0.34, 0.24, 0.16), rough=0.75),
"bin_grey": mat("bin_grey", (0.30, 0.31, 0.33), rough=0.7),
"glass_dark": mat("glass_dark", (0.12, 0.14, 0.15), rough=0.2),
"led": mat("led", (0.15, 0.85, 0.30), rough=0.3),
"led_amber": mat("led_amber", (0.90, 0.55, 0.10), rough=0.3),
"server": mat("server", (0.24, 0.25, 0.28), rough=0.5, metal=0.4),
"fabric": mat("fabric", (0.30, 0.33, 0.38), rough=0.98),
"fabric_light": mat("fabric_light", (0.38, 0.41, 0.46), rough=0.98),
"wood": mat("wood", (0.36, 0.24, 0.14), rough=0.6),
"stapler_red": mat("stapler_red", (0.52, 0.08, 0.10), rough=0.4),
}
def export(objs, name):
objs = [o for o in objs if o is not None]
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
path = os.path.join(OUT_DIR, name + ".glb")
bpy.ops.export_scene.gltf(filepath=path, export_format='GLB',
use_selection=True, export_apply=True, export_yup=True)
print("[props] %-20s -> %s" % (name, os.path.basename(path)))
bpy.ops.object.select_all(action='DESELECT')
def render_preview(name):
scene = bpy.context.scene
engines = scene.render.bl_rna.properties['engine'].enum_items.keys()
for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
if want in engines:
scene.render.engine = want
break
scene.render.resolution_x = 520
scene.render.resolution_y = 520
if scene.world is None:
scene.world = bpy.data.worlds.new("preview")
scene.world.use_nodes = True
bg = scene.world.node_tree.nodes.get("Background")
if bg:
bg.inputs[0].default_value = (0.07, 0.07, 0.08, 1.0)
lo = Vector((1e9,) * 3)
hi = Vector((-1e9,) * 3)
for o in scene.objects:
if o.type != 'MESH':
continue
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z)))
hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z)))
centre = (lo + hi) * 0.5
radius = max((hi - lo).length * 0.5, 0.05)
dist = radius * 2.9
d = Vector((0.66, -0.72, 0.30)).normalized()
bpy.ops.object.camera_add(location=tuple(centre + d * dist))
cam = bpy.context.active_object
cam.data.lens = 58
cam.rotation_mode = 'QUATERNION'
cam.rotation_quaternion = (-d).to_track_quat('-Z', 'Y')
scene.camera = cam
for off, e, s in ((Vector((1.0, -1.0, 1.1)), 58.0, 1.2),
(Vector((-1.1, -0.5, 0.3)), 17.0, 1.8)):
bpy.ops.object.light_add(type='AREA', location=tuple(centre + off * dist))
L = bpy.context.active_object
L.data.energy = e * (dist ** 2)
L.data.size = s * radius
scene.render.filepath = os.path.join(PREVIEW_DIR, name + ".png")
bpy.ops.render.render(write_still=True)
def main():
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(PREVIEW_DIR, exist_ok=True)
for name, builder in PROPS.items():
reset()
M = materials()
parts = builder(M)
for p in parts:
shade_smooth(p, 34.0)
export(parts, name)
if RENDER:
render_preview(name)
print("[props] done -> %s" % OUT_DIR)
main()