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
|
of the lattice lines, much bigger gaps), which also matches the reference photos far
|
||||||
better; they're mostly open floor with occasional slabs.
|
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
|
**DREAD and the three deaths** *(built)* — one meter, not a health bar. Nothing else in
|
||||||
that behaves differently, and the "did that chair move?" pass on wrapping — the room is
|
this game does damage, so hit points would be a survival-horror UI bolted onto a
|
||||||
identical, so *changing one thing* on a loop would be very cheap and very mean.
|
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.
|
land in the same second.
|
||||||
|
|
||||||
It can't be fought — weapons pass through. The outs are distance, silence, and not
|
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
|
looking, three things the rest of the game actively discourages.
|
||||||
kill you; it costs you a lungful, a spike of rage, and the knowledge that it can do that
|
|
||||||
whenever it likes.
|
### 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
|
`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
|
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
|
return null
|
||||||
|
|
||||||
func _build_script() -> void:
|
func _build_script() -> void:
|
||||||
# LEVEL 0, the horror pass. NOTE the level swap is async and enter() repositions the
|
# LEVEL 0: stand in front of it, let the dread max out, and die.
|
||||||
# entity on wake, so it has to be placed a step LATER or the wake overwrites it.
|
|
||||||
_script = [
|
_script = [
|
||||||
{"dur": 1.2, "move_to": null, "look_at": null, "act": "level:backrooms"},
|
{"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),
|
{"dur": 0.3, "move_to": Vector3(0, 0, 3.0), "look_at": Vector3(0, 1.5, -3.0),
|
||||||
"act": "ent:0,-4"},
|
"act": "ent:0,-3"},
|
||||||
{"dur": 2.4, "move_to": null, "look_at": null, "act": ""},
|
{"dur": 2.6, "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": 0.4, "move_to": null, "look_at": null, "act": "kill"},
|
||||||
{"dur": 1.6, "move_to": null, "look_at": null, "act": ""},
|
{"dur": 7.0, "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": ""},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
func _motion(verbose := false) -> float:
|
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.global_position = Vector3(float(p[0]), 0.0, float(p[1]))
|
||||||
br.entity.state = Entity.S.STALKING
|
br.entity.state = Entity.S.STALKING
|
||||||
return
|
return
|
||||||
|
if act == "kill":
|
||||||
|
var b3 = _main.get("_backrooms")
|
||||||
|
if b3 != null:
|
||||||
|
b3.dread = 0.99
|
||||||
|
return
|
||||||
if act == "scare":
|
if act == "scare":
|
||||||
var br2 = _main.get("_backrooms")
|
var br2 = _main.get("_backrooms")
|
||||||
if br2 != null and br2.entity != null:
|
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"
|
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.
|
## How close to the outer wall triggers a wrap.
|
||||||
const WRAP_MARGIN := 1.4
|
const WRAP_MARGIN := 1.4
|
||||||
## Seconds after a wrap before another can fire (stops thrashing on a corner).
|
## 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 active := false
|
||||||
var entity: Entity
|
var entity: Entity
|
||||||
var dread: Dread
|
var tape: Dread ## the found-footage layer
|
||||||
var rage: Rage
|
var rage: Rage
|
||||||
var dust: Dust
|
var dust: Dust
|
||||||
|
|
||||||
var loops := 0 ## how many times the room has come back around
|
var loops := 0 ## how many times the room has come back around
|
||||||
var caught := 0
|
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 _player: Node3D
|
||||||
var _cam: Camera3D
|
var _cam: Camera3D
|
||||||
@ -61,9 +90,13 @@ func setup(player: Node3D, cam: Camera3D, hud: Hud, r: Rage, d: Dust,
|
|||||||
dust = d
|
dust = d
|
||||||
_host = host
|
_host = host
|
||||||
|
|
||||||
dread = Dread.new()
|
tape = Dread.new()
|
||||||
dread.setup()
|
tape.setup()
|
||||||
host.add_child(dread)
|
host.add_child(tape)
|
||||||
|
|
||||||
|
gore = Gore.new()
|
||||||
|
gore.setup()
|
||||||
|
host.add_child(gore)
|
||||||
|
|
||||||
entity = Entity.new()
|
entity = Entity.new()
|
||||||
host.add_child(entity)
|
host.add_child(entity)
|
||||||
@ -79,7 +112,11 @@ func enter(half: float) -> void:
|
|||||||
_wrap_cd = 0.0
|
_wrap_cd = 0.0
|
||||||
entity._bounds = half
|
entity._bounds = half
|
||||||
entity.wake()
|
entity.wake()
|
||||||
dread.set_active(true)
|
dread = 0.0
|
||||||
|
dying = false
|
||||||
|
death_cause = Death.NONE
|
||||||
|
gore.clear()
|
||||||
|
tape.set_active(true)
|
||||||
_scatter_totems()
|
_scatter_totems()
|
||||||
if _hud != null:
|
if _hud != null:
|
||||||
_hud.toast("there is no exit. there is no one else here.")
|
_hud.toast("there is no exit. there is no one else here.")
|
||||||
@ -87,7 +124,7 @@ func enter(half: float) -> void:
|
|||||||
func leave() -> void:
|
func leave() -> void:
|
||||||
active = false
|
active = false
|
||||||
entity.sleep()
|
entity.sleep()
|
||||||
dread.set_active(false)
|
tape.set_active(false)
|
||||||
for t in _totems:
|
for t in _totems:
|
||||||
if is_instance_valid(t):
|
if is_instance_valid(t):
|
||||||
t.queue_free()
|
t.queue_free()
|
||||||
@ -120,15 +157,23 @@ func _scatter_totems() -> void:
|
|||||||
func _process(dt: float) -> void:
|
func _process(dt: float) -> void:
|
||||||
if not active or _player == null:
|
if not active or _player == null:
|
||||||
return
|
return
|
||||||
|
if dying:
|
||||||
|
_tick_death(dt)
|
||||||
|
return
|
||||||
_wrap_cd = maxf(0.0, _wrap_cd - dt)
|
_wrap_cd = maxf(0.0, _wrap_cd - dt)
|
||||||
_check_wrap()
|
_check_wrap()
|
||||||
|
_tick_dread(dt)
|
||||||
|
|
||||||
# how near it is while UNSEEN — the vignette breathing is the only tell you get
|
# how near it is while UNSEEN — the vignette breathing is the only tell you get
|
||||||
var near := 0.0
|
var near := 0.0
|
||||||
if entity.is_awake() and not entity.seen_now:
|
if entity.is_awake() and not entity.seen_now:
|
||||||
var d := entity.global_position.distance_to(_player.global_position)
|
var d := entity.global_position.distance_to(_player.global_position)
|
||||||
near = clampf(1.0 - d / 16.0, 0.0, 1.0)
|
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
|
# it whispers when it's close and behind you
|
||||||
_next_whisper -= dt
|
_next_whisper -= dt
|
||||||
@ -139,6 +184,93 @@ func _process(dt: float) -> void:
|
|||||||
if rage != null:
|
if rage != null:
|
||||||
rage.provoke(0.03, "something is breathing")
|
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,
|
## 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.
|
## because every room here is identical. The entity repositions on the same frame.
|
||||||
func _check_wrap() -> void:
|
func _check_wrap() -> void:
|
||||||
@ -160,11 +292,15 @@ func _check_wrap() -> void:
|
|||||||
_player.global_position = p
|
_player.global_position = p
|
||||||
if _player is CharacterBody3D:
|
if _player is CharacterBody3D:
|
||||||
(_player as CharacterBody3D).velocity = Vector3.ZERO
|
(_player as CharacterBody3D).velocity = Vector3.ZERO
|
||||||
dread.flash()
|
tape.flash()
|
||||||
# the good bit: it is somewhere new, and you have no idea where
|
# the good bit: it is somewhere new, and you have no idea where
|
||||||
entity._reposition(9.0)
|
entity._reposition(9.0)
|
||||||
if entity.state == Entity.S.STALKING and loops >= 2:
|
if entity.state == Entity.S.STALKING and loops >= 2:
|
||||||
entity.state = Entity.S.HUNTING # it learns
|
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:
|
if _hud != null:
|
||||||
_hud.toast(WRAP_LINES[loops % WRAP_LINES.size()])
|
_hud.toast(WRAP_LINES[loops % WRAP_LINES.size()])
|
||||||
if rage != null:
|
if rage != null:
|
||||||
@ -172,7 +308,7 @@ func _check_wrap() -> void:
|
|||||||
|
|
||||||
func _on_caught() -> void:
|
func _on_caught() -> void:
|
||||||
caught += 1
|
caught += 1
|
||||||
dread.flash()
|
tape.flash()
|
||||||
if _cam != null:
|
if _cam != null:
|
||||||
_cam.rotation.x += randf_range(-0.22, 0.22)
|
_cam.rotation.x += randf_range(-0.22, 0.22)
|
||||||
_cam.rotation.z += randf_range(-0.2, 0.2)
|
_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)
|
dust.inhaled = clampf(dust.inhaled + 0.30, 0.0, 1.0)
|
||||||
if rage != null:
|
if rage != null:
|
||||||
rage.provoke(0.16, "IT TOUCHED YOU")
|
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:
|
if _hud != null:
|
||||||
_hud.toast("IT TOUCHED YOU")
|
_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:
|
func hud_line() -> String:
|
||||||
if not active:
|
if not active:
|
||||||
return ""
|
return ""
|
||||||
|
if dying:
|
||||||
|
return ""
|
||||||
var st := int(round(entity.static_level * 100.0))
|
var st := int(round(entity.static_level * 100.0))
|
||||||
var warn := ""
|
var warn := ""
|
||||||
if entity.state == Entity.S.HUNTING:
|
if entity.state == Entity.S.HUNTING:
|
||||||
warn = " · it is looking for you"
|
warn = " · it is looking for you"
|
||||||
elif entity.state == Entity.S.LUNGE:
|
elif entity.state == Entity.S.LUNGE:
|
||||||
warn = " · RUN"
|
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:
|
KEY_B:
|
||||||
_rain(500)
|
_rain(500)
|
||||||
KEY_R:
|
KEY_R:
|
||||||
|
if _backrooms != null and _backrooms.dying:
|
||||||
|
_backrooms.revive()
|
||||||
_reset()
|
_reset()
|
||||||
KEY_M:
|
KEY_M:
|
||||||
if _modes != null:
|
if _modes != null:
|
||||||
|
|||||||
@ -36,6 +36,13 @@ var tasks: Tasks = null
|
|||||||
var gauntlet: Gauntlet = null
|
var gauntlet: Gauntlet = null
|
||||||
## Set by Main. RMB discharges powder on sites where the smashables are invisible.
|
## Set by Main. RMB discharges powder on sites where the smashables are invisible.
|
||||||
var dust: Dust = null
|
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 _head: Node3D
|
||||||
var _pitch := 0.0
|
var _pitch := 0.0
|
||||||
@ -147,6 +154,9 @@ func _build_crosshair() -> void:
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- input
|
# ---------------------------------------------------------------- input
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
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
|
# a task swallows the keyboard first, so digits and arrows edit the spreadsheet
|
||||||
# instead of switching weapons and walking away from the desk
|
# instead of switching weapons and walking away from the desk
|
||||||
if tasks != null and tasks.is_working():
|
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():
|
if Input.is_key_pressed(KEY_SPACE) and is_on_floor():
|
||||||
velocity.y = jump_velocity
|
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
|
var input_dir := Vector2.ZERO
|
||||||
if not seated:
|
if not seated:
|
||||||
input_dir = Vector2(
|
input_dir = Vector2(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user