LANE7: you can die in LEVEL 0, and how you died decides which death you get
NO HEALTH BAR. Nothing else in this game does damage, so hit points would be a
survival-horror UI bolted onto a demolition game. Instead: one meter, DREAD, fed by
every way this place gets at you — being stared at, something close behind you, each
loop, and your own coughing. It decays when nothing is happening to you, and every loop
makes it grip harder (DREAD_LOOP_GRIP), so the place gets better at this the longer you
stay.
At 100% you die, and WHICH source pushed it over decides the death, which is what keeps
the three memes legible at the one moment it matters:
STARE "YOU LOOKED TOO LONG / it was still. it was always still. that was never the
safe part." (Slenderman)
REACH "IT WAS ALREADY IN THE ROOM / you were making so much noise."
Sprays. This is the gory one. (the xenomorph)
LOOP "YOU ARE STILL IN THE ROOM / you walked it eleven times. you will walk it
again." Eleven loops and the room simply keeps you. (Blair Witch)
Gore.gd is procedural — seeded blobs that swell on the lens and then bleed downward in
streaks, a wet burst at the camera (physical deaths only; being taken by the static
shouldn't spray), the camera going down to land on its side on the carpet, then the
card. The sequence is deliberately SLOW: a jumpscare is a spike, but a death should take
its time, because the seconds spent watching the lens settle are where it lands. R wakes
you up.
Player.set_frozen() so you can't stroll around during your own death.
Renamed Backrooms' VHS layer to `tape` — `dread` is the meter now, and it was clearer
anyway.
dev/probe_death.gd drives all three deaths and the revive.
Note: the "Parameter material is null" warning only appears under the headless dummy
renderer; windowed runs are clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
006c324f1f
commit
bcd67906a4
@ -233,6 +233,16 @@ Two bugs worth remembering:
|
||||
of the lattice lines, much bigger gaps), which also matches the reference photos far
|
||||
better; they're mostly open floor with occasional slabs.
|
||||
|
||||
Still open: it can only touch you, never end a run. A real fail state, a second entity
|
||||
that behaves differently, and the "did that chair move?" pass on wrapping — the room is
|
||||
identical, so *changing one thing* on a loop would be very cheap and very mean.
|
||||
**DREAD and the three deaths** *(built)* — one meter, not a health bar. Nothing else in
|
||||
this game does damage, so hit points would be a survival-horror UI bolted onto a
|
||||
demolition game. DREAD is fed by being stared at, by proximity while unseen, by each
|
||||
loop, and by your own coughing; it decays when nothing is happening to you, and every
|
||||
loop makes it grip harder. At 100% you die, and whichever source pushed it over decides
|
||||
which death you get, so the three memes stay legible: STARE (Slender), REACH (gory, the
|
||||
xenomorph sprays), LOOP (the room simply keeps you at eleven). `Gore.gd` does blood on
|
||||
the lens with drips, a wet burst, and the camera going down onto the carpet — slow on
|
||||
purpose, because a jumpscare is a spike but a death should take its time.
|
||||
|
||||
Still open: a second entity that behaves differently, and the "did that chair move?" pass
|
||||
on wrapping — the room is identical on every loop, so *changing exactly one thing* would
|
||||
be very cheap and very mean.
|
||||
|
||||
@ -89,9 +89,31 @@ settles → spray again, coughing.
|
||||
land in the same second.
|
||||
|
||||
It can't be fought — weapons pass through. The outs are distance, silence, and not
|
||||
looking, three things the rest of the game actively discourages. Getting caught doesn't
|
||||
kill you; it costs you a lungful, a spike of rage, and the knowledge that it can do that
|
||||
whenever it likes.
|
||||
looking, three things the rest of the game actively discourages.
|
||||
|
||||
### DREAD, and dying
|
||||
|
||||
**No health bar.** Nothing else in this game does damage, so hit points would be a
|
||||
survival-horror UI bolted onto a demolition game. Instead there is one meter — **DREAD** —
|
||||
fed by every way this place gets at you: being stared at, something close behind you,
|
||||
each loop, and your own coughing. It falls when nothing is happening to you, and every
|
||||
loop makes it harder to shake.
|
||||
|
||||
At 100% you die, and **which** death you get is decided by whichever source pushed it
|
||||
over — so the three memes stay legible in the moment it matters:
|
||||
|
||||
| | how | the card |
|
||||
|---|---|---|
|
||||
| **Slenderman** | you looked too long | *YOU LOOKED TOO LONG — it was still. it was always still. that was never the safe part.* |
|
||||
| **the xenomorph** | it reached you, and it sprays | *IT WAS ALREADY IN THE ROOM — you were making so much noise.* |
|
||||
| **Blair Witch** | eleven loops of the same room | *YOU ARE STILL IN THE ROOM — you walked it eleven times. you will walk it again.* |
|
||||
|
||||
`scripts/Gore.gd` does the rest, procedurally: seeded blobs that swell on the lens and
|
||||
then bleed downward in streaks, a wet burst at the camera (physical deaths only — being
|
||||
taken by the static shouldn't spray), and the camera going down to land on its side on
|
||||
the carpet. The sequence is deliberately **slow** — a jumpscare is a spike, but a death
|
||||
should take its time, because the seconds you spend watching the lens settle are where it
|
||||
actually lands. `R` to wake up.
|
||||
|
||||
`scripts/Dread.gd` is the tape: grain, scanlines, chroma split, a wandering tracking
|
||||
tear, and Slender's interference driven straight off the entity's stare — the HUD element
|
||||
|
||||
@ -48,17 +48,14 @@ func _find_player(n: Node) -> Player:
|
||||
return null
|
||||
|
||||
func _build_script() -> void:
|
||||
# LEVEL 0, the horror pass. NOTE the level swap is async and enter() repositions the
|
||||
# entity on wake, so it has to be placed a step LATER or the wake overwrites it.
|
||||
# LEVEL 0: stand in front of it, let the dread max out, and die.
|
||||
_script = [
|
||||
{"dur": 1.2, "move_to": null, "look_at": null, "act": "level:backrooms"},
|
||||
{"dur": 0.3, "move_to": Vector3(0, 0, 4.0), "look_at": Vector3(0, 1.5, -4.0),
|
||||
"act": "ent:0,-4"},
|
||||
{"dur": 2.4, "move_to": null, "look_at": null, "act": ""},
|
||||
{"dur": 2.4, "move_to": Vector3(0, 0, 1.5), "look_at": Vector3(0, 1.6, -4.0), "act": ""},
|
||||
{"dur": 1.6, "move_to": null, "look_at": null, "act": ""},
|
||||
{"dur": 2.6, "move_to": Vector3(0, 0, 18.4), "look_at": Vector3(0, 1.3, 19.0), "act": ""},
|
||||
{"dur": 2.4, "move_to": null, "look_at": Vector3(3.0, 1.3, -8.0), "act": ""},
|
||||
{"dur": 0.3, "move_to": Vector3(0, 0, 3.0), "look_at": Vector3(0, 1.5, -3.0),
|
||||
"act": "ent:0,-3"},
|
||||
{"dur": 2.6, "move_to": null, "look_at": null, "act": ""},
|
||||
{"dur": 0.4, "move_to": null, "look_at": null, "act": "kill"},
|
||||
{"dur": 7.0, "move_to": null, "look_at": null, "act": ""},
|
||||
]
|
||||
|
||||
func _motion(verbose := false) -> float:
|
||||
@ -166,6 +163,11 @@ func _do(act: String) -> void:
|
||||
br.entity.global_position = Vector3(float(p[0]), 0.0, float(p[1]))
|
||||
br.entity.state = Entity.S.STALKING
|
||||
return
|
||||
if act == "kill":
|
||||
var b3 = _main.get("_backrooms")
|
||||
if b3 != null:
|
||||
b3.dread = 0.99
|
||||
return
|
||||
if act == "scare":
|
||||
var br2 = _main.get("_backrooms")
|
||||
if br2 != null and br2.entity != null:
|
||||
|
||||
70
game/dev/probe_death.gd
Normal file
70
game/dev/probe_death.gd
Normal file
@ -0,0 +1,70 @@
|
||||
extends SceneTree
|
||||
|
||||
## All three deaths, and that you can get up again.
|
||||
## Godot --headless --path game --script dev/probe_death.gd
|
||||
|
||||
func _initialize() -> void:
|
||||
var main: Node = (load("res://main.tscn") as PackedScene).instantiate()
|
||||
get_root().add_child(main)
|
||||
await process_frame
|
||||
main._load_level("backrooms")
|
||||
for i in 8:
|
||||
await physics_frame
|
||||
var br = main.get("_backrooms")
|
||||
var player = main.get("_player")
|
||||
|
||||
# --- 1. STARE: park it in view and don't look away ---
|
||||
print("--- stare ---")
|
||||
player.global_position = Vector3.ZERO
|
||||
player.rotation.y = 0.0
|
||||
var placed := false
|
||||
for d in [3.0, 4.0, 5.0, 6.0, 8.0]:
|
||||
br.entity.global_position = Vector3(0, 0, -d)
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
if br.entity.seen_now:
|
||||
placed = true
|
||||
break
|
||||
print(" sightline: ", placed)
|
||||
var ticks := 0
|
||||
while not br.dying and ticks < 900:
|
||||
br.entity.global_position = Vector3(0, 0, -4) # hold it there
|
||||
await physics_frame
|
||||
ticks += 1
|
||||
print(" dying=%s cause=%s after %.1fs dread=%.2f" % [
|
||||
br.dying, br.death_cause, ticks / 60.0, br.dread])
|
||||
print(" player frozen: ", player._frozen)
|
||||
# let the sequence run
|
||||
for i in 300:
|
||||
await physics_frame
|
||||
print(" card shown: ", br._card_shown)
|
||||
|
||||
# --- get up ---
|
||||
br.revive()
|
||||
main._reset()
|
||||
for i in 8:
|
||||
await physics_frame
|
||||
print(" after revive: dying=%s dread=%.2f frozen=%s" % [
|
||||
br.dying, br.dread, player._frozen])
|
||||
|
||||
# --- 2. REACH: a touch while already terrified ---
|
||||
print("--- reach ---")
|
||||
br.dread = 0.80
|
||||
br.entity._cd = 0.0
|
||||
br.entity._catch()
|
||||
await process_frame
|
||||
print(" dying=%s cause=%s" % [br.dying, br.death_cause])
|
||||
br.revive()
|
||||
main._reset()
|
||||
for i in 6:
|
||||
await physics_frame
|
||||
|
||||
# --- 3. LOOP: walk the same room until it keeps you ---
|
||||
print("--- loop ---")
|
||||
br.loops = 10
|
||||
br.dread = 0.0
|
||||
player.global_position = Vector3(0, 0, 18.6)
|
||||
for i in 8:
|
||||
await physics_frame
|
||||
print(" loops=%d dying=%s cause=%s" % [br.loops, br.dying, br.death_cause])
|
||||
quit()
|
||||
@ -19,6 +19,28 @@ class_name Backrooms
|
||||
|
||||
const TOTEM := "res://assets/store/totem.glb"
|
||||
|
||||
## DREAD is the death meter, and it is deliberately the ONLY new number.
|
||||
##
|
||||
## A health bar would be a foreign object in this game — nothing else does damage, and
|
||||
## adding hit points just to have something to subtract from is how you end up with a
|
||||
## survival-horror UI bolted to a demolition game. So instead: one meter, fed by every
|
||||
## way this place gets at you, and at 100% you die.
|
||||
##
|
||||
## Which death you get depends on WHICH input pushed it over, so the three memes stay
|
||||
## distinguishable in the moment it matters:
|
||||
## STARE you looked too long, and the static took you. (Slenderman)
|
||||
## REACH it got to you. (gory; the xenomorph)
|
||||
## LOOP you walked the same room too many times. (Blair Witch)
|
||||
enum Death { NONE, STARE, REACH, LOOP }
|
||||
|
||||
const DREAD_FROM_STARE := 0.155 ## per second at full stare pressure
|
||||
const DREAD_FROM_NEAR := 0.085 ## per second when it's right behind you, unseen
|
||||
const DREAD_FROM_LOOP := 0.11 ## per wrap
|
||||
const DREAD_FROM_LUNGS := 0.030 ## per second while you're coughing your guts up
|
||||
const DREAD_CALM := 0.055 ## per second when nothing is happening to you
|
||||
## Every loop makes the place better at this. Twelve loops and it barely lets go.
|
||||
const DREAD_LOOP_GRIP := 0.055
|
||||
|
||||
## How close to the outer wall triggers a wrap.
|
||||
const WRAP_MARGIN := 1.4
|
||||
## Seconds after a wrap before another can fire (stops thrashing on a corner).
|
||||
@ -26,12 +48,19 @@ const WRAP_COOLDOWN := 1.2
|
||||
|
||||
var active := false
|
||||
var entity: Entity
|
||||
var dread: Dread
|
||||
var tape: Dread ## the found-footage layer
|
||||
var rage: Rage
|
||||
var dust: Dust
|
||||
|
||||
var loops := 0 ## how many times the room has come back around
|
||||
var caught := 0
|
||||
var dread := 0.0 ## 0..1 — the death meter
|
||||
var dying := false
|
||||
var death_cause: int = Death.NONE
|
||||
var gore: Gore
|
||||
|
||||
var _death_t := 0.0
|
||||
var _death_from := Transform3D.IDENTITY
|
||||
|
||||
var _player: Node3D
|
||||
var _cam: Camera3D
|
||||
@ -61,9 +90,13 @@ func setup(player: Node3D, cam: Camera3D, hud: Hud, r: Rage, d: Dust,
|
||||
dust = d
|
||||
_host = host
|
||||
|
||||
dread = Dread.new()
|
||||
dread.setup()
|
||||
host.add_child(dread)
|
||||
tape = Dread.new()
|
||||
tape.setup()
|
||||
host.add_child(tape)
|
||||
|
||||
gore = Gore.new()
|
||||
gore.setup()
|
||||
host.add_child(gore)
|
||||
|
||||
entity = Entity.new()
|
||||
host.add_child(entity)
|
||||
@ -79,7 +112,11 @@ func enter(half: float) -> void:
|
||||
_wrap_cd = 0.0
|
||||
entity._bounds = half
|
||||
entity.wake()
|
||||
dread.set_active(true)
|
||||
dread = 0.0
|
||||
dying = false
|
||||
death_cause = Death.NONE
|
||||
gore.clear()
|
||||
tape.set_active(true)
|
||||
_scatter_totems()
|
||||
if _hud != null:
|
||||
_hud.toast("there is no exit. there is no one else here.")
|
||||
@ -87,7 +124,7 @@ func enter(half: float) -> void:
|
||||
func leave() -> void:
|
||||
active = false
|
||||
entity.sleep()
|
||||
dread.set_active(false)
|
||||
tape.set_active(false)
|
||||
for t in _totems:
|
||||
if is_instance_valid(t):
|
||||
t.queue_free()
|
||||
@ -120,15 +157,23 @@ func _scatter_totems() -> void:
|
||||
func _process(dt: float) -> void:
|
||||
if not active or _player == null:
|
||||
return
|
||||
if dying:
|
||||
_tick_death(dt)
|
||||
return
|
||||
_wrap_cd = maxf(0.0, _wrap_cd - dt)
|
||||
_check_wrap()
|
||||
_tick_dread(dt)
|
||||
|
||||
# how near it is while UNSEEN — the vignette breathing is the only tell you get
|
||||
var near := 0.0
|
||||
if entity.is_awake() and not entity.seen_now:
|
||||
var d := entity.global_position.distance_to(_player.global_position)
|
||||
near = clampf(1.0 - d / 16.0, 0.0, 1.0)
|
||||
dread.drive(dt, entity.static_level, near)
|
||||
tape.drive(dt, entity.static_level, near)
|
||||
|
||||
gore.set_blood(clampf((dread - 0.72) * 2.2, 0.0, 0.55))
|
||||
if dread > 0.72:
|
||||
gore.arm()
|
||||
|
||||
# it whispers when it's close and behind you
|
||||
_next_whisper -= dt
|
||||
@ -139,6 +184,93 @@ func _process(dt: float) -> void:
|
||||
if rage != null:
|
||||
rage.provoke(0.03, "something is breathing")
|
||||
|
||||
## Everything this place does to you lands in one number. Whichever source pushed it
|
||||
## over the line decides how you die — that's what keeps three deaths readable instead of
|
||||
## one generic fail.
|
||||
func _tick_dread(dt: float) -> void:
|
||||
var src := Death.NONE
|
||||
var gain := 0.0
|
||||
|
||||
if entity.seen_now:
|
||||
gain += DREAD_FROM_STARE * (0.4 + entity.static_level)
|
||||
src = Death.STARE
|
||||
var d := entity.global_position.distance_to(_player.global_position)
|
||||
if d < 7.0:
|
||||
var g := DREAD_FROM_NEAR * (1.0 - d / 7.0)
|
||||
if g > gain:
|
||||
src = Death.REACH
|
||||
gain += g
|
||||
if dust != null and dust.inhaled > 0.6:
|
||||
gain += DREAD_FROM_LUNGS * dust.inhaled
|
||||
|
||||
if gain <= 0.0:
|
||||
dread = maxf(0.0, dread - DREAD_CALM * dt)
|
||||
return
|
||||
# the longer you've been going round, the harder it grips
|
||||
dread = clampf(dread + gain * dt * (1.0 + loops * DREAD_LOOP_GRIP), 0.0, 1.0)
|
||||
if dread >= 1.0:
|
||||
_die(src if src != Death.NONE else Death.STARE)
|
||||
|
||||
# ---------------------------------------------------------------- dying
|
||||
const EPITAPHS := {
|
||||
Death.STARE: ["YOU LOOKED TOO LONG",
|
||||
"it was still. it was always still. that was never the safe part."],
|
||||
Death.REACH: ["IT WAS ALREADY IN THE ROOM",
|
||||
"you were making so much noise."],
|
||||
Death.LOOP: ["YOU ARE STILL IN THE ROOM",
|
||||
"you walked it eleven times. you will walk it again."],
|
||||
}
|
||||
|
||||
func _die(cause: int) -> void:
|
||||
if dying:
|
||||
return
|
||||
dying = true
|
||||
death_cause = cause
|
||||
dread = 1.0
|
||||
_death_t = 0.0
|
||||
gore.arm()
|
||||
entity.state = Entity.S.LUNGE
|
||||
if _cam != null:
|
||||
_death_from = _cam.transform
|
||||
# only the physical deaths spray. Being taken by the static shouldn't.
|
||||
if cause != Death.STARE:
|
||||
Gore.splatter(_host, _cam.global_position - _cam.global_transform.basis.z * 0.4,
|
||||
-_cam.global_transform.basis.z)
|
||||
if _player != null and _player.has_method("set_frozen"):
|
||||
_player.set_frozen(true)
|
||||
|
||||
## Slow on purpose. A jumpscare is a spike; a death should take its time, because the
|
||||
## seconds spent watching the camera settle onto the carpet are where it lands.
|
||||
func _tick_death(dt: float) -> void:
|
||||
_death_t += dt
|
||||
var t := _death_t
|
||||
|
||||
# blood floods the glass over the first second and a half
|
||||
gore.set_blood(clampf(t / 1.5, 0.0, 1.0))
|
||||
|
||||
if _cam != null:
|
||||
if t < 0.35:
|
||||
# the hit
|
||||
_cam.transform = _death_from
|
||||
_cam.rotation.x = _death_from.basis.get_euler().x + randf_range(-0.30, 0.30)
|
||||
_cam.rotation.z = randf_range(-0.35, 0.35)
|
||||
else:
|
||||
# and then you go down, and the camera lands on its side on the carpet
|
||||
var k := clampf((t - 0.35) / 2.4, 0.0, 1.0)
|
||||
k = k * k * (3.0 - 2.0 * k)
|
||||
_cam.position.y = lerpf(0.0, -1.35, k)
|
||||
_cam.rotation.z = lerpf(_cam.rotation.z, 1.35, clampf(dt * 2.0, 0.0, 1.0))
|
||||
_cam.rotation.x = lerpf(_cam.rotation.x, -0.55, clampf(dt * 2.0, 0.0, 1.0))
|
||||
|
||||
if t > 2.6:
|
||||
gore.set_fade(clampf((t - 2.6) / 1.4, 0.0, 1.0))
|
||||
if t > 4.0 and not _card_shown:
|
||||
_card_shown = true
|
||||
var e: Array = EPITAPHS.get(death_cause, EPITAPHS[Death.STARE])
|
||||
gore.show_card(String(e[0]), String(e[1]))
|
||||
|
||||
var _card_shown := false
|
||||
|
||||
## Cross the boundary and you come out the far side — into a room that is identical,
|
||||
## because every room here is identical. The entity repositions on the same frame.
|
||||
func _check_wrap() -> void:
|
||||
@ -160,11 +292,15 @@ func _check_wrap() -> void:
|
||||
_player.global_position = p
|
||||
if _player is CharacterBody3D:
|
||||
(_player as CharacterBody3D).velocity = Vector3.ZERO
|
||||
dread.flash()
|
||||
tape.flash()
|
||||
# the good bit: it is somewhere new, and you have no idea where
|
||||
entity._reposition(9.0)
|
||||
if entity.state == Entity.S.STALKING and loops >= 2:
|
||||
entity.state = Entity.S.HUNTING # it learns
|
||||
dread = clampf(dread + DREAD_FROM_LOOP, 0.0, 1.0)
|
||||
if loops >= 11 or dread >= 1.0:
|
||||
_die(Death.LOOP) # the room keeps you
|
||||
return
|
||||
if _hud != null:
|
||||
_hud.toast(WRAP_LINES[loops % WRAP_LINES.size()])
|
||||
if rage != null:
|
||||
@ -172,7 +308,7 @@ func _check_wrap() -> void:
|
||||
|
||||
func _on_caught() -> void:
|
||||
caught += 1
|
||||
dread.flash()
|
||||
tape.flash()
|
||||
if _cam != null:
|
||||
_cam.rotation.x += randf_range(-0.22, 0.22)
|
||||
_cam.rotation.z += randf_range(-0.2, 0.2)
|
||||
@ -182,16 +318,46 @@ func _on_caught() -> void:
|
||||
dust.inhaled = clampf(dust.inhaled + 0.30, 0.0, 1.0)
|
||||
if rage != null:
|
||||
rage.provoke(0.16, "IT TOUCHED YOU")
|
||||
dread = clampf(dread + 0.34, 0.0, 1.0)
|
||||
if dread >= 1.0:
|
||||
_die(Death.REACH)
|
||||
return
|
||||
if _hud != null:
|
||||
_hud.toast("IT TOUCHED YOU")
|
||||
|
||||
## R after a death. Rebuild is Main's job; this just clears the horror state.
|
||||
func revive() -> void:
|
||||
dying = false
|
||||
_card_shown = false
|
||||
death_cause = Death.NONE
|
||||
dread = 0.0
|
||||
loops = 0
|
||||
caught = 0
|
||||
gore.clear()
|
||||
if _cam != null:
|
||||
_cam.position = Vector3.ZERO
|
||||
_cam.rotation = Vector3.ZERO
|
||||
if _player != null and _player.has_method("set_frozen"):
|
||||
_player.set_frozen(false)
|
||||
entity.wake()
|
||||
|
||||
func hud_line() -> String:
|
||||
if not active:
|
||||
return ""
|
||||
if dying:
|
||||
return ""
|
||||
var st := int(round(entity.static_level * 100.0))
|
||||
var warn := ""
|
||||
if entity.state == Entity.S.HUNTING:
|
||||
warn = " · it is looking for you"
|
||||
elif entity.state == Entity.S.LUNGE:
|
||||
warn = " · RUN"
|
||||
return "loops %d · touched %d · signal %d%%%s" % [loops, caught, st, warn]
|
||||
var label := "steady"
|
||||
if dread > 0.85:
|
||||
label = "YOU ARE GOING TO DIE HERE"
|
||||
elif dread > 0.62:
|
||||
label = "get out get out get out"
|
||||
elif dread > 0.34:
|
||||
label = "you are not alone"
|
||||
return "loops %d · touched %d · signal %d%% · DREAD %d%% (%s)%s" % [
|
||||
loops, caught, st, int(round(dread * 100.0)), label, warn]
|
||||
|
||||
170
game/scripts/Gore.gd
Normal file
170
game/scripts/Gore.gd
Normal file
@ -0,0 +1,170 @@
|
||||
extends CanvasLayer
|
||||
class_name Gore
|
||||
|
||||
## Blood on the lens, and the death card.
|
||||
##
|
||||
## All procedural — a shader stamping seeded blobs that grow and then bleed downward in
|
||||
## streaks, plus a burst of red at the camera. No textures, same as everything else here.
|
||||
##
|
||||
## The death sequence is deliberately SLOW. A jumpscare is a spike; a death should take
|
||||
## its time, because the four seconds you spend watching the camera settle onto the
|
||||
## carpet while blood runs down the glass is where the dread actually lands. Then a card
|
||||
## that names what got you, so the three deaths stay distinguishable.
|
||||
##
|
||||
## Godot 4.7 GDScript 2.0.
|
||||
|
||||
const SHADER := """
|
||||
shader_type canvas_item;
|
||||
uniform float amount : hint_range(0.0, 1.0) = 0.0;
|
||||
uniform float t = 0.0;
|
||||
uniform vec3 tint = vec3(0.42, 0.02, 0.02);
|
||||
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
||||
|
||||
void fragment() {
|
||||
vec2 uv = UV;
|
||||
float a = 0.0;
|
||||
|
||||
// splatter: a fixed set of blobs that swell with `amount`
|
||||
for (int i = 0; i < 14; i++) {
|
||||
float fi = float(i);
|
||||
vec2 c = vec2(hash(vec2(fi, 3.1)), hash(vec2(fi, 7.7)));
|
||||
float rad = (0.05 + 0.13 * hash(vec2(fi, 11.3))) * amount;
|
||||
float d = length((uv - c) * vec2(1.0, 0.62));
|
||||
a += smoothstep(rad, rad * 0.35, d);
|
||||
|
||||
// and each blob bleeds downward once it's big enough
|
||||
float run = clamp((amount - 0.35) * 1.6, 0.0, 1.0) * (0.10 + 0.22 * hash(vec2(fi, 5.5)));
|
||||
if (uv.y > c.y && uv.y < c.y + run) {
|
||||
float w = rad * 0.30 * (1.0 - (uv.y - c.y) / max(run, 0.001));
|
||||
a += smoothstep(w, w * 0.2, abs(uv.x - c.x)) * 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
// heavier at the edges — it's on the housing as well as the glass
|
||||
vec2 d2 = (uv - vec2(0.5)) * vec2(1.0, 0.62);
|
||||
a += smoothstep(0.30, 0.95, length(d2) * 2.0) * amount * 1.2;
|
||||
|
||||
// clotted texture so it isn't flat
|
||||
a *= 0.80 + 0.20 * hash(floor(uv * 220.0));
|
||||
a = clamp(a, 0.0, 1.0) * clamp(amount * 1.35, 0.0, 1.0);
|
||||
|
||||
vec3 col = mix(tint, vec3(0.16, 0.0, 0.0), 0.5 + 0.5 * sin(t * 0.7));
|
||||
COLOR = vec4(col, a);
|
||||
}
|
||||
"""
|
||||
|
||||
var _blood: ColorRect
|
||||
var _black: ColorRect
|
||||
var _card: Control
|
||||
var _title: Label
|
||||
var _epitaph: Label
|
||||
var _hint: Label
|
||||
var _t := 0.0
|
||||
var _amount := 0.0
|
||||
|
||||
func setup() -> void:
|
||||
layer = 40 # above the tape and everything else
|
||||
_blood = ColorRect.new()
|
||||
_blood.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_blood.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var sh := Shader.new()
|
||||
sh.code = SHADER
|
||||
var m := ShaderMaterial.new()
|
||||
m.shader = sh
|
||||
_blood.material = m
|
||||
add_child(_blood)
|
||||
|
||||
_black = ColorRect.new()
|
||||
_black.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_black.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_black.color = Color(0, 0, 0, 0)
|
||||
add_child(_black)
|
||||
|
||||
_card = Control.new()
|
||||
_card.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_card.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_card.visible = false
|
||||
add_child(_card)
|
||||
|
||||
_title = _label(_card, 46, Color(0.78, 0.06, 0.07), -70)
|
||||
_epitaph = _label(_card, 20, Color(0.72, 0.70, 0.66), 10)
|
||||
_hint = _label(_card, 15, Color(0.45, 0.44, 0.42), 74)
|
||||
set_process(true)
|
||||
|
||||
func _label(parent: Control, size: int, col: Color, y: int) -> Label:
|
||||
var l := Label.new()
|
||||
l.add_theme_font_size_override("font_size", size)
|
||||
l.add_theme_color_override("font_color", col)
|
||||
l.anchor_left = 0.5
|
||||
l.anchor_right = 0.5
|
||||
l.anchor_top = 0.5
|
||||
l.offset_left = -420
|
||||
l.offset_right = 420
|
||||
l.offset_top = y
|
||||
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
parent.add_child(l)
|
||||
return l
|
||||
|
||||
## 0..1. Backrooms drives this up over the death sequence.
|
||||
func set_blood(v: float) -> void:
|
||||
_amount = clampf(v, 0.0, 1.0)
|
||||
|
||||
func set_fade(v: float) -> void:
|
||||
_black.color = Color(0, 0, 0, clampf(v, 0.0, 1.0))
|
||||
|
||||
func show_card(title: String, epitaph: String) -> void:
|
||||
_card.visible = true
|
||||
_title.text = title
|
||||
_epitaph.text = epitaph
|
||||
_hint.text = "R — wake up"
|
||||
|
||||
func clear() -> void:
|
||||
_amount = 0.0
|
||||
_card.visible = false
|
||||
_black.color = Color(0, 0, 0, 0)
|
||||
visible = false
|
||||
|
||||
func arm() -> void:
|
||||
visible = true
|
||||
|
||||
func _process(dt: float) -> void:
|
||||
_t += dt
|
||||
if _blood != null:
|
||||
var m := _blood.material as ShaderMaterial
|
||||
m.set_shader_parameter("amount", _amount)
|
||||
m.set_shader_parameter("t", _t)
|
||||
|
||||
## A wet burst at the camera. Called once, on the kill.
|
||||
static func splatter(host: Node3D, at: Vector3, dir: Vector3) -> void:
|
||||
var p := GPUParticles3D.new()
|
||||
p.one_shot = true
|
||||
p.explosiveness = 1.0
|
||||
p.amount = 220
|
||||
p.lifetime = 2.4
|
||||
p.local_coords = false
|
||||
var pm := ParticleProcessMaterial.new()
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
||||
pm.emission_sphere_radius = 0.12
|
||||
pm.direction = dir
|
||||
pm.spread = 62.0
|
||||
pm.initial_velocity_min = 2.5
|
||||
pm.initial_velocity_max = 9.5
|
||||
pm.gravity = Vector3(0, -11.0, 0)
|
||||
pm.scale_min = 0.5
|
||||
pm.scale_max = 1.9
|
||||
pm.angular_velocity_min = -600.0
|
||||
pm.angular_velocity_max = 600.0
|
||||
p.process_material = pm
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = Vector3(0.035, 0.035, 0.035)
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(0.36, 0.015, 0.02)
|
||||
mat.roughness = 0.35
|
||||
mesh.material = mat
|
||||
p.draw_pass_1 = mesh
|
||||
host.add_child(p)
|
||||
p.global_position = at
|
||||
p.emitting = true
|
||||
p.finished.connect(p.queue_free)
|
||||
1
game/scripts/Gore.gd.uid
Normal file
1
game/scripts/Gore.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bylx3efultyeh
|
||||
@ -500,6 +500,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
KEY_B:
|
||||
_rain(500)
|
||||
KEY_R:
|
||||
if _backrooms != null and _backrooms.dying:
|
||||
_backrooms.revive()
|
||||
_reset()
|
||||
KEY_M:
|
||||
if _modes != null:
|
||||
|
||||
@ -36,6 +36,13 @@ var tasks: Tasks = null
|
||||
var gauntlet: Gauntlet = null
|
||||
## Set by Main. RMB discharges powder on sites where the smashables are invisible.
|
||||
var dust: Dust = null
|
||||
## Set while you're dying, so you can't stroll around during it.
|
||||
var _frozen := false
|
||||
|
||||
func set_frozen(v: bool) -> void:
|
||||
_frozen = v
|
||||
if v:
|
||||
velocity = Vector3.ZERO
|
||||
|
||||
var _head: Node3D
|
||||
var _pitch := 0.0
|
||||
@ -147,6 +154,9 @@ func _build_crosshair() -> void:
|
||||
|
||||
# ---------------------------------------------------------------- input
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# dead men don't switch weapons. R is handled by Main and reaches it regardless.
|
||||
if _frozen:
|
||||
return
|
||||
# a task swallows the keyboard first, so digits and arrows edit the spreadsheet
|
||||
# instead of switching weapons and walking away from the desk
|
||||
if tasks != null and tasks.is_working():
|
||||
@ -250,7 +260,7 @@ func _physics_process(delta: float) -> void:
|
||||
if Input.is_key_pressed(KEY_SPACE) and is_on_floor():
|
||||
velocity.y = jump_velocity
|
||||
|
||||
var seated := tasks != null and tasks.is_working()
|
||||
var seated := (tasks != null and tasks.is_working()) or _frozen
|
||||
var input_dir := Vector2.ZERO
|
||||
if not seated:
|
||||
input_dir = Vector2(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user