LANE8: the greengrocer — soft stock, stacks that collapse, and litres

The only site whose contents are mostly SOFT, and that one material change rewrites the
level. `produce` is a new Smashable material that throws no shards at all — 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, both falling out of existing systems rather
than needing new ones:
  * produce has a low brittle_speed, so anything heavy landing on it squashes it — which
    means a pyramid crushes its OWN bottom layer as it collapses, with no special code.
    Knock one off the top and the pile does the rest. Shove test: 257 items -> 45, and
    42 litres of juice out of the collapse.
  * puddles are slippery, cutting ACCELERATION rather than top speed so it reads as
    sliding rather than as being slowed down. The more mess you make, the less the floor
    cooperates.

Stacks are built bottom-up by Main._build_stack and nothing is glued or frozen — they
stand because they are stacked, so pulling one out of the bottom row does what you'd
hope. 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 in the room that
still rewards a proper swing, and the contrast between litres and shards is the joke.

Round produce is authored CENTRED, not floor-centred like every other prop in this repo,
because a rolling body whose origin sits on the floor plane wobbles like a loaded die.
_glb_piece grew a `centred` flag for it.

REAL BUG FOUND, and it was costing hits in every level: _strike took the NEAREST collider
of any kind, so a static surface could eat a swing. Leaning over a 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 — 0/306 for the whole first take. It now prefers a Smashable and
only falls back to loose bodies if there isn't one. The offices were quietly losing hits
on desks and shelves the same way.

tools/gen_produce.py: apple, orange, tomato, watermelon, cabbage, banana hand, glass
juice bottle, display crate. The melon's stripes were radial boxes first time and it came
out looking like a sea mine; they're thin lenses through the middle now.

dev/probe_grocer.gd verifies stacks settle, that produce yields litres and NO debris,
that a collapse crushes, and that none of it leaks to the next site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Monster Robot Party 2026-08-01 09:38:35 +10:00
parent a806272a7f
commit c96c2f1a67
29 changed files with 1317 additions and 23 deletions

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

View File

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

83
game/dev/probe_grocer.gd Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

215
game/scripts/Splat.gd Normal file
View File

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

View File

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

353
tools/gen_produce.py Normal file
View File

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