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:
parent
61757d24bc
commit
d17e2314aa
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
BIN
game/assets/store/microwave.glb
Normal file
BIN
game/assets/store/microwave.glb
Normal file
Binary file not shown.
45
game/assets/store/microwave.glb.import
Normal file
45
game/assets/store/microwave.glb.import
Normal 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
|
||||
BIN
game/assets/store/office-chair.glb
Normal file
BIN
game/assets/store/office-chair.glb
Normal file
Binary file not shown.
45
game/assets/store/office-chair.glb.import
Normal file
45
game/assets/store/office-chair.glb.import
Normal 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
|
||||
BIN
game/assets/store/office-plant.glb
Normal file
BIN
game/assets/store/office-plant.glb
Normal file
Binary file not shown.
45
game/assets/store/office-plant.glb.import
Normal file
45
game/assets/store/office-plant.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-aircon.glb
Normal file
BIN
game/assets/store/store-aircon.glb
Normal file
Binary file not shown.
45
game/assets/store/store-aircon.glb.import
Normal file
45
game/assets/store/store-aircon.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-bin-long.glb
Normal file
BIN
game/assets/store/store-bin-long.glb
Normal file
Binary file not shown.
45
game/assets/store/store-bin-long.glb.import
Normal file
45
game/assets/store/store-bin-long.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-column-white.glb
Normal file
BIN
game/assets/store/store-column-white.glb
Normal file
Binary file not shown.
45
game/assets/store/store-column-white.glb.import
Normal file
45
game/assets/store/store-column-white.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-crate-blue.glb
Normal file
BIN
game/assets/store/store-crate-blue.glb
Normal file
Binary file not shown.
45
game/assets/store/store-crate-blue.glb.import
Normal file
45
game/assets/store/store-crate-blue.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-door.glb
Normal file
BIN
game/assets/store/store-door.glb
Normal file
Binary file not shown.
45
game/assets/store/store-door.glb.import
Normal file
45
game/assets/store/store-door.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-rack-long.glb
Normal file
BIN
game/assets/store/store-rack-long.glb
Normal file
Binary file not shown.
45
game/assets/store/store-rack-long.glb.import
Normal file
45
game/assets/store/store-rack-long.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-rack-wide.glb
Normal file
BIN
game/assets/store/store-rack-wide.glb
Normal file
Binary file not shown.
45
game/assets/store/store-rack-wide.glb.import
Normal file
45
game/assets/store/store-rack-wide.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-station.glb
Normal file
BIN
game/assets/store/store-station.glb
Normal file
Binary file not shown.
45
game/assets/store/store-station.glb.import
Normal file
45
game/assets/store/store-station.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-trolley.glb
Normal file
BIN
game/assets/store/store-trolley.glb
Normal file
Binary file not shown.
45
game/assets/store/store-trolley.glb.import
Normal file
45
game/assets/store/store-trolley.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-tub-black.glb
Normal file
BIN
game/assets/store/store-tub-black.glb
Normal file
Binary file not shown.
45
game/assets/store/store-tub-black.glb.import
Normal file
45
game/assets/store/store-tub-black.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-tub-white.glb
Normal file
BIN
game/assets/store/store-tub-white.glb
Normal file
Binary file not shown.
45
game/assets/store/store-tub-white.glb.import
Normal file
45
game/assets/store/store-tub-white.glb.import
Normal 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
|
||||
BIN
game/assets/store/store-window.glb
Normal file
BIN
game/assets/store/store-window.glb
Normal file
Binary file not shown.
45
game/assets/store/store-window.glb.import
Normal file
45
game/assets/store/store-window.glb.import
Normal 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
|
||||
BIN
game/assets/store/vending-machine.glb
Normal file
BIN
game/assets/store/vending-machine.glb
Normal file
Binary file not shown.
45
game/assets/store/vending-machine.glb.import
Normal file
45
game/assets/store/vending-machine.glb.import
Normal 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
|
||||
@ -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"]
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
454
game/scripts/Office.gd
Normal 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)
|
||||
1
game/scripts/Office.gd.uid
Normal file
1
game/scripts/Office.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://btc0q0j2wj0bs
|
||||
@ -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()]
|
||||
|
||||
@ -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
321
tools/gen_office_props.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user