destroyulator/tools/gen_horror_props.py
Monster Robot Party 006c324f1f LANE7: LEVEL 0 gets an entity, a tape, and a loop
THE ENTITY (Entity.gd) borrows from three places and each borrowing does a job:

  SLENDERMAN  it does not move while you look at it, and has closed the distance every
              time you look back. Looking is ALSO what builds the static — so the safe
              option (keep it in view) kills you slowly and the fast option (run) lets
              it catch up. That's the trap, and it's why Slender worked.
  XENOMORPH   it hunts SOUND, and the thing you came here to do is smash furniture.
              Every break and every puff of powder drops a marker it walks toward.
              Playing well is what feeds it. A loud enough noise overrides the freeze,
              because staring is a valid answer to a stalker and not a valid answer to
              something that just heard a sledgehammer hit a filing cabinet.
  BLAIR WITCH the level wraps: walk far enough and you come out of the opposite wall
              into the same room, because every room here IS the same room. A wrap is
              also when it repositions, so "I've been here before" and "it's already
              here" land in the same second.

It can't be fought. Getting caught doesn't kill you — it costs a lungful, a spike of
rage, and the knowledge that it can do that whenever it likes.

Deliberately unresolved design: 2.5 m, faceless, arms past the knees, cranium swept
backwards. Slenderman from the front, something else in profile. A silhouette your brain
finishes beats a monster it recognises. Blair Witch stick totems hang in the maze.

THE TAPE (Dread.gd) — one shader: grain, scanlines, chroma split, a wandering tracking
tear, an edge that breathes when it's near but unseen, and Slender interference driven
straight off the stare. The static IS the read-out; there's no meter for it.

TWO BUGS WORTH THE COMMIT MESSAGE
1. `seen_now` was a pure view-cone test with NO line of sight, so it built static
   through walls — and in a maze that's most of the time.
2. Fixing that exposed the real one: the generated maze was so dense there was no
   sightline longer than a few metres ANYWHERE, which kills the entity outright, since
   a stalker you can never see can never be stared at. Thinned the generator — skip a
   third of the lattice lines, much bigger gaps — which also matches the reference
   photographs far better. They're mostly open floor with occasional slabs, not
   corridors.

dev/probe_horror.gd verifies all of it headlessly, including hunting for a clear
sightline first (otherwise the Slender test silently tests nothing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:25:28 +10:00

290 lines
11 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
PROPS = {"entity": p_entity, "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()