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>
171 lines
5.0 KiB
GDScript
171 lines
5.0 KiB
GDScript
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)
|