"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>
706 lines
26 KiB
GDScript
706 lines
26 KiB
GDScript
extends Node3D
|
|
|
|
## Destroyulator — LEVEL 01, the office.
|
|
##
|
|
## Main is the glue: it builds the level shell (Office), spawns everything SMASHABLE into
|
|
## it, owns score/tally, and routes each Smashable's three outcomes (break / dent /
|
|
## futile) to Juice and the HUD.
|
|
##
|
|
## Two things it still proves, from the prototype days:
|
|
## THE DAY-0 GATE: press B to rain 500 rigid bodies and watch the FPS hold.
|
|
## THE CASCADE: smash a frozen desk -> the monitor on it falls; smash a crate ->
|
|
## the records spill -> a record cracks a beat later when it lands.
|
|
##
|
|
## Controls: see KEYS_LINE below, or the Dev HUD (H cycles HUD styles).
|
|
|
|
@onready var _cam: Camera3D
|
|
var _world: Node3D # everything smashable/debris lives here so reset is one line
|
|
var _hud: Hud
|
|
var _smash_count := 0
|
|
var _score := 0
|
|
var _tally := {} # material -> how many of it you've destroyed (work-order HUD)
|
|
|
|
## Score per material. Steel is worth the most because it's the hardest to break, which
|
|
## is what makes the sledgehammer worth its cooldown.
|
|
const POINTS := {
|
|
"paper": 5, "cardboard": 10, "plastic": 20, "vinyl": 25,
|
|
"wood": 30, "glass": 40, "steel": 60,
|
|
}
|
|
var _juice: Juice
|
|
var _player: Player # kept so GameMode can reach player.grab
|
|
var _records: Array[Record] = [] # the live records, handed to GameMode each round
|
|
var _game_mode: GameMode
|
|
var _plan: Floorplan # the level shell: walls, fittings, lighting — see Levels.gd
|
|
var _level_id := "scranton"
|
|
var _rage: Rage # the tilt meter — see Rage.gd
|
|
var _rage_hud: RageOverlay
|
|
var _tasks: Tasks
|
|
var _shift: CubicleHell
|
|
var _env_node: WorldEnvironment
|
|
var _ambience: Ambience
|
|
var _modes: Modes
|
|
var _gauntlet: Gauntlet
|
|
var _dust: Dust
|
|
|
|
func _ready() -> void:
|
|
randomize()
|
|
_setup_world()
|
|
_juice = Juice.new()
|
|
add_child(_juice)
|
|
_juice.setup(_cam) # _setup_world() set _cam to the player's camera
|
|
_populate()
|
|
_spawn_guard = SPAWN_GUARD_TIME
|
|
|
|
# --- CUBICLE HELL: the tilt meter, the job, and the payslip that scores both ---
|
|
_rage = Rage.new()
|
|
add_child(_rage)
|
|
_rage_hud = RageOverlay.new()
|
|
_rage_hud.setup(_rage)
|
|
add_child(_rage_hud)
|
|
|
|
_tasks = Tasks.new()
|
|
add_child(_tasks)
|
|
_tasks.setup(_player, _rage)
|
|
_register_stations()
|
|
_player.tasks = _tasks
|
|
|
|
_shift = CubicleHell.new()
|
|
add_child(_shift)
|
|
_shift.setup(_rage, _tasks)
|
|
_shift.begin()
|
|
|
|
# the provocations you can't play around: flickering tube, fish in the microwave
|
|
_ambience = Ambience.new()
|
|
add_child(_ambience)
|
|
|
|
_modes = Modes.new()
|
|
add_child(_modes)
|
|
_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()
|
|
add_child(_game_mode)
|
|
_game_mode.setup(_player.grab)
|
|
_game_mode.begin_free_play(_records)
|
|
|
|
_setup_hud()
|
|
_ambience.setup(_rage, _hud)
|
|
_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.
|
|
func _register_stations() -> void:
|
|
var i := 1
|
|
for t in _plan.desk_spots():
|
|
# alternating, so walking to "a desk" isn't always the same minigame
|
|
if i % 2 == 1:
|
|
_tasks.register(Tasks.Kind.SPREADSHEET, (t as Transform3D).origin,
|
|
"work at desk %d" % i)
|
|
else:
|
|
_tasks.register(Tasks.Kind.STAPLES, (t as Transform3D).origin,
|
|
"de-staple the bundle on desk %d" % i)
|
|
i += 1
|
|
if _plan.has_copier():
|
|
_tasks.register(Tasks.Kind.COPIER, _plan.copier_spot().origin,
|
|
"use the photocopier")
|
|
# a station wherever the site put a guillotine
|
|
for e in _plan.smashables():
|
|
var d: Dictionary = e
|
|
if String(d.get("glb", "")) == "guillotine":
|
|
var a: Vector2 = d["at"]
|
|
_tasks.register(Tasks.Kind.GUILLOTINE, Vector3(a.x, 0.9, a.y),
|
|
"trim the ream")
|
|
|
|
# ---------------------------------------------------------------- world / camera
|
|
func _setup_world() -> void:
|
|
# The level shell: floor, walls, ceiling, windows, fittings and ALL the lighting.
|
|
# It owns the environment too, because interior lighting and the ambient/tonemap
|
|
# settings are one decision, not two.
|
|
_plan = Floorplan.new()
|
|
add_child(_plan)
|
|
_plan.build(Levels.get_level(_level_id))
|
|
|
|
_env_node = WorldEnvironment.new()
|
|
var env := Environment.new()
|
|
_plan.configure_environment(env)
|
|
_env_node.environment = env
|
|
add_child(_env_node)
|
|
|
|
# First-person player. add_child() runs its _ready() synchronously, so player.camera
|
|
# and player.grab exist on the next line; we reuse them.
|
|
_player = Player.new()
|
|
add_child(_player)
|
|
_player.global_position = _plan.spawn_point()
|
|
_player.rotation.y = _plan.spawn_yaw()
|
|
_cam = _player.camera
|
|
|
|
_world = Node3D.new()
|
|
_world.name = "World"
|
|
add_child(_world)
|
|
|
|
# ---------------------------------------------------------------- the store
|
|
# Real Monster Robot Party assets: wooden racks (frozen backdrop you can smash) and
|
|
# crates of records in front. Colliders auto-size to each GLB's mesh AABB and every
|
|
# piece is dropped so its bottom rests exactly on the floor — no hardcoded dimensions.
|
|
const RACK_GLB := "res://assets/store/rack.glb"
|
|
const CRATE_GLB := "res://assets/store/crate.glb"
|
|
const RECORD_GLB := "res://assets/store/record.glb"
|
|
|
|
# Lane 2 hero props (each ships a .fractured.glb sibling → real shard-by-shard smashing)
|
|
const DESK_GLB := "res://assets/store/office-desk.glb"
|
|
const PRINTER_GLB := "res://assets/store/office-printer.glb"
|
|
const CRT_GLB := "res://assets/store/crt-monitor.glb"
|
|
const CABINET_GLB := "res://assets/store/filing-cabinet.glb"
|
|
const COOLER_GLB := "res://assets/store/water-cooler.glb"
|
|
const BOX_GLB := "res://assets/store/cardboard-box.glb"
|
|
const TURNTABLE_GLB := "res://assets/store/turntable.glb"
|
|
const CHAIR_GLB := "res://assets/store/office-chair.glb"
|
|
const P := "res://assets/store/"
|
|
|
|
## Mass by material, so a thrown record can't tip a filing cabinet but a sledgehammer
|
|
## still can. Everything defaulted to 1 kg before, which is why the level fell over.
|
|
const MASSES := {
|
|
"paper": 0.3, "cardboard": 1.2, "plastic": 6.0, "vinyl": 0.6,
|
|
"wood": 18.0, "glass": 12.0, "steel": 40.0,
|
|
}
|
|
|
|
# Generated sleeve art (Nano Banana): each file is a 3x3 sheet of nine original
|
|
# fictional album covers. A record picks one tile at random via uv1 scale/offset —
|
|
# one texture in VRAM, eighteen different-looking records on the floor.
|
|
const SLEEVE_ATLASES: Array[String] = [
|
|
"res://assets/art/sleeves_a.jpg",
|
|
"res://assets/art/sleeves_b.jpg",
|
|
]
|
|
var _sleeve_textures: Array[Texture2D] = []
|
|
var _records_skinned := 0
|
|
|
|
## Fill the office with the SMASHABLE half of the level. The Office class owns the shell
|
|
## and the static fittings; everything spawned here is a rigid body you can wreck.
|
|
func _populate() -> void:
|
|
_records.clear() # stale refs from the freed world; repopulated by _place_record
|
|
|
|
# --- the bullpen: a desk at every cluster spot, each with a monitor on it ---
|
|
var spots := _plan.desk_spots()
|
|
for i in spots.size():
|
|
var t: Transform3D = spots[i]
|
|
var xz := Vector2(t.origin.x, t.origin.z)
|
|
var desk := _glb_piece(DESK_GLB, "wood", xz, true, 0.0, t.basis.get_euler().y)
|
|
var top: float = desk["top"]
|
|
var crt := _glb_piece(CRT_GLB, "glass", xz + Vector2(0.0, 0.10), false, top)
|
|
(desk["piece"] as Smashable).supports.append(crt["piece"])
|
|
# Chair goes behind the desk, offset by the desk's OWN measured depth. Office.gd
|
|
# used to guess a constant here and kept putting chairs inside desks (or, once
|
|
# pushed out far enough to clear them, inside each other across the aisle).
|
|
var depth: float = (desk["aabb"] as AABB).size.z
|
|
var back := signf(t.origin.z - _plan.desk_cluster(i).y)
|
|
if is_zero_approx(back):
|
|
back = 1.0
|
|
_glb_piece(CHAIR_GLB, "plastic", xz + Vector2(0.0, back * (depth * 0.5 + 0.42)),
|
|
false, 0.0, t.basis.get_euler().y)
|
|
|
|
# --- the copier alcove: the PRINTER is the level boss, on its own desk ---
|
|
if _plan.has_copier():
|
|
var cs := _plan.copier_spot()
|
|
var cxz := Vector2(cs.origin.x, cs.origin.z)
|
|
var cdesk := _glb_piece(DESK_GLB, "wood", cxz, true, 0.0, cs.basis.get_euler().y)
|
|
var printer := _glb_piece(PRINTER_GLB, "steel", cxz, false, cdesk["top"])
|
|
var boss := printer["piece"] as Smashable
|
|
boss.is_boss = true
|
|
boss.toughness_scale = 5.0 # steel x5 — a real fight for anything but the sledge
|
|
boss.mass = 8.0 # heavy: hits rock it but don't shove it off the desk
|
|
(cdesk["piece"] as Smashable).supports.append(printer["piece"])
|
|
|
|
# --- everything else this site contains, straight from its spec ---
|
|
for e in _plan.smashables():
|
|
var d: Dictionary = e
|
|
_glb_piece(P + String(d["glb"]) + ".glb", String(d.get("kind", "wood")),
|
|
d["at"], bool(d.get("frozen", false)), float(d.get("sit_on", 0.0)),
|
|
float(d.get("yaw", 0.0)))
|
|
|
|
# --- crates of records, where the site has any. Records stand side by side ON the
|
|
# --- crate lid, offset by more than a sleeve is thick: they used to be stacked 3 cm
|
|
# --- apart vertically while being 30 cm TALL, so Jolt resolved 27 cm of
|
|
# --- interpenetration explosively on frame one.
|
|
for spot in _plan.spec.get("records", []):
|
|
var crate := _glb_piece(CRATE_GLB, "wood", spot, false)
|
|
var crate_top: float = crate["top"]
|
|
for i in range(3):
|
|
var rec := _place_record(spot + Vector2(randf_range(-0.02, 0.02),
|
|
(float(i) - 1.0) * 0.045), crate_top)
|
|
(crate["piece"] as Smashable).supports.append(rec)
|
|
|
|
# --- chairs: the best thing in an office to send across the room ---
|
|
for t in _plan.chair_spots():
|
|
_glb_piece(CHAIR_GLB, "plastic", Vector2(t.origin.x, t.origin.z), false, 0.0,
|
|
t.basis.get_euler().y)
|
|
|
|
print("[art] records skinned with sleeve art: ", _records_skinned)
|
|
|
|
## Spawn one grabbable Record (sleeve + nested disc) resting on `sit_on`. Mirrors
|
|
## _glb_piece's "drop the AABB bottom onto sit_on" trick, but the Record builds its own
|
|
## visuals/colliders/disc in _ready, so we just skin the sleeve and place it.
|
|
func _place_record(xz: Vector2, sit_on: float) -> Record:
|
|
var rec := Record.new()
|
|
rec.name = "record"
|
|
rec.setup()
|
|
var g: String = GameMode.GENRES[randi() % GameMode.GENRES.size()]
|
|
rec.sleeve_genre = g
|
|
rec.disc_genre = g # matches until GameMode plants a mismatch
|
|
_world.add_child(rec) # _ready() splits record.glb, builds the disc
|
|
if rec.sleeve_visual != null:
|
|
_skin_record(rec.sleeve_visual) # random cover art on the jacket faces
|
|
var ab := _body_local_aabb(rec, rec.sleeve_visual) if rec.sleeve_visual != null else AABB()
|
|
rec.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y)
|
|
_wire(rec) # the empty sleeve breaking counts + juices
|
|
var disc := rec.disc_body()
|
|
if disc != null:
|
|
_wire(disc) # the cracked disc counts + juices too
|
|
_records.append(rec)
|
|
return rec
|
|
|
|
## Instance a GLB as the VISUAL of a Smashable, give it a box collider auto-sized to
|
|
## the mesh AABB, and drop it so its bottom rests at height `sit_on`. Returns
|
|
## { piece: Smashable, top: float } so a caller can stack the next thing on top.
|
|
func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := 0.0,
|
|
yaw := 0.0) -> Dictionary:
|
|
var s := Smashable.new()
|
|
s.kind = kind
|
|
s.start_frozen = frozen
|
|
s.mass = float(MASSES.get(kind, 5.0))
|
|
s.name = path.get_file().get_basename() # so physics diagnostics name the culprit
|
|
# real destruction: if Lane 2 shipped a <name>.fractured.glb next to this GLB, use it
|
|
var frac := path.get_basename() + ".fractured.glb"
|
|
if ResourceLoader.exists(frac):
|
|
s.fractured_scene = load(frac) as PackedScene
|
|
var packed := load(path) as PackedScene
|
|
var vis: Node3D = packed.instantiate()
|
|
s.add_child(vis)
|
|
if kind == "vinyl":
|
|
_skin_record(vis) # random sleeve art on the jacket faces
|
|
_world.add_child(s) # in-tree so transforms/AABBs resolve
|
|
# AABB is measured BEFORE yaw so the collider stays axis-aligned in body space; the
|
|
# body is then rotated as a whole, which keeps box/mesh in agreement.
|
|
var ab := _body_local_aabb(s, vis)
|
|
if ab.size == Vector3.ZERO:
|
|
ab = AABB(Vector3(-0.2, 0.0, -0.2), Vector3(0.4, 0.4, 0.4)) # fallback
|
|
var col := CollisionShape3D.new()
|
|
var box := BoxShape3D.new()
|
|
box.size = ab.size
|
|
col.shape = box
|
|
col.position = ab.get_center()
|
|
s.add_child(col)
|
|
s.rotation.y = yaw
|
|
s.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) # AABB bottom lands on sit_on
|
|
s.set_meta("spawn_pos", s.global_position) # for the spawn-guard warning
|
|
_wire(s)
|
|
return {"piece": s, "top": sit_on + ab.size.y, "aabb": ab}
|
|
|
|
## Give a record's sleeve faces a random cover from the generated 3x3 sheets.
|
|
## The record.glb sleeve cube carries M_Sleeve_Front / M_Sleeve_Back materials
|
|
## with real UVs — we duplicate them and window one tile of the atlas via
|
|
## uv1_scale/uv1_offset (no image slicing, one texture serves nine covers).
|
|
func _skin_record(vis: Node3D) -> void:
|
|
if _sleeve_textures.is_empty():
|
|
for p in SLEEVE_ATLASES:
|
|
var t := load(p) as Texture2D
|
|
if t != null:
|
|
_sleeve_textures.append(t)
|
|
if _sleeve_textures.is_empty():
|
|
return # art not imported yet — records stay plain
|
|
var tex: Texture2D = _sleeve_textures[randi() % _sleeve_textures.size()]
|
|
var tile_col := randi() % 3
|
|
var tile_row := randi() % 3
|
|
var mis: Array[MeshInstance3D] = []
|
|
_collect_meshes(vis, mis)
|
|
var did := false
|
|
for mi in mis:
|
|
if mi.mesh == null:
|
|
continue
|
|
for si in mi.mesh.get_surface_count():
|
|
var mat := mi.mesh.surface_get_material(si)
|
|
if mat == null or not (mat is BaseMaterial3D):
|
|
continue
|
|
var nm: String = mat.resource_name
|
|
if nm.contains("Sleeve_Front") or nm.contains("Sleeve_Back"):
|
|
var dup := mat.duplicate() as BaseMaterial3D
|
|
dup.albedo_color = Color.WHITE
|
|
dup.albedo_texture = tex
|
|
dup.uv1_scale = Vector3(1.0 / 3.0, 1.0 / 3.0, 1.0)
|
|
dup.uv1_offset = Vector3(tile_col / 3.0, tile_row / 3.0, 0.0)
|
|
mi.set_surface_override_material(si, dup)
|
|
did = true
|
|
if did:
|
|
_records_skinned += 1
|
|
|
|
## Combined AABB of every MeshInstance3D under `visual_root`, in `body`'s local space
|
|
## (independent of where `body` currently sits in the world).
|
|
func _body_local_aabb(body: Node3D, visual_root: Node3D) -> AABB:
|
|
var mis: Array[MeshInstance3D] = []
|
|
_collect_meshes(visual_root, mis)
|
|
var inv := body.global_transform.affine_inverse()
|
|
var acc := AABB()
|
|
var started := false
|
|
for mi in mis:
|
|
var a := mi.get_aabb()
|
|
var rel := inv * mi.global_transform
|
|
for i in range(8):
|
|
var corner := a.position + Vector3(
|
|
a.size.x * float(i & 1),
|
|
a.size.y * float((i >> 1) & 1),
|
|
a.size.z * float((i >> 2) & 1))
|
|
var p := rel * corner
|
|
if not started:
|
|
acc = AABB(p, Vector3.ZERO)
|
|
started = true
|
|
else:
|
|
acc = acc.expand(p)
|
|
return acc
|
|
|
|
func _collect_meshes(n: Node, acc: Array[MeshInstance3D]) -> void:
|
|
if n is MeshInstance3D:
|
|
acc.append(n)
|
|
for c in n.get_children():
|
|
_collect_meshes(c, acc)
|
|
|
|
func _piece(kind: String, size: Vector3, pos: Vector3, frozen: bool, cylinder := false) -> Smashable:
|
|
var s := Smashable.new()
|
|
s.kind = kind
|
|
s.start_frozen = frozen
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = Smashable.PROFILES[kind]["color"]
|
|
var mesh := MeshInstance3D.new()
|
|
mesh.material_override = mat
|
|
var col := CollisionShape3D.new()
|
|
if cylinder:
|
|
var cm := CylinderMesh.new()
|
|
cm.top_radius = size.x
|
|
cm.bottom_radius = size.x
|
|
cm.height = size.y
|
|
mesh.mesh = cm
|
|
var cs := CylinderShape3D.new()
|
|
cs.radius = size.x
|
|
cs.height = size.y
|
|
col.shape = cs
|
|
else:
|
|
var bm := BoxMesh.new()
|
|
bm.size = size
|
|
mesh.mesh = bm
|
|
var bs := BoxShape3D.new()
|
|
bs.size = size
|
|
col.shape = bs
|
|
s.add_child(mesh)
|
|
s.add_child(col)
|
|
_world.add_child(s) # _ready() runs here (kind + frozen already set)
|
|
s.global_position = pos
|
|
_wire(s)
|
|
return s
|
|
|
|
# ---------------------------------------------------------------- spawn guard
|
|
## A prop that spawns even slightly inside another collider gets depenetrated by Jolt,
|
|
## and depenetration is not gentle: a chair overlapping a table left the building at
|
|
## 450 m/s on frame one. Placing 30-odd props by formula means this WILL happen again
|
|
## the next time a room is re-laid-out, so it's handled systemically rather than by
|
|
## nudging coordinates until the symptom goes away.
|
|
##
|
|
## For the first fraction of a second, dynamic bodies are speed-limited — enough for a
|
|
## real overlap to push apart calmly, far too little to launch anything. Each offender
|
|
## is named once in a warning, so the underlying overlap stays visible and fixable
|
|
## instead of being silently papered over.
|
|
const SPAWN_GUARD_TIME := 0.75
|
|
const SPAWN_MAX_SPEED := 2.5
|
|
const SPAWN_MAX_SPIN := 10.0
|
|
var _spawn_guard := 0.0
|
|
var _guard_warned := {}
|
|
|
|
func _physics_process(dt: float) -> void:
|
|
if _spawn_guard <= 0.0:
|
|
return
|
|
_spawn_guard -= dt
|
|
for n in get_tree().get_nodes_in_group("smashable"):
|
|
var b := n as RigidBody3D
|
|
if b == null or b.freeze:
|
|
continue
|
|
var v := b.linear_velocity.length()
|
|
if v > SPAWN_MAX_SPEED:
|
|
if not _guard_warned.has(b):
|
|
_guard_warned[b] = true
|
|
# report where it was PLACED, not where it is now — at 500 m/s a body is
|
|
# already 8 m away by the time this runs, which points at nothing useful
|
|
var at = b.get_meta("spawn_pos", b.global_position)
|
|
push_warning("[spawn] %s (%s) placed overlapping something at %s (%.0f m/s) — clamped" % [
|
|
b.name, (b as Smashable).kind if b is Smashable else "?",
|
|
(at as Vector3).snappedf(0.01), v])
|
|
b.linear_velocity = b.linear_velocity.normalized() * SPAWN_MAX_SPEED
|
|
if b.angular_velocity.length() > SPAWN_MAX_SPIN:
|
|
b.angular_velocity = b.angular_velocity.normalized() * SPAWN_MAX_SPIN
|
|
|
|
func _on_smashed(kind: String, at: Vector3, shards: int) -> void:
|
|
_smash_count += 1
|
|
_tally[kind] = int(_tally.get(kind, 0)) + 1
|
|
if _juice != null:
|
|
_juice.impact(kind, at, shards)
|
|
# score AFTER impact() so this break's own combo step is counted
|
|
_score += int(POINTS.get(kind, 15)) * maxi(_juice.combo(), 1)
|
|
else:
|
|
_score += int(POINTS.get(kind, 15))
|
|
# Rage drains a FLAT amount per object; the payslip bills each object at its own
|
|
# value. That gap is the whole decision during a meltdown — break a lot of cheap
|
|
# things fast, or take one expensive swing and pay for it.
|
|
if _rage != null:
|
|
_rage.on_smashed()
|
|
if _shift != null:
|
|
_shift.on_smashed(int(POINTS.get(kind, 15)))
|
|
if _modes != null:
|
|
_modes.on_smashed(int(POINTS.get(kind, 15)))
|
|
if _game_mode != null:
|
|
_game_mode.on_smashed() # scores the mess during a FindMisfiled round
|
|
|
|
## A swing that couldn't hurt what it hit. No score, no combo — just the clank, plus a
|
|
## one-time nudge toward the right tool.
|
|
func _on_resisted(kind: String, at: Vector3) -> void:
|
|
if _juice != null:
|
|
_juice.resist(kind, at)
|
|
if _hud != null:
|
|
_hud.toast("%s shrugs it off — try a heavier tool" % kind.to_upper())
|
|
|
|
func _on_damaged(kind: String, at: Vector3, frac: float) -> void:
|
|
if _juice != null:
|
|
_juice.dent(kind, at, frac)
|
|
|
|
## Every Smashable routes its three outcomes here.
|
|
func _wire(s: Smashable) -> void:
|
|
s.smashed.connect(_on_smashed)
|
|
s.resisted.connect(_on_resisted)
|
|
s.damaged.connect(_on_damaged)
|
|
|
|
# ---------------------------------------------------------------- input
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
# Swings, weapon slots (1-6 / wheel / Q), grab (E/G/drag) and Esc live in Player.gd.
|
|
# Number keys are the LOADOUT now, so game modes moved to M and the HUD cycles on H.
|
|
if event is InputEventKey and event.pressed and not event.echo:
|
|
match (event as InputEventKey).keycode:
|
|
KEY_B:
|
|
_rain(500)
|
|
KEY_R:
|
|
_reset()
|
|
KEY_M:
|
|
if _modes != null:
|
|
_modes.cycle()
|
|
KEY_G:
|
|
if _modes != null and _modes.mode == Modes.M.GAUNTLET:
|
|
_start_gauntlet()
|
|
KEY_H:
|
|
if _hud != null:
|
|
_hud.cycle()
|
|
KEY_L:
|
|
_load_level(Levels.next_id(_level_id))
|
|
KEY_F:
|
|
if _game_mode != null:
|
|
_game_mode.report() # report the disc you're holding (FindMisfiled)
|
|
|
|
## Swap sites without restarting: tear down the shell and everything in it, build the
|
|
## new spec, and re-arm the shift. The player is moved to the new spawn because the old
|
|
## one is very likely now inside a wall.
|
|
func _load_level(id: String) -> void:
|
|
_level_id = id
|
|
for c in _world.get_children():
|
|
c.queue_free()
|
|
_plan.queue_free()
|
|
if _env_node != null:
|
|
_env_node.queue_free()
|
|
await get_tree().process_frame
|
|
|
|
_plan = Floorplan.new()
|
|
add_child(_plan)
|
|
_plan.build(Levels.get_level(_level_id))
|
|
_env_node = WorldEnvironment.new()
|
|
var env := Environment.new()
|
|
_plan.configure_environment(env)
|
|
_env_node.environment = env
|
|
add_child(_env_node)
|
|
|
|
_player.global_position = _plan.spawn_point()
|
|
_player.rotation.y = _plan.spawn_yaw()
|
|
_player.velocity = Vector3.ZERO
|
|
|
|
_smash_count = 0
|
|
_score = 0
|
|
_tally.clear()
|
|
_populate()
|
|
_spawn_guard = SPAWN_GUARD_TIME
|
|
_guard_warned.clear()
|
|
|
|
_tasks.clear_stations()
|
|
_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:
|
|
_shift.begin()
|
|
if _hud != null:
|
|
_hud.toast("%s — %s" % [_plan.level_name(), _plan.subtitle()])
|
|
|
|
## Switching mode re-arms whatever that mode needs. Cubicle Hell and the Gauntlet are
|
|
## mutually exclusive — you cannot be doing your job and shredding the Henderson file at
|
|
## the same time — so entering one shuts the other down.
|
|
func _on_mode_changed(m: int) -> void:
|
|
var hell := m == Modes.M.CUBICLE_HELL
|
|
_shift.running = hell
|
|
_ambience.running = hell
|
|
_tasks.clear_stations()
|
|
if hell:
|
|
_register_stations()
|
|
_shift.begin()
|
|
_gauntlet.running = false
|
|
if m == Modes.M.GAUNTLET:
|
|
_start_gauntlet()
|
|
_modes.begin(get_tree().get_nodes_in_group("smashable").size())
|
|
if _hud != null:
|
|
_hud.toast(_modes.name_of())
|
|
|
|
## Build the cabinet bank in this site's records area and stand the player at the row.
|
|
## Placing it relative to wherever the player happened to be standing put it through
|
|
## walls and behind them; every level now nominates the spot.
|
|
func _start_gauntlet() -> void:
|
|
var g: Dictionary = _plan.spec.get("gauntlet", {})
|
|
var origin: Vector3 = g.get("at", _player.global_position
|
|
+ (-_player.global_transform.basis.z) * 3.4)
|
|
var yaw := float(g.get("yaw", _player.rotation.y + PI))
|
|
_gauntlet.begin(_world, origin, yaw, _wire, _find_shredder(g))
|
|
if g.has("stand"):
|
|
_player.global_position = g["stand"]
|
|
_player.velocity = Vector3.ZERO
|
|
_player.rotation.y = yaw + PI # face the row
|
|
|
|
## The level's perfect tool. Deliberately NOT signposted — see Gauntlet.gd.
|
|
func _find_shredder(g: Dictionary) -> Node3D:
|
|
for n in get_tree().get_nodes_in_group("smashable"):
|
|
if (n as Node).name.begins_with("shredder"):
|
|
return n as Node3D
|
|
# the site doesn't already have one, so put it where the spec says — in the room,
|
|
# in plain sight, and completely unremarked upon
|
|
var at: Vector3 = g.get("shredder", Vector3(
|
|
_player.global_position.x + 2.2, 0.0, _player.global_position.z - 2.6))
|
|
var res := _glb_piece(P + "shredder.glb", "plastic", Vector2(at.x, at.z), false)
|
|
return res["piece"] as Node3D
|
|
|
|
# The Day-0 stress gate: rain N bodies, watch the FPS counter hold.
|
|
func _rain(n: int) -> void:
|
|
for i in n:
|
|
var b := RigidBody3D.new()
|
|
b.add_to_group("debris")
|
|
var mesh := MeshInstance3D.new()
|
|
var bm := BoxMesh.new()
|
|
bm.size = Vector3(0.12, 0.12, 0.12)
|
|
mesh.mesh = bm
|
|
b.add_child(mesh)
|
|
var col := CollisionShape3D.new()
|
|
var shape := BoxShape3D.new()
|
|
shape.size = bm.size
|
|
col.shape = shape
|
|
b.add_child(col)
|
|
_world.add_child(b)
|
|
b.global_position = Vector3(randf_range(-2.2, 2.2), randf_range(3.5, 8.0), randf_range(-2.2, 2.2))
|
|
|
|
func _reset() -> void:
|
|
for c in _world.get_children():
|
|
c.queue_free()
|
|
_smash_count = 0
|
|
_score = 0
|
|
_tally.clear()
|
|
await get_tree().process_frame
|
|
_populate()
|
|
_spawn_guard = SPAWN_GUARD_TIME
|
|
_guard_warned.clear()
|
|
if _shift != null:
|
|
_shift.begin()
|
|
# re-arm whatever mode we're in against the fresh set of records
|
|
if _game_mode != null:
|
|
if _game_mode.mode == GameMode.Mode.FIND_MISFILED:
|
|
_game_mode.begin_find_misfiled(_records)
|
|
else:
|
|
_game_mode.begin_free_play(_records)
|
|
|
|
## Switch mode and rebuild the store fresh so a round always starts clean.
|
|
func _switch_mode(m: int) -> void:
|
|
if _game_mode == null:
|
|
return
|
|
_game_mode.mode = m # _reset() reads this to pick begin_free_play/begin_find_misfiled
|
|
await _reset()
|
|
|
|
# ---------------------------------------------------------------- HUD
|
|
func _setup_hud() -> void:
|
|
_hud = Hud.new()
|
|
add_child(_hud)
|
|
if _player != null:
|
|
_player.weapon_changed.connect(_on_weapon_changed)
|
|
|
|
## Whichever mode is running gets the status line.
|
|
func _mode_line() -> String:
|
|
if _modes == null:
|
|
return ""
|
|
match _modes.mode:
|
|
Modes.M.CUBICLE_HELL:
|
|
return _shift.hud_line() if _shift != null else ""
|
|
Modes.M.GAUNTLET:
|
|
return _gauntlet.hud_line() if _gauntlet != null else ""
|
|
return _modes.hud_line()
|
|
|
|
func _on_weapon_changed(w: Weapon) -> void:
|
|
if _hud != null and w != null:
|
|
_hud.toast(w.display_name)
|
|
|
|
const KEYS_LINE := "WASD move · 1-6 / wheel weapon · Q swap · LMB swing · E grab · drag ←→ pull · G drop · F report · M mode · H hud · L site · B rain · R reset"
|
|
|
|
func _process(_dt: float) -> void:
|
|
if _hud == null:
|
|
return
|
|
var bodies := get_tree().get_nodes_in_group("smashable").size() \
|
|
+ get_tree().get_nodes_in_group("debris").size()
|
|
var w: Weapon = _player.weapon() if _player != null else null
|
|
if _tasks != null:
|
|
var slip := PackedStringArray()
|
|
if _modes != null and _modes.mode == Modes.M.CUBICLE_HELL \
|
|
and _shift != null and not _shift.running and _shift.time_left <= 0.0:
|
|
slip = _shift.payslip()
|
|
_hud.feed_work(_tasks.panel_state(), _tasks.prompt(), slip)
|
|
_hud.feed({
|
|
"fps": Engine.get_frames_per_second(),
|
|
"bodies": bodies,
|
|
"smashed": _smash_count,
|
|
"score": _score,
|
|
"tally": _tally,
|
|
"combo": _juice.combo() if _juice != null else 0,
|
|
"combo_decay": _juice.combo_decay() if _juice != null else 0.0,
|
|
"weapon": w.display_name 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 "",
|
|
"slot": _player.slot() if _player != null else 0,
|
|
"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,
|
|
"rage": _rage.value if _rage != null else 0.0,
|
|
"rage_state": _rage.state_name() if _rage != null else "",
|
|
"rage_status": _rage.status_line() if _rage != null else "",
|
|
"pay": _shift.wages() if _shift != null else 0,
|
|
"damages": _shift.damages if _shift != null else 0,
|
|
"net": _shift.net() if _shift != null else 0,
|
|
})
|