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:
parent
cbe59c1568
commit
7c282c298e
@ -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.
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
66
game/dev/probe_backrooms.gd
Normal file
66
game/dev/probe_backrooms.gd
Normal 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
|
||||||
1
game/dev/probe_backrooms.gd.uid
Normal file
1
game/dev/probe_backrooms.gd.uid
Normal file
@ -0,0 +1 @@
|
|||||||
|
uid://cei5w7qe3sji1
|
||||||
282
game/scripts/Dust.gd
Normal file
282
game/scripts/Dust.gd
Normal 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
1
game/scripts/Dust.gd.uid
Normal file
@ -0,0 +1 @@
|
|||||||
|
uid://bgxf0g07khtek
|
||||||
@ -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},
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user