LANE6: LEVEL 01 is an office — Dunder Mifflin floor plan, and a spawn guard

The game had no room. Props sat on a grey disc in a black void, which quietly broke
the premise: a game about wrecking your workplace needs a workplace.

Office.gd — LEVEL 01
- Open-plan office laid out after The Office (US) floor plan: bullpen of facing desk
  pairs behind cubicle partitions, reception, glass-walled manager's office, conference
  room, break room with vending machines, copier alcove, warehouse roller door.
- Drop ceiling on a T-bar grid with fluorescent troffers that ARE the light sources
  (the old scene lit an interior with one outdoor directional lamp, which is exactly
  why it read as "props on a plane"), window wall with blinds, magnolia and carpet.
- Office owns the shell + static fittings; Main._populate() owns everything smashable
  and asks Office where things go. Walls/desks/counters are boxes because they ARE
  boxes; chairs, vending machines, microwave and plant come from a new Blender
  generator (tools/gen_office_props.py) because a box reads as wrong for those.

The level no longer falls over on its own
- Records were stacked 3 cm apart vertically while being 30 cm TALL, so Jolt resolved
  27 cm of interpenetration explosively on frame one and shoved the furniture over
  before the player touched anything. They now stand side by side.
- Per-material mass (MASSES): everything was 1 kg, so a thrown record could tip a
  filing cabinet.
- Spawn guard: placing ~40 props by formula guarantees an occasional overlap, and
  depenetration is violent (a chair left the building at 500 m/s). Dynamic bodies are
  speed-limited for 0.75 s, and each offender is named once with the position it was
  PLACED at, so the cause stays visible instead of being papered over. It then found
  the real bug: bullpen rows 3.6 m apart left the two rows' chairs meeting
  back-to-back with 3 cm to spare. Rows are now 4.8 m apart.
- dev/DemoDriver.gd act 0 touches nothing for 5 s and prints total body speed. Now
  reads 0.00 m/s with zero spawn warnings.

Two real melee bugs found while testing the level
- The hit test was a SPHERE parked at `reach`, so anything CLOSER than the weapon's
  reach fell in front of it and was missed — you could stand against the printer with
  a sledgehammer and swing straight through it. Now a capsule swept from the camera.
- Swings started at eye height (1.6 m), so a carton on the floor was ~1.5 m away even
  standing over it and short-reach weapons could never touch anything on the ground.
  Swings now originate at hand height.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Monster Robot Party 2026-07-31 17:13:23 +10:00
parent 61757d24bc
commit d17e2314aa
42 changed files with 1788 additions and 125 deletions

View File

@ -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

View File

@ -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.

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

View File

@ -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"]

View File

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

View File

@ -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 <name>.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:

454
game/scripts/Office.gd Normal file
View File

@ -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)

View File

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

View File

@ -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()]

View File

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

321
tools/gen_office_props.py Normal file
View File

@ -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()