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>
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""LEVEL 0's inhabitants.
|
|
|
|
/Applications/Blender.app/Contents/MacOS/Blender --background \
|
|
--python tools/gen_horror_props.py -- [--render]
|
|
|
|
THE ENTITY is deliberately ambiguous. It's 2.5 m, far too thin, has no face, arms that
|
|
reach past its knees, and a smooth cranium swept backwards — which reads as Slenderman
|
|
from the front and as something else entirely in profile. Keeping it unresolved is the
|
|
point: a silhouette your brain finishes is worse than a monster it recognises.
|
|
|
|
Origin at floor-centre, facing -Z (so `look_at` and yaw work the obvious way).
|
|
|
|
THE TOTEM is the Blair Witch stick figure — twigs lashed into a person — which exists to
|
|
be found hanging in a corridor you have definitely already walked down.
|
|
"""
|
|
|
|
import bpy
|
|
import math
|
|
import os
|
|
import sys
|
|
from mathutils import Vector, Matrix
|
|
|
|
OUT_DIR = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"game", "assets", "store")
|
|
PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews")
|
|
RENDER = "--render" in sys.argv
|
|
|
|
|
|
def reset():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
|
|
def mat(name, color, rough=0.6, metal=0.0):
|
|
m = bpy.data.materials.new(name)
|
|
m.use_nodes = True
|
|
b = m.node_tree.nodes["Principled BSDF"]
|
|
b.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0)
|
|
b.inputs["Roughness"].default_value = rough
|
|
b.inputs["Metallic"].default_value = metal
|
|
return m
|
|
|
|
|
|
def assign(o, m):
|
|
o.data.materials.clear()
|
|
o.data.materials.append(m)
|
|
return o
|
|
|
|
|
|
def box(size, loc=(0, 0, 0), rot=(0, 0, 0)):
|
|
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot)
|
|
o = bpy.context.active_object
|
|
o.scale = (size[0], size[1], size[2])
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
return o
|
|
|
|
|
|
def cyl(r, depth, loc=(0, 0, 0), rot=(0, 0, 0), verts=14, r2=None):
|
|
if r2 is None:
|
|
bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=depth, vertices=verts,
|
|
location=loc, rotation=rot)
|
|
else:
|
|
bpy.ops.mesh.primitive_cone_add(radius1=r, radius2=r2, depth=depth,
|
|
vertices=verts, location=loc, rotation=rot)
|
|
return bpy.context.active_object
|
|
|
|
|
|
def ball(r, loc=(0, 0, 0), segs=16, rings=10):
|
|
bpy.ops.mesh.primitive_uv_sphere_add(radius=r, segments=segs, ring_count=rings,
|
|
location=loc)
|
|
return bpy.context.active_object
|
|
|
|
|
|
def bevel(o, width=0.006, segments=2):
|
|
m = o.modifiers.new("bevel", "BEVEL")
|
|
m.width = width
|
|
m.segments = segments
|
|
m.limit_method = 'ANGLE'
|
|
m.angle_limit = math.radians(40)
|
|
bpy.context.view_layer.objects.active = o
|
|
try:
|
|
bpy.ops.object.modifier_apply(modifier=m.name)
|
|
except Exception:
|
|
o.modifiers.remove(m)
|
|
return o
|
|
|
|
|
|
def shade_smooth(o, angle=40.0):
|
|
bpy.context.view_layer.objects.active = o
|
|
o.select_set(True)
|
|
try:
|
|
bpy.ops.object.shade_auto_smooth(angle=math.radians(angle))
|
|
except Exception:
|
|
bpy.ops.object.shade_smooth()
|
|
o.select_set(False)
|
|
|
|
|
|
def span(r0, r1, p0, p1, verts=8):
|
|
p0, p1 = Vector(p0), Vector(p1)
|
|
d = p1 - p0
|
|
n = d.length
|
|
if n < 1e-6:
|
|
return None
|
|
o = cyl(r0, n, verts=verts, r2=r1)
|
|
quat = Vector((0, 0, 1)).rotation_difference(d.normalized())
|
|
o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ quat.to_matrix().to_4x4()
|
|
bpy.ops.object.transform_apply(location=True, rotation=True)
|
|
return o
|
|
|
|
|
|
# ---------------------------------------------------------------- the entity
|
|
|
|
def p_entity(M):
|
|
"""2.5 m, far too thin, no face. Faces -Y in Blender, which the glTF export turns
|
|
into -Z — the direction Godot's look_at treats as forward."""
|
|
parts = []
|
|
H = 2.50
|
|
|
|
# legs: absurdly long, barely tapering, no feet to speak of
|
|
for s in (-1, 1):
|
|
hip = Vector((s * 0.085, 0.0, 1.18))
|
|
knee = Vector((s * 0.10, 0.02, 0.62))
|
|
ankle = Vector((s * 0.095, -0.01, 0.045))
|
|
parts.append(assign(span(0.062, 0.052, hip, knee), M["suit"]))
|
|
parts.append(assign(span(0.052, 0.040, knee, ankle), M["suit"]))
|
|
foot = box((0.10, 0.24, 0.045), loc=(s * 0.095, -0.05, 0.022))
|
|
parts.append(assign(bevel(foot, 0.014, 2), M["suit"]))
|
|
|
|
# torso: a narrow slab, wider at the shoulders than anything human
|
|
torso = box((0.40, 0.20, 0.78), loc=(0.0, 0.0, 1.56))
|
|
parts.append(assign(bevel(torso, 0.055, 3), M["suit"]))
|
|
hips = box((0.26, 0.17, 0.22), loc=(0.0, 0.0, 1.20))
|
|
parts.append(assign(bevel(hips, 0.045, 3), M["suit"]))
|
|
# lapels, so it reads as a suit and not a mannequin
|
|
for s in (-1, 1):
|
|
lap = box((0.10, 0.03, 0.46), loc=(s * 0.085, -0.10, 1.68),
|
|
rot=(0, math.radians(s * 5), 0))
|
|
parts.append(assign(lap, M["shirt"]))
|
|
tie = box((0.05, 0.02, 0.40), loc=(0.0, -0.108, 1.62))
|
|
parts.append(assign(tie, M["tie"]))
|
|
|
|
# arms: they reach past the knees. This is the tell.
|
|
for s in (-1, 1):
|
|
sh = Vector((s * 0.205, 0.0, 1.90))
|
|
elb = Vector((s * 0.255, 0.03, 1.24))
|
|
wr = Vector((s * 0.235, 0.01, 0.58))
|
|
parts.append(assign(span(0.058, 0.044, sh, elb), M["suit"]))
|
|
parts.append(assign(span(0.044, 0.034, elb, wr), M["suit"]))
|
|
# long pale fingers
|
|
for f in range(4):
|
|
a = wr + Vector((s * 0.006 * (f - 1.5), -0.012, -0.02))
|
|
b = a + Vector((s * 0.012 * (f - 1.5), -0.03, -0.20 - 0.02 * abs(f - 1.5)))
|
|
parts.append(assign(span(0.011, 0.006, a, b, verts=6), M["skin"]))
|
|
parts.append(assign(ball(0.038, loc=tuple(wr), segs=12, rings=8), M["skin"]))
|
|
|
|
# head: smooth, blank, and swept BACKWARDS — Slenderman from the front, something
|
|
# with a cranium from the side. Never resolving which is the whole idea.
|
|
neck = span(0.052, 0.060, Vector((0, 0, 1.93)), Vector((0, 0.01, 2.10)))
|
|
parts.append(assign(neck, M["skin"]))
|
|
skull = ball(0.115, loc=(0.0, 0.03, 2.24), segs=24, rings=16)
|
|
skull.scale = (0.86, 1.30, 1.02)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
parts.append(assign(skull, M["skin"]))
|
|
back = ball(0.095, loc=(0.0, 0.14, 2.27), segs=20, rings=14)
|
|
back.scale = (0.78, 1.55, 0.86)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
parts.append(assign(back, M["skin"]))
|
|
return parts
|
|
|
|
|
|
# ---------------------------------------------------------------- the totem
|
|
|
|
def p_totem(M):
|
|
"""Twigs lashed into a person. Hangs from a doorway you have already walked through."""
|
|
parts = []
|
|
# the cord it hangs by
|
|
parts.append(assign(span(0.006, 0.006, Vector((0, 0, 1.10)), Vector((0, 0, 0.86))),
|
|
M["cord"]))
|
|
# body + limbs, all crooked on purpose
|
|
parts.append(assign(span(0.015, 0.013, Vector((0, 0, 0.86)), Vector((0.01, 0, 0.40))),
|
|
M["twig"]))
|
|
parts.append(assign(span(0.013, 0.011, Vector((-0.20, 0.02, 0.78)),
|
|
Vector((0.21, -0.02, 0.72))), M["twig"]))
|
|
for s in (-1, 1):
|
|
parts.append(assign(span(0.012, 0.009, Vector((0.01, 0, 0.42)),
|
|
Vector((s * 0.16, 0.03 * s, 0.06))), M["twig"]))
|
|
# head: a bundle
|
|
for i in range(5):
|
|
a = i * 1.1
|
|
parts.append(assign(span(0.008, 0.008,
|
|
Vector((math.cos(a) * 0.05, math.sin(a) * 0.04, 0.90)),
|
|
Vector((-math.cos(a) * 0.05, -math.sin(a) * 0.04, 0.80))),
|
|
M["twig"]))
|
|
# lashings
|
|
for z in (0.86, 0.75, 0.42):
|
|
r = cyl(0.026, 0.014, loc=(0.005, 0, z), verts=10)
|
|
parts.append(assign(r, M["cord"]))
|
|
return parts
|
|
|
|
|
|
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():
|
|
return {
|
|
# near-black, but not pure — pure black reads as a hole, and a hole is less
|
|
# frightening than a thing
|
|
"suit": mat("suit", (0.045, 0.045, 0.055), rough=0.72),
|
|
"shirt": mat("shirt", (0.80, 0.79, 0.76), rough=0.85),
|
|
"tie": mat("tie", (0.10, 0.09, 0.11), rough=0.6),
|
|
"skin": mat("skin", (0.90, 0.89, 0.86), rough=0.42),
|
|
"twig": mat("twig", (0.32, 0.24, 0.15), rough=0.95),
|
|
"cord": mat("cord", (0.58, 0.52, 0.38), rough=1.0),
|
|
}
|
|
|
|
|
|
def export(objs, name):
|
|
objs = [o for o in objs if o is not None]
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for o in objs:
|
|
o.select_set(True)
|
|
bpy.context.view_layer.objects.active = objs[0]
|
|
path = os.path.join(OUT_DIR, name + ".glb")
|
|
bpy.ops.export_scene.gltf(filepath=path, export_format='GLB',
|
|
use_selection=True, export_apply=True, export_yup=True)
|
|
print("[horror] %-12s -> %s" % (name, os.path.basename(path)))
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
|
|
|
|
def render_preview(name):
|
|
scene = bpy.context.scene
|
|
engines = scene.render.bl_rna.properties['engine'].enum_items.keys()
|
|
for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
|
|
if want in engines:
|
|
scene.render.engine = want
|
|
break
|
|
scene.render.resolution_x = 560
|
|
scene.render.resolution_y = 760
|
|
if scene.world is None:
|
|
scene.world = bpy.data.worlds.new("preview")
|
|
scene.world.use_nodes = True
|
|
bg = scene.world.node_tree.nodes.get("Background")
|
|
if bg:
|
|
bg.inputs[0].default_value = (0.44, 0.42, 0.22, 1.0) # backrooms yellow
|
|
lo = Vector((1e9,) * 3)
|
|
hi = Vector((-1e9,) * 3)
|
|
for o in scene.objects:
|
|
if o.type != 'MESH':
|
|
continue
|
|
for c in o.bound_box:
|
|
w = o.matrix_world @ Vector(c)
|
|
lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z)))
|
|
hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z)))
|
|
centre = (lo + hi) * 0.5
|
|
radius = max((hi - lo).length * 0.5, 0.05)
|
|
dist = radius * 2.5
|
|
d = Vector((0.42, -0.88, 0.10)).normalized()
|
|
bpy.ops.object.camera_add(location=tuple(centre + d * dist))
|
|
cam = bpy.context.active_object
|
|
cam.data.lens = 62
|
|
cam.rotation_mode = 'QUATERNION'
|
|
cam.rotation_quaternion = (-d).to_track_quat('-Z', 'Y')
|
|
scene.camera = cam
|
|
for off, e, s in ((Vector((0.7, -1.0, 0.9)), 42.0, 1.4),
|
|
(Vector((-1.0, -0.3, 0.2)), 12.0, 2.0)):
|
|
bpy.ops.object.light_add(type='AREA', location=tuple(centre + off * dist))
|
|
L = bpy.context.active_object
|
|
L.data.energy = e * (dist ** 2)
|
|
L.data.size = s * radius
|
|
scene.render.filepath = os.path.join(PREVIEW_DIR, name + ".png")
|
|
bpy.ops.render.render(write_still=True)
|
|
|
|
|
|
def main():
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
os.makedirs(PREVIEW_DIR, exist_ok=True)
|
|
for name, builder in PROPS.items():
|
|
reset()
|
|
M = materials()
|
|
parts = builder(M)
|
|
for p in parts:
|
|
shade_smooth(p, 40.0)
|
|
export(parts, name)
|
|
if RENDER:
|
|
render_preview(name)
|
|
print("[horror] done -> %s" % OUT_DIR)
|
|
|
|
|
|
main()
|