diff --git a/README.md b/README.md index 5a771d4..812c41a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ *Clock out. Smash everything.* A comedic, non-violent, first-person workplace-destruction game from Monster Robot -Party. Level 01 is our actual record store. Nobody gets hurt but the furniture. +Party. Level 01 is an open-plan office laid out after the Dunder Mifflin floor plan — +bullpen, conference room, break room, and a photocopier that fights back. Nobody gets +hurt but the furniture. ## Monorepo layout — one lane per directory @@ -11,11 +13,14 @@ Party. Level 01 is our actual record store. Nobody gets hurt but the furniture. |---|---|---| | `game/` | **Lane 1 — the game** | Godot 4.7 (Jolt physics, Mac-native). The shippable build. | | `web/` | **Lane 3 — web toy** | Three.js dig-and-destroy demo (planned; thriftgod-adjacent). | +| `tools/` | asset build | Blender generators for the viewmodel + office props. Re-runnable. | | `LANES/` | coordination | Execution specs for parallel Claude/Opus work sessions. | | `3D-STORE/` | *not in git* | 700MB Blender/Unreal source-asset vault (see `.gitignore`). | | `destroyulater.txt` | lore | The founding chat where the idea happened. | -Asset generation lives in its own repo: `~/Documents/meshgod` (text→GLB factory). +Asset generation lives in its own repo: `~/Documents/meshgod` (text→GLB factory). The +first-person arms are cut from `~/Documents/character_kit_modular` (the GODVERSE modular +character kit) by `tools/gen_fps_arms.py`. ## Rules for parallel work sessions diff --git a/game/README.md b/game/README.md index 96b2fb4..d461c6d 100644 --- a/game/README.md +++ b/game/README.md @@ -103,7 +103,34 @@ too coarse to tell a 5 cm error from a 50 cm one. Zero script errors required. After adding any `class_name` script, run `Godot --path game --editor --quit` first — headless Godot won't rescan a stale class cache. -## What's still missing +## The level -The store has no **room**: props sit on a grey plane in a black void, and several topple -on spawn before you touch them. That's the biggest remaining gap — see the top-level README. +`scripts/Office.gd` builds LEVEL 01: an open-plan office after the Dunder Mifflin floor +plan — bullpen of facing desk pairs behind cubicle partitions, reception, a glass-walled +manager's office, conference room, break room with vending machines, a copier alcove for +the printer boss, and a warehouse roller door. Drop ceiling, fluorescent troffers (which +are the actual light sources), magnolia walls, grey carpet, a window wall with blinds. + +Office owns the **shell and static fittings**; `Main._populate()` owns everything +**smashable**, and asks Office where things go (`desk_spots()`, `chair_spots()`, +`copier_spot()`). Walls, desks and counters are boxes built in code because they are +boxes; chairs, vending machines, the microwave and the plant come from +`tools/gen_office_props.py`, because a box reads as obviously wrong for those. + +### The spawn guard + +Placing ~40 props by formula means something will eventually spawn a few centimetres +inside something else, and Jolt's depenetration is not gentle — a chair overlapping a +desk left the building at 500 m/s on frame one, knocking over the furniture on its way. + +`Main._physics_process()` speed-limits dynamic bodies for the first 0.75 s: enough for a +real overlap to push apart calmly, far too little to launch anything. Every offender is +named once in a warning with **the position it was placed at**, so the underlying overlap +stays visible and fixable rather than silently papered over. + +Verify a level is genuinely at rest with: +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path game --quit-after 300 dev/demo.tscn +``` +The demo's first act touches nothing and prints total body speed each second. It must +read `0.00 m/s`, and no `[spawn]` warnings should appear. diff --git a/game/assets/store/microwave.glb b/game/assets/store/microwave.glb new file mode 100644 index 0000000..7b647ca Binary files /dev/null and b/game/assets/store/microwave.glb differ diff --git a/game/assets/store/microwave.glb.import b/game/assets/store/microwave.glb.import new file mode 100644 index 0000000..d2ae9ed --- /dev/null +++ b/game/assets/store/microwave.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://c5eckque6cbus" +path="res://.godot/imported/microwave.glb-b7b5167c74b8b5c7123b987171251d4d.scn" + +[deps] + +source_file="res://assets/store/microwave.glb" +dest_files=["res://.godot/imported/microwave.glb-b7b5167c74b8b5c7123b987171251d4d.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 diff --git a/game/assets/store/office-chair.glb b/game/assets/store/office-chair.glb new file mode 100644 index 0000000..455be34 Binary files /dev/null and b/game/assets/store/office-chair.glb differ diff --git a/game/assets/store/office-chair.glb.import b/game/assets/store/office-chair.glb.import new file mode 100644 index 0000000..8671c2b --- /dev/null +++ b/game/assets/store/office-chair.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://d2uf6074d7xkc" +path="res://.godot/imported/office-chair.glb-ac60e8b5aac0754236256cee0b8fc797.scn" + +[deps] + +source_file="res://assets/store/office-chair.glb" +dest_files=["res://.godot/imported/office-chair.glb-ac60e8b5aac0754236256cee0b8fc797.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 diff --git a/game/assets/store/office-plant.glb b/game/assets/store/office-plant.glb new file mode 100644 index 0000000..fb579c8 Binary files /dev/null and b/game/assets/store/office-plant.glb differ diff --git a/game/assets/store/office-plant.glb.import b/game/assets/store/office-plant.glb.import new file mode 100644 index 0000000..33222c0 --- /dev/null +++ b/game/assets/store/office-plant.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://db5d8pc8dyv6m" +path="res://.godot/imported/office-plant.glb-1de190cf01a50f8f08e256dfed234617.scn" + +[deps] + +source_file="res://assets/store/office-plant.glb" +dest_files=["res://.godot/imported/office-plant.glb-1de190cf01a50f8f08e256dfed234617.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 diff --git a/game/assets/store/store-aircon.glb b/game/assets/store/store-aircon.glb new file mode 100644 index 0000000..1eb21d5 Binary files /dev/null and b/game/assets/store/store-aircon.glb differ diff --git a/game/assets/store/store-aircon.glb.import b/game/assets/store/store-aircon.glb.import new file mode 100644 index 0000000..3aeec89 --- /dev/null +++ b/game/assets/store/store-aircon.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dd5jcl680fp1r" +path="res://.godot/imported/store-aircon.glb-3936a0f9c59319b1096328c7a6d1194c.scn" + +[deps] + +source_file="res://assets/store/store-aircon.glb" +dest_files=["res://.godot/imported/store-aircon.glb-3936a0f9c59319b1096328c7a6d1194c.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 diff --git a/game/assets/store/store-bin-long.glb b/game/assets/store/store-bin-long.glb new file mode 100644 index 0000000..504492d Binary files /dev/null and b/game/assets/store/store-bin-long.glb differ diff --git a/game/assets/store/store-bin-long.glb.import b/game/assets/store/store-bin-long.glb.import new file mode 100644 index 0000000..8a01c2f --- /dev/null +++ b/game/assets/store/store-bin-long.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://cm4eatc040s7p" +path="res://.godot/imported/store-bin-long.glb-682bed3d6855531dd328bb07f257ed0f.scn" + +[deps] + +source_file="res://assets/store/store-bin-long.glb" +dest_files=["res://.godot/imported/store-bin-long.glb-682bed3d6855531dd328bb07f257ed0f.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 diff --git a/game/assets/store/store-column-white.glb b/game/assets/store/store-column-white.glb new file mode 100644 index 0000000..41c44a3 Binary files /dev/null and b/game/assets/store/store-column-white.glb differ diff --git a/game/assets/store/store-column-white.glb.import b/game/assets/store/store-column-white.glb.import new file mode 100644 index 0000000..859188b --- /dev/null +++ b/game/assets/store/store-column-white.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dbsjlmo7ue3yw" +path="res://.godot/imported/store-column-white.glb-d047a30c24b2dbcdfcb8fbfa2b09b210.scn" + +[deps] + +source_file="res://assets/store/store-column-white.glb" +dest_files=["res://.godot/imported/store-column-white.glb-d047a30c24b2dbcdfcb8fbfa2b09b210.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 diff --git a/game/assets/store/store-crate-blue.glb b/game/assets/store/store-crate-blue.glb new file mode 100644 index 0000000..b479986 Binary files /dev/null and b/game/assets/store/store-crate-blue.glb differ diff --git a/game/assets/store/store-crate-blue.glb.import b/game/assets/store/store-crate-blue.glb.import new file mode 100644 index 0000000..66795bc --- /dev/null +++ b/game/assets/store/store-crate-blue.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://c5x1hboh2enh" +path="res://.godot/imported/store-crate-blue.glb-2ea0f65193f35865ca594dac5849a6d2.scn" + +[deps] + +source_file="res://assets/store/store-crate-blue.glb" +dest_files=["res://.godot/imported/store-crate-blue.glb-2ea0f65193f35865ca594dac5849a6d2.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 diff --git a/game/assets/store/store-door.glb b/game/assets/store/store-door.glb new file mode 100644 index 0000000..0625327 Binary files /dev/null and b/game/assets/store/store-door.glb differ diff --git a/game/assets/store/store-door.glb.import b/game/assets/store/store-door.glb.import new file mode 100644 index 0000000..4bc1725 --- /dev/null +++ b/game/assets/store/store-door.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dyxqblpqhssdl" +path="res://.godot/imported/store-door.glb-f2292c391563c5fd442da8178e40fa28.scn" + +[deps] + +source_file="res://assets/store/store-door.glb" +dest_files=["res://.godot/imported/store-door.glb-f2292c391563c5fd442da8178e40fa28.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 diff --git a/game/assets/store/store-rack-long.glb b/game/assets/store/store-rack-long.glb new file mode 100644 index 0000000..3e004dc Binary files /dev/null and b/game/assets/store/store-rack-long.glb differ diff --git a/game/assets/store/store-rack-long.glb.import b/game/assets/store/store-rack-long.glb.import new file mode 100644 index 0000000..b672da6 --- /dev/null +++ b/game/assets/store/store-rack-long.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bgb58tfndol1h" +path="res://.godot/imported/store-rack-long.glb-2e288b622925827ec69249208c9454ca.scn" + +[deps] + +source_file="res://assets/store/store-rack-long.glb" +dest_files=["res://.godot/imported/store-rack-long.glb-2e288b622925827ec69249208c9454ca.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 diff --git a/game/assets/store/store-rack-wide.glb b/game/assets/store/store-rack-wide.glb new file mode 100644 index 0000000..457f4b3 Binary files /dev/null and b/game/assets/store/store-rack-wide.glb differ diff --git a/game/assets/store/store-rack-wide.glb.import b/game/assets/store/store-rack-wide.glb.import new file mode 100644 index 0000000..c88f44d --- /dev/null +++ b/game/assets/store/store-rack-wide.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://cqx85qlh7ylle" +path="res://.godot/imported/store-rack-wide.glb-f670adfad19f539e72546b79d1e245bf.scn" + +[deps] + +source_file="res://assets/store/store-rack-wide.glb" +dest_files=["res://.godot/imported/store-rack-wide.glb-f670adfad19f539e72546b79d1e245bf.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 diff --git a/game/assets/store/store-station.glb b/game/assets/store/store-station.glb new file mode 100644 index 0000000..bb75a30 Binary files /dev/null and b/game/assets/store/store-station.glb differ diff --git a/game/assets/store/store-station.glb.import b/game/assets/store/store-station.glb.import new file mode 100644 index 0000000..5c4a229 --- /dev/null +++ b/game/assets/store/store-station.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://cfwbkib4wxu3p" +path="res://.godot/imported/store-station.glb-ff04c3ceb60f72cdb0c4efd392a3d508.scn" + +[deps] + +source_file="res://assets/store/store-station.glb" +dest_files=["res://.godot/imported/store-station.glb-ff04c3ceb60f72cdb0c4efd392a3d508.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 diff --git a/game/assets/store/store-trolley.glb b/game/assets/store/store-trolley.glb new file mode 100644 index 0000000..af29336 Binary files /dev/null and b/game/assets/store/store-trolley.glb differ diff --git a/game/assets/store/store-trolley.glb.import b/game/assets/store/store-trolley.glb.import new file mode 100644 index 0000000..9c7a60a --- /dev/null +++ b/game/assets/store/store-trolley.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dpk2voicjeawl" +path="res://.godot/imported/store-trolley.glb-49508e5c9ef42ca5a5d13aa7e32c6041.scn" + +[deps] + +source_file="res://assets/store/store-trolley.glb" +dest_files=["res://.godot/imported/store-trolley.glb-49508e5c9ef42ca5a5d13aa7e32c6041.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 diff --git a/game/assets/store/store-tub-black.glb b/game/assets/store/store-tub-black.glb new file mode 100644 index 0000000..94d0075 Binary files /dev/null and b/game/assets/store/store-tub-black.glb differ diff --git a/game/assets/store/store-tub-black.glb.import b/game/assets/store/store-tub-black.glb.import new file mode 100644 index 0000000..38427de --- /dev/null +++ b/game/assets/store/store-tub-black.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bcxdenu0m6i8b" +path="res://.godot/imported/store-tub-black.glb-15be468856505cf15769400fb7c391d2.scn" + +[deps] + +source_file="res://assets/store/store-tub-black.glb" +dest_files=["res://.godot/imported/store-tub-black.glb-15be468856505cf15769400fb7c391d2.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 diff --git a/game/assets/store/store-tub-white.glb b/game/assets/store/store-tub-white.glb new file mode 100644 index 0000000..cab483b Binary files /dev/null and b/game/assets/store/store-tub-white.glb differ diff --git a/game/assets/store/store-tub-white.glb.import b/game/assets/store/store-tub-white.glb.import new file mode 100644 index 0000000..da932bf --- /dev/null +++ b/game/assets/store/store-tub-white.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://vasll88y1gxc" +path="res://.godot/imported/store-tub-white.glb-bf06473c83b6ad2657a626f32ba03170.scn" + +[deps] + +source_file="res://assets/store/store-tub-white.glb" +dest_files=["res://.godot/imported/store-tub-white.glb-bf06473c83b6ad2657a626f32ba03170.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 diff --git a/game/assets/store/store-window.glb b/game/assets/store/store-window.glb new file mode 100644 index 0000000..543b070 Binary files /dev/null and b/game/assets/store/store-window.glb differ diff --git a/game/assets/store/store-window.glb.import b/game/assets/store/store-window.glb.import new file mode 100644 index 0000000..44f7a9a --- /dev/null +++ b/game/assets/store/store-window.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://cl1mbjurpjjsd" +path="res://.godot/imported/store-window.glb-117aee5b13f1f48f855cc59a5478ab6e.scn" + +[deps] + +source_file="res://assets/store/store-window.glb" +dest_files=["res://.godot/imported/store-window.glb-117aee5b13f1f48f855cc59a5478ab6e.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 diff --git a/game/assets/store/vending-machine.glb b/game/assets/store/vending-machine.glb new file mode 100644 index 0000000..010edef Binary files /dev/null and b/game/assets/store/vending-machine.glb differ diff --git a/game/assets/store/vending-machine.glb.import b/game/assets/store/vending-machine.glb.import new file mode 100644 index 0000000..4ba6624 --- /dev/null +++ b/game/assets/store/vending-machine.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://b76xahj1v6a5s" +path="res://.godot/imported/vending-machine.glb-2ebbeb04f9452ff8d815cfa46a64cf12.scn" + +[deps] + +source_file="res://assets/store/vending-machine.glb" +dest_files=["res://.godot/imported/vending-machine.glb-2ebbeb04f9452ff8d815cfa46a64cf12.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 diff --git a/game/dev/DemoDriver.gd b/game/dev/DemoDriver.gd index 1c73258..ec50b4c 100644 --- a/game/dev/DemoDriver.gd +++ b/game/dev/DemoDriver.gd @@ -19,6 +19,7 @@ var _player: Player var _t := 0.0 var _step := 0 var _step_t := 0.0 +var _frames := 0 # Each step: {dur, move_to (Vector3 or null), look_at (Vector3 or null), act (String)} # act: "" | "punch" | "kick" | "rain" | "reset" @@ -47,54 +48,108 @@ func _find_player(n: Node) -> Player: return null func _build_script() -> void: - # world layout (from Main._build_rack / _build_office, origin 0,0): - # racks (-0.95,-0.7) (0.95,-0.7) - # crates y=0.5 and y=1.2 rows, x -0.8..0.8 - # desk+printer (2.7,-0.3) cabinet (-2.7,-0.3) cooler (3.3,1.6) - # CRT (-2.7,1.3) box (1.9,2.4) turntable (-1.7,-1.3) + # 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). _script = [] - # ---- ACT 1: the loadout. Stand still and hold each weapon so the viewmodel, - # ---- the grip and one swing per archetype can all be inspected frame by frame. + # ---- 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 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(0, 0, 3.2), - "look_at": Vector3(0, 1.0, 0), "act": "weapon:%d" % slot}) - _script.append({"dur": 1.0, "move_to": null, "look_at": null, "act": ""}) - _script.append({"dur": 1.1, "move_to": null, "look_at": null, "act": "swing"}) + _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 2: the matrix. Box cutter shreds the carton, then clanks off the - # ---- filing cabinet; the sledge then removes the cabinet in one go. + # ---- ACT 3: the matrix. Cutter shreds a carton, clanks off a filing cabinet, + # ---- then the sledge takes the cabinet out. _script += [ - {"dur": 0.6, "move_to": null, "look_at": null, "act": "weapon:1"}, - {"dur": 1.2, "move_to": Vector3(1.9, 0, 3.4), "look_at": Vector3(1.9, 0.3, 2.4), "act": ""}, - {"dur": 0.45, "move_to": null, "look_at": null, "act": "swing"}, - {"dur": 0.45, "move_to": null, "look_at": null, "act": "swing"}, + {"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"}, - # cutter vs steel — expect the clank + the HUD nudge, and no damage - {"dur": 1.3, "move_to": Vector3(-2.7, 0, 1.1), "look_at": Vector3(-2.7, 0.6, -0.3), "act": ""}, + {"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": 0.9, "move_to": null, "look_at": null, "act": "swing"}, - {"dur": 0.5, "move_to": null, "look_at": null, "act": "weapon:4"}, {"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"}, - {"dur": 1.2, "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 3: the boss + the HUD styles + # ---- ACT 4: the printer boss, then a lap of the HUD styles. _script += [ - {"dur": 1.4, "move_to": Vector3(2.7, 0, 1.3), "look_at": Vector3(2.7, 0.95, -0.3), "act": ""}, - {"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"}, - {"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"}, - {"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"}, - {"dur": 1.4, "move_to": null, "look_at": null, "act": "swing"}, - {"dur": 1.6, "move_to": Vector3(0.6, 0, 5.2), "look_at": Vector3(0, 0.6, 0), "act": "hud"}, - {"dur": 1.6, "move_to": null, "look_at": null, "act": "hud"}, - {"dur": 1.6, "move_to": null, "look_at": null, "act": "hud"}, - {"dur": 1.6, "move_to": null, "look_at": null, "act": "hud"}, + {"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 := {} + var worst: Array = [] + for g in ["smashable", "debris"]: # every body is in one of these two + for n in get_tree().get_nodes_in_group(g): + if seen.has(n) or not (n is RigidBody3D): + continue + seen[n] = true + var b := n as RigidBody3D + if b.freeze: + continue + var v := b.linear_velocity.length() + total += v + worst.append([v, b]) + if verbose: + worst.sort_custom(func(a, c): return a[0] > c[0]) + for i in mini(6, worst.size()): + var b: RigidBody3D = worst[i][1] + var what := "RigidBody3D" + if b is Record: + what = "Record(sleeve %s)" % (b as Record).sleeve_genre + elif b is Smashable: + what = "Smashable(%s)" % (b as Smashable).kind + var groups := "" + for g in b.get_groups(): + groups += str(g) + " " + print(" %7.1f m/s %-18s %-22s parent=%s groups=[%s] at %s" % [ + worst[i][0], b.name, what, b.get_parent().name, groups.strip_edges(), + b.global_position.snappedf(0.01)]) + return total + func _process(dt: float) -> void: if _player == null or _step >= _script.size(): return + if _frames < 3: + _frames += 1 + if _frames == 2: + print("[settle] FIRST FRAME — where things start:") + _motion(true) + if _t < 6.0 and int(_t) != int(_t + dt): + print("[settle] t=%ds total body speed = %.2f m/s" % [int(_t + dt), _motion(true)]) _t += dt var s: Dictionary = _script[_step] var dur: float = s["dur"] diff --git a/game/scripts/Hud.gd b/game/scripts/Hud.gd index 0c4d4b0..a78aa71 100644 --- a/game/scripts/Hud.gd +++ b/game/scripts/Hud.gd @@ -292,7 +292,7 @@ func _tick_workorder() -> void: _w_head.text = "MONSTER ROBOT PARTY · WORK ORDER #%04d" % int(_g("order_no", 4471)) var tally: Dictionary = _g("tally", {}) var lines := PackedStringArray() - lines.append("SITE: LEVEL 01 — THE RECORD STORE") + lines.append("SITE: LEVEL 01 — BRANCH OFFICE, 2ND FLOOR") lines.append("TOOL: %s" % str(_g("weapon", "—"))) lines.append("") lines.append("ITEM QTY") diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index 3aaf6c1..845e3dd 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -1,16 +1,17 @@ extends Node3D -## Destroyulator — cascade prototype. +## Destroyulator — LEVEL 01, the office. ## -## Builds a little record rack out of primitives and lets you smash it. The point -## isn't the art (that's a one-line swap for your real .glb racks later) — it's to -## prove two things on your actual M3, today: -## 1. THE DAY-0 GATE: press S to rain 500 rigid bodies and watch the FPS hold. -## 2. THE CASCADE: smash a frozen shelf -> the crates on it fall -> jackets spill -## -> records tumble out -> records crack when they hit the floor (brittle). +## Main is the glue: it builds the level shell (Office), spawns everything SMASHABLE into +## it, owns score/tally, and routes each Smashable's three outcomes (break / dent / +## futile) to Juice and the HUD. ## -## Controls: WASD move · Space jump · L-click punch · R-click kick · Esc release mouse -## · B = rain 500 (stress gate) · R = reset +## Two things it still proves, from the prototype days: +## THE DAY-0 GATE: press B to rain 500 rigid bodies and watch the FPS hold. +## THE CASCADE: smash a frozen desk -> the monitor on it falls; smash a crate -> +## the records spill -> a record cracks a beat later when it lands. +## +## Controls: see KEYS_LINE below, or the Dev HUD (H cycles HUD styles). @onready var _cam: Camera3D var _world: Node3D # everything smashable/debris lives here so reset is one line @@ -29,6 +30,7 @@ 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 func _ready() -> void: randomize() @@ -36,7 +38,8 @@ func _ready() -> void: _juice = Juice.new() add_child(_juice) _juice.setup(_cam) # _setup_world() set _cam to the player's camera - _build_rack(Vector3.ZERO) + _populate() + _spawn_guard = SPAWN_GUARD_TIME # rules layer: starts in FreePlay; keys 1/2 switch, F reports in FindMisfiled _game_mode = GameMode.new() @@ -48,47 +51,27 @@ func _ready() -> void: # ---------------------------------------------------------------- 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() + var we := WorldEnvironment.new() var env := Environment.new() - env.background_mode = Environment.BG_COLOR - env.background_color = Color(0.07, 0.06, 0.09) - env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - env.ambient_light_color = Color(0.5, 0.5, 0.62) - env.ambient_light_energy = 0.6 + _office.configure_environment(env) we.environment = env add_child(we) - var sun := DirectionalLight3D.new() - sun.rotation_degrees = Vector3(-52, -38, 0) - sun.light_energy = 1.3 - sun.shadow_enabled = true - add_child(sun) - - # First-person player replaces the old fixed camera. add_child() runs its _ready() - # synchronously, so player.camera/grab exist on the next line; we reuse them. + # 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 = Vector3(0, 0, 4) # stand back, facing -Z toward the rack + _player.global_position = _office.spawn_point() + _player.rotation.y = _office.spawn_yaw() _cam = _player.camera - # floor - var floor_body := StaticBody3D.new() - var fmesh := MeshInstance3D.new() - var plane := BoxMesh.new() - plane.size = Vector3(24, 0.2, 24) - fmesh.mesh = plane - var fmat := StandardMaterial3D.new() - fmat.albedo_color = Color(0.13, 0.12, 0.15) - fmesh.material_override = fmat - floor_body.add_child(fmesh) - var fcol := CollisionShape3D.new() - var fshape := BoxShape3D.new() - fshape.size = plane.size - fcol.shape = fshape - floor_body.add_child(fcol) - floor_body.position = Vector3(0, -0.1, 0) - add_child(floor_body) - _world = Node3D.new() _world.name = "World" add_child(_world) @@ -109,6 +92,14 @@ const CABINET_GLB := "res://assets/store/filing-cabinet.glb" 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" + +## 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. +const MASSES := { + "paper": 0.3, "cardboard": 1.2, "plastic": 6.0, "vinyl": 0.6, + "wood": 18.0, "glass": 12.0, "steel": 40.0, +} # Generated sleeve art (Nano Banana): each file is a 3x3 sheet of nine original # fictional album covers. A record picks one tile at random via uv1 scale/offset — @@ -120,56 +111,82 @@ const SLEEVE_ATLASES: Array[String] = [ var _sleeve_textures: Array[Texture2D] = [] var _records_skinned := 0 -func _build_rack(origin: Vector3) -> void: +## Fill the office with the SMASHABLE half of the level. The Office class owns the shell +## and the static fittings; everything spawned here is a rigid body you can wreck. +func _populate() -> void: _records.clear() # stale refs from the freed world; repopulated by _place_record - # two racks form a smashable back wall of the booth - _glb_piece(RACK_GLB, "wood", Vector2(origin.x - 0.95, origin.z - 0.7), true) - _glb_piece(RACK_GLB, "wood", Vector2(origin.x + 0.95, origin.z - 0.7), true) - # crates of records on the floor in front, in two rows - var spots := [ - Vector2(-0.8, 0.5), Vector2(0.0, 0.5), Vector2(0.8, 0.5), - Vector2(-0.4, 1.2), Vector2(0.4, 1.2), - ] - for spot in spots: - var xz: Vector2 = spot + Vector2(origin.x, origin.z) - var crate := _glb_piece(CRATE_GLB, "wood", xz, false) - var crate_top: float = crate["top"] - # a few records stacked on the crate; smashing the crate spills them, and each - # one is now a grabbable Record (sleeve + hidden disc), not a one-hit prop - for i in range(3): - var jitter := Vector2(randf_range(-0.06, 0.06), randf_range(-0.06, 0.06)) - var rec := _place_record(xz + jitter, crate_top + float(i) * 0.03) - (crate["piece"] as Smashable).supports.append(rec) - print("[art] records skinned with sleeve art: ", _records_skinned) - _build_office(origin) + # --- the bullpen: a desk at every cluster spot, each with a monitor on it --- + var spots := _office.desk_spots() + for i in spots.size(): + var t: Transform3D = spots[i] + var xz := Vector2(t.origin.x, t.origin.z) + var desk := _glb_piece(DESK_GLB, "wood", xz, true, 0.0, t.basis.get_euler().y) + var top: float = desk["top"] + var crt := _glb_piece(CRT_GLB, "glass", xz + Vector2(0.0, 0.10), false, top) + (desk["piece"] as Smashable).supports.append(crt["piece"]) + # Chair goes behind the desk, offset by the desk's OWN measured depth. Office.gd + # 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) + 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) -## Lane 2's workplace props scattered as furniture, with the office PRINTER as the level -## boss on its desk (soaks several hits, dies in a bigger burst). Each of these ships a -## .fractured.glb, so _glb_piece auto-wires real chunk destruction (see there). -func _build_office(origin: Vector3) -> void: - var o := Vector2(origin.x, origin.z) - # the boss: a printer on a desk. Desk is a frozen support; smashing it drops the printer. - var desk := _glb_piece(DESK_GLB, "wood", o + Vector2(2.7, -0.3), true) - var printer := _glb_piece(PRINTER_GLB, "steel", o + Vector2(2.7, -0.3), false, desk["top"]) + # --- 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 - (desk["piece"] as Smashable).supports.append(printer["piece"]) + (cdesk["piece"] as Smashable).supports.append(printer["piece"]) - # furniture around the booth — floor props you can wreck into real chunks - _glb_piece(CABINET_GLB, "steel", o + Vector2(-2.7, -0.3), false) - _glb_piece(COOLER_GLB, "steel", o + Vector2(3.3, 1.6), false) - _glb_piece(CRT_GLB, "glass", o + Vector2(-2.7, 1.3), false) - _glb_piece(BOX_GLB, "cardboard", o + Vector2(1.9, 2.4), false) - _glb_piece(TURNTABLE_GLB, "wood", o + Vector2(-1.7, -1.3), false) + # --- 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) + + # --- 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)]: + 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(): + _glb_piece(CHAIR_GLB, "plastic", Vector2(t.origin.x, t.origin.z), false, 0.0, + t.basis.get_euler().y) + + print("[art] records skinned with sleeve art: ", _records_skinned) ## Spawn one grabbable Record (sleeve + nested disc) resting on `sit_on`. Mirrors ## _glb_piece's "drop the AABB bottom onto sit_on" trick, but the Record builds its own ## visuals/colliders/disc in _ready, so we just skin the sleeve and place it. func _place_record(xz: Vector2, sit_on: float) -> Record: var rec := Record.new() + rec.name = "record" rec.setup() var g: String = GameMode.GENRES[randi() % GameMode.GENRES.size()] rec.sleeve_genre = g @@ -189,10 +206,13 @@ func _place_record(xz: Vector2, sit_on: float) -> Record: ## Instance a GLB as the VISUAL of a Smashable, give it a box collider auto-sized to ## the mesh AABB, and drop it so its bottom rests at height `sit_on`. Returns ## { piece: Smashable, top: float } so a caller can stack the next thing on top. -func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := 0.0) -> Dictionary: +func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := 0.0, + yaw := 0.0) -> Dictionary: var s := Smashable.new() s.kind = kind s.start_frozen = frozen + s.mass = float(MASSES.get(kind, 5.0)) + s.name = path.get_file().get_basename() # so physics diagnostics name the culprit # real destruction: if Lane 2 shipped a .fractured.glb next to this GLB, use it var frac := path.get_basename() + ".fractured.glb" if ResourceLoader.exists(frac): @@ -203,6 +223,8 @@ func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := if kind == "vinyl": _skin_record(vis) # random sleeve art on the jacket faces _world.add_child(s) # in-tree so transforms/AABBs resolve + # AABB is measured BEFORE yaw so the collider stays axis-aligned in body space; the + # body is then rotated as a whole, which keeps box/mesh in agreement. var ab := _body_local_aabb(s, vis) if ab.size == Vector3.ZERO: ab = AABB(Vector3(-0.2, 0.0, -0.2), Vector3(0.4, 0.4, 0.4)) # fallback @@ -212,9 +234,11 @@ func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := col.shape = box col.position = ab.get_center() s.add_child(col) + s.rotation.y = yaw s.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) # AABB bottom lands on sit_on + s.set_meta("spawn_pos", s.global_position) # for the spawn-guard warning _wire(s) - return {"piece": s, "top": sit_on + ab.size.y} + return {"piece": s, "top": sit_on + ab.size.y, "aabb": ab} ## Give a record's sleeve faces a random cover from the generated 3x3 sheets. ## The record.glb sleeve cube carries M_Sleeve_Front / M_Sleeve_Back materials @@ -316,6 +340,44 @@ func _piece(kind: String, size: Vector3, pos: Vector3, frozen: bool, cylinder := _wire(s) return s +# ---------------------------------------------------------------- spawn guard +## A prop that spawns even slightly inside another collider gets depenetrated by Jolt, +## and depenetration is not gentle: a chair overlapping a table left the building at +## 450 m/s on frame one. Placing 30-odd props by formula means this WILL happen again +## the next time a room is re-laid-out, so it's handled systemically rather than by +## nudging coordinates until the symptom goes away. +## +## For the first fraction of a second, dynamic bodies are speed-limited — enough for a +## real overlap to push apart calmly, far too little to launch anything. Each offender +## is named once in a warning, so the underlying overlap stays visible and fixable +## instead of being silently papered over. +const SPAWN_GUARD_TIME := 0.75 +const SPAWN_MAX_SPEED := 2.5 +const SPAWN_MAX_SPIN := 10.0 +var _spawn_guard := 0.0 +var _guard_warned := {} + +func _physics_process(dt: float) -> void: + if _spawn_guard <= 0.0: + return + _spawn_guard -= dt + for n in get_tree().get_nodes_in_group("smashable"): + var b := n as RigidBody3D + if b == null or b.freeze: + continue + var v := b.linear_velocity.length() + if v > SPAWN_MAX_SPEED: + if not _guard_warned.has(b): + _guard_warned[b] = true + # 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]) + 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 + func _on_smashed(kind: String, at: Vector3, shards: int) -> void: _smash_count += 1 _tally[kind] = int(_tally.get(kind, 0)) + 1 @@ -392,7 +454,7 @@ func _reset() -> void: _score = 0 _tally.clear() await get_tree().process_frame - _build_rack(Vector3.ZERO) + _populate() # 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: diff --git a/game/scripts/Office.gd b/game/scripts/Office.gd new file mode 100644 index 0000000..5921101 --- /dev/null +++ b/game/scripts/Office.gd @@ -0,0 +1,454 @@ +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) diff --git a/game/scripts/Office.gd.uid b/game/scripts/Office.gd.uid new file mode 100644 index 0000000..5794565 --- /dev/null +++ b/game/scripts/Office.gd.uid @@ -0,0 +1 @@ +uid://btc0q0j2wj0bs diff --git a/game/scripts/Player.gd b/game/scripts/Player.gd index a4377dc..5fb6a26 100644 --- a/game/scripts/Player.gd +++ b/game/scripts/Player.gd @@ -23,6 +23,8 @@ class_name Player @export var mouse_sensitivity: float = 0.0025 # radians per pixel of mouse motion @export var pitch_limit_deg: float = 89.0 @export var eye_height: float = 1.6 +## How far below the eye a swing actually starts (see _strike). +const SWING_DROP := 0.32 var camera: Camera3D # Main reads this for the crosshair raycast var grab: GrabController # the record grab/extract/throw hands @@ -246,21 +248,36 @@ func _process(dt: float) -> void: _look_delta = _look_delta.lerp(Vector2.ZERO, clampf(dt * 14.0, 0.0, 1.0)) # ---------------------------------------------------------------- the hit -## Forgiving SPHERE query from the camera centre (the crosshair), so melee doesn't need +## Forgiving CAPSULE sweep from the camera along the crosshair, so melee doesn't need ## pixel aim. intersect_shape is NOT distance-sorted, so pick the nearest collider. +## +## This used to be a sphere parked AT `reach`, which meant anything CLOSER than the +## weapon's reach fell in front of the test and was missed completely — you could stand +## against the printer with a sledgehammer and swing through it. A capsule spanning +## camera -> camera + dir*reach covers the whole swing instead of just its far end. +## ## Runs from _physics_process, where direct_space_state is valid. func _strike(w: Weapon) -> Node: var space := get_world_3d().direct_space_state if space == null: return null - var origin := camera.global_position + # Swing from roughly where the hands are, not from the eyeballs. With eyes at 1.6 m, + # a carton sitting on the floor is ~1.5 m away even when you're standing right over + # it, so a short-reach weapon could never touch anything on the ground. Dropping the + # origin to hand height fixes that without inflating every weapon's reach. + var origin := camera.global_position - Vector3(0.0, SWING_DROP, 0.0) var dir := -camera.global_transform.basis.z - var sphere := SphereShape3D.new() - sphere.radius = w.radius + var cap := CapsuleShape3D.new() + cap.radius = w.radius + cap.height = maxf(w.reach, w.radius * 2.0 + 0.01) # total height, caps included + # CapsuleShape3D runs along its local Y, so build a basis whose Y is the view dir + var ref := Vector3.UP if absf(dir.dot(Vector3.UP)) < 0.95 else Vector3.RIGHT + var bx := ref.cross(dir).normalized() + var bz := bx.cross(dir).normalized() var params := PhysicsShapeQueryParameters3D.new() - params.shape = sphere - params.transform = Transform3D(Basis.IDENTITY, origin + dir * w.reach) + params.shape = cap + params.transform = Transform3D(Basis(bx, dir, bz), origin + dir * (w.reach * 0.5)) params.collide_with_bodies = true params.collide_with_areas = false params.exclude = [get_rid()] diff --git a/game/scripts/Record.gd b/game/scripts/Record.gd index e729097..43f9763 100644 --- a/game/scripts/Record.gd +++ b/game/scripts/Record.gd @@ -109,6 +109,7 @@ func _split_glb() -> void: # --- disc: its own frozen vinyl Smashable that lives in the world and follows us --- _disc = Smashable.new() + _disc.name = "disc" _disc.kind = "vinyl" _disc.start_frozen = false # we drive freeze ourselves (kinematic follow) _disc.add_to_group("disc") diff --git a/tools/gen_office_props.py b/tools/gen_office_props.py new file mode 100644 index 0000000..1c42742 --- /dev/null +++ b/tools/gen_office_props.py @@ -0,0 +1,321 @@ +"""Generate the office props Godot primitives can't fake, as GLBs, in Blender. + +The office level builds its walls, desks, partitions and counters from boxes in code — +those ARE boxes, so modelling them is wasted effort. These four aren't: an office chair, +a vending machine, a microwave and a potted plant all have silhouettes that a box reads +as obviously wrong, and all four are things you instantly recognise in a workplace. + + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --python tools/gen_office_props.py -- [--render] + +Convention (same as the rest of the repo): glTF Y-up, 1 unit = 1 m, ORIGIN AT +FLOOR-CENTRE, so Office.gd can drop a prop at a floor position with no offset maths. +In Blender that means authoring Z-up with the base sitting on z = 0. +""" + +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 ball(r, loc=(0, 0, 0), segs=12, rings=8): + bpy.ops.mesh.primitive_uv_sphere_add(radius=r, segments=segs, ring_count=rings, + location=loc) + 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=35.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_chair(M): + """Task chair: 5-star base, gas lift, seat, backrest, arms. ~1.05 m tall.""" + parts = [] + # star base + castors + for i in range(5): + a = math.radians(i * 72.0) + d = Vector((math.cos(a), math.sin(a), 0.0)) + arm = box((0.055, 0.30, 0.035), loc=tuple(d * 0.16 + Vector((0, 0, 0.055))), + rot=(0, 0, a + math.radians(90))) + parts.append(assign(bevel(arm, 0.010), M["plastic_dark"])) + w = cyl(0.030, 0.022, loc=tuple(d * 0.30 + Vector((0, 0, 0.030))), + rot=(math.radians(90), 0, a), verts=12) + parts.append(assign(w, M["rubber"])) + hub = cyl(0.055, 0.06, loc=(0, 0, 0.075), verts=16) + parts.append(assign(hub, M["plastic_dark"])) + # gas cylinder + col = cyl(0.030, 0.24, loc=(0, 0, 0.22), verts=14) + parts.append(assign(col, M["steel"])) + shroud = cyl(0.042, 0.16, loc=(0, 0, 0.18), r2=0.034, verts=14) + parts.append(assign(shroud, M["plastic_dark"])) + # seat pan + seat = box((0.46, 0.44, 0.075), loc=(0, 0, 0.375)) + parts.append(assign(bevel(seat, 0.028, 3), M["fabric"])) + # backrest, reclined + back = box((0.44, 0.070, 0.50), loc=(0, -0.20, 0.70), + rot=(math.radians(-11), 0, 0)) + parts.append(assign(bevel(back, 0.026, 3), M["fabric"])) + spine = box((0.07, 0.09, 0.20), loc=(0, -0.185, 0.47)) + parts.append(assign(bevel(spine, 0.012), M["plastic_dark"])) + # armrests + for s in (-1, 1): + post = box((0.04, 0.05, 0.16), loc=(s * 0.245, -0.03, 0.475)) + parts.append(assign(bevel(post, 0.010), M["plastic_dark"])) + pad = box((0.06, 0.26, 0.035), loc=(s * 0.245, 0.01, 0.565)) + parts.append(assign(bevel(pad, 0.014, 3), M["plastic_dark"])) + return parts + + +def p_vending(M): + """Drinks machine, 1.83 m. Glass front so it reads as a vending machine, not a box.""" + parts = [] + shell = box((0.90, 0.78, 1.83), loc=(0, 0, 0.915)) + parts.append(assign(bevel(shell, 0.014, 2), M["vend_red"])) + glass = box((0.62, 0.03, 1.28), loc=(-0.10, -0.395, 1.02)) + parts.append(assign(glass, M["glass"])) + frame = box((0.68, 0.05, 1.36), loc=(-0.10, -0.375, 1.02)) + parts.append(assign(bevel(frame, 0.008), M["plastic_dark"])) + # stacked cans behind the glass + for row in range(5): + for c in range(4): + can = cyl(0.033, 0.115, loc=(-0.31 + c * 0.14, -0.30, 0.52 + row * 0.24), + verts=10) + parts.append(assign(can, M["can"] if (row + c) % 2 else M["can2"])) + # selection panel + delivery flap + panel = box((0.17, 0.04, 1.10), loc=(0.31, -0.39, 1.12)) + parts.append(assign(bevel(panel, 0.008), M["plastic_dark"])) + for i in range(5): + btn = box((0.055, 0.02, 0.045), loc=(0.31, -0.41, 1.52 - i * 0.13)) + parts.append(assign(btn, M["can2"])) + flap = box((0.60, 0.05, 0.26), loc=(-0.10, -0.38, 0.28)) + parts.append(assign(bevel(flap, 0.010), M["plastic_dark"])) + top = box((0.92, 0.80, 0.10), loc=(0, 0, 1.86)) + parts.append(assign(bevel(top, 0.012), M["plastic_dark"])) + return parts + + +def p_microwave(M): + parts = [] + shell = box((0.50, 0.38, 0.29), loc=(0, 0, 0.145)) + parts.append(assign(bevel(shell, 0.010, 2), M["appliance"])) + door = box((0.34, 0.03, 0.22), loc=(-0.07, -0.20, 0.15)) + parts.append(assign(door, M["glass_dark"])) + handle = box((0.030, 0.035, 0.20), loc=(0.115, -0.215, 0.15)) + parts.append(assign(bevel(handle, 0.008), M["plastic_dark"])) + pad = box((0.10, 0.02, 0.22), loc=(0.19, -0.20, 0.15)) + parts.append(assign(pad, M["plastic_dark"])) + return parts + + +def p_plant(M): + """The obligatory sad office ficus.""" + parts = [] + pot = cyl(0.17, 0.34, loc=(0, 0, 0.17), r2=0.22, verts=18) + parts.append(assign(pot, M["terracotta"])) + rim = cyl(0.235, 0.05, loc=(0, 0, 0.335), verts=18) + parts.append(assign(rim, M["terracotta"])) + soil = cyl(0.20, 0.03, loc=(0, 0, 0.345), verts=18) + parts.append(assign(soil, M["soil"])) + trunk = cyl(0.028, 0.44, loc=(0, 0, 0.56), r2=0.020, verts=8) + parts.append(assign(trunk, M["stem"])) + # Leaves radiate from points ON the trunk and droop outward. Building each one as a + # span between two explicit points keeps it attached — driving it with euler tilts + # left the blades floating in mid-air, disconnected from their stalks. + for i in range(12): + a = math.radians(i * 73.0) + h = 0.60 + (i % 4) * 0.12 + droop = -0.10 - (i % 3) * 0.06 + out = Vector((math.cos(a), math.sin(a), 0.0)) + root = Vector((0.0, 0.0, h)) + out * 0.018 + stalk_end = root + out * 0.11 + Vector((0, 0, droop * 0.4)) + tip = stalk_end + out * 0.21 + Vector((0, 0, droop)) + parts.append(assign(_span(0.009, 0.009, root, stalk_end), M["stem"])) + parts.append(assign(_span(0.075, 0.012, stalk_end, tip, flat=True), M["leaf"])) + return parts + + +def _span(half_w, half_t, p0, p1, flat=False): + """A box (or flattened blade) running from p0 to p1, oriented along the span.""" + p0, p1 = Vector(p0), Vector(p1) + d = p1 - p0 + n = d.length + if n < 1e-6: + return None + o = box((half_w * 2.0, n, half_t * 2.0) if flat else (half_w * 2.0, n, half_w * 2.0)) + quat = Vector((0, 1, 0)).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 bevel(o, 0.006, 2) + + +PROPS = { + "office-chair": p_chair, + "vending-machine": p_vending, + "microwave": p_microwave, + "office-plant": p_plant, +} + + +def materials(): + return { + "plastic_dark": mat("plastic_dark", (0.09, 0.09, 0.10), rough=0.55), + "rubber": mat("rubber", (0.05, 0.05, 0.06), rough=0.92), + "steel": mat("steel", (0.66, 0.67, 0.70), rough=0.32, metal=1.0), + "fabric": mat("fabric", (0.16, 0.18, 0.24), rough=0.96), + "vend_red": mat("vend_red", (0.55, 0.07, 0.09), rough=0.42), + "glass": mat("glass", (0.72, 0.80, 0.85), rough=0.06, alpha=0.30), + "glass_dark": mat("glass_dark", (0.10, 0.12, 0.13), rough=0.16), + "can": mat("can", (0.72, 0.14, 0.14), rough=0.35, metal=0.7), + "can2": mat("can2", (0.14, 0.34, 0.66), rough=0.35, metal=0.7), + "appliance": mat("appliance", (0.80, 0.80, 0.82), rough=0.36, metal=0.3), + "terracotta": mat("terracotta", (0.52, 0.29, 0.20), rough=0.85), + "soil": mat("soil", (0.14, 0.11, 0.09), rough=1.0), + "stem": mat("stem", (0.22, 0.30, 0.16), rough=0.8), + "leaf": mat("leaf", (0.20, 0.42, 0.19), rough=0.72), + } + + +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("[office] %-18s -> %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 = 560 + scene.render.resolution_y = 560 + 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("[office] done -> %s" % OUT_DIR) + + +main()