Compare commits

...

2 Commits

Author SHA1 Message Date
9dd6824c14 LANE4 (web): catalog Lane 2 batch, printer boss, 404 fix, drag-to-look
Consume Lane 2's props + close the review gaps in the browser toy.

props.js: all 16 depot slugs added to CATALOG (material/kind per prop; printer is the
boss). loadFractured() now (a) caches its result per id — including the null miss — so a
prop with no fractured sibling stops re-fetching + 404-ing on EVERY spawn (the record.
fractured.glb spam), and (b) returns the real chunk container (the fractured_root wrapper
Blender exports), so physics.js's direct-children chunk swap actually fires.

main.js: loadHeroProps() places the printer BOSS + office set dressing (desk, crt-monitor,
filing-cabinet, water-cooler) with their fractured templates. The boss soaks 4 hits then
bursts into its 16 GLB chunks (kept non-shatterable so a topple can't break it early).

Pointer-lock fallback (shareability fix): if the lock request is refused — iframes/embedded
hosts, where the toy used to dead-end on the title screen — we fall back to DRAG-TO-LOOK
(hold-drag rotates, a tap still smashes) so the shareable link always plays.

Assets: office-printer / crt-monitor / filing-cabinet mirrored into web/assets/ (offline
fallback); record/crate/rack.fractured.glb generated + mirrored so the record ritual smashes
into real chunks. The three store fractured GLBs were also pushed to the 3GOD shelf.

Verified in-browser via ?debug: printer aimSmash→"printer" then 16 chunks; thrown record →
8 chunks; debris drains 16→0; record.fractured.glb serves 200 (no 404); drag-look rotates
the camera when pointer lock is unavailable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:28:25 +10:00
51210215e2 LANE4 (Godot): real fractured-chunk destruction + printer boss
Consume Lane 2's pre-fractured props in the Godot game. Smashable.shatter() now prefers
a <name>.fractured.glb sibling: it instances the fractured template and turns each
chunk_00…chunk_NN mesh into its own debris RigidBody3D that inherits the intact body's
velocity + the smash impulse (box collider per chunk, debris group so the HUD count +
Juice keep working), falling back to primitive shards when no fractured sibling exists.
_glb_piece auto-wires the sibling, so every GLB prop upgrades for free.

Smashable gains hits_to_break + is_boss: the office PRINTER is the level boss — it sits on
a desk, soaks 5 hits (rocking with each), then bursts in a bigger juice cloud into its 16
GLB chunks. Six more Lane 2 props (crt-monitor, filing-cabinet, water-cooler, cardboard-box,
turntable, office-desk) are scattered as smashable furniture; the store crate/rack also pick
up real 8-chunk shatter from their mirrored fractured GLBs.

Assets pulled into game/assets/store/ (intact + .fractured.glb): office-printer, office-desk,
crt-monitor, filing-cabinet, water-cooler, cardboard-box, turntable + record/crate/rack
fractured siblings (generated via meshgod's Voronoi fracture CLI).

Verified headless: smoke test clean (0 script errors), printer boss soaks 4 hits then
spawns 16 chunk bodies, a crate spawns 8 — both real GLB chunks, not primitive shards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:27:50 +10:00
58 changed files with 1345 additions and 23 deletions

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://da7nw437f560t"
path="res://.godot/imported/cardboard-box_Image_0.png-1ee980fda9ae4d497f5cca190eeb76c8.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "0cf1114797fe45a81d154d2fcc0a4f46"
}
[deps]
source_file="res://assets/store/cardboard-box_Image_0.png"
dest_files=["res://.godot/imported/cardboard-box_Image_0.png-1ee980fda9ae4d497f5cca190eeb76c8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://drngjhms1rnpn"
path="res://.godot/imported/crt-monitor_Image_0.png-da1d917eff01416cd93723dced8b6369.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "3875bd2eff71b363cd60ad42044396e3"
}
[deps]
source_file="res://assets/store/crt-monitor_Image_0.png"
dest_files=["res://.godot/imported/crt-monitor_Image_0.png-da1d917eff01416cd93723dced8b6369.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bir2e14ccpyuo"
path="res://.godot/imported/filing-cabinet_Image_0.png-313e4db071c213a113487af87b8aea31.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "ccf317fa58cf300cc0b0127ce3c8ee2b"
}
[deps]
source_file="res://assets/store/filing-cabinet_Image_0.png"
dest_files=["res://.godot/imported/filing-cabinet_Image_0.png-313e4db071c213a113487af87b8aea31.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c4jdtjsy2a4d2"
path="res://.godot/imported/office-desk_Image_0.png-b0bb1e28cdc788a542ac6f6a6412e209.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "3e881cbe87ea8723040dd9e590ac23b6"
}
[deps]
source_file="res://assets/store/office-desk_Image_0.png"
dest_files=["res://.godot/imported/office-desk_Image_0.png-b0bb1e28cdc788a542ac6f6a6412e209.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dbaffh0f136nx"
path="res://.godot/imported/office-printer_Image_0.png-519809ff2a7badc15e516223548200d4.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "5e512866a9375ed59c4ac7fdb2afc748"
}
[deps]
source_file="res://assets/store/office-printer_Image_0.png"
dest_files=["res://.godot/imported/office-printer_Image_0.png-519809ff2a7badc15e516223548200d4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bwdpl5puobadl"
path="res://.godot/imported/turntable_Image_0.png-96693b5eca194d04c9a4e37390f0886a.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "380e36d37b70b4fb371e8ff3a77dc39b"
}
[deps]
source_file="res://assets/store/turntable_Image_0.png"
dest_files=["res://.godot/imported/turntable_Image_0.png-96693b5eca194d04c9a4e37390f0886a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dp2giuk7nmmtn"
path="res://.godot/imported/water-cooler_Image_0.png-549056f3a4f7be121fc54dd397f77869.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "b9d554065d31b71c06e9c8417b773938"
}
[deps]
source_file="res://assets/store/water-cooler_Image_0.png"
dest_files=["res://.godot/imported/water-cooler_Image_0.png-549056f3a4f7be121fc54dd397f77869.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -92,6 +92,15 @@ const RACK_GLB := "res://assets/store/rack.glb"
const CRATE_GLB := "res://assets/store/crate.glb"
const RECORD_GLB := "res://assets/store/record.glb"
# Lane 2 hero props (each ships a .fractured.glb sibling → real shard-by-shard smashing)
const DESK_GLB := "res://assets/store/office-desk.glb"
const PRINTER_GLB := "res://assets/store/office-printer.glb"
const CRT_GLB := "res://assets/store/crt-monitor.glb"
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"
# 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 —
# one texture in VRAM, eighteen different-looking records on the floor.
@ -124,6 +133,28 @@ func _build_rack(origin: Vector3) -> void:
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)
## 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"])
var boss := printer["piece"] as Smashable
boss.is_boss = true
boss.hits_to_break = 5 # the boss soaks a beating before it bursts
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"])
# 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)
## 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
@ -153,6 +184,10 @@ func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on :=
var s := Smashable.new()
s.kind = kind
s.start_frozen = frozen
# 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):
s.fractured_scene = load(frac) as PackedScene
var packed := load(path) as PackedScene
var vis: Node3D = packed.instantiate()
s.add_child(vis)

View File

@ -13,6 +13,16 @@ class_name Smashable
@export_enum("wood", "cardboard", "vinyl", "glass", "steel") var kind: String = "wood"
@export var start_frozen: bool = false
## Pre-fractured sibling (Lane 2 contract #2): a scene whose leaf `chunk_*` meshes are
## the real shards. When set, shatter() spawns those instead of primitive cubes. Main
## auto-assigns it from a `<name>.fractured.glb` next to the intact GLB.
@export var fractured_scene: PackedScene = null
## Hits to destroy. 1 = normal (one smash breaks it). The printer boss takes several.
@export var hits_to_break: int = 1
## Boss props get a bigger death burst (more juice particles) + survive extra hits.
@export var is_boss: bool = false
var _hits_taken := 0
# per-material behaviour — this table is where "material identity" is authored.
# brittle_speed 0.0 == not brittle (won't self-shatter on impact).
const PROFILES := {
@ -58,8 +68,17 @@ func _on_body_entered(_body: Node) -> void:
if _speed >= float(p["brittle_speed"]):
shatter(Vector3.ZERO)
## Called by the hammer.
## Called by the hammer. Bosses (hits_to_break > 1) soak hits and visibly rock before
## the final blow shatters them; a normal prop (hits_to_break == 1) breaks on hit one.
func smash(impulse: Vector3) -> void:
if _shattered:
return
_hits_taken += 1
if _hits_taken < hits_to_break:
# survives this hit — a physical knockback so the boss reacts to the beating
sleeping = false
apply_central_impulse(impulse.limit_length(6.0) * 0.25)
return
shatter(impulse)
func shatter(impulse: Vector3) -> void:
@ -67,9 +86,13 @@ func shatter(impulse: Vector3) -> void:
return
_shattered = true
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
smashed.emit(kind, global_position, int(p["shards"]))
var burst := 24 if is_boss else int(p["shards"]) # boss dies in a bigger cloud of juice
smashed.emit(kind, global_position, burst)
_release_supported() # drop what I was holding up BEFORE I leave the tree — the cascade
_spawn_shards(int(p["shards"]), p["color"], impulse)
if fractured_scene != null:
_spawn_chunks(impulse) # real GLB shards (Lane 2)
else:
_spawn_shards(int(p["shards"]), p["color"], impulse)
queue_free()
## Wake every piece this one was holding up. Guarded on both ends (each child's own
@ -93,6 +116,60 @@ func release() -> void:
sleeping = false # defensive against a Jolt sleep frame
apply_central_impulse(Vector3(randf_range(-0.6, 0.6), 0.15, randf_range(-0.6, 0.6)))
## Real destruction: instance the pre-fractured sibling and turn each `chunk_*` mesh into
## its own rigid body that inherits our velocity + the smash impulse. Chunks are named
## chunk_00…chunk_NN with each origin at its own centroid, in this model's local space
## (Lane 2 contract #2). We add the template at our transform so each chunk's global
## transform is already correct, then reparent it onto a fresh body. Debris group so the
## HUD count + Juice keep working. Falls back to primitive shards if the template is empty.
func _spawn_chunks(impulse: Vector3) -> void:
var parent := get_parent()
if parent == null:
return
var tmpl: Node3D = fractured_scene.instantiate()
parent.add_child(tmpl)
tmpl.global_transform = global_transform # chunks now sit exactly where the intact body was
var chunks: Array[MeshInstance3D] = []
_collect_chunk_meshes(tmpl, chunks)
if chunks.is_empty():
tmpl.queue_free()
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
_spawn_shards(int(p["shards"]), p["color"], impulse)
return
var inherited := linear_velocity
var here := global_position
for chunk in chunks:
var gt := chunk.global_transform
var body := RigidBody3D.new()
body.add_to_group("debris")
body.mass = 0.25
# reparent the chunk mesh onto the body, centred on the body origin
chunk.owner = null
chunk.get_parent().remove_child(chunk)
body.add_child(chunk)
chunk.transform = Transform3D.IDENTITY
var ab := chunk.get_aabb()
var col := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = ab.size.max(Vector3(0.02, 0.02, 0.02))
col.shape = box
col.position = ab.get_center()
body.add_child(col)
parent.add_child(body)
body.global_transform = gt
# fling outward from the smash centre + inherit motion + the hit impulse
var out := (gt.origin - here)
out = out.normalized() if out.length() > 0.001 else Vector3(randf_range(-1, 1), 0.5, randf_range(-1, 1)).normalized()
body.linear_velocity = inherited + out * randf_range(1.2, 3.2) + impulse * 0.3
body.angular_velocity = Vector3(randf_range(-8, 8), randf_range(-8, 8), randf_range(-8, 8))
tmpl.queue_free() # the now-empty template wrapper
func _collect_chunk_meshes(n: Node, acc: Array[MeshInstance3D]) -> void:
if n is MeshInstance3D and n.name.to_lower().begins_with("chunk"):
acc.append(n)
for c in n.get_children():
_collect_chunk_meshes(c, acc)
func _spawn_shards(n: int, color: Color, impulse: Vector3) -> void:
if n <= 0:
return

Binary file not shown.

BIN
web/assets/crt-monitor.glb Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -52,22 +52,75 @@ const key = new THREE.DirectionalLight(0xfff6ea, 1.35); key.position.set(4, 8, 3
const fill = new THREE.PointLight(0xffe2b4, 0.9, 44); fill.position.set(-3, 3.2, 2); scene.add(fill);
const fill2 = new THREE.PointLight(0xd8e0ff, 0.55, 40); fill2.position.set(4, 3.2, -3); scene.add(fill2);
// ---------------- pointer lock (thriftgod's safeLock pattern) ----------------
// ---------------- pointer lock + drag-to-look fallback ----------------
// Pointer Lock is the primary look control, but it's unavailable in embedded/iframe
// contexts (no allow="pointer-lock") — where it used to dead-end on the title screen.
// If the lock request is refused we fall back to DRAG-TO-LOOK so the shareable link
// always plays: hold-drag rotates, a tap still smashes.
const controls = new PointerLockControls(camera, renderer.domElement);
const startEl = $('start');
let paused = true, booted = false;
let dragLook = false; // true once we've fallen back to manual drag-look
let lockPending = false;
function enterDragLook() {
lockPending = false;
dragLook = true;
paused = false;
startEl.classList.add('hidden');
document.body.style.cursor = 'grab';
}
function onLockFail() { if (!controls.isLocked) enterDragLook(); }
function safeLock() {
if (dragLook) { paused = false; startEl.classList.add('hidden'); return; }
lockPending = true;
try {
const p = renderer.domElement.requestPointerLock();
if (p && p.catch) p.catch(() => { startEl.classList.remove('hidden'); });
} catch (e) { startEl.classList.remove('hidden'); }
if (p && typeof p.then === 'function') p.then(() => { lockPending = false; }, onLockFail);
} catch (e) { onLockFail(); return; }
// watchdog: browsers that neither reject the promise nor fire pointerlockerror
setTimeout(() => { if (lockPending && !controls.isLocked && !dragLook) onLockFail(); }, 350);
}
document.addEventListener('pointerlockerror', onLockFail);
$('goBtn').onclick = () => { if (booted) { juice.resumeAudio(); safeLock(); } };
startEl.onclick = e => { if (booted && e.target.id !== 'shareBtn') { juice.resumeAudio(); safeLock(); } };
controls.addEventListener('lock', () => { paused = false; startEl.classList.add('hidden'); });
controls.addEventListener('lock', () => { lockPending = false; paused = false; startEl.classList.add('hidden'); });
controls.addEventListener('unlock', () => { paused = true; if (!shareOpen) startEl.classList.remove('hidden'); });
// manual drag-look input (only active in the fallback mode)
let dragging = false, dragMoved = 0, lastX = 0, lastY = 0;
const LOOK_SPEED = 0.0026;
const _lookEuler = new THREE.Euler(0, 0, 0, 'YXZ');
function applyLook(dYaw, dPitch) {
_lookEuler.setFromQuaternion(camera.quaternion);
_lookEuler.y += dYaw;
_lookEuler.x = Math.max(-Math.PI / 2 + 0.02, Math.min(Math.PI / 2 - 0.02, _lookEuler.x + dPitch));
_lookEuler.z = 0;
camera.quaternion.setFromEuler(_lookEuler);
}
renderer.domElement.addEventListener('mousedown', e => {
if (!dragLook || e.button !== 0) return;
dragging = true; dragMoved = 0; lastX = e.clientX; lastY = e.clientY;
document.body.style.cursor = 'grabbing';
});
addEventListener('mousemove', e => {
if (!dragLook || !dragging) return;
const dx = e.clientX - lastX, dy = e.clientY - lastY;
lastX = e.clientX; lastY = e.clientY;
dragMoved += Math.abs(dx) + Math.abs(dy);
applyLook(-dx * LOOK_SPEED, -dy * LOOK_SPEED); // drag right → look right (mouse-look parity)
});
addEventListener('mouseup', e => {
if (!dragLook || e.button !== 0) return;
const wasDrag = dragging && dragMoved > 6;
dragging = false;
document.body.style.cursor = 'grab';
if (!wasDrag) onClick(); // a tap (not a look-drag) still smashes / pulls / throws
});
// ---------------- juice ----------------
const juice = createJuice({
camera, scene,
@ -249,11 +302,20 @@ function smashProp(prop, power = 1.1) {
const dir = { x: _fwd.x, y: 0, z: _fwd.z };
const t = prop.body.translation();
const wp = new THREE.Vector3(t.x + prop.center.x, t.y + prop.center.y, t.z + prop.center.z);
juice.smash(wp, prop.material, power);
// the printer BOSS soaks several hits (rocks + juices), then bursts into its GLB chunks.
// Placed non-shatterable so a topple can't auto-break it before the final blow lands.
if (prop.isBoss && prop.bossHits > 1) {
prop.bossHits--;
juice.smash(wp, prop.material, power);
phys.knock(prop, dir, power * 0.5);
return;
}
juice.smash(wp, prop.material, prop.isBoss ? 1.9 : power); // boss dies loud
if (prop.kind === 'rack') {
phys.knock(prop, dir, power); // topple the support → dependents pancake
} else if (prop.shatterable) {
phys.shatter(prop, dir, power); // crate / bin / record → shards
} else if (prop.shatterable || prop.isBoss) {
phys.shatter(prop, dir, prop.isBoss ? power * 1.6 : power); // crate / bin / record / boss → chunks
} else {
phys.knock(prop, dir, power); // cashbot / mpc → comedic topple + steel ring
}
@ -265,7 +327,9 @@ function onClick() {
if (lookRecord) { pullRecord(nearestRecord()); return; }
if (lookProp) { smashProp(lookProp, 1.15); return; }
}
addEventListener('mousedown', e => { if (e.button === 0) onClick(); });
// pointer-lock mode: a click is a smash. In drag-look mode the drag handlers own the
// mouse (a tap → onClick on mouseup), so skip here to avoid a double-fire.
addEventListener('mousedown', e => { if (e.button === 0 && !dragLook) onClick(); });
addEventListener('keydown', e => {
if (e.code === 'KeyE') { if (held) throwHeld(); else if (lookRecord) pullRecord(nearestRecord()); }
});
@ -338,6 +402,11 @@ async function build() {
layoutRoom({ crateRes, rackRes });
// Lane 2 hero props: the printer BOSS + workplace set dressing, with real fractured
// chunk destruction. Awaited so the boss is present the instant play starts.
boot.textContent = 'wheeling in the printer…';
await loadHeroProps();
// flavor props from the live depot (local fallback for cashbot/bin; others optional)
loadDepotFlavor();
@ -414,6 +483,35 @@ function layoutRoom({ crateRes, rackRes }) {
}
}
// Lane 2's hero props — the printer boss + a little office set dressing, each with its
// pre-fractured template so a smash spawns real GLB chunks (not generative cubes). GLBs
// have origin at floor-centre (pipeline contract), so y=0 sits them on the floor.
async function loadHeroProps() {
const heroes = [
{ id: 'office-printer', pos: [0, 0, -3.0], rotY: 0.0, boss: true },
{ id: 'office-desk', pos: [2.4, 0, -3.3], rotY: -0.5 },
{ id: 'crt-monitor', pos: [-2.4, 0, -3.0], rotY: 0.4 },
{ id: 'filing-cabinet', pos: [-5.5, 0, -2.4], rotY: 0.2 },
{ id: 'water-cooler', pos: [5.5, 0, -2.0], rotY: -0.3 },
];
for (const h of heroes) {
try {
const res = await loadProp(h.id);
if (!res) continue;
let fractured = null;
try { fractured = await loadFractured(h.id); } catch (e) {}
const def = CATALOG[h.id];
const prop = placeProp(res.scene, new THREE.Vector3(h.pos[0], h.pos[1], h.pos[2]), {
material: def.material, kind: def.kind, fixed: true, rotY: h.rotY,
brittle: !!def.brittle,
// boss stays non-shatterable so a topple can't break it early; smashProp shatters it
shatterable: !h.boss, fractured,
});
if (prop && h.boss) { prop.isBoss = true; prop.bossHits = 4; }
} catch (e) { /* hero prop unavailable (offline, no mirror) — skip gracefully */ }
}
}
async function loadDepotFlavor() {
// positions kept clear of the back-wall racks (x∈{-4.2,0,4.2}, z≈-7.6),
// the mid pyramid (≈-3.5,1) and the record bin (0.5,3.2)

View File

@ -24,6 +24,26 @@ export const CATALOG = {
// live-only flavor from the depot (no local mirror — absent gracefully if offline)
mpc: { depot: 'akai-mpc-live-iii-6fe39f.glb', material: 'steel', kind: 'prop' },
gameboy: { depot: 'gameboybox-3c6cb9.glb', material: 'cardboard', kind: 'prop' },
// --- Lane 2's Destroyulator workplace batch (16 props on the 3GOD shelf, each with a
// .fractured.glb sibling → real chunk destruction via loadFractured + physics.js). The
// printer is the boss. `local` = mirrored into web/assets/ for offline fallback.
'office-printer': { depot: 'office-printer.glb', local: 'office-printer.glb', material: 'steel', kind: 'printer' },
'office-desk': { depot: 'office-desk.glb', material: 'wood', kind: 'prop' },
'crt-monitor': { depot: 'crt-monitor.glb', local: 'crt-monitor.glb', material: 'glass', kind: 'prop', brittle: true },
'desk-phone': { depot: 'desk-phone.glb', material: 'steel', kind: 'prop' },
'filing-cabinet': { depot: 'filing-cabinet.glb', local: 'filing-cabinet.glb', material: 'steel', kind: 'prop' },
'water-cooler': { depot: 'water-cooler.glb', material: 'steel', kind: 'prop' },
'office-chair': { depot: 'office-chair.glb', material: 'steel', kind: 'prop' },
'fluorescent-light':{ depot: 'fluorescent-light.glb', material: 'glass', kind: 'prop', brittle: true },
'coffee-mug': { depot: 'coffee-mug.glb', material: 'glass', kind: 'prop', brittle: true },
'potted-plant': { depot: 'potted-plant.glb', material: 'wood', kind: 'prop' },
'wall-clock': { depot: 'wall-clock.glb', material: 'wood', kind: 'prop' },
'cardboard-box': { depot: 'cardboard-box.glb', material: 'cardboard', kind: 'prop' },
'turntable': { depot: 'turntable.glb', material: 'wood', kind: 'prop' },
'amp-speaker': { depot: 'amp-speaker.glb', material: 'wood', kind: 'prop' },
'cash-register': { depot: 'cash-register.glb', material: 'steel', kind: 'prop' },
'wall-shelf': { depot: 'wall-shelf.glb', material: 'wood', kind: 'prop' },
};
function depotURL(file) { return `${DEPOT}/a/${encodeURIComponent(file)}`; }
@ -71,22 +91,48 @@ export async function loadProp(id) {
// Try to fetch the pre-fractured sibling (Lane 2 contract #2): <base>.fractured.glb.
// Returns an Object3D whose direct children are chunk_* meshes, or null if none.
//
// Result (template OR the null miss) is cached per id — CRUCIAL: without it a prop that
// has no fractured sibling (e.g. record before B.4) re-fetched + 404'd on EVERY spawn.
// Now it's one lookup per id for the life of the page; callers still clone the template.
const _fracCache = new Map(); // id -> Promise<Object3D|null>
export async function loadFractured(id) {
const def = CATALOG[id];
if (!def) return null;
const base = (def.depot || def.local || '').replace(/\.glb$/i, '');
if (!base) return null;
const tryURLs = [];
if (def.depot) tryURLs.push(depotURL(base + '.fractured.glb'));
if (def.local) tryURLs.push(localURL(def.local.replace(/\.glb$/i, '') + '.fractured.glb'));
for (const url of tryURLs) {
try {
const gltf = await gltfLoader.loadAsync(url);
const hasChunks = gltf.scene.children.some(c => /^chunk/i.test(c.name));
if (hasChunks) return prep(gltf.scene);
} catch (e) { /* not shipped yet — brittle props use generative shards */ }
if (_fracCache.has(id)) {
const tmpl = await _fracCache.get(id);
return tmpl ? tmpl.clone(true) : null;
}
return null;
const promise = (async () => {
const tryURLs = [];
if (def.depot) tryURLs.push(depotURL(def.depot.replace(/\.glb$/i, '') + '.fractured.glb'));
if (def.local) tryURLs.push(localURL(def.local.replace(/\.glb$/i, '') + '.fractured.glb'));
for (const url of tryURLs) {
try {
const gltf = await gltfLoader.loadAsync(url);
const container = chunkContainer(gltf.scene); // the node whose CHILDREN are chunk_*
if (container) return prep(container);
} catch (e) { /* not shipped / 404 — cached as a miss below, tried once */ }
}
return null; // cached miss: brittle props fall back to generative shards, no re-fetch
})();
_fracCache.set(id, promise);
const tmpl = await promise;
return tmpl ? tmpl.clone(true) : null;
}
// Blender exports the chunks under a `fractured_root` wrapper, so they're NOT direct
// children of the loaded scene. physics.js iterates a template's direct children, so we
// return the node that actually parents the chunk_* meshes (the scene root itself if the
// chunks sit there, otherwise the wrapper). Its own transform is identity, so each chunk's
// local position stays the centroid-in-model-space the consumer expects.
function chunkContainer(root) {
if (root.children.some(c => /^chunk/i.test(c.name))) return root;
let found = null;
root.traverse(o => {
if (!found && o.children && o.children.some(c => /^chunk/i.test(c.name))) found = o;
});
return found;
}
function cloneResult(res) {