diff --git a/LANES/LANE7-cubicle-hell.md b/LANES/LANE7-cubicle-hell.md index 48a970b..29ba8d0 100644 --- a/LANES/LANE7-cubicle-hell.md +++ b/LANES/LANE7-cubicle-hell.md @@ -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 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. diff --git a/README.md b/README.md index 044a33e..175b092 100644 --- a/README.md +++ b/README.md @@ -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 filing cabinet. Falling Down, scored by payroll. -**Four sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT -basement) and **four modes** (Cubicle Hell, the Gauntlet, Total Destruction, Against the +**Five sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT +basement, and the Backrooms — where the walls are procedurally generated and everything +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`. Design, built and planned, lives in [`LANES/LANE7-cubicle-hell.md`](LANES/LANE7-cubicle-hell.md). diff --git a/game/README.md b/game/README.md index 3182af0..8993c23 100644 --- a/game/README.md +++ b/game/README.md @@ -43,9 +43,32 @@ generator can later emit the same structure. | **PAWNEE CITY HALL** | civic beige, blue-grey | a public counter, pinboards, over-partitioned | | **THE INCUBATOR** | timber, white, 3 m, pendants | nobody has a desk; they work at a dining table | | **SUB-LEVEL 4** | concrete, green strip lights | **no windows**, exposed services, a wall of servers | +| **LEVEL 0** | one colour, everywhere | the Backrooms. Walls are **generated**, and everything you're here to destroy is **invisible** | `dev/probe_levels.gd` builds every one and reports residual motion after a full second. -All 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 diff --git a/game/dev/DemoDriver.gd b/game/dev/DemoDriver.gd index 5f96482..94f0c1f 100644 --- a/game/dev/DemoDriver.gd +++ b/game/dev/DemoDriver.gd @@ -48,54 +48,25 @@ func _find_player(n: Node) -> Player: return null func _build_script() -> void: - # The showcase: a lap of all four sites, then Cubicle Hell (work -> tilt -> snap -> - # wreck), then the Gauntlet. - _script = [] - var tours := { - "scranton": [[Vector3(-8.6, 0, 6.4), Vector3(-4.0, 1.1, 0.0)], - [Vector3(-2.6, 0, 3.4), Vector3(-0.4, 1.0, -1.0)]], - "pawnee": [[Vector3(-8.0, 0, 6.6), Vector3(-2.0, 1.1, 1.0)], - [Vector3(-1.0, 0, 2.6), Vector3(-2.6, 1.2, -5.2)]], - "house": [[Vector3(-1.0, 0, 7.0), Vector3(-4.4, 1.2, 0.6)], - [Vector3(0.5, 0, 2.0), Vector3(6.0, 1.3, 6.0)]], - "basement": [[Vector3(1.0, 0, -5.2), Vector3(-8.0, 1.3, -1.0)], - [Vector3(-4.0, 0, -1.0), Vector3(-10.6, 1.4, -1.0)]], - } - for id in ["scranton", "pawnee", "house", "basement"]: - var shots: Array = tours[id] - _script.append({"dur": 0.4, "move_to": shots[0][0], "look_at": shots[0][1], - "act": "level:" + id}) - _script.append({"dur": 2.0, "move_to": null, "look_at": null, "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"}, + # LEVEL 0. Walk in, see nothing, spray, see for a moment, spray again while the haze + # builds. Total Destruction is the right mode here — the level IS a search. + _script = [ + {"dur": 0.4, "move_to": null, "look_at": null, "act": "level:backrooms"}, + {"dur": 0.4, "move_to": null, "look_at": null, "act": "mode:2"}, + {"dur": 2.6, "move_to": Vector3(0, 0, 13.0), "look_at": Vector3(0, 1.3, 2.0), "act": ""}, + {"dur": 1.6, "move_to": Vector3(0, 0, 9.0), "look_at": Vector3(2.0, 1.3, 0.0), "act": ""}, + {"dur": 1.8, "move_to": null, "look_at": null, "act": "puff"}, + {"dur": 1.6, "move_to": null, "look_at": null, "act": "puff"}, + {"dur": 2.2, "move_to": Vector3(2.0, 0, 5.0), "look_at": Vector3(-3.0, 1.2, -1.0), "act": ""}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, + {"dur": 2.6, "move_to": Vector3(-2.0, 0, 2.0), "look_at": Vector3(-6.0, 1.2, -4.0), "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: @@ -195,6 +166,12 @@ func _do(act: String) -> void: if md != null: md.set_mode(int(act.substr(5))) return + if act == "puff": + var dd = _main.get("_dust") + if dd != null: + dd._cd = 0.0 + dd.discharge() + return if act == "interact": var gg = _main.get("_gauntlet") if gg != null: diff --git a/game/dev/probe_backrooms.gd b/game/dev/probe_backrooms.gd new file mode 100644 index 0000000..dc6d7af --- /dev/null +++ b/game/dev/probe_backrooms.gd @@ -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 diff --git a/game/dev/probe_backrooms.gd.uid b/game/dev/probe_backrooms.gd.uid new file mode 100644 index 0000000..8c5cc7d --- /dev/null +++ b/game/dev/probe_backrooms.gd.uid @@ -0,0 +1 @@ +uid://cei5w7qe3sji1 diff --git a/game/scripts/Dust.gd b/game/scripts/Dust.gd new file mode 100644 index 0000000..4c9601b --- /dev/null +++ b/game/scripts/Dust.gd @@ -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 diff --git a/game/scripts/Dust.gd.uid b/game/scripts/Dust.gd.uid new file mode 100644 index 0000000..0c046ba --- /dev/null +++ b/game/scripts/Dust.gd.uid @@ -0,0 +1 @@ +uid://bgxf0g07khtek diff --git a/game/scripts/Levels.gd b/game/scripts/Levels.gd index d430ba5..be79aec 100644 --- a/game/scripts/Levels.gd +++ b/game/scripts/Levels.gd @@ -20,13 +20,14 @@ class_name Levels ## ## 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: match id: "pawnee": return pawnee() "house": return house() "basement": return basement() + "backrooms": return backrooms() return scranton() 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)}, "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}, + } diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index 5f1d80c..71ff45f 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -40,6 +40,7 @@ var _env_node: WorldEnvironment var _ambience: Ambience var _modes: Modes var _gauntlet: Gauntlet +var _dust: Dust func _ready() -> void: randomize() @@ -77,6 +78,8 @@ func _ready() -> void: _modes.changed.connect(_on_mode_changed) _gauntlet = Gauntlet.new() add_child(_gauntlet) + _dust = Dust.new() + add_child(_dust) # rules layer: the record modes still exist behind M _game_mode = GameMode.new() @@ -89,6 +92,9 @@ func _ready() -> void: _ambience.adopt_lights(_plan) _gauntlet.setup(_player, _hud) _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()) ## 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() if _ambience != null: _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: _game_mode.begin_free_play(_records) if _shift != null: @@ -680,7 +691,8 @@ func _process(_dt: float) -> void: "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 "", "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 "", "headline": _modes.headline() if _modes != null else "", "keys": KEYS_LINE, diff --git a/game/scripts/Player.gd b/game/scripts/Player.gd index 1b1e20e..fb497b9 100644 --- a/game/scripts/Player.gd +++ b/game/scripts/Player.gd @@ -34,6 +34,8 @@ var viewmodel: ViewModel # the arms + weapon rig var tasks: Tasks = null ## Set by Main. E opens cabinet drawers / feeds the shredder when a run is live. 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 _pitch := 0.0 @@ -179,6 +181,10 @@ func _unhandled_input(event: InputEvent) -> void: grab.primary() else: _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: cycle(-1) MOUSE_BUTTON_WHEEL_DOWN: