destroyulator/tools/gen_horror_props.py
Monster Robot Party a806272a7f 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>
2026-07-31 20:07:44 +10:00

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()