The only site whose contents are mostly SOFT, and that one material change rewrites the
level. `produce` is a new Smashable material that throws no shards at all — Splat.gd
bursts it instead: coloured pulp, a puddle that STAYS, a squelch, and litres counted,
because "how much juice did you make" is a far better score for a greengrocer than "how
many objects did you destroy".
Two knock-ons make the level play itself, both falling out of existing systems rather
than needing new ones:
* produce has a low brittle_speed, so anything heavy landing on it squashes it — which
means a pyramid crushes its OWN bottom layer as it collapses, with no special code.
Knock one off the top and the pile does the rest. Shove test: 257 items -> 45, and
42 litres of juice out of the collapse.
* puddles are slippery, cutting ACCELERATION rather than top speed so it reads as
sliding rather than as being slowed down. The more mess you make, the less the floor
cooperates.
Stacks are built bottom-up by Main._build_stack and nothing is glued or frozen — they
stand because they are stacked, so pulling one out of the bottom row does what you'd
hope. 258 produce items spawn and settle to 0.00 m/s with no spawn crush. The glass
bottles behind the juice bar are the deliberate exception: one shelf in the room that
still rewards a proper swing, and the contrast between litres and shards is the joke.
Round produce is authored CENTRED, not floor-centred like every other prop in this repo,
because a rolling body whose origin sits on the floor plane wobbles like a loaded die.
_glb_piece grew a `centred` flag for it.
REAL BUG FOUND, and it was costing hits in every level: _strike took the NEAREST collider
of any kind, so a static surface could eat a swing. Leaning over a display table, the
table edge is a few centimetres nearer than the fruit piled on it, so every swing hit the
table and nothing broke — 0/306 for the whole first take. It now prefers a Smashable and
only falls back to loose bodies if there isn't one. The offices were quietly losing hits
on desks and shelves the same way.
tools/gen_produce.py: apple, orange, tomato, watermelon, cabbage, banana hand, glass
juice bottle, display crate. The melon's stripes were radial boxes first time and it came
out looking like a sea mine; they're thin lenses through the middle now.
dev/probe_grocer.gd verifies stacks settle, that produce yields litres and NO debris,
that a collapse crushes, and that none of it leaks to the next site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
795 lines
30 KiB
GDScript
795 lines
30 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
|
|
|
|
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)
|
|
_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")
|
|
# 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)
|
|
|
|
# --- 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
|
|
|
|
## 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. Rows are inset and the whole
|
|
## thing spawns a few millimetres apart so the spawn guard has nothing to complain about.
|
|
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 per item, so the lattice spacing matches what's actually being stacked
|
|
var radii := {"apple": 0.041, "orange": 0.038, "tomato": 0.034,
|
|
"melon": 0.150, "cabbage": 0.084}
|
|
var r: float = radii.get(item, 0.040)
|
|
var step: float = r * 2.06 # a whisker of air between neighbours
|
|
var lift: float = r * 1.72 # rows nest into the gaps below
|
|
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)
|
|
|
|
## 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()
|
|
var box := BoxShape3D.new()
|
|
box.size = ab.size
|
|
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 := 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 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)))
|
|
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
|
|
|
|
## 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,
|
|
})
|