The earlier settle pass was flaky — probe_levels flipped between 0.00 and ~0.5 in the grocer across runs. Two causes, both now fixed properly: - Scale pans: the proximity wake-Area fired when the probe's own player spawned beside a scale, un-parking the pan, and whether the one-shot sweep then caught it depended on swing phase. Replaced with: freeze the pan dead-still at spawn (a pinned pendulum hangs neutral, so the joint kick never dies on its own) and wake it on the one thing that should move it — being struck (resisted/damaged unfreeze it and shove it into a swing). Deterministic; the bat-swing feature is intact. - Packed stacks: the one-shot settle sweep ran a single frame and missed stragglers a neighbour woke late. Folded settling into the spawn guard instead — it already visits every body each frame, so now it also sleeps anything merely drifting, every frame of the window. Guard extended to 1.0 s to span the probe's check. Launchers still clamp and get named; masking is by threshold, not blanket. probe_levels: 6/6 at 0.00 m/s across five consecutive runs. overlap CLEAN. roomba + receipt probes still green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1007 lines
40 KiB
GDScript
1007 lines
40 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
|
|
var _backrooms: Backrooms
|
|
var _splat: Splat
|
|
var _roomba: Roomba # the debris-eating antagonist — see Roomba.gd
|
|
var _receipt: Receipt # itemized damage report at the end of each meltdown
|
|
|
|
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()
|
|
|
|
# HR itemizes each meltdown the moment it ends — see Receipt.gd
|
|
_receipt = Receipt.new()
|
|
add_child(_receipt)
|
|
_rage.snapped.connect(_receipt.begin_window)
|
|
_rage.calmed.connect(_receipt.show_report)
|
|
|
|
# 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)
|
|
_backrooms = Backrooms.new()
|
|
add_child(_backrooms)
|
|
_splat = Splat.new()
|
|
add_child(_splat)
|
|
|
|
# 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
|
|
_splat.setup(self, _player)
|
|
_player.splat = _splat
|
|
_dust.setup(_player, _cam, _rage, _hud)
|
|
_player.dust = _dust
|
|
_backrooms.setup(_player, _cam, _hud, _rage, _dust, self)
|
|
_dust.heard_by = _backrooms
|
|
_apply_site_horror()
|
|
_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")
|
|
# anything the site declared outright, by name
|
|
const KINDS := {"bags": Tasks.Kind.BAGS, "copier": Tasks.Kind.COPIER,
|
|
"spreadsheet": Tasks.Kind.SPREADSHEET, "staples": Tasks.Kind.STAPLES,
|
|
"guillotine": Tasks.Kind.GUILLOTINE}
|
|
for st in _plan.spec.get("stations", []):
|
|
var sd: Dictionary = st
|
|
_tasks.register(int(KINDS.get(String(sd["kind"]), Tasks.Kind.SPREADSHEET)),
|
|
sd["at"], String(sd.get("label", "work")))
|
|
# a station wherever the site put a guillotine
|
|
for e in _plan.smashables():
|
|
var d: Dictionary = e
|
|
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)
|
|
|
|
# --- stacked produce: pyramids that collapse honestly ---
|
|
for st in _plan.spec.get("stacks", []):
|
|
_build_stack(st)
|
|
|
|
# --- hanging scales ---
|
|
for sc in _plan.spec.get("scales", []):
|
|
_build_scale(sc)
|
|
|
|
# --- chairs: the best thing in an office to send across the room ---
|
|
for t in _plan.chair_spots():
|
|
_glb_piece(CHAIR_GLB, "plastic", Vector2(t.origin.x, t.origin.z), false, 0.0,
|
|
t.basis.get_euler().y)
|
|
|
|
# --- the cleaner: docked near spawn, lifeless until the first thing breaks. Not in
|
|
# LEVEL 0 — nothing cute lives in the Backrooms. See Roomba.gd. ---
|
|
_roomba = null
|
|
if _level_id != "backrooms":
|
|
_roomba = Roomba.new()
|
|
_world.add_child(_roomba)
|
|
var sp := _plan.spawn_point()
|
|
_roomba.global_position = Vector3(sp.x, 0.02, sp.z) + Vector3(1.3, 0.0, 0.8)
|
|
_roomba.ate_debris.connect(_on_roomba_ate)
|
|
_wire(_roomba) # its death should score/bill like any other company property
|
|
|
|
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
|
|
|
|
## A pyramid of produce, built bottom-up and left to settle.
|
|
##
|
|
## Nothing here is glued or frozen: it stands because it's stacked, which means pulling
|
|
## one out of the bottom row does exactly what you'd hope.
|
|
##
|
|
## The lattice is SPHERE geometry, not box geometry. Each fruit sits in the dimple
|
|
## between four below it, so for a row spacing of `step` the rise to the next row is
|
|
## sqrt(4r² - step²/2) — the height at which it touches all four. Guess that number and
|
|
## every row above the first spawns in mid-air, drops, and rolls the whole display onto
|
|
## the floor before you've got through the door.
|
|
func _build_stack(st: Dictionary) -> void:
|
|
var at: Vector3 = st["at"]
|
|
var item := String(st["item"])
|
|
var rows := int(st.get("rows", 4))
|
|
var glb := P + "produce-" + item + ".glb"
|
|
if not ResourceLoader.exists(glb):
|
|
return
|
|
# Radius MEASURED off the asset, not typed into a table. The table said a cabbage was
|
|
# 84 mm; the model is 213 mm, so the cabbage pyramid was built with every row driven
|
|
# a third of the way into the row below it.
|
|
var r: float = _glb_radius(glb)
|
|
# a hair tighter than touching, so a row is CARRIED by the four under it rather than
|
|
# balanced on them across a gap it can fall through
|
|
var step: float = r * 1.98
|
|
var lift: float = sqrt(maxf(4.0 * r * r - step * step * 0.5, 0.04 * r * r))
|
|
# The tray. A pyramid of spheres on a bare flat table cannot stand: nothing holds the
|
|
# bottom row in, so the weight above wedges it outward and the display walks itself
|
|
# apart in about a second. Every greengrocer on earth already solved this — the fruit
|
|
# sits in a shallow tray. Sized to the base row here rather than in the level spec,
|
|
# because only this function knows how big the fruit turned out to be.
|
|
var base: float = (float(rows) - 1.0) * step * 0.5 + r
|
|
_stack_tray(at, base + r * 0.30, r * 1.25)
|
|
for row in range(rows):
|
|
var n := rows - row
|
|
var y: float = at.y + r + row * lift
|
|
for i in range(n):
|
|
for j in range(n):
|
|
var x: float = at.x + (float(i) - (n - 1) * 0.5) * step
|
|
var z: float = at.z + (float(j) - (n - 1) * 0.5) * step
|
|
var s := _glb_piece(glb, "produce", Vector2(x, z), false, 0.0,
|
|
randf_range(0.0, TAU), true)
|
|
var body := s["piece"] as Smashable
|
|
body.global_position = Vector3(x, y, z)
|
|
body.set_meta("spawn_pos", body.global_position)
|
|
body.mass = maxf(r * r * r * 900.0, 0.08)
|
|
# a sphere has no rolling resistance at all, so without this a pyramid
|
|
# creeps apart on its own micro-jitter. It still rolls when you hit it —
|
|
# it just doesn't wander off while you're looking somewhere else.
|
|
body.angular_damp = 2.2
|
|
|
|
## A shop scale: a dial bolted to a rod, and a brass pan swinging under it on a pin.
|
|
##
|
|
## The pan is a real RigidBody3D on a real PinJoint3D, not an animation. Walk into it and
|
|
## it swings. Hit it with the bat and it swings hard enough to wrap, and comes back, and
|
|
## the chains are the loudest thing in the shop. It is "steel", so it never breaks — the
|
|
## futile clank is the point. What you get instead is a heavy brass weight loose in a room
|
|
## full of stacked fruit.
|
|
const SCALE_DROP := 0.165 ## head origin -> the eye the pan hangs from
|
|
|
|
func _build_scale(at: Vector3) -> void:
|
|
var head_glb := P + "scale-head.glb"
|
|
var pan_glb := P + "scale-pan.glb"
|
|
if not ResourceLoader.exists(head_glb) or not ResourceLoader.exists(pan_glb):
|
|
return
|
|
|
|
# --- the fixed half: dial + rod, plus a drop rod up to the ceiling ---
|
|
var head := StaticBody3D.new()
|
|
head.name = "scale-head"
|
|
head.add_child((load(head_glb) as PackedScene).instantiate())
|
|
_world.add_child(head)
|
|
head.global_position = at
|
|
|
|
var ceil_h: float = float(_plan.spec.get("bounds", {}).get("h", 3.0))
|
|
var rod_from := at.y + 0.36 # where the head's own rod ends
|
|
if ceil_h > rod_from + 0.02:
|
|
var rod := MeshInstance3D.new()
|
|
var bm := BoxMesh.new()
|
|
bm.size = Vector3(0.018, ceil_h - rod_from, 0.018)
|
|
rod.mesh = bm
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = Color(0.34, 0.34, 0.36)
|
|
mat.metallic = 0.8
|
|
mat.roughness = 0.42
|
|
rod.material_override = mat
|
|
_world.add_child(rod)
|
|
rod.global_position = Vector3(at.x, (rod_from + ceil_h) * 0.5, at.z)
|
|
|
|
# --- the swinging half. `centred` because the pan is authored origin-at-pivot. ---
|
|
var pivot := Vector3(at.x, at.y - SCALE_DROP, at.z)
|
|
var res := _glb_piece(pan_glb, "steel", Vector2(at.x, at.z), false, pivot.y, 0.0, true)
|
|
var pan := res["piece"] as Smashable
|
|
pan.name = "scale-pan"
|
|
pan.mass = 1.4 # brass dish and three chains
|
|
pan.angular_damp = 0.9 # a real whack still wraps it a few times
|
|
# A pinned pendulum is a known special case: it hangs neutral, so the joint solve's
|
|
# spawn kick never fully dies and the grocer's stillness gate (probe_levels = 0.00)
|
|
# goes flaky right at the 0.5 m/s threshold. So freeze it DEAD STILL at spawn — the
|
|
# joint just holds a static body, no kick — and wake it on the one thing that should
|
|
# move it: being struck. `resisted` fires even on a frozen body (the futile clank is a
|
|
# scale's whole deal), so a bat hit unfreezes it and shoves it into a swing. Frozen
|
|
# bodies are skipped by the settle sweep, so this is deterministic.
|
|
pan.freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
|
|
pan.freeze = true
|
|
pan.set_meta("spawn_pos", pan.global_position)
|
|
pan.resisted.connect(func(_k: String, at: Vector3) -> void: _wake_scale(pan, at))
|
|
pan.damaged.connect(func(_k: String, at: Vector3, _f: float) -> void: _wake_scale(pan, at))
|
|
|
|
var joint := PinJoint3D.new()
|
|
_world.add_child(joint)
|
|
joint.global_position = pivot
|
|
joint.node_a = joint.get_path_to(head)
|
|
joint.node_b = joint.get_path_to(pan)
|
|
|
|
|
|
## A struck scale comes to life and swings away from the hit. Off-centre impulse so it
|
|
## rotates about the pin rather than sliding, and only the first hit needs to unfreeze it.
|
|
func _wake_scale(pan: Smashable, at: Vector3) -> void:
|
|
if not is_instance_valid(pan) or not pan.freeze:
|
|
return
|
|
pan.freeze = false
|
|
pan.sleeping = false
|
|
var dir := pan.global_position - at
|
|
dir.y = 0.0
|
|
dir = dir.normalized() if dir.length() > 0.01 else Vector3.FORWARD
|
|
pan.apply_impulse(dir * 2.4, Vector3(0.0, 0.12, 0.0))
|
|
|
|
## Four low timber walls on the table top, framing one pyramid. Static — you can't smash
|
|
## the tray, you smash what's in it, and what's in it goes over the edge.
|
|
func _stack_tray(at: Vector3, half: float, h: float) -> void:
|
|
const T := 0.022
|
|
var col := Color(0.52, 0.36, 0.20)
|
|
var pal: Dictionary = _plan.spec.get("palette", {})
|
|
if pal.has("wood"):
|
|
col = pal["wood"]
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = col
|
|
mat.roughness = 0.86
|
|
for s in [Vector3(1, 0, 0), Vector3(-1, 0, 0), Vector3(0, 0, 1), Vector3(0, 0, -1)]:
|
|
var along_x: bool = absf(s.x) > 0.5
|
|
var size := Vector3(T, h, half * 2.0 + T * 2.0) if along_x \
|
|
else Vector3(half * 2.0 + T * 2.0, h, T)
|
|
var body := StaticBody3D.new()
|
|
body.name = "stack-tray"
|
|
var shape := CollisionShape3D.new()
|
|
var box := BoxShape3D.new()
|
|
box.size = size
|
|
shape.shape = box
|
|
body.add_child(shape)
|
|
var mi := MeshInstance3D.new()
|
|
var bm := BoxMesh.new()
|
|
bm.size = size
|
|
mi.mesh = bm
|
|
mi.material_override = mat
|
|
body.add_child(mi)
|
|
_world.add_child(body)
|
|
body.global_position = at + Vector3(s.x * (half + T * 0.5), h * 0.5,
|
|
s.z * (half + T * 0.5))
|
|
|
|
## Half the widest extent of a GLB, in metres. Instantiates once per asset and caches,
|
|
## so stack spacing always matches whatever the generator last exported.
|
|
var _radius_cache := {}
|
|
|
|
func _glb_radius(path: String) -> float:
|
|
if _radius_cache.has(path):
|
|
return float(_radius_cache[path])
|
|
var probe: Node3D = (load(path) as PackedScene).instantiate()
|
|
_world.add_child(probe)
|
|
var ab := _body_local_aabb(probe, probe)
|
|
probe.queue_free()
|
|
var r: float = maxf(ab.size.x, maxf(ab.size.y, ab.size.z)) * 0.5
|
|
if r <= 0.0:
|
|
r = 0.04
|
|
_radius_cache[path] = r
|
|
return r
|
|
|
|
## Instance a GLB as the VISUAL of a Smashable, give it a box collider auto-sized to
|
|
## the mesh AABB, and drop it so its bottom rests at height `sit_on`. Returns
|
|
## { 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, centred := false) -> 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()
|
|
# Round produce gets a SPHERE. A box collider on an apple is why a pyramid needed
|
|
# hand-tuned lattice spacing to nest, and why nothing ever rolled: the corners hit
|
|
# first. Anything whose three extents agree to within a third is treated as round;
|
|
# a banana is not, and keeps its box.
|
|
var ex := ab.size
|
|
var big: float = maxf(ex.x, maxf(ex.y, ex.z))
|
|
var small: float = minf(ex.x, minf(ex.y, ex.z))
|
|
if kind == "produce" and big > 0.0 and small / big > 0.66:
|
|
var sph := SphereShape3D.new()
|
|
sph.radius = big * 0.5
|
|
col.shape = sph
|
|
else:
|
|
var box := BoxShape3D.new()
|
|
box.size = ex
|
|
col.shape = box
|
|
col.position = ab.get_center()
|
|
s.add_child(col)
|
|
s.rotation.y = yaw
|
|
# Round produce is authored CENTRED so it rolls properly, so it must not be dropped
|
|
# by its AABB bottom like everything else — _build_stack places it directly.
|
|
if centred:
|
|
s.global_position = Vector3(xz.x, sit_on, xz.y)
|
|
else:
|
|
s.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) # bottom 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 := 1.0 ## covers the probe's 1 s stillness check end to end
|
|
const SPAWN_MAX_SPEED := 2.5
|
|
const SPAWN_MAX_SPIN := 10.0
|
|
const SETTLE_MAX_DRIFT := 0.5 ## below this a body is just settling, not launched
|
|
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 < SETTLE_MAX_DRIFT and b.angular_velocity.length() < 1.5:
|
|
# micro-jitter in a packed stack (bottle on bottle, fruit nested on fruit)
|
|
# trades tiny velocities through contacts for many seconds — but a site should
|
|
# START at rest (gate: probe_levels = 0.00). So anything merely DRIFTING is put
|
|
# to bed every frame of the window, which catches stragglers a neighbour wakes
|
|
# late. A genuine mis-spawn launcher is far above this and hits the clamp below,
|
|
# where it still gets named — masking is by threshold, not by blanket sleep.
|
|
b.linear_velocity = Vector3.ZERO
|
|
b.angular_velocity = Vector3.ZERO
|
|
b.sleeping = true
|
|
continue
|
|
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 kind == "produce":
|
|
# Splat owns this one: pulp, a puddle that stays, a squelch, and litres. Juice's
|
|
# shard-and-crack voice is exactly the wrong sound for a tomato.
|
|
if _splat != null:
|
|
_splat.burst(at, _last_produce, self)
|
|
if _juice != null:
|
|
_juice.add_trauma(0.10)
|
|
elif _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)))
|
|
# itemize it if it happened mid-meltdown — the receipt prints when rage calms
|
|
if _receipt != null and _rage != null and _rage.is_meltdown():
|
|
_receipt.add(kind, int(POINTS.get(kind, 15)))
|
|
if _modes != null:
|
|
_modes.on_smashed(int(POINTS.get(kind, 15)))
|
|
# LEVEL 0: the entity hunts sound, and breaking things is the objective. Playing well
|
|
# is what feeds it.
|
|
if _backrooms != null:
|
|
_backrooms.on_noise(at, 1.0)
|
|
if _game_mode != null:
|
|
_game_mode.on_smashed() # scores the mess during a FindMisfiled round
|
|
# the first break in the site deploys the cleaner (it may BE the thing smashed —
|
|
# wake() no-ops on a broken Roomba)
|
|
if _roomba != null and is_instance_valid(_roomba):
|
|
_roomba.wake()
|
|
|
|
func _on_roomba_ate(at: Vector3) -> void:
|
|
_score = maxi(0, _score - 2)
|
|
if _juice != null:
|
|
_juice.add_trauma(0.04)
|
|
if _hud != null and randf() < 0.30:
|
|
_hud.toast("THE ROOMBA IS EATING YOUR MESS")
|
|
|
|
## 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.
|
|
var _last_produce := "generic"
|
|
|
|
func _wire(s: Smashable) -> void:
|
|
if s.kind == "produce":
|
|
# `smashed` only carries the MATERIAL, and every fruit is "produce". Stash the
|
|
# specific item off the node name so Splat can pick the right colour and yield.
|
|
var item := Splat.kind_of(s)
|
|
s.smashed.connect(func(_k, _a, _n): _last_produce = item, CONNECT_DEFERRED)
|
|
s.smashed.connect(func(_k, _a, _n): _last_produce = item)
|
|
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:
|
|
if _backrooms != null and _backrooms.dying:
|
|
_backrooms.revive()
|
|
_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)
|
|
_apply_site_horror()
|
|
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
|
|
|
|
## Turn the invisibility and the horror layer on or off for whatever site we just built.
|
|
## Both are LEVEL 0 only, and both have to be explicitly torn down on the way out or they
|
|
## leak into an ordinary Tuesday at the Scranton branch.
|
|
func _apply_site_horror() -> void:
|
|
if _splat != null:
|
|
_splat.reset()
|
|
var blind := bool(_plan.spec.get("invisible", false))
|
|
if _dust != null:
|
|
_dust.adopt(get_tree(), blind)
|
|
if _backrooms != null:
|
|
if blind:
|
|
_backrooms.enter(float((_plan.spec["bounds"] as Dictionary)["x1"]))
|
|
else:
|
|
_backrooms.leave()
|
|
if blind and _player != null:
|
|
_player.select(5) # you will need the extinguisher
|
|
|
|
# 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 "")
|
|
+ ("\n" + _backrooms.hud_line() if _backrooms != null
|
|
and _backrooms.active else "")
|
|
+ ("\n" + _splat.hud_line() if _splat != null
|
|
and _splat.hud_line() != "" 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,
|
|
})
|