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