diff --git a/LANES/LANE7-cubicle-hell.md b/LANES/LANE7-cubicle-hell.md index 29ba8d0..8bb5acd 100644 --- a/LANES/LANE7-cubicle-hell.md +++ b/LANES/LANE7-cubicle-hell.md @@ -208,3 +208,31 @@ again, coughing. Pairs naturally with TOTAL DESTRUCTION, because the level *is* Still open here: a second gas with a different trade (something that reveals further but costs more lungs), and the entity. There is currently nothing in here with you. + +### LEVEL 0's horror layer *(built)* + +`Entity.gd` · `Dread.gd` · `Backrooms.gd` · `tools/gen_horror_props.py` + +Three borrowings, each doing a different job. **Slenderman**: frozen while watched, +closer every time you look back, and staring is what builds the static — so the safe +play kills you slowly and the fast play lets it catch up. **Xenomorph**: it hunts sound, +and the objective is to make sound, so playing well feeds it. **Blair Witch**: the level +wraps, and a wrap is when it repositions, so "I've been here" and "it's already here" +arrive together. + +The entity is deliberately unresolved — 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. + +Two bugs worth remembering: +- `seen_now` was a pure view-cone test with **no line of sight**, so it built static + through walls. In a maze that's most of the time. +- Then the fix 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 — 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 photos far + better; they're mostly open floor with occasional slabs. + +Still open: it can only touch you, never end a run. A real fail state, a second entity +that behaves differently, and the "did that chair move?" pass on wrapping — the room is +identical, so *changing one thing* on a loop would be very cheap and very mean. diff --git a/game/README.md b/game/README.md index 8993c23..bc60477 100644 --- a/game/README.md +++ b/game/README.md @@ -70,6 +70,33 @@ jolts, the screen blooms, and it feeds the rage meter, because of course it does The intended rhythm is spray → switch to something heavy → break it before the coating settles → spray again, coughing. +### …and there is something in there with you + +`scripts/Entity.gd` borrows from three places, and each borrowing does a different job: + +- **Slenderman** — it does not move while you are looking at it, and it has closed the + distance every time you look back. Looking at it is *also* what builds the **static**, + so the safe option (keep it in view) is the one that kills you slowly and the fast + option (run) is the one that lets it catch up. That's the trap, and it's why Slender + worked. +- **Xenomorph** — it hunts by **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; playing quietly means not playing. A loud enough noise + overrides the freeze entirely. +- **Blair Witch** — walk far enough in any direction and you come out of the opposite + wall into the same room, because every room here *is* the same room. And a wrap is + when the entity gets to reposition, so "I've been here before" and "it's already here" + land in the same second. + +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. Getting caught doesn't +kill you; it costs you a lungful, a spike of rage, and the knowledge that it can do that +whenever it likes. + +`scripts/Dread.gd` is the tape: grain, scanlines, chroma split, a wandering tracking +tear, and Slender's interference driven straight off the entity's stare — the HUD element +that isn't a HUD element. + ## The four modes `scripts/Modes.gd`, cycled with M. diff --git a/game/assets/store/entity.glb b/game/assets/store/entity.glb new file mode 100644 index 0000000..838a165 Binary files /dev/null and b/game/assets/store/entity.glb differ diff --git a/game/assets/store/entity.glb.import b/game/assets/store/entity.glb.import new file mode 100644 index 0000000..c327755 --- /dev/null +++ b/game/assets/store/entity.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://burexbugk1tw" +path="res://.godot/imported/entity.glb-aeb39bf4ffc2f33ed3f1a75f589dae4d.scn" + +[deps] + +source_file="res://assets/store/entity.glb" +dest_files=["res://.godot/imported/entity.glb-aeb39bf4ffc2f33ed3f1a75f589dae4d.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 diff --git a/game/assets/store/totem.glb b/game/assets/store/totem.glb new file mode 100644 index 0000000..8babb6c Binary files /dev/null and b/game/assets/store/totem.glb differ diff --git a/game/assets/store/totem.glb.import b/game/assets/store/totem.glb.import new file mode 100644 index 0000000..718b429 --- /dev/null +++ b/game/assets/store/totem.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://5qpxvj3s5ajo" +path="res://.godot/imported/totem.glb-b8caf92fb06fc9cf491fcba7b13cab07.scn" + +[deps] + +source_file="res://assets/store/totem.glb" +dest_files=["res://.godot/imported/totem.glb-b8caf92fb06fc9cf491fcba7b13cab07.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 diff --git a/game/dev/DemoDriver.gd b/game/dev/DemoDriver.gd index 94f0c1f..df8f674 100644 --- a/game/dev/DemoDriver.gd +++ b/game/dev/DemoDriver.gd @@ -48,25 +48,17 @@ func _find_player(n: Node) -> Player: return null func _build_script() -> void: - # LEVEL 0. Walk in, see nothing, spray, see for a moment, spray again while the haze - # builds. Total Destruction is the right mode here — the level IS a search. + # LEVEL 0, the horror pass. NOTE the level swap is async and enter() repositions the + # entity on wake, so it has to be placed a step LATER or the wake overwrites it. _script = [ - {"dur": 0.4, "move_to": null, "look_at": null, "act": "level:backrooms"}, - {"dur": 0.4, "move_to": null, "look_at": null, "act": "mode:2"}, - {"dur": 2.6, "move_to": Vector3(0, 0, 13.0), "look_at": Vector3(0, 1.3, 2.0), "act": ""}, - {"dur": 1.6, "move_to": Vector3(0, 0, 9.0), "look_at": Vector3(2.0, 1.3, 0.0), "act": ""}, - {"dur": 1.8, "move_to": null, "look_at": null, "act": "puff"}, - {"dur": 1.6, "move_to": null, "look_at": null, "act": "puff"}, - {"dur": 2.2, "move_to": Vector3(2.0, 0, 5.0), "look_at": Vector3(-3.0, 1.2, -1.0), "act": ""}, - {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, - {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, - {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, - {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, - {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, - {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, - {"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"}, - {"dur": 2.6, "move_to": Vector3(-2.0, 0, 2.0), "look_at": Vector3(-6.0, 1.2, -4.0), "act": ""}, - {"dur": 3.0, "move_to": null, "look_at": null, "act": ""}, + {"dur": 1.2, "move_to": null, "look_at": null, "act": "level:backrooms"}, + {"dur": 0.3, "move_to": Vector3(0, 0, 4.0), "look_at": Vector3(0, 1.5, -4.0), + "act": "ent:0,-4"}, + {"dur": 2.4, "move_to": null, "look_at": null, "act": ""}, + {"dur": 2.4, "move_to": Vector3(0, 0, 1.5), "look_at": Vector3(0, 1.6, -4.0), "act": ""}, + {"dur": 1.6, "move_to": null, "look_at": null, "act": ""}, + {"dur": 2.6, "move_to": Vector3(0, 0, 18.4), "look_at": Vector3(0, 1.3, 19.0), "act": ""}, + {"dur": 2.4, "move_to": null, "look_at": Vector3(3.0, 1.3, -8.0), "act": ""}, ] func _motion(verbose := false) -> float: @@ -166,6 +158,20 @@ func _do(act: String) -> void: if md != null: md.set_mode(int(act.substr(5))) return + if act.begins_with("ent:"): + # park the entity somewhere specific so the tour can look at it on cue + var br = _main.get("_backrooms") + if br != null and br.entity != null: + var p := act.substr(4).split(",") + br.entity.global_position = Vector3(float(p[0]), 0.0, float(p[1])) + br.entity.state = Entity.S.STALKING + return + if act == "scare": + var br2 = _main.get("_backrooms") + if br2 != null and br2.entity != null: + br2.entity._cd = 0.0 + br2.entity._catch() + return if act == "puff": var dd = _main.get("_dust") if dd != null: diff --git a/game/dev/probe_horror.gd b/game/dev/probe_horror.gd new file mode 100644 index 0000000..1d3ef4b --- /dev/null +++ b/game/dev/probe_horror.gd @@ -0,0 +1,95 @@ +extends SceneTree + +## LEVEL 0's horror layer, verified headlessly: does the entity wake only there, does it +## FREEZE while watched and CLOSE while not (the Slender rule), does noise pull it (the +## xenomorph rule), does the loop wrap you, and does all of it shut down on the way out. + +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 ent = br.entity + var player = main.get("_player") + var cam = main.get("_cam") + print("active : ", br.active, " entity awake: ", ent.is_awake()) + print("totems hung : ", br._totems.size()) + + # --- SLENDER RULE: frozen while watched --- + # It's a maze, so a random pair of points usually has a wall between them and the + # line-of-sight test correctly reports "not seen". Hunt for a clear sightline first, + # or this tests nothing. + player.global_position = Vector3(0, 0, 0) + player.rotation.y = 0.0 # facing -Z + var placed := false + for d in [3.0, 4.0, 5.0, 6.0, 8.0, 10.0]: + ent.global_position = Vector3(0, 0, -d) + await physics_frame + await physics_frame + if ent.seen_now: + print("clear sightline at %.0f m" % d) + placed = true + break + if not placed: + print("NO clear sightline found — maze too dense at this seed") + for i in 3: + await physics_frame + var before = ent.global_position + for i in 40: + await physics_frame + var moved_watched = before.distance_to(ent.global_position) + print("watched : moved %.2f m seen=%s static=%.2f" % [ + moved_watched, ent.seen_now, ent.static_level]) + + # --- and closes when you look away --- + player.rotation.y = PI # now facing +Z, away from it + ent.state = Entity.S.STALKING + before = ent.global_position + for i in 40: + await physics_frame + print("looking away : moved %.2f m seen=%s" % [ + before.distance_to(ent.global_position), ent.seen_now]) + + # --- XENOMORPH RULE: it walks toward noise --- + ent.global_position = Vector3(14, 0, 14) + ent.state = Entity.S.STALKING + ent.noise_heat = 0.0 + player.rotation.y = 0.0 # look AWAY from it, at -Z + var noise := Vector3(-14, 0, -14) + br.on_noise(noise, 2.0) + var d0 = ent.global_position.distance_to(noise) + for i in 60: + await physics_frame + var d1 = ent.global_position.distance_to(noise) + print("noise at %s : closed %.2f m toward it" % [noise, d0 - d1]) + + # --- BLAIR WITCH: the loop --- + var loops0 = br.loops + player.global_position = Vector3(0, 0, 18.6) # right at the boundary + for i in 6: + await physics_frame + print("wrap : loops %d -> %d player now z=%.1f" % [ + loops0, br.loops, player.global_position.z]) + + # --- the scare costs you something --- + var dust = main.get("_dust") + var rage = main.get("_rage") + var lungs0 = dust.inhaled + var rage0 = rage.value + ent._cd = 0.0 + ent._catch() + await process_frame + print("caught : lungs %.2f -> %.2f rage %.2f -> %.2f touched=%d" % [ + lungs0, dust.inhaled, rage0, rage.value, br.caught]) + + # --- and none of it follows you to the office --- + main._load_level("scranton") + for i in 6: + await physics_frame + print("back at office: horror active=%s entity awake=%s dust active=%s" % [ + br.active, ent.is_awake(), dust.active]) + quit() diff --git a/game/dev/probe_horror.gd.uid b/game/dev/probe_horror.gd.uid new file mode 100644 index 0000000..bf178b4 --- /dev/null +++ b/game/dev/probe_horror.gd.uid @@ -0,0 +1 @@ +uid://buns5cohy373i diff --git a/game/scripts/Backrooms.gd b/game/scripts/Backrooms.gd new file mode 100644 index 0000000..cc04515 --- /dev/null +++ b/game/scripts/Backrooms.gd @@ -0,0 +1,197 @@ +extends Node +class_name Backrooms + +## LEVEL 0's horror layer: the entity, the loop, the totems, and the scares. +## +## THE LOOP is the Blair Witch bit and the most important part. Walk far enough in any +## direction and you come out of the opposite wall — into the same room, because the maze +## is generated from a fixed seed and every part of it looks like every other part. You +## get a lurch, a tear on the tape, and the growing certainty that you have been here. +## +## Crucially, a wrap is also when the entity gets to reposition. So "I've been here +## before" and "it's already here" arrive in the same second, which is a far better +## scare than either on its own. +## +## THE NOISE RULE ties the horror to the game: the entity hunts sound, and the objective +## is to break things. Playing well is what feeds it. Playing quietly means not playing. +## +## Godot 4.7 GDScript 2.0. + +const TOTEM := "res://assets/store/totem.glb" + +## How close to the outer wall triggers a wrap. +const WRAP_MARGIN := 1.4 +## Seconds after a wrap before another can fire (stops thrashing on a corner). +const WRAP_COOLDOWN := 1.2 + +var active := false +var entity: Entity +var dread: Dread +var rage: Rage +var dust: Dust + +var loops := 0 ## how many times the room has come back around +var caught := 0 + +var _player: Node3D +var _cam: Camera3D +var _hud: Hud +var _host: Node3D +var _half := 19.0 +var _wrap_cd := 0.0 +var _totems: Array[Node3D] = [] +var _next_whisper := 0.0 + +## Lines that fire on a wrap. Deliberately flat — the room is doing the work. +const WRAP_LINES := [ + "…you've been here.", + "this is the same room.", + "you turned left. you have always turned left.", + "the carpet has the same stain.", + "you are further in than you were.", + "it was behind you a moment ago.", +] + +func setup(player: Node3D, cam: Camera3D, hud: Hud, r: Rage, d: Dust, + host: Node3D) -> void: + _player = player + _cam = cam + _hud = hud + rage = r + dust = d + _host = host + + dread = Dread.new() + dread.setup() + host.add_child(dread) + + entity = Entity.new() + host.add_child(entity) + entity.setup(player, cam, _half) + entity.caught_you.connect(_on_caught) + +## Called when a level finishes building. `half` is the site's half-extent. +func enter(half: float) -> void: + _half = half + active = true + loops = 0 + caught = 0 + _wrap_cd = 0.0 + entity._bounds = half + entity.wake() + dread.set_active(true) + _scatter_totems() + if _hud != null: + _hud.toast("there is no exit. there is no one else here.") + +func leave() -> void: + active = false + entity.sleep() + dread.set_active(false) + for t in _totems: + if is_instance_valid(t): + t.queue_free() + _totems.clear() + +## 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) + +# ---------------------------------------------------------------- totems +## Hung in doorways you have definitely already walked through. +func _scatter_totems() -> void: + for t in _totems: + if is_instance_valid(t): + t.queue_free() + _totems.clear() + if not ResourceLoader.exists(TOTEM): + return + var packed := load(TOTEM) as PackedScene + for i in 7: + var n: Node3D = packed.instantiate() + _host.add_child(n) + n.position = Vector3(randf_range(-_half + 3.0, _half - 3.0), 1.30, + randf_range(-_half + 3.0, _half - 3.0)) + n.rotation.y = randf_range(0.0, TAU) + _totems.append(n) + +# ---------------------------------------------------------------- per-frame +func _process(dt: float) -> void: + if not active or _player == null: + return + _wrap_cd = maxf(0.0, _wrap_cd - dt) + _check_wrap() + + # how near it is while UNSEEN — the vignette breathing is the only tell you get + var near := 0.0 + 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) + dread.drive(dt, entity.static_level, near) + + # it whispers when it's close and behind you + _next_whisper -= dt + if near > 0.55 and _next_whisper <= 0.0: + _next_whisper = randf_range(5.0, 11.0) + if _hud != null: + _hud.toast("something is breathing") + if rage != null: + rage.provoke(0.03, "something is breathing") + +## Cross the boundary and you come out the far side — into a room that is identical, +## because every room here is identical. The entity repositions on the same frame. +func _check_wrap() -> void: + if _wrap_cd > 0.0: + return + var p := _player.global_position + var lim := _half - WRAP_MARGIN + var wrapped := false + if absf(p.x) > lim: + p.x = -signf(p.x) * (lim - 1.2) + wrapped = true + if absf(p.z) > lim: + p.z = -signf(p.z) * (lim - 1.2) + wrapped = true + if not wrapped: + return + _wrap_cd = WRAP_COOLDOWN + loops += 1 + _player.global_position = p + if _player is CharacterBody3D: + (_player as CharacterBody3D).velocity = Vector3.ZERO + dread.flash() + # the good bit: it is somewhere new, and you have no idea where + entity._reposition(9.0) + if entity.state == Entity.S.STALKING and loops >= 2: + entity.state = Entity.S.HUNTING # it learns + if _hud != null: + _hud.toast(WRAP_LINES[loops % WRAP_LINES.size()]) + if rage != null: + rage.provoke(0.035, "the same room") + +func _on_caught() -> void: + caught += 1 + dread.flash() + if _cam != null: + _cam.rotation.x += randf_range(-0.22, 0.22) + _cam.rotation.z += randf_range(-0.2, 0.2) + # it doesn't kill you. It costs you: a lungful, a spike of rage, and the certainty + # that it can do that whenever it likes. + if dust != null: + dust.inhaled = clampf(dust.inhaled + 0.30, 0.0, 1.0) + if rage != null: + rage.provoke(0.16, "IT TOUCHED YOU") + if _hud != null: + _hud.toast("IT TOUCHED YOU") + +func hud_line() -> String: + if not active: + return "" + var st := int(round(entity.static_level * 100.0)) + var warn := "" + if entity.state == Entity.S.HUNTING: + warn = " · it is looking for you" + elif entity.state == Entity.S.LUNGE: + warn = " · RUN" + return "loops %d · touched %d · signal %d%%%s" % [loops, caught, st, warn] diff --git a/game/scripts/Backrooms.gd.uid b/game/scripts/Backrooms.gd.uid new file mode 100644 index 0000000..e068e49 --- /dev/null +++ b/game/scripts/Backrooms.gd.uid @@ -0,0 +1 @@ +uid://qknlqihf742x diff --git a/game/scripts/Dread.gd b/game/scripts/Dread.gd new file mode 100644 index 0000000..654a2e5 --- /dev/null +++ b/game/scripts/Dread.gd @@ -0,0 +1,110 @@ +extends CanvasLayer +class_name Dread + +## The camera you are apparently holding. +## +## One full-screen shader doing four jobs, because they're all the same job — making the +## image feel like a recording of something rather than a view of it: +## +## VHS ......... grain, scanlines, a chroma split, and a tracking tear that wanders. +## Found-footage grammar. It also quietly hides the fact that the +## geometry is very simple. +## STATIC ...... Slender's interference. Driven by Entity.static_level, so it is a +## read-out of how long you've been looking at it — the HUD element that +## isn't a HUD element. +## SCARE ....... a white-hot bloom and a violent tear on the frame it takes you. +## PULSE ....... a slow breathing darkness at the edges when it's near but unseen, +## which is the only warning you get that it's behind you. +## +## Godot 4.7 GDScript 2.0. + +const SHADER := """ +shader_type canvas_item; +uniform float t = 0.0; +uniform float grain : hint_range(0.0, 1.0) = 0.35; +uniform float static_lv : hint_range(0.0, 1.0) = 0.0; +uniform float scare : hint_range(0.0, 1.0) = 0.0; +uniform float near : hint_range(0.0, 1.0) = 0.0; +uniform sampler2D screen_tex : hint_screen_texture, filter_linear; + +float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } + +void fragment() { + vec2 uv = SCREEN_UV; + + // tracking tear: a band that slides down the frame and drags the image sideways + float band = fract(uv.y * 1.4 - t * 0.11); + float tear = smoothstep(0.982, 1.0, band) * (0.55 + static_lv + scare * 4.0); + uv.x += tear * (hash(vec2(floor(uv.y * 200.0), floor(t * 12.0))) - 0.5) * 0.06; + + // static shoves the image around in blocks — legibility falls apart as it stares + if (static_lv > 0.02) { + vec2 blk = floor(uv * vec2(48.0, 90.0)); + float j = hash(blk + floor(t * 20.0)); + uv += (vec2(hash(blk.yx), j) - 0.5) * static_lv * 0.05 * step(0.72, j); + } + + // chroma split, widening with dread + float split = (0.0016 + static_lv * 0.010 + scare * 0.020); + vec3 col; + col.r = texture(screen_tex, uv + vec2(split, 0.0)).r; + col.g = texture(screen_tex, uv).g; + col.b = texture(screen_tex, uv - vec2(split, 0.0)).b; + + // grain + scanlines + float g = hash(uv * vec2(1024.0, 768.0) + fract(t) * 91.0); + col += (g - 0.5) * (grain * 0.13 + static_lv * 0.34); + col *= 0.90 + 0.10 * sin(SCREEN_UV.y * 900.0); + + // the white blocks of real interference + col = mix(col, vec3(g), static_lv * static_lv * 0.34); + + // edges close in when it's near but out of sight — your only warning + vec2 d = (SCREEN_UV - vec2(0.5)) * vec2(1.0, 0.62); + float r = length(d) * 2.0; + float breathe = 0.5 + 0.5 * sin(t * 2.1); + col *= 1.0 - smoothstep(0.42, 1.25, r) * (near * (0.45 + 0.25 * breathe)); + + // the frame it takes you + col = mix(col, vec3(1.0), scare * 0.62); + + COLOR = vec4(col, 1.0); +} +""" + +var _rect: ColorRect +var _t := 0.0 +var _scare := 0.0 +var _face: TextureRect +var _face_left := 0.0 + +func setup() -> void: + layer = 30 # above everything, including the rage vignette + _rect = ColorRect.new() + _rect.set_anchors_preset(Control.PRESET_FULL_RECT) + _rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + var sh := Shader.new() + sh.code = SHADER + var m := ShaderMaterial.new() + m.shader = sh + _rect.material = m + add_child(_rect) + visible = false + +## Driven every frame by Backrooms. +func drive(dt: float, static_lv: float, near: float) -> void: + _t += dt + _scare = maxf(0.0, _scare - dt * 1.7) + var m := _rect.material as ShaderMaterial + m.set_shader_parameter("t", _t) + m.set_shader_parameter("static_lv", static_lv) + m.set_shader_parameter("near", near) + m.set_shader_parameter("scare", _scare) + +func flash() -> void: + _scare = 1.0 + +func set_active(on: bool) -> void: + visible = on + if not on: + _scare = 0.0 diff --git a/game/scripts/Dread.gd.uid b/game/scripts/Dread.gd.uid new file mode 100644 index 0000000..cb6cc5b --- /dev/null +++ b/game/scripts/Dread.gd.uid @@ -0,0 +1 @@ +uid://dp1rttnc5achn diff --git a/game/scripts/Dust.gd b/game/scripts/Dust.gd index 4c9601b..82b95c2 100644 --- a/game/scripts/Dust.gd +++ b/game/scripts/Dust.gd @@ -29,6 +29,7 @@ const COUGH_AT := 0.72 const PUFF_COOLDOWN := 0.55 var rage: Rage +var heard_by: Backrooms ## a puff is a noise, and it hunts sound var active := false ## only true on a site whose spec says invisible var inhaled := 0.0 ## 0..1 @@ -139,6 +140,8 @@ func discharge() -> bool: _puff_snd.pitch_scale = randf_range(0.94, 1.08) _puff_snd.play() _spawn_puff() + if heard_by != null: + heard_by.on_noise(_player.global_position, 0.7) var origin := _cam.global_position var dir := -_cam.global_transform.basis.z diff --git a/game/scripts/Entity.gd b/game/scripts/Entity.gd new file mode 100644 index 0000000..ce73de9 --- /dev/null +++ b/game/scripts/Entity.gd @@ -0,0 +1,296 @@ +extends Node3D +class_name Entity + +## The thing in LEVEL 0. +## +## It borrows from three places, and the borrowings do different jobs: +## +## SLENDERMAN — it does not move while you are looking at it. Look away and it has +## closed the distance. Looking at it is also what builds the STATIC, so +## the safe option (keep it in view) is the one that kills you slowly and +## the fast option (run) is the one that lets it catch up. That's the +## whole trap, and it's why Slender worked. +## +## XENOMORPH — it hunts by SOUND. And the thing you came to LEVEL 0 to do is smash +## furniture, so the game's own objective feeds it. Every break and every +## puff of the extinguisher drops a noise marker it walks toward. +## +## BLAIR WITCH— it uses the loop. When the level wraps you round to the far side, it +## gets to reposition, so "I've been here before" and "it's already here" +## arrive together. +## +## It cannot be fought. Weapons pass through it. The only outs are distance, silence, and +## not looking at it — three things the rest of the game actively discourages. +## +## Godot 4.7 GDScript 2.0. + +const MODEL := "res://assets/store/entity.glb" + +enum S { DORMANT, STALKING, HUNTING, LUNGE } + +## How far it can be seen from; beyond this it simply isn't rendered. +const SEE_DIST := 34.0 +## Inside this, a lunge begins. +const LUNGE_DIST := 3.6 +## Cone (dot product) within which the player counts as LOOKING at it. +const LOOK_DOT := 0.55 + +@export var stalk_speed := 2.5 +@export var hunt_speed := 4.4 +@export var lunge_speed := 9.5 +## Static gained per second while it is on screen, scaled by how close it is. +@export var stare_static := 0.28 +@export var static_decay := 0.22 + +var state: int = S.DORMANT +var static_level := 0.0 ## 0..1 — Slender's interference. At 1.0 it takes you. +var seen_now := false +var noise_at := Vector3.ZERO +var noise_heat := 0.0 + +var _player: Node3D +var _cam: Camera3D +var _bounds := 19.0 +var _mesh: Node3D +var _cd := 0.0 ## seconds until it may act again after a scare +var _think := 0.0 +var _breath: AudioStreamPlayer3D +var _sting: AudioStreamPlayer + +signal caught_you() ## the jumpscare fires; Backrooms decides the cost + +func setup(player: Node3D, cam: Camera3D, half_extent: float) -> void: + _player = player + _cam = cam + _bounds = half_extent + if ResourceLoader.exists(MODEL): + var packed := load(MODEL) as PackedScene + if packed != null: + _mesh = packed.instantiate() + add_child(_mesh) + visible = false + + # it breathes, and you hear it before you see it + _breath = AudioStreamPlayer3D.new() + _breath.stream = _noise_wav(1.6, 70.0, 1.4, true) + _breath.unit_size = 14.0 + _breath.max_db = -2.0 + _breath.autoplay = false + add_child(_breath) + + _sting = AudioStreamPlayer.new() + _sting.stream = _sting_wav() + _sting.volume_db = 2.0 + add_child(_sting) + +## Put it somewhere far from the player and switch it on. +func wake() -> void: + state = S.STALKING + static_level = 0.0 + _cd = 0.0 + visible = true + _reposition(18.0) + if _breath != null and not _breath.playing: + _breath.play() + +func sleep() -> void: + state = S.DORMANT + visible = false + static_level = 0.0 + if _breath != null: + _breath.stop() + +func is_awake() -> bool: + return state != S.DORMANT + +## 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 loudness >= noise_heat * 0.6: + noise_at = at + noise_heat = clampf(noise_heat + loudness, 0.0, 3.0) + # A loud enough noise overrides the Slender freeze. Standing still and staring at it + # is a valid answer to a stalker; it is not a valid answer to something that just + # heard you put a sledgehammer through a filing cabinet. The escalation is the point: + # the quiet rules protect you right up until you do the thing the game is about. + if noise_heat > 1.2 and state == S.STALKING: + state = S.HUNTING + +## Teleport somewhere at least `min_dist` away, out of the player's current view if it +## can manage it — being *found* somewhere new is better than being *seen* moving there. +func _reposition(min_dist: float) -> void: + if _player == null: + return + for attempt in 24: + var p := Vector3(randf_range(-_bounds + 2.0, _bounds - 2.0), 0.0, + randf_range(-_bounds + 2.0, _bounds - 2.0)) + if p.distance_to(_player.global_position) < min_dist: + continue + if attempt < 16 and _in_view(p): + continue + global_position = p + return + global_position = Vector3(randf_range(-_bounds, _bounds), 0.0, + randf_range(-_bounds, _bounds)) + +## In the view cone AND actually visible. The line-of-sight test is not optional: this +## is a maze, so most of the time the cone contains a wall, and without the raycast the +## static built while you stared at plasterboard with the thing quietly behind it. +func _in_view(p: Vector3) -> bool: + if _cam == null: + return false + var eye := _cam.global_position + var to := p - eye + if to.length() < 0.01: + return true + if to.normalized().dot(-_cam.global_transform.basis.z) <= LOOK_DOT: + return false + return _clear_line(eye, p + Vector3(0.0, 1.7, 0.0)) + +## True if nothing static blocks the line. Only walls and fittings occlude — the loose +## furniture is chest-high and shouldn't hide a two-and-a-half metre figure. +func _clear_line(from: Vector3, to: Vector3) -> bool: + var space := get_world_3d().direct_space_state + if space == null: + return true + var q := PhysicsRayQueryParameters3D.create(from, to) + q.collide_with_bodies = true + q.collide_with_areas = false + var hit := space.intersect_ray(q) + if hit.is_empty(): + return true + var col = hit.get("collider") + return not (col is StaticBody3D) + +# ---------------------------------------------------------------- per-frame +func _physics_process(dt: float) -> void: + if state == S.DORMANT or _player == null or _cam == null: + return + _cd = maxf(0.0, _cd - dt) + noise_heat = maxf(0.0, noise_heat - dt * 0.35) + + var to_player := _player.global_position - global_position + var dist := to_player.length() + seen_now = _in_view(global_position) and dist < SEE_DIST + + # always face you. It has no other business. + var flat := Vector3(to_player.x, 0.0, to_player.z) + if flat.length() > 0.05: + 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: + 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) + else: + static_level = maxf(0.0, static_level - static_decay * dt) + + if static_level >= 1.0 and _cd <= 0.0: + _catch() + return + + match state: + S.STALKING: + _tick_stalk(dt, dist) + S.HUNTING: + _tick_hunt(dt, dist) + S.LUNGE: + _tick_lunge(dt, dist) + +## Slender's rule: frozen while watched, and closer every time you look back. +func _tick_stalk(dt: float, dist: float) -> void: + _think -= dt + if seen_now: + return # it does not move while you watch + # closes ground, but toward the last noise if there was one — the two rules compose + var goal := _player.global_position + if noise_heat > 0.4: + goal = noise_at + _step_toward(goal, stalk_speed * dt) + if dist < LUNGE_DIST * 2.2 or noise_heat > 1.6: + state = S.HUNTING + +func _tick_hunt(dt: float, dist: float) -> void: + # hunting, it no longer respects being watched. That escalation is the point. + var goal := _player.global_position + if noise_heat > 1.2: + goal = noise_at + _step_toward(goal, hunt_speed * dt) + if dist < LUNGE_DIST: + state = S.LUNGE + elif dist > SEE_DIST * 0.8 and noise_heat < 0.3: + state = S.STALKING + +func _tick_lunge(dt: float, dist: float) -> void: + _step_toward(_player.global_position, lunge_speed * dt) + if dist < 1.5 and _cd <= 0.0: + _catch() + +func _step_toward(goal: Vector3, amount: float) -> void: + var d := goal - global_position + d.y = 0.0 + if d.length() < 0.05: + return + global_position += d.normalized() * amount + +func _catch() -> void: + _cd = 3.0 + static_level = 0.0 + _sting.pitch_scale = randf_range(0.94, 1.06) + _sting.play() + caught_you.emit() + state = S.STALKING + _reposition(16.0) + +# ---------------------------------------------------------------- audio +## A slow rasp for the breathing; `rough` doubles the noise and drops the tone. +func _noise_wav(dur: float, tone: float, decay: float, rough: bool) -> AudioStreamWAV: + var rate := 22050 + var n := int(rate * dur) + var out := PackedFloat32Array() + out.resize(n) + for i in n: + var t := float(i) / float(rate) + var env := exp(-fposmod(t, 0.8) * decay) * 0.8 + var s := sin(TAU * tone * t) * 0.4 * env + if rough: + s += (randf() * 2.0 - 1.0) * 0.35 * env + s += sin(TAU * tone * 0.5 * t) * 0.25 * env + out[i] = clampf(s, -1.0, 1.0) + return _wav(out, rate, true) + +## The jumpscare sting: a downward shriek over a hit of noise. +func _sting_wav() -> AudioStreamWAV: + var rate := 22050 + var dur := 1.15 + var n := int(rate * dur) + var out := PackedFloat32Array() + out.resize(n) + for i in n: + var t := float(i) / float(rate) + var env := exp(-t * 3.2) + var f := 1800.0 - 1500.0 * clampf(t / dur, 0.0, 1.0) + var s := sin(TAU * f * t) * 0.45 * env + s += sin(TAU * f * 1.5 * t) * 0.20 * env + s += (randf() * 2.0 - 1.0) * 0.55 * exp(-t * 11.0) + s += sin(TAU * 46.0 * t) * 0.35 * exp(-t * 2.0) + out[i] = clampf(s, -1.0, 1.0) + return _wav(out, rate, false) + +func _wav(samples: PackedFloat32Array, rate: int, loop: bool) -> AudioStreamWAV: + var w := AudioStreamWAV.new() + w.format = AudioStreamWAV.FORMAT_16_BITS + w.mix_rate = rate + w.stereo = false + var b := PackedByteArray() + b.resize(samples.size() * 2) + for i in samples.size(): + b.encode_s16(i * 2, int(samples[i] * 32767.0)) + w.data = b + if loop: + w.loop_mode = AudioStreamWAV.LOOP_FORWARD + w.loop_end = samples.size() + return w diff --git a/game/scripts/Entity.gd.uid b/game/scripts/Entity.gd.uid new file mode 100644 index 0000000..281c4bf --- /dev/null +++ b/game/scripts/Entity.gd.uid @@ -0,0 +1 @@ +uid://bgi656kbbcclx diff --git a/game/scripts/Levels.gd b/game/scripts/Levels.gd index be79aec..c5bb60f 100644 --- a/game/scripts/Levels.gd +++ b/game/scripts/Levels.gd @@ -463,16 +463,23 @@ static func backrooms() -> Dictionary: var walls: Array = [] # Wall runs on a 4.6 m lattice. Each line gets a few segments with gaps between, so # you can always get through somewhere but never see far. + # Density matters more than it looks. The first pass put a run on every lattice line + # in both axes and the result had NO sightline longer than a few metres — which kills + # the entity outright, because a stalker you can never see can never be stared at. The + # reference photographs are mostly open floor with occasional slabs, so: skip roughly + # a third of the lines, and leave big gaps. var lines := [-13.8, -9.2, -4.6, 0.0, 4.6, 9.2, 13.8] for axis in ["x", "z"]: for at in lines: + if rng.randf() < 0.34: + continue # this line is simply open floor var cursor := -BR_HALF while cursor < BR_HALF - 2.0: - var gap := rng.randf_range(1.6, 6.5) + var gap := rng.randf_range(4.5, 12.0) cursor += gap if cursor >= BR_HALF - 2.0: break - var run := rng.randf_range(3.2, 9.0) + var run := rng.randf_range(3.0, 7.5) var end: float = minf(cursor + run, BR_HALF) if end - cursor > 1.4: walls.append({"a": axis, "at": at, "from": cursor, "to": end}) @@ -480,7 +487,7 @@ static func backrooms() -> Dictionary: # freestanding pillars, because every Backrooms photo has one in the way var slabs: Array = [] - for i in 22: + for i in 16: var px := rng.randf_range(-BR_HALF + 2.0, BR_HALF - 2.0) var pz := rng.randf_range(-BR_HALF + 2.0, BR_HALF - 2.0) slabs.append({"size": Vector3(0.62, BR_H, 0.62), diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index 71ff45f..29b8205 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -41,6 +41,7 @@ var _ambience: Ambience var _modes: Modes var _gauntlet: Gauntlet var _dust: Dust +var _backrooms: Backrooms func _ready() -> void: randomize() @@ -80,6 +81,8 @@ func _ready() -> void: add_child(_gauntlet) _dust = Dust.new() add_child(_dust) + _backrooms = Backrooms.new() + add_child(_backrooms) # rules layer: the record modes still exist behind M _game_mode = GameMode.new() @@ -94,7 +97,9 @@ func _ready() -> void: _player.gauntlet = _gauntlet _dust.setup(_player, _cam, _rage, _hud) _player.dust = _dust - _dust.adopt(get_tree(), bool(_plan.spec.get("invisible", false))) + _backrooms.setup(_player, _cam, _hud, _rage, _dust, self) + _dust.heard_by = _backrooms + _apply_site_horror() _modes.begin(get_tree().get_nodes_in_group("smashable").size()) ## Every desk is a spreadsheet you have to fill in; the copier is the copier. @@ -461,6 +466,10 @@ func _on_smashed(kind: String, at: Vector3, shards: int) -> void: _shift.on_smashed(int(POINTS.get(kind, 15))) if _modes != null: _modes.on_smashed(int(POINTS.get(kind, 15))) + # LEVEL 0: the entity hunts sound, and breaking things is the objective. Playing well + # is what feeds it. + if _backrooms != null: + _backrooms.on_noise(at, 1.0) if _game_mode != null: _game_mode.on_smashed() # scores the mess during a FindMisfiled round @@ -543,11 +552,7 @@ func _load_level(id: String) -> void: _register_stations() if _ambience != null: _ambience.adopt_lights(_plan) - if _dust != null: - var blind := bool(_plan.spec.get("invisible", false)) - _dust.adopt(get_tree(), blind) - if blind: - _player.select(5) # you will need the extinguisher + _apply_site_horror() if _game_mode != null: _game_mode.begin_free_play(_records) if _shift != null: @@ -599,6 +604,21 @@ func _find_shredder(g: Dictionary) -> Node3D: var res := _glb_piece(P + "shredder.glb", "plastic", Vector2(at.x, at.z), false) return res["piece"] as Node3D +## Turn the invisibility and the horror layer on or off for whatever site we just built. +## Both are LEVEL 0 only, and both have to be explicitly torn down on the way out or they +## leak into an ordinary Tuesday at the Scranton branch. +func _apply_site_horror() -> void: + var blind := bool(_plan.spec.get("invisible", false)) + if _dust != null: + _dust.adopt(get_tree(), blind) + if _backrooms != null: + if blind: + _backrooms.enter(float((_plan.spec["bounds"] as Dictionary)["x1"])) + else: + _backrooms.leave() + if blind and _player != null: + _player.select(5) # you will need the extinguisher + # The Day-0 stress gate: rain N bodies, watch the FPS counter hold. func _rain(n: int) -> void: for i in n: @@ -691,8 +711,10 @@ func _process(_dt: float) -> void: "weapon_blurb": w.blurb if w != null else "", "weapon_stats": ("pow %.1f cd %.2fs reach %.1fm" % [w.power, w.cooldown, w.reach]) if w != null else "", "slot": _player.slot() if _player != null else 0, - "mode_line": _mode_line() + ("\n" + _dust.hud_line() if _dust != null - and _dust.active else ""), + "mode_line": _mode_line() + + ("\n" + _dust.hud_line() if _dust != null and _dust.active else "") + + ("\n" + _backrooms.hud_line() if _backrooms != null + and _backrooms.active else ""), "mode_name": _modes.name_of() if _modes != null else "", "headline": _modes.headline() if _modes != null else "", "keys": KEYS_LINE, diff --git a/tools/gen_horror_props.py b/tools/gen_horror_props.py new file mode 100644 index 0000000..7472b33 --- /dev/null +++ b/tools/gen_horror_props.py @@ -0,0 +1,289 @@ +"""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()