diff --git a/LANES/LANE7-cubicle-hell.md b/LANES/LANE7-cubicle-hell.md index 009ac7d..e5b7454 100644 --- a/LANES/LANE7-cubicle-hell.md +++ b/LANES/LANE7-cubicle-hell.md @@ -288,7 +288,53 @@ 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. +die — and it now carries a SPHERE collider rather than the auto-boxed AABB everything +else gets, which is what finally made it roll. -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. +### The pyramid took three fixes, not one + +Swapping to sphere colliders broke every display instantly (28 m/s of residual motion, +fruit on the floor before you were through the door). Three things were wrong, and all +three had to be right: + +1. **The radii were typed, not measured.** `Main._glb_radius()` now reads the asset. The + table said a cabbage was 84 mm; the model is 213 mm. +2. **The lattice was box geometry.** For spheres the rise per row is + `sqrt(4r² − step²/2)` — the height at which a fruit touches all four beneath it. + Anything else spawns every row above the first in mid-air. +3. **There was no tray.** A pyramid of spheres on a bare flat table *cannot* stand; + nothing holds the bottom row in. `Main._stack_tray()` frames each pile in four low + timber walls sized to its base row, which is exactly what a real shop does. All 310 + bodies are asleep within 3 s. + +### Hanging scales *(built)* + +> *"feel like hanging scales would be involved somehow too"* + +Real `PinJoint3D` pendulums, not animations. A static dial + rod, and a `scale-pan` rigid +body pinned at its own origin so it can only rotate about the pivot. They hang at chest +height down the aisles — dial at eye level, dish below it, where a real one hangs and +where you keep walking into it. Measured: the dish swings 0.40 m off a light knock and +the pin holds the pivot to 1.1 mm. The pan is `steel`, so it never breaks — you get a +heavy brass weight loose in a room full of stacked fruit instead. + +### Carry and throw *(built)* + +E picks up anything under 6 kg that isn't a record; LMB throws it at 11 m/s, six times +any brittle threshold in the game. Thrown fruit bursts on landing through the same +`brittle_speed` path a collapse uses, so there is no separate "thrown object" code at +all. The carried body rides frozen-kinematic with its collision layers zeroed and gets +them back on release. + +### The bag you cannot open *(built)* + +> *"and those rolls of plastic bags you cant open haha"* + +`Tasks.Kind.BAGS`, and it may be the purest thing in the game. Tearing one off the roll +is easy — that's the setup. Then you have to open it. The bag has two ends, only one of +which opens, and **nothing tells you which**. You rub, alternating ← and → because +mashing one key is not rubbing, and either the meter moves or it doesn't. The only way to +learn which end you're holding is to have already lost several seconds to the other one. +SPACE turns it over. That is the entire solution and the game never mentions it. After +about five seconds on the sealed end it takes pity and says so — but only after the +provoke has already landed on the meter. diff --git a/game/README.md b/game/README.md index 1fb8c21..25da577 100644 --- a/game/README.md +++ b/game/README.md @@ -24,9 +24,10 @@ Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics. | **L** | cycle site (Scranton / Pawnee / Incubator / Sub-level 4 / Greengrocer / Level 0) | | **G** | restart a Gauntlet run | | **F** | report the disc in hand (Find-the-Misfiled-Disc) | -| **E** | work at the station you're standing at (locks you in place); otherwise grab a record | -| **drag ←→ · LMB · G** | with a record held: slide the disc out · throw it · drop | -| **digits · arrows · ENTER** | while working: type · move cell · commit | +| **E** | work at the station you're standing at (locks you in place); otherwise pick up whatever's under the crosshair | +| **LMB · G** | with something in hand: throw it · put it down | +| **drag ←→** | with a *record* held: slide the disc out of the sleeve | +| **digits · arrows · ENTER · SPACE** | while working: type · move cell · commit · the other thing | | **B · R** | rain 500 bodies (stress gate) · reset the office | ## The six sites @@ -73,7 +74,45 @@ what you'd hope. The glass bottles behind the juice bar are the deliberate excep 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. +a rolling body whose origin sits on the floor plane wobbles like a loaded die. It also +gets a **sphere** collider rather than the usual auto-boxed AABB — a box on an apple is +why nothing used to roll, and why the lattice needed hand-tuned spacing to nest. + +Three things make a pyramid of spheres actually stand up, and all three are needed: + +- **Measured radii.** `Main._glb_radius()` reads the asset instead of a table. The table + said a cabbage was 84 mm; the model is 213 mm, so every row of that pyramid was driven + a third of the way into the row beneath it. +- **Sphere lattice geometry.** Each fruit sits in the dimple between four below, so the + rise per row is `sqrt(4r² − step²/2)`, not a guessed multiple of `r`. Get it wrong and + every row above the first spawns in mid-air. +- **A tray.** A pyramid of frictionless spheres on a bare flat table cannot stand: nothing + holds the bottom row in, so the weight above wedges it outward and the display walks + itself apart in about a second. `Main._stack_tray()` frames each pile in four low + timber walls sized to its base row — which is what every greengrocer on earth already + does. Everything sleeps within 3 s. + +**Hanging scales.** Brass shop scales hang down the aisles at chest height on real +`PinJoint3D` pendulums — a static dial and rod, a `scale-pan` rigid body pinned at its +own origin, so the pan can only rotate about the pivot. Walk into one and it swings; hit +it with the bat and it swings hard, and the pin holds to within a millimetre. It's +`steel`, so it never breaks: what you get instead is a heavy brass weight loose in a room +full of stacked fruit. + +**Carrying and throwing.** E picks up anything under 6 kg that isn't a record — an apple, +a bottle, a stapler — and LMB throws it at 11 m/s, which is six times any brittle +threshold in the game. Thrown fruit bursts wherever it lands via the same +`brittle_speed` path as a collapse, so there's no separate "thrown object" code. The +carried body is frozen kinematic with its collision layers zeroed while it's in the hand, +and gets them back on release. + +**The bag roll.** The station at the bag stand is `Tasks.Kind.BAGS`, and it is the purest +distillation of what this game is about. Tearing one off the roll is easy. Then you have +to open it, and the bag has two ends, only one of which opens, and **nothing anywhere +tells you which**. You rub — alternating ← and →, because mashing one key is not rubbing +— and either the meter climbs or it doesn't, and the only way to find out is to have +already wasted several seconds on the wrong one. SPACE turns it over. That's the answer. +The game never says so. ### LEVEL 0 — the Backrooms, and the dust @@ -329,3 +368,18 @@ Verify a level is genuinely at rest with: ``` The demo's first act touches nothing and prints total body speed each second. It must read `0.00 m/s`, and no `[spawn]` warnings should appear. + +The guard is a net, not a test: it only fires when Jolt happens to resolve a pair +violently on the frames it's watching, and a symmetric overlap can sit quietly for a +whole run and then launch a filing cabinet the day you change something unrelated. That +is exactly what happened — a `cardboard-box` pair had been interpenetrating in four +levels for weeks and only surfaced when an unrelated change shifted the RNG stream. +`dev/probe_overlap.gd` finds them by measurement instead of by luck, comparing every pair +of dynamic colliders across all six sites: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path game --script dev/probe_overlap.gd +``` +It must print `CLEAN`. The box asset is 1.35 m wide, which is why the specs now stack +boxes into piles instead of dotting them about, and why the Backrooms scatter uses +rejection sampling with a 1.6 m minimum. diff --git a/game/assets/store/bag-roll.glb b/game/assets/store/bag-roll.glb new file mode 100644 index 0000000..d01f592 Binary files /dev/null and b/game/assets/store/bag-roll.glb differ diff --git a/game/assets/store/bag-roll.glb.import b/game/assets/store/bag-roll.glb.import new file mode 100644 index 0000000..07a88f2 --- /dev/null +++ b/game/assets/store/bag-roll.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://b6euwwdcao60y" +path="res://.godot/imported/bag-roll.glb-50dcef2da8305484d7a0466b4825848e.scn" + +[deps] + +source_file="res://assets/store/bag-roll.glb" +dest_files=["res://.godot/imported/bag-roll.glb-50dcef2da8305484d7a0466b4825848e.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 index e7c51bc..0c05cfb 100644 Binary files a/game/assets/store/produce-apple.glb and b/game/assets/store/produce-apple.glb differ diff --git a/game/assets/store/produce-cabbage.glb b/game/assets/store/produce-cabbage.glb index bad09d7..aefc083 100644 Binary files a/game/assets/store/produce-cabbage.glb and b/game/assets/store/produce-cabbage.glb differ diff --git a/game/assets/store/produce-melon.glb b/game/assets/store/produce-melon.glb index aaa1a55..c5bfdc5 100644 Binary files a/game/assets/store/produce-melon.glb and b/game/assets/store/produce-melon.glb differ diff --git a/game/assets/store/produce-orange.glb b/game/assets/store/produce-orange.glb index a388baf..cbe75ec 100644 Binary files a/game/assets/store/produce-orange.glb and b/game/assets/store/produce-orange.glb differ diff --git a/game/assets/store/produce-tomato.glb b/game/assets/store/produce-tomato.glb index fa2462a..ce12b87 100644 Binary files a/game/assets/store/produce-tomato.glb and b/game/assets/store/produce-tomato.glb differ diff --git a/game/assets/store/scale-head.glb b/game/assets/store/scale-head.glb new file mode 100644 index 0000000..9e40f1c Binary files /dev/null and b/game/assets/store/scale-head.glb differ diff --git a/game/assets/store/scale-head.glb.import b/game/assets/store/scale-head.glb.import new file mode 100644 index 0000000..8822620 --- /dev/null +++ b/game/assets/store/scale-head.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://drmrffy7y2b6b" +path="res://.godot/imported/scale-head.glb-8d6abfc6b01d34fc7664b05a8aedcf64.scn" + +[deps] + +source_file="res://assets/store/scale-head.glb" +dest_files=["res://.godot/imported/scale-head.glb-8d6abfc6b01d34fc7664b05a8aedcf64.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/scale-pan.glb b/game/assets/store/scale-pan.glb new file mode 100644 index 0000000..26eab7c Binary files /dev/null and b/game/assets/store/scale-pan.glb differ diff --git a/game/assets/store/scale-pan.glb.import b/game/assets/store/scale-pan.glb.import new file mode 100644 index 0000000..4faf12a --- /dev/null +++ b/game/assets/store/scale-pan.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://b3f2s2685b3w7" +path="res://.godot/imported/scale-pan.glb-50928edd53a6c769bc49a43a4cadec66.scn" + +[deps] + +source_file="res://assets/store/scale-pan.glb" +dest_files=["res://.godot/imported/scale-pan.glb-50928edd53a6c769bc49a43a4cadec66.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 75b196f..f651fa2 100644 --- a/game/dev/DemoDriver.gd +++ b/game/dev/DemoDriver.gd @@ -48,8 +48,8 @@ func _find_player(n: Node) -> Player: return null func _build_script() -> void: - # 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. + # THE GREENGROCER: the stacks come down, the scale swings, an apple gets thrown at + # the bottles, and the bag does not open. _script = [ {"dur": 1.2, "move_to": null, "look_at": null, "act": "level:grocer"}, {"dur": 0.3, "move_to": null, "look_at": null, "act": "mode:2"}, @@ -62,12 +62,42 @@ func _build_script() -> void: {"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": ""}, + # the hanging scale, which is at face height and stays swinging + {"dur": 2.4, "move_to": Vector3(-3.7, 0, 0.9), "look_at": Vector3(-3.7, 1.78, -0.4), "act": ""}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "swing"}, + {"dur": 2.6, "move_to": null, "look_at": null, "act": ""}, + # pick an orange up off the pile and throw it at the bottle shelf + {"dur": 2.2, "move_to": Vector3(-5.4, 0, 2.5), "look_at": Vector3(-5.4, 1.10, 1.4), "act": ""}, + {"dur": 0.6, "move_to": null, "look_at": null, "act": "grab"}, + {"dur": 1.0, "move_to": null, "look_at": Vector3(-4.4, 1.70, -7.7), "act": ""}, + {"dur": 0.4, "move_to": null, "look_at": null, "act": "throw"}, + {"dur": 1.8, "move_to": null, "look_at": null, "act": ""}, + # and the bottles, properly + {"dur": 2.4, "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": ""}, + # the bag. Stations only exist while you're on the clock, so clock back on. + {"dur": 0.3, "move_to": null, "look_at": null, "act": "mode:0"}, + {"dur": 2.8, "move_to": Vector3(-0.3, 0, 2.2), "look_at": Vector3(-0.3, 1.06, 3.6), "act": ""}, + {"dur": 0.8, "move_to": null, "look_at": null, "act": "task"}, + {"dur": 1.0, "move_to": null, "look_at": null, "act": "key:space"}, + # rub away at it. The meter climbs the whole time you're getting nowhere. + {"dur": 0.6, "move_to": null, "look_at": null, "act": "key:left"}, + {"dur": 0.6, "move_to": null, "look_at": null, "act": "key:right"}, + {"dur": 0.6, "move_to": null, "look_at": null, "act": "key:left"}, + {"dur": 0.6, "move_to": null, "look_at": null, "act": "key:right"}, + {"dur": 0.5, "move_to": null, "look_at": null, "act": "rage:0.16"}, + {"dur": 0.6, "move_to": null, "look_at": null, "act": "key:left"}, + {"dur": 0.6, "move_to": null, "look_at": null, "act": "key:right"}, + {"dur": 0.5, "move_to": null, "look_at": null, "act": "rage:0.16"}, + # turn it over. This was the answer the whole time. + {"dur": 1.0, "move_to": null, "look_at": null, "act": "key:space"}, + {"dur": 0.6, "move_to": null, "look_at": null, "act": "key:left"}, + {"dur": 0.6, "move_to": null, "look_at": null, "act": "key:right"}, + {"dur": 0.6, "move_to": null, "look_at": null, "act": "key:left"}, + {"dur": 1.0, "move_to": null, "look_at": null, "act": "key:right"}, + {"dur": 3.4, "move_to": Vector3(-1.0, 0, 2.0), "look_at": Vector3(-4.0, 0.6, -0.5), "act": ""}, ] func _motion(verbose := false) -> float: @@ -210,7 +240,20 @@ func _do(act: String) -> void: if r != null: r.provoke(float(act.substr(5)), "the demo is provoking you") return + if act.begins_with("key:"): # feed one key to the open task panel + var tk = _main.get("_tasks") + var codes := {"space": KEY_SPACE, "left": KEY_LEFT, "right": KEY_RIGHT, + "up": KEY_UP, "down": KEY_DOWN, "enter": KEY_ENTER} + if tk != null: + tk.handle_key(int(codes.get(act.substr(4), KEY_SPACE))) + return match act: + "grab": # E on whatever is under the crosshair + if _player.grab != null: + _player.grab.try_grab() + "throw": # LMB while holding = chuck it + if _player.grab != null: + _player.grab.primary() "task": # press E at the nearest station var t = _main.get("_tasks") if t != null: diff --git a/game/dev/probe_bags.gd b/game/dev/probe_bags.gd new file mode 100644 index 0000000..b12284e --- /dev/null +++ b/game/dev/probe_bags.gd @@ -0,0 +1,34 @@ +extends SceneTree +## Can you actually reach the bag station, and does the panel render? +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") + main._modes.set_mode(0) + for i in 20: + await physics_frame + var tasks = main.get("_tasks") + print("stations : ", tasks.get("_stations").size()) + for s in tasks.get("_stations"): + print(" ", Tasks.Kind.keys()[int(s["kind"])], " @ ", s["pos"], " ", s["label"]) + var player = main.get("_player") + player.global_position = Vector3(-0.3, 0.0, 2.2) + await process_frame + await process_frame + print("near : '", tasks.nearest_label(), "' prompt='", tasks.prompt(), "'") + print("start : ", tasks.try_start(), " phase=", tasks.phase) + var st: Dictionary = tasks.panel_state() + print("panel : ", st.get("title"), " / ", st.get("instruction"), " / ", st.get("footer")) + print(main.get("_hud")._bags_art(st)) + tasks.handle_key(KEY_SPACE) + st = tasks.panel_state() + print("torn : ", st.get("instruction"), " / ", st.get("footer")) + for i in 8: + tasks.handle_key(KEY_LEFT if i % 2 == 0 else KEY_RIGHT) + print(main.get("_hud")._bags_art(tasks.panel_state())) + tasks.handle_key(KEY_SPACE) # turn it over + for i in 8: + tasks.handle_key(KEY_LEFT if i % 2 == 0 else KEY_RIGHT) + print(main.get("_hud")._bags_art(tasks.panel_state())) + quit() diff --git a/game/dev/probe_bags.gd.uid b/game/dev/probe_bags.gd.uid new file mode 100644 index 0000000..3431197 --- /dev/null +++ b/game/dev/probe_bags.gd.uid @@ -0,0 +1 @@ +uid://dhvlnwvmwvnya diff --git a/game/dev/probe_grocer.gd b/game/dev/probe_grocer.gd index e98b02f..05d4467 100644 --- a/game/dev/probe_grocer.gd +++ b/game/dev/probe_grocer.gd @@ -1,8 +1,9 @@ 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. +## shattering, does a collapse crush its own bottom layer, does the floor get slippery, +## do the scales hang and swing, can you carry and throw fruit, and is the bag actually +## impossible to open from the wrong end. ## Godot --headless --path game --script dev/probe_grocer.gd func _initialize() -> void: @@ -68,6 +69,84 @@ func _initialize() -> void: print("slip here : %.2f (0 = dry floor)" % splat.slip_under_player()) print("hud : ", splat.hud_line()) + # --- the hanging scales: do they hang, and do they swing when hit? --- + var pans: Array = [] + for n in get_nodes_in_group("smashable"): + if String(n.name).begins_with("scale-pan"): + pans.append(n) + var joints := 0 + for n in main.get("_world").get_children(): + if n is PinJoint3D: + joints += 1 + print("scales : %d pans, %d pin joints" % [pans.size(), joints]) + if not pans.is_empty(): + var pan: RigidBody3D = pans[0] + var anchor := pan.global_position # the ORIGIN is the pin: it must not move + var bob := func() -> Vector3: return pan.global_transform * Vector3(0.0, -0.25, 0.0) + var rest: Vector3 = bob.call() + pan.apply_central_impulse(Vector3(4.0, 0.0, 0.0)) + var peak := 0.0 + for i in 180: + await physics_frame + peak = maxf(peak, ((bob.call() as Vector3) - rest).length()) + print("swing : dish swings %.2f m, pin drifts %.4f m, still moving at 3s: %.2f" % [ + peak, (pan.global_position - anchor).length(), + ((bob.call() as Vector3) - rest).length()]) + + # --- carry and throw --- + var grab = player.get("grab") + var apple: Smashable = null + for n in get_nodes_in_group("smashable"): + var sm := n as Smashable + if sm != null and sm.kind == "produce" and not sm.freeze and not sm.is_broken(): + apple = sm + break + if apple != null and grab != null: + var cam = main.get("_cam") + # stand the player next to it and look at it + player.global_position = Vector3(apple.global_position.x, 0.0, + apple.global_position.z + 0.9) + await physics_frame + cam.look_at(apple.global_position, Vector3.UP) + await physics_frame + grab.try_grab() + print("carry : holding=%s carrying=%s" % [grab.is_holding(), grab.is_carrying()]) + for i in 4: + await physics_frame # let it ride up to the hand first + var l0: float = splat.litres + grab.primary() # throw it + var thrown := apple.linear_velocity.length() if is_instance_valid(apple) else -1.0 + print("throw : leaves the hand at %.1f m/s (brittle threshold is 1.8)" % thrown) + for i in 60: + await physics_frame + print("landed : litres %.2f -> %.2f (a thrown apple must burst)" % [l0, splat.litres]) + + # --- the bag --- + var tasks = main.get("_tasks") + tasks.phase = Tasks.Phase.IDLE + tasks._begin_bags() + var open_end: int = tasks.get("_bag_open_end") + tasks.handle_key(KEY_SPACE) # tear one off + print("bag : torn off, stage=%d" % tasks.get("_bag_stage")) + # rub the WRONG end for twenty strokes + tasks._bag_end = 1 - open_end + for i in 20: + tasks.handle_key(KEY_LEFT if i % 2 == 0 else KEY_RIGHT) + print("wrong end : grip %.2f after 20 strokes (must be 0.00)" % tasks.get("_grip")) + # mash ONE key on the right end: also nothing, because that is not rubbing + tasks._bag_end = open_end + for i in 20: + tasks.handle_key(KEY_LEFT) + print("mashing : grip %.2f after 20 identical keys (must be ~0.08: one stroke)" % + tasks.get("_grip")) + # now do it properly + var strokes := 0 + while tasks.phase == Tasks.Phase.BAGS and strokes < 60: + tasks.handle_key(KEY_LEFT if strokes % 2 == 0 else KEY_RIGHT) + strokes += 1 + print("right end : opened in %d alternating strokes, phase now %d" % [ + strokes, tasks.phase]) + # --- and none of it leaks to the office --- main._load_level("scranton") for i in 6: diff --git a/game/dev/probe_overlap.gd b/game/dev/probe_overlap.gd new file mode 100644 index 0000000..ac43f17 --- /dev/null +++ b/game/dev/probe_overlap.gd @@ -0,0 +1,76 @@ +extends SceneTree + +## Spawn-overlap audit. Every site, every pair of dynamic bodies: do their colliders +## interpenetrate on frame one? +## +## The spawn guard clamps an exploding prop and names it, but only if Jolt happens to +## resolve that pair violently on the frames it's watching — a symmetric overlap can sit +## quietly for a whole run and then launch a filing cabinet the day you change something +## unrelated. This finds them by measurement instead of by luck. +## +## Godot --headless --path game --script dev/probe_overlap.gd + +const CLEAR := 0.004 ## metres of slop allowed before we call it an overlap + +func _initialize() -> void: + var main: Node = (load("res://main.tscn") as PackedScene).instantiate() + get_root().add_child(main) + await process_frame + var bad := 0 + for id in Levels.ORDER: + main._load_level(id) + await process_frame + bad += _audit(id) + print("\n%s" % ("CLEAN — nothing interpenetrates at spawn" if bad == 0 + else "%d overlapping pairs" % bad)) + quit() + +## Godot renames duplicates to @RigidBody3D@NNN, which is useless in a report. The GLB +## instance under the body still knows what it is. +func _label(b: Node) -> String: + for c in b.get_children(): + if c is Node3D and not (c is CollisionShape3D): + return String(c.name).to_lower() + return String(b.name) + +func _audit(id: String) -> int: + var boxes: Array = [] + for n in get_nodes_in_group("smashable"): + var b := n as RigidBody3D + if b == null or b.freeze: + continue + for c in b.get_children(): + var col := c as CollisionShape3D + if col == null: + continue + var half := Vector3.ZERO + if col.shape is BoxShape3D: + half = (col.shape as BoxShape3D).size * 0.5 + elif col.shape is SphereShape3D: + # a sphere's AABB over-reports at the corners, so nested round produce + # would false-positive; shrink it to the inscribed cube + var r: float = (col.shape as SphereShape3D).radius * 0.577 + half = Vector3(r, r, r) + else: + continue + boxes.append({"node": b, "name": _label(b), + "c": b.global_position + col.position, "h": half}) + var hits: Array = [] + for i in boxes.size(): + for j in range(i + 1, boxes.size()): + var a: Dictionary = boxes[i] + var b2: Dictionary = boxes[j] + var d: Vector3 = (a["c"] as Vector3) - (b2["c"] as Vector3) + var need: Vector3 = (a["h"] as Vector3) + (b2["h"] as Vector3) + # AABB test: they only overlap if they overlap on ALL THREE axes + var pen := Vector3(need.x - absf(d.x), need.y - absf(d.y), need.z - absf(d.z)) + if pen.x > CLEAR and pen.y > CLEAR and pen.z > CLEAR: + hits.append({"an": a["name"], "bn": b2["name"], + "pen": minf(pen.x, minf(pen.y, pen.z)), "at": a["c"]}) + hits.sort_custom(func(x, y): return float(x["pen"]) > float(y["pen"])) + print("\n=== %-11s %d dynamic boxes, %d overlapping pairs" % [id, boxes.size(), hits.size()]) + for k in mini(8, hits.size()): + var h: Dictionary = hits[k] + print(" %.3f m into %-18s / %-18s at %s" % [h["pen"], + h["an"], h["bn"], h["at"]]) + return hits.size() diff --git a/game/dev/probe_overlap.gd.uid b/game/dev/probe_overlap.gd.uid new file mode 100644 index 0000000..928ea5f --- /dev/null +++ b/game/dev/probe_overlap.gd.uid @@ -0,0 +1 @@ +uid://kddgnxo4q7el diff --git a/game/dev/probe_settle.gd b/game/dev/probe_settle.gd new file mode 100644 index 0000000..77b53ce --- /dev/null +++ b/game/dev/probe_settle.gd @@ -0,0 +1,26 @@ +extends SceneTree +## Does the greengrocer actually come to REST, or does it jitter forever? A pyramid of +## spheres that never quite stops is fruit slowly walking off the display all shift. +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 t in 10: + for i in 60: + await physics_frame + var total := 0.0 + var worst := 0.0 + var awake := 0 + for n in get_nodes_in_group("smashable"): + var b := n as RigidBody3D + if b == null or b.freeze: + continue + if not b.sleeping: + awake += 1 + var v := b.linear_velocity.length() + total += v + worst = maxf(worst, v) + print("t=%2ds total %6.3f m/s worst body %5.3f awake %d" % [ + t + 1, total, worst, awake]) + quit() diff --git a/game/scripts/GrabController.gd b/game/scripts/GrabController.gd index 63e9e5a..1b5df63 100644 --- a/game/scripts/GrabController.gd +++ b/game/scripts/GrabController.gd @@ -11,6 +11,11 @@ class_name GrabController ## LMB ........... throw the freed disc (then a second LMB trashes the empty sleeve) ## G ............. drop / trash whatever's in hand ## +## E also PICKS UP anything light and loose — an apple, a bottle, a stapler — when +## there's no record under the crosshair. LMB throws it. A thrown apple is travelling +## far faster than its 1.8 m/s brittle threshold, so it bursts wherever it lands, which +## is the whole reason the greengrocer exists. +## ## Godot 4.7 GDScript 2.0. const GRAB_REACH := 2.0 ## metres in front of the camera a record is grabbable @@ -18,16 +23,27 @@ const GRAB_RADIUS := 0.55 ## forgiving fat sphere, same idea as MeleeAttack const DRAG_FULL_PX := 600.0 ## pixels of horizontal drag == one full disc pull const THROW_FORCE := 6.5 ## impulse handed to a chucked disc +const CARRY_MAX_MASS := 6.0 ## heavier than this and you have to swing at it instead +const CARRY_REACH := 2.4 +const CARRY_RADIUS := 0.60 +const THROW_SPEED := 11.0 ## m/s, comfortably past every brittle threshold +const THROW_SPIN := 7.0 + var _cam: Camera3D var _hold_point: Node3D var _held: Record = null +var _carried: RigidBody3D = null ## a loose prop in the off hand +var _carry_layer := 0 +var _carry_mask := 0 +var _body: Node3D = null ## the player, for inheriting throw velocity # extract meter UI var _meter_root: Control var _meter_fill: ColorRect -func setup(cam: Camera3D) -> void: +func setup(cam: Camera3D, body: Node3D = null) -> void: _cam = cam + _body = body _hold_point = Node3D.new() _hold_point.name = "HoldPoint" _cam.add_child(_hold_point) @@ -69,6 +85,78 @@ func try_grab() -> void: if best != null: best.grab(_hold_point) _held = best + return + _try_carry() + +# ---------------------------------------------------------------- carry / throw +## No record under the crosshair, so pick up whatever loose thing is there instead. +## Anything you could actually lift: apples, bottles, staplers, folders. +func _try_carry() -> void: + if _carried != null or _cam == null: + return + var space := _cam.get_world_3d().direct_space_state + if space == null: + return + var origin := _cam.global_position + var dir := -_cam.global_transform.basis.z + + var sphere := SphereShape3D.new() + sphere.radius = CARRY_RADIUS + var params := PhysicsShapeQueryParameters3D.new() + params.shape = sphere + params.transform = Transform3D(Basis.IDENTITY, origin + dir * (CARRY_REACH * 0.5)) + params.collide_with_areas = false + + var best: RigidBody3D = null + var best_d := INF + for h in space.intersect_shape(params, 16): + var col = h.get("collider") + if col is RigidBody3D and not (col is Record): + var rb := col as RigidBody3D + if rb.freeze or rb.mass > CARRY_MAX_MASS: + continue + var d: float = (rb.global_position - origin).length_squared() + if d < best_d: + best_d = d + best = rb + if best == null: + return + _carried = best + _carry_layer = best.collision_layer + _carry_mask = best.collision_mask + # it rides in the hand, so it must stop shoving the player and the shelf it came off + best.collision_layer = 0 + best.collision_mask = 0 + best.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC + best.freeze = true + best.sleeping = false + +## Hand the prop back to physics wherever it currently is. +func _release_carried(throw_it: bool) -> void: + if _carried == null: + return + var rb := _carried + _carried = null + if not is_instance_valid(rb): + return + rb.collision_layer = _carry_layer + rb.collision_mask = _carry_mask + rb.freeze = false + rb.sleeping = false + if throw_it and _cam != null: + var dir := -_cam.global_transform.basis.z + var inherit := Vector3.ZERO + if _body is CharacterBody3D: + inherit = (_body as CharacterBody3D).velocity + rb.linear_velocity = dir * THROW_SPEED + inherit + rb.angular_velocity = Vector3( + randf_range(-1.0, 1.0), randf_range(-1.0, 1.0), randf_range(-1.0, 1.0) + ).normalized() * THROW_SPIN + else: + rb.linear_velocity = Vector3.ZERO + +func is_carrying() -> bool: + return _carried != null and is_instance_valid(_carried) # ---------------------------------------------------------------- extract ## Feed horizontal mouse motion into the held record's disc pull. @@ -81,6 +169,9 @@ func feed_drag(dx: float) -> void: ## Two-stage: throw the freed disc, then a later click trashes the empty sleeve. ## Returns true if the click was consumed (so Player doesn't ALSO punch). func primary() -> bool: + if is_carrying(): + _release_carried(true) + return true if _held == null: return false if _held.has_disc_in_hand(): @@ -93,6 +184,9 @@ func primary() -> bool: # ---------------------------------------------------------------- drop (G) func drop() -> void: + if is_carrying(): + _release_carried(false) + return if _held == null: return _held.drop() @@ -100,7 +194,7 @@ func drop() -> void: # ---------------------------------------------------------------- queries func is_holding() -> bool: - return _held != null + return _held != null or is_carrying() ## While pulling a disc, Player routes horizontal mouse to feed_drag() instead of yaw. func is_extracting() -> bool: @@ -138,6 +232,18 @@ func _build_meter() -> void: _meter_root.add_child(lbl) func _process(_dt: float) -> void: + # a carried prop is glued to the hand point every frame; it is kinematic-frozen so + # this is a move, not a teleport, and it still sweeps other bodies out of the way + if _carried != null: + if not is_instance_valid(_carried): + _carried = null + elif _hold_point != null: + var t := _hold_point.global_transform + # down and to the right of the crosshair: you are carrying it, not + # presenting it, and you still have to be able to see where you're going + _carried.global_position = t.origin - t.basis.z * 0.20 \ + - t.basis.y * 0.13 + t.basis.x * 0.15 + if _meter_root == null: return var pulling := is_extracting() diff --git a/game/scripts/Hud.gd b/game/scripts/Hud.gd index 88f70cf..2c61013 100644 --- a/game/scripts/Hud.gd +++ b/game/scripts/Hud.gd @@ -425,6 +425,8 @@ func feed_work(task: Dictionary, prompt_text: String, payslip: PackedStringArray _work_grid.text = _staples_art(task) "guillotine": _work_grid.text = _guillotine_art(task) + "bags": + _work_grid.text = _bags_art(task) _: if bool(task.get("jam", false)): var trays: Array = task.get("trays", []) @@ -458,6 +460,35 @@ func _staples_art(task: Dictionary) -> String: out.append(" tearing: " + bar) return "\n".join(out) +## The bag. Both ends are drawn IDENTICALLY and the art never hints which one opens — +## because in a real shop it doesn't either. All it tells you is which end you're +## currently working, so that when you finally turn it over you know you turned it over. +func _bags_art(task: Dictionary) -> String: + if int(task.get("stage", 0)) == 0: + return "\n .-------------------------.\n" \ + + " ( ((((((((( o )))))))))) )\n" \ + + " '-------------------------'\n" \ + + " | |\n" \ + + " ___|_|___\n" \ + + " | torn |\n" \ + + " '---------'\n" + var e := int(task.get("end", 0)) + var g := float(task.get("grip", 0.0)) + const L := 5 # left edge column of the bag + const W := 30 # bag width in characters + var pointer := " ".repeat(L + (1 if e == 0 else W - 2)) + "V" + var wall := " ".repeat(L) + "#".repeat(W) + var mid := " ".repeat(L) + "#" + " ".repeat(W - 2) + "#" + var bar := "" + var n := int(round(g * 26.0)) + for i in 26: + bar += "#" if i < n else "." + return "\n".join([ + pointer, wall, mid, mid, wall, "", + " ".repeat(L) + "holding the %s end" % ("LEFT" if e == 0 else "RIGHT"), + " ".repeat(L) + "grip [" + bar + "]", + ]) + ## A rule, a mark, and the sheet. The blade does NOT land where the sheet sits — the ## guide has a permanent offset — and nothing on screen tells you that. func _guillotine_art(task: Dictionary) -> String: diff --git a/game/scripts/Levels.gd b/game/scripts/Levels.gd index e2c11e9..d3ea228 100644 --- a/game/scripts/Levels.gd +++ b/game/scripts/Levels.gd @@ -135,10 +135,14 @@ static func scranton() -> Dictionary: {"glb": "water-cooler", "kind": "steel", "at": Vector2(-6.2, 3.4)}, {"glb": "water-cooler", "kind": "steel", "at": Vector2(5.9, -0.6)}, {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-2.3, 5.2)}, + # an archive box is 1.35 m across, so a "couple of boxes dumped together" is a + # PILE, not two of them side by side inside each other {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(1.6, 5.4)}, - {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(2.1, 4.7)}, + {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(1.74, 5.26), + "sit_on": 0.40, "yaw": 0.10}, {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(6.4, -3.3)}, - {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(7.3, -3.8)}, + {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(6.52, -3.44), + "sit_on": 0.40, "yaw": -0.08}, {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-9.7, 1.2)}, {"glb": "wastebin", "kind": "plastic", "at": Vector2(-6.0, -1.0)}, {"glb": "wastebin", "kind": "plastic", "at": Vector2(1.0, 1.0)}, @@ -147,7 +151,7 @@ static func scranton() -> Dictionary: {"glb": "rack", "kind": "wood", "at": Vector2(9.4, -4.3), "frozen": true}, {"glb": "turntable", "kind": "wood", "at": Vector2(6.9, -5.9)}, {"glb": "guillotine", "kind": "steel", "at": Vector2(4.5, 1.2)}, - {"glb": "stapler", "kind": "steel", "at": Vector2(-4.6, -2.9), "sit_on": 0.75}, + {"glb": "stapler", "kind": "steel", "at": Vector2(-5.25, -2.78), "sit_on": 0.75}, ], "records": [Vector2(6.2, -6.6), Vector2(7.0, -6.9), Vector2(7.8, -6.6)], "gauntlet": {"at": Vector3(0.0, 0.0, -3.9), "yaw": PI, @@ -234,18 +238,20 @@ static func pawnee() -> Dictionary: ], "copier": {"at": Vector3(6.4, 0.0, 2.4), "yaw": PI}, "smashables": [ + # a cabinet is 0.80 m wide; a row of them 0.70 m apart is one cabinet {"glb": "filing-cabinet", "kind": "steel", "at": Vector2(5.4, -3.2)}, - {"glb": "filing-cabinet", "kind": "steel", "at": Vector2(6.1, -3.2)}, - {"glb": "filing-cabinet", "kind": "steel", "at": Vector2(6.8, -3.2)}, - {"glb": "filing-cabinet", "kind": "steel", "at": Vector2(7.5, -3.2)}, + {"glb": "filing-cabinet", "kind": "steel", "at": Vector2(6.26, -3.2)}, + {"glb": "filing-cabinet", "kind": "steel", "at": Vector2(7.12, -3.2)}, + {"glb": "filing-cabinet", "kind": "steel", "at": Vector2(7.98, -3.2)}, {"glb": "water-cooler", "kind": "steel", "at": Vector2(3.9, 4.2)}, {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(8.6, -5.4)}, - {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(9.2, -4.8)}, + {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(8.74, -5.26), + "sit_on": 0.40, "yaw": 0.12}, {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-4.8, 3.0)}, {"glb": "wastebin", "kind": "plastic", "at": Vector2(-8.8, -3.9)}, {"glb": "wastebin", "kind": "plastic", "at": Vector2(4.4, 0.9)}, {"glb": "guillotine", "kind": "steel", "at": Vector2(6.4, 3.6), "sit_on": 0.0}, - {"glb": "desk-phone", "kind": "plastic", "at": Vector2(-7.4, -5.4), "sit_on": 0.78}, + {"glb": "desk-phone", "kind": "plastic", "at": Vector2(-6.6, -5.4), "sit_on": 0.78}, {"glb": "crt-monitor", "kind": "glass", "at": Vector2(-7.6, -5.2), "sit_on": 0.78}, ], "records": [], @@ -331,7 +337,8 @@ static func house() -> Dictionary: "smashables": [ {"glb": "crt-monitor", "kind": "glass", "at": Vector2(-4.4, -2.2)}, {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(8.4, 2.2)}, - {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(8.9, 1.5)}, + {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(8.54, 2.06), + "sit_on": 0.40, "yaw": -0.14}, {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-8.2, 6.4)}, {"glb": "wastebin", "kind": "plastic", "at": Vector2(-2.6, -3.2)}, {"glb": "wastebin", "kind": "plastic", "at": Vector2(6.2, 3.0)}, @@ -421,12 +428,17 @@ static func basement() -> Dictionary: {"glb": "crt-monitor", "kind": "glass", "at": Vector2(-8.4, -3.2)}, {"glb": "filing-cabinet", "kind": "steel", "at": Vector2(10.8, 3.0)}, {"glb": "filing-cabinet", "kind": "steel", "at": Vector2(10.8, 2.2)}, + # nobody in an IT basement puts a box down on its own {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-2.6, -4.8)}, - {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-2.0, -5.3)}, - {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-1.4, -4.6)}, + {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-2.46, -4.92), + "sit_on": 0.40, "yaw": 0.09}, + {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(-2.58, -4.70), + "sit_on": 0.80, "yaw": -0.16}, {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(5.6, -4.8)}, - {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(6.3, -5.2)}, - {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(7.0, -4.6)}, + {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(5.72, -4.94), + "sit_on": 0.40, "yaw": -0.11}, + {"glb": "cardboard-box", "kind": "cardboard", "at": Vector2(5.58, -4.68), + "sit_on": 0.80, "yaw": 0.20}, {"glb": "wastebin", "kind": "plastic", "at": Vector2(2.0, 2.6)}, {"glb": "wastebin", "kind": "plastic", "at": Vector2(9.0, 4.4)}, {"glb": "water-cooler", "kind": "steel", "at": Vector2(0.6, 2.2)}, @@ -519,12 +531,27 @@ static func backrooms() -> Dictionary: ["water-cooler", "steel"], ["office-plant", "wood"]] # dense enough that a single cloud finds you two or three things — the level is # a search, and a search with nothing in it is just a walk - for i in 78: + # Rejection sampling: the widest thing on this list is a 1.35 m cardboard box, so + # anything landing inside 1.6 m of a spot already taken gets redrawn. Two props + # spawned inside each other are a filing cabinet launched across the maze on frame + # one, which in a level built on silence is not the surprise you wanted. + var taken: Array = [] + var tries := 0 + while smash.size() < 78 and tries < 4000: + tries += 1 + var spot := Vector2(rng.randf_range(-BR_HALF + 2.5, BR_HALF - 2.5), + rng.randf_range(-BR_HALF + 2.5, BR_HALF - 2.5)) + var clear := true + for t in taken: + if (spot - (t as Vector2)).length() < 1.6: + clear = false + break + if not clear: + continue + taken.append(spot) var k: Array = kinds[rng.randi() % kinds.size()] smash.append({ - "glb": String(k[0]), "kind": String(k[1]), - "at": Vector2(rng.randf_range(-BR_HALF + 2.5, BR_HALF - 2.5), - rng.randf_range(-BR_HALF + 2.5, BR_HALF - 2.5)), + "glb": String(k[0]), "kind": String(k[1]), "at": spot, "yaw": rng.randf_range(0.0, TAU), }) @@ -668,6 +695,19 @@ static func grocer() -> Dictionary: "props": [ {"glb": "office-plant", "at": Vector3(-10.2, 0.0, 7.0)}, {"glb": "office-plant", "at": Vector3(9.6, 0.0, 7.0)}, + # the bag roll. Its post stands on the floor; the station is the roll itself. + {"glb": "bag-roll", "at": Vector3(-0.3, 0.0, 3.6), "yaw": PI}, + ], + # Brass shop scales hung down the aisles, dial at eye level and the dish at chest + # height — where a real one hangs, where you look INTO the pan rather than at its + # unlit underside, and where you keep walking into it. + "scales": [ + Vector3(-3.7, 1.58, -0.4), Vector3(-0.3, 1.58, -0.4), + # the counter one hangs higher, so you can still see the bottle shelf + Vector3(3.1, 1.58, -0.4), Vector3(-3.0, 1.88, -6.5), + ], + "stations": [ + {"kind": "bags", "at": Vector3(-0.3, 1.1, 3.6), "label": "bag up the order"}, ], "slabs": slabs, "smashables": smash, diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index a07a216..a7c76ff 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -122,6 +122,14 @@ func _register_stations() -> void: if _plan.has_copier(): _tasks.register(Tasks.Kind.COPIER, _plan.copier_spot().origin, "use the photocopier") + # anything the site declared outright, by name + const KINDS := {"bags": Tasks.Kind.BAGS, "copier": Tasks.Kind.COPIER, + "spreadsheet": Tasks.Kind.SPREADSHEET, "staples": Tasks.Kind.STAPLES, + "guillotine": Tasks.Kind.GUILLOTINE} + for st in _plan.spec.get("stations", []): + var sd: Dictionary = st + _tasks.register(int(KINDS.get(String(sd["kind"]), Tasks.Kind.SPREADSHEET)), + sd["at"], String(sd.get("label", "work"))) # a station wherever the site put a guillotine for e in _plan.smashables(): var d: Dictionary = e @@ -252,6 +260,10 @@ func _populate() -> void: for st in _plan.spec.get("stacks", []): _build_stack(st) + # --- hanging scales --- + for sc in _plan.spec.get("scales", []): + _build_scale(sc) + # --- 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, @@ -284,8 +296,13 @@ func _place_record(xz: Vector2, sit_on: float) -> Record: ## 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. +## one out of the bottom row does exactly what you'd hope. +## +## The lattice is SPHERE geometry, not box geometry. Each fruit sits in the dimple +## between four below it, so for a row spacing of `step` the rise to the next row is +## sqrt(4r² - step²/2) — the height at which it touches all four. Guess that number and +## every row above the first spawns in mid-air, drops, and rolls the whole display onto +## the floor before you've got through the door. func _build_stack(st: Dictionary) -> void: var at: Vector3 = st["at"] var item := String(st["item"]) @@ -293,12 +310,21 @@ func _build_stack(st: Dictionary) -> void: 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 + # Radius MEASURED off the asset, not typed into a table. The table said a cabbage was + # 84 mm; the model is 213 mm, so the cabbage pyramid was built with every row driven + # a third of the way into the row below it. + var r: float = _glb_radius(glb) + # a hair tighter than touching, so a row is CARRIED by the four under it rather than + # balanced on them across a gap it can fall through + var step: float = r * 1.98 + var lift: float = sqrt(maxf(4.0 * r * r - step * step * 0.5, 0.04 * r * r)) + # The tray. A pyramid of spheres on a bare flat table cannot stand: nothing holds the + # bottom row in, so the weight above wedges it outward and the display walks itself + # apart in about a second. Every greengrocer on earth already solved this — the fruit + # sits in a shallow tray. Sized to the base row here rather than in the level spec, + # because only this function knows how big the fruit turned out to be. + var base: float = (float(rows) - 1.0) * step * 0.5 + r + _stack_tray(at, base + r * 0.30, r * 1.25) for row in range(rows): var n := rows - row var y: float = at.y + r + row * lift @@ -312,6 +338,111 @@ func _build_stack(st: Dictionary) -> void: 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) + # a sphere has no rolling resistance at all, so without this a pyramid + # creeps apart on its own micro-jitter. It still rolls when you hit it — + # it just doesn't wander off while you're looking somewhere else. + body.angular_damp = 2.2 + +## A shop scale: a dial bolted to a rod, and a brass pan swinging under it on a pin. +## +## The pan is a real RigidBody3D on a real PinJoint3D, not an animation. Walk into it and +## it swings. Hit it with the bat and it swings hard enough to wrap, and comes back, and +## the chains are the loudest thing in the shop. It is "steel", so it never breaks — the +## futile clank is the point. What you get instead is a heavy brass weight loose in a room +## full of stacked fruit. +const SCALE_DROP := 0.165 ## head origin -> the eye the pan hangs from + +func _build_scale(at: Vector3) -> void: + var head_glb := P + "scale-head.glb" + var pan_glb := P + "scale-pan.glb" + if not ResourceLoader.exists(head_glb) or not ResourceLoader.exists(pan_glb): + return + + # --- the fixed half: dial + rod, plus a drop rod up to the ceiling --- + var head := StaticBody3D.new() + head.name = "scale-head" + head.add_child((load(head_glb) as PackedScene).instantiate()) + _world.add_child(head) + head.global_position = at + + var ceil_h: float = float(_plan.spec.get("bounds", {}).get("h", 3.0)) + var rod_from := at.y + 0.36 # where the head's own rod ends + if ceil_h > rod_from + 0.02: + var rod := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = Vector3(0.018, ceil_h - rod_from, 0.018) + rod.mesh = bm + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(0.34, 0.34, 0.36) + mat.metallic = 0.8 + mat.roughness = 0.42 + rod.material_override = mat + _world.add_child(rod) + rod.global_position = Vector3(at.x, (rod_from + ceil_h) * 0.5, at.z) + + # --- the swinging half. `centred` because the pan is authored origin-at-pivot. --- + var pivot := Vector3(at.x, at.y - SCALE_DROP, at.z) + var res := _glb_piece(pan_glb, "steel", Vector2(at.x, at.z), false, pivot.y, 0.0, true) + var pan := res["piece"] as Smashable + pan.name = "scale-pan" + pan.mass = 1.4 # brass dish and three chains + pan.angular_damp = 0.25 # keeps swinging long after you hit it + pan.set_meta("spawn_pos", pan.global_position) + + var joint := PinJoint3D.new() + _world.add_child(joint) + joint.global_position = pivot + joint.node_a = joint.get_path_to(head) + joint.node_b = joint.get_path_to(pan) + +## Four low timber walls on the table top, framing one pyramid. Static — you can't smash +## the tray, you smash what's in it, and what's in it goes over the edge. +func _stack_tray(at: Vector3, half: float, h: float) -> void: + const T := 0.022 + var col := Color(0.52, 0.36, 0.20) + var pal: Dictionary = _plan.spec.get("palette", {}) + if pal.has("wood"): + col = pal["wood"] + var mat := StandardMaterial3D.new() + mat.albedo_color = col + mat.roughness = 0.86 + for s in [Vector3(1, 0, 0), Vector3(-1, 0, 0), Vector3(0, 0, 1), Vector3(0, 0, -1)]: + var along_x: bool = absf(s.x) > 0.5 + var size := Vector3(T, h, half * 2.0 + T * 2.0) if along_x \ + else Vector3(half * 2.0 + T * 2.0, h, T) + var body := StaticBody3D.new() + body.name = "stack-tray" + var shape := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = size + shape.shape = box + body.add_child(shape) + var mi := MeshInstance3D.new() + var bm := BoxMesh.new() + bm.size = size + mi.mesh = bm + mi.material_override = mat + body.add_child(mi) + _world.add_child(body) + body.global_position = at + Vector3(s.x * (half + T * 0.5), h * 0.5, + s.z * (half + T * 0.5)) + +## Half the widest extent of a GLB, in metres. Instantiates once per asset and caches, +## so stack spacing always matches whatever the generator last exported. +var _radius_cache := {} + +func _glb_radius(path: String) -> float: + if _radius_cache.has(path): + return float(_radius_cache[path]) + var probe: Node3D = (load(path) as PackedScene).instantiate() + _world.add_child(probe) + var ab := _body_local_aabb(probe, probe) + probe.queue_free() + var r: float = maxf(ab.size.x, maxf(ab.size.y, ab.size.z)) * 0.5 + if r <= 0.0: + r = 0.04 + _radius_cache[path] = r + return r ## 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 @@ -339,9 +470,21 @@ func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := if ab.size == Vector3.ZERO: ab = AABB(Vector3(-0.2, 0.0, -0.2), Vector3(0.4, 0.4, 0.4)) # fallback var col := CollisionShape3D.new() - var box := BoxShape3D.new() - box.size = ab.size - col.shape = box + # Round produce gets a SPHERE. A box collider on an apple is why a pyramid needed + # hand-tuned lattice spacing to nest, and why nothing ever rolled: the corners hit + # first. Anything whose three extents agree to within a third is treated as round; + # a banana is not, and keeps its box. + var ex := ab.size + var big: float = maxf(ex.x, maxf(ex.y, ex.z)) + var small: float = minf(ex.x, minf(ex.y, ex.z)) + if kind == "produce" and big > 0.0 and small / big > 0.66: + var sph := SphereShape3D.new() + sph.radius = big * 0.5 + col.shape = sph + else: + var box := BoxShape3D.new() + box.size = ex + col.shape = box col.position = ab.get_center() s.add_child(col) s.rotation.y = yaw diff --git a/game/scripts/Player.gd b/game/scripts/Player.gd index 1b65ddd..13a89ad 100644 --- a/game/scripts/Player.gd +++ b/game/scripts/Player.gd @@ -103,7 +103,7 @@ func _ready() -> void: # the record ritual hands: a child node that owns a hold point under the camera grab = GrabController.new() add_child(grab) - grab.setup(camera) + grab.setup(camera, self) Input.mouse_mode = Input.MOUSE_MODE_CAPTURED weapon_changed.emit(_loadout[_slot]) diff --git a/game/scripts/RageOverlay.gd b/game/scripts/RageOverlay.gd index 36f34eb..06aaecf 100644 --- a/game/scripts/RageOverlay.gd +++ b/game/scripts/RageOverlay.gd @@ -5,8 +5,10 @@ class_name RageOverlay ## ## Three layers, all driven by the same Rage instance: ## 1. a red vignette that closes in as the meter fills, pulsing on the heartbeat -## 2. actual VEINS — branching polylines creeping in from the corners, thickening -## with each thump. Generated once from a fixed seed so they don't crawl. +## 2. actual VEINS — a branching tree rooted at the screen edges that grows INWARD as +## the meter fills: thick dark trunks first, fine capillaries only when you're +## really going, and a branch never appears before the trunk carrying it. Generated +## once from a fixed seed so they don't crawl. ## 3. a synthesized lub-dub heartbeat whose rate IS the meter (Rage.bpm) ## ## No textures and no shader files: the vignette is a small ShaderMaterial built in code @@ -41,7 +43,7 @@ var _veins: Control var _heart: AudioStreamPlayer var _beat_wav: AudioStreamWAV var _last_pulse := 0.0 -var _paths: Array = [] # each entry: PackedVector2Array in 0..1 screen space +var _paths: Array = [] # {pts: 0..1 screen space, depth, t0, span} func setup(r: Rage) -> void: rage = r @@ -85,11 +87,15 @@ func _build_veins() -> void: [Vector2(0.28, 1.0), Vector2(0.3, -1.0)], [Vector2(0.72, 1.0), Vector2(-0.3, -1.0)], ] for r in roots: - _grow(r[0], (r[1] as Vector2).normalized(), 0.30, 5, rng) + _grow(r[0], (r[1] as Vector2).normalized(), 0.30, 5, rng, 0.0, 0.34) +## One segment plus its children. `t0`/`span` are WHERE ALONG THE REVEAL this segment +## lives: a branch cannot appear before the trunk that carries it has finished growing, +## which is the difference between veins creeping in from the edge and a screen of +## disconnected scratches. func _grow(from: Vector2, dir: Vector2, length: float, depth: int, - rng: RandomNumberGenerator) -> void: - if depth <= 0 or length < 0.02: + rng: RandomNumberGenerator, t0: float, span: float) -> void: + if depth <= 0 or length < 0.02 or t0 >= 1.0: return var pts := PackedVector2Array() pts.append(from) @@ -97,15 +103,18 @@ func _grow(from: Vector2, dir: Vector2, length: float, depth: int, var d := dir var steps := 4 for i in steps: - d = d.rotated(rng.randf_range(-0.42, 0.42)).normalized() + # wander, but keep being pulled the way the vein set off: a vessel meanders, + # it doesn't change its mind + d = d.rotated(rng.randf_range(-0.30, 0.30)).lerp(dir, 0.25).normalized() p += d * (length / float(steps)) pts.append(p) - _paths.append(pts) - # branch + _paths.append({"pts": pts, "depth": depth, "t0": t0, "span": span}) if rng.randf() < 0.85: - _grow(p, d.rotated(rng.randf_range(0.35, 0.95)), length * 0.62, depth - 1, rng) + _grow(p, d.rotated(rng.randf_range(0.35, 0.95)), length * 0.62, depth - 1, rng, + t0 + span, span * 0.66) if rng.randf() < 0.7: - _grow(p, d.rotated(rng.randf_range(-0.95, -0.35)), length * 0.58, depth - 1, rng) + _grow(p, d.rotated(rng.randf_range(-0.95, -0.35)), length * 0.58, depth - 1, rng, + t0 + span, span * 0.66) func _draw_veins() -> void: if rage == null: @@ -118,19 +127,27 @@ func _draw_veins() -> void: var a := clampf((r - 0.10) * 1.25, 0.0, 1.0) if rage.is_meltdown(): a = 1.0 - var col := Color(0.62, 0.04, 0.05, a * (0.30 + 0.45 * pulse)) - var w := (1.4 + 4.2 * r) * (0.80 + 0.55 * pulse) - # only the fraction of each vein proportional to rage is drawn, so they visibly - # creep further into the frame the angrier you get - var reveal := clampf(0.25 + r * 0.85, 0.0, 1.0) - for path in _paths: - var pts: PackedVector2Array = path - var n := int(ceil(pts.size() * reveal)) + var base_w := (1.1 + 5.0 * r) * (0.80 + 0.55 * pulse) + # how far the growth has got. Trunks first; the fine stuff only at real rage. + var reveal := clampf(0.18 + r * 1.05, 0.0, 1.0) + for e in _paths: + var path: Dictionary = e + var t0: float = path["t0"] + if reveal <= t0: + continue # its parent hasn't got here yet + var pts: PackedVector2Array = path["pts"] + var f := clampf((reveal - t0) / maxf(float(path["span"]), 0.001), 0.0, 1.0) + var n := int(ceil(pts.size() * f)) if n < 2: continue var scaled := PackedVector2Array() for i in n: scaled.append(Vector2(pts[i].x * size.x, pts[i].y * size.y)) + # thick dark trunks, fine pale capillaries — a flat width for both is what made + # them read as biro scribble instead of a vessel + var deep := float(path["depth"]) / 5.0 + var w := base_w * (0.22 + 0.78 * deep) + var col := Color(0.62, 0.04, 0.05, a * (0.22 + 0.40 * pulse) * (0.45 + 0.55 * deep)) _veins.draw_polyline(scaled, col, w, true) # ---------------------------------------------------------------- heartbeat diff --git a/game/scripts/Smashable.gd b/game/scripts/Smashable.gd index a802728..8139c43 100644 --- a/game/scripts/Smashable.gd +++ b/game/scripts/Smashable.gd @@ -113,6 +113,11 @@ func smash(impulse: Vector3, damage: float = -1.0) -> void: apply_central_impulse(impulse.limit_length(6.0) * 0.25) damaged.emit(kind, global_position, clampf(_hp / _hp_max, 0.0, 1.0)) +## Has this already come apart? Freed-but-not-yet-collected pieces linger in the +## `smashable` group for a frame or two, so anything iterating the group needs this. +func is_broken() -> bool: + return _shattered + ## Remaining hp as 0..1, for HUD damage reads. func hp_frac() -> float: return clampf(_hp / maxf(_hp_max, 0.001), 0.0, 1.0) diff --git a/game/scripts/Tasks.gd b/game/scripts/Tasks.gd index a02b0a0..9a85368 100644 --- a/game/scripts/Tasks.gd +++ b/game/scripts/Tasks.gd @@ -18,13 +18,17 @@ class_name Tasks ## Take too long on one and the sheet tears and you redo the bundle. ## GUILLOTINE .. align the sheet to a mark and cut. The guide reads 2 mm off, always ## has, and everyone who works here knows and nobody has fixed it. +## BAGS ........ tear a produce bag off the roll, then open it. The bag has TWO ends +## and only one of them opens. Nothing tells you which. You find out +## you picked wrong because the meter isn't moving. ## ## Completing work pays WAGES and soothes the meter a little — never quite enough. ## ## Godot 4.7 GDScript 2.0. -enum Kind { SPREADSHEET, COPIER, STAPLES, GUILLOTINE } -enum Phase { IDLE, SPREADSHEET, COPIER_RUN, COPIER_JAM, STAPLES, GUILLOTINE, DONE } +enum Kind { SPREADSHEET, COPIER, STAPLES, GUILLOTINE, BAGS } +enum Phase { IDLE, SPREADSHEET, COPIER_RUN, COPIER_JAM, STAPLES, GUILLOTINE, + BAGS, DONE } const REACH := 2.1 ## metres you must be within to work at a station const COLS := ["A", "B", "C", "D", "E", "F"] @@ -62,6 +66,15 @@ var _staple_dir := 0 # which arrow the current staple wants var _staple_armed := false # SPACE lifts it; then the arrow pulls it out var _staple_clock := 0.0 +# --- bag state +var _bag_stage := 0 # 0 = tear one off the roll, 1 = get it open +var _bag_end := 0 # which end you're currently working +var _bag_open_end := 0 # the one that opens. Never displayed. +var _grip := 0.0 +var _last_rub := 0 # rubbing needs alternating keys, like real life +var _futile := 0.0 # seconds spent on the sealed end +var _hinted := false + # --- guillotine state var _cut_target := 0.0 # where the sheet should sit, 0..1 var _cut_at := 0.5 # where it actually is @@ -147,6 +160,8 @@ func try_start() -> bool: _begin_staples() Kind.GUILLOTINE: _begin_guillotine() + Kind.BAGS: + _begin_bags() return true func abandon() -> void: @@ -358,6 +373,61 @@ func _key_guillotine(k: int) -> void: _fumble("crooked. that's the whole ream.", 0.11) _next_cut() +# ---------------------------------------------------------------- bags +## The roll of produce bags. +## +## Stage one is fine: tear one off. Stage two is the whole joke — the bag is welded shut +## by static and has two ends, only one of which opens, and NOTHING indicates which. You +## rub, and either the meter climbs or it doesn't, and the only way to learn is to have +## already wasted several seconds on the wrong end. +## +## Rubbing also has to ALTERNATE (left, right, left) because mashing one key isn't +## rubbing. After a while on the sealed end it takes pity and tells you, but only after +## the provoke has landed. +const RUB_PER_STROKE := 0.075 +const BAG_HINT_AFTER := 4.5 + +func _begin_bags() -> void: + phase = Phase.BAGS + _bag_stage = 0 + _bag_end = 0 + _bag_open_end = randi() % 2 + _grip = 0.0 + _last_rub = 0 + _futile = 0.0 + _hinted = false + +func _tick_bags(dt: float) -> void: + if _bag_stage != 1 or _bag_end == _bag_open_end: + return + _futile += dt + if _futile > BAG_HINT_AFTER and not _hinted: + _hinted = true + _fumble("that is the sealed end", 0.13) + +func _key_bags(k: int) -> void: + if _bag_stage == 0: + if k == KEY_SPACE or k == KEY_ENTER or k == KEY_KP_ENTER: + # tearing one off is the easy half; it always works, and that's the setup + _bag_stage = 1 + _grip = 0.0 + _futile = 0.0 + return + match k: + KEY_SPACE: + # turn it over. This is the answer, and the game never says so. + _bag_end = 1 - _bag_end + _futile = 0.0 + KEY_LEFT, KEY_RIGHT: + if k == _last_rub: + return # mashing one key is not rubbing + _last_rub = k + if _bag_end != _bag_open_end: + return # nothing happens. That is the entire mechanic. + _grip += RUB_PER_STROKE + if _grip >= 1.0: + _finish() + # ---------------------------------------------------------------- input ## Player forwards keys here while a task is open. Returns true if consumed. func handle_key(k: int) -> bool: @@ -375,6 +445,8 @@ func handle_key(k: int) -> bool: _key_staples(k) Phase.GUILLOTINE: _key_guillotine(k) + Phase.BAGS: + _key_bags(k) return true # ---------------------------------------------------------------- HUD feed @@ -419,6 +491,20 @@ func panel_state() -> Dictionary: "urgency": clampf(1.0 - _staple_clock / STAPLE_PATIENCE, 0.0, 1.0), "footer": "SPACE lift · arrow pull · E leave", } + Phase.BAGS: + if _bag_stage == 0: + return { + "kind": "bags", "title": "PRODUCE BAGS", + "instruction": "tear one off the roll", + "stage": 0, + "footer": "SPACE tear · E leave", + } + return { + "kind": "bags", "title": "PRODUCE BAGS", + "instruction": "open the bag", + "stage": 1, "grip": _grip, "end": _bag_end, + "footer": "← → rub (alternate) · SPACE turn it over · E leave", + } Phase.GUILLOTINE: return { "kind": "guillotine", "title": "ROTATRIM A3", diff --git a/tools/gen_produce.py b/tools/gen_produce.py index 385e26d..0ead91b 100644 --- a/tools/gen_produce.py +++ b/tools/gen_produce.py @@ -245,7 +245,83 @@ def p_crate(M): return parts +def p_scale_head(M): + """The dial and hanger of a shop scale. STATIC — the pan hangs off it on a joint.""" + parts = [] + parts.append(assign(cyl(0.010, 0.36, loc=(0, 0, 0.18), verts=8), M["steel"])) + hook = cyl(0.013, 0.070, loc=(0, 0, 0.02), verts=8) + parts.append(assign(hook, M["steel"])) + # the dial: a fat disc facing -Y, which is the direction shoppers stand in + body = cyl(0.135, 0.075, loc=(0, 0, -0.085), rot=(math.radians(90), 0, 0), verts=26) + parts.append(assign(body, M["brass"])) + face = cyl(0.120, 0.012, loc=(0, -0.042, -0.085), rot=(math.radians(90), 0, 0), + verts=26) + parts.append(assign(face, M["dial"])) + for i in range(12): + a = i * (math.tau / 12.0) + tick = box((0.006, 0.006, 0.022), + loc=(math.cos(a) * 0.098, -0.049, -0.085 + math.sin(a) * 0.098), + rot=(0, a, 0)) + parts.append(assign(tick, M["steel"])) + needle = box((0.005, 0.008, 0.090), loc=(0.012, -0.050, -0.050), + rot=(0, 0, math.radians(18))) + parts.append(assign(needle, M["needle"])) + parts.append(assign(cyl(0.014, 0.014, loc=(0, -0.050, -0.085), + rot=(math.radians(90), 0, 0), verts=12), M["steel"])) + # the eye the pan hangs from + parts.append(assign(cyl(0.011, 0.045, loc=(0, 0, -0.165), verts=8), M["steel"])) + return parts + + +def p_scale_pan(M): + """The swinging half: three chains and a brass pan. Origin at the TOP, on the pivot, + so a PinJoint3D at the origin hangs it correctly.""" + parts = [] + ring = cyl(0.016, 0.010, loc=(0, 0, -0.006), verts=12) + parts.append(assign(ring, M["steel"])) + for i in range(3): + a = i * (math.tau / 3.0) + top = Vector((0, 0, -0.010)) + bot = Vector((math.cos(a) * 0.115, math.sin(a) * 0.115, -0.230)) + parts.append(assign(span(0.005, 0.005, top, bot, verts=6), M["steel"])) + pan = cyl(0.155, 0.030, loc=(0, 0, -0.246), r2=0.185, verts=28) + parts.append(assign(pan, M["brass"])) + lip = cyl(0.186, 0.016, loc=(0, 0, -0.262), verts=28) + parts.append(assign(lip, M["brass"])) + return parts + + +def p_bag_roll(M): + """The roll of produce bags. The single most infuriating object in any shop.""" + parts = [] + # A floor stand, at the height a shop actually puts one: the roll wants to be under + # your hands, not your knees. + H = 1.14 + parts.append(assign(cyl(0.140, 0.030, loc=(0, 0, 0.015), verts=18), M["steel"])) + parts.append(assign(box((0.034, 0.034, H), loc=(0, 0, H * 0.5)), M["steel"])) + parts.append(assign(box((0.30, 0.026, 0.026), loc=(0, 0, H)), M["steel"])) + for s in (-1, 1): + parts.append(assign(box((0.026, 0.026, 0.10), loc=(s * 0.14, 0, H - 0.045)), + M["steel"])) + # the roll itself, on the spindle + roll = cyl(0.075, 0.245, loc=(0, 0, H - 0.045), rot=(0, math.radians(90), 0), verts=22) + parts.append(assign(roll, M["bag"])) + core = cyl(0.020, 0.255, loc=(0, 0, H - 0.045), rot=(0, math.radians(90), 0), verts=12) + parts.append(assign(core, M["core"])) + # and one hanging off it, half torn, forever + hang = box((0.20, 0.008, 0.30), loc=(0, 0.055, H - 0.185), + rot=(math.radians(-8), 0, 0)) + parts.append(assign(hang, M["bag"])) + tail = box((0.16, 0.006, 0.10), loc=(0.02, 0.060, H - 0.330), + rot=(math.radians(-16), 0, math.radians(7))) + parts.append(assign(tail, M["bag"])) + return parts + + PROPS = { + "scale-head": p_scale_head, + "scale-pan": p_scale_pan, + "bag-roll": p_bag_roll, "produce-apple": p_apple, "produce-orange": p_orange, "produce-tomato": p_tomato, @@ -275,6 +351,14 @@ def materials(): "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), + "steel": mat("steel", (0.66, 0.67, 0.70), rough=0.30, metal=1.0), + "brass": mat("brass", (0.72, 0.56, 0.22), rough=0.28, metal=1.0), + "dial": mat("dial", (0.94, 0.93, 0.89), rough=0.5), + "needle": mat("needle", (0.68, 0.08, 0.06), rough=0.4), + # tinted: a white bag on a white wall is invisible, and you have to be able to + # find the thing you are about to be defeated by + "bag": mat("bag", (0.62, 0.78, 0.80), rough=0.40, alpha=0.66), + "core": mat("core", (0.62, 0.50, 0.34), rough=0.9), } @@ -284,6 +368,14 @@ def export(objs, name): for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] + # Blender's cone primitive winds its caps inward when radius2 > radius1, which is + # exactly how the scale pan is built — and a backface-culled renderer then shows you + # the unlit inside of the dish as a black hole. Recalculate outward for everything, + # once, here, so no generator has to remember. + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.mesh.normals_make_consistent(inside=False) + bpy.ops.object.mode_set(mode='OBJECT') 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)