LANE7: there are two of them, and the room changes one thing every loop
THE OTHER ONE (Entity.Kind.MIRROR)
After three loops a second one turns up, and it shares the first one's silhouette on
purpose — same height, same suit, same dark — so at range you cannot tell which you are
looking at. Its rules are the hunter's rules INVERTED:
hunter the other one
while watched frozen IT COMES
while unwatched it closes inert
noise hunts it deaf
correct play keep it in view LOOK AWAY
Every instinct the first one trains into you is the way the second one kills you, and
that is the entire design. The tells are close-range only and all wrong in the same
direction: head thrown back so it stares at the ceiling instead of you, arms raised, and
feet that never reach the carpet (Backrooms floats it 14 cm).
It also makes no static and doesn't breathe, so the only warning the tape gives for it
is the edge closing in — even while you are looking straight at it.
ONE THING PER LOOP (Backrooms._disturb)
Exactly one change per wrap. Never two. That constraint IS the effect: change nothing
and a loop is just a teleport, change several and it reads as a new room, which is the
opposite of what this place is for. One change means you are never sure whether you
noticed something or imagined it — and since the room is otherwise identical, when you
ARE sure it is worse.
Escalating: early loops shift a prop half a metre, turn one, or kill a bank of lights.
Later ones hang a totem that wasn't there, permanently reveal an object nobody dusted,
or put the hunter exactly where you were standing a moment ago.
The shift is half a metre, not one — a metre is enough to shove a neighbouring prop and
read as TWO changes, and it's too obvious anyway. Half is "…was that there?"
dev/probe_two.gd verifies the inversion with numbers (unwatched 0.00 m, watched 4.39 m,
noise heat 0.00) and that the disturbance fires per loop. Note the probe has to wait out
WRAP_COOLDOWN between laps or only the first wrap ever fires.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
bcd67906a4
commit
a806272a7f
@ -243,6 +243,20 @@ xenomorph sprays), LOOP (the room simply keeps you at eleven). `Gore.gd` does bl
|
||||
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.
|
||||
**THE OTHER ONE** *(built)* — after three loops there are two, and they share a
|
||||
silhouette. Its rules are the first one's inverted: it advances ONLY while watched, is
|
||||
inert when you look away, and is completely deaf. Every instinct the hunter trains into
|
||||
you is how the other one kills you. Tells are close-range only and all wrong the same
|
||||
way: head thrown back at the ceiling, arms up, feet that never reach the carpet.
|
||||
|
||||
**ONE THING PER LOOP** *(built)* — `Backrooms._disturb()`. Exactly one change per wrap,
|
||||
never two, because that constraint IS the effect: nothing and a loop is just a teleport,
|
||||
several and it reads as a new room. One means you're never sure whether you noticed or
|
||||
imagined it. Escalates from moving a prop half a metre or killing a light bank, to
|
||||
hanging a totem that wasn't there, to permanently revealing something nobody dusted, to
|
||||
putting the hunter exactly where you were standing.
|
||||
|
||||
Still open in LEVEL 0: the two entities can't yet be told apart by anything except
|
||||
getting close, which is correct but means a first-time player has no way to learn the
|
||||
difference except dying. A tell at medium range — a sound only one of them makes, or one
|
||||
that never appears in the same frame as the other — would give skill somewhere to live.
|
||||
|
||||
@ -91,6 +91,34 @@ settles → spray again, coughing.
|
||||
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.
|
||||
|
||||
**And after three loops there are two of them.** THE OTHER ONE shares the silhouette —
|
||||
same height, same suit, same dark — so at range you cannot tell which you're looking at.
|
||||
Its rules are the first one's rules **inverted**:
|
||||
|
||||
| | the hunter | the other one |
|
||||
|---|---|---|
|
||||
| while you look at it | frozen | **it comes** |
|
||||
| while you don't | it closes | inert |
|
||||
| noise | hunts it | **deaf** |
|
||||
| correct play | keep it in view, stay quiet | **look away**; noise is irrelevant |
|
||||
|
||||
Every instinct the first one trains into you is the way the second one kills you. The
|
||||
tells are close-range only, and all wrong in the same direction: its head is thrown back
|
||||
so it's staring at the ceiling rather than at you, its arms are raised, and its feet
|
||||
never reach the carpet.
|
||||
|
||||
### The room changes one thing
|
||||
|
||||
Every loop, **exactly one thing** is different. Never two — that constraint is the whole
|
||||
effect. Change nothing and a loop is just a teleport; change several and it reads as a
|
||||
new room, which is the opposite of what this place is for. One change means you're never
|
||||
sure whether you noticed something or imagined it, and since the room is otherwise
|
||||
identical, when you *are* sure it's worse.
|
||||
|
||||
It escalates: early loops move or turn a prop, or kill a bank of lights. Later ones hang
|
||||
a totem that wasn't there, permanently reveal an object nobody dusted, or put the entity
|
||||
where you were standing a moment ago.
|
||||
|
||||
### DREAD, and dying
|
||||
|
||||
**No health bar.** Nothing else in this game does damage, so hit points would be a
|
||||
|
||||
Binary file not shown.
BIN
game/assets/store/entity2.glb
Normal file
BIN
game/assets/store/entity2.glb
Normal file
Binary file not shown.
45
game/assets/store/entity2.glb.import
Normal file
45
game/assets/store/entity2.glb.import
Normal file
@ -0,0 +1,45 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://oj65sy2sq2j7"
|
||||
path="res://.godot/imported/entity2.glb-664185e05164cbf13bb141c7656803fa.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/store/entity2.glb"
|
||||
dest_files=["res://.godot/imported/entity2.glb-664185e05164cbf13bb141c7656803fa.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
@ -48,14 +48,17 @@ func _find_player(n: Node) -> Player:
|
||||
return null
|
||||
|
||||
func _build_script() -> void:
|
||||
# LEVEL 0: stand in front of it, let the dread max out, and die.
|
||||
# The two of them. Same silhouette at range; the tells are close-range only — the
|
||||
# other one's head is thrown back, its arms are up, and its feet don't reach.
|
||||
_script = [
|
||||
{"dur": 1.2, "move_to": null, "look_at": null, "act": "level:backrooms"},
|
||||
{"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": ""},
|
||||
{"dur": 0.3, "move_to": Vector3(0, 0, 4.0), "look_at": Vector3(0, 1.6, -4.0),
|
||||
"act": "ent:0,-4"},
|
||||
{"dur": 3.0, "move_to": null, "look_at": null, "act": ""},
|
||||
{"dur": 0.3, "move_to": null, "look_at": Vector3(4.0, 1.6, -4.0), "act": "two:4,-4"},
|
||||
{"dur": 3.4, "move_to": null, "look_at": null, "act": ""},
|
||||
{"dur": 3.0, "move_to": Vector3(2.0, 0, 1.0), "look_at": Vector3(4.0, 1.7, -4.0),
|
||||
"act": ""},
|
||||
]
|
||||
|
||||
func _motion(verbose := false) -> float:
|
||||
@ -163,6 +166,14 @@ 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.begins_with("two:"):
|
||||
var b4 = _main.get("_backrooms")
|
||||
if b4 != null and b4.other != null:
|
||||
var q := act.substr(4).split(",")
|
||||
b4.other.wake()
|
||||
b4.other.global_position = Vector3(float(q[0]), 0.14, float(q[1]))
|
||||
b4.other.state = Entity.S.STALKING
|
||||
return
|
||||
if act == "kill":
|
||||
var b3 = _main.get("_backrooms")
|
||||
if b3 != null:
|
||||
|
||||
1
game/dev/probe_death.gd.uid
Normal file
1
game/dev/probe_death.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://b51u6svomjek2
|
||||
77
game/dev/probe_two.gd
Normal file
77
game/dev/probe_two.gd
Normal file
@ -0,0 +1,77 @@
|
||||
extends SceneTree
|
||||
|
||||
## The second entity's inverted rules, and that exactly one thing changes per loop.
|
||||
## Godot --headless --path game --script dev/probe_two.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")
|
||||
var other = br.other
|
||||
|
||||
print("at entry: other awake = ", other.is_awake(), " (should be false)")
|
||||
|
||||
# --- loop until it turns up. WRAP_COOLDOWN is 1.2 s, so the probe has to wait it
|
||||
# --- out or only the first wrap ever fires.
|
||||
for lap in 6:
|
||||
var before := _snapshot()
|
||||
var was: int = br.loops
|
||||
player.global_position = Vector3(0, 0, 18.6)
|
||||
for i in 90:
|
||||
await physics_frame
|
||||
var after := _snapshot()
|
||||
print("loop %d -> %d : %d prop(s) moved" % [was, br.loops, _diff(before, after)])
|
||||
print("after %d loops: other awake = %s" % [br.loops, other.is_awake()])
|
||||
|
||||
# --- INVERTED RULE: inert when unwatched ---
|
||||
player.global_position = Vector3.ZERO
|
||||
player.rotation.y = 0.0 # facing -Z
|
||||
other.global_position = Vector3(0, 0.14, 9) # behind us
|
||||
other.state = Entity.S.STALKING
|
||||
for i in 4:
|
||||
await physics_frame
|
||||
var p0 = other.global_position
|
||||
for i in 45:
|
||||
await physics_frame
|
||||
print("unwatched : moved %.2f m seen=%s (should be ~0)" % [
|
||||
p0.distance_to(other.global_position), other.seen_now])
|
||||
|
||||
# --- and it comes when you DO look ---
|
||||
player.rotation.y = PI # now facing +Z, straight at it
|
||||
for i in 4:
|
||||
await physics_frame
|
||||
p0 = other.global_position
|
||||
for i in 45:
|
||||
await physics_frame
|
||||
print("watched : moved %.2f m seen=%s (should be large)" % [
|
||||
p0.distance_to(other.global_position), other.seen_now])
|
||||
|
||||
# --- and it is deaf ---
|
||||
other.noise_heat = 0.0
|
||||
br.on_noise(Vector3(-15, 0, -15), 3.0)
|
||||
print("after a noise: heat=%.2f (should be 0 — it does not care)" % other.noise_heat)
|
||||
quit()
|
||||
|
||||
func _snapshot() -> Dictionary:
|
||||
var d := {}
|
||||
for n in get_nodes_in_group("smashable"):
|
||||
if is_instance_valid(n) and n is Node3D:
|
||||
d[n.get_instance_id()] = [(n as Node3D).global_position,
|
||||
(n as Node3D).rotation.y]
|
||||
return d
|
||||
|
||||
func _diff(a: Dictionary, b: Dictionary) -> int:
|
||||
var n := 0
|
||||
for k in b:
|
||||
if not a.has(k):
|
||||
continue
|
||||
var pa: Array = a[k]
|
||||
var pb: Array = b[k]
|
||||
if (pa[0] as Vector3).distance_to(pb[0]) > 0.25 or absf(pa[1] - pb[1]) > 0.2:
|
||||
n += 1
|
||||
return n
|
||||
1
game/dev/probe_two.gd.uid
Normal file
1
game/dev/probe_two.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dhifjxyhb351x
|
||||
@ -49,6 +49,10 @@ const WRAP_COOLDOWN := 1.2
|
||||
var active := false
|
||||
var entity: Entity
|
||||
var tape: Dread ## the found-footage layer
|
||||
var other: Entity ## THE OTHER ONE — inverted rules, see Entity.Kind
|
||||
## Loops before the second one turns up. You get long enough to learn the first set of
|
||||
## rules properly, and then they stop being true.
|
||||
const OTHER_AFTER_LOOPS := 3
|
||||
var rage: Rage
|
||||
var dust: Dust
|
||||
|
||||
@ -100,9 +104,16 @@ func setup(player: Node3D, cam: Camera3D, hud: Hud, r: Rage, d: Dust,
|
||||
|
||||
entity = Entity.new()
|
||||
host.add_child(entity)
|
||||
entity.setup(player, cam, _half)
|
||||
entity.setup(player, cam, _half, Entity.Kind.HUNTER)
|
||||
entity.caught_you.connect(_on_caught)
|
||||
|
||||
other = Entity.new()
|
||||
host.add_child(other)
|
||||
other.setup(player, cam, _half, Entity.Kind.MIRROR)
|
||||
other.caught_you.connect(_on_caught)
|
||||
# it hangs — the feet never reach the carpet, which is the close-range tell
|
||||
other.position.y = 0.14
|
||||
|
||||
## Called when a level finishes building. `half` is the site's half-extent.
|
||||
func enter(half: float) -> void:
|
||||
_half = half
|
||||
@ -112,6 +123,7 @@ func enter(half: float) -> void:
|
||||
_wrap_cd = 0.0
|
||||
entity._bounds = half
|
||||
entity.wake()
|
||||
other.sleep()
|
||||
dread = 0.0
|
||||
dying = false
|
||||
death_cause = Death.NONE
|
||||
@ -124,6 +136,7 @@ func enter(half: float) -> void:
|
||||
func leave() -> void:
|
||||
active = false
|
||||
entity.sleep()
|
||||
other.sleep()
|
||||
tape.set_active(false)
|
||||
for t in _totems:
|
||||
if is_instance_valid(t):
|
||||
@ -133,7 +146,7 @@ func leave() -> void:
|
||||
## Every destroyed object and every puff of powder is a dinner bell.
|
||||
func on_noise(at: Vector3, loudness: float) -> void:
|
||||
if active:
|
||||
entity.hear(at, loudness)
|
||||
entity.hear(at, loudness) # the other one is deaf; it does not care
|
||||
|
||||
# ---------------------------------------------------------------- totems
|
||||
## Hung in doorways you have definitely already walked through.
|
||||
@ -169,6 +182,11 @@ func _process(dt: float) -> void:
|
||||
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)
|
||||
if other.is_awake():
|
||||
# it makes no static and it doesn't breathe at you, so the ONLY warning the tape
|
||||
# gives for the other one is the edge closing in — even while you're staring at it
|
||||
var d2 := other.global_position.distance_to(_player.global_position)
|
||||
near = maxf(near, clampf(1.0 - d2 / 13.0, 0.0, 1.0) * 0.8)
|
||||
tape.drive(dt, entity.static_level, near)
|
||||
|
||||
gore.set_blood(clampf((dread - 0.72) * 2.2, 0.0, 0.55))
|
||||
@ -195,6 +213,8 @@ func _tick_dread(dt: float) -> void:
|
||||
gain += DREAD_FROM_STARE * (0.4 + entity.static_level)
|
||||
src = Death.STARE
|
||||
var d := entity.global_position.distance_to(_player.global_position)
|
||||
if other.is_awake():
|
||||
d = minf(d, other.global_position.distance_to(_player.global_position))
|
||||
if d < 7.0:
|
||||
var g := DREAD_FROM_NEAR * (1.0 - d / 7.0)
|
||||
if g > gain:
|
||||
@ -297,6 +317,7 @@ func _check_wrap() -> void:
|
||||
entity._reposition(9.0)
|
||||
if entity.state == Entity.S.STALKING and loops >= 2:
|
||||
entity.state = Entity.S.HUNTING # it learns
|
||||
_disturb()
|
||||
dread = clampf(dread + DREAD_FROM_LOOP, 0.0, 1.0)
|
||||
if loops >= 11 or dread >= 1.0:
|
||||
_die(Death.LOOP) # the room keeps you
|
||||
@ -306,6 +327,97 @@ func _check_wrap() -> void:
|
||||
if rage != null:
|
||||
rage.provoke(0.035, "the same room")
|
||||
|
||||
# ---------------------------------------------------------------- the disturbance
|
||||
## EXACTLY ONE THING changes per loop. Never two.
|
||||
##
|
||||
## That constraint is the whole effect. Change nothing and the loop is just a teleport;
|
||||
## change several things and it reads as a new room, which is the opposite of what this
|
||||
## place is for. One change means you are never sure whether you noticed something or
|
||||
## imagined it — and the room is otherwise pixel-identical, so when you DO notice, you
|
||||
## are certain, and that's worse.
|
||||
##
|
||||
## It escalates: early loops move furniture, later ones start putting things where you
|
||||
## were standing.
|
||||
func _disturb() -> void:
|
||||
var props: Array = []
|
||||
for n in _host.get_tree().get_nodes_in_group("smashable"):
|
||||
if is_instance_valid(n) and n is Node3D:
|
||||
props.append(n)
|
||||
|
||||
var options: Array = ["shift", "spin", "dark"]
|
||||
if loops >= 2:
|
||||
options.append("totem")
|
||||
if loops >= 3:
|
||||
options.append("reveal")
|
||||
if loops >= 5:
|
||||
options.append("behind")
|
||||
var pick: String = options[randi() % options.size()]
|
||||
|
||||
match pick:
|
||||
"shift":
|
||||
# something is a metre from where it was. Only a metre.
|
||||
if props.is_empty():
|
||||
return
|
||||
var p: Node3D = props[randi() % props.size()]
|
||||
# small on purpose: a metre is enough to shove a neighbour and read as two
|
||||
# changes, and it's also too obvious. Half a metre is "…was that there?"
|
||||
p.global_position += Vector3(randf_range(-0.55, 0.55), 0.0,
|
||||
randf_range(-0.55, 0.55))
|
||||
"spin":
|
||||
if props.is_empty():
|
||||
return
|
||||
var q: Node3D = props[randi() % props.size()]
|
||||
q.rotation.y += randf_range(1.2, 2.6)
|
||||
"dark":
|
||||
# a bank of lights simply isn't on any more
|
||||
var lights: Array = []
|
||||
_collect_lights(_host, lights)
|
||||
if lights.is_empty():
|
||||
return
|
||||
lights.shuffle()
|
||||
for i in mini(4, lights.size()):
|
||||
(lights[i] as OmniLight3D).light_energy *= 0.25
|
||||
"totem":
|
||||
# one more of them than there was
|
||||
if not ResourceLoader.exists(TOTEM):
|
||||
return
|
||||
var packed := load(TOTEM) as PackedScene
|
||||
var n: Node3D = packed.instantiate()
|
||||
_host.add_child(n)
|
||||
var fwd := -_player.global_transform.basis.z
|
||||
fwd.y = 0.0
|
||||
n.position = _player.global_position + fwd.normalized() * randf_range(4.0, 8.0) \
|
||||
+ Vector3(0, 1.30, 0)
|
||||
n.rotation.y = randf_range(0.0, TAU)
|
||||
_totems.append(n)
|
||||
"reveal":
|
||||
# one object stops being invisible. Permanently. Nobody dusted it.
|
||||
if dust == null or props.is_empty():
|
||||
return
|
||||
var r: Node3D = props[randi() % props.size()]
|
||||
if r is Smashable:
|
||||
dust._coat(r as Smashable)
|
||||
(r as Smashable).set_meta("dust_until", 0x7FFFFFFF)
|
||||
"behind":
|
||||
# it is standing where you were a moment ago
|
||||
var back := _player.global_transform.basis.z
|
||||
back.y = 0.0
|
||||
entity.global_position = _player.global_position + back.normalized() * 3.2
|
||||
entity.state = Entity.S.HUNTING
|
||||
|
||||
# and after enough of them, the other one is here too
|
||||
if loops >= OTHER_AFTER_LOOPS and not other.is_awake():
|
||||
other.wake()
|
||||
other.position.y = 0.14
|
||||
if _hud != null:
|
||||
_hud.toast("there are two of them.")
|
||||
|
||||
func _collect_lights(n: Node, acc: Array) -> void:
|
||||
if n is OmniLight3D:
|
||||
acc.append(n)
|
||||
for c in n.get_children():
|
||||
_collect_lights(c, acc)
|
||||
|
||||
func _on_caught() -> void:
|
||||
caught += 1
|
||||
tape.flash()
|
||||
@ -352,6 +464,7 @@ func hud_line() -> String:
|
||||
warn = " · it is looking for you"
|
||||
elif entity.state == Entity.S.LUNGE:
|
||||
warn = " · RUN"
|
||||
var twos := " · TWO" if other.is_awake() else ""
|
||||
var label := "steady"
|
||||
if dread > 0.85:
|
||||
label = "YOU ARE GOING TO DIE HERE"
|
||||
@ -359,5 +472,5 @@ func hud_line() -> String:
|
||||
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]
|
||||
return "loops %d%s · touched %d · signal %d%% · DREAD %d%% (%s)%s" % [
|
||||
loops, twos, caught, st, int(round(dread * 100.0)), label, warn]
|
||||
|
||||
@ -25,9 +25,24 @@ class_name Entity
|
||||
## Godot 4.7 GDScript 2.0.
|
||||
|
||||
const MODEL := "res://assets/store/entity.glb"
|
||||
const MODEL_OTHER := "res://assets/store/entity2.glb"
|
||||
|
||||
enum S { DORMANT, STALKING, HUNTING, LUNGE }
|
||||
|
||||
## Two of them, and the second one's rules are the FIRST one's rules inverted.
|
||||
##
|
||||
## HUNTER freezes while you look at it, closes when you don't, and hunts sound.
|
||||
## Correct play: keep it in view, stay quiet.
|
||||
## MIRROR advances ONLY while you look at it, is inert when you don't, and is deaf.
|
||||
## Correct play: look away. Noise is irrelevant.
|
||||
##
|
||||
## They share a silhouette on purpose. At thirty metres in a yellow room you cannot tell
|
||||
## which one you're looking at, so every sighting is a question, and the answer you
|
||||
## learned first is the one that kills you. The tells are close-range only: the MIRROR's
|
||||
## head is thrown back, its arms are up, and its feet never reach the carpet.
|
||||
enum Kind { HUNTER, MIRROR }
|
||||
var kind: int = Kind.HUNTER
|
||||
|
||||
## How far it can be seen from; beyond this it simply isn't rendered.
|
||||
const SEE_DIST := 34.0
|
||||
## Inside this, a lunge begins.
|
||||
@ -59,12 +74,15 @@ var _sting: AudioStreamPlayer
|
||||
|
||||
signal caught_you() ## the jumpscare fires; Backrooms decides the cost
|
||||
|
||||
func setup(player: Node3D, cam: Camera3D, half_extent: float) -> void:
|
||||
func setup(player: Node3D, cam: Camera3D, half_extent: float,
|
||||
which: int = Kind.HUNTER) -> void:
|
||||
_player = player
|
||||
_cam = cam
|
||||
_bounds = half_extent
|
||||
if ResourceLoader.exists(MODEL):
|
||||
var packed := load(MODEL) as PackedScene
|
||||
kind = which
|
||||
var path := MODEL if kind == Kind.HUNTER else MODEL_OTHER
|
||||
if ResourceLoader.exists(path):
|
||||
var packed := load(path) as PackedScene
|
||||
if packed != null:
|
||||
_mesh = packed.instantiate()
|
||||
add_child(_mesh)
|
||||
@ -105,8 +123,8 @@ func is_awake() -> bool:
|
||||
|
||||
## Something made a noise. The xenomorph half of it walks toward the last loud thing.
|
||||
func hear(at: Vector3, loudness: float) -> void:
|
||||
if state == S.DORMANT:
|
||||
return
|
||||
if state == S.DORMANT or kind == Kind.MIRROR:
|
||||
return # the other one does not care what you break
|
||||
if loudness >= noise_heat * 0.6:
|
||||
noise_at = at
|
||||
noise_heat = clampf(noise_heat + loudness, 0.0, 3.0)
|
||||
@ -180,8 +198,11 @@ func _physics_process(dt: float) -> void:
|
||||
var want := atan2(-flat.x, -flat.z)
|
||||
rotation.y = lerp_angle(rotation.y, want, clampf(dt * 6.0, 0.0, 1.0))
|
||||
|
||||
# --- STATIC: looking at it is what hurts you ---
|
||||
if seen_now:
|
||||
# --- STATIC ---
|
||||
# Only the HUNTER's stare degrades the signal. The MIRROR doesn't stare at you at
|
||||
# all — it's looking at the ceiling — so its threat is purely that it is closer than
|
||||
# it was, and it makes no noise on the tape to warn you.
|
||||
if seen_now and kind == Kind.HUNTER:
|
||||
var closeness := clampf(1.0 - dist / SEE_DIST, 0.0, 1.0)
|
||||
static_level = clampf(static_level + stare_static * (0.35 + closeness) * dt,
|
||||
0.0, 1.0)
|
||||
@ -192,6 +213,10 @@ func _physics_process(dt: float) -> void:
|
||||
_catch()
|
||||
return
|
||||
|
||||
if kind == Kind.MIRROR:
|
||||
_tick_mirror(dt, dist)
|
||||
return
|
||||
|
||||
match state:
|
||||
S.STALKING:
|
||||
_tick_stalk(dt, dist)
|
||||
@ -224,6 +249,20 @@ func _tick_hunt(dt: float, dist: float) -> void:
|
||||
elif dist > SEE_DIST * 0.8 and noise_heat < 0.3:
|
||||
state = S.STALKING
|
||||
|
||||
## The inversion. It is inert until observed, and then it comes — fast, and in a
|
||||
## straight line, and it does not stop while you are still looking. Every instinct the
|
||||
## HUNTER trained into you (keep it in sight, it's safe while watched) is now the way
|
||||
## you die.
|
||||
func _tick_mirror(dt: float, dist: float) -> void:
|
||||
if not seen_now:
|
||||
return # unwatched, it is furniture
|
||||
var speed := hunt_speed * 1.15
|
||||
if dist < LUNGE_DIST * 1.6:
|
||||
speed = lunge_speed
|
||||
_step_toward(_player.global_position, speed * dt)
|
||||
if dist < 1.5 and _cd <= 0.0:
|
||||
_catch()
|
||||
|
||||
func _tick_lunge(dt: float, dist: float) -> void:
|
||||
_step_toward(_player.global_position, lunge_speed * dt)
|
||||
if dist < 1.5 and _cd <= 0.0:
|
||||
|
||||
@ -198,7 +198,62 @@ def p_totem(M):
|
||||
return parts
|
||||
|
||||
|
||||
PROPS = {"entity": p_entity, "totem": p_totem}
|
||||
def p_entity2(M):
|
||||
"""THE OTHER ONE. Same height, same darkness, same suit — at thirty metres in a
|
||||
yellow room you cannot tell it from the first one, and that is the entire point,
|
||||
because its rules are inverted and guessing wrong kills you.
|
||||
|
||||
Up close there are tells, and they're all wrong in the same direction: the head is
|
||||
tilted fully back so it is looking at the ceiling and not at you, the arms are raised
|
||||
above it, and the feet hang — Backrooms.gd floats it, so it never touches the carpet."""
|
||||
parts = []
|
||||
|
||||
# legs hang straight and limp; no knee break, no weight on them
|
||||
for s in (-1, 1):
|
||||
hip = Vector((s * 0.075, 0.0, 1.16))
|
||||
ankle = Vector((s * 0.080, 0.03, 0.10))
|
||||
parts.append(assign(span(0.055, 0.036, hip, ankle), M["suit"]))
|
||||
foot = box((0.085, 0.20, 0.038), loc=(s * 0.080, 0.02, 0.085),
|
||||
rot=(math.radians(28), 0, 0))
|
||||
parts.append(assign(bevel(foot, 0.012, 2), M["suit"]))
|
||||
|
||||
torso = box((0.36, 0.19, 0.80), loc=(0.0, 0.0, 1.56))
|
||||
parts.append(assign(bevel(torso, 0.055, 3), M["suit"]))
|
||||
hips = box((0.24, 0.16, 0.20), loc=(0.0, 0.0, 1.20))
|
||||
parts.append(assign(bevel(hips, 0.045, 3), M["suit"]))
|
||||
for s in (-1, 1):
|
||||
lap = box((0.09, 0.03, 0.44), loc=(s * 0.078, -0.095, 1.68),
|
||||
rot=(0, math.radians(s * 5), 0))
|
||||
parts.append(assign(lap, M["shirt"]))
|
||||
|
||||
# arms UP, not down — reaching for the ceiling it is already staring at
|
||||
for s in (-1, 1):
|
||||
sh = Vector((s * 0.190, 0.0, 1.92))
|
||||
elb = Vector((s * 0.290, 0.02, 2.32))
|
||||
wr = Vector((s * 0.250, 0.01, 2.76))
|
||||
parts.append(assign(span(0.054, 0.042, sh, elb), M["suit"]))
|
||||
parts.append(assign(span(0.042, 0.032, elb, wr), M["suit"]))
|
||||
for f in range(4):
|
||||
a = wr + Vector((s * 0.006 * (f - 1.5), -0.008, 0.015))
|
||||
b = a + Vector((s * 0.014 * (f - 1.5), -0.02, 0.19 + 0.02 * abs(f - 1.5)))
|
||||
parts.append(assign(span(0.010, 0.006, a, b, verts=6), M["skin"]))
|
||||
parts.append(assign(ball(0.035, loc=tuple(wr), segs=12, rings=8), M["skin"]))
|
||||
|
||||
# head thrown back: the throat is what faces you
|
||||
neck = span(0.050, 0.058, Vector((0, 0, 1.94)), Vector((0, -0.09, 2.14)))
|
||||
parts.append(assign(neck, M["skin"]))
|
||||
skull = ball(0.112, loc=(0.0, -0.12, 2.22), segs=24, rings=16)
|
||||
skull.scale = (0.84, 1.02, 1.34)
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
parts.append(assign(skull, M["skin"]))
|
||||
back = ball(0.090, loc=(0.0, -0.02, 2.30), segs=20, rings=14)
|
||||
back.scale = (0.76, 0.90, 1.50)
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
parts.append(assign(back, M["skin"]))
|
||||
return parts
|
||||
|
||||
|
||||
PROPS = {"entity": p_entity, "entity2": p_entity2, "totem": p_totem}
|
||||
|
||||
|
||||
def materials():
|
||||
|
||||
Loading…
Reference in New Issue
Block a user