LANE8: scales that swing, bags that don't open, and fruit you can throw
The three things the greengrocer was still missing, plus the physics bug that
finding them uncovered.
HANGING SCALES. Real PinJoint3D pendulums, not animations: a static dial and
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 — where a real shop scale hangs and where you keep walking into it.
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; what you get is a heavy brass weight loose
in a room full of stacked fruit.
THE BAG YOU CANNOT OPEN. Tearing one off the roll is the easy half — that's the
setup. Then you have to open it, and the bag has two ends, only one of which
opens, and nothing tells you which. Rubbing alternates arrow keys, because
mashing one key is not rubbing. Either the meter climbs or it doesn't, and 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 whole solution and
the game never says so.
CARRY AND THROW. 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 — no separate
thrown-object code at all.
Then the part that took the longest. Round produce got sphere colliders (a box
on an apple is why nothing ever rolled) and every display in the shop instantly
fell over. Three separate things were wrong:
- the stack radii were TYPED, not measured. The table said a cabbage was
84 mm; the model is 213 mm, so that pyramid was built with every row driven
a third of the way into the row below it. Main._glb_radius() reads the asset.
- the lattice was box geometry. For spheres the rise per row is
sqrt(4r^2 - step^2/2) — the height at which a fruit touches all four
beneath it. Anything else spawns every row above the first in mid-air.
- there was no 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 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.
All 310 bodies asleep within 3 s.
While chasing that, the spawn guard fired on a cardboard box in the OFFICE. The
guard is a net, not a test: it only catches a pair Jolt happens to resolve
violently on the frames it's watching, and this one had been interpenetrating
in four levels for weeks. dev/probe_overlap.gd now finds them by measurement,
comparing every pair of dynamic colliders across all six sites. It found 15,
including a row of filing cabinets 0.70 m apart that are 0.80 m wide, and a
stapler inside a monitor. The box asset is 1.35 m across, so boxes are now
stacked into piles rather than dotted about, and the Backrooms scatter uses
rejection sampling with a 1.6 m minimum. All six sites read CLEAN.
Also: the rage veins were drawing every branch from its own start point
regardless of how far the trunk had grown, so at low rage you got disconnected
fragments floating mid-screen that read as biro scribble rather than blood. A
branch now can't appear before the trunk carrying it, and trunks are thick and
dark where capillaries are fine and pale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3f79eb8677
commit
81c9c4a349
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
BIN
game/assets/store/bag-roll.glb
Normal file
BIN
game/assets/store/bag-roll.glb
Normal file
Binary file not shown.
45
game/assets/store/bag-roll.glb.import
Normal file
45
game/assets/store/bag-roll.glb.import
Normal file
@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
game/assets/store/scale-head.glb
Normal file
BIN
game/assets/store/scale-head.glb
Normal file
Binary file not shown.
45
game/assets/store/scale-head.glb.import
Normal file
45
game/assets/store/scale-head.glb.import
Normal file
@ -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
|
||||
BIN
game/assets/store/scale-pan.glb
Normal file
BIN
game/assets/store/scale-pan.glb
Normal file
Binary file not shown.
45
game/assets/store/scale-pan.glb.import
Normal file
45
game/assets/store/scale-pan.glb.import
Normal file
@ -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
|
||||
@ -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:
|
||||
|
||||
34
game/dev/probe_bags.gd
Normal file
34
game/dev/probe_bags.gd
Normal file
@ -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()
|
||||
1
game/dev/probe_bags.gd.uid
Normal file
1
game/dev/probe_bags.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dhvlnwvmwvnya
|
||||
@ -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:
|
||||
|
||||
76
game/dev/probe_overlap.gd
Normal file
76
game/dev/probe_overlap.gd
Normal file
@ -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()
|
||||
1
game/dev/probe_overlap.gd.uid
Normal file
1
game/dev/probe_overlap.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://kddgnxo4q7el
|
||||
26
game/dev/probe_settle.gd
Normal file
26
game/dev/probe_settle.gd
Normal file
@ -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()
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user