diff --git a/LANES/LANE7-cubicle-hell.md b/LANES/LANE7-cubicle-hell.md index faa55cf..009ac7d 100644 --- a/LANES/LANE7-cubicle-hell.md +++ b/LANES/LANE7-cubicle-hell.md @@ -260,3 +260,35 @@ Still open in LEVEL 0: the two entities can't yet be told apart by anything exce getting close, which is correct but means a first-time player has no way to learn the difference except dying. A tell at medium range — a sound only one of them makes, or one that never appears in the same frame as the other — would give skill somewhere to live. + +--- + +## THE GREENGROCER *(built)* + +> *"stack and fall physics, can squash to make juice, throw it, smash glass bottles of +> juice etc"* + +`Levels.grocer()` + `Splat.gd` + `tools/gen_produce.py`. + +The only site whose stock is mostly **soft**, and that single material change rewrites +everything. `produce` throws no shards; it bursts — coloured pulp, a puddle that stays, a +squelch, and LITRES, which is a much better score for a greengrocer than a body count. + +Two knock-ons make the level play itself: +- Low `brittle_speed` means anything heavy landing on produce squashes it, so a pyramid + **crushes its own bottom layer as it collapses** with no special code. Shove test: 257 + items became 45, and 42 litres. +- Puddles are slippery, cutting acceleration rather than top speed so it reads as sliding. + The more fun you have, the worse the footing. + +Stacks are built bottom-up and nothing is glued or frozen — they stand because they're +stacked. 258 produce items spawn and settle to 0.00 m/s with no spawn crush. The glass +bottles behind the juice bar are the deliberate exception: one shelf that still wants a +proper swing, and the contrast between litres and shards is the whole joke. + +Round produce is authored CENTRED rather than floor-centred like every other prop in the +repo, because a rolling body whose origin sits on the floor plane wobbles like a loaded +die. + +Still open here: you can't yet PICK UP a piece of fruit and throw it. The record +grab/throw ritual is right there and produce is the obvious second user of it. diff --git a/README.md b/README.md index 175b092..34462be 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ rest of your day. Your score is your payslip: **wages earned minus damages cause during a meltdown you want to wreck a lot of cheap rubbish rather than one expensive filing cabinet. Falling Down, scored by payroll. -**Five sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT -basement, and the Backrooms — where the walls are procedurally generated and everything +**Six sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT +basement, a greengrocer where everything is soft except the bottles, and the Backrooms — where the walls are procedurally generated and everything you're there to destroy is invisible until you coat it in extinguisher powder, some of which you breathe) and **four modes** (Cubicle Hell, the Gauntlet, Total Destruction, Against the Clock). Sites are data, not code — see `game/scripts/Levels.gd`. diff --git a/game/README.md b/game/README.md index 7ce41c6..964a7aa 100644 --- a/game/README.md +++ b/game/README.md @@ -43,11 +43,38 @@ generator can later emit the same structure. | **PAWNEE CITY HALL** | civic beige, blue-grey | a public counter, pinboards, over-partitioned | | **THE INCUBATOR** | timber, white, 3 m, pendants | nobody has a desk; they work at a dining table | | **SUB-LEVEL 4** | concrete, green strip lights | **no windows**, exposed services, a wall of servers | +| **THE GREENGROCER** | bright, pale, warm strips | everything is **soft** except the bottles — see below | | **LEVEL 0** | one colour, everywhere | the Backrooms. Walls are **generated**, and everything you're here to destroy is **invisible** | `dev/probe_levels.gd` builds every one and reports residual motion after a full second. All five must read `0.00 m/s`. +### THE GREENGROCER — soft things + +The only site whose contents are mostly **soft**, and that one material change rewrites +the level. `produce` is a new material in `Smashable.PROFILES` that doesn't throw shards +at all — `scripts/Splat.gd` bursts it instead: coloured pulp, a puddle that **stays**, a +squelch, and litres counted, because "how much juice did you make" is a far better score +for a greengrocer than "how many objects did you destroy". + +Two knock-ons make the level play itself: + +- Produce has a low `brittle_speed`, so **anything heavy landing on it squashes it**. A + pyramid therefore crushes its own bottom layer as it collapses, with no special code — + knock one off the top and the pile does the rest. A shove test turned 257 items into 45 + and 42 litres. +- Puddles are **slippery**. The more mess you make, the less the floor cooperates. It cuts + your acceleration rather than your top speed, so it reads as sliding rather than as + being slowed down. + +Stacks are built bottom-up by `Main._build_stack()` and nothing is glued or frozen — +they stand because they're stacked, so pulling one out of the bottom row does exactly +what you'd hope. The glass bottles behind the juice bar are the deliberate exception: +one shelf in the room that still rewards a proper swing. + +Round produce is authored **centred**, not floor-centred like every other prop, because +a rolling body whose origin sits on the floor plane wobbles like a loaded die. + ### LEVEL 0 — the Backrooms, and the dust The only site whose walls are **generated** rather than authored, which is both the diff --git a/game/assets/store/juice-bottle.glb b/game/assets/store/juice-bottle.glb new file mode 100644 index 0000000..badfd2d Binary files /dev/null and b/game/assets/store/juice-bottle.glb differ diff --git a/game/assets/store/juice-bottle.glb.import b/game/assets/store/juice-bottle.glb.import new file mode 100644 index 0000000..93d147d --- /dev/null +++ b/game/assets/store/juice-bottle.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://c1xc737dqd8ew" +path="res://.godot/imported/juice-bottle.glb-ab512d3a5d4e16cab1f48e2bcb7a8bac.scn" + +[deps] + +source_file="res://assets/store/juice-bottle.glb" +dest_files=["res://.godot/imported/juice-bottle.glb-ab512d3a5d4e16cab1f48e2bcb7a8bac.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/game/assets/store/produce-apple.glb b/game/assets/store/produce-apple.glb new file mode 100644 index 0000000..e7c51bc Binary files /dev/null and b/game/assets/store/produce-apple.glb differ diff --git a/game/assets/store/produce-apple.glb.import b/game/assets/store/produce-apple.glb.import new file mode 100644 index 0000000..29bcaf6 --- /dev/null +++ b/game/assets/store/produce-apple.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://rjyakfurx2o1" +path="res://.godot/imported/produce-apple.glb-34f52f7a240ba65dc916b2ed165d4090.scn" + +[deps] + +source_file="res://assets/store/produce-apple.glb" +dest_files=["res://.godot/imported/produce-apple.glb-34f52f7a240ba65dc916b2ed165d4090.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/game/assets/store/produce-banana.glb b/game/assets/store/produce-banana.glb new file mode 100644 index 0000000..3f536d9 Binary files /dev/null and b/game/assets/store/produce-banana.glb differ diff --git a/game/assets/store/produce-banana.glb.import b/game/assets/store/produce-banana.glb.import new file mode 100644 index 0000000..c2fd538 --- /dev/null +++ b/game/assets/store/produce-banana.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://n387rbsyllsf" +path="res://.godot/imported/produce-banana.glb-0c648edd65c43bc0ea3c5c898ede294e.scn" + +[deps] + +source_file="res://assets/store/produce-banana.glb" +dest_files=["res://.godot/imported/produce-banana.glb-0c648edd65c43bc0ea3c5c898ede294e.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/game/assets/store/produce-cabbage.glb b/game/assets/store/produce-cabbage.glb new file mode 100644 index 0000000..bad09d7 Binary files /dev/null and b/game/assets/store/produce-cabbage.glb differ diff --git a/game/assets/store/produce-cabbage.glb.import b/game/assets/store/produce-cabbage.glb.import new file mode 100644 index 0000000..f484d04 --- /dev/null +++ b/game/assets/store/produce-cabbage.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dwaxxfwt5683b" +path="res://.godot/imported/produce-cabbage.glb-941ee0811d1f31b5864607836e125a87.scn" + +[deps] + +source_file="res://assets/store/produce-cabbage.glb" +dest_files=["res://.godot/imported/produce-cabbage.glb-941ee0811d1f31b5864607836e125a87.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/game/assets/store/produce-crate.glb b/game/assets/store/produce-crate.glb new file mode 100644 index 0000000..cb9af7a Binary files /dev/null and b/game/assets/store/produce-crate.glb differ diff --git a/game/assets/store/produce-crate.glb.import b/game/assets/store/produce-crate.glb.import new file mode 100644 index 0000000..12a79fc --- /dev/null +++ b/game/assets/store/produce-crate.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://x1cagigbbfcg" +path="res://.godot/imported/produce-crate.glb-7d146d61b07b4a3c6851aa5a253e5475.scn" + +[deps] + +source_file="res://assets/store/produce-crate.glb" +dest_files=["res://.godot/imported/produce-crate.glb-7d146d61b07b4a3c6851aa5a253e5475.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/game/assets/store/produce-melon.glb b/game/assets/store/produce-melon.glb new file mode 100644 index 0000000..aaa1a55 Binary files /dev/null and b/game/assets/store/produce-melon.glb differ diff --git a/game/assets/store/produce-melon.glb.import b/game/assets/store/produce-melon.glb.import new file mode 100644 index 0000000..da70d43 --- /dev/null +++ b/game/assets/store/produce-melon.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://ccxqbgqdnthpf" +path="res://.godot/imported/produce-melon.glb-b2c52371dc88f9b458a88985a9557913.scn" + +[deps] + +source_file="res://assets/store/produce-melon.glb" +dest_files=["res://.godot/imported/produce-melon.glb-b2c52371dc88f9b458a88985a9557913.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/game/assets/store/produce-orange.glb b/game/assets/store/produce-orange.glb new file mode 100644 index 0000000..a388baf Binary files /dev/null and b/game/assets/store/produce-orange.glb differ diff --git a/game/assets/store/produce-orange.glb.import b/game/assets/store/produce-orange.glb.import new file mode 100644 index 0000000..cca339e --- /dev/null +++ b/game/assets/store/produce-orange.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dyxit85msktn1" +path="res://.godot/imported/produce-orange.glb-1bd0c62975a090bd5dffe3cc0bd3f046.scn" + +[deps] + +source_file="res://assets/store/produce-orange.glb" +dest_files=["res://.godot/imported/produce-orange.glb-1bd0c62975a090bd5dffe3cc0bd3f046.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/game/assets/store/produce-tomato.glb b/game/assets/store/produce-tomato.glb new file mode 100644 index 0000000..fa2462a Binary files /dev/null and b/game/assets/store/produce-tomato.glb differ diff --git a/game/assets/store/produce-tomato.glb.import b/game/assets/store/produce-tomato.glb.import new file mode 100644 index 0000000..d54c0ad --- /dev/null +++ b/game/assets/store/produce-tomato.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://rlu1alxko70t" +path="res://.godot/imported/produce-tomato.glb-12a3fb563f34a713c265ee370ebaae8f.scn" + +[deps] + +source_file="res://assets/store/produce-tomato.glb" +dest_files=["res://.godot/imported/produce-tomato.glb-12a3fb563f34a713c265ee370ebaae8f.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/game/dev/DemoDriver.gd b/game/dev/DemoDriver.gd index e374ad9..75b196f 100644 --- a/game/dev/DemoDriver.gd +++ b/game/dev/DemoDriver.gd @@ -48,17 +48,26 @@ func _find_player(n: Node) -> Player: return null func _build_script() -> void: - # The two of them. Same silhouette at range; the tells are close-range only — the - # other one's head is thrown back, its arms are up, and its feet don't reach. + # THE GREENGROCER: look at the stacks, take the bat to one, watch it come down, then + # the bottles — the one shelf in here that still wants a proper swing. _script = [ - {"dur": 1.2, "move_to": null, "look_at": null, "act": "level:backrooms"}, - {"dur": 0.3, "move_to": Vector3(0, 0, 4.0), "look_at": Vector3(0, 1.6, -4.0), - "act": "ent:0,-4"}, - {"dur": 3.0, "move_to": null, "look_at": null, "act": ""}, - {"dur": 0.3, "move_to": null, "look_at": Vector3(4.0, 1.6, -4.0), "act": "two:4,-4"}, - {"dur": 3.4, "move_to": null, "look_at": null, "act": ""}, - {"dur": 3.0, "move_to": Vector3(2.0, 0, 1.0), "look_at": Vector3(4.0, 1.7, -4.0), - "act": ""}, + {"dur": 1.2, "move_to": null, "look_at": null, "act": "level:grocer"}, + {"dur": 0.3, "move_to": null, "look_at": null, "act": "mode:2"}, + {"dur": 2.6, "move_to": Vector3(-2.0, 0, 4.4), "look_at": Vector3(-3.6, 1.3, 0.0), "act": ""}, + {"dur": 2.0, "move_to": Vector3(-4.6, 0, 0.4), "look_at": Vector3(-5.4, 1.15, 1.4), "act": ""}, + {"dur": 0.4, "move_to": null, "look_at": null, "act": "weapon:2"}, + {"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"}, + {"dur": 1.2, "move_to": null, "look_at": null, "act": "swing"}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "swing"}, + {"dur": 2.4, "move_to": Vector3(-4.9, 0, -1.4), "look_at": Vector3(-5.4, 1.10, -2.2), "act": ""}, + {"dur": 1.2, "move_to": null, "look_at": null, "act": "swing"}, + {"dur": 1.6, "move_to": null, "look_at": null, "act": "swing"}, + # and the bottles + {"dur": 2.6, "move_to": Vector3(-3.0, 0, -6.4), "look_at": Vector3(-3.0, 1.75, -7.7), "act": ""}, + {"dur": 0.4, "move_to": null, "look_at": null, "act": "weapon:4"}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "swing"}, + {"dur": 1.6, "move_to": null, "look_at": null, "act": "swing"}, + {"dur": 2.6, "move_to": Vector3(-1.0, 0, 2.0), "look_at": Vector3(-4.0, 0.6, -0.5), "act": ""}, ] func _motion(verbose := false) -> float: diff --git a/game/dev/probe_grocer.gd b/game/dev/probe_grocer.gd new file mode 100644 index 0000000..e98b02f --- /dev/null +++ b/game/dev/probe_grocer.gd @@ -0,0 +1,83 @@ +extends SceneTree + +## The greengrocer: do the pyramids build and SETTLE, does produce burst instead of +## shattering, does a collapse crush its own bottom layer, and does the floor get +## slippery. +## Godot --headless --path game --script dev/probe_grocer.gd + +func _initialize() -> void: + var main: Node = (load("res://main.tscn") as PackedScene).instantiate() + get_root().add_child(main) + await process_frame + main._load_level("grocer") + for i in 10: + await physics_frame + + var splat = main.get("_splat") + var produce := _count("produce") + print("site : ", main.get("_plan").level_name()) + print("produce : %d glass: %d total smashables: %d" % [ + produce, _count("glass"), get_nodes_in_group("smashable").size()]) + + # --- do the stacks hold? --- + for i in 120: + await physics_frame + var moving := 0 + var total := 0.0 + for n in get_nodes_in_group("smashable"): + var b := n as RigidBody3D + if b == null or b.freeze: + continue + var v := b.linear_velocity.length() + total += v + if v > 0.15: + moving += 1 + print("after 2s : %d still moving, total %.2f m/s (stacks should be at rest)" % [ + moving, total]) + print("survived : %d produce (spawn crush would show up as losses)" % _count("produce")) + + # --- squash one: does it burst rather than shard? --- + var before_l: float = splat.litres + var debris_before := get_nodes_in_group("debris").size() + var victim: Smashable = null + for n in get_nodes_in_group("smashable"): + if (n as Smashable).kind == "produce": + victim = n + break + var at := victim.global_position + victim.shatter(Vector3.UP) + await process_frame + print("squash one: litres %.2f -> %.2f debris %d -> %d (must NOT rise: fruit has no shards)" % [ + before_l, splat.litres, debris_before, get_nodes_in_group("debris").size()]) + + # --- drop a melon on a pile: does the collapse crush? --- + var n0 := _count("produce") + for n in get_nodes_in_group("smashable"): + var b := n as Smashable + if b != null and b.kind == "produce": + b.apply_central_impulse(Vector3(randf_range(-3, 3), 0, randf_range(-3, 3))) + for i in 150: + await physics_frame + print("shoved all: %d -> %d produce %.1f litres from the collapse" % [ + n0, _count("produce"), splat.litres]) + + # --- slippery? --- + var player = main.get("_player") + player.global_position = Vector3(at.x, 0.0, at.z) + await process_frame + print("slip here : %.2f (0 = dry floor)" % splat.slip_under_player()) + print("hud : ", splat.hud_line()) + + # --- and none of it leaks to the office --- + main._load_level("scranton") + for i in 6: + await physics_frame + print("at office : litres %.1f (reset) produce %d" % [splat.litres, _count("produce")]) + quit() + +func _count(kind: String) -> int: + var n := 0 + for s in get_nodes_in_group("smashable"): + if (s as Smashable) != null and (s as Smashable).kind == kind: + n += 1 + return n diff --git a/game/dev/probe_grocer.gd.uid b/game/dev/probe_grocer.gd.uid new file mode 100644 index 0000000..47e57f4 --- /dev/null +++ b/game/dev/probe_grocer.gd.uid @@ -0,0 +1 @@ +uid://dkeg30h37ndx1 diff --git a/game/scripts/Levels.gd b/game/scripts/Levels.gd index c5bb60f..e2c11e9 100644 --- a/game/scripts/Levels.gd +++ b/game/scripts/Levels.gd @@ -20,7 +20,7 @@ class_name Levels ## ## Godot 4.7 GDScript 2.0. -const ORDER := ["scranton", "pawnee", "house", "basement", "backrooms"] +const ORDER := ["scranton", "pawnee", "house", "basement", "grocer", "backrooms"] static func get_level(id: String) -> Dictionary: match id: @@ -28,6 +28,7 @@ static func get_level(id: String) -> Dictionary: "house": return house() "basement": return basement() "backrooms": return backrooms() + "grocer": return grocer() return scranton() static func next_id(id: String) -> String: @@ -558,3 +559,121 @@ static func backrooms() -> Dictionary: "stand": Vector3(0.0, 0.0, 0.2), "shredder": Vector3(3.0, 0.0, -1.0)}, "spawn": {"at": Vector3(0.0, 0.0, 16.5), "yaw": PI}, } + +# ================================================================ THE GROCER +## A greengrocer, and the only site whose contents are mostly SOFT. +## +## Everything else in the game shatters; here the stock bursts. That one material change +## rewrites the level: produce squashes instead of breaking, a pyramid crushes its own +## bottom layer as it collapses, and the floor fills with juice you then slip in. The +## glass bottles behind the juice bar are the exception, and the contrast is the point — +## one shelf in the room still rewards a proper swing. +## +## Bright, clean, warm track lighting on a pale floor, because the whole level is about +## making a mess and a mess needs somewhere clean to happen. +static func grocer() -> Dictionary: + var stacks: Array = [] + # Angled island displays. `at` is the TOP surface a pyramid piles onto; Main builds + # the pyramid itself so the physics settles honestly rather than being placed. + var islands := [ + [Vector3(-5.4, 0.95, -2.2), "apple", 5], + [Vector3(-5.4, 0.95, 1.4), "orange", 5], + [Vector3(-2.0, 0.95, -2.2), "tomato", 5], + [Vector3(-2.0, 0.95, 1.4), "apple", 4], + [Vector3(1.4, 0.95, -2.2), "cabbage", 3], + [Vector3(1.4, 0.95, 1.4), "orange", 4], + [Vector3(4.8, 0.80, -0.4), "melon", 3], + ] + for i in islands: + stacks.append({"at": i[0], "item": String(i[1]), "rows": int(i[2])}) + + var slabs: Array = [] + # the island tables themselves: a plinth, a timber top, and a raised back edge so a + # pyramid has something to fail against + for i in islands: + var p: Vector3 = i[0] + var big: bool = String(i[1]) == "melon" + var w := 2.4 if big else 1.5 + var d := 1.9 if big else 1.5 + var h: float = p.y + slabs.append({"size": Vector3(w, h - 0.10, d), + "at": Vector3(p.x, (h - 0.10) * 0.5, p.z), "mat": "trim"}) + slabs.append({"size": Vector3(w + 0.14, 0.10, d + 0.14), + "at": Vector3(p.x, h - 0.05, p.z), "mat": "wood"}) + for s in [-1.0, 1.0]: + slabs.append({"size": Vector3(w + 0.14, 0.10, 0.06), + "at": Vector3(p.x, h + 0.05, p.z + s * (d * 0.5 + 0.04)), "mat": "wood"}) + slabs.append({"size": Vector3(0.06, 0.10, d + 0.14), + "at": Vector3(p.x + s * (w * 0.5 + 0.04), h + 0.05, p.z), "mat": "wood"}) + + # juice bar along the back, with shelving above it for the bottles + slabs.append({"size": Vector3(7.0, 1.05, 0.75), "at": Vector3(-3.0, 0.525, -7.4), + "mat": "trim"}) + slabs.append({"size": Vector3(7.2, 0.09, 0.92), "at": Vector3(-3.0, 1.10, -7.4), + "mat": "wood"}) + for k in range(3): + slabs.append({"size": Vector3(6.6, 0.06, 0.34), + "at": Vector3(-3.0, 1.62 + k * 0.46, -7.72), "mat": "wood"}) + # chiller run down the right wall + slabs.append({"size": Vector3(0.80, 2.10, 8.0), "at": Vector3(10.4, 1.05, 1.0), + "mat": "trim"}) + for k in range(4): + slabs.append({"size": Vector3(0.86, 0.05, 8.0), + "at": Vector3(10.3, 0.55 + k * 0.46, 1.0), "mat": "ceiling"}) + + var smash: Array = [] + # bottles on the bar shelves — the one thing in here that still wants a proper swing + for k in range(3): + for b in range(11): + smash.append({"glb": "juice-bottle", "kind": "glass", + "at": Vector2(-6.0 + b * 0.60, -7.72), "sit_on": 1.65 + k * 0.46}) + for b in range(9): + smash.append({"glb": "juice-bottle", "kind": "glass", + "at": Vector2(-5.6 + b * 0.62, -7.4), "sit_on": 1.15}) + # loose crates of stock on the floor + for c in [Vector2(-8.6, 3.4), Vector2(-8.6, 4.4), Vector2(-8.0, 5.4), + Vector2(7.4, -4.6), Vector2(8.2, -4.0), Vector2(7.8, 5.2)]: + smash.append({"glb": "produce-crate", "kind": "wood", "at": c, + "yaw": randf_range(0.0, TAU)}) + # a few bananas and cabbages just lying about + for b in [Vector2(-7.2, 0.4), Vector2(3.2, 4.2), Vector2(6.0, 2.0)]: + smash.append({"glb": "produce-banana", "kind": "produce", "at": b}) + for c in [Vector2(-9.4, -2.0), Vector2(2.4, -5.6)]: + smash.append({"glb": "produce-cabbage", "kind": "produce", "at": c}) + + return { + "name": "THE GREENGROCER", + "subtitle": "everything here is soft except the bottles", + "bounds": {"x0": -11.0, "x1": 11.0, "z0": -8.0, "z1": 8.0, "h": 3.30}, + "palette": { + "floor": Color(0.70, 0.68, 0.64), "wall": Color(0.90, 0.89, 0.85), + "ceiling": Color(0.20, 0.20, 0.21), "trim": Color(0.15, 0.15, 0.16), + "partition": Color(0.30, 0.44, 0.40), "wood": Color(0.52, 0.36, 0.20), + "accent": Color(0.20, 0.48, 0.42), + }, + "floor_style": "lino", + "ceiling": "flat", + "walls": [], + "windows": [{"a": "z", "at": 8.0, "from": -11.0, "to": 11.0, + "y0": 0.35, "y1": 2.85, "blinds": false}], + "lights": {"xs": [-8.0, -4.0, 0.0, 4.0, 8.0], "zs": [-6.0, -2.0, 2.0, 6.0], + "color": Color(1.0, 0.94, 0.84), "energy": 2.4, "style": "strip", + "key_energy": 1.8}, + "sun": {"color": Color(1.0, 0.96, 0.88), "energy": 1.1, + "rot": Vector3(-40, 8, 0)}, + "env": {"bg": Color(0.76, 0.82, 0.88), "ambient": Color(0.60, 0.58, 0.55), + "ambient_energy": 0.66, "exposure": 1.04}, + "clusters": [], + "chairs": [], + "props": [ + {"glb": "office-plant", "at": Vector3(-10.2, 0.0, 7.0)}, + {"glb": "office-plant", "at": Vector3(9.6, 0.0, 7.0)}, + ], + "slabs": slabs, + "smashables": smash, + "stacks": stacks, + "records": [], + "gauntlet": {"at": Vector3(-8.6, 0.0, -6.0), "yaw": PI, + "stand": Vector3(-8.6, 0.0, -4.0), "shredder": Vector3(-6.4, 0.0, -5.4)}, + "spawn": {"at": Vector3(0.0, 0.0, 6.4), "yaw": PI}, + } diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index 03d8ab6..a07a216 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -42,6 +42,7 @@ var _modes: Modes var _gauntlet: Gauntlet var _dust: Dust var _backrooms: Backrooms +var _splat: Splat func _ready() -> void: randomize() @@ -83,6 +84,8 @@ func _ready() -> void: add_child(_dust) _backrooms = Backrooms.new() add_child(_backrooms) + _splat = Splat.new() + add_child(_splat) # rules layer: the record modes still exist behind M _game_mode = GameMode.new() @@ -95,6 +98,8 @@ func _ready() -> void: _ambience.adopt_lights(_plan) _gauntlet.setup(_player, _hud) _player.gauntlet = _gauntlet + _splat.setup(self, _player) + _player.splat = _splat _dust.setup(_player, _cam, _rage, _hud) _player.dust = _dust _backrooms.setup(_player, _cam, _hud, _rage, _dust, self) @@ -243,6 +248,10 @@ func _populate() -> void: (float(i) - 1.0) * 0.045), crate_top) (crate["piece"] as Smashable).supports.append(rec) + # --- stacked produce: pyramids that collapse honestly --- + for st in _plan.spec.get("stacks", []): + _build_stack(st) + # --- chairs: the best thing in an office to send across the room --- for t in _plan.chair_spots(): _glb_piece(CHAIR_GLB, "plastic", Vector2(t.origin.x, t.origin.z), false, 0.0, @@ -272,11 +281,43 @@ func _place_record(xz: Vector2, sit_on: float) -> Record: _records.append(rec) return rec +## A pyramid of produce, built bottom-up and left to settle. +## +## Nothing here is glued or frozen: it stands because it's stacked, which means pulling +## one out of the bottom row does exactly what you'd hope. Rows are inset and the whole +## thing spawns a few millimetres apart so the spawn guard has nothing to complain about. +func _build_stack(st: Dictionary) -> void: + var at: Vector3 = st["at"] + var item := String(st["item"]) + var rows := int(st.get("rows", 4)) + var glb := P + "produce-" + item + ".glb" + if not ResourceLoader.exists(glb): + return + # radius per item, so the lattice spacing matches what's actually being stacked + var radii := {"apple": 0.041, "orange": 0.038, "tomato": 0.034, + "melon": 0.150, "cabbage": 0.084} + var r: float = radii.get(item, 0.040) + var step: float = r * 2.06 # a whisker of air between neighbours + var lift: float = r * 1.72 # rows nest into the gaps below + for row in range(rows): + var n := rows - row + var y: float = at.y + r + row * lift + for i in range(n): + for j in range(n): + var x: float = at.x + (float(i) - (n - 1) * 0.5) * step + var z: float = at.z + (float(j) - (n - 1) * 0.5) * step + var s := _glb_piece(glb, "produce", Vector2(x, z), false, 0.0, + randf_range(0.0, TAU), true) + var body := s["piece"] as Smashable + body.global_position = Vector3(x, y, z) + body.set_meta("spawn_pos", body.global_position) + body.mass = maxf(r * r * r * 900.0, 0.08) + ## 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, - yaw := 0.0) -> Dictionary: + yaw := 0.0, centred := false) -> Dictionary: var s := Smashable.new() s.kind = kind s.start_frozen = frozen @@ -304,7 +345,12 @@ func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := 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 + # Round produce is authored CENTRED so it rolls properly, so it must not be dropped + # by its AABB bottom like everything else — _build_stack places it directly. + if centred: + s.global_position = Vector3(xz.x, sit_on, xz.y) + else: + s.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) # bottom 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, "aabb": ab} @@ -451,7 +497,14 @@ func _physics_process(dt: float) -> void: func _on_smashed(kind: String, at: Vector3, shards: int) -> void: _smash_count += 1 _tally[kind] = int(_tally.get(kind, 0)) + 1 - if _juice != null: + if kind == "produce": + # Splat owns this one: pulp, a puddle that stays, a squelch, and litres. Juice's + # shard-and-crack voice is exactly the wrong sound for a tomato. + if _splat != null: + _splat.burst(at, _last_produce, self) + if _juice != null: + _juice.add_trauma(0.10) + elif _juice != null: _juice.impact(kind, at, shards) # score AFTER impact() so this break's own combo step is counted _score += int(POINTS.get(kind, 15)) * maxi(_juice.combo(), 1) @@ -486,7 +539,15 @@ func _on_damaged(kind: String, at: Vector3, frac: float) -> void: _juice.dent(kind, at, frac) ## Every Smashable routes its three outcomes here. +var _last_produce := "generic" + func _wire(s: Smashable) -> void: + if s.kind == "produce": + # `smashed` only carries the MATERIAL, and every fruit is "produce". Stash the + # specific item off the node name so Splat can pick the right colour and yield. + var item := Splat.kind_of(s) + s.smashed.connect(func(_k, _a, _n): _last_produce = item, CONNECT_DEFERRED) + s.smashed.connect(func(_k, _a, _n): _last_produce = item) s.smashed.connect(_on_smashed) s.resisted.connect(_on_resisted) s.damaged.connect(_on_damaged) @@ -610,6 +671,8 @@ func _find_shredder(g: Dictionary) -> Node3D: ## Both are LEVEL 0 only, and both have to be explicitly torn down on the way out or they ## leak into an ordinary Tuesday at the Scranton branch. func _apply_site_horror() -> void: + if _splat != null: + _splat.reset() var blind := bool(_plan.spec.get("invisible", false)) if _dust != null: _dust.adopt(get_tree(), blind) @@ -716,7 +779,9 @@ func _process(_dt: float) -> void: "mode_line": _mode_line() + ("\n" + _dust.hud_line() if _dust != null and _dust.active else "") + ("\n" + _backrooms.hud_line() if _backrooms != null - and _backrooms.active else ""), + and _backrooms.active else "") + + ("\n" + _splat.hud_line() if _splat != null + and _splat.hud_line() != "" else ""), "mode_name": _modes.name_of() if _modes != null else "", "headline": _modes.headline() if _modes != null else "", "keys": KEYS_LINE, diff --git a/game/scripts/Player.gd b/game/scripts/Player.gd index 666dc19..1b65ddd 100644 --- a/game/scripts/Player.gd +++ b/game/scripts/Player.gd @@ -36,6 +36,9 @@ var tasks: Tasks = null var gauntlet: Gauntlet = null ## Set by Main. RMB discharges powder on sites where the smashables are invisible. var dust: Dust = null +## Set by Main. Juice on the floor costs you grip — the more mess you make, the less +## the floor cooperates. +var splat: Splat = null ## Set while you're dying, so you can't stroll around during it. var _frozen := false @@ -273,6 +276,10 @@ func _physics_process(delta: float) -> void: var target := wish * move_speed var a := accel if is_on_floor() else air_accel + if splat != null and is_on_floor(): + # standing in juice: you keep your top speed but lose the ability to change it, + # which reads as sliding rather than as being slowed down + a *= 1.0 - 0.82 * splat.slip_under_player() velocity.x = move_toward(velocity.x, target.x, a * move_speed * delta) velocity.z = move_toward(velocity.z, target.z, a * move_speed * delta) @@ -323,17 +330,32 @@ func _strike(w: Weapon) -> Node: if hits.is_empty(): return null + # Prefer a SMASHABLE over anything else in the sweep, and only fall back to loose + # bodies if there isn't one. + # + # This used to just take the nearest collider of any kind, which meant a static + # surface could eat the swing: leaning over a greengrocer's display table, the table + # edge is a few centimetres nearer than the fruit piled on it, so every swing hit + # the table and nothing broke. Same bug was quietly costing hits on desks and shelves + # in the offices too. var best: Node = null var best_d := INF + var fallback: Node = null + var fallback_d := INF for h in hits: var c = h.get("collider") if c == null or not (c is Node3D): continue var d: float = (c.global_position - origin).length_squared() - if d < best_d: - best_d = d - best = c - + if c is Smashable: + if d < best_d: + best_d = d + best = c + elif c is RigidBody3D and d < fallback_d: + fallback_d = d + fallback = c + if best == null: + best = fallback if best == null: return null diff --git a/game/scripts/Smashable.gd b/game/scripts/Smashable.gd index 4e6818a..a802728 100644 --- a/game/scripts/Smashable.gd +++ b/game/scripts/Smashable.gd @@ -10,7 +10,7 @@ class_name Smashable ## hard enough — that's the satisfying TAIL of the cascade: the record survives the ## tumble out of its jacket, then cracks a beat later when it smacks the floor. -@export_enum("wood", "cardboard", "vinyl", "glass", "steel", "plastic", "paper") var kind: String = "wood" +@export_enum("wood", "cardboard", "vinyl", "glass", "steel", "plastic", "paper", "produce") var kind: String = "wood" @export var start_frozen: bool = false ## Pre-fractured sibling (Lane 2 contract #2): a scene whose leaf `chunk_*` meshes are @@ -36,6 +36,10 @@ const PROFILES := { "steel": {"color": Color(0.7, 0.72, 0.76), "shards": 0, "brittle_speed": 0.0, "bounce": 0.25, "hp": 4.5}, "plastic": {"color": Color(0.82, 0.78, 0.70), "shards": 6, "brittle_speed": 0.0, "bounce": 0.15, "hp": 1.4}, "paper": {"color": Color(0.92, 0.90, 0.84), "shards": 3, "brittle_speed": 0.0, "bounce": 0.0, "hp": 0.3}, + # Produce does NOT throw shards — Splat.gd bursts it instead (see shatter()). The + # low brittle_speed is what makes a collapsing pyramid crush its own bottom layer + # with no special code: anything landing on it hard enough squashes it. + "produce": {"color": Color(0.80, 0.42, 0.20), "shards": 0, "brittle_speed": 1.8, "bounce": 0.10, "hp": 0.35}, } signal smashed(kind: String, at: Vector3, shard_count: int) @@ -121,7 +125,10 @@ func shatter(impulse: Vector3) -> void: 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 - if fractured_scene != null: + # produce bursts rather than shattering; Main routes it to Splat off `smashed` + if kind == "produce": + pass + elif fractured_scene != null: _spawn_chunks(impulse) # real GLB shards (Lane 2) else: _spawn_shards(int(p["shards"]), p["color"], impulse) diff --git a/game/scripts/Splat.gd b/game/scripts/Splat.gd new file mode 100644 index 0000000..ae143fe --- /dev/null +++ b/game/scripts/Splat.gd @@ -0,0 +1,215 @@ +extends Node +class_name Splat + +## Produce doesn't break. It bursts. +## +## Everything else in this game shatters into shards, which is the wrong verb entirely +## for a tomato. So `produce` gets its own death: a wet burst of pulp in the fruit's own +## colour, a puddle left on the floor that STAYS, a squelch, and a litre counter — because +## "how much juice did you make" is a far better score for a greengrocer than "how many +## objects did you destroy". +## +## Two knock-ons that make the level play itself: +## * Anything heavy enough landing on produce squashes it, so a collapsing pyramid +## crushes its own bottom layer without any special code. Knock one melon off the top +## and the pile does the rest. +## * Puddles are SLIPPERY. Make enough juice and the floor stops cooperating, which is +## a self-inflicted hazard — the more fun you have, the worse the footing gets. +## +## Godot 4.7 GDScript 2.0. + +## Litres per item, roughly by size. A watermelon is worth a lot of tomatoes. +const YIELD := { + "apple": 0.18, "orange": 0.22, "tomato": 0.12, "melon": 2.40, + "cabbage": 0.35, "banana": 0.14, "generic": 0.20, +} + +## Juice colour per item. This is the whole visual identity of the level's mess. +const JUICE := { + "apple": Color(0.86, 0.78, 0.34), + "orange": Color(0.94, 0.52, 0.08), + "tomato": Color(0.68, 0.10, 0.07), + "melon": Color(0.88, 0.24, 0.30), + "cabbage": Color(0.72, 0.82, 0.48), + "banana": Color(0.92, 0.85, 0.52), + "generic": Color(0.85, 0.55, 0.25), +} + +## A body must land at least this hard to squash what it lands on. +const CRUSH_SPEED := 2.2 +## Puddles beyond this are recycled oldest-first, so a long session stays bounded. +const MAX_PUDDLES := 90 +## How close to a puddle counts as standing in it. +const PUDDLE_R := 0.55 + +var litres := 0.0 +var squashed := 0 + +var _host: Node3D +var _player: Node3D +var _puddles: Array = [] # [{node, pos, r}] +var _snd: Array[AudioStreamWAV] = [] +var _players: Array[AudioStreamPlayer3D] = [] +var _next_player := 0 + +func setup(host: Node3D, player: Node3D) -> void: + _host = host + _player = player + for i in 3: + _snd.append(_squelch(0.9 + i * 0.15)) + # a small pool, because a collapsing pyramid squashes a dozen things in one frame + for i in 8: + var p := AudioStreamPlayer3D.new() + p.unit_size = 5.0 + p.max_db = 2.0 + host.add_child(p) + _players.append(p) + +func reset() -> void: + litres = 0.0 + squashed = 0 + for e in _puddles: + var n = e["node"] + if is_instance_valid(n): + n.queue_free() + _puddles.clear() + +## Which produce is this? Derived from the node name, which Main sets from the GLB +## filename (`produce-apple` -> `apple`). +static func kind_of(n: Node) -> String: + var s := String(n.name).to_lower() + for k in YIELD.keys(): + if s.contains(k): + return k + return "generic" + +# ---------------------------------------------------------------- the burst +## Called instead of the usual shard spawn when a `produce` Smashable dies. +func burst(at: Vector3, item: String, host: Node3D) -> void: + var col: Color = JUICE.get(item, JUICE["generic"]) + var vol: float = YIELD.get(item, YIELD["generic"]) + litres += vol + squashed += 1 + _pulp(at, col, host, vol) + _puddle(at, col, vol) + _squelch_at(at) + +func _pulp(at: Vector3, col: Color, host: Node3D, vol: float) -> void: + var p := GPUParticles3D.new() + p.one_shot = true + p.explosiveness = 1.0 + p.amount = clampi(int(28 + vol * 40.0), 24, 140) + p.lifetime = 1.5 + p.local_coords = false + var pm := ParticleProcessMaterial.new() + pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + pm.emission_sphere_radius = 0.05 + pm.direction = Vector3(0, 1, 0) + pm.spread = 80.0 + pm.initial_velocity_min = 1.4 + pm.initial_velocity_max = 4.6 + vol + pm.gravity = Vector3(0, -12.0, 0) + pm.scale_min = 0.5 + pm.scale_max = 1.6 + pm.angular_velocity_min = -500.0 + pm.angular_velocity_max = 500.0 + p.process_material = pm + var mesh := BoxMesh.new() + mesh.size = Vector3(0.028, 0.028, 0.028) + var mat := StandardMaterial3D.new() + mat.albedo_color = col + mat.roughness = 0.35 + mesh.material = mat + p.draw_pass_1 = mesh + host.add_child(p) + p.global_position = at + p.emitting = true + p.finished.connect(p.queue_free) + +## A flat quad on the floor. Not a decal projector — this is one unshaded quad slightly +## above the carpet, which is cheap, and at grazing angles it reads exactly the same. +func _puddle(at: Vector3, col: Color, vol: float) -> void: + var r := clampf(0.22 + vol * 0.30, 0.22, 0.95) + var mi := MeshInstance3D.new() + var q := QuadMesh.new() + q.size = Vector2(r * 2.0, r * 2.0) + mi.mesh = q + var m := StandardMaterial3D.new() + m.albedo_color = Color(col.r, col.g, col.b, 0.72) + m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + m.cull_mode = BaseMaterial3D.CULL_DISABLED + m.render_priority = -1 + mi.material_override = m + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _host.add_child(mi) + mi.global_position = Vector3(at.x, 0.012, at.z) + mi.rotation = Vector3(-PI * 0.5, randf_range(0.0, TAU), 0.0) + _puddles.append({"node": mi, "pos": mi.global_position, "r": r}) + while _puddles.size() > MAX_PUDDLES: + var old = _puddles.pop_front() + if is_instance_valid(old["node"]): + old["node"].queue_free() + +func _squelch_at(at: Vector3) -> void: + if _players.is_empty(): + return + var p := _players[_next_player] + _next_player = (_next_player + 1) % _players.size() + p.stream = _snd[randi() % _snd.size()] + p.pitch_scale = randf_range(0.86, 1.18) + p.global_position = at + p.play() + +# ---------------------------------------------------------------- slipping +## How slippery the ground is under the player, 0..1. Player reads this to cut its +## acceleration — the more juice you've made, the less the floor cooperates. +func slip_under_player() -> float: + if _player == null: + return 0.0 + var here := _player.global_position + var worst := 0.0 + for e in _puddles: + var p: Vector3 = e["pos"] + var d := Vector2(here.x - p.x, here.z - p.z).length() + var reach: float = float(e["r"]) + PUDDLE_R * 0.5 + if d < reach: + worst = maxf(worst, 1.0 - d / reach) + return worst + +func hud_line() -> String: + if squashed <= 0: + return "" + var slip := slip_under_player() + return "%.1f litres · %d squashed%s" % [ + litres, squashed, " · SLIPPERY" if slip > 0.35 else ""] + +# ---------------------------------------------------------------- audio +## Wet, short, and low. Noise through a falling low-pass, roughly. +func _squelch(pitch: float) -> AudioStreamWAV: + var rate := 22050 + var dur := 0.30 + var n := int(rate * dur) + var out := PackedFloat32Array() + out.resize(n) + var lp := 0.0 + for i in n: + var t := float(i) / float(rate) + var env := exp(-t * 14.0) * (1.0 - exp(-t * 200.0)) + var raw := (randf() * 2.0 - 1.0) + # sweep the filter closed so it goes from a splat to a glop + var k := clampf(0.55 - t * 1.4, 0.06, 0.55) + lp += (raw - lp) * k + var s := lp * 1.5 * env + s += sin(TAU * 120.0 * pitch * t) * 0.22 * env + out[i] = clampf(s, -1.0, 1.0) + var w := AudioStreamWAV.new() + w.format = AudioStreamWAV.FORMAT_16_BITS + w.mix_rate = rate + w.stereo = false + var b := PackedByteArray() + b.resize(n * 2) + for i in n: + b.encode_s16(i * 2, int(out[i] * 32767.0)) + w.data = b + return w diff --git a/game/scripts/Splat.gd.uid b/game/scripts/Splat.gd.uid new file mode 100644 index 0000000..9978edb --- /dev/null +++ b/game/scripts/Splat.gd.uid @@ -0,0 +1 @@ +uid://bal2sx0f4h75a diff --git a/tools/gen_produce.py b/tools/gen_produce.py new file mode 100644 index 0000000..385e26d --- /dev/null +++ b/tools/gen_produce.py @@ -0,0 +1,353 @@ +"""The greengrocer's stock. + + /Applications/Blender.app/Contents/MacOS/Blender --background \ + --python tools/gen_produce.py -- [--render] + +ORIGIN RULE, and it differs from every other prop in this repo: round produce is +CENTRED, not floor-centred. These things are meant to roll, and a rolling body whose +origin sits on the floor plane wobbles like a loaded die. Everything else here (crates, +bottles, the banana bunch) keeps the usual floor-centre origin, and Main knows which is +which from the level spec. + +Deliberately a bit under-detailed: these spawn in pyramids of thirty, so the budget goes +on silhouette and colour rather than on a nice stem. +""" + +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.55, 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=20, rings=12): + 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.005, 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 smooth(o, angle=45.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) + + +def span(r0, r1, p0, p1, verts=8): + p0, p1 = Vector(p0), Vector(p1) + d = p1 - p0 + n = d.length + if n < 1e-6: + return None + o = cyl(r0, n, verts=verts, r2=r1) + q = Vector((0, 0, 1)).rotation_difference(d.normalized()) + o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ q.to_matrix().to_4x4() + bpy.ops.object.transform_apply(location=True, rotation=True) + return o + + +# ---------------------------------------------------------------- produce +# All the round ones are CENTRED on the origin so they roll properly. + +def p_apple(M): + parts = [] + b = ball(0.040, segs=18, rings=12) + b.scale = (1.0, 1.0, 0.92) + bpy.ops.object.transform_apply(scale=True) + parts.append(assign(b, M["apple"])) + # the dimple: a dark disc SUNK into the top, not a bobble sitting on it + d = ball(0.017, loc=(0, 0, 0.029), segs=12, rings=8) + d.scale = (1.0, 1.0, 0.34) + bpy.ops.object.transform_apply(scale=True) + parts.append(assign(d, M["apple_dark"])) + parts.append(assign(span(0.004, 0.003, Vector((0, 0, 0.032)), + Vector((0.006, 0.004, 0.062)), verts=6), M["stem"])) + return parts + + +def p_orange(M): + parts = [] + b = ball(0.037, segs=18, rings=12) + b.scale = (1.0, 1.0, 0.95) + bpy.ops.object.transform_apply(scale=True) + parts.append(assign(b, M["orange"])) + parts.append(assign(cyl(0.006, 0.004, loc=(0, 0, 0.034), verts=8), M["stem"])) + return parts + + +def p_tomato(M): + parts = [] + b = ball(0.033, segs=18, rings=12) + b.scale = (1.0, 1.0, 0.82) + bpy.ops.object.transform_apply(scale=True) + parts.append(assign(b, M["tomato"])) + # calyx: five little leaves + for i in range(5): + a = i * (math.tau / 5.0) + parts.append(assign(span(0.006, 0.002, + Vector((0, 0, 0.026)), + Vector((math.cos(a) * 0.020, math.sin(a) * 0.020, 0.030)), + verts=5), M["leaf"])) + return parts + + +def p_melon(M): + """Watermelon. Big, heavy, and the best thing in the shop to drop on other produce.""" + parts = [] + b = ball(0.145, segs=26, rings=16) + b.scale = (1.0, 1.28, 1.0) + bpy.ops.object.transform_apply(scale=True) + parts.append(assign(b, M["melon"])) + # Stripes as thin lenses through the middle, each a hair proud of the skin, so they + # read as bands wrapping the fruit. Boxes poking out radially made it look like a + # sea mine. + for i in range(7): + a = i * (math.pi / 7.0) + st = ball(0.1465, segs=26, rings=16) + st.scale = (0.115, 1.283, 1.004) + bpy.ops.object.transform_apply(scale=True) + st.rotation_euler = (0.0, 0.0, a) + bpy.ops.object.transform_apply(rotation=True) + parts.append(assign(st, M["melon_dark"])) + return parts + + +def p_cabbage(M): + parts = [] + b = ball(0.082, segs=20, rings=14) + b.scale = (1.0, 1.0, 0.88) + bpy.ops.object.transform_apply(scale=True) + parts.append(assign(b, M["cabbage"])) + # a few outer leaves, so it isn't just a green ball + for i in range(6): + a = i * (math.tau / 6.0) + lf = ball(0.052, loc=(math.cos(a) * 0.058, math.sin(a) * 0.058, -0.016), + segs=12, rings=8) + lf.scale = (1.25, 1.25, 0.42) + bpy.ops.object.transform_apply(scale=True) + parts.append(assign(lf, M["cabbage_out"])) + return parts + + +def p_banana(M): + """A hand of bananas. Floor-centred — these don't roll, they flop.""" + parts = [] + for i in range(5): + off = (i - 2) * 0.018 + pos = Vector((off, 0.0, 0.030)) + prev = pos + for seg in range(4): + a = math.radians(-38 + seg * 26) + nxt = prev + Vector((0.0, math.cos(a) * 0.042, math.sin(a) * 0.042 * 0.5)) + r = 0.016 - seg * 0.0022 + parts.append(assign(span(r, r * 0.86, prev, nxt, verts=8), M["banana"])) + prev = nxt + crown = box((0.10, 0.030, 0.026), loc=(0, -0.012, 0.030)) + parts.append(assign(bevel(crown, 0.008), M["stem"])) + return parts + + +# ---------------------------------------------------------------- fittings + +def p_bottle(M): + """Glass juice bottle. Floor-centred. Breaks like glass, not like fruit.""" + parts = [] + body = cyl(0.037, 0.185, loc=(0, 0, 0.093), verts=20) + parts.append(assign(body, M["glass"])) + juice = cyl(0.031, 0.140, loc=(0, 0, 0.076), verts=18) + parts.append(assign(juice, M["juice"])) + shoulder = cyl(0.037, 0.055, loc=(0, 0, 0.212), r2=0.017, verts=20) + parts.append(assign(shoulder, M["glass"])) + neck = cyl(0.017, 0.048, loc=(0, 0, 0.262), verts=14) + parts.append(assign(neck, M["glass"])) + cap = cyl(0.020, 0.020, loc=(0, 0, 0.294), verts=14) + parts.append(assign(cap, M["cap"])) + label = cyl(0.0385, 0.070, loc=(0, 0, 0.085), verts=20) + parts.append(assign(label, M["label"])) + return parts + + +def p_crate(M): + """Angled wooden display crate. Produce piles into it.""" + parts = [] + W, D, H, T = 0.52, 0.38, 0.15, 0.014 + parts.append(assign(box((W, T, H), loc=(0, -D / 2, H / 2)), M["crate"])) + parts.append(assign(box((W, T, H * 1.5), loc=(0, D / 2, H * 0.75)), M["crate"])) + for s in (-1, 1): + parts.append(assign(box((T, D, H * 1.2), loc=(s * W / 2, 0, H * 0.6)), M["crate"])) + for i in range(5): + parts.append(assign(box((W, D / 5 - 0.01, T), + loc=(0, -D / 2 + (i + 0.5) * D / 5, T / 2)), M["crate"])) + return parts + + +PROPS = { + "produce-apple": p_apple, + "produce-orange": p_orange, + "produce-tomato": p_tomato, + "produce-melon": p_melon, + "produce-cabbage": p_cabbage, + "produce-banana": p_banana, + "juice-bottle": p_bottle, + "produce-crate": p_crate, +} + + +def materials(): + return { + "apple": mat("apple", (0.62, 0.06, 0.07), rough=0.32), + "apple_dark": mat("apple_dark", (0.30, 0.04, 0.05), rough=0.5), + "orange": mat("orange", (0.88, 0.42, 0.05), rough=0.62), + "tomato": mat("tomato", (0.72, 0.07, 0.05), rough=0.28), + "melon": mat("melon", (0.16, 0.36, 0.12), rough=0.45), + "melon_dark": mat("melon_dark", (0.08, 0.20, 0.07), rough=0.45), + "cabbage": mat("cabbage", (0.68, 0.78, 0.48), rough=0.55), + "cabbage_out": mat("cabbage_out", (0.38, 0.58, 0.26), rough=0.62), + "banana": mat("banana", (0.90, 0.78, 0.18), rough=0.5), + "leaf": mat("leaf", (0.24, 0.44, 0.16), rough=0.7), + "stem": mat("stem", (0.34, 0.26, 0.13), rough=0.85), + "glass": mat("glass", (0.74, 0.82, 0.78), rough=0.06, alpha=0.34), + "juice": mat("juice", (0.86, 0.44, 0.06), rough=0.22), + "cap": mat("cap", (0.72, 0.70, 0.66), rough=0.3, metal=0.8), + "label": mat("label", (0.94, 0.92, 0.86), rough=0.85), + "crate": mat("crate", (0.56, 0.40, 0.22), rough=0.85), + } + + +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("[produce] %-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 = 480 + scene.render.resolution_y = 480 + 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.10, 0.10, 0.11, 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.02) + dist = radius * 3.0 + d = Vector((0.62, -0.74, 0.34)).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)), 60.0, 1.2), + (Vector((-1.1, -0.4, 0.3)), 18.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: + smooth(p, 45.0) + export(parts, name) + if RENDER: + render_preview(name) + print("[produce] done -> %s" % OUT_DIR) + + +main()