LANE7: LEVEL 0 — the Backrooms, generated walls, and the dust mechanic

"stuff is invisible have to release dust or gas or something to coat items to see them
to destroy them? and you inhale some of it too"

THE SITE (Levels.backrooms)
Mono-yellow, 2.55 m ceiling, damp carpet, a dense grid of buzzing troffers, no windows
and no exit. THE WALLS ARE GENERATED — this is the site that proves the Floorplan spec
can be emitted rather than authored: a seeded RNG lays partial wall runs on a 4.6 m
lattice with random gaps, plus 22 freestanding pillars. It follows the reference photos
rather than a maze algorithm on purpose — the Backrooms aren't corridors, they're big
offset slabs in an open floor, and a proper maze would be legible. Legible is wrong.

THE MECHANIC (Dust.gd) — it argues with itself, which is the whole point.
Everything smashable here is invisible but SOLID; you find things by walking into them.
RMB with the extinguisher throws a cloud of powder. Anything it touches is coated,
glows faintly out of all that yellow for 7 seconds, then settles back into nothing.

And you breathe it. Every cloud puts a little in your lungs, and the haze that builds is
on YOUR side of the glass. So: spray more to see individual objects, and see the room
less. Past 72% you cough — camera jolt, screen bloom, and a rage tick, because of course
it does. Intended rhythm is spray -> switch to something heavy -> break it before the
coating settles -> spray again, coughing. Pairs with TOTAL DESTRUCTION, since the level
IS a search.

Tuning that mattered: the first cloud was a wall of big blocky quads that hid the very
thing it was revealing (150 finer, shorter-lived particles now), and a coated object has
to POP out of a room that is entirely one colour, so the coat is near-white and faintly
self-lit rather than a subtle tint. 78 objects, not 34 — a search with nothing in it is
just a walk.

Bug found while testing: `x as Smashable` on a freed object is itself an error in Godot
4, so the validity check has to come BEFORE the cast — destroying anything mid-level was
spewing "trying to cast a freed object" every frame.

dev/probe_backrooms.gd verifies the whole loop headlessly, including that the mechanic
does NOT leak into the other four sites when you leave.

All five sites still settle to 0.00 m/s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Monster Robot Party 2026-07-31 19:05:56 +10:00
parent cbe59c1568
commit 7c282c298e
11 changed files with 565 additions and 52 deletions

View File

@ -178,3 +178,33 @@ Needed next, in rough priority order:
Rule of thumb established this lane: **if it's a box, build it in code**; if its silhouette Rule of thumb established this lane: **if it's a box, build it in code**; if its silhouette
can't be faked by a box, generate it. can't be faked by a box, generate it.
---
## LEVEL 0 — THE BACKROOMS *(built)*
> *"special back rooms level can get weird with it? stuff is invisible have to release
> dust or gas or something to coat items to see them to destroy them? and you inhale
> some of it too"*
`Levels.backrooms()` + `Dust.gd`. Mono-yellow, 2.55 m ceiling, damp carpet, a dense grid
of buzzing troffers, no windows and no way out.
**The walls are generated.** This is the site that proves the Floorplan spec can be
emitted rather than authored — a seeded RNG lays partial wall runs on a 4.6 m lattice
with random gaps, plus 22 freestanding pillars. It follows the reference photographs
rather than a maze algorithm on purpose: the Backrooms aren't corridors, they're big
offset slabs in an open floor. A proper maze would be legible, and legible is wrong.
**The mechanic argues with itself.** Everything smashable is invisible but solid — you
find things by walking into them. RMB with the extinguisher throws powder; anything in
the cloud is coated, glows faintly out of all that yellow for 7 s, then settles back to
nothing. Every cloud also goes in your lungs, and that haze is on YOUR side of the glass.
Spray more to see individual objects; see the room less. Past 72% you cough: camera jolt,
screen bloom, and a rage tick.
Rhythm: spray → switch to something heavy → break it before the coating settles → spray
again, coughing. Pairs naturally with TOTAL DESTRUCTION, because the level *is* a search.
Still open here: a second gas with a different trade (something that reveals further but
costs more lungs), and the entity. There is currently nothing in here with you.

View File

@ -12,8 +12,10 @@ rest of your day. Your score is your payslip: **wages earned minus damages cause
during a meltdown you want to wreck a lot of cheap rubbish rather than one expensive during a meltdown you want to wreck a lot of cheap rubbish rather than one expensive
filing cabinet. Falling Down, scored by payroll. filing cabinet. Falling Down, scored by payroll.
**Four sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT **Five sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT
basement) and **four modes** (Cubicle Hell, the Gauntlet, Total Destruction, Against the basement, and the Backrooms — where the walls are procedurally generated and everything
you're there to destroy is invisible until you coat it in extinguisher powder, some of
which you breathe) and **four modes** (Cubicle Hell, the Gauntlet, Total Destruction, Against the
Clock). Sites are data, not code — see `game/scripts/Levels.gd`. Clock). Sites are data, not code — see `game/scripts/Levels.gd`.
Design, built and planned, lives in [`LANES/LANE7-cubicle-hell.md`](LANES/LANE7-cubicle-hell.md). Design, built and planned, lives in [`LANES/LANE7-cubicle-hell.md`](LANES/LANE7-cubicle-hell.md).

View File

@ -43,9 +43,32 @@ generator can later emit the same structure.
| **PAWNEE CITY HALL** | civic beige, blue-grey | a public counter, pinboards, over-partitioned | | **PAWNEE CITY HALL** | civic beige, blue-grey | a public counter, pinboards, over-partitioned |
| **THE INCUBATOR** | timber, white, 3 m, pendants | nobody has a desk; they work at a dining table | | **THE INCUBATOR** | timber, white, 3 m, pendants | nobody has a desk; they work at a dining table |
| **SUB-LEVEL 4** | concrete, green strip lights | **no windows**, exposed services, a wall of servers | | **SUB-LEVEL 4** | concrete, green strip lights | **no windows**, exposed services, a wall of servers |
| **LEVEL 0** | one colour, everywhere | the Backrooms. Walls are **generated**, and everything you're here to destroy is **invisible** |
`dev/probe_levels.gd` builds every one and reports residual motion after a full second. `dev/probe_levels.gd` builds every one and reports residual motion after a full second.
All four must read `0.00 m/s`. All five must read `0.00 m/s`.
### LEVEL 0 — the Backrooms, and the dust
The only site whose walls are **generated** rather than authored, which is both the
honest test of the spec and the right call for the fiction: the place is meant to feel
endless and samey, and a seeded RNG does that better than a human placing slabs. It
emits partial wall runs on a coarse lattice plus freestanding pillars — the Backrooms
aren't corridors, they're big offset slabs in an open floor, and a proper maze would be
legible.
`scripts/Dust.gd` is its mechanic. **Everything smashable here is invisible but solid**
you find things by walking into them. Right-click with the fire extinguisher throws a
cloud of powder; anything it touches is coated, glows faintly out of all that yellow for
seven seconds, and then settles back into nothing.
And you breathe it. Every cloud puts a little in your lungs, and the haze it builds is on
**your** side of the glass. So the mechanic argues with itself: spray more to see
individual objects, and see the room less. Past 72% you start coughing — the camera
jolts, the screen blooms, and it feeds the rage meter, because of course it does.
The intended rhythm is spray → switch to something heavy → break it before the coating
settles → spray again, coughing.
## The four modes ## The four modes

View File

@ -48,54 +48,25 @@ func _find_player(n: Node) -> Player:
return null return null
func _build_script() -> void: func _build_script() -> void:
# The showcase: a lap of all four sites, then Cubicle Hell (work -> tilt -> snap -> # LEVEL 0. Walk in, see nothing, spray, see for a moment, spray again while the haze
# wreck), then the Gauntlet. # builds. Total Destruction is the right mode here — the level IS a search.
_script = [] _script = [
var tours := { {"dur": 0.4, "move_to": null, "look_at": null, "act": "level:backrooms"},
"scranton": [[Vector3(-8.6, 0, 6.4), Vector3(-4.0, 1.1, 0.0)], {"dur": 0.4, "move_to": null, "look_at": null, "act": "mode:2"},
[Vector3(-2.6, 0, 3.4), Vector3(-0.4, 1.0, -1.0)]], {"dur": 2.6, "move_to": Vector3(0, 0, 13.0), "look_at": Vector3(0, 1.3, 2.0), "act": ""},
"pawnee": [[Vector3(-8.0, 0, 6.6), Vector3(-2.0, 1.1, 1.0)], {"dur": 1.6, "move_to": Vector3(0, 0, 9.0), "look_at": Vector3(2.0, 1.3, 0.0), "act": ""},
[Vector3(-1.0, 0, 2.6), Vector3(-2.6, 1.2, -5.2)]], {"dur": 1.8, "move_to": null, "look_at": null, "act": "puff"},
"house": [[Vector3(-1.0, 0, 7.0), Vector3(-4.4, 1.2, 0.6)], {"dur": 1.6, "move_to": null, "look_at": null, "act": "puff"},
[Vector3(0.5, 0, 2.0), Vector3(6.0, 1.3, 6.0)]], {"dur": 2.2, "move_to": Vector3(2.0, 0, 5.0), "look_at": Vector3(-3.0, 1.2, -1.0), "act": ""},
"basement": [[Vector3(1.0, 0, -5.2), Vector3(-8.0, 1.3, -1.0)], {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
[Vector3(-4.0, 0, -1.0), Vector3(-10.6, 1.4, -1.0)]], {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
} {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
for id in ["scranton", "pawnee", "house", "basement"]: {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
var shots: Array = tours[id] {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
_script.append({"dur": 0.4, "move_to": shots[0][0], "look_at": shots[0][1], {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
"act": "level:" + id}) {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
_script.append({"dur": 2.0, "move_to": null, "look_at": null, "act": ""}) {"dur": 2.6, "move_to": Vector3(-2.0, 0, 2.0), "look_at": Vector3(-6.0, 1.2, -4.0), "act": ""},
_script.append({"dur": 2.4, "move_to": shots[1][0], "look_at": shots[1][1],
"act": ""})
_script.append({"dur": 1.2, "move_to": null, "look_at": null, "act": ""})
# --- CUBICLE HELL, back at Scranton
_script += [
{"dur": 0.4, "move_to": null, "look_at": null, "act": "level:scranton"},
{"dur": 0.4, "move_to": null, "look_at": null, "act": "mode:0"},
{"dur": 2.2, "move_to": Vector3(-4.6, 0, -0.55), "look_at": Vector3(-4.6, 1.0, -1.4), "act": ""},
{"dur": 1.0, "move_to": null, "look_at": null, "act": "task"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "type"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "commit"},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "rage:0.32"},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "rage:0.32"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "rage:0.36"},
{"dur": 0.9, "move_to": Vector3(-4.6, 0, 0.3), "look_at": Vector3(-4.6, 0.9, -0.7), "act": "weapon:4"},
{"dur": 1.1, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.3, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.8, "move_to": Vector3(-2.4, 0, 4.9), "look_at": Vector3(-2.3, 0.25, 5.2), "act": ""},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "swing"},
]
# --- THE GAUNTLET
_script += [
{"dur": 0.5, "move_to": null, "look_at": null, "act": "mode:1"},
{"dur": 3.0, "move_to": null, "look_at": null, "act": ""}, {"dur": 3.0, "move_to": null, "look_at": null, "act": ""},
{"dur": 2.0, "move_to": null, "look_at": null, "act": "interact"},
{"dur": 2.4, "move_to": null, "look_at": null, "act": "interact"},
{"dur": 2.6, "move_to": null, "look_at": null, "act": ""},
] ]
func _motion(verbose := false) -> float: func _motion(verbose := false) -> float:
@ -195,6 +166,12 @@ func _do(act: String) -> void:
if md != null: if md != null:
md.set_mode(int(act.substr(5))) md.set_mode(int(act.substr(5)))
return return
if act == "puff":
var dd = _main.get("_dust")
if dd != null:
dd._cd = 0.0
dd.discharge()
return
if act == "interact": if act == "interact":
var gg = _main.get("_gauntlet") var gg = _main.get("_gauntlet")
if gg != null: if gg != null:

View File

@ -0,0 +1,66 @@
extends SceneTree
## Verify LEVEL 0 end to end: the generated maze produces walls, everything smashable is
## invisible on arrival, a discharge coats what's in front of you and NOT what's behind,
## the coating settles again, and breathing it registers.
##
## Godot --headless --path game --script dev/probe_backrooms.gd
func _initialize() -> void:
var main: Node = (load("res://main.tscn") as PackedScene).instantiate()
get_root().add_child(main)
await process_frame
main._load_level("backrooms")
for i in 8:
await physics_frame
var plan = main.get("_plan")
var dust = main.get("_dust")
var player = main.get("_player")
print("site : ", plan.level_name(), "", plan.subtitle())
print("generated walls: ", plan.spec["walls"].size(), " pillars+trim slabs: ",
plan.spec["slabs"].size())
print("smashables : ", get_nodes_in_group("smashable").size())
print("dust active : ", dust.active)
print("visible now : ", _visible_count())
# stand in the middle and spray
player.global_position = Vector3(0, 0, 0)
await physics_frame
dust._cd = 0.0
dust.discharge()
await physics_frame
print("after 1 puff : visible = ", _visible_count(), " inhaled = %.2f" % dust.inhaled)
dust._cd = 0.0
dust.discharge()
dust._cd = 0.0
dust.discharge()
await physics_frame
print("after 3 puffs : visible = ", _visible_count(), " inhaled = %.2f" % dust.inhaled)
# fast-forward past the coating time
for e in dust._hidden:
if is_instance_valid(e):
e.set_meta("dust_until", 1)
await process_frame
await process_frame
print("after settling: visible = ", _visible_count())
print("hud : ", dust.hud_line())
# leaving must clear it, or the mechanic leaks into every other site
main._load_level("scranton")
for i in 6:
await physics_frame
print("back at office: dust active = ", dust.active,
" visible = ", _visible_count(), " / ", get_nodes_in_group("smashable").size())
quit()
func _visible_count() -> int:
var n := 0
for s in get_nodes_in_group("smashable"):
for mi in Floorplan.meshes(s):
if (mi as MeshInstance3D).visible:
n += 1
break
return n

View File

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

282
game/scripts/Dust.gd Normal file
View File

@ -0,0 +1,282 @@
extends Node
class_name Dust
## LEVEL 0's mechanic: you cannot see what you are here to destroy.
##
## Every smashable in the Backrooms is invisible but SOLID — you find things by walking
## into them. Discharging the fire extinguisher throws a cloud of powder; anything it
## touches is coated and shows for a few seconds before the coating settles and the
## object goes back to being nothing.
##
## And you breathe it. Every cloud puts a little in your lungs, and the haze that builds
## is on YOUR side of the glass — it whites out the room. So the mechanic argues with
## itself: spray more to see individual objects, and see the room less. The intended
## rhythm is spray → switch to something heavy → break it before the coating settles →
## spray again, coughing.
##
## Enough dust in you and you cough: the camera jolts, the screen blooms, and it feeds
## Rage, because of course it does.
##
## Godot 4.7 GDScript 2.0.
const CLOUD_RANGE := 9.5 ## how far the powder carries
const CLOUD_ANGLE := 0.80 ## half-angle of the cone, radians
const CLOUD_ENVELOPE := 2.6 ## everything this close is coated regardless of aim
const COAT_TIME := 7.0 ## seconds an object stays visible
const INHALE_PER_PUFF := 0.085
const DECAY := 0.021 ## dust per second your lungs clear
const COUGH_AT := 0.72
const PUFF_COOLDOWN := 0.55
var rage: Rage
var active := false ## only true on a site whose spec says invisible
var inhaled := 0.0 ## 0..1
var _cd := 0.0
var _cough_cd := 0.0
var _cam: Camera3D
var _player: Node3D
var _hidden: Array = [] ## every smashable we've hidden on this site
var _haze: ColorRect
var _cough_snd: AudioStreamPlayer
var _puff_snd: AudioStreamPlayer
const HAZE_SHADER := """
shader_type canvas_item;
uniform float dust : hint_range(0.0, 1.0) = 0.0;
uniform float bloom : hint_range(0.0, 1.0) = 0.0;
void fragment() {
// flat lift plus a heavier wash at the edges: powder in the air, and powder on you
vec2 d = (UV - vec2(0.5)) * vec2(1.0, 0.62);
float r = length(d) * 2.0;
float edge = smoothstep(0.25, 1.25, r);
float a = dust * (0.16 + 0.38 * edge) + bloom * 0.45;
COLOR = vec4(0.93, 0.91, 0.80, clamp(a, 0.0, 0.92));
}
"""
func setup(player: Node3D, cam: Camera3D, r: Rage, hud_layer: CanvasLayer) -> void:
_player = player
_cam = cam
rage = r
_haze = ColorRect.new()
_haze.set_anchors_preset(Control.PRESET_FULL_RECT)
_haze.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sh := Shader.new()
sh.code = HAZE_SHADER
var m := ShaderMaterial.new()
m.shader = sh
_haze.material = m
hud_layer.add_child(_haze)
_puff_snd = AudioStreamPlayer.new()
_puff_snd.stream = _make_noise(0.55, 900.0, 3.0)
_puff_snd.volume_db = -8.0
add_child(_puff_snd)
_cough_snd = AudioStreamPlayer.new()
_cough_snd.stream = _make_noise(0.42, 190.0, 11.0)
_cough_snd.volume_db = -4.0
add_child(_cough_snd)
# ---------------------------------------------------------------- site setup
## Called after a level is populated. On an `invisible` site every smashable is hidden;
## anywhere else this clears itself out so the mechanic can't leak between levels.
func adopt(tree: SceneTree, invisible: bool) -> void:
for e in _hidden:
if is_instance_valid(e):
_show(e as Smashable, true)
_hidden.clear()
inhaled = 0.0
active = invisible
if not invisible:
return
for n in tree.get_nodes_in_group("smashable"):
var s := n as Smashable
if s == null:
continue
_hidden.append(s)
# _coat writes surface overrides; a mesh with no override slots silently ignores
# them, so seed each surface with a copy of its own material up front
_seed_overrides(s)
_show(s, false)
## _coat writes surface OVERRIDES, and a surface with no override slot silently ignores
## them — so seed every surface up front with a copy of whatever material it actually
## resolves to. Imported GLBs put materials in either place, hence the two-step lookup.
func _seed_overrides(s: Smashable) -> void:
for mi in Floorplan.meshes(s):
var m := mi as MeshInstance3D
if m.mesh == null:
continue
for i in m.mesh.get_surface_count():
var src := _surface_material(m, i)
if src != null:
m.set_surface_override_material(i, src.duplicate())
static func _surface_material(m: MeshInstance3D, i: int) -> Material:
var over := m.get_surface_override_material(i)
if over != null:
return over
if m.mesh == null:
return null
return m.mesh.surface_get_material(i)
func _show(s: Smashable, visible: bool) -> void:
if not is_instance_valid(s):
return
for mi in Floorplan.meshes(s):
(mi as MeshInstance3D).visible = visible
# ---------------------------------------------------------------- the cloud
## Fired by Player on right-click while the extinguisher is out. Returns true if it went.
func discharge() -> bool:
if not active or _cd > 0.0 or _cam == null:
return false
_cd = PUFF_COOLDOWN
inhaled = clampf(inhaled + INHALE_PER_PUFF, 0.0, 1.0)
_puff_snd.pitch_scale = randf_range(0.94, 1.08)
_puff_snd.play()
_spawn_puff()
var origin := _cam.global_position
var dir := -_cam.global_transform.basis.z
var coated := 0
for e in _hidden:
if not is_instance_valid(e):
continue # check BEFORE the cast: casting a freed
var s := e as Smashable # object is itself an error in Godot 4
var to := s.global_position - origin
var dist := to.length()
if dist > CLOUD_RANGE:
continue
# a cone — except close in, where the cloud simply envelops you and everything
# in arm's reach, which is also how you find the thing you just walked into
if dist > CLOUD_ENVELOPE and to.normalized().dot(dir) < cos(CLOUD_ANGLE):
continue
_coat(s)
coated += 1
return true
func _coat(s: Smashable) -> void:
_show(s, true)
s.set_meta("dust_until", Time.get_ticks_msec() + int(COAT_TIME * 1000.0))
# powder-white wash so a coated object reads as coated, not merely present
for mi in Floorplan.meshes(s):
var m := mi as MeshInstance3D
if m.mesh == null:
continue
for i in m.mesh.get_surface_count():
var src := _surface_material(m, i) as BaseMaterial3D
if src == null:
continue
var d := src.duplicate() as BaseMaterial3D
d.albedo_color = src.albedo_color.lerp(Color(0.97, 0.96, 0.92), 0.80)
d.emission_enabled = true
d.emission = Color(0.55, 0.54, 0.48)
d.emission_energy_multiplier = 0.55
m.set_surface_override_material(i, d)
func _spawn_puff() -> void:
var p := GPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.82
p.amount = 150
p.lifetime = 1.1
p.local_coords = false
var pm := ParticleProcessMaterial.new()
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
pm.emission_sphere_radius = 0.18
pm.direction = -_cam.global_transform.basis.z
pm.spread = 26.0
pm.initial_velocity_min = 3.0
pm.initial_velocity_max = 7.5
pm.damping_min = 3.0
pm.damping_max = 6.0
pm.gravity = Vector3(0, -0.6, 0)
pm.scale_min = 0.5
pm.scale_max = 1.6
p.process_material = pm
var mesh := QuadMesh.new()
mesh.size = Vector2(0.20, 0.20)
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.96, 0.94, 0.86, 0.22)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mesh.material = mat
p.draw_pass_1 = mesh
add_child(p)
p.global_position = _cam.global_position - _cam.global_transform.basis.z * 0.6
p.emitting = true
p.finished.connect(p.queue_free)
# ---------------------------------------------------------------- per-frame
func _process(dt: float) -> void:
_cd = maxf(0.0, _cd - dt)
_cough_cd = maxf(0.0, _cough_cd - dt)
var bloom := 0.0
if active:
inhaled = maxf(0.0, inhaled - DECAY * dt)
# coatings settle
var now := Time.get_ticks_msec()
for e in _hidden:
if not is_instance_valid(e):
continue
var s := e as Smashable
var until := int(s.get_meta("dust_until", 0))
if until > 0 and now >= until:
s.set_meta("dust_until", 0)
_show(s, false)
# coughing: a jolt, a bloom, and it winds you up
if inhaled > COUGH_AT and _cough_cd <= 0.0:
_cough_cd = randf_range(2.4, 4.2)
_cough_snd.pitch_scale = randf_range(0.9, 1.12)
_cough_snd.play()
bloom = 1.0
if rage != null:
rage.provoke(0.045, "you are breathing this")
if _cam != null:
_cam.rotation.x += randf_range(-0.05, 0.05)
_cam.rotation.z += randf_range(-0.04, 0.04)
if _haze != null:
var m := _haze.material as ShaderMaterial
m.set_shader_parameter("dust", inhaled)
m.set_shader_parameter("bloom", bloom)
_haze.visible = active
# ---------------------------------------------------------------- readouts
func hud_line() -> String:
if not active:
return ""
var lungs := int(round(inhaled * 100.0))
var warn := " · COUGHING" if inhaled > COUGH_AT else ""
return "LEVEL 0 · RMB: dust (extinguisher) · in your lungs %d%%%s" % [
lungs, warn]
## A short breath of shaped noise — same zero-asset trick as Juice.gd.
func _make_noise(dur: float, tone: float, decay: float) -> AudioStreamWAV:
var rate := 22050
var n := int(rate * dur)
var out := PackedFloat32Array()
out.resize(n)
for i in n:
var t := float(i) / float(rate)
var env := exp(-t * decay) * (1.0 - exp(-t * 60.0))
var s := (randf() * 2.0 - 1.0) * 0.7 * env
s += sin(TAU * tone * t) * 0.18 * env
out[i] = clampf(s, -1.0, 1.0)
var wav := AudioStreamWAV.new()
wav.format = AudioStreamWAV.FORMAT_16_BITS
wav.mix_rate = rate
wav.stereo = false
var bytes := PackedByteArray()
bytes.resize(n * 2)
for i in n:
bytes.encode_s16(i * 2, int(out[i] * 32767.0))
wav.data = bytes
return wav

1
game/scripts/Dust.gd.uid Normal file
View File

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

View File

@ -20,13 +20,14 @@ class_name Levels
## ##
## Godot 4.7 GDScript 2.0. ## Godot 4.7 GDScript 2.0.
const ORDER := ["scranton", "pawnee", "house", "basement"] const ORDER := ["scranton", "pawnee", "house", "basement", "backrooms"]
static func get_level(id: String) -> Dictionary: static func get_level(id: String) -> Dictionary:
match id: match id:
"pawnee": return pawnee() "pawnee": return pawnee()
"house": return house() "house": return house()
"basement": return basement() "basement": return basement()
"backrooms": return backrooms()
return scranton() return scranton()
static func next_id(id: String) -> String: static func next_id(id: String) -> String:
@ -438,3 +439,115 @@ static func basement() -> Dictionary:
"stand": Vector3(-3.4, 0.0, -1.5), "shredder": Vector3(-2.2, 0.0, -2.0)}, "stand": Vector3(-3.4, 0.0, -1.5), "shredder": Vector3(-2.2, 0.0, -2.0)},
"spawn": {"at": Vector3(1.0, 0.0, -5.2), "yaw": deg_to_rad(0.0)}, "spawn": {"at": Vector3(1.0, 0.0, -5.2), "yaw": deg_to_rad(0.0)},
} }
# ================================================================ LEVEL 00
## THE BACKROOMS. The only site whose walls are GENERATED rather than authored, which is
## both the honest test of the Floorplan spec and the right call for the fiction: this
## place is supposed to feel endless and samey, and a seeded RNG produces that better
## than a human placing slabs ever would.
##
## Layout follows the reference photographs rather than a maze algorithm — the Backrooms
## aren't corridors, they're big offset SLABS sitting in an open floor with gaps between
## them, so that's what this emits: partial wall runs along a coarse grid, plus pillars.
## A proper maze would be legible; this is deliberately not.
##
## Everything smashable here is INVISIBLE (see Dust.gd). You find it by walking into it.
const BR_SEED := 0xBACC
const BR_HALF := 19.0
const BR_H := 2.55 # low. lower than any real office.
static func backrooms() -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = BR_SEED
var walls: Array = []
# Wall runs on a 4.6 m lattice. Each line gets a few segments with gaps between, so
# you can always get through somewhere but never see far.
var lines := [-13.8, -9.2, -4.6, 0.0, 4.6, 9.2, 13.8]
for axis in ["x", "z"]:
for at in lines:
var cursor := -BR_HALF
while cursor < BR_HALF - 2.0:
var gap := rng.randf_range(1.6, 6.5)
cursor += gap
if cursor >= BR_HALF - 2.0:
break
var run := rng.randf_range(3.2, 9.0)
var end: float = minf(cursor + run, BR_HALF)
if end - cursor > 1.4:
walls.append({"a": axis, "at": at, "from": cursor, "to": end})
cursor = end
# freestanding pillars, because every Backrooms photo has one in the way
var slabs: Array = []
for i in 22:
var px := rng.randf_range(-BR_HALF + 2.0, BR_HALF - 2.0)
var pz := rng.randf_range(-BR_HALF + 2.0, BR_HALF - 2.0)
slabs.append({"size": Vector3(0.62, BR_H, 0.62),
"at": Vector3(px, BR_H * 0.5, pz), "mat": "wall"})
# skirting-height trim run around the outer wall, the one detail the photos all share
for s in [-1.0, 1.0]:
slabs.append({"size": Vector3(BR_HALF * 2.0, 0.12, 0.06),
"at": Vector3(0.0, 0.06, s * (BR_HALF - 0.03)), "mat": "trim",
"collide": false})
slabs.append({"size": Vector3(0.06, 0.12, BR_HALF * 2.0),
"at": Vector3(s * (BR_HALF - 0.03), 0.06, 0.0), "mat": "trim",
"collide": false})
# a dense grid of buzzing troffers — the light IS the horror
var lx: Array = []
var lz: Array = []
var v := -BR_HALF + 2.3
while v < BR_HALF:
lx.append(v)
lz.append(v)
v += 3.05
# sparse, wrong office objects. All invisible until you dust them.
var smash: Array = []
var kinds := [["office-chair", "plastic"], ["wastebin", "plastic"],
["cardboard-box", "cardboard"], ["filing-cabinet", "steel"],
["crt-monitor", "glass"], ["desk-phone", "plastic"],
["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:
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)),
"yaw": rng.randf_range(0.0, TAU),
})
return {
"name": "LEVEL 0",
"subtitle": "you noclipped out of reality",
"invisible": true, # Dust.gd hides every smashable here
"bounds": {"x0": -BR_HALF, "x1": BR_HALF, "z0": -BR_HALF, "z1": BR_HALF,
"h": BR_H},
"palette": {
# the whole point is that there is only one colour
"floor": Color(0.52, 0.47, 0.30), "wall": Color(0.76, 0.72, 0.42),
"ceiling": Color(0.80, 0.77, 0.55), "trim": Color(0.62, 0.58, 0.34),
"partition": Color(0.74, 0.70, 0.40), "wood": Color(0.62, 0.55, 0.32),
"accent": Color(0.70, 0.66, 0.38),
},
"floor_style": "carpet",
"ceiling": "tile",
"walls": walls,
"windows": [], # there is no outside
"slabs": slabs,
"lights": {"xs": lx, "zs": lz, "color": Color(1.0, 0.97, 0.80),
"energy": 1.15, "style": "troffer", "key_energy": 0.9},
"env": {"bg": Color(0.30, 0.28, 0.16), "ambient": Color(0.62, 0.58, 0.36),
"ambient_energy": 0.70, "exposure": 1.12},
"clusters": [],
"props": [],
"chairs": [],
"smashables": smash,
"records": [],
"gauntlet": {"at": Vector3(0.0, 0.0, -2.2), "yaw": PI,
"stand": Vector3(0.0, 0.0, 0.2), "shredder": Vector3(3.0, 0.0, -1.0)},
"spawn": {"at": Vector3(0.0, 0.0, 16.5), "yaw": PI},
}

View File

@ -40,6 +40,7 @@ var _env_node: WorldEnvironment
var _ambience: Ambience var _ambience: Ambience
var _modes: Modes var _modes: Modes
var _gauntlet: Gauntlet var _gauntlet: Gauntlet
var _dust: Dust
func _ready() -> void: func _ready() -> void:
randomize() randomize()
@ -77,6 +78,8 @@ func _ready() -> void:
_modes.changed.connect(_on_mode_changed) _modes.changed.connect(_on_mode_changed)
_gauntlet = Gauntlet.new() _gauntlet = Gauntlet.new()
add_child(_gauntlet) add_child(_gauntlet)
_dust = Dust.new()
add_child(_dust)
# rules layer: the record modes still exist behind M # rules layer: the record modes still exist behind M
_game_mode = GameMode.new() _game_mode = GameMode.new()
@ -89,6 +92,9 @@ func _ready() -> void:
_ambience.adopt_lights(_plan) _ambience.adopt_lights(_plan)
_gauntlet.setup(_player, _hud) _gauntlet.setup(_player, _hud)
_player.gauntlet = _gauntlet _player.gauntlet = _gauntlet
_dust.setup(_player, _cam, _rage, _hud)
_player.dust = _dust
_dust.adopt(get_tree(), bool(_plan.spec.get("invisible", false)))
_modes.begin(get_tree().get_nodes_in_group("smashable").size()) _modes.begin(get_tree().get_nodes_in_group("smashable").size())
## Every desk is a spreadsheet you have to fill in; the copier is the copier. ## Every desk is a spreadsheet you have to fill in; the copier is the copier.
@ -537,6 +543,11 @@ func _load_level(id: String) -> void:
_register_stations() _register_stations()
if _ambience != null: if _ambience != null:
_ambience.adopt_lights(_plan) _ambience.adopt_lights(_plan)
if _dust != null:
var blind := bool(_plan.spec.get("invisible", false))
_dust.adopt(get_tree(), blind)
if blind:
_player.select(5) # you will need the extinguisher
if _game_mode != null: if _game_mode != null:
_game_mode.begin_free_play(_records) _game_mode.begin_free_play(_records)
if _shift != null: if _shift != null:
@ -680,7 +691,8 @@ func _process(_dt: float) -> void:
"weapon_blurb": w.blurb if w != null else "", "weapon_blurb": w.blurb if w != null else "",
"weapon_stats": ("pow %.1f cd %.2fs reach %.1fm" % [w.power, w.cooldown, w.reach]) if w != null else "", "weapon_stats": ("pow %.1f cd %.2fs reach %.1fm" % [w.power, w.cooldown, w.reach]) if w != null else "",
"slot": _player.slot() if _player != null else 0, "slot": _player.slot() if _player != null else 0,
"mode_line": _mode_line(), "mode_line": _mode_line() + ("\n" + _dust.hud_line() if _dust != null
and _dust.active else ""),
"mode_name": _modes.name_of() if _modes != null else "", "mode_name": _modes.name_of() if _modes != null else "",
"headline": _modes.headline() if _modes != null else "", "headline": _modes.headline() if _modes != null else "",
"keys": KEYS_LINE, "keys": KEYS_LINE,

View File

@ -34,6 +34,8 @@ var viewmodel: ViewModel # the arms + weapon rig
var tasks: Tasks = null var tasks: Tasks = null
## Set by Main. E opens cabinet drawers / feeds the shredder when a run is live. ## Set by Main. E opens cabinet drawers / feeds the shredder when a run is live.
var gauntlet: Gauntlet = null var gauntlet: Gauntlet = null
## Set by Main. RMB discharges powder on sites where the smashables are invisible.
var dust: Dust = null
var _head: Node3D var _head: Node3D
var _pitch := 0.0 var _pitch := 0.0
@ -179,6 +181,10 @@ func _unhandled_input(event: InputEvent) -> void:
grab.primary() grab.primary()
else: else:
_queued = true _queued = true
MOUSE_BUTTON_RIGHT:
# the extinguisher is the only thing you can shake powder out of
if dust != null and dust.active and weapon().id == "extinguisher":
dust.discharge()
MOUSE_BUTTON_WHEEL_UP: MOUSE_BUTTON_WHEEL_UP:
cycle(-1) cycle(-1)
MOUSE_BUTTON_WHEEL_DOWN: MOUSE_BUTTON_WHEEL_DOWN: